feat: auto-generate self-signed certs, hoover modal processing guard, refactor backends/proxy pages
This commit is contained in:
+102
-120
@@ -1,9 +1,7 @@
|
||||
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup } from '/static/hoover/index.js?v=8';
|
||||
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=8';
|
||||
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=9';
|
||||
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=9';
|
||||
import { openBackendModal } from '/static/pages/backends.js?v=11';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cert lookup map from ACME state keyed by domain name
|
||||
// ---------------------------------------------------------------------------
|
||||
function certLookup(acmeData) {
|
||||
const m = {};
|
||||
if (acmeData && acmeData.certs) {
|
||||
@@ -14,9 +12,6 @@ function certLookup(acmeData) {
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build cert select options from ACME certs array
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildCertOptions(certs) {
|
||||
const opts = [
|
||||
['', '(none)'],
|
||||
@@ -25,26 +20,18 @@ function buildCertOptions(certs) {
|
||||
['file', 'file — custom path'],
|
||||
];
|
||||
for (const c of (certs || [])) {
|
||||
const days = c.expired
|
||||
? 'Expired'
|
||||
: `${c.days_remaining}d`;
|
||||
const days = c.expired ? 'Expired' : `${c.days_remaining}d`;
|
||||
opts.push([`acme|${c.domain}`, `acme: ${c.domain} (${days})`]);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map cert select value to payload cert string
|
||||
// ---------------------------------------------------------------------------
|
||||
function certValueFromSelect(raw) {
|
||||
if (raw === 'acme' || (raw && raw.startsWith('acme|'))) return 'acme';
|
||||
if (raw === 'selfsigned' || raw === 'file') return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build backend select options from backends model
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildBackendOptions(backends) {
|
||||
const opts = [['', '(select backend)']];
|
||||
const entries = Object.entries(backends || {});
|
||||
@@ -57,14 +44,44 @@ function buildBackendOptions(backends) {
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add Domain modal — backend selector
|
||||
// ---------------------------------------------------------------------------
|
||||
function addDomain(state) {
|
||||
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' },
|
||||
@@ -76,34 +93,26 @@ function addDomain(state) {
|
||||
label: 'Add',
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
handler: async () => {
|
||||
handler: formAction(async () => {
|
||||
const domain = ($val('p-domain') || '').trim();
|
||||
if (!domain) throw 'Domain is required';
|
||||
const backend = ($val('p-backend') || '').trim();
|
||||
const rawCert = $val('p-cert');
|
||||
|
||||
if (!domain) { toast('Domain is required', 'error'); return; }
|
||||
if (!backend) { toast('Backend is required', 'error'); return; }
|
||||
|
||||
const body = {
|
||||
domain,
|
||||
backend,
|
||||
force_ssl: true,
|
||||
};
|
||||
const certVal = certValueFromSelect(rawCert);
|
||||
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) {
|
||||
toast('Domain added', 'success');
|
||||
closeModal();
|
||||
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
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) {
|
||||
@@ -117,14 +126,10 @@ function addDomain(state) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Domain modal — cert and force_ssl only (backend is read-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
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 = '';
|
||||
@@ -133,8 +138,6 @@ function editDomain(d, state) {
|
||||
} else if (d.cert) {
|
||||
selectedCert = d.cert;
|
||||
}
|
||||
|
||||
// Build path summary rows (read-only)
|
||||
const paths = backend.paths || {};
|
||||
const pathKeys = Object.keys(paths);
|
||||
const pathSummary = pathKeys.map(p => {
|
||||
@@ -142,7 +145,6 @@ function editDomain(d, state) {
|
||||
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 },
|
||||
@@ -156,59 +158,35 @@ function editDomain(d, state) {
|
||||
label: 'Save',
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
handler: async () => {
|
||||
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 = {};
|
||||
body.cert = certValueFromSelect(rawCert);
|
||||
body.force_ssl = forceSsl;
|
||||
|
||||
const body = { cert: certValueFromSelect(rawCert), force_ssl: forceSsl };
|
||||
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body });
|
||||
if (res.ok) {
|
||||
toast('Domain updated', 'success');
|
||||
closeModal();
|
||||
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
if (!res.ok) throw res.error || 'Failed';
|
||||
toast('Domain updated', 'success');
|
||||
closeModal();
|
||||
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
// Set cert select value
|
||||
const certSelect = inner.querySelector('#pe-cert');
|
||||
if (certSelect) certSelect.value = selectedCert;
|
||||
|
||||
// Make read-only fields actually read-only
|
||||
const domainInput = inner.querySelector('#pe-domain');
|
||||
if (domainInput) {
|
||||
domainInput.readOnly = true;
|
||||
domainInput.style.background = '#f5f5f5';
|
||||
}
|
||||
if (domainInput) { domainInput.readOnly = true; domainInput.style.background = '#f5f5f5'; }
|
||||
const backendInput = inner.querySelector('#pe-backend');
|
||||
if (backendInput) {
|
||||
backendInput.readOnly = true;
|
||||
backendInput.style.background = '#f5f5f5';
|
||||
}
|
||||
if (backendInput) { backendInput.readOnly = true; backendInput.style.background = '#f5f5f5'; }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row for a domain (grouped by domain name)
|
||||
// ---------------------------------------------------------------------------
|
||||
function domainRow(domainName, domainPaths, state) {
|
||||
const d = domainPaths[0];
|
||||
const certMap = certLookup(state.acme ? state.acme.data : null);
|
||||
const backend = state.backends ? (state.backends.data || {})[d.backend_name] : {};
|
||||
|
||||
const cert = certMap[d.domain];
|
||||
let certBadge, certTitle;
|
||||
if (cert) {
|
||||
certBadge = certStatusBadge({
|
||||
daysRemaining: cert.days_remaining,
|
||||
expired: cert.expired,
|
||||
});
|
||||
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' });
|
||||
@@ -220,8 +198,6 @@ function domainRow(domainName, domainPaths, state) {
|
||||
certBadge = Badge({ text: '—', variant: 'info' });
|
||||
certTitle = 'No certificate';
|
||||
}
|
||||
|
||||
// Build paths summary
|
||||
const pathSummaries = domainPaths.map(p => {
|
||||
const be = p.backend || {};
|
||||
let parts = [esc(p.path), `${esc(be.host || '-')}:${be.port || '-'}`];
|
||||
@@ -231,13 +207,8 @@ function domainRow(domainName, domainPaths, state) {
|
||||
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>
|
||||
<${Badge} text=${esc(d.backend_name || '-')} variant="primary" />
|
||||
<span class="text-muted" style="margin-left:4px">${esc(backend.label || '')}</span>
|
||||
</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>
|
||||
@@ -254,9 +225,37 @@ function domainRow(domainName, domainPaths, state) {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
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 {
|
||||
@@ -268,36 +267,19 @@ export default definePage({
|
||||
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 || [];
|
||||
|
||||
// Group by domain name
|
||||
const groups = {};
|
||||
for (const d of domains) {
|
||||
if (!groups[d.domain]) groups[d.domain] = [];
|
||||
groups[d.domain].push(d);
|
||||
}
|
||||
|
||||
const rows = Object.values(groups).map(paths => domainRow(paths[0].domain, paths, state));
|
||||
|
||||
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'],
|
||||
}),
|
||||
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply', refresh: ['nginx', 'acme'] }),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
||||
domains.length
|
||||
? Table({
|
||||
columns: ['Domain', 'Backend', 'Paths', 'Cert', 'Force SSL', 'Actions'],
|
||||
rows,
|
||||
})
|
||||
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
|
||||
Object.keys(backends).length || domains.length
|
||||
? sectionVNodes
|
||||
: Empty({ text: 'No backends configured. Create a backend first, then add domains.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user