**nginx served the bundle uncompressed and uncacheable.** The base image
ships gzip commented out and nothing set `Cache-Control`, so every load
carried the whole thing and every reload cost a 304 per asset. Measured on
the built image: the entry chunk 838 kB → 311 kB, the Monaco chunk 2.66 MB
→ 832 kB, and the ~50 content-hashed assets are now immutable for a year.
`index.html` is explicitly `no-cache`, since it is what names the rest.
**A widget that throws no longer blanks the screen.** There was one error
boundary in the app, on the root route, so anything that threw replaced
everything including the navigation — on `/view/{name}`, an unattended wall
panel with no way back. Each tile has its own boundary now, and the app
shell has one inside it so a screen that fails leaves the sidebar standing.
`react-error-boundary` was already a dependency and imported nowhere.
**`localStorage` cannot take the app down.** Reaching it raises where the
browser blocks site data, and `setItem` raises once the origin's quota is
full — which the flow editor's node clipboard, carrying whole Python
sources, can genuinely reach. Thrown from a key handler that escaped to
`window.onerror`, which the single root boundary then turned into a blank
page. `lib/safeStorage.ts` is the guarded pair the pre-paint theme script in
`index.html` was already using; a copy too large to store now says so.
Queries default to `staleTime: 5000` — below every poll interval on any
screen, so nothing polls less often than it did, but a route mounting twice
in a few seconds stops refetching everything it touches. Window-focus
refetching is off: the socket pushes what changes and a reconnect
invalidates what it feeds, so a focus event has nothing of its own to say.
Home alone reads about ten queries on every one of those.
Render cost, two that showed up in the audit:
- `LogsPanel` was rendered unconditionally by the dock and decided inside
itself whether to draw, so with the panel *shut* it still subscribed to
the log store and re-filtered five hundred lines per line a flow
published. It returns before any of that now.
- `HealthActivity` subscribed to the whole engine-event array and used one
number from it, so a flapping node re-rendered the component that draws
Home's two uPlot charts — each of which rebuilds its series on every
render by design. It subscribes to that number.
- the global search bucketed the index nine times per keystroke, once per
group. One pass, and each group offers at most twenty.
461 lines
16 KiB
TypeScript
461 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 { useEngineEventMinute } 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 seen = useEngineEventMinute()
|
|
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. Subscribed to as that scalar rather
|
|
// than as the events array, so an event inside a minute already seen does
|
|
// not re-render the two charts below.
|
|
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>
|
|
</>
|
|
)
|
|
}
|