Files
vacuum-wall/webui/static/pages/proxy.js
T
mteehan 332d14e37d ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
2026-08-20 01:38:00 +00:00

276 lines
12 KiB
JavaScript

import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js';
import { openBackendModal } from '/static/pages/backends.js';
function certLookup(acmeData) {
const m = {};
if (acmeData && acmeData.certs) {
for (const c of acmeData.certs) {
m[c.domain] = c;
}
}
return m;
}
function buildCertOptions(certs) {
const opts = [
['', '(none)'],
['acme', 'acme — auto-issue'],
['selfsigned', 'selfsigned'],
['file', 'file — custom path'],
];
for (const c of (certs || [])) {
const days = c.expired ? 'Expired' : `${c.days_remaining}d`;
opts.push([`acme|${c.domain}`, `acme: ${c.domain} (${days})`]);
}
return opts;
}
function certValueFromSelect(raw) {
if (raw === 'acme' || (raw && raw.startsWith('acme|'))) return 'acme';
if (raw === 'selfsigned' || raw === 'file') return raw;
return undefined;
}
function buildBackendOptions(backends) {
const opts = [['', '(select backend)']];
const entries = Object.entries(backends || {});
entries.sort((a, b) => (a[1].label || a[0]).localeCompare(b[1].label || b[0]));
entries.forEach(([name, b]) => {
const label = b.label || name;
const suffix = b.builtin ? ' (builtin)' : '';
opts.push([name, label + suffix]);
});
return opts;
}
function _groupByBackend(domains, backends) {
const backendMap = {};
for (const d of domains) {
const bn = d.backend_name;
if (!backendMap[bn]) backendMap[bn] = {};
if (!backendMap[bn][d.domain]) backendMap[bn][d.domain] = [];
backendMap[bn][d.domain].push(d);
}
const backendNames = new Set(Object.keys(backends || {}));
for (const bn of Object.keys(backendMap)) {
backendNames.add(bn);
}
const sections = [];
for (const bn of backendNames) {
const b = (backends || {})[bn] || {};
const domainGroups = backendMap[bn] || {};
const sortedDomains = Object.entries(domainGroups)
.map(([domainName, paths]) => ({ domain: domainName, paths }))
.sort((a, b) => a.domain.localeCompare(b.domain));
sections.push({
backendName: bn,
backend: { name: bn, label: b.label || bn, builtin: !!b.builtin, paths_count: Object.keys(b.paths || {}).length, ...b },
domains: sortedDomains,
hasBuiltin: !!b.builtin,
});
}
sections.sort((a, b) => {
if (a.hasBuiltin && !b.hasBuiltin) return -1;
if (!a.hasBuiltin && b.hasBuiltin) return 1;
return a.backend.label.localeCompare(b.backend.label);
});
return sections;
}
function addDomain(state, preselectedBackend) {
const backends = state.backends ? (state.backends.data || {}) : {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const backendOptions = buildBackendOptions(backends);
const modal = QuickModal({
title: 'Add Proxy Domain',
fields: [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Backend', id: 'p-backend', tag: 'select', options: backendOptions },
{ label: 'Cert', id: 'p-cert', tag: 'select', options: certOptions },
],
submit: {
url: '/api/proxy/domains',
body: () => {
const body = {
domain: ($val('p-domain') || '').trim(),
backend: ($val('p-backend') || '').trim(),
force_ssl: true,
};
const certVal = certValueFromSelect($val('p-cert'));
if (certVal) body.cert = certVal;
return body;
},
validate: (b) => !b.domain ? 'Domain is required' :
!b.backend ? 'Backend is required' : null,
successMsg: 'Domain added',
},
postRender: (inner) => {
if (preselectedBackend) {
const backendSelect = inner.querySelector('#p-backend');
if (backendSelect) backendSelect.value = preselectedBackend;
}
const certSelect = inner.querySelector('#p-cert');
const domainInput = inner.querySelector('#p-domain');
if (certSelect && domainInput) {
certSelect.addEventListener('change', () => {
const val = certSelect.value;
if (val && val.startsWith('acme|')) {
domainInput.value = val.slice(5);
}
});
}
},
});
modal({});
}
function editDomain(d, state) {
const backends = state.backends ? (state.backends.data || {}) : {};
const backend = backends[d.backend_name] || {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const certMap = certLookup(state.acme ? state.acme.data : null);
const domainCert = certMap[d.domain];
let selectedCert = '';
if (domainCert) {
selectedCert = `acme|${domainCert.domain}`;
} else if (d.cert) {
selectedCert = d.cert;
}
const paths = backend.paths || {};
const pathSummary = Object.entries(paths).map(([p, pcfg]) => {
const be = pcfg.backend || {};
return `${esc(p)}${esc(be.host || '-')}:${be.port || '-'}`;
}).join('\n') || '—';
const modal = QuickModal({
title: 'Edit: ' + esc(d.domain),
fields: [
{ label: 'Domain', id: 'pe-domain', value: d.domain },
{ label: 'Backend', id: 'pe-backend', value: (d.backend_name || '-') + ' (' + (backend.label || '—') + ')' },
{ label: 'Paths', id: 'pe-paths', tag: 'textarea', value: pathSummary },
{ label: 'Cert', id: 'pe-cert', tag: 'select', options: certOptions },
{ label: 'Force SSL', id: 'pe-force-ssl', tag: 'checkbox', checked: d.force_ssl },
],
submit: {
url: '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: () => ({
cert: certValueFromSelect($val('pe-cert')) || '',
force_ssl: document.getElementById('pe-force-ssl')?.checked ?? true,
}),
validate: (b) => !b.cert ? 'Cert is required' : null,
successMsg: 'Domain updated',
},
postRender: (inner) => {
const certSelect = inner.querySelector('#pe-cert');
if (certSelect) certSelect.value = selectedCert;
for (const id of ['pe-domain', 'pe-backend', 'pe-paths']) {
const el = inner.querySelector('#' + id);
if (el) { el.readOnly = true; el.style.background = '#f5f5f5'; }
}
},
});
modal({});
}
function domainRow(domainName, domainPaths, state) {
const d = domainPaths[0];
const certMap = certLookup(state.acme ? state.acme.data : null);
const cert = certMap[d.domain];
let certBadge, certTitle;
if (cert) {
certBadge = certStatusBadge({ daysRemaining: cert.days_remaining, expired: cert.expired });
certTitle = 'ACME: ' + cert.domain;
} else if (d.cert === 'selfsigned') {
certBadge = Badge({ text: 'Self-signed', variant: 'warning' });
certTitle = 'Self-signed';
} else if (d.cert === 'file') {
certBadge = Badge({ text: 'File', variant: 'secondary' });
certTitle = 'File';
} else {
certBadge = Badge({ text: '—', variant: 'info' });
certTitle = 'No certificate';
}
const pathSummaries = domainPaths.map(p => {
const be = p.backend || {};
let parts = [esc(p.path), `${esc(be.host || '-')}:${be.port || '-'}`];
const flags = [];
if (p.is_websocket) flags.push('ws');
if (p.is_management) flags.push('mgmt');
if (flags.length) parts.push(flags.join(', '));
return parts.join(' → ');
});
return html`<tr key=${domainName} class="domain-row">
<td><strong>${esc(domainName)}</strong></td>
<td>${pathSummaries}</td>
<td title=${certTitle}>${certBadge}</td>
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
<td>
<${ActionCell}
editLabel="Edit"
editClick=${() => editDomain(d, state)}
removeUrl=${'/api/proxy/domains/' + enc(domainName)}
removeMessage=${'Remove ' + enc(domainName) + '?'}
removeSuccess="Domain removed"
removeLabel="Delete" />
</td>
</tr>`;
}
function backendSection(section, state) {
const { backendName, backend, domains } = section;
const rows = domains.map(d => domainRow(d.domain, d.paths, state));
const sectionActions = [];
if (!backend.builtin) {
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
if (domains.length === 0) {
sectionActions.push(html`<${ConfirmDelete}
url=${'/api/proxy/backends/' + enc(backendName)}
deleteKey=${backendName}
message=${'Remove backend ' + enc(backendName) + '?'}
success="Backend removed"
onComplete=${() => modelFetch('backends')}
label="Delete" />`);
}
}
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
return html`<div class="backend-section" key=${backendName} style="margin-bottom:24px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
<h3 style="margin:0;display:flex;align-items:center;gap:8px;">
<${Badge} text=${esc(backendName)} variant="primary" />
${esc(backend.label || backendName)}
</h3>
<div style="display:flex;gap:8px;">${sectionActions}</div>
</div>
${domains.length
? Table({ columns: ['Domain', 'Paths', 'Cert', 'Force SSL', 'Actions'], rows })
: Empty({ text: 'No domains using this backend.' })}
</div>`;
}
export default definePage({
init() {
return {
nginx: getModel('nginx'),
backends: getModel('backends'),
acme: getModel('acme'),
};
},
render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard;
const domains = state.nginx.data.domains || [];
const backends = state.backends.data || {};
const sections = _groupByBackend(domains, backends);
const sectionVNodes = sections.map(s => backendSection(s, state));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
);
return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
Object.keys(backends).length || domains.length
? sectionVNodes
: Empty({ text: 'No backends configured. Create a backend first, then add domains.' }),
];
},
});