diff --git a/frontend/src/components/dashboard/__tests__/useDashboardData.netHistory.test.ts b/frontend/src/components/dashboard/__tests__/useDashboardData.netHistory.test.ts new file mode 100644 index 00000000..a0c796c9 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/useDashboardData.netHistory.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { buildNetHistory } from '../useDashboardData'; +import type { MetricPoint } from '../types'; + +const WINDOW_MS = 10 * 60 * 1000; +const BUCKETS = 20; +const BUCKET_MS = WINDOW_MS / BUCKETS; +const BYTES_PER_MB = 1024 * 1024; + +const point = (over: Partial): MetricPoint => ({ + container_id: 'c1', + stack_name: 'web', + timestamp: 0, + cpu_percent: 0, + memory_mb: 0, + net_rx_mb: 0, + net_tx_mb: 0, + ...over, +}); + +describe('buildNetHistory', () => { + const historyEndAt = 1_000_000; + const start = historyEndAt - WINDOW_MS; + + it('returns zero-filled buckets when metrics are empty', () => { + expect(buildNetHistory([], historyEndAt, WINDOW_MS, BUCKETS)).toEqual(Array(BUCKETS).fill(0)); + }); + + it('returns zero-filled buckets when historyEndAt is null', () => { + expect(buildNetHistory([point({ timestamp: historyEndAt, net_rx_mb: 1 })], null, WINDOW_MS, BUCKETS)) + .toEqual(Array(BUCKETS).fill(0)); + }); + + it('converts aggregate MB/s to bytes/s for a single container', () => { + const ts = start + BUCKET_MS; + const result = buildNetHistory( + [point({ timestamp: ts, net_rx_mb: 0.5, net_tx_mb: 0.5 })], + historyEndAt, + WINDOW_MS, + BUCKETS, + ); + const idx = Math.floor((ts - start) / BUCKET_MS); + expect(result[idx]).toBeCloseTo(BYTES_PER_MB, 0); + }); + + it('sums two containers at the same timestamp before bucketing', () => { + const ts = start + BUCKET_MS; + const result = buildNetHistory( + [ + point({ container_id: 'a', timestamp: ts, net_rx_mb: 1, net_tx_mb: 0 }), + point({ container_id: 'b', timestamp: ts, net_rx_mb: 1, net_tx_mb: 0 }), + ], + historyEndAt, + WINDOW_MS, + BUCKETS, + ); + const idx = Math.floor((ts - start) / BUCKET_MS); + expect(result[idx]).toBeCloseTo(2 * BYTES_PER_MB, 0); + }); + + it('averages multiple timestamp aggregates within the same spark bucket', () => { + const bucketStart = start + BUCKET_MS; + const ts1 = bucketStart + 1_000; + const ts2 = bucketStart + 2_000; + const result = buildNetHistory( + [ + point({ timestamp: ts1, net_rx_mb: 1, net_tx_mb: 0 }), + point({ timestamp: ts2, net_rx_mb: 3, net_tx_mb: 0 }), + ], + historyEndAt, + WINDOW_MS, + BUCKETS, + ); + const idx = Math.floor((ts1 - start) / BUCKET_MS); + expect(result[idx]).toBeCloseTo(2 * BYTES_PER_MB, 0); + }); + + it('produces non-zero values for steady traffic instead of delta noise near zero', () => { + const ts1 = start + BUCKET_MS; + const ts2 = start + 2 * BUCKET_MS; + const result = buildNetHistory( + [ + point({ timestamp: ts1, net_rx_mb: 1, net_tx_mb: 0 }), + point({ timestamp: ts2, net_rx_mb: 1, net_tx_mb: 0 }), + ], + historyEndAt, + WINDOW_MS, + BUCKETS, + ); + const idx1 = Math.floor((ts1 - start) / BUCKET_MS); + const idx2 = Math.floor((ts2 - start) / BUCKET_MS); + expect(result[idx1]).toBeCloseTo(BYTES_PER_MB, 0); + expect(result[idx2]).toBeCloseTo(BYTES_PER_MB, 0); + }); + + it('forward-fills empty buckets from the previous observed bucket', () => { + const ts = start + 3 * BUCKET_MS; + const result = buildNetHistory( + [point({ timestamp: ts, net_rx_mb: 2, net_tx_mb: 0 })], + historyEndAt, + WINDOW_MS, + BUCKETS, + ); + const idx = Math.floor((ts - start) / BUCKET_MS); + expect(result[idx - 1]).toBe(0); + expect(result[idx]).toBeCloseTo(2 * BYTES_PER_MB, 0); + expect(result[idx + 1]).toBeCloseTo(2 * BYTES_PER_MB, 0); + }); + + it('resets forward-fill to zero after an explicit zero sample', () => { + const tsPositive = start + BUCKET_MS; + const tsZero = start + 3 * BUCKET_MS; + const result = buildNetHistory( + [ + point({ timestamp: tsPositive, net_rx_mb: 2, net_tx_mb: 0 }), + point({ timestamp: tsZero, net_rx_mb: 0, net_tx_mb: 0 }), + ], + historyEndAt, + WINDOW_MS, + BUCKETS, + ); + const positiveIdx = Math.floor((tsPositive - start) / BUCKET_MS); + const zeroIdx = Math.floor((tsZero - start) / BUCKET_MS); + expect(result[positiveIdx]).toBeCloseTo(2 * BYTES_PER_MB, 0); + expect(result[zeroIdx]).toBe(0); + expect(result[zeroIdx + 1]).toBe(0); + }); + + it('excludes rows before the spark window', () => { + const ts = start - 1; + const result = buildNetHistory( + [point({ timestamp: ts, net_rx_mb: 99, net_tx_mb: 99 })], + historyEndAt, + WINDOW_MS, + BUCKETS, + ); + expect(result.every((v) => v === 0)).toBe(true); + }); +}); diff --git a/frontend/src/components/dashboard/useDashboardData.ts b/frontend/src/components/dashboard/useDashboardData.ts index 2e6345f3..b0e0dbf5 100644 --- a/frontend/src/components/dashboard/useDashboardData.ts +++ b/frontend/src/components/dashboard/useDashboardData.ts @@ -14,6 +14,7 @@ import type { const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 }; const SPARK_BUCKETS = 20; const SPARK_WINDOW_MS = 10 * 60 * 1000; +const BYTES_PER_MB = 1024 * 1024; // Trailing-edge debounce window for live state-invalidate refetches. Matches // useNextAutoUpdateRun so dashboard surfaces feel "live" without amplifying a // container-event burst into one HTTP request per event. @@ -45,6 +46,42 @@ function bucketCpu(points: MetricPoint[], windowMs: number, buckets: number): nu return out; } +// Historical rows from /metrics/historical carry net_rx_mb / net_tx_mb as MB/s rates +// (legacy field names), not cumulative megabytes. Aggregate per timestamp, bucket, +// and emit bytes/s so the sparkline matches the live NETWORK headline units. +export function buildNetHistory( + metrics: MetricPoint[], + historyEndAt: number | null, + windowMs: number, + buckets: number, +): number[] { + if (metrics.length === 0 || historyEndAt === null) return Array(buckets).fill(0); + + const start = historyEndAt - windowMs; + const bucketMs = windowMs / buckets; + const bucketSum = Array(buckets).fill(0); + const bucketTimestamps = Array.from({ length: buckets }, () => new Set()); + + for (const p of metrics) { + if (p.timestamp < start) continue; + const idx = Math.min(buckets - 1, Math.max(0, Math.floor((p.timestamp - start) / bucketMs))); + bucketSum[idx] += (p.net_rx_mb + p.net_tx_mb) * BYTES_PER_MB; + bucketTimestamps[idx].add(p.timestamp); + } + + let last = 0; + for (let i = 0; i < buckets; i += 1) { + const tsCount = bucketTimestamps[i].size; + if (tsCount > 0) { + bucketSum[i] /= tsCount; + last = bucketSum[i]; + } else { + bucketSum[i] = last; + } + } + return bucketSum; +} + // After three consecutive failures of the live metrics endpoints, surface a // "metrics stale" indicator so the operator knows the gauges are no longer // being refreshed (the Docker socket or the metrics service is unreachable) @@ -290,36 +327,10 @@ export function useDashboardData(): DashboardData { return out; }, [metrics, cores, historyEndAt]); - // Network throughput over time: compute per-container deltas between - // consecutive samples, assign each delta to the bucket of the later sample, - // and sum across containers. This is robust to container churn because each - // delta is paired within a single container's lifeline. Negative deltas - // (counter reset after a restart) clamp to zero. - const netHistory = useMemo(() => { - if (metrics.length === 0 || historyEndAt === null) return Array(SPARK_BUCKETS).fill(0); - const start = historyEndAt - SPARK_WINDOW_MS; - const bucketMs = SPARK_WINDOW_MS / SPARK_BUCKETS; - const byContainer = new Map(); - for (const p of metrics) { - const bucket = byContainer.get(p.container_id) ?? []; - bucket.push(p); - byContainer.set(p.container_id, bucket); - } - const out = Array(SPARK_BUCKETS).fill(0); - for (const samples of byContainer.values()) { - samples.sort((a, b) => a.timestamp - b.timestamp); - for (let i = 1; i < samples.length; i += 1) { - const curr = samples[i]; - if (curr.timestamp < start) continue; - const prev = samples[i - 1]; - const delta = (curr.net_rx_mb + curr.net_tx_mb) - (prev.net_rx_mb + prev.net_tx_mb); - if (delta <= 0) continue; - const idx = Math.min(SPARK_BUCKETS - 1, Math.max(0, Math.floor((curr.timestamp - start) / bucketMs))); - out[idx] += delta; - } - } - return out; - }, [metrics, historyEndAt]); + const netHistory = useMemo( + () => buildNetHistory(metrics, historyEndAt, SPARK_WINDOW_MS, SPARK_BUCKETS), + [metrics, historyEndAt], + ); return { stats,