wulf-pulse/components/status/activity-sparkline.tsx
lorentz 3fa41c25a3 feat(status): 24-hour activity sparklines on worker pulse cards
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>
2026-05-03 10:03:54 -04:00

91 lines
2.7 KiB
TypeScript

/* ActivitySparkline — 24-bucket success/failure strip for a worker.
*
* Each column is one hour of activity. Successes stack from the top
* down in brand blue; failures stack from the top down in destructive
* red over the success column so the worst hours read first. Heights
* scale to the loudest hour in the series so a quiet worker still
* shows shape.
*
* No tooltip — hover-title gives the count. At 24px tall this is a
* stacked bar histogram, not a line chart, so absolute counts read
* directly. */
'use client';
import { cn } from '@/lib/utils';
interface ActivityBucket {
hour: string;
success: number;
failure: number;
}
interface ActivitySparklineProps {
data: ActivityBucket[];
className?: string;
height?: number;
}
function fmtHour(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
});
}
export function ActivitySparkline({
data,
className,
height = 32,
}: ActivitySparklineProps) {
if (data.length === 0) {
return null;
}
const max = Math.max(1, ...data.map((d) => d.success + d.failure));
return (
<div className={cn('flex items-end gap-px w-full', className)} style={{ height }}>
{data.map((bucket, i) => {
const total = bucket.success + bucket.failure;
const totalPct = (total / max) * 100;
const failPct = total > 0 ? (bucket.failure / total) * 100 : 0;
const succPct = 100 - failPct;
const empty = total === 0;
return (
<span
key={bucket.hour}
title={
empty
? `${fmtHour(bucket.hour)} · idle`
: `${fmtHour(bucket.hour)} · ${bucket.success} ok · ${bucket.failure} fail`
}
aria-label={`${fmtHour(bucket.hour)}: ${bucket.success} ok, ${bucket.failure} fail`}
className="relative flex-1 min-w-[1px] flex flex-col-reverse rounded-[1px] overflow-hidden"
style={{ height: `${empty ? 12 : Math.max(totalPct, 8)}%` }}
data-bucket-index={i}
>
{/* Success segment (bottom) */}
{bucket.success > 0 && (
<span
className="bg-primary/70"
style={{ height: `${succPct}%` }}
/>
)}
{/* Failure segment (top) */}
{bucket.failure > 0 && (
<span
className="bg-destructive"
style={{ height: `${failPct}%` }}
/>
)}
{/* Idle hour — render a thin baseline */}
{empty && (
<span className="bg-border/60 h-px self-end w-full" />
)}
</span>
);
})}
</div>
);
}