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
1 change: 1 addition & 0 deletions apps/loopover-miner-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@tanstack/react-router": "^1.170.17",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"recharts": "^2.15.4",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.1.1"
Expand Down
97 changes: 97 additions & 0 deletions apps/loopover-miner-ui/src/ledgers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ describe("emptyLedgersSummary (#4855)", () => {
});
});

function manyEventTypes(count: number): Record<string, number> {
return Object.fromEntries(Array.from({ length: count }, (_, index) => [`event_type_${index}`, count - index]));
}

function manyRecentEvents(count: number): LedgersSummary["events"]["recent"] {
return Array.from({ length: count }, (_, index) => ({
eventType: `event_type_${index}`,
repoFullName: index % 2 === 0 ? `acme/repo-${index}` : null,
createdAt: index % 3 === 0 ? null : `2026-07-10T06:${String(index).padStart(2, "0")}:00.000Z`,
}));
}

describe("LedgersView (#4855)", () => {
it("renders claim status counts, the governor type table, the events-by-type table, and the recent-events feed", () => {
render(<LedgersView result={{ ok: true, summary: fixtureSummary }} />);
Expand All @@ -101,6 +113,11 @@ describe("LedgersView (#4855)", () => {
expect(screen.getAllByText("acme/widgets").length).toBeGreaterThan(0);
});

it("renders a claims-by-status chart via the ui-kit ChartContainer (#6832)", () => {
render(<LedgersView result={{ ok: true, summary: fixtureSummary }} />);
expect(screen.getByLabelText("Claims by status chart")).toBeTruthy();
});

it("renders the fresh-install empty state when every ledger is empty", () => {
render(<LedgersView result={{ ok: true, summary: emptyLedgersSummary() }} />);
expect(screen.getByText(/No ledger activity yet/i)).toBeTruthy();
Expand All @@ -117,6 +134,86 @@ describe("LedgersView (#4855)", () => {
expect(screen.getByRole("status", { name: /loading local ledgers/i })).toBeTruthy();
expect(screen.queryByText("Loading local ledgers…")).toBeNull(); // the pre-#6512 sentence is gone
});

it("does not paginate count/feed tables at or below 20 rows (#6832)", () => {
// Only events-by-type + recent feed are populated (20 each) so type labels stay unique across tables.
const summary: LedgersSummary = {
claims: { total: 1, byStatus: { active: 1, released: 0, expired: 0 } },
events: {
total: 20,
byType: manyEventTypes(20),
recent: manyRecentEvents(20),
},
governor: { total: 0, byEventType: {} },
};
render(<LedgersView result={{ ok: true, summary }} />);
expect(screen.queryByRole("navigation", { name: /pagination/i })).toBeNull();
// Each type appears twice (count table + recent feed); both ends of the range must be visible.
expect(screen.getAllByText("event_type_0").length).toBe(2);
expect(screen.getAllByText("event_type_19").length).toBe(2);
});

it("paginates the events-by-type CountTable client-side above 20 rows (#6832)", () => {
const summary: LedgersSummary = {
claims: { total: 1, byStatus: { active: 1, released: 0, expired: 0 } },
events: {
total: 45,
byType: manyEventTypes(45),
recent: [],
},
governor: { total: 0, byEventType: {} },
};
render(<LedgersView result={{ ok: true, summary }} />);
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
// Sorted descending by count: event_type_0 has the highest count and appears first.
expect(screen.getByText("event_type_0")).toBeTruthy();
expect(screen.queryByText("event_type_20")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "2" }));
expect(screen.getByText("event_type_20")).toBeTruthy();
expect(screen.queryByText("event_type_0")).toBeNull();
});

it("paginates the recent-events feed client-side above 20 rows, with null column fallbacks (#6832)", () => {
const summary: LedgersSummary = {
claims: { total: 1, byStatus: { active: 1, released: 0, expired: 0 } },
events: {
total: 45,
byType: { attempt_started: 45 },
recent: manyRecentEvents(45),
},
governor: { total: 0, byEventType: {} },
};
render(<LedgersView result={{ ok: true, summary }} />);
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
expect(screen.getByText("event_type_0")).toBeTruthy();
expect(screen.queryByText("event_type_20")).toBeNull();
// Null repo/createdAt render as em-dashes (odd indices / multiples of 3 on page 1).
expect(screen.getAllByText("—").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("link", { name: "2" }));
expect(screen.getByText("event_type_20")).toBeTruthy();
expect(screen.queryByText("event_type_0")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "3" }));
expect(screen.getByText("event_type_40")).toBeTruthy();
});

it("paginates the governor-events CountTable independently of the events tables (#6832)", () => {
const summary: LedgersSummary = {
claims: { total: 1, byStatus: { active: 1, released: 0, expired: 0 } },
events: { total: 0, byType: {}, recent: [] },
governor: { total: 45, byEventType: manyEventTypes(45) },
};
render(<LedgersView result={{ ok: true, summary }} />);
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
expect(screen.getByText("event_type_0")).toBeTruthy();
expect(screen.queryByText("event_type_20")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "2" }));
expect(screen.getByText("event_type_20")).toBeTruthy();
// Previous / Next buttons also advance the page (covers both onClick arms).
fireEvent.click(screen.getByRole("link", { name: /go to previous page/i }));
expect(screen.getByText("event_type_0")).toBeTruthy();
fireEvent.click(screen.getByRole("link", { name: /go to next page/i }));
expect(screen.getByText("event_type_20")).toBeTruthy();
});
});

