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(); }