refactor: modernize frontend with hoover framework components and docs

- Add quick modal, table, service status, and confirmation dialog components
- Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns
- Introduce refactor load utility and render guard for consistent UX
- Add hoover documentation and update AGENTS.md, architecture, overview
This commit is contained in:
2026-06-21 04:29:27 +00:00
parent b8f20e99d9
commit 633505e7dc
29 changed files with 2558 additions and 1414 deletions
+57 -106
View File
@@ -1,83 +1,46 @@
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, MonoText, QuickModal } from '/static/hoover/index.js?v=6';
function addRuleModal(zones, state) {
openModal((inner, idx) => {
formModal(inner, 'Add Rich Rule',
[
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) },
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const zone = $val('rule-zone');
const rule = ($val('rule-text') || '').trim();
if (!zone || !rule) { toast('Zone and rule are required', 'error'); return; }
const r = await apiFetch('/api/firewall/rich-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zone, rule }),
});
if (r.ok) {
toast('Rule added', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
},
},
],
);
});
}
const addRule = QuickModal({
title: 'Add Rich Rule',
fields: (d) => [
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: d.zones },
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
],
submit: {
url: '/api/firewall/rich-rules',
body: (s) => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
successMsg: 'Rule added',
},
reload: (s) => load(s._s),
});
async function load(state, abortController, entry) {
if (Object.keys(state.config || {}).length) state.refreshing = true;
else state.loading = true;
try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const r = await apiFetch('/api/firewall/config', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (r.ok) state.config = r.data || {};
else state.error = r.error;
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (zr.ok) state.zones = Object.keys(zr.data?.active || {});
} catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
await refactorLoad(state,
s => Object.keys(s.config || {}).length,
async (s, sig, isAborted) => {
const r = await apiFetch('/api/firewall/config', { signal: sig });
if (isAborted()) return;
if (r.ok) s.config = r.data || {};
else s.error = r.error;
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
if (isAborted()) return;
if (zr.ok) s.zones = Object.keys(zr.data?.active || {});
else if (!s.error) s.error = zr.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { config: {}, loading: true, refreshing: false, error: null, zones: [] };
return { config: {}, zones: [] };
},
subscribe: ['firewall'],
load,
render(state) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Rules' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const guard = renderGuard(state, 'Rules', 'Firewall rich rules', state.config);
if (guard) return guard;
const cfg = state.config || {};
const zoneData = cfg.zones || {};
@@ -88,43 +51,31 @@ export default definePage({
});
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
return h('div', { class: 'card', key: zone },
h('div', { class: 'card-header' }, 'Zone: ' + esc(zone)),
h('div', { class: 'card-body' },
h('table', { class: 'table' },
h('thead', null,
h('tr', null,
h('th', null, '#'),
h('th', null, 'Rule'),
h('th', { style: 'width:80px;' }, 'Action'),
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',
reload: () => load(state),
}),
),
),
h('tbody', null,
(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', { style: 'font-family:monospace;font-size:12px;word-break:break-all;' }, esc(ruleText)),
h('td', null,
h('button', { class: 'btn btn-sm btn-danger',
'on:click': async () => {
if (!confirm('Remove rule: ' + ruleText.substring(0, 40) + '...?')) return;
const r = await apiFetch('/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''), { method: 'DELETE' });
if (r.ok) {
toast('Rule removed', 'success');
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}}, 'Remove'),
),
);
}),
),
),
),
);
);
}),
emptyText: 'No rules',
wrapCard: false,
})],
});
});
return [
@@ -132,7 +83,7 @@ export default definePage({
title: 'Rules',
subtitle: 'Firewall rich rules',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => addRuleModal(state.zones, state) }, 'Add Rule'),
'on:click': () => addRule({ zones: state.zones, _s: state }), }, 'Add Rule'),
}),
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
];