Failures were slot 1 of a ramp that carries identity by lightness alone, so at a 2px stroke the line that matters read as one picture with avg and lag. It takes --brand-secondary now — the same terracotta the brain graph above it already spends on a flow that cannot run. A palette entry naming a token is resolved off the chart's element, since a canvas cannot parse var().
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { ChevronDown, ChevronRight, X } from "lucide-react"
|
|
import { useEffect, useState } from "react"
|
|
|
|
import type { EventRow, HistoryPoint } from "@/client"
|
|
import type { Range } from "@/components/Common/RangePicker"
|
|
import { UplotChart } from "@/components/Common/UplotChart"
|
|
import { useEngineEvents } from "@/components/Flow/liveStore"
|
|
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
|
|
import { Button } from "@/components/ui/button"
|
|
import { cn, dur, si } from "@/lib/utils"
|
|
import {
|
|
ago,
|
|
auditQueryOptions,
|
|
CARD,
|
|
clock,
|
|
deadLetterQueryOptions,
|
|
failuresQueryOptions,
|
|
healthKeys,
|
|
minuteFailuresQueryOptions,
|
|
minuteOf,
|
|
minuteRunsQueryOptions,
|
|
runsQueryOptions,
|
|
timeseriesQueryOptions,
|
|
} from "./queries"
|
|
|
|
/** How much of each list is shown while it is not tied to a moment. */
|
|
const RUNS_SHOWN = 15
|
|
const FAILURES_SHOWN = 25
|
|
|
|
/**
|
|
* A list of rows in a card: capped and scrolled rather than as long as it
|
|
* happens to be.
|
|
*
|
|
* The cap is the visible reason; the scroll container is the structural one. A
|
|
* row is `min-w-0 flex-1 truncate`, and a truncating flex item still offers its
|
|
* whole unwrapped line as a min-content contribution — so without this the card
|
|
* sizes to the longest entry it holds and takes the page sideways with it. See
|
|
* DESIGN-GUIDELINES.md → Responsive.
|
|
*/
|
|
const LIST = "max-h-96 overflow-y-auto"
|
|
|
|
/**
|
|
* A moment picked off a chart.
|
|
*
|
|
* Hovering previews it, so the list under the pointer scrubs along with the
|
|
* cursor. A click holds it: reading the list means moving the pointer off the
|
|
* chart, and a held moment ignores the cursor until it is released.
|
|
*/
|
|
function useMoment() {
|
|
const [hovered, setHovered] = useState<number | null>(null)
|
|
const [pinned, setPinned] = useState<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (pinned === null) return
|
|
const release = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setPinned(null)
|
|
}
|
|
window.addEventListener("keydown", release)
|
|
return () => window.removeEventListener("keydown", release)
|
|
}, [pinned])
|
|
|
|
return {
|
|
/** The minute the paired list is showing, or null for all of it. */
|
|
at: pinned ?? hovered,
|
|
pinned,
|
|
onCursor: setHovered,
|
|
onSelect: (ts: number | null) =>
|
|
setPinned((held) => (ts === null || ts === held ? null : ts)),
|
|
release: () => setPinned(null),
|
|
}
|
|
}
|
|
|
|
type Moment = ReturnType<typeof useMoment>
|
|
|
|
/**
|
|
* The way out of a held moment, on both the chart that set it and the list it
|
|
* drives — the same control in both places, because it is the same action.
|
|
*
|
|
* A control rather than the Escape hint it replaces on the chart: a phone has
|
|
* no Escape key. The shortcut still works, it is just no longer the only way.
|
|
*/
|
|
function ClearPin({ moment }: { moment: Moment }) {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="-my-1 gap-1 px-2 text-xs"
|
|
onClick={moment.release}
|
|
aria-label="Clear the pinned moment"
|
|
>
|
|
<X className="size-3" />
|
|
Clear
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
/** One chart, and the moment it hands to its list. */
|
|
function Chart({
|
|
title,
|
|
labels,
|
|
plots,
|
|
pending,
|
|
moment,
|
|
palette,
|
|
}: {
|
|
title: string
|
|
labels: string[]
|
|
plots: HistoryPoint[][]
|
|
pending: boolean
|
|
moment: Moment
|
|
/** Only where a line means something the ramp has no step for. */
|
|
palette?: string[]
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
CARD,
|
|
"flex h-64 flex-col",
|
|
moment.pinned !== null && "border-primary",
|
|
)}
|
|
>
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<h2 className={PANEL_SECTION}>{title}</h2>
|
|
{moment.pinned !== null ? (
|
|
<ClearPin moment={moment} />
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">click to pin</span>
|
|
)}
|
|
</div>
|
|
<UplotChart
|
|
labels={labels}
|
|
plots={plots}
|
|
palette={palette}
|
|
empty="Nothing has run yet."
|
|
pending={pending}
|
|
onCursor={moment.onCursor}
|
|
onSelect={moment.onSelect}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** What a list is showing, and the way back to all of it. */
|
|
function ListHeader({
|
|
title,
|
|
moment,
|
|
note,
|
|
}: {
|
|
title: string
|
|
moment: Moment
|
|
note?: string
|
|
}) {
|
|
return (
|
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
|
<h2 className={PANEL_SECTION}>{title}</h2>
|
|
{moment.at !== null ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{moment.pinned !== null ? "pinned to" : "showing"} {clock(moment.at)}
|
|
</span>
|
|
) : null}
|
|
{note ? (
|
|
<span className="text-xs text-muted-foreground">{note}</span>
|
|
) : null}
|
|
{moment.pinned !== null ? <ClearPin moment={moment} /> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Failure({ event }: { event: EventRow }) {
|
|
const [open, setOpen] = useState(false)
|
|
const [first, ...rest] = event.detail.split("\n")
|
|
return (
|
|
<div className="border-b border-border py-2 last:border-0">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-start gap-2 text-left"
|
|
onClick={() => setOpen(!open)}
|
|
disabled={rest.length === 0}
|
|
>
|
|
{rest.length ? (
|
|
open ? (
|
|
<ChevronDown className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
)
|
|
) : (
|
|
<span className="size-4 shrink-0" />
|
|
)}
|
|
{/* A traceback's first line runs long; it wraps rather than widening
|
|
the card, and the rest is behind the chevron anyway. */}
|
|
<span className="min-w-0 flex-1 break-words">
|
|
<span className="font-mono text-sm">
|
|
{event.node || event.flow || "engine"}
|
|
</span>
|
|
<span className="ml-2 text-sm text-muted-foreground">{first}</span>
|
|
</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{ago(event.ts)}
|
|
</span>
|
|
</button>
|
|
{open && rest.length ? (
|
|
<pre className="mt-2 max-h-64 overflow-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs">
|
|
{rest.join("\n")}
|
|
</pre>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What the engine has been doing over the selected window, as charts and the
|
|
* records behind them.
|
|
*
|
|
* The two charts each drive the list beside them: throughput picks the runs of
|
|
* a minute, failures and timing picks that minute's failures. The window comes
|
|
* from the health block's one range control, so the lists cover the same span
|
|
* the charts are drawn from rather than however far their newest rows reach.
|
|
*/
|
|
export function HealthActivity({ range }: { range: Range }) {
|
|
const live = useEngineEvents()
|
|
const queryClient = useQueryClient()
|
|
const runsAt = useMoment()
|
|
const failuresAt = useMoment()
|
|
|
|
const { data: series, isPending: seriesPending } = useQuery(
|
|
timeseriesQueryOptions(range),
|
|
)
|
|
const { data: runs } = useQuery(runsQueryOptions(range))
|
|
const { data: pinnedRuns } = useQuery(minuteRunsQueryOptions(runsAt.pinned))
|
|
const { data: failures } = useQuery(failuresQueryOptions(range))
|
|
const { data: pinnedFailures } = useQuery(
|
|
minuteFailuresQueryOptions(failuresAt.pinned),
|
|
)
|
|
const { data: audit } = useQuery(auditQueryOptions())
|
|
const { data: dead } = useQuery(deadLetterQueryOptions())
|
|
|
|
// Something just went wrong on the socket. The row for it is written on the
|
|
// collector's next flush, so the refetch waits that out rather than asking
|
|
// for a failure the database does not have yet.
|
|
//
|
|
// The newest event's minute, not the count: the count stops changing once the
|
|
// ring is full, and a per-event key would let a flapping node restart the
|
|
// timer forever without it ever firing.
|
|
const seen = live.length ? Math.floor(live[live.length - 1].ts / 60) : 0
|
|
useEffect(() => {
|
|
if (!seen) return
|
|
const timer = setTimeout(() => {
|
|
queryClient.invalidateQueries({ queryKey: healthKeys.events })
|
|
queryClient.invalidateQueries({ queryKey: healthKeys.summary })
|
|
}, 16000)
|
|
return () => clearTimeout(timer)
|
|
}, [seen, queryClient])
|
|
|
|
const points = series ?? []
|
|
const at = (
|
|
pick: (point: (typeof points)[number]) => number,
|
|
): HistoryPoint[] =>
|
|
points.map((point) => ({ ts: point.ts, value: pick(point) }))
|
|
|
|
// A pin is read back from the server, so it reaches a minute the recent list
|
|
// is nowhere near deep enough to hold. A hover stays the client-side preview
|
|
// it is: scrubbing a whole window's chart would otherwise be a request per
|
|
// minute the pointer rests on.
|
|
const shownRuns =
|
|
runsAt.pinned !== null
|
|
? (pinnedRuns?.data ?? [])
|
|
: runsAt.at === null
|
|
? (runs?.data ?? []).slice(0, RUNS_SHOWN)
|
|
: (runs?.data ?? []).filter(
|
|
(run) => minuteOf(run.started_at) === runsAt.at,
|
|
)
|
|
// A busy minute writes more runs than one page carries. Without the total
|
|
// behind it, a truncated list is indistinguishable from a quiet minute.
|
|
const runsNote =
|
|
runsAt.pinned !== null && (pinnedRuns?.count ?? 0) > shownRuns.length
|
|
? `showing ${shownRuns.length} of ${si(pinnedRuns?.count ?? 0)}`
|
|
: undefined
|
|
const shownFailures =
|
|
failuresAt.pinned !== null
|
|
? (pinnedFailures ?? [])
|
|
: failuresAt.at === null
|
|
? (failures ?? []).slice(0, FAILURES_SHOWN)
|
|
: (failures ?? []).filter(
|
|
(event) => minuteOf(event.ts) === failuresAt.at,
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<section className="grid gap-4 lg:grid-cols-2">
|
|
<Chart
|
|
title="Throughput per minute"
|
|
labels={["messages", "executions"]}
|
|
plots={[
|
|
at((point) => point.messages),
|
|
at((point) => point.executions),
|
|
]}
|
|
pending={seriesPending}
|
|
moment={runsAt}
|
|
/>
|
|
<Chart
|
|
title="Failures and timing (ms)"
|
|
labels={["errors", "avg", "lag"]}
|
|
plots={[
|
|
at((point) => point.errors),
|
|
at((point) => point.avg_ms),
|
|
at((point) => point.avg_lag_ms),
|
|
]}
|
|
pending={seriesPending}
|
|
moment={failuresAt}
|
|
// Failures are the one line here that is not a measurement of how
|
|
// much ran, and five steps of one blue ramp cannot say so — at a
|
|
// 2px stroke they read as one picture. Terracotta is what the brain
|
|
// graph above already spends on a flow that cannot run.
|
|
palette={["--brand-secondary", "3", "5"]}
|
|
/>
|
|
</section>
|
|
|
|
<section className="grid content-start gap-4 lg:grid-cols-2">
|
|
<div className="grid content-start gap-3">
|
|
<ListHeader title="Recent runs" moment={runsAt} note={runsNote} />
|
|
<div
|
|
className={cn(
|
|
CARD,
|
|
LIST,
|
|
runsAt.pinned !== null && "border-primary",
|
|
)}
|
|
data-testid="recent-runs"
|
|
>
|
|
{shownRuns.length ? (
|
|
shownRuns.map((run) => (
|
|
<div
|
|
key={run.id}
|
|
className="flex items-baseline gap-3 border-b border-border py-2 text-sm last:border-0"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate font-mono">
|
|
{run.flow}
|
|
</span>
|
|
{/* Five fixed columns do not fit a phone. What caused a run
|
|
is the one a narrow row can do without — the status, the
|
|
duration and when it ran are why anyone reads this. */}
|
|
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
|
{run.source}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"shrink-0",
|
|
run.status === "error"
|
|
? "text-destructive"
|
|
: run.status === "ok"
|
|
? "text-status-success"
|
|
: "text-muted-foreground",
|
|
)}
|
|
>
|
|
{run.status}
|
|
</span>
|
|
<span className="w-20 shrink-0 whitespace-nowrap text-right text-muted-foreground">
|
|
{dur(run.duration_ms)}
|
|
</span>
|
|
<span className="w-16 shrink-0 text-right text-xs text-muted-foreground">
|
|
{ago(run.started_at)}
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
{runsAt.at === null
|
|
? "No runs recorded yet."
|
|
: runsAt.pinned !== null
|
|
? "Nothing ran in this minute."
|
|
: "No run from this minute is in the recent list."}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid content-start gap-3">
|
|
<ListHeader title="Failures" moment={failuresAt} />
|
|
<div
|
|
className={cn(
|
|
CARD,
|
|
LIST,
|
|
failuresAt.pinned !== null && "border-primary",
|
|
)}
|
|
data-testid="failures"
|
|
>
|
|
{shownFailures.length ? (
|
|
shownFailures.map((event) => (
|
|
<Failure key={event.id} event={event} />
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
{failuresAt.at === null
|
|
? `Nothing has failed in the last ${range.label}.`
|
|
: failuresAt.pinned !== null
|
|
? "Nothing failed in this minute."
|
|
: "No failure from this minute is in the recent list."}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{dead?.length ? (
|
|
<section className="grid gap-3">
|
|
<h2 className={PANEL_SECTION}>Given up on</h2>
|
|
<div className={cn(CARD, LIST)}>
|
|
{dead.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-baseline gap-3 border-b border-border py-2 text-sm last:border-0"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate font-mono">
|
|
{item.node}
|
|
</span>
|
|
<span className="min-w-0 max-w-[40%] truncate text-muted-foreground">
|
|
{item.reason}
|
|
</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{ago(item.ts)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="grid gap-3">
|
|
<h2 className={PANEL_SECTION}>Changes</h2>
|
|
<div className={cn(CARD, LIST)}>
|
|
{audit?.length ? (
|
|
audit.map((event) => (
|
|
<div
|
|
key={event.id}
|
|
className="flex items-baseline gap-3 border-b border-border py-2 text-sm last:border-0"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{event.actor} {event.detail}
|
|
{event.flow ? (
|
|
<span className="ml-1 font-mono text-muted-foreground">
|
|
{event.flow}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{ago(event.ts)}
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Nothing has changed yet.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</>
|
|
)
|
|
}
|