[grade=A] feat(caddy-builder): DC-106 visual reverse proxy builder (frontend)

Backend endpoints /api/v1/caddycode/{generate,validate,templates} already
shipped at commit 7f83151 (GLM grade B). This commit ships the visual
builder frontend that consumes them.

- status/js/caddy-builder.js — IIFE module that injects a modal with a
  form-driven visual builder. State → JSON payload → debounced POST
  /generate → preview pane. 5 presets loaded from /templates (simple,
  websocket, auth-gated, cors-api, subdirectory). Custom headers list
  (add/remove rows), live validation, copy-to-clipboard, reset. Exposes
  window.__caddyBuilder for testing.

- status/css/caddy-builder.css — page-specific styles, themed via
  existing --bg/--border/--accent/--ok-fg/--warn-fg/--err-fg CSS
  variables. Mobile-friendly single-column layout below 880 px.

- status/index.html — adds /css/caddy-builder.css link + the
  "🔧 Reverse Proxy Builder" button in the Tools menu.

- status/build.js — registers caddy-builder.js in features.js bundle.

- dashcaddy-api/__tests__/unit/caddy-builder.unit.test.js — 19
  pure-function tests covering state defaults, buildPayload, applyTemplate,
  generate() against mocked fetch, XSS regression via global escapeHtml.

Verified:
  - jest: 19/19 unit + 8/8 caddycode-fleet routes pass
  - node build.js: features.js now bundles 27 files (was 26),
    new SW cache tag dashcaddy-shell-1ceeb68cff
  - Frontend bundle grep finds 6 distinct caddy-builder identifiers
    in dist/features.js
  - Qwen stand-in judge: A (0 blocking, 0 polish). Substitute for Codex
  CLI quota wall. Verdict URN: urn:ump:fco2jwhmcv4tjhmfvutownqbvc6pmvln23ym5jckivvj42ykpc2a
This commit is contained in:
Hermes
2026-08-18 23:50:37 -07:00
parent fa6c4c6b20
commit 5abf385c7e
5 changed files with 1079 additions and 0 deletions
+504
View File
@@ -0,0 +1,504 @@
// ========== 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', `<div id="caddy-builder-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 920px; max-width: 1100px;">
<h3>🔧 Reverse Proxy Builder</h3>
<p class="modal-subtitle">
Describe your reverse proxy in plain fields. Get a Caddyfile snippet you can
paste into <code>/etc/caddy/Caddyfile</code> and reload with
<code>caddy-apply</code>.
</p>
<!-- Template picker -->
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 14px;">
<label class="text-muted-sm">Template:</label>
<select id="cb-template" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
<option value="">— Custom —</option>
</select>
<button id="cb-load-template" class="btn-sm">📋 Load template</button>
<span style="flex: 1;"></span>
<span id="cb-status" class="text-muted-sm"></span>
</div>
<!-- Form (left) + preview (right) -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div>
<div class="cb-section">
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Routing</h4>
<label class="cb-label">Domain
<input type="text" id="cb-domain" placeholder="blog.example.com" autocomplete="off" />
<small>Public hostname clients will use.</small>
</label>
<label class="cb-label">Upstream (host:port)
<input type="text" id="cb-upstream" placeholder="localhost:8080" autocomplete="off" />
<small>Where requests go. <code>localhost:8080</code>, <code>my-container:80</code>, or <code>[::1]:5000</code>.</small>
</label>
<label class="cb-label">Upstream protocol
<select id="cb-upstream-protocol">
<option value="http">http://</option>
<option value="https">https://</option>
</select>
</label>
<label class="cb-label">Strip prefix (optional)
<input type="text" id="cb-strip-prefix" placeholder="/api" autocomplete="off" />
<small>Removes this prefix from the URL before proxying. E.g. <code>/api</code> rewrites <code>/api/users</code> → <code>/users</code>.</small>
</label>
</div>
<div class="cb-section">
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">TLS</h4>
<label class="cb-label">TLS mode
<select id="cb-tls">
<option value="auto">auto (Caddy issues Let's Encrypt)</option>
<option value="internal">internal (private CA only)</option>
<option value="letsencrypt">letsencrypt (explicit)</option>
</select>
<small><code>auto</code> is the default for any public hostname.</small>
</label>
</div>
<div class="cb-section">
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Behavior</h4>
<label class="cb-checkbox"><input type="checkbox" id="cb-websocket" /> WebSocket support</label>
<label class="cb-checkbox"><input type="checkbox" id="cb-cors" /> CORS headers (Access-Control-Allow-Origin: *)</label>
<label class="cb-checkbox"><input type="checkbox" id="cb-compress" checked /> Compression (gzip + zstd)</label>
<label class="cb-checkbox"><input type="checkbox" id="cb-redirect-https" checked /> HTTP→HTTPS redirect (default in Caddy 2)</label>
</div>
<div class="cb-section">
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">DashCaddy SSO</h4>
<label class="cb-checkbox"><input type="checkbox" id="cb-auth" /> Gate behind DashCaddy auth</label>
<label class="cb-label">Service ID (for auth)
<input type="text" id="cb-auth-service" placeholder="blog" autocomplete="off" />
<small>Must match a service in DashCaddy's catalog (lowercase, hyphens).</small>
</label>
</div>
<div class="cb-section">
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Custom headers</h4>
<div id="cb-headers-list"></div>
<button id="cb-add-header" class="btn-sm" style="margin-top: 8px;">+ Add header</button>
</div>
</div>
<div>
<div class="cb-section" style="display: flex; flex-direction: column; height: 100%;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<h4 style="margin: 0; font-size: 0.95rem;">Generated Caddyfile</h4>
<span style="flex: 1;"></span>
<button id="cb-copy" class="btn-sm">📋 Copy</button>
<button id="cb-validate-btn" class="btn-sm">✓ Validate</button>
</div>
<pre id="cb-preview" class="cb-preview"><code></code></pre>
<div id="cb-validation" class="cb-validation"></div>
<div id="cb-error" class="cb-error" style="display: none;"></div>
</div>
</div>
</div>
<div class="weather-modal-buttons">
<button id="cb-reset">Reset</button>
<button id="cb-close">Close</button>
</div>
</div>
</div>`);
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 = '<option value="">— Custom —</option>';
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 = `
<input type="text" class="cb-h-key" placeholder="Header-Name" value="${escapeHtml(h.key || '')}" data-idx="${idx}" />
<input type="text" class="cb-h-value" placeholder="value" value="${escapeHtml(h.value || '')}" data-idx="${idx}" />
<button class="cb-h-remove" data-idx="${idx}" title="Remove">✕</button>
`;
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 = '<span class="cb-ok">✓ Valid</span>';
}
} catch (err) {
// silent — generation status already covers network errors
}
}
function showValidationErrors(errors, warnings) {
let html = '<div class="cb-issues">';
errors.forEach(e => { html += `<div class="cb-err">⚠ ${escapeHtml(e)}</div>`; });
(warnings || []).forEach(w => { html += `<div class="cb-warn">⚠ ${escapeHtml(w)}</div>`; });
html += '</div>';
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,
};
})();