From d7ec471a14f971c6901119814d677c730db81c64 Mon Sep 17 00:00:00 2001 From: Victor Oliva Date: Tue, 23 Jun 2026 13:25:57 +0200 Subject: [PATCH] feat: new chain metrics page (#159) * playground * playground tx and weights * vibe UI * chart improvements * wip fix ai slop * wip fix ai slop * fix ai slop on charts * subscribe to main state * show blocks in chronological order, fix performance issue on startup * update node health * add connected peers chart * fix unused locals --- package.json | 1 + pnpm-lock.yaml | 8 + src/App.tsx | 4 +- src/lib/externalState.ts | 12 +- src/main.tsx | 7 +- src/pages/AppShell.tsx | 7 + src/pages/Explorer/BlockPopover.tsx | 31 +-- src/pages/Metrics/DataChart.tsx | 235 +++++++++++++++++++ src/pages/Metrics/ForkChart.tsx | 131 +++++++++++ src/pages/Metrics/MetricCards.tsx | 267 +++++++++++++++++++++ src/pages/Metrics/Metrics.tsx | 62 +++++ src/pages/Metrics/NodeHealth.tsx | 125 ++++++++++ src/pages/Metrics/RecentBlocks.tsx | 82 +++++++ src/pages/Metrics/format.ts | 30 +++ src/pages/Metrics/metrics.state.ts | 350 ++++++++++++++++++++++++++++ src/state/identity.state.ts | 4 +- tsconfig.app.json | 4 +- 17 files changed, 1340 insertions(+), 20 deletions(-) create mode 100644 src/pages/Metrics/DataChart.tsx create mode 100644 src/pages/Metrics/ForkChart.tsx create mode 100644 src/pages/Metrics/MetricCards.tsx create mode 100644 src/pages/Metrics/Metrics.tsx create mode 100644 src/pages/Metrics/NodeHealth.tsx create mode 100644 src/pages/Metrics/RecentBlocks.tsx create mode 100644 src/pages/Metrics/format.ts create mode 100644 src/pages/Metrics/metrics.state.ts diff --git a/package.json b/package.json index 9be01d7..6d3b189 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "save-as": "^0.1.8", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", + "uplot": "^1.6.32", "uuid": "^14.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdd1a54..7249ff0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.3.1) + uplot: + specifier: ^1.6.32 + version: 1.6.32 uuid: specifier: ^14.0.0 version: 14.0.0 @@ -4013,6 +4016,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uplot@1.6.32: + resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -9183,6 +9189,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uplot@1.6.32: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/src/App.tsx b/src/App.tsx index 2d766ad..8ffbaf1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { VaultTxModal } from "polkahub" +import { lazy } from "react" import { Navigate, Route, Routes } from "react-router-dom" import { ConnectionState } from "./components/ConnectionState" import { Accounts } from "./pages/Accounts/Accounts" @@ -11,9 +12,9 @@ import { RpcCalls } from "./pages/RpcCalls" import { RuntimeCalls } from "./pages/RuntimeCalls" import { Storage } from "./pages/Storage" import { ViewFns } from "./pages/ViewFns" -import { lazy } from "react" const Teleport = lazy(() => import("./pages/Teleport/Teleport")) +const Metrics = lazy(() => import("./pages/Metrics/Metrics")) export default function App() { return ( @@ -30,6 +31,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/lib/externalState.ts b/src/lib/externalState.ts index f515106..9b7568e 100644 --- a/src/lib/externalState.ts +++ b/src/lib/externalState.ts @@ -1,7 +1,7 @@ import { state } from "@react-rxjs/core" import { createSignal } from "@react-rxjs/utils" import { useState } from "react" -import { map } from "rxjs" +import { defer, from, map, mergeAll, Observable, retry, timer } from "rxjs" import { getHashParams, setHashParams } from "../hashParams" interface Parser { @@ -131,3 +131,13 @@ export const createState = (defaultValue: T) => { return [state$, setValue] as const } + +export const codeSplit$ = (fetch: () => Promise>) => + defer(() => + from(fetch()).pipe( + retry({ + delay: () => timer(1000), + }), + mergeAll(), + ), + ) diff --git a/src/main.tsx b/src/main.tsx index 6aeb8db..be99382 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,12 +8,17 @@ import { merge } from "rxjs" import App from "./App.tsx" import { TooltipProvider } from "./components/ui/tooltip.tsx" import "./index.css" +import { codeSplit$ } from "./lib/externalState.ts" import { explorer$ } from "./pages/Explorer" import { polkaHub } from "./state/polkahub.ts" import { ThemeProvider } from "./ThemeProvider.tsx" +const metrics$ = codeSplit$(() => + import("./pages/Metrics/metrics.state.ts").then((r) => r.blockStats$), +) + createRoot(document.getElementById("root")!).render( - + diff --git a/src/pages/AppShell.tsx b/src/pages/AppShell.tsx index 976d0e5..a94db1c 100644 --- a/src/pages/AppShell.tsx +++ b/src/pages/AppShell.tsx @@ -12,6 +12,7 @@ import { Link } from "@/hashParams" import { cn } from "@/lib/utils" import { changeTheme, useTheme } from "@/ThemeProvider" import { + Activity, ArrowRightLeft, BookOpenText, Cable, @@ -76,6 +77,12 @@ const navigationGroups: Array<{ // Alt Boxes icon: BookOpenText, }, + { + path: "/metrics", + label: "Metrics", + // Alt Gauge + icon: Activity, + }, { path: "/rpcCalls", label: "RPC Calls", diff --git a/src/pages/Explorer/BlockPopover.tsx b/src/pages/Explorer/BlockPopover.tsx index bba15cf..20198f2 100644 --- a/src/pages/Explorer/BlockPopover.tsx +++ b/src/pages/Explorer/BlockPopover.tsx @@ -2,7 +2,7 @@ import { CircularProgress } from "@/components/CircularProgress" import { CopyText } from "@/components/Copy" import { Link } from "@/hashParams" import { groupBy } from "@/lib/groupBy" -import { blockInfoState$ } from "@/state/block.state" +import { BlockInfo, blockInfoState$ } from "@/state/block.state" import { runtimeCtx$ } from "@/state/chains/chain.state" import { state, useStateObservable } from "@react-rxjs/core" import { FC } from "react" @@ -23,22 +23,14 @@ const maxBlockWeight$ = state( ), null, ) -export const BlockPopover: FC<{ hash: string }> = ({ hash }) => { - const block = useStateObservable(blockInfoState$(hash)) - if (!block) return null - - const eventGroups = block.events - ? groupBy(block.events, (evt) => evt.phase.type) - : null - const filteredEvents = - eventGroups?.["ApplyExtrinsic"]?.filter(filterEvt).length ?? 0 +export const getBlockWeight = (block: BlockInfo) => { const extrinsicEvents = block.events?.filter( (evt) => evt.event.type === "System" && ["ExtrinsicFailed", "ExtrinsicSuccess"].includes(evt.event.value.type), ) - const weight = + return ( extrinsicEvents ?.map( (evt) => @@ -49,6 +41,19 @@ export const BlockPopover: FC<{ hash: string }> = ({ hash }) => { proof_size: 0n, ref_time: 0n, }) ?? null + ) +} +export const BlockPopover: FC<{ hash: string }> = ({ hash }) => { + const block = useStateObservable(blockInfoState$(hash)) + if (!block) return null + + const eventGroups = block.events + ? groupBy(block.events, (evt) => evt.phase.type) + : null + const filteredEvents = + eventGroups?.["ApplyExtrinsic"]?.filter(filterEvt).length ?? 0 + + const weight = getBlockWeight(block) return (
@@ -131,11 +136,11 @@ const WeightLimits: FC<{ weight: Weight | null }> = ({ weight }) => { ) } -type Weight = { +export type Weight = { ref_time: bigint proof_size: bigint } -function addWeight(a: Weight, b: Weight | null): Weight { +export function addWeight(a: Weight, b: Weight | null): Weight { if (!b) { console.warn("some weight is undefined", { a, b }) return a diff --git a/src/pages/Metrics/DataChart.tsx b/src/pages/Metrics/DataChart.tsx new file mode 100644 index 0000000..91f62aa --- /dev/null +++ b/src/pages/Metrics/DataChart.tsx @@ -0,0 +1,235 @@ +import { FC, useEffect, useMemo, useRef } from "react" +import uPlot from "uplot" +import "uplot/dist/uPlot.min.css" + +export function DataChart({ + values, + color, +}: { + values: number[] | null + color: string +}) { + const chart = useMemo(() => { + if (!values) return null + const result = buildChartData(values) + return result + }, [values]) + + if (!chart?.data[0].length) { + return
+ } + + return ( + + ) +} + +export const UPlotChart: FC<{ + data: uPlot.AlignedData + seriesCount: number + primaryLane: number + yRange: [number, number] + color: string + height: number +}> = ({ data, seriesCount, primaryLane, yRange, color, height }) => { + const ref = useRef(null) + const plotRef = useRef(null) + + useEffect(() => { + const element = ref.current + if (!element) return + + const width = Math.max(element.clientWidth, 120) + const plot = new uPlot( + getChartOptions({ + width, + height, + seriesCount, + primaryLane, + yRange, + color, + }), + data, + element, + ) + plotRef.current = plot + + const resizeObserver = new ResizeObserver(([entry]) => { + plot.setSize({ + width: Math.max(entry.contentRect.width, 120), + height, + }) + }) + resizeObserver.observe(element) + + return () => { + resizeObserver.disconnect() + plot.destroy() + plotRef.current = null + } + }, [color, data, height, primaryLane, seriesCount, yRange]) + + return
+} + +const getChartOptions = ({ + width, + height, + seriesCount, + primaryLane, + yRange, + color, +}: { + width: number + height: number + seriesCount: number + primaryLane: number + yRange: [number, number] + color: string +}): uPlot.Options => ({ + width, + height, + legend: { show: false }, + cursor: { show: false }, + scales: { + x: { time: false }, + y: { + range: () => yRange, + }, + }, + axes: [ + { show: false }, + { + side: 1, + size: 34, + gap: 3, + stroke: "rgba(100,116,139,0.75)", + grid: { stroke: "rgba(148,163,184,0.16)", width: 1 }, + ticks: { show: false }, + border: { show: false }, + splits: (_plot, _axisIdx, scaleMin, scaleMax, foundIncr) => + getAxisSplits(scaleMin, scaleMax, foundIncr), + values: (_u, values) => values.map(formatCompactAxisValue), + }, + ], + series: [ + {}, + ...Array.from({ length: seriesCount }, (_, index) => ({ + stroke: index === primaryLane ? color : withAlpha(color, 0.5), + width: index === primaryLane ? 2 : 1.5, + points: { show: false }, + spanGaps: false, + })), + ], +}) + +const buildChartData = (values: number[]) => { + const xValues = values.map((_, i) => i) + + return { + data: [xValues, values] satisfies uPlot.AlignedData, + seriesCount: 1, + primaryLane: 0, + yRange: getYRange(values), + } +} + +const withAlpha = (hex: string, alpha: number) => { + const value = hex.replace("#", "") + const r = Number.parseInt(value.slice(0, 2), 16) + const g = Number.parseInt(value.slice(2, 4), 16) + const b = Number.parseInt(value.slice(4, 6), 16) + return `rgba(${r},${g},${b},${alpha})` +} + +export const getYRange = (values: number[]): [number, number] => { + const sorted = values + .filter((value) => Number.isFinite(value)) + .sort((a, b) => a - b) + + let [min, max] = getOutlierTrimmedBounds(sorted) + if (min == null || max == null) return [0, 1] + + const delta = max - min + const pad = delta > 0 ? delta * 0.12 : Math.max(Math.abs(max) * 0.12, 1) + + min = min >= 0 ? Math.max(0, min - pad) : min - pad + max += pad + + return min === max ? [min - 1, max + 1] : [min, max] +} + +// In general, this is 1.5. However, we're cool with bigger outliers, since we +// still want to keep the chart realistic, but without causing a big-ass value from +// squishing the whole chart +const outlierFactor = 16 +const getOutlierTrimmedBounds = (sorted: number[]) => { + if (sorted.length < 6) return [sorted[0], sorted.at(-1)] + + const getPercentile = (p: number) => { + const idx = sorted.length * p + const diff = idx - Math.floor(idx) + if (diff === 0) return sorted[Math.floor(idx)] + const low = sorted[Math.floor(idx)] + const high = sorted[Math.ceil(idx)] + return low * (1 - diff) + high * diff + } + + const q1 = getPercentile(1 / 4) + const q3 = getPercentile(3 / 4) + const iqr = Math.max(q3 - q1, 1) + const lowBound = q1 - outlierFactor * iqr + const highBound = q3 + outlierFactor * iqr + + const min = sorted.find((v) => v >= lowBound) + const max = sorted.findLast((v) => v <= highBound) + + return [min, max] +} + +const getAxisSplits = (min: number, max: number, increment: number) => { + if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [] + + let currentIncrement = increment + let splits = getSplitsForIncrement(min, max, currentIncrement) + + while (splits.length < 2 && currentIncrement > 0) { + currentIncrement /= 2 + splits = getSplitsForIncrement(min, max, currentIncrement) + } + + return splits +} + +const getSplitsForIncrement = (min: number, max: number, increment: number) => { + if (!Number.isFinite(increment) || increment <= 0) return [] + + const precision = Math.max(0, Math.ceil(-Math.log10(increment)) + 2) + const result: number[] = [] + + for ( + let value = Math.ceil(min / increment) * increment; + value <= max; + value += increment + ) { + result.push(Number(value.toFixed(precision))) + } + + return result +} + +const formatCompactAxisValue = (value: number) => { + const abs = Math.abs(value) + if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m` + if (abs >= 1_000) return `${(value / 1_000).toFixed(1)}k` + if (abs >= 100) return Math.round(value).toLocaleString() + if (abs >= 10) return value.toFixed(0) + return value.toFixed(1) +} diff --git a/src/pages/Metrics/ForkChart.tsx b/src/pages/Metrics/ForkChart.tsx new file mode 100644 index 0000000..21e9c04 --- /dev/null +++ b/src/pages/Metrics/ForkChart.tsx @@ -0,0 +1,131 @@ +import { mapObject } from "polkadot-api/utils" +import { useMemo } from "react" +import uPlot from "uplot" +import { getYRange, UPlotChart } from "./DataChart" +import type { BlockMap } from "./metrics.state" +import { WINDOW_SIZE } from "./metrics.state" + +export function ForkChart({ + blocks, + select, + color, +}: { + blocks: BlockMap | null + select: (block: T) => number | null + color: string +}) { + const chart = useMemo(() => { + if (!blocks) return null + const result = buildForkChartData(blocks, select) + return result + }, [blocks, select]) + + if (!chart?.data[0].length) { + return
+ } + + return ( + + ) +} + +const buildForkChartData = ( + blocks: BlockMap, + select: (block: T) => number | null, +) => { + // We'll work in reverse order in order to ensure that the primaryLane is the longest one. + const forks: Array<{ data: Array; parent: string }> = [] + const primaryLane: { data: Array; parent: string } = { + data: [], + parent: "", + } + + let initialEmpty = 0 + for (let i = 0; i < WINDOW_SIZE + initialEmpty && i < 2 * WINDOW_SIZE; i++) { + const blocksAtHeight = blocks.at(-(i + 1)) + if (!blocksAtHeight) break + + const blockValues = mapObject(blocksAtHeight, select) + // Exclude first empty values to prevent the chart from continuously moving backwards + if ( + !primaryLane.parent && + !Object.values(blockValues).some((v) => v !== null) + ) { + initialEmpty++ + continue + } + + if (!primaryLane.parent) { + // Arbitrary select one block. Ideally we would select the actual best, but at this point this isn't important. + const [first, ...others] = Object.values(blocksAtHeight) + if (first) { + primaryLane.data = [select(first)] + primaryLane.parent = first.parent + } + others.forEach((other) => { + forks.push({ + data: [select(other)], + parent: other.parent, + }) + }) + continue + } + const primaryParent = blocksAtHeight[primaryLane.parent] + // Found a discontinuity: Stop rendering the chart as we can't know where forks follow through + if (!primaryParent) break + + const newForks = new Set(Object.keys(blocksAtHeight)) + + forks.forEach((fork) => { + if (!fork.parent) { + // Push null value to keep it aligned + fork.data.push(null) + return + } + + newForks.delete(fork.parent) + fork.data.push(blockValues[fork.parent]) + if (fork.parent === primaryLane.parent) { + // With the previous push we have it connected, mark it as done + fork.parent = "" + } else { + fork.parent = blocksAtHeight[fork.parent]?.parent ?? "" + } + }) + + newForks.delete(primaryLane.parent) + primaryLane.data.push(blockValues[primaryLane.parent]) + primaryLane.parent = primaryParent.parent + + // Remember we are building the data from recent to old, and we'll reverse at the end for the chart + // For this reason, we don't need to pad new forks, since the series will end at that point. + newForks.forEach((hash) => { + forks.push({ + data: [blockValues[hash]], + parent: blocksAtHeight[hash].parent, + }) + }) + } + + const xValues = primaryLane.data.map((_, i) => i) + + const lanes = [ + primaryLane.data.reverse(), + ...forks.map((fork) => fork.data.reverse()), + ] + const yValues = lanes.flatMap((lane) => lane.filter((value) => value != null)) + + return { + data: [xValues, ...lanes] satisfies uPlot.AlignedData, + seriesCount: lanes.length, + primaryLane: 0, + yRange: getYRange(yValues), + } +} diff --git a/src/pages/Metrics/MetricCards.tsx b/src/pages/Metrics/MetricCards.tsx new file mode 100644 index 0000000..5a705e9 --- /dev/null +++ b/src/pages/Metrics/MetricCards.tsx @@ -0,0 +1,267 @@ +import { useStateObservable, withDefault } from "@react-rxjs/core" +import { + ArrowRight, + Clock3, + Flag, + Network, + Scale, + ShieldCheck, +} from "lucide-react" +import { FC, PropsWithChildren, ReactNode, useMemo } from "react" +import { filter, map, scan } from "rxjs" +import { DataChart } from "./DataChart" +import { ForkChart } from "./ForkChart" +import { nodeHealth$ } from "./NodeHealth" +import { + formatDuration, + formatInteger, + formatPercent, + nodeStatusText, + toSeconds, +} from "./format" +import { + WINDOW_SIZE, + blockFinalization$, + blockTimes$, + blockWeights$, + eventsCount$, + transactionCount$, + withDefer, +} from "./metrics.state" + +const MetricCard: FC< + PropsWithChildren<{ + title: string + value: string + caption: string + icon: ReactNode + }> +> = ({ title, value, caption, icon, children }) => ( +
+
+

{title}

+
{icon}
+
+
+
+
+ {value} +
+
{caption}
+
+ {children ?
{children}
: null} +
+
+) + +export const BlockTimeMetrics = () => { + const blockTimes = useStateObservable(blockTimes$) + const avgBlockTime = useMemo(() => { + // blocks is an array that starts at "block_number". filter makes it 0-based instead, which is what we need + const blocks = blockTimes.filter((v) => + Object.values(v).some((v) => v.blockTime != null), + ) + if (blocks.length < 3) return null + // We're skipping the first one since it's very often an outlier + const first = Object.values(blocks[1]).find((v) => v.blockTime != null)! + const last = Object.values(blocks.at(-1)!).find((v) => v.blockTime != null)! + return (last.created! - first.created!) / (blocks.length - 2) + }, [blockTimes]) + + return ( + } + > + toSeconds(block.blockTime)} + /> + + ) +} + +export const FinalizationMetrics = () => { + const finalityTimes = useStateObservable(blockFinalization$) + const avgFinalizedTime = useMemo(() => { + const blocks = finalityTimes + // blocks is an array that starts at "block_number". filter makes it 0-based instead, which is what we need + .filter((v) => + Object.values(v).some( + (v) => v.finalized !== null && v.finalizedSum != null, + ), + ) + if (blocks.length < 3) return null + // We're skipping the first one since it's very often an outlier + const first = Object.values(blocks[1]).find( + (v) => v.finalized !== null && v.finalizedSum != null, + )! + const last = Object.values(blocks.at(-1)!).find( + (v) => v.finalized !== null && v.finalizedSum != null, + )! + return (last.finalizedSum! - first.finalizedSum!) / (blocks.length - 2) + }, [finalityTimes]) + + return ( + } + > + toSeconds(block.finalized)} + /> + + ) +} + +export const WeightMetrics = () => { + const blockWeights = useStateObservable(blockWeights$) + const avgBlockWeight = useMemo(() => { + if (!blockWeights) return null + // blocks is an array that starts at "block_number". filter makes it 0-based instead, which is what we need + const blocks = blockWeights.filter((v) => + Object.values(v).some((v) => v.weightSum !== null), + ) + if (blocks.length < 2) return null + + // Although we might match different branches, they happen very randomly and we're + // computing a long average, so the difference will be minimal. + const first = Object.values(blocks[0]).find((v) => v.weightSum != null)! + const last = Object.values(blocks.at(-1)!).find( + (v) => v.weightSum !== null, + )! + return { + refTime: + (last.weightSum!.refTime - first.weightSum!.refTime) / + (blocks.length - 1), + proofSize: + (last.weightSum!.proofSize - first.weightSum!.proofSize) / + (blocks.length - 1), + } + }, [blockWeights]) + + const avgWeight = + avgBlockWeight && + Math.max(avgBlockWeight.refTime, avgBlockWeight.proofSize) * 100 + + return ( + } + > + + block.weight + ? Math.max(block.weight.refTime, block.weight.proofSize) * 100 + : null + } + /> + + ) +} + +export const TransactionMetrics = () => { + const transactionCounts = useStateObservable(transactionCount$) + const transactionStats = useMemo(() => { + const blocks = transactionCounts + // blocks is an array that starts at "block_number". filter makes it 0-based instead, which is what we need + .filter((v) => Object.values(v).some((v) => v.transactionSum !== null)) + if (blocks.length < 2) return null + + const first = Object.values(blocks[0]).find( + (v) => v.transactionSum != null, + )! + const last = Object.values(blocks.at(-1)!).find( + (v) => v.transactionSum !== null, + )! + const totalCount = last.transactionSum! - first.transactionSum! + const tpm = (totalCount * (1000 * 60)) / (last.created - first.created) + return { totalCount, tpm } + }, [transactionCounts]) + + return ( + } + > + block.transactions} + /> + + ) +} + +export const EventMetrics = () => { + const eventsCount = useStateObservable(eventsCount$) + + const eventStats = useMemo(() => { + const blocks = eventsCount + // blocks is an array that starts at "block_number". filter makes it 0-based instead, which is what we need + .filter((v) => Object.values(v).some((v) => v.eventSum !== null)) + if (blocks.length < 2) return null + + const first = Object.values(blocks[0]).find((v) => v.eventSum != null)! + const last = Object.values(blocks.at(-1)!).find((v) => v.eventSum !== null)! + const totalCount = last.eventSum! - first.eventSum! + const epm = (totalCount * (1000 * 60)) / (last.created - first.created) + return { totalCount, epm } + }, [eventsCount]) + + return ( + } + > + block.events} + /> + + ) +} + +const peerHistory$ = nodeHealth$.pipeState( + map((v) => v?.peers ?? null), + filter((v) => v != null), + withDefer(() => + scan( + (acc: number[], newValue) => [newValue, ...acc].slice(0, WINDOW_SIZE), + [], + ), + ), + map((v) => [...v].reverse()), + withDefault([]), +) +export const NodeStatusMetrics = () => { + const peerHistory = useStateObservable(peerHistory$) + const nodeHealth = useStateObservable(nodeHealth$) + + return ( + } + > + + + ) +} diff --git a/src/pages/Metrics/Metrics.tsx b/src/pages/Metrics/Metrics.tsx new file mode 100644 index 0000000..bf5217d --- /dev/null +++ b/src/pages/Metrics/Metrics.tsx @@ -0,0 +1,62 @@ +import { LoadingBlocks } from "@/components/Loading" +import { withSubscribe } from "@/components/withSuspense" +import { Copy } from "lucide-react" +import { FC, ReactNode } from "react" +import { CenteredScrollContainer } from "../AppShell" +import { + BlockTimeMetrics, + EventMetrics, + FinalizationMetrics, + NodeStatusMetrics, + TransactionMetrics, + WeightMetrics, +} from "./MetricCards" +import { NodeStatus } from "./NodeHealth" +import { RecentBlocksTable } from "./RecentBlocks" + +function Metrics() { + return ( + +
+

+ Chain metrics +

+ +
+ + + + + + +
+ +
+ }> + + + + }> + + +
+
+
+ ) +} +const _Metrics = withSubscribe(Metrics, { fallback: }) +export default _Metrics + +const Panel: FC<{ + title: string + action?: ReactNode + children: ReactNode +}> = ({ title, action, children }) => ( +
+
+

{title}

+ {action ?
{action}
: null} +
+
{children}
+
+) diff --git a/src/pages/Metrics/NodeHealth.tsx b/src/pages/Metrics/NodeHealth.tsx new file mode 100644 index 0000000..fa07472 --- /dev/null +++ b/src/pages/Metrics/NodeHealth.tsx @@ -0,0 +1,125 @@ +import { client$ } from "@/state/chains/chain.state" +import { useStateObservable, withDefault } from "@react-rxjs/core" +import { CheckCircle2 } from "lucide-react" +import { FC } from "react" +import { + catchError, + combineLatest, + defer, + map, + of, + repeat, + switchMap, +} from "rxjs" +import { formatBlockNumber, formatInteger } from "./format" + +export const chainStatus$ = client$.pipeState( + switchMap((client) => + combineLatest({ + best: client.bestBlocks$.pipe( + map(([block]) => ({ hash: block.hash, number: block.number })), + ), + finalized: client.finalizedBlock$.pipe( + map((block) => ({ hash: block.hash, number: block.number })), + ), + }), + ), + withDefault(null), +) + +export const nodeHealth$ = client$.pipeState( + switchMap((client) => + defer(() => + client._request<{ + peers: number + isSyncing: boolean + }>("system_health", []), + ).pipe( + catchError(() => of(null)), + repeat({ + delay: 5000, + }), + ), + ), + withDefault(null), +) + +export const NodeStatus: FC = () => { + const chainStatus = useStateObservable(chainStatus$) + const nodeHealth = useStateObservable(nodeHealth$) + + return ( +
+
+ + + +
+
+ + 0} + /> +
+
+ ) +} + +const StatusRow: FC<{ label: string; value: string; accent?: boolean }> = ({ + label, + value, + accent, +}) => ( +
+ {label} + + {value} + +
+) + +const HealthRow: FC<{ label: string; healthy: boolean }> = ({ + label, + healthy, +}) => ( +
+ + + {label} + + + {healthy ? "Healthy" : "Pending"} + +
+) diff --git a/src/pages/Metrics/RecentBlocks.tsx b/src/pages/Metrics/RecentBlocks.tsx new file mode 100644 index 0000000..c20958f --- /dev/null +++ b/src/pages/Metrics/RecentBlocks.tsx @@ -0,0 +1,82 @@ +import { Link } from "@/hashParams" +import { state, useStateObservable } from "@react-rxjs/core" +import { combineLatest, map } from "rxjs" +import "uplot/dist/uPlot.min.css" +import { + formatDuration, + formatInteger, + formatPercent, + shortHash, +} from "./format" +import { blockStats$, blockTimes$, blockWeights$ } from "./metrics.state" + +const latestBlocks$ = state( + combineLatest([blockStats$, blockTimes$, blockWeights$]).pipe( + map(([{ stats }, blockTimes, blockWeights]) => + stats + .slice(-8) + .flatMap((blocks) => Object.values(blocks)) + .slice(-8) + .map((block) => { + const blockTime = blockTimes.find((blocks) => blocks[block.hash]) + const blockWeight = blockWeights?.find((blocks) => blocks[block.hash]) + return { + hash: block.hash, + number: block.number, + created: block.created, + blockTime: blockTime?.[block.hash]?.blockTime ?? null, + transactions: block.info.transactions, + events: block.info.events, + weight: blockWeight?.[block.hash]?.weight ?? null, + } + }) + .sort((a, b) => b.number - a.number || b.created - a.created), + ), + ), + [], +) + +export const RecentBlocksTable = () => { + const blocks = useStateObservable(latestBlocks$) + + return ( + + + + + + + + + + + + {blocks.map((block) => ( + + + + + + + + ))} + +
HashTimeTxsEventsWeight
+ + {shortHash(block.hash)} + + + {formatDuration(block.blockTime)} + + {formatInteger(block.transactions)} + + {formatInteger(block.events)} + + {formatPercent( + block.weight + ? Math.max(block.weight.refTime, block.weight.proofSize) * 100 + : null, + )} +
+ ) +} diff --git a/src/pages/Metrics/format.ts b/src/pages/Metrics/format.ts new file mode 100644 index 0000000..658bcf1 --- /dev/null +++ b/src/pages/Metrics/format.ts @@ -0,0 +1,30 @@ +export const toSeconds = (value: number | null) => + value == null ? null : Math.max(0, value / 1000) + +export const formatInteger = (value: number | null | undefined) => + value == null || !Number.isFinite(value) + ? "-" + : Math.round(value).toLocaleString() + +export const formatBlockNumber = (value: number | null | undefined) => + value == null ? "-" : `#${value.toLocaleString()}` + +export const shortHash = (hash: string) => + `${hash.slice(0, 8)}…${hash.slice(-6)}` + +export const formatDuration = (value: number | null | undefined) => + value == null || !Number.isFinite(value) + ? "-" + : `${(value / 1000).toFixed(1)}s` + +export const formatPercent = (value: number | null | undefined) => + value == null || !Number.isFinite(value) ? "-" : `${Math.round(value)}%` + +export const nodeStatusText = (health: { + isSyncing: boolean + peers: number +}) => { + if (health.isSyncing) return "node syncing" + if (health.peers === 0) return "no peers" + return "node healthy" +} diff --git a/src/pages/Metrics/metrics.state.ts b/src/pages/Metrics/metrics.state.ts new file mode 100644 index 0000000..80676e2 --- /dev/null +++ b/src/pages/Metrics/metrics.state.ts @@ -0,0 +1,350 @@ +import { blockInfo$ } from "@/state/block.state" +import { client$ } from "@/state/chains/chain.state" +import { DotAh } from "@polkadot-api/descriptors" +import { + combineLatest, + defer, + endWith, + filter, + ignoreElements, + map, + mergeMap, + ObservedValueOf, + OperatorFunction, + repeat, + scan, + startWith, + switchMap, + take, + takeUntil, +} from "rxjs" +import { getBlockWeight } from "../Explorer/BlockPopover" + +export const withDefer = + (fn: () => OperatorFunction): OperatorFunction => + (source$) => + defer(() => fn()(source$)) + +// block number -> hash -> T +export type BlockMap = Array> +export type BlockMapType = T extends BlockMap ? R : never + +export const WINDOW_SIZE = 50 +export const blockStats$ = client$.pipeState( + switchMap((client) => { + const timeRef = Date.now() + + const groupStats = () => + withDefer(() => + scan( + ( + { + stats, + cleanFrom, + }: { + stats: BlockMap + cleanFrom: number + changed: T | null + }, + block: T, + ) => { + stats[block.number] ??= {} + stats[block.number][block.hash] = block + const newCleanFrom = pruneOldBlocks(stats, cleanFrom) + return { stats, changed: block, cleanFrom: newCleanFrom } + }, + { + stats: [], + cleanFrom: 0, + changed: null, + }, + ), + ) + + const blocksComplete$ = client.blocks$.pipe(ignoreElements(), endWith(true)) + + return client.blocks$.pipe( + mergeMap((block) => { + const created = Date.now() - timeRef + + const finalized$ = client.finalizedBlock$.pipe( + filter((v) => block.hash === v.hash), + take(1), + map(() => Date.now() - timeRef), + startWith(null), + ) + + const info$ = blockInfo$(block.hash).pipe( + filter( + (info) => info.body?.length != null || info.events?.length != null, + ), + map((info) => ({ + transactions: info.body?.length ?? null, + events: info.events?.length ?? null, + weight: getBlockWeight(info), + })), + startWith({ + transactions: null, + events: null, + weight: null, + }), + ) + + return combineLatest([finalized$, info$]).pipe( + map(([finalized, info]) => ({ + ...block, + finalized, + created, + info, + })), + ) + }), + groupStats(), + takeUntil(blocksComplete$), + repeat(), + ) + }), +) +type BlockStats = BlockMapType["stats"]> + +const pruneOldBlocks = (blocks: BlockMap, cleanFrom: number) => { + // Initial state we can skip to the first window, to include out-of-order blocks + if (cleanFrom === 0) return blocks.length - WINDOW_SIZE + // Skip if we're in a window-size length + if (blocks.length - cleanFrom < WINDOW_SIZE * 3) return cleanFrom + for (let i = cleanFrom; i < blocks.length - WINDOW_SIZE * 2; i++) { + delete blocks[i] + } + return blocks.length - WINDOW_SIZE * 2 +} + +const accumulate = ( + acumulator: ( + stats: BlockStats, + prev?: { + acc: T + stats: BlockStats + }, + ) => T, + eqFn: (current: T, prev: T) => boolean, + hasValue: (value: T) => boolean, +) => + blockStats$.pipeState( + // logs("accumulate"), + withDefer(() => + scan( + ({ acc }: { changed: boolean; acc: BlockMap | null }, evt) => { + const getValue = (block: BlockStats, accs: BlockMap) => { + const parent = evt.stats[block.number - 1]?.[block.parent] + const parentAcc = accs[block.number - 1]?.[block.parent] + + return acumulator(block, { acc: parentAcc, stats: parent }) + } + + if (!acc) { + const result: BlockMap = [] + for (const i in evt.stats) { + result[i] ??= {} + const blocks = evt.stats[i] + for (const hash in blocks) { + result[i][hash] = getValue(blocks[hash], result) + } + } + return { + changed: true, + acc: result, + } + } + + let hasChanged = false + if (evt.changed) { + let toUpdate = [evt.changed] + while (toUpdate.length) { + const target = toUpdate.pop()! + acc[target.number] ??= {} + const oldAcc = acc[target.number][target.hash] + const newAcc = (acc[target.number][target.hash] = getValue( + target, + acc, + )) + const targetChanged = !oldAcc || !eqFn(oldAcc, newAcc) + hasChanged ||= targetChanged + if (targetChanged) { + toUpdate = [ + ...toUpdate, + ...Object.values(evt.stats[target.number + 1] ?? {}).filter( + (block) => block.parent === target.hash, + ), + ] + } + } + } + + // We could prune the sparse-array acc as well, but I'm keeping it separate + // as this observable only lives as long as there's a component subscribing to it + // (whereas blockStats$ is designed to be long-lived) + + return { + changed: hasChanged, + acc, + } + }, + { changed: false, acc: null }, + ), + ), + filter((v) => v.changed && v.acc !== null), + map((v): Array> => { + const broadSlice = v.acc!.slice(-2 * WINDOW_SIZE) + const lastWithValue = broadSlice.findLastIndex( + (v) => v && Object.values(v).some(hasValue), + ) + const r = broadSlice! + .slice(-(broadSlice!.length - lastWithValue + WINDOW_SIZE)) + // Remove empty values, since we're turning this into a 0-based array. + .filter(() => true) + return r + }), + ) + +export const blockTimes$ = accumulate<{ + parent: string + blockTime: number | null + created: number +}>( + (stats, prev) => { + const parent = prev?.stats + + const isBurst = !parent || stats.created - parent.created <= 1 + const blockTime = isBurst || !parent ? null : stats.created - parent.created + + return { + parent: stats.parent, + blockTime, + created: stats.created, + } + }, + () => true, + (v) => v.blockTime != null, +) + +export const blockFinalization$ = accumulate<{ + parent: string + finalized: number | null + finalizedSum: number | null +}>( + (stats, prev) => { + const parentTimings = prev?.acc + const finalized = + stats.finalized != null ? stats.finalized - stats.created : null + + return { + parent: stats.parent, + finalized, + finalizedSum: + finalized == null + ? (parentTimings?.finalizedSum ?? null) + : (parentTimings?.finalizedSum ?? 0) + finalized, + } + }, + (current, prev) => current.finalizedSum === prev.finalizedSum, + (v) => v.finalized != null, +) + +export const transactionCount$ = accumulate<{ + parent: string + created: number + transactions: number | null + transactionSum: number | null +}>( + (stats, prev) => { + const parentAcc = prev?.acc + const transactions = stats.info.transactions + + return { + parent: stats.parent, + created: stats.created, + transactions, + transactionSum: + transactions == null + ? (parentAcc?.transactionSum ?? null) + : (parentAcc?.transactionSum ?? 0) + transactions, + } + }, + (current, prev) => current.transactionSum === prev.transactionSum, + (v) => v.transactions != null, +) + +export const eventsCount$ = accumulate<{ + parent: string + created: number + events: number | null + eventSum: number | null +}>( + (stats, prev) => { + const parentAcc = prev?.acc + const events = stats.info.events + + return { + parent: stats.parent, + created: stats.created, + events, + eventSum: + events == null + ? (parentAcc?.eventSum ?? null) + : (parentAcc?.eventSum ?? 0) + events, + } + }, + (current, prev) => current.eventSum === prev.eventSum, + (v) => v.events != null, +) + +export const blockWeights$ = client$.pipeState( + switchMap((client) => + client + .getUnsafeApi() + .constants.System.BlockWeights() + .catch(() => null), + ), + map((v) => v?.max_block ?? null), + switchMap((max_weight) => + max_weight + ? accumulate<{ + weight: { refTime: number; proofSize: number } | null + weightSum: { refTime: number; proofSize: number } | null + parent: string + }>( + (stats, prev) => { + const parentAcc = prev?.acc + const weight = stats.info.weight + + const weightPct = weight + ? { + refTime: + Number(weight.ref_time) / Number(max_weight.ref_time), + proofSize: + Number(weight.proof_size) / Number(max_weight.proof_size), + } + : null + + return { + parent: stats.parent, + weight: weightPct, + weightSum: + weightPct == null + ? (parentAcc?.weightSum ?? null) + : { + refTime: + (parentAcc?.weightSum?.refTime ?? 0) + + weightPct.refTime, + proofSize: + (parentAcc?.weightSum?.proofSize ?? 0) + + weightPct.proofSize, + }, + } + }, + (current, prev) => current.weightSum === prev.weightSum, + (v) => v.weight !== null, + ) + : [null], + ), +) diff --git a/src/state/identity.state.ts b/src/state/identity.state.ts index cfe9d9f..196c17a 100644 --- a/src/state/identity.state.ts +++ b/src/state/identity.state.ts @@ -1,15 +1,15 @@ import { localStorageSubject } from "@/utils/localStorageSubject" import { + getMetadata, IdentityData, IdentityJudgement, polkadot_people, - getMetadata, } from "@polkadot-api/descriptors" import { state } from "@react-rxjs/core" import { Binary, createClient, SS58String } from "polkadot-api" +import { fromHex } from "polkadot-api/utils" import { catchError, from, of, tap } from "rxjs" import { getProvider } from "./chains/chain.state" -import { fromHex } from "polkadot-api/utils" export interface Identity { displayName: string diff --git a/tsconfig.app.json b/tsconfig.app.json index 7e08b1b..bbb7449 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2023", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true,