// ========== CADDY VISUAL BUILDER (DC-106) ========== // Visual reverse-proxy builder. Lets the user describe what they want // in plain form fields ("blog.yourdomain.com β†’ container X on port 80, // with auth, rate limiting, compression") and renders the corresponding // Caddyfile snippet. Uses the existing /api/v1/caddycode/generate + // /caddycode/validate + /caddycode/templates endpoints. // // Design: stateless β€” every keystroke rebuilds the snippet via debounced // fetch. State machine is a single plain-object `state` snapshot. No // external libraries. Matches the existing log-insights.js IIFE pattern. (function() { 'use strict'; // --- Constants --------------------------------------------------------- const DEBOUNCE_MS = 250; const TEMPLATE_PRESETS = [ { id: 'simple-proxy', label: 'Simple reverse proxy' }, { id: 'websocket-app', label: 'WebSocket application' }, { id: 'auth-gated', label: 'Auth-gated (DashCaddy SSO)' }, { id: 'cors-api', label: 'API with CORS' }, { id: 'subdirectory', label: 'Subdirectory proxy' }, ]; // --- State ------------------------------------------------------------- // Single source of truth for the form. Updates flow in via setState, // which triggers debounced regeneration. const state = { domain: 'blog.example.com', upstream: 'localhost:8080', upstreamProtocol: 'http', tls: 'auto', auth: false, authService: '', websocket: false, cors: false, compress: true, stripPrefix: '', redirectToHttps: true, headers: [], // [{ key, value }] caddyfile: '', validationIssues: [], lastError: '', generating: false, }; let regenTimer = null; let validateTimer = null; // --- DOM injection ----------------------------------------------------- injectModal('caddy-builder-modal', `

πŸ”§ Reverse Proxy Builder

Routing

TLS

Behavior

DashCaddy SSO

Custom headers

Generated Caddyfile

