From b871080e36aa528a4659bab581a37d09aa33bb63 Mon Sep 17 00:00:00 2001 From: kurosawareiji7007-hub Date: Fri, 31 Jul 2026 06:14:12 -0700 Subject: [PATCH] fix(ui): show StatUnavailable when KPI source queries fail (#8818) Phase Total stake, Positions, ActivityEventRollup, and Sudo key through statPhase so a failed request cannot fabricate 0 / no positions / Unset. Pass portfolioPhase into AccountKpiBand. Size StatUnavailable with h-[1em] so error tiles match neighbouring KPI value-line height. --- apps/ui/src/components/metagraphed/states.tsx | 9 ++- apps/ui/src/routes/-accounts-ss58-page.tsx | 26 ++++++-- apps/ui/src/routes/-subnets-index-page.tsx | 22 +++++-- apps/ui/src/routes/-subnets-netuid-page.tsx | 61 ++++++++++++++----- apps/ui/src/routes/-sudo-index-page.tsx | 13 +++- .../src/routes/accounts-positions-kpi.test.ts | 23 +++++++ .../routes/subnets-activity-rollup.test.ts | 29 +++++++++ .../routes/subnets-total-stake-tile.test.ts | 8 +++ apps/ui/src/routes/sudo-key-card.test.ts | 29 +++++++++ 9 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 apps/ui/src/routes/accounts-positions-kpi.test.ts create mode 100644 apps/ui/src/routes/subnets-activity-rollup.test.ts create mode 100644 apps/ui/src/routes/sudo-key-card.test.ts diff --git a/apps/ui/src/components/metagraphed/states.tsx b/apps/ui/src/components/metagraphed/states.tsx index e28a45ad5f..94038c9db2 100644 --- a/apps/ui/src/components/metagraphed/states.tsx +++ b/apps/ui/src/components/metagraphed/states.tsx @@ -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 ( - - Unavailable + + + Unavailable ); } diff --git a/apps/ui/src/routes/-accounts-ss58-page.tsx b/apps/ui/src/routes/-accounts-ss58-page.tsx index f34bb652c8..54c32a6ed5 100644 --- a/apps/ui/src/routes/-accounts-ss58-page.tsx +++ b/apps/ui/src/routes/-accounts-ss58-page.tsx @@ -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"; @@ -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} @@ -575,6 +582,7 @@ function AccountKpiBand({ balanceImplausible, onRetryBalance, portfolio, + portfolioPhase, stakeFlow, validator, estApyPct, @@ -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; @@ -682,12 +691,17 @@ function AccountKpiBand({ { + if (portfolioPhase === "pending") return ; + if (portfolioPhase === "error") return ; + 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} /> 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" ? ( + + ) : economicsPhase === "error" ? ( + + ) : economicsRows.length === 0 ? ( + "—" + ) : ( + formatTao(totalStake) + ); return (
@@ -372,7 +382,7 @@ function SubnetsCompactStats() { Total stake{" "} - {formatTao(totalStake)} + {totalStakeFigure} {stale ? ( diff --git a/apps/ui/src/routes/-subnets-netuid-page.tsx b/apps/ui/src/routes/-subnets-netuid-page.tsx index 1e1a364a11..e6d1f14e43 100644 --- a/apps/ui/src/routes/-subnets-netuid-page.tsx +++ b/apps/ui/src/routes/-subnets-netuid-page.tsx @@ -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"; @@ -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] @@ -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" ? ( + + ) : summaryPhase === "error" ? ( + + ) : ( + ready + ); + + const axonValue = + axonPhase === "pending" ? ( + + ) : axonPhase === "error" ? ( + + ) : ( + formatNumber(axon?.removals) + ); + return (
diff --git a/apps/ui/src/routes/-sudo-index-page.tsx b/apps/ui/src/routes/-sudo-index-page.tsx index bfde574e46..00cf4c7405 100644 --- a/apps/ui/src/routes/-sudo-index-page.tsx +++ b/apps/ui/src/routes/-sudo-index-page.tsx @@ -4,7 +4,9 @@ import { TimeAgo } from "@jsonbored/ui-kit"; 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"; @@ -31,6 +33,7 @@ export function sudoQueryParams(search: GovernanceSearch): RecordCurrent Sudo key
- {keyResult.isPending ? ( + {phase === "pending" ? ( + ) : phase === "error" ? ( + ) : hotkey ? ( {hotkey}} keep={8} linkToAccount={false} /> ) : ( - Unset + Unset )} - {queriedAt ? ( + {phase === "ready" && queriedAt ? ( queried + ) : phase === "ready" && !hotkey ? ( + root key renounced ) : null}
diff --git a/apps/ui/src/routes/accounts-positions-kpi.test.ts b/apps/ui/src/routes/accounts-positions-kpi.test.ts new file mode 100644 index 0000000000..5e30f0f627 --- /dev/null +++ b/apps/ui/src/routes/accounts-positions-kpi.test.ts @@ -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"); + }); +}); diff --git a/apps/ui/src/routes/subnets-activity-rollup.test.ts b/apps/ui/src/routes/subnets-activity-rollup.test.ts new file mode 100644 index 0000000000..e958276e7a --- /dev/null +++ b/apps/ui/src/routes/subnets-activity-rollup.test.ts @@ -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"); + }); +}); diff --git a/apps/ui/src/routes/subnets-total-stake-tile.test.ts b/apps/ui/src/routes/subnets-total-stake-tile.test.ts index 94c3b547e4..db8f863627 100644 --- a/apps/ui/src/routes/subnets-total-stake-tile.test.ts +++ b/apps/ui/src/routes/subnets-total-stake-tile.test.ts @@ -46,6 +46,14 @@ 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\)/); + }); + 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"); diff --git a/apps/ui/src/routes/sudo-key-card.test.ts b/apps/ui/src/routes/sudo-key-card.test.ts new file mode 100644 index 0000000000..d23acb622e --- /dev/null +++ b/apps/ui/src/routes/sudo-key-card.test.ts @@ -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"); + }); +});