Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 130 additions & 28 deletions apps/loopover-miner-ui/src/routes/run-history.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";

import { Badge } from "@loopover/ui-kit/components/badge";
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@loopover/ui-kit/components/pagination";
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";

import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";
Expand All @@ -12,8 +23,13 @@ export const Route = createFileRoute("/run-history")({
});

// Read-only run-history table (#4305): one row per repo from the local `miner_run_state` store (repo, state,
// last-updated), served by the dev server's local API. No writes, no new state — a fresh install renders the
// empty state, an unreachable API renders an error message.
// last-updated), served by the dev server's local API. No writes, no new state.
//
// #6510: the hand-rolled loading/error/empty `<p>` branches are replaced by the shared @loopover/ui-kit
// `StateBoundary`, with a content-shaped `Skeleton` table for the loading state (so the layout doesn't jump when
// the poll resolves), and the table paginates client-side once it exceeds PAGE_SIZE rows via the kit's
// `Pagination`. Purely presentational — `lib/run-history.ts`'s fetch/poll is untouched, and the
// Repository/State/Last-updated columns + data shown are unchanged.

const STATE_BADGE_VARIANT: Record<RunStateRow["state"], "secondary" | "outline"> = {
idle: "secondary",
Expand All @@ -22,35 +38,57 @@ const STATE_BADGE_VARIANT: Record<RunStateRow["state"], "secondary" | "outline">
preparing: "outline",
};

