332d14e37d
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
277 lines
11 KiB
JavaScript
277 lines
11 KiB
JavaScript
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, getModel, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
|
|
import { SUBSYSTEMS } from '/static/hoover/schema.js';
|
|
|
|
import DashboardPage from '/static/pages/dashboard.js';
|
|
import InterfacesPage from '/static/pages/interfaces.js';
|
|
import ZonesPage from '/static/pages/zones.js';
|
|
import RulesPage from '/static/pages/rules.js';
|
|
import NatPage from '/static/pages/nat.js';
|
|
import DhcpPage from '/static/pages/dhcp.js';
|
|
import ProxyPage from '/static/pages/proxy.js';
|
|
import BackendsPage from '/static/pages/backends.js';
|
|
import CertsPage from '/static/pages/certs.js';
|
|
import WireguardPage from '/static/pages/wireguard.js';
|
|
import LogsPage from '/static/pages/logs.js';
|
|
import NotFoundPage from '/static/pages/notfound.js';
|
|
import LoginPage from '/static/pages/login.js';
|
|
import PasskeysPage from '/static/pages/passkeys.js';
|
|
import UsersPage from '/static/pages/users.js';
|
|
|
|
/* ── Navigation items ──────────────────────────────────────── */
|
|
const _NavBase = [
|
|
{ path: '/dashboard', label: 'Dashboard' },
|
|
{ path: '/interfaces', label: 'Interfaces' },
|
|
{ path: '/zones', label: 'Zones' },
|
|
{ path: '/rules', label: 'Rules' },
|
|
{ path: '/nat', label: 'NAT' },
|
|
{ path: '/dhcp', label: 'DHCP' },
|
|
{ path: '/proxy', label: 'Proxy' },
|
|
{ path: '/backends', label: 'Backends' },
|
|
{ path: '/certs', label: 'Certs' },
|
|
{ path: '/wireguard', label: 'WireGuard' },
|
|
{ path: '/logs', label: 'Logs' },
|
|
{ path: '/passkeys', label: 'Passkeys' },
|
|
];
|
|
|
|
function getNav() {
|
|
const perms = getAuthData()?.permissions;
|
|
const nav = [..._NavBase];
|
|
if (perms && perms.auth === 'rw') {
|
|
nav.push({ path: '/users', label: 'Users' });
|
|
}
|
|
return nav;
|
|
}
|
|
|
|
/* ── Auth model (silent topic — the daemon never broadcasts 'auth') ── */
|
|
modelRegister('auth', createAuthModel());
|
|
|
|
/* ── State-backed models ──────────────────────────────────── */
|
|
/* All state-backed models stream over the WS (snapshot on connect,
|
|
* per-subsystem deltas). The fetch below is the HTTP fallback: it hits
|
|
* POST /api/status/refresh with a subsystem filter and returns the
|
|
* subsystem state verbatim — the exact shape the state store holds. */
|
|
function _stateModelFetch(subsystem) {
|
|
return async () => {
|
|
const r = await apiFetch('/api/status/refresh', {
|
|
method: 'POST',
|
|
body: { subsystems: [subsystem] },
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
const payload = r.data?.[subsystem];
|
|
// Collector failure: the daemon returns null for that subsystem.
|
|
// Throw instead of returning {} so modelFetch keeps the current
|
|
// data (schema defaults) and sets model.error rather than
|
|
// clobbering it with an empty object.
|
|
if (payload == null) throw new Error(subsystem + ': state not populated yet');
|
|
return payload;
|
|
};
|
|
}
|
|
|
|
// Each maps to one subsystem in the state store. Model name may differ
|
|
// from subsystem name (e.g. `network` → `networkd`).
|
|
const STATE_MODELS = [
|
|
{ name: 'firewall', subsystem: 'firewall' },
|
|
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
|
|
{ name: 'nginx', subsystem: 'nginx' },
|
|
{ name: 'acme', subsystem: 'acme' },
|
|
{ name: 'wireguard', subsystem: 'wireguard' },
|
|
{ name: 'network', subsystem: 'networkd' },
|
|
{ name: 'system', subsystem: 'system' },
|
|
];
|
|
|
|
for (const { name, subsystem } of STATE_MODELS) {
|
|
modelRegister(name, {
|
|
subsystem,
|
|
defaultData: SUBSYSTEMS[subsystem].defaults,
|
|
fetch: _stateModelFetch(subsystem),
|
|
});
|
|
}
|
|
|
|
modelRegister('backends', {
|
|
subsystem: 'nginx',
|
|
fetch: async () => {
|
|
const r = await apiFetch('/api/proxy/backends');
|
|
if (!r.ok) throw new Error(r.error);
|
|
return r.data || {};
|
|
},
|
|
});
|
|
|
|
const LOG_TABS = {
|
|
journal: '/api/logs/journal',
|
|
'nginx-access': '/api/logs/nginx/access',
|
|
'nginx-error': '/api/logs/nginx/error',
|
|
dnsmasq: '/api/logs/dnsmasq',
|
|
app: '/api/logs/app',
|
|
};
|
|
|
|
modelRegister('logs', {
|
|
subsystem: '*',
|
|
fetch: async (signal, tab) => {
|
|
const tabKey = tab || 'journal';
|
|
const url = LOG_TABS[tabKey];
|
|
if (!url) throw new Error('Unknown log tab: ' + tabKey);
|
|
const r = await apiFetch(url, { signal });
|
|
if (!r.ok) throw new Error(r.error);
|
|
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tabKey };
|
|
},
|
|
});
|
|
|
|
/* ── Initial fetch (after auth check) ───────────────────────── */
|
|
function fetchInitialData() {
|
|
// State-backed models: first data arrives via the WS snapshot.
|
|
// If WS hasn't delivered data within 3s, fall back to HTTP.
|
|
for (const { name } of STATE_MODELS) {
|
|
setTimeout(() => {
|
|
const model = getModel(name);
|
|
if (model.loading) { // snapshot (or a prior fetch) hasn't completed
|
|
modelFetch(name);
|
|
}
|
|
}, 3000);
|
|
}
|
|
// Non-state models fetch immediately
|
|
modelFetch('backends');
|
|
modelFetch('logs', 'journal');
|
|
}
|
|
|
|
/* ── Page map ──────────────────────────────────────────────── */
|
|
const Pages = {
|
|
login: LoginPage,
|
|
dashboard: DashboardPage,
|
|
interfaces: InterfacesPage,
|
|
zones: ZonesPage,
|
|
rules: RulesPage,
|
|
nat: NatPage,
|
|
dhcp: DhcpPage,
|
|
proxy: ProxyPage,
|
|
backends: BackendsPage,
|
|
certs: CertsPage,
|
|
wireguard: WireguardPage,
|
|
logs: LogsPage,
|
|
passkeys: PasskeysPage,
|
|
users: UsersPage,
|
|
};
|
|
|
|
/* ── Router ────────────────────────────────────────────────── */
|
|
const router = {
|
|
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
|
component() {
|
|
const { path } = this.state;
|
|
// Auth guard: unauthenticated users see the login page for any
|
|
// protected route (manual hash entry, back/forward, runtime
|
|
// expiry). Reactive — the auth model's data mutation re-renders
|
|
// this function, so the real page appears the instant login
|
|
// completes.
|
|
if (path !== '/login' && !isAuthenticated()) {
|
|
return hComp(LoginPage, '/login');
|
|
}
|
|
const name = path.replace(/^\//, '');
|
|
const page = Pages[name] || NotFoundPage;
|
|
return hComp(page, path);
|
|
},
|
|
};
|
|
|
|
// Set once the bootstrap session check settles (and implicitly on every
|
|
// later login/logout transition — isAuthenticated flips reactively). Until
|
|
// then the hashchange clamp below must NOT force unauthenticated hashes to
|
|
// #/login: a valid-session reload arrives with its 'check' still in flight,
|
|
// and clamping early would strand the user on login.
|
|
let authChecked = false;
|
|
|
|
window.location.hash || (window.location.hash = router.state.path);
|
|
window.addEventListener('hashchange', () => {
|
|
const raw = location.hash.slice(1) || '/dashboard';
|
|
const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw;
|
|
router.state.path = path;
|
|
// Keep the URL in sync with the clamped path (loop-safe: the follow-up
|
|
// hashchange lands on the already-clamped '/login').
|
|
if (location.hash.slice(1) !== path) location.hash = path;
|
|
});
|
|
|
|
/* ── Sidebar render root ───────────────────────────────────── */
|
|
function Sidebar() {
|
|
// No nav when logged out — unauthenticated users get the full-bleed
|
|
// login page. Reactive: the auth model's terminal transition (logout /
|
|
// session expiry) re-renders this root back to null.
|
|
if (!isAuthenticated()) return null;
|
|
const current = router.state.path;
|
|
const nav = getNav();
|
|
return h('div', { class: 'sidebar' },
|
|
h('div', { class: 'logo' }, 'Vacuum Wall'),
|
|
h('nav', null,
|
|
nav.map(item =>
|
|
Link({
|
|
path: item.path,
|
|
class: current === item.path ? 'active' : '',
|
|
children: [item.label],
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/* ── Main content render root ──────────────────────────────── */
|
|
function MainContent() {
|
|
return [
|
|
router.component(),
|
|
ToastContainer(),
|
|
];
|
|
}
|
|
|
|
/* ── Init ──────────────────────────────────────────────────── */
|
|
export async function initApp() {
|
|
// Listen for login events to update router state after auth
|
|
window.addEventListener('auth:login', () => {
|
|
// Defer to a macrotask: at dispatch time (microtask) the login form's
|
|
// hash change has not run yet — router.state.path is still '/login'.
|
|
// The deferred check runs after the hashchange task, so a fresh login
|
|
// fetches all models. connect() is idempotent (_wsConnect no-ops with
|
|
// a live connection) and gives the post-login session its WS — today
|
|
// WS only connects on an authenticated page load (pre-existing gap).
|
|
setTimeout(() => {
|
|
connect();
|
|
if (!router.state.path.startsWith('/login')) {
|
|
fetchInitialData();
|
|
}
|
|
}, 0);
|
|
});
|
|
|
|
// Terminal auth transition — close the WS socket so a same-tab relogin
|
|
// establishes a fresh connection with the new user's token.
|
|
window.addEventListener('auth:logout', () => {
|
|
disconnect();
|
|
});
|
|
|
|
// Check auth state BEFORE mounting the shell: an unauthenticated visitor
|
|
// must never flash the sidebar or a protected page before the redirect
|
|
// to #/login lands.
|
|
await modelFetch('auth', { action: 'check' });
|
|
authChecked = true;
|
|
if (isAuthenticated()) {
|
|
// A session restored at bootstrap (or a reload) may leave the URL on
|
|
// #/login — the auth guard renders the login form for that hash even
|
|
// when authenticated. Bounce to the default page so a valid session
|
|
// never strands the user on a stale login screen.
|
|
if (router.state.path === '/login') {
|
|
window.location.hash = '/dashboard';
|
|
}
|
|
fetchInitialData();
|
|
setTimeout(connect, 0);
|
|
} else if (router.state.path !== '/login') {
|
|
// No valid session — redirect to login before the first paint.
|
|
window.location.hash = '/login';
|
|
}
|
|
|
|
const sidebarEl = document.getElementById('sidebar');
|
|
const mainEl = document.getElementById('main');
|
|
if (sidebarEl && mainEl) {
|
|
render(sidebarEl, Sidebar);
|
|
render(mainEl, MainContent);
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initApp);
|
|
} else {
|
|
initApp();
|
|
}
|