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>
541 lines
20 KiB
TypeScript
541 lines
20 KiB
TypeScript
/* /status — System health dashboard.
|
|
*
|
|
* Pulls from:
|
|
* GET /api/dashboard/integration-health (live API check + token expiry)
|
|
* GET /api/dashboard/overview (syncHealth array)
|
|
*
|
|
* Surfaces what's wrong so the dashboard can stay focused on operational
|
|
* KPIs. Polls every 60 s while the page is visible. */
|
|
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { PageHeader } from '@/components/navigation/page-header';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { SkeletonTable } from '@/components/ui/skeleton-helpers';
|
|
import { EmptyState } from '@/components/ui/empty-state';
|
|
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
|
|
import { StatusBadge } from '@/components/ui/status-badge';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { WorkerPulse } from '@/components/status/worker-pulse';
|
|
import {
|
|
AlertTriangle,
|
|
KeyRound,
|
|
RefreshCw,
|
|
ShieldCheck,
|
|
Plug,
|
|
Clock,
|
|
Activity,
|
|
} from 'lucide-react';
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────
|
|
|
|
type IntegrationCategory =
|
|
| 'psa' | 'rmm' | 'docs' | 'security' | 'backup'
|
|
| 'network' | 'identity' | 'mdm' | 'mail'
|
|
| 'finance' | 'productivity' | 'llm';
|
|
|
|
interface IntegrationHealthItem {
|
|
key: string;
|
|
name: string;
|
|
category: IntegrationCategory;
|
|
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown' | 'disabled';
|
|
configured: boolean;
|
|
latencyMs?: number;
|
|
error?: string | null;
|
|
tokenExpiry?: {
|
|
envVar: string;
|
|
expiresAt: string;
|
|
daysRemaining: number;
|
|
subject?: string | null;
|
|
} | null;
|
|
checkedAt: string;
|
|
}
|
|
|
|
interface IntegrationHealthResponse {
|
|
items: IntegrationHealthItem[];
|
|
summary: {
|
|
total: number;
|
|
ok: number;
|
|
failed: number;
|
|
notConfigured: number;
|
|
disabled: number;
|
|
expiringWithin14Days: number;
|
|
expired: number;
|
|
hasIssues: boolean;
|
|
};
|
|
}
|
|
|
|
interface SyncHealthItem {
|
|
id: string;
|
|
name: string;
|
|
syncType: string;
|
|
isEnabled: boolean;
|
|
lastRun: string | null;
|
|
lastStatus: string | null;
|
|
lastError: string | null;
|
|
nextRun: string | null;
|
|
}
|
|
|
|
interface OverviewResponse {
|
|
syncHealth: SyncHealthItem[];
|
|
}
|
|
|
|
interface WorkerSnapshot {
|
|
name: string;
|
|
lastActivity: string | null;
|
|
inFlight: number;
|
|
oneHour: { success: number; failure: number };
|
|
activity24h?: Array<{ hour: string; success: number; failure: number }>;
|
|
}
|
|
|
|
interface WorkersResponse {
|
|
workers: WorkerSnapshot[];
|
|
}
|
|
|
|
const WORKER_FRESHNESS: Record<string, number> = {
|
|
Analyzer: 5,
|
|
'RMM Overshell': 10,
|
|
'Sync scheduler': 60,
|
|
};
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
const STALE_HOURS = 24;
|
|
const POLL_MS = 60_000;
|
|
|
|
const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
|
|
psa: 'PSA',
|
|
rmm: 'RMM',
|
|
docs: 'Documentation',
|
|
security: 'Security',
|
|
backup: 'Backup',
|
|
network: 'Network',
|
|
identity: 'Identity',
|
|
mdm: 'MDM',
|
|
mail: 'Mail',
|
|
finance: 'Finance',
|
|
productivity: 'Productivity',
|
|
llm: 'LLM',
|
|
};
|
|
|
|
const CATEGORY_ORDER: IntegrationCategory[] = [
|
|
'psa', 'rmm', 'docs', 'security', 'backup',
|
|
'network', 'identity', 'mdm', 'mail',
|
|
'finance', 'productivity', 'llm',
|
|
];
|
|
|
|
function relTime(iso: string | null): string {
|
|
if (!iso) return 'never';
|
|
const ms = Date.now() - new Date(iso).getTime();
|
|
if (ms < 0) return 'in the future';
|
|
const min = Math.floor(ms / 60000);
|
|
if (min < 1) return 'just now';
|
|
if (min < 60) return `${min} min ago`;
|
|
const hr = Math.floor(min / 60);
|
|
if (hr < 48) return `${hr} h ago`;
|
|
const day = Math.floor(hr / 24);
|
|
return `${day} d ago`;
|
|
}
|
|
|
|
function isStale(iso: string | null): boolean {
|
|
if (!iso) return true;
|
|
return Date.now() - new Date(iso).getTime() > STALE_HOURS * 3600_000;
|
|
}
|
|
|
|
function integrationLight(item: IntegrationHealthItem): StatusLightState {
|
|
if (item.status === 'disabled') return 'idle';
|
|
const tokenExpired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
|
|
const tokenExpiring =
|
|
item.tokenExpiry &&
|
|
item.tokenExpiry.daysRemaining > 0 &&
|
|
item.tokenExpiry.daysRemaining <= 14;
|
|
if (item.status === 'auth_failed' || item.status === 'unreachable' || tokenExpired) {
|
|
return 'error';
|
|
}
|
|
if (tokenExpiring) return 'warn';
|
|
if (item.status === 'ok') return 'ok';
|
|
if (item.status === 'not_configured') return 'idle';
|
|
return 'idle';
|
|
}
|
|
|
|
function syncLight(item: SyncHealthItem): StatusLightState {
|
|
if (!item.isEnabled) return 'idle';
|
|
if (item.lastStatus === 'failed') return 'error';
|
|
if (isStale(item.lastRun)) return 'warn';
|
|
if (item.lastStatus === 'success') return 'ok';
|
|
return 'idle';
|
|
}
|
|
|
|
// ── Page ─────────────────────────────────────────────────────────────
|
|
|
|
export default function StatusPage() {
|
|
const [health, setHealth] = useState<IntegrationHealthResponse | null>(null);
|
|
const [overview, setOverview] = useState<OverviewResponse | null>(null);
|
|
const [workers, setWorkers] = useState<WorkerSnapshot[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
async function load(force = false) {
|
|
setRefreshing(true);
|
|
try {
|
|
const [hRes, oRes, wRes] = await Promise.all([
|
|
fetch(`/api/dashboard/integration-health${force ? '?refresh=1' : ''}`, { cache: 'no-store' }),
|
|
fetch('/api/dashboard/overview', { cache: 'no-store' }),
|
|
fetch('/api/status/workers', { cache: 'no-store' }),
|
|
]);
|
|
if (hRes.ok) setHealth((await hRes.json()) as IntegrationHealthResponse);
|
|
if (oRes.ok) setOverview((await oRes.json()) as OverviewResponse);
|
|
if (wRes.ok) {
|
|
const j = (await wRes.json()) as WorkersResponse;
|
|
setWorkers(j.workers);
|
|
}
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load');
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
const id = setInterval(() => void load(), POLL_MS);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
// Roll-up
|
|
const overall: StatusLightState = !health
|
|
? 'idle'
|
|
: health.summary.failed > 0 || health.summary.expired > 0
|
|
? 'error'
|
|
: health.summary.expiringWithin14Days > 0 ||
|
|
(overview?.syncHealth.some((s) => syncLight(s) === 'error') ?? false)
|
|
? 'warn'
|
|
: 'ok';
|
|
|
|
const overallTitle = !health
|
|
? 'Loading…'
|
|
: overall === 'error'
|
|
? `${health.summary.failed} integration${health.summary.failed === 1 ? '' : 's'} failing`
|
|
: overall === 'warn'
|
|
? health.summary.expiringWithin14Days > 0
|
|
? `${health.summary.expiringWithin14Days} token${health.summary.expiringWithin14Days === 1 ? '' : 's'} expiring soon`
|
|
: 'Some sync tasks degraded'
|
|
: 'All systems operational';
|
|
|
|
// Group integrations
|
|
const grouped = (() => {
|
|
if (!health) return null;
|
|
const map: Record<string, IntegrationHealthItem[]> = {};
|
|
for (const item of health.items) {
|
|
(map[item.category] ??= []).push(item);
|
|
}
|
|
return map;
|
|
})();
|
|
|
|
const expiring = health?.items
|
|
.filter((i) => i.tokenExpiry && i.tokenExpiry.daysRemaining <= 30)
|
|
.sort((a, b) => (a.tokenExpiry!.daysRemaining ?? 999) - (b.tokenExpiry!.daysRemaining ?? 999));
|
|
|
|
const failingSyncs = overview?.syncHealth.filter((s) => syncLight(s) === 'error');
|
|
const failingIntegrations = health?.items.filter(
|
|
(i) => i.status === 'auth_failed' || i.status === 'unreachable',
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="System Status"
|
|
description={overallTitle}
|
|
breadcrumbs={[{ label: 'Status' }]}
|
|
accent
|
|
watermark
|
|
actions={
|
|
<>
|
|
<StatusLight state={overall} size="lg" label={overallTitle} />
|
|
<Button
|
|
onClick={() => void load(true)}
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={refreshing}
|
|
>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="container mx-auto px-6 py-6 space-y-6">
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>Failed to load status</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* CONDITIONAL BANNER -------------------------------------------- */}
|
|
{(failingIntegrations?.length || failingSyncs?.length) ? (
|
|
<Alert variant="destructive">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertTitle>Action needed</AlertTitle>
|
|
<AlertDescription>
|
|
<ul className="list-disc pl-5 mt-1 space-y-0.5">
|
|
{failingIntegrations?.map((i) => (
|
|
<li key={i.key}>
|
|
<span className="font-medium">{i.name}</span> —{' '}
|
|
{i.status === 'auth_failed' ? 'authentication failed' : 'unreachable'}
|
|
{i.error && <span className="text-muted-foreground"> · {i.error.slice(0, 120)}</span>}
|
|
</li>
|
|
))}
|
|
{failingSyncs?.map((s) => (
|
|
<li key={s.id}>
|
|
<span className="font-medium">{s.name}</span> sync failed
|
|
{s.lastError && <span className="text-muted-foreground"> · {s.lastError.slice(0, 120)}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{/* INTEGRATION TILES --------------------------------------------- */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Plug className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Integrations
|
|
</h2>
|
|
{health && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{health.summary.ok} of {health.summary.total - health.summary.notConfigured} healthy
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{!grouped ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{[1, 2, 3, 4, 5, 6].map((i) => <Skeleton key={i} className="h-24" />)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{CATEGORY_ORDER.filter((c) => grouped[c]?.length).map((category) => (
|
|
<div key={category} className="space-y-2">
|
|
<h3 className="metric-label">{CATEGORY_LABELS[category]}</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{grouped[category]
|
|
.slice()
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map((item) => <IntegrationTile key={item.key} item={item} />)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* WORKERS ------------------------------------------------------- */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Workers
|
|
</h2>
|
|
</div>
|
|
{!workers ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-36" />)}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{workers.map((w) => (
|
|
<WorkerPulse
|
|
key={w.name}
|
|
worker={w}
|
|
freshnessMinutes={WORKER_FRESHNESS[w.name]}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* TOKEN EXPIRY -------------------------------------------------- */}
|
|
{expiring && expiring.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<KeyRound className="h-4 w-4" />
|
|
Tokens expiring within 30 days
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="divide-y divide-border">
|
|
{expiring.map((item) => {
|
|
const days = item.tokenExpiry!.daysRemaining;
|
|
const tone = days <= 0 ? 'error' : days <= 14 ? 'warn' : 'pending';
|
|
return (
|
|
<div key={item.key} className="flex items-center justify-between py-2 text-sm">
|
|
<div className="min-w-0">
|
|
<span className="font-medium">{item.name}</span>
|
|
<span className="text-muted-foreground"> · {item.tokenExpiry!.envVar}</span>
|
|
</div>
|
|
<StatusBadge tone={tone}>
|
|
{days <= 0 ? `expired ${Math.abs(days)} d ago` : `${days} d`}
|
|
</StatusBadge>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* SYNC HEALTH --------------------------------------------------- */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Clock className="h-4 w-4" />
|
|
Scheduled syncs
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{!overview ? (
|
|
<SkeletonTable rows={6} cols={5} />
|
|
) : overview.syncHealth.length === 0 ? (
|
|
<div className="px-6 py-6">
|
|
<EmptyState
|
|
icon={Clock}
|
|
title="No scheduled syncs"
|
|
description="Configure schedules in Admin to populate this list."
|
|
action={{ label: 'Open admin', href: '/admin' }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="bg-muted/40">
|
|
<TableRow>
|
|
<TableHead>Schedule</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Last run</TableHead>
|
|
<TableHead>Next run</TableHead>
|
|
<TableHead className="text-right">Status</TableHead>
|
|
<TableHead className="w-10" aria-label="indicator" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{overview.syncHealth.map((s) => (
|
|
<TableRow key={s.id}>
|
|
<TableCell className="font-medium">{s.name}</TableCell>
|
|
<TableCell className="num text-muted-foreground">{s.syncType}</TableCell>
|
|
<TableCell className="num text-muted-foreground">{relTime(s.lastRun)}</TableCell>
|
|
<TableCell className="num text-muted-foreground">{relTime(s.nextRun)}</TableCell>
|
|
<TableCell className="text-right">
|
|
{s.isEnabled
|
|
? s.lastStatus === 'failed'
|
|
? <StatusBadge tone="error">failed</StatusBadge>
|
|
: isStale(s.lastRun)
|
|
? <StatusBadge tone="warn">stale</StatusBadge>
|
|
: s.lastStatus === 'success'
|
|
? <StatusBadge tone="ok">success</StatusBadge>
|
|
: <StatusBadge tone="neutral">idle</StatusBadge>
|
|
: <StatusBadge tone="inactive">off</StatusBadge>}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<StatusLight state={syncLight(s)} size="sm" label={s.lastStatus ?? 'idle'} />
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* COMPLIANCE FOOTER -------------------------------------------- */}
|
|
{health && (
|
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
<ShieldCheck className="h-3.5 w-3.5" />
|
|
<span><span className="num">{health.summary.ok}</span> healthy</span>
|
|
<span>·</span>
|
|
<span><span className="num">{health.summary.failed}</span> failing</span>
|
|
<span>·</span>
|
|
<span><span className="num">{health.summary.notConfigured}</span> unconfigured</span>
|
|
{health.summary.disabled > 0 && (
|
|
<>
|
|
<span>·</span>
|
|
<span><span className="num">{health.summary.disabled}</span> disabled</span>
|
|
</>
|
|
)}
|
|
<span>·</span>
|
|
<span><span className="num">{health.summary.expiringWithin14Days}</span> expiring</span>
|
|
<span>·</span>
|
|
<span>last checked {relTime(health.items[0]?.checkedAt ?? null)}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Integration tile ────────────────────────────────────────────────
|
|
|
|
function IntegrationTile({ item }: { item: IntegrationHealthItem }) {
|
|
const light = integrationLight(item);
|
|
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
|
|
const expiringSoon =
|
|
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
|
|
|
|
let detail: string | null = null;
|
|
if (item.status === 'disabled') detail = 'disabled by operator';
|
|
else if (item.status === 'auth_failed') detail = 'authentication failed';
|
|
else if (item.status === 'unreachable') detail = 'unreachable';
|
|
else if (expired) detail = `token expired ${Math.abs(item.tokenExpiry!.daysRemaining)} d ago`;
|
|
else if (expiringSoon) detail = `token expires in ${item.tokenExpiry!.daysRemaining} d`;
|
|
else if (item.status === 'not_configured') detail = 'not configured';
|
|
else if (item.status === 'ok' && item.latencyMs !== undefined) detail = `${item.latencyMs} ms`;
|
|
else if (item.status === 'unknown' && item.configured) detail = 'configured';
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
'rounded-md border bg-card px-3 py-3 flex items-start gap-3 ' +
|
|
(light === 'error'
|
|
? 'border-destructive/40'
|
|
: item.status === 'disabled'
|
|
? 'border-border opacity-60'
|
|
: 'border-border')
|
|
}
|
|
>
|
|
<StatusLight state={light} size="md" label={item.status} className="mt-1" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="font-medium truncate">{item.name}</p>
|
|
{(expired || expiringSoon) && (
|
|
<KeyRound className={`h-3.5 w-3.5 shrink-0 ${expired ? 'text-destructive' : 'text-amber-500'}`} />
|
|
)}
|
|
</div>
|
|
{detail && (
|
|
<p className={`text-xs num truncate ${light === 'error' ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
{detail}
|
|
</p>
|
|
)}
|
|
{item.error && light === 'error' && (
|
|
<p className="text-xs text-muted-foreground/80 truncate" title={item.error}>
|
|
{item.error.slice(0, 80)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|