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.query && (

{source.name}

{source.query}
)}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { Card } from "@/components/ui/card"; import { Sparkline } from "../sparkline/Sparkline"; import { SourceBadge, type Source } from "./SourceBadge"; /** * Two panels on one page, each with its own source. Prometheus is current. * The finance sheet is polled every ten minutes and its newest row is twenty * minutes old, so its badge says so. The clock is fixed so both badges render * the same on the server and in the browser. */ const NOW = new Date("2026-09-15T09:00:00Z"); const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000); const PROM: Source = { name: "prom-prod", lastArrival: ago(0.25), expectedEveryMs: 15_000, query: 'sum(rate(http_requests_total{job="api", env="prod"}[5m]))', }; const SHEET: Source = { name: "finance-sheet", lastArrival: ago(20), expectedEveryMs: 10 * 60_000, query: "SELECT date, active_users FROM 'Active users'!A:B ORDER BY date", }; function Panel({ title, source, values }: { title: string; source: Source; values: number[] }) { return (
{title}
); } export default function Demo() { return (
); }