import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, fmtBytes } from '/static/hoover/index.js'; // Render a single firewall change as "current → new". // `live` is the currently applied value; `config` is the target value it // will become on apply. Mirrors lib.firewall.fw_change_summary. function fwChangeLine(c) { const zone = c.zone || 'unknown'; const type = c.type || 'unknown'; const join = (arr) => (Array.isArray(arr) && arr.length ? arr.join(', ') : '∅'); switch (type) { case 'interfaces': return `Zone ${zone}: interfaces ${join(c.live)} → ${join(c.config)}`; case 'services': return `Zone ${zone}: services ${join(c.live)} → ${join(c.config)}`; case 'rich_rules': return `Zone ${zone}: rich rules ${c.live_count ?? 0} → ${c.config_count ?? 0}`; case 'forward_ports': return `Zone ${zone}: port forwards ${c.live_count ?? 0} → ${c.config_count ?? 0}`; case 'masquerade': return `Zone ${zone}: masquerade ${c.live} → ${c.config}`; case 'target': return `Zone ${zone}: target ${c.live ?? 'default'} → ${c.config ?? 'default'}`; default: return `Zone ${zone}: ${type} changed`; } } function fmtVal(v) { if (Array.isArray(v)) return v.length ? v.join(', ') : '∅'; if (v === null || v === undefined) return '∅'; if (typeof v === 'boolean') return v; return v; } // Render a single field-level pending change (applied → current). function diffLine(d) { const val = (x) => ` ${fmtVal(x)}`; if (d.action === 'added') return `${d.path}: added (now${val(d.new)})`; if (d.action === 'removed') return `${d.path}: removed (was${val(d.old)})`; return `${d.path}: ${fmtVal(d.old)} → ${fmtVal(d.new)}`; } export default definePage({ init() { return { firewall: getModel('firewall'), network: getModel('network'), dnsmasq: getModel('dnsmasq'), wireguard: getModel('wireguard'), acme: getModel('acme'), nginx: getModel('nginx'), system: getModel('system'), }; }, render(state) { const guard = renderGuardMulti('Dashboard', 'System overview', state.firewall, state.network, state.dnsmasq, state.wireguard, state.acme, state.nginx, state.system); if (guard) return guard; // Extract data const fwIfaces = Array.isArray(state.firewall.data?.interfaces) ? state.firewall.data.interfaces : []; const fwZones = state.firewall.data?.zones || {}; const netIfaces = state.network.data?.interfaces || {}; const dnsmasqStatus = state.dnsmasq.data?.status || {}; const dnsmasqLeases = state.dnsmasq.data?.leases || []; const activeLeaseCount = dnsmasqStatus.active_leases || dnsmasqLeases.length; const proxyDomains = state.nginx.data?.domains || []; const onlineDomains = proxyDomains ? proxyDomains.filter(d => d.online).length : 0; const offlineDomains = (proxyDomains?.length || 0) - onlineDomains; const wgp = state.wireguard.data?.peers || []; const wgStatus = state.wireguard.data?.status || {}; const allCerts = state.acme.data?.certs || []; const expiringCerts = allCerts.filter(c => c.expired || (c.days_remaining !== undefined && c.days_remaining <= 30)); // System metrics from the system model const sysLoad = state.system.data?.load || {}; const sysMem = state.system.data?.memory || {}; const sysSwap = state.system.data?.swap || {}; const sysTraffic = state.system.data?.traffic || {}; // Pending changes — derived from the config-backed subsystem models. // Firewall uses pending.needs_apply (config_pending() output); all // others use status.pending_changes. `system` is metrics-only. // // Each entry is { label, lines: [string] } so the card can render a // concise per-change summary. Firewall and the hash-based subsystems // both carry real per-field diffs (applied → current). const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' }; const pendingBlocks = []; const fwPending = state.firewall.data?.pending || {}; if (fwPending.needs_apply) { const lines = (fwPending.pending || []).map(fwChangeLine); for (const z of Object.keys(fwPending.unmanaged_zones || {})) { lines.push(`Zone ${z}: unmanaged (not in config)`); } pendingBlocks.push({ label: pendLabels.firewall, lines }); } for (const k of ['dnsmasq', 'nginx', 'wireguard', 'networkd']) { const d = getModel(k === 'networkd' ? 'network' : k).data || {}; if (d.status?.pending_changes) { const diff = d.status.pending_diff; const lines = (Array.isArray(diff) && diff.length) ? diff.map(diffLine) : ['configuration saved but not applied yet']; pendingBlocks.push({ label: pendLabels[k], lines }); } } const totalChanges = pendingBlocks.reduce((n, b) => n + b.lines.length, 0); // Build merged interface list const allNames = [...new Set([...fwIfaces.map(f => f.name), ...Object.keys(netIfaces)])]; const ifaces = allNames.map(name => { const fw = fwIfaces.find(f => f.name === name); const netEntry = netIfaces[name] || {}; const traffic = sysTraffic[name] || {}; const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : []; const addrs = netEntry.addresses || []; const isUp = ['routable', 'degraded', 'carrier'].some(s => (netEntry.state || '').startsWith(s)); return { name, mac: fw?.mac || null, ips: ips.length ? ips : addrs, isUp, zone: fw?.zone || '—', rx: traffic.rx_bytes || 0, tx: traffic.tx_bytes || 0, }; }); // ── Stat cards ── const zoneNames = Object.keys(fwZones); const stats = html`
| CPU Load | ${(sysLoad.load1 || 0).toFixed(2)} / ${(sysLoad.load5 || 0).toFixed(2)} / ${(sysLoad.load15 || 0).toFixed(2)} | 1m / 5m / 15m |
| Memory | <${Badge} text=${Math.round(memPct) + '%'} variant=${memVariant} /> ${fmtBytes(sysMem.used || 0)} / ${fmtBytes(sysMem.total || 0)} | used / total |
| Swap | ${sysSwap.total > 0 ? html`<${Badge} text=${Math.round(swapPct) + '%'} variant=${swapVariant} /> ${fmtBytes(sysSwap.used || 0)} / ${fmtBytes(sysSwap.total)}` : html`Disabled`} | used / total |
Showing 10 of ${dnsmasqLeases.length}.
` : ''}