refactor: introduce model layer for centralized data synchronization

Add hoover model.js as a central reactive store per subsystem, replacing
per-component data fetching with a single source of truth.

- Add hoover/model.js with modelRegister, modelFetch, and WS invalidation
- Refactor websocket.js to route messages to model refresh (drop per-component
  subscribe/unsubscribe)
- Simplify component.js by removing WS subscription management
- Add refresh option to apiSubmit, deprecate refactorLoad and checkAbort
- Rewrite all pages to use getModel() instead of inline data fetching
- Bootstrap model registrations in app.js
- Add GET /api/firewall/state endpoint
- Fix restart-services.sh restart order and add service health verification
- Update hoover.md docs with model layer architecture
This commit is contained in:
2026-06-22 22:54:29 +00:00
parent 633505e7dc
commit b673e87c9b
27 changed files with 952 additions and 838 deletions
+9 -22
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, refactorLoad, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=6';
import { h, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
function issueCertModal(state) {
openModal((inner, idx) => {
@@ -40,7 +40,7 @@ async function pollCertIssue(rid, state) {
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued for ' + (d.domain || rid), 'success');
load(state);
modelFetch('acme');
},
onError: (d) => {
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
@@ -48,30 +48,17 @@ async function pollCertIssue(rid, state) {
});
}
async function load(state, abortController, entry) {
await refactorLoad(state,
s => s.certs?.length,
async (s, sig, isAborted) => {
const r = await apiFetch('/api/certs/list', { signal: sig });
if (isAborted()) return;
if (r.ok) s.certs = r.data || [];
else s.error = r.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { certs: [] };
return {
acme: getModel('acme'),
};
},
subscribe: ['acme'],
load,
render(state) {
const guard = renderGuard(state, 'Certificates', 'ACME certificate management', state.certs);
const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data);
if (guard) return guard;
const rows = state.certs.map(c => {
const rows = (state.acme.data || []).map(c => {
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
return h('tr', { key: c.domain },
@@ -89,7 +76,7 @@ export default definePage({
removeUrl: '/api/certs/' + enc(c.domain),
removeMessage: 'Remove certificate for ' + c.domain + '?',
removeSuccess: 'Certificate removed',
removeReload: () => load(state),
removeRefresh: 'acme',
}),
);
});
@@ -109,4 +96,4 @@ export default definePage({
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
];
},
});
});
+7 -18
View File
@@ -1,27 +1,16 @@
import { h, PageHeader, apiFetch, definePage, renderGuard, refactorLoad, ServiceStatus, StatCard } from '/static/hoover/index.js?v=6';
import { h, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=7';
export default definePage({
init() {
return { data: null };
},
subscribe: ['firewall', 'dnsmasq', 'wireguard', 'acme', 'networkd'],
async load(state, abortController, entry) {
await refactorLoad(state,
s => s.data,
async (s, sig, isAborted) => {
const res = await apiFetch('/api/status/all', { signal: sig });
if (isAborted()) return;
if (res.ok) s.data = res.data;
else s.error = res.error;
},
{ entry, abortController },
);
return {
status: getModel('status'),
};
},
render(state) {
const guard = renderGuard(state, 'Dashboard', 'System overview', state.data);
const guard = renderGuard(state.status, 'Dashboard', 'System overview', state.status.data);
if (guard) return guard;
const d = state.data;
const d = state.status.data;
const fwZones = (d.firewall?.zones) || {};
const net = d.net || {};
const nCount = Object.keys(net).length;
@@ -69,4 +58,4 @@ export default definePage({
),
];
},
});
});
+21 -49
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
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';
const addRange = QuickModal({
title: 'Add DHCP Range',
@@ -19,7 +19,7 @@ const addRange = QuickModal({
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
successMsg: 'Range added',
},
reload: (s) => load(s),
refresh: 'dnsmasq',
});
const addLease = QuickModal({
@@ -39,7 +39,7 @@ const addLease = QuickModal({
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
successMsg: 'Lease added',
},
reload: (s) => load(s),
refresh: 'dnsmasq',
});
const addDns = QuickModal({
@@ -54,55 +54,27 @@ const addDns = QuickModal({
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
successMsg: 'DNS record added',
},
reload: (s) => load(s),
refresh: 'dnsmasq',
});
async function load(state, abortController, entry) {
await refactorLoad(state,
s => Object.keys(s.config || {}).length,
async (s, sig, isAborted) => {
const [cfgR, stR, lsR] = await Promise.allSettled([
apiFetch('/api/dhcp/config', { signal: sig }),
apiFetch('/api/dhcp/status', { signal: sig }),
apiFetch('/api/dhcp/leases', { signal: sig }),
]);
if (isAborted()) return;
const errors = [];
if (cfgR.status === 'rejected') errors.push(cfgR.reason?.message || 'Failed');
else if (!cfgR.value.ok) errors.push(cfgR.value.error || 'Failed');
if (stR.status === 'rejected') errors.push(stR.reason?.message || 'Failed');
else if (!stR.value.ok) errors.push(stR.value.error || 'Failed');
if (lsR.status === 'rejected') errors.push(lsR.reason?.message || 'Failed');
else if (!lsR.value.ok) errors.push(lsR.value.error || 'Failed');
if (errors.length) {
s.error = errors[0];
return;
}
s.config = cfgR.value.data || {};
s.status = stR.value.data || {};
s.leases = lsR.value.data || [];
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { config: {}, status: {}, leases: [], activeTab: 'ranges' };
return {
dnsmasq: getModel('dnsmasq'),
activeTab: 'ranges',
};
},
subscribe: ['dnsmasq'],
load,
render(state) {
const guard = renderGuard(state, 'DHCP & DNS', null, state.leases);
const guard = renderGuard(state.dnsmasq, 'DHCP & DNS', 'Dnsmasq management', state.dnsmasq.data);
if (guard) return guard;
const cfg = state.config || {};
const cfg = state.dnsmasq.data?.config || {};
const ranges = cfg.ranges || [];
const staticLeases = cfg.static_leases || [];
const dnsRecords = cfg.dns_records || [];
const statusUp = state.status || {};
const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r, i) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
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)),
@@ -113,12 +85,12 @@ export default definePage({
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
body: { interface: r.interface || '', start: r.start, end: r.end },
success: 'Range removed',
reload: () => load(state),
refresh: 'dnsmasq',
}),
),
));
const leaseRows = staticLeases.map((l, i) => h('tr', { key: l.mac },
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 || '-'),
@@ -127,12 +99,12 @@ export default definePage({
url: '/api/dhcp/static-lease/' + enc(l.mac),
message: 'Remove lease ' + l.mac + '?',
success: 'Lease removed',
reload: () => load(state),
refresh: 'dnsmasq',
}),
),
));
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: rec.name },
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,
@@ -140,7 +112,7 @@ export default definePage({
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
success: 'Record removed',
reload: () => load(state),
refresh: 'dnsmasq',
}),
),
));
@@ -154,13 +126,13 @@ export default definePage({
url: '/api/dhcp/apply',
successMsg: 'dnsmasq applied',
label: 'Apply',
reload: () => load(state),
refresh: 'dnsmasq',
}),
);
return [
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
ServiceStatus({ state: statusUp.state || 'down', label: 'Dnsmasq' }),
ServiceStatus({ state: status.state || 'down', label: 'Dnsmasq' }),
Tabs({ state, tabs: tabNames }),
state.activeTab === 'ranges'
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
@@ -169,7 +141,7 @@ export default definePage({
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.leases || []).map((l, i) => h('tr', { key: l.mac || i },
? 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 || '-')),
@@ -177,4 +149,4 @@ export default definePage({
)), emptyText: 'No active leases' }) : null,
];
},
});
});
+35 -43
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=6';
import { h, 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', {
@@ -7,7 +7,8 @@ async function changeZone(name, zone, state) {
});
if (r.ok) {
toast(name + ' \u2192 ' + zone, 'success');
await load(state);
modelFetch('firewall');
modelFetch('network');
} else {
toast(r.error || 'Failed', 'error');
}
@@ -32,53 +33,44 @@ const cfgModalFn = QuickModal({
}),
successMsg: 'Config saved',
},
reload: (s) => load(s),
refresh: ['firewall', 'network'],
});
async function load(state, abortController, entry) {
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;
}
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { ifaces: [], zones: [] };
return {
firewall: getModel('firewall'),
network: getModel('network'),
};
},
subscribe: ['firewall', 'networkd'],
load,
render(state) {
const guard = renderGuard(state, 'Interfaces', 'Network interface management', state.ifaces.length);
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
if (guard) return guard;
const rows = state.ifaces.map(iface => {
const fwZones = state.firewall.data?.zones || {};
const netData = state.network.data?.interfaces || {};
const zones = fwZones.available || [];
const activeZones = fwZones.active || {};
const ifaces = Object.entries(netData).map(([name, entry]) => {
let zone = null;
for (const [zoneName, ifaces] of Object.entries(activeZones)) {
if ((ifaces || []).includes(name)) {
zone = zoneName;
break;
}
}
return {
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,
config: entry?.config || {},
};
});
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')),
@@ -86,7 +78,7 @@ export default definePage({
h('td', null, StatusText({ status: iface.state })),
h('td', null,
ZoneSelect({
zones: state.zones,
zones,
value: iface.zone,
onChange: (z) => changeZone(iface.name, z, state),
}),
@@ -108,4 +100,4 @@ export default definePage({
}),
];
},
});
});
+33 -84
View File
@@ -1,108 +1,57 @@
import { h, PageHeader, Tabs, esc, definePage, refactorLoad } from '/static/hoover/index.js?v=6';
import { h, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=7';
const logTabs = [
{ key: 'journal', label: 'Journal', url: '/api/logs/journal' },
{ key: 'nginx-access', label: 'Nginx Access', url: '/api/logs/nginx/access' },
{ key: 'nginx-error', label: 'Nginx Error', url: '/api/logs/nginx/error' },
{ key: 'dnsmasq', label: 'Dnsmasq', url: '/api/logs/dnsmasq' },
{ key: 'app', label: 'App', url: '/api/logs/app' },
{ key: 'journal', label: 'Journal' },
{ key: 'nginx-access', label: 'Nginx Access' },
{ key: 'nginx-error', label: 'Nginx Error' },
{ key: 'dnsmasq', label: 'Dnsmasq' },
{ key: 'app', label: 'App' },
];
async function fetchLog(state, url, signal) {
if (signal?.aborted) return;
const res = await fetch(url, { signal });
if (signal?.aborted) return;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
if (signal?.aborted) return;
state.lines = text.split('\n').filter(l => l.length > 0);
}
export default definePage({
init() {
return { activeTab: 'journal', lines: [], loading: false, _abortCtrl: null };
},
subscribe: [],
async load(state, abortController, entry) {
await refactorLoad(state,
s => s.lines?.length,
async (s, sig, isAborted) => {
const tab = logTabs.find(t => t.key === s.activeTab) || logTabs[0];
await fetchLog(s, tab.url, sig);
},
{ entry, abortController },
);
},
onUnmount(state) {
state._abortCtrl?.abort();
state.lines = [];
return {
logs: getModel('logs'),
activeTab: 'journal',
};
},
render(state) {
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
const logData = state.logs.data;
const stale = logData?.tab !== state.activeTab;
const guard = renderGuard(state.logs, 'Logs', 'System and service logs', stale ? undefined : logData?.data);
if (guard) return guard;
const lineVnodes = state.lines.map((line, i) =>
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))
);
const tabsBody = Tabs({
state,
tabs: logTabs.map(t => t.key),
formatLabel: (k) => {
const t = logTabs.find(t => t.key === k);
return t ? t.label : k.charAt(0).toUpperCase() + k.slice(1);
},
onTabClick: (key) => modelFetch('logs', key),
});
return [
PageHeader({ title: 'Logs', subtitle: 'System & service logs' }),
Tabs({
state,
tabs: logTabs.map(t => t.key),
formatLabel: (k) => {
const tab = logTabs.find(t => t.key === k);
return tab ? tab.label : k.charAt(0).toUpperCase() + k.slice(1);
},
onTabClick: async (key) => {
const tab = logTabs.find(t => t.key === key);
if (!tab) return;
state._abortCtrl?.abort();
const ctrl = new AbortController();
state._abortCtrl = ctrl;
const tabs = state.lines?.length ? state : null;
state.refreshing = !!tabs;
if (!tabs) state.loading = true;
state.error = null;
try {
await fetchLog(state, tab.url, ctrl.signal);
} catch (e) {
if (!ctrl.signal.aborted) state.error = String(e);
}
state.loading = false;
state.refreshing = false;
},
}),
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': async () => {
state._abortCtrl?.abort();
const ctrl = new AbortController();
state._abortCtrl = ctrl;
state.refreshing = true;
state.error = null;
try {
await fetchLog(state, tab.url, ctrl.signal);
} catch (e) {
if (!ctrl.signal.aborted) state.error = String(e);
}
state.loading = false;
state.refreshing = false;
},
}, '\u21BB')
'on:click': () => modelFetch('logs', state.activeTab),
}, '\u21BB'),
),
h('div', { class: 'card-body log-body' },
state.loading && !state.refreshing
? h('div', { class: 'loading' }, state.refreshing ? 'Refreshing...' : 'Loading...')
: state.error
? h('div', { class: 'error-msg' }, state.error)
: lineVnodes.length > 0
? h('pre', null, lineVnodes)
: h('div', { class: 'text-muted text-sm' }, 'No log lines available')
)
h('pre', null, lineVnodes),
),
),
];
},
+14 -33
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, ZoneSelect } from '/static/hoover/index.js?v=6';
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';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -11,7 +11,7 @@ const addFwd = QuickModal({
],
submit: {
url: '/api/firewall/forward-port',
body: (s) => ({
body: () => ({
zone: $val('fwd-zone'),
port: parseInt($val('fwd-port')),
proto: ($val('fwd-proto') || 'tcp').trim(),
@@ -21,43 +21,23 @@ const addFwd = QuickModal({
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
successMsg: 'Forward rule added',
},
reload: (s) => load(s._s),
refresh: 'firewall',
});
async function load(state, abortController, entry) {
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.activeZones = Object.keys(zr.data?.active || {});
else if (!s.error) s.error = zr.error;
const sr = await apiFetch('/api/firewall/state', { signal: sig });
if (isAborted()) return;
if (sr.ok) s.stateData = sr.data;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { config: {}, activeZones: [], stateData: null };
return {
firewall: getModel('firewall'),
};
},
subscribe: ['firewall'],
load,
render(state) {
const guard = renderGuard(state, 'NAT', 'Masquerade & port forwarding', state.config);
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
if (guard) return guard;
const cfg = state.config || {};
const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {};
const sIface = (state.stateData || {}).interfaces || [];
const sIface = (state.firewall.data?.state || {}).interfaces || [];
const masqZones = new Set(
Object.entries(zoneData)
.filter(([, zcfg]) => !!zcfg.masquerade)
@@ -94,7 +74,7 @@ export default definePage({
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
body: () => ({ zone, enable: !masq }),
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
reload: () => load(state),
refresh: 'firewall',
}),
),
);
@@ -117,7 +97,7 @@ export default definePage({
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
success: 'Rule removed',
reload: () => load(state),
refresh: 'firewall',
}),
),
));
@@ -148,7 +128,8 @@ export default definePage({
Card({ children: [
ActionGroup(
h('button', { class: 'btn btn-sm btn-primary',
'on:click': () => addFwd({ zones: state.activeZones, _s: state }) }, 'Add Forward'),
'on:click': () => addFwd({ zones: Object.keys(zoneData) })
}, 'Add Forward'),
),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
@@ -159,4 +140,4 @@ export default definePage({
]}),
];
},
});
});
+2 -6
View File
@@ -1,12 +1,8 @@
import { h, PageHeader, definePage } from '/static/hoover/index.js?v=6';
import { h, PageHeader, definePage } from '/static/hoover/index.js?v=7';
export default definePage({
init() {
return { path: '' };
},
subscribe: [],
async load(state) {
state.path = location.hash.slice(1) || '';
return { path: location.hash.slice(1) || '' };
},
render(state) {
return [
+15 -30
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
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';
const addDomain = QuickModal({
title: 'Add Proxy Domain',
@@ -21,7 +21,7 @@ const addDomain = QuickModal({
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
successMsg: 'Domain added',
},
reload: (s) => load(s),
refresh: ['nginx', 'acme'],
});
const editDomain = QuickModal({
@@ -35,7 +35,7 @@ const editDomain = QuickModal({
submit: {
url: (d) => '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: (d) => ({
body: () => ({
backend_host: ($val('pe-host') || '').trim(),
backend_port: parseInt($val('pe-port')),
backend_proto: ($val('pe-proto') || 'http').trim(),
@@ -44,37 +44,22 @@ const editDomain = QuickModal({
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
successMsg: 'Domain updated',
},
reload: (s) => load(s._s),
refresh: ['nginx', 'acme'],
});
async function load(state, abortController, entry) {
await refactorLoad(state,
s => s.domains?.length,
async (s, sig, isAborted) => {
const domainsR = await apiFetch('/api/proxy/domains', { signal: sig });
if (isAborted()) return;
if (domainsR.ok) s.domains = domainsR.data || [];
else s.error = domainsR.error;
const certsR = await apiFetch('/api/certs/list', { signal: sig });
if (isAborted()) return;
if (certsR.ok) s.certs = certsR.data || [];
else if (!s.error) s.error = certsR.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { domains: [], certs: [] };
return {
nginx: getModel('nginx'),
acme: getModel('acme'),
};
},
subscribe: ['nginx', 'acme'],
load,
render(state) {
const guard = renderGuard(state, 'Proxy', 'Nginx reverse proxy', state.domains);
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
if (guard) return guard;
const rows = state.domains.map(d => {
const domains = state.nginx.data || [];
const rows = domains.map(d => {
const certBadge = certStatusBadge({
certStatus: d.cert_status,
daysRemaining: d.days_remaining,
@@ -89,11 +74,11 @@ export default definePage({
h('td', null, certBadge),
ActionCell({
editLabel: 'Edit',
editClick: () => editDomain({ ...d, _s: state }),
editClick: () => editDomain(d),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove proxy for ' + d.domain + '?',
removeSuccess: 'Domain removed',
removeReload: () => load(state),
removeRefresh: ['nginx', 'acme'],
removeLabel: 'Delete',
}),
);
@@ -105,7 +90,7 @@ export default definePage({
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
label: 'Apply',
reload: () => load(state),
refresh: ['nginx', 'acme'],
}),
);
@@ -119,4 +104,4 @@ export default definePage({
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
];
},
});
});
+12 -28
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, MonoText, QuickModal } from '/static/hoover/index.js?v=6';
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';
const addRule = QuickModal({
title: 'Add Rich Rule',
@@ -8,41 +8,25 @@ const addRule = QuickModal({
],
submit: {
url: '/api/firewall/rich-rules',
body: (s) => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
body: () => ({ 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),
refresh: 'firewall',
});
async function load(state, abortController, entry) {
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: {}, zones: [] };
return {
firewall: getModel('firewall'),
};
},
subscribe: ['firewall'],
load,
render(state) {
const guard = renderGuard(state, 'Rules', 'Firewall rich rules', state.config);
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
if (guard) return guard;
const cfg = state.config || {};
const cfg = state.firewall.data?.config || {};
const zones = state.firewall.data?.zones?.available || [];
const zoneData = cfg.zones || {};
const zoneRules = {};
Object.entries(zoneData).forEach(([zname, zcfg]) => {
@@ -67,7 +51,7 @@ export default definePage({
url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''),
message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?',
success: 'Rule removed',
reload: () => load(state),
refresh: 'firewall',
}),
),
);
@@ -83,9 +67,9 @@ export default definePage({
title: 'Rules',
subtitle: 'Firewall rich rules',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => addRule({ zones: state.zones, _s: state }), }, 'Add Rule'),
'on:click': () => addRule({ zones }), }, 'Add Rule'),
}),
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
];
},
});
});
+14 -35
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, apiSubmit, refactorLoad, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=6';
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';
const addPeer = QuickModal({
title: 'Add WireGuard Peer',
@@ -19,7 +19,7 @@ const addPeer = QuickModal({
validate: (b) => !b.name ? 'Name is required' : null,
successMsg: 'Peer added',
},
reload: (s) => load(s),
refresh: 'wireguard',
});
function downloadConfigModal(peerName, config, state) {
@@ -50,42 +50,21 @@ function downloadConfigModal(peerName, config, state) {
});
}
async function load(state, abortController, entry) {
await refactorLoad(state,
s => s.peers?.length,
async (s, sig, isAborted) => {
const stR = await apiFetch('/api/wireguard/status', { signal: sig });
if (isAborted()) return;
if (stR.ok) s.status = stR.data || {};
else s.error = stR.error;
const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
if (isAborted()) return;
if (pR.ok) s.peers = pR.data || [];
else if (!s.error) s.error = pR.error;
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
if (isAborted()) return;
if (cfgR.ok) s.config = cfgR.data || {};
else if (!s.error) s.error = cfgR.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { status: {}, peers: [], config: {} };
return {
wireguard: getModel('wireguard'),
};
},
subscribe: ['wireguard'],
load,
render(state) {
const guard = renderGuard(state, 'WireGuard', 'Tunnel: ' + ((state.status || {}).state || 'unknown') + ', Listen: ' + ((state.config?.interface || {}).listen_port || '-'), state.peers);
const guard = renderGuard(state.wireguard, 'WireGuard', null, state.wireguard.data?.peers);
if (guard) return guard;
const st = state.status || {};
const st = state.wireguard.data?.status || {};
const isUp = st.state === 'up';
const listenPort = (state.config?.interface || {}).listen_port || '-';
const listenPort = (state.wireguard.data?.config?.interface || {}).listen_port || '-';
const peerRows = state.peers.map(p => {
const peerRows = (state.wireguard.data?.peers || []).map(p => {
const hasHandshake = !!p.latest_handshake;
return h('tr', { key: p.name },
h('td', null,
@@ -103,11 +82,11 @@ export default definePage({
),
ActionCell({
editLabel: 'Config',
editClick: () => downloadConfigModal(p.name, state.config, state),
editClick: () => downloadConfigModal(p.name, state.wireguard.data?.config, state),
removeUrl: '/api/wireguard/peers/' + enc(p.name),
removeMessage: 'Remove peer ' + p.name + '?',
removeSuccess: 'Peer removed',
removeReload: () => load(state),
removeRefresh: 'wireguard',
}),
);
});
@@ -118,13 +97,13 @@ export default definePage({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
reload: () => load(state),
refresh: 'wireguard',
}),
ActionButton({
url: '/api/wireguard/apply',
successMsg: 'Config applied',
label: 'Apply',
reload: () => load(state),
refresh: 'wireguard',
}),
);
@@ -143,4 +122,4 @@ export default definePage({
: Empty({ text: 'No peers configured. Add a peer above.' }),
];
},
});
});
+22 -69
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, apiFetch, toast, definePage, refactorLoad, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=6';
import { h, 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',
@@ -12,75 +12,28 @@ const addZone = QuickModal({
validate: (b) => !b.name ? 'Zone name required' : null,
successMsg: 'Zone created',
},
reload: (s) => load(s),
refresh: 'firewall',
});
async function load(state, abortController, entry) {
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;
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;
}
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 })
);
const detailResults = await Promise.allSettled(detailPromises);
if (isAborted()) return;
const zones = {};
for (let i = 0; i < availableZones.length; i++) {
const name = availableZones[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];
if (Array.isArray(activeIfaces)) {
zones[name].interfaces = activeIfaces;
}
}
}
s.zones = zones;
s.services = svcRes.value.data || [];
s.interfaces = ifRes.value.data || [];
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { zones: {}, services: [], interfaces: [] };
return {
firewall: getModel('firewall'),
};
},
subscribe: ['firewall'],
load,
render(state) {
const guard = renderGuard(state, 'Zones', 'Firewall zone management', Object.keys(state.zones || {}).length);
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard;
const zoneCards = Object.entries(state.zones || []).map(([name, zdata]) => {
const zones = state.firewall.data?.zones?.available || [];
const activeZones = state.firewall.data?.zones?.active || {};
const zoneDetails = {};
for (const name of zones) {
const activeIfaces = activeZones[name];
zoneDetails[name] = { interfaces: Array.isArray(activeIfaces) ? activeIfaces : [] };
}
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : [];
@@ -110,29 +63,29 @@ export default definePage({
'on:click': () => MultiSelectModal({
title: 'Interfaces: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
options: state.interfaces,
options: state.firewall.data?.interfaces || [],
selected: ifacesArr,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
reload: () => load(state),
refresh: 'firewall',
})(),
}, 'Interfaces'),
h('button', { class: 'btn btn-sm btn-outline',
'on:click': () => MultiSelectModal({
title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.services,
options: state.firewall.data?.services || [],
selected: svcsArr,
fieldKey: 'services',
successMsg: 'Services updated',
reload: () => load(state),
refresh: 'firewall',
})(),
}, 'Services'),
ConfirmDelete({
url: '/api/firewall/zones/' + enc(name),
message: 'Delete zone ' + name + '?',
success: 'Zone ' + name + ' deleted',
reload: () => load(state),
refresh: 'firewall',
label: 'Delete',
}),
),
@@ -144,11 +97,11 @@ export default definePage({
title: 'Zones',
subtitle: 'Firewall zones',
actions: h('button', { class: 'btn btn-primary',
'on:click': () => addZone(state), }, 'Add Zone'),
'on:click': () => addZone(), }, 'Add Zone'),
}),
zoneCards.length
? h('div', { class: 'card-grid' }, ...zoneCards)
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
];
},
});
});