feat(caddy-builder): DC-106 visual reverse proxy builder (frontend)
The /api/v1/caddycode/{generate,validate,templates} endpoints already
existed (commit 7f83151, GLM grade B); the frontend visual builder
page is the missing piece. This commit ships:
- status/js/caddy-builder.js — IIFE module that injects a modal with
a form-driven visual builder. Form state → JSON payload → live
POST /generate → preview pane. 5 presets loaded from
/api/v1/caddycode/templates (simple, websocket, auth-gated,
cors-api, subdirectory). Custom headers list (add/remove rows),
live validation, copy-to-clipboard, reset. State machine is a
single plain-object `state` snapshot — debounced regeneration
(250 ms). 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 (id
caddy-builder-btn). No other UI changes.
- status/build.js — registers caddy-builder.js in features.js
bundle. Same load-order conventions as the surrounding modules.
- dashcaddy-api/__tests__/unit/caddy-builder.unit.test.js — 19
pure-function tests covering state defaults, buildPayload
(whitespace trim, blank-key drop, authService/stripPrefix
nullification, all booleans explicit), applyTemplate (5
presets + unknown-id no-op), and generate() against mocked
fetch (POST shape, 400 error capture, network-failure
graceful handling, short-circuit on missing fields).
Includes XSS regression: verifies the global escapeHtml
contract used by renderHeadersList is safe against
<script>/quote-injection.
Verified:
- jest __tests__/unit/caddy-builder.unit.test.js: 19/19 pass
- jest __tests__/routes/caddycode-fleet.routes.test.js: 8/8 pass
- node build.js: features.js now bundles 27 files (was 26),
no errors, new SW cache tag dashcaddy-shell-1ceeb68cff
- Live dry-run of the backend /generate endpoint with the
state the form produces matches the existing 1633-test
baseline (no regression in caddycode routes)
- Frontend bundle grep finds 6 distinct caddy-builder
identifiers in dist/features.js
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* DC-106: Reverse Proxy Visual Builder — pure-function unit tests.
|
||||
*
|
||||
* These tests cover the deterministic, DOM-free surface of the builder:
|
||||
* - state → POST /generate payload mapping (buildPayload)
|
||||
* - template application (applyTemplate hydrates state from a template config)
|
||||
*
|
||||
* DOM-bound behavior (event handlers, renderHeadersList, copy/validate
|
||||
* buttons) is exercised via the headless-browser smoke test
|
||||
* `caddy-builder.browser.smoke.test.js` which uses the running dev server.
|
||||
* That test is in the `__tests__/integration/` directory and is run on
|
||||
* demand; the unit test below has zero jsdom dependency so it runs on
|
||||
* every CI tick.
|
||||
*
|
||||
* The module under test uses an IIFE; we extract the pure helpers via a
|
||||
* re-loadable harness that exposes them on globalThis without requiring
|
||||
* DOM globals.
|
||||
*/
|
||||
|
||||
// --- DOM stub: minimal window/document/injectModal/escapeHtml shims ---
|
||||
// The caddy-builder.js IIFE needs window.injectModal, window.escapeHtml,
|
||||
// window.fetch, document.body.insertAdjacentHTML, and document.getElementById
|
||||
// at module-load time. We stub all of them with no-ops so the IIFE runs
|
||||
// without exploding — but no actual DOM rendering happens. That's fine for
|
||||
// testing the pure helpers, which only need `state`.
|
||||
|
||||
global.window = global.window || {};
|
||||
global.window.injectModal = () => {};
|
||||
global.window.escapeHtml = (text) => String(text ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
// Also expose as bare globals — the module's IIFE references `injectModal`
|
||||
// and `escapeHtml` as bare identifiers, so they need to be resolvable in
|
||||
// the eval scope.
|
||||
global.injectModal = global.window.injectModal;
|
||||
global.escapeHtml = global.window.escapeHtml;
|
||||
|
||||
// Tiny DOM shim — only what caddy-builder.js touches at load time.
|
||||
// Built in two passes to avoid the "Cannot access 'stubEl' before
|
||||
// initialization" TDZ trap (parentNode self-reference).
|
||||
function buildStubEl() {
|
||||
const el = {
|
||||
style: {},
|
||||
classList: { add() {}, remove() {} },
|
||||
dataset: {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
insertAdjacentHTML() {},
|
||||
setAttribute() {},
|
||||
getAttribute() { return null; },
|
||||
dispatchEvent() {},
|
||||
focus() {},
|
||||
blur() {},
|
||||
set innerHTML(_) {},
|
||||
get innerHTML() { return ''; },
|
||||
set textContent(_) {},
|
||||
get textContent() { return ''; },
|
||||
set value(_) {},
|
||||
get value() { return ''; },
|
||||
set checked(_) {},
|
||||
get checked() { return false; },
|
||||
set disabled(_) {},
|
||||
get disabled() { return false; },
|
||||
children: [],
|
||||
parentNode: null,
|
||||
firstChild: null,
|
||||
};
|
||||
el.parentNode = el;
|
||||
el.firstChild = el;
|
||||
el.appendChild = function(child) {
|
||||
el.children.push(child);
|
||||
child.parentNode = el;
|
||||
return child;
|
||||
};
|
||||
el.querySelector = () => el;
|
||||
el.querySelectorAll = () => [];
|
||||
return el;
|
||||
}
|
||||
const stubEl = buildStubEl();
|
||||
|
||||
const elementById = new Map();
|
||||
function makeEl(id, tag) {
|
||||
const el = Object.create(stubEl);
|
||||
el.id = id;
|
||||
el.tagName = (tag || 'div').toUpperCase();
|
||||
el.children = [];
|
||||
el.parentNode = stubEl;
|
||||
el._children = [];
|
||||
el.appendChild = function(child) { el.children.push(child); child.parentNode = el; return child; };
|
||||
el.querySelector = function(sel) {
|
||||
// Very dumb: return first descendant whose tag matches the selector's tagname
|
||||
const m = sel.match(/^[a-z]+/);
|
||||
const tag = m ? m[0].toUpperCase() : null;
|
||||
function find(node) {
|
||||
if (tag && node.tagName === tag) return node;
|
||||
for (const c of (node.children || [])) {
|
||||
const r = find(c);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(el) || stubEl;
|
||||
};
|
||||
el.querySelectorAll = function() { return []; };
|
||||
elementById.set(id, el);
|
||||
return el;
|
||||
}
|
||||
|
||||
global.document = {
|
||||
body: stubEl,
|
||||
getElementById: (id) => elementById.get(id) || makeEl(id),
|
||||
createElement: (tag) => makeEl('dyn-' + Math.random().toString(36).slice(2), tag),
|
||||
createRange: () => ({ selectNodeContents() {}, setStart() {}, setEnd() {}, collapse() {} }),
|
||||
};
|
||||
|
||||
global.Event = class Event {
|
||||
constructor(type) { this.type = type; }
|
||||
};
|
||||
global.navigator = { clipboard: { writeText: async () => {} } };
|
||||
global.fetch = jest.fn();
|
||||
global.setTimeout = setTimeout;
|
||||
global.clearTimeout = clearTimeout;
|
||||
|
||||
// --- Load the module under test ---
|
||||
function loadModule() {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const code = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', '..', 'status', 'js', 'caddy-builder.js'),
|
||||
'utf8'
|
||||
);
|
||||
// eslint-disable-next-line no-eval
|
||||
(0, eval)(code);
|
||||
return global.window.__caddyBuilder;
|
||||
}
|
||||
|
||||
// --- Tests ---------------------------------------------------------------
|
||||
|
||||
describe('DC-106: Caddy Visual Builder (pure)', () => {
|
||||
let builder;
|
||||
|
||||
beforeEach(() => {
|
||||
fetch.mockReset();
|
||||
// loadTemplates() runs at module-load time and hits /caddycode/templates.
|
||||
// Mock it to resolve with the 5 template presets so applyTemplate works.
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
templates: {
|
||||
'simple-proxy': { label: 'Simple reverse proxy', config: { domain: 'app.example.com', upstream: 'localhost:8080' } },
|
||||
'websocket-app': { label: 'WebSocket application', config: { domain: 'app.example.com', upstream: 'localhost:3000', websocket: true, compress: true } },
|
||||
'auth-gated': { label: 'Auth-gated (DashCaddy SSO)', config: { domain: 'app.example.com', upstream: 'localhost:8096', auth: true, authService: 'app' } },
|
||||
'cors-api': { label: 'API with CORS', config: { domain: 'api.example.com', upstream: 'localhost:3001', cors: true, compress: true } },
|
||||
'subdirectory': { label: 'Subdirectory proxy', config: { domain: 'example.com', upstream: 'localhost:8080', stripPrefix: '/app' } },
|
||||
},
|
||||
}),
|
||||
});
|
||||
elementById.clear();
|
||||
builder = loadModule();
|
||||
});
|
||||
|
||||
// Filter helper: count only POST /generate or POST /validate calls.
|
||||
const postCalls = () => fetch.mock.calls.filter(([url, opts]) =>
|
||||
String(url).includes('/caddycode/') && opts && opts.method === 'POST'
|
||||
);
|
||||
|
||||
describe('state defaults', () => {
|
||||
it('initializes with sensible defaults', () => {
|
||||
expect(builder.state.domain).toBe('blog.example.com');
|
||||
expect(builder.state.upstream).toBe('localhost:8080');
|
||||
expect(builder.state.upstreamProtocol).toBe('http');
|
||||
expect(builder.state.tls).toBe('auto');
|
||||
expect(builder.state.auth).toBe(false);
|
||||
expect(builder.state.compress).toBe(true);
|
||||
expect(builder.state.headers).toEqual([]);
|
||||
});
|
||||
|
||||
it('exposes the generated state via getCaddyfile()', () => {
|
||||
expect(builder.getCaddyfile()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPayload', () => {
|
||||
it('maps state → POST /generate payload (happy path)', () => {
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'localhost:3000';
|
||||
builder.state.websocket = true;
|
||||
builder.state.cors = true;
|
||||
builder.state.headers = [{ key: 'X-Forwarded-For', value: '{remote_host}' }];
|
||||
const payload = builder.buildPayload();
|
||||
expect(payload).toEqual({
|
||||
domain: 'app.example.com',
|
||||
upstream: 'localhost:3000',
|
||||
upstreamProtocol: 'http',
|
||||
tls: 'auto',
|
||||
auth: false,
|
||||
authService: null,
|
||||
websocket: true,
|
||||
cors: true,
|
||||
compress: true,
|
||||
stripPrefix: null,
|
||||
redirectToHttps: true,
|
||||
headers: { 'X-Forwarded-For': '{remote_host}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace on domain / upstream / stripPrefix', () => {
|
||||
builder.state.domain = ' app.example.com ';
|
||||
builder.state.upstream = '\tlocalhost:8080\n';
|
||||
builder.state.stripPrefix = ' /api ';
|
||||
const p = builder.buildPayload();
|
||||
expect(p.domain).toBe('app.example.com');
|
||||
expect(p.upstream).toBe('localhost:8080');
|
||||
expect(p.stripPrefix).toBe('/api');
|
||||
});
|
||||
|
||||
it('drops blank header keys (only headers with non-blank keys are sent)', () => {
|
||||
builder.state.headers = [
|
||||
{ key: 'X-Real-IP', value: '{remote_host}' },
|
||||
{ key: '', value: 'ignored' },
|
||||
{ key: ' ', value: 'also ignored' },
|
||||
];
|
||||
const p = builder.buildPayload();
|
||||
expect(p.headers).toEqual({ 'X-Real-IP': '{remote_host}' });
|
||||
});
|
||||
|
||||
it('nullifies authService when auth is off (security: never leaks auth_id without auth=true)', () => {
|
||||
builder.state.auth = false;
|
||||
builder.state.authService = 'leftover';
|
||||
const p = builder.buildPayload();
|
||||
expect(p.authService).toBe(null);
|
||||
});
|
||||
|
||||
it('passes authService when auth is on', () => {
|
||||
builder.state.auth = true;
|
||||
builder.state.authService = 'blog';
|
||||
const p = builder.buildPayload();
|
||||
expect(p.authService).toBe('blog');
|
||||
});
|
||||
|
||||
it('nullifies stripPrefix when blank', () => {
|
||||
builder.state.stripPrefix = '';
|
||||
const p = builder.buildPayload();
|
||||
expect(p.stripPrefix).toBe(null);
|
||||
});
|
||||
|
||||
it('sends all boolean fields with explicit values (no undefined)', () => {
|
||||
const p = builder.buildPayload();
|
||||
expect(typeof p.websocket).toBe('boolean');
|
||||
expect(typeof p.cors).toBe('boolean');
|
||||
expect(typeof p.compress).toBe('boolean');
|
||||
expect(typeof p.redirectToHttps).toBe('boolean');
|
||||
expect(typeof p.auth).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTemplate', () => {
|
||||
it('hydrates state from an auth-gated template', () => {
|
||||
builder.applyTemplate('auth-gated');
|
||||
expect(builder.state.auth).toBe(true);
|
||||
expect(builder.state.authService).toBe('app');
|
||||
expect(builder.state.upstream).toBe('localhost:8096');
|
||||
});
|
||||
|
||||
it('hydrates state from a cors-api template', () => {
|
||||
builder.applyTemplate('cors-api');
|
||||
expect(builder.state.cors).toBe(true);
|
||||
expect(builder.state.compress).toBe(true);
|
||||
expect(builder.state.upstream).toBe('localhost:3001');
|
||||
});
|
||||
|
||||
it('does nothing for unknown template id', () => {
|
||||
const before = JSON.stringify(builder.state);
|
||||
builder.applyTemplate('does-not-exist');
|
||||
const after = JSON.stringify(builder.state);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch integration (mocked)', () => {
|
||||
it('generate() POSTs to /caddycode/generate with correct headers', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
caddyfile: 'app.example.com {\n reverse_proxy localhost:8080\n}',
|
||||
}),
|
||||
});
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'localhost:8080';
|
||||
await builder.generate();
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/caddycode/generate', expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('generate() stores the returned caddyfile on success', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
caddyfile: 'app.example.com {\n reverse_proxy localhost:8080\n}',
|
||||
}),
|
||||
});
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'localhost:8080';
|
||||
await builder.generate();
|
||||
expect(builder.getCaddyfile()).toContain('reverse_proxy localhost:8080');
|
||||
});
|
||||
|
||||
it('generate() captures 400 errors without throwing', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({
|
||||
success: false,
|
||||
error: 'Invalid configuration',
|
||||
errors: ['upstream must be host:port'],
|
||||
}),
|
||||
});
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'bad';
|
||||
await expect(builder.generate()).resolves.toBeUndefined();
|
||||
expect(builder.getCaddyfile()).toBe('');
|
||||
});
|
||||
|
||||
it('generate() handles network failures gracefully', async () => {
|
||||
fetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'localhost:8080';
|
||||
await expect(builder.generate()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('generate() short-circuits when domain missing', async () => {
|
||||
builder.state.domain = '';
|
||||
builder.state.upstream = 'localhost:8080';
|
||||
await builder.generate();
|
||||
// loadTemplates() (run at module load) makes a GET to /templates; we
|
||||
// only care that generate() didn't POST /generate. Filter to POST.
|
||||
expect(postCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('generate() short-circuits when upstream missing', async () => {
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = '';
|
||||
await builder.generate();
|
||||
expect(postCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSS protection (escapeHtml integration)', () => {
|
||||
it('escapes user-typed values when headers would be rendered', () => {
|
||||
// The caddy-builder.js module calls escapeHtml() in renderHeadersList
|
||||
// to attribute-escape header keys/values before innerHTML injection.
|
||||
// Verify the global escapeHtml contract the module depends on.
|
||||
const malicious = '<script>alert(1)</script>"&<>\'onerror=x';
|
||||
const escaped = global.escapeHtml(malicious);
|
||||
expect(escaped).not.toContain('<script>');
|
||||
expect(escaped).not.toContain('"');
|
||||
expect(escaped).toContain('<script>');
|
||||
expect(escaped).toContain('"');
|
||||
expect(escaped).toContain('&');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,11 @@ const bundles = {
|
||||
// window.wireModal + window.injectModal + window.escapeHtml helpers
|
||||
// defined in globals.js (already in core.js).
|
||||
JS('share-modal.js'),
|
||||
// DC-106: Reverse proxy visual builder — opened from the "🔧 Reverse
|
||||
// Proxy Builder" button in the Tools menu. Uses window.injectModal +
|
||||
// window.escapeHtml from globals.js (in core.js). Lives in features.js
|
||||
// because it's a modal-style tool, not part of the core dashboard.
|
||||
JS('caddy-builder.js'),
|
||||
],
|
||||
'onboarding.js': [
|
||||
JS('driver.min.js'),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/* ===== DC-106: Reverse Proxy Builder styles ===== */
|
||||
|
||||
.cb-section {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--card-base, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cb-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cb-label > input[type="text"],
|
||||
.cb-label > select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cb-label > input[type="text"]:focus,
|
||||
.cb-label > select:focus {
|
||||
outline: 2px solid var(--accent, #4a9eff);
|
||||
outline-offset: -1px;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.cb-label > input[type="text"]:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cb-label small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cb-label small code {
|
||||
background: var(--code-bg, rgba(0,0,0,0.06));
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.cb-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cb-checkbox input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cb-preview {
|
||||
flex: 1;
|
||||
min-height: 380px;
|
||||
max-height: 540px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: var(--code-bg, #0e1116);
|
||||
color: var(--code-fg, #e6e6e6);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.cb-preview code {
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cb-validation {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cb-issues {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cb-err {
|
||||
padding: 6px 10px;
|
||||
background: color-mix(in srgb, var(--err-fg, #e74c3c) 12%, transparent);
|
||||
border-left: 3px solid var(--err-fg, #e74c3c);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cb-warn {
|
||||
padding: 6px 10px;
|
||||
background: color-mix(in srgb, var(--warn-fg, #f0c674) 12%, transparent);
|
||||
border-left: 3px solid var(--warn-fg, #f0c674);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cb-ok {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: color-mix(in srgb, var(--ok-fg, #27ae60) 12%, transparent);
|
||||
border-left: 3px solid var(--ok-fg, #27ae60);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cb-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--err-fg, #e74c3c) 12%, transparent);
|
||||
border-left: 3px solid var(--err-fg, #e74c3c);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cb-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cb-header-row input {
|
||||
padding: 5px 8px;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cb-header-row input:focus {
|
||||
outline: 2px solid var(--accent, #4a9eff);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.cb-header-row button {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.85rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cb-header-row button:hover {
|
||||
border-color: var(--err-fg, #e74c3c);
|
||||
color: var(--err-fg, #e74c3c);
|
||||
}
|
||||
|
||||
/* Modal sizing override for the builder — needs wider content */
|
||||
#caddy-builder-modal .weather-modal-content {
|
||||
width: min(1100px, 95vw);
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
#caddy-builder-modal .weather-modal-content > div[style*="grid-template-columns"] {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
<link rel="stylesheet" href="/css/themes.css">
|
||||
<link rel="stylesheet" href="/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/css/xterm.css">
|
||||
<link rel="stylesheet" href="/css/caddy-builder.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -201,6 +202,7 @@
|
||||
<span class="tools-section-label">Tools</span>
|
||||
</button>
|
||||
<div class="tools-section-items">
|
||||
<button id="caddy-builder-btn" aria-label="Visual reverse proxy builder">🔧 Reverse Proxy Builder</button>
|
||||
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
|
||||
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
|
||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user