`); const modal = document.getElementById('caddy-builder-modal'); const openBtn = document.getElementById('caddy-builder-btn'); const closeBtn = document.getElementById('cb-close'); const resetBtn = document.getElementById('cb-reset'); const templateSel = document.getElementById('cb-template'); const loadTplBtn = document.getElementById('cb-load-template'); const statusEl = document.getElementById('cb-status'); const previewEl = document.getElementById('cb-preview').querySelector('code'); const validationEl = document.getElementById('cb-validation'); const errorEl = document.getElementById('cb-error'); const headersList = document.getElementById('cb-headers-list'); const addHeaderBtn = document.getElementById('cb-add-header'); const copyBtn = document.getElementById('cb-copy'); const validateBtn = document.getElementById('cb-validate-btn'); // Form field references const fields = { domain: document.getElementById('cb-domain'), upstream: document.getElementById('cb-upstream'), upstreamProtocol: document.getElementById('cb-upstream-protocol'), tls: document.getElementById('cb-tls'), auth: document.getElementById('cb-auth'), authService: document.getElementById('cb-auth-service'), websocket: document.getElementById('cb-websocket'), cors: document.getElementById('cb-cors'), compress: document.getElementById('cb-compress'), stripPrefix: document.getElementById('cb-strip-prefix'), redirectToHttps: document.getElementById('cb-redirect-https'), }; // --- Template loading -------------------------------------------------- let availableTemplates = {}; async function loadTemplates() { try { const res = await fetch('/api/v1/caddycode/templates', { credentials: 'same-origin' }); const data = await res.json(); if (data && data.templates) { availableTemplates = data.templates; // Populate the picker templateSel.innerHTML = ''; for (const preset of TEMPLATE_PRESETS) { const opt = document.createElement('option'); opt.value = preset.id; opt.textContent = preset.label; templateSel.appendChild(opt); } } } catch (err) { console.warn('[caddy-builder] Failed to load templates:', err); } } function applyTemplate(id) { const tpl = availableTemplates[id]; if (!tpl || !tpl.config) return; const cfg = tpl.config; if (cfg.domain != null) state.domain = cfg.domain; if (cfg.upstream != null) state.upstream = cfg.upstream; if (cfg.upstreamProtocol != null) state.upstreamProtocol = cfg.upstreamProtocol; if (cfg.tls != null) state.tls = cfg.tls; if (cfg.auth != null) state.auth = !!cfg.auth; if (cfg.authService != null) state.authService = cfg.authService || ''; if (cfg.websocket != null) state.websocket = !!cfg.websocket; if (cfg.cors != null) state.cors = !!cfg.cors; if (cfg.compress != null) state.compress = !!cfg.compress; if (cfg.stripPrefix != null) state.stripPrefix = cfg.stripPrefix || ''; if (cfg.redirectToHttps != null) state.redirectToHttps = !!cfg.redirectToHttps; if (Array.isArray(cfg.headers)) state.headers = cfg.headers.slice(); syncFieldsFromState(); triggerRegen(); setStatus('Template loaded: ' + (tpl.label || id)); } // --- Headers list ------------------------------------------------------ function renderHeadersList() { headersList.innerHTML = ''; state.headers.forEach((h, idx) => { const row = document.createElement('div'); row.className = 'cb-header-row'; row.innerHTML = ` `; headersList.appendChild(row); }); // Wire handlers headersList.querySelectorAll('.cb-h-key').forEach(el => { el.addEventListener('input', e => { const i = +e.target.dataset.idx; state.headers[i].key = e.target.value; triggerRegen(); }); }); headersList.querySelectorAll('.cb-h-value').forEach(el => { el.addEventListener('input', e => { const i = +e.target.dataset.idx; state.headers[i].value = e.target.value; triggerRegen(); }); }); headersList.querySelectorAll('.cb-h-remove').forEach(el => { el.addEventListener('click', e => { const i = +e.currentTarget.dataset.idx; state.headers.splice(i, 1); renderHeadersList(); triggerRegen(); }); }); } // --- Generation -------------------------------------------------------- function buildPayload() { const headers = {}; for (const h of state.headers) { if (h.key && h.key.trim()) { headers[h.key.trim()] = h.value || ''; } } return { domain: state.domain.trim(), upstream: state.upstream.trim(), upstreamProtocol: state.upstreamProtocol, tls: state.tls, auth: state.auth, authService: state.auth ? state.authService.trim() || null : null, websocket: state.websocket, cors: state.cors, compress: state.compress, stripPrefix: state.stripPrefix.trim() || null, redirectToHttps: state.redirectToHttps, headers, }; } function triggerRegen() { clearTimeout(regenTimer); regenTimer = setTimeout(generate, DEBOUNCE_MS); } function triggerValidate() { clearTimeout(validateTimer); validateTimer = setTimeout(validateGenerated, DEBOUNCE_MS + 100); } async function generate() { const payload = buildPayload(); if (!payload.domain || !payload.upstream) { previewEl.textContent = '(fill in domain + upstream to generate)'; state.caddyfile = ''; validationEl.innerHTML = ''; errorEl.style.display = 'none'; return; } state.generating = true; setStatus('Generating…'); try { const res = await fetch('/api/v1/caddycode/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok || !data.success) { state.caddyfile = ''; previewEl.textContent = ''; const errs = (data && data.errors) || [data.error || 'Generation failed']; showValidationErrors(errs); setStatus('Validation failed'); return; } state.caddyfile = data.caddyfile || ''; previewEl.textContent = state.caddyfile; errorEl.style.display = 'none'; validationEl.innerHTML = ''; setStatus('βœ“ Generated'); triggerValidate(); } catch (err) { showError('Network error: ' + (err.message || err)); setStatus('Network error'); } finally { state.generating = false; } } async function validateGenerated() { if (!state.caddyfile) { validationEl.innerHTML = ''; return; } try { const res = await fetch('/api/v1/caddycode/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ caddyfile: state.caddyfile }), }); const data = await res.json(); if (data && data.issues && data.issues.length) { showValidationErrors(data.issues, data.warnings || []); } else { validationEl.innerHTML = 'βœ“ Valid'; } } catch (err) { // silent β€” generation status already covers network errors } } function showValidationErrors(errors, warnings) { let html = '
'; errors.forEach(e => { html += `
⚠ ${escapeHtml(e)}
`; }); (warnings || []).forEach(w => { html += `
⚠ ${escapeHtml(w)}
`; }); html += '
'; validationEl.innerHTML = html; } function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; } function setStatus(text) { statusEl.textContent = text; if (text.startsWith('βœ“') || text.startsWith('Template')) { setTimeout(() => { if (statusEl.textContent === text) statusEl.textContent = ''; }, 2500); } } // --- Field β†’ state sync ------------------------------------------------ function syncFieldsFromState() { fields.domain.value = state.domain; fields.upstream.value = state.upstream; fields.upstreamProtocol.value = state.upstreamProtocol; fields.tls.value = state.tls; fields.auth.checked = state.auth; fields.authService.value = state.authService; fields.authService.disabled = !state.auth; fields.websocket.checked = state.websocket; fields.cors.checked = state.cors; fields.compress.checked = state.compress; fields.stripPrefix.value = state.stripPrefix; fields.redirectToHttps.checked = state.redirectToHttps; renderHeadersList(); } function bindFieldEvents() { fields.domain.addEventListener('input', e => { state.domain = e.target.value; triggerRegen(); }); fields.upstream.addEventListener('input', e => { state.upstream = e.target.value; triggerRegen(); }); fields.upstreamProtocol.addEventListener('change', e => { state.upstreamProtocol = e.target.value; triggerRegen(); }); fields.tls.addEventListener('change', e => { state.tls = e.target.value; triggerRegen(); }); fields.auth.addEventListener('change', e => { state.auth = e.target.checked; fields.authService.disabled = !state.auth; triggerRegen(); }); fields.authService.addEventListener('input', e => { state.authService = e.target.value; triggerRegen(); }); fields.websocket.addEventListener('change', e => { state.websocket = e.target.checked; triggerRegen(); }); fields.cors.addEventListener('change', e => { state.cors = e.target.checked; triggerRegen(); }); fields.compress.addEventListener('change', e => { state.compress = e.target.checked; triggerRegen(); }); fields.stripPrefix.addEventListener('input', e => { state.stripPrefix = e.target.value; triggerRegen(); }); fields.redirectToHttps.addEventListener('change', e => { state.redirectToHttps = e.target.checked; triggerRegen(); }); } // --- Buttons ----------------------------------------------------------- if (openBtn) { openBtn.addEventListener('click', () => { modal.style.display = 'flex'; syncFieldsFromState(); generate(); }); } closeBtn.addEventListener('click', () => { modal.style.display = 'none'; }); resetBtn.addEventListener('click', () => { state.domain = 'blog.example.com'; state.upstream = 'localhost:8080'; state.upstreamProtocol = 'http'; state.tls = 'auto'; state.auth = false; state.authService = ''; state.websocket = false; state.cors = false; state.compress = true; state.stripPrefix = ''; state.redirectToHttps = true; state.headers = []; templateSel.value = ''; syncFieldsFromState(); triggerRegen(); setStatus('Reset'); }); loadTplBtn.addEventListener('click', () => { const id = templateSel.value; if (!id) { setStatus('Pick a template first'); return; } applyTemplate(id); }); addHeaderBtn.addEventListener('click', () => { state.headers.push({ key: '', value: '' }); renderHeadersList(); }); copyBtn.addEventListener('click', async () => { if (!state.caddyfile) { setStatus('Nothing to copy'); return; } try { await navigator.clipboard.writeText(state.caddyfile); setStatus('βœ“ Copied to clipboard'); } catch (err) { // Fallback: select the preview const range = document.createRange(); range.selectNodeContents(previewEl.parentNode); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); setStatus('Selected β€” press ⌘/Ctrl-C to copy'); } }); validateBtn.addEventListener('click', () => { if (!state.caddyfile) { setStatus('Generate first'); return; } validateGenerated(); setStatus('Validated'); }); // --- Init -------------------------------------------------------------- bindFieldEvents(); syncFieldsFromState(); loadTemplates(); // Expose for testing window.__caddyBuilder = { state, generate, validateGenerated, applyTemplate, buildPayload, getCaddyfile: () => state.caddyfile, }; })();