wulf-pulse/components/navigation/status-indicator.tsx
lorentz c97e5fc45c feat: status popover + CSV export
Two follow-ons after the ⌘K palette:

StatusIndicator → Popover
- The top-bar status light is no longer a direct link to /status.
  Clicking it opens a popover with grouped issues (failing
  integrations, expired tokens, expiring tokens) so a quick glance
  answers "what's broken" without leaving the current page.  A "View
  full status" link at the bottom routes to /status when needed.
- The trigger keeps the same color rollup so the visual hint is
  visible without opening the popover.

DataTable → CSV export
- Optional `exportable` + `exportFilename` props add an "Export CSV"
  button next to the search bar.  Default behavior exports the current
  page; pass `onExportAll` for server-side full-result downloads.
- Built client-side from column defs (label → header, raw value →
  cell).  BOM-prefixed UTF-8 so Excel decodes correctly.  Quoting +
  escape handled.
- Enabled on /admin/data-browser/{companies,tickets} as initial demos.
  Other data-browser pages opt in by adding two props.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:20:27 -04:00

267 lines
7.4 KiB
TypeScript

/* StatusIndicator — top-bar status pill with inline issue summary.
*
* Click to open a Popover that lists what's wrong (failed integrations,
* expiring/expired tokens, recent sync failures) with deep-links to the
* relevant tools. When everything is healthy the popover just confirms
* "All systems operational". A "View full status" link at the bottom
* routes to /status.
*
* Polls /api/dashboard/integration-health every 60 s. Rolls up the
* summary into a single StatusLight color in the trigger so the visual
* hint is visible without opening the popover. */
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { ArrowRight, KeyRound, Power, ShieldCheck, XCircle } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
import { Separator } from '@/components/ui/separator';
interface IntegrationHealthItem {
key: string;
name: string;
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown' | 'disabled';
error?: string | null;
tokenExpiry?: { envVar: string; daysRemaining: number } | null;
}
interface HealthSummary {
total: number;
ok: number;
failed: number;
notConfigured: number;
disabled: number;
expiringWithin14Days: number;
expired: number;
hasIssues: boolean;
}
interface HealthResponse {
items: IntegrationHealthItem[];
summary: HealthSummary;
}
const POLL_MS = 60_000;
export function StatusIndicator() {
const [data, setData] = useState<HealthResponse | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch('/api/dashboard/integration-health', { cache: 'no-store' });
if (!res.ok || cancelled) return;
const j = (await res.json()) as HealthResponse;
if (!cancelled) setData(j);
} catch {
/* leave null — trigger renders idle */
}
}
void load();
const id = setInterval(() => void load(), POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
const summary = data?.summary;
const state: StatusLightState = !summary
? 'idle'
: summary.failed > 0 || summary.expired > 0
? 'error'
: summary.expiringWithin14Days > 0
? 'warn'
: 'ok';
const triggerTitle = !summary
? 'System status'
: state === 'error'
? `${summary.failed + summary.expired} integration issue(s)`
: state === 'warn'
? `${summary.expiringWithin14Days} token(s) expiring soon`
: 'All systems operational';
return (
<Popover>
<PopoverTrigger
title={triggerTitle}
aria-label={triggerTitle}
className="inline-flex h-9 w-9 items-center justify-center rounded-md hover:bg-accent/40 transition-colors"
>
<StatusLight state={state} size="md" label={triggerTitle} />
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<StatusSummary data={data} />
</PopoverContent>
</Popover>
);
}
function StatusSummary({ data }: { data: HealthResponse | null }) {
if (!data) {
return (
<div className="px-4 py-3">
<p className="text-sm text-muted-foreground">Loading system status</p>
</div>
);
}
const failing = data.items.filter(
(i) => i.status === 'auth_failed' || i.status === 'unreachable',
);
const expired = data.items.filter(
(i) => i.tokenExpiry && i.tokenExpiry.daysRemaining <= 0,
);
const expiring = data.items.filter(
(i) =>
i.tokenExpiry &&
i.tokenExpiry.daysRemaining > 0 &&
i.tokenExpiry.daysRemaining <= 14,
);
const allOk = failing.length === 0 && expired.length === 0 && expiring.length === 0;
return (
<>
<div className="px-4 pt-3 pb-2">
<div className="flex items-center gap-2">
{allOk ? (
<>
<ShieldCheck className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<p className="text-sm font-medium">All systems operational</p>
</>
) : (
<>
<Power className="h-4 w-4 text-destructive" />
<p className="text-sm font-medium">
{failing.length + expired.length + expiring.length} item(s) need attention
</p>
</>
)}
</div>
<p className="text-xs text-muted-foreground mt-1">
<span className="num">{data.summary.ok}</span> healthy ·{' '}
<span className="num">{data.summary.disabled}</span> disabled ·{' '}
<span className="num">{data.summary.notConfigured}</span> unconfigured
</p>
</div>
{failing.length > 0 && (
<>
<Separator />
<IssueGroup
title="Failing"
icon={<XCircle className="h-3.5 w-3.5 text-destructive" />}
>
{failing.map((i) => (
<IssueRow
key={i.key}
primary={i.name}
secondary={
i.status === 'auth_failed' ? 'authentication failed' : 'unreachable'
}
tone="error"
/>
))}
</IssueGroup>
</>
)}
{expired.length > 0 && (
<>
<Separator />
<IssueGroup
title="Expired tokens"
icon={<KeyRound className="h-3.5 w-3.5 text-destructive" />}
>
{expired.map((i) => (
<IssueRow
key={i.key}
primary={i.name}
secondary={`expired ${Math.abs(i.tokenExpiry!.daysRemaining).toFixed(0)} d ago`}
tone="error"
/>
))}
</IssueGroup>
</>
)}
{expiring.length > 0 && (
<>
<Separator />
<IssueGroup
title="Expiring soon"
icon={<KeyRound className="h-3.5 w-3.5 text-amber-500" />}
>
{expiring.map((i) => (
<IssueRow
key={i.key}
primary={i.name}
secondary={`expires in ${i.tokenExpiry!.daysRemaining.toFixed(0)} d`}
tone="warn"
/>
))}
</IssueGroup>
</>
)}
<Separator />
<Link
href="/status"
className="flex items-center justify-between px-4 py-2.5 text-sm hover:bg-accent/40 transition-colors"
>
<span>View full status</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
</Link>
</>
);
}
function IssueGroup({
title,
icon,
children,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="px-4 py-2 space-y-1">
<p className="metric-label flex items-center gap-1.5">
{icon}
{title}
</p>
<ul className="space-y-0.5 mt-1">{children}</ul>
</div>
);
}
function IssueRow({
primary,
secondary,
tone,
}: {
primary: string;
secondary: string;
tone: 'error' | 'warn';
}) {
const toneClass =
tone === 'error'
? 'text-destructive'
: 'text-amber-700 dark:text-amber-400';
return (
<li className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{primary}</span>
<span className={`text-xs num ${toneClass}`}>{secondary}</span>
</li>
);
}