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:
@@ -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": {
|
||||
|
||||
Generated
+8
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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],
|
||||
),
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user