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
25 changes: 25 additions & 0 deletions __tests__/components/ActivityCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ActivityCard } from "@/components/overview/ActivityCard";

vi.mock("swr", () => ({ default: vi.fn() }));

const useSWR = (await import("swr")).default as ReturnType<typeof vi.fn>;

beforeEach(() => vi.clearAllMocks());

describe("ActivityCard", () => {
it("says what the headline counts and lists every state, proposals named apart", () => {
useSWR.mockReturnValue({
data: { deltaStatusCounts: { candidate: 0, canonical: 717, discarded: 4, retained: 15 }, inFlightProposalCount: 2 },
});
render(<ActivityCard />);
expect(screen.getByText("717")).toBeInTheDocument();
expect(screen.getByText("confirmed transactions, all time")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Expand" }));
for (const label of ["Confirmed", "Submitted", "Recovering", "Discarded", "Proposals awaiting signatures"]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(screen.getByText("4")).toBeInTheDocument();
});
});
16 changes: 14 additions & 2 deletions __tests__/components/AssetsCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { AssetsCard } from "@/components/overview/AssetsCard";

vi.mock("swr", () => ({ default: vi.fn() }));
Expand Down Expand Up @@ -46,7 +46,7 @@ describe("AssetsCard", () => {
mockData({ usd7d: null, computedAt: "2026-10-07T12:00:00Z", priced: 0, unpriced: 104 });
render(<AssetsCard />);
expect(screen.getByText("Unpriced")).toBeInTheDocument();
expect(screen.getByTitle(/104 faucet/)).toBeInTheDocument();
expect(screen.getByTitle(/104 token/)).toBeInTheDocument();
expect(screen.queryByText("—")).not.toBeInTheDocument();
});

Expand Down Expand Up @@ -100,3 +100,15 @@ describe("AssetsCard poll scheduling", () => {
expect(optionsFromCall(0).refreshInterval).toBe(60_000);
});
});

describe("AssetsCard expander", () => {
it("expands into the priced and unpriced token counts", () => {
mockData({ usd7d: 2.32, computedAt: "2026-10-08T10:00:00Z", priced: 1, unpriced: 3 });
render(<AssetsCard />);
expect(screen.getByText(/held by accounts updated in the last 7d/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.getByText("Priced tokens")).toBeInTheDocument();
expect(screen.getByText("Unpriced tokens")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
});
});
4 changes: 2 additions & 2 deletions __tests__/lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ describe("activityToCsv", () => {
});

it("leaves the counterparty and amount blank for a proposal", () => {
const row = activityToCsv([{ accountId: "0xabc", label: "Swap", status: "1/2 signed", timestamp: "t" }]).split("\r\n")[1];
expect(row).toBe("0xabc,,,Swap,,1/2 signed,t");
const row = activityToCsv([{ accountId: "0xabc", label: "Swap", status: "1/2 acknowledged", timestamp: "t" }]).split("\r\n")[1];
expect(row).toBe("0xabc,,,Swap,,1/2 acknowledged,t");
});
});

