From 8bb3619ddc1611ce280e0fc1dc6d02d059e50b3b Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Tue, 28 Jul 2026 17:32:51 +0000 Subject: [PATCH] refactor: extract shared utilities and standardize page patterns - Add fmtBytes() and csvToArr() helpers to hoover/helpers.js - Replace inline async patterns with ActionButton/ConfirmDelete in wireguard.js - Convert addDomain/editDomain to QuickModal + apiSubmit in proxy.js - Convert settingsModal handlers to formAction in certs.js - Remove redundant synced handling from dhcp.js apply button - Add onComplete callback to ConfirmDelete (fixes users.js onRefresh bug) - Fix passkeys.js ActionCell/Table usage (invalid component API) - Remove duplicate fmtBytes from dashboard.js --- webui/static/hoover/components/data.js | 8 +- webui/static/hoover/helpers.js | 22 ++++ webui/static/hoover/index.js | 2 +- webui/static/pages/certs.js | 57 ++++------- webui/static/pages/dashboard.js | 10 +- webui/static/pages/dhcp.js | 10 +- webui/static/pages/passkeys.js | 65 +++++------- webui/static/pages/proxy.js | 134 ++++++++++++------------- webui/static/pages/users.js | 2 +- webui/static/pages/wireguard.js | 66 +++--------- 10 files changed, 156 insertions(+), 220 deletions(-) diff --git a/webui/static/hoover/components/data.js b/webui/static/hoover/components/data.js index 6605bcd..bc9a49b 100644 --- a/webui/static/hoover/components/data.js +++ b/webui/static/hoover/components/data.js @@ -82,6 +82,7 @@ export function Card(props = {}) { * @param {string} [props.label] - Button text (default: 'Remove') * @param {object} [props.body] - Optional JSON body to send with DELETE * @param {string} [props.deleteKey] - Unique ID for pending-delete row styling + * @param {function} [props.onComplete] - Callback after successful deletion */ export function ConfirmDelete(props = {}) { const opts = { method: 'DELETE' }; @@ -118,13 +119,16 @@ export function ConfirmDelete(props = {}) { if (props.deleteKey && promises.length) { Promise.all(promises).finally(() => { _deleting.delete(props.deleteKey); + if (props.onComplete) props.onComplete(); }); } } else if (props.deleteKey) { - // Cleanup dimming synchronously on DELETE completion rather than - // relying on a heuristic timeout that breaks when tabs are throttled. _deleting.delete(props.deleteKey); } + + if (props.onComplete && !props.refresh) { + props.onComplete(); + } } else { toast(r.error || 'Failed', 'error'); } diff --git a/webui/static/hoover/helpers.js b/webui/static/hoover/helpers.js index c7f3bc7..f599af0 100644 --- a/webui/static/hoover/helpers.js +++ b/webui/static/hoover/helpers.js @@ -56,6 +56,28 @@ export function parseZones(data) { * @param {Blob} blob * @param {string} filename */ +/** + * Format bytes to human-readable string. + * @param {number} bytes + */ +export function fmtBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + sizes[i]; +} + +/** + * Split a comma-separated string into trimmed, non-empty values. + * @param {string} [value] + * @returns {string[]} + */ +export function csvToArr(value) { + if (!value || !value.trim()) return []; + return value.split(',').map(s => s.trim()).filter(Boolean); +} + export function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index e164926..df4a685 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -35,7 +35,7 @@ export { scheduleTokenRefresh, cancelTokenRefresh, checkSession, logout, initAut export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js'; /* ── Helpers ─────────────────────────────────────────────────── */ -export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js'; +export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob } from './helpers.js'; /* ── UI Components: Layout ───────────────────────────────────── */ export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js'; diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js index de6e408..be89322 100644 --- a/webui/static/pages/certs.js +++ b/webui/static/pages/certs.js @@ -92,47 +92,28 @@ function settingsModal(account) { closeModal(idx); }); - inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => { - if (isModalProcessing()) return; - setModalProcessing(true); - try { + inner.querySelector('[data-action="set-save"]')?.addEventListener('click', + formAction(async () => { const email = ($val('set-email') || '').trim(); - if (!email) { toast('Email is required', 'error'); return; } - const resp = await apiFetch('/api/certs/email', { - method: 'POST', - body: { email }, - }); - if (resp.ok) { - toast('Email updated', 'success'); - closeModal(idx); - modelFetch('acme'); - } else { - toast(resp.error || 'Failed', 'error'); - } - } finally { - setModalProcessing(false); - refreshModals(); - } - }); + if (!email) throw 'Email is required'; + const resp = await apiFetch('/api/certs/email', { method: 'POST', body: { email } }); + if (!resp.ok) throw resp.error || 'Failed'; + toast('Email updated', 'success'); + closeModal(idx); + modelFetch('acme'); + }), + ); - inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => { - if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return; - if (isModalProcessing()) return; - setModalProcessing(true); - try { + inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', + formAction(async () => { + if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) throw new Error('Cancelled'); const resp = await apiFetch('/api/certs/account', { method: 'DELETE' }); - if (resp.ok) { - toast('Account deactivated', 'success'); - closeModal(idx); - modelFetch('acme'); - } else { - toast(resp.error || 'Failed', 'error'); - } - } finally { - setModalProcessing(false); - refreshModals(); - } - }); + if (!resp.ok) throw resp.error || 'Failed'; + toast('Account deactivated', 'success'); + closeModal(idx); + modelFetch('acme'); + }), + ); }); } diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js index 9fd0919..2019ff4 100644 --- a/webui/static/pages/dashboard.js +++ b/webui/static/pages/dashboard.js @@ -1,12 +1,4 @@ -import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton } from '/static/hoover/index.js'; - -function fmtBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return (bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + sizes[i]; -} +import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, fmtBytes } from '/static/hoover/index.js'; export default definePage({ init() { diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index 0270452..a68bbb8 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -1,4 +1,4 @@ -import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js'; +import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js'; function makeAddRange(activeZones, interfaces) { const opts = [ @@ -209,13 +209,7 @@ export default definePage({ 'on:click': async () => { const res = await apiFetch('/api/dhcp/apply', { method: 'POST' }); if (res.ok) { - const synced = res.data?.synced; - let msg = 'dnsmasq applied'; - if (synced && synced.length) { - msg += ' (auto-synced: ' + synced.join(', ') + ')'; - synced.forEach(s => modelFetch(s)); - } - toast(msg, 'success'); + toast('dnsmasq applied', 'success'); modelFetch('dnsmasq'); } else { toast(res.error || 'Apply failed', 'error'); diff --git a/webui/static/pages/passkeys.js b/webui/static/pages/passkeys.js index 344fe7a..79b2119 100644 --- a/webui/static/pages/passkeys.js +++ b/webui/static/pages/passkeys.js @@ -18,9 +18,8 @@ import { PageHeader, Empty, Table, - esc, - ActionCell, Badge, + esc, startRegistration, webauthnSupported, isModalProcessing, @@ -224,60 +223,44 @@ function CredentialsPage() { PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication', - actions: html``, + actions: webauthnSupported() + ? html`` + : html`WebAuthn not supported`, }), html`
No passkeys registered
- + ${webauthnSupported() + ? html`` + : ''}
`, ]; } - const cols = [ - { key: 'name', label: 'Name' }, - { key: 'transports', label: 'Transports' }, - { key: 'signCount', label: 'Uses' }, - { key: 'id', label: 'ID' }, - { key: '_action', label: '' }, - ]; - - const rows = state.credentials.map(c => ({ - name: esc(c.name || 'Unnamed'), - transports: (c.transports || ['internal']).map(t => - html`${esc(t)}` - ), - signCount: c.sign_count ?? 0, - id: esc(c.id.slice(0, 12) + '...'), - _action: ActionCell({ - actions: [ - { - label: 'Remove', - cls: 'btn-danger', - icon: 'Delete', - onClick: () => confirmRemove(c.id, c.name), - }, - ], - }), - })); + const rows = state.credentials.map(c => + html` + ${esc(c.name || 'Unnamed')} + ${(c.transports || ['internal']).map(t => html`<${Badge} text=${esc(t)} />`)} + ${c.sign_count ?? 0} + ${esc(c.id.slice(0, 12) + '...')} + + ` + ); const actions = webauthnSupported() - ? html`` - : html`WebAuthn not supported in this browser`; + ? html`` + : html`WebAuthn not supported`; return [ PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication', - actions: actions, + actions, + }), + Table({ + columns: ['Name', 'Transports', 'Uses', 'ID', 'Actions'], + rows, + emptyText: 'No passkeys found', }), - Table({ columns: cols, rows }), ]; } diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index 5bdc374..2336542 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -1,5 +1,4 @@ -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 { 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'; import { openBackendModal } from '/static/pages/backends.js'; function certLookup(acmeData) { @@ -82,48 +81,48 @@ 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', [ + 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 }, - ], [ - { 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))); - }), + ], + 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; }, - ]); - 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); - } - }); - } + validate: (b) => !b.domain ? 'Domain is required' : + !b.backend ? 'Backend is required' : null, + successMsg: 'Domain added', + }, + refresh: ['nginx', 'acme'], + 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) { @@ -139,45 +138,40 @@ function editDomain(d, state) { selectedCert = d.cert; } const paths = backend.paths || {}; - const pathKeys = Object.keys(paths); - const pathSummary = pathKeys.map(p => { - const pcfg = paths[p]; + const pathSummary = Object.entries(paths).map(([p, pcfg]) => { const be = pcfg.backend || {}; return `${esc(p)} → ${esc(be.host || '-')}:${be.port || '-'}`; }).join('\n') || '—'; - openModal((inner) => { - formModal(inner, 'Edit: ' + esc(d.domain), [ + 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, readonly: true }, + { 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 }, - ], [ - { 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'; } + ], + 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', + }, + refresh: ['nginx', 'acme'], + postRender: (inner) => { + const certSelect = inner.querySelector('#pe-cert'); + if (certSelect) certSelect.value = selectedCert; + for (const id of ['pe-domain', 'pe-backend']) { + const el = inner.querySelector('#' + id); + if (el) { el.readOnly = true; el.style.background = '#f5f5f5'; } + } + }, }); + modal({}); } function domainRow(domainName, domainPaths, state) { diff --git a/webui/static/pages/users.js b/webui/static/pages/users.js index 665b35b..f4591b0 100644 --- a/webui/static/pages/users.js +++ b/webui/static/pages/users.js @@ -222,7 +222,7 @@ function UsersPage() { deleteKey=${u.username} message=${'Delete user ' + esc(u.username) + '? This cannot be undone.'} success=${'User ' + esc(u.username) + ' deleted'} - onRefresh=${() => loadUsers()} />`} + onComplete=${() => loadUsers()} />`} `; }); diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js index 087b530..9643080 100644 --- a/webui/static/pages/wireguard.js +++ b/webui/static/pages/wireguard.js @@ -1,5 +1,5 @@ /** WireGuard page — tunnel & peer management. */ -import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG } from '/static/hoover/index.js'; +import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js'; /* ── LAN detection helper ────────────────────────────────────── */ function getLanSubnets() { @@ -22,11 +22,7 @@ function getLanSubnets() { } } -/* ── Allowed IPs helper ──────────────────────────────────────── */ -function parseAllowedIps(value) { - if (!value || !value.trim()) return []; - return value.split(',').map(s => s.trim()).filter(Boolean); -} + /* ── Color helpers ────────────────────────────────────────────── */ function classColor(classKey) { @@ -63,7 +59,7 @@ const addPeer = QuickModal({ } else if (preset === 'none') { allowed_ips = []; } else if (preset === 'custom') { - allowed_ips = parseAllowedIps($val('wg-allowed')); + allowed_ips = csvToArr($val('wg-allowed')); } else { allowed_ips = ['0.0.0.0/0']; } @@ -241,7 +237,7 @@ function settingsModal(wireguardData, state) { handler: formAction(async () => { const port = parseInt($val('wg-port'), 10); if (isNaN(port) || port < 1 || port > 65535) throw 'Invalid port'; - const addresses = ($val('wg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean); + const addresses = csvToArr($val('wg-addrs')); if (!addresses.length) throw 'At least one address required'; const body = { interface: { @@ -342,43 +338,6 @@ function editClassModal(key, cls, peerCount) { }); } -async function initClassKeys(classKey) { - const resp = await apiFetch('/api/wireguard/classes/keys/' + enc(classKey), { - method: 'POST', - }); - if (!resp.ok) { - toast(resp.error || 'Failed to generate keys', 'error'); - return; - } - toast('Keys generated for class "' + classKey + '"', 'success'); - modelFetch('wireguard'); -} - -async function deleteAccessClass(key) { - if (!confirm(`Delete access class '${key}'?`)) return; - const resp = await apiFetch('/api/wireguard/classes', { - method: 'DELETE', - body: { key }, - }); - if (!resp.ok) { - toast(resp.error || 'Failed to delete class', 'error'); - return; - } - toast('Class deleted', 'success'); - modelFetch('wireguard'); -} - -async function toggleClassTunnel(classKey, isUp) { - const url = '/api/wireguard/classes/' + enc(classKey) + '/' + (isUp ? 'down' : 'up'); - const resp = await apiFetch(url, { method: 'POST' }); - if (!resp.ok) { - toast(resp.error || 'Failed', 'error'); - return; - } - toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success'); - modelFetch('wireguard'); -} - /* ── Access Classes Section ──────────────────────────────────── */ function renderAccessClasses(config, status) { const classes = config?.access_classes || {}; @@ -414,13 +373,18 @@ function renderAccessClasses(config, status) { ${!hasKeys - ? html`` + ? html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Keys" + cls="btn btn-sm btn-warning" successMsg=${'Keys generated for ' + esc(k)} refresh="wireguard" />` : ''} - + <${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')} + cls="btn btn-sm btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp} + successMsg=${isUp ? 'Tunnel stopped' : 'Tunnel started'} refresh="wireguard" /> ${(pCount > 0) ? html`` - : html``} + : html`<${ConfirmDelete} url=${'/api/wireguard/classes'} body=${{ key: k }} + deleteKey=${k} message=${'Delete access class ' + esc(k) + '?'} success="Class deleted" + refresh="wireguard" label="Delete" />`} `; }); @@ -528,10 +492,12 @@ export default definePage({
Subnet: ${esc(v.subnet || '-')} LAN: ${v.lan_access ? 'Yes' : 'No'} - Keys: ${classHasKeys(v) ? 'Ready' : html``} + Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} refresh="wireguard" />`}
- + <${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')} + cls="btn btn-xs btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp} + successMsg=${isUp ? 'Stopped' : 'Started'} refresh="wireguard" />
`;