fix: base Stack health uptime on container start, not creation (#1341)

The dashboard Stack health UP column counted from each container's
Created timestamp, which never moves on stop/start or restart, so a
restarted container kept reporting its original age. Resolve uptime from
State.StartedAt (via a briefly cached inspect with bounded concurrency,
falling back to Created when inspect is unavailable) so it reflects the
real time since last start.

The current CPU and MEM columns separately summed the latest sample per
container with no recency filter, letting a recently stopped container's
final reading linger in the totals. Drop samples that trail the freshest
sample by more than the stale window so stopped containers leave the sum.
This commit is contained in:
Anso
2026-06-09 20:16:04 -04:00
committed by GitHub
parent 3dc8199907
commit e7895c889d
5 changed files with 330 additions and 32 deletions
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Sparkline } from '@/components/ui/sparkline';
import { ChevronLeft, ChevronRight, Layers } from 'lucide-react';
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
interface StackHealthTableProps {
stackStatuses: Record<string, StackStatusEntry>;
@@ -80,28 +81,7 @@ export function StackHealthTable({
return () => clearInterval(id);
}, []);
const stackAggregates = useMemo(() => {
const latestPerContainer: Record<string, Record<string, MetricPoint>> = {};
for (const m of metrics) {
if (!m.stack_name) continue;
if (!latestPerContainer[m.stack_name]) latestPerContainer[m.stack_name] = {};
const existing = latestPerContainer[m.stack_name][m.container_id];
if (!existing || m.timestamp > existing.timestamp) {
latestPerContainer[m.stack_name][m.container_id] = m;
}
}
const result: Record<string, { mem: number; cpu: number }> = {};
for (const [stack, containers] of Object.entries(latestPerContainer)) {
let mem = 0;
let cpu = 0;
for (const m of Object.values(containers)) {
mem += m.memory_mb;
cpu += m.cpu_percent;
}
result[stack] = { mem, cpu };
}
return result;
}, [metrics]);
const stackAggregates = useMemo(() => aggregateCurrentUsage(metrics), [metrics]);
const rows = useMemo(() => {
const list = Object.entries(stackStatuses).map(([file, entry]) => {
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
import type { MetricPoint } from './types';
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('aggregateCurrentUsage', () => {
it('returns an empty map when there are no metrics', () => {
expect(aggregateCurrentUsage([])).toEqual({});
});
it('sums the latest sample per container into a per-stack total', () => {
const result = aggregateCurrentUsage([
point({ container_id: 'a', timestamp: 1000, cpu_percent: 10, memory_mb: 100 }),
point({ container_id: 'a', timestamp: 2000, cpu_percent: 15, memory_mb: 150 }), // newer wins for a
point({ container_id: 'b', timestamp: 2000, cpu_percent: 5, memory_mb: 50 }),
], 90_000);
expect(result.web).toEqual({ cpu: 20, mem: 200 });
});
it('excludes a container whose latest sample is stale relative to the freshest', () => {
const staleWindow = 90_000;
const result = aggregateCurrentUsage([
// live container keeps reporting; its newest point is the freshest sample
point({ container_id: 'live', timestamp: 500_000, cpu_percent: 30, memory_mb: 300 }),
// stopped container's final reading lingers far behind the freshest sample
point({ container_id: 'stopped', timestamp: 500_000 - staleWindow - 1, cpu_percent: 99, memory_mb: 999 }),
], staleWindow);
// only the live container counts; the stale 99% / 999MB reading is dropped
expect(result.web).toEqual({ cpu: 30, mem: 300 });
});
it('keeps a sample sitting exactly on the stale-window boundary', () => {
const staleWindow = 90_000;
const result = aggregateCurrentUsage([
point({ container_id: 'live', timestamp: 500_000, cpu_percent: 10, memory_mb: 100 }),
// exactly freshest - staleWindow: the cutoff is `< cutoff`, so this is kept
point({ container_id: 'edge', timestamp: 500_000 - staleWindow, cpu_percent: 7, memory_mb: 70 }),
], staleWindow);
expect(result.web).toEqual({ cpu: 17, mem: 170 });
});
it('omits a stack entirely when all its containers are stale', () => {
const result = aggregateCurrentUsage([
point({ stack_name: 'alive', container_id: 'x', timestamp: 1_000_000, cpu_percent: 12, memory_mb: 120 }),
point({ stack_name: 'dead', container_id: 'y', timestamp: 1_000_000 - 200_000, cpu_percent: 80, memory_mb: 800 }),
], 90_000);
expect(result.alive).toEqual({ cpu: 12, mem: 120 });
expect(result.dead).toBeUndefined();
});
});
@@ -0,0 +1,57 @@
import type { MetricPoint } from './types';
/**
* How far behind the freshest sample a container's latest point may be and
* still count toward current usage. Metrics are collected on a ~30s cadence,
* so 90s comfortably includes live containers while dropping the lingering
* final reading of one that stopped a couple of cycles ago.
*/
export const CURRENT_USAGE_STALE_WINDOW_MS = 90_000;
export interface StackUsage {
mem: number;
cpu: number;
}
/**
* Sum the latest CPU/memory sample per container into a per-stack total,
* ignoring containers whose most recent sample is stale. A stopped container
* stops producing samples, so its last reading would otherwise persist in the
* total until it aged out of the metrics window; gating on recency relative to
* the freshest sample (not wall-clock, so overall fetch lag does not matter)
* drops it once live containers report newer points.
*/
export function aggregateCurrentUsage(
metrics: MetricPoint[],
staleWindowMs: number = CURRENT_USAGE_STALE_WINDOW_MS,
): Record<string, StackUsage> {
if (metrics.length === 0) return {};
let freshest = -Infinity;
const latestPerContainer: Record<string, Record<string, MetricPoint>> = {};
for (const m of metrics) {
if (m.timestamp > freshest) freshest = m.timestamp;
if (!m.stack_name) continue;
if (!latestPerContainer[m.stack_name]) latestPerContainer[m.stack_name] = {};
const existing = latestPerContainer[m.stack_name][m.container_id];
if (!existing || m.timestamp > existing.timestamp) {
latestPerContainer[m.stack_name][m.container_id] = m;
}
}
const cutoff = freshest - staleWindowMs;
const result: Record<string, StackUsage> = {};
for (const [stack, containers] of Object.entries(latestPerContainer)) {
let mem = 0;
let cpu = 0;
let fresh = false;
for (const m of Object.values(containers)) {
if (m.timestamp < cutoff) continue;
mem += m.memory_mb;
cpu += m.cpu_percent;
fresh = true;
}
if (fresh) result[stack] = { mem, cpu };
}
return result;
}