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
+78 -167
View File
@@ -1,142 +1,59 @@
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, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, apiFetch, toast, definePage, refactorLoad, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=6';
function addZoneModal(state) {
openModal((inner, idx) => {
formModal(inner, 'Add Zone',
[
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Create', cls: 'btn-primary', action: 's', handler: async () => {
const name = ($val('zone-name') || '').trim();
if (!name) { toast('Zone name required', 'error'); return; }
const target = ($val('zone-target') || '').trim() || 'default';
const r = await apiFetch('/api/firewall/zones', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, target }),
});
if (r.ok) {
toast('Zone ' + name + ' created', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
},
},
],
);
});
}
function zoneIfaceModal(zoneName, state) {
const zdata = state.zones?.[zoneName] || {};
const current = Array.isArray(zdata.interfaces) ? zdata.interfaces : [];
const allIfaces = Array.isArray(state.interfaces) ? state.interfaces : [];
openModal((inner, idx) => {
formModal(inner, 'Interfaces: ' + zoneName,
[
{
label: 'Interfaces', id: 'z-iface-select', tag: 'select',
options: allIfaces.map(i => [i, current.includes(i)]),
},
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
const sel = document.getElementById('z-iface-select');
const selected = Array.from(sel.selectedOptions).map(o => o.value);
const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/interfaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interfaces: selected }),
});
if (r.ok) {
toast('Interfaces updated', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
},
},
],
);
});
}
function zoneSvcModal(zoneName, state) {
const zdata = state.zones?.[zoneName] || {};
const current = Array.isArray(zdata.services) ? zdata.services : [];
const all = Array.isArray(state.services) ? state.services : [];
openModal((inner, idx) => {
formModal(inner, 'Services: ' + zoneName,
[
{
label: 'Services', id: 'z-svc-select', tag: 'select',
options: all.map(s => [s, current.includes(s)]),
},
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
const sel = document.getElementById('z-svc-select');
const selected = Array.from(sel.selectedOptions).map(o => o.value);
const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ services: selected }),
});
if (r.ok) {
toast('Services updated', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
},
},
],
);
});
}
const addZone = QuickModal({
title: 'Add Zone',
fields: [
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
],
submit: {
url: '/api/firewall/zones',
body: () => ({ name: ($val('zone-name') || '').trim(), target: ($val('zone-target') || '').trim() || 'default' }),
validate: (b) => !b.name ? 'Zone name required' : null,
successMsg: 'Zone created',
},
reload: (s) => load(s),
});
async function load(state, abortController, entry) {
if (Object.keys(state.zones || {}).length) state.refreshing = true;
else state.loading = true;
try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const [zRes, svcRes, ifRes] = await Promise.all([
apiFetch('/api/firewall/zones', { signal: sig }),
apiFetch('/api/firewall/services', { signal: sig }),
apiFetch('/api/firewall/interfaces', { signal: sig }),
]);
await refactorLoad(state,
s => Object.keys(s.zones || {}).length,
async (s, sig, isAborted) => {
const [zRes, svcRes, ifRes] = await Promise.allSettled([
apiFetch('/api/firewall/zones', { signal: sig }),
apiFetch('/api/firewall/services', { signal: sig }),
apiFetch('/api/firewall/interfaces', { signal: sig }),
]);
if (isAborted()) return;
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
const errors = [];
if (zRes.status === 'rejected') errors.push(zRes.reason?.message || 'Failed');
else if (!zRes.value.ok) errors.push(zRes.value.error || 'Failed');
if (svcRes.status === 'rejected') errors.push(svcRes.reason?.message || 'Failed');
else if (!svcRes.value.ok) errors.push(svcRes.value.error || 'Failed');
if (ifRes.status === 'rejected') errors.push(ifRes.reason?.message || 'Failed');
else if (!ifRes.value.ok) errors.push(ifRes.value.error || 'Failed');
if (errors.length) {
s.error = errors[0];
return;
}
if (zRes.ok) {
const data = zRes.data || {};
const data = zRes.value.data || {};
const activeZones = data.active || {};
const availableZones = data.available || [];
const detailPromises = availableZones.map(name =>
apiFetch('/api/firewall/zones/' + enc(name), { signal: sig }).catch(() => null)
apiFetch('/api/firewall/zones/' + enc(name), { signal: sig })
);
const detailResults = await Promise.all(detailPromises);
const detailResults = await Promise.allSettled(detailPromises);
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (isAborted()) return;
const zones = {};
for (let i = 0; i < availableZones.length; i++) {
const name = availableZones[i];
const detail = detailResults[i];
const res = detailResults[i];
const detail = res.status === 'fulfilled' ? res.value : null;
if (detail && detail.ok) {
zones[name] = detail.data;
const activeIfaces = activeZones[name];
@@ -145,43 +62,23 @@ async function load(state, abortController, entry) {
}
}
}
state.zones = zones;
}
if (svcRes.ok) state.services = svcRes.data || [];
if (ifRes.ok) state.interfaces = ifRes.data || [];
} catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
s.zones = zones;
s.services = svcRes.value.data || [];
s.interfaces = ifRes.value.data || [];
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { zones: {}, services: [], interfaces: [], loading: true, refreshing: false, error: null };
return { zones: {}, services: [], interfaces: [] };
},
subscribe: ['firewall'],
load,
render(state) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Zones' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const guard = renderGuard(state, 'Zones', 'Firewall zone management', Object.keys(state.zones || {}).length);
if (guard) return guard;
const zoneCards = Object.entries(state.zones || []).map(([name, zdata]) => {
const z = typeof zdata === 'object' ? zdata : {};
@@ -210,20 +107,34 @@ export default definePage({
),
h('div', { style: 'display:flex;gap:6px;' },
h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => zoneIfaceModal(name, state) }, 'Interfaces'),
'on:click': () => MultiSelectModal({
title: 'Interfaces: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
options: state.interfaces,
selected: ifacesArr,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
reload: () => load(state),
})(),
}, 'Interfaces'),
h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => zoneSvcModal(name, state) }, 'Services'),
h('button', { class: 'btn btn-sm btn-danger', style: 'margin-left:auto;',
'on:click': async () => {
if (!confirm('Delete zone ' + name + '?')) return;
const r = await apiFetch('/api/firewall/zones/' + enc(name), { method: 'DELETE' });
if (r.ok) {
toast('Zone ' + name + ' deleted', 'success');
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}}, 'Delete'),
'on:click': () => MultiSelectModal({
title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.services,
selected: svcsArr,
fieldKey: 'services',
successMsg: 'Services updated',
reload: () => load(state),
})(),
}, 'Services'),
ConfirmDelete({
url: '/api/firewall/zones/' + enc(name),
message: 'Delete zone ' + name + '?',
success: 'Zone ' + name + ' deleted',
reload: () => load(state),
label: 'Delete',
}),
),
);
});
@@ -233,7 +144,7 @@ export default definePage({
title: 'Zones',
subtitle: 'Firewall zones',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => addZoneModal(state) }, 'Add Zone'),
'on:click': () => addZone(state), }, 'Add Zone'),
}),
zoneCards.length
? h('div', { class: 'card-grid' }, ...zoneCards)