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>
This commit is contained in:
parent
e1427b62d7
commit
3fa41c25a3
5 changed files with 197 additions and 20 deletions
91
components/status/activity-sparkline.tsx
Normal file
91
components/status/activity-sparkline.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,12 +15,20 @@
|
|||
|
||||
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 {
|
||||
|
|
@ -84,6 +92,16 @@ export function WorkerPulse({ worker, freshnessMinutes = 30 }: WorkerPulseProps)
|
|||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue