263 lines
13 KiB
JavaScript
Executable File
263 lines
13 KiB
JavaScript
Executable File
/*
|
|
* SysDeck - Network Monitor Panel (v0.0.43)
|
|
* Author: Jeremy Anderson (https://dcos.net)
|
|
*
|
|
* v0.0.43 REWRITE — IPTRAF-NG STYLE.
|
|
*
|
|
* The v0.0.10-v0.0.43 panel used `ss -tulpn` for a static socket list.
|
|
* v0.0.43 recreates the iptraf-ng UI by reading the same kernel sources
|
|
* iptraf-ng reads from directly — no iptraf-ng binary dependency, no
|
|
* ncurses parsing.
|
|
*
|
|
* The panel has three sections (matching iptraf-ng's views):
|
|
* 1. Interface Overview — live RX/TX rate cards (bytes/s, packets/s)
|
|
* per interface. Auto-refreshes every 5s. The traffic subcommand
|
|
* samples /proc/net/dev twice (1s apart) to compute live rates.
|
|
* 2. IP Traffic Monitor — active TCP/UDP connections table (proto,
|
|
* state, local addr:port, remote addr:port, TX/RX queue). Reads
|
|
* /proc/net/tcp + /proc/net/udp directly.
|
|
* 3. Protocol Statistics — IP/TCP/UDP/ICMP counters from
|
|
* /proc/net/snmp (InReceives, OutRequests, InDiscards, etc.).
|
|
*
|
|
* Data sources (same as iptraf-ng):
|
|
* /proc/net/dev per-interface RX/TX byte + packet counters
|
|
* /proc/net/snmp IP/TCP/UDP/ICMP protocol counters
|
|
* /proc/net/tcp active TCP connections (state, local, remote)
|
|
* /proc/net/udp active UDP sockets
|
|
*
|
|
* The traffic subcommand takes 1 second (samples /proc/net/dev twice,
|
|
* 1s apart) — the panel shows a loading indicator during that window.
|
|
*/
|
|
|
|
export async function mount(panel, { bridge, EventBus }) {
|
|
panel.innerHTML = renderSkeleton();
|
|
|
|
// Load summary + connections + protocols in parallel (traffic is
|
|
// slow — 1s — so we load it separately and show it when ready).
|
|
const [summary, connections, protocols] = await Promise.all([
|
|
safe(bridge.netsec.summary(), { total_interfaces: 0, total_connections: 0 }),
|
|
safe(bridge.netsec.connections(), []),
|
|
safe(bridge.netsec.protocols(), {}),
|
|
]);
|
|
|
|
panel.innerHTML = `
|
|
<header>
|
|
<h2 class="suite-panel-title">Network Monitor</h2>
|
|
<p class="suite-panel-subtitle">
|
|
${summary.total_interfaces || 0} interfaces
|
|
(${summary.interfaces_up || 0} active)
|
|
· ${summary.total_connections || 0} connections
|
|
· ESTABLISHED: ${summary.tcp_established || 0}
|
|
· LISTEN: ${summary.tcp_listen || 0}
|
|
· <button class="suite-btn suite-btn-ghost" id="btn-netsec-refresh" style="font-size:0.75rem;padding:2px 8px">↻ Refresh</button>
|
|
</p>
|
|
</header>
|
|
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Interface Overview (live traffic)</h3>
|
|
<span class="suite-muted suite-mono" id="netsec-traffic-status" style="font-size:0.8rem">sampling…</span>
|
|
</div>
|
|
<div id="netsec-traffic-host">
|
|
<p class="suite-muted" style="padding:1rem">Sampling /proc/net/dev (1s window)…</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">IP Traffic Monitor (${connections.length} connections)</h3>
|
|
</div>
|
|
<div style="max-height:400px;overflow:auto">
|
|
<table class="suite-table">
|
|
<thead>
|
|
<tr><th>Proto</th><th>State</th><th>Local</th><th>Remote</th><th>TxQ</th><th>RxQ</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
${connections.slice(0, 100).map((c) => `
|
|
<tr>
|
|
<td><span class="suite-badge ${c.proto === 'tcp' ? 'info' : ''}">${escapeHtml(c.proto)}</span></td>
|
|
<td><span class="suite-badge ${c.state === 'ESTABLISHED' ? 'success' : c.state === 'LISTEN' ? 'info' : ''}" style="font-size:0.7rem">${escapeHtml(c.state)}</span></td>
|
|
<td class="suite-table-mono">${escapeHtml(c.local_ip)}:${c.local_port}</td>
|
|
<td class="suite-table-mono ${c.remote_ip === '0.0.0.0' ? 'suite-muted' : ''}">${escapeHtml(c.remote_ip)}:${c.remote_port}</td>
|
|
<td class="suite-table-mono suite-muted">${c.tx_queue}</td>
|
|
<td class="suite-table-mono suite-muted">${c.rx_queue}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="6" class="suite-muted">No active connections.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
${connections.length > 100 ? `<p class="suite-muted" style="padding:0.5rem;font-size:0.8rem">Showing first 100 of ${connections.length} connections.</p>` : ''}
|
|
</div>
|
|
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Protocol Statistics</h3>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
${renderProtocolStats(protocols)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Load live traffic data asynchronously (1s sample window).
|
|
loadTrafficData(panel, bridge);
|
|
|
|
wireEvents(panel, { bridge, EventBus });
|
|
EventBus.emit('netsec.loaded', {
|
|
totalInterfaces: summary.total_interfaces,
|
|
totalConnections: summary.total_connections,
|
|
});
|
|
}
|
|
|
|
// ── Traffic data loader (async, 1s sample) ─────────────────────────
|
|
|
|
async function loadTrafficData(panel, bridge) {
|
|
const host = panel.querySelector('#netsec-traffic-host');
|
|
const status = panel.querySelector('#netsec-traffic-status');
|
|
if (!host) return;
|
|
try {
|
|
const traffic = await bridge.netsec.traffic();
|
|
status.textContent = `updated ${new Date().toLocaleTimeString()}`;
|
|
host.innerHTML = renderTrafficCards(traffic);
|
|
} catch (err) {
|
|
status.textContent = 'error';
|
|
host.innerHTML = `<p class="suite-muted" style="padding:1rem">Traffic sample failed: ${escapeHtml(err.message || err)}</p>`;
|
|
}
|
|
}
|
|
|
|
function renderTrafficCards(traffic) {
|
|
if (!traffic || !traffic.length) {
|
|
return '<p class="suite-muted" style="padding:1rem">No network interfaces found.</p>';
|
|
}
|
|
return `
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:0.75rem;padding:0.75rem">
|
|
${traffic.map((t) => {
|
|
const rxKbps = (t.rx_bps / 1024).toFixed(1);
|
|
const txKbps = (t.tx_bps / 1024).toFixed(1);
|
|
const rxMbps = (t.rx_bps / (1024 * 1024)).toFixed(2);
|
|
const txMbps = (t.tx_bps / (1024 * 1024)).toFixed(2);
|
|
const rxRate = t.rx_bps > 1024 * 1024 ? `${rxMbps} Mbps` : `${rxKbps} KB/s`;
|
|
const txRate = t.tx_bps > 1024 * 1024 ? `${txMbps} Mbps` : `${txKbps} KB/s`;
|
|
const rxTotal = t.rx_bytes_total > 1024 * 1024 * 1024
|
|
? `${(t.rx_bytes_total / (1024**3)).toFixed(1)} GB`
|
|
: t.rx_bytes_total > 1024 * 1024
|
|
? `${(t.rx_bytes_total / (1024**2)).toFixed(1)} MB`
|
|
: `${(t.rx_bytes_total / 1024).toFixed(0)} KB`;
|
|
const txTotal = t.tx_bytes_total > 1024 * 1024 * 1024
|
|
? `${(t.tx_bytes_total / (1024**3)).toFixed(1)} GB`
|
|
: t.tx_bytes_total > 1024 * 1024
|
|
? `${(t.tx_bytes_total / (1024**2)).toFixed(1)} MB`
|
|
: `${(t.tx_bytes_total / 1024).toFixed(0)} KB`;
|
|
// Bar width relative to 1 Mbps max for visual scaling.
|
|
const maxBps = 1024 * 1024;
|
|
const rxBarW = Math.min(100, (t.rx_bps / maxBps) * 100);
|
|
const txBarW = Math.min(100, (t.tx_bps / maxBps) * 100);
|
|
return `
|
|
<div style="border:1px solid var(--sysdeck-border);border-radius:6px;padding:0.75rem">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
|
<strong class="suite-mono">${escapeHtml(t.iface)}</strong>
|
|
${(t.rx_errs_total + t.tx_errs_total + t.rx_drop_total + t.tx_drop_total) > 0
|
|
? '<span class="suite-badge danger" style="font-size:0.7rem">errors</span>'
|
|
: '<span class="suite-badge success" style="font-size:0.7rem">clean</span>'}
|
|
</div>
|
|
<div style="margin-bottom:0.4rem">
|
|
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.15rem">
|
|
<span class="suite-muted">↓ RX</span>
|
|
<span class="suite-mono"><strong>${rxRate}</strong> · ${t.rx_pps} pps · total ${rxTotal}</span>
|
|
</div>
|
|
<div class="suite-progress-bar" style="height:4px">
|
|
<div class="suite-progress-fill" style="width:${rxBarW}%;background:var(--sysdeck-accent)"></div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.15rem">
|
|
<span class="suite-muted">↑ TX</span>
|
|
<span class="suite-mono"><strong>${txRate}</strong> · ${t.tx_pps} pps · total ${txTotal}</span>
|
|
</div>
|
|
<div class="suite-progress-bar" style="height:4px">
|
|
<div class="suite-progress-fill" style="width:${txBarW}%;background:var(--sysdeck-accent-success, #28a745)"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderProtocolStats(protocols) {
|
|
if (!protocols || protocols.error) {
|
|
return '<p class="suite-muted">Protocol statistics unavailable.</p>';
|
|
}
|
|
const protos = ['ip', 'tcp', 'udp', 'icmp', 'icmp6'];
|
|
const cards = protos.filter((p) => protocols[p]).map((p) => {
|
|
const stats = protocols[p];
|
|
const fields = Object.entries(stats).slice(0, 12).map(([k, v]) =>
|
|
`<tr><td class="suite-muted">${escapeHtml(k)}</td><td class="suite-table-mono">${escapeHtml(String(v))}</td></tr>`
|
|
).join('');
|
|
return `
|
|
<div style="margin-bottom:1rem">
|
|
<h4 class="suite-mono" style="text-transform:uppercase;color:var(--sysdeck-accent);margin-bottom:0.25rem">${escapeHtml(p)}</h4>
|
|
<table class="suite-table" style="font-size:0.85rem">
|
|
<tbody>${fields}</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
return cards || '<p class="suite-muted">No protocol data.</p>';
|
|
}
|
|
|
|
// ── Event wiring ────────────────────────────────────────────────────
|
|
|
|
function wireEvents(panel, { bridge, EventBus }) {
|
|
panel.querySelector('#btn-netsec-refresh')?.addEventListener('click', () => {
|
|
mount(panel, { bridge, EventBus });
|
|
});
|
|
|
|
// Auto-refresh traffic every 5 seconds (the traffic subcommand
|
|
// itself takes 1s to sample, so 5s gives a good balance between
|
|
// freshness and load).
|
|
if (panel._netsecTrafficInterval) clearInterval(panel._netsecTrafficInterval);
|
|
panel._netsecTrafficInterval = setInterval(() => {
|
|
loadTrafficData(panel, bridge);
|
|
}, 5000);
|
|
|
|
// Clean up interval when the panel is unmounted.
|
|
// Cockpit doesn't have a native unmount hook, but we can detect
|
|
// when the panel's DOM is removed via MutationObserver.
|
|
const observer = new MutationObserver(() => {
|
|
if (!document.body.contains(panel)) {
|
|
clearInterval(panel._netsecTrafficInterval);
|
|
observer.disconnect();
|
|
}
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
|
|
// ── Utilities ───────────────────────────────────────────────────────
|
|
|
|
async function safe(p, fallback) {
|
|
try {
|
|
const v = await p;
|
|
return v ?? fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function renderSkeleton() {
|
|
return `<div class="suite-skeleton">
|
|
<div class="suite-skeleton-line w-1/3"></div>
|
|
<div class="suite-skeleton-line w-2/3"></div>
|
|
<div class="suite-skeleton-line w-1/2"></div>
|
|
</div>`;
|
|
}
|