Data source badge
Where a panel's numbers come from, and whether that source is healthy. Two panels side by side can be drawn from different systems with different lags, and nothing on a chart says so.
npx shadcn@latest add badge button popover card
Tokens this needs: --card, --foreground, --muted-foreground, --border, --state-live, --state-stale, --status-critical, --status-unknown
The status, chart, scale, state and direction names are an extension,
not a rename. shadcn has --destructive and five --chart-* and nothing
else in this territory.
────────────────────────────────────────────────────────────────────────
// SourceBadge.tsx
────────────────────────────────────────────────────────────────────────
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { freshness, FRESHNESS_COLOR } from "../freshness-indicator/freshness";
/**
* A configured source, the way Grafana holds one: a name a viewer recognises,
* the arrival time of its newest sample, the cadence it should arrive at, and
* the query that was run against it.
*/
export type Source = {
name: string;
lastArrival: Date | null;
expectedEveryMs: number;
query?: string;
};
type Props = {
source: Source;
/** The clock to age against. Pass one to render on a server. */
now?: Date;
/** Where "view query" goes when the query is too long for a popover. */
onViewQuery?: (source: Source) => void;
};
/** 20m, 1h 05m. Rounded down: a lag is never reported shorter than it is. */
const lag = (ms: number) => {
const m = Math.floor(ms / 60_000);
const h = Math.floor(m / 60);
return h ? `${h}h ${String(m % 60).padStart(2, "0")}m` : `${m}m`;
};
/**
* Identity, health and derivation on one pill. Identity is always shown.
* Health is computed from the sample age, so a source that answers every
* request but has nothing new to say cannot look current. Derivation opens
* inline, because a query behind a settings gate ends no disputes.
*/
export function SourceBadge({ source, now = new Date(), onViewQuery }: Props) {
const { state, ageMs } = freshness(source.lastArrival, source.expectedEveryMs, now);
const behind = state === "late" || state === "stale";
const text = behind && ageMs !== null ? `${source.name} ${lag(ageMs)}` : source.name;
const said =
state === "unknown" ? `${source.name}, no samples` :
behind ? `${source.name}, ${lag(ageMs ?? 0)} behind` :
`${source.name}, current`;
return (
{source.name}{source.query}