Add state management, WebSocket polling, html.js templating, and refactor pages

- lib/state.py: per-subsystem collectors with versioned state store
- daemon/server.py: state refresh on request, batch routing updates
- webui/static/hoover/html.js: new html tag template helper via htm.js
- webui/static/hoover/websocket.js: real-time state change notifications
- webui/static/hoover/vdom.js: VDOM improvements for keyed diff
- All frontend pages refactored to use html templates
- Add tests for state management and polling
- Update docs and AGENTS.md
This commit is contained in:
2026-06-23 21:11:45 +00:00
parent 5025dfaf30
commit 5ba0f31767
26 changed files with 1193 additions and 495 deletions
+27 -3
View File
@@ -9,6 +9,20 @@
import { esc } from '../helpers.js?v=7';
import { att_esc } from '../helpers.js?v=7';
import { apiSubmit } from '../api.js?v=7';
import { createDom } from '../vdom.js?v=7';
/**
* Render Hoover VNodes into a modal content element.
* VDOM is not diffed across modal re-render — modals are transient and
* innerHTML is cleared/repainted each time (avoids lifecycle baggage).
*/
export function modalVNodes(inner, vnodes) {
inner.innerHTML = '';
const nodes = Array.isArray(vnodes) ? vnodes : [vnodes];
for (const vnode of nodes) {
if (vnode) inner.appendChild(createDom(vnode));
}
}
const _modalQueue = [];
@@ -35,10 +49,15 @@ function _renderModals() {
/**
* Open a modal dialog.
*
* @param {function} renderFn (contentEl, idx) => void, renders into contentEl
* @param {function|object} content Either:
* - renderFn(contentEl, idx) => void (legacy innerHTML path)
* - VNode / VNode[] (new VDOM path — uses modalVNodes)
*/
export function openModal(renderFn) {
_modalQueue.push({ renderFn, id: _modalQueue.length });
export function openModal(content) {
const entry = typeof content === 'function'
? { renderFn: content, id: _modalQueue.length }
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content) };
_modalQueue.push(entry);
_renderModals();
}
@@ -61,6 +80,11 @@ export function closeAllModals() {
_renderModals();
}
/** Re-render all open modals. Used by long-lived modals that update in place. */
export function refreshModals() {
_renderModals();
}
/**
* Render a standard modal layout: title, form fields, action buttons.
*
+4
View File
@@ -0,0 +1,4 @@
import htm from '../../vendor/htm.js';
import { htmAdapter } from './vdom.js?v=7';
export const html = htm.bind(htmAdapter);
+4 -1
View File
@@ -10,6 +10,9 @@ export { reactive, requestUpdate } from './reactivity.js?v=7';
/* ── VDOM ────────────────────────────────────────────────────── */
export { h } from './vdom.js?v=7';
/* ── HTM ──────────────────────────────────────────────────────── */
export { html } from './html.js?v=7';
/* ── Render ──────────────────────────────────────────────────── */
export { render } from './render.js?v=7';
@@ -38,7 +41,7 @@ export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGr
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=7';
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=7';
+23
View File
@@ -21,6 +21,29 @@ export const _unmountFn = { fn: null };
export function setMountFn(fn) { _mountFn.fn = fn; }
export function setUnmountFn(fn) { _unmountFn.fn = fn; }
/**
* htm event adapter: translates camelCase events (onClick) to Hoover's on:click.
*/
export function htmAdapter(tag, props, ...children) {
if (tag === '#text' || tag === '#comp' || typeof tag === 'function') {
return h(tag, props, ...children);
}
if (props) {
const normalized = {};
for (const [key, val] of Object.entries(props)) {
if (key.startsWith('on') && key.length > 2 && key[2] >= 'A' && key[2] <= 'Z') {
normalized['on:' + key.slice(2).toLowerCase()] = val;
} else {
normalized[key] = val;
}
}
props = normalized;
}
return h(tag, props, ...children);
}
/**
* Build a VNode. Three forms:
* h('div', { class: 'x' }, h('span', null, 'hi')) — element
+3 -2
View File
@@ -57,14 +57,15 @@ function _wsConnect() {
*
* Expected message shapes:
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
* { type: 'tick', subsystems: ['firewall', 'wireguard', …] }
* { type: 'notify', topic: 'firewall' }
* { type: 'status', topic: 'firewall', … }
*/
function handleMessage(msg) {
const topics = [];
if (msg.type === 'versions' || msg.type === 'refresh') {
topics.push(...(msg.updated || msg.topics || []));
if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') {
topics.push(...(msg.updated || msg.subsystems || msg.topics || []));
} else if (msg.type === 'notify') {
topics.push(msg.topic);
} else if (msg.type === 'status') {
+152 -171
View File
@@ -1,40 +1,29 @@
import { h, PageHeader, Empty, Table, Card, Badge, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
const _issueState = { domain: '', modalIdx: -1, account: null, validating: false };
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
function _accountCard(account) {
if (!account || !account.registered) {
return h('div', { class: 'card' },
h('div', { class: 'card-header' },
[
h('span', null, 'ACME Account'),
h('button', {
class: 'btn btn-sm btn-primary',
style: 'margin-left:auto;',
'on:click': () => registerAccountModal(),
}, 'Register Account'),
]
),
h('div', { class: 'card-body' },
h('div', { class: 'text-muted text-sm' }, 'Not registered'),
),
);
return html`<div class="card">
<div class="card-header">
<span>ACME Account</span>
<button class="btn btn-sm btn-primary" style="margin-left:auto"
onClick=${() => registerAccountModal()}>Register Account</button>
</div>
<div class="card-body">
<div class="text-muted text-sm">Not registered</div>
</div>
</div>`;
}
return h('div', { class: 'card' },
h('div', { class: 'card-header' },
[
h('span', null, 'ACME Account'),
h('button', {
class: 'btn btn-sm btn-outline',
style: 'margin-left:auto;',
'on:click': () => settingsModal(account),
}, '\u2699'),
]
),
h('div', { class: 'card-body' },
h('div', null, ['Registered as ', h('strong', null, esc(account.email))]),
h('div', { class: 'text-sm text-muted' }, ['CA: ', esc(account.ca)]),
),
);
return html`<div class="card">
<div class="card-header">
<span>ACME Account</span>
<button class="btn btn-sm btn-outline" style="margin-left:auto"
onClick=${() => settingsModal(account)}>\u2699</button>
</div>
<div class="card-body">
<div>Registered as <strong>${esc(account.email)}</strong></div>
<div class="text-sm text-muted">CA: ${esc(account.ca)}</div>
</div>
</div>`;
}
function registerAccountModal() {
@@ -78,25 +67,31 @@ function registerAccountModal() {
}
function settingsModal(account) {
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Account Settings</h2>'
+ '<div class="modal-body">'
+ '<div class="form-group"><label>Current Account</label><div class="text-sm">'
+ esc(account.email) + (account.ca ? ' (' + esc(account.ca) + ')' : '')
+ '</div></div>'
+ '<hr>'
+ '<div class="form-group"><label>Update Email</label>'
+ '<input id="set-email" type="email" placeholder="new@example.com"></div>'
+ '<hr>'
+ '<div class="text-danger"><strong>Danger Zone</strong></div>'
+ '<button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="set-cancel">Cancel</button>'
+ '<button class="btn btn-primary" data-action="set-save">Save Email</button>'
+ '</div>';
openModal((inner, idx) => {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Account Settings</h2>
<div class="modal-body">
<div class="form-group">
<label>Current Account</label>
<div class="text-sm">${esc(account.email)}${account.ca ? ' (' + esc(account.ca) + ')' : ''}</div>
</div>
<hr />
<div class="form-group">
<label>Update Email</label>
<input id="set-email" type="email" placeholder="new@example.com" />
</div>
<hr />
<div class="text-danger"><strong>Danger Zone</strong></div>
<button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>
</div>
<div class="modal-actions">
<button class="btn btn-outline" data-action="set-cancel">Cancel</button>
<button class="btn btn-primary" data-action="set-save">Save Email</button>
</div>
</div>`);
inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => {
closeModal();
closeModal(idx);
});
inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => {
@@ -108,7 +103,7 @@ function settingsModal(account) {
});
if (resp.ok) {
toast('Email updated', 'success');
closeModal();
closeModal(idx);
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
@@ -120,7 +115,7 @@ function settingsModal(account) {
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
if (resp.ok) {
toast('Account deactivated', 'success');
closeModal();
closeModal(idx);
modelFetch('acme');
} else {
toast(resp.error || 'Failed', 'error');
@@ -129,142 +124,129 @@ function settingsModal(account) {
});
}
function issueCertModal(state) {
_issueState.domain = '';
_issueState.modalIdx = -1;
_issueState.account = null;
_issueState.validating = false;
function createIssueState() {
return {
step: 'init',
domain: '',
account: null,
validating: false,
checks: [],
ready: false,
};
}
let _currentIssueState = null;
function issueCertModal(state) {
_currentIssueState = createIssueState();
(async () => {
const accountResp = await apiFetch('/api/certs/account');
_issueState.account = accountResp.ok ? accountResp.data : null;
_renderIssueModal(state);
_currentIssueState.account = accountResp.ok ? accountResp.data : null;
openModal((inner, modalIdx) => {
modalVNodes(inner, _renderIssueContent());
_bindIssueButtons(inner, modalIdx);
});
})();
}
function _renderIssueModal(state) {
const account = _issueState.account;
function _renderIssueContent() {
const s = _currentIssueState;
const account = s.account;
const registered = account && account.registered;
const accountBadge = registered
? esc(account.email) + ' (' + esc(account.ca) + ')'
: 'No account registered';
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Issue Certificate</h2>'
+ '<div class="modal-body">'
+ '<div id="ic-account-info" class="text-sm mb-2">'
+ '<strong>Using account:</strong> ' + esc(accountBadge) + '</div>'
+ (registered
? '<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com"></div>'
: '<div class="text-warning">Register an ACME account first</div>'
)
+ '<div id="ic-vresults"></div>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
+ (registered
? '<button class="btn btn-primary" data-action="ic-validate">Validate</button>'
: '<button class="btn btn-primary" disabled>Validate</button>'
+ '<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>'
)
+ '</div>';
_issueState.modalIdx = document.querySelectorAll('#modal-root > div').length - 1;
inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx);
if (s.step === 'results') {
const resultsVNodes = s.checks.map(c => {
let cls = 'text-success', icon = '\u2713';
if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; }
else if (!c.passed) { cls = 'text-warning'; icon = '\u26A0'; }
return html`<div>${icon} <strong>${esc(c.name)}</strong>: <span class=${cls}>${esc(c.message)}</span></div>`;
});
inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx);
registerAccountModal();
});
return html`<div>
<h2 class="modal-title">Validate: ${esc(s.domain)}</h2>
<div class="modal-body">
<div class="form-group">
<label>Domain</label>
<input id="ic-domain" value=${esc(s.domain)} />
</div>
<div id="ic-vresults">${...resultsVNodes}</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>
<button class="btn btn-primary" data-action="ic-issue"${s.ready ? '' : ' disabled'}>Issue</button>
</div>
</div>`;
}
if (!registered) return;
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
if (_issueState.validating) return;
const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; }
_issueState.validating = true;
_issueState.domain = domain;
try {
const resp = await apiFetch('/api/certs/validate', {
method: 'POST',
body: { domain },
});
if (!resp.ok) {
toast(resp.error || 'Validation failed', 'error');
return;
}
_showValidate(inner, domain, resp.data.checks, resp.data.ready, state);
} finally {
_issueState.validating = false;
}
});
});
return html`<div>
<h2 class="modal-title">Issue Certificate</h2>
<div class="modal-body">
<div id="ic-account-info" class="text-sm mb-2">
<strong>Using account:</strong> ${esc(accountBadge)}
</div>
${registered
? html`<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com" /></div>`
: html`<div class="text-warning">Register an ACME account first</div>`}
<div id="ic-vresults"></div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
${registered
? html`<button class="btn btn-primary" data-action="ic-validate">Validate</button>`
: html`<button class="btn btn-primary" disabled>Validate</button>
<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>`}
</div>
</div>`;
}
function _showValidate(inner, domain, checks, ready, state) {
const resultsHtml = checks.map(c => {
let cls = 'text-success';
let icon = '\u2713';
if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; }
else if (!c.passed && !c.blocking) { cls = 'text-warning'; icon = '\u26A0'; }
return '<div>' + icon + ' <strong>' + esc(c.name) + '</strong>'
+ ': ' + '<span class="' + cls + '">' + esc(c.message) + '</span></div>';
}).join('');
inner.innerHTML = '<h2 class="modal-title">Validate: ' + esc(domain) + '</h2>'
+ '<div class="modal-body">'
+ '<div class="form-group"><label>Domain</label><input id="ic-domain" value="' + esc(domain) + '"></div>'
+ '<div id="ic-vresults">' + resultsHtml + '</div>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
+ '<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>'
+ '<button class="btn btn-primary" data-action="ic-issue"'
+ (ready ? '' : ' disabled') + '>Issue</button>'
+ '</div>';
function _bindIssueButtons(inner, modalIdx) {
inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx);
closeModal(modalIdx);
});
inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => {
closeModal(modalIdx);
registerAccountModal();
});
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
if (_issueState.validating) return;
const domain2 = ($val('ic-domain') || '').trim();
if (!domain2) { toast('Domain is required', 'error'); return; }
_issueState.validating = true;
_issueState.domain = domain2;
if (_currentIssueState.validating) return;
const s = _currentIssueState;
const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; }
s.validating = true;
s.domain = domain;
try {
const resp2 = await apiFetch('/api/certs/validate', {
method: 'POST',
body: { domain: domain2 },
});
if (!resp2.ok) { toast(resp2.error || 'Validation failed', 'error'); return; }
_showValidate(inner, domain2, resp2.data.checks, resp2.data.ready, state);
const resp = await apiFetch('/api/certs/validate', { method: 'POST', body: { domain } });
if (!resp.ok) { toast(resp.error || 'Validation failed', 'error'); return; }
s.step = 'results';
s.checks = resp.data.checks;
s.ready = resp.data.ready;
refreshModals();
} finally {
_issueState.validating = false;
s.validating = false;
}
});
inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => {
const body = { domain: _issueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', {
method: 'POST',
body,
});
const body = { domain: _currentIssueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
if (issueResp.ok) {
toast('Issuance started for ' + _issueState.domain, 'success');
closeModal(_issueState.modalIdx);
toast('Issuance started for ' + _currentIssueState.domain, 'success');
closeModal(modalIdx);
const rid = issueResp.data?.request_id;
if (rid) pollCertIssue(rid, state);
if (rid) pollCertIssue(rid);
} else {
toast(issueResp.error || 'Failed', 'error');
}
});
}
async function pollCertIssue(rid, state) {
async function pollCertIssue(rid) {
poll({
url: '/api/certs/issue/' + enc(rid),
successKey: (d) => d.status === 'completed',
@@ -293,32 +275,31 @@ export default definePage({
const rows = (state.acme.data?.certs || []).map(c => {
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
return h('tr', { key: c.domain },
h('td', null, h('strong', null, esc(c.domain || 'unknown'))),
h('td', { class: 'text-sm' }, esc(c.issuer || '-')),
h('td', null, esc(c.expiry || 'N/A')),
h('td', null, badge),
ActionCell({
editLabel: 'Renew',
editClick: async () => {
return html`<tr key=${c.domain}>
<td><strong>${esc(c.domain || 'unknown')}</strong></td>
<td class="text-sm">${esc(c.issuer || '-')}</td>
<td>${esc(c.expiry || 'N/A')}</td>
<td>${badge}</td>
<${ActionCell}
editLabel="Renew"
editClick=${async () => {
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
else toast(resp.error || 'Failed', 'error');
},
removeUrl: '/api/certs/' + enc(c.domain),
removeMessage: 'Remove certificate for ' + c.domain + '?',
removeSuccess: 'Certificate removed',
removeRefresh: 'acme',
}),
);
}}
removeUrl=${'/api/certs/' + enc(c.domain)}
removeMessage=${'Remove certificate for ' + c.domain + '?'}
removeSuccess="Certificate removed"
removeRefresh="acme" />
</tr>`;
});
return [
PageHeader({
title: 'Certificates',
subtitle: 'ACME certificate management',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
actions: html`<button class="btn btn-primary"
onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
}),
_accountCard(account),
rows.length
+26 -34
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=7';
import { html, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=7';
export default definePage({
init() {
@@ -21,41 +21,33 @@ export default definePage({
const dmsk = d.dnsmasq?.status || {};
const wP = (d.wg || {}).peers || [];
const stats = html`<div class="grid grid-4">
<${StatCard} label="Active Zones" value=${Object.keys(fwZones).length}
meta=${Object.keys(fwZones).join(', ') || 'None'} />
<${StatCard} label="Interfaces Up" value=${upC + '/' + nCount}
meta=${upI.map(i => i.name).join(', ') || 'None up'} />
<${StatCard} label="WireGuard" value=${String(d.wg?.state || 'unknown')}
meta=${wP.length + ' peers'} />
<${StatCard} label="Certificates" value=${certs.length}
meta=${certW.length + ' expiring/expired'} />
</div>`;
const services = html`<div class="grid grid-2">
<div class="card">
<div class="card-header">Services</div>
<div class="card-body">
<ul class="service-list">
<li><${ServiceStatus} state=${dmsk.state || 'down'} label="Dnsmasq" /></li>
<li><${ServiceStatus} state=${d.wg?.state || 'down'} label="WireGuard" /></li>
</ul>
</div>
</div>
</div>`;
return [
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
h('div', { class: 'grid grid-4' },
StatCard({
label: 'Active Zones',
value: Object.keys(fwZones).length,
meta: Object.keys(fwZones).join(', ') || 'None',
}),
StatCard({
label: 'Interfaces Up',
value: upC + '/' + nCount,
meta: upI.map(i => i.name).join(', ') || 'None up',
}),
StatCard({
label: 'WireGuard',
value: String(d.wg?.state || 'unknown'),
meta: wP.length + ' peers',
}),
StatCard({
label: 'Certificates',
value: certs.length,
meta: certW.length + ' expiring/expired',
}),
),
h('div', { class: 'grid grid-2' },
h('div', { class: 'card' },
h('div', { class: 'card-header' }, 'Services'),
h('div', { class: 'card-body' },
h('ul', { class: 'service-list' },
h('li', null, ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })),
h('li', null, ServiceStatus({ state: d.wg?.state || 'down', label: 'WireGuard' })),
),
),
),
),
stats,
services,
];
},
});
+55 -52
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
const addRange = QuickModal({
title: 'Add DHCP Range',
@@ -74,54 +74,51 @@ export default definePage({
const dnsRecords = cfg.dns_records || [];
const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
h('td', null, r.interface || '(global)'),
h('td', null, esc(r.start)),
h('td', null, esc(r.end)),
h('td', null, esc(r.lease_time || '12h')),
h('td', null,
ConfirmDelete({
url: '/api/dhcp/ranges',
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
body: { interface: r.interface || '', start: r.start, end: r.end },
success: 'Range removed',
refresh: 'dnsmasq',
}),
),
));
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
<td>${r.interface || '(global)'}</td>
<td>${esc(r.start)}</td>
<td>${esc(r.end)}</td>
<td>${esc(r.lease_time || '12h')}</td>
<td>
<${ConfirmDelete}
url="/api/dhcp/ranges"
message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success="Range removed"
refresh="dnsmasq" />
</td>
</tr>`);
const leaseRows = staticLeases.map((l) => h('tr', { key: l.mac },
h('td', null, esc(l.mac)),
h('td', null, esc(l.ip)),
h('td', null, l.hostname || '-'),
h('td', null,
ConfirmDelete({
url: '/api/dhcp/static-lease/' + enc(l.mac),
message: 'Remove lease ' + l.mac + '?',
success: 'Lease removed',
refresh: 'dnsmasq',
}),
),
));
const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}>
<td>${esc(l.mac)}</td>
<td>${esc(l.ip)}</td>
<td>${l.hostname || '-'}</td>
<td>
<${ConfirmDelete}
url=${'/api/dhcp/static-lease/' + enc(l.mac)}
message=${'Remove lease ' + l.mac + '?'}
success="Lease removed"
refresh="dnsmasq" />
</td>
</tr>`);
const dnsRows = dnsRecords.map((rec) => h('tr', { key: rec.name },
h('td', null, h('strong', null, esc(rec.name || 'unnamed'))),
h('td', { class: 'text-sm' }, esc(rec.address || '-')),
h('td', null,
ConfirmDelete({
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
success: 'Record removed',
refresh: 'dnsmasq',
}),
),
));
const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}>
<td><strong>${esc(rec.name || 'unnamed')}</strong></td>
<td class="text-sm">${esc(rec.address || '-')}</td>
<td>
<${ConfirmDelete}
url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success="Record removed"
refresh="dnsmasq" />
</td>
</tr>`);
const tabNames = ['ranges', 'leases', 'dns', 'active'];
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addRange(state) }, 'Add Range'),
h('button', { class: 'btn btn-outline', 'on:click': () => addLease(state) }, 'Static Lease'),
h('button', { class: 'btn btn-outline', 'on:click': () => addDns(state) }, 'DNS Record'),
html`<button class="btn btn-primary" onClick=${() => addRange(state)}>Add Range</button>`,
html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`,
html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`,
ActionButton({
url: '/api/dhcp/apply',
successMsg: 'dnsmasq applied',
@@ -130,6 +127,18 @@ export default definePage({
}),
);
const leaseTable = state.activeTab === 'active'
? Table({
columns: ['MAC', 'IP', 'Hostname', 'Expires'],
rows: (state.dnsmasq.data?.leases || []).map((l) => html`<tr key=${l.mac || l.ip}>
<td>${esc(l.mac || '-')}</td>
<td>${esc(l.ip || '-')}</td>
<td>${esc(l.hostname || '-')}</td>
<td>${esc(l.expires || '-')}</td>
</tr>`),
emptyText: 'No active leases',
}) : null;
return [
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
ServiceStatus({ state: status.state || 'down', label: 'Dnsmasq' }),
@@ -140,13 +149,7 @@ export default definePage({
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
state.activeTab === 'dns'
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
state.activeTab === 'active'
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.dnsmasq.data?.leases || []).map((l) => h('tr', { key: l.mac || l.ip },
h('td', null, esc(l.mac || '-')),
h('td', null, esc(l.ip || '-')),
h('td', null, esc(l.hostname || '-')),
h('td', null, esc(l.expires || '-')),
)), emptyText: 'No active leases' }) : null,
leaseTable,
];
},
});
});
+18 -24
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
@@ -54,8 +54,8 @@ export default definePage({
const ifaces = Object.entries(netData).map(([name, entry]) => {
let zone = null;
for (const [zoneName, ifaces] of Object.entries(activeZones)) {
if ((ifaces || []).includes(name)) {
for (const [zoneName, zIfaces] of Object.entries(activeZones)) {
if ((zIfaces || []).includes(name)) {
zone = zoneName;
break;
}
@@ -70,26 +70,20 @@ export default definePage({
};
});
const rows = ifaces.map(iface => {
return h('tr', { key: iface.name },
h('td', null, h('strong', null, iface.name)),
h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')),
h('td', null, (iface.ips || []).join(', ') || 'N/A'),
h('td', null, StatusText({ status: iface.state })),
h('td', null,
ZoneSelect({
zones,
value: iface.zone,
onChange: (z) => changeZone(iface.name, z, state),
}),
h('button', {
class: 'btn btn-sm btn-outline',
style: 'margin-left:8px',
'on:click': () => cfgModalFn(iface),
}, 'Config'),
),
);
});
const rows = ifaces.map(iface =>
html`<tr key=${iface.name}>
<td><strong>${iface.name}</strong></td>
<td class="text-muted">${String(iface.mac || 'N/A')}</td>
<td>${(iface.ips || []).join(', ') || 'N/A'}</td>
<td><${StatusText} status=${iface.state} /></td>
<td>
<${ZoneSelect} zones=${zones} value=${iface.zone}
onChange=${(z) => changeZone(iface.name, z, state)} />
<button class="btn btn-sm btn-outline" style="margin-left:8px"
onClick=${() => cfgModalFn(iface)}>Config</button>
</td>
</tr>`
);
return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
@@ -100,4 +94,4 @@ export default definePage({
}),
];
},
});
});
+13 -16
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=7';
const logTabs = [
{ key: 'journal', label: 'Journal' },
@@ -24,7 +24,7 @@ export default definePage({
const lines = logData.data || [];
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
const lineVnodes = lines.map((line, i) =>
h('div', { class: 'log-line', key: i }, esc(line))
html`<div class="log-line" key=${i}>${esc(line)}</div>`
);
const tabsBody = Tabs({
@@ -39,20 +39,17 @@ export default definePage({
return [
PageHeader({ title: 'Logs', subtitle: 'System and service logs' }),
h('div', { class: 'card', key: 'log-card' },
tabsBody,
h('div', { class: 'card-header' },
h('span', null, tab.label),
h('button', {
class: 'btn btn-sm btn-outline',
style: 'float:right;',
'on:click': () => modelFetch('logs', state.activeTab),
}, '\u21BB'),
),
h('div', { class: 'card-body log-body' },
h('pre', null, lineVnodes),
),
),
html`<div class="card" key="log-card">
${tabsBody}
<div class="card-header">
<span>${tab.label}</span>
<button class="btn btn-sm btn-outline" style="float:right"
onClick=${() => modelFetch('logs', state.activeTab)}>\u21BB</button>
</div>
<div class="card-body log-body">
<pre>${lineVnodes}</pre>
</div>
</div>`,
];
},
});
+43 -48
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -47,37 +47,34 @@ export default definePage({
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
const ifaceRows = (ifaces) =>
ifaces.map((iface) =>
h('tr', { key: 'ii-' + iface.name },
h('td', null,
h('div', { class: 'd-flex align-items-center gap-2' },
StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }),
h('strong', null, iface.name),
),
),
h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')),
h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })),
)
);
ifaces.map((iface) => html`<tr key=${'ii-' + iface.name}>
<td>
<div class="d-flex align-items-center gap-2">
<${StatusDot} status=${iface.state === 'UP' ? 'up' : 'down'} />
<strong>${iface.name}</strong>
</div>
</td>
<td>${(iface.ips || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
<td>${(iface.ipv6 || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
<td>${iface.mac || html`<span class="text-muted">—</span>`}</td>
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
</tr>`);
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade;
return h('tr', { key: 'm-' + zone },
h('td', null, h('strong', null, zone)),
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })),
h('td', null,
ActionButton({
url: '/api/firewall/masquerade',
cls: 'btn btn-sm btn-outline',
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
body: () => ({ zone, enable: !masq }),
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
refresh: 'firewall',
}),
),
);
return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong></td>
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
<td>
<${ActionButton}
url="/api/firewall/masquerade"
cls="btn btn-sm btn-outline"
labelOn="Disable" labelOff="Enable" condition=${masq}
body=${() => ({ zone, enable: !masq })}
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone}
refresh="firewall" />
</td>
</tr>`;
});
const fwRows = [];
@@ -86,21 +83,20 @@ export default definePage({
forwards.forEach((fwd, i) => {
const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i },
h('td', null, h('strong', null, zone)),
h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })),
h('td', null, port),
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'),
h('td', null, fwd['to-port'] || fwd.toport || '-'),
h('td', null,
ConfirmDelete({
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
success: 'Rule removed',
refresh: 'firewall',
}),
),
));
fwRows.push(html`<tr key=${'f-' + zone + '-' + i}>
<td><strong>${zone}</strong></td>
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
<td>${port}</td>
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
<td>${fwd['to-port'] || fwd.toport || '-'}</td>
<td>
<${ConfirmDelete}
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
success="Rule removed"
refresh="firewall" />
</td>
</tr>`);
});
});
@@ -127,9 +123,8 @@ export default definePage({
SectionTitle({ title: 'Port Forwarding' }),
Card({ children: [
ActionGroup(
h('button', { class: 'btn btn-sm btn-primary',
'on:click': () => addFwd({ zones: Object.keys(zoneData) })
}, 'Add Forward'),
html`<button class="btn btn-sm btn-primary"
onClick=${() => addFwd({ zones: Object.keys(zoneData) })}>Add Forward</button>`,
),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
@@ -140,4 +135,4 @@ export default definePage({
]}),
];
},
});
});
+4 -4
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, definePage } from '/static/hoover/index.js?v=7';
import { html, PageHeader, definePage } from '/static/hoover/index.js?v=7';
export default definePage({
init() {
@@ -7,9 +7,9 @@ export default definePage({
render(state) {
return [
PageHeader({ title: '404' }),
h('div', { class: 'card' },
h('div', { class: 'card-body text-muted' }, 'Page not found: ' + state.path),
),
html`<div class="card">
<div class="card-body text-muted">Page not found: ${state.path}</div>
</div>`,
];
},
});
+17 -19
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
const addDomain = QuickModal({
title: 'Add Proxy Domain',
@@ -66,26 +66,24 @@ export default definePage({
expired: d.cert_status === 'expired',
});
return h('tr', { key: d.domain },
h('td', null, h('strong', null, esc(d.domain))),
h('td', null, esc(d.backend_host || '-')),
h('td', null, d.backend_port || '-'),
h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })),
h('td', null, certBadge),
ActionCell({
editLabel: 'Edit',
editClick: () => editDomain(d),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove proxy for ' + d.domain + '?',
removeSuccess: 'Domain removed',
removeRefresh: ['nginx', 'acme'],
removeLabel: 'Delete',
}),
);
return html`<tr key=${d.domain}>
<td><strong>${esc(d.domain)}</strong></td>
<td>${esc(d.backend_host || '-')}</td>
<td>${d.backend_port || '-'}</td>
<td><${Badge} text=${d.backend_proto || d.protocol || 'http'} variant="info" /></td>
<td>${certBadge}</td>
<${ActionCell}
editLabel="Edit" editClick=${() => editDomain(d)}
removeUrl=${'/api/proxy/domains/' + enc(d.domain)}
removeMessage=${'Remove proxy for ' + d.domain + '?'}
removeSuccess="Domain removed"
removeRefresh={['nginx', 'acme']}
removeLabel="Delete" />
</tr>`;
});
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addDomain(state) }, 'Add Domain'),
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
@@ -104,4 +102,4 @@ export default definePage({
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
];
},
});
});
+20 -20
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=7';
const addRule = QuickModal({
title: 'Add Rich Rule',
@@ -35,27 +35,27 @@ export default definePage({
});
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
return html`<tr key=${i}>
<td class="text-muted">${i + 1}</td>
<td class="mono-text td-fullwidth"><${MonoText} text=${ruleText} /></td>
<td>
<${ConfirmDelete}
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
success="Rule removed"
refresh="firewall" />
</td>
</tr>`;
});
return Card({
header: 'Zone: ' + esc(zone),
key: zone,
children: [Table({
columns: ['#', 'Rule', 'Action'],
rows: (Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
return h('tr', { key: i },
h('td', { class: 'text-muted' }, i + 1),
h('td', { class: 'mono-text td-fullwidth' }, MonoText({ text: ruleText })),
h('td', null,
ConfirmDelete({
url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''),
message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?',
success: 'Rule removed',
refresh: 'firewall',
}),
),
);
}),
rows: ruleRows,
emptyText: 'No rules',
wrapCard: false,
})],
@@ -66,10 +66,10 @@ export default definePage({
PageHeader({
title: 'Rules',
subtitle: 'Firewall rich rules',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => addRule({ zones }), }, 'Add Rule'),
actions: html`<button class="btn btn-primary"
onClick=${() => addRule({ zones })}>Add Rule</button>`,
}),
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
];
},
});
});
+23 -26
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=7';
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 } from '/static/hoover/index.js?v=7';
const addPeer = QuickModal({
title: 'Add WireGuard Peer',
@@ -66,33 +66,30 @@ export default definePage({
const peerRows = (state.wireguard.data?.peers || []).map(p => {
const hasHandshake = !!p.latest_handshake;
return h('tr', { key: p.name },
h('td', null,
StatusDot({ status: hasHandshake ? 'success' : 'danger' }),
h('strong', null, esc(p.name || 'unnamed')),
),
h('td', null, MonoText({ text: p.public_key || 'N/A', maxLength: 20 })),
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')),
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')),
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')),
h('td', { class: 'text-sm' },
'Recv: ' + esc(p.transfer_recv || '0'),
h('br'),
'Sent: ' + esc(p.transfer_sent || '0'),
),
ActionCell({
editLabel: 'Config',
editClick: () => downloadConfigModal(p.name, state.wireguard.data?.config, state),
removeUrl: '/api/wireguard/peers/' + enc(p.name),
removeMessage: 'Remove peer ' + p.name + '?',
removeSuccess: 'Peer removed',
removeRefresh: 'wireguard',
}),
);
return html`<tr key=${p.name}>
<td>
<${StatusDot} status=${hasHandshake ? 'success' : 'danger'} />
<strong>${esc(p.name || 'unnamed')}</strong>
</td>
<td><${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /></td>
<td class="text-sm">${esc(p.allowed_ips || '-')}</td>
<td class="text-sm">${esc(p.endpoint || '-')}</td>
<td class="text-sm">${esc(p.latest_handshake || 'Never')}</td>
<td class="text-sm">
Recv: ${esc(p.transfer_recv || '0')}<br/>
Sent: ${esc(p.transfer_sent || '0')}
</td>
<${ActionCell}
editLabel="Config" editClick=${() => downloadConfigModal(p.name, state.wireguard.data?.config, state)}
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
removeMessage=${'Remove peer ' + p.name + '?'}
removeSuccess="Peer removed"
removeRefresh="wireguard" />
</tr>`;
});
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addPeer(state) }, 'Add Peer'),
html`<button class="btn btn-primary" onClick=${() => addPeer(state)}>Add Peer</button>`,
ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
@@ -122,4 +119,4 @@ export default definePage({
: Empty({ text: 'No peers configured. Add a peer above.' }),
];
},
});
});
+41 -44
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=7';
const addZone = QuickModal({
title: 'Add Zone',
@@ -37,30 +37,30 @@ export default definePage({
const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : [];
return h('div', { class: 'card', key: name, style: 'position:relative;' },
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;' },
h('div', null,
h('h3', { style: 'font-size:16px;color:var(--accent);' }, name),
h('div', { class: 'text-muted text-sm', style: 'margin-bottom:10px;' },
z.target ? 'Target: ' + esc(z.target) : '',
),
),
),
h('div', { class: 'text-sm mb-4' },
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Interfaces'),
ifacesArr.length
? ifacesArr.map(i => Badge({ text: esc(i) }))
: h('span', { class: 'text-muted' }, 'None'),
),
h('div', { class: 'text-sm mb-4' },
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Services'),
svcsArr.length
? svcsArr.map(s => Badge({ text: esc(s), variant: 'success' }))
: h('span', { class: 'text-muted' }, 'None'),
),
h('div', { style: 'display:flex;gap:6px;' },
h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => MultiSelectModal({
return html`<div class="card" key=${name} style="position:relative">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h3 style="font-size:16px;color:var(--accent)">${name}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px">
${z.target ? 'Target: ' + esc(z.target) : ''}
</div>
</div>
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
${ifacesArr.length
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
: html`<span class="text-muted">None</span>`}
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px">Services</div>
${svcsArr.length
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
: html`<span class="text-muted">None</span>`}
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-sm btn-outline"
onClick=${() => MultiSelectModal({
title: 'Interfaces: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
options: state.firewall.data?.interfaces || [],
@@ -68,10 +68,9 @@ export default definePage({
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
refresh: 'firewall',
})(),
}, 'Interfaces'),
h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => MultiSelectModal({
})()}>Interfaces</button>
<button class="btn btn-sm btn-outline"
onClick=${() => MultiSelectModal({
title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.firewall.data?.services || [],
@@ -79,29 +78,27 @@ export default definePage({
fieldKey: 'services',
successMsg: 'Services updated',
refresh: 'firewall',
})(),
}, 'Services'),
ConfirmDelete({
url: '/api/firewall/zones/' + enc(name),
message: 'Delete zone ' + name + '?',
success: 'Zone ' + name + ' deleted',
refresh: 'firewall',
label: 'Delete',
}),
),
);
})()}>Services</button>
<${ConfirmDelete}
url=${'/api/firewall/zones/' + enc(name)}
message=${'Delete zone ' + name + '?'}
success=${'Zone ' + name + ' deleted'}
refresh="firewall"
label="Delete" />
</div>
</div>`;
});
return [
PageHeader({
title: 'Zones',
subtitle: 'Firewall zones',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => addZone(), }, 'Add Zone'),
actions: html`<button class="btn btn-primary"
onClick=${() => addZone()}>Add Zone</button>`,
}),
zoneCards.length
? h('div', { class: 'card-grid' }, ...zoneCards)
? html`<div class="card-grid">${zoneCards}</div>`
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
];
},
});
});