Let a dashboard be read against runs instead of the live engine

This commit is contained in:
2026-08-25 11:48:15 +02:00
parent d2951a325e
commit 5ece544b14
13 changed files with 438 additions and 32 deletions
@@ -1,5 +1,5 @@
import { slotsFor } from "@/components/Common/UplotChart"
import { useLiveValues } from "@/components/Flow/liveStore"
import { useBoundValues } from "./dataContext"
import { usePalette } from "./settings"
import { useUi } from "./ui"
import { rowsOf } from "./ui/core/config"
@@ -24,7 +24,7 @@ import type { WidgetProps } from "./widgets"
export function BarWidget({ widget }: WidgetProps) {
const { Bar } = useUi()
const rows = rowsOf(widget).filter((row) => row.message)
const live = useLiveValues(rows.map((row) => row.message as string))
const live = useBoundValues(rows.map((row) => row.message as string))
// The dashboard's own data colours, in the order a chart would take them, so
// a bar and a chart of the same readings agree about which line is which.
const colors = slotsFor(rows.length, usePalette())
@@ -9,6 +9,8 @@ import {
} from "@/components/Common/RangePicker"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore"
import { NO_CURVE } from "@/components/Runs/queries"
import { type DataContext, useDataContext } from "./dataContext"
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
import { usePalette } from "./settings"
import { useUi } from "./ui"
@@ -90,6 +92,10 @@ function merge(fetched: HistoryPoint[], tail: HistoryPoint[], cap: number) {
* different set of hooks, and React counts them.
*/
export function ChartWidget(props: WidgetProps) {
// A dashboard opened against something — runs today, a replayed day later —
// draws from that instead, whichever source it was pointed at otherwise.
const context = useDataContext()
if (context) return <ContextChart {...props} context={context} />
return config(props.widget).source === "query" ? (
<QueryChart {...props} />
) : (
@@ -97,6 +103,62 @@ export function ChartWidget(props: WidgetProps) {
)
}
/**
* The same chart, drawn from a data context.
*
* One line per member per bound message: three runs of a two-series chart is
* six curves, capped like any other chart at what the ramp can tell apart.
*/
function ContextChart({
widget,
context,
}: WidgetProps & { context: DataContext }) {
const cfg = widget.config as {
series?: Series[]
runs?: { metric?: string }
}
const bound = [
...(cfg.series ?? []).map((entry) => entry.message),
cfg.runs?.metric,
].filter((name): name is string => Boolean(name))
const palette = usePalette()
const drawn = bound.flatMap((name) => {
const lines = context.lines(name) ?? []
// The message name is only worth writing when the chart draws more than
// one of them; otherwise the member's own label is the whole story.
return lines.map((line) => ({
label: bound.length > 1 ? `${name} · ${line.label}` : line.label,
points: line.points,
}))
})
const shown = drawn.slice(0, MAX_SERIES)
const points = shown.reduce((total, line) => total + line.points.length, 0)
if (bound.length === 0) {
return <p className="text-muted-foreground">Pick a message.</p>
}
return (
<div className="flex h-full flex-col">
<UplotChart
labels={shown.map((line) => line.label)}
plots={shown.map((line) => line.points)}
palette={palette}
xTime={context.xTime}
pending={context.pending}
empty={points === 0 ? NO_CURVE : undefined}
{...presentation(widget.config as Record<string, unknown>)}
/>
{drawn.length > MAX_SERIES && (
<p className="text-muted-foreground text-xs">
Showing {MAX_SERIES} of {drawn.length} lines.
</p>
)}
</div>
)
}
/**
* Several messages over time, drawn on one axis.
*
@@ -0,0 +1,71 @@
import { Link } from "@tanstack/react-router"
import { X } from "lucide-react"
import { shortId } from "@/components/Runs/queries"
import type { DataContext } from "./dataContext"
/**
* What this dashboard is being shown against, and the way back out.
*
* Above the canvas rather than on it: the widgets are the dashboard, and this
* is a statement about the whole page. Written generically enough that a
* replayed day says its own name here without a second bar.
*/
export function ContextBar({
context,
members,
onRemove,
onExit,
}: {
context: DataContext
/** One chip per member, by id. */
members: string[]
onRemove: (id: string) => void
onExit: () => void
}) {
return (
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
<span className="font-medium text-sm">
Showing {context.label}
{context.pending ? "…" : ""}
</span>
{members.length > 1 &&
members.map((id) => (
<span
key={id}
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 font-mono text-muted-foreground text-xs"
>
{shortId(id)}
<button
type="button"
onClick={() => onRemove(id)}
aria-label={`Stop showing ${id}`}
className="hover:text-foreground"
>
<X className="size-3" />
</button>
</span>
))}
{members.length > 1 && (
<span className="text-muted-foreground text-xs">
single readings come from {shortId(members[0])}
</span>
)}
<div className="ml-auto flex items-center gap-3 text-sm">
<Link to="/runs" className="text-muted-foreground hover:underline">
Runs
</Link>
<button
type="button"
onClick={onExit}
className="text-muted-foreground hover:underline"
>
Show live
</button>
</div>
</div>
)
}
@@ -1,5 +1,5 @@
import { useLiveValue } from "@/components/Flow/liveStore"
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { ICON_COLORS, ICONS } from "./icons"
import { config } from "./ui/core/config"
import type { WidgetProps } from "./widgets"
@@ -24,7 +24,7 @@ type ForecastItem = {
export function ForecastWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = cfg.message ? String(cfg.message) : ""
const live = useLiveValue(message || undefined)
const live = useBoundValue(message || undefined)
if (!message) {
return <p className="text-muted-foreground">Pick a message.</p>
}
@@ -1,5 +1,5 @@
import { useLiveValue } from "@/components/Flow/liveStore"
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { ICON_COLORS, ICONS } from "./icons"
import { config, text } from "./ui/core/config"
import type { WidgetProps } from "./widgets"
@@ -33,7 +33,7 @@ const matches = (value: unknown, at: unknown) =>
export function IconWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
const live = useBoundValue(message || undefined)
const value = live?.value
const rules = (cfg.rules ?? []) as Rule[]
@@ -0,0 +1,131 @@
import { useQueries } from "@tanstack/react-query"
import type { ReactNode } from "react"
import type { RunDetail } from "@/client"
import type { LiveValue } from "@/components/Flow/liveStore"
import {
compareQueryOptions,
runQueryOptions,
shortId,
} from "@/components/Runs/queries"
import { type Dashboard, flatWidgets, pagesOf } from "./DashboardView"
import {
type ContextLine,
type DataContext,
DataContextProvider,
} from "./dataContext"
/**
* The message names this dashboard would want a curve for.
*
* Only charts: a stat shows one number and reads the run's result instead, so
* asking the server for its whole series would be a query nobody draws.
*/
function chartedNames(dashboard: Dashboard | undefined): string[] {
const page = pagesOf(dashboard ?? ({} as Dashboard))[0]
if (!page) return []
const names = new Set<string>()
for (const widget of flatWidgets(page)) {
if (widget.type !== "chart") continue
const config = (widget.config ?? {}) as {
series?: { message?: string }[]
runs?: { metric?: string }
}
for (const entry of config.series ?? [])
if (entry.message) names.add(entry.message)
if (config.runs?.metric) names.add(config.runs.metric)
}
return [...names]
}
/** A run's own label in a legend, beside four others. */
const runLabel = (run: RunDetail) =>
run.seed === null || run.seed === undefined
? shortId(run.id)
: `${shortId(run.id)} (seed ${run.seed})`
/**
* A dashboard drawn against runs rather than against the engine.
*
* The values a run finished with are its `result`, which the API reports with
* the flow prefix stripped — a run of `study` reports `accuracy`, while the
* dashboard binds `study.accuracy` — so they are qualified again here. The
* curves come from the comparison endpoint, which already answers in the
* shape a chart draws.
*
* Where several runs are shown, a chart draws all of them and anything with
* room for one number shows the first, which is the one the bar names.
*/
export function RunsContextProvider({
runIds,
dashboard,
children,
}: {
runIds: string[]
dashboard: Dashboard | undefined
children: ReactNode
}) {
const runs = useQueries({
queries: runIds.map((id) => runQueryOptions(id)),
})
const names = chartedNames(dashboard)
const series = useQueries({
queries: names.map((name) => compareQueryOptions(runIds, name)),
})
const loaded = runs
.map((query) => query.data)
.filter((run): run is RunDetail => Boolean(run))
const primary = loaded[0]
const finals = new Map<string, LiveValue>()
if (primary) {
const at = primary.finished_at ? Date.parse(String(primary.finished_at)) : 0
for (const [key, value] of Object.entries(primary.result ?? {})) {
finals.set(key.includes(".") ? key : `${primary.flow}.${key}`, {
value,
ts: at ? at / 1000 : null,
})
}
}
const curves = new Map<string, ContextLine[]>()
names.forEach((name, index) => {
const answer = series[index]?.data
if (!answer) return
curves.set(
name,
(answer.lines ?? []).map((line, position) => ({
// The endpoint labels by run id; the run rows carry the nicer name.
label: loaded[position] ? runLabel(loaded[position]) : line.label,
points: (line.points ?? []).map(([step, value]) => ({
ts: step,
value,
})),
})),
)
})
const flow = primary?.flow ?? ""
const context: DataContext = {
kind: "runs",
label:
runIds.length === 1
? `run ${shortId(runIds[0])}${flow ? ` of ${flow}` : ""}`
: `${runIds.length} runs${flow ? ` of ${flow}` : ""}`,
readOnly: true,
// A run's metrics are indexed by step, not by the clock.
xTime: false,
pending:
runs.some((query) => query.isPending) ||
series.some((query) => query.isPending),
value: (name) => finals.get(name),
lines: (name) => curves.get(name),
}
return <DataContextProvider value={context}>{children}</DataContextProvider>
}
/** The runs a dashboard was opened against, from `?runs=a,b,c`. */
export const runIdsOf = (runs: string | undefined) =>
runs ? runs.split(",").filter(Boolean) : []
@@ -0,0 +1,86 @@
import { createContext, useContext } from "react"
import type { HistoryPoint } from "@/client"
import {
type LiveValue,
useLiveValue,
useLiveValues,
} from "@/components/Flow/liveStore"
/** One member's curve for a bound message: a run, later a replayed day. */
export type ContextLine = {
label: string
/** `ts` carries whatever the x axis is — a step for a run, a moment for a
* replay. Which of the two it is, is `xTime` on the context. */
points: HistoryPoint[]
}
/**
* Where a dashboard's widgets read from, when it is not the live engine.
*
* A dashboard binds its widgets to message names. Normally those resolve
* against what the engine is holding right now, which is what makes a wall
* panel a wall panel. A data context puts something else behind the same
* names, so the page someone already built to watch a training run live is
* also the page that shows a finished one — no second dashboard, no widget
* that knows what a run is.
*
* `kind` is for the bar at the top to name what is being shown. Widgets must
* not switch on it: the point of this seam is that "three runs" and "last
* Tuesday, replayed" are the same shape, and Labs is the second one.
*
* Deliberately plain data and two lookups rather than hooks, so a provider is
* free to re-render with a fresh object whenever its data moves — a run
* finishing, a replay ticking — without every consumer subscribing to
* something of its own.
*
* What a consumer may not assume: that x is a time (`xTime`), that there is
* exactly one member (`lines` returns as many as there are), or that the data
* is settled (`pending`).
*/
export type DataContext = {
kind: string
/** What is being shown, in words: "3 runs of demo_training". */
label: string
/** Nothing here can be published to; the input widgets go quiet. */
readOnly: boolean
/** Whether the x values are moments. False for a run, whose x is a step. */
xTime: boolean
pending: boolean
/** The single value a stat or a gauge should show for this name. */
value: (name: string) => LiveValue | undefined
/** One line per member for this name, or undefined if it carries none. */
lines: (name: string) => ContextLine[] | undefined
}
const DataCtx = createContext<DataContext | null>(null)
export const DataContextProvider = DataCtx.Provider
/** The context a widget is being drawn in, or null on a live dashboard. */
export const useDataContext = () => useContext(DataCtx)
/**
* What a widget bound to this name should show.
*
* The one reading every value widget goes through, so putting a dashboard in
* a context is a change in one place rather than in each tile. Both hooks are
* called either way — React counts them — and which answer is used is decided
* afterwards.
*
* Not `useLiveValue` itself: a dashboard's own settings read live values too,
* and opening a page against a run must not change what look or theme it is
* drawn in.
*/
export function useBoundValue(name: string | undefined) {
const context = useDataContext()
const live = useLiveValue(name)
return context && name ? context.value(name) : live
}
/** The same, for a widget that binds several names at once. */
export function useBoundValues(names: string[]) {
const context = useDataContext()
const live = useLiveValues(names)
return context ? names.map((name) => context.value(name)) : live
}
@@ -7,6 +7,7 @@ import { handleError } from "@/utils"
// The transmit ring's rule lives with the dashboard's own geometry, and CSS is
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
import "./ui/core/core.css"
import { useDataContext } from "./dataContext"
import { usePublishMessage } from "./queries"
import { useLocked } from "./settings"
@@ -54,7 +55,10 @@ function confirms(live: unknown, sent: unknown): boolean {
export function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = (widget.config ?? {}) as Record<string, unknown>
const target = cfg.target == null ? "" : String(cfg.target)
const locked = useLocked()
// A dashboard opened against a run or a replay is a picture of something
// that already happened; there is nothing for a control to publish to.
const context = useDataContext()
const locked = useLocked() || Boolean(context?.readOnly)
const publish = usePublishMessage()
const live = useLiveValue(target || undefined)
const { showErrorToast } = useCustomToast()
@@ -1,12 +1,12 @@
import { useState } from "react"
import type { WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import { cn } from "@/lib/utils"
import { BarWidget } from "./BarWidget"
import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget"
import { ColorWidget } from "./ColorWidget"
import { useBoundValue } from "./dataContext"
// The grid's own rules live beside the components that use them; CSS is
// chunked per entry, so the sheet is pulled in wherever a widget is drawn.
import "./dashboard.css"
@@ -197,7 +197,7 @@ function StatWidget({ widget }: WidgetProps) {
const { Readout } = useUi()
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
return (
@@ -219,7 +219,7 @@ function GaugeWidget({ widget }: WidgetProps) {
const { Gauge } = useUi()
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
return (
@@ -304,7 +304,7 @@ function dayLabel(when: Date, now: Date): string {
function AgendaWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
const now = new Date()
@@ -360,7 +360,7 @@ function AgendaWidget({ widget }: WidgetProps) {
function NotificationWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
const record = (live?.value ?? null) as Record<string, unknown> | null
+6 -11
View File
@@ -9,17 +9,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { compareQueryOptions, runMetricsQueryOptions, shortId } from "./queries"
/**
* Why a finished run can have nothing to draw.
*
* A cache hit restores what a node returned, not the values it emitted along
* the way, so a run whose training node was reused has a result and no curve.
* Said here rather than left as an empty chart, which reads as a fault.
*/
export const NO_CURVE =
"No curve was recorded. A node restored from the cache replays no emissions, so a run that reused an earlier one draws nothing here — its outputs are still on the result."
import {
compareQueryOptions,
NO_CURVE,
runMetricsQueryOptions,
shortId,
} from "./queries"
/** The metrics one run recorded, in the order they are worth offering. */
export function useMetricNames(runId: string | undefined) {
+2 -6
View File
@@ -17,17 +17,13 @@ import {
} from "@/components/ui/table"
import useCustomToast from "@/hooks/useCustomToast"
import { cn, dur, si } from "@/lib/utils"
import {
MetricPicker,
NO_CURVE,
RunMetricChart,
useMetricNames,
} from "./MetricChart"
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
import { OpenInDashboard } from "./OpenInDashboard"
import {
CARD,
downloadArtifact,
isLive,
NO_CURVE,
paramText,
runQueryOptions,
shortCommit,
+10
View File
@@ -125,6 +125,16 @@ export async function downloadArtifact(digest: string, name: string) {
URL.revokeObjectURL(url)
}
/**
* Why a finished run can have nothing to draw.
*
* A cache hit restores what a node returned, not the values it emitted along
* the way, so a run whose training node was reused has a result and no curve.
* Said out loud rather than left as an empty chart, which reads as a fault.
*/
export const NO_CURVE =
"No curve was recorded. A node restored from the cache replays no emissions, so a run that reused an earlier one draws nothing here — its outputs are still on the result."
/** A run id, short enough for a table cell. The tail is the random half. */
export const shortId = (id: string) => id.slice(-8)
+53 -2
View File
@@ -1,9 +1,14 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
import { ContextBar } from "@/components/Dashboard/ContextBar"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { useDataContext } from "@/components/Dashboard/dataContext"
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import {
RunsContextProvider,
runIdsOf,
} from "@/components/Dashboard/RunsContext"
import { useCanvasRoot } from "@/components/Dashboard/ui/core/look"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth"
@@ -28,14 +33,24 @@ export const Route = createFileRoute("/view/$name")({
throw redirect({ to: "/login" })
}
},
// Which runs this dashboard is being read against, if any. In the address
// rather than in the document: it is a way of looking at the page, not a
// change to it, and a comparison someone found is then a link they can send.
validateSearch: (search: Record<string, unknown>): { runs?: string } => ({
runs:
typeof search.runs === "string" && search.runs ? search.runs : undefined,
}),
head: ({ params }) => ({ meta: [{ title: `${params.name} - Fluksio` }] }),
})
function PanelView() {
const { name } = Route.useParams()
const { runs } = Route.useSearch()
const navigate = useNavigate()
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
const stacked = useIsMobile()
const runIds = runIdsOf(runs)
// A tab pointed at one dashboard is that dashboard, so its appearance
// covers the whole page rather than only the canvas inside it.
const root = useCanvasRoot(dashboard as Dashboard | undefined)
@@ -54,7 +69,43 @@ function PanelView() {
)}
style={root.style}
>
{runIds.length > 0 ? (
<RunsContextProvider runIds={runIds} dashboard={dashboard as Dashboard}>
<ContextBarFor
members={runIds}
onChange={(next) =>
navigate({
to: "/view/$name",
params: { name },
search: next.length ? { runs: next.join(",") } : {},
})
}
/>
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
</RunsContextProvider>
) : (
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
)}
</main>
)
}
/** The bar, drawn from inside the provider so it can read what it resolved. */
function ContextBarFor({
members,
onChange,
}: {
members: string[]
onChange: (next: string[]) => void
}) {
const context = useDataContext()
if (!context) return null
return (
<ContextBar
context={context}
members={members}
onRemove={(id) => onChange(members.filter((one) => one !== id))}
onExit={() => onChange([])}
/>
)
}