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`