diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index 5398abc..a702341 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -1,4 +1,5 @@ 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 @@ -14,92 +15,188 @@ function certLookup(acmeData) { } // --------------------------------------------------------------------------- -// Add Domain modal — paths-based body +// Build cert select options from ACME certs array // --------------------------------------------------------------------------- -const addDomain = QuickModal({ - title: 'Add Proxy Domain', - fields: [ - { 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 (optional)', id: 'p-cert', placeholder: 'acme' }, - ], - submit: { - url: '/api/proxy/domains', - body: () => { - const path = ($val('p-path') || '/').trim() || '/'; - return { - 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: ($val('p-cert') || '').trim() || undefined, - }; - }, - validate: (b) => { - if (!b.domain) return 'Domain is required'; - const p = b.paths ? Object.values(b.paths)[0] : {}; - const be = p && p.backend; - if (!be || !be.host || !be.port) return 'Host and port are required'; - return null; - }, - successMsg: 'Domain added', - }, - refresh: ['nginx', 'acme'], -}); +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; +} // --------------------------------------------------------------------------- -// Edit Domain modal — updates backend for the root path +// Map cert select value to payload cert string // --------------------------------------------------------------------------- -const editDomain = QuickModal({ - title: (d) => 'Edit: ' + d.domain + ' ' + d.path, - fields: (d) => { - const be = d.backend || {}; - return [ +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 (optional)', id: 'pe-cert', value: d._cert || d.cert || '' }, - ]; - }, - submit: { - url: (d) => '/api/proxy/domains/' + enc(d.domain), - method: 'PUT', - body: (d) => ({ - backend: { - host: ($val('pe-host') || '').trim(), - port: parseInt($val('pe-port')), - proto: ($val('pe-proto') || 'http').trim(), + { 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'); + } + }, }, - cert: ($val('pe-cert') || '').trim() || undefined, - }), - validate: (b) => !b.backend || !b.backend.host || !b.backend.port ? 'Host and port are required' : null, - successMsg: 'Domain updated', - }, - refresh: ['nginx', 'acme'], -}); + ]); + + const certSelect = inner.querySelector('#pe-cert'); + if (certSelect) { + certSelect.value = selectedCert; + } + }); +} // --------------------------------------------------------------------------- // Path detail row // --------------------------------------------------------------------------- -function pathRow(d, certs, domainPaths) { +function pathRow(d, certs, domainPaths, state) { const be = d.backend || {}; const cert = certs[d.domain]; - const certBadge = cert - ? certStatusBadge({ + let certBadge, certTitle; + if (cert) { + certBadge = certStatusBadge({ daysRemaining: cert.days_remaining, expired: cert.expired, - }) - : Badge({ text: '—', variant: 'info' }); + }); + 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; @@ -121,7 +218,7 @@ function pathRow(d, certs, domainPaths) { } else { actions = ActionCell({ editLabel: 'Edit', - editClick: () => editDomain(d), + editClick: () => editDomain(d, state), removeUrl: '/api/proxy/domains/' + enc(d.domain), removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?', removeSuccess: 'Removed', @@ -141,7 +238,7 @@ function pathRow(d, certs, domainPaths) {