3de82e3b9b
Drop ?v=N version pins from all JS imports and HTML <link>/<script> tags. Cache invalidation is now handled solely by server-side cache-control headers. Update docs and AGENTS.md accordingly.
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';
|
|
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.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);
|
|
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.' }),
|
|
];
|
|
},
|
|
}); |