Home: one sparkline style with a left fade, SI values, a full-width activity table, roomier panels

The trend curve was two components: a rich one in the node panel and on edges,
and a bare line in the flow table. It is one `Sparkline` now, taking the colour
token, the height and whether the live dot and the readout show. The flow table
draws its rollup in the chart ramp with no dot — the rollups are polled, so the
right edge is the last completed slice rather than this instant — and keeps its
"nothing yet" state, as the panel keeps its three distinct silences.

All of them fade out to the left, through an SVG mask over the curve and its
area. The dot sits outside the mask: the newest reading is the one thing that
must stay solid.

`si` replaces `compact` and the ad-hoc "k" the uPlot axis carried. It prefixes
k/M/G and m/µ, but only outside 0.01–1000, where the plain number is already
the shortest thing to read and a written unit ("0.4 ms") stays honest. The
sparkline readout asks for four digits, so two neighbouring readings never
collapse into one string. Exact counts and anything the user acts on — a
payload, a form field, the edge inspector's value — are left unrounded.
`src/lib/utils.check.ts` asserts the rounding cases.

The activity table now spans its card: the flow name anchors the left, the four
numbers read down their own centre, and the trend takes the slack on the right.
Panel rhythm steps up one notch, gap-5 to gap-6 outside and gap-2 to gap-3
within a section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
2026-08-17 11:27:54 +02:00
co-authored by Claude Opus 5
parent 5483de13c0
commit 78c605bd37
10 changed files with 320 additions and 188 deletions
@@ -0,0 +1,190 @@
import { useId } from "react"
import type { HistoryPoint } from "@/client"
import { cn, si } from "@/lib/utils"
/** Room above and below the curve for the stroke, in viewBox units. */
const PAD = 8
/** Above this ratio a linear series is all baseline and one spike. */
const LOG_RATIO = 100
/** How far the left end fades over, in viewBox units. */
const FADE = 40
/** Enough digits to tell two neighbouring readings apart. */
const READOUT_DIGITS = 4
/**
* Curve and area for a series, in a 0100 box.
*
* The awkward series are the point: one reading has no line to draw, a series
* that never moved has no span to divide by, and one spanning decades is only
* legible once the exponent is what varies.
*/
function shape(points: HistoryPoint[]) {
const values = points.map((point) => point.value)
const low = Math.min(...values)
const high = Math.max(...values)
// Logs need every reading on the same side of zero.
const logged = low > 0 && high / low >= LOG_RATIO
const project = (value: number) => (logged ? Math.log10(value) : value)
const floor = project(low)
const span = project(high) - floor
const y = (value: number) =>
span === 0
? 50
: 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD)
const x = (index: number) =>
points.length === 1 ? 100 : (index / (points.length - 1)) * 100
const line = points
.map(
(point, index) =>
`${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`,
)
.join(" ")
return {
low,
high,
line,
area: `${line} L100,100 L0,100 Z`,
end: y(values[values.length - 1]),
}
}
/**
* How a series has been moving — the one trend curve, drawn the same way in
* the node panel, on an edge and in the flow table.
*
* The left end fades out, so the oldest readings read as the tail they are
* rather than as an edge the series was cut at. The fade masks the curve and
* its area together; the live dot sits outside the mask, since the newest
* reading is the one thing that must stay solid.
*
* Needs at least one point: an empty series has no story, and what to say
* instead is the caller's to decide.
*/
export function Sparkline({
points,
color = "var(--primary)",
height = "h-8",
dot = true,
readout = true,
}: {
points: HistoryPoint[]
/** The token the curve, its area and the dot are drawn in. */
color?: string
/** Tailwind height of the curve's box. */
height?: string
/** A dot on the newest reading; only honest where the series is live. */
dot?: boolean
/** The current value and the range, beside the curve. */
readout?: boolean
}) {
const id = useId()
const { low, high, line, area, end } = shape(points)
return (
// The gap leaves the live dot room to sit on the last reading without
// touching the labels.
<div className="flex items-center gap-3">
<div className={cn("relative min-w-0 flex-1", height)}>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="h-full w-full overflow-visible"
aria-hidden="true"
>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.28" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
{/* A mask reads luminance, so white here is "keep this", not a
colour the design system has anything to say about. */}
<linearGradient
id={`${id}-ramp`}
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2={FADE}
y2="0"
>
<stop offset="0%" stopColor="white" stopOpacity="0" />
<stop offset="100%" stopColor="white" stopOpacity="1" />
</linearGradient>
{/* Wider than the box, so a round cap or a stroke leaning past the
viewBox is faded rather than cut. */}
<mask
id={`${id}-fade`}
maskUnits="userSpaceOnUse"
x="-10"
y="-10"
width="120"
height="120"
>
<rect
x="-10"
y="-10"
width="120"
height="120"
fill={`url(#${id}-ramp)`}
/>
</mask>
</defs>
{points.length > 1 ? (
<g mask={`url(#${id}-fade)`}>
<path d={area} fill={`url(#${id})`} />
<path
d={line}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
// Keeps the stroke even, though the box is far wider than tall.
vectorEffect="non-scaling-stroke"
/>
</g>
) : null}
</svg>
{/* The newest reading is always the right edge, so the dot only needs to
know how high it sits. */}
{dot ? (
<>
<span
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{
left: "100%",
top: `${end}%`,
background: color,
opacity: 0.2,
}}
/>
<span
className="pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ left: "100%", top: `${end}%`, background: color }}
/>
</>
) : null}
</div>
{/* A shared minimum width, so the curves all end on the same line. */}
{readout ? (
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
<div className="font-medium">
{si(points[points.length - 1].value, READOUT_DIGITS)}
</div>
{/* A series that never moved has no range worth repeating. */}
{low === high ? null : (
<div className="text-muted-foreground">
{si(low, READOUT_DIGITS)}{si(high, READOUT_DIGITS)}
</div>
)}
</div>
) : null}
</div>
)
}
+7 -16
View File
@@ -4,7 +4,7 @@ import "uplot/dist/uPlot.min.css"
import type { HistoryPoint } from "@/client"
import { useTheme } from "@/components/theme-provider"
import { compact } from "@/lib/utils"
import { si } from "@/lib/utils"
/**
* How many lines one chart carries.
@@ -29,17 +29,6 @@ function token(name: string): string {
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`)
/**
* An axis tick, kept short.
*
* The gutter the ticks are drawn in has a fixed width, so a grouped "15,000"
* is clipped to something that reads as a different number entirely.
*/
const tick = (value: number) =>
Math.abs(value) >= 1000
? `${+(value / 1000).toPrecision(3)}k`
: compact(value)
/** The series joined onto one x axis, which is what uPlot draws. */
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
return uPlot.join(
@@ -156,7 +145,10 @@ export function UplotChart({
{
...axis,
size: 46,
values: (_self: uPlot, ticks: number[]) => ticks.map(tick),
// The gutter has a fixed width, so a grouped "15,000" would be
// clipped to something that reads as a different number entirely.
values: (_self: uPlot, ticks: number[]) =>
ticks.map((value) => si(value)),
},
],
series: [
@@ -168,9 +160,8 @@ export function UplotChart({
// rebuilt chart.
stroke: () => seriesColor(index),
// The cursor readout is what decides how wide the legend gets, so
// it is rounded here and the unit named in the card's title.
value: (_self: uPlot, raw: number) =>
Number.isFinite(raw) ? compact(raw) : "--",
// it is shortened here and the unit named in the card's title.
value: (_self: uPlot, raw: number) => si(raw),
// Series arrive on their own clocks; a joined table is mostly
// holes, and a line with a hole per point is not a line.
spanGaps: true,
+4 -4
View File
@@ -75,8 +75,8 @@ export function FlowPanel({
</Button>
}
>
<div className="grid gap-5 p-4">
<div className="grid gap-2">
<div className="grid gap-6 p-4">
<div className="grid gap-3">
<span className={PANEL_SECTION}>Running</span>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
@@ -94,7 +94,7 @@ export function FlowPanel({
</div>
</div>
<div className="grid gap-2">
<div className="grid gap-3">
<span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{definition.name}</span> namespaces
@@ -106,7 +106,7 @@ export function FlowPanel({
</div>
{hasDraft ? (
<div className="grid gap-2">
<div className="grid gap-3">
<span className={PANEL_SECTION}>Unpublished changes</span>
<p className="text-sm text-muted-foreground">
The engine is still running the last published version of this
@@ -1,7 +1,8 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useId, useState } from "react"
import { useEffect, useState } from "react"
import type { HistoryPoint } from "@/client"
import { Sparkline } from "@/components/Common/Sparkline"
import { qualify } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { messageHistoryQueryOptions } from "./queries"
@@ -9,20 +10,6 @@ import { messageHistoryQueryOptions } from "./queries"
/** The same bound the server keeps, so the live tail cannot outgrow the window. */
const WINDOW = 120
/** Room above and below the curve for the stroke, in viewBox units. */
const PAD = 8
/** Above this ratio a linear series is all baseline and one spike. */
const LOG_RATIO = 100
/** Short enough for a label, precise enough to tell two of them apart. */
function compact(value: number): string {
const size = Math.abs(value)
if (size === 0) return "0"
if (size >= 1e6 || size < 1e-2) return value.toExponential(1)
return String(Number(value.toPrecision(4)))
}
/** How a value that cannot be plotted still reads. */
function describe(value: unknown): string {
if (typeof value === "string") return value
@@ -42,46 +29,6 @@ function useSettled(value: string): string {
return settled
}
/**
* Curve and area for a series, in a 0100 box.
*
* The awkward series are the point: one reading has no line to draw, a series
* that never moved has no span to divide by, and one spanning decades is only
* legible once the exponent is what varies.
*/
export function shape(points: HistoryPoint[]) {
const values = points.map((point) => point.value)
const low = Math.min(...values)
const high = Math.max(...values)
// Logs need every reading on the same side of zero.
const logged = low > 0 && high / low >= LOG_RATIO
const project = (value: number) => (logged ? Math.log10(value) : value)
const floor = project(low)
const span = project(high) - floor
const y = (value: number) =>
span === 0
? 50
: 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD)
const x = (index: number) =>
points.length === 1 ? 100 : (index / (points.length - 1)) * 100
const line = points
.map(
(point, index) =>
`${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`,
)
.join(" ")
return {
low,
high,
line,
area: `${line} L100,100 L0,100 Z`,
end: y(values[values.length - 1]),
}
}
/**
* How one message has been moving, as a sparkline with a live end.
*
@@ -100,7 +47,6 @@ export function MessageSparkline({
const live = useLiveValue(qualify(flow, message))
const { data } = useQuery(messageHistoryQueryOptions(flow, message))
const [tail, setTail] = useState<HistoryPoint[]>([])
const gradient = useId()
// Pointing the field at another message makes the collected tail meaningless.
// biome-ignore lint/correctness/useExhaustiveDependencies: the name is what invalidates the tail, not anything the effect reads.
@@ -147,64 +93,6 @@ export function MessageSparkline({
)
}
const { low, high, line, area, end } = shape(points)
return (
// The gap leaves the live dot room to sit on the last reading without
// touching the labels.
<div className="flex items-center gap-3">
<div className="relative h-8 min-w-0 flex-1">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="h-full w-full overflow-visible"
aria-hidden="true"
>
<defs>
<linearGradient id={gradient} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.28" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
</linearGradient>
</defs>
{points.length > 1 ? (
<>
<path d={area} fill={`url(#${gradient})`} />
<path
d={line}
fill="none"
stroke="var(--primary)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
// Keeps the stroke even, though the box is far wider than tall.
vectorEffect="non-scaling-stroke"
/>
</>
) : null}
</svg>
{/* The newest reading is always the right edge, so the dot only needs to
know how high it sits. */}
<span
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20"
style={{ left: "100%", top: `${end}%` }}
/>
<span
className="pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
style={{ left: "100%", top: `${end}%` }}
/>
</div>
{/* A shared minimum width, so the curves all end on the same line. */}
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
<div className="font-medium">
{compact(points[points.length - 1].value)}
</div>
{/* A series that never moved has no range worth repeating. */}
{low === high ? null : (
<div className="text-muted-foreground">
{compact(low)}{compact(high)}
</div>
)}
</div>
</div>
)
// The value is live here, so the dot on the newest reading is earned.
return <Sparkline points={points} />
}
+5 -5
View File
@@ -223,7 +223,7 @@ function PortList({
}
return (
<div className="grid gap-2">
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={SECTION}>{title}</span>
<Button
@@ -366,7 +366,7 @@ function FreeParamsForm({
}
return (
<div className="grid gap-2">
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={SECTION}>Settings</span>
<Button
@@ -704,7 +704,7 @@ function SharingSection({
if (shared) {
return (
<div className="grid gap-2">
<div className="grid gap-3">
<span className={SECTION}>Shared</span>
<p className="text-sm text-muted-foreground">
Runs <span className="font-mono">{shared}</span> from the library
@@ -730,7 +730,7 @@ function SharingSection({
}
return (
<div className="grid gap-2">
<div className="grid gap-3">
<span className={SECTION}>Reuse</span>
<p className="text-sm text-muted-foreground">
Move this node's code to the library so other flows can run it too.
@@ -901,7 +901,7 @@ function PanelBody({
return (
<>
<div className={cn("grid gap-5 p-4", expanded && "max-w-2xl")}>
<div className={cn("grid gap-6 p-4", expanded && "max-w-2xl")}>
<PortList
title="Consumes"
specs={node.requires ?? []}
@@ -7,7 +7,7 @@ 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, compact } from "@/lib/utils"
import { cn, si } from "@/lib/utils"
import {
ago,
auditQueryOptions,
@@ -293,7 +293,7 @@ export function HealthActivity() {
{run.status}
</span>
<span className="w-16 text-right text-muted-foreground">
{compact(run.duration_ms)} ms
{si(run.duration_ms)} ms
</span>
<span className="w-16 text-right text-xs text-muted-foreground">
{ago(run.started_at)}
@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import type { FlowRollup, HistoryPoint } from "@/client"
import { shape } from "@/components/Flow/MessageSparkline"
import { Sparkline } from "@/components/Common/Sparkline"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { Badge } from "@/components/ui/badge"
import { compact } from "@/lib/utils"
import { si } from "@/lib/utils"
import {
ago,
CARD,
@@ -34,7 +34,13 @@ function Tile({
)
}
/** A flow's execution trend, drawn from the 60 slices the rollup carries. */
/**
* A flow's execution trend, drawn from the 60 slices the rollup carries.
*
* The same curve the node panel and the edge popover draw, in the chart ramp
* this page's other graphs use. No live dot: the rollups are polled, so the
* right edge is the last completed slice rather than this instant.
*/
function Spark({ counts }: { counts: number[] }) {
const points: HistoryPoint[] = counts.map((value, index) => ({
ts: index,
@@ -43,22 +49,14 @@ function Spark({ counts }: { counts: number[] }) {
if (points.every((point) => point.value === 0)) {
return <span className="text-xs text-muted-foreground">nothing yet</span>
}
const { line } = shape(points)
return (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="h-6 w-24 overflow-visible"
aria-hidden="true"
>
<path
d={line}
fill="none"
stroke="var(--chart-1)"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
</svg>
<Sparkline
points={points}
color="var(--chart-1)"
height="h-6"
dot={false}
readout={false}
/>
)
}
@@ -129,8 +127,8 @@ export function HealthOverview() {
/>
<Tile
label="Loop lag"
value={`${compact(summary?.loop_lag.ewma ?? 0)} ms`}
note={`peak ${compact(summary?.loop_lag.max_60s ?? 0)} ms in the last minute`}
value={`${si(summary?.loop_lag.ewma ?? 0)} ms`}
note={`peak ${si(summary?.loop_lag.max_60s ?? 0)} ms in the last minute`}
/>
</div>
</section>
@@ -138,21 +136,27 @@ export function HealthOverview() {
<section className="grid gap-3">
<h2 className={PANEL_SECTION}>Flow activity (24h)</h2>
<div className={`${CARD} overflow-x-auto`}>
{/* The name anchors the left, the numbers read down their own
centre, and the trend takes whatever width is left over. */}
<table className="w-full text-sm">
<thead className="text-xs text-muted-foreground">
<tr className="text-left">
<th className="pb-2 font-medium">Flow</th>
<th className="pb-2 font-medium">Executions</th>
<th className="pb-2 font-medium">Errors</th>
<th className="pb-2 font-medium">Avg</th>
<th className="pb-2 font-medium">Lag</th>
<th className="pb-2 font-medium">Trend</th>
<tr>
<th className="pb-2 text-left font-medium">Flow</th>
<th className="px-3 pb-2 text-center font-medium">
Executions
</th>
<th className="px-3 pb-2 text-center font-medium">Errors</th>
<th className="px-3 pb-2 text-center font-medium">Avg</th>
<th className="px-3 pb-2 text-center font-medium">Lag</th>
<th className="w-full min-w-32 pb-2 pl-3 text-right font-medium">
Trend
</th>
</tr>
</thead>
<tbody>
{(flows ?? []).map((row: FlowRollup) => (
<tr key={row.flow} className="border-t border-border">
<td className="py-2">
<td className="py-2 pr-3">
<Link
to="/flows/$flowName"
params={{ flowName: row.flow }}
@@ -161,17 +165,21 @@ export function HealthOverview() {
{row.flow || "—"}
</Link>
</td>
<td className="py-2">{row.executions}</td>
<td className="py-2">
<td className="px-3 py-2 text-center">{row.executions}</td>
<td className="px-3 py-2 text-center">
{row.errors ? (
<Badge variant="destructive">{row.errors} failed</Badge>
) : (
<span className="text-muted-foreground">none</span>
)}
</td>
<td className="py-2">{compact(row.avg_ms)} ms</td>
<td className="py-2">{compact(row.avg_lag_ms)} ms</td>
<td className="py-2">
<td className="whitespace-nowrap px-3 py-2 text-center">
{si(row.avg_ms)} ms
</td>
<td className="whitespace-nowrap px-3 py-2 text-center">
{si(row.avg_lag_ms)} ms
</td>
<td className="py-2 pl-3 text-right">
<Spark counts={row.spark} />
</td>
</tr>
+32
View File
@@ -0,0 +1,32 @@
/**
* The cases `si` gets wrong when its rounding is written the obvious way.
*
* Run: `bun src/lib/utils.check.ts` (there is no unit runner; the suite in
* `tests/` drives a running stack).
*/
import assert from "node:assert/strict"
import { si } from "./utils"
// Nothing is prefixed in the plain window: these are written with a unit after
// them, and "400m ms" is not an improvement on "0.4 ms".
assert.equal(si(0), "0")
assert.equal(si(0.4), "0.4")
assert.equal(si(203.63), "204")
// Rounding must not leave a value in the step it just rounded out of.
assert.equal(si(999.7), "1k")
assert.equal(si(15000), "15k")
assert.equal(si(-2.5e6), "-2.5M")
// A small reading has to survive as itself; a series of these is not zeroes.
assert.equal(si(0.0031, 4), "3.1m")
assert.equal(si(0.000031), "31µ")
assert.equal(si(1e-9), "1.0e-9")
// Two neighbouring readings stay apart at the sparkline readout's four digits.
assert.notEqual(si(1234, 4), si(1235, 4))
assert.equal(si(Number.NaN), "--")
console.log("si: ok")
+30 -5
View File
@@ -5,12 +5,37 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Steps of a thousand, largest first. */
const SI_STEPS: [number, string][] = [
[1e9, "G"],
[1e6, "M"],
[1e3, "k"],
[1e-3, "m"],
[1e-6, "µ"],
]
/**
* A reading at about three significant digits: 203.63 → 204, 2.714 → 2.7.
* A reading at `digits` significant digits, with an SI prefix where the plain
* number would be long: 15000 → "15k", 0.0031 → "3.1m", 203.63 → "204".
*
* Shared by the health tables and the chart legends, so a value read off the
* cursor cannot grow wide enough to push a legend out of its card.
* Between 0.01 and 1000 the plain number is the shortest thing to read, so
* nothing is prefixed there — "0.4" beats "400m", and a value written with its
* unit after it ("0.4 ms") stays honest. Past those bounds the prefix is what
* keeps a reading in its column, and beyond giga or micro the exponent is.
*
* Where it applies: anything read at a glance and bounded by the width of a
* tile, a table cell, an axis gutter or a legend. Exact counts (executions,
* failures) and anything the user is about to act on — a payload, a form
* field, the edge inspector's value — stay unrounded.
*/
export function compact(value: number): string {
return String(Math.abs(value) >= 100 ? Math.round(value) : +value.toFixed(1))
export function si(value: number, digits = 3): string {
if (!Number.isFinite(value)) return "--"
// Rounded before the step is picked, so 999.7 reads as "1k", not "1000".
const rounded = Number(value.toPrecision(digits))
const size = Math.abs(rounded)
if (size === 0) return "0"
if (size >= 0.01 && size < 1e3) return String(rounded)
if (size >= 1e12 || size < 1e-6) return rounded.toExponential(1)
const [factor, suffix] = SI_STEPS.find(([step]) => size >= step) ?? [1, ""]
return `${Number((rounded / factor).toPrecision(digits))}${suffix}`
}