30b51ad7d3
- lib/common: stamp_applied() now records a _last_applied_config snapshot alongside the hash; strip_apply_meta() centralizes bookkeeping-key stripping; deep_diff() reports field-level changes - state collectors (dnsmasq/nginx/wireguard/networkd) expose pending_diff so the dashboard can show exactly which fields changed since the last apply (wireguard diff excludes private_key paths) - dashboard pending-changes card renders per-change lines with a generic fallback when no snapshot is recorded - firewall: firewalld built-in zones no longer flagged as unmanaged; public-zone masquerade skipped in pending changes since apply drives it via nftables propagation - schema: PendingChange TypedDict; pending_diff on DnsmasqStatus / WgStatus; tests in test_common.py, test_firewall.py, test_state.py
285 lines
14 KiB
JavaScript
285 lines
14 KiB
JavaScript
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`<div class="grid grid-4">
|
|
<${StatCard} label="Active Zones" value=${zoneNames.length}
|
|
meta=${zoneNames.join(', ') || 'None'} />
|
|
<${StatCard} label="DHCP Leases" value=${activeLeaseCount}
|
|
meta=${dnsmasqLeases.slice(0, 3).map(l => l.hostname || l.mac).join(', ') || 'None'} />
|
|
<${StatCard} label="Proxy Domains" value=${onlineDomains + '/' + (proxyDomains?.length || 0)}
|
|
meta=${offlineDomains + ' offline'} />
|
|
<${StatCard} label="Certificates" value=${allCerts.length}
|
|
meta=${expiringCerts.length ? expiringCerts.length + ' expiring' : 'All valid'} />
|
|
</div>`;
|
|
|
|
// ── Pending changes ──
|
|
const pendingCard = pendingBlocks.length > 0
|
|
? html`<div class="card">
|
|
<div class="card-header">Pending Changes <span style="margin-left:8px"><${Badge} text=${String(totalChanges)} variant="warning" /></span></div>
|
|
<div class="card-body">
|
|
<ul class="pending-list">
|
|
${pendingBlocks.map(b => html`<li>
|
|
<strong>${b.label}</strong>
|
|
<ul>
|
|
${b.lines.map(line => html`<li class="text-sm">${line}</li>`)}
|
|
</ul>
|
|
</li>`)}
|
|
</ul>
|
|
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
|
successMsg="All changes applied"
|
|
cls="btn btn-sm btn-primary" />
|
|
</div>
|
|
</div>`
|
|
: html`<div class="card">
|
|
<div class="card-header">Pending Changes</div>
|
|
<div class="card-body"><${Badge} text="All configured" variant="success" /></div>
|
|
</div>`;
|
|
|
|
// ── System resources ──
|
|
const memPct = sysMem.used_pct || 0;
|
|
const memVariant = memPct > 80 ? 'danger' : memPct > 60 ? 'warning' : 'success';
|
|
const swapPct = sysSwap.used_pct || 0;
|
|
const swapVariant = swapPct > 50 ? 'warning' : 'success';
|
|
const systemCard = html`<div class="card">
|
|
<div class="card-header">System Resources</div>
|
|
<div class="card-body">
|
|
<table class="table"><tbody>
|
|
<tr>
|
|
<td style="width:120px"><strong>CPU Load</strong></td>
|
|
<td>${(sysLoad.load1 || 0).toFixed(2)} / ${(sysLoad.load5 || 0).toFixed(2)} / ${(sysLoad.load15 || 0).toFixed(2)}</td>
|
|
<td class="text-right text-muted" style="width:100px">1m / 5m / 15m</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Memory</strong></td>
|
|
<td><${Badge} text=${Math.round(memPct) + '%'} variant=${memVariant} />
|
|
${fmtBytes(sysMem.used || 0)} / ${fmtBytes(sysMem.total || 0)}</td>
|
|
<td class="text-right text-muted">used / total</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Swap</strong></td>
|
|
<td>${sysSwap.total > 0
|
|
? html`<${Badge} text=${Math.round(swapPct) + '%'} variant=${swapVariant} />
|
|
${fmtBytes(sysSwap.used || 0)} / ${fmtBytes(sysSwap.total)}`
|
|
: html`<span class="text-muted">Disabled</span>`}</td>
|
|
<td class="text-right text-muted">used / total</td>
|
|
</tr>
|
|
</tbody></table>
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── Interface table ──
|
|
const ifaceRows = ifaces.map(i => html`<tr key=${i.name}>
|
|
<td><strong>${i.name}</strong></td>
|
|
<td class="text-sm text-muted">${i.mac || '—'}</td>
|
|
<td class="text-sm">${i.ips.join(', ') || '—'}</td>
|
|
<td>${i.zone !== '—' ? html`<${Badge} text=${i.zone} variant="info" />` : html`<span class="text-muted">—</span>`}</td>
|
|
<td><${ServiceStatus} state=${i.isUp ? 'up' : 'down'} label="" /></td>
|
|
<td class="text-sm">${fmtBytes(i.rx)}</td>
|
|
<td class="text-sm">${fmtBytes(i.tx)}</td>
|
|
</tr>`);
|
|
const interfacesCard = html`<div class="card">
|
|
<div class="card-header">Network Interfaces</div>
|
|
<div class="card-body">
|
|
<${Table}
|
|
columns=${['Name', 'MAC', 'IP Addresses', 'Zone', 'State', 'RX', 'TX']}
|
|
rows=${ifaceRows} emptyText="No interfaces found" />
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── DHCP leases ──
|
|
const leaseRows = dnsmasqLeases.slice(0, 10).map(l => html`<tr key=${l.mac + l.ip}>
|
|
<td class="text-sm">${l.ip}</td>
|
|
<td class="text-sm text-muted">${l.mac}</td>
|
|
<td class="text-sm">${l.hostname || '—'}</td>
|
|
<td class="text-sm text-muted">${l.interface || '—'}</td>
|
|
</tr>`);
|
|
const dhcpCard = html`<div class="card">
|
|
<div class="card-header">Active DHCP Leases (${activeLeaseCount})</div>
|
|
<div class="card-body">
|
|
<${Table}
|
|
columns=${['IP', 'MAC', 'Hostname', 'Interface']}
|
|
rows=${leaseRows} emptyText="No active leases" />
|
|
${dnsmasqLeases.length > 10 ? html`<p class="text-muted text-sm">Showing 10 of ${dnsmasqLeases.length}.</p>` : ''}
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── Proxy domains ──
|
|
const proxyCard = html`<div class="card">
|
|
<div class="card-header">Proxy Domains</div>
|
|
<div class="card-body">
|
|
<div style="display:flex;gap:16px;margin-bottom:8px">
|
|
<${Badge} text=${onlineDomains} variant="success" /> Online
|
|
<${Badge} text=${offlineDomains} variant=${offlineDomains > 0 ? 'danger' : 'info'} /> Offline
|
|
</div>
|
|
${proxyDomains.slice(0, 3).map(d => html`<div class="text-sm">
|
|
<strong>${d.domain}</strong> → ${d.backend_name || '—'}
|
|
<span class="text-muted">${d.path || '/'}</span>
|
|
</div>`)}
|
|
${proxyDomains.length === 0 ? html`<span class="text-muted text-sm">No proxy domains configured.</span>` : ''}
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── Services ──
|
|
const upCount = ifaces.filter(i => i.isUp).length;
|
|
const services = html`<div class="card">
|
|
<div class="card-header">Services</div>
|
|
<div class="card-body">
|
|
<ul class="service-list">
|
|
<li><${ServiceStatus} state=${dnsmasqStatus.service_active ? 'up' : 'down'} label="Dnsmasq" /></li>
|
|
<li><${ServiceStatus} state=${wgStatus.up ? 'up' : 'down'} label="WireGuard" />
|
|
<span class="text-muted text-sm">${wgp.length} peers</span></li>
|
|
<li><${ServiceStatus} state=${upCount > 0 ? 'up' : 'down'} label="Network" />
|
|
<span class="text-muted text-sm">${upCount}/${ifaces.length} interfaces online</span></li>
|
|
</ul>
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── Side-by-side layout ──
|
|
const dualCards = html`<div class="grid grid-2">
|
|
${dhcpCard}
|
|
${proxyCard}
|
|
</div>`;
|
|
|
|
return [
|
|
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
|
stats,
|
|
pendingCard,
|
|
systemCard,
|
|
interfacesCard,
|
|
dualCards,
|
|
services,
|
|
];
|
|
},
|
|
}); |