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
16 changes: 11 additions & 5 deletions apps/loopover-miner-ui/src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,17 @@ describe("OverviewView (#4853)", () => {
expect(statValue("Oldest queued")).toBe("90m");
});

it("shows an independent loading message per card before data arrives", () => {
it("shows an independent content-shaped loading skeleton per card before data arrives (#6509)", () => {
render(<OverviewView runs={null} portfolio={null} claims={null} />);
expect(screen.getByText(/Loading run state/i)).toBeTruthy();
expect(screen.getByText(/Loading the portfolio queue/i)).toBeTruthy();
expect(screen.getByText(/Loading the claim ledger/i)).toBeTruthy();
// Each card's loading surface is now a role="status" Skeleton placeholder, not a flat gray sentence.
expect(screen.getByRole("status", { name: /loading run activity/i })).toBeTruthy();
expect(screen.getByRole("status", { name: /loading the portfolio queue/i })).toBeTruthy();
expect(screen.getByRole("status", { name: /loading the claim ledger/i })).toBeTruthy();
// The pre-#6509 hand-rolled "Loading …" text is gone.
expect(screen.queryByText(/Loading run state/i)).toBeNull();
});

it("degrades each card independently: an errored source shows an alert while the others still render", () => {
it("degrades each card independently: an errored source shows a StateBoundary alert while the others still render", () => {
render(
<OverviewView
runs={{ ok: false, error: "down" }}
Expand All @@ -69,6 +72,9 @@ describe("OverviewView (#4853)", () => {
/>,
);
expect(screen.getAllByRole("alert")).toHaveLength(2); // runs + claims errored
// The shared StateBoundary error surface, with this route's per-card copy (#6509).
expect(screen.getByText(/Couldn't read run state/i)).toBeTruthy();
expect(screen.getByText(/Couldn't read the claim ledger/i)).toBeTruthy();
expect(statValue("Total items")).toBe("5"); // portfolio still renders
});
});
Expand Down
126 changes: 76 additions & 50 deletions apps/loopover-miner-ui/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";

import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";

import { fetchLedgers, type LedgersResult } from "../lib/ledgers";
import { fetchPortfolioQueue, type PortfolioQueueResult } from "../lib/portfolio-queue";
Expand All @@ -14,7 +16,13 @@ export const Route = createFileRoute("/")({
// Overview dashboard (#4853): replaces the Phase-6 placeholder with a live, at-a-glance summary of real miner
// state — run activity, portfolio queue, and claims — aggregated from the same local read-only APIs the dedicated
// views use (run-state, portfolio-queue, ledgers). Each card degrades independently: it shows its own loading or
// error message without taking the others down. Live-refreshed on the shared poll cadence (#4856).
// error surface without taking the others down. Live-refreshed on the shared poll cadence (#4856).
//
// #6509: the per-card loading/error surface is the shared `StateBoundary` + content-shaped `Skeleton` from
// @loopover/ui-kit (the same primitives the main app's routes already use), replacing this route's own
// hand-rolled "Loading …" / "Could not read …" text. The skeleton mirrors each card's Stat rows so the layout
// doesn't shift once data arrives, and — unlike a flat gray sentence re-rendered every 10s poll — reads as a
// live placeholder. Errors auto-recover on the next successful poll, so no manual retry action is wired.

/** One metric line inside a summary card. */
function Stat({ label, value, tone }: { label: string; value: string | number; tone?: string }) {
Expand All @@ -37,75 +45,93 @@ function SummaryCard({ title, children }: { title: string; children: React.React
);
}

function Fallback({ state, subject }: { state: "loading" | "error"; subject: string }) {
return state === "loading" ? (
<p className="text-token-sm text-muted-foreground">Loading {subject}…</p>
) : (
<p role="alert" className="text-token-sm text-[var(--danger)]">
Could not read {subject}.
</p>
/** Content-shaped loading placeholder for a summary card: `rows` shimmer lines mirroring the `Stat` label/value
* layout, so the card keeps its height and the content doesn't jump once the poll resolves. `role="status"`
* keeps the loading state announced to assistive tech (the flat "Loading …" text it replaces was announced too). */
function CardStatsSkeleton({ rows, label }: { rows: number; label: string }) {
return (
<div className="grid gap-2" role="status" aria-label={label}>
{Array.from({ length: rows }).map((_, index) => (
<div key={index} className="flex items-baseline justify-between gap-4">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-12" />
</div>
))}
</div>
);
}

export function OverviewRunsCard({ runs }: { runs: RunHistoryResult | null }) {
return (
<SummaryCard title="Run activity">
{runs === null ? (
<Fallback state="loading" subject="run state" />
) : !runs.ok ? (
<Fallback state="error" subject="run state" />
) : (
<>
<Stat label="Repositories tracked" value={runs.rows.length} />
<Stat
label="Currently working"
value={runs.rows.filter((row) => row.state !== "idle").length}
tone="text-[var(--success)]"
/>
</>
)}
<StateBoundary
isLoading={runs === null}
isError={runs !== null && !runs.ok}
loadingSkeleton={<CardStatsSkeleton rows={2} label="Loading run activity" />}
errorTitle="Couldn't read run state"
errorDescription="The local run-state API didn't respond. This refreshes automatically."
>
{runs?.ok && (
<>
<Stat label="Repositories tracked" value={runs.rows.length} />
<Stat
label="Currently working"
value={runs.rows.filter((row) => row.state !== "idle").length}
tone="text-[var(--success)]"
/>
</>
)}
</StateBoundary>
</SummaryCard>
);
}

export function OverviewPortfolioCard({ portfolio }: { portfolio: PortfolioQueueResult | null }) {
return (
<SummaryCard title="Portfolio queue">
{portfolio === null ? (
<Fallback state="loading" subject="the portfolio queue" />
) : !portfolio.ok ? (
<Fallback state="error" subject="the portfolio queue" />
) : (
<>
<Stat label="Total items" value={portfolio.summary.total} />
<Stat label="Queued" value={portfolio.summary.byStatus.queued} />
<Stat label="In progress" value={portfolio.summary.byStatus.in_progress} tone="text-[var(--warning)]" />
<Stat label="Done" value={portfolio.summary.byStatus.done} tone="text-[var(--success)]" />
{/* Deliver the CLI/web-UI parity the portfolio-queue data path promises (#6185): the CLI's `queue
dashboard` renders "oldest-queued: Xm" (portfolio-dashboard.js), and the same minutes-rounded age
is shown here. Omitted (like the CLI) when the queue is empty and the age is null. */}
{portfolio.summary.oldestQueuedAgeMs !== null && (
<Stat label="Oldest queued" value={`${Math.round(portfolio.summary.oldestQueuedAgeMs / 60000)}m`} />
)}
</>
)}
<StateBoundary
isLoading={portfolio === null}
isError={portfolio !== null && !portfolio.ok}
loadingSkeleton={<CardStatsSkeleton rows={4} label="Loading the portfolio queue" />}
errorTitle="Couldn't read the portfolio queue"
errorDescription="The local portfolio-queue API didn't respond. This refreshes automatically."
>
{portfolio?.ok && (
<>
<Stat label="Total items" value={portfolio.summary.total} />
<Stat label="Queued" value={portfolio.summary.byStatus.queued} />
<Stat label="In progress" value={portfolio.summary.byStatus.in_progress} tone="text-[var(--warning)]" />
<Stat label="Done" value={portfolio.summary.byStatus.done} tone="text-[var(--success)]" />
{/* Deliver the CLI/web-UI parity the portfolio-queue data path promises (#6185): the CLI's `queue
dashboard` renders "oldest-queued: Xm" (portfolio-dashboard.js), and the same minutes-rounded age
is shown here. Omitted (like the CLI) when the queue is empty and the age is null. */}
{portfolio.summary.oldestQueuedAgeMs !== null && (
<Stat label="Oldest queued" value={`${Math.round(portfolio.summary.oldestQueuedAgeMs / 60000)}m`} />
)}
</>
)}
</StateBoundary>
</SummaryCard>
);
}

export function OverviewClaimsCard({ claims }: { claims: LedgersResult | null }) {
return (
<SummaryCard title="Claims">
{claims === null ? (
<Fallback state="loading" subject="the claim ledger" />
) : !claims.ok ? (
<Fallback state="error" subject="the claim ledger" />
) : (
<>
<Stat label="Active" value={claims.summary.claims.byStatus.active} tone="text-[var(--success)]" />
<Stat label="Total recorded" value={claims.summary.claims.total} />
</>
)}
<StateBoundary
isLoading={claims === null}
isError={claims !== null && !claims.ok}
loadingSkeleton={<CardStatsSkeleton rows={2} label="Loading the claim ledger" />}
errorTitle="Couldn't read the claim ledger"
errorDescription="The local ledgers API didn't respond. This refreshes automatically."
>
{claims?.ok && (
<>
<Stat label="Active" value={claims.summary.claims.byStatus.active} tone="text-[var(--success)]" />
<Stat label="Total recorded" value={claims.summary.claims.total} />
</>
)}
</StateBoundary>
</SummaryCard>
);
}
Expand Down
Loading