mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
fix: normalize dashboard network sparkline to bytes per second (#1589)
Historical net_rx_mb/net_tx_mb values are MB/s rates, not cumulative bytes. Replace delta bucketing with per-bucket aggregate averaging converted to bytes/s so the NETWORK sparkline matches the live headline units.
This commit is contained in:
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<number>(buckets).fill(0);
|
||||
const bucketTimestamps = Array.from({ length: buckets }, () => new Set<number>());
|
||||
|
||||
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<number[]>(() => {
|
||||
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<string, MetricPoint[]>();
|
||||
for (const p of metrics) {
|
||||
const bucket = byContainer.get(p.container_id) ?? [];
|
||||
bucket.push(p);
|
||||
byContainer.set(p.container_id, bucket);
|
||||
}
|
||||
const out = Array<number>(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,
|
||||
|
||||
Reference in New Issue
Block a user