diff --git a/PRPs/PRP-21-explorer-runs-jobs-interactivity.md b/PRPs/PRP-21-explorer-runs-jobs-interactivity.md new file mode 100644 index 00000000..d18abb09 --- /dev/null +++ b/PRPs/PRP-21-explorer-runs-jobs-interactivity.md @@ -0,0 +1,1022 @@ +name: "PRP-21 — Explorer Interactivity: Model Runs & Jobs (detail views, comparison, verify, sorting)" +description: | + Extend the **Model Runs** and **Jobs** pages of the ForecastLabAI dashboard + Explorer menu from flat, read-only tables into an interactive investigation + surface — the direct sibling of PRP-20 (`#187`/`#188`, which did this for + Sales / Stores / Products), applied to the `registry` and `jobs` slices: + + 1. **Click-through detail views** — three new deep-linkable routes: + - `/explorer/runs/:runId` — model-run profile, JSON config/metrics/runtime + info, store/product cross-links, an artifact section with a "Verify + integrity" button, and a "Compare with…" link. + - `/explorer/jobs/:jobId` — job profile, `params`/`result` JSON, error + details, a linked `run_id`, and a cancel action; live-polls while running. + - `/explorer/runs/compare?a=&b=` — run-vs-run comparison: side-by-side + profile table, `config_diff`, and `metrics_diff` with delta indicators. + 2. **Artifact verify action** — a button on the run detail page calling the + existing `GET /registry/runs/{run_id}/verify` (SHA-256 integrity check). + 3. **Richer tables** — server-side column sorting + row-click navigation on both + tables; the Jobs table additionally gains CSV export + column-visibility + toggles (the Runs table already has both from PRP-20). + 4. **URL state** — filter / sort / page state on both tables persisted in the + URL query string (`useSearchParams`), so a pasted URL reproduces the view. + + Backend-touching but **additive**: the only server change is two optional + `sort_by` / `sort_order` query params on `GET /registry/runs` and `GET /jobs` + (allow-listed columns; unknown → default order, never an error). **No Alembic + migration**, **no new slice**, **no new env var**, **no `app/main.py` change** + (both routers are already wired). Every detail/compare/verify endpoint the + frontend needs **already exists**; three of four hooks (`useRun`, `useJob`, + `useCompareRuns`) already exist and are currently unused. + +> **PRP numbering:** `PRP-16` is reserved (Phase-2 LightGBM). `PRP-17`–`PRP-20` +> are used. This is `PRP-21`. Source plan: +> `.agents/plans/explorer-runs-jobs-interactivity.md`. + +## Purpose +Close the "Model Runs / Jobs Explorer pages are terminal" gap. Today a row shows a +truncated `run_id`/`job_id`, a status badge, and a few columns — there is no way to +see a run's `model_config`, `metrics`, `runtime_info`, the artifact hash, or a job's +`params`/`result`/`error_message` from the UI. Two runs cannot be compared. Artifact +integrity cannot be checked. Neither table sorts, the Jobs table cannot export, and +filter state is lost on every refresh. Every one of those answers already exists in +the backend; the dashboard simply does not surface them. PRP-20 solved the identical +gap for Stores/Products — this PRP applies the same pattern to the remaining two +Explorer pages. + +## Core Principles +1. **Context is King** — every endpoint shape, hook name, schema field, service + method, and pattern below is linked to a real source file + verified line numbers. +2. **Reuse existing patterns** — the backend change is a verbatim copy of the + `dimensions` allow-listed-sort pattern; the new pages mirror `store-detail.tsx`; + the table upgrades mirror `stores.tsx`; the routes register exactly like the + existing Explorer detail routes in `App.tsx`. +3. **Additive only** — no new slice, no migration, no new `.env` var, no + `app/main.py` edit. The backend delta is two optional query params on two + already-wired list endpoints. +4. **Strict gates honored** — because `.py` files in the `registry` and `jobs` + slices change, the repo-wide `ruff` / `mypy --strict` / `pyright --strict` / + `pytest` CI jobs genuinely apply and must stay green; each backend change ships + with slice tests. +5. **UI through skills** — pages built via `frontend-design` + `shadcn-ui` and + dogfooded via `webapp-testing` / `agent-browser` per `.claude/rules/ui-design.md`. + A green `tsc` is NOT proof the UI works. + +--- + +## Goal + +**Backend (additive, no migration):** +- `GET /registry/runs` gains optional `sort_by` + `sort_order` params (allow-listed + columns; unknown `sort_by` → default `created_at desc`, never an error). +- `GET /jobs` gains the identical `sort_by` + `sort_order` params. + +**Frontend:** +- Three new routes — `/explorer/runs/:runId`, `/explorer/jobs/:jobId`, + `/explorer/runs/compare` — composed entirely from already-shipped endpoints. +- Row-click navigation from the Runs / Jobs tables into the detail pages. +- Server-side column sorting on both tables; CSV export + column-visibility on the + Jobs table (Runs already has them). +- Filter / sort / page state on both tables persisted in the URL query string. +- One new hook (`useVerifyArtifact`), one new shared component (`JsonBlock`), one new + TS type (`ArtifactVerifyResponse`); `sortBy`/`sortOrder` added to `useRuns`/`useJobs`. + +## Why +- **Portfolio identity.** `.claude/rules/product-vision.md` principle 1 — + "portfolio-grade, end-to-end … every phase ships working code". The registry and + jobs slices are fully built (`GET /registry/runs/{id}`, `/registry/compare/{a}/{b}`, + `/registry/runs/{id}/verify`, `GET /jobs/{id}`) but the dashboard exposes them only + as flat tables — a reviewer cannot inspect a single run or compare two without + leaving the UI for Swagger. +- **Analyst workflow.** "Why did this run perform worse" and "what did this job + return" are core questions; today they are unanswerable in-product. +- **Consistency.** Stores/Products already have detail pages, sorting, export and URL + state (PRP-20). Runs/Jobs being flat is a visible, jarring inconsistency. +- **High value per line.** Almost everything is composition of shipped endpoints and + shipped components; the only new server code is two query params. + +## What +Backend-touching but additive. Two `sort_by`/`sort_order` query params + allow-listed +ordering on the `registry` and `jobs` list endpoints, with tests for both (the `jobs` +slice has **no DB test fixtures and no route tests today** — this PRP also closes that +gap). Frontend: 3 new pages, 3 new routes, 1 new hook, 1 new component, 1 new TS type, +2 existing table pages upgraded to interactive. No migration, no new env var, no new +slice, no `app/main.py` change. + +### Success Criteria +- [ ] `GET /registry/runs` and `GET /jobs` accept `sort_by` + `sort_order`; omitting + them preserves the current `created_at desc` default; an unknown `sort_by` + falls back to the default order without erroring; `sort_order` outside + `{asc,desc}` is rejected (422 via the `Query` regex). +- [ ] Clicking a Model Runs row navigates to a working `/explorer/runs/:runId` page + with status, model type, store/product links, data window, hashes, + timestamps, and the `model_config`/`feature_config`/`metrics`/`runtime_info`/ + `agent_context` JSON. +- [ ] Clicking a Jobs row navigates to a working `/explorer/jobs/:jobId` page with + status, type, timestamps, linked `run_id`, `params`/`result` JSON, error + details, and a cancel action for pending jobs; the page live-polls until terminal. +- [ ] `/explorer/runs/compare?a=&b=` shows two run pickers, a side-by-side profile + table, `config_diff`, and `metrics_diff` with delta indicators; the comparison + is deep-linkable via the URL. +- [ ] The run detail "Verify integrity" button calls `GET /registry/runs/{id}/verify` + and surfaces pass/fail (incl. the `verified:false` checksum-mismatch branch). +- [ ] Both tables support server-side column sorting + row-click; the Jobs table + also supports CSV export + column-visibility; the Jobs table's in-row cancel + button cancels WITHOUT navigating. +- [ ] Filter / sort / page state on both tables round-trips through the URL (paste a + filtered URL into a fresh tab → identical view). +- [ ] `uv run ruff check . && uv run ruff format --check . && uv run mypy app/ && + uv run pyright app/` clean; `uv run pytest -v -m "not integration"` and the + `registry` + `jobs` integration tests green. +- [ ] `cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run` clean. +- [ ] No Alembic migration; no new slice; no `app/main.py` change; no `.env` var. +- [ ] All three new pages dogfooded in a real browser (screenshots captured). + +--- + +## All Needed Context + +### Documentation & References +```yaml +# ---- External docs ---- +- url: https://tanstack.com/query/latest/docs/framework/react/guides/queries + why: useQuery shape for the new useVerifyArtifact hook. + critical: GET data → useQuery({ queryKey, queryFn, enabled }). The repo hooks + (use-runs.ts, use-jobs.ts) follow this exactly — copy that shape. + +- url: https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries + why: useVerifyArtifact is a button-gated GET — `enabled` toggled by component + state, then refetch(). This is exactly how useCompareRuns (use-runs.ts:50-56) + is already built — mirror it. + +- url: https://tanstack.com/table/v8/docs/guide/sorting#manual-server-side-sorting + why: DataTable already sets `manualSorting: true`; SortingState (`[{id,desc}]`) + MUST round-trip through the backend sort_by/sort_order params. + critical: A client-only sort reorders ONLY the visible page. Server-side is + mandatory — thread SortingState → sort_by/sort_order → useRuns/useJobs. + +- url: https://tanstack.com/table/v8/docs/guide/column-visibility + why: column-visibility dropdown — already implemented in DataTable via + `enableColumnVisibility`; the Jobs table just needs to pass the prop. + +- url: https://reactrouter.com/6.30.1/hooks/use-params + why: `:runId` / `:jobId` dynamic-segment extraction. + critical: useParams() returns `Record`. Run/Job IDs + are UUID-hex STRINGS — guard truthiness only, do NOT Number()-parse them + (unlike store-detail.tsx / product-detail.tsx which parse numeric ids). + +- url: https://reactrouter.com/6.30.1/hooks/use-search-params + why: URL filter/sort/page persistence + reading ?a=&b= on the compare page. + critical: useSearchParams() → [params, setParams]; params.get('x') is + `string | null`. Treat it as controlled state initialised from the URL. + +- url: https://reactrouter.com/6.30.1/route/route + why: route ranking. v6 ranks routes by specificity — the static + `/explorer/runs/compare` outranks the dynamic `/explorer/runs/:runId`, so + `compare` is never captured as a `:runId`. Registration order does not matter; + register `compare` first anyway for readability. + +# ---- THE sibling PRP to mirror ---- +- file: PRPs/PRP-20-explorer-interactivity.md + why: this PRP's direct precedent. Read its "Known Gotchas", "list of tasks", + "Validation Loop", and "Anti-Patterns" — this PRP applies the same shape to + the registry/jobs slices. PRP-20 added DataTable's onRowClick / sorting / + enableColumnVisibility, csv-export.ts, DataTableColumnHeader, and the + store-detail/product-detail pages — all REUSED here, not rebuilt. + +# ---- Backend: the sort pattern to copy verbatim ---- +- file: app/features/dimensions/service.py + why: lines 29-45 — `_STORE_SORT_COLUMNS` / `_PRODUCT_SORT_COLUMNS` allow-list + dicts typed `dict[str, InstrumentedAttribute[Any]]`; import on line 12 + (`from sqlalchemy.orm import InstrumentedAttribute`). Lines 105-113 — the + resolve-or-default ordering logic. COPY this dict + logic exactly. + +- file: app/features/dimensions/routes.py + why: lines 70-91 / 195-216 — the `sort_by` / `sort_order` `Query(...)` + declarations + docstrings. `sort_order` uses + `Query("asc", pattern="^(asc|desc)$", description=...)`. + +# ---- Backend: registry slice (gets sort params) ---- +- file: app/features/registry/routes.py + why: `list_runs` handler lines 147-182 — add sort_by/sort_order Query params + here (after `product_id`), pass into service.list_runs(). get_run (185-217), + verify_artifact (311-383), compare_runs (566-606) ALREADY EXIST — do NOT + modify, the frontend just consumes them. + critical: verify_artifact returns HTTP 200 with `{"verified": false, ...}` on a + checksum mismatch (lines 377-383) — it does NOT raise. Only a missing + run/artifact 404s. The frontend must handle both the 200-false branch and + the error branch. + +- file: app/features/registry/service.py + why: `list_runs` lines 257-310 — default order is + `stmt.order_by(ModelRun.created_at.desc())` (line 300). Imports already have + `from typing import Any` (line 19) and `from sqlalchemy import func, select` + (line 22). Add `InstrumentedAttribute` import + `_RUN_SORT_COLUMNS` here. + +- file: app/features/registry/tests/conftest.py + why: ALREADY has integration DB fixtures — `db_session` (lines 26-56), `client` + (lines 59-79) — plus sample run fixtures. REUSE these. + critical: the `db_session` cleanup deletes runs via + `ModelRun.model_type.like("test-%")` (lines 49,53). Every run a sort test + creates MUST use a `model_type` starting with `test-` or it will not be + cleaned up. Aliases for those runs are cleaned too. + +- file: app/features/registry/tests/test_routes.py + why: already has `@pytest.mark.integration` route tests using the `client` + fixture. Add the sort-param test cases here in that same style. + +# ---- Backend: jobs slice (gets sort params; needs DB test fixtures) ---- +- file: app/features/jobs/routes.py + why: `list_jobs` handler lines 169-195 — add sort_by/sort_order Query params + (after `status`). get_job (222-247), cancel_job (265-297) ALREADY EXIST. + +- file: app/features/jobs/service.py + why: `list_jobs` lines 204-251 — default order is + `stmt.order_by(Job.created_at.desc())` (line 240). Imports: `from typing + import TYPE_CHECKING, Any` (line 14), `from sqlalchemy import func, select` + (line 16). Add `InstrumentedAttribute` import + `_JOB_SORT_COLUMNS` here. + +- file: app/features/jobs/models.py + why: the `Job` ORM model. Columns for the sort allow-list: `created_at`, + `completed_at` (both `DateTime`, `completed_at` nullable), `job_type`, + `status` (both `String`). `params`/`result` are JSONB — NOT sortable. + `job_id` is `String(32)` (exactly a uuid4 hex). + +- file: app/features/jobs/tests/conftest.py + why: currently UNIT-ONLY (`sample_train_job_create` etc.). It has NO + `db_session` / `client` fixture; the `jobs` slice has NO `test_routes.py`. + critical: Task 4 must COPY `db_session` + `client` from + app/features/registry/tests/conftest.py:26-79, swapping the cleanup to delete + `Job` rows. Job has no `model_type` — use a `job_id` prefix for the cleanup + key (see Gotchas). + +# ---- Frontend: hooks (3 of 4 already exist) ---- +- file: frontend/src/hooks/use-runs.ts + why: `useRuns` (list, lines 15-40), `useRun(runId, enabled)` (42-48), + `useCompareRuns(a, b, enabled)` (50-56). Add sortBy/sortOrder to UseRunsParams; + add a new `useVerifyArtifact` hook here. + +- file: frontend/src/hooks/use-jobs.ts + why: `useJobs` (list, 13-35), `useJob(jobId, enabled)` (37-47 — ALREADY polls + every 2s while pending/running, stops otherwise), `useCancelJob` (60-69). Add + sortBy/sortOrder to UseJobsParams. + +- file: frontend/src/hooks/use-stores.ts + why: the PRP-20 precedent for adding optional sortBy/sortOrder to a list hook — + threaded into both the queryKey array and the api params. Mirror it. + +# ---- Frontend: components (all already exist — REUSED, not built) ---- +- file: frontend/src/components/data-table/data-table.tsx + why: ALREADY supports `sorting`, `onSortingChange`, `onRowClick`, + `enableColumnVisibility` (PRP-20, props at lines 31-36). NO change needed — + just pass the props from runs.tsx / jobs.tsx. + +- file: frontend/src/components/data-table/data-table-column-header.tsx + why: `` — a sortable header button. Use it + in the runs/jobs column defs for sortable columns. A column is sortable unless + its def sets `enableSorting: false`. + +- file: frontend/src/components/common/status-badge.tsx + frontend/src/lib/status-utils.ts + why: `StatusBadge` + `getStatusVariant(status)`. status-utils ALREADY maps every + RunStatus (pending/running/success/failed/archived) and JobStatus + (pending/running/completed/failed/cancelled) value — no change needed. + +- file: frontend/src/components/common/error-display.tsx + why: `ErrorDisplay` (error + optional onRetry/title) — used on every detail + page for the invalid-id and query-error states. + +- file: frontend/src/components/common/job-picker.tsx + why: an existing entity-picker component — read it as the pattern for the + run-picker ` + + + + + {runs.map((run) => ( + + {run.run_id.slice(0, 8)} · {run.model_type} · {run.status} + + ))} + + + + ) +} + +export default function RunComparePage() { + const [params, setParams] = useSearchParams() + const a = params.get('a') ?? '' + const b = params.get('b') ?? '' + + const runsQuery = useRuns({ page: 1, pageSize: 100 }) + const compareQuery = useCompareRuns(a, b, !!a && !!b) + + function selectRun(slot: 'a' | 'b', runId: string) { + setParams((prev) => { + const next = new URLSearchParams(prev) + next.set(slot, runId) + return next + }) + } + + const runs = runsQuery.data?.runs ?? [] + const comparison = compareQuery.data + + return ( +
+
+ +

