wulf-pulse/app/api/status/workers/route.ts
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

165 lines
5.6 KiB
TypeScript

/**
* GET /api/status/workers
* Heartbeat snapshot for the three in-process workers:
* • analyzer — analyzer_jobs
* • rmm — rmm_executions
* • 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, 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,
analyzerBuckets,
rmmRes,
rmmBuckets,
syncRes,
syncBuckets,
] = await Promise.all([
postgresClient.query<SnapshotRow>(
`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,
COUNT(*) FILTER (WHERE status = 'complete' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM analyzer_jobs`,
),
postgresClient.query<BucketRow>(
bucketsCte('analyzer_jobs', 'status', 'finished_at', ['complete'], ['failed']),
),
postgresClient.query<SnapshotRow>(
`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,
COUNT(*) FILTER (WHERE status = 'complete' AND completed_at >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM rmm_executions`,
),
postgresClient.query<BucketRow>(
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,
COUNT(*) FILTER (WHERE last_status = 'failed' AND last_run >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM sync_schedules
WHERE is_enabled = true`,
),
postgresClient.query<BucketRow>(
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];
const workers: WorkerSnapshot[] = [
{
name: 'Analyzer',
lastActivity: a?.last_activity ?? null,
inFlight: parseInt(a?.in_flight ?? '0', 10),
oneHour: {
success: parseInt(a?.ok_1h ?? '0', 10),
failure: parseInt(a?.fail_1h ?? '0', 10),
},
activity24h: toBuckets(analyzerBuckets.rows),
},
{
name: 'RMM Overshell',
lastActivity: r?.last_activity ?? null,
inFlight: parseInt(r?.in_flight ?? '0', 10),
oneHour: {
success: parseInt(r?.ok_1h ?? '0', 10),
failure: parseInt(r?.fail_1h ?? '0', 10),
},
activity24h: toBuckets(rmmBuckets.rows),
},
{
name: 'Sync scheduler',
lastActivity: s?.last_run ?? null,
inFlight: 0,
oneHour: {
success: parseInt(s?.ok_1h ?? '0', 10),
failure: parseInt(s?.fail_1h ?? '0', 10),
},
activity24h: toBuckets(syncBuckets.rows),
},
];
return NextResponse.json({ workers });
}