Home charts execution time per node type on a log scale, from a /observability/node-types rollup

This commit is contained in:
2026-10-15 16:57:07 +02:00
parent fa8b178866
commit 56fa34c2c9
9 changed files with 360 additions and 3 deletions
@@ -0,0 +1,153 @@
import { useQuery } from "@tanstack/react-query"
import type { NodeTypeTiming } from "@/client"
import type { Range } from "@/components/Common/RangePicker"
import { nodeTypesQueryOptions } from "@/components/Flow/queries"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { Skeleton } from "@/components/ui/skeleton"
import { dur, si } from "@/lib/utils"
import { CARD, nodeTypeTimingsQueryOptions } from "./queries"
/**
* One row's columns: the type, its bar, its average. A phone puts the bar on a
* line of its own under the other two, where it has the card's whole width.
*/
const COLUMNS =
"grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 sm:grid-cols-[12rem_minmax(0,1fr)_5rem]"
/** The fastest the axis reaches down to, in ms: a microsecond. */
const FLOOR = 0.001
/**
* The axis, in whole decades of milliseconds: from below the fastest type to
* above the slowest, two decades at least so a bar has room to differ.
*/
function decades(timings: NodeTypeTiming[]): number[] {
const measured = timings.map((t) => t.avg_ms).filter((ms) => ms >= FLOOR)
if (!measured.length) return []
const low = Math.floor(Math.log10(Math.min(...measured)))
const high = Math.max(Math.ceil(Math.log10(Math.max(...measured))), low + 2)
return Array.from({ length: high - low + 1 }, (_, i) => 10 ** (low + i))
}
/** Where a duration sits along the axis, as a percentage. */
function at(ms: number, axis: number[]): number {
const low = Math.log10(axis[0])
const high = Math.log10(axis[axis.length - 1])
return ((Math.log10(ms) - low) / (high - low)) * 100
}
/**
* How long each kind of node takes per run, on a log scale.
*
* One bar per node type at its average, slowest first, so a hotspot is the top
* row. The scale is logarithmic because node types sit decades apart, a timer
* at microseconds and a model at seconds, and a linear one shows only the
* slowest. A type that ran without a measurable duration has no bar.
*/
export function NodeTypeTimes({ range }: { range: Range }) {
const { data, isPending } = useQuery(nodeTypeTimingsQueryOptions(range))
const { data: types } = useQuery(nodeTypesQueryOptions())
const titles = new Map((types ?? []).map((info) => [info.type, info.title]))
const timings = data ?? []
const axis = decades(timings)
return (
<section className="grid gap-3" data-testid="node-type-times">
<div className="flex flex-wrap items-baseline gap-x-3">
<h2 className={PANEL_SECTION}>Execution time per node type</h2>
<p className="text-muted-foreground text-xs">
average per run over the last {range.label}, log scale
</p>
</div>
<div className={CARD}>
{isPending ? (
<div className="grid gap-3">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-5 w-1/2" />
</div>
) : timings.length === 0 ? (
<p className="text-muted-foreground text-sm">Nothing has run yet.</p>
) : (
<div className="grid">
{timings.map((timing) => (
<Row
key={timing.type}
timing={timing}
title={titles.get(timing.type) ?? timing.type}
axis={axis}
/>
))}
{axis.length ? (
<div className={COLUMNS}>
<div className="relative col-span-2 mt-1 h-4 text-muted-foreground text-xs sm:col-span-1 sm:col-start-2">
{axis.map((ms, index) => (
<span
key={ms}
className="absolute top-0 whitespace-nowrap"
style={{
left: `${at(ms, axis)}%`,
// The ends stay inside the track; the rest centre on it.
transform:
index === 0
? undefined
: index === axis.length - 1
? "translateX(-100%)"
: "translateX(-50%)",
}}
>
{dur(ms)}
</span>
))}
</div>
</div>
) : null}
</div>
)}
</div>
</section>
)
}
function Row({
timing,
title,
axis,
}: {
timing: NodeTypeTiming
title: string
axis: number[]
}) {
const width =
timing.avg_ms >= FLOOR && axis.length ? at(timing.avg_ms, axis) : 0
return (
<div className={COLUMNS}>
<div className="col-start-1 row-start-1 min-w-0 pt-1.5 sm:py-1.5">
<p className="truncate text-sm">{title}</p>
<p className="truncate text-muted-foreground text-xs">
{si(timing.executions)} runs · peak {dur(timing.max_ms)}
</p>
</div>
{/* The decade lines run through every row, so together they read as
the grid; the bar starts at the axis's own floor. */}
<div className="relative col-span-2 row-start-2 h-4 sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:h-full">
{axis.map((ms) => (
<span
key={ms}
className="absolute inset-y-0 w-px bg-border"
style={{ left: `${at(ms, axis)}%` }}
/>
))}
<span
className="absolute top-1/2 left-0 h-2.5 -translate-y-1/2 rounded-e-[4px] bg-chart-1"
style={{ width: `${width}%` }}
/>
</div>
<span className="col-start-2 row-start-1 self-center whitespace-nowrap text-right font-mono text-sm sm:col-start-3">
{dur(timing.avg_ms)}
</span>
</div>
)
}
@@ -147,3 +147,10 @@ export const clock = (ts: number) =>
hour: "2-digit",
minute: "2-digit",
})
export const nodeTypeTimingsQueryOptions = (range: Range) => ({
queryKey: ["observability", "node-types", range.hours] as const,
queryFn: () =>
ObservabilityService.readNodeTypeTimings({ hours: range.hours }),
refetchInterval: REFRESH,
})