@@ -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 }) => (
+
+
+
+
+
+ {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 (
+
+
+
+ | Hash |
+ Time |
+ Txs |
+ Events |
+ Weight |
+
+
+
+ {blocks.map((block) => (
+
+ |
+
+ {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,