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

Pick a message.

+ } + + return ( +
+ 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)} + /> + {drawn.length > MAX_SERIES && ( +

+ Showing {MAX_SERIES} of {drawn.length} lines. +

+ )} +
+ ) +} + /** * 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 ( +
+ + Showing {context.label} + {context.pending ? "…" : ""} + + + {members.length > 1 && + members.map((id) => ( + + {shortId(id)} + + + ))} + + {members.length > 1 && ( + + single readings come from {shortId(members[0])} + + )} + +
+ + Runs + + +
+
+ ) +} 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([])} + /> + ) +}