diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index 72fe70d51..50bfa28ec 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -25,13 +25,19 @@ import type { LogEntry, ConnectionStage, } from "../../../types/connection-log.js"; -import { collectCpuMetrics } from "./widgets/cpu-collector.js"; +import { + collectCpuMetrics, + clearCpuSampleCache, +} from "./widgets/cpu-collector.js"; import { collectMemoryMetrics } from "./widgets/memory-collector.js"; import { collectDiskMetrics, type DiskFilesystem, } from "./widgets/disk-collector.js"; -import { collectNetworkMetrics } from "./widgets/network-collector.js"; +import { + collectNetworkMetrics, + clearNetworkSampleCache, +} from "./widgets/network-collector.js"; import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; import { collectProcessesMetrics } from "./widgets/processes-collector.js"; import { collectSystemMetrics } from "./widgets/system-collector.js"; @@ -515,6 +521,8 @@ class PollingManager { this.syncPollConcurrency(); this.statusStore.delete(host.id); this.metricsStore.delete(host.id); + clearCpuSampleCache(host.id); + clearNetworkSampleCache(host.id); return; } @@ -848,6 +856,8 @@ class PollingManager { if (clearData) { this.statusStore.delete(hostId); this.metricsStore.delete(hostId); + clearCpuSampleCache(hostId); + clearNetworkSampleCache(hostId); } } hostPollCache.invalidate(hostId); @@ -917,6 +927,8 @@ class PollingManager { if (!currentHostIds.has(hostId)) { this.statusStore.delete(hostId); this.metricsStore.delete(hostId); + clearCpuSampleCache(hostId); + clearNetworkSampleCache(hostId); } } @@ -1771,7 +1783,7 @@ async function collectMetrics( const collectFn = async (client: Client) => { onAuthenticated?.(); const platform = await detectPlatform(client); - const cpu = await collectCpuMetrics(client, platform); + const cpu = await collectCpuMetrics(client, platform, host.id); const memory = await collectMemoryMetrics(client, platform); const disk = await collectDiskMetrics( client, @@ -1779,7 +1791,7 @@ async function collectMetrics( monitoredMounts, platform, ); - const network = await collectNetworkMetrics(client, platform); + const network = await collectNetworkMetrics(client, platform, host.id); const uptime = await collectUptimeMetrics(client, platform); const processes = await collectProcessesMetrics(client); const system = await collectSystemMetrics(client, platform); diff --git a/src/backend/hosts/metrics/widgets/cpu-collector.ts b/src/backend/hosts/metrics/widgets/cpu-collector.ts index 83d523c2d..70d35fcd4 100644 --- a/src/backend/hosts/metrics/widgets/cpu-collector.ts +++ b/src/backend/hosts/metrics/widgets/cpu-collector.ts @@ -6,6 +6,24 @@ import { type HostPlatform, } from "./common-utils.js"; +/** + * CPU percent is derived from the delta against the previous poll's + * /proc/stat sample rather than a same-poll double-read, so a poll no longer + * has to block for a fixed settle window. Keyed by host. + */ +const previousStat = new Map< + number, + { total: number; idle: number; timestamp: number } +>(); + +export function clearCpuSampleCache(hostId?: number): void { + if (hostId === undefined) { + previousStat.clear(); + return; + } + previousStat.delete(hostId); +} + export function parseCpuLine( cpuLine: string, ): { total: number; idle: number } | undefined { @@ -130,6 +148,7 @@ async function collectWindowsCpuMetrics(client: Client): Promise<{ export async function collectCpuMetrics( client: Client, platform?: HostPlatform, + hostId?: number, ): Promise<{ percent: number | null; cores: number | null; @@ -147,7 +166,8 @@ export async function collectCpuMetrics( let loadTriplet: [number, number, number] | null = null; try { - const [stat1, loadAvgOut, coresOut] = await Promise.race([ + const readAt = Date.now(); + const [statOut, loadAvgOut, coresOut] = await Promise.race([ Promise.all([ execCommand(client, "cat /proc/stat"), execCommand(client, "cat /proc/loadavg"), @@ -164,24 +184,22 @@ export async function collectCpuMetrics( ), ]); - await new Promise((r) => setTimeout(r, 500)); - const stat2 = await execCommand(client, "cat /proc/stat"); - - const cpuLine1 = ( - stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" + const cpuLine = ( + statOut.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" ).trim(); - const cpuLine2 = ( - stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" - ).trim(); - const a = parseCpuLine(cpuLine1); - const b = parseCpuLine(cpuLine2); - if (a && b) { - const totalDiff = b.total - a.total; - const idleDiff = b.idle - a.idle; + const current = parseCpuLine(cpuLine); + const previous = + hostId !== undefined ? previousStat.get(hostId) : undefined; + if (current && previous) { + const totalDiff = current.total - previous.total; + const idleDiff = current.idle - previous.idle; const used = totalDiff - idleDiff; if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); } + if (current && hostId !== undefined) { + previousStat.set(hostId, { ...current, timestamp: readAt }); + } const laParts = loadAvgOut.stdout.trim().split(/\s+/); if (laParts.length >= 3) { diff --git a/src/backend/hosts/metrics/widgets/network-collector.ts b/src/backend/hosts/metrics/widgets/network-collector.ts index d2b4a425c..7fd0d1170 100644 --- a/src/backend/hosts/metrics/widgets/network-collector.ts +++ b/src/backend/hosts/metrics/widgets/network-collector.ts @@ -44,6 +44,31 @@ export function counterRate( return Math.round((second - first) / elapsedSeconds); } +/** + * Rate is derived from the previous poll's counters rather than a same-poll + * double-sample, so a poll no longer has to block for a fixed settle window. + * Keyed by host, since counters from one host are meaningless against another. + */ +const previousSamples = new Map< + number, + { counters: Map; timestamp: number } +>(); + +const previousWindowsSamples = new Map< + number, + { rows: WindowsAdapterRow[]; timestamp: number } +>(); + +export function clearNetworkSampleCache(hostId?: number): void { + if (hostId === undefined) { + previousSamples.clear(); + previousWindowsSamples.clear(); + return; + } + previousSamples.delete(hostId); + previousWindowsSamples.delete(hostId); +} + export function parseDarwinIfconfig( output: string, ): Map { @@ -85,7 +110,10 @@ export function parseDarwinNetstat( return counters; } -async function collectDarwinNetworkMetrics(client: Client): Promise<{ +async function collectDarwinNetworkMetrics( + client: Client, + hostId?: number, +): Promise<{ interfaces: Array<{ name: string; ip: string; @@ -111,31 +139,35 @@ async function collectDarwinNetworkMetrics(client: Client): Promise<{ const ifMap = parseDarwinIfconfig(ifconfigOut.stdout); try { - const firstReadAt = Date.now(); - const netstat1 = await execCommand(client, "netstat -ib 2>/dev/null"); - await new Promise((resolve) => setTimeout(resolve, 500)); - const netstat2 = await execCommand(client, "netstat -ib 2>/dev/null"); - const elapsedSeconds = (Date.now() - firstReadAt) / 1000; - const before = parseDarwinNetstat(netstat1.stdout); - const after = parseDarwinNetstat(netstat2.stdout); + const readAt = Date.now(); + const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null"); + const current = parseDarwinNetstat(netstatOut.stdout); + const previous = + hostId !== undefined ? previousSamples.get(hostId) : undefined; + const elapsedSeconds = previous + ? (readAt - previous.timestamp) / 1000 + : 0; if (ifMap.size === 0) { - for (const name of before.keys()) { + for (const name of current.keys()) { ifMap.set(name, { ip: "", state: "UNKNOWN" }); } } for (const [name, data] of ifMap.entries()) { - const b = before.get(name); - const a = after.get(name); + const c = current.get(name); + const p = previous?.counters.get(name); interfaces.push({ name, ip: data.ip, state: data.state, - rxBytes: b?.rx ?? null, - txBytes: b?.tx ?? null, - rxRateBps: counterRate(b?.rx, a?.rx, elapsedSeconds), - txRateBps: counterRate(b?.tx, a?.tx, elapsedSeconds), + rxBytes: c?.rx ?? null, + txBytes: c?.tx ?? null, + rxRateBps: p ? counterRate(p.rx, c?.rx, elapsedSeconds) : null, + txRateBps: p ? counterRate(p.tx, c?.tx, elapsedSeconds) : null, }); } + if (hostId !== undefined) { + previousSamples.set(hostId, { counters: current, timestamp: readAt }); + } } catch { for (const [name, data] of ifMap.entries()) { interfaces.push({ @@ -191,7 +223,10 @@ export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] { } } -async function collectWindowsNetworkMetrics(client: Client): Promise<{ +async function collectWindowsNetworkMetrics( + client: Client, + hostId?: number, +): Promise<{ interfaces: Array<{ name: string; ip: string; @@ -213,27 +248,37 @@ async function collectWindowsNetworkMetrics(client: Client): Promise<{ }> = []; try { - const firstReadAt = Date.now(); - const before = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT); - await new Promise((resolve) => setTimeout(resolve, 500)); - const after = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT); - const elapsedSeconds = (Date.now() - firstReadAt) / 1000; - - const beforeRows = parseWindowsAdapterJson(before.stdout); - const afterMap = new Map( - parseWindowsAdapterJson(after.stdout).map((row) => [row.name, row]), + const readAt = Date.now(); + const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT); + const currentRows = parseWindowsAdapterJson(result.stdout); + const previous = + hostId !== undefined ? previousWindowsSamples.get(hostId) : undefined; + const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0; + const previousMap = new Map( + (previous?.rows ?? []).map((row) => [row.name, row]), ); - for (const row of beforeRows) { - const afterRow = afterMap.get(row.name); + for (const row of currentRows) { + const previousRow = previousMap.get(row.name); interfaces.push({ name: row.name, ip: row.ip, state: row.state, rxBytes: row.rx || null, txBytes: row.tx || null, - rxRateBps: counterRate(row.rx, afterRow?.rx, elapsedSeconds), - txRateBps: counterRate(row.tx, afterRow?.tx, elapsedSeconds), + rxRateBps: previousRow + ? counterRate(previousRow.rx, row.rx, elapsedSeconds) + : null, + txRateBps: previousRow + ? counterRate(previousRow.tx, row.tx, elapsedSeconds) + : null, + }); + } + + if (hostId !== undefined) { + previousWindowsSamples.set(hostId, { + rows: currentRows, + timestamp: readAt, }); } } catch { @@ -246,6 +291,7 @@ async function collectWindowsNetworkMetrics(client: Client): Promise<{ export async function collectNetworkMetrics( client: Client, platform?: HostPlatform, + hostId?: number, ): Promise<{ interfaces: Array<{ name: string; @@ -258,10 +304,10 @@ export async function collectNetworkMetrics( }>; }> { if (platform === "darwin") { - return collectDarwinNetworkMetrics(client); + return collectDarwinNetworkMetrics(client, hostId); } if (platform === "windows") { - return collectWindowsNetworkMetrics(client); + return collectWindowsNetworkMetrics(client, hostId); } const interfaces: Array<{ @@ -318,13 +364,14 @@ export async function collectNetworkMetrics( } try { - const firstReadAt = Date.now(); + const readAt = Date.now(); const procNet = await execCommand(client, "cat /proc/net/dev"); - await new Promise((resolve) => setTimeout(resolve, 500)); - const procNetAfter = await execCommand(client, "cat /proc/net/dev"); - const elapsedSeconds = (Date.now() - firstReadAt) / 1000; const rxTxMap = parseNetworkCounters(procNet.stdout); - const afterMap = parseNetworkCounters(procNetAfter.stdout); + const previous = + hostId !== undefined ? previousSamples.get(hostId) : undefined; + const elapsedSeconds = previous + ? (readAt - previous.timestamp) / 1000 + : 0; if (ifMap.size === 0) { for (const name of rxTxMap.keys()) { if (name !== "lo") ifMap.set(name, { ip: "", state: "UNKNOWN" }); @@ -332,15 +379,25 @@ export async function collectNetworkMetrics( } for (const [name, data] of ifMap.entries()) { const rxTx = rxTxMap.get(name); - const after = afterMap.get(name); + const previousCounters = previous?.counters.get(name); interfaces.push({ name, ip: data.ip, state: data.state, rxBytes: rxTx?.rx ?? null, txBytes: rxTx?.tx ?? null, - rxRateBps: counterRate(rxTx?.rx, after?.rx, elapsedSeconds), - txRateBps: counterRate(rxTx?.tx, after?.tx, elapsedSeconds), + rxRateBps: previousCounters + ? counterRate(previousCounters.rx, rxTx?.rx, elapsedSeconds) + : null, + txRateBps: previousCounters + ? counterRate(previousCounters.tx, rxTx?.tx, elapsedSeconds) + : null, + }); + } + if (hostId !== undefined) { + previousSamples.set(hostId, { + counters: rxTxMap, + timestamp: readAt, }); } } catch {