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