export function RunHistoryView({ result }: { result: RunHistoryResult | null }) {
if (result === null) {
return <p className="text-token-sm text-muted-foreground">Loading local run state…</p>;
}
if (!result.ok) {
return (
<p role="alert" className="text-token-sm text-[var(--danger)]">
Could not read local run state: {result.error}
</p>
);
}
if (result.rows.length === 0) {
return (
<p className="text-token-sm text-muted-foreground">
No local run state yet — the table fills in once the miner records its first repo run.
</p>
);
}
/** Rows per page once the run-state table grows past this; below it the full table renders unpaginated. */
const PAGE_SIZE = 20;

const TABLE_COLUMNS = ["Repository", "State", "Last updated"] as const;

function RunHistoryTableHeader() {
return (
<TableHeader>
<TableRow>
{TABLE_COLUMNS.map((column) => (
<TableHead key={column}>{column}</TableHead>
))}
</TableRow>
</TableHeader>
);
}

/** Table-shaped loading placeholder: header + `rows` shimmer rows matching the real column layout, so the table
* keeps its shape and the content doesn't jump once the poll resolves. `role="status"` keeps the loading state
* announced to assistive tech (as the flat "Loading…" text it replaces was). */
function RunHistorySkeleton({ rows = 5 }: { rows?: number }) {
return (
<div role="status" aria-label="Loading local run state">
<Table>
<RunHistoryTableHeader />
<TableBody>
{Array.from({ length: rows }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-4 w-48" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

function RunStateTable({ rows }: { rows: RunStateRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>State</TableHead>
<TableHead>Last updated</TableHead>
</TableRow>
</TableHeader>
<RunHistoryTableHeader />
<TableBody>
{result.rows.map((row) => (
{rows.map((row) => (
<TableRow key={row.repoFullName}>
<TableCell className="font-mono text-foreground">{row.repoFullName}</TableCell>
<TableCell>
Expand All @@ -64,6 +102,70 @@ export function RunHistoryView({ result }: { result: RunHistoryResult | null })
);
}

export function RunHistoryView({ result }: { result: RunHistoryResult | null }) {
const [page, setPage] = useState(0);
const rows = result?.ok ? result.rows : [];
const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const isPaginated = rows.length > PAGE_SIZE;
const safePage = Math.min(page, pageCount - 1);
const visibleRows = isPaginated ? rows.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : rows;

return (
<StateBoundary
isLoading={result === null}
isError={result !== null && !result.ok}
isEmpty={result !== null && result.ok && result.rows.length === 0}
loadingSkeleton={<RunHistorySkeleton />}
errorTitle="Couldn't read local run state"
errorDescription="The local run-state API didn't respond. This refreshes automatically on the next poll."
emptyTitle="No local run state yet"
emptyDescription="The table fills in once the miner records its first repo run."
>
<RunStateTable rows={visibleRows} />
{isPaginated && (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
aria-disabled={safePage === 0}
onClick={(event) => {
event.preventDefault();
setPage((current) => Math.max(0, current - 1));
}}
/>
</PaginationItem>
{Array.from({ length: pageCount }).map((_, index) => (
<PaginationItem key={index}>
<PaginationLink
href="#"
isActive={index === safePage}
onClick={(event) => {
event.preventDefault();
setPage(index);
}}
>
{index + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
aria-disabled={safePage >= pageCount - 1}
onClick={(event) => {
event.preventDefault();
setPage((current) => Math.min(pageCount - 1, current + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</StateBoundary>
);
}

export function RunHistoryPage({
loadRunStates = fetchRunStates,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
Expand Down
50 changes: 41 additions & 9 deletions apps/loopover-miner-ui/src/run-history.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { fetchRunStates, RUN_STATE_API_PATH, type RunHistoryResult, type RunStateRow } from "./lib/run-history";
Expand All @@ -9,7 +9,15 @@ const fixtureRows: RunStateRow[] = [
{ repoFullName: "acme/gadgets", state: "idle", updatedAt: "2026-07-10T05:00:00.000Z" },
];

describe("RunHistoryView (#4305)", () => {
function manyRows(count: number): RunStateRow[] {
return Array.from({ length: count }, (_, index) => ({
repoFullName: `acme/repo-${index}`,
state: "idle" as const,
updatedAt: "2026-07-10T05:00:00.000Z",
}));
}

describe("RunHistoryView (#4305, redesigned #6510)", () => {
it("renders one table row per run-state fixture row with repo, state badge, and last-updated", () => {
render(<RunHistoryView result={{ ok: true, rows: fixtureRows }} />);
expect(screen.getByRole("columnheader", { name: "Repository" })).toBeTruthy();
Expand All @@ -20,20 +28,44 @@ describe("RunHistoryView (#4305)", () => {
expect(screen.getAllByRole("row")).toHaveLength(3); // header + 2 fixture rows
});

it("renders the fresh-install empty state without erroring", () => {
it("renders a content-shaped loading skeleton (role=status), not the old flat loading text (#6510)", () => {
render(<RunHistoryView result={null} />);
expect(screen.getByRole("status", { name: /loading local run state/i })).toBeTruthy();
expect(screen.queryByText("Loading local run state…")).toBeNull(); // the pre-#6510 sentence is gone
});

it("renders the shared StateBoundary error surface on an unreachable API (#6510)", () => {
render(<RunHistoryView result={{ ok: false, error: "connection refused" }} />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(/Couldn't read local run state/i)).toBeTruthy();
});

it("renders the empty state via StateBoundary when there are no tracked repos (#6510)", () => {
render(<RunHistoryView result={{ ok: true, rows: [] }} />);
expect(screen.getByText(/No local run state yet/i)).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});

it("renders an error message when the local API is unreachable", () => {
render(<RunHistoryView result={{ ok: false, error: "connection refused" }} />);
expect(screen.getByRole("alert").textContent).toContain("connection refused");
it("does not paginate at or below 20 rows — full table, no controls (#6510)", () => {
render(<RunHistoryView result={{ ok: true, rows: manyRows(20) }} />);
expect(screen.queryByRole("navigation", { name: /pagination/i })).toBeNull();
expect(screen.getAllByRole("row")).toHaveLength(21); // header + all 20 rows shown
});

it("renders the loading state before the first result arrives", () => {
render(<RunHistoryView result={null} />);
expect(screen.getByText(/Loading local run state/i)).toBeTruthy();
it("paginates client-side above 20 rows, paging without any refetch (#6510)", () => {
render(<RunHistoryView result={{ ok: true, rows: manyRows(45) }} />);
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
// page 1: first 20 rows only
expect(screen.getAllByRole("row")).toHaveLength(21);
expect(screen.getByText("acme/repo-0")).toBeTruthy();
expect(screen.queryByText("acme/repo-20")).toBeNull();
// page 2
fireEvent.click(screen.getByRole("link", { name: "2" }));
expect(screen.getByText("acme/repo-20")).toBeTruthy();
expect(screen.queryByText("acme/repo-0")).toBeNull();
// page 3 holds the remaining 5 rows (header + 5)
fireEvent.click(screen.getByRole("link", { name: "3" }));
expect(screen.getAllByRole("row")).toHaveLength(6);
});
});

Expand Down
Loading