Each worker card on /status now renders a stacked-bar histogram of the last 24 hourly buckets — successes from the bottom up in primary blue, failures from the top down in destructive red, idle hours as a thin baseline. Heights normalise to the loudest hour in the series so quiet workers still show shape. - /api/status/workers: extended the response with activity24h per worker, computed via a generate_series CTE joined to analyzer_jobs / rmm_executions / sync_history (zero-fill so the 24-bucket shape is consistent regardless of activity). - ActivitySparkline (components/status/activity-sparkline.tsx) — pure flex-end bar strip, no recharts dependency, 32px tall by default. - WorkerPulse renders the strip below the in-flight / 1h tiles with "24h ago" / "now" labels. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4 KiB
TypeScript
136 lines
4 KiB
TypeScript
/* WorkerPulse — single worker heartbeat tile.
|
|
*
|
|
* State derives from:
|
|
* • last activity is fresher than the worker's expected cadence → ok / pending
|
|
* • last activity stale → idle (worker quiet, not necessarily broken)
|
|
* • any failures in the last hour → warn (degraded)
|
|
* • all 1h runs failing → error
|
|
*
|
|
* Per-worker freshness thresholds:
|
|
* • Analyzer — 5 min (poll every 2s, gets work intermittently)
|
|
* • RMM Overshell — 10 min
|
|
* • Sync scheduler — 60 min (cron-driven; quietest worker) */
|
|
|
|
'use client';
|
|
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
|
|
import { ActivitySparkline } from '@/components/status/activity-sparkline';
|
|
|
|
interface ActivityBucket {
|
|
hour: string;
|
|
success: number;
|
|
failure: number;
|
|
}
|
|
|
|
interface WorkerSnapshot {
|
|
name: string;
|
|
lastActivity: string | null;
|
|
inFlight: number;
|
|
oneHour: { success: number; failure: number };
|
|
activity24h?: ActivityBucket[];
|
|
}
|
|
|
|
interface WorkerPulseProps {
|
|
worker: WorkerSnapshot;
|
|
/** Stale threshold in minutes; varies per worker. */
|
|
freshnessMinutes?: number;
|
|
}
|
|
|
|
function relTime(iso: string | null): string {
|
|
if (!iso) return 'never';
|
|
const ms = Date.now() - new Date(iso).getTime();
|
|
if (ms < 0) return 'just now';
|
|
const min = Math.floor(ms / 60000);
|
|
if (min < 1) return 'just now';
|
|
if (min < 60) return `${min} min ago`;
|
|
const hr = Math.floor(min / 60);
|
|
if (hr < 48) return `${hr} h ago`;
|
|
const day = Math.floor(hr / 24);
|
|
return `${day} d ago`;
|
|
}
|
|
|
|
export function WorkerPulse({ worker, freshnessMinutes = 30 }: WorkerPulseProps) {
|
|
const { lastActivity, inFlight, oneHour } = worker;
|
|
const fresh =
|
|
lastActivity != null &&
|
|
Date.now() - new Date(lastActivity).getTime() < freshnessMinutes * 60_000;
|
|
|
|
let state: StatusLightState;
|
|
let stateLabel: string;
|
|
if (oneHour.failure > 0 && oneHour.success === 0) {
|
|
state = 'error';
|
|
stateLabel = 'failing';
|
|
} else if (oneHour.failure > 0) {
|
|
state = 'warn';
|
|
stateLabel = 'degraded';
|
|
} else if (inFlight > 0) {
|
|
state = 'pending';
|
|
stateLabel = 'in flight';
|
|
} else if (fresh) {
|
|
state = 'ok';
|
|
stateLabel = 'ok';
|
|
} else {
|
|
state = 'idle';
|
|
stateLabel = 'idle';
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent className="pt-4 pb-3 space-y-3">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium leading-none">{worker.name}</p>
|
|
<p className="text-xs text-muted-foreground mt-1 capitalize">{stateLabel}</p>
|
|
</div>
|
|
<StatusLight state={state} size="lg" pulse={state === 'pending'} label={stateLabel} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2 text-center">
|
|
<Stat label="In flight" value={inFlight} />
|
|
<Stat label="Ok · 1h" value={oneHour.success} />
|
|
<Stat label="Fail · 1h" value={oneHour.failure} tone={oneHour.failure > 0 ? 'error' : 'default'} />
|
|
</div>
|
|
|
|
{worker.activity24h && worker.activity24h.length > 0 && (
|
|
<div className="space-y-1">
|
|
<ActivitySparkline data={worker.activity24h} />
|
|
<div className="flex justify-between text-[10px] text-muted-foreground num">
|
|
<span>24h ago</span>
|
|
<span>now</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
Last activity <span className="num">{relTime(lastActivity)}</span>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function Stat({
|
|
label,
|
|
value,
|
|
tone = 'default',
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
tone?: 'default' | 'error';
|
|
}) {
|
|
return (
|
|
<div className="rounded-sm bg-muted/40 py-1.5 px-2">
|
|
<p
|
|
className={
|
|
'num text-base ' + (tone === 'error' ? 'text-destructive' : 'text-foreground')
|
|
}
|
|
>
|
|
{value}
|
|
</p>
|
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
{label}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|