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?v=7'; import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=7'; // --------------------------------------------------------------------------- // Cert lookup map from ACME state keyed by domain name // --------------------------------------------------------------------------- function certLookup(acmeData) { const m = {}; if (acmeData && acmeData.certs) { for (const c of acmeData.certs) { m[c.domain] = c; } } return m; } // --------------------------------------------------------------------------- // Build cert select options from ACME certs array // --------------------------------------------------------------------------- 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; } // --------------------------------------------------------------------------- // 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; } // --------------------------------------------------------------------------- // Add Domain modal — paths-based body with cert selector // --------------------------------------------------------------------------- function addDomain(state) { const certs = state.acme ? (state.acme.data.certs || []) : []; const certOptions = buildCertOptions(certs); openModal((inner) => { formModal(inner, 'Add Proxy Domain', [ { label: 'Domain', id: 'p-domain', placeholder: 'example.com' }, { label: 'Path', id: 'p-path', placeholder: '/' }, { label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' }, { label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' }, { label: 'Protocol', id: 'p-proto', placeholder: 'http or https' }, { 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: async () => { const path = ($val('p-path') || '/').trim() || '/'; const rawCert = $val('p-cert'); const body = { domain: ($val('p-domain') || '').trim(), paths: { [path]: { backend: { host: ($val('p-host') || '').trim(), port: parseInt($val('p-port')), proto: ($val('p-proto') || 'http').trim() || 'http', }, headers: {}, }, }, cert: certValueFromSelect(rawCert), }; if (!body.domain) { toast('Domain is required', 'error'); return; } const p = body.paths ? Object.values(body.paths)[0] : {}; const be = p && p.backend; if (!be || !be.host || !be.port) { toast('Host and port are required', 'error'); return; } 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'); } }, }, ]); const certSelect = document.getElementById('p-cert'); const domainInput = document.getElementById('p-domain'); if (certSelect && domainInput) { certSelect.addEventListener('change', () => { const val = certSelect.value; if (val && val.startsWith('acme|')) { domainInput.value = val.slice(5); } }); } }); } // --------------------------------------------------------------------------- // Edit Domain modal — updates backend for the root path, with cert selector // --------------------------------------------------------------------------- function editDomain(d, state) { const certs = state.acme ? (state.acme.data.certs || []) : []; const certOptions = buildCertOptions(certs); let selectedCert = ''; if (d._cert) { selectedCert = `acme|${d._cert.domain}`; } else if (d.cert) { selectedCert = d.cert; } const be = d.backend || {}; openModal((inner) => { formModal(inner, 'Edit: ' + d.domain + ' ' + d.path, [ { label: 'Backend Host', id: 'pe-host', value: be.host || '' }, { label: 'Backend Port', id: 'pe-port', type: 'number', value: be.port || '' }, { label: 'Protocol', id: 'pe-proto', value: be.proto || 'http' }, { label: 'Cert', id: 'pe-cert', tag: 'select', options: certOptions }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, { label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { const rawCert = $val('pe-cert'); const body = { backend: { host: ($val('pe-host') || '').trim(), port: parseInt($val('pe-port')), proto: ($val('pe-proto') || 'http').trim(), }, cert: certValueFromSelect(rawCert), }; if (!body.backend || !body.backend.host || !body.backend.port) { toast('Host and port are required', 'error'); return; } 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'); } }, }, ]); const certSelect = inner.querySelector('#pe-cert'); if (certSelect) { certSelect.value = selectedCert; } }); } // --------------------------------------------------------------------------- // Path detail row // --------------------------------------------------------------------------- function pathRow(d, certs, domainPaths, state) { const be = d.backend || {}; const cert = certs[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 isWs = d.is_websocket; const isMgmt = d.is_management; const multiPath = (domainPaths || []).length > 1; let actions; if (isWs) { actions = ActionButton({ url: '/api/proxy/domains/' + enc(d.domain), method: 'PUT', body: () => ({ path: d.path }), label: 'Delete', cls: 'btn btn-sm btn-danger', successMsg: 'Path removed', refresh: ['nginx', 'acme'], }); } else { actions = ActionCell({ editLabel: 'Edit', editClick: () => editDomain(d, state), removeUrl: '/api/proxy/domains/' + enc(d.domain), removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?', removeSuccess: 'Removed', removeRefresh: ['nginx', 'acme'], removeLabel: 'Delete', }); } const flagBadges = []; if (isWs) flagBadges.push(Badge({ text: 'ws', variant: 'secondary' })); if (isMgmt) flagBadges.push(Badge({ text: 'mgmt', variant: 'warning' })); return html`
${esc(d.path)}