diff --git a/DESIGN.md b/DESIGN.md index 52cc86a..5476fb0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -363,6 +363,10 @@ below is the working backlog; expand as we go. logged, ticket touch count). - [x] ~~Worker pulse section on `/status`~~ — analyzer / RMM / sync scheduler heartbeats via `/api/status/workers` and `WorkerPulse`. +- [x] ~~Worker activity sparklines~~ — `ActivitySparkline` shows 24 + hourly buckets per worker (success bottom-up in primary, failure + top-down in destructive). Backed by zero-filled hour series + generated in the `/api/status/workers` query. ### Tokens & theming - [-] Hard-coded Tailwind palette colors are extensive (~770 references) diff --git a/app/api/status/workers/route.ts b/app/api/status/workers/route.ts index f596b4f..06fc494 100644 --- a/app/api/status/workers/route.ts +++ b/app/api/status/workers/route.ts @@ -3,33 +3,86 @@ * Heartbeat snapshot for the three in-process workers: * • analyzer — analyzer_jobs * • rmm — rmm_executions - * • sync — sync_schedules / sync_history (proxy for the scheduler) + * • sync — sync_history (proxy for the scheduler; one row per + * triggered task) * * For each: last activity timestamp, in-flight count, last-1h success/ - * failure totals. Cheap — just SELECT COUNT(*) FILTER queries. */ + * failure totals, and 24 hourly buckets of activity (success vs + * failure counts) for sparkline rendering. */ import { NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; +export interface ActivityBucket { + /** Hour-truncated UTC timestamp. */ + hour: string; + success: number; + failure: number; +} + export interface WorkerSnapshot { name: string; lastActivity: string | null; inFlight: number; oneHour: { success: number; failure: number }; + activity24h: ActivityBucket[]; +} + +interface SnapshotRow { + last_activity: string | null; + in_flight: string; + ok_1h: string; + fail_1h: string; +} + +interface BucketRow { + hour: string; + ok: string; + fail: string; +} + +/** + * Build a 24-row series with zero-fill so the sparkline always has the + * same number of points (even when the worker is idle). Generated as a + * date_trunc('hour') series joined to the source table's status histogram. + */ +function bucketsCte(table: string, statusCol: string, tsCol: string, okValues: string[], failValues: string[]) { + const okIn = okValues.map((v) => `'${v}'`).join(','); + const failIn = failValues.map((v) => `'${v}'`).join(','); + return ` + WITH hours AS ( + SELECT generate_series( + date_trunc('hour', NOW()) - INTERVAL '23 hours', + date_trunc('hour', NOW()), + INTERVAL '1 hour' + ) AS h + ) + SELECT hours.h::text AS hour, + COALESCE(SUM(CASE WHEN ${statusCol} IN (${okIn}) THEN 1 ELSE 0 END), 0)::text AS ok, + COALESCE(SUM(CASE WHEN ${statusCol} IN (${failIn}) THEN 1 ELSE 0 END), 0)::text AS fail + FROM hours + LEFT JOIN ${table} t + ON date_trunc('hour', t.${tsCol}) = hours.h + AND t.${tsCol} >= NOW() - INTERVAL '24 hours' + GROUP BY hours.h + ORDER BY hours.h + `; } export async function GET() { const { error } = await requireAuth(); if (error) return error; - const [analyzerRes, rmmRes, syncRes] = await Promise.all([ - postgresClient.query<{ - last_activity: string | null; - in_flight: string; - ok_1h: string; - fail_1h: string; - }>( + const [ + analyzerRes, + analyzerBuckets, + rmmRes, + rmmBuckets, + syncRes, + syncBuckets, + ] = await Promise.all([ + postgresClient.query( `SELECT GREATEST(MAX(queued_at), MAX(started_at), MAX(finished_at))::text AS last_activity, COUNT(*) FILTER (WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review'))::text AS in_flight, @@ -37,12 +90,10 @@ export async function GET() { COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h FROM analyzer_jobs`, ), - postgresClient.query<{ - last_activity: string | null; - in_flight: string; - ok_1h: string; - fail_1h: string; - }>( + postgresClient.query( + bucketsCte('analyzer_jobs', 'status', 'finished_at', ['complete'], ['failed']), + ), + postgresClient.query( `SELECT GREATEST(MAX(queued_at), MAX(started_at), MAX(completed_at))::text AS last_activity, COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight, @@ -50,11 +101,10 @@ export async function GET() { COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h FROM rmm_executions`, ), - postgresClient.query<{ - last_run: string | null; - ok_1h: string; - fail_1h: string; - }>( + postgresClient.query( + bucketsCte('rmm_executions', 'status', 'completed_at', ['complete'], ['failed', 'timeout']), + ), + postgresClient.query<{ last_run: string | null; ok_1h: string; fail_1h: string }>( `SELECT MAX(last_run)::text AS last_run, COUNT(*) FILTER (WHERE last_status = 'success' AND last_run >= NOW() - INTERVAL '1 hour')::text AS ok_1h, @@ -62,8 +112,18 @@ export async function GET() { FROM sync_schedules WHERE is_enabled = true`, ), + postgresClient.query( + bucketsCte('sync_history', 'status', 'started_at', ['completed'], ['failed']), + ), ]); + const toBuckets = (rows: BucketRow[]): ActivityBucket[] => + rows.map((r) => ({ + hour: r.hour, + success: parseInt(r.ok, 10), + failure: parseInt(r.fail, 10), + })); + const a = analyzerRes.rows[0]; const r = rmmRes.rows[0]; const s = syncRes.rows[0]; @@ -77,6 +137,7 @@ export async function GET() { success: parseInt(a?.ok_1h ?? '0', 10), failure: parseInt(a?.fail_1h ?? '0', 10), }, + activity24h: toBuckets(analyzerBuckets.rows), }, { name: 'RMM Overshell', @@ -86,6 +147,7 @@ export async function GET() { success: parseInt(r?.ok_1h ?? '0', 10), failure: parseInt(r?.fail_1h ?? '0', 10), }, + activity24h: toBuckets(rmmBuckets.rows), }, { name: 'Sync scheduler', @@ -95,6 +157,7 @@ export async function GET() { success: parseInt(s?.ok_1h ?? '0', 10), failure: parseInt(s?.fail_1h ?? '0', 10), }, + activity24h: toBuckets(syncBuckets.rows), }, ]; diff --git a/app/status/page.tsx b/app/status/page.tsx index b45b7b8..e3689c9 100644 --- a/app/status/page.tsx +++ b/app/status/page.tsx @@ -96,6 +96,7 @@ interface WorkerSnapshot { lastActivity: string | null; inFlight: number; oneHour: { success: number; failure: number }; + activity24h?: Array<{ hour: string; success: number; failure: number }>; } interface WorkersResponse { diff --git a/components/status/activity-sparkline.tsx b/components/status/activity-sparkline.tsx new file mode 100644 index 0000000..1265540 --- /dev/null +++ b/components/status/activity-sparkline.tsx @@ -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 ( +
+ {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 ( + + {/* Success segment (bottom) */} + {bucket.success > 0 && ( + + )} + {/* Failure segment (top) */} + {bucket.failure > 0 && ( + + )} + {/* Idle hour — render a thin baseline */} + {empty && ( + + )} + + ); + })} +
+ ); +} diff --git a/components/status/worker-pulse.tsx b/components/status/worker-pulse.tsx index b4f5e07..60e91f6 100644 --- a/components/status/worker-pulse.tsx +++ b/components/status/worker-pulse.tsx @@ -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) 0 ? 'error' : 'default'} /> + {worker.activity24h && worker.activity24h.length > 0 && ( +
+ +
+ 24h ago + now +
+
+ )} +

Last activity {relTime(lastActivity)}