import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal, PendingDot, dirtySet, dirtyInfo } 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', }, 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', }, postRender: (inner) => { const certSelect = inner.querySelector('#pe-cert'); if (certSelect) certSelect.value = selectedCert; for (const id of ['pe-domain', 'pe-backend', 'pe-paths']) { const el = inner.querySelector('#' + id); if (el) { el.readOnly = true; el.style.background = '#f5f5f5'; } } }, }); modal({}); } function domainRow(domainName, domainPaths, state, set) { const d = domainPaths[0]; const info = dirtyInfo(set, 'domains.' + domainName); 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`