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