A record or an artifact reference was serialised into the port row, and the panel widened until the type selects and the buttons beside them were pushed off its edge — a checkpoint reference is 130 characters of digest, and none of them are what you want while wiring a flow. What shows now is what the value *is*: 'artifact · weights.json · 60B', 'record · 3 fields'. A chevron unfolds the whole of it, wrapped, inside the panel it belongs to. A scalar still reads as itself, and scrolls its own overflow into view when it is longer than the room it was given. That behaviour already existed inside the edge inspector; it moves to Common/Marquee so the panel can have it too, and the inspector drops its own copy of the raw-JSON block along with it. The rows are smaller for it: the type select finally fits the word 'artifact', and a port nothing has come through on says so with a dash rather than a sentence — nine ports of 'nothing has come through yet' is a panel of prose about the absence of values. The e2e check asserts both halves: that the summary is what appears, and that the panel is still exactly 400px with the value unfolded.
137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
import { useQuery } from "@tanstack/react-query"
|
|
import { useEffect, useState } from "react"
|
|
|
|
import type { DType, HistoryPoint } from "@/client"
|
|
import { Sparkline } from "@/components/Common/Sparkline"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { qualify } from "./deriveEdges"
|
|
import { useLiveValue } from "./liveStore"
|
|
import { messageHistoryQueryOptions } from "./queries"
|
|
import { ValuePreview } from "./ValuePreview"
|
|
|
|
/** The same bound the server keeps, so the live tail cannot outgrow the window. */
|
|
const WINDOW = 120
|
|
|
|
/** A stretch of time in the coarsest unit that still says it. */
|
|
function span(seconds: number): string {
|
|
if (seconds < 90) return `${Math.round(seconds)}s`
|
|
if (seconds < 5400) return `${Math.round(seconds / 60)}m`
|
|
return `${Math.round(seconds / 3600)}h`
|
|
}
|
|
|
|
/**
|
|
* What the curve is actually showing.
|
|
*
|
|
* These are a ring of the last `WINDOW` values per message, not a window of
|
|
* time — a message that fires twice an hour and one that fires at 10 Hz draw
|
|
* the same width for wildly different spans. The readings themselves are what
|
|
* says which, so the caption reads it off them rather than claiming a range.
|
|
*/
|
|
function caption(points: HistoryPoint[]): string {
|
|
const readings = `The last ${points.length} reading${points.length === 1 ? "" : "s"}`
|
|
const covered = points[points.length - 1].ts - points[0].ts
|
|
return covered > 0 ? `${readings}, over ${span(covered)}.` : `${readings}.`
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
dtype,
|
|
}: {
|
|
flow: string
|
|
name: string
|
|
/** What the port declares, so a value that never arrived still says what it would be. */
|
|
dtype?: DType
|
|
}) {
|
|
const message = useSettled(name)
|
|
const live = useLiveValue(qualify(flow, message))
|
|
const { data } = useQuery(messageHistoryQueryOptions(flow, message))
|
|
const [tail, setTail] = useState<HistoryPoint[]>([])
|
|
|
|
// 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) {
|
|
// Three silences, kept apart: nothing has run, or something arrived that
|
|
// no curve can say anything about. The second is worth showing; the first
|
|
// is worth admitting to in as few words as the row can spare.
|
|
if (live === undefined) {
|
|
// A dash rather than a sentence: one of these sits under every port, and
|
|
// nine ports of "nothing has come through yet" is a panel of prose about
|
|
// the absence of values. The row keeps its height either way, so nothing
|
|
// shifts when the first one arrives.
|
|
return (
|
|
<p
|
|
className="text-xs leading-5 text-muted-foreground"
|
|
title="Nothing has come through yet"
|
|
>
|
|
—
|
|
</p>
|
|
)
|
|
}
|
|
return <ValuePreview value={live.value} dtype={dtype} />
|
|
}
|
|
|
|
// The value is live here, so the dot on the newest reading is earned. The
|
|
// caption is on hover rather than beside the curve: these sit one per port
|
|
// in a dense panel, and a line of prose each would crowd it out.
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div>
|
|
<Sparkline points={points} />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{caption(points)}</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
}
|