diff --git a/apps/loopover-miner-ui/src/governor.test.tsx b/apps/loopover-miner-ui/src/governor.test.tsx index 4e89425078..67291c2287 100644 --- a/apps/loopover-miner-ui/src/governor.test.tsx +++ b/apps/loopover-miner-ui/src/governor.test.tsx @@ -35,11 +35,14 @@ describe("defaultGovernorPauseState (#4857)", () => { }); describe("GovernorControlSection (#4857)", () => { - it("renders the loading state before the first result arrives", () => { - render( + it("renders content-shaped Skeleton placeholders (not plain text) before the first result arrives (#6512)", () => { + const { container } = render( undefined} onResume={() => undefined} />, ); - expect(screen.getByText(/Loading governor state/i)).toBeTruthy(); + // The plain-text "Loading governor state…" branch is gone; the StateBoundary now renders animate-pulse + // Skeleton blocks shaped to the eventual status-line + input/button layout. + expect(screen.queryByText(/Loading governor state/i)).toBeNull(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); }); it("renders an error message when the local API is unreachable", () => { diff --git a/apps/loopover-miner-ui/src/ledgers.test.tsx b/apps/loopover-miner-ui/src/ledgers.test.tsx index da16cb4447..fd5794d5d9 100644 --- a/apps/loopover-miner-ui/src/ledgers.test.tsx +++ b/apps/loopover-miner-ui/src/ledgers.test.tsx @@ -101,20 +101,23 @@ describe("LedgersView (#4855)", () => { expect(screen.getAllByText("acme/widgets").length).toBeGreaterThan(0); }); - it("renders the fresh-install empty state when every ledger is empty", () => { + it("renders the fresh-install empty state when every ledger is empty (#6512 StateBoundary EmptyState)", () => { render(); expect(screen.getByText(/No ledger activity yet/i)).toBeTruthy(); expect(screen.queryByRole("table")).toBeNull(); }); - it("renders an error message when the local API is unreachable", () => { + it("renders the ui-kit ErrorState, surfacing the raw error, when the local API is unreachable (#6512)", () => { render(); expect(screen.getByRole("alert").textContent).toContain("connection refused"); }); - it("renders the loading state before the first result arrives", () => { - render(); - expect(screen.getByText(/Loading local ledgers/i)).toBeTruthy(); + it("renders content-shaped Skeleton placeholders (not plain text) before the first result arrives (#6512)", () => { + const { container } = render(); + // The plain-text "Loading local ledgers…" branch is gone; the StateBoundary now renders animate-pulse + // Skeleton blocks shaped to the eventual card/table layout. + expect(screen.queryByText(/Loading local ledgers/i)).toBeNull(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); }); }); diff --git a/apps/loopover-miner-ui/src/routes/ledgers.tsx b/apps/loopover-miner-ui/src/routes/ledgers.tsx index c69bb86af2..5b8dd5e823 100644 --- a/apps/loopover-miner-ui/src/routes/ledgers.tsx +++ b/apps/loopover-miner-ui/src/routes/ledgers.tsx @@ -4,9 +4,17 @@ import { useEffect, useState } from "react"; import { Button } from "@loopover/ui-kit/components/button"; import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card"; import { Input } from "@loopover/ui-kit/components/input"; +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 { CLAIM_STATUSES, fetchLedgers, type ClaimStatus, type LedgersResult } from "../lib/ledgers"; +import { + CLAIM_STATUSES, + fetchLedgers, + type ClaimStatus, + type LedgersResult, + type LedgersSummary, +} from "../lib/ledgers"; import { fetchGovernorPauseState, pauseGovernor, resumeGovernor, type GovernorPauseStateResult } from "../lib/governor"; export const Route = createFileRoute("/ledgers")({ @@ -16,12 +24,15 @@ export const Route = createFileRoute("/ledgers")({ // Read-only views over the miner's local claim / event / governor ledgers (#4855). All three are aggregated // server-side (see vite-ledgers-api.ts) to status/type counts plus a small feed of SAFE columns — raw payloads // and the free-text claim note never reach this component. Same 4-state pattern as the portfolio/run-history -// views (loading / error / fresh-install empty / populated). +// views (loading / error / fresh-install empty / populated), now rendered through ui-kit's shared StateBoundary +// / LoadingState / ErrorState primitives with content-shaped Skeletons instead of hand-rolled plain text (#6512). // // 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 // miner-ui's first write-capable endpoint, safe only because vite-auth.ts (#4858) now authenticates every -// /api/* request. It does not touch, and is unrelated to, the governor EVENT ledger already shown below. +// /api/* request. It does not touch, and is unrelated to, the governor EVENT ledger already shown below. Its +// read has its OWN StateBoundary so a governor-state fetch failure never blocks the ledger summary, and the +// pause/resume write path (lib/governor.ts + the Button click handlers) is deliberately left untouched (#6512). const CLAIM_STATUS_LABELS: Record = { active: "Active", @@ -57,6 +68,50 @@ function CountTable({ counts, keyLabel }: { counts: Record; keyL ); } +// Content-shaped placeholder for the governor-control panel: a status line plus the input/button row, so the +// layout doesn't jump once the pause state arrives. +function GovernorControlSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} + +// Content-shaped placeholder for the ledger summary: the three claim count-cards plus a couple of table blocks, +// approximating the eventual card-grid + table layout rather than a single generic bar. +function LedgerSummarySkeleton() { + return ( +
+
+ +
+ {Array.from({ length: 3 }, (_, index) => ( +
+ + +
+ ))} +
+
+ {Array.from({ length: 2 }, (_, section) => ( +
+ +
+ {Array.from({ length: 3 }, (_, row) => ( + + ))} +
+
+ ))} +
+ ); +} + export function GovernorControlSection({ result, pending, @@ -71,67 +126,62 @@ export function GovernorControlSection({ // Optional pause reason, mirroring the CLI's `governor pause [--reason ]`; an empty field // is passed through as `undefined` so it matches the CLI's own optional-flag behavior. const [reason, setReason] = useState(""); + const pauseState = result?.ok ? result.pauseState : null; + const governorError = result !== null && !result.ok ? result.error : null; return (

Governor control

- {result === null ? ( -

Loading governor state…

- ) : !result.ok ? ( -

- Could not read the local governor state: {result.error} -

- ) : ( -
-

- {result.pauseState.paused - ? `Paused since ${result.pauseState.pausedAt}${result.pauseState.reason ? ` (${result.pauseState.reason})` : ""}` - : "Not paused"} -

- {result.pauseState.paused ? ( - - ) : ( - <> - setReason(event.target.value)} - disabled={pending} - placeholder="Reason (optional)" - aria-label="Pause reason" - className="w-auto flex-1 min-w-[12rem]" + } + errorTitle="Couldn't read the governor state" + errorDescription={governorError ? `Could not read the local governor state: ${governorError}` : undefined} + > + {pauseState && ( +
+
+ - - - )} -
- )} +

+ {pauseState.paused + ? `Paused since ${pauseState.pausedAt}${pauseState.reason ? ` (${pauseState.reason})` : ""}` + : "Not paused"} +

+
+ {pauseState.paused ? ( +
+ +
+ ) : ( +
+ setReason(event.target.value)} + disabled={pending} + placeholder="Reason (optional)" + aria-label="Pause reason" + className="w-auto flex-1 min-w-[12rem]" + /> + +
+ )} +
+ )} +
); } -export function LedgersView({ result }: { result: LedgersResult | null }) { - if (result === null) { - return

Loading local ledgers…

; - } - if (!result.ok) { - return ( -

- Could not read the local ledgers: {result.error} -

- ); - } - const { claims, events, governor } = result.summary; - if (claims.total === 0 && events.total === 0 && governor.total === 0) { - return ( -

- No ledger activity yet — claims, events, and governor entries appear here once the miner starts working. -

- ); - } +function LedgerSummary({ summary }: { summary: LedgersSummary }) { + const { claims, events, governor } = summary; return (
@@ -199,6 +249,27 @@ export function LedgersView({ result }: { result: LedgersResult | null }) { ); } +export function LedgersView({ result }: { result: LedgersResult | null }) { + const summary = result?.ok ? result.summary : null; + const ledgersError = result !== null && !result.ok ? result.error : null; + const isEmpty = + summary !== null && summary.claims.total === 0 && summary.events.total === 0 && summary.governor.total === 0; + return ( + } + errorTitle="Couldn't read the local ledgers" + errorDescription={ledgersError ? `Could not read the local ledgers: ${ledgersError}` : undefined} + emptyTitle="No ledger activity yet" + emptyDescription="Claims, events, and governor entries appear here once the miner starts working." + > + {summary && } + + ); +} + export function LedgersPage({ loadLedgers = fetchLedgers, loadGovernorPauseState = fetchGovernorPauseState,