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