Skip to content

Commit 5b6e784

Browse files
fix: pad metrics charts to full 30-min window with null gaps
Instead of pre-filling server-side ring buffers with zeros, generate a full 600-slot time grid on the client and use null for missing data points. Recharts renders null values as gaps, cleanly distinguishing "no data yet" from actual zero values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6ff601 commit 5b6e784

3 files changed

Lines changed: 105 additions & 76 deletions

File tree

src/components/admin-metrics/server-metrics-charts.tsx

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,7 @@ import type {
2323
GetServerMetricsResponse,
2424
ServerMetricsSnapshot,
2525
} from "@/generated/soulfire/metrics";
26-
27-
function formatTime(snapshot: ServerMetricsSnapshot): string {
28-
if (!snapshot.timestamp) return "";
29-
const date = new Date(
30-
Number(snapshot.timestamp.seconds) * 1000 +
31-
snapshot.timestamp.nanos / 1_000_000,
32-
);
33-
return date.toLocaleTimeString([], {
34-
hour: "2-digit",
35-
minute: "2-digit",
36-
second: "2-digit",
37-
});
38-
}
26+
import { padSnapshots } from "@/lib/metrics-utils";
3927

4028
function formatMB(bytes: number): string {
4129
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
@@ -118,14 +106,18 @@ export function CpuUsageChart({
118106
const { t } = useTranslation("admin");
119107
const chartData = useMemo(
120108
() =>
121-
snapshots.map((s) => ({
122-
time: formatTime(s),
123-
process:
124-
s.processCpuLoad >= 0
109+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
110+
time,
111+
process: s
112+
? s.processCpuLoad >= 0
125113
? Number((s.processCpuLoad * 100).toFixed(1))
126-
: 0,
127-
system:
128-
s.systemCpuLoad >= 0 ? Number((s.systemCpuLoad * 100).toFixed(1)) : 0,
114+
: 0
115+
: null,
116+
system: s
117+
? s.systemCpuLoad >= 0
118+
? Number((s.systemCpuLoad * 100).toFixed(1))
119+
: 0
120+
: null,
129121
})),
130122
[snapshots],
131123
);
@@ -191,14 +183,15 @@ export function MemoryUsageChart({
191183
const { t } = useTranslation("admin");
192184
const chartData = useMemo(
193185
() =>
194-
snapshots.map((s) => ({
195-
time: formatTime(s),
196-
used: Number(s.heapUsedBytes) / (1024 * 1024),
197-
committed: Number(s.heapCommittedBytes) / (1024 * 1024),
198-
max:
199-
Number(s.heapMaxBytes) > 0
186+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
187+
time,
188+
used: s ? Number(s.heapUsedBytes) / (1024 * 1024) : null,
189+
committed: s ? Number(s.heapCommittedBytes) / (1024 * 1024) : null,
190+
max: s
191+
? Number(s.heapMaxBytes) > 0
200192
? Number(s.heapMaxBytes) / (1024 * 1024)
201-
: undefined,
193+
: undefined
194+
: null,
202195
})),
203196
[snapshots],
204197
);
@@ -293,10 +286,10 @@ export function ThreadCountChart({
293286
const { t } = useTranslation("admin");
294287
const chartData = useMemo(
295288
() =>
296-
snapshots.map((s) => ({
297-
time: formatTime(s),
298-
total: s.threadCount,
299-
daemon: s.daemonThreadCount,
289+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
290+
time,
291+
total: s ? s.threadCount : null,
292+
daemon: s ? s.daemonThreadCount : null,
300293
})),
301294
[snapshots],
302295
);
@@ -354,10 +347,10 @@ export function AggregateBotsChart({
354347
const { t } = useTranslation("admin");
355348
const chartData = useMemo(
356349
() =>
357-
snapshots.map((s) => ({
358-
time: formatTime(s),
359-
online: s.totalBotsOnline,
360-
total: s.totalBotsTotal,
350+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
351+
time,
352+
online: s ? s.totalBotsOnline : null,
353+
total: s ? s.totalBotsTotal : null,
361354
})),
362355
[snapshots],
363356
);

src/components/instance-metrics/metrics-charts.tsx

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,7 @@ import type {
2929
GetInstanceMetricsResponse,
3030
MetricsSnapshot,
3131
} from "@/generated/soulfire/metrics";
32-
33-
function formatTime(snapshot: MetricsSnapshot): string {
34-
if (!snapshot.timestamp) return "";
35-
const date = new Date(
36-
Number(snapshot.timestamp.seconds) * 1000 +
37-
snapshot.timestamp.nanos / 1_000_000,
38-
);
39-
return date.toLocaleTimeString([], {
40-
hour: "2-digit",
41-
minute: "2-digit",
42-
second: "2-digit",
43-
});
44-
}
32+
import { padSnapshots } from "@/lib/metrics-utils";
4533

4634
function formatBytes(bytes: number): string {
4735
if (bytes < 1024) return `${bytes.toFixed(0)} B/s`;
@@ -130,10 +118,10 @@ export function BotsOnlineChart({
130118
}) {
131119
const chartData = useMemo(
132120
() =>
133-
snapshots.map((s) => ({
134-
time: formatTime(s),
135-
online: s.botsOnline,
136-
total: s.botsTotal,
121+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
122+
time,
123+
online: s ? s.botsOnline : null,
124+
total: s ? s.botsTotal : null,
137125
})),
138126
[snapshots],
139127
);
@@ -194,10 +182,10 @@ export function NetworkTrafficChart({
194182
}) {
195183
const chartData = useMemo(
196184
() =>
197-
snapshots.map((s) => ({
198-
time: formatTime(s),
199-
sent: Math.round(s.packetsSentPerSecond),
200-
received: Math.round(s.packetsReceivedPerSecond),
185+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
186+
time,
187+
sent: s ? Math.round(s.packetsSentPerSecond) : null,
188+
received: s ? Math.round(s.packetsReceivedPerSecond) : null,
201189
})),
202190
[snapshots],
203191
);
@@ -253,10 +241,10 @@ export function BandwidthChart({
253241
}) {
254242
const chartData = useMemo(
255243
() =>
256-
snapshots.map((s) => ({
257-
time: formatTime(s),
258-
upload: s.bytesSentPerSecond,
259-
download: s.bytesReceivedPerSecond,
244+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
245+
time,
246+
upload: s ? s.bytesSentPerSecond : null,
247+
download: s ? s.bytesReceivedPerSecond : null,
260248
})),
261249
[snapshots],
262250
);
@@ -339,10 +327,10 @@ export function TickDurationChart({
339327
}) {
340328
const chartData = useMemo(
341329
() =>
342-
snapshots.map((s) => ({
343-
time: formatTime(s),
344-
avg: Number(s.avgTickDurationMs.toFixed(2)),
345-
max: Number(s.maxTickDurationMs.toFixed(2)),
330+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
331+
time,
332+
avg: s ? Number(s.avgTickDurationMs.toFixed(2)) : null,
333+
max: s ? Number(s.maxTickDurationMs.toFixed(2)) : null,
346334
})),
347335
[snapshots],
348336
);
@@ -402,10 +390,10 @@ export function HealthFoodChart({
402390
}) {
403391
const chartData = useMemo(
404392
() =>
405-
snapshots.map((s) => ({
406-
time: formatTime(s),
407-
health: Number(s.avgHealth.toFixed(1)),
408-
food: Number(s.avgFoodLevel.toFixed(1)),
393+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
394+
time,
395+
health: s ? Number(s.avgHealth.toFixed(1)) : null,
396+
food: s ? Number(s.avgFoodLevel.toFixed(1)) : null,
409397
})),
410398
[snapshots],
411399
);
@@ -464,10 +452,10 @@ export function ChunksEntitiesChart({
464452
}) {
465453
const chartData = useMemo(
466454
() =>
467-
snapshots.map((s) => ({
468-
time: formatTime(s),
469-
chunks: s.totalLoadedChunks,
470-
entities: s.totalTrackedEntities,
455+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
456+
time,
457+
chunks: s ? s.totalLoadedChunks : null,
458+
entities: s ? s.totalTrackedEntities : null,
471459
})),
472460
[snapshots],
473461
);
@@ -528,10 +516,10 @@ export function ConnectionEventsChart({
528516
}) {
529517
const chartData = useMemo(
530518
() =>
531-
snapshots.map((s) => ({
532-
time: formatTime(s),
533-
connections: s.connections,
534-
disconnections: s.disconnections,
519+
padSnapshots(snapshots).map(({ time, snapshot: s }) => ({
520+
time,
521+
connections: s ? s.connections : null,
522+
disconnections: s ? s.disconnections : null,
535523
})),
536524
[snapshots],
537525
);

src/lib/metrics-utils.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
const INTERVAL_SECONDS = 3;
2+
const MAX_SNAPSHOTS = 600; // 30 minutes at 3-second intervals
3+
4+
interface HasTimestamp {
5+
timestamp?: { seconds: string; nanos: number };
6+
}
7+
8+
function formatTimeFromDate(date: Date): string {
9+
return date.toLocaleTimeString([], {
10+
hour: "2-digit",
11+
minute: "2-digit",
12+
second: "2-digit",
13+
});
14+
}
15+
16+
/// Pads a sparse snapshot array into a full 30-minute time grid.
17+
/// Returns 600 entries at 3-second intervals; slots without a real
18+
/// snapshot have `snapshot: null` so Recharts renders gaps.
19+
export function padSnapshots<T extends HasTimestamp>(
20+
snapshots: T[],
21+
): { time: string; snapshot: T | null }[] {
22+
const nowMs = Date.now();
23+
const intervalMs = INTERVAL_SECONDS * 1000;
24+
// Align "now" to the nearest 3-second boundary
25+
const endSlot = Math.round(nowMs / intervalMs) * intervalMs;
26+
const startSlot = endSlot - (MAX_SNAPSHOTS - 1) * intervalMs;
27+
28+
// Index snapshots by their rounded time slot
29+
const snapshotMap = new Map<number, T>();
30+
for (const s of snapshots) {
31+
if (!s.timestamp) continue;
32+
const ms =
33+
Number(s.timestamp.seconds) * 1000 + s.timestamp.nanos / 1_000_000;
34+
const slotMs = Math.round(ms / intervalMs) * intervalMs;
35+
snapshotMap.set(slotMs, s);
36+
}
37+
38+
const result: { time: string; snapshot: T | null }[] = [];
39+
for (let i = 0; i < MAX_SNAPSHOTS; i++) {
40+
const slotMs = startSlot + i * intervalMs;
41+
result.push({
42+
time: formatTimeFromDate(new Date(slotMs)),
43+
snapshot: snapshotMap.get(slotMs) ?? null,
44+
});
45+
}
46+
47+
return result;
48+
}

0 commit comments

Comments
 (0)