Files
cyclone/src/components/TailStatusPill.tsx
T

123 lines
3.8 KiB
TypeScript

// ---------------------------------------------------------------------------
// Live-tail status pill (sub-project 5, Phase 5 Task 21).
//
// Tiny presentational component used in the toolbar of `Claims`,
// `Remittances`, and `ActivityLog` to surface the connection status of
// `useTailStream`. Renders the existing `<Badge>` (variants from
// `src/components/ui/badge.tsx`) plus a "Last event: 12s ago" subtitle
// that re-ticks every second, plus a "↻ Reconnect" button that only
// appears when the connection has visibly failed (`stalled` or `error`).
//
// Variant mapping (per spec):
// live → success
// connecting → warning
// reconnecting → warning
// closed → destructive
// stalled → destructive
// error → destructive
// ---------------------------------------------------------------------------
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { TailStatus } from "@/hooks/useTailStream";
export interface TailStatusPillProps {
status: TailStatus;
lastEventAt: Date | null;
onReconnect: () => void;
}
const STATUS_LABEL: Record<TailStatus, string> = {
connecting: "Connecting…",
live: "Live",
reconnecting: "Reconnecting…",
closed: "Closed",
stalled: "Stalled",
error: "Error",
};
type BadgeVariant = "success" | "warning" | "destructive";
const STATUS_VARIANT: Record<TailStatus, BadgeVariant> = {
live: "success",
connecting: "warning",
reconnecting: "warning",
closed: "destructive",
stalled: "destructive",
error: "destructive",
};
/**
* Render a duration in seconds as a compact human label. We deliberately
* keep the units small — under a minute is the common case for a healthy
* tail (the stall detector fires at 30s, so anything beyond is a real
* outage the user will want to investigate).
*/
function formatAge(seconds: number): string {
if (seconds < 0) return "0s";
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m < 60) return s === 0 ? `${m}m` : `${m}m ${s}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
export function TailStatusPill({
status,
lastEventAt,
onReconnect,
}: TailStatusPillProps): React.JSX.Element {
// Re-tick every second so the "Last event: 12s ago" subtitle stays
// current without the parent having to re-render. We mount the
// interval only once per pill instance.
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const ageSeconds = lastEventAt
? Math.max(0, Math.floor((now - lastEventAt.getTime()) / 1000))
: null;
const subtitle =
ageSeconds === null
? "Last event: never"
: `Last event: ${formatAge(ageSeconds)} ago`;
// Only show the manual reconnect affordance when the connection has
// visibly failed. `closed` is reserved for the post-unmount state and
// is intentionally not actionable — the component is about to vanish.
const showReconnect = status === "stalled" || status === "error";
return (
<div
className="inline-flex items-center gap-2"
data-testid="tail-status-pill"
>
<Badge variant={STATUS_VARIANT[status]} aria-label={`tail status: ${status}`}>
{STATUS_LABEL[status]}
</Badge>
<span
className="text-xs text-muted-foreground"
data-testid="tail-status-pill-subtitle"
>
{subtitle}
</span>
{showReconnect && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onReconnect}
data-testid="tail-status-pill-reconnect"
>
Reconnect
</Button>
)}
</div>
);
}