8bb3619ddc
- Add fmtBytes() and csvToArr() helpers to hoover/helpers.js - Replace inline async patterns with ActionButton/ConfirmDelete in wireguard.js - Convert addDomain/editDomain to QuickModal + apiSubmit in proxy.js - Convert settingsModal handlers to formAction in certs.js - Remove redundant synced handling from dhcp.js apply button - Add onComplete callback to ConfirmDelete (fixes users.js onRefresh bug) - Fix passkeys.js ActionCell/Table usage (invalid component API) - Remove duplicate fmtBytes from dashboard.js
279 lines
12 KiB
JavaScript
279 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',
|
|
},
|
|
refresh: ['nginx', 'acme'],
|
|
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',
|
|
},
|
|
refresh: ['nginx', 'acme'],
|
|
postRender: (inner) => {
|
|
const certSelect = inner.querySelector('#pe-cert');
|
|
if (certSelect) certSelect.value = selectedCert;
|
|
for (const id of ['pe-domain', 'pe-backend']) {
|
|
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"
|
|
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.' }),
|
|
];
|
|
},
|
|
}); |