diff --git a/eslint.config.js b/eslint.config.js index 6013f7d..db48b39 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -39,6 +39,7 @@ export default tseslint.config( }, ], "react-refresh/only-export-components": "off", + "prefer-const": "warn", }, }, ) diff --git a/package.json b/package.json index 6021c6c..94802b8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/patches/@polkadot-api__observable-client.patch b/patches/@polkadot-api__observable-client.patch index cd09cb9..16434ed 100644 --- a/patches/@polkadot-api__observable-client.patch +++ b/patches/@polkadot-api__observable-client.patch @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb4700b..c27f9a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/App.tsx b/src/App.tsx index f8faa9f..5b3558e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() {
+ } /> } /> } /> } /> diff --git a/src/chain.state.ts b/src/chain.state.ts index 3bba1a9..cdc9e38 100644 --- a/src/chain.state.ts +++ b/src/chain.state.ts @@ -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(), diff --git a/src/pages/Explorer/BlockTable.tsx b/src/pages/Explorer/BlockTable.tsx new file mode 100644 index 0000000..9712fe4 --- /dev/null +++ b/src/pages/Explorer/BlockTable.tsx @@ -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 = [] + + const blockPositions: Record = {} + const positionsTaken = new Set() + 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 ( +
+

+ Recent Blocks +

+ + + {rows.map((row, i) => ( + + {rows[i - 1]?.block.number !== row.block.number ? ( + + ) : null} + + + + ))} + +
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()} + + + +
+
finalized.number + ? "opacity-80" + : "opacity-50", + )} + > + {row.block.hash} +
+ +
+
+
+ ) +} + +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 ( + + {row.branches.map((branch, i) => ( + + ))} + {row.branched != null ? ( + + ) : row.branches.includes(row.position) ? null : ( + + )} + + + ) +} diff --git a/src/pages/Explorer/BlockTime.tsx b/src/pages/Explorer/BlockTime.tsx new file mode 100644 index 0000000..0b5cc9e --- /dev/null +++ b/src/pages/Explorer/BlockTime.tsx @@ -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 ( + + + {progress ? ( + + ) : null} + {progress && progress > 1 ? ( + + ) : null} + + {time}s + + + ) +} diff --git a/src/pages/Explorer/Events.tsx b/src/pages/Explorer/Events.tsx new file mode 100644 index 0000000..033a5a7 --- /dev/null +++ b/src/pages/Explorer/Events.tsx @@ -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 ( +
+

+ Recent Events +

+ + + {events.map((evt) => ( + + + + + ))} + +
{`${evt.event.type}.${evt.event.value.type}`}{`${evt.number}-${evt.extrinsicNumber}`}
+ {events.length === 0 ? ( +
(No events yet)
+ ) : null} +
+ ) +} + +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), + ), + ), + [], +) diff --git a/src/pages/Explorer/Explorer.tsx b/src/pages/Explorer/Explorer.tsx new file mode 100644 index 0000000..5b17d2f --- /dev/null +++ b/src/pages/Explorer/Explorer.tsx @@ -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( + () => ( +
+ +
+ + +
+
+ ), + { + source$: explorer$, + }, +) diff --git a/src/pages/Explorer/Summary.tsx b/src/pages/Explorer/Summary.tsx new file mode 100644 index 0000000..dec39e6 --- /dev/null +++ b/src/pages/Explorer/Summary.tsx @@ -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 ( +
+ + + +
+ + {finalized$} + + + {best$} + +
+ ) +} + +const SummaryItem: FC< + PropsWithChildren<{ title: string; className?: string }> +> = ({ title, className, children }) => { + return ( +
+

{title}

+
{children}
+
+ ) +} diff --git a/src/pages/Explorer/block.state.ts b/src/pages/Explorer/block.state.ts new file mode 100644 index 0000000..3f87cb5 --- /dev/null +++ b/src/pages/Explorer/block.state.ts @@ -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 = {} + 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 + }, + ), + mergeMap(({ value }) => value), + ), + ), + ), + (v) => v.hash, + (initialized$) => + initialized$.pipe( + take(1), + withLatestFrom(chainHead$), + switchMap( + ([{ hash, parent, number }, chainHead]): Observable => + 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>, + ), + ), +) + +function withInitializedNumber() { + return (source$: Observable) => + 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 => + 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), + ) + }), + ) diff --git a/src/pages/Explorer/index.ts b/src/pages/Explorer/index.ts new file mode 100644 index 0000000..55d855a --- /dev/null +++ b/src/pages/Explorer/index.ts @@ -0,0 +1 @@ +export * from "./Explorer" diff --git a/src/pages/Header.tsx b/src/pages/Header.tsx index 6be2a45..e163e7f 100644 --- a/src/pages/Header.tsx +++ b/src/pages/Header.tsx @@ -4,6 +4,12 @@ export const Header = () => (

PAPI Console

+ + Explorer +