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
|
|
@ -363,6 +363,10 @@ below is the working backlog; expand as we go.
|
||||||
logged, ticket touch count).
|
logged, ticket touch count).
|
||||||
- [x] ~~Worker pulse section on `/status`~~ — analyzer / RMM / sync
|
- [x] ~~Worker pulse section on `/status`~~ — analyzer / RMM / sync
|
||||||
scheduler heartbeats via `/api/status/workers` and `WorkerPulse`.
|
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
|
### Tokens & theming
|
||||||
- [-] Hard-coded Tailwind palette colors are extensive (~770 references)
|
- [-] Hard-coded Tailwind palette colors are extensive (~770 references)
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,86 @@
|
||||||
* Heartbeat snapshot for the three in-process workers:
|
* Heartbeat snapshot for the three in-process workers:
|
||||||
* • analyzer — analyzer_jobs
|
* • analyzer — analyzer_jobs
|
||||||
* • rmm — rmm_executions
|
* • 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/
|
* 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 { NextResponse } from 'next/server';
|
||||||
import { requireAuth } from '@/lib/auth-utils';
|
import { requireAuth } from '@/lib/auth-utils';
|
||||||
import postgresClient from '@/lib/services/postgres-client';
|
import postgresClient from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
export interface ActivityBucket {
|
||||||
|
/** Hour-truncated UTC timestamp. */
|
||||||
|
hour: string;
|
||||||
|
success: number;
|
||||||
|
failure: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkerSnapshot {
|
export interface WorkerSnapshot {
|
||||||
name: string;
|
name: string;
|
||||||
lastActivity: string | null;
|
lastActivity: string | null;
|
||||||
inFlight: number;
|
inFlight: number;
|
||||||
oneHour: { success: number; failure: 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() {
|
export async function GET() {
|
||||||
const { error } = await requireAuth();
|
const { error } = await requireAuth();
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
|
|
||||||
const [analyzerRes, rmmRes, syncRes] = await Promise.all([
|
const [
|
||||||
postgresClient.query<{
|
analyzerRes,
|
||||||
last_activity: string | null;
|
analyzerBuckets,
|
||||||
in_flight: string;
|
rmmRes,
|
||||||
ok_1h: string;
|
rmmBuckets,
|
||||||
fail_1h: string;
|
syncRes,
|
||||||
}>(
|
syncBuckets,
|
||||||
|
] = await Promise.all([
|
||||||
|
postgresClient.query<SnapshotRow>(
|
||||||
`SELECT
|
`SELECT
|
||||||
GREATEST(MAX(queued_at), MAX(started_at), MAX(finished_at))::text AS last_activity,
|
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 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
|
COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
|
||||||
FROM analyzer_jobs`,
|
FROM analyzer_jobs`,
|
||||||
),
|
),
|
||||||
postgresClient.query<{
|
postgresClient.query<BucketRow>(
|
||||||
last_activity: string | null;
|
bucketsCte('analyzer_jobs', 'status', 'finished_at', ['complete'], ['failed']),
|
||||||
in_flight: string;
|
),
|
||||||
ok_1h: string;
|
postgresClient.query<SnapshotRow>(
|
||||||
fail_1h: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
`SELECT
|
||||||
GREATEST(MAX(queued_at), MAX(started_at), MAX(completed_at))::text AS last_activity,
|
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 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
|
COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
|
||||||
FROM rmm_executions`,
|
FROM rmm_executions`,
|
||||||
),
|
),
|
||||||
postgresClient.query<{
|
postgresClient.query<BucketRow>(
|
||||||
last_run: string | null;
|
bucketsCte('rmm_executions', 'status', 'completed_at', ['complete'], ['failed', 'timeout']),
|
||||||
ok_1h: string;
|
),
|
||||||
fail_1h: string;
|
postgresClient.query<{ last_run: string | null; ok_1h: string; fail_1h: string }>(
|
||||||
}>(
|
|
||||||
`SELECT
|
`SELECT
|
||||||
MAX(last_run)::text AS last_run,
|
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 = 'success' AND last_run >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
|
||||||
|
|
@ -62,8 +112,18 @@ export async function GET() {
|
||||||
FROM sync_schedules
|
FROM sync_schedules
|
||||||
WHERE is_enabled = true`,
|
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 a = analyzerRes.rows[0];
|
||||||
const r = rmmRes.rows[0];
|
const r = rmmRes.rows[0];
|
||||||
const s = syncRes.rows[0];
|
const s = syncRes.rows[0];
|
||||||
|
|
@ -77,6 +137,7 @@ export async function GET() {
|
||||||
success: parseInt(a?.ok_1h ?? '0', 10),
|
success: parseInt(a?.ok_1h ?? '0', 10),
|
||||||
failure: parseInt(a?.fail_1h ?? '0', 10),
|
failure: parseInt(a?.fail_1h ?? '0', 10),
|
||||||
},
|
},
|
||||||
|
activity24h: toBuckets(analyzerBuckets.rows),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'RMM Overshell',
|
name: 'RMM Overshell',
|
||||||
|
|
@ -86,6 +147,7 @@ export async function GET() {
|
||||||
success: parseInt(r?.ok_1h ?? '0', 10),
|
success: parseInt(r?.ok_1h ?? '0', 10),
|
||||||
failure: parseInt(r?.fail_1h ?? '0', 10),
|
failure: parseInt(r?.fail_1h ?? '0', 10),
|
||||||
},
|
},
|
||||||
|
activity24h: toBuckets(rmmBuckets.rows),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Sync scheduler',
|
name: 'Sync scheduler',
|
||||||
|
|
@ -95,6 +157,7 @@ export async function GET() {
|
||||||
success: parseInt(s?.ok_1h ?? '0', 10),
|
success: parseInt(s?.ok_1h ?? '0', 10),
|
||||||
failure: parseInt(s?.fail_1h ?? '0', 10),
|
failure: parseInt(s?.fail_1h ?? '0', 10),
|
||||||
},
|
},
|
||||||
|
activity24h: toBuckets(syncBuckets.rows),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ interface WorkerSnapshot {
|
||||||
lastActivity: string | null;
|
lastActivity: string | null;
|
||||||
inFlight: number;
|
inFlight: number;
|
||||||
oneHour: { success: number; failure: number };
|
oneHour: { success: number; failure: number };
|
||||||
|
activity24h?: Array<{ hour: string; success: number; failure: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkersResponse {
|
interface WorkersResponse {
|
||||||
|
|
|
||||||
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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
|
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 {
|
interface WorkerSnapshot {
|
||||||
name: string;
|
name: string;
|
||||||
lastActivity: string | null;
|
lastActivity: string | null;
|
||||||
inFlight: number;
|
inFlight: number;
|
||||||
oneHour: { success: number; failure: number };
|
oneHour: { success: number; failure: number };
|
||||||
|
activity24h?: ActivityBucket[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkerPulseProps {
|
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'} />
|
<Stat label="Fail · 1h" value={oneHour.failure} tone={oneHour.failure > 0 ? 'error' : 'default'} />
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
Last activity <span className="num">{relTime(lastActivity)}</span>
|
Last activity <span className="num">{relTime(lastActivity)}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue