Skip to content
Closed
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
9 changes: 7 additions & 2 deletions apps/ui/src/components/metagraphed/states.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,16 @@ export function StaleBanner({
* failed — a distinct error affordance so failure reads differently from a
* loading skeleton or a legitimately-empty "—". Used in the homepage KPI panels
* (#3964) and the About "At a glance" sidebar (#3968).
*
* `h-[1em]` matches the parent value line's font-size (e.g. StatTile's
* font-display text-2xl) so an error tile stays the same height as its
* numeric neighbours instead of collapsing to text-sm metrics (#8818).
*/
export function StatUnavailable({ iconClassName = "size-3.5" }: { iconClassName?: string }) {
return (
<span className="inline-flex items-center gap-1 text-sm font-medium text-health-down">
<AlertCircle className={iconClassName} /> Unavailable
<span className="inline-flex h-[1em] max-w-full items-center gap-1 text-sm font-medium leading-none text-health-down">
<AlertCircle className={`shrink-0 ${iconClassName}`} aria-hidden />
<span className="min-w-0 truncate">Unavailable</span>
</span>
);
}
Expand Down
26 changes: 20 additions & 6 deletions apps/ui/src/routes/-accounts-ss58-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ import { PriceAtTx } from "@/components/metagraphed/price-at-tx";
import { AddressLabelEditor } from "@/components/metagraphed/address-label-editor";
import { AppShell } from "@/components/metagraphed/app-shell";
import { ApiSourceFooter } from "@/components/metagraphed/api-source-footer";
import { EmptyState, Skeleton, StaleBanner } from "@/components/metagraphed/states";
import {
EmptyState,
Skeleton,
StatUnavailable,
StaleBanner,
} from "@/components/metagraphed/states";
import { statPhase, type StatPhase } from "@/lib/metagraphed/stat-phase";
import { SelectFilter, FilterChip } from "@/components/metagraphed/table-controls";
import { EndpointSnippet } from "@/components/metagraphed/endpoint-snippet";
import { WatchStarButton } from "@/components/metagraphed/watch-star-button";
Expand Down Expand Up @@ -338,6 +344,7 @@ function ValidAccountDetail({ ss58 }: { ss58: string }) {
balanceImplausible={balanceImplausible}
onRetryBalance={() => void balanceResult.refetch()}
portfolio={portfolio}
portfolioPhase={statPhase(portfolioResult)}
stakeFlow={stakeFlow}
validator={validator}
estApyPct={estApyPct}
Expand Down Expand Up @@ -575,6 +582,7 @@ function AccountKpiBand({
balanceImplausible,
onRetryBalance,
portfolio,
portfolioPhase,
stakeFlow,
validator,
estApyPct,
Expand All @@ -593,6 +601,7 @@ function AccountKpiBand({
subnet_count: number;
total_stake_tao: number | null;
} | null;
portfolioPhase: StatPhase;
stakeFlow?: { net_flow_tao: number | null; window: string } | null;
validator?: {
total_stake_tao: number;
Expand Down Expand Up @@ -682,12 +691,17 @@ function AccountKpiBand({
<StatTile
icon={Boxes}
eyebrow="Positions"
value={formatNumber(portfolio?.position_count ?? 0)}
hint={
portfolio
value={(() => {
if (portfolioPhase === "pending") return <Skeleton className="h-5 w-10" />;
if (portfolioPhase === "error") return <StatUnavailable />;
return formatNumber(portfolio?.position_count);
})()}
hint={(() => {
if (portfolioPhase !== "ready") return null;
return portfolio
? `across ${formatNumber(portfolio.subnet_count)} subnets`
: "no positions"
}
: "no positions";
})()}
className={KPI_TILE}
/>
<StatTile
Expand Down
26 changes: 19 additions & 7 deletions apps/ui/src/routes/-subnets-index-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
} from "@/components/metagraphed/primitives";
import { AppShell } from "@/components/metagraphed/app-shell";
import { ApiSourceFooter } from "@/components/metagraphed/api-source-footer";
import { EmptyState } from "@/components/metagraphed/states";
import { EmptyState, Skeleton, StatUnavailable } from "@/components/metagraphed/states";
import { statPhase } from "@/lib/metagraphed/stat-phase";
import {
BrandIcon,
prefetchBrandIcon,
Expand Down Expand Up @@ -344,12 +345,24 @@ function SubnetsCompactStats() {
// table's Total stake column reads, so the masthead figure and the column
// total can never diverge from a second source.
const economicsRes = useQuery(economicsQuery());
const totalStake = (economicsRes.data?.data ?? []).reduce(
(sum, e) => sum + (e.total_stake_tao ?? 0),
0,
);
const economicsPhase = statPhase(economicsRes);
const economicsRows = economicsRes.data?.data ?? [];
const totalStake = economicsRows.reduce((sum, e) => sum + (e.total_stake_tao ?? 0), 0);
const generatedAt = coverageRes.data.meta?.generated_at ?? healthRes.data.meta?.generated_at;
const stale = isStaleFreshness(generatedAt);
const totalStakeFigure =
economicsPhase === "pending" ? (
<Skeleton className="h-4 w-16" />
) : economicsPhase === "error" ? (
<StatUnavailable />
) : economicsRows.length === 0 ? (
"—"
) : (
<>
<Coins className="size-3.5 text-accent" aria-hidden />
{formatTao(totalStake)}
</>
);
return (
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 mg-type-data text-ink-muted">
<span>
Expand All @@ -371,8 +384,7 @@ function SubnetsCompactStats() {
<span>
Total stake{" "}
<span className="inline-flex items-center gap-1 font-medium text-ink-strong">
<Coins className="size-3.5 text-accent" aria-hidden />
{formatTao(totalStake)}
{totalStakeFigure}
</span>
</span>
{stale ? (
Expand Down
61 changes: 47 additions & 14 deletions apps/ui/src/routes/-subnets-netuid-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
} from "@/components/metagraphed/primitives";
import { AddressDisplay } from "@/components/metagraphed/address-display";
import { AppShell } from "@/components/metagraphed/app-shell";
import { EmptyState, Skeleton, RECOVERY } from "@/components/metagraphed/states";
import { EmptyState, Skeleton, StatUnavailable, RECOVERY } from "@/components/metagraphed/states";
import { statPhase } from "@/lib/metagraphed/stat-phase";
import { QueryErrorBoundary } from "@/components/metagraphed/error-boundary";
import { EvidencePanel } from "@/components/metagraphed/evidence-panel";
import { ProfileTabs, useActiveTab } from "@/components/metagraphed/profile-tabs";
Expand Down Expand Up @@ -1181,11 +1182,19 @@ function ActivityPanel({ netuid }: { netuid: number }) {
const TOP_CATEGORY_COUNT = 3;

function ActivityEventRollup({ netuid }: { netuid: number }) {
const { data: summaryRes } = useQuery(subnetEventSummaryQuery(netuid));
const { data: axonRes } = useQuery(subnetAxonRemovalsQuery(netuid));
const summary = summaryRes?.data;
const axon = axonRes?.data;
if (!summary && !axon) return null;
const summaryResult = useQuery(subnetEventSummaryQuery(netuid));
const axonResult = useQuery(subnetAxonRemovalsQuery(netuid));
const summaryPhase = statPhase(summaryResult);
const axonPhase = statPhase(axonResult);
const summary = summaryResult.data?.data;
const axon = axonResult.data?.data;

// Both still pending with no data yet — wait. Once either settles (ready or
// error), render the strip so a dual failure shows four Unavailable tiles
// instead of vanishing (#8818).
if (summaryPhase === "pending" && axonPhase === "pending" && !summary && !axon) {
return null;
}

const categories = summary?.categories ?? [];
const topCategories = [...categories]
Expand All @@ -1196,34 +1205,58 @@ function ActivityEventRollup({ netuid }: { netuid: number }) {
const totalTao = categories.reduce((sum, c) => sum + c.amount_tao, 0);
const totalAlpha = categories.reduce((sum, c) => sum + c.alpha_amount, 0);

const summaryValue = (ready: ReactNode) =>
summaryPhase === "pending" ? (
<Skeleton className="h-5 w-12" />
) : summaryPhase === "error" ? (
<StatUnavailable />
) : (
ready
);

const axonValue =
axonPhase === "pending" ? (
<Skeleton className="h-5 w-12" />
) : axonPhase === "error" ? (
<StatUnavailable />
) : (
formatNumber(axon?.removals)
);

return (
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatTile
icon={Activity}
eyebrow="Events"
value={formatNumber(summary?.total_events ?? 0)}
hint={summary ? `over ${summary.window}` : "—"}
value={summaryValue(formatNumber(summary?.total_events))}
hint={summaryPhase === "ready" && summary ? `over ${summary.window}` : "—"}
tooltip="Total decoded chain events for this subnet in the window."
/>
<StatTile
icon={Layers}
eyebrow="Kinds / categories"
value={`${formatNumber(summary?.kind_count ?? 0)} / ${formatNumber(summary?.category_count ?? 0)}`}
hint={topCategories || "—"}
value={summaryValue(
`${formatNumber(summary?.kind_count)} / ${formatNumber(summary?.category_count)}`,
)}
hint={summaryPhase === "ready" ? topCategories || "—" : "—"}
tooltip="Distinct event kinds and categories seen, with the top categories by volume."
/>
<StatTile
icon={Coins}
eyebrow="TAO / alpha moved"
value={`${taoCompact(totalTao)} τ`}
hint={`${taoCompact(totalAlpha)} α`}
value={summaryValue(`${taoCompact(totalTao)} τ`)}
hint={summaryPhase === "ready" ? `${taoCompact(totalAlpha)} α` : "—"}
tooltip="Summed TAO and alpha amounts across all categorized events in the window."
/>
<StatTile
icon={UserMinus}
eyebrow="Axon removals"
value={formatNumber(axon?.removals ?? 0)}
hint={axon ? `${formatNumber(axon.distinct_removers)} removers · ${axon.window}` : "—"}
value={axonValue}
hint={
axonPhase === "ready" && axon
? `${formatNumber(axon.distinct_removers)} removers · ${axon.window}`
: "—"
}
tooltip="AxonInfoRemoved events and distinct removing hotkeys over the window."
/>
</div>
Expand Down
13 changes: 10 additions & 3 deletions apps/ui/src/routes/-sudo-index-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import { Panel } from "@/components/metagraphed/primitives";
import { AddressDisplay } from "@/components/metagraphed/address-display";
import { CallModuleExtrinsicsTable } from "@/components/metagraphed/call-module-extrinsics-table";
import { StatUnavailable } from "@/components/metagraphed/states";
import { sudoCallsQuery, sudoKeyQuery } from "@/lib/metagraphed/queries";
import { statPhase } from "@/lib/metagraphed/stat-phase";
import { API_BASE } from "@/lib/metagraphed/config";
import type { GovernanceSearch } from "./chain.governance";

export function sudoQueryParams(search: GovernanceSearch): Record<string, string | number> {

Check warning on line 13 in apps/ui/src/routes/-sudo-index-page.tsx

View workflow job for this annotation

GitHub Actions / ui

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const queryParams: Record<string, string | number> = {
limit: search.limit,
offset: search.offset,
Expand All @@ -31,6 +33,7 @@
*/
export function SudoKeyCard() {
const keyResult = useQuery(sudoKeyQuery());
const phase = statPhase(keyResult);
const hotkey = keyResult.data?.data.hotkey;
const queriedAt = keyResult.data?.data.queried_at;

Expand All @@ -39,18 +42,22 @@
<div className="mg-label">Current Sudo key</div>
<div className="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-mono mg-type-data text-ink-strong">
{keyResult.isPending ? (
{phase === "pending" ? (
<span className="text-ink-muted">…</span>
) : phase === "error" ? (
<StatUnavailable />
) : hotkey ? (
<AddressDisplay ss58={hotkey} fallback={<>{hotkey}</>} keep={8} linkToAccount={false} />
) : (
<span>Unset</span>
<span title="Root key has been renounced on-chain">Unset</span>
)}
</span>
{queriedAt ? (
{phase === "ready" && queriedAt ? (
<span className="mg-type-caption text-ink-muted">
queried <TimeAgo at={queriedAt} />
</span>
) : phase === "ready" && !hotkey ? (
<span className="mg-type-caption text-ink-muted">root key renounced</span>
) : null}
</div>
</Panel>
Expand Down
23 changes: 23 additions & 0 deletions apps/ui/src/routes/accounts-positions-kpi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

// #8818: Positions KPI must not fabricate "0" / "no positions" from a failed
// accountPortfolioQuery. Node-environment source assertions (same convention
// as subnets-total-stake-tile.test.ts).
const source = readFileSync(
fileURLToPath(new URL("./-accounts-ss58-page.tsx", import.meta.url)),
"utf8",
);

describe("accounts.$ss58 Positions KPI (#8818)", () => {
it("no longer fabricates position_count via ?? 0", () => {
expect(source).not.toContain("portfolio?.position_count ?? 0");
});

it("phases the Positions tile through statPhase + StatUnavailable", () => {
expect(source).toContain("statPhase(portfolioResult)");
expect(source).toContain("portfolioPhase");
expect(source).toContain("StatUnavailable");
});
});
29 changes: 29 additions & 0 deletions apps/ui/src/routes/subnets-activity-rollup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

// #8818: ActivityEventRollup must phase each tile from its own query so a
// failed summary cannot paint Events/Kinds/TAO as 0 beside a real axon figure.
const source = readFileSync(
fileURLToPath(new URL("./-subnets-netuid-page.tsx", import.meta.url)),
"utf8",
);

const rollup = source.slice(
source.indexOf("function ActivityEventRollup"),
source.indexOf("// Subnets have no per-subnet event_kinds"),
);

describe("subnets.$netuid ActivityEventRollup (#8818)", () => {
it("no longer fabricates event counts via ?? 0", () => {
expect(rollup).not.toContain("summary?.total_events ?? 0");
expect(rollup).not.toContain("summary?.kind_count ?? 0");
expect(rollup).not.toContain("axon?.removals ?? 0");
});

it("phases summary and axon tiles independently via statPhase + StatUnavailable", () => {
expect(rollup).toContain("statPhase(summaryResult)");
expect(rollup).toContain("statPhase(axonResult)");
expect(rollup).toContain("StatUnavailable");
});
});
18 changes: 18 additions & 0 deletions apps/ui/src/routes/subnets-total-stake-tile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ describe("subnets.index.tsx compact masthead stats (post-#8248)", () => {
expect(strip).toContain("formatTao(totalStake)");
});

it("phases Total stake via statPhase so a failed economics query cannot fabricate 0 τ (#8818)", () => {
expect(strip).toContain("statPhase(economicsRes)");
expect(strip).toContain("StatUnavailable");
expect(strip).toContain("economicsRows.length === 0 ? (");
// formatTao must sit behind the ready branch, not on an unguarded path
expect(strip).toMatch(/economicsPhase === "error"[\s\S]*formatTao\(totalStake\)/);
// Coins glyph only accompanies a real figure — never beside StatUnavailable
// (desktop/tablet double-icon look that closed #8933).
expect(strip).toMatch(
/economicsPhase === "error"[\s\S]*<>\s*<Coins[\s\S]*formatTao\(totalStake\)/,
);
const errorBranch = strip.slice(
strip.indexOf('economicsPhase === "error"'),
strip.indexOf("economicsRows.length === 0"),
);
expect(errorBranch).not.toContain("<Coins");
});

it("caps the masthead at Active / Healthy / Total stake, plus a freshness chip shown only when stale", () => {
expect(strip).toContain("active");
expect(strip).toContain("Healthy");
Expand Down
29 changes: 29 additions & 0 deletions apps/ui/src/routes/sudo-key-card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

// #8818: SudoKeyCard must not claim "Unset" when sudoKeyQuery failed.
const source = readFileSync(
fileURLToPath(new URL("./-sudo-index-page.tsx", import.meta.url)),
"utf8",
);

const card = source.slice(
source.indexOf("export function SudoKeyCard"),
source.indexOf("export function SudoIndexPage") === -1
? source.length
: source.indexOf("export function SudoIndexPage"),
);

describe("sudo SudoKeyCard (#8818)", () => {
it("phases the key through statPhase and renders StatUnavailable on error", () => {
expect(card).toContain("statPhase(keyResult)");
expect(card).toContain("StatUnavailable");
expect(card).toContain('phase === "error"');
});

it("only reaches Unset on a ready response with a nullish hotkey", () => {
expect(card).toMatch(/phase === "error"[\s\S]*Unset/);
expect(card).toContain("root key renounced");
});
});
Loading