Pin a chart to the last few runs of a flow
This commit is contained in:
@@ -33,6 +33,10 @@ DASHBOARD_DIR = "_dashboards"
|
|||||||
#: A chart cannot ask for an unbounded series; this is the ceiling.
|
#: A chart cannot ask for an unbounded series; this is the ceiling.
|
||||||
HISTORY_CAP = 5000
|
HISTORY_CAP = 5000
|
||||||
|
|
||||||
|
#: How many runs one pinned chart puts side by side. The client's own
|
||||||
|
#: ``MAX_SERIES``: the palette is five steps, and a sixth line repeats one.
|
||||||
|
RUN_LINES = 5
|
||||||
|
|
||||||
#: How many readings one bar draws. Mirrored in the client
|
#: How many readings one bar draws. Mirrored in the client
|
||||||
#: (``ui/core/config.ts``, ``MAX_ROWS``).
|
#: (``ui/core/config.ts``, ``MAX_ROWS``).
|
||||||
#:
|
#:
|
||||||
@@ -207,6 +211,17 @@ class WidgetDef(BaseModel):
|
|||||||
"""A chart that asks a flow for its series instead of reading the ring."""
|
"""A chart that asks a flow for its series instead of reading the ring."""
|
||||||
return self.type == "chart" and self.config.get("source") == "query"
|
return self.type == "chart" and self.config.get("source") == "query"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _runs_chart(self) -> bool:
|
||||||
|
"""A chart pinned to runs: it reads the run tables, not the engine.
|
||||||
|
|
||||||
|
The other way round from opening a dashboard against a run — that is a
|
||||||
|
way of looking at a page, this is a tile that always shows the last
|
||||||
|
few runs of something, which is what a wall panel over a lab bench
|
||||||
|
wants.
|
||||||
|
"""
|
||||||
|
return self.type == "chart" and self.config.get("source") == "runs"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def inner_bindings(self) -> Bindings:
|
def inner_bindings(self) -> Bindings:
|
||||||
"""A bar's nested readings, as documents written before rows carry them.
|
"""A bar's nested readings, as documents written before rows carry them.
|
||||||
@@ -245,6 +260,10 @@ class WidgetDef(BaseModel):
|
|||||||
@property
|
@property
|
||||||
def messages(self) -> list[str]:
|
def messages(self) -> list[str]:
|
||||||
"""Every message name this widget reads."""
|
"""Every message name this widget reads."""
|
||||||
|
if self._runs_chart:
|
||||||
|
# Its metric is a run's recorded series, which no live message
|
||||||
|
# carries — nothing for the engine to route or to keep.
|
||||||
|
return []
|
||||||
if self._query_chart:
|
if self._query_chart:
|
||||||
name = self.config.get("message")
|
name = self.config.get("message")
|
||||||
return [str(name)] if name else []
|
return [str(name)] if name else []
|
||||||
@@ -280,7 +299,7 @@ class WidgetDef(BaseModel):
|
|||||||
Nothing, for a chart that queries: the answer carries its own past, so
|
Nothing, for a chart that queries: the answer carries its own past, so
|
||||||
asking the engine to keep a ring as well would store it twice.
|
asking the engine to keep a ring as well would store it twice.
|
||||||
"""
|
"""
|
||||||
if self.type != "chart" or self._query_chart:
|
if self.type != "chart" or self._query_chart or self._runs_chart:
|
||||||
return 0
|
return 0
|
||||||
points = int((self.config.get("history") or {}).get("points") or 0)
|
points = int((self.config.get("history") or {}).get("points") or 0)
|
||||||
return min(points, HISTORY_CAP)
|
return min(points, HISTORY_CAP)
|
||||||
@@ -304,6 +323,21 @@ class WidgetDef(BaseModel):
|
|||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_binding(self) -> WidgetDef:
|
def _check_binding(self) -> WidgetDef:
|
||||||
"""Refuse a widget wired to a message it cannot carry."""
|
"""Refuse a widget wired to a message it cannot carry."""
|
||||||
|
if self._runs_chart:
|
||||||
|
runs = self.config.get("runs") or {}
|
||||||
|
if not runs.get("metric"):
|
||||||
|
raise ValueError("a chart of runs must name the metric it draws")
|
||||||
|
if not (runs.get("ids") or runs.get("group") or runs.get("flow")):
|
||||||
|
raise ValueError(
|
||||||
|
"a chart of runs must say which: a flow, a sweep, or run ids"
|
||||||
|
)
|
||||||
|
latest = int(runs.get("latest") or 1)
|
||||||
|
if not 1 <= latest <= RUN_LINES:
|
||||||
|
raise ValueError(f"a chart draws between 1 and {RUN_LINES} runs")
|
||||||
|
if len(runs.get("ids") or []) > RUN_LINES:
|
||||||
|
raise ValueError(f"a chart draws at most {RUN_LINES} runs")
|
||||||
|
return self
|
||||||
|
|
||||||
if self._query_chart:
|
if self._query_chart:
|
||||||
for key, want in (("dtype", "series"), ("request_dtype", "record")):
|
for key, want in (("dtype", "series"), ("request_dtype", "record")):
|
||||||
bound = str(self.config.get(key) or "")
|
bound = str(self.config.get(key) or "")
|
||||||
@@ -818,6 +852,7 @@ def results_dashboard(flow: FlowDef) -> DashboardDef:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BAR_ROWS",
|
"BAR_ROWS",
|
||||||
|
"RUN_LINES",
|
||||||
"COLOR_DTYPES",
|
"COLOR_DTYPES",
|
||||||
"DASHBOARD_DIR",
|
"DASHBOARD_DIR",
|
||||||
"HISTORY_CAP",
|
"HISTORY_CAP",
|
||||||
|
|||||||
@@ -493,3 +493,24 @@ def test_a_flow_declaring_no_outputs_draws_no_stats():
|
|||||||
defn = results_dashboard(training_flow().model_copy(update={"outputs": []}))
|
defn = results_dashboard(training_flow().model_copy(update={"outputs": []}))
|
||||||
|
|
||||||
assert [w.type for w in defn.widgets] == ["chart"]
|
assert [w.type for w in defn.widgets] == ["chart"]
|
||||||
|
|
||||||
|
|
||||||
|
def runs_chart(**runs) -> WidgetDef:
|
||||||
|
return WidgetDef(id="curves", type="chart", config={"source": "runs", "runs": runs})
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_chart_pinned_to_runs_reads_no_live_message():
|
||||||
|
"""Its series is in the run tables; the engine has nothing to keep for it."""
|
||||||
|
widget = runs_chart(metric="study.loss", flow="study", latest=3)
|
||||||
|
|
||||||
|
assert widget.messages == []
|
||||||
|
assert widget.history_points == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_chart_of_runs_must_say_which_runs_and_which_metric():
|
||||||
|
with pytest.raises(ValueError, match="metric"):
|
||||||
|
runs_chart(flow="study")
|
||||||
|
with pytest.raises(ValueError, match="a flow, a sweep, or run ids"):
|
||||||
|
runs_chart(metric="study.loss")
|
||||||
|
with pytest.raises(ValueError, match="between 1 and 5"):
|
||||||
|
runs_chart(metric="study.loss", flow="study", latest=9)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQueries } from "@tanstack/react-query"
|
import { useQueries, useQuery } from "@tanstack/react-query"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
import type { HistoryPoint } from "@/client"
|
import type { HistoryPoint } from "@/client"
|
||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
} from "@/components/Common/RangePicker"
|
} from "@/components/Common/RangePicker"
|
||||||
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
|
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
|
||||||
import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore"
|
import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore"
|
||||||
import { NO_CURVE } from "@/components/Runs/queries"
|
import {
|
||||||
|
compareQueryOptions,
|
||||||
|
NO_CURVE,
|
||||||
|
runsListQueryOptions,
|
||||||
|
shortenRunLabel,
|
||||||
|
} from "@/components/Runs/queries"
|
||||||
import { type DataContext, useDataContext } from "./dataContext"
|
import { type DataContext, useDataContext } from "./dataContext"
|
||||||
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
|
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
|
||||||
import { usePalette } from "./settings"
|
import { usePalette } from "./settings"
|
||||||
@@ -96,13 +101,86 @@ export function ChartWidget(props: WidgetProps) {
|
|||||||
// draws from that instead, whichever source it was pointed at otherwise.
|
// draws from that instead, whichever source it was pointed at otherwise.
|
||||||
const context = useDataContext()
|
const context = useDataContext()
|
||||||
if (context) return <ContextChart {...props} context={context} />
|
if (context) return <ContextChart {...props} context={context} />
|
||||||
return config(props.widget).source === "query" ? (
|
const source = config(props.widget).source
|
||||||
|
if (source === "runs") return <RunsChart {...props} />
|
||||||
|
return source === "query" ? (
|
||||||
<QueryChart {...props} />
|
<QueryChart {...props} />
|
||||||
) : (
|
) : (
|
||||||
<LiveChart {...props} />
|
<LiveChart {...props} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last few runs of something, side by side, always.
|
||||||
|
*
|
||||||
|
* A tile rather than a way of looking at the page: what a panel over a lab
|
||||||
|
* bench shows without anyone picking runs first. The comparison endpoint
|
||||||
|
* already answers in the shape this draws, so the widget only has to decide
|
||||||
|
* which runs it means.
|
||||||
|
*/
|
||||||
|
function RunsChart({ widget }: WidgetProps) {
|
||||||
|
const cfg = (widget.config ?? {}) as {
|
||||||
|
runs?: {
|
||||||
|
metric?: string
|
||||||
|
flow?: string
|
||||||
|
group?: string
|
||||||
|
ids?: string[]
|
||||||
|
latest?: number
|
||||||
|
}
|
||||||
|
refresh_s?: number
|
||||||
|
}
|
||||||
|
const pick = cfg.runs ?? {}
|
||||||
|
const metric = pick.metric ?? ""
|
||||||
|
const explicit = pick.ids ?? []
|
||||||
|
const refreshMs = Math.max(15, Number(cfg.refresh_s) || 60) * 1000
|
||||||
|
|
||||||
|
// Named runs need no lookup. Otherwise the newest of a sweep, or of a flow —
|
||||||
|
// and of a flow only the ones that finished, since a panel comparing curves
|
||||||
|
// wants results rather than the crash from ten minutes ago.
|
||||||
|
const found = useQuery({
|
||||||
|
...runsListQueryOptions(
|
||||||
|
pick.group
|
||||||
|
? { group: pick.group }
|
||||||
|
: {
|
||||||
|
flow: pick.flow,
|
||||||
|
status: "ok",
|
||||||
|
limit: Math.min(Math.max(Number(pick.latest) || 3, 1), MAX_SERIES),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
enabled: explicit.length === 0 && Boolean(pick.group || pick.flow),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ids = explicit.length
|
||||||
|
? explicit.slice(0, MAX_SERIES)
|
||||||
|
: (found.data ?? []).slice(0, MAX_SERIES).map((run) => run.id)
|
||||||
|
|
||||||
|
const { data, isPending } = useQuery(
|
||||||
|
compareQueryOptions(ids, metric, refreshMs),
|
||||||
|
)
|
||||||
|
const palette = usePalette()
|
||||||
|
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
||||||
|
const points = lines.reduce(
|
||||||
|
(total, line) => total + (line.points?.length ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!metric) return <p className="text-muted-foreground">Pick a metric.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UplotChart
|
||||||
|
labels={lines.map((line) => shortenRunLabel(line.label))}
|
||||||
|
plots={lines.map((line) =>
|
||||||
|
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
|
||||||
|
)}
|
||||||
|
palette={palette}
|
||||||
|
xTime={false}
|
||||||
|
pending={isPending || found.isPending}
|
||||||
|
empty={points === 0 ? NO_CURVE : undefined}
|
||||||
|
{...presentation(widget.config as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The same chart, drawn from a data context.
|
* The same chart, drawn from a data context.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
PanelTitle,
|
PanelTitle,
|
||||||
SidePanel,
|
SidePanel,
|
||||||
} from "@/components/Flow/SidePanel"
|
} from "@/components/Flow/SidePanel"
|
||||||
|
import { runOverviewQueryOptions } from "@/components/Runs/queries"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -144,10 +145,158 @@ function MessagePicker({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which runs a pinned chart draws, and which of their metrics. */
|
||||||
|
type RunsPick = {
|
||||||
|
metric?: string
|
||||||
|
flow?: string
|
||||||
|
group?: string
|
||||||
|
ids?: string[]
|
||||||
|
latest?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a pinned chart says which runs it means. */
|
||||||
|
const RUN_PICKS = [
|
||||||
|
["latest", "Latest"],
|
||||||
|
["group", "Sweep"],
|
||||||
|
["ids", "Named"],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runs a chart is pinned to.
|
||||||
|
*
|
||||||
|
* The flows offered are the ones that have actually run — a flow with no runs
|
||||||
|
* has no curve to draw, and the run tables already know which those are.
|
||||||
|
*/
|
||||||
|
function RunsSourceFields({
|
||||||
|
runs,
|
||||||
|
refreshS,
|
||||||
|
onChange,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
runs: RunsPick
|
||||||
|
refreshS: unknown
|
||||||
|
onChange: (next: RunsPick) => void
|
||||||
|
onRefresh: (seconds: number) => void
|
||||||
|
}) {
|
||||||
|
const { data: flows } = useQuery(runOverviewQueryOptions())
|
||||||
|
const mode = runs.ids?.length ? "ids" : runs.group ? "group" : "latest"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="font-normal text-sm">Of flow</Label>
|
||||||
|
<Select
|
||||||
|
value={runs.flow ?? ""}
|
||||||
|
onValueChange={(flow) => onChange({ ...runs, flow })}
|
||||||
|
>
|
||||||
|
<SelectTrigger data-testid="runs-flow">
|
||||||
|
<SelectValue placeholder="Pick a flow" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(flows ?? []).map((row) => (
|
||||||
|
<SelectItem key={row.flow} value={row.flow}>
|
||||||
|
{row.flow}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessagePicker
|
||||||
|
kind="chart"
|
||||||
|
value={runs.metric ?? ""}
|
||||||
|
label="Draws"
|
||||||
|
testId="runs-metric"
|
||||||
|
placeholder="Pick a metric"
|
||||||
|
filter={(message) =>
|
||||||
|
(message.dtype === "float" || message.dtype === "int") &&
|
||||||
|
(!runs.flow || message.name.startsWith(`${runs.flow}.`))
|
||||||
|
}
|
||||||
|
onPick={(metric) => onChange({ ...runs, metric })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Segmented
|
||||||
|
value={mode}
|
||||||
|
options={RUN_PICKS}
|
||||||
|
label="Which runs"
|
||||||
|
testId="runs-pick"
|
||||||
|
onChange={(picked) =>
|
||||||
|
onChange({
|
||||||
|
...runs,
|
||||||
|
group: undefined,
|
||||||
|
ids: undefined,
|
||||||
|
latest: picked === "latest" ? (runs.latest ?? 3) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === "latest" && (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="font-normal text-sm">How many</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={MAX_SERIES}
|
||||||
|
value={runs.latest ?? 3}
|
||||||
|
aria-label="How many runs"
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...runs, latest: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "group" && (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="font-normal text-sm">Sweep</Label>
|
||||||
|
<Input
|
||||||
|
value={runs.group ?? ""}
|
||||||
|
placeholder="A sweep's group id"
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...runs, group: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "ids" && (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="font-normal text-sm">Runs</Label>
|
||||||
|
<Input
|
||||||
|
value={(runs.ids ?? []).join(",")}
|
||||||
|
placeholder="Run ids, comma separated"
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
...runs,
|
||||||
|
ids: event.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((one) => one.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="font-normal text-sm">Refresh (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={15}
|
||||||
|
value={Number(refreshS) || 60}
|
||||||
|
aria-label="Refresh seconds"
|
||||||
|
onChange={(event) => onRefresh(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Where a chart's lines come from: what the engine kept, or what it asks for. */
|
/** Where a chart's lines come from: what the engine kept, or what it asks for. */
|
||||||
const CHART_SOURCES = [
|
const CHART_SOURCES = [
|
||||||
["live", "Live"],
|
["live", "Live"],
|
||||||
["query", "Query"],
|
["query", "Query"],
|
||||||
|
["runs", "Runs"],
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -278,6 +427,7 @@ export function WidgetPanel({
|
|||||||
onChange({ config: { ...cfg, ...changes } })
|
onChange({ config: { ...cfg, ...changes } })
|
||||||
|
|
||||||
const series = seriesOf(widget)
|
const series = seriesOf(widget)
|
||||||
|
const runsPick = (cfg.runs ?? {}) as RunsPick
|
||||||
const setSeries = (next: Series[]) => set({ series: next })
|
const setSeries = (next: Series[]) => set({ series: next })
|
||||||
// A bar's readings. An empty row stands in for none, so a fresh bar offers
|
// A bar's readings. An empty row stands in for none, so a fresh bar offers
|
||||||
// the picker rather than only a button.
|
// the picker rather than only a button.
|
||||||
@@ -305,6 +455,7 @@ export function WidgetPanel({
|
|||||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||||
const setOptions = (next: typeof options) => set({ options: next })
|
const setOptions = (next: typeof options) => set({ options: next })
|
||||||
const querying = cfg.source === "query"
|
const querying = cfg.source === "query"
|
||||||
|
const pinnedRuns = cfg.source === "runs"
|
||||||
// What this chart would refresh at with nothing configured. The viewer can
|
// What this chart would refresh at with nothing configured. The viewer can
|
||||||
// pick another window on the widget, which moves it.
|
// pick another window on the widget, which moves it.
|
||||||
const paced = refreshFor(
|
const paced = refreshFor(
|
||||||
@@ -375,13 +526,20 @@ export function WidgetPanel({
|
|||||||
) : widget.type === "chart" ? (
|
) : widget.type === "chart" ? (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Segmented
|
<Segmented
|
||||||
value={querying ? "query" : "live"}
|
value={pinnedRuns ? "runs" : querying ? "query" : "live"}
|
||||||
options={CHART_SOURCES}
|
options={CHART_SOURCES}
|
||||||
label="Where the chart's data comes from"
|
label="Where the chart's data comes from"
|
||||||
testId="chart-source"
|
testId="chart-source"
|
||||||
onChange={(source) => set({ source })}
|
onChange={(source) => set({ source })}
|
||||||
/>
|
/>
|
||||||
{querying ? (
|
{pinnedRuns ? (
|
||||||
|
<RunsSourceFields
|
||||||
|
runs={runsPick}
|
||||||
|
refreshS={cfg.refresh_s}
|
||||||
|
onChange={(runs) => set({ runs })}
|
||||||
|
onRefresh={(refresh_s) => set({ refresh_s })}
|
||||||
|
/>
|
||||||
|
) : querying ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<MessagePicker
|
<MessagePicker
|
||||||
kind="chart"
|
kind="chart"
|
||||||
|
|||||||
@@ -118,6 +118,20 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
|||||||
if (widget.type === "markdown" || widget.type === "clock") return null
|
if (widget.type === "markdown" || widget.type === "clock") return null
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
|
|
||||||
|
if (widget.type === "chart" && cfg.source === "runs") {
|
||||||
|
const runs = (cfg.runs ?? {}) as {
|
||||||
|
metric?: string
|
||||||
|
flow?: string
|
||||||
|
group?: string
|
||||||
|
ids?: string[]
|
||||||
|
}
|
||||||
|
if (!runs.metric) return "This chart names no run metric yet."
|
||||||
|
if (!runs.ids?.length && !runs.group && !runs.flow) {
|
||||||
|
return "Say which runs: a flow, a sweep, or run ids."
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (widget.type === "chart" && cfg.source === "query") {
|
if (widget.type === "chart" && cfg.source === "query") {
|
||||||
if (!text(cfg.request)) return "This chart does not ask for anything yet."
|
if (!text(cfg.request)) return "This chart does not ask for anything yet."
|
||||||
if (!text(cfg.message)) return "This chart has no answer to draw yet."
|
if (!text(cfg.message)) return "This chart has no answer to draw yet."
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
compareQueryOptions,
|
compareQueryOptions,
|
||||||
NO_CURVE,
|
NO_CURVE,
|
||||||
runMetricsQueryOptions,
|
runMetricsQueryOptions,
|
||||||
shortId,
|
shortenRunLabel,
|
||||||
} from "./queries"
|
} from "./queries"
|
||||||
|
|
||||||
/** The metrics one run recorded, in the order they are worth offering. */
|
/** The metrics one run recorded, in the order they are worth offering. */
|
||||||
@@ -46,11 +46,7 @@ export function RunMetricChart({
|
|||||||
compareQueryOptions(ids, metric, refreshMs),
|
compareQueryOptions(ids, metric, refreshMs),
|
||||||
)
|
)
|
||||||
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
||||||
const labels = lines.map((line) =>
|
const labels = lines.map((line) => shortenRunLabel(line.label))
|
||||||
// The endpoint labels a line with the whole run id, which is too long to
|
|
||||||
// read in a legend beside four others.
|
|
||||||
line.label.replace(/^\S+/, (id) => shortId(id)),
|
|
||||||
)
|
|
||||||
const plots: HistoryPoint[][] = lines.map((line) =>
|
const plots: HistoryPoint[][] = lines.map((line) =>
|
||||||
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
|
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ export const runsInfiniteQueryOptions = (filters: RunFilters) => ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** One page of runs, for a caller that pages nothing — a pinned chart. */
|
||||||
|
export const runsListQueryOptions = (
|
||||||
|
filters: RunFilters & { limit?: number },
|
||||||
|
) => ({
|
||||||
|
queryKey: [...runKeys.all, "pinned", filters] as const,
|
||||||
|
queryFn: () => RunsService.readRuns(filters),
|
||||||
|
})
|
||||||
|
|
||||||
export const runOverviewQueryOptions = () => ({
|
export const runOverviewQueryOptions = () => ({
|
||||||
queryKey: runKeys.overview,
|
queryKey: runKeys.overview,
|
||||||
queryFn: () => RunsService.readOverview(),
|
queryFn: () => RunsService.readOverview(),
|
||||||
@@ -138,6 +146,15 @@ export const NO_CURVE =
|
|||||||
/** A run id, short enough for a table cell. The tail is the random half. */
|
/** A run id, short enough for a table cell. The tail is the random half. */
|
||||||
export const shortId = (id: string) => id.slice(-8)
|
export const shortId = (id: string) => id.slice(-8)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A series label from the comparison endpoint, cut down to fit a legend.
|
||||||
|
*
|
||||||
|
* It leads with the whole run id, which is 22 characters of mostly timestamp
|
||||||
|
* beside four others.
|
||||||
|
*/
|
||||||
|
export const shortenRunLabel = (label: string) =>
|
||||||
|
label.replace(/^\S+/, (id) => shortId(id))
|
||||||
|
|
||||||
/** A commit, at the length everyone reads one at. */
|
/** A commit, at the length everyone reads one at. */
|
||||||
export const shortCommit = (commit: string) => commit.slice(0, 7)
|
export const shortCommit = (commit: string) => commit.slice(0, 7)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user