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
This commit is contained in:
2026-07-28 17:32:51 +00:00
parent 244576b8eb
commit 8bb3619ddc
10 changed files with 156 additions and 220 deletions
+6 -2
View File
@@ -82,6 +82,7 @@ export function Card(props = {}) {
* @param {string} [props.label] - Button text (default: 'Remove') * @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE * @param {object} [props.body] - Optional JSON body to send with DELETE
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling * @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
* @param {function} [props.onComplete] - Callback after successful deletion
*/ */
export function ConfirmDelete(props = {}) { export function ConfirmDelete(props = {}) {
const opts = { method: 'DELETE' }; const opts = { method: 'DELETE' };
@@ -118,13 +119,16 @@ export function ConfirmDelete(props = {}) {
if (props.deleteKey && promises.length) { if (props.deleteKey && promises.length) {
Promise.all(promises).finally(() => { Promise.all(promises).finally(() => {
_deleting.delete(props.deleteKey); _deleting.delete(props.deleteKey);
if (props.onComplete) props.onComplete();
}); });
} }
} else if (props.deleteKey) { } 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); _deleting.delete(props.deleteKey);
} }
if (props.onComplete && !props.refresh) {
props.onComplete();
}
} else { } else {
toast(r.error || 'Failed', 'error'); toast(r.error || 'Failed', 'error');
} }
+22
View File
@@ -56,6 +56,28 @@ export function parseZones(data) {
* @param {Blob} blob * @param {Blob} blob
* @param {string} filename * @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) { export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
+1 -1
View File
@@ -35,7 +35,7 @@ export { scheduleTokenRefresh, cancelTokenRefresh, checkSession, logout, initAut
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js'; export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js';
/* ── Helpers ─────────────────────────────────────────────────── */ /* ── 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 ───────────────────────────────────── */ /* ── UI Components: Layout ───────────────────────────────────── */
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js'; export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js';
+19 -38
View File
@@ -92,47 +92,28 @@ function settingsModal(account) {
closeModal(idx); closeModal(idx);
}); });
inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => { inner.querySelector('[data-action="set-save"]')?.addEventListener('click',
if (isModalProcessing()) return; formAction(async () => {
setModalProcessing(true);
try {
const email = ($val('set-email') || '').trim(); const email = ($val('set-email') || '').trim();
if (!email) { toast('Email is required', 'error'); return; } if (!email) throw 'Email is required';
const resp = await apiFetch('/api/certs/email', { const resp = await apiFetch('/api/certs/email', { method: 'POST', body: { email } });
method: 'POST', if (!resp.ok) throw resp.error || 'Failed';
body: { email }, toast('Email updated', 'success');
}); closeModal(idx);
if (resp.ok) { modelFetch('acme');
toast('Email updated', 'success'); }),
closeModal(idx); );
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
});
inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => { inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click',
if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return; formAction(async () => {
if (isModalProcessing()) return; if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) throw new Error('Cancelled');
setModalProcessing(true);
try {
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' }); const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
if (resp.ok) { if (!resp.ok) throw resp.error || 'Failed';
toast('Account deactivated', 'success'); toast('Account deactivated', 'success');
closeModal(idx); closeModal(idx);
modelFetch('acme'); modelFetch('acme');
} else { }),
toast(resp.error || 'Failed', 'error'); );
}
} finally {
setModalProcessing(false);
refreshModals();
}
});
}); });
} }
+1 -9
View File
@@ -1,12 +1,4 @@
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton } from '/static/hoover/index.js'; import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, fmtBytes } 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];
}
export default definePage({ export default definePage({
init() { init() {
+2 -8
View File
@@ -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) { function makeAddRange(activeZones, interfaces) {
const opts = [ const opts = [
@@ -209,13 +209,7 @@ export default definePage({
'on:click': async () => { 'on:click': async () => {
const res = await apiFetch('/api/dhcp/apply', { method: 'POST' }); const res = await apiFetch('/api/dhcp/apply', { method: 'POST' });
if (res.ok) { if (res.ok) {
const synced = res.data?.synced; toast('dnsmasq applied', 'success');
let msg = 'dnsmasq applied';
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
toast(msg, 'success');
modelFetch('dnsmasq'); modelFetch('dnsmasq');
} else { } else {
toast(res.error || 'Apply failed', 'error'); toast(res.error || 'Apply failed', 'error');
+24 -41
View File
@@ -18,9 +18,8 @@ import {
PageHeader, PageHeader,
Empty, Empty,
Table, Table,
esc,
ActionCell,
Badge, Badge,
esc,
startRegistration, startRegistration,
webauthnSupported, webauthnSupported,
isModalProcessing, isModalProcessing,
@@ -224,60 +223,44 @@ function CredentialsPage() {
PageHeader({ PageHeader({
title: 'Passkeys', title: 'Passkeys',
subtitle: 'Manage your passkey credentials for passwordless authentication', subtitle: 'Manage your passkey credentials for passwordless authentication',
actions: html`<button class="btn btn-sm btn-primary" onClick=${() => webauthnSupported() && addCredentialModal()}> actions: webauthnSupported()
Add passkey ? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>Add passkey</button>`
</button>`, : html`<span class="text-sm text-muted">WebAuthn not supported</span>`,
}), }),
html`<div class="card" key="empty"> html`<div class="card" key="empty">
<div class="text-muted text-sm">No passkeys registered</div> <div class="text-muted text-sm">No passkeys registered</div>
<button class="btn btn-sm btn-primary" ${webauthnSupported()
onClick=${() => webauthnSupported() && addCredentialModal()}> ? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>Add passkey</button>`
Add passkey : ''}
</button>
</div>`, </div>`,
]; ];
} }
const cols = [ const rows = state.credentials.map(c =>
{ key: 'name', label: 'Name' }, html`<tr key=${c.id}>
{ key: 'transports', label: 'Transports' }, <td><strong>${esc(c.name || 'Unnamed')}</strong></td>
{ key: 'signCount', label: 'Uses' }, <td>${(c.transports || ['internal']).map(t => html`<${Badge} text=${esc(t)} />`)}</td>
{ key: 'id', label: 'ID' }, <td>${c.sign_count ?? 0}</td>
{ key: '_action', label: '' }, <td class="text-sm">${esc(c.id.slice(0, 12) + '...')}</td>
]; <td><button class="btn btn-sm btn-outline" onClick=${() => confirmRemove(c.id, c.name)}>Remove</button></td>
</tr>`
const rows = state.credentials.map(c => ({ );
name: esc(c.name || 'Unnamed'),
transports: (c.transports || ['internal']).map(t =>
html`<Badge>${esc(t)}</Badge>`
),
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 actions = webauthnSupported() const actions = webauthnSupported()
? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}> ? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>Add passkey</button>`
Add passkey : html`<span class="text-sm text-muted">WebAuthn not supported</span>`;
</button>`
: html`<span class="text-sm text-muted">WebAuthn not supported in this browser</span>`;
return [ return [
PageHeader({ PageHeader({
title: 'Passkeys', title: 'Passkeys',
subtitle: 'Manage your passkey credentials for passwordless authentication', 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 }),
]; ];
} }
+64 -70
View File
@@ -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 { 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 { openModal, formModal, closeModal } from '/static/hoover/components/modal.js';
import { openBackendModal } from '/static/pages/backends.js'; import { openBackendModal } from '/static/pages/backends.js';
function certLookup(acmeData) { function certLookup(acmeData) {
@@ -82,48 +81,48 @@ function addDomain(state, preselectedBackend) {
const backends = state.backends ? (state.backends.data || {}) : {}; const backends = state.backends ? (state.backends.data || {}) : {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []); const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const backendOptions = buildBackendOptions(backends); const backendOptions = buildBackendOptions(backends);
openModal((inner) => { const modal = QuickModal({
formModal(inner, 'Add Proxy Domain', [ title: 'Add Proxy Domain',
fields: [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' }, { label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Backend', id: 'p-backend', tag: 'select', options: backendOptions }, { label: 'Backend', id: 'p-backend', tag: 'select', options: backendOptions },
{ label: 'Cert', id: 'p-cert', tag: 'select', options: certOptions }, { label: 'Cert', id: 'p-cert', tag: 'select', options: certOptions },
], [ ],
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, submit: {
{ url: '/api/proxy/domains',
label: 'Add', body: () => {
cls: 'btn-primary', const body = {
action: 's', domain: ($val('p-domain') || '').trim(),
handler: formAction(async () => { backend: ($val('p-backend') || '').trim(),
const domain = ($val('p-domain') || '').trim(); force_ssl: true,
if (!domain) throw 'Domain is required'; };
const backend = ($val('p-backend') || '').trim(); const certVal = certValueFromSelect($val('p-cert'));
if (!backend) throw 'Backend is required'; if (certVal) body.cert = certVal;
const body = { domain, backend, force_ssl: true }; return body;
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)));
}),
}, },
]); validate: (b) => !b.domain ? 'Domain is required' :
if (preselectedBackend) { !b.backend ? 'Backend is required' : null,
const backendSelect = inner.querySelector('#p-backend'); successMsg: 'Domain added',
if (backendSelect) backendSelect.value = preselectedBackend; },
} refresh: ['nginx', 'acme'],
const certSelect = inner.querySelector('#p-cert'); postRender: (inner) => {
const domainInput = inner.querySelector('#p-domain'); if (preselectedBackend) {
if (certSelect && domainInput) { const backendSelect = inner.querySelector('#p-backend');
certSelect.addEventListener('change', () => { if (backendSelect) backendSelect.value = preselectedBackend;
const val = certSelect.value; }
if (val && val.startsWith('acme|')) { const certSelect = inner.querySelector('#p-cert');
domainInput.value = val.slice(5); 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) { function editDomain(d, state) {
@@ -139,45 +138,40 @@ function editDomain(d, state) {
selectedCert = d.cert; selectedCert = d.cert;
} }
const paths = backend.paths || {}; const paths = backend.paths || {};
const pathKeys = Object.keys(paths); const pathSummary = Object.entries(paths).map(([p, pcfg]) => {
const pathSummary = pathKeys.map(p => {
const pcfg = paths[p];
const be = pcfg.backend || {}; const be = pcfg.backend || {};
return `${esc(p)}${esc(be.host || '-')}:${be.port || '-'}`; return `${esc(p)}${esc(be.host || '-')}:${be.port || '-'}`;
}).join('\n') || '—'; }).join('\n') || '—';
openModal((inner) => { const modal = QuickModal({
formModal(inner, 'Edit: ' + esc(d.domain), [ title: 'Edit: ' + esc(d.domain),
fields: [
{ label: 'Domain', id: 'pe-domain', value: d.domain }, { label: 'Domain', id: 'pe-domain', value: d.domain },
{ label: 'Backend', id: 'pe-backend', value: (d.backend_name || '-') + ' (' + (backend.label || '—') + ')' }, { 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: 'Cert', id: 'pe-cert', tag: 'select', options: certOptions },
{ label: 'Force SSL', id: 'pe-force-ssl', tag: 'checkbox', checked: d.force_ssl }, { label: 'Force SSL', id: 'pe-force-ssl', tag: 'checkbox', checked: d.force_ssl },
], [ ],
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, submit: {
{ url: '/api/proxy/domains/' + enc(d.domain),
label: 'Save', method: 'PUT',
cls: 'btn-primary', body: () => ({
action: 's', cert: certValueFromSelect($val('pe-cert')) || '',
handler: formAction(async () => { force_ssl: document.getElementById('pe-force-ssl')?.checked ?? true,
const rawCert = $val('pe-cert'); }),
if (!rawCert) throw 'Cert is required'; validate: (b) => !b.cert ? 'Cert is required' : null,
const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true; successMsg: 'Domain updated',
const body = { cert: certValueFromSelect(rawCert), force_ssl: forceSsl }; },
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body }); refresh: ['nginx', 'acme'],
if (!res.ok) throw res.error || 'Failed'; postRender: (inner) => {
toast('Domain updated', 'success'); const certSelect = inner.querySelector('#pe-cert');
closeModal(); if (certSelect) certSelect.value = selectedCert;
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m))); for (const id of ['pe-domain', 'pe-backend']) {
}), const el = inner.querySelector('#' + id);
}, if (el) { el.readOnly = true; el.style.background = '#f5f5f5'; }
]); }
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'; }
}); });
modal({});
} }
function domainRow(domainName, domainPaths, state) { function domainRow(domainName, domainPaths, state) {
+1 -1
View File
@@ -222,7 +222,7 @@ function UsersPage() {
deleteKey=${u.username} deleteKey=${u.username}
message=${'Delete user ' + esc(u.username) + '? This cannot be undone.'} message=${'Delete user ' + esc(u.username) + '? This cannot be undone.'}
success=${'User ' + esc(u.username) + ' deleted'} success=${'User ' + esc(u.username) + ' deleted'}
onRefresh=${() => loadUsers()} />`} onComplete=${() => loadUsers()} />`}
</td> </td>
</tr>`; </tr>`;
}); });
+16 -50
View File
@@ -1,5 +1,5 @@
/** WireGuard page — tunnel & peer management. */ /** 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 ────────────────────────────────────── */ /* ── LAN detection helper ────────────────────────────────────── */
function getLanSubnets() { 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 ────────────────────────────────────────────── */ /* ── Color helpers ────────────────────────────────────────────── */
function classColor(classKey) { function classColor(classKey) {
@@ -63,7 +59,7 @@ const addPeer = QuickModal({
} else if (preset === 'none') { } else if (preset === 'none') {
allowed_ips = []; allowed_ips = [];
} else if (preset === 'custom') { } else if (preset === 'custom') {
allowed_ips = parseAllowedIps($val('wg-allowed')); allowed_ips = csvToArr($val('wg-allowed'));
} else { } else {
allowed_ips = ['0.0.0.0/0']; allowed_ips = ['0.0.0.0/0'];
} }
@@ -241,7 +237,7 @@ function settingsModal(wireguardData, state) {
handler: formAction(async () => { handler: formAction(async () => {
const port = parseInt($val('wg-port'), 10); const port = parseInt($val('wg-port'), 10);
if (isNaN(port) || port < 1 || port > 65535) throw 'Invalid port'; 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'; if (!addresses.length) throw 'At least one address required';
const body = { const body = {
interface: { 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 ──────────────────────────────────── */ /* ── Access Classes Section ──────────────────────────────────── */
function renderAccessClasses(config, status) { function renderAccessClasses(config, status) {
const classes = config?.access_classes || {}; const classes = config?.access_classes || {};
@@ -414,13 +373,18 @@ function renderAccessClasses(config, status) {
</td> </td>
<td> <td>
${!hasKeys ${!hasKeys
? html`<button class="btn btn-sm btn-warning" onClick=${() => initClassKeys(k)} title="Generate keys">Keys</button>` ? 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" />`
: ''} : ''}
<button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button> <button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button>
<button class="btn btn-sm btn-outline" onClick=${() => toggleClassTunnel(k, isUp)}>${isUp ? 'Stop' : 'Start'}</button> <${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) ${(pCount > 0)
? html`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>` ? html`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>`
: html`<button class="btn btn-sm btn-outline" onClick=${() => deleteAccessClass(k)}>Delete</button>`} : html`<${ConfirmDelete} url=${'/api/wireguard/classes'} body=${{ key: k }}
deleteKey=${k} message=${'Delete access class ' + esc(k) + '?'} success="Class deleted"
refresh="wireguard" label="Delete" />`}
</td> </td>
</tr>`; </tr>`;
}); });
@@ -528,10 +492,12 @@ export default definePage({
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<span>Subnet: ${esc(v.subnet || '-')}</span> <span>Subnet: ${esc(v.subnet || '-')}</span>
<span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span> <span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span>
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<button class="btn btn-xs btn-warning" onClick=${() => initClassKeys(k)}>Generate</button>`}</span> <span>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" />`}</span>
</div> </div>
<div style="margin-top: 4px;"> <div style="margin-top: 4px;">
<button class="btn btn-xs btn-outline" onClick=${() => toggleClassTunnel(k, isUp)}>${isUp ? 'Stop' : 'Start'}</button> <${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" />
</div> </div>
</div> </div>
</div>`; </div>`;