ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
285 lines
13 KiB
JavaScript
285 lines
13 KiB
JavaScript
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, formAction } from '/static/hoover/index.js?v=10';
|
|
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=9';
|
|
import { openBackendModal } from '/static/pages/backends.js?v=11';
|
|
|
|
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);
|
|
openModal((inner) => {
|
|
formModal(inner, 'Add Proxy Domain', [
|
|
{ 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 },
|
|
], [
|
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
|
{
|
|
label: 'Add',
|
|
cls: 'btn-primary',
|
|
action: 's',
|
|
handler: formAction(async () => {
|
|
const domain = ($val('p-domain') || '').trim();
|
|
if (!domain) throw 'Domain is required';
|
|
const backend = ($val('p-backend') || '').trim();
|
|
if (!backend) throw 'Backend is required';
|
|
const body = { domain, backend, force_ssl: true };
|
|
const certVal = certValueFromSelect($val('p-cert'));
|
|
if (certVal) body.cert = certVal;
|
|
const res = await apiFetch('/api/proxy/domains', { method: 'POST', body });
|
|
if (!res.ok) throw res.error || 'Failed';
|
|
toast('Domain added', 'success');
|
|
closeModal();
|
|
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
|
}),
|
|
},
|
|
]);
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
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 pathKeys = Object.keys(paths);
|
|
const pathSummary = pathKeys.map(p => {
|
|
const pcfg = paths[p];
|
|
const be = pcfg.backend || {};
|
|
return `${esc(p)} → ${esc(be.host || '-')}:${be.port || '-'}`;
|
|
}).join('\n') || '—';
|
|
openModal((inner) => {
|
|
formModal(inner, 'Edit: ' + esc(d.domain), [
|
|
{ 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, readonly: true },
|
|
{ label: 'Cert', id: 'pe-cert', tag: 'select', options: certOptions },
|
|
{ label: 'Force SSL', id: 'pe-force-ssl', tag: 'checkbox', checked: d.force_ssl },
|
|
], [
|
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
|
{
|
|
label: 'Save',
|
|
cls: 'btn-primary',
|
|
action: 's',
|
|
handler: formAction(async () => {
|
|
const rawCert = $val('pe-cert');
|
|
if (!rawCert) throw 'Cert is required';
|
|
const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true;
|
|
const body = { cert: certValueFromSelect(rawCert), force_ssl: forceSsl };
|
|
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body });
|
|
if (!res.ok) throw res.error || 'Failed';
|
|
toast('Domain updated', 'success');
|
|
closeModal();
|
|
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
|
}),
|
|
},
|
|
]);
|
|
const certSelect = inner.querySelector('#pe-cert');
|
|
if (certSelect) certSelect.value = selectedCert;
|
|
const domainInput = inner.querySelector('#pe-domain');
|
|
if (domainInput) { domainInput.readOnly = true; domainInput.style.background = '#f5f5f5'; }
|
|
const backendInput = inner.querySelector('#pe-backend');
|
|
if (backendInput) { backendInput.readOnly = true; backendInput.style.background = '#f5f5f5'; }
|
|
});
|
|
}
|
|
|
|
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"
|
|
removeRefresh=["nginx", "acme"]
|
|
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"
|
|
refresh=["backends", "nginx"]
|
|
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', refresh: ['nginx', 'acme'] }),
|
|
);
|
|
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.' }),
|
|
];
|
|
},
|
|
}); |