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" />
`;