From 0b85c706baf2661b6fd56881918320ede65a898a Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Mon, 18 May 2026 21:32:48 +0200 Subject: [PATCH 1/6] feat(registry): add sort_by/sort_order to model-run listing (#189) --- app/features/registry/routes.py | 15 +++++++++++++++ app/features/registry/service.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/features/registry/routes.py b/app/features/registry/routes.py index 701a15d7..55f81b59 100644 --- a/app/features/registry/routes.py +++ b/app/features/registry/routes.py @@ -142,6 +142,11 @@ async def create_run( **Pagination:** - `page`: Page number (1-indexed, default: 1) - `page_size`: Runs per page (default: 20, max: 100) + +**Sorting:** +- `sort_by`: Allow-listed column (created_at, model_type, status, store_id, + product_id). Unknown values fall back to the default order. +- `sort_order`: `asc` or `desc` (default: `asc`). """, ) async def list_runs( @@ -152,6 +157,12 @@ async def list_runs( run_status: RunStatus | None = Query(None, alias="status", description="Filter by status"), store_id: int | None = Query(None, ge=1, description="Filter by store ID"), product_id: int | None = Query(None, ge=1, description="Filter by product ID"), + sort_by: str | None = Query( + None, + description="Sort column: created_at|model_type|status|store_id|product_id. " + "Unknown values use the default order (created_at desc).", + ), + sort_order: str = Query("asc", pattern="^(asc|desc)$", description="Sort direction."), ) -> RunListResponse: """List model runs with filtering and pagination. @@ -163,6 +174,8 @@ async def list_runs( run_status: Filter by status. store_id: Filter by store ID. product_id: Filter by product ID. + sort_by: Allow-listed sort column; unknown values use the default order. + sort_order: Sort direction ("asc" or "desc"). Returns: Paginated list of runs. @@ -177,6 +190,8 @@ async def list_runs( status=run_status, store_id=store_id, product_id=product_id, + sort_by=sort_by, + sort_order=sort_order, ) return response diff --git a/app/features/registry/service.py b/app/features/registry/service.py index 71243ae3..ceb0b4bf 100644 --- a/app/features/registry/service.py +++ b/app/features/registry/service.py @@ -21,6 +21,7 @@ import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute from app.core.config import get_settings from app.features.registry.models import DeploymentAlias, ModelRun @@ -39,6 +40,18 @@ logger = structlog.get_logger() +# Allow-listed sort columns for the run list endpoint. sort_by is user input — +# it MUST resolve through this map to a real mapped column; an unknown key +# falls back to the default order (never an error, never raw SQL). JSONB +# columns (metrics, runtime_info, agent_context) are intentionally excluded. +_RUN_SORT_COLUMNS: dict[str, InstrumentedAttribute[Any]] = { + "created_at": ModelRun.created_at, + "model_type": ModelRun.model_type, + "status": ModelRun.status, + "store_id": ModelRun.store_id, + "product_id": ModelRun.product_id, +} + class InvalidTransitionError(ValueError): """Invalid state transition attempted.""" @@ -263,6 +276,8 @@ async def list_runs( status: RunStatus | None = None, store_id: int | None = None, product_id: int | None = None, + sort_by: str | None = None, + sort_order: str = "asc", ) -> RunListResponse: """List runs with filtering and pagination. @@ -274,6 +289,10 @@ async def list_runs( status: Filter by status. store_id: Filter by store ID. product_id: Filter by product ID. + sort_by: Allow-listed sort column (created_at, model_type, status, + store_id, product_id). Unknown values fall back to the default + order (created_at desc). + sort_order: Sort direction ("asc" or "desc"). Returns: Paginated list of runs. @@ -295,9 +314,17 @@ async def list_runs( total_result = await db.execute(count_stmt) total = total_result.scalar_one() + # Apply ordering: allow-listed sort column, else the default + # (created_at desc — UNCHANGED, keeps existing callers/tests green). + sort_column = _RUN_SORT_COLUMNS.get(sort_by) if sort_by else None + if sort_column is not None: + order_by = sort_column.desc() if sort_order == "desc" else sort_column.asc() + else: + order_by = ModelRun.created_at.desc() + # Apply pagination offset = (page - 1) * page_size - stmt = stmt.order_by(ModelRun.created_at.desc()).offset(offset).limit(page_size) + stmt = stmt.order_by(order_by).offset(offset).limit(page_size) result = await db.execute(stmt) runs = result.scalars().all() From 2371bd17d454b30371d6552366d85a47eccca209 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Mon, 18 May 2026 21:33:01 +0200 Subject: [PATCH 2/6] feat(jobs): add sort_by/sort_order to job listing (#189) --- app/features/jobs/routes.py | 16 ++++++++++++++++ app/features/jobs/service.py | 28 +++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/features/jobs/routes.py b/app/features/jobs/routes.py index 2347fa26..9af5d63d 100644 --- a/app/features/jobs/routes.py +++ b/app/features/jobs/routes.py @@ -159,11 +159,17 @@ async def create_job( - `job_type`: Filter by job type (train, predict, backtest) - `status`: Filter by status (pending, running, completed, failed, cancelled) +**Sorting**: +- `sort_by`: Allow-listed column (created_at, completed_at, job_type, status). + Unknown values fall back to the default order. +- `sort_order`: `asc` or `desc` (default: `asc`). + **Example Use Cases**: 1. List all jobs: `GET /jobs` 2. List failed jobs: `GET /jobs?status=failed` 3. List train jobs: `GET /jobs?job_type=train` 4. Paginate: `GET /jobs?page=2&page_size=10` +5. Sort by status: `GET /jobs?sort_by=status&sort_order=desc` """, ) async def list_jobs( @@ -172,6 +178,12 @@ async def list_jobs( page_size: int = Query(20, ge=1, le=100, description="Jobs per page (max 100)"), job_type: JobType | None = Query(None, description="Filter by job type"), status: JobStatus | None = Query(None, description="Filter by status"), + sort_by: str | None = Query( + None, + description="Sort column: created_at|completed_at|job_type|status. " + "Unknown values use the default order (created_at desc).", + ), + sort_order: str = Query("asc", pattern="^(asc|desc)$", description="Sort direction."), ) -> JobListResponse: """List jobs with pagination and filtering. @@ -181,6 +193,8 @@ async def list_jobs( page_size: Number of jobs per page. job_type: Filter by job type (optional). status: Filter by status (optional). + sort_by: Allow-listed sort column; unknown values use the default order. + sort_order: Sort direction ("asc" or "desc"). Returns: Paginated list of jobs. @@ -192,6 +206,8 @@ async def list_jobs( page_size=page_size, job_type=job_type, status=status, + sort_by=sort_by, + sort_order=sort_order, ) diff --git a/app/features/jobs/service.py b/app/features/jobs/service.py index 46b071cf..e559fb99 100644 --- a/app/features/jobs/service.py +++ b/app/features/jobs/service.py @@ -15,6 +15,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute from app.core.config import get_settings from app.core.logging import get_logger @@ -46,6 +47,17 @@ # most meaningful single number; change this constant to pick a different one. _STABILITY_METRIC: str = "wape" +# Allow-listed sort columns for the job list endpoint. sort_by is user input — +# it MUST resolve through this map to a real mapped column; an unknown key +# falls back to the default order (never an error, never raw SQL). JSONB +# columns (params, result) are intentionally excluded — not meaningfully sortable. +_JOB_SORT_COLUMNS: dict[str, InstrumentedAttribute[Any]] = { + "created_at": Job.created_at, + "completed_at": Job.completed_at, + "job_type": Job.job_type, + "status": Job.status, +} + def _finite(value: float) -> float: """Coerce NaN/inf to 0.0 so a job result stays JSON/JSONB-safe. @@ -208,6 +220,8 @@ async def list_jobs( page_size: int = 20, job_type: JobType | None = None, status: JobStatus | None = None, + sort_by: str | None = None, + sort_order: str = "asc", ) -> JobListResponse: """List jobs with pagination and filtering. @@ -217,6 +231,10 @@ async def list_jobs( page_size: Number of jobs per page. job_type: Filter by job type (optional). status: Filter by status (optional). + sort_by: Allow-listed sort column (created_at, completed_at, + job_type, status). Unknown values fall back to the default + order (created_at desc). + sort_order: Sort direction ("asc" or "desc"). Returns: Paginated list of jobs. @@ -235,9 +253,17 @@ async def list_jobs( count_result = await db.execute(count_stmt) total = count_result.scalar_one() + # Apply ordering: allow-listed sort column, else the default + # (created_at desc — UNCHANGED, keeps existing callers/tests green). + sort_column = _JOB_SORT_COLUMNS.get(sort_by) if sort_by else None + if sort_column is not None: + order_by = sort_column.desc() if sort_order == "desc" else sort_column.asc() + else: + order_by = Job.created_at.desc() + # Apply pagination offset = (page - 1) * page_size - stmt = stmt.order_by(Job.created_at.desc()).offset(offset).limit(page_size) + stmt = stmt.order_by(order_by).offset(offset).limit(page_size) # Execute query result = await db.execute(stmt) From 242e2e2c7ffcd0fafb6e76c14314d9b3c08e4826 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Mon, 18 May 2026 21:33:01 +0200 Subject: [PATCH 3/6] test(registry,jobs): cover list-endpoint sorting (#189) --- app/features/jobs/tests/conftest.py | 108 ++++++++++++++++++- app/features/jobs/tests/test_routes.py | 115 +++++++++++++++++++++ app/features/registry/tests/test_routes.py | 91 ++++++++++++++++ 3 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 app/features/jobs/tests/test_routes.py diff --git a/app/features/jobs/tests/conftest.py b/app/features/jobs/tests/conftest.py index 273dee37..41589643 100644 --- a/app/features/jobs/tests/conftest.py +++ b/app/features/jobs/tests/conftest.py @@ -1,14 +1,120 @@ """Test fixtures for jobs module.""" +import uuid +from collections.abc import AsyncGenerator from datetime import UTC, datetime import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from app.features.jobs.models import JobStatus, JobType +from app.core.config import get_settings +from app.core.database import get_db +from app.features.jobs.models import Job, JobStatus, JobType from app.features.jobs.schemas import ( JobCreate, JobResponse, ) +from app.main import app + +# ============================================================================= +# Database Fixtures for Integration Tests +# ============================================================================= + + +@pytest.fixture +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """Create async database session for integration tests. + + Provides a session and cleans up test data (jobs whose job_id starts + with "test"). Requires PostgreSQL to be running (docker-compose up -d). + """ + settings = get_settings() + engine = create_async_engine(settings.database_url, echo=False) + + async_session_maker = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + async with async_session_maker() as session: + try: + yield session + finally: + # Job has no model_type column for a cleanup key — key on the + # "test" job_id prefix every fixture/test uses. + await session.execute(delete(Job).where(Job.job_id.like("test%"))) + await session.commit() + + await engine.dispose() + + +@pytest.fixture +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + """Create test client with database dependency override.""" + + async def override_get_db() -> AsyncGenerator[AsyncSession, None]: + try: + yield db_session + await db_session.commit() + except Exception: + await db_session.rollback() + raise + + app.dependency_overrides[get_db] = override_get_db + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture +async def sample_jobs_multi(db_session: AsyncSession) -> list[Job]: + """Insert three jobs with distinct job_type / status / created_at. + + Drives the list-endpoint sort tests. Every job_id starts with "test" + so the db_session cleanup removes them. created_at is set explicitly + (overriding the server_default) so created_at sorting is deterministic. + """ + jobs = [ + Job( + job_id=f"test{uuid.uuid4().hex[:28]}", + job_type=JobType.TRAIN.value, + status=JobStatus.PENDING.value, + params={}, + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ), + Job( + job_id=f"test{uuid.uuid4().hex[:28]}", + job_type=JobType.PREDICT.value, + status=JobStatus.RUNNING.value, + params={}, + created_at=datetime(2024, 1, 2, tzinfo=UTC), + ), + Job( + job_id=f"test{uuid.uuid4().hex[:28]}", + job_type=JobType.BACKTEST.value, + status=JobStatus.COMPLETED.value, + params={}, + created_at=datetime(2024, 1, 3, tzinfo=UTC), + ), + ] + db_session.add_all(jobs) + await db_session.commit() + for job in jobs: + await db_session.refresh(job) + return jobs + + +# ============================================================================= +# Unit Test Fixtures +# ============================================================================= @pytest.fixture diff --git a/app/features/jobs/tests/test_routes.py b/app/features/jobs/tests/test_routes.py new file mode 100644 index 00000000..4981873d --- /dev/null +++ b/app/features/jobs/tests/test_routes.py @@ -0,0 +1,115 @@ +"""Integration tests for jobs API routes. + +These tests require PostgreSQL to be running (docker-compose up -d). +Run with: pytest app/features/jobs/tests/ -v -m integration +""" + +import uuid +from typing import Any + +import pytest +from httpx import AsyncClient + +from app.features.jobs.models import Job + +pytestmark = pytest.mark.integration + + +class TestListJobsEndpoint: + """Tests for GET /jobs endpoint.""" + + async def test_list_jobs_ok(self, client: AsyncClient) -> None: + """GET /jobs returns 200 with the paginated envelope.""" + response = await client.get("/jobs") + assert response.status_code == 200 + data = response.json() + assert "jobs" in data + assert data["page"] == 1 + assert data["page_size"] == 20 + + async def test_list_jobs_returns_seeded_rows( + self, client: AsyncClient, sample_jobs_multi: list[Job] + ) -> None: + """Seeded jobs appear in the listing.""" + response = await client.get("/jobs?page_size=100") + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 3 + listed_ids = {j["job_id"] for j in data["jobs"]} + assert {job.job_id for job in sample_jobs_multi} <= listed_ids + + +class TestListJobsSortEndpoint: + """Tests for sort_by / sort_order on GET /jobs.""" + + @staticmethod + def _test_job_types(payload: dict[str, Any]) -> list[str]: + """Job types of the test-prefixed jobs, in response order.""" + return [j["job_type"] for j in payload["jobs"] if str(j["job_id"]).startswith("test")] + + async def test_sort_by_job_type_asc( + self, client: AsyncClient, sample_jobs_multi: list[Job] + ) -> None: + """sort_by=job_type&sort_order=asc orders jobs ascending.""" + response = await client.get("/jobs?sort_by=job_type&sort_order=asc&page_size=100") + assert response.status_code == 200 + assert self._test_job_types(response.json()) == ["backtest", "predict", "train"] + + async def test_sort_by_job_type_desc( + self, client: AsyncClient, sample_jobs_multi: list[Job] + ) -> None: + """sort_by=job_type&sort_order=desc orders jobs descending.""" + response = await client.get("/jobs?sort_by=job_type&sort_order=desc&page_size=100") + assert response.status_code == 200 + assert self._test_job_types(response.json()) == ["train", "predict", "backtest"] + + async def test_sort_by_status_asc( + self, client: AsyncClient, sample_jobs_multi: list[Job] + ) -> None: + """sort_by=status&sort_order=asc orders jobs by status value.""" + response = await client.get("/jobs?sort_by=status&sort_order=asc&page_size=100") + assert response.status_code == 200 + # status asc: completed < pending < running -> backtest, train, predict + assert self._test_job_types(response.json()) == ["backtest", "train", "predict"] + + async def test_sort_by_created_at_desc( + self, client: AsyncClient, sample_jobs_multi: list[Job] + ) -> None: + """sort_by=created_at&sort_order=desc returns newest first.""" + response = await client.get("/jobs?sort_by=created_at&sort_order=desc&page_size=100") + assert response.status_code == 200 + # created_at 2024-01-03 > 01-02 > 01-01 -> backtest, predict, train + assert self._test_job_types(response.json()) == ["backtest", "predict", "train"] + + async def test_unknown_sort_by_falls_back_to_default( + self, client: AsyncClient, sample_jobs_multi: list[Job] + ) -> None: + """An unknown sort_by uses the default order, never errors.""" + default = await client.get("/jobs?page_size=100") + unknown = await client.get("/jobs?sort_by=params&page_size=100") + assert default.status_code == 200 + assert unknown.status_code == 200 + default_ids = [j["job_id"] for j in default.json()["jobs"]] + unknown_ids = [j["job_id"] for j in unknown.json()["jobs"]] + assert unknown_ids == default_ids + + async def test_invalid_sort_order_rejected(self, client: AsyncClient) -> None: + """sort_order outside {asc,desc} is rejected with 422 via the Query regex.""" + response = await client.get("/jobs?sort_order=sideways") + assert response.status_code == 422 + + +class TestGetJobEndpoint: + """Tests for GET /jobs/{job_id} endpoint.""" + + async def test_get_job_success(self, client: AsyncClient, sample_jobs_multi: list[Job]) -> None: + """GET /jobs/{job_id} returns the job.""" + job_id = sample_jobs_multi[0].job_id + response = await client.get(f"/jobs/{job_id}") + assert response.status_code == 200 + assert response.json()["job_id"] == job_id + + async def test_get_job_not_found(self, client: AsyncClient) -> None: + """GET /jobs/{job_id} returns 404 for an unknown job.""" + response = await client.get(f"/jobs/test{uuid.uuid4().hex[:28]}") + assert response.status_code == 404 diff --git a/app/features/registry/tests/test_routes.py b/app/features/registry/tests/test_routes.py index 0b9e2e5e..2c4b1c3c 100644 --- a/app/features/registry/tests/test_routes.py +++ b/app/features/registry/tests/test_routes.py @@ -215,6 +215,97 @@ async def test_list_runs_pagination(self, client: AsyncClient) -> None: assert data["page_size"] == 2 +class TestListRunsSortEndpoint: + """Tests for sort_by / sort_order on GET /registry/runs.""" + + @staticmethod + async def _create_run(client: AsyncClient, model_type: str) -> str: + """Create a run with the given model_type, return its run_id.""" + response = await client.post( + "/registry/runs", + json={ + "model_type": model_type, + "model_config": {}, + "data_window_start": "2024-01-01", + "data_window_end": "2024-01-31", + "store_id": 1, + "product_id": 1, + }, + ) + assert response.status_code == 201 + return str(response.json()["run_id"]) + + @staticmethod + def _test_model_types(payload: dict[str, object]) -> list[str]: + """Model types of the test-prefixed runs, in response order.""" + runs = payload["runs"] + assert isinstance(runs, list) + return [r["model_type"] for r in runs if str(r["model_type"]).startswith("test-sort-")] + + async def test_sort_by_model_type_desc(self, client: AsyncClient) -> None: + """sort_by=model_type&sort_order=desc orders runs descending.""" + for model_type in ("test-sort-aaa", "test-sort-bbb", "test-sort-ccc"): + await self._create_run(client, model_type) + + response = await client.get( + "/registry/runs?sort_by=model_type&sort_order=desc&page_size=100" + ) + assert response.status_code == 200 + assert self._test_model_types(response.json()) == [ + "test-sort-ccc", + "test-sort-bbb", + "test-sort-aaa", + ] + + async def test_sort_by_model_type_asc(self, client: AsyncClient) -> None: + """sort_by=model_type&sort_order=asc orders runs ascending.""" + for model_type in ("test-sort-ccc", "test-sort-aaa", "test-sort-bbb"): + await self._create_run(client, model_type) + + response = await client.get( + "/registry/runs?sort_by=model_type&sort_order=asc&page_size=100" + ) + assert response.status_code == 200 + assert self._test_model_types(response.json()) == [ + "test-sort-aaa", + "test-sort-bbb", + "test-sort-ccc", + ] + + async def test_unknown_sort_by_falls_back_to_default(self, client: AsyncClient) -> None: + """An unknown sort_by uses the default order (created_at desc), never errors.""" + for model_type in ("test-sort-fallback-1", "test-sort-fallback-2"): + await self._create_run(client, model_type) + + default = await client.get("/registry/runs?page_size=100") + unknown = await client.get("/registry/runs?sort_by=metrics&page_size=100") + assert default.status_code == 200 + assert unknown.status_code == 200 + default_ids = [r["run_id"] for r in default.json()["runs"]] + unknown_ids = [r["run_id"] for r in unknown.json()["runs"]] + assert unknown_ids == default_ids + + async def test_default_matches_explicit_created_at_desc(self, client: AsyncClient) -> None: + """Omitting sort params == explicit created_at desc (default unchanged).""" + for model_type in ("test-sort-def-1", "test-sort-def-2", "test-sort-def-3"): + await self._create_run(client, model_type) + + default = await client.get("/registry/runs?page_size=100") + explicit = await client.get( + "/registry/runs?sort_by=created_at&sort_order=desc&page_size=100" + ) + assert default.status_code == 200 + assert explicit.status_code == 200 + assert [r["run_id"] for r in default.json()["runs"]] == [ + r["run_id"] for r in explicit.json()["runs"] + ] + + async def test_invalid_sort_order_rejected(self, client: AsyncClient) -> None: + """sort_order outside {asc,desc} is rejected with 422 via the Query regex.""" + response = await client.get("/registry/runs?sort_order=sideways") + assert response.status_code == 422 + + class TestGetRunEndpoint: """Tests for GET /registry/runs/{run_id} endpoint.""" From 13e0c6da61d70a20c70094cf494d0083a59b12ab Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Mon, 18 May 2026 21:33:09 +0200 Subject: [PATCH 4/6] feat(ui): add run/job detail and run-comparison pages (#189) --- frontend/src/App.tsx | 27 ++ frontend/src/components/common/index.ts | 1 + frontend/src/components/common/json-block.tsx | 28 ++ frontend/src/hooks/use-jobs.ts | 8 +- frontend/src/hooks/use-runs.ts | 35 ++- frontend/src/lib/constants.ts | 5 + frontend/src/pages/explorer/job-detail.tsx | 208 ++++++++++++++ frontend/src/pages/explorer/run-compare.tsx | 271 +++++++++++++++++ frontend/src/pages/explorer/run-detail.tsx | 272 ++++++++++++++++++ frontend/src/types/api.ts | 11 + 10 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/common/json-block.tsx create mode 100644 frontend/src/pages/explorer/job-detail.tsx create mode 100644 frontend/src/pages/explorer/run-compare.tsx create mode 100644 frontend/src/pages/explorer/run-detail.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed9ca552..bf953b13 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,7 +16,10 @@ const StoreDetailPage = lazy(() => import('@/pages/explorer/store-detail')) const ProductsExplorerPage = lazy(() => import('@/pages/explorer/products')) const ProductDetailPage = lazy(() => import('@/pages/explorer/product-detail')) const RunsExplorerPage = lazy(() => import('@/pages/explorer/runs')) +const RunDetailPage = lazy(() => import('@/pages/explorer/run-detail')) +const RunComparePage = lazy(() => import('@/pages/explorer/run-compare')) const JobsMonitorPage = lazy(() => import('@/pages/explorer/jobs')) +const JobDetailPage = lazy(() => import('@/pages/explorer/job-detail')) const ForecastPage = lazy(() => import('@/pages/visualize/forecast')) const BacktestPage = lazy(() => import('@/pages/visualize/backtest')) const ChatPage = lazy(() => import('@/pages/chat')) @@ -99,6 +102,22 @@ function App() { } /> + }> + + + } + /> + }> + + + } + /> } /> + }> + + + } + /> block. Intentionally has no + * syntax-highlighter dependency — it surfaces run/job JSONB payloads as-is. + */ +export function JsonBlock({ value, className }: JsonBlockProps) { + if (value === null || value === undefined) { + return + } + + return ( +
+      {JSON.stringify(value, null, 2)}
+    
+ ) +} diff --git a/frontend/src/hooks/use-jobs.ts b/frontend/src/hooks/use-jobs.ts index aee1b26b..46c4f6d1 100644 --- a/frontend/src/hooks/use-jobs.ts +++ b/frontend/src/hooks/use-jobs.ts @@ -7,6 +7,8 @@ interface UseJobsParams { pageSize: number jobType?: JobType status?: JobStatus + sortBy?: string + sortOrder?: 'asc' | 'desc' enabled?: boolean } @@ -15,10 +17,12 @@ export function useJobs({ pageSize, jobType, status, + sortBy, + sortOrder, enabled = true, }: UseJobsParams) { return useQuery({ - queryKey: ['jobs', { page, pageSize, jobType, status }], + queryKey: ['jobs', { page, pageSize, jobType, status, sortBy, sortOrder }], queryFn: () => api('/jobs', { params: { @@ -26,6 +30,8 @@ export function useJobs({ page_size: pageSize, job_type: jobType, status, + sort_by: sortBy, + sort_order: sortOrder, }, }), placeholderData: keepPreviousData, diff --git a/frontend/src/hooks/use-runs.ts b/frontend/src/hooks/use-runs.ts index e1d0ffbe..1234919a 100644 --- a/frontend/src/hooks/use-runs.ts +++ b/frontend/src/hooks/use-runs.ts @@ -1,6 +1,13 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' import { api } from '@/lib/api' -import type { RunListResponse, ModelRun, Alias, RunCompareResponse, RunStatus } from '@/types/api' +import type { + RunListResponse, + ModelRun, + Alias, + RunCompareResponse, + RunStatus, + ArtifactVerifyResponse, +} from '@/types/api' interface UseRunsParams { page: number @@ -9,6 +16,8 @@ interface UseRunsParams { status?: RunStatus storeId?: number productId?: number + sortBy?: string + sortOrder?: 'asc' | 'desc' enabled?: boolean } @@ -19,10 +28,15 @@ export function useRuns({ status, storeId, productId, + sortBy, + sortOrder, enabled = true, }: UseRunsParams) { return useQuery({ - queryKey: ['runs', { page, pageSize, modelType, status, storeId, productId }], + queryKey: [ + 'runs', + { page, pageSize, modelType, status, storeId, productId, sortBy, sortOrder }, + ], queryFn: () => api('/registry/runs', { params: { @@ -32,6 +46,8 @@ export function useRuns({ status, store_id: storeId, product_id: productId, + sort_by: sortBy, + sort_order: sortOrder, }, }), placeholderData: keepPreviousData, @@ -55,6 +71,21 @@ export function useCompareRuns(runIdA: string, runIdB: string, enabled = false) }) } +/** + * Button-gated artifact integrity check. Pass `enabled` from component state + * (false until the user clicks "Verify"), then call `refetch()` to re-run. + * The endpoint returns HTTP 200 with `verified:false` on a checksum mismatch + * and only errors (404/400) for a missing run/artifact. + */ +export function useVerifyArtifact(runId: string, enabled = false) { + return useQuery({ + queryKey: ['runs', runId, 'verify'], + queryFn: () => api(`/registry/runs/${runId}/verify`), + enabled: enabled && !!runId, + retry: false, + }) +} + export function useUpdateRun() { const queryClient = useQueryClient() return useMutation({ diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index c0299adc..e6c749bc 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -12,6 +12,11 @@ export const ROUTES = { // intentionally NOT in NAV_ITEMS. STORE_DETAIL: '/explorer/stores/:storeId', PRODUCT_DETAIL: '/explorer/products/:productId', + RUN_DETAIL: '/explorer/runs/:runId', + JOB_DETAIL: '/explorer/jobs/:jobId', + // Static — must out-rank RUN_DETAIL's :runId segment (React Router v6 + // ranks by specificity, so registration order does not matter). + RUN_COMPARE: '/explorer/runs/compare', }, VISUALIZE: { FORECAST: '/visualize/forecast', diff --git a/frontend/src/pages/explorer/job-detail.tsx b/frontend/src/pages/explorer/job-detail.tsx new file mode 100644 index 00000000..e112b18a --- /dev/null +++ b/frontend/src/pages/explorer/job-detail.tsx @@ -0,0 +1,208 @@ +import { Link, useParams } from 'react-router-dom' +import { format } from 'date-fns' +import { ArrowLeft, Loader2, XCircle } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' +import { useJob, useCancelJob } from '@/hooks/use-jobs' +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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +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, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +export default function JobDetailPage() { + const { jobId } = useParams() + // useJob already polls every 2s while the job is pending/running. + const jobQuery = useJob(jobId ?? '', !!jobId) + const cancelJob = useCancelJob() + const queryClient = useQueryClient() + + if (!jobId) { + return ( +
+

Job Detail

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

Job Detail

+ void jobQuery.refetch()} /> +
+ ) + } + + if (jobQuery.isLoading || !jobQuery.data) { + return + } + + const job = jobQuery.data + + async function handleCancel() { + await cancelJob.mutateAsync(jobId) + // useCancelJob invalidates ['jobs']; refresh this detail query explicitly + // so the page reflects the cancelled status immediately. + void queryClient.invalidateQueries({ queryKey: ['jobs', jobId] }) + } + + return ( +
+
+
+ +
+

{job.job_id}

+ {job.status} +
+

{job.job_type} job

+
+ {job.status === 'pending' && ( + + + + + + + Cancel Job + + Are you sure you want to cancel this job? This action cannot be undone. + + + + No, keep it + void handleCancel()}> + Yes, cancel + + + + + )} +
+ + + + Job profile + Execution record for this job. + + +
+ + {job.job_type} + + + {job.status} + + + {job.run_id ? ( + + {job.run_id} + + ) : ( + '—' + )} + + {fmtDate(job.created_at)} + {fmtDate(job.started_at)} + {fmtDate(job.completed_at)} +
+
+
+ + {job.status === 'failed' && ( + + + Error + {job.error_type && ( + + {job.error_type} + + )} + + +

+ {job.error_message ?? 'The job failed without an error message.'} +

+
+
+ )} + + + + Parameters + Input configuration this job ran with. + + + + + + + + + Result + Output payload produced by the job. + + + {job.result == null ? ( +

+ {job.status === 'pending' || job.status === 'running' + ? 'No result yet — the job is still running.' + : 'This job produced no result.'} +

+ ) : ( + + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/explorer/run-compare.tsx b/frontend/src/pages/explorer/run-compare.tsx new file mode 100644 index 00000000..3aff8672 --- /dev/null +++ b/frontend/src/pages/explorer/run-compare.tsx @@ -0,0 +1,271 @@ +import { Link, useSearchParams } from 'react-router-dom' +import { format } from 'date-fns' +import { ArrowDown, ArrowLeft, ArrowUp } from 'lucide-react' +import { useRuns, useCompareRuns } 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { formatNumber } from '@/lib/api' +import { ROUTES } from '@/lib/constants' +import type { ModelRun } from '@/types/api' + +function fmtDate(value: string | null | undefined): string { + return value ? format(new Date(value), 'MMM d, yyyy HH:mm') : '—' +} + +/** Neutral delta indicator — sign only, no better/worse colour-coding. */ +function DeltaCell({ diff }: { diff: number | null }) { + if (diff == null) { + return + } + if (diff > 0) { + return ( + + + {formatNumber(diff, 4)} + + ) + } + if (diff < 0) { + return ( + + + {formatNumber(diff, 4)} + + ) + } + return {formatNumber(diff, 4)} +} + +function RunPicker({ + label, + value, + runs, + onSelect, +}: { + label: string + value: string + runs: ModelRun[] + onSelect: (runId: string) => void +}) { + return ( +
+ {label} + +
+ ) +} + +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/types/api.ts b/frontend/src/types/api.ts index cfc5ee77..ecd51d02 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -165,6 +165,17 @@ export interface RunCompareResponse { metrics_diff: Record } +// Response from GET /registry/runs/{run_id}/verify (SHA-256 integrity check). +// On a checksum mismatch the endpoint returns HTTP 200 with verified:false + error. +export interface ArtifactVerifyResponse { + verified: boolean + run_id: string + artifact_uri: string + stored_hash?: string + computed_hash?: string + error?: string +} + // === Jobs === export type JobType = 'train' | 'predict' | 'backtest' export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' From 6f22a721539c9b0a2d4a2943ae0669500d89aca6 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Mon, 18 May 2026 21:33:09 +0200 Subject: [PATCH 5/6] feat(ui): make Runs and Jobs tables interactive (#189) --- frontend/src/pages/explorer/jobs.tsx | 212 ++++++++++++++++++--------- frontend/src/pages/explorer/runs.tsx | 163 +++++++++++++------- 2 files changed, 250 insertions(+), 125 deletions(-) diff --git a/frontend/src/pages/explorer/jobs.tsx b/frontend/src/pages/explorer/jobs.tsx index 2410e203..380a732c 100644 --- a/frontend/src/pages/explorer/jobs.tsx +++ b/frontend/src/pages/explorer/jobs.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react' import { format } from 'date-fns' -import { ColumnDef, PaginationState } from '@tanstack/react-table' -import { XCircle } from 'lucide-react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { ColumnDef, OnChangeFn, PaginationState, SortingState } from '@tanstack/react-table' +import { Download, XCircle } from 'lucide-react' import { useJobs, useCancelJob } from '@/hooks/use-jobs' 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' @@ -20,21 +21,44 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' +import { toCsv, downloadCsv, type CsvColumn } from '@/lib/csv-export' import type { Job, JobStatus, JobType } from '@/types/api' import { DEFAULT_PAGE_SIZE } from '@/lib/constants' +const csvColumns: CsvColumn[] = [ + { key: 'job_id', header: 'Job ID' }, + { key: 'job_type', header: 'Type' }, + { key: 'status', header: 'Status' }, + { key: 'run_id', header: 'Run ID' }, + { key: 'created_at', header: 'Created' }, + { key: 'completed_at', header: 'Completed' }, +] + export default function JobsMonitorPage() { - 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 jobType = searchParams.get('job_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 } = useJobs({ - page: pagination.pageIndex + 1, + page, pageSize: pagination.pageSize, - jobType: filters.jobType as JobType | undefined, - status: filters.status as JobStatus | undefined, + jobType: jobType as JobType | undefined, + status: status as JobStatus | undefined, + sortBy, + sortOrder: sortBy ? sortOrder : undefined, }) const cancelJob = useCancelJob() @@ -47,13 +71,15 @@ export default function JobsMonitorPage() { { accessorKey: 'job_id', header: 'Job ID', + enableSorting: false, + enableHiding: false, cell: ({ row }) => ( {row.original.job_id.substring(0, 8)}... ), }, { accessorKey: 'status', - header: 'Status', + header: ({ column }) => , cell: ({ row }) => ( {row.original.status} @@ -62,7 +88,7 @@ export default function JobsMonitorPage() { }, { accessorKey: 'job_type', - header: 'Type', + header: ({ column }) => , cell: ({ row }) => ( {row.original.job_type} ), @@ -70,6 +96,7 @@ export default function JobsMonitorPage() { { accessorKey: 'params', header: 'Model', + enableSorting: false, cell: ({ row }) => { const modelType = row.original.params?.model_type return modelType ? String(modelType) : '-' @@ -77,12 +104,12 @@ export default function JobsMonitorPage() { }, { accessorKey: 'created_at', - header: 'Created', + header: ({ column }) => , cell: ({ row }) => format(new Date(row.original.created_at), 'MMM d, HH:mm'), }, { accessorKey: 'completed_at', - header: 'Completed', + header: ({ column }) => , cell: ({ row }) => row.original.completed_at ? format(new Date(row.original.completed_at), 'MMM d, HH:mm') @@ -91,54 +118,89 @@ export default function JobsMonitorPage() { { id: 'actions', header: '', + enableSorting: false, + enableHiding: false, cell: ({ row }) => { const job = row.original if (job.status !== 'pending') return null return ( - - - - - - - Cancel Job - - Are you sure you want to cancel this job? This action cannot be undone. - - - - No, keep it - handleCancelJob(job.job_id)}> - Yes, cancel - - - - + // The row is clickable (onRowClick navigates) — stop the cancel + // control's clicks from bubbling up and also triggering navigation. +
e.stopPropagation()}> + + + + + + + Cancel Job + + Are you sure you want to cancel this job? This action cannot be undone. + + + + No, keep it + handleCancelJob(job.job_id)}> + Yes, cancel + + + + +
) }, }, ] + 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 === 'jobType' ? 'job_type' : key + updateParams({ [paramKey]: value, page: '1' }) } const handleReset = () => { - setFilters({}) - setPagination({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }) + setSearchParams(new URLSearchParams()) } - const hasActiveFilters = Object.values(filters).some(Boolean) + const handleExport = () => { + downloadCsv('jobs.csv', toCsv(data?.jobs ?? [], csvColumns)) + } + + const hasActiveFilters = !!jobType || !!status || !!sortBy if (error) { return (

Jobs

- + void refetch()} />
) } @@ -149,41 +211,51 @@ export default function JobsMonitorPage() {

Jobs Monitor

- +
+ + +
navigate(`/explorer/jobs/${job.job_id}`)} + enableColumnVisibility isLoading={isLoading} emptyMessage="No jobs found." /> 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

+ +
-
+
+