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:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
+13
-32
@@ -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) {
|
||||
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');
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
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) {
|
||||
if (!resp.ok) throw resp.error || 'Failed';
|
||||
toast('Account deactivated', 'success');
|
||||
closeModal(idx);
|
||||
modelFetch('acme');
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`<button class="btn btn-sm btn-primary" onClick=${() => webauthnSupported() && addCredentialModal()}>
|
||||
Add passkey
|
||||
</button>`,
|
||||
actions: webauthnSupported()
|
||||
? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>Add passkey</button>`
|
||||
: html`<span class="text-sm text-muted">WebAuthn not supported</span>`,
|
||||
}),
|
||||
html`<div class="card" key="empty">
|
||||
<div class="text-muted text-sm">No passkeys registered</div>
|
||||
<button class="btn btn-sm btn-primary"
|
||||
onClick=${() => webauthnSupported() && addCredentialModal()}>
|
||||
Add passkey
|
||||
</button>
|
||||
${webauthnSupported()
|
||||
? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>Add passkey</button>`
|
||||
: ''}
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
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`<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 rows = state.credentials.map(c =>
|
||||
html`<tr key=${c.id}>
|
||||
<td><strong>${esc(c.name || 'Unnamed')}</strong></td>
|
||||
<td>${(c.transports || ['internal']).map(t => html`<${Badge} text=${esc(t)} />`)}</td>
|
||||
<td>${c.sign_count ?? 0}</td>
|
||||
<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 actions = webauthnSupported()
|
||||
? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>
|
||||
Add passkey
|
||||
</button>`
|
||||
: html`<span class="text-sm text-muted">WebAuthn not supported in this browser</span>`;
|
||||
? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>Add passkey</button>`
|
||||
: html`<span class="text-sm text-muted">WebAuthn not supported</span>`;
|
||||
|
||||
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 }),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+44
-50
@@ -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,33 +81,31 @@ 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 };
|
||||
],
|
||||
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;
|
||||
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)));
|
||||
}),
|
||||
return body;
|
||||
},
|
||||
]);
|
||||
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;
|
||||
@@ -123,7 +120,9 @@ function addDomain(state, preselectedBackend) {
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
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)));
|
||||
],
|
||||
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;
|
||||
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'; }
|
||||
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) {
|
||||
|
||||
@@ -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()} />`}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
</td>
|
||||
<td>
|
||||
${!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=${() => 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)
|
||||
? 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>
|
||||
</tr>`;
|
||||
});
|
||||
@@ -528,10 +492,12 @@ export default definePage({
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>Subnet: ${esc(v.subnet || '-')}</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 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>`;
|
||||
|
||||
Reference in New Issue
Block a user