From e3b3d234ce923f26209b23510f0302de85db07e3 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Sat, 15 Aug 2026 21:59:30 +0200 Subject: [PATCH] Sparkline every message a node names A number in the panel says where a value is, not where it has been. Each named port in Consumes and Provides now carries a sparkline under it, fetched once on open and extended from the socket as values arrive, held to the same 120-point window the server keeps. Both lists render through the shared port row, so a sink node shows what flows through it rather than nothing. A flat series draws its mid line and drops the redundant range label, a lone reading is just its dot, and a series spanning decades switches to a log scale with the labels left in real units. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o --- .../src/components/Flow/MessageSparkline.tsx | 210 ++++++++++++++++++ frontend/src/components/Flow/NodePanel.tsx | 75 ++++--- frontend/src/components/Flow/queries.ts | 8 + 3 files changed, 260 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/Flow/MessageSparkline.tsx diff --git a/frontend/src/components/Flow/MessageSparkline.tsx b/frontend/src/components/Flow/MessageSparkline.tsx new file mode 100644 index 0000000..f9e4a27 --- /dev/null +++ b/frontend/src/components/Flow/MessageSparkline.tsx @@ -0,0 +1,210 @@ +import { useQuery } from "@tanstack/react-query" +import { useEffect, useId, useState } from "react" + +import type { HistoryPoint } from "@/client" +import { qualify } from "./deriveEdges" +import { useLiveValue } from "./liveStore" +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 + return JSON.stringify(value) ?? String(value) +} + +/** + * The name only settles once typing stops. Without this, every keystroke in the + * message field would ask the server for a history. + */ +function useSettled(value: string): string { + const [settled, setSettled] = useState(value) + useEffect(() => { + const timer = setTimeout(() => setSettled(value), 400) + return () => clearTimeout(timer) + }, [value]) + return settled +} + +/** + * Curve and area for a series, in a 0–100 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 one message has been moving, as a sparkline with a live end. + * + * The panel already knows the current value, so the three silences stay + * distinct: nothing has run yet, the message carries something a chart cannot + * say anything about, or there is a series to draw. + */ +export function MessageSparkline({ + flow, + name, +}: { + flow: string + name: string +}) { + const message = useSettled(name) + const live = useLiveValue(qualify(flow, message)) + const { data } = useQuery(messageHistoryQueryOptions(flow, message)) + const [tail, setTail] = useState([]) + 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. + useEffect(() => { + setTail((previous) => (previous.length ? [] : previous)) + }, [message]) + + // Values keep arriving over the socket while the panel is open. Appending + // them beats refetching the whole series on every emission. + const ts = live?.ts + const value = live?.value + useEffect(() => { + if (typeof value !== "number" || !ts) return + setTail((previous) => + previous[previous.length - 1]?.ts === ts + ? previous + : [...previous, { ts, value }].slice(-WINDOW), + ) + }, [ts, value]) + + const fetched = data?.points ?? [] + // The fetch and the socket overlap; only what the server had not yet seen. + const since = fetched[fetched.length - 1]?.ts ?? 0 + const points = [ + ...fetched, + ...tail.filter((point) => point.ts > since), + ].slice(-WINDOW) + + if (points.length === 0) { + if (live === undefined) { + return ( +

+ Nothing has come through yet. +

+ ) + } + return ( +
+ + {describe(live.value)} + + no history +
+ ) + } + + 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. +
+
+ + {/* The newest reading is always the right edge, so the dot only needs to + know how high it sits. */} + + +
+ {/* A shared minimum width, so the curves all end on the same line. */} +
+
+ {compact(points[points.length - 1].value)} +
+ {/* A series that never moved has no range worth repeating. */} + {low === high ? null : ( +
+ {compact(low)}–{compact(high)} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 5f649db..0707eee 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" +import { MessageSparkline } from "./MessageSparkline" import { nodeSourceQueryOptions } from "./queries" import { PANEL_SECTION, SidePanel } from "./SidePanel" @@ -177,39 +178,47 @@ function PortList({ ) : null} {specs.map((spec, index) => ( -
- update(index, { name, port: "" })} - onRenamed={onRenamed} - /> - - +
+
+ update(index, { name, port: "" })} + onRenamed={onRenamed} + /> + + +
+ {spec.name ? : null}
))}
diff --git a/frontend/src/components/Flow/queries.ts b/frontend/src/components/Flow/queries.ts index 694d580..c20946c 100644 --- a/frontend/src/components/Flow/queries.ts +++ b/frontend/src/components/Flow/queries.ts @@ -12,6 +12,8 @@ export const flowKeys = { detail: (name: string) => ["flows", name] as const, source: (name: string, nodeId: string) => ["flows", name, "source", nodeId] as const, + history: (name: string, message: string) => + ["flows", name, "history", message] as const, nodeTypes: ["flows", "node-types"] as const, } @@ -36,6 +38,12 @@ export const nodeSourceQueryOptions = (name: string, nodeId: string) => ({ queryFn: () => FlowsService.readNodeSource({ name, nodeId }), }) +/** The recent values of one message, for the panel's sparkline. */ +export const messageHistoryQueryOptions = (name: string, message: string) => ({ + queryKey: flowKeys.history(name, message), + queryFn: () => FlowsService.readMessageHistory({ name, message }), +}) + const AUTOSAVE_DELAY = 800 /**