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
This commit is contained in:
Victor Oliva
2026-06-23 13:25:57 +02:00
committed by GitHub
parent 90c43f9efd
commit d7ec471a14
17 changed files with 1340 additions and 20 deletions
+1
View File
@@ -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": {
+8
View File
@@ -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
+3 -1
View File
@@ -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() {
<Route path="rpcCalls/*" element={<RpcCalls />} />
<Route path="accounts/*" element={<Accounts />} />
<Route path="teleport" element={<Teleport />} />
<Route path="metrics" element={<Metrics />} />
<Route path="*" element={<Navigate to="/explorer" replace />} />
</Routes>
</AppShell>
+11 -1
View File
@@ -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<T> {
@@ -131,3 +131,13 @@ export const createState = <T>(defaultValue: T) => {
return [state$, setValue] as const
}
export const codeSplit$ = <T>(fetch: () => Promise<Observable<T>>) =>
defer(() =>
from(fetch()).pipe(
retry({
delay: () => timer(1000),
}),
mergeAll(),
),
)
+6 -1
View File
@@ -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(
<Subscribe source$={merge(dynamicBuilder$, explorer$)}>
<Subscribe source$={merge(dynamicBuilder$, explorer$, metrics$)}>
<RemoveSubscribe>
<StrictMode>
<ThemeProvider>
+7
View File
@@ -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",
+18 -13
View File
@@ -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 (
<div>
@@ -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
+235
View File
@@ -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 <div className="h-full min-h-16 rounded bg-muted/30" />
}
return (
<UPlotChart
data={chart.data}
seriesCount={chart.seriesCount}
primaryLane={chart.primaryLane}
yRange={chart.yRange}
color={color}
height={112}
/>
)
}
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<HTMLDivElement>(null)
const plotRef = useRef<uPlot | null>(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 <div ref={ref} className="h-full min-h-16 w-full [&_.uplot]:w-full" />
}
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)
}
+131
View File
@@ -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<T extends { parent: string }>({
blocks,
select,
color,
}: {
blocks: BlockMap<T> | 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 <div className="h-full min-h-16 rounded bg-muted/30" />
}
return (
<UPlotChart
data={chart.data}
seriesCount={chart.seriesCount}
primaryLane={chart.primaryLane}
yRange={chart.yRange}
color={color}
height={112}
/>
)
}
const buildForkChartData = <T extends { parent: string }>(
blocks: BlockMap<T>,
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<number | null>; parent: string }> = []
const primaryLane: { data: Array<number | null>; 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),
}
}
+267
View File
@@ -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 }) => (
<article className="min-h-36 rounded-lg border bg-card p-5 shadow-sm">
<div className="mb-3 flex items-start justify-between gap-4">
<h2 className="text-sm font-semibold text-card-foreground">{title}</h2>
<div className="text-muted-foreground">{icon}</div>
</div>
<div className="flex items-center gap-4 overflow-hidden">
<div className="min-w-0 w-32">
<div className="text-3xl font-semibold leading-tight tabular-nums">
{value}
</div>
<div className="mt-1 text-sm text-muted-foreground">{caption}</div>
</div>
{children ? <div className="flex-1">{children}</div> : null}
</div>
</article>
)
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 (
<MetricCard
title="Block time"
value={formatDuration(avgBlockTime)}
caption={`avg over last ${WINDOW_SIZE} blocks`}
icon={<Clock3 className="h-5 w-5" />}
>
<ForkChart
color="#ec4899"
blocks={blockTimes}
select={(block) => toSeconds(block.blockTime)}
/>
</MetricCard>
)
}
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 (
<MetricCard
title="Finalization time"
value={formatDuration(avgFinalizedTime)}
caption={`avg over last ${WINDOW_SIZE} blocks`}
icon={<ShieldCheck className="h-5 w-5" />}
>
<ForkChart
color="#ec4899"
blocks={finalityTimes}
select={(block) => toSeconds(block.finalized)}
/>
</MetricCard>
)
}
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 (
<MetricCard
title="Block weight"
value={formatPercent(avgWeight)}
caption="of max block weight"
icon={<Scale className="h-5 w-5" />}
>
<ForkChart
color="#e6007a"
blocks={blockWeights}
select={(block) =>
block.weight
? Math.max(block.weight.refTime, block.weight.proofSize) * 100
: null
}
/>
</MetricCard>
)
}
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 (
<MetricCard
title="Transactions"
value={formatInteger(transactionStats?.totalCount)}
caption={`${formatInteger(transactionStats?.tpm)} tx/min`}
icon={<ArrowRight className="h-5 w-5" />}
>
<ForkChart
color="#8b5cf6"
blocks={transactionCounts}
select={(block) => block.transactions}
/>
</MetricCard>
)
}
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 (
<MetricCard
title="Events"
value={formatInteger(eventStats?.totalCount)}
caption={`${formatInteger(eventStats?.epm)} events/min`}
icon={<Flag className="h-5 w-5" />}
>
<ForkChart
color="#8b5cf6"
blocks={eventsCount}
select={(block) => block.events}
/>
</MetricCard>
)
}
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 (
<MetricCard
title="Connected peers"
value={formatInteger(nodeHealth?.peers)}
caption={nodeHealth ? nodeStatusText(nodeHealth) : "unavailable"}
icon={<Network className="h-5 w-5" />}
>
<DataChart color="#8b5cf6" values={peerHistory} />
</MetricCard>
)
}
+62
View File
@@ -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 (
<CenteredScrollContainer className="max-w-360">
<div className="p-4 space-y-5">
<h1 className="text-2xl font-semibold tracking-normal text-foreground">
Chain metrics
</h1>
<section className="grid gap-4 lg:grid-cols-3">
<BlockTimeMetrics />
<FinalizationMetrics />
<WeightMetrics />
<TransactionMetrics />
<EventMetrics />
<NodeStatusMetrics />
</section>
<section className="grid gap-4 xl:grid-cols-[1fr_1.05fr]">
<Panel title="Recent blocks" action={<Copy className="h-4 w-4" />}>
<RecentBlocksTable />
</Panel>
<Panel title="Node status" action={<Copy className="h-4 w-4" />}>
<NodeStatus />
</Panel>
</section>
</div>
</CenteredScrollContainer>
)
}
const _Metrics = withSubscribe(Metrics, { fallback: <LoadingBlocks /> })
export default _Metrics
const Panel: FC<{
title: string
action?: ReactNode
children: ReactNode
}> = ({ title, action, children }) => (
<section className="rounded-lg border bg-card shadow-sm">
<div className="flex h-14 items-center justify-between border-b px-5">
<h2 className="text-sm font-semibold">{title}</h2>
{action ? <div className="text-muted-foreground">{action}</div> : null}
</div>
<div className="p-5">{children}</div>
</section>
)
+125
View File
@@ -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 (
<div className="space-y-5 text-sm">
<div className="space-y-3">
<StatusRow
label="Connected peers"
value={formatInteger(nodeHealth?.peers)}
/>
<StatusRow
label="Best block"
value={formatBlockNumber(chainStatus?.best.number)}
accent
/>
<StatusRow
label="Finalized block"
value={formatBlockNumber(chainStatus?.finalized.number)}
accent
/>
</div>
<div className="border-t pt-4 space-y-3">
<HealthRow
label="Node sync"
healthy={nodeHealth?.isSyncing === false}
/>
<HealthRow
label="Network connectivity"
healthy={nodeHealth != null && nodeHealth.peers > 0}
/>
</div>
</div>
)
}
const StatusRow: FC<{ label: string; value: string; accent?: boolean }> = ({
label,
value,
accent,
}) => (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">{label}</span>
<span
className={
accent
? "font-mono text-xs font-semibold text-polkadot-500"
: "font-medium tabular-nums"
}
>
{value}
</span>
</div>
)
const HealthRow: FC<{ label: string; healthy: boolean }> = ({
label,
healthy,
}) => (
<div className="flex items-center justify-between gap-4">
<span className="inline-flex items-center gap-2 text-muted-foreground">
<CheckCircle2
className={
healthy ? "h-4 w-4 text-green-600" : "h-4 w-4 text-muted-foreground"
}
/>
{label}
</span>
<span
className={
healthy
? "font-medium text-green-700 dark:text-green-400"
: "font-medium text-muted-foreground"
}
>
{healthy ? "Healthy" : "Pending"}
</span>
</div>
)
+82
View File
@@ -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 (
<table className="w-full min-w-120 text-sm">
<thead>
<tr className="border-b text-left text-xs font-medium text-muted-foreground">
<th className="pb-3">Hash</th>
<th className="pb-3">Time</th>
<th className="pb-3 text-right">Txs</th>
<th className="pb-3 text-right">Events</th>
<th className="pb-3 text-right">Weight</th>
</tr>
</thead>
<tbody>
{blocks.map((block) => (
<tr key={block.hash} className="border-b last:border-b-0">
<td className="py-3 pr-3 font-mono text-xs font-semibold text-polkadot-500">
<Link to={`/explorer/${block.hash}`} title={block.hash}>
{shortHash(block.hash)}
</Link>
</td>
<td className="py-3 pr-3 text-muted-foreground">
{formatDuration(block.blockTime)}
</td>
<td className="py-3 pr-3 text-right tabular-nums">
{formatInteger(block.transactions)}
</td>
<td className="py-3 pr-3 text-right tabular-nums">
{formatInteger(block.events)}
</td>
<td className="py-3 pr-3 text-right tabular-nums">
{formatPercent(
block.weight
? Math.max(block.weight.refTime, block.weight.proofSize) * 100
: null,
)}
</td>
</tr>
))}
</tbody>
</table>
)
}
+30
View File
@@ -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"
}
+350
View File
@@ -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 =
<I, O>(fn: () => OperatorFunction<I, O>): OperatorFunction<I, O> =>
(source$) =>
defer(() => fn()(source$))
// block number -> hash -> T
export type BlockMap<T> = Array<Record<string, T>>
export type BlockMapType<T> = T extends BlockMap<infer R> ? R : never
export const WINDOW_SIZE = 50
export const blockStats$ = client$.pipeState(
switchMap((client) => {
const timeRef = Date.now()
const groupStats = <T extends { hash: string; number: number }>() =>
withDefer(() =>
scan(
(
{
stats,
cleanFrom,
}: {
stats: BlockMap<T>
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<ObservedValueOf<typeof blockStats$>["stats"]>
const pruneOldBlocks = <T>(blocks: BlockMap<T>, 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 = <T>(
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<T> | null }, evt) => {
const getValue = (block: BlockStats, accs: BlockMap<T>) => {
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<T> = []
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<Record<string, T>> => {
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<DotAh>()
.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],
),
)
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,