FeedHealth
A strip that shows each feed's connection state, the age of its data, and a staleness tier.
import { useState } from "react"
import { FeedHealth, type FeedDescriptor } from "@/components/ui/feed-health"
export default function FeedHealthDemo() {
const [lastMessageAt, setLastMessageAt] = useState(Date.now)
const feeds: FeedDescriptor[] = [{ id: "md", label: "Market data", state: "connected", lane: "coalesced", lastMessageAt }]
return (
<>
<div data-demo-controls className="text-xs">
<button type="button" className="rounded border px-2 py-1" onClick={() => setLastMessageAt(Date.now())}>Receive a message</button>
</div>
<FeedHealth feeds={feeds} thresholds={{ agingMs: 2000, staleMs: 10_000 }} className="w-fit max-w-full flex-wrap" />
</>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/feed-health#v1.4.13
npx shadcn@latest add tradecn/ui/feed-health#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/feed-health#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/feed-health#v1.4.13
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge dropdown-menu separator spinner tooltipnpx shadcn@latest add badge dropdown-menu separator spinner tooltipyarn dlx shadcn@latest add badge dropdown-menu separator spinner tooltipbunx --bun shadcn@latest add badge dropdown-menu separator spinner tooltip -
Copy the files into your project:
components/ui/ feed-health.tsx import { cn } from "cn" import { useEffect, useRef, useState } from "react" import { Badge } from "@/components/ui/badge" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Separator } from "@/components/ui/separator" import { Spinner } from "@/components/ui/spinner" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { useNow } from "@/hooks/use-clock" import { createClock, sharedClock, type Clock } from "@/lib/clock" // A strip that says, for each feed, whether it is connected and how old its data is. // // A number that admits it is two seconds old can be worked with; a frozen screen cannot. So every // feed gets a state, an age, and a tier, and past a threshold the tier says stale. Only the leaves // subscribe to the clock: the strip and the app around it do not re-render every second. // // A feed may also offer actions, the ids the server allows on it (pause, resume, reconnect, // resubscribe are the usual set) with the consumer's labels, in a menu on the feed. A press is a // request: the feed shows it pending until its state changes or a while passes, and the tier never // moves on a click, only on what the feed reports. export type FeedState = "connected" | "connecting" | "disconnected" | "unknown" export type FeedLane = "coalesced" | "ordered" export type Tier = "live" | "aging" | "stale" | "offline" | "closed" export interface FeedDescriptor { id: string label: string state: FeedState /** `coalesced` lanes drop stale ticks and count them; `ordered` lanes never drop and can have gaps. */ lane: FeedLane /** Wall clock of the last message, ms since epoch. */ lastMessageAt: number | null /** Messages dropped so far (coalesced lane). */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A sequence gap is open (ordered lane). */ gap?: { since: number; replaying: boolean } | null /** What the server allows on this feed now, by id. No list means nothing may. */ allowedActions?: readonly string[] } export interface FeedAction { /** Matched against each feed's `allowedActions`. */ id: string label: string /** Given the feed as it was when pressed. A returned promise settles the pending mark when it resolves or rejects. */ run: (feed: FeedDescriptor) => void | Promise<unknown> destructive?: boolean } export interface FeedHealthLabels { /** The menu button's name. `{feed}` is the feed's label. */ actions: string /** The tooltip's word for an action that is out. */ pending: string } export const DEFAULT_FEED_HEALTH_LABELS: FeedHealthLabels = { actions: "Actions: {feed}", pending: "Pending" } /** The actions a feed offers now: yours, in your order, kept to the ids in its `allowedActions`. */ export function feedActionsFor(feed: FeedDescriptor, actions: readonly FeedAction[] | undefined): FeedAction[] { if (!actions?.length || !feed.allowedActions?.length) return [] return actions.filter((action) => feed.allowedActions!.includes(action.id)) } /** An action out on a feed: which, the state the feed was in, and when. It clears when the state moves, when `pendingMs` lapses, or when the run's promise settles. */ export interface PendingFeedAction { action: string state: FeedState since: number } export interface StalenessThresholds { agingMs: number staleMs: number } /** Placeholders until the people who trade on the data agree on real ones. */ export const PROVISIONAL_THRESHOLDS: StalenessThresholds = { agingMs: 2000, staleMs: 10_000 } export interface SessionCalendar { status(now: number): "open" | "closed" | "pre" | "post" | "holiday" } export const alwaysOpen: SessionCalendar = { status: () => "open" } /** Pure tiering. A disconnect is offline whatever the hour; a closed session is closed, not stale. */ export function stalenessTier(feed: FeedDescriptor, now: number, thresholds: StalenessThresholds = PROVISIONAL_THRESHOLDS, session: SessionCalendar = alwaysOpen): Tier { if (feed.state === "disconnected") return "offline" const status = session.status(now) if (status === "closed" || status === "holiday") return "closed" if (feed.lastMessageAt === null) return "aging" const age = now - feed.lastMessageAt if (age < thresholds.agingMs) return "live" if (age < thresholds.staleMs) return "aging" return "stale" } /** "now", "12s", "3m", "2h". */ export function formatAge(ms: number | null): string { if (ms === null || !Number.isFinite(ms)) return "–" if (ms < 1000) return "now" if (ms < 60_000) return `${Math.floor(ms / 1000)}s` if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m` return `${Math.floor(ms / 3_600_000)}h` } // The clock lives in lib/clock so a countdown ticks on the same interval. Exported from here still, so nothing that imported it moves. export { createClock } export type { Clock } const TIER_CLASS: Record<Tier, string> = { live: "text-foreground", aging: "text-stale/80", stale: "bg-stale-soft text-stale", offline: "text-destructive", closed: "text-muted-foreground", } const STATE_DOT: Record<FeedState, string> = { connected: "bg-up", connecting: "animate-pulse bg-stale", disconnected: "bg-destructive", unknown: "bg-muted-foreground", } export interface FeedAgeProps { feed: FeedDescriptor clock?: Clock className?: string } /** The age of a feed's data, ticking once a second. Hidden from assistive tech; the strip announces tier changes instead. */ export function FeedAge({ feed, clock, className }: FeedAgeProps) { const now = useNow(clock ?? sharedClock()) return ( <span aria-hidden data-numeric="" className={cn("lining-nums tabular-nums", className)}> {formatAge(feed.lastMessageAt === null ? null : Math.max(0, now - feed.lastMessageAt))} </span> ) } interface ItemProps { feed: FeedDescriptor thresholds: StalenessThresholds session: SessionCalendar clock: Clock compact: boolean actions: readonly FeedAction[] pending: PendingFeedAction | undefined labels: FeedHealthLabels onRun: (feed: FeedDescriptor, action: FeedAction) => void } const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? "") function FeedItem({ feed, thresholds, session, clock, compact, actions, pending, labels, onRun }: ItemProps) { const now = useNow(clock) const tier = stalenessTier(feed, now, thresholds, session) const offered = feedActionsFor(feed, actions) const pendingLabel = pending ? (actions.find((a) => a.id === pending.action)?.label ?? pending.action) : null return ( <> <Tooltip> <TooltipTrigger data-feed={feed.id} data-tier={tier} data-state={feed.state} data-pending={pending?.action} className={cn("inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/40", TIER_CLASS[tier])} > <span aria-hidden className={cn("size-1.5 rounded-full", STATE_DOT[feed.state])} /> <span className="font-medium">{feed.label}</span> {!compact && ( <Badge variant="outline" className={cn("h-4 px-1 text-xs uppercase", TIER_CLASS[tier])}> {tier} </Badge> )} <FeedAge feed={feed} clock={clock} /> {feed.lane === "coalesced" && Boolean(feed.dropped) && <span className="text-muted-foreground lining-nums tabular-nums">{`drop ${feed.dropped!.toLocaleString()}`}</span>} {feed.lane === "ordered" && feed.gap && ( <span className="inline-flex items-center gap-1 text-stale"> {feed.gap.replaying && <Spinner className="size-3" />} {`gap ${formatAge(Math.max(0, now - feed.gap.since))}`} </span> )} {/* An action out on the feed: the spinner and the word; the tier is untouched. */} {pending && ( <span data-feed-pending={pending.action} className="inline-flex items-center gap-1 text-muted-foreground"> <Spinner className="size-3" /> {pendingLabel} </span> )} </TooltipTrigger> <TooltipContent> <div className="grid grid-cols-[auto_auto] gap-x-3 gap-y-0.5 text-left lining-nums tabular-nums"> <span>State</span> <span>{feed.state}</span> <span>Data</span> <span>{tier}</span> <span>Last message</span> <span>{feed.lastMessageAt === null ? "–" : new Date(feed.lastMessageAt).toLocaleTimeString()}</span> {pending && ( <> <span>{labels.pending}</span> <span>{pendingLabel}</span> </> )} {feed.lane === "ordered" && feed.seq !== undefined && ( <> <span>Sequence</span> <span>{feed.seq.toLocaleString()}</span> </> )} {feed.lane === "coalesced" && ( <> <span>Dropped</span> <span>{(feed.dropped ?? 0).toLocaleString()}</span> </> )} {feed.gap && ( <> <span>Gap</span> <span>{feed.gap.replaying ? "replaying" : "open"}</span> </> )} </div> </TooltipContent> </Tooltip> {/* The menu, only for a feed the server allows something on. Its items are held while an action is out. */} {offered.length > 0 && ( <DropdownMenu> <DropdownMenuTrigger aria-label={fill(labels.actions, { feed: feed.label })} data-feed-actions={feed.id} className="rounded px-1 text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40" > <svg aria-hidden width="10" height="10" viewBox="0 0 10 10" fill="currentColor"> <circle cx="5" cy="1.5" r="1.2" /> <circle cx="5" cy="5" r="1.2" /> <circle cx="5" cy="8.5" r="1.2" /> </svg> </DropdownMenuTrigger> <DropdownMenuContent align="start"> {offered.map((action) => ( <DropdownMenuItem key={action.id} disabled={Boolean(pending)} data-feed-action={action.id} className={cn(action.destructive && "text-destructive")} onClick={() => onRun(feed, action)}> {action.label} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> )} </> ) } function Announcer({ feeds, thresholds, session, clock }: Pick<ItemProps, "thresholds" | "session" | "clock"> & { feeds: FeedDescriptor[] }) { const now = useNow(clock) const tiers = feeds.map((f) => `${f.id}=${stalenessTier(f, now, thresholds, session)}`).join("|") const [seen, setSeen] = useState(tiers) const [message, setMessage] = useState("") // Derived state, set during render: speak only when a tier changes, never on a plain tick. if (seen !== tiers) { const before = new Map(seen.split("|").map((p) => p.split("=") as [string, string])) const changes = feeds .map((f) => ({ f, tier: stalenessTier(f, now, thresholds, session) })) .filter(({ f, tier }) => before.get(f.id) !== tier) .map(({ f, tier }) => `${f.label} ${tier}${tier === "stale" || tier === "aging" ? `, ${formatAge(f.lastMessageAt === null ? null : now - f.lastMessageAt)}` : ""}`) setSeen(tiers) if (changes.length) setMessage(changes.join(". ")) } return ( <span aria-live="polite" className="sr-only"> {message} </span> ) } export interface FeedHealthProps { feeds: FeedDescriptor[] thresholds?: StalenessThresholds session?: SessionCalendar clock?: Clock /** Hide the tier pill; the color and the age still show. */ compact?: boolean /** What can be done to a feed, in your order and your words. A feed offers only the ids in its `allowedActions`, in a menu of its own. */ actions?: readonly FeedAction[] /** How long an action shows pending when neither the feed's state nor the run's promise settles it. Default 5000. */ pendingMs?: number labels?: Partial<FeedHealthLabels> className?: string } const NO_ACTIONS: readonly FeedAction[] = [] const NO_PENDING: Readonly<Record<string, PendingFeedAction>> = {} export function FeedHealth({ feeds, thresholds = PROVISIONAL_THRESHOLDS, session = alwaysOpen, clock, compact = false, actions = NO_ACTIONS, pendingMs = 5000, labels: labelsProp, className }: FeedHealthProps) { const c = clock ?? sharedClock() const labels = { ...DEFAULT_FEED_HEALTH_LABELS, ...labelsProp } // Actions out, by feed id. Derived state settled during render: a feed whose state moved since the press is answered. const [pending, setPending] = useState(NO_PENDING) let settled: Record<string, PendingFeedAction> | null = null for (const [id, entry] of Object.entries(pending)) { const feed = feeds.find((f) => f.id === id) if (feed && feed.state === entry.state) continue settled ??= { ...pending } delete settled[id] } const live = settled ?? pending if (settled) setPending(settled) const latest = useRef({ actions, pendingMs }) useEffect(() => { latest.current = { actions, pendingMs } }) const clear = (id: string, since: number) => setPending((current) => (current[id]?.since === since ? Object.fromEntries(Object.entries(current).filter(([key]) => key !== id)) : current)) const run = (feed: FeedDescriptor, action: FeedAction) => { // Asked of the feed as it is now, not as it was drawn. if (!feed.allowedActions?.includes(action.id) || live[feed.id]) return const since = c.now() setPending((current) => ({ ...current, [feed.id]: { action: action.id, state: feed.state, since } })) setTimeout(() => clear(feed.id, since), latest.current.pendingMs) let result: void | Promise<unknown> try { result = action.run(feed) } catch { clear(feed.id, since) return } if (result && typeof (result as Promise<unknown>).then === "function") (result as Promise<unknown>).then(() => clear(feed.id, since), () => clear(feed.id, since)) } // Radix tooltips throw without a provider above them; Base UI tooltips do not need one. Every style // exports TooltipProvider, so the strip brings its own and works in a consumer that never added one. return ( <TooltipProvider> <div role="group" aria-label="Feed health" data-slot="tradecn-feed-health" className={cn("flex items-center gap-1 lining-nums tabular-nums", className)}> {feeds.map((feed, i) => ( <span key={feed.id} className="inline-flex items-center gap-1"> {i > 0 && <Separator orientation="vertical" className="h-3" />} <FeedItem feed={feed} thresholds={thresholds} session={session} clock={c} compact={compact} actions={actions} pending={live[feed.id]} labels={labels} onRun={run} /> </span> ))} <Announcer feeds={feeds} thresholds={thresholds} session={session} clock={c} /> </div> </TooltipProvider> ) }hooks/use-clock.ts import { useSyncExternalStore } from "react" import { sharedClock, type Clock } from "@/lib/clock" /** The clock's time. The caller re-renders on each tick and on nothing else. The shared one-second clock unless given another. */ export function useNow(clock?: Clock): number { const c = clock ?? sharedClock() return useSyncExternalStore(c.subscribe, c.now, c.now) }lib/clock.ts // A clock shared by everything that ticks: one interval, running only while someone listens. // // Feed ages and countdowns both want "now" once a second, and a screen with forty of them wants one // timer, not forty. `now()` is the time of the last tick and is stable between ticks, which is what // useSyncExternalStore needs from a snapshot: two reads with no tick between them agree. export interface Clock { subscribe(cb: () => void): () => void /** The time of the last tick; stable between ticks. */ now(): number } /** One interval shared by every subscriber; it runs only while someone is listening. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, intervalMs) } return () => { listeners.delete(cb) if (!listeners.size && timer !== null) { clearInterval(timer) timer = null } } }, } } let shared: Clock | null = null /** The one-second clock every tradecn item ticks on unless it is given another. Made on first use. */ export function sharedClock(): Clock { return (shared ??= createClock(1000)) } -
Add the tokens to your stylesheet:
:root { --stale: oklch(0.52 0.13 75); --stale-soft: oklch(0.52 0.13 75 / 16%); --up: oklch(0.52 0.13 165); } .dark { --stale: oklch(0.8 0.15 80); --stale-soft: oklch(0.8 0.15 80 / 16%); --up: oklch(0.74 0.15 165); }
Usage
import { useState } from "react"
import { FeedHealth, type FeedDescriptor } from "@/components/ui/feed-health"
function MarketDataHealth() {
const [lastMessageAt, setLastMessageAt] = useState(Date.now)
const feeds: FeedDescriptor[] = [{ id: "md", label: "Market data", state: "connected", lane: "coalesced", lastMessageAt }]
return (
<>
<div data-demo-controls className="text-xs">
<button type="button" className="rounded border px-2 py-1" onClick={() => setLastMessageAt(Date.now())}>Receive a message</button>
</div>
<FeedHealth feeds={feeds} thresholds={{ agingMs: 2000, staleMs: 10_000 }} className="w-fit max-w-full flex-wrap" />
</>
)
}
The feed starts with a message timestamp. Without another message, its data becomes aging at two seconds and stale at ten seconds, on the shared clock's next tick. Receive a message supplies a new timestamp and makes it live again. The connection stays connected throughout: a connection and fresh data are separate facts.
These thresholds are sample values. Agree real boundaries with the people using the data, then pass both values. The component reads descriptors from your feed integration; it does not connect or publish messages. Its shared clock starts and stops with subscribers, and a quiet example deliberately stays stale until you send another message.
Lanes and compact display
Coalesced market data reports seven dropped updates. The ordered RFQ feed reports sequence 42 in its tooltip and a three-second gap while replay is in progress. Close RFQ gap removes that report; open it to restore the sample. Drops and gaps do not change either feed's live tier.
This example fixes the clock at a sample instant so the readings stay comparable. Toggle Compact to hide the tier badges while keeping names, ages and lane details. Hover or focus a feed to read its tooltip. The strip wraps to fit a narrow preview.
import { useState } from "react"
import { FeedHealth, type Clock, type FeedDescriptor } from "@/components/ui/feed-health"
// Freeze the sample so its lane details stay comparable while changing the presentation.
const now = Date.parse("2026-09-22T14:00:00Z")
const clock: Clock = { now: () => now, subscribe: () => () => {} }
export default function FeedHealthLanesDemo() {
const [compact, setCompact] = useState(false)
const [hasGap, setHasGap] = useState(true)
const feeds: FeedDescriptor[] = [
{ id: "md", label: "Market data", state: "connected", lane: "coalesced", lastMessageAt: now - 1000, dropped: 7 },
{ id: "rfq", label: "RFQ", state: "connected", lane: "ordered", lastMessageAt: now - 1000, seq: 42, gap: hasGap ? { since: now - 3000, replaying: true } : null },
]
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={compact} onChange={(event) => setCompact(event.target.checked)} />Compact</label>
<button type="button" className="rounded border px-2 py-1" onClick={() => setHasGap(!hasGap)}>{hasGap ? "Close RFQ gap" : "Open RFQ gap"}</button>
</div>
<FeedHealth feeds={feeds} clock={clock} compact={compact} className="w-fit max-w-full flex-wrap" />
</>
)
}
Actions and replies
Open Actions: RFQ and choose Reconnect. The disconnected feed stays offline while the request is pending. A simulated reply arrives after 1.2 seconds, marks it connected and supplies a message timestamp. Its allowed action then becomes Resubscribe, which waits for another reply and advances the sequence without changing the connection state.
The action returns a promise, so pending clears even when the reply leaves the connection state unchanged. Disconnect RFQ resets the sample for another run; it is disabled while a reply is pending. The reply timer is cleared on unmount. In an integration, return the server request's promise and update the descriptor from its response. Pending alone does not mean success; report a rejected request yourself.
There is no continuing publisher here. Once connected, the data ages until another reply supplies a timestamp. Pause and resume use the same action-id and allowed-actions contract when your feed supports them.
import { useEffect, useRef, useState } from "react"
import { FeedHealth, type FeedAction, type FeedDescriptor } from "@/components/ui/feed-health"
const disconnected: FeedDescriptor = { id: "rfq", label: "RFQ", state: "disconnected", lane: "ordered", lastMessageAt: null, seq: 0, allowedActions: ["reconnect"] }
export default function FeedHealthActionsDemo() {
const [feed, setFeed] = useState(disconnected)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("Disconnected.")
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => () => {
if (timer.current !== null) clearTimeout(timer.current)
}, [])
// Stand in for a server reply; returning the promise lets the strip settle pending.
const request = (current: FeedDescriptor) => new Promise<void>((resolve) => {
setBusy(true)
setMessage("Waiting for reply.")
timer.current = setTimeout(() => {
timer.current = null
setFeed({ ...current, state: "connected", lastMessageAt: Date.now(), seq: (current.seq ?? 0) + 1, allowedActions: ["resubscribe"] })
setBusy(false)
setMessage(current.state === "disconnected" ? "Reconnected." : "Resubscribed.")
resolve()
}, 1200)
})
const actions: FeedAction[] = [
{ id: "reconnect", label: "Reconnect", run: request },
{ id: "resubscribe", label: "Resubscribe", run: request },
]
return (
<>
<div data-demo-controls className="text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={busy || feed.state === "disconnected"} onClick={() => { setFeed(disconnected); setMessage("Disconnected.") }}>Disconnect RFQ</button>
</div>
<div className="w-fit max-w-full space-y-2">
<FeedHealth feeds={[feed]} actions={actions} className="flex-wrap" />
<p role="status" className="text-xs text-muted-foreground">{message}</p>
</div>
</>
)
}
API Reference
Props
FeedHealthProps accepts these inputs:
| Prop | Type | Default | Purpose |
|---|---|---|---|
feeds |
FeedDescriptor[] |
Required | Feeds in display order. Use distinct, stable ids. |
thresholds |
StalenessThresholds |
PROVISIONAL_THRESHOLDS |
Age boundaries in milliseconds. |
session |
SessionCalendar |
alwaysOpen |
Calendar shared by all feeds in the strip. |
clock |
Clock |
Shared one-second clock | Time source for ages, tiers, and pending-action timestamps. |
compact |
boolean |
false |
Hides the tier badge; keeps the dot, label, age, lane details, and actions. |
actions |
readonly FeedAction[] |
[] |
Available actions, in menu order. |
pendingMs |
number |
5000 |
Timeout in milliseconds for a pending action. |
labels |
Partial<FeedHealthLabels> |
DEFAULT_FEED_HEALTH_LABELS |
Overrides the action button's accessible name and pending tooltip label. |
className |
string |
Omitted | Classes on the outer group, named Feed health. |
Feeds
Each FeedDescriptor supplies the connection state and message metadata. The component displays them; it does not connect, detect gaps, or replay messages.
| Field | Type | Required | Purpose |
|---|---|---|---|
id |
string |
Yes | Identity for rendering, pending actions, and announcements. |
label |
string |
Yes | Visible feed name. |
state |
FeedState |
Yes | "connected", "connecting", "disconnected", or "unknown"; sets the state dot. |
lane |
FeedLane |
Yes | "coalesced" or "ordered"; selects lane details. |
lastMessageAt |
number | null |
Yes | Last message time in milliseconds since the epoch; null means no timestamp. |
dropped |
number |
No | Cumulative dropped count for a coalesced lane; defaults to zero in the tooltip and appears inline when nonzero. |
seq |
number |
No | Last applied sequence, shown in an ordered lane's tooltip when supplied. |
gap |
{ since: number; replaying: boolean } | null |
No | Open gap; since is milliseconds since the epoch. Omit or pass null for none. |
allowedActions |
readonly string[] |
No | Action ids currently allowed on this feed; omission or an empty list allows none. |
Tiers
stalenessTier(feed, now, thresholds, session) returns the first matching tier below. It is a pure helper for using the same tier elsewhere, such as graying out a grid; its threshold and session defaults match the component.
| Condition, in precedence order | Tier |
|---|---|
state is "disconnected", whatever the session |
offline |
Session status is "closed" or "holiday" |
closed |
lastMessageAt is null |
aging |
now - lastMessageAt < agingMs |
live |
now - lastMessageAt < staleMs |
aging |
| Otherwise | stale |
At the boundaries, age equal to agingMs is aging and age equal to staleMs is stale. "connecting" and "unknown" use the same age rules as "connected", so either can have a live tier. A null timestamp stays aging while the session is active. Drops and gaps do not change the tier.
Lanes
Coalesced lanes report intentional drops of stale ticks. Ordered lanes carry messages that must not be dropped; they report an open gap's age, with a spinner when gap.replaying is true. The tooltip also shows connection state, tier, and last-message time. It shows gap status whenever gap is supplied, even on a coalesced lane.
To map a row-store's useStoreMeta into a descriptor, use lane, dropped, lastBatchAt as lastMessageAt, and seq ?? undefined. Supply the feed's identity, label, and connection state yourself. Store metadata has only a boolean gap; track its opening time and replay state in your feed integration to build { since, replaying }.
Thresholds and the session
StalenessThresholds requires agingMs and staleMs, both numbers in milliseconds. PROVISIONAL_THRESHOLDS is { agingMs: 2000, staleMs: 10_000 }. These are placeholders: choose thresholds with the people who use the data. Supply the whole object with 0 <= agingMs < staleMs; the component does not validate or reorder it.
SessionCalendar needs one method: status(now: number), returning "open", "closed", "pre", "post", or "holiday". now is milliseconds since the epoch. Pre- and post-session feeds still age normally; only closed and holiday suppress staleness, and neither overrides a disconnection. Real exchange calendars are yours to supply; session-calendar builds one from sessions, holidays, and early closes in the venue's zone.
Actions
Use FeedAction for operations such as pause, resume, reconnect, and resubscribe:
| Field | Type | Required | Purpose |
|---|---|---|---|
id |
string |
Yes | Matches the feed's allowedActions. Use distinct action ids. |
label |
string |
Yes | Menu text and pending-action label. |
run |
(feed: FeedDescriptor) => void | Promise<unknown> |
Yes | Receives the descriptor used for the press. A returned promise clears pending on resolution or rejection. |
destructive |
boolean |
No | Adds destructive text color; does not add confirmation. |
feedActionsFor(feed, actions) filters actions against allowedActions, preserving your action order and labels, as blotter and the tickets do. The feed gets a menu on your dropdown-menu only when at least one action matches. Missing actions or missing allowed ids means no menu.
A press shows a spinner and the action label, then disables that feed's menu items while pending. Other feeds remain usable. Pending clears when the feed's state changes, the feed is removed, the returned promise settles, run throws, or the timeout expires. Returning void alone does not clear it. Changing pendingMs does not reschedule an existing timeout; the timeout uses setTimeout, independently of clock ticks.
Pending indicates a request, not success. The component catches synchronous errors and promise rejections without displaying an error; report failures in your integration. A press does not change the tier, which still follows the feed, session, and clock.
Pending actions are identified by feed id and the clock's cached timestamp. If a cleared action's timer or promise settles after a newer action starts on the same id before the clock advances, it can clear the newer pending mark. Removing and re-adding that id does not prevent this.
labels.actions defaults to "Actions: {feed}", with {feed} replaced by the feed label. labels.pending defaults to "Pending" in the tooltip. These overrides do not translate tier names, other tooltip labels, or the outer group's name.
The clock
Feed items, ages, and the announcer subscribe to the clock. The default shared one-second interval runs while it has subscribers and stops after the last unsubscribe; it is shared with other ticking components. Clock ticks do not re-render the strip or its parent.
createClock(intervalMs = 1000, source = Date.now) is exported here for custom clocks and tests. A Clock provides now(): number and subscribe(cb): () => void, which returns an unsubscribe function. The created clock caches its timestamp between ticks and also refreshes it when its first subscriber arrives after inactivity. Use epoch milliseconds to match feed timestamps.
FeedAge displays an age on its own:
| Prop | Type | Default | Purpose |
|---|---|---|---|
feed |
FeedDescriptor |
Required | Supplies the last-message timestamp. |
clock |
Clock |
Shared one-second clock | Time source for the age. |
className |
string |
Omitted | Classes on the age span. |
Ages clamp future timestamps to zero and are hidden from assistive technology. formatAge(ms) formats milliseconds as now below one second, then whole seconds, minutes, or hours (12s, 3m, 2h); null or nonfinite input displays –.
The polite live region initializes empty. It updates when a feed changes tier or a new feed is added, combining simultaneous changes into one message. A clock that refreshes an old timestamp on subscription can cause a tier change and announcement during mounting. Aging and stale announcements include the age at that transition, such as "Market data stale, 10s". Plain age ticks, label changes, and removals do not create a new message, so the spoken age does not keep counting.
Tokens
The install adds the stale and up tokens to your stylesheet if you do not have them.
Built on shadcn's badge, dropdown-menu, separator, spinner, and tooltip.
This page is docs/feed-health.md at v1.4.13.