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
|
|
@ -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<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,
|
||||
|
|
@ -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<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,
|
||||
|
|
@ -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<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,
|
||||
|
|
@ -62,8 +112,18 @@ export async function GET() {
|
|||
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];
|
||||
|
|
@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue