Scroll the runs table, pick a range, and choose what a comparison plots against
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Failing after 3m14s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m44s
pre-commit / pre-commit (push) Failing after 3m54s
Test Backend / test-backend (push) Successful in 3m12s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m28s
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Failing after 3m14s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m44s
pre-commit / pre-commit (push) Failing after 3m54s
Test Backend / test-backend (push) Successful in 3m12s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m28s
This commit is contained in:
@@ -132,6 +132,9 @@ class MetricSeries(BaseModel):
|
|||||||
|
|
||||||
class SeriesAnswer(BaseModel):
|
class SeriesAnswer(BaseModel):
|
||||||
metric: str
|
metric: str
|
||||||
|
#: What the x values are: "step", "time" (seconds since this run's first
|
||||||
|
#: reading), or the name of another metric this one was plotted against.
|
||||||
|
x: str = "step"
|
||||||
lines: list[MetricSeries] = Field(default_factory=list)
|
lines: list[MetricSeries] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,13 +359,38 @@ def read_metrics(
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _points(session: Session, run_id: str, metric: str, x: str) -> list[list[float]]:
|
||||||
|
"""One run's readings of ``metric``, against whichever x was asked for.
|
||||||
|
|
||||||
|
The step is the default because it is what every run has. Time answers
|
||||||
|
"which one got there sooner", and is measured from this run's own first
|
||||||
|
reading so that runs started hours apart still lie on top of each other.
|
||||||
|
Another metric answers "against what the loop was actually counting" — an
|
||||||
|
epoch, or samples seen — and is joined on the step the two share, which is
|
||||||
|
the only thing they have in common.
|
||||||
|
"""
|
||||||
|
rows = _series(session, run_id, metric)
|
||||||
|
if x == "time":
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
start = min(row.ts for row in rows)
|
||||||
|
return [[row.ts - start, row.value] for row in rows]
|
||||||
|
if x and x != "step":
|
||||||
|
against = {row.step: row.value for row in _series(session, run_id, x)}
|
||||||
|
return [[against[row.step], row.value] for row in rows if row.step in against]
|
||||||
|
return [[float(row.step), row.value] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/series/compare", response_model=SeriesAnswer)
|
@router.get("/series/compare", response_model=SeriesAnswer)
|
||||||
def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
|
def compare_metric(session: SessionDep, ids: str, metric: str, x: str = "") -> Any:
|
||||||
"""One metric across several runs, as the chart widget's series shape.
|
"""One metric across several runs, as the chart widget's series shape.
|
||||||
|
|
||||||
This is the comparison view: it answers in the same shape a flow answers a
|
This is the comparison view: it answers in the same shape a flow answers a
|
||||||
chart's query with, so putting three training curves beside each other is
|
chart's query with, so putting three training curves beside each other is
|
||||||
a widget binding rather than a screen of its own.
|
a widget binding rather than a screen of its own.
|
||||||
|
|
||||||
|
``x`` names what to plot against — nothing or "step", "time", or another
|
||||||
|
metric of the same runs.
|
||||||
"""
|
"""
|
||||||
run_ids = [part for part in ids.split(",") if part]
|
run_ids = [part for part in ids.split(",") if part]
|
||||||
if not run_ids:
|
if not run_ids:
|
||||||
@@ -376,13 +404,10 @@ def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
|
|||||||
run = runs.get(run_id)
|
run = runs.get(run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
continue
|
continue
|
||||||
rows = _series(session, run_id, metric)
|
|
||||||
label = run_id
|
label = run_id
|
||||||
if run.seed is not None:
|
if run.seed is not None:
|
||||||
label = f"{run_id} (seed {run.seed})"
|
label = f"{run_id} (seed {run.seed})"
|
||||||
lines.append(
|
lines.append(
|
||||||
MetricSeries(
|
MetricSeries(label=label, points=_points(session, run_id, metric, x))
|
||||||
label=label, points=[[float(row.step), row.value] for row in rows]
|
|
||||||
)
|
)
|
||||||
)
|
return SeriesAnswer(metric=metric, x=x or "step", lines=lines)
|
||||||
return SeriesAnswer(metric=metric, lines=lines)
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import json
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
from fluksio.core.config import settings
|
from fluksio.core.config import settings
|
||||||
from fluksio.core.db import engine as db_engine
|
from fluksio.core.db import engine as db_engine
|
||||||
@@ -380,3 +380,85 @@ def test_a_curve_whose_run_is_gone_is_empty_rather_than_an_error(
|
|||||||
)
|
)
|
||||||
assert answer.status_code == 200
|
assert answer.status_code == 200
|
||||||
assert answer.json() == []
|
assert answer.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def _metric(run_id: str, name: str, step: int, value: float, ts: float) -> RunMetric:
|
||||||
|
return RunMetric(run_id=run_id, name=name, step=step, value=value, ts=ts)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plotted():
|
||||||
|
"""A run with a loss curve and an epoch counter beside it."""
|
||||||
|
run_id = new_run_id()
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
session.add(
|
||||||
|
Run(id=run_id, flow="study", status="ok", created_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
for step, (loss, epoch) in enumerate([(1.0, 10.0), (0.5, 20.0), (0.25, 30.0)]):
|
||||||
|
session.add(_metric(run_id, "study.loss", step, loss, 100.0 + step * 5))
|
||||||
|
session.add(_metric(run_id, "study.epoch", step, epoch, 100.0 + step * 5))
|
||||||
|
session.commit()
|
||||||
|
yield run_id
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
for row in session.exec(
|
||||||
|
select(RunMetric).where(col(RunMetric.run_id) == run_id)
|
||||||
|
).all():
|
||||||
|
session.delete(row)
|
||||||
|
session.delete(session.get(Run, run_id))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_comparison_is_plotted_against_the_step_by_default(
|
||||||
|
client, superuser_token_headers, plotted
|
||||||
|
):
|
||||||
|
answer = client.get(
|
||||||
|
f"{settings.API_V1_STR}/runs/series/compare",
|
||||||
|
params={"ids": plotted, "metric": "study.loss"},
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert answer["x"] == "step"
|
||||||
|
assert answer["lines"][0]["points"] == [[0.0, 1.0], [1.0, 0.5], [2.0, 0.25]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_is_measured_from_this_runs_own_first_reading(
|
||||||
|
client, superuser_token_headers, plotted
|
||||||
|
):
|
||||||
|
"""Runs started hours apart still lie on top of each other."""
|
||||||
|
answer = client.get(
|
||||||
|
f"{settings.API_V1_STR}/runs/series/compare",
|
||||||
|
params={"ids": plotted, "metric": "study.loss", "x": "time"},
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert answer["x"] == "time"
|
||||||
|
assert answer["lines"][0]["points"] == [[0.0, 1.0], [5.0, 0.5], [10.0, 0.25]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_metric_can_be_plotted_against_another(
|
||||||
|
client, superuser_token_headers, plotted
|
||||||
|
):
|
||||||
|
answer = client.get(
|
||||||
|
f"{settings.API_V1_STR}/runs/series/compare",
|
||||||
|
params={"ids": plotted, "metric": "study.loss", "x": "study.epoch"},
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert answer["lines"][0]["points"] == [[10.0, 1.0], [20.0, 0.5], [30.0, 0.25]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_step_the_x_metric_never_reached_is_left_out(
|
||||||
|
client, superuser_token_headers, plotted
|
||||||
|
):
|
||||||
|
"""The join is on the step, which is the only thing two series share."""
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
session.add(_metric(plotted, "study.loss", 3, 0.1, 120.0))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
answer = client.get(
|
||||||
|
f"{settings.API_V1_STR}/runs/series/compare",
|
||||||
|
params={"ids": plotted, "metric": "study.loss", "x": "study.epoch"},
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert [point[0] for point in answer["lines"][0]["points"]] == [10.0, 20.0, 30.0]
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ published to. Flows own the namespace; everything else is a client of it.
|
|||||||
| `GET` | `/runs/{id}` | one run in full: params, result, per-node record, artifacts |
|
| `GET` | `/runs/{id}` | one run in full: params, result, per-node record, artifacts |
|
||||||
| `POST` | `/runs/{id}/cancel` | stop it |
|
| `POST` | `/runs/{id}/cancel` | stop it |
|
||||||
| `GET` | `/runs/{id}/metrics?name=&stride=` | one metric's series, in step order; every series of the run without `name` |
|
| `GET` | `/runs/{id}/metrics?name=&stride=` | one metric's series, in step order; every series of the run without `name` |
|
||||||
| `GET` | `/runs/series/compare?ids=a,b,c&metric=` | that metric across several runs |
|
| `GET` | `/runs/series/compare?ids=a,b,c&metric=&x=` | that metric across several runs. `x` is what to plot against: nothing or `step`, `time` (seconds since each run's own first reading), or another metric's name, joined on the step the two share |
|
||||||
|
|
||||||
Submitting answers immediately with a `queued` run. Wrong parameters — an
|
Submitting answers immediately with a `queued` run. Wrong parameters — an
|
||||||
undeclared name, a value of the wrong type — come back as a 422 naming the
|
undeclared name, a value of the wrong type — come back as a 422 naming the
|
||||||
|
|||||||
@@ -295,9 +295,16 @@ flow, by status, or down to one sweep. A sweep is worth filtering to — the
|
|||||||
table then draws a column per parameter that actually varied, which is what
|
table then draws a column per parameter that actually varied, which is what
|
||||||
makes fifty runs of one flow readable.
|
makes fifty runs of one flow readable.
|
||||||
|
|
||||||
Tick two or more and their curves go side by side. That comparison is the
|
Tick two or more and their curves go side by side — shift-click to take a
|
||||||
|
range, or the header box to take everything on screen. That comparison is the
|
||||||
address, so a link to it is a link someone else can open.
|
address, so a link to it is a link someone else can open.
|
||||||
|
|
||||||
|
The curves are drawn against the step by default. They can also be drawn
|
||||||
|
against elapsed seconds, which answers "which one got there sooner" and is
|
||||||
|
measured from each run's own first reading so that runs started hours apart
|
||||||
|
still lie on top of each other; or against another metric of the same runs —
|
||||||
|
an epoch, or samples seen — joined on the step the two share.
|
||||||
|
|
||||||
One run in full is params, the per-node record with its logs and traceback,
|
One run in full is params, the per-node record with its logs and traceback,
|
||||||
the artifacts it made, its metrics and its result.
|
the artifacts it made, its metrics and its result.
|
||||||
|
|
||||||
|
|||||||
@@ -2622,6 +2622,11 @@ export const SeriesAnswerSchema = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
title: 'Metric'
|
title: 'Metric'
|
||||||
},
|
},
|
||||||
|
x: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'X',
|
||||||
|
default: 'step'
|
||||||
|
},
|
||||||
lines: {
|
lines: {
|
||||||
items: {
|
items: {
|
||||||
'$ref': '#/components/schemas/MetricSeries'
|
'$ref': '#/components/schemas/MetricSeries'
|
||||||
|
|||||||
@@ -1899,9 +1899,13 @@ export class RunsService {
|
|||||||
* This is the comparison view: it answers in the same shape a flow answers a
|
* This is the comparison view: it answers in the same shape a flow answers a
|
||||||
* chart's query with, so putting three training curves beside each other is
|
* chart's query with, so putting three training curves beside each other is
|
||||||
* a widget binding rather than a screen of its own.
|
* a widget binding rather than a screen of its own.
|
||||||
|
*
|
||||||
|
* ``x`` names what to plot against — nothing or "step", "time", or another
|
||||||
|
* metric of the same runs.
|
||||||
* @param data The data for the request.
|
* @param data The data for the request.
|
||||||
* @param data.ids
|
* @param data.ids
|
||||||
* @param data.metric
|
* @param data.metric
|
||||||
|
* @param data.x
|
||||||
* @returns SeriesAnswer Successful Response
|
* @returns SeriesAnswer Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
@@ -1911,7 +1915,8 @@ export class RunsService {
|
|||||||
url: '/api/v1/runs/series/compare',
|
url: '/api/v1/runs/series/compare',
|
||||||
query: {
|
query: {
|
||||||
ids: data.ids,
|
ids: data.ids,
|
||||||
metric: data.metric
|
metric: data.metric,
|
||||||
|
x: data.x
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: 'Validation Error'
|
422: 'Validation Error'
|
||||||
|
|||||||
@@ -929,6 +929,7 @@ export type SecretValue = {
|
|||||||
|
|
||||||
export type SeriesAnswer = {
|
export type SeriesAnswer = {
|
||||||
metric: string;
|
metric: string;
|
||||||
|
x?: string;
|
||||||
lines?: Array<MetricSeries>;
|
lines?: Array<MetricSeries>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1611,6 +1612,7 @@ export type RunsReadMetricsResponse = (Array<MetricPoint>);
|
|||||||
export type RunsCompareMetricData = {
|
export type RunsCompareMetricData = {
|
||||||
ids: string;
|
ids: string;
|
||||||
metric: string;
|
metric: string;
|
||||||
|
x?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RunsCompareMetricResponse = (SeriesAnswer);
|
export type RunsCompareMetricResponse = (SeriesAnswer);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
compareQueryOptions,
|
compareQueryOptions,
|
||||||
NO_CURVE,
|
NO_CURVE,
|
||||||
runsListQueryOptions,
|
runsListQueryOptions,
|
||||||
|
STEP_AXIS,
|
||||||
shortenRunLabel,
|
shortenRunLabel,
|
||||||
} from "@/components/Runs/queries"
|
} from "@/components/Runs/queries"
|
||||||
import { type DataContext, useDataContext } from "./dataContext"
|
import { type DataContext, useDataContext } from "./dataContext"
|
||||||
@@ -155,7 +156,7 @@ function RunsChart({ widget }: WidgetProps) {
|
|||||||
: (found.data ?? []).slice(0, MAX_SERIES).map((run) => run.id)
|
: (found.data ?? []).slice(0, MAX_SERIES).map((run) => run.id)
|
||||||
|
|
||||||
const { data, isPending } = useQuery(
|
const { data, isPending } = useQuery(
|
||||||
compareQueryOptions(ids, metric, refreshMs),
|
compareQueryOptions(ids, metric, STEP_AXIS, refreshMs),
|
||||||
)
|
)
|
||||||
const palette = usePalette()
|
const palette = usePalette()
|
||||||
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
compareQueryOptions,
|
compareQueryOptions,
|
||||||
NO_CURVE,
|
NO_CURVE,
|
||||||
runMetricsQueryOptions,
|
runMetricsQueryOptions,
|
||||||
|
STEP_AXIS,
|
||||||
shortenRunLabel,
|
shortenRunLabel,
|
||||||
|
TIME_AXIS,
|
||||||
} 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. */
|
||||||
@@ -28,32 +31,41 @@ export function useMetricNames(runId: string | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One metric across one or more runs, drawn on a step axis.
|
* One metric across one or more runs.
|
||||||
*
|
*
|
||||||
* The endpoint answers in the chart widget's own series shape, so comparing
|
* The endpoint answers in the chart widget's own series shape, so comparing
|
||||||
* three curves and showing one are the same call with a different id list.
|
* three curves and showing one are the same call with a different id list.
|
||||||
|
*
|
||||||
|
* `x` is what the readings are plotted against: the step they were recorded
|
||||||
|
* at, the seconds since the run's own first one, or another metric of the same
|
||||||
|
* runs. Never a clock — even "time" is an elapsed count, which is what makes
|
||||||
|
* two runs started hours apart comparable.
|
||||||
*/
|
*/
|
||||||
export function RunMetricChart({
|
export function RunMetricChart({
|
||||||
ids,
|
ids,
|
||||||
metric,
|
metric,
|
||||||
|
x = STEP_AXIS,
|
||||||
refreshMs,
|
refreshMs,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
ids: string[]
|
ids: string[]
|
||||||
metric: string
|
metric: string
|
||||||
|
x?: string
|
||||||
refreshMs?: number
|
refreshMs?: number
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { data, isPending } = useQuery(
|
const { data, isPending } = useQuery(
|
||||||
compareQueryOptions(ids, metric, refreshMs),
|
compareQueryOptions(ids, metric, x, refreshMs),
|
||||||
)
|
)
|
||||||
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
||||||
const labels = lines.map((line) => shortenRunLabel(line.label))
|
const labels = lines.map((line) => shortenRunLabel(line.label))
|
||||||
const plots: HistoryPoint[][] = lines.map((line) =>
|
const plots: HistoryPoint[][] = lines.map((line) =>
|
||||||
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
|
(line.points ?? []).map(([at, value]) => ({ ts: at, value })),
|
||||||
)
|
)
|
||||||
const drawn = plots.reduce((total, plot) => total + plot.length, 0)
|
const drawn = plots.reduce((total, plot) => total + plot.length, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 flex-col gap-2">
|
<div className={cn("flex min-h-0 flex-col gap-2", className ?? "h-64")}>
|
||||||
<UplotChart
|
<UplotChart
|
||||||
labels={labels}
|
labels={labels}
|
||||||
plots={plots}
|
plots={plots}
|
||||||
@@ -84,7 +96,7 @@ export function MetricPicker({
|
|||||||
if (names.length === 0) return null
|
if (names.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={onChange}>
|
<Select value={value} onValueChange={onChange}>
|
||||||
<SelectTrigger className="h-8 w-56">
|
<SelectTrigger className="h-8 w-56" aria-label="Metric">
|
||||||
<SelectValue placeholder="Metric" />
|
<SelectValue placeholder="Metric" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -97,3 +109,44 @@ export function MetricPicker({
|
|||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the comparison is plotted against.
|
||||||
|
*
|
||||||
|
* Doubles as the axis label — it sits directly above the chart, and naming the
|
||||||
|
* axis twice is one caption too many.
|
||||||
|
*/
|
||||||
|
export function AxisPicker({
|
||||||
|
names,
|
||||||
|
metric,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
names: string[]
|
||||||
|
/** Excluded from the choices: a metric against itself is a straight line. */
|
||||||
|
metric: string
|
||||||
|
value: string
|
||||||
|
onChange: (x: string) => void
|
||||||
|
}) {
|
||||||
|
const options = [
|
||||||
|
{ value: STEP_AXIS, label: "vs step" },
|
||||||
|
{ value: TIME_AXIS, label: "vs time (s)" },
|
||||||
|
...names
|
||||||
|
.filter((name) => name !== metric)
|
||||||
|
.map((name) => ({ value: name, label: `vs ${name}` })),
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="h-8 w-44" aria-label="Plotted against">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, Download } from "lucide-react"
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
import type { ArtifactRow, RunNodeRow } from "@/client"
|
import type { ArtifactRow, RunNodeRow } from "@/client"
|
||||||
|
import { ValuePreview } from "@/components/Flow/ValuePreview"
|
||||||
import { ago } from "@/components/Health/queries"
|
import { ago } from "@/components/Health/queries"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -121,15 +122,7 @@ export function RunDetail({ id }: { id: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{Object.entries(run.params).map(([key, value]) => (
|
{Object.entries(run.params).map(([key, value]) => (
|
||||||
<div
|
<Entry key={key} name={key} value={value} />
|
||||||
key={key}
|
|
||||||
className="flex justify-between gap-4 border-border border-b py-1"
|
|
||||||
>
|
|
||||||
<dt className={LABEL}>{key}</dt>
|
|
||||||
<dd className="truncate font-mono text-sm">
|
|
||||||
{paramText(value)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
@@ -154,15 +147,7 @@ export function RunDetail({ id }: { id: string }) {
|
|||||||
<h2 className="font-medium text-sm">Result</h2>
|
<h2 className="font-medium text-sm">Result</h2>
|
||||||
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{Object.entries(run.result ?? {}).map(([key, value]) => (
|
{Object.entries(run.result ?? {}).map(([key, value]) => (
|
||||||
<div
|
<Entry key={key} name={key} value={value} />
|
||||||
key={key}
|
|
||||||
className="flex justify-between gap-4 border-border border-b py-1"
|
|
||||||
>
|
|
||||||
<dt className={LABEL}>{key}</dt>
|
|
||||||
<dd className="truncate font-mono text-sm">
|
|
||||||
{paramText(value)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
@@ -175,6 +160,39 @@ export function RunDetail({ id }: { id: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One named value: read directly when it is a scalar, unfolded when it is not.
|
||||||
|
*
|
||||||
|
* The same disclosure a node's ports use on the canvas — a run's report is a
|
||||||
|
* record like any other, and serialising it onto one truncated line answers
|
||||||
|
* nothing.
|
||||||
|
*/
|
||||||
|
function Entry({ name, value }: { name: string; value: unknown }) {
|
||||||
|
const structured = value !== null && typeof value === "object"
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"gap-4 border-border border-b py-1",
|
||||||
|
structured ? "flex flex-col" : "flex justify-between",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<dt className={LABEL}>{name}</dt>
|
||||||
|
<dd
|
||||||
|
className={cn(
|
||||||
|
"min-w-0",
|
||||||
|
structured ? "" : "truncate font-mono text-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{structured ? (
|
||||||
|
<ValuePreview value={value} defaultOpen />
|
||||||
|
) : (
|
||||||
|
paramText(value)
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Fact({
|
function Fact({
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import { FlaskConical, X } from "lucide-react"
|
import { FlaskConical, X } from "lucide-react"
|
||||||
|
import { useRef } from "react"
|
||||||
|
|
||||||
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
|
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
|
||||||
import { MAX_SERIES } from "@/components/Common/UplotChart"
|
import { MAX_SERIES } from "@/components/Common/UplotChart"
|
||||||
|
import { ValuePreview } from "@/components/Flow/ValuePreview"
|
||||||
import { ago } from "@/components/Health/queries"
|
import { ago } from "@/components/Health/queries"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@@ -24,16 +26,22 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { cn, dur } from "@/lib/utils"
|
import { cn, dur } from "@/lib/utils"
|
||||||
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
|
import {
|
||||||
|
AxisPicker,
|
||||||
|
MetricPicker,
|
||||||
|
RunMetricChart,
|
||||||
|
useMetricNames,
|
||||||
|
} from "./MetricChart"
|
||||||
import { OpenInDashboard } from "./OpenInDashboard"
|
import { OpenInDashboard } from "./OpenInDashboard"
|
||||||
import {
|
import {
|
||||||
CARD,
|
CARD,
|
||||||
LIST_CAP,
|
LIST_CAP,
|
||||||
paramsSummary,
|
MAX_SELECTION,
|
||||||
paramText,
|
paramText,
|
||||||
runOverviewQueryOptions,
|
runOverviewQueryOptions,
|
||||||
runsInfiniteQueryOptions,
|
runsInfiniteQueryOptions,
|
||||||
STATUSES,
|
STATUSES,
|
||||||
|
STEP_AXIS,
|
||||||
shortCommit,
|
shortCommit,
|
||||||
shortId,
|
shortId,
|
||||||
varyingKeys,
|
varyingKeys,
|
||||||
@@ -47,6 +55,8 @@ export type RunsSearch = {
|
|||||||
/** The runs being compared, comma-joined — a comparison is a link. */
|
/** The runs being compared, comma-joined — a comparison is a link. */
|
||||||
compare?: string
|
compare?: string
|
||||||
metric?: string
|
metric?: string
|
||||||
|
/** What the comparison is plotted against: a step, a time, or a metric. */
|
||||||
|
x?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunsScreen({
|
export function RunsScreen({
|
||||||
@@ -68,11 +78,38 @@ export function RunsScreen({
|
|||||||
const runs: RunRow[] = data?.pages.flat() ?? []
|
const runs: RunRow[] = data?.pages.flat() ?? []
|
||||||
const selected = search.compare ? search.compare.split(",") : []
|
const selected = search.compare ? search.compare.split(",") : []
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
const put = (next: string[]) =>
|
||||||
const next = selected.includes(id)
|
update({
|
||||||
|
compare: next.length ? next.slice(0, MAX_SELECTION).join(",") : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Where a shift-click measures from: the row last picked on its own. Held in
|
||||||
|
// a ref rather than the address — it is how the last click was made, not
|
||||||
|
// part of what the link says.
|
||||||
|
const anchor = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const select = (index: number, shift: boolean) => {
|
||||||
|
const id = runs[index]?.id
|
||||||
|
if (!id) return
|
||||||
|
if (shift && anchor.current !== null && anchor.current !== index) {
|
||||||
|
const [from, to] = [anchor.current, index].sort((a, b) => a - b)
|
||||||
|
const span = runs.slice(from, to + 1).map((run) => run.id)
|
||||||
|
put([...selected, ...span.filter((one) => !selected.includes(one))])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
anchor.current = index
|
||||||
|
put(
|
||||||
|
selected.includes(id)
|
||||||
? selected.filter((one) => one !== id)
|
? selected.filter((one) => one !== id)
|
||||||
: [...selected, id]
|
: [...selected, id],
|
||||||
update({ compare: next.length ? next.join(",") : undefined })
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
const shown = runs.map((run) => run.id)
|
||||||
|
const already = shown.every((id) => selected.includes(id))
|
||||||
|
anchor.current = null
|
||||||
|
put(already ? [] : shown)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,7 +167,9 @@ export function RunsScreen({
|
|||||||
runs={runs}
|
runs={runs}
|
||||||
search={search}
|
search={search}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onToggle={toggle}
|
compact={selected.length > 0}
|
||||||
|
onSelect={select}
|
||||||
|
onSelectAll={selectAll}
|
||||||
onGroup={(group) => update({ group, compare: undefined })}
|
onGroup={(group) => update({ group, compare: undefined })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -148,6 +187,10 @@ export function RunsScreen({
|
|||||||
)}
|
)}
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{runs.length} run{runs.length === 1 ? "" : "s"}
|
{runs.length} run{runs.length === 1 ? "" : "s"}
|
||||||
|
{selected.length > 0 ? `, ${selected.length} picked` : ""}
|
||||||
|
{selected.length >= MAX_SELECTION
|
||||||
|
? ` — ${MAX_SELECTION} is as many as one comparison carries`
|
||||||
|
: ""}
|
||||||
{!hasNextPage && runs.length >= LIST_CAP
|
{!hasNextPage && runs.length >= LIST_CAP
|
||||||
? ` — the newest ${LIST_CAP}, which is as deep as this list reads`
|
? ` — the newest ${LIST_CAP}, which is as deep as this list reads`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -238,30 +281,72 @@ function FlowRail({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One parameter: read directly when it is a scalar, unfolded when it is not. */
|
||||||
|
function ParamValue({ value }: { value: unknown }) {
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
return <ValuePreview value={value} className="max-w-56" />
|
||||||
|
}
|
||||||
|
return <span className="font-mono text-xs">{paramText(value)}</span>
|
||||||
|
}
|
||||||
|
|
||||||
function RunsTable({
|
function RunsTable({
|
||||||
runs,
|
runs,
|
||||||
search,
|
search,
|
||||||
selected,
|
selected,
|
||||||
onToggle,
|
compact,
|
||||||
|
onSelect,
|
||||||
|
onSelectAll,
|
||||||
onGroup,
|
onGroup,
|
||||||
}: {
|
}: {
|
||||||
runs: RunRow[]
|
runs: RunRow[]
|
||||||
search: RunsSearch
|
search: RunsSearch
|
||||||
selected: string[]
|
selected: string[]
|
||||||
onToggle: (id: string) => void
|
/** A comparison is open below, so the table gives up some of its height. */
|
||||||
|
compact: boolean
|
||||||
|
onSelect: (index: number, shift: boolean) => void
|
||||||
|
onSelectAll: () => void
|
||||||
onGroup: (group: string) => void
|
onGroup: (group: string) => void
|
||||||
}) {
|
}) {
|
||||||
// Under a sweep filter the shared parameters say nothing; the two or three
|
// Under a sweep filter the shared parameters say nothing; the two or three
|
||||||
// that were swept are the whole point, so they get columns of their own.
|
// that were swept are the whole point, so they get columns of their own.
|
||||||
const varying = search.group ? varyingKeys(runs).slice(0, 4) : []
|
const varying = search.group ? varyingKeys(runs).slice(0, 4) : []
|
||||||
const showFlow = !search.flow
|
const showFlow = !search.flow
|
||||||
|
const columns = 6 + (showFlow ? 1 : 0) + Math.max(1, varying.length)
|
||||||
|
|
||||||
|
// Whether a click carried shift, read where the event still has it: Radix
|
||||||
|
// hands `onCheckedChange` the new state and nothing else.
|
||||||
|
const shift = useRef(false)
|
||||||
|
const remember = (event: { shiftKey: boolean }) => {
|
||||||
|
shift.current = event.shiftKey
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = runs.filter((run) => selected.includes(run.id)).length
|
||||||
|
const all = runs.length > 0 && picked === runs.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(CARD, "overflow-x-auto p-0")}>
|
<Table
|
||||||
<Table>
|
containerClassName={cn(
|
||||||
<TableHeader>
|
"overflow-y-auto",
|
||||||
|
// Bounded so the page does not scroll away from the comparison drawn
|
||||||
|
// under it: the table is the thing with a scrollbar, not the screen.
|
||||||
|
// While one is open the room left over is what the chart does not
|
||||||
|
// need, which is a height rather than a share of the window — a short
|
||||||
|
// laptop screen has the same chart on it as a tall one.
|
||||||
|
compact ? "max-h-[calc(100svh-33rem)] min-h-40" : "max-h-[72svh]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-muted">
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="w-8" />
|
<TableHead className="w-8">
|
||||||
|
<Checkbox
|
||||||
|
checked={all ? true : picked > 0 ? "indeterminate" : false}
|
||||||
|
onCheckedChange={onSelectAll}
|
||||||
|
aria-label={
|
||||||
|
all ? "Clear the selection" : "Select every run shown"
|
||||||
|
}
|
||||||
|
data-testid="run-select-all"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>Run</TableHead>
|
<TableHead>Run</TableHead>
|
||||||
{showFlow && <TableHead>Flow</TableHead>}
|
{showFlow && <TableHead>Flow</TableHead>}
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
@@ -272,28 +357,28 @@ function RunsTable({
|
|||||||
)}
|
)}
|
||||||
<TableHead>Seed</TableHead>
|
<TableHead>Seed</TableHead>
|
||||||
<TableHead>Code</TableHead>
|
<TableHead>Code</TableHead>
|
||||||
<TableHead className="text-right">Took</TableHead>
|
<TableHead>Duration</TableHead>
|
||||||
<TableHead>Started</TableHead>
|
|
||||||
<TableHead>By</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{runs.length === 0 && (
|
{runs.length === 0 && (
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={10}
|
colSpan={columns}
|
||||||
className="h-24 text-center text-muted-foreground"
|
className="h-24 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
No runs match this filter.
|
No runs match this filter.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{runs.map((run) => (
|
{runs.map((run, index) => (
|
||||||
<TableRow key={run.id} data-testid="run-row">
|
<TableRow key={run.id} data-testid="run-row">
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.includes(run.id)}
|
checked={selected.includes(run.id)}
|
||||||
onCheckedChange={() => onToggle(run.id)}
|
onPointerDown={remember}
|
||||||
|
onKeyDown={remember}
|
||||||
|
onCheckedChange={() => onSelect(index, shift.current)}
|
||||||
aria-label={`Compare ${run.id}`}
|
aria-label={`Compare ${run.id}`}
|
||||||
data-testid="run-select"
|
data-testid="run-select"
|
||||||
/>
|
/>
|
||||||
@@ -329,13 +414,29 @@ function RunsTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
{varying.length > 0 ? (
|
{varying.length > 0 ? (
|
||||||
varying.map((key) => (
|
varying.map((key) => (
|
||||||
<TableCell key={key} className="font-mono text-sm">
|
<TableCell key={key}>
|
||||||
{paramText(run.params[key])}
|
<ParamValue value={run.params[key]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableCell className="max-w-64 truncate font-mono text-muted-foreground text-xs">
|
<TableCell>
|
||||||
{paramsSummary(run.params) || "—"}
|
{Object.keys(run.params).length === 0 ? (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex max-w-96 flex-wrap items-baseline gap-x-3 gap-y-0.5">
|
||||||
|
{Object.entries(run.params).map(([key, value]) => (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
className="flex min-w-0 items-baseline gap-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-muted-foreground text-xs">
|
||||||
|
{key}
|
||||||
|
</span>
|
||||||
|
<ParamValue value={value} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="text-muted-foreground text-sm tabular-nums">
|
<TableCell className="text-muted-foreground text-sm tabular-nums">
|
||||||
@@ -348,20 +449,19 @@ function RunsTable({
|
|||||||
only the store's, which is then the whole answer. */}
|
only the store's, which is then the whole answer. */}
|
||||||
{shortCommit(run.origin_commit || run.commit || "") || "—"}
|
{shortCommit(run.origin_commit || run.commit || "") || "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
|
<TableCell className="whitespace-nowrap text-muted-foreground text-sm">
|
||||||
{run.duration_ms ? dur(run.duration_ms) : "—"}
|
{/* When it started, and how long it then took. Two readings of
|
||||||
</TableCell>
|
one thing, so one column rather than two. */}
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{ago(String(run.created_at ?? ""))}
|
{ago(String(run.created_at ?? ""))}
|
||||||
</TableCell>
|
<span className="px-1.5 text-border">|</span>
|
||||||
<TableCell className="max-w-40 truncate text-muted-foreground text-sm">
|
<span className="tabular-nums">
|
||||||
{run.actor || "—"}
|
{run.duration_ms ? dur(run.duration_ms) : "—"}
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +482,13 @@ function Compare({
|
|||||||
search.metric && names.includes(search.metric)
|
search.metric && names.includes(search.metric)
|
||||||
? search.metric
|
? search.metric
|
||||||
: (names[0] ?? "")
|
: (names[0] ?? "")
|
||||||
|
// An axis naming a metric these runs stopped recording falls back to the
|
||||||
|
// step, which every run has.
|
||||||
|
const x =
|
||||||
|
search.x &&
|
||||||
|
(search.x === STEP_AXIS || search.x === "time" || names.includes(search.x))
|
||||||
|
? search.x
|
||||||
|
: STEP_AXIS
|
||||||
const tooMany = ids.length > MAX_SERIES
|
const tooMany = ids.length > MAX_SERIES
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -395,6 +502,16 @@ function Compare({
|
|||||||
value={metric}
|
value={metric}
|
||||||
onChange={(name) => update({ metric: name })}
|
onChange={(name) => update({ metric: name })}
|
||||||
/>
|
/>
|
||||||
|
{names.length > 0 && (
|
||||||
|
<AxisPicker
|
||||||
|
names={names}
|
||||||
|
metric={metric}
|
||||||
|
value={x}
|
||||||
|
onChange={(next) =>
|
||||||
|
update({ x: next === STEP_AXIS ? undefined : next })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<OpenInDashboard flow={flow} ids={ids} />
|
<OpenInDashboard flow={flow} ids={ids} />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -414,7 +531,12 @@ function Compare({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{metric ? (
|
{metric ? (
|
||||||
<RunMetricChart ids={ids.slice(0, MAX_SERIES)} metric={metric} />
|
<RunMetricChart
|
||||||
|
ids={ids.slice(0, MAX_SERIES)}
|
||||||
|
metric={metric}
|
||||||
|
x={x}
|
||||||
|
className="h-56"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
These runs recorded no metric series to compare.
|
These runs recorded no metric series to compare.
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export const runKeys = {
|
|||||||
detail: (id: string) => ["runs", "detail", id] as const,
|
detail: (id: string) => ["runs", "detail", id] as const,
|
||||||
metrics: (id: string, name: string) =>
|
metrics: (id: string, name: string) =>
|
||||||
["runs", "detail", id, "metrics", name] as const,
|
["runs", "detail", id, "metrics", name] as const,
|
||||||
compare: (ids: string[], metric: string) =>
|
compare: (ids: string[], metric: string, x: string) =>
|
||||||
["runs", "compare", ids.join(","), metric] as const,
|
["runs", "compare", ids.join(","), metric, x] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The resting surface these screens are built from, as Health names it. */
|
/** The resting surface these screens are built from, as Health names it. */
|
||||||
@@ -93,17 +93,31 @@ export const runMetricsQueryOptions = (id: string, name = "") => ({
|
|||||||
queryFn: () => RunsService.readMetrics({ runId: id, name }),
|
queryFn: () => RunsService.readMetrics({ runId: id, name }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** What a comparison can be plotted against, beside another metric's name. */
|
||||||
|
export const STEP_AXIS = "step"
|
||||||
|
export const TIME_AXIS = "time"
|
||||||
|
|
||||||
export const compareQueryOptions = (
|
export const compareQueryOptions = (
|
||||||
ids: string[],
|
ids: string[],
|
||||||
metric: string,
|
metric: string,
|
||||||
|
x = STEP_AXIS,
|
||||||
refetchInterval?: number,
|
refetchInterval?: number,
|
||||||
) => ({
|
) => ({
|
||||||
queryKey: runKeys.compare(ids, metric),
|
queryKey: runKeys.compare(ids, metric, x),
|
||||||
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric }),
|
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric, x }),
|
||||||
enabled: ids.length > 0 && Boolean(metric),
|
enabled: ids.length > 0 && Boolean(metric),
|
||||||
...(refetchInterval ? { refetchInterval } : {}),
|
...(refetchInterval ? { refetchInterval } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many runs one selection carries.
|
||||||
|
*
|
||||||
|
* The selection is the address, which is what makes a comparison a link — and
|
||||||
|
* a URL is not a place to put five hundred 22-character ids. A chart draws
|
||||||
|
* five of them anyway; the rest of the room is for changing your mind.
|
||||||
|
*/
|
||||||
|
export const MAX_SELECTION = 50
|
||||||
|
|
||||||
export function useCancelRun() {
|
export function useCancelRun() {
|
||||||
const client = useQueryClient()
|
const client = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -2,11 +2,23 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"table"> & {
|
||||||
|
/** For the scroll container rather than the table — a bounded height here is
|
||||||
|
* what lets a long table scroll inside the page instead of with it, and it
|
||||||
|
* is the box a sticky header sticks to. */
|
||||||
|
containerClassName?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="table-container"
|
data-slot="table-container"
|
||||||
className="relative w-full overflow-x-auto rounded-lg border"
|
className={cn(
|
||||||
|
"relative w-full overflow-x-auto rounded-lg border",
|
||||||
|
containerClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
data-slot="table"
|
data-slot="table"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const Route = createFileRoute("/_layout/runs/")({
|
|||||||
group: text(search.group),
|
group: text(search.group),
|
||||||
compare: text(search.compare),
|
compare: text(search.compare),
|
||||||
metric: text(search.metric),
|
metric: text(search.metric),
|
||||||
|
x: text(search.x),
|
||||||
}),
|
}),
|
||||||
head: () => ({ meta: [{ title: "Runs - Fluksio" }] }),
|
head: () => ({ meta: [{ title: "Runs - Fluksio" }] }),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user