Compare runs

+

+ Pick two model runs to compare their configuration and metrics side by side. +

+
+ + + + Select runs + + The comparison is deep-linkable — the URL carries the two run ids. + + + + selectRun('a', id)} /> + selectRun('b', id)} /> + + + + {(!a || !b) && ( + + + Select two runs above to see the comparison. + + + )} + + {a && b && compareQuery.error && ( + void compareQuery.refetch()} /> + )} + + {a && b && compareQuery.isLoading && } + + {a && b && comparison && ( + <> + + + Profile + Side-by-side registry records. + + + + + + Field + Run A + Run B + + + + + Run ID + + {comparison.run_a.run_id} + + + {comparison.run_b.run_id} + + + + Model type + {comparison.run_a.model_type} + {comparison.run_b.model_type} + + + Status + + + {comparison.run_a.status} + + + + + {comparison.run_b.status} + + + + + Data window + + {comparison.run_a.data_window_start} → {comparison.run_a.data_window_end} + + + {comparison.run_b.data_window_start} → {comparison.run_b.data_window_end} + + + + Config hash + + {comparison.run_a.config_hash} + + + {comparison.run_b.config_hash} + + + + Created + {fmtDate(comparison.run_a.created_at)} + {fmtDate(comparison.run_b.created_at)} + + +
+
+
+ + + + Config diff + Keys whose values differ between the two runs. + + + {Object.keys(comparison.config_diff).length === 0 ? ( +

+ The two runs share an identical configuration. +

+ ) : ( + + )} +
+
+ + + + Metrics diff + + Δ is Run B minus Run A — sign only, not a quality judgement. + + + + {Object.keys(comparison.metrics_diff).length === 0 ? ( +

No metrics to compare.

+ ) : ( + + + + Metric + Run A + Run B + Δ + + + + {Object.entries(comparison.metrics_diff).map(([metric, m]) => ( + + {metric} + {m.a != null ? formatNumber(m.a, 4) : '—'} + {m.b != null ? formatNumber(m.b, 4) : '—'} + + + + + ))} + +
+ )} +
+
+ + )} +
+ ) +} diff --git a/frontend/src/pages/explorer/run-detail.tsx b/frontend/src/pages/explorer/run-detail.tsx new file mode 100644 index 00000000..1a41ca5e --- /dev/null +++ b/frontend/src/pages/explorer/run-detail.tsx @@ -0,0 +1,272 @@ +import { useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { format } from 'date-fns' +import { + AlertTriangle, + ArrowLeft, + CheckCircle2, + GitCompare, + Loader2, + ShieldCheck, +} from 'lucide-react' +import { useRun, useVerifyArtifact } from '@/hooks/use-runs' +import { JsonBlock } from '@/components/common/json-block' +import { ErrorDisplay } from '@/components/common/error-display' +import { LoadingState } from '@/components/common/loading-state' +import { StatusBadge } from '@/components/common/status-badge' +import { getStatusVariant } from '@/lib/status-utils' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { formatNumber, getErrorMessage } from '@/lib/api' +import { ROUTES } from '@/lib/constants' + +function fmtDate(value: string | null | undefined): string { + return value ? format(new Date(value), 'MMM d, yyyy HH:mm') : '—' +} + +function Field({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +export default function RunDetailPage() { + const { runId } = useParams() + const runQuery = useRun(runId ?? '', !!runId) + + // The verify GET is button-gated: disabled until the first click, then refetch. + const [verifyOn, setVerifyOn] = useState(false) + const verifyQuery = useVerifyArtifact(runId ?? '', verifyOn) + + if (!runId) { + return ( +
+

Run Detail

+ +
+ ) + } + + if (runQuery.error) { + return ( +
+

Run Detail

+ void runQuery.refetch()} /> +
+ ) + } + + if (runQuery.isLoading || !runQuery.data) { + return + } + + const run = runQuery.data + + function handleVerify() { + if (!verifyOn) setVerifyOn(true) + else void verifyQuery.refetch() + } + + return ( +
+
+
+ +
+

{run.run_id}

+ {run.status} +
+

{run.model_type}

+
+ +
+ + + + Run profile + Registry record for this model run. + + +
+ +
+
Store
+
+ + #{run.store_id} + +
+
+
+
Product
+
+ + #{run.product_id} + +
+
+ + + + + + +
+
+
+ + {run.status === 'failed' && run.error_message && ( + + + Error + + +

{run.error_message}

+
+
+ )} + + + + Metrics + Evaluation metrics recorded for this run. + + + + + + +
+ + + Model config + + + + + + + + Feature config + + + + + +
+ + + + Runtime info + Environment captured at training time. + + + + + + + {run.agent_context && ( + + + Agent context + The agent session that created this run. + + + + + + )} + + + + Artifact + Stored model artifact and SHA-256 integrity check. + + +
+ + + +
+ +
+ + {!run.artifact_uri && ( + This run has no artifact. + )} +
+ + {verifyOn && !verifyQuery.isFetching && verifyQuery.error && ( +
+ + {getErrorMessage(verifyQuery.error)} +
+ )} + + {verifyOn && + !verifyQuery.isFetching && + verifyQuery.data && + (verifyQuery.data.verified ? ( +
+ + + Artifact verified — the stored checksum matches. + {verifyQuery.data.computed_hash && ( + + {verifyQuery.data.computed_hash} + + )} + +
+ ) : ( +
+ + + Integrity check failed — the artifact does not match its stored hash. + {verifyQuery.data.error && ( + {verifyQuery.data.error} + )} + +
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/explorer/runs.tsx b/frontend/src/pages/explorer/runs.tsx index c7b23d24..c6fb43fc 100644 --- a/frontend/src/pages/explorer/runs.tsx +++ b/frontend/src/pages/explorer/runs.tsx @@ -1,29 +1,32 @@ -import { useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { format } from 'date-fns' -import { ColumnDef, PaginationState } from '@tanstack/react-table' -import { Download } from 'lucide-react' +import { ColumnDef, OnChangeFn, PaginationState, SortingState } from '@tanstack/react-table' +import { Download, GitCompare } from 'lucide-react' import { useRuns } from '@/hooks/use-runs' import { DataTable } from '@/components/data-table/data-table' import { DataTableToolbar } from '@/components/data-table/data-table-toolbar' +import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header' import { StatusBadge } from '@/components/common/status-badge' import { getStatusVariant } from '@/lib/status-utils' import { ErrorDisplay } from '@/components/common/error-display' import { Button } from '@/components/ui/button' import { toCsv, downloadCsv, type CsvColumn } from '@/lib/csv-export' import type { ModelRun, RunStatus } from '@/types/api' -import { DEFAULT_PAGE_SIZE } from '@/lib/constants' +import { DEFAULT_PAGE_SIZE, ROUTES } from '@/lib/constants' const columns: ColumnDef[] = [ { accessorKey: 'run_id', header: 'Run ID', + enableSorting: false, + enableHiding: false, cell: ({ row }) => ( {row.original.run_id.substring(0, 8)}... ), }, { accessorKey: 'status', - header: 'Status', + header: ({ column }) => , cell: ({ row }) => ( {row.original.status} @@ -32,20 +35,21 @@ const columns: ColumnDef[] = [ }, { accessorKey: 'model_type', - header: 'Model Type', + header: ({ column }) => , cell: ({ row }) => {row.original.model_type}, }, { accessorKey: 'store_id', - header: 'Store', + header: ({ column }) => , }, { accessorKey: 'product_id', - header: 'Product', + header: ({ column }) => , }, { accessorKey: 'data_window_start', header: 'Data Window', + enableSorting: false, cell: ({ row }) => ( {format(new Date(row.original.data_window_start), 'MMM d')} -{' '} @@ -56,6 +60,7 @@ const columns: ColumnDef[] = [ { accessorKey: 'metrics', header: 'MAE', + enableSorting: false, cell: ({ row }) => { const mae = row.original.metrics?.mae return mae !== undefined ? mae.toFixed(2) : '-' @@ -63,7 +68,7 @@ const columns: ColumnDef[] = [ }, { accessorKey: 'created_at', - header: 'Created', + header: ({ column }) => , cell: ({ row }) => format(new Date(row.original.created_at), 'MMM d, HH:mm'), }, ] @@ -80,40 +85,78 @@ const csvColumns: CsvColumn[] = [ ] export default function RunsExplorerPage() { - const [pagination, setPagination] = useState({ - pageIndex: 0, + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + + // URL query string is the single source of truth for filter/sort/page state, + // so a pasted URL reproduces the exact view. + const modelType = searchParams.get('model_type') ?? undefined + const status = searchParams.get('status') ?? undefined + const page = Number(searchParams.get('page')) || 1 + const sortBy = searchParams.get('sort_by') ?? undefined + const sortOrder: 'asc' | 'desc' = searchParams.get('sort_order') === 'desc' ? 'desc' : 'asc' + + const pagination: PaginationState = { + pageIndex: page - 1, pageSize: DEFAULT_PAGE_SIZE, - }) - const [filters, setFilters] = useState>({}) + } + const sorting: SortingState = sortBy ? [{ id: sortBy, desc: sortOrder === 'desc' }] : [] const { data, isLoading, error, refetch } = useRuns({ - page: pagination.pageIndex + 1, + page, pageSize: pagination.pageSize, - modelType: filters.modelType, - status: filters.status as RunStatus | undefined, + modelType, + status: status as RunStatus | undefined, + sortBy, + sortOrder: sortBy ? sortOrder : undefined, }) + function updateParams(updates: Record) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + for (const [key, value] of Object.entries(updates)) { + if (value === undefined || value === '') next.delete(key) + else next.set(key, value) + } + return next + }) + } + + const handlePaginationChange: OnChangeFn = (updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater + updateParams({ page: String(next.pageIndex + 1) }) + } + + const handleSortingChange: OnChangeFn = (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater + const first = next[0] + updateParams({ + sort_by: first?.id, + sort_order: first ? (first.desc ? 'desc' : 'asc') : undefined, + page: '1', + }) + } + const handleFilterChange = (key: string, value: string | undefined) => { - setFilters((prev) => ({ ...prev, [key]: value })) - setPagination((prev) => ({ ...prev, pageIndex: 0 })) + const paramKey = key === 'modelType' ? 'model_type' : key + updateParams({ [paramKey]: value, page: '1' }) } const handleReset = () => { - setFilters({}) - setPagination({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }) + setSearchParams(new URLSearchParams()) } const handleExport = () => { downloadCsv('model-runs.csv', toCsv(data?.runs ?? [], csvColumns)) } - const hasActiveFilters = Object.values(filters).some(Boolean) + const hasActiveFilters = !!modelType || !!status || !!sortBy if (error) { return (

Model Runs

- + void refetch()} />
) } @@ -122,39 +165,46 @@ export default function RunsExplorerPage() { return (
-

Model Runs

- - +
+

Model Runs

+ +
-
+
+