import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive, initAuth, getAuthToken, checkSession } from '/static/hoover/index.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' }, ]; function getNav() { const nav = [..._NavBase]; const perms = JSON.parse(sessionStorage.getItem('vw:permissions') || 'null'); if (perms && perms.auth === 'rw') { nav.push({ path: '/users', label: 'Users' }); } return nav; } modelRegister('firewall', { subsystem: 'firewall', fetch: async () => { const [cfg, zones, services, interfaces, state] = await Promise.allSettled([ apiFetch('/api/firewall/config'), apiFetch('/api/firewall/zones'), apiFetch('/api/firewall/services'), apiFetch('/api/firewall/interfaces'), apiFetch('/api/firewall/state'), ]); const result = {}; if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {}; else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed')); if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {}; else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed')); if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || []; else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed')); if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || []; else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed')); if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {}; return result; }, }); modelRegister('network', { subsystem: 'networkd', fetch: async () => { const r = await apiFetch('/api/network/interfaces'); if (!r.ok) throw new Error(r.error); return r.data || { interfaces: {} }; }, }); modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: async () => { const [cfg, status, leases] = await Promise.allSettled([ apiFetch('/api/dhcp/config'), apiFetch('/api/dhcp/status'), apiFetch('/api/dhcp/leases'), ]); const result = {}; if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {}; else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed')); if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {}; else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed')); if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || []; else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed')); return result; }, }); modelRegister('nginx', { subsystem: 'nginx', fetch: async () => { const r = await apiFetch('/api/proxy/domains'); if (!r.ok) throw new Error(r.error); return { domains: r.data || [] }; }, }); modelRegister('backends', { subsystem: 'nginx', fetch: async () => { const r = await apiFetch('/api/proxy/backends'); if (!r.ok) throw new Error(r.error); return r.data || {}; }, }); modelRegister('acme', { subsystem: 'acme', fetch: async () => { const [listR, acctR] = await Promise.allSettled([ apiFetch('/api/certs/list'), apiFetch('/api/certs/account'), ]); const result = {}; if (listR.status === 'fulfilled' && listR.value.ok) { result.certs = listR.value.data || []; } else if (listR.status === 'rejected' || !listR.value.ok) { throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed')); } if (acctR.status === 'fulfilled' && acctR.value.ok) { result.account = acctR.value.data || { registered: false, email: '', ca: '' }; } return result; }, }); modelRegister('wireguard', { subsystem: 'wireguard', fetch: async () => { const [stR, pR, cfgR] = await Promise.allSettled([ apiFetch('/api/wireguard/status'), apiFetch('/api/wireguard/peers'), apiFetch('/api/wireguard/config'), ]); const result = {}; if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {}; else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed')); if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || []; else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed')); if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {}; return result; }, }); 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 }; }, }); modelRegister('status', { subsystem: '*', fetch: async () => { const [pendingR, metricsR] = await Promise.allSettled([ apiFetch('/api/status/pending'), apiFetch('/api/status/system-metrics'), ]); const result = {}; if (pendingR.status === 'fulfilled' && pendingR.value.ok) { result.pending = pendingR.value.data || {}; } if (metricsR.status === 'fulfilled' && metricsR.value.ok) { result.metrics = metricsR.value.data || {}; } return result; }, }); /* ── Initial fetch (after auth check) ───────────────────────── */ function fetchInitialData() { for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) { modelFetch(name); } 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' }), isAuthenticated: false, component() { const name = this.state.path.replace(/^\//, ''); const page = Pages[name] || NotFoundPage; return hComp(page, this.state.path); }, }; window.location.hash || (window.location.hash = router.state.path); window.addEventListener('hashchange', () => { router.state.path = location.hash.slice(1) || '/dashboard'; }); /* ── Sidebar render root ───────────────────────────────────── */ function Sidebar() { 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() { const sidebarEl = document.getElementById('sidebar'); const mainEl = document.getElementById('main'); if (sidebarEl && mainEl) { render(sidebarEl, Sidebar); render(mainEl, MainContent); } // Listen for login events to update router state after auth window.addEventListener('auth:login', () => { router.isAuthenticated = true; if (!router.state.path.startsWith('/login')) { fetchInitialData(); } }); // Check auth state before connecting WS const ok = await initAuth(); if (ok) { router.isAuthenticated = true; fetchInitialData(); setTimeout(connect, 0); } else { // No valid session — redirect to login if (router.state.path !== '/login') { window.location.hash = '/login'; } } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initApp); } else { initApp(); }