describe("LedgersPage (#4855)", () => {
Expand Down
215 changes: 175 additions & 40 deletions apps/loopover-miner-ui/src/routes/ledgers.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Bar, BarChart, Cell, XAxis, YAxis } from "recharts";

import { Button } from "@loopover/ui-kit/components/button";
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@loopover/ui-kit/components/chart";
import { Input } from "@loopover/ui-kit/components/input";
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";
Expand All @@ -12,6 +22,8 @@ import {
CLAIM_STATUSES,
fetchLedgers,
type ClaimStatus,
type ClaimStatusCounts,
type EventFeedEntry,
type LedgersResult,
type LedgersSummary,
} from "../lib/ledgers";
Expand All @@ -29,9 +41,12 @@ export const Route = createFileRoute("/ledgers")({
// governor control section) are each replaced by the shared @loopover/ui-kit `StateBoundary`, with a
// content-shaped `Skeleton` placeholder for the loading state so the layout doesn't jump when the poll resolves.
// The two flows keep their OWN independent boundary — a governor-state fetch failure must not blank the ledger
// summary, and vice-versa. Purely presentational: `lib/ledgers.ts`/`lib/governor.ts`, the two fetch loops, and
// the pause/resume Button wiring are untouched; only the loading/error chrome and the governor-control layout
// change.
// summary, and vice-versa.
//
// #6832: claims status cards gain a ui-kit `ChartContainer` bar chart (so the bare numbers aren't the only
// signal), and both event count-tables + the recent-events feed paginate client-side via the kit's `Pagination`
// once they exceed PAGE_SIZE rows — matching the run-history restyle (#6510). Purely presentational:
// `lib/ledgers.ts`/`lib/governor.ts`, the two fetch loops, and the pause/resume Button wiring stay untouched.
//
// The governor control section below is a SEPARATE fetch/action loop from the read-only ledger summary above
// (#4857, the governor half): it reads/writes the governor's pause state via vite-governor-api.ts, the
Expand All @@ -50,32 +65,167 @@ const CLAIM_STATUS_TONE: Record<ClaimStatus, string> = {
expired: "text-warning",
};

/** Rows per page once a count/feed table grows past this; below it the full table renders unpaginated. */
const PAGE_SIZE = 20;

const CLAIMS_CHART_CONFIG = {
count: { label: "Claims" },
active: { label: "Active", color: "var(--success)" },
released: { label: "Released", color: "var(--muted-foreground)" },
expired: { label: "Expired", color: "var(--warning)" },
} satisfies ChartConfig;

function TablePagination({
page,
pageCount,
onPageChange,
}: {
page: number;
pageCount: number;
onPageChange: (next: number) => void;
}) {
return (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
aria-disabled={page === 0}
onClick={(event) => {
event.preventDefault();
onPageChange(Math.max(0, page - 1));
}}
/>
</PaginationItem>
{Array.from({ length: pageCount }).map((_, index) => (
<PaginationItem key={index}>
<PaginationLink
href="#"
isActive={index === page}
onClick={(event) => {
event.preventDefault();
onPageChange(index);
}}
>
{index + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
aria-disabled={page >= pageCount - 1}
onClick={(event) => {
event.preventDefault();
onPageChange(Math.min(pageCount - 1, page + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}

function CountTable({ counts, keyLabel }: { counts: Record<string, number>; keyLabel: string }) {
const [page, setPage] = useState(0);
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
const pageCount = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
const isPaginated = entries.length > PAGE_SIZE;
const safePage = Math.min(page, pageCount - 1);
const visible = isPaginated ? entries.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : entries;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{keyLabel}</TableHead>
<TableHead>Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(([type, count]) => (
<TableRow key={type}>
<TableCell className="font-mono text-foreground">{type}</TableCell>
<TableCell>{count}</TableCell>
<div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{keyLabel}</TableHead>
<TableHead>Count</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{visible.map(([type, count]) => (
<TableRow key={type}>
<TableCell className="font-mono text-foreground">{type}</TableCell>
<TableCell>{count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{isPaginated && <TablePagination page={safePage} pageCount={pageCount} onPageChange={setPage} />}
</div>
);
}

function RecentEventsTable({ entries }: { entries: EventFeedEntry[] }) {
const [page, setPage] = useState(0);
const pageCount = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
const isPaginated = entries.length > PAGE_SIZE;
const safePage = Math.min(page, pageCount - 1);
const visible = isPaginated ? entries.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : entries;
return (
<div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Event type</TableHead>
<TableHead>Repository</TableHead>
<TableHead>Recorded</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((entry, index) => (
<TableRow key={`${entry.eventType}-${entry.createdAt ?? index}`}>
<TableCell className="font-mono text-foreground">{entry.eventType}</TableCell>
<TableCell className="font-mono">{entry.repoFullName ?? "—"}</TableCell>
<TableCell>{entry.createdAt ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{isPaginated && <TablePagination page={safePage} pageCount={pageCount} onPageChange={setPage} />}
</div>
);
}

/** Horizontal bar chart of claim status counts — the chart.tsx adoption for the claims cards section (#6832).
* Cards still show the exact numbers; the chart is the glanceable breakdown the bare `<dd>`s alone weren't. */
function ClaimsStatusChart({ byStatus }: { byStatus: ClaimStatusCounts }) {
const data = CLAIM_STATUSES.map((status) => ({
status,
label: CLAIM_STATUS_LABELS[status],
count: byStatus[status],
}));
return (
<ChartContainer
config={CLAIMS_CHART_CONFIG}
className="aspect-auto h-40 w-full"
aria-label="Claims by status chart"
>
<BarChart data={data} layout="vertical" margin={{ left: 4, right: 12, top: 4, bottom: 4 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="label"
width={72}
tickLine={false}
axisLine={false}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
/>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="count" radius={4}>
{data.map((entry) => (
<Cell key={entry.status} fill={`var(--color-${entry.status})`} />
))}
</Bar>
</BarChart>
</ChartContainer>
);
}

/** Card+table-shaped loading placeholder for the ledger summary: mirrors the 3 status cards and the stacked
* count/feed tables below them, so the summary keeps its shape while the first fetch resolves. `role="status"`
* keeps the loading state announced to assistive tech (as the flat "Loading local ledgers…" text it replaces
* was). */
/** Card+chart+table-shaped loading placeholder for the ledger summary: mirrors the 3 status cards, the claims
* chart, and the stacked count/feed tables below them, so the summary keeps its shape while the first fetch
* resolves. `role="status"` keeps the loading state announced to assistive tech (as the flat "Loading local
* ledgers…" text it replaces was). */
function LedgerSummarySkeleton() {
return (
<div className="grid gap-6" role="status" aria-label="Loading local ledgers">
Expand All @@ -91,6 +241,7 @@ function LedgerSummarySkeleton() {
</Card>
))}
</div>
<Skeleton className="h-40 w-full" />
</section>
{Array.from({ length: 2 }).map((_, index) => (
<section key={index} className="grid gap-3">
Expand Down Expand Up @@ -138,6 +289,7 @@ function LedgersSummaryContent({ summary }: { summary: LedgersSummary }) {
</Card>
))}
</dl>
<ClaimsStatusChart byStatus={claims.byStatus} />
</section>

<section className="grid gap-3">
Expand All @@ -163,24 +315,7 @@ function LedgersSummaryContent({ summary }: { summary: LedgersSummary }) {
{events.recent.length === 0 ? (
<p className="text-token-sm text-muted-foreground">No event-ledger entries recorded.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Event type</TableHead>
<TableHead>Repository</TableHead>
<TableHead>Recorded</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.recent.map((entry, index) => (
<TableRow key={`${entry.eventType}-${entry.createdAt ?? index}`}>
<TableCell className="font-mono text-foreground">{entry.eventType}</TableCell>
<TableCell className="font-mono">{entry.repoFullName ?? "—"}</TableCell>
<TableCell>{entry.createdAt ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<RecentEventsTable entries={events.recent} />
)}
</section>
</div>
Expand Down
Loading
Loading