Files
app/frontend/src/components/Flow/MessageSparkline.tsx
T
stroblme 40864d5ca5 Push a frame instead of storing and fetching it
The rate the media dtypes could carry was one frame every second or two: each
was a file on the data volume, an event on the socket, and a request back for
the bytes. This closes both halves of that, and they are one feature.

`save_artifact(..., volatile=True)` writes to a `VolatileStore` — the same
content-addressed store, in `/dev/shm`, bounded by size with the oldest falling
out (`ARTIFACT_VOLATILE_BYTES`, 48 MB under the container's raised `shm_size`).
Nothing sweeps it: a frame nobody kept is not worth walking the store to find.
`ArtifactStore.path` falls through to it, which is what lets a volatile frame be
an ordinary reference everywhere else — the dtype check, a panel's digest scope,
`load_artifact` in a node, and the widget's own fetch all work on one unchanged.
`adopt` copies one into the store when a run records it, so "returned media is
kept, emitted media is not" stays true.

The bytes then go down the flows websocket as a length-prefixed binary frame,
sent just ahead of the `message_value` naming them, so a tile has the frame when
it hears the value moved. Nothing is pushed unasked: a client names the messages
it is drawing (`{"type":"media","names":[…]}`), a panel's list is intersected
with the scope it already had, and only the newest frame per name in a batch is
sent — a client that fell behind is not handed frames it would draw over. The
tunnel relays text only, so a screen reached through a portal falls back to
fetching, which is why the rate table now has two rows.

Around the edges: the remote worker's fetch cache is bounded at last
(`FLUKSIO_ARTIFACT_CACHE_BYTES`), since content addressing means nothing in it
ever expires and a media stream fills it with chunks nothing asks for twice; a
port carrying an image draws the frame in the node panel rather than only
saying `image/png · frame.png · 1.79kB`; and an edge chip says that much instead
of a line of hash. The media screenshot stops waiting for `networkidle` — a
camera is a socket that never goes quiet, which is the point of it.
2026-09-02 10:15:14 +02:00

148 lines
5.1 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(() => {
// A flag is recorded as 0/1, the way the server keeps it.
if ((typeof value !== "number" && typeof value !== "boolean") || !ts) return
const reading = Number(value)
setTail((previous) =>
previous[previous.length - 1]?.ts === ts
? previous
: [...previous, { ts, value: reading }].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}
name={qualify(flow, message)}
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}
bool={dtype === "bool" || typeof live?.value === "boolean"}
/>
</div>
</TooltipTrigger>
<TooltipContent>{caption(points)}</TooltipContent>
</Tooltip>
)
}