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
+67 -122
View File
@@ -1,10 +1,9 @@
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, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=6';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interfaces: [name] }),
body: { interfaces: [name] },
});
if (r.ok) {
toast(name + ' \u2192 ' + zone, 'success');
@@ -14,126 +13,87 @@ async function changeZone(name, zone, state) {
}
}
function cfgModal(name, state) {
openModal((inner, idx) => {
formModal(inner, 'Config: ' + name,
[
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', placeholder: '192.168.1.1/24' },
{ label: 'Gateway', id: 'cfg-gw' },
{ label: 'DNS (comma-separated)', id: 'cfg-dns', placeholder: '1.1.1.1, 8.8.8.8' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
gateway: ($val('cfg-gw') || '').trim() || undefined,
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
};
const r = await apiFetch('/api/network/interfaces/' + enc(name), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) {
toast('Config saved', 'success');
closeModal(idx);
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
},
},
],
);
});
}
const cfgModalFn = QuickModal({
title: (d) => 'Config: ' + d.name,
fields: (d) => {
const cfg = d.config || {};
return [
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', value: (cfg.addresses || []).join(', '), placeholder: '192.168.1.1/24' },
{ label: 'Gateway', id: 'cfg-gw', value: cfg.gateway || '' },
{ label: 'DNS (comma-separated)', id: 'cfg-dns', value: (cfg.dns || []).join(', '), placeholder: '1.1.1.1, 8.8.8.8' },
];
},
submit: {
url: (d) => '/api/network/interfaces/' + enc(d.name),
body: () => ({
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
gateway: ($val('cfg-gw') || '').trim() || undefined,
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
}),
successMsg: 'Config saved',
},
reload: (s) => load(s),
});
async function load(state, abortController, entry) {
if (state.ifaces?.length) state.refreshing = true;
else state.loading = true;
try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const [fw, net] = await Promise.all([
apiFetch('/api/firewall/zones', { signal: sig }),
apiFetch('/api/network/interfaces', { signal: sig }),
]);
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
// Extract zone names from available zones (for the dropdown)
state.zones = fw.ok ? (fw.data?.available || []) : [];
if (net.ok) {
// Build reverse zone map: interface name → zone name, from active zones
const ifaceZone = {};
for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) {
for (const name of (ifaces || [])) ifaceZone[name] = zoneName;
await refactorLoad(state,
s => s.ifaces?.length,
async (s, sig, isAborted) => {
const [fw, net] = await Promise.all([
apiFetch('/api/firewall/zones', { signal: sig }),
apiFetch('/api/network/interfaces', { signal: sig }),
]);
if (isAborted()) return;
if (fw.ok) s.zones = fw.data?.available || [];
else s.error = fw.error;
if (net.ok) {
const ifaceZone = {};
for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) {
for (const name of (ifaces || [])) ifaceZone[name] = zoneName;
}
const ifacesObj = net.data?.interfaces || {};
s.ifaces = Object.entries(ifacesObj).map(([name, entry]) => ({
name,
mac: entry?.runtime?.mac || null,
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
zone: ifaceZone[name] || null,
config: entry?.config || {},
}));
} else if (!s.error) {
s.error = net.error;
}
// Transform { interfaces: { name: { config, runtime } }, timestamp }
// → array of { name, mac, ips, state, zone }
const ifacesObj = net.data?.interfaces || {};
state.ifaces = Object.entries(ifacesObj).map(([name, entry]) => ({
name,
mac: entry?.runtime?.mac || null,
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
zone: ifaceZone[name] || null,
}));
} else {
state.error = net.error;
}
} catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { ifaces: [], zones: [], loading: true, refreshing: false, error: null };
return { ifaces: [], zones: [] };
},
subscribe: ['firewall', 'networkd'],
load,
render(state) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title: 'Interfaces' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Interfaces' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const guard = renderGuard(state, 'Interfaces', 'Network interface management', state.ifaces.length);
if (guard) return guard;
const rows = state.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,
StatusDot({ status: iface.state }),
' ' + (iface.state === 'up' ? 'Up' : 'Down'),
),
h('td', null,
h('select', {
'on:change': (e) => changeZone(iface.name, e.target.value, state),
}, state.zones.map(z =>
h('option', { value: z, selected: z === iface.zone }, z),
)),
ZoneSelect({
zones: state.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': () => cfgModal(iface.name, state),
'on:click': () => cfgModalFn(iface),
}, 'Config'),
),
);
@@ -141,26 +101,11 @@ export default definePage({
return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
h('div', { class: 'card' },
h('table', { class: 'table' },
h('thead', null,
h('tr', null,
h('th', null, 'Name'),
h('th', null, 'MAC'),
h('th', null, 'IPs'),
h('th', null, 'State'),
h('th', null, 'Zone / Actions'),
),
),
h('tbody', null,
...(rows.length ? rows : [
h('tr', null,
h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No interfaces found'),
),
]),
),
),
),
Table({
columns: ['Name', 'MAC', 'IPs', 'State', 'Zone / Actions'],
rows,
emptyText: 'No interfaces found',
}),
];
},
});