Expand Down
2 changes: 1 addition & 1 deletion components/accounts/StatStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function StatStrip() {
)}
{assets?.usd7d != null && (
<span className="text-muted-foreground">
Assets (updated last 7d)&nbsp;&nbsp;<span className="font-semibold text-foreground">${assets.usd7d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
Assets (accounts updated last 7d)&nbsp;&nbsp;<span className="font-semibold text-foreground">${assets.usd7d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</span>
)}
</div>
Expand Down
7 changes: 6 additions & 1 deletion components/overview/AccountsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ChevronDown, ChevronUp } from "lucide-react";
import { fetcher } from "@/lib/utils";
import { formatCount } from "@/lib/format";
import { InfoTip } from "@/components/ui/InfoTip";

interface OverviewData {
totalAccounts: number;
Expand Down Expand Up @@ -36,7 +37,10 @@ export function AccountsCard() {
<CardContent className="pt-4 pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs text-muted-foreground mb-1">Accounts</p>
<p className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
Accounts
<InfoTip text="Every account registered with this Guardian." />
</p>
{loading ? (
<Skeleton className="h-8 w-12 mt-1" />
) : (
Expand All @@ -58,6 +62,7 @@ export function AccountsCard() {
</div>
{expanded && data && (
<div className="mt-3 pt-3 border-t space-y-1.5">
<p className="text-xs text-muted-foreground">By signature scheme</p>
{data.falcon === null ? (
<p
className="text-xs text-muted-foreground"
Expand Down
20 changes: 13 additions & 7 deletions components/overview/ActivityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ChevronDown, ChevronUp } from "lucide-react";
import { fetcher } from "@/lib/utils";
import { formatCount } from "@/lib/format";
import { InfoTip } from "@/components/ui/InfoTip";

interface OverviewData {
// `retained` arrived in Guardian 0.16.1 (issue #345): candidates the worker
Expand Down Expand Up @@ -37,7 +38,10 @@ export function ActivityCard() {
<CardContent className="pt-4 pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs text-muted-foreground mb-1">Activity</p>
<p className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
Activity
<InfoTip text="Transactions this Guardian has confirmed on chain, over its whole history. Expand for the other states and the proposals still collecting signatures." />
</p>
{loading ? (
<Skeleton className="h-8 w-12 mt-1" />
) : (
Expand All @@ -46,7 +50,7 @@ export function ActivityCard() {
</p>
)}
{data && (
<p className="text-xs text-muted-foreground mt-1">confirmed</p>
<p className="text-xs text-muted-foreground mt-1">confirmed transactions, all time</p>
)}
</div>
{data && (
Expand All @@ -72,11 +76,6 @@ export function ActivityCard() {
value={data.deltaStatusCounts.candidate}
accent={data.deltaStatusCounts.candidate > 0 ? "text-state-pending" : undefined}
/>
<Row
label="Awaiting signatures"
value={data.inFlightProposalCount}
accent={data.inFlightProposalCount > 0 ? "text-state-pending" : undefined}
/>
{/* Shown only when there are any. A permanent "Recovering 0" adds a
row of noise to every Guardian that never has one, and Guardians
below 0.16.1 do not report this at all. */}
Expand All @@ -87,6 +86,13 @@ export function ActivityCard() {
accent="text-state-pending"
/>
)}
<Row label="Discarded" value={data.deltaStatusCounts.discarded} />
{/* The one row that is not a transaction state. */}
<Row
label="Proposals awaiting signatures"
value={data.inFlightProposalCount}
accent={data.inFlightProposalCount > 0 ? "text-state-pending" : undefined}
/>
</div>
)}
</CardContent>
Expand Down
134 changes: 86 additions & 48 deletions components/overview/AssetsCard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { fetcher } from "@/lib/utils";
import { formatCount } from "@/lib/format";
import { InfoTip } from "@/components/ui/InfoTip";

type AssetTotals = {
usd7d?: number | null;
Expand All @@ -25,59 +29,93 @@ export function AssetsCard() {
const { data, error } = useSWR<AssetTotals>("/api/accounts/asset-totals", fetcher, {
refreshInterval: 60_000,
});
const [expanded, setExpanded] = useState(false);
const split = data?.priced != null && data.unpriced != null;

return (
<Card>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground mb-1">Assets (updated last 7d)</p>
{!data && !error ? (
<Skeleton className="h-8 w-20 mt-1" />
) : data?.usd7d != null ? (
<p className="text-stat text-foreground">
${data.usd7d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
) : data?.unsupported ? (
// Names the reason rather than showing the same dash a dead Guardian
// would. Nothing here will change until this Guardian's operator
// upgrades, so there is no progress to report.
<p
className="text-section text-muted-foreground"
title="This Guardian computes no cross-account totals. The endpoint the dashboard reads them from arrived in Guardian 0.18.0."
>
Needs Guardian 0.18.0
</p>
) : data?.unpriced && !data.priced ? (
// Holdings exist, and none of them has a market. Priced the way the
// Miden wallet does (lib/prices.ts): a test mint or an unlisted token
// gets no dollar figure rather than an invented one.
<p
className="text-section text-muted-foreground"
title={`Holdings in ${data.unpriced.toLocaleString()} faucet(s) with no price: not on the verified token list, or the price feed is unreachable. The Miden wallet shows the same holdings with no dollar figure.`}
>
Unpriced
</p>
) : data?.warming ? (
// A 0.18.0 Guardian that has not finished its first pass, or one whose
// pass could not decode every vault. It says how far it got, and the
// number climbing is the only evidence that waiting will end.
<p
className="text-section text-muted-foreground"
title="The Guardian is still computing its first asset aggregate. This clears within one refresh interval."
>
Calculating…
{data.done != null && data.total != null && (
<span className="ml-1 text-data">
{data.done.toLocaleString()} of {data.total.toLocaleString()}
</span>
<div className="flex items-start justify-between gap-2">
<div>
<p className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
Assets
<InfoTip text="Dollar value of what the accounts updated in the last 7 days hold, for tokens on the verified token list with a market price. Anything else is unpriced." />
</p>
{!data && !error ? (
<Skeleton className="h-8 w-20 mt-1" />
) : data?.usd7d != null ? (
<p className="text-stat text-foreground">
${data.usd7d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
) : data?.unsupported ? (
// Names the reason rather than showing the same dash a dead Guardian
// would. Nothing here will change until this Guardian's operator
// upgrades, so there is no progress to report.
<p
className="text-section text-muted-foreground"
title="This Guardian computes no cross-account totals. The endpoint the dashboard reads them from arrived in Guardian 0.18.0."
>
Needs Guardian 0.18.0
</p>
) : data?.unpriced && !data.priced ? (
// Holdings exist, and none of them has a market. Priced the way the
// Miden wallet does (lib/prices.ts): a test mint or an unlisted token
// gets no dollar figure rather than an invented one.
<p
className="text-section text-muted-foreground"
title={`Holdings in ${data.unpriced.toLocaleString()} token(s) with no price: not on the verified token list, or the price feed is unreachable. The Miden wallet shows the same holdings with no dollar figure.`}
>
Unpriced
</p>
) : data?.warming ? (
// A 0.18.0 Guardian that has not finished its first pass, or one whose
// pass could not decode every vault. It says how far it got, and the
// number climbing is the only evidence that waiting will end.
<p
className="text-section text-muted-foreground"
title="The Guardian is still computing its first asset aggregate. This clears within one refresh interval."
>
Calculating…
{data.done != null && data.total != null && (
<span className="ml-1 text-data">
{data.done.toLocaleString()} of {data.total.toLocaleString()}
</span>
)}
</p>
) : (
<p
className="text-stat text-muted-foreground"
title={error ? "The Guardian did not answer." : "Not computed yet."}
>
—
</p>
)}
</p>
) : (
<p
className="text-stat text-muted-foreground"
title={error ? "The Guardian did not answer." : "Not computed yet."}
>
—
</p>
{split && (
<p className="text-xs text-muted-foreground mt-1">held by accounts updated in the last 7d</p>
)}
</div>
{split && (
<button
onClick={() => setExpanded((v) => !v)}
className="text-muted-foreground hover:text-foreground transition-colors mt-1"
title={expanded ? "Collapse" : "Expand"}
aria-label={expanded ? "Collapse" : "Expand"}
>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
)}
</div>
{expanded && split && (
<div className="mt-3 pt-3 border-t space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Priced tokens</span>
<span className="font-medium">{formatCount(data.priced!)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Unpriced tokens</span>
<span className="font-medium">{formatCount(data.unpriced!)}</span>
</div>
</div>
)}
</CardContent>
</Card>
Expand Down
29 changes: 25 additions & 4 deletions components/overview/AttentionCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Timestamp } from "@/components/ui/Timestamp";
import { fetcher } from "@/lib/utils";
import { formatCount } from "@/lib/format";
import { InfoTip } from "@/components/ui/InfoTip";
import { STATS_KEY, type AccountStats } from "@/components/accounts/StatStrip";

/**
Expand All @@ -23,11 +24,13 @@ import { STATS_KEY, type AccountStats } from "@/components/accounts/StatStrip";
function Stat({
icon,
label,
info,
children,
tone = "quiet",
}: {
icon: React.ReactNode;
label: string;
info: string;
children: React.ReactNode;
tone?: "quiet" | "attention";
}) {
Expand All @@ -37,6 +40,7 @@ function Stat({
<p className="mb-1 flex items-center gap-1.5 text-label text-muted-foreground">
{icon}
{label}
<InfoTip text={info} />
</p>
<div className={tone === "attention" ? "text-stat text-state-frozen" : "text-stat text-muted-foreground"}>
{children}
Expand All @@ -63,16 +67,33 @@ export function AttentionCards() {

return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Stat icon={<CircleCheck className="h-3.5 w-3.5" />} label="Active">
<Stat
icon={<CircleCheck className="h-3.5 w-3.5" />}
label="Active accounts"
info="Accounts this Guardian acknowledges transactions for, neither frozen nor released."
>
{count(stats?.active, "/accounts?state=active")}
</Stat>
<Stat icon={<ArrowRightFromLine className="h-3.5 w-3.5" />} label="Released">
<Stat
icon={<ArrowRightFromLine className="h-3.5 w-3.5" />}
label="Released accounts"
info="Accounts moved to another Guardian. This one no longer acknowledges their transactions."
>
{count(stats?.released, "/accounts?state=released")}
</Stat>
<Stat icon={<Snowflake className="h-3.5 w-3.5" />} label="Frozen" tone={stats?.frozen ? "attention" : "quiet"}>
<Stat
icon={<Snowflake className="h-3.5 w-3.5" />}
label="Frozen accounts"
info="Accounts paused by the operator. No transaction is acknowledged for them until they are unfrozen."
tone={stats?.frozen ? "attention" : "quiet"}
>
{count(stats?.frozen, "/accounts?paused=true")}
</Stat>
<Stat icon={<Activity className="h-3.5 w-3.5" />} label="Last activity">
<Stat
icon={<Activity className="h-3.5 w-3.5" />}
label="Last activity"
info="When this Guardian last recorded a transaction or a proposal."
>
{overview === undefined ? (
<Skeleton className="h-8 w-28" />
) : overview.latestActivity ? (
Expand Down
Loading
Loading