diff --git a/src/components/metagraphed/nav-mega-menu-data.ts b/src/components/metagraphed/nav-mega-menu-data.ts index 1960a4d..ed7f9bc 100644 --- a/src/components/metagraphed/nav-mega-menu-data.ts +++ b/src/components/metagraphed/nav-mega-menu-data.ts @@ -60,6 +60,11 @@ export const MEGA_PANELS: MegaPanel[] = [ blurb: "Recent blocks indexed directly from the chain.", apiPath: "/api/v1/blocks", browse: [ + { + to: "/explorer", + label: "Chain explorer", + hint: "Network at a glance — activity, fees, top accounts", + }, { to: "/blocks", label: "Recent blocks", hint: "Newest first" }, { to: "/blocks", search: { limit: "100" }, label: "100 per page" }, { to: "/extrinsics", label: "Extrinsics", hint: "Transactions, newest first" }, diff --git a/src/lib/metagraphed/queries.ts b/src/lib/metagraphed/queries.ts index 94e3d47..c149bd0 100644 --- a/src/lib/metagraphed/queries.ts +++ b/src/lib/metagraphed/queries.ts @@ -16,6 +16,10 @@ import type { AccountRegistration, AccountSummary, Block, + ChainActivity, + ChainCalls, + ChainFees, + ChainSigners, Extrinsic, ExtrinsicCallArg, Transfer, @@ -1363,6 +1367,113 @@ export const accountTransfersQuery = (ss58: string, params?: QueryParams) => staleTime: STALE_SHORT, }); +// ---- Chain analytics dashboard (#266, epic #1986) ------------------------- +// Display-only views over the live /api/v1/chain/* aggregates. Each guards only +// the array containers + the window stamp; the row shapes are our own backend's. + +type ChainWindow = "7d" | "30d"; + +export const chainActivityQuery = (window: ChainWindow = "7d") => + queryOptions({ + queryKey: k("chain-activity", window), + queryFn: async ({ signal }) => { + const res = await apiFetch("/api/v1/chain/activity", { + params: { window }, + signal, + }); + const d = isRecord(res.data) ? res.data : {}; + return { + data: { + schema_version: 1, + window, + observed_at: firstString(d.observed_at) ?? null, + day_count: firstFiniteNumber(d.day_count) ?? 0, + days: Array.isArray(d.days) ? (d.days as ChainActivity["days"]) : [], + } as ChainActivity, + meta: res.meta, + url: res.url, + } as ApiResult; + }, + staleTime: STALE_SHORT, + }); + +export const chainCallsQuery = (window: ChainWindow = "7d") => + queryOptions({ + queryKey: k("chain-calls", window), + queryFn: async ({ signal }) => { + const res = await apiFetch("/api/v1/chain/calls", { + params: { window, limit: 12 }, + signal, + }); + const d = isRecord(res.data) ? res.data : {}; + return { + data: { + schema_version: 1, + window, + group_by: firstString(d.group_by) ?? "module", + observed_at: firstString(d.observed_at) ?? null, + total_extrinsics: firstFiniteNumber(d.total_extrinsics) ?? 0, + call_count: firstFiniteNumber(d.call_count) ?? 0, + calls: Array.isArray(d.calls) ? (d.calls as ChainCalls["calls"]) : [], + } as ChainCalls, + meta: res.meta, + url: res.url, + } as ApiResult; + }, + staleTime: STALE_SHORT, + }); + +export const chainSignersQuery = (window: ChainWindow = "7d") => + queryOptions({ + queryKey: k("chain-signers", window), + queryFn: async ({ signal }) => { + const res = await apiFetch("/api/v1/chain/signers", { + params: { window, limit: 20 }, + signal, + }); + const d = isRecord(res.data) ? res.data : {}; + return { + data: { + schema_version: 1, + window, + observed_at: firstString(d.observed_at) ?? null, + signer_count: firstFiniteNumber(d.signer_count) ?? 0, + signers: Array.isArray(d.signers) ? (d.signers as ChainSigners["signers"]) : [], + } as ChainSigners, + meta: res.meta, + url: res.url, + } as ApiResult; + }, + staleTime: STALE_SHORT, + }); + +export const chainFeesQuery = (window: ChainWindow = "7d") => + queryOptions({ + queryKey: k("chain-fees", window), + queryFn: async ({ signal }) => { + const res = await apiFetch("/api/v1/chain/fees", { + params: { window, limit: 12 }, + signal, + }); + const d = isRecord(res.data) ? res.data : {}; + return { + data: { + schema_version: 1, + window, + observed_at: firstString(d.observed_at) ?? null, + day_count: firstFiniteNumber(d.day_count) ?? 0, + daily: Array.isArray(d.daily) ? (d.daily as ChainFees["daily"]) : [], + top_fee_payers: Array.isArray(d.top_fee_payers) + ? (d.top_fee_payers as ChainFees["top_fee_payers"]) + : [], + } as ChainFees, + meta: res.meta, + url: res.url, + } as ApiResult; + }, + staleTime: STALE_SHORT, + }); + const READINESS_COMPONENT_KEYS = [ "has_callable_api", "callable_now", diff --git a/src/lib/metagraphed/types.ts b/src/lib/metagraphed/types.ts index efc7f24..c6e4cb8 100644 --- a/src/lib/metagraphed/types.ts +++ b/src/lib/metagraphed/types.ts @@ -1082,3 +1082,73 @@ export const __contractAssertions = { surfaceWireHasAuthority: true as _SurfaceWireHasAuthority, healthSurfaceStatusIsHealthStatus: true as _HealthSurfaceStatusIsHealthStatus, } satisfies Record; + +// ---- Chain analytics dashboard (epic #1986) ------------------------------- + +export interface ChainActivityDay { + day: string; + block_count: number; + extrinsic_count: number; + event_count: number; + successful_extrinsics: number; + success_rate: number | null; + unique_signers: number; +} +export interface ChainActivity { + schema_version: number; + window: string; + observed_at: string | null; + day_count: number; + days: ChainActivityDay[]; +} +export interface ChainCallEntry { + call_module: string; + call_function: string | null; + count: number; + share: number | null; +} +export interface ChainCalls { + schema_version: number; + window: string; + group_by: string; + observed_at: string | null; + total_extrinsics: number; + call_count: number; + calls: ChainCallEntry[]; +} +export interface ChainSignerEntry { + signer: string; + tx_count: number; + total_fee_tao: number; + total_tip_tao: number; + last_tx_block: number | null; +} +export interface ChainSigners { + schema_version: number; + window: string; + observed_at: string | null; + signer_count: number; + signers: ChainSignerEntry[]; +} +export interface ChainFeeDay { + day: string; + extrinsic_count: number; + total_fee_tao: number; + avg_fee_tao: number | null; + total_tip_tao: number; + avg_tip_tao: number | null; +} +export interface ChainFeePayer { + signer: string; + total_fee_tao: number; + total_tip_tao: number; + extrinsic_count: number; +} +export interface ChainFees { + schema_version: number; + window: string; + observed_at: string | null; + day_count: number; + daily: ChainFeeDay[]; + top_fee_payers: ChainFeePayer[]; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 762049f..ae424c8 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as StatusRouteImport } from './routes/status' import { Route as SchemasRouteImport } from './routes/schemas' import { Route as HealthRouteImport } from './routes/health' import { Route as GapsRouteImport } from './routes/gaps' +import { Route as ExplorerRouteImport } from './routes/explorer' import { Route as EndpointsRouteImport } from './routes/endpoints' import { Route as AgentsRouteImport } from './routes/agents' import { Route as AboutRouteImport } from './routes/about' @@ -54,6 +55,11 @@ const GapsRoute = GapsRouteImport.update({ path: '/gaps', getParentRoute: () => rootRouteImport, } as any) +const ExplorerRoute = ExplorerRouteImport.update({ + id: '/explorer', + path: '/explorer', + getParentRoute: () => rootRouteImport, +} as any) const EndpointsRoute = EndpointsRouteImport.update({ id: '/endpoints', path: '/endpoints', @@ -130,6 +136,7 @@ export interface FileRoutesByFullPath { '/about': typeof AboutRoute '/agents': typeof AgentsRoute '/endpoints': typeof EndpointsRoute + '/explorer': typeof ExplorerRoute '/gaps': typeof GapsRoute '/health': typeof HealthRoute '/schemas': typeof SchemasRoute @@ -151,6 +158,7 @@ export interface FileRoutesByTo { '/about': typeof AboutRoute '/agents': typeof AgentsRoute '/endpoints': typeof EndpointsRoute + '/explorer': typeof ExplorerRoute '/gaps': typeof GapsRoute '/health': typeof HealthRoute '/schemas': typeof SchemasRoute @@ -173,6 +181,7 @@ export interface FileRoutesById { '/about': typeof AboutRoute '/agents': typeof AgentsRoute '/endpoints': typeof EndpointsRoute + '/explorer': typeof ExplorerRoute '/gaps': typeof GapsRoute '/health': typeof HealthRoute '/schemas': typeof SchemasRoute @@ -196,6 +205,7 @@ export interface FileRouteTypes { | '/about' | '/agents' | '/endpoints' + | '/explorer' | '/gaps' | '/health' | '/schemas' @@ -217,6 +227,7 @@ export interface FileRouteTypes { | '/about' | '/agents' | '/endpoints' + | '/explorer' | '/gaps' | '/health' | '/schemas' @@ -238,6 +249,7 @@ export interface FileRouteTypes { | '/about' | '/agents' | '/endpoints' + | '/explorer' | '/gaps' | '/health' | '/schemas' @@ -260,6 +272,7 @@ export interface RootRouteChildren { AboutRoute: typeof AboutRoute AgentsRoute: typeof AgentsRoute EndpointsRoute: typeof EndpointsRoute + ExplorerRoute: typeof ExplorerRoute GapsRoute: typeof GapsRoute HealthRoute: typeof HealthRoute SchemasRoute: typeof SchemasRoute @@ -314,6 +327,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GapsRouteImport parentRoute: typeof rootRouteImport } + '/explorer': { + id: '/explorer' + path: '/explorer' + fullPath: '/explorer' + preLoaderRoute: typeof ExplorerRouteImport + parentRoute: typeof rootRouteImport + } '/endpoints': { id: '/endpoints' path: '/endpoints' @@ -420,6 +440,7 @@ const rootRouteChildren: RootRouteChildren = { AboutRoute: AboutRoute, AgentsRoute: AgentsRoute, EndpointsRoute: EndpointsRoute, + ExplorerRoute: ExplorerRoute, GapsRoute: GapsRoute, HealthRoute: HealthRoute, SchemasRoute: SchemasRoute, @@ -439,13 +460,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { startInstance } from './start.ts' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - config: Awaited> - } -} diff --git a/src/routes/explorer.tsx b/src/routes/explorer.tsx new file mode 100644 index 0000000..a58c156 --- /dev/null +++ b/src/routes/explorer.tsx @@ -0,0 +1,249 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Suspense } from "react"; +import { z } from "zod"; +import { fallback, zodValidator } from "@tanstack/zod-adapter"; +import { Activity, Boxes, Coins, Layers, Zap } from "lucide-react"; +import { AppShell } from "@/components/metagraphed/app-shell"; +import { PageHero } from "@/components/metagraphed/page-hero"; +import { ApiSourceFooter } from "@/components/metagraphed/api-source-footer"; +import { Skeleton } from "@/components/metagraphed/states"; +import { ShareButton } from "@/components/metagraphed/share-button"; +import { QueryErrorBoundary } from "@/components/metagraphed/error-boundary"; +import { StatTile } from "@/components/metagraphed/charts/stat-tile"; +import { Sparkline } from "@/components/metagraphed/charts/sparkline"; +import { BarMini } from "@/components/metagraphed/charts/bar-mini"; +import { + chainActivityQuery, + chainCallsQuery, + chainFeesQuery, + chainSignersQuery, +} from "@/lib/metagraphed/queries"; +import { formatNumber } from "@/lib/metagraphed/format"; +import { shortHash } from "@/lib/metagraphed/blocks"; + +const explorerSearchSchema = z.object({ + window: fallback(z.enum(["7d", "30d"]), "7d").default("7d"), +}); + +export const Route = createFileRoute("/explorer")({ + validateSearch: zodValidator(explorerSearchSchema), + head: () => ({ + meta: [ + { title: "Chain explorer — Metagraphed" }, + { + name: "description", + content: + "Bittensor network at a glance: daily extrinsic/block/event activity, fees, call mix, and the most active accounts — chain-direct analytics.", + }, + { property: "og:title", content: "Chain explorer — Metagraphed" }, + { + property: "og:description", + content: + "Bittensor network at a glance: daily activity, fees, call mix, and the most active accounts.", + }, + ], + }), + component: ExplorerPage, +}); + +function sum(values: number[]): number { + return values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0); +} + +function fmtTao(v: number): string { + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M τ`; + if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k τ`; + if (v >= 1) return `${v.toFixed(2)} τ`; + return `${v.toFixed(4)} τ`; +} + +function ExplorerPage() { + return ( + + } + /> + + }> + + + + + + ); +} + +const TH = "px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-muted"; + +function ExplorerDashboard() { + const search = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + const win = search.window; + + const activity = useSuspenseQuery(chainActivityQuery(win)).data.data; + const fees = useSuspenseQuery(chainFeesQuery(win)).data.data; + const calls = useSuspenseQuery(chainCallsQuery(win)).data.data; + const signers = useSuspenseQuery(chainSignersQuery(win)).data.data; + + // The API returns newest-day-first; sparkline wants chronological order. + const chrono = [...activity.days].reverse(); + const totalExtrinsics = sum(activity.days.map((d) => d.extrinsic_count)); + const totalBlocks = sum(activity.days.map((d) => d.block_count)); + const totalEvents = sum(activity.days.map((d) => d.event_count)); + const totalSuccessful = sum(activity.days.map((d) => d.successful_extrinsics)); + const successRate = totalExtrinsics > 0 ? totalSuccessful / totalExtrinsics : null; + const totalFees = sum(fees.daily.map((d) => d.total_fee_tao)); + + return ( +
+ {/* window toggle */} +
+ {(["7d", "30d"] as const).map((w) => ( + + ))} +
+ + {/* KPI tiles */} +
+ + + + + +
+ + {/* daily activity sparkline */} +
+
+

+ Daily extrinsics +

+ {activity.day_count} days +
+ {chrono.length > 0 ? ( + d.extrinsic_count)} + width={640} + height={64} + ariaLabel="Daily extrinsic count" + formatValue={(v) => formatNumber(v)} + /> + ) : ( +

+ No activity indexed yet — the chain poller fills this every few minutes. +

+ )} +
+ +
+ {/* call mix */} +
+
+

+ Call mix +

+ + {formatNumber(calls.total_extrinsics)} calls + +
+ {calls.calls.length > 0 ? ( + ({ + label: c.call_module, + value: c.count, + }))} + /> + ) : ( +

No calls yet.

+ )} +
+ + {/* top signers */} +
+

+ Most active accounts +

+ {signers.signers.length > 0 ? ( + + + + + + + + + + {signers.signers.slice(0, 12).map((s) => ( + + + + + + ))} + +
AccountTxsFees
+ + {shortHash(s.signer)} + + + {formatNumber(s.tx_count)} + + {fmtTao(s.total_fee_tao)} +
+ ) : ( +

No signers in this window yet.

+ )} +
+
+
+ ); +}