diff --git a/frontend/src/components/Dashboard/BarWidget.tsx b/frontend/src/components/Dashboard/BarWidget.tsx
index 6a3e67b..4b5756e 100644
--- a/frontend/src/components/Dashboard/BarWidget.tsx
+++ b/frontend/src/components/Dashboard/BarWidget.tsx
@@ -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())
diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx
index d494fc9..9cf7fc6 100644
--- a/frontend/src/components/Dashboard/ChartWidget.tsx
+++ b/frontend/src/components/Dashboard/ChartWidget.tsx
@@ -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
return config(props.widget).source === "query" ? (
) : (
@@ -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
+ )
+}
+
/**
* Several messages over time, drawn on one axis.
*
diff --git a/frontend/src/components/Dashboard/ContextBar.tsx b/frontend/src/components/Dashboard/ContextBar.tsx
new file mode 100644
index 0000000..8e4593c
--- /dev/null
+++ b/frontend/src/components/Dashboard/ContextBar.tsx
@@ -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 (
+
+ )
+}
diff --git a/frontend/src/components/Dashboard/ForecastWidget.tsx b/frontend/src/components/Dashboard/ForecastWidget.tsx
index b1075f7..ddab05b 100644
--- a/frontend/src/components/Dashboard/ForecastWidget.tsx
+++ b/frontend/src/components/Dashboard/ForecastWidget.tsx
@@ -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
Pick a message.
}
diff --git a/frontend/src/components/Dashboard/IconWidget.tsx b/frontend/src/components/Dashboard/IconWidget.tsx
index a6d2deb..d634251 100644
--- a/frontend/src/components/Dashboard/IconWidget.tsx
+++ b/frontend/src/components/Dashboard/IconWidget.tsx
@@ -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[]
diff --git a/frontend/src/components/Dashboard/RunsContext.tsx b/frontend/src/components/Dashboard/RunsContext.tsx
new file mode 100644
index 0000000..0ae9477
--- /dev/null
+++ b/frontend/src/components/Dashboard/RunsContext.tsx
@@ -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()
+ 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()
+ 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()
+ 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 {children}
+}
+
+/** The runs a dashboard was opened against, from `?runs=a,b,c`. */
+export const runIdsOf = (runs: string | undefined) =>
+ runs ? runs.split(",").filter(Boolean) : []
diff --git a/frontend/src/components/Dashboard/dataContext.tsx b/frontend/src/components/Dashboard/dataContext.tsx
new file mode 100644
index 0000000..e63289c
--- /dev/null
+++ b/frontend/src/components/Dashboard/dataContext.tsx
@@ -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(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
+}
diff --git a/frontend/src/components/Dashboard/publish.tsx b/frontend/src/components/Dashboard/publish.tsx
index db219a1..4e26ae0 100644
--- a/frontend/src/components/Dashboard/publish.tsx
+++ b/frontend/src/components/Dashboard/publish.tsx
@@ -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
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()
diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx
index f3f450f..0949eb1 100644
--- a/frontend/src/components/Dashboard/widgets.tsx
+++ b/frontend/src/components/Dashboard/widgets.tsx
@@ -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
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
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
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
const record = (live?.value ?? null) as Record | null
diff --git a/frontend/src/components/Runs/MetricChart.tsx b/frontend/src/components/Runs/MetricChart.tsx
index f2fee4b..69f39e3 100644
--- a/frontend/src/components/Runs/MetricChart.tsx
+++ b/frontend/src/components/Runs/MetricChart.tsx
@@ -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) {
diff --git a/frontend/src/components/Runs/RunDetail.tsx b/frontend/src/components/Runs/RunDetail.tsx
index 4f2586b..30c32e6 100644
--- a/frontend/src/components/Runs/RunDetail.tsx
+++ b/frontend/src/components/Runs/RunDetail.tsx
@@ -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,
diff --git a/frontend/src/components/Runs/queries.ts b/frontend/src/components/Runs/queries.ts
index 73f855a..3d7c558 100644
--- a/frontend/src/components/Runs/queries.ts
+++ b/frontend/src/components/Runs/queries.ts
@@ -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)
diff --git a/frontend/src/routes/view.$name.tsx b/frontend/src/routes/view.$name.tsx
index e8feb4a..72a419f 100644
--- a/frontend/src/routes/view.$name.tsx
+++ b/frontend/src/routes/view.$name.tsx
@@ -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): { 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 ? (
+
+
+ navigate({
+ to: "/view/$name",
+ params: { name },
+ search: next.length ? { runs: next.join(",") } : {},
+ })
+ }
+ />
+
+
+ ) : (
+
+ )}
)
}
+
+/** 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 (
+ onChange(members.filter((one) => one !== id))}
+ onExit={() => onChange([])}
+ />
+ )
+}