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
504 lines
20 KiB
JavaScript
504 lines
20 KiB
JavaScript
// ========== 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,
|
|
};
|
|
})(); |