ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
223 lines
11 KiB
JavaScript
223 lines
11 KiB
JavaScript
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton } from '/static/hoover/index.js?v=11';
|
|
|
|
function fmtBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return (bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + sizes[i];
|
|
}
|
|
|
|
export default definePage({
|
|
init() {
|
|
return {
|
|
firewall: getModel('firewall'),
|
|
network: getModel('network'),
|
|
dnsmasq: getModel('dnsmasq'),
|
|
wireguard: getModel('wireguard'),
|
|
acme: getModel('acme'),
|
|
nginx: getModel('nginx'),
|
|
status: getModel('status'),
|
|
};
|
|
},
|
|
render(state) {
|
|
const guard = renderGuardMulti('Dashboard', 'System overview',
|
|
state.firewall, state.network, state.dnsmasq, state.wireguard,
|
|
state.acme, state.nginx, state.status);
|
|
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 status model
|
|
const sysMetrics = state.status.data?.metrics || {};
|
|
const sysLoad = sysMetrics.load || {};
|
|
const sysMem = sysMetrics.memory || {};
|
|
const sysSwap = sysMetrics.swap || {};
|
|
const sysTraffic = sysMetrics.traffic || {};
|
|
|
|
// Pending changes
|
|
const pend = state.status.data?.pending || {};
|
|
const totalChanges = pend.total_changes || 0;
|
|
const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k =>
|
|
k === 'firewall' ? (pend[k]?.needs_apply) : (pend[k]?.pending_changes)
|
|
);
|
|
const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' };
|
|
|
|
// 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'].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 = pendKeys.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">
|
|
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
|
|
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
|
successMsg="All changes applied" refresh="status"
|
|
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,
|
|
];
|
|
},
|
|
}); |