Merge branch 'vo-block-explorer'
This commit is contained in:
@@ -39,6 +39,7 @@ export default tseslint.config(
|
||||
},
|
||||
],
|
||||
"react-refresh/only-export-components": "off",
|
||||
"prefer-const": "warn",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@polkadot-api/metadata-builders": "^0.9.1",
|
||||
"@polkadot-api/observable-client": "^0.5.14",
|
||||
"@polkadot-api/substrate-bindings": "^0.9.3",
|
||||
"@polkadot-api/substrate-client": "^0.2.2",
|
||||
"@polkadot-api/utils": "^0.1.2",
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
diff --git a/dist/esm/chainHead/chainHead.mjs b/dist/esm/chainHead/chainHead.mjs
|
||||
index f1962b05e17ac7c3c66c0ab453a61cbece1525e0..f9ab1b168f3b72410055cc315ba1864576254f9d 100644
|
||||
--- a/dist/esm/chainHead/chainHead.mjs
|
||||
+++ b/dist/esm/chainHead/chainHead.mjs
|
||||
@@ -157,7 +157,7 @@ const getChainHead$ = (chainHead) => {
|
||||
best$.pipe(map((b) => b.hash))
|
||||
);
|
||||
const _body$ = withOptionalHash$(commonEnhancer(lazyFollower("body")));
|
||||
- const body$ = (hash) => upsertCachedStream(hash, "body", _body$(hash, true));
|
||||
+ const body$ = (hash, canonical) => upsertCachedStream(hash, "body", _body$(hash, canonical));
|
||||
const _storage$ = commonEnhancer(lazyFollower("storage"));
|
||||
const storage$ = withOptionalHash$(
|
||||
(hash, withCanonicalChain2, type, keyMapper, childTrie = null, mapper) => pinnedBlocks$.pipe(
|
||||
@@ -237,11 +237,11 @@ const getChainHead$ = (chainHead) => {
|
||||
runtime$,
|
||||
metadata$,
|
||||
header$,
|
||||
- body$,
|
||||
- call$: withCanonicalChain(call$),
|
||||
- storage$: withCanonicalChain(storage$),
|
||||
+ body$: withCanonicalChain(body$, false),
|
||||
+ call$: withCanonicalChain(call$, false),
|
||||
+ storage$: withCanonicalChain(storage$, false),
|
||||
storageQueries$,
|
||||
- eventsAt$: withCanonicalChain(eventsAt$),
|
||||
+ eventsAt$: withCanonicalChain(eventsAt$, false),
|
||||
trackTx$,
|
||||
trackTxWithoutEvents$,
|
||||
validateTx$,
|
||||
diff --git a/dist/esm/getObservableClient.mjs b/dist/esm/getObservableClient.mjs
|
||||
index 511be34f2b04e0125858e03ff1dfce2759d3041b..b86e02a655903e3f5277269c5f46ca03b00b7ada 100644
|
||||
--- a/dist/esm/getObservableClient.mjs
|
||||
|
||||
Generated
+7
-4
@@ -6,7 +6,7 @@ settings:
|
||||
|
||||
patchedDependencies:
|
||||
'@polkadot-api/observable-client':
|
||||
hash: 2dyuwkurmwrhcez6heyjslsxsu
|
||||
hash: 37l2tyyzplymltubkxaic2hvlm
|
||||
path: patches/@polkadot-api__observable-client.patch
|
||||
'@polkadot-api/substrate-client':
|
||||
hash: lsuryiezeyeaoedwoxh2e4wda4
|
||||
@@ -25,6 +25,9 @@ importers:
|
||||
'@polkadot-api/metadata-builders':
|
||||
specifier: ^0.9.1
|
||||
version: 0.9.1
|
||||
'@polkadot-api/observable-client':
|
||||
specifier: ^0.5.14
|
||||
version: 0.5.14(patch_hash=37l2tyyzplymltubkxaic2hvlm)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
|
||||
'@polkadot-api/substrate-bindings':
|
||||
specifier: ^0.9.3
|
||||
version: 0.9.3
|
||||
@@ -2971,7 +2974,7 @@ snapshots:
|
||||
'@polkadot-api/json-rpc-provider': 0.0.4
|
||||
'@polkadot-api/known-chains': 0.5.5
|
||||
'@polkadot-api/metadata-compatibility': 0.1.11
|
||||
'@polkadot-api/observable-client': 0.5.14(patch_hash=2dyuwkurmwrhcez6heyjslsxsu)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
|
||||
'@polkadot-api/observable-client': 0.5.14(patch_hash=37l2tyyzplymltubkxaic2hvlm)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
|
||||
'@polkadot-api/polkadot-sdk-compat': 2.3.1
|
||||
'@polkadot-api/sm-provider': 0.1.3(smoldot@2.0.30)
|
||||
'@polkadot-api/smoldot': 0.3.3
|
||||
@@ -3038,7 +3041,7 @@ snapshots:
|
||||
'@polkadot-api/metadata-builders': 0.9.1
|
||||
'@polkadot-api/substrate-bindings': 0.9.3
|
||||
|
||||
'@polkadot-api/observable-client@0.5.14(patch_hash=2dyuwkurmwrhcez6heyjslsxsu)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)':
|
||||
'@polkadot-api/observable-client@0.5.14(patch_hash=37l2tyyzplymltubkxaic2hvlm)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)':
|
||||
dependencies:
|
||||
'@polkadot-api/metadata-builders': 0.9.1
|
||||
'@polkadot-api/substrate-bindings': 0.9.3
|
||||
@@ -4325,7 +4328,7 @@ snapshots:
|
||||
'@polkadot-api/logs-provider': 0.0.6
|
||||
'@polkadot-api/metadata-builders': 0.9.1
|
||||
'@polkadot-api/metadata-compatibility': 0.1.11
|
||||
'@polkadot-api/observable-client': 0.5.14(patch_hash=2dyuwkurmwrhcez6heyjslsxsu)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
|
||||
'@polkadot-api/observable-client': 0.5.14(patch_hash=37l2tyyzplymltubkxaic2hvlm)(@polkadot-api/substrate-client@0.2.2(patch_hash=lsuryiezeyeaoedwoxh2e4wda4))(rxjs@7.8.1)
|
||||
'@polkadot-api/pjs-signer': 0.5.2
|
||||
'@polkadot-api/polkadot-sdk-compat': 2.3.1
|
||||
'@polkadot-api/polkadot-signer': 0.1.6
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom"
|
||||
import { Constants } from "./pages/Constants"
|
||||
import { Explorer } from "./pages/Explorer"
|
||||
import { Extrinsics } from "./pages/Extrinsics"
|
||||
import { Header } from "./pages/Header"
|
||||
import { RuntimeCalls } from "./pages/RuntimeCalls"
|
||||
@@ -11,6 +12,7 @@ export default function App() {
|
||||
<Header />
|
||||
|
||||
<Routes>
|
||||
<Route path="explorer/*" element={<Explorer />} />
|
||||
<Route path="extrinsics/*" element={<Extrinsics />} />
|
||||
<Route path="storage/*" element={<Storage />} />
|
||||
<Route path="constants/*" element={<Constants />} />
|
||||
|
||||
+12
-2
@@ -8,6 +8,7 @@ import {
|
||||
V15,
|
||||
} from "@polkadot-api/substrate-bindings"
|
||||
import { toHex } from "@polkadot-api/utils"
|
||||
import { createClient as createSubstrateClient } from "@polkadot-api/substrate-client"
|
||||
import { shareLatest } from "@react-rxjs/core"
|
||||
import { createClient, PolkadotClient } from "polkadot-api"
|
||||
import { chainSpec } from "polkadot-api/chains/polkadot"
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs"
|
||||
import { getObservableClient } from "@polkadot-api/observable-client"
|
||||
|
||||
export type ChainSource = { id: string } & (
|
||||
| {
|
||||
@@ -57,8 +59,16 @@ export const chainClient$ = selectedSource$.pipe(
|
||||
map((src) => [src.id, getProvider(src)] as const),
|
||||
switchMap(([id, provider]) => {
|
||||
const client = createClient(provider)
|
||||
return concat(of({ id, client }), NEVER).pipe(
|
||||
finalize(() => client.destroy()),
|
||||
const substrateClient = createSubstrateClient(provider)
|
||||
const observableClient = getObservableClient(substrateClient)
|
||||
return concat(
|
||||
of({ id, client, substrateClient, observableClient }),
|
||||
NEVER,
|
||||
).pipe(
|
||||
finalize(() => {
|
||||
client.destroy()
|
||||
observableClient.destroy()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
shareLatest(),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { CopyText } from "@/components/Copy"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { FC } from "react"
|
||||
import { combineLatest, debounceTime, map, switchMap } from "rxjs"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import {
|
||||
BlockInfo,
|
||||
blocksByHeight$,
|
||||
chainHead$,
|
||||
finalized$,
|
||||
} from "./block.state"
|
||||
|
||||
const best$ = chainHead$.pipeState(switchMap((chainHead) => chainHead.best$))
|
||||
|
||||
interface PositionedBlock {
|
||||
block: BlockInfo
|
||||
position: number
|
||||
branched: number | null
|
||||
branches: number[]
|
||||
}
|
||||
|
||||
// 1 hour in polkadot
|
||||
export const MAX_LENGTH = 3600 / 6
|
||||
const blockTable$ = state(
|
||||
combineLatest([blocksByHeight$, best$]).pipe(
|
||||
debounceTime(0),
|
||||
map(([blocks, best]) => {
|
||||
const result: Array<PositionedBlock> = []
|
||||
|
||||
const blockPositions: Record<string, number> = {}
|
||||
const positionsTaken = new Set<number>()
|
||||
const lockFreePosition = () => {
|
||||
for (let i = 0; ; i++) {
|
||||
if (!positionsTaken.has(i)) {
|
||||
positionsTaken.add(i)
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
for (
|
||||
let height = best.number;
|
||||
blocks[height] && result.length < MAX_LENGTH;
|
||||
height--
|
||||
) {
|
||||
const competingBlocks = [...blocks[height].values()]
|
||||
if (competingBlocks.length > 1) {
|
||||
if (height === best.number) {
|
||||
competingBlocks.sort((a) => (a.hash === best.hash ? -1 : 1))
|
||||
} else {
|
||||
competingBlocks.sort((a, b) =>
|
||||
(blockPositions[a.hash] ?? Number.POSITIVE_INFINITY) <
|
||||
(blockPositions[b.hash] ?? Number.POSITIVE_INFINITY)
|
||||
? -1
|
||||
: 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
const positionsMerged: number[] = []
|
||||
competingBlocks.forEach((block) => {
|
||||
const branches = [...positionsTaken].filter(
|
||||
(v) => !positionsMerged.includes(v),
|
||||
)
|
||||
|
||||
const position = blockPositions[block.hash] ?? lockFreePosition()
|
||||
if (blockPositions[block.parent] != null) {
|
||||
// then it means the parent was already discovered by a previous
|
||||
// so this is the start of a branch
|
||||
result.push({
|
||||
block,
|
||||
branched: blockPositions[block.parent],
|
||||
branches,
|
||||
position,
|
||||
})
|
||||
positionsMerged.push(position)
|
||||
} else {
|
||||
// We put our parent underneath us
|
||||
blockPositions[block.parent] = position
|
||||
result.push({
|
||||
block,
|
||||
branched: null,
|
||||
branches,
|
||||
position,
|
||||
})
|
||||
}
|
||||
})
|
||||
positionsMerged.forEach((v) => positionsTaken.delete(v))
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
export const BlockTable = () => {
|
||||
const rows = useStateObservable(blockTable$)
|
||||
const finalized = useStateObservable(finalized$)
|
||||
|
||||
const numberSpan = (idx: number) => {
|
||||
const initialIdx = idx
|
||||
const number = rows[idx].block.number
|
||||
do {
|
||||
idx++
|
||||
} while (number === rows[idx]?.block.number)
|
||||
return idx - initialIdx
|
||||
}
|
||||
if (!finalized) return null
|
||||
|
||||
return (
|
||||
<div className="w-full p-2 border border-polkadot-800 rounded">
|
||||
<h2 className="font-bold p-2 border-b border-slate-400 mb-2">
|
||||
Recent Blocks
|
||||
</h2>
|
||||
<table className="border-collapse m-auto">
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={row.block.hash}
|
||||
className={twMerge(
|
||||
row.block.number <= finalized.number ? "bg-polkadot-900" : "",
|
||||
row.block.number === finalized.number &&
|
||||
row.position === 0 &&
|
||||
"border-t",
|
||||
)}
|
||||
>
|
||||
{rows[i - 1]?.block.number !== row.block.number ? (
|
||||
<td
|
||||
rowSpan={numberSpan(i)}
|
||||
className={twMerge(
|
||||
"px-2",
|
||||
numberSpan(i) > 1
|
||||
? twMerge(
|
||||
i > 0 ? "border-y" : "border-b",
|
||||
"border-slate-500",
|
||||
)
|
||||
: null,
|
||||
row.block.number === finalized.number && "border-t-white",
|
||||
row.block.number === finalized.number + 1 &&
|
||||
"border-b-white",
|
||||
)}
|
||||
>
|
||||
{row.block.number.toLocaleString()}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="p-0">
|
||||
<ForkRenderer row={row} />
|
||||
</td>
|
||||
<td className="max-w-xs w-full">
|
||||
<div className="flex gap-1">
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-hidden text-ellipsis whitespace-nowrap font-mono text-sm",
|
||||
row.position === 0
|
||||
? ""
|
||||
: row.block.number > finalized.number
|
||||
? "opacity-80"
|
||||
: "opacity-50",
|
||||
)}
|
||||
>
|
||||
{row.block.hash}
|
||||
</div>
|
||||
<CopyText text={row.block.hash} binary />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CELL_WIDTH = 20
|
||||
const CELL_HEIGHT = 40
|
||||
const CIRCLE_R = 5
|
||||
const ForkRenderer: FC<{ row: PositionedBlock }> = ({ row }) => {
|
||||
const totalCells = Math.max(row.position, ...row.branches) + 1
|
||||
|
||||
const getPositionCenter = (p: number) => CELL_WIDTH * p + CELL_WIDTH / 2
|
||||
|
||||
return (
|
||||
<svg
|
||||
height={CELL_HEIGHT}
|
||||
width={CELL_WIDTH * totalCells}
|
||||
className="stroke-polkadot-200"
|
||||
>
|
||||
{row.branches.map((branch, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={getPositionCenter(branch)}
|
||||
y1={0}
|
||||
x2={getPositionCenter(branch)}
|
||||
y2={
|
||||
row.branched != null && branch === row.position
|
||||
? CELL_HEIGHT / 2
|
||||
: CELL_HEIGHT
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{row.branched != null ? (
|
||||
<line
|
||||
x1={getPositionCenter(row.branched)}
|
||||
y1={CELL_HEIGHT / 2}
|
||||
x2={getPositionCenter(row.position)}
|
||||
y2={CELL_HEIGHT / 2}
|
||||
/>
|
||||
) : row.branches.includes(row.position) ? null : (
|
||||
<line
|
||||
x1={getPositionCenter(row.position)}
|
||||
y1={CELL_HEIGHT / 2}
|
||||
x2={getPositionCenter(row.position)}
|
||||
y2={CELL_HEIGHT}
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={getPositionCenter(row.position)}
|
||||
cy={CELL_HEIGHT / 2}
|
||||
r={CIRCLE_R}
|
||||
className={
|
||||
row.position === 0 ? "fill-polkadot-500" : "fill-polkadot-600"
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { lookup$ } from "@/chain.state"
|
||||
import { getDynamicBuilder } from "@polkadot-api/metadata-builders"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import {
|
||||
animationFrames,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
switchMap,
|
||||
} from "rxjs"
|
||||
import { chainHead$ } from "./block.state"
|
||||
|
||||
const best$ = chainHead$.pipeState(switchMap((chainHead) => chainHead.best$))
|
||||
const bestBlockTime$ = best$.pipeState(
|
||||
// Only count when increasing height
|
||||
map((block) => block.number),
|
||||
distinctUntilChanged((prev, current) => prev >= current),
|
||||
switchMap(() => {
|
||||
const timestamp = Date.now()
|
||||
return animationFrames().pipe(map(() => Date.now() - timestamp))
|
||||
}),
|
||||
)
|
||||
const targetTime$ = state(
|
||||
lookup$.pipe(
|
||||
map((lookup) => {
|
||||
const ct = lookup.metadata.pallets
|
||||
.find((p) => p.name === "Babe")
|
||||
?.constants.find((ct) => ct.name === "ExpectedBlockTime")
|
||||
if (!ct) return null
|
||||
try {
|
||||
const res = getDynamicBuilder(lookup)
|
||||
.buildDefinition(ct.type)
|
||||
.dec(ct.value)
|
||||
return Number(res)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const timeProps$ = state(
|
||||
combineLatest([bestBlockTime$, targetTime$]).pipe(
|
||||
map(([bestBlockTime, targetTime]) => {
|
||||
const time = (Math.round(bestBlockTime / 100) / 10).toFixed(1)
|
||||
const progress = targetTime ? bestBlockTime / targetTime : null
|
||||
return {
|
||||
time,
|
||||
progress: progress ? Math.round(progress * 100) / 100 : null,
|
||||
}
|
||||
}),
|
||||
distinctUntilChanged((a, b) => a.time === b.time),
|
||||
),
|
||||
{
|
||||
time: "0",
|
||||
progress: null,
|
||||
},
|
||||
)
|
||||
|
||||
const SIZE = 50
|
||||
const STROKE_W = 4
|
||||
const CENTER = SIZE / 2
|
||||
const RADIUS = CENTER - STROKE_W
|
||||
const PERIMTER = Math.PI * 2 * RADIUS
|
||||
export const BlockTime = () => {
|
||||
const { time, progress } = useStateObservable(timeProps$)
|
||||
|
||||
return (
|
||||
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
|
||||
<path
|
||||
d={`
|
||||
M${CENTER} ${STROKE_W}
|
||||
a ${RADIUS} ${RADIUS} 0 0 1 0 ${RADIUS * 2}
|
||||
a ${RADIUS} ${RADIUS} 0 0 1 0 -${RADIUS * 2}
|
||||
`}
|
||||
strokeWidth={STROKE_W}
|
||||
fill="transparent"
|
||||
className="transition-all ease-linear stroke-polkadot-500"
|
||||
opacity="0.3"
|
||||
/>
|
||||
{progress ? (
|
||||
<path
|
||||
d={`
|
||||
M${CENTER} ${STROKE_W}
|
||||
a ${RADIUS} ${RADIUS} 0 0 1 0 ${RADIUS * 2}
|
||||
a ${RADIUS} ${RADIUS} 0 0 1 0 -${RADIUS * 2}
|
||||
`}
|
||||
strokeDasharray={`${progress * PERIMTER} ${PERIMTER}`}
|
||||
strokeWidth={STROKE_W}
|
||||
strokeLinecap="round"
|
||||
fill="transparent"
|
||||
className="transition-all ease-linear stroke-polkadot-500"
|
||||
opacity="0.7"
|
||||
/>
|
||||
) : null}
|
||||
{progress && progress > 1 ? (
|
||||
<path
|
||||
d={`
|
||||
M${CENTER} ${STROKE_W}
|
||||
a ${RADIUS} ${RADIUS} 0 0 1 0 ${RADIUS * 2}
|
||||
a ${RADIUS} ${RADIUS} 0 0 1 0 -${RADIUS * 2}
|
||||
`}
|
||||
strokeDasharray={`${(progress - 1) * PERIMTER} ${PERIMTER}`}
|
||||
strokeWidth={STROKE_W}
|
||||
strokeLinecap="round"
|
||||
fill="transparent"
|
||||
className="transition-all ease-linear stroke-polkadot-500"
|
||||
/>
|
||||
) : null}
|
||||
<text
|
||||
x={CENTER}
|
||||
y={CENTER}
|
||||
textAnchor="middle"
|
||||
dy=".3em"
|
||||
className="tabular-nums fill-polkadot-200"
|
||||
>
|
||||
{time}s
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { combineKeys } from "@react-rxjs/utils"
|
||||
import { filter, map, takeWhile } from "rxjs"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import {
|
||||
blockInfo$,
|
||||
BlockState,
|
||||
finalized$,
|
||||
recordedBlocks$,
|
||||
} from "./block.state"
|
||||
import { MAX_LENGTH } from "./BlockTable"
|
||||
|
||||
export const Events = () => {
|
||||
const events = useStateObservable(recentEvents$)
|
||||
const finalized = useStateObservable(finalized$)
|
||||
|
||||
const firstFinalized = finalized
|
||||
? events.find((evt) => evt.number <= finalized?.number)
|
||||
: events[0]
|
||||
|
||||
return (
|
||||
<div className="w-full p-2 border border-polkadot-800 rounded">
|
||||
<h2 className="font-bold p-2 border-b border-slate-400 mb-2">
|
||||
Recent Events
|
||||
</h2>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{events.map((evt) => (
|
||||
<tr
|
||||
key={`${evt.hash}-${evt.index}`}
|
||||
className={twMerge(
|
||||
evt === firstFinalized ? "border-t border-white" : "",
|
||||
finalized && evt.number <= finalized.number
|
||||
? "bg-polkadot-900"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<td className="p-2 w-full">{`${evt.event.type}.${evt.event.value.type}`}</td>
|
||||
<td className="p-2 whitespace-nowrap">{`${evt.number}-${evt.extrinsicNumber}`}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{events.length === 0 ? (
|
||||
<div className="text-slate-400">(No events yet)</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const blackList = new Set([
|
||||
"System.*",
|
||||
"Balances.Deposit",
|
||||
"Treasury.Deposit",
|
||||
"ParaInclusion.*",
|
||||
"Balances.Withdraw",
|
||||
"Balances.Endowed",
|
||||
"Balances.Locked",
|
||||
"TransactionPayment.TransactionFeePaid",
|
||||
"Staking.Rewarded",
|
||||
])
|
||||
const recentEvents$ = state(
|
||||
combineKeys(recordedBlocks$, (key) =>
|
||||
blockInfo$(key).pipe(
|
||||
map((block) => ({
|
||||
status: block.status,
|
||||
hash: block.hash,
|
||||
number: block.number,
|
||||
events: block.events
|
||||
?.filter((evt) => evt.phase.type === "ApplyExtrinsic")
|
||||
.filter(
|
||||
(evt) =>
|
||||
!blackList.has(`${evt.event.type}.*`) &&
|
||||
!blackList.has(`${evt.event.type}.${evt.event.value.type}`),
|
||||
)
|
||||
.map((evt) => ({
|
||||
event: evt.event,
|
||||
extrinsicNumber: (evt.phase as any).value as number,
|
||||
})),
|
||||
})),
|
||||
takeWhile(
|
||||
(r) =>
|
||||
(r.status !== BlockState.Finalized &&
|
||||
r.status !== BlockState.Pruned) ||
|
||||
r.events == null,
|
||||
true,
|
||||
),
|
||||
filter((result) => Boolean(result.events?.length)),
|
||||
),
|
||||
).pipe(
|
||||
map((events) =>
|
||||
[...events.values()]
|
||||
.reverse()
|
||||
.filter(
|
||||
(evt) =>
|
||||
evt.status === BlockState.Best ||
|
||||
evt.status === BlockState.Finalized,
|
||||
)
|
||||
.flatMap(({ status, hash, number, events }) =>
|
||||
events!.map(({ event, extrinsicNumber }, index) => ({
|
||||
status,
|
||||
hash,
|
||||
number,
|
||||
event,
|
||||
extrinsicNumber,
|
||||
index,
|
||||
})),
|
||||
)
|
||||
.slice(0, MAX_LENGTH),
|
||||
),
|
||||
),
|
||||
[],
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import { withSubscribe } from "@/components/withSuspense"
|
||||
import { blocksByHeight$ } from "./block.state"
|
||||
import { BlockTable } from "./BlockTable"
|
||||
import { Summary } from "./Summary"
|
||||
import { Events } from "./Events"
|
||||
|
||||
const explorer$ = blocksByHeight$
|
||||
|
||||
export const Explorer = withSubscribe(
|
||||
() => (
|
||||
<div className="overflow-auto">
|
||||
<Summary />
|
||||
<div className="flex gap-2 items-start flex-wrap lg:flex-nowrap">
|
||||
<BlockTable />
|
||||
<Events />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
source$: explorer$,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FC, PropsWithChildren } from "react"
|
||||
import { map, switchMap } from "rxjs"
|
||||
import { chainHead$ } from "./block.state"
|
||||
import { BlockTime } from "./BlockTime"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
const finalized$ = chainHead$.pipeState(
|
||||
switchMap((chainHead) => chainHead.finalized$),
|
||||
map((v) => v.number.toLocaleString()),
|
||||
)
|
||||
const best$ = chainHead$.pipeState(
|
||||
switchMap((chainHead) => chainHead.best$),
|
||||
map((v) => v.number.toLocaleString()),
|
||||
)
|
||||
|
||||
export const Summary: FC = () => {
|
||||
return (
|
||||
<div className="flex gap-2 items-center p-2">
|
||||
<SummaryItem title="Block Time">
|
||||
<BlockTime />
|
||||
</SummaryItem>
|
||||
<div className="flex-1" />
|
||||
<SummaryItem
|
||||
className="border border-slate-500 rounded p-2"
|
||||
title="Finalized"
|
||||
>
|
||||
{finalized$}
|
||||
</SummaryItem>
|
||||
<SummaryItem className="border border-slate-500 rounded p-2" title="Best">
|
||||
{best$}
|
||||
</SummaryItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SummaryItem: FC<
|
||||
PropsWithChildren<{ title: string; className?: string }>
|
||||
> = ({ title, className, children }) => {
|
||||
return (
|
||||
<div className={twMerge("flex flex-col items-center", className)}>
|
||||
<h3>{title}</h3>
|
||||
<div className="tabular-nums text-polkadot-200 text-sm">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { chainClient$ } from "@/chain.state"
|
||||
import {
|
||||
ChainHead$,
|
||||
PinnedBlocks,
|
||||
SystemEvent,
|
||||
} from "@polkadot-api/observable-client"
|
||||
import { FollowEventWithRuntime } from "@polkadot-api/substrate-client"
|
||||
import { state, withDefault } from "@react-rxjs/core"
|
||||
import { partitionByKey } from "@react-rxjs/utils"
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
concat,
|
||||
concatMap,
|
||||
filter,
|
||||
finalize,
|
||||
forkJoin,
|
||||
map,
|
||||
mergeMap,
|
||||
NEVER,
|
||||
Observable,
|
||||
of,
|
||||
scan,
|
||||
skip,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
takeWhile,
|
||||
withLatestFrom,
|
||||
} from "rxjs"
|
||||
|
||||
export const chainHead$ = state(
|
||||
chainClient$.pipe(
|
||||
switchMap(({ observableClient }) => {
|
||||
const chainHead = observableClient.chainHead$()
|
||||
return concat(of(chainHead), NEVER).pipe(finalize(chainHead.unfollow))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const finalized$ = chainHead$.pipeState(
|
||||
switchMap((chainHead) => chainHead.finalized$),
|
||||
withDefault(null),
|
||||
)
|
||||
|
||||
export enum BlockState {
|
||||
Fork = "fork",
|
||||
Best = "best",
|
||||
Finalized = "finalized",
|
||||
Pruned = "pruned",
|
||||
}
|
||||
export interface BlockInfo {
|
||||
hash: string
|
||||
parent: string
|
||||
number: number
|
||||
body: string[] | null
|
||||
events: SystemEvent[] | null
|
||||
header: unknown | null
|
||||
status: BlockState
|
||||
}
|
||||
export const [blockInfo$, recordedBlocks$] = partitionByKey(
|
||||
chainHead$.pipe(
|
||||
switchMap((chainHead) =>
|
||||
chainHead.follow$.pipe(
|
||||
withInitializedNumber(),
|
||||
scan(
|
||||
(acc, evt) => {
|
||||
switch (evt.type) {
|
||||
case "initialized": {
|
||||
const blockNumbers: Record<string, number> = {}
|
||||
evt.finalizedBlockHashes.forEach((hash, i) => {
|
||||
const parent = blockNumbers[evt.finalizedBlockHashes[i - 1]]
|
||||
blockNumbers[hash] = parent ? parent + 1 : evt.number
|
||||
})
|
||||
return {
|
||||
value: evt.finalizedBlockHashes.map((hash, i) => ({
|
||||
hash,
|
||||
parent: evt.finalizedBlockHashes[i - 1] || evt.parentHash,
|
||||
number: blockNumbers[hash],
|
||||
})),
|
||||
blockNumbers,
|
||||
}
|
||||
}
|
||||
case "newBlock": {
|
||||
const number = acc.blockNumbers[evt.parentBlockHash] + 1
|
||||
acc.blockNumbers[evt.blockHash] = number
|
||||
return {
|
||||
value: [
|
||||
{
|
||||
hash: evt.blockHash,
|
||||
parent: evt.parentBlockHash,
|
||||
number,
|
||||
},
|
||||
],
|
||||
blockNumbers: acc.blockNumbers,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { value: [], blockNumbers: acc.blockNumbers }
|
||||
},
|
||||
{ value: [], blockNumbers: {} } as {
|
||||
value: Array<{
|
||||
hash: string
|
||||
parent: string
|
||||
number: number
|
||||
}>
|
||||
blockNumbers: Record<string, number>
|
||||
},
|
||||
),
|
||||
mergeMap(({ value }) => value),
|
||||
),
|
||||
),
|
||||
),
|
||||
(v) => v.hash,
|
||||
(initialized$) =>
|
||||
initialized$.pipe(
|
||||
take(1),
|
||||
withLatestFrom(chainHead$),
|
||||
switchMap(
|
||||
([{ hash, parent, number }, chainHead]): Observable<BlockInfo> =>
|
||||
concat(
|
||||
combineLatest({
|
||||
hash: of(hash),
|
||||
parent: of(parent),
|
||||
number: of(number),
|
||||
body: chainHead.body$(hash).pipe(
|
||||
startWith(null),
|
||||
catchError(() => of(null)),
|
||||
),
|
||||
events: chainHead.eventsAt$(hash).pipe(
|
||||
startWith(null),
|
||||
catchError(() => of(null)),
|
||||
),
|
||||
header: chainHead.header$(hash).pipe(
|
||||
startWith(null),
|
||||
catchError(() => of(null)),
|
||||
),
|
||||
status: getBlockStatus$(chainHead, hash, number),
|
||||
}),
|
||||
NEVER,
|
||||
),
|
||||
),
|
||||
// Reset when chainHead is changed
|
||||
takeUntil(chainHead$.pipe(skip(1))),
|
||||
),
|
||||
)
|
||||
|
||||
export const blocksByHeight$ = state(
|
||||
recordedBlocks$.pipe(
|
||||
mergeMap((change) => {
|
||||
const targets$ = forkJoin(
|
||||
[...change.keys].map((hash) => blockInfo$(hash).pipe(take(1))),
|
||||
)
|
||||
|
||||
return targets$.pipe(
|
||||
map((targets) => ({
|
||||
type: change.type,
|
||||
targets,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
scan(
|
||||
(acc, evt) => {
|
||||
if (evt.type === "remove") {
|
||||
for (const { hash, number } of evt.targets) {
|
||||
acc[number]?.delete(hash)
|
||||
if (!acc[number]?.size) {
|
||||
delete acc[number]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const block of evt.targets) {
|
||||
acc[block.number] = acc[block.number] ?? new Map()
|
||||
acc[block.number].set(block.hash, block)
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<number, Map<string, BlockInfo>>,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function withInitializedNumber() {
|
||||
return (source$: Observable<FollowEventWithRuntime>) =>
|
||||
source$.pipe(
|
||||
withLatestFrom(chainHead$),
|
||||
concatMap(([event, chainHead]) => {
|
||||
return event.type !== "initialized"
|
||||
? of(event)
|
||||
: chainHead.header$(event.finalizedBlockHashes[0]).pipe(
|
||||
map((header) => ({
|
||||
...event,
|
||||
number: header.number,
|
||||
parentHash: header.parentHash,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const getBlockStatus$ = (
|
||||
chainHead: ChainHead$,
|
||||
hash: string,
|
||||
number: number,
|
||||
): Observable<BlockState> =>
|
||||
chainHead.pinnedBlocks$.pipe(
|
||||
take(1),
|
||||
switchMap((pinnedBlocks) => {
|
||||
const block = (hash: string) => pinnedBlocks.blocks.get(hash)
|
||||
const blockNum = (hash: string) => block(hash)?.number
|
||||
const finalized = blockNum(pinnedBlocks.finalized)!
|
||||
if (number <= finalized) {
|
||||
// assume we are in the list of finalized blocks on `initialized`
|
||||
return of(BlockState.Finalized)
|
||||
}
|
||||
|
||||
const getInitialState = (pinnedBlocks: PinnedBlocks) => {
|
||||
let bestBranch = pinnedBlocks.best
|
||||
while (blockNum(bestBranch)! > finalized && hash !== bestBranch) {
|
||||
bestBranch = block(bestBranch)!.parent
|
||||
}
|
||||
return block(bestBranch)?.hash === hash
|
||||
? BlockState.Best
|
||||
: BlockState.Fork
|
||||
}
|
||||
const initialState = getInitialState(pinnedBlocks)
|
||||
|
||||
return chainHead.follow$.pipe(
|
||||
concatMap((evt) => {
|
||||
switch (evt.type) {
|
||||
case "bestBlockChanged":
|
||||
return chainHead.pinnedBlocks$.pipe(take(1), map(getInitialState))
|
||||
case "finalized":
|
||||
if (evt.finalizedBlockHashes.includes(hash))
|
||||
return of(BlockState.Finalized)
|
||||
if (evt.prunedBlockHashes.includes(hash))
|
||||
return of(BlockState.Pruned)
|
||||
}
|
||||
return of(null)
|
||||
}),
|
||||
filter((v) => v !== null),
|
||||
takeWhile(
|
||||
(v) => v !== BlockState.Finalized && v !== BlockState.Pruned,
|
||||
true,
|
||||
),
|
||||
startWith(initialState),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Explorer"
|
||||
@@ -4,6 +4,12 @@ export const Header = () => (
|
||||
<div className="flex bg-polkadot-900 p-2 items-center flex-shrink-0">
|
||||
<h1 className="text-xl flex-1">PAPI Console</h1>
|
||||
<div className="flex flex-row items-center justify-end gap-2">
|
||||
<Link
|
||||
to="/explorer"
|
||||
className="text-polkadot-200 border-polkadot-200 cursor-pointer border-b hover:text-polkadot-500 hover:border-polkadot-500"
|
||||
>
|
||||
Explorer
|
||||
</Link>
|
||||
<Link
|
||||
to="/storage"
|
||||
className="text-polkadot-200 border-polkadot-200 cursor-pointer border-b hover:text-polkadot-500 hover:border-polkadot-500"
|
||||
|
||||
Reference in New Issue
Block a user