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 {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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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,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() {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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>`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user