feat: console workspace panel (#143)

* worksapace mockup

* create global state, rewrite UI

* wire up storage with workspace

* fix browser warnings

* wire up transactions, refactor state to have it persist

* cleanup extrinsic workspace entry

* refactor: manage storage subscriptions from workspace

* implement filters

* add tooltips to ambiguous icons

* explorer workspace

* add runtime calls and viewFns to workspace

* add constants to workspace

* add rpc calls to workspace

* remove pinned filter

* synchronize storage inputs with id params

* fix build

* fix copilot's comments: code clarity, pinned state getting reset
This commit is contained in:
Victor Oliva
2026-05-21 15:45:18 +02:00
committed by GitHub
parent 20574d8452
commit 60e6ff77f0
43 changed files with 2008 additions and 948 deletions
-2
View File
@@ -10,7 +10,6 @@ import { Metadata } from "./pages/Metadata"
import { RpcCalls } from "./pages/RpcCalls"
import { RuntimeCalls } from "./pages/RuntimeCalls"
import { Storage } from "./pages/Storage"
import { Transactions } from "./pages/Transactions"
import { ViewFns } from "./pages/ViewFns"
export default function App() {
@@ -30,7 +29,6 @@ export default function App() {
<Route path="*" element={<Navigate to="/explorer" replace />} />
</Routes>
</AppShell>
<Transactions />
<VaultTxModal />
<ConnectionState />
</div>
+55
View File
@@ -0,0 +1,55 @@
import { Button } from "@polkahub/ui-components"
import { BookmarkPlus } from "lucide-react"
import { ButtonHTMLAttributes, ComponentProps, FC, ReactNode } from "react"
import { Link } from "@/hashParams"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
import { twMerge } from "tailwind-merge"
import { actionButtonClassName } from "./ActionButton"
export const IconButton: FC<
ComponentProps<typeof Button> & {
tooltip: ReactNode
}
> = ({ tooltip, ...props }) => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" {...props} />
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
export const IconLink: FC<
ComponentProps<typeof Link> & {
tooltip: ReactNode
}
> = ({ tooltip, ...props }) => (
<Tooltip>
<TooltipTrigger asChild>
<Link {...props} />
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
export const IconActionButton: FC<
ButtonHTMLAttributes<any> & {
tooltip: ReactNode
}
> = ({ tooltip, ...props }) => (
<Tooltip>
<TooltipTrigger asChild>
<button
{...props}
className={twMerge(actionButtonClassName(), props.className)}
/>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
export const AddToWorkspace: FC<ComponentProps<typeof Button>> = (props) => (
<IconButton {...props} tooltip="Add to workspace">
<BookmarkPlus className="size-5" />
</IconButton>
)
+2 -1
View File
@@ -92,7 +92,7 @@ export const SearchableSelect = <T,>({
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className={twMerge("w-[200px] p-0", contentClassName)}>
<PopoverContent className={twMerge("w-50 p-0", contentClassName)}>
<Command shouldFilter={!allowCustomValue}>
<CommandInput placeholder="Filter…" onValueChange={setFilterValue} />
<CommandList>
@@ -104,6 +104,7 @@ export const SearchableSelect = <T,>({
onSelect={() => {
setValue(option.value)
setOpen(false)
setFilterValue("")
}}
>
{option.text}
@@ -0,0 +1,329 @@
import { IconButton } from "@/components/IconButton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Link } from "@/hashParams"
import { useStateObservable } from "@react-rxjs/core"
import {
Activity,
CheckLine,
CircleX,
Dock,
History,
Hourglass,
PanelRightOpen,
Pin,
X,
} from "lucide-react"
import {
ComponentType,
FC,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { twMerge } from "tailwind-merge"
import {
OperationStatus,
WorkspaceEntry,
pinWorkspaceEntry,
removeWorkspaceEntry,
setWorkspaceDocked,
setWorkspaceFilter,
setWorkspaceOpen,
workspaceDocked$,
workspaceEntries$,
workspaceFilter$,
workspaceOpen$,
workspaceSources$,
} from "./workspace.state"
export const HistoryDrawerTrigger: FC = () => {
const open = useStateObservable(workspaceOpen$)
const docked = useStateObservable(workspaceDocked$())
const isDockedViewport = useIsDockedViewport()
const effectiveDocked = docked && isDockedViewport
return open && effectiveDocked ? null : (
<IconButton
tooltip="Open workspace"
className={twMerge("relative shrink-0 text-foreground hover:bg-accent")}
onClick={() => setWorkspaceOpen(true)}
aria-label="Open workspace"
>
<History className="h-5 w-5" />
</IconButton>
)
}
export const HistoryDrawer = () => {
const open = useStateObservable(workspaceOpen$)
const docked = useStateObservable(workspaceDocked$())
const workspaceEntries = useStateObservable(workspaceEntries$)
const workspaceFilter = useStateObservable(workspaceFilter$)
const workspaceSources = useStateObservable(workspaceSources$)
const isDockedViewport = useIsDockedViewport()
const effectiveDocked = docked && isDockedViewport
const drawerRef = useRef<HTMLElement>(null)
const closeHistory = useCallback(() => {
const activeElement = document.activeElement
if (
activeElement instanceof HTMLElement &&
drawerRef.current?.contains(activeElement)
) {
// Move focus out before hiding the drawer; otherwise the browser warns
// about hiding an element that still contains the focused control.
activeElement.blur()
}
setWorkspaceOpen(false)
}, [])
useEffect(() => {
if (!open || effectiveDocked) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeHistory()
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [closeHistory, effectiveDocked, open])
return (
<>
{open && !effectiveDocked ? (
// Transparent backdrop that captures clicks outside the undocked drawer.
// It intentionally has no visual overlay so the page stays readable.
<button
type="button"
tabIndex={-1}
className="fixed inset-0 z-30 cursor-default bg-transparent"
onClick={closeHistory}
aria-label="Close workspace"
/>
) : null}
<aside
ref={drawerRef}
aria-label="Workspace"
inert={!open}
className={twMerge(
"fixed inset-y-0 right-0 z-40 flex w-[min(92vw,28rem)] translate-x-full flex-col border-l bg-background shadow-xl transition-transform duration-200",
docked &&
"xl:static xl:z-auto xl:h-screen xl:translate-x-0 xl:shadow-none",
open ? "translate-x-0" : "pointer-events-none",
!open && effectiveDocked && "xl:hidden",
)}
>
<div className="flex h-16 items-center gap-1 border-b px-4 py-3">
<h2 className="text-base font-semibold flex-1">Workspace</h2>
<IconButton
tooltip={docked ? "Undock workspace" : "Dock workspace"}
className="hidden h-8 w-8 xl:inline-flex"
onClick={() => setWorkspaceDocked(!docked)}
title={docked ? "Undock workspace" : "Dock workspace"}
aria-label={docked ? "Undock workspace" : "Dock workspace"}
>
{docked ? <PanelRightOpen size={16} /> : <Dock size={16} />}
</IconButton>
<Button
variant="ghost"
size="sm"
className="h-8 shrink-0"
disabled={!workspaceEntries.length}
onClick={() =>
workspaceEntries.forEach((entry) =>
removeWorkspaceEntry(entry.data.id),
)
}
>
Clear
</Button>
<Button
variant="ghost"
size="icon"
onClick={closeHistory}
aria-label="Close workspace drawer"
>
<X size={16} />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-2 overflow-x-auto border-b px-4 py-2">
<WorkspaceFilterBadge
active={workspaceFilter === null}
onClick={() => setWorkspaceFilter(null)}
>
All
</WorkspaceFilterBadge>
{workspaceSources.map((source) => (
<WorkspaceFilterBadge
key={source}
active={workspaceFilter === source}
onClick={() => setWorkspaceFilter(source)}
>
{source}
</WorkspaceFilterBadge>
))}
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{workspaceEntries.map((entry) => (
<OperationCard key={entry.data.id} entry={entry} />
))}
</div>
</div>
</aside>
</>
)
}
const WorkspaceFilterBadge: FC<{
active: boolean
onClick: () => void
children: string
}> = ({ active, onClick, children }) => (
<Badge
asChild
variant={active ? "default" : "outline"}
className={twMerge(
"cursor-pointer select-none",
active && "bg-polkadot-500 text-white",
)}
>
<button type="button" onClick={onClick}>
{children}
</button>
</Badge>
)
const DOCKED_DRAWER_MEDIA_QUERY = "(min-width: 80rem)"
const useIsDockedViewport = () => {
const [isDockedViewport, setIsDockedViewport] = useState(() =>
typeof window === "undefined"
? false
: window.matchMedia(DOCKED_DRAWER_MEDIA_QUERY).matches,
)
useEffect(() => {
const mediaQuery = window.matchMedia(DOCKED_DRAWER_MEDIA_QUERY)
const updateDockedViewport = () => setIsDockedViewport(mediaQuery.matches)
updateDockedViewport()
mediaQuery.addEventListener("change", updateDockedViewport)
return () => mediaQuery.removeEventListener("change", updateDockedViewport)
}, [])
return isDockedViewport
}
const OperationCard: FC<{ entry: WorkspaceEntry }> = ({ entry }) => {
const {
icon: Icon,
source,
title,
subtitle,
content: Content,
link,
} = entry.data
return (
<article className="rounded-md border bg-card text-card-foreground shadow-xs">
<div className="flex items-start gap-3 border-b px-3 py-2.5">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-accent text-accent-foreground">
<Icon size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
{source}
</span>
<StatusBadge status={entry.status} />
</div>
<div className="mt-1 truncate text-sm font-medium">
{link ? (
<Link to={link} className="underline">
{title}
</Link>
) : (
title
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{subtitle}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
className={twMerge(
"rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
entry.pinned
? "border-sky-500/30 bg-sky-500/10 hover:bg-sky-500/20 text-sky-700 dark:text-sky-300"
: null,
)}
aria-label={`Pin ${title}`}
onClick={() => pinWorkspaceEntry(entry.data.id)}
>
<Pin size={14} />
</button>
<button
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label={`Remove ${title}`}
onClick={() => removeWorkspaceEntry(entry.data.id)}
>
<X size={14} />
</button>
</div>
</div>
<div className="max-h-[30svh] overflow-auto">
<Content id={entry.data.id} context={entry.data.context} />
</div>
</article>
)
}
const StatusBadge: FC<{ status?: OperationStatus }> = ({ status }) => {
if (!status) return null
const Icon = statusIcons[status]
return (
<span
className={twMerge(
"inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium leading-none",
statusClasses[status],
)}
>
<Icon size={11} />
{statusLabels[status]}
</span>
)
}
const statusLabels: Record<OperationStatus, string> = {
live: "Live",
pending: "Pending",
done: "Done",
error: "Error",
}
const statusIcons: Record<
OperationStatus,
ComponentType<{ size?: number; className?: string }>
> = {
live: Activity,
pending: Hourglass,
done: CheckLine,
error: CircleX,
}
const statusClasses: Record<OperationStatus, string> = {
live: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
pending:
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
done: "border-muted bg-muted text-muted-foreground",
error: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./WorkspaceDrawer"
export * from "./workspace.state"
+204
View File
@@ -0,0 +1,204 @@
import { createLocalStorageState, createState } from "@/lib/externalState"
import { selectedChainChanged$ } from "@/state/chains/chain.state"
import { shareLatest, state } from "@react-rxjs/core"
import {
combineKeys,
createKeyedSignal,
createSignal,
partitionByKey,
toKeySet,
} from "@react-rxjs/utils"
import { ComponentType } from "react"
import {
catchError,
combineLatest,
defer,
distinctUntilChanged,
from,
map,
merge,
Observable,
ObservableInput,
scan,
startWith,
switchMap,
take,
takeUntil,
tap,
} from "rxjs"
export const [workspaceDocked$, setWorkspaceDocked] = createLocalStorageState(
"workspace-docked",
true,
)
export const [workspaceOpen$, setWorkspaceOpen] = createState(false)
export type OperationStatus = "live" | "pending" | "error" | "done"
export type WorkspaceEntryData<T = any> = {
id: string
// Explorer, Storage, Extrinsics, etc.
source: string
// The main action - usually Pallet.name
title: string
// A small description with additional info. E.g. block number, signer, etc.
subtitle?: string
link?: string
icon: ComponentType<{ size?: number; className?: string }>
content: ComponentType<{ id: string; context: T }>
status?: Observable<OperationStatus>
context?: T
}
export type WorkspaceEntry<T = any> = {
timestamp: number
pinned: boolean
data: WorkspaceEntryData<T>
status?: OperationStatus
}
const [newWorkspaceEntry$, _pushWorkspaceEntry] =
createSignal<WorkspaceEntryData>()
export const pushWorkspaceEntry: <T>(payload: WorkspaceEntryData<T>) => void =
_pushWorkspaceEntry
export const [removeWorkspaceEntry$, removeWorkspaceEntry] =
createKeyedSignal<string>()
export const [pinWorkspaceEntry$, pinWorkspaceEntry] =
createKeyedSignal<string>()
export const [workspaceEntry$, workspaceEntryKeys$] = partitionByKey(
newWorkspaceEntry$,
(data) => data.id,
(group$, id) =>
combineLatest({
data: group$,
pinned: pinWorkspaceEntry$(id).pipe(
scan((acc) => !acc, false),
startWith(false),
),
}).pipe(
switchMap(({ data, pinned }): ObservableInput<WorkspaceEntry> => {
const timestamp = Date.now()
if (!data.status) {
return [
{
data,
pinned,
timestamp,
},
]
}
return data.status.pipe(
startWith(undefined),
catchError((ex): ObservableInput<OperationStatus> => {
console.error(ex)
return ["error"]
}),
map(
(status): WorkspaceEntry => ({
data,
pinned,
timestamp,
status,
}),
),
)
}),
takeUntil(merge(removeWorkspaceEntry$(id), selectedChainChanged$)),
),
)
export const [workspaceFilter$, setWorkspaceFilter] = createState<
string | null
>(null)
const allWorkspaceEntries$ = state(
combineKeys(workspaceEntryKeys$, workspaceEntry$).pipe(
map((entryMap) => {
const entries = [...entryMap.values()]
entries.sort((a, b) => {
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
return b.timestamp - a.timestamp
})
return entries
}),
),
[],
)
export const workspaceEntries$ = state(
combineLatest([
allWorkspaceEntries$,
workspaceFilter$.pipe(distinctUntilChanged()),
]).pipe(
map(([entries, workspaceFilter]) => {
if (workspaceFilter) {
entries = entries.filter(
(entry) => entry.data.source === workspaceFilter,
)
}
return entries
}),
tap((entries) => {
if (entries.length === 0) {
setWorkspaceFilter(null)
}
}),
),
[],
)
export const workspaceSources$ = state(
allWorkspaceEntries$.pipe(
map((entries) =>
[...new Set(entries.map((entry) => entry.data.source))].sort((a, b) =>
a.localeCompare(b),
),
),
),
[],
)
const keySet$ = workspaceEntryKeys$.pipe(toKeySet(), shareLatest())
export const workspaceEntryCtxOr$ = <T>(id: string, fallback$: Observable<T>) =>
keySet$.pipe(
take(1),
switchMap((keys) =>
keys.has(id)
? workspaceEntry$(id).pipe(
take(1),
map((v) => v.data.context as T),
)
: fallback$,
),
shareLatest(),
)
export const workspaceEntryCtxOrAdd$ = <T>(
id: string,
fallback: () => Promise<WorkspaceEntryData<T>>,
) =>
workspaceEntryCtxOr$(
id,
defer(() => {
const data = fallback()
return from(data).pipe(
tap((entryData) => {
if (entryData.id !== id) {
console.warn(
"New id doesn't match existing id. This could create multiple instances in the long run",
{
id,
entryData,
},
)
}
pushWorkspaceEntry(entryData)
}),
map((data) => data.context!),
)
}),
)
+11
View File
@@ -120,3 +120,14 @@ export const createLocalStorageState = <T>(
return [state$, setValue] as const
}
export const createState = <T>(defaultValue: T) => {
const [valueChange$, setValue] = createSignal<T | null>()
const state$ = state(
valueChange$.pipe(map((v) => (v === null ? defaultValue : v))),
defaultValue,
)
return [state$, setValue] as const
}
+1 -5
View File
@@ -9,15 +9,11 @@ import App from "./App.tsx"
import { TooltipProvider } from "./components/ui/tooltip.tsx"
import "./index.css"
import { explorer$ } from "./pages/Explorer"
import { storage$ } from "./pages/Storage/storage.state.ts"
import { transactions$ } from "./pages/Transactions"
import { polkaHub } from "./state/polkahub.ts"
import { ThemeProvider } from "./ThemeProvider.tsx"
createRoot(document.getElementById("root")!).render(
<Subscribe
source$={merge(dynamicBuilder$, explorer$, transactions$, storage$)}
>
<Subscribe source$={merge(dynamicBuilder$, explorer$)}>
<RemoveSubscribe>
<StrictMode>
<ThemeProvider>
+10 -3
View File
@@ -2,8 +2,9 @@ import {
GlobalCommandPalette,
type NavigationItem,
} from "@/components/GlobalCommandPalette"
import SliderToggle from "@/components/Toggle"
import { HistoryDrawer, HistoryDrawerTrigger } from "@/components/Workspace"
import { GithubIcon } from "@/components/Icons"
import SliderToggle from "@/components/Toggle"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Link } from "@/hashParams"
@@ -116,6 +117,7 @@ export const AppShell: FC<PropsWithChildren> = ({ children }) => {
</div>
</div>
</div>
<HistoryDrawer />
</div>
)
}
@@ -178,7 +180,9 @@ const SidebarContent: FC<{ mobile?: boolean; onNavigate?: () => void }> = ({
</div>
)
const TopBar: FC<{ onOpenSidebar: () => void }> = ({ onOpenSidebar }) => {
const TopBar: FC<{
onOpenSidebar: () => void
}> = ({ onOpenSidebar }) => {
return (
<header className="flex h-16 shrink-0 items-center border-b bg-background/95 px-3 lg:px-4">
<Button
@@ -194,7 +198,10 @@ const TopBar: FC<{ onOpenSidebar: () => void }> = ({ onOpenSidebar }) => {
<div className="ml-3 hidden sm:block">
<NetworkSwitcher className="w-55" />
</div>
<GlobalCommandPalette navigationItems={navigationItems} />
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
<GlobalCommandPalette navigationItems={navigationItems} />
<HistoryDrawerTrigger />
</div>
</div>
</header>
)
+59 -7
View File
@@ -2,6 +2,8 @@ import { ViewCodec } from "@/codec-components/ViewCodec"
import { ButtonGroup } from "@/components/ButtonGroup"
import { DocsRenderer } from "@/components/DocsRenderer"
import { ExpandBtn } from "@/components/Expand"
import { AddToWorkspace } from "@/components/IconButton"
import { JsonDisplay } from "@/components/JsonDisplay"
import { LoadingMetadata } from "@/components/Loading"
import {
Tooltip,
@@ -9,15 +11,17 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { withSubscribe } from "@/components/withSuspense"
import { pushWorkspaceEntry, setWorkspaceOpen } from "@/components/Workspace"
import { lookup$, runtimeCtx$ } from "@/state/chains/chain.state"
import { getTypeComplexity } from "@/utils/shape"
import { CodecComponentType } from "@polkadot-api/react-builder"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { Dot } from "lucide-react"
import { Dot, SquareEqual } from "lucide-react"
import { HexString } from "polkadot-api"
import { FC, useState } from "react"
import { map } from "rxjs"
import { firstValueFrom, map } from "rxjs"
import { twMerge } from "tailwind-merge"
import { v4 } from "uuid"
import { ValueDisplay } from "./Storage/StorageSubscriptions"
const metadataConstants$ = state(
@@ -42,7 +46,7 @@ export const Constants = withSubscribe(
<div className="p-4 pb-0 flex flex-col gap-2 items-start leading-relaxed">
<ul>
{entries.map(({ name, constants }) => (
<PalletConstants key={name} name={name} entries={constants} />
<PalletConstants key={name} pallet={name} entries={constants} />
))}
</ul>
</div>
@@ -54,24 +58,44 @@ export const Constants = withSubscribe(
)
const PalletConstants: FC<{
name: string
pallet: string
entries: Array<{
name: string
type: number
value: HexString
docs: string[]
}>
}> = ({ name, entries }) => {
}> = ({ pallet, entries }) => {
const [expanded, setExpanded] = useState(false)
return (
<li>
<li className="group">
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setExpanded((e) => !e)}
>
<ExpandBtn expanded={expanded} />
<div>{name}</div>
<div>{pallet}</div>
<AddToWorkspace
className="h-7 w-7 opacity-0 group-hover:opacity-100"
onClick={async (evt) => {
evt.stopPropagation()
const ctx = await firstValueFrom(runtimeCtx$)
addPalletConstantsToWorkspace(
pallet,
Object.fromEntries(
entries.map((entry) => [
entry.name,
ctx.dynamicBuilder
.buildConstant(pallet, entry.name)
.dec(entry.value),
]),
),
)
setWorkspaceOpen(true)
}}
/>
</div>
{expanded && (
<ul>
@@ -84,6 +108,34 @@ const PalletConstants: FC<{
)
}
type ConstantsWorkspaceContext = {
values: Record<string, unknown>
}
const addPalletConstantsToWorkspace = (
pallet: string,
values: Record<string, unknown>,
) => {
pushWorkspaceEntry({
id: v4(),
source: "Constants",
title: `${pallet}`,
icon: SquareEqual,
context: { values },
content: ConstantsWorkspaceEntry,
})
}
const ConstantsWorkspaceEntry: FC<{ context: ConstantsWorkspaceContext }> = ({
context,
}) => {
return (
<div className="space-y-3 p-3 text-sm">
<JsonDisplay collapsed={1} src={context.values} />
</div>
)
}
const constantValueProps$ = state(
runtimeCtx$.pipe(
map(({ dynamicBuilder, lookup }) => ({
+2
View File
@@ -10,6 +10,7 @@ import { state, useStateObservable } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import { FC, ReactNode, useContext } from "react"
import { filter, map, startWith, switchMap, take } from "rxjs"
import { AddBlockToWorkspace } from "../ExplorerWorkspaceEntry"
import { BlockAuthor } from "./BlockAuthor"
import { BlockStatusIcon, statusText } from "./BlockState"
@@ -23,6 +24,7 @@ export const BlockInfoView: FC = () => {
<div className="space-y-2">
<p className="text-3xl font-semibold tracking-tight">
#{block.number.toLocaleString()}
<AddBlockToWorkspace block={block} />
</p>
<div className="flex flex-wrap items-center gap-2 text-sm font-mono text-foreground/90">
<span className="truncate max-w-full">{block.hash}</span>
+11 -5
View File
@@ -1,13 +1,13 @@
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
import { CopyText } from "@/components/Copy"
import { ExpandBtn } from "@/components/Expand"
import { IconLink } from "@/components/IconButton"
import {
PriorityValue,
Sender,
SignedExtensions,
} from "@/components/Extrinsics"
import { JsonDisplay } from "@/components/JsonDisplay"
import { Link } from "@/hashParams"
import { cn, shortStr } from "@/utils"
import { SystemEvent } from "@polkadot-api/observable-client"
import { DecodedExtrinsic } from "@polkadot-api/tx-utils"
@@ -66,12 +66,18 @@ export const Extrinsic: FC<{
</button>
<div className="flex gap-2 items-center">
<CopyBinary value={extrinsic.callData} />
<Link to={`/extrinsics/analyzer#extrinsic=${rawExtrinsic}`}>
<IconLink
tooltip="Analyze extrinsic"
to={`/extrinsics/analyzer#extrinsic=${rawExtrinsic}`}
>
<FileSearch size={15} />
</Link>
<Link to={"/extrinsics#data=" + toHex(extrinsic.callData)}>
</IconLink>
<IconLink
tooltip="Edit extrinsic"
to={"/extrinsics#data=" + toHex(extrinsic.callData)}
>
<Edit size={14} />
</Link>
</IconLink>
</div>
</div>
{expanded ? (
@@ -0,0 +1,69 @@
import { AddToWorkspace } from "@/components/IconButton"
import { pushWorkspaceEntry, setWorkspaceOpen } from "@/components/Workspace"
import { Link } from "@/hashParams"
import { BlockInfo, blockInfoState$ } from "@/state/block.state"
import { shortStr } from "@/utils"
import { useStateObservable } from "@react-rxjs/core"
import { GitGraph } from "lucide-react"
import { FC } from "react"
import { BlockStatusIcon, statusText } from "./Detail/BlockState"
export const AddBlockToWorkspace: FC<{ block: BlockInfo }> = ({ block }) => (
<AddToWorkspace
className="align-baseline ml-1"
onClick={() => {
pushWorkspaceEntry({
id: `explorer:block:${block.hash}`,
source: "Explorer",
title: `Block #${block.number.toLocaleString()}`,
subtitle: shortStr(block.hash, 8),
link: `/explorer/${block.hash}`,
icon: GitGraph,
context: { hash: block.hash },
content: BlockWorkspaceEntry,
})
setWorkspaceOpen(true)
}}
/>
)
const BlockWorkspaceEntry: FC<{ context: { hash: string } }> = ({
context,
}) => {
const block = useStateObservable(blockInfoState$(context.hash))
if (!block) {
return (
<div className="p-3 text-sm text-muted-foreground">Loading block...</div>
)
}
return (
<div className="space-y-3 p-3 text-sm">
<dl className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-x-3 gap-y-2">
<dt className="text-muted-foreground">Status</dt>
<dd className="flex min-w-0 items-center gap-1">
<BlockStatusIcon state={block.status} size={16} />
{statusText[block.status]}
</dd>
<dt className="text-muted-foreground">Height</dt>
<dd>{block.number.toLocaleString()}</dd>
<dt className="text-muted-foreground">Hash</dt>
<dd className="flex min-w-0 items-center gap-1 font-mono">
<Link className="truncate underline" to={`/explorer/${block.hash}`}>
{shortStr(block.hash, 8)}
</Link>
</dd>
<dt className="text-muted-foreground">Parent</dt>
<dd className="flex min-w-0 items-center gap-1 font-mono">
<Link className="truncate underline" to={`/explorer/${block.parent}`}>
{shortStr(block.parent, 8)}
</Link>
</dd>
</dl>
</div>
)
}
+10 -10
View File
@@ -1,9 +1,10 @@
import { BinaryDisplay } from "@/codec-components/LookupTypeEdit"
import { ActionButton, actionButtonClassName } from "@/components/ActionButton"
import { actionButtonClassName } from "@/components/ActionButton"
import { ButtonGroup } from "@/components/ButtonGroup"
import { IconActionButton, IconLink } from "@/components/IconButton"
import { LoadingMetadata } from "@/components/Loading"
import { withSubscribe } from "@/components/withSuspense"
import { getHashParams, Link, useSyncHashParam } from "@/hashParams"
import { getHashParams, useSyncHashParam } from "@/hashParams"
import { runtimeCtx$ } from "@/state/chains/chain.state"
import { cn } from "@/utils"
import {
@@ -141,22 +142,21 @@ const ExtrinsicEditor: FC<{
]}
/>
<div className="flex flex-row items-center gap-2">
<Link
<IconLink
tooltip="Analyze extrinsic"
to="/extrinsics/analyzer"
className={cn(
actionButtonClassName(),
"text-foreground/70 flex items-center gap-1",
)}
className={cn(actionButtonClassName(), "text-foreground/70")}
>
<FileSearch />
</Link>
<ActionButton
</IconLink>
<IconActionButton
tooltip="Custom signed extensions"
className="text-foreground/70 flex items-center gap-1"
onClick={() => setPage("extensions")}
>
{customExtensionsCount$}
<Settings />
</ActionButton>
</IconActionButton>
<ExtrinsicModal callData={binaryValue ?? undefined} />
</div>
</div>
@@ -0,0 +1,201 @@
import { CopyText } from "@/components/Copy"
import { JsonDisplay } from "@/components/JsonDisplay"
import {
OperationStatus,
pushWorkspaceEntry,
setWorkspaceOpen,
} from "@/components/Workspace"
import { Link } from "@/hashParams"
import { BlockState } from "@/state/block.state"
import { chainClient$ } from "@/state/chains/chain.state"
import { shortStr } from "@/utils"
import { state, StateObservable, useStateObservable } from "@react-rxjs/core"
import { Send } from "lucide-react"
import { InvalidTxError, TxBroadcastEvent, TxCallData } from "polkadot-api"
import { jsonSerialize, toHex } from "polkadot-api/utils"
import { Account } from "polkahub"
import { FC } from "react"
import { catchError, filter, map, of, tap } from "rxjs"
import { v4 } from "uuid"
import { BlockStatusIcon } from "../Explorer/Detail/BlockState"
type TrackedTransactionEvent =
| (TxBroadcastEvent & { raw: Uint8Array; callData: TxCallData })
| {
type: "invalid" | "error"
value: any
txHash: string
raw: Uint8Array
callData: TxCallData
}
const onGoingEvents = new Set(["signed", "broadcasted", "txBestBlocksState"])
export const trackTx = async (
extrinsic: Uint8Array,
callData: TxCallData,
signer?: Account,
) => {
const { client } = await chainClient$.getValue()
let txHash: string = ""
const tx$: StateObservable<TrackedTransactionEvent | null> = state(
client.submitAndWatch(extrinsic).pipe(
tap((e) => {
txHash = e.txHash
}),
map((event) => ({
...event,
raw: extrinsic,
callData,
})),
catchError((err) => {
console.error(err)
console.log(extrinsic, client.getFinalizedBlock())
return of({
type:
err instanceof InvalidTxError
? ("invalid" as const)
: ("error" as const),
txHash,
raw: extrinsic,
callData,
value: err,
})
}),
),
null,
)
const title = callData ? `${callData.type}.${callData.value.type}` : null
pushWorkspaceEntry({
id: v4(),
source: "Extrinsics",
title: title ?? "Transaction",
// TODO correct format
subtitle: signer ? `Signer ${signer.name ?? signer.address}` : undefined,
icon: Send,
status: tx$.pipe(
filter((v) => v != null),
map(getOperationStatus),
),
content: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const data = useStateObservable(tx$)
return data && <ExtrinsicsWorkspaceEntry event={data} />
},
})
// Forcing it to open as it's the only place where Transactions are tracked
setWorkspaceOpen(true)
}
const ExtrinsicsWorkspaceEntry: FC<{ event: TrackedTransactionEvent }> = ({
event,
}) => {
return (
<div className="space-y-2 p-2 text-sm">
<dl className="grid grid-cols-[4em_minmax(0,1fr)] gap-x-3 gap-y-2">
<div className="contents">
<dt className="text-muted-foreground">Status</dt>
<dd className="truncate flex items-center gap-1">
{getStatus(event)}
</dd>
</div>
<div className="contents">
<dt className="text-muted-foreground">Extrinsic</dt>
<dd className="truncate font-mono flex items-center gap-1">
<Link
className="underline"
to={`/extrinsics/analyzer#extrinsic=${toHex(event.raw)}`}
>
{shortStr(toHex(event.raw), 8)}
</Link>
</dd>
</div>
<div className="contents">
<dt className="text-muted-foreground">Hash</dt>
<dd className="truncate font-mono flex items-center gap-1">
{event.txHash ? shortStr(event.txHash, 8) : ""}
<CopyText text={event.txHash} disabled={!event.txHash} size={14} />
</dd>
</div>
{"block" in event ? (
<div className="contents">
<dt className="text-muted-foreground">Block</dt>
<dd className="truncate font-mono flex items-center gap-1">
<Link
className="truncate underline"
to={`/explorer/${event.block.hash}#tx=${event.block.index}`}
>
{shortStr(event.block.hash, 8)}
</Link>
<BlockStatusIcon
size={20}
state={
event.type === "finalized"
? BlockState.Finalized
: BlockState.Best
}
/>
</dd>
</div>
) : null}
{"ok" in event ? (
<div className="contents">
<dt className="text-muted-foreground">Result</dt>
<dd className="truncate font-bold">
{event.ok ? (
<span className="text-green-500">Succeeded</span>
) : (
<span className="truncate text-red-500">
Failed:{" "}
<span className="font-mono">
{JSON.stringify(event.dispatchError, jsonSerialize)}.
</span>
</span>
)}
</dd>
</div>
) : null}
{event.type === "error" ? (
<div className="contents">
<dt className="text-muted-foreground">Error</dt>
<dd className="truncate font-bold font-mono text-red-500">
{JSON.stringify(event.value, jsonSerialize)}
</dd>
</div>
) : null}
</dl>
<div className="text-xs">
<JsonDisplay src={event.callData.value.value} />
</div>
</div>
)
}
const getStatus = (event: TrackedTransactionEvent): string => {
switch (event.type) {
case "error":
return "Unexpected error"
case "invalid":
return `Invalid`
case "txBestBlocksState":
return event.found ? `Found in best block` : "No longer in a best block"
case "signed":
return `Signed`
case "broadcasted":
return `Broadcasting`
case "finalized":
return "Finalized"
}
}
const getOperationStatus = (
event: TrackedTransactionEvent,
): OperationStatus => {
if (onGoingEvents.has(event.type)) return "pending"
if (event.type === "finalized" && event.ok) return "done"
return "error"
}
+7 -12
View File
@@ -1,11 +1,8 @@
import { ActionButton } from "@/components/ActionButton"
import { Spinner } from "@/components/Icons"
import { cn } from "@/lib/utils"
import { trackSignedTx, trackUnsignedTx } from "@/pages/Transactions"
import { unsafeApi$ } from "@/state/chains/chain.state"
import { selectedAccount$ } from "@/state/polkahub"
import { compactNumber } from "@polkadot-api/substrate-bindings"
import { fromHex, mergeUint8 } from "polkadot-api/utils"
import { AccountPicker } from "@polkahub/ui-components"
import { useStateObservable } from "@react-rxjs/core"
import { Binary } from "polkadot-api"
@@ -18,6 +15,7 @@ import { FC, useState } from "react"
import { combineLatest, firstValueFrom } from "rxjs"
import { twMerge } from "tailwind-merge"
import { customSignedExtensions$ } from "../CustomSignedExt"
import { trackTx } from "../ExtrinsicsWorkspaceEntry"
import { ExtensionProvider } from "./ExtensionProvider"
const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
@@ -41,7 +39,7 @@ const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
const signedExtrinsic = await tx.sign(account.signer, {
customSignedExtensions: signedExt as any,
})
trackSignedTx(signedExtrinsic)
trackTx(signedExtrinsic, tx.decodedCall, account)
onClose()
} catch (ex) {
console.error(ex)
@@ -65,14 +63,11 @@ const SubmitUnsigned: FC<{ callData: string; onClose: () => void }> = ({
}) => {
return (
<ActionButton
onClick={() => {
const data = fromHex(callData)
const unsignedTx = mergeUint8([
compactNumber.enc(data.length + 1),
new Uint8Array([4]),
data,
])
trackUnsignedTx(unsignedTx)
onClick={async () => {
const unsafeApi = await firstValueFrom(unsafeApi$)
const tx = await unsafeApi.txFromCallData(Binary.fromHex(callData))
trackTx(await tx.getBareTx(), tx.decodedCall)
onClose()
}}
className="flex gap-2 items-center justify-center"
+4 -3
View File
@@ -1,5 +1,5 @@
import { ExpandBtn } from "@/components/Expand"
import { Link } from "@/hashParams"
import { IconLink } from "@/components/IconButton"
import { V14Lookup } from "@polkadot-api/substrate-bindings"
import { Edit, Search } from "lucide-react"
import {
@@ -232,12 +232,13 @@ const GenericLookupNode: FC<PropsWithChildren<{ entry: V14Entry }>> = ({
<div className="flex flex-col gap-2 w-full py-2">
<div>
<div className="flex gap-2 items-center">
<Link
<IconLink
tooltip="Edit lookup type"
to={`/metadata/lookup/editor/${entry.id}`}
className="hover:text-polkadot-400"
>
<Edit size={20} />
</Link>
</IconLink>
<h4 className="font-bold">Type: {entry.def.tag}</h4>
</div>
{entry.path.length ? <p>Path: {entry.path.join(".")}</p> : null}
@@ -0,0 +1,33 @@
import { JsonDisplay } from "@/components/JsonDisplay"
import { workspaceEntry$ } from "@/components/Workspace"
import { state, useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import { catchError, of, switchMap } from "rxjs"
const workspaceValue$ = state(
(id: string) =>
workspaceEntry$(id).pipe(
switchMap((v) => v.data.context!.promise),
catchError((ex) =>
of({
type: "error",
value: ex,
}),
),
),
null,
)
export const RpcCallWorkspaceEntry: FC<{
id: string
}> = ({ id }) => {
const value = useStateObservable(workspaceValue$(id))
if (!id) return null
return (
<div className="p-3 text-sm">
<JsonDisplay src={value} collapsed={1} />
</div>
)
}
+2 -4
View File
@@ -46,7 +46,7 @@ export const RpcCalls = withSubscribe(
})
}
const isReady = method !== "" && isJson(params)
const isReady = !!method && isJson(params)
return (
<div className="p-4 pb-0 flex flex-col gap-2 items-start">
@@ -66,9 +66,7 @@ export const RpcCalls = withSubscribe(
</label>
<div className="w-full">
JSON Payload
<Suspense
fallback={<div className="border rounded h-80 w-full" />}
>
<Suspense fallback={<div className="border rounded h-80 w-full" />}>
<Editor
className="border rounded h-80"
language="json"
+27 -1
View File
@@ -1,3 +1,4 @@
import { pushWorkspaceEntry } from "@/components/Workspace"
import { state } from "@react-rxjs/core"
import {
createKeyedSignal,
@@ -5,6 +6,7 @@ import {
partitionByKey,
toKeySet,
} from "@react-rxjs/utils"
import { Cable } from "lucide-react"
import {
catchError,
from,
@@ -16,6 +18,7 @@ import {
takeUntil,
} from "rxjs"
import { v4 as uuid } from "uuid"
import { RpcCallWorkspaceEntry } from "./RpcCallWorkspaceEntry"
export type RpcCallMetadataMethod = {
api: string
@@ -32,7 +35,7 @@ export const [entryChange$, setSelectedMethod] =
createSignal<RpcCallMetadataMethod | null>()
export const selectedEntry$ = state(entryChange$, null)
export const [newRpcCallQuery$, addRpcCallQuery] = createSignal<{
const [newRpcCallQuery$, _addRpcCallQuery] = createSignal<{
method: string
payload: string
promise: Promise<unknown>
@@ -40,6 +43,29 @@ export const [newRpcCallQuery$, addRpcCallQuery] = createSignal<{
export const [removeRpcCallResult$, removeRpcCallResult] =
createKeyedSignal<string>()
export const addRpcCallQuery = (value: {
method: string
payload: string
promise: Promise<unknown>
}) => {
_addRpcCallQuery(value)
pushWorkspaceEntry({
id: uuid(),
icon: Cable,
content: RpcCallWorkspaceEntry,
source: "RPC Calls",
title: value.method,
subtitle: value.payload === "[]" ? undefined : value.payload,
status: from(value.promise).pipe(
map(() => "done" as const),
startWith("pending" as const),
),
context: {
promise: value.promise,
},
})
}
export type RpcCallResult = {
method: string
payload: string
+11 -12
View File
@@ -1,14 +1,11 @@
import {
dynamicBuilder$,
lookup$,
unsafeApi$,
} from "@/state/chains/chain.state"
import {
InlineLookupTypeEdit,
LookupTypeEdit,
} from "@/codec-components/LookupTypeEdit"
import { ActionButton } from "@/components/ActionButton"
import { ExpandBtn } from "@/components/Expand"
import { useNavigate } from "@/hashParams"
import { client$, dynamicBuilder$, lookup$ } from "@/state/chains/chain.state"
import { getTypeComplexity } from "@/utils/shape"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
@@ -29,16 +26,17 @@ import { addRuntimeCallQuery, selectedEntry$ } from "./runtimeCalls.state"
export const RuntimeCallQuery: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const isReady = useStateObservable(isReady$)
const navigate = useNavigate()
if (!selectedEntry) return null
const submit = async () => {
const [entry, unsafeApi, inputValues, builder] = await firstValueFrom(
const [entry, inputValues, builder, block] = await firstValueFrom(
combineLatest([
selectedEntry$,
unsafeApi$,
inputValues$,
dynamicBuilder$,
client$.pipe(switchMap((client) => client.finalizedBlock$)),
]),
)
const decodedValues = inputValues.map((v, i) =>
@@ -46,13 +44,14 @@ export const RuntimeCallQuery: FC = () => {
.buildDefinition(selectedEntry.inputs[i].type)
.dec(v as Uint8Array),
)
const promise = unsafeApi.apis[entry!.api][entry!.name](...decodedValues)
addRuntimeCallQuery({
name: `${entry!.api}.${entry!.name}(…)`,
promise,
type: entry!.output,
const id = await addRuntimeCallQuery({
blockHash: block.hash,
api: entry!.api,
method: entry!.name,
args: decodedValues,
})
navigate(`/runtimeCalls/${id}`)
}
return (
+62 -42
View File
@@ -1,46 +1,59 @@
import { PathsRoot } from "@/codec-components/common/paths.state"
import { ButtonGroup } from "@/components/ButtonGroup"
import { JsonDisplay } from "@/components/JsonDisplay"
import { workspaceEntryCtxOrAdd$ } from "@/components/Workspace"
import { runtimeCtx$ } from "@/state/chains/chain.state"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { Trash2 } from "lucide-react"
import { FC, useState } from "react"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { FC, useMemo, useState } from "react"
import { useParams } from "react-router-dom"
import { ValueDisplay } from "../Storage/StorageSubscriptions"
import { RuntimeCallWorkspaceContext } from "./RuntimeCallWorkspaceEntry"
import {
removeRuntimeCallResult,
RuntimeCallResult,
runtimeCallResult$,
runtimeCallResultKeys$,
idToRuntimeQuery,
runtimeCallToWorkspaceEntry,
} from "./runtimeCalls.state"
export const RuntimeCallResults: FC = () => {
const keys = useStateObservable(runtimeCallResultKeys$)
const { callId } = useParams()
if (!keys.length) return null
if (!callId) return null
return (
<div className="p-2 w-full border-t border-border">
<h2 className="text-lg text-foreground mb-2">Results</h2>
<h2 className="text-lg text-foreground mb-2">Result</h2>
<ul className="flex flex-col gap-2">
{keys.map((key) => (
<RuntimeCallResultBox key={key} subscription={key} />
))}
<RuntimeCallResultBox id={callId} />
</ul>
</div>
)
}
const RuntimeCallResultBox: FC<{ subscription: string }> = ({
subscription,
}) => {
const runtimeCallCtx$ = state(
(id: string) =>
workspaceEntryCtxOrAdd$(id, async () => {
const query = await idToRuntimeQuery(id)
return runtimeCallToWorkspaceEntry(query)
}),
null,
)
const RuntimeCallResultBox: FC<{ id: string }> = ({ id }) => {
const context = useStateObservable(runtimeCallCtx$(id))
return context ? <RuntimeCallResultContent id={id} context={context} /> : null
}
const RuntimeCallResultContent: FC<{
id: string
context: RuntimeCallWorkspaceContext
}> = ({ id, context }) => {
const [mode, setMode] = useState<"json" | "decoded">("decoded")
const runtimeCallResult = useStateObservable(runtimeCallResult$(subscription))
if (!runtimeCallResult) return null
return (
<li className="border rounded bg-card text-card-foreground p-2">
<div className="flex justify-between items-center pb-1 overflow-hidden">
<h3 className="overflow-hidden text-ellipsis whitespace-nowrap">
{runtimeCallResult.name}
{context.api}.{context.method}
</h3>
<div className="flex items-center shrink-0 gap-2">
<ButtonGroup
@@ -57,16 +70,10 @@ const RuntimeCallResultBox: FC<{ subscription: string }> = ({
},
]}
/>
<button onClick={() => removeRuntimeCallResult(subscription)}>
<Trash2
size={20}
className="text-destructive cursor-pointer hover:text-polkadot-500"
/>
</button>
</div>
</div>
<PathsRoot.Provider value={subscription}>
<ResultDisplay runtimeCallResult={runtimeCallResult} mode={mode} />
<PathsRoot.Provider value={id}>
<ResultDisplay context={context} mode={mode} />
</PathsRoot.Provider>
</li>
)
@@ -74,34 +81,47 @@ const RuntimeCallResultBox: FC<{ subscription: string }> = ({
const defaultedCtx$ = runtimeCtx$.pipeState(withDefault(null))
const ResultDisplay: FC<{
runtimeCallResult: RuntimeCallResult
context: RuntimeCallWorkspaceContext
mode: "json" | "decoded"
}> = ({ runtimeCallResult, mode }) => {
}> = ({ context, mode }) => {
const runtimeCallResult = useStateObservable(context.result$)
const ctx = useStateObservable(defaultedCtx$)
const type = useMemo(() => {
const api = ctx?.lookup.metadata.apis.find(
(api) => api.name === context.api,
)
const method = api?.methods.find((method) => method.name === context.method)
return method?.output ?? null
}, [context, ctx])
if (!ctx) return null
if ("error" in runtimeCallResult) {
if (!runtimeCallResult) {
return <div className="text-sm text-foreground/50">Loading…</div>
}
if (!runtimeCallResult.success) {
return (
<div className="text-sm">
<div>The call crashed</div>
<div>Message: {runtimeCallResult.error.message ?? "N/A"}</div>
<div>Message: {runtimeCallResult.value.message ?? "N/A"}</div>
</div>
)
}
if (!("result" in runtimeCallResult)) {
return <div className="text-sm text-foreground/50">Loading…</div>
}
return (
<div className="max-h-[60svh] overflow-auto">
<ValueDisplay
mode={mode}
ctx={ctx}
type={runtimeCallResult.type}
value={runtimeCallResult.result}
title={"Result"}
/>
{type == null ? (
<JsonDisplay src={runtimeCallResult.value} />
) : (
<ValueDisplay
mode={mode}
ctx={ctx}
type={type}
value={runtimeCallResult.value}
title={"Result"}
/>
)}
</div>
)
}
@@ -0,0 +1,27 @@
import { JsonDisplay } from "@/components/JsonDisplay"
import type { DefaultedStateObservable } from "@react-rxjs/core"
import { useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import type { RuntimeCallResult } from "./runtimeCalls.state"
export type RuntimeCallWorkspaceContext = {
api: string
method: string
result$: DefaultedStateObservable<RuntimeCallResult | null>
}
export const RuntimeCallWorkspaceEntry: FC<{
context: RuntimeCallWorkspaceContext
}> = ({ context }) => {
const result = useStateObservable(context.result$)
if (!result) {
return <div className="p-3 text-sm text-muted-foreground">Loading...</div>
}
return (
<div className="p-3 text-sm">
<JsonDisplay src={result.value} />
</div>
)
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { SearchableSelect } from "@/components/Select"
import { withSubscribe } from "@/components/withSuspense"
import { state, useStateObservable } from "@react-rxjs/core"
import { useEffect, useState } from "react"
import { Route, Routes } from "react-router-dom"
import { map } from "rxjs"
import { RuntimeCallQuery } from "./RuntimeCallQuery"
import { RuntimeCallResults } from "./RuntimeCallResults"
@@ -92,7 +93,9 @@ export const RuntimeCalls = withSubscribe(
</div>
)}
<RuntimeCallQuery />
<RuntimeCallResults />
<Routes>
<Route path=":callId" element={<RuntimeCallResults />} />
</Routes>
</div>
)
},
+100 -58
View File
@@ -1,21 +1,24 @@
import { pushWorkspaceEntry, WorkspaceEntryData } from "@/components/Workspace"
import { runtimeCtxAt$, unsafeApi$ } from "@/state/chains/chain.state"
import { RuntimeContext } from "@polkadot-api/observable-client"
import { state } from "@react-rxjs/core"
import {
createKeyedSignal,
createSignal,
partitionByKey,
toKeySet,
} from "@react-rxjs/utils"
import { createSignal } from "@react-rxjs/utils"
import { ServerCog } from "lucide-react"
import { Binary, HexString, ResultPayload } from "polkadot-api"
import {
catchError,
combineLatest,
firstValueFrom,
from,
map,
Observable,
of,
startWith,
switchMap,
takeUntil,
} from "rxjs"
import { v4 as uuid } from "uuid"
import { stringifyArg } from "../Storage/storage.state"
import {
RuntimeCallWorkspaceContext,
RuntimeCallWorkspaceEntry,
} from "./RuntimeCallWorkspaceEntry"
export type RuntimeCallMetadataMethod = {
api: string
@@ -32,55 +35,94 @@ export const [entryChange$, setSelectedMethod] =
createSignal<RuntimeCallMetadataMethod | null>()
export const selectedEntry$ = state(entryChange$, null)
export const [newRuntimeCallQuery$, addRuntimeCallQuery] = createSignal<{
name: string
type: number
promise: Promise<unknown>
}>()
export const [removeRuntimeCallResult$, removeRuntimeCallResult] =
createKeyedSignal<string>()
type RuntimeCallQuery = {
blockHash: HexString
api: string
method: string
args: unknown[]
}
export type RuntimeCallResult = {
name: string
type: number
} & ({ result: unknown } | { error?: any })
const [getRuntimeCallSubscription$, runtimeCallKeyChange$] = partitionByKey(
newRuntimeCallQuery$,
() => uuid(),
(src$, id) =>
src$.pipe(
switchMap(
({ promise, ...props }): Observable<RuntimeCallResult> =>
from(promise).pipe(
map((result) => ({
...props,
result,
paused: false,
})),
catchError((ex) => {
console.error(ex)
return of({
...props,
error: ex,
})
}),
startWith(props),
),
),
takeUntil(removeRuntimeCallResult$(id)),
const runtimeQueryToId = (
ctx: Pick<RuntimeContext, "dynamicBuilder" | "lookup">,
query: RuntimeCallQuery,
) => {
const codec = ctx.dynamicBuilder.buildRuntimeCall(query.api, query.method)
return [
query.blockHash,
query.api,
query.method,
Binary.toHex(codec.args.enc(query.args)),
].join(":")
}
export const idToRuntimeQuery = async (
id: string,
): Promise<RuntimeCallQuery> => {
const [blockHash, api, method, args] = id.split(":")
const ctx = await firstValueFrom(runtimeCtxAt$(blockHash))
const codec = ctx.dynamicBuilder.buildRuntimeCall(api, method).args
return {
blockHash,
api,
method,
args: codec.dec(args),
}
}
export const runtimeCallToWorkspaceEntry = async (
query: RuntimeCallQuery,
): Promise<WorkspaceEntryData<RuntimeCallWorkspaceContext>> => {
const [unsafeApi, ctx] = await firstValueFrom(
combineLatest([unsafeApi$, runtimeCtxAt$(query.blockHash)]),
)
const query$ = from(
unsafeApi.apis[query.api][query.method](...query.args, {
at: query.blockHash,
}),
).pipe(
map((result) => ({
success: true,
value: result,
})),
catchError((ex) => {
console.error(ex)
return of({
success: false,
value: ex,
})
}),
)
const result$ = state(query$, null)
const context: RuntimeCallWorkspaceContext = {
api: query.api,
method: query.method,
result$,
}
const id = runtimeQueryToId(ctx, query)
return {
id,
source: "Runtime Calls",
title: [query.api, query.method].join("."),
subtitle: `${query.args.map(stringifyArg)}`,
link: `/runtimeCalls/${id}`,
status: query$.pipe(
map((v) => (v.success ? ("done" as const) : ("error" as const))),
startWith("pending" as const),
),
)
icon: ServerCog,
context,
content: RuntimeCallWorkspaceEntry,
}
}
export const runtimeCallResultKeys$ = state(
runtimeCallKeyChange$.pipe(
toKeySet(),
map((keys) => [...keys].reverse()),
),
[],
)
export type RuntimeCallResult = ResultPayload<unknown, any>
export const runtimeCallResult$ = state(
(key: string): Observable<RuntimeCallResult> =>
getRuntimeCallSubscription$(key),
null,
)
export const addRuntimeCallQuery = async (query: RuntimeCallQuery) => {
const data = await runtimeCallToWorkspaceEntry(query)
pushWorkspaceEntry(data)
return data.id
}
+1
View File
@@ -27,6 +27,7 @@ import {
const [valueChange$, setValue] = createSignal<string>()
const inputValue$ = state(valueChange$, "Latest")
export const setBlockHashValue = setValue
const loadedCtx$ = state(
inputValue$.pipe(
debounceTime(200),
+9 -3
View File
@@ -4,9 +4,11 @@ import { Chopsticks } from "@/components/Icons"
import { LoadingMetadata } from "@/components/Loading"
import { SearchableSelect } from "@/components/Select"
import { withSubscribe } from "@/components/withSuspense"
import { createState } from "@/lib/externalState"
import { canSetStorage$ } from "@/state/chains/chain.state"
import { state, useStateObservable } from "@react-rxjs/core"
import { FC, useState } from "react"
import { FC } from "react"
import { Route, Routes } from "react-router-dom"
import { map } from "rxjs"
import { BlockPicker, selectedBlock$ } from "./BlockPicker"
import { partialEntry$, selectedEntry$, selectEntry } from "./storage.state"
@@ -82,7 +84,9 @@ export const Storage = withSubscribe(
</div>
) : null}
<StorageEntry />
<StorageSubscriptions />
<Routes>
<Route path=":subId" element={<StorageSubscriptions />} />
</Routes>
</div>
)
},
@@ -91,10 +95,12 @@ export const Storage = withSubscribe(
},
)
export const [mode$, setMode] = createState<"query" | "decode" | "set">("query")
const StorageEntry: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const canSetStorage = useStateObservable(canSetStorage$)
const [mode, setMode] = useState<"query" | "decode" | "set">("query")
const mode = useStateObservable(mode$)
if (!selectedEntry) return null
+13 -15
View File
@@ -1,14 +1,15 @@
import { ActionButton } from "@/components/ActionButton"
import { useNavigate } from "@/hashParams"
import { createState } from "@/lib/externalState"
import { NOTIN } from "@polkadot-api/react-builder"
import { state, useStateObservable } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { Enum } from "polkadot-api"
import { FC } from "react"
import { combineLatest, firstValueFrom, map, of } from "rxjs"
import { combineLatest, firstValueFrom, map } from "rxjs"
import { selectedBlock$ } from "./BlockPicker"
import { addStorageSubscription, selectedEntry$ } from "./storage.state"
const [valueChange, setValue] = createSignal<string>()
const value$ = state(valueChange, "")
export const [value$, setValue] = createState("")
const valueDecoder$ = combineLatest([selectedEntry$, selectedBlock$]).pipe(
map(([selectedEntry, { ctx }]) =>
@@ -34,23 +35,20 @@ const decodedValue$ = state(
export const StorageDecode: FC = () => {
const value = useStateObservable(value$)
const decoded = useStateObservable(decodedValue$)
const navigate = useNavigate()
const submit = async () => {
const [entry, { ctx }] = await firstValueFrom(
const [entry, { hash }] = await firstValueFrom(
combineLatest([selectedEntry$, selectedBlock$]),
)
addStorageSubscription({
name: `${entry!.pallet}.${entry!.entry}(…)`,
args: null,
single: true,
value: of({
type: entry!.value,
payload: decoded,
ctx: ctx!,
blockHash: null,
}),
const id = await addStorageSubscription({
blockHash: hash ?? null,
pallet: entry!.pallet,
item: entry!.entry,
value: Enum("decode", value),
})
navigate(`/storage/${id}`)
}
return (
+36 -183
View File
@@ -3,150 +3,57 @@ import { ActionButton } from "@/components/ActionButton"
import { BinaryEditButton } from "@/components/BinaryEditButton"
import { CopyText } from "@/components/Copy"
import SliderToggle from "@/components/Toggle"
import {
chainClient$,
runtimeCtxAt$,
unsafeApi$,
} from "@/state/chains/chain.state"
import { useNavigate } from "@/hashParams"
import {
CodecComponentType,
CodecComponentValue,
NOTIN,
} from "@polkadot-api/react-builder"
import { Twox128 } from "@polkadot-api/substrate-bindings"
import { fromHex, toHex } from "polkadot-api/utils"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { createSignal, mergeWithKey } from "@react-rxjs/utils"
import { Enum } from "polkadot-api"
import { fromHex } from "polkadot-api/utils"
import { FC } from "react"
import {
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
from,
map,
ObservedValueOf,
of,
scan,
startWith,
switchMap,
take,
withLatestFrom,
} from "rxjs"
import { twMerge } from "tailwind-merge"
import { selectedBlock$ } from "./BlockPicker"
import { DecodedKey, decodeKey } from "./decodeKey"
import {
addStorageSubscription,
selectedEntry$,
selectEntry,
stringifyArg,
} from "./storage.state"
export const StorageQuery: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const isReady = useStateObservable(isReady$)
const navigate = useNavigate()
if (!selectedEntry) return null
const submit = async () => {
const [entry, unsafeApi, keyValues, keysEnabled, { hash }] =
await firstValueFrom(
combineLatest([
selectedEntry$,
unsafeApi$,
keyValues$,
keysEnabled$,
selectedBlock$,
]),
)
const [entry, keyValues, keysEnabled, { hash }] = await firstValueFrom(
combineLatest([selectedEntry$, keyValues$, keysEnabled$, selectedBlock$]),
)
const args = keyValues.slice(0, keysEnabled)
const storageEntry = unsafeApi.query[entry!.pallet][entry!.entry]
const single = keyValues.length === keysEnabled
const argString = [...args.map(stringifyArg), ...(single ? [] : ["…"])]
const at = (hash: string) => {
const value$ = from(
single
? storageEntry.getValue(...args, {
at: hash,
})
: storageEntry.getEntries(...args, {
at: hash,
}),
)
const hash$ = single
? chainClient$.pipe(
switchMap(({ chainHead }) =>
chainHead
.storage$(hash, "hash", (ctx) =>
ctx.dynamicBuilder
.buildStorage(entry!.pallet, entry!.entry)
.keys.enc(...args),
)
.pipe(map(({ value }) => value)),
),
)
: of(null)
const ctxType$ = runtimeCtxAt$(hash).pipe(
map((ctx) => {
const pallet = ctx.lookup.metadata.pallets.find(
(p) => p.name === entry!.pallet,
)
const ctxEntry = pallet?.storage?.items.find(
(it) => it.name === entry!.entry,
)
if (!ctxEntry) {
throw new Error(
`Storage entry ${entry?.pallet}.${entry?.entry} not found in ${hash}`,
)
}
const type =
ctxEntry.type.tag === "plain"
? ctxEntry.type.value
: ctxEntry.type.value.value
return { ctx, type }
}),
)
return combineLatest([
combineLatest({ payload: value$, hash: hash$ }),
ctxType$,
]).pipe(
map(([a, b]) => ({ ...a, ...b })),
take(1),
)
}
const keyCodec = (hash: string) =>
runtimeCtxAt$(hash).pipe(
map(
(ctx) =>
ctx.dynamicBuilder.buildStorage(entry!.pallet, entry!.entry).keys,
),
)
if (hash) {
addStorageSubscription({
name: `${entry!.pallet}.${entry!.entry}(${argString})`,
args,
single,
keyCodec,
value: at(hash).pipe(
map((r) => ({
...r,
blockHash: hash,
})),
),
})
} else {
addStorageSubscription({
name: `${entry!.pallet}.${entry!.entry}(${argString})`,
args,
single,
keyCodec,
at,
})
}
const id = await addStorageSubscription({
blockHash: hash ?? null,
pallet: entry!.pallet,
item: entry!.entry,
value: Enum("query", args),
})
navigate(`/storage/${id}`)
}
return (
@@ -174,9 +81,13 @@ const hashers$ = selectedEntry$.pipeState(
)
const [toggleKey$, toggleKey] = createSignal<number>()
export const [changeKeysEnabled$, setKeysEnabled] = createSignal<number>()
const keysEnabled$ = keys$.pipeState(
switchMap((k) =>
toggleKey$.pipe(
mergeWithKey({
toggle: toggleKey$,
set: changeKeysEnabled$,
}).pipe(
/*
acc=2
[X,X, , ]
@@ -186,14 +97,20 @@ const keysEnabled$ = keys$.pipeState(
toggle 2 => acc=3
toggle 3 => acc=4
*/
scan((acc, toggle) => (acc <= toggle ? toggle + 1 : toggle), k.length),
scan(
(acc, evt) =>
evt.type === "set" || acc > evt.payload
? evt.payload
: evt.payload + 1,
k.length,
),
startWith(k.length),
),
),
withDefault(0),
)
const [keyValueChange$, setKeyValue] = createSignal<{
export const [keyValueChange$, setKeyValue] = createSignal<{
idx: number
value: unknown | NOTIN
}>()
@@ -475,7 +392,13 @@ export const KeyDisplay: FC = () => {
)
}}
decode={(v) => {
const decoded = decodeKey(builder, v)
const decoded = decodeKey(
{
dynamicBuilder: builder,
lookup: builder.lookup,
},
v,
)
if (!decoded) throw null
return decoded
}}
@@ -483,73 +406,3 @@ export const KeyDisplay: FC = () => {
</div>
)
}
const textEncoder = new TextEncoder()
const hashersToLength: Record<string, number> = {
Identity: 0,
Twox64Concat: 8,
Blake2128Concat: 16,
Blake2128: -16,
Blake2256: -32,
Twox128: -16,
Twox256: -32,
}
const decodeKey = (
builder: NonNullable<ObservedValueOf<typeof builderState$>>,
key: Uint8Array,
) => {
const twoxHash = (v: string) => toHex(Twox128(textEncoder.encode(v)))
const keyHex = toHex(key)
const pallet = builder.lookup.metadata.pallets.find(
(p) => p.storage && keyHex.startsWith(twoxHash(p.storage.prefix)),
)
if (!pallet) return null
const keyRemaining = keyHex.replace(twoxHash(pallet.storage!.prefix), "0x")
const item = pallet.storage!.items.find((v) =>
keyRemaining.startsWith(twoxHash(v.name)),
)
if (!item) return null
const codec = builder.buildStorage(pallet.name, item.name)
const hasherLengths =
item.type.tag === "plain"
? []
: item.type.value.hashers.map((x) => hashersToLength[x.tag])
let argsRemaining = fromHex(keyRemaining.replace(twoxHash(item.name), "0x"))
const args: any[] = []
const argsLen = codec.args.inner.length
for (let i = 0; i < argsLen && argsRemaining.length; i++) {
const hashLength = hasherLengths[i]
if (argsRemaining.length < Math.abs(hashLength)) return null
argsRemaining = argsRemaining.slice(Math.abs(hashLength))
if (hashLength < 0) {
// Signals a non-reversible hasher
args.push(null)
} else {
const argCodec = codec.args.inner[i]
try {
const value = argCodec.dec(argsRemaining)
// This is needed not just for the length, but see case AccountId: Can decode 0x, but then can't re-encode back. <- TODO bug?
const reEnc = argCodec.enc(value)
argsRemaining = argsRemaining.slice(reEnc.length)
args.push(value)
} catch {
return null
}
}
}
return {
pallet,
item,
args,
}
}
type DecodedKey = ReturnType<typeof decodeKey>
+88 -66
View File
@@ -3,11 +3,7 @@ import { ViewCodec } from "@/codec-components/ViewCodec"
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
import { ButtonGroup } from "@/components/ButtonGroup"
import { JsonDisplay } from "@/components/JsonDisplay"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { workspaceEntryCtxOrAdd$ } from "@/components/Workspace"
import { Link } from "@/hashParams"
import { cn } from "@/lib/utils"
import { BlockState } from "@/state/block.state"
@@ -16,70 +12,84 @@ import { ViewValue } from "@/ViewValue"
import { RuntimeContext } from "@polkadot-api/observable-client"
import { CodecComponentType, NOTIN } from "@polkadot-api/react-builder"
import { Button } from "@polkahub/ui-components"
import { useStateObservable } from "@react-rxjs/core"
import { ChevronLeft, ChevronRight, StopCircle, Trash2 } from "lucide-react"
import { state, useStateObservable } from "@react-rxjs/core"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Enum } from "polkadot-api"
import { fromHex } from "polkadot-api/utils"
import { FC, MouseEvent, ReactNode, useMemo, useState } from "react"
import { FC, MouseEvent, ReactNode, useEffect, useMemo, useState } from "react"
import { useParams } from "react-router-dom"
import { Virtuoso } from "react-virtuoso"
import { filter, switchMap } from "rxjs"
import { BlockStatusIcon } from "../Explorer/Detail/BlockState"
import { setBlockHashValue } from "./BlockPicker"
import { setMode } from "./Storage"
import {
idToStorageSubscription,
KeyCodec,
removeStorageSubscription,
stopStorageSubscription,
storageSubscription$,
storageSubscriptionKeys$,
selectEntry,
storageSubscriptionToWorkspaceEntry,
StorageSubscriptionValue,
stringifyArg,
} from "./storage.state"
import { setValue } from "./StorageDecode"
import { setKeysEnabled, setKeyValue } from "./StorageQuery"
export const StorageSubscriptions: FC = () => {
const keys = useStateObservable(storageSubscriptionKeys$)
const params = useParams()
if (!keys.length) return null
if (!params.subId) return null
return (
<div className="p-2 w-full border-t border-border">
<h2 className="text-lg text-foreground mb-2">Results</h2>
<h2 className="text-lg text-foreground mb-2">Result</h2>
<ul className="flex flex-col gap-2">
{keys.map((key) => (
<StorageSubscriptionBox key={key} subscription={key} />
))}
<StorageSubscriptionBox id={params.subId} />
</ul>
</div>
)
}
const StorageSubscriptionBox: FC<{ subscription: string }> = ({
subscription,
}) => {
const storageSubscription = useStateObservable(
storageSubscription$(subscription),
)
if (!storageSubscription) return null
const storageSubscriptionCtx$ = state(
(id: string) =>
workspaceEntryCtxOrAdd$(id, async () => {
const sub = await idToStorageSubscription(id)
return storageSubscriptionToWorkspaceEntry(sub)
}),
null,
)
const storageSubscriptionStatus$ = state(
(id: string) =>
storageSubscriptionCtx$(id).pipe(
filter((v) => v != null),
switchMap((v) => v.status$),
),
Enum("loading"),
)
switch (storageSubscription.status.type) {
const StorageSubscriptionBox: FC<{ id: string }> = ({ id }) => {
const status = useStateObservable(storageSubscriptionStatus$(id))
useSynchronizeInputs(id)
switch (status.type) {
case "loading":
return (
<SubscriptionBox subscription={subscription}>
<SubscriptionBox subscription={id}>
{() => <div>Loading…</div>}
</SubscriptionBox>
)
case "value":
return <ValueSubscriptionBox subscription={subscription} />
return <ValueSubscriptionBox subscription={id} />
case "values":
return <ValuesSubscriptionBox subscription={subscription} />
return <ValuesSubscriptionBox subscription={id} />
}
}
const ValueSubscriptionBox: FC<{ subscription: string }> = ({
subscription,
}) => {
const storageSubscription = useStateObservable(
storageSubscription$(subscription),
)
if (!storageSubscription) return null
const status = storageSubscription.status
if (status.type !== "value") return null
const status = useStateObservable(storageSubscriptionStatus$(subscription))
const subCtx = useStateObservable(storageSubscriptionCtx$(subscription))
if (status.type !== "value" || !subCtx) return null
const { ctx, payload, type, blockHash } = status.value
return (
@@ -107,7 +117,7 @@ const ValueSubscriptionBox: FC<{ subscription: string }> = ({
hash: null,
}),
}}
single={storageSubscription.single}
single={!subCtx.isEntries}
mode={mode}
/>
)}
@@ -151,14 +161,11 @@ const onHold = (cb: () => void) => (evt: MouseEvent) => {
const ValuesSubscriptionBox: FC<{ subscription: string }> = ({
subscription,
}) => {
const storageSubscription = useStateObservable(
storageSubscription$(subscription),
)
const ctx = useStateObservable(storageSubscriptionCtx$(subscription))
const status = useStateObservable(storageSubscriptionStatus$(subscription))
const [target, setTarget] = useState<number | "best">("best")
if (!storageSubscription) return null
const status = storageSubscription.status
if (status.type !== "values") return null
if (status.type !== "values" || !ctx) return null
const targetValueIdx =
target === "best"
@@ -236,7 +243,7 @@ const ValuesSubscriptionBox: FC<{ subscription: string }> = ({
<ResultDisplay
id={subscription}
subValue={targetValue}
single={storageSubscription.single}
single={!ctx.isEntries}
mode={mode}
/>
) : null
@@ -252,21 +259,20 @@ const SubscriptionBox: FC<{
}> = ({ actions, subscription, children }) => {
const [mode, setMode] = useState<"json" | "decoded">("decoded")
const storageSubscription = useStateObservable(
storageSubscription$(subscription),
)
if (!storageSubscription) return null
const ctx = useStateObservable(storageSubscriptionCtx$(subscription))
const status = useStateObservable(storageSubscriptionStatus$(subscription))
if (!status || !ctx) return null
const iconButtonProps = {
size: 20,
className: "text-polkadot-400 cursor-pointer hover:text-polkadot-500",
}
const argString = ctx.args
? `(${[...ctx.args.map(stringifyArg), ...(ctx.isEntries ? ["…"] : [])]})`
: "(…)"
return (
<li className="border rounded bg-card text-card-foreground p-2">
<div className="flex justify-between items-center pb-1 overflow-hidden">
<h3 className="overflow-hidden text-ellipsis whitespace-nowrap">
{storageSubscription.name}
{ctx.name}
{argString}
</h3>
<div className="flex items-center shrink-0 gap-2">
{actions}
@@ -284,20 +290,6 @@ const SubscriptionBox: FC<{
},
]}
/>
{storageSubscription.completed ? (
<button onClick={() => removeStorageSubscription(subscription)}>
<Trash2 {...iconButtonProps} />
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button onClick={() => stopStorageSubscription(subscription)}>
<StopCircle {...iconButtonProps} />
</button>
</TooltipTrigger>
<TooltipContent>Stop subscription</TooltipContent>
</Tooltip>
)}
</div>
</div>
{children(mode)}
@@ -488,3 +480,33 @@ const KeyDisplay: FC<{
</div>
)
}
const useSynchronizeInputs = (id: string) => {
useEffect(() => {
let cancelled = false
const run = async () => {
const params = await idToStorageSubscription(id)
if (cancelled) return
setBlockHashValue(params.blockHash ?? "Latest")
selectEntry({
pallet: params.pallet,
entry: params.item,
})
setMode(params.value.type)
// Let entry settle
await Promise.resolve()
if (params.value.type === "query") {
if (cancelled) return
setKeysEnabled(params.value.value.length)
params.value.value.forEach((value, idx) => setKeyValue({ idx, value }))
} else {
setValue(params.value.value)
}
}
run()
return () => {
cancelled = true
}
}, [id])
}
@@ -0,0 +1,37 @@
import { JsonDisplay } from "@/components/JsonDisplay"
import { useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import type { StorageEntryContext } from "./storage.state"
export const StorageWorkspaceEntry: FC<{
context: StorageEntryContext
}> = ({ context }) => {
const status = useStateObservable(context.status$)
if (!status) {
return <p className="text-muted-foreground p-2">Removed</p>
}
if (status.type === "loading") {
return <p className="text-muted-foreground p-2">Loading</p>
}
if (status.type === "value") {
return (
<div className="text-xs p-2">
<JsonDisplay src={status.value.payload} />
</div>
)
}
if (!status.value.length) return null
const result = status.value[status.value.length - 1].result
return (
<div className="text-xs p-2">
<JsonDisplay
src={result.type === "error" ? result.value : result.value.payload}
/>
</div>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { RuntimeContext } from "@polkadot-api/observable-client"
import { Twox128 } from "@polkadot-api/substrate-bindings"
import { fromHex, toHex } from "polkadot-api/utils"
const textEncoder = new TextEncoder()
const hashersToLength: Record<string, number> = {
Identity: 0,
Twox64Concat: 8,
Blake2128Concat: 16,
Blake2128: -16,
Blake2256: -32,
Twox128: -16,
Twox256: -32,
}
export const decodeKey = (
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">,
key: Uint8Array,
) => {
const twoxHash = (v: string) => toHex(Twox128(textEncoder.encode(v)))
const keyHex = toHex(key)
const pallet = ctx.lookup.metadata.pallets.find(
(p) => p.storage && keyHex.startsWith(twoxHash(p.storage.prefix)),
)
if (!pallet) return null
const keyRemaining = keyHex.replace(twoxHash(pallet.storage!.prefix), "0x")
const item = pallet.storage!.items.find((v) =>
keyRemaining.startsWith(twoxHash(v.name)),
)
if (!item) return null
const codec = ctx.dynamicBuilder.buildStorage(pallet.name, item.name)
const hasherLengths =
item.type.tag === "plain"
? []
: item.type.value.hashers.map((x) => hashersToLength[x.tag])
let argsRemaining = fromHex(keyRemaining.replace(twoxHash(item.name), "0x"))
const args: any[] = []
const argsLen = codec.args.inner.length
for (let i = 0; i < argsLen && argsRemaining.length; i++) {
const hashLength = hasherLengths[i]
if (argsRemaining.length < Math.abs(hashLength)) return null
argsRemaining = argsRemaining.slice(Math.abs(hashLength))
if (hashLength < 0) {
// Signals a non-reversible hasher
args.push(null)
} else {
const argCodec = codec.args.inner[i]
try {
const value = argCodec.dec(argsRemaining)
// This is needed not just for the length, but see case AccountId: Can decode 0x, but then can't re-encode back. <- TODO bug?
const reEnc = argCodec.enc(value)
argsRemaining = argsRemaining.slice(reEnc.length)
args.push(value)
} catch {
return null
}
}
}
return {
pallet,
item,
entry: getEntry(ctx, item.type),
args,
}
}
export type DecodedKey = ReturnType<typeof decodeKey>
export const getEntry = (
ctx: Pick<RuntimeContext, "lookup">,
type:
| {
tag: "plain"
value: number
}
| {
tag: "map"
value: {
hashers: {
tag: string
value: undefined
}[]
key: number
value: number
}
},
) => {
if (type.tag === "plain") {
return { value: type.value, keys: [] }
}
const value = type.value.value
const hashers = type.value.hashers.map((x) => x.tag)
if (hashers.length === 1) {
return {
value,
keys: [
{
type: type.value.key,
hasher: hashers[0],
},
],
}
}
const keyDef = ctx.lookup(type.value.key)
if (keyDef.type === "array") {
return {
value,
keys: hashers.map((hasher) => ({ type: keyDef.value.id, hasher })),
}
}
if (keyDef.type === "tuple") {
return {
value,
keys: hashers.map((hasher, i) => ({ type: keyDef.value[i].id, hasher })),
}
}
throw new Error("Invalid key type " + keyDef.type)
}
export const getStorageItem = (
ctx: Pick<RuntimeContext, "lookup">,
palletName: string,
itemName: string,
) => {
const pallet = ctx.lookup.metadata.pallets.find((p) => p.name === palletName)
if (!pallet?.storage) return null
const item = pallet.storage.items.find((v) => v.name === itemName)
if (!item) return null
return { pallet, item }
}
+237 -142
View File
@@ -1,43 +1,49 @@
import { bytesToString } from "@/components/BinaryInput"
import { pushWorkspaceEntry, WorkspaceEntryData } from "@/components/Workspace"
import { getHashParams } from "@/hashParams"
import { client$, selectedChainChanged$ } from "@/state/chains/chain.state"
import {
chainClient$,
client$,
runtimeCtx$,
runtimeCtxAt$,
selectedChainChanged$,
unsafeApi$,
} from "@/state/chains/chain.state"
import {
BlockInfo,
concatMapEager,
RuntimeContext,
} from "@polkadot-api/observable-client"
import { state } from "@react-rxjs/core"
import {
createKeyedSignal,
createSignal,
mergeWithKey,
partitionByKey,
toKeySet,
} from "@react-rxjs/utils"
import { Enum, HexString } from "polkadot-api"
import { DefaultedStateObservable, state } from "@react-rxjs/core"
import { createSignal, mergeWithKey } from "@react-rxjs/utils"
import { DatabaseSearch } from "lucide-react"
import { Binary, Enum, HexString } from "polkadot-api"
import {
catchError,
combineLatest,
combineLatestWith,
concat,
distinct,
EMPTY,
endWith,
filter,
firstValueFrom,
from,
ignoreElements,
map,
merge,
mergeMap,
Observable,
ObservedValueOf,
of,
scan,
shareReplay,
startWith,
switchMap,
take,
takeUntil,
} from "rxjs"
import { v4 as uuid } from "uuid"
import { selectedBlock$ } from "./BlockPicker"
import { StorageWorkspaceEntry } from "./StorageWorkspaceEntry"
import { getEntry, getStorageItem } from "./decodeKey"
export type StorageMetadataEntry = {
pallet: string
@@ -128,22 +134,14 @@ export const selectedEntry$ = state(
const { type, docs } = entry
const pallet = partialEntry.pallet!
const { keys } = getEntry(ctx, type)
const key = keys.map((v) => v.type)
const hashers = keys.map((v) => v.hasher)
if (type.tag === "plain") {
return {
value: type.value,
key: [],
pallet,
entry: entry.name,
docs,
hashers: [],
}
}
const hashers = type.value.hashers.map((x) => x.tag)
if (hashers.length === 1) {
return {
value: type.value.value,
key: [type.value.key],
key,
pallet,
entry: entry.name,
docs,
@@ -151,19 +149,9 @@ export const selectedEntry$ = state(
}
}
const keyDef = ctx.lookup(type.value.key)
const key = (() => {
if (keyDef.type === "array") {
return new Array(keyDef.len).fill(keyDef.value.id)
}
if (keyDef.type === "tuple") {
return keyDef.value.map((e) => e.id)
}
throw new Error("Invalid key type " + keyDef.type)
})()
return {
key,
value: type.value.value,
key,
pallet,
entry: entry.name,
docs,
@@ -178,28 +166,193 @@ export type KeyCodec = {
enc: (...args: any[]) => string
dec: (value: string) => any[]
}
export const [newStorageSubscription$, addStorageSubscription] = createSignal<{
name: string
args: unknown[] | null
single: boolean
keyCodec?: (hash: HexString) => Observable<KeyCodec>
at?: (hash: HexString) => Observable<{
type: number
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">
hash: HexString | null
payload: unknown
export type StorageSubscription = {
blockHash: HexString | null
pallet: string
item: string
value: Enum<{
decode: HexString
query: unknown[]
}>
value?: Observable<{
type: number
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">
blockHash: HexString | null
payload: unknown
}>
}>()
export const [removeStorageSubscription$, removeStorageSubscription] =
createKeyedSignal<string>()
export const [stopStorageSubscription$, stopStorageSubscription] =
createKeyedSignal<string>()
}
const storageSubscriptionId = (
ctx: Pick<RuntimeContext, "dynamicBuilder" | "lookup">,
sub: StorageSubscription,
) => {
const item = getStorageItem(ctx, sub.pallet, sub.item)
if (!item) {
throw new Error(
`Storage entry ${sub.pallet}.${sub.item} not found in context`,
)
}
const entry = getEntry(ctx, item.item.type)
return [
sub.blockHash ?? "latest",
sub.pallet,
sub.item,
sub.value.type,
...(sub.value.type === "decode"
? [sub.value.value]
: sub.value.value.map((v, i) =>
Binary.toHex(
ctx.dynamicBuilder.buildDefinition(entry.keys[i].type).enc(v),
),
)),
].join("_")
}
export const idToStorageSubscription = async (
id: string,
): Promise<StorageSubscription> => {
const [blockHash, pallet, item, type, ...values] = id.split("_")
const ctx = await firstValueFrom(
blockHash === "latest" ? runtimeCtx$ : runtimeCtxAt$(blockHash),
)
const storageItem = getStorageItem(ctx, pallet, item)
if (!storageItem) {
throw new Error(`Storage entry ${pallet}.${item} not found in context`)
}
const entry = getEntry(ctx, storageItem.item.type)
return {
blockHash: blockHash === "latest" ? null : blockHash,
pallet,
item,
value:
type === "decode"
? Enum("decode", values[0])
: Enum(
"query",
values.map((v, i) =>
ctx.dynamicBuilder.buildDefinition(entry.keys[i].type).dec(v),
),
),
}
}
export const storageSubscriptionToWorkspaceEntry = async ({
blockHash,
pallet,
item,
value,
}: StorageSubscription): Promise<WorkspaceEntryData<StorageEntryContext>> => {
const [ctx, unsafeApi] = await firstValueFrom(
combineLatest([
blockHash ? runtimeCtxAt$(blockHash) : runtimeCtx$,
unsafeApi$,
]),
)
const entry = getEntry(ctx, getStorageItem(ctx, pallet, item)!.item.type)
const storageEntry = unsafeApi.query[pallet][item]
const name = `${pallet}.${item}`
const args = value.type === "query" ? value.value : []
const isEntries = value.type === "query" && args.length !== entry.keys.length
const at = (blockHash: string) => {
const value$ = from(
isEntries
? storageEntry.getEntries(...args, {
at: blockHash,
})
: storageEntry.getValue(...args, {
at: blockHash,
}),
)
const hash$ = chainClient$.pipe(
switchMap(({ chainHead }) =>
chainHead
.storage$(blockHash, "hash", (ctx) =>
ctx.dynamicBuilder.buildStorage(pallet, item).keys.enc(...args),
)
.pipe(map(({ value }) => value)),
),
)
const ctxType$ = runtimeCtxAt$(blockHash).pipe(
map((ctx) => {
const ctxEntry = getStorageItem(ctx, pallet, item)
if (!ctxEntry) {
throw new Error(
`Storage entry ${pallet}.${item} not found in ${blockHash}`,
)
}
const type =
ctxEntry.item.type.tag === "plain"
? ctxEntry.item.type.value
: ctxEntry.item.type.value.value
return { ctx, type }
}),
)
return combineLatest([
combineLatest({ payload: value$, hash: hash$ }),
ctxType$,
]).pipe(
map(([a, b]) => ({ ...a, ...b })),
take(1),
)
}
const keyCodec = (hash: string) =>
runtimeCtxAt$(hash).pipe(
map((ctx) => ctx.dynamicBuilder.buildStorage(pallet, item).keys),
)
const id = storageSubscriptionId(ctx, { blockHash, pallet, item, value })
const value$: Observable<ObservedValueOf<StorageEntryContext["status$"]>> =
value.type === "decode"
? of(
Enum("value", {
blockHash: null,
ctx,
type: entry.value,
payload: ctx.dynamicBuilder
.buildStorage(pallet, item)
.value.dec(value.value),
}),
)
: getValues$(at, isEntries, keyCodec).pipe(
map((v) => Enum("values", v)),
takeUntil(selectedChainChanged$),
shareReplay({
bufferSize: 1,
refCount: true,
}),
)
const status$: StorageEntryContext["status$"] = state(value$, Enum("loading"))
const completed$ = state(value$.pipe(ignoreElements(), endWith(true)), false)
const context: StorageEntryContext = {
id,
name,
args,
isEntries,
completed$,
status$,
}
return {
id,
source: "Storage",
link: `/storage/${id}`,
title: context.name,
subtitle: context.args?.map(stringifyArg).join(" "),
icon: DatabaseSearch,
status: context.completed$.pipe(
map((completed) => (completed ? "done" : "live")),
),
context,
content: StorageWorkspaceEntry,
}
}
export const addStorageSubscription = async (sub: StorageSubscription) => {
const entry = await storageSubscriptionToWorkspaceEntry(sub)
pushWorkspaceEntry(entry)
return entry.id
}
export type StorageSubscriptionValue = {
height: number
@@ -216,35 +369,38 @@ export type StorageSubscriptionValue = {
error: string
}>
}
export type StorageSubscription = {
export type StorageEntryContext = {
id: string
name: string
args: unknown[] | null
single: boolean
completed: boolean
status: Enum<{
loading: undefined
// Non-subscription: fetches/decodes one value, then done
value: {
blockHash: HexString | null
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">
type: number
payload: unknown
}
// For subscriptions, adds one new value on every change
values: Array<StorageSubscriptionValue>
}>
isEntries: boolean
completed$: DefaultedStateObservable<boolean>
status$: DefaultedStateObservable<
Enum<{
loading: undefined
// Non-subscription: fetches/decodes one value, then done
value: {
blockHash: HexString | null
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">
type: number
payload: unknown
}
// For subscriptions, adds one new value on every change
values: Array<StorageSubscriptionValue>
}>
>
}
const getStatus$ = (
const getValues$ = (
at: (hash: HexString) => Observable<{
type: number
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">
hash: HexString | null
payload: unknown
}>,
single: boolean,
isEntries: boolean,
keyCodec?: (hash: HexString) => Observable<KeyCodec>,
): Observable<StorageSubscription["status"]> => {
): Observable<Array<StorageSubscriptionValue>> => {
const queryAt$ = (
block: BlockInfo,
settled: boolean,
@@ -274,22 +430,22 @@ const getStatus$ = (
switchMap((client) => client.finalizedBlock$),
mergeMap((block) => queryAt$(block, true)),
// Not supporting watchEntries for now. In case it's querying entries, we only take one
single ? (v) => v : take(1),
isEntries ? take(1) : (v) => v,
)
const bestResults$ = single
? client$.pipe(
const bestResults$ = isEntries
? EMPTY
: client$.pipe(
switchMap((client) => client.bestBlocks$),
filter((v) => v.length > 1),
mergeMap((blocks) => blocks.slice(0, -1).reverse()),
distinct(),
concatMapEager((block) => queryAt$(block, false)),
)
: EMPTY
const getValueHash = (value: StorageSubscriptionValue) =>
value.result.type === "success" ? value.result.value.hash : null
const values$ = merge(finalizedResults$, bestResults$).pipe(
return merge(finalizedResults$, bestResults$).pipe(
scan(
(
acc: {
@@ -360,69 +516,8 @@ const getStatus$ = (
}),
]),
)
return values$.pipe(map((values) => Enum("values", values)))
}
const [getStorageSubscription$, storageSubscriptionKeyChange$] = partitionByKey(
newStorageSubscription$,
() => uuid(),
(src$, id) =>
src$.pipe(
switchMap(
({
at,
value,
keyCodec,
...props
}): Observable<StorageSubscription> => {
const status$: Observable<StorageSubscription["status"]> = value
? value.pipe(map((v) => Enum("value", v)))
: getStatus$(at!, props.single, keyCodec)
const result$ = status$.pipe(
map((status) => ({
...props,
status,
})),
startWith({
...props,
status: Enum("loading"),
}),
takeUntil(stopStorageSubscription$(id)),
shareReplay(1),
)
const completed$ = concat(
of(false),
result$.pipe(ignoreElements()),
of(true),
)
return combineLatest([completed$, result$]).pipe(
map(([completed, result]) => ({
...result,
completed,
})),
)
},
),
takeUntil(merge(removeStorageSubscription$(id), selectedChainChanged$)),
),
)
export const storageSubscriptionKeys$ = state(
storageSubscriptionKeyChange$.pipe(
toKeySet(),
map((keys) => [...keys].reverse()),
),
[],
)
export const storage$ = storageSubscriptionKeys$
export const storageSubscription$ = state(
(key: string): Observable<StorageSubscription> =>
getStorageSubscription$(key),
null,
)
export const stringifyArg = (value: unknown) => {
if (typeof value === "object" && value !== null) {
return value instanceof Uint8Array ? bytesToString(value) : "arg"
-107
View File
@@ -1,107 +0,0 @@
import { CopyText } from "@/components/Copy"
import { Link } from "@/hashParams"
import { shortStr } from "@/utils"
import { FileSearch, Trash2 } from "lucide-react"
import { TxBroadcastEvent } from "polkadot-api"
import { jsonSerialize, toHex } from "polkadot-api/utils"
import * as React from "react"
import { dismissTransaction, onGoingEvents } from "./transactions.state"
export const Transaction: React.FC<{
event:
| (TxBroadcastEvent & { raw: Uint8Array })
| {
type: "invalid" | "error"
value: any
txHash: string
raw: Uint8Array
}
index: number
onClose: () => void
}> = ({ event, index, onClose }) => {
const getStatus = () => {
const { type } = event
switch (event.type) {
case "error":
return <span>There was an unexpected error.</span>
case "invalid":
return (
<span>
Invalid transaction. {JSON.stringify(event.value, jsonSerialize)}
</span>
)
case "txBestBlocksState": {
return event.found ? (
<span>
Transaction in{" "}
<Link
className="underline font-bold"
to={`/explorer/${event.block.hash}#tx=${event.block.index}`}
onClick={() => onClose()}
>
best block
</Link>
{event.ok ? "." : ", but it's failing."}
</span>
) : (
<span>Transaction no longer in a best block.</span>
)
}
case "signed":
case "broadcasted":
return <span>Transaction {type}.</span>
default:
return (
<span>
Transaction in{" "}
<Link
className="underline font-bold"
to={`/explorer/${event.block.hash}#tx=${event.block.index}`}
onClick={() => onClose()}
>
{" "}
finalized block
</Link>
{event.ok
? "."
: `, but it failed: ${JSON.stringify(event.dispatchError, jsonSerialize)}.`}
</span>
)
}
}
const { txHash, type } = event
return (
<div className="mb-4 p-3 bg-secondary/60 text-secondary-foreground/80 border border-border rounded-lg">
<div className="flex justify-between items-start">
<div className="min-w-0">
<p className="font-medium">
<span className="text-muted-foreground">{index + 1}.</span>{" "}
{shortStr(txHash, 14)} <CopyText text={txHash} />
</p>
</div>
<div className="flex items-center gap-2">
<Link to={`/extrinsics/analyzer#extrinsic=${toHex(event.raw)}`}>
<FileSearch size={15} />
</Link>
<button
className="text-red-500"
onClick={() => dismissTransaction(txHash)}
>
<Trash2 size={16} />
</button>
</div>
</div>
<p
className={`text-sm ${
onGoingEvents.has(type)
? "text-yellow-500"
: type === "finalized" && event.ok
? "text-green-500"
: "text-red-500"
}`}
>
{getStatus()}
</p>
</div>
)
}
-68
View File
@@ -1,68 +0,0 @@
import * as Dialog from "@radix-ui/react-dialog"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { Loader2, X } from "lucide-react"
import * as React from "react"
import { map } from "rxjs"
import { Transaction } from "./Transaction"
import { grouppedTransactions$, onGoingEvents } from "./transactions.state"
const txsSummary$ = grouppedTransactions$.pipeState(
map((x) => {
const all = [...x.values()]
const nOngoing = all.filter((x) => onGoingEvents.has(x.type)).length
return { nTxs: all.length, nOngoing }
}),
withDefault({ nTxs: 0, nOngoing: 0 }),
)
const transactionList$ = grouppedTransactions$.pipeState(
map((x) => [...x.values()]),
withDefault([]),
)
export function Transactions() {
const [isOpen, setIsOpen] = React.useState(false)
const { nTxs, nOngoing } = useStateObservable(txsSummary$)
const list = useStateObservable(transactionList$)
return !nTxs ? null : (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button className="fixed bottom-4 right-4 bg-polkadot/90 text-polkadot-foreground px-4 py-2 rounded-full shadow-lg hover:bg-polkadot hover:font-bold focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-opacity-50 flex items-center space-x-2">
{nOngoing > 0 && (
<>
<Loader2 className="animate-spin" size={20} />
<span className="font-bold">{nOngoing}</span>
</>
)}
<span>Transactions</span>
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed bottom-0 border-t border-x border-border right-0 w-full sm:w-md h-96 bg-card text-card-foreground rounded-t-xl shadow-xl focus:outline-hidden">
<div className="flex justify-between items-center p-4 border-b border-border">
<Dialog.Title className="text-lg font-semibold">
Transactions
</Dialog.Title>
<Dialog.Close asChild>
<button className="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</Dialog.Close>
</div>
<div className="p-4 h-[calc(100%-4rem)] overflow-y-auto">
{list.map((event, index) => (
<Transaction
key={event.txHash}
event={event}
index={index}
onClose={() => setIsOpen(false)}
/>
))}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { grouppedTransactions$ } from "./transactions.state"
export * from "./Transactions"
export { trackSignedTx, trackUnsignedTx } from "./transactions.state"
export const transactions$ = grouppedTransactions$
@@ -1,79 +0,0 @@
import { chainClient$ } from "@/state/chains/chain.state"
import { state } from "@react-rxjs/core"
import {
combineKeys,
createSignal,
mergeWithKey,
partitionByKey,
} from "@react-rxjs/utils"
import { InvalidTxError } from "polkadot-api"
import {
catchError,
filter,
map,
merge,
mergeMap,
of,
skip,
takeUntil,
tap,
withLatestFrom,
} from "rxjs"
const [signedTx$, trackSignedTx] = createSignal<Uint8Array>()
const [unsignedTx$, trackUnsignedTx] = createSignal<Uint8Array>()
export { trackSignedTx, trackUnsignedTx }
export const [dismissTransaction$, dismissTransaction] = createSignal<string>()
const transactions$ = mergeWithKey({ signedTx$, unsignedTx$ }).pipe(
withLatestFrom(chainClient$),
mergeMap(([tx, { client }]) => {
let txHash: string = ""
return client.submitAndWatch(tx.payload, "finalized").pipe(
tap((e) => {
txHash = e.txHash
}),
map((event) => ({
...event,
raw: tx.payload,
})),
catchError((err) => {
console.log(err)
console.log(tx.payload, client.getFinalizedBlock())
return of({
type:
err instanceof InvalidTxError
? ("invalid" as const)
: ("error" as const),
txHash,
raw: tx.payload,
value: err,
})
}),
takeUntil(dismissTransaction$.pipe(filter((v) => v === txHash))),
)
}),
)
const [tx$, txKeys$] = partitionByKey(
transactions$,
(x) => x.txHash,
(x, hash) =>
x.pipe(
takeUntil(
merge(
chainClient$.pipe(skip(1)),
dismissTransaction$.pipe(filter((v) => v === hash)),
),
),
),
)
export const grouppedTransactions$ = state(combineKeys(txKeys$, tx$))
export const onGoingEvents = new Set([
"signed",
"broadcasted",
"txBestBlocksState",
])
+11 -12
View File
@@ -1,14 +1,11 @@
import {
dynamicBuilder$,
lookup$,
unsafeApi$,
} from "@/state/chains/chain.state"
import {
InlineLookupTypeEdit,
LookupTypeEdit,
} from "@/codec-components/LookupTypeEdit"
import { ActionButton } from "@/components/ActionButton"
import { ExpandBtn } from "@/components/Expand"
import { useNavigate } from "@/hashParams"
import { client$, dynamicBuilder$, lookup$ } from "@/state/chains/chain.state"
import { getTypeComplexity } from "@/utils/shape"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
@@ -29,16 +26,17 @@ import { addViewFnCall, selectedEntry$ } from "./viewFns.state"
export const ViewFnQuery: FC = () => {
const selectedEntry = useStateObservable(selectedEntry$)
const isReady = useStateObservable(isReady$)
const navigate = useNavigate()
if (!selectedEntry) return null
const submit = async () => {
const [entry, unsafeApi, inputValues, builder] = await firstValueFrom(
const [entry, inputValues, builder, block] = await firstValueFrom(
combineLatest([
selectedEntry$,
unsafeApi$,
inputValues$,
dynamicBuilder$,
client$.pipe(switchMap((client) => client.finalizedBlock$)),
]),
)
const decodedValues = inputValues.map((v, i) =>
@@ -46,13 +44,14 @@ export const ViewFnQuery: FC = () => {
.buildDefinition(selectedEntry.inputs[i].type)
.dec(v as Uint8Array),
)
const promise = unsafeApi.view[entry!.pallet][entry!.name](...decodedValues)
addViewFnCall({
name: `${entry!.pallet}.${entry!.name}(…)`,
promise,
type: entry!.output,
const id = await addViewFnCall({
blockHash: block.hash,
pallet: entry!.pallet,
name: entry!.name,
args: decodedValues,
})
navigate(`/viewFns/${id}`)
}
return (
+63 -44
View File
@@ -1,44 +1,56 @@
import { ButtonGroup } from "@/components/ButtonGroup"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { Trash2 } from "lucide-react"
import { FC, useState } from "react"
import { ValueDisplay } from "../Storage/StorageSubscriptions"
import {
removeViewFnResult,
ViewFnResult,
viewFnResult$,
viewFnResultKeys$,
} from "./viewFns.state"
import { PathsRoot } from "@/codec-components/common/paths.state"
import { ButtonGroup } from "@/components/ButtonGroup"
import { JsonDisplay } from "@/components/JsonDisplay"
import { workspaceEntryCtxOrAdd$ } from "@/components/Workspace"
import { runtimeCtx$ } from "@/state/chains/chain.state"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { FC, useMemo, useState } from "react"
import { useParams } from "react-router-dom"
import { ValueDisplay } from "../Storage/StorageSubscriptions"
import { ViewFnWorkspaceContext } from "./ViewFnWorkspaceEntry"
import { idToViewFnCall, viewFnCallToWorkspaceEntry } from "./viewFns.state"
export const ViewFnResults: FC = () => {
const keys = useStateObservable(viewFnResultKeys$)
const { callId } = useParams()
if (!keys.length) return null
if (!callId) return null
return (
<div className="p-2 w-full border-t border-border">
<h2 className="text-lg text-foreground mb-2">Results</h2>
<h2 className="text-lg text-foreground mb-2">Result</h2>
<ul className="flex flex-col gap-2">
{keys.map((key) => (
<ViewFnResultBox key={key} subscription={key} />
))}
<ViewFnResultBox id={callId} />
</ul>
</div>
)
}
const ViewFnResultBox: FC<{ subscription: string }> = ({ subscription }) => {
const viewFnCtx$ = state(
(id: string) =>
workspaceEntryCtxOrAdd$(id, async () => {
const call = await idToViewFnCall(id)
return viewFnCallToWorkspaceEntry(call)
}),
null,
)
const ViewFnResultBox: FC<{ id: string }> = ({ id }) => {
const context = useStateObservable(viewFnCtx$(id))
return context ? <ViewFnResultContent id={id} context={context} /> : null
}
const ViewFnResultContent: FC<{
id: string
context: ViewFnWorkspaceContext
}> = ({ id, context }) => {
const [mode, setMode] = useState<"json" | "decoded">("decoded")
const viewFnResult = useStateObservable(viewFnResult$(subscription))
if (!viewFnResult) return null
return (
<li className="border rounded bg-card text-card-foreground p-2">
<div className="flex justify-between items-center pb-1 overflow-hidden">
<h3 className="overflow-hidden text-ellipsis whitespace-nowrap">
{viewFnResult.name}
{context.pallet}.{context.name}
</h3>
<div className="flex items-center shrink-0 gap-2">
<ButtonGroup
@@ -55,16 +67,10 @@ const ViewFnResultBox: FC<{ subscription: string }> = ({ subscription }) => {
},
]}
/>
<button onClick={() => removeViewFnResult(subscription)}>
<Trash2
size={20}
className="text-destructive cursor-pointer hover:text-polkadot-500"
/>
</button>
</div>
</div>
<PathsRoot.Provider value={subscription}>
<ResultDisplay viewFnResult={viewFnResult} mode={mode} />
<PathsRoot.Provider value={id}>
<ResultDisplay context={context} mode={mode} />
</PathsRoot.Provider>
</li>
)
@@ -72,34 +78,47 @@ const ViewFnResultBox: FC<{ subscription: string }> = ({ subscription }) => {
const defaultedCtx$ = runtimeCtx$.pipeState(withDefault(null))
const ResultDisplay: FC<{
viewFnResult: ViewFnResult
context: ViewFnWorkspaceContext
mode: "json" | "decoded"
}> = ({ viewFnResult, mode }) => {
}> = ({ context, mode }) => {
const viewFnResult = useStateObservable(context.result$)
const ctx = useStateObservable(defaultedCtx$)
const type = useMemo(() => {
const pallet = ctx?.lookup.metadata.pallets.find(
(pallet) => pallet.name === context.pallet,
)
const fn = pallet?.viewFns.find((fn) => fn.name === context.name)
return fn?.output ?? null
}, [context, ctx])
if (!ctx) return null
if ("error" in viewFnResult) {
if (!viewFnResult) {
return <div className="text-sm text-foreground/50">Loading...</div>
}
if (!viewFnResult.success) {
return (
<div className="text-sm">
<div>The call crashed</div>
<div>Message: {viewFnResult.error.message ?? "N/A"}</div>
<div>Message: {viewFnResult.value.message ?? "N/A"}</div>
</div>
)
}
if (!("result" in viewFnResult)) {
return <div className="text-sm text-foreground/50">Loading…</div>
}
return (
<div className="max-h-[60svh] overflow-auto">
<ValueDisplay
mode={mode}
ctx={ctx}
type={viewFnResult.type}
value={viewFnResult.result}
title={"Result"}
/>
{type == null ? (
<JsonDisplay src={viewFnResult.value} />
) : (
<ValueDisplay
mode={mode}
ctx={ctx}
type={type}
value={viewFnResult.value}
title={"Result"}
/>
)}
</div>
)
}
@@ -0,0 +1,27 @@
import { JsonDisplay } from "@/components/JsonDisplay"
import type { DefaultedStateObservable } from "@react-rxjs/core"
import { useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
import type { ViewFnResult } from "./viewFns.state"
export type ViewFnWorkspaceContext = {
pallet: string
name: string
result$: DefaultedStateObservable<ViewFnResult | null>
}
export const ViewFnWorkspaceEntry: FC<{
context: ViewFnWorkspaceContext
}> = ({ context }) => {
const result = useStateObservable(context.result$)
if (!result) {
return <div className="p-3 text-sm text-muted-foreground">Loading...</div>
}
return (
<div className="p-3 text-sm">
<JsonDisplay src={result.value} />
</div>
)
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { SearchableSelect } from "@/components/Select"
import { withSubscribe } from "@/components/withSuspense"
import { state, useStateObservable } from "@react-rxjs/core"
import { useEffect, useState } from "react"
import { Route, Routes } from "react-router-dom"
import { map } from "rxjs"
import { ViewFnResults } from "./ViewFnResults"
import { selectedEntry$, setSelectedFn } from "./viewFns.state"
@@ -102,7 +103,9 @@ export const ViewFns = withSubscribe(
</div>
)}
<ViewFnQuery />
<ViewFnResults />
<Routes>
<Route path=":callId" element={<ViewFnResults />} />
</Routes>
</div>
)
},
+97 -57
View File
@@ -1,22 +1,25 @@
import { pushWorkspaceEntry, WorkspaceEntryData } from "@/components/Workspace"
import { runtimeCtxAt$, unsafeApi$ } from "@/state/chains/chain.state"
import { RuntimeContext } from "@polkadot-api/observable-client"
import { UnifiedMetadata } from "@polkadot-api/substrate-bindings"
import { state } from "@react-rxjs/core"
import {
createKeyedSignal,
createSignal,
partitionByKey,
toKeySet,
} from "@react-rxjs/utils"
import { shareLatest, state } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { SquareFunction } from "lucide-react"
import { Binary, HexString, ResultPayload } from "polkadot-api"
import {
catchError,
combineLatest,
firstValueFrom,
from,
map,
Observable,
of,
startWith,
switchMap,
takeUntil,
} from "rxjs"
import { v4 as uuid } from "uuid"
import { stringifyArg } from "../Storage/storage.state"
import {
ViewFnWorkspaceContext,
ViewFnWorkspaceEntry,
} from "./ViewFnWorkspaceEntry"
type Pallet = UnifiedMetadata["pallets"][number]
export type ViewFnEntry = Pallet["viewFns"][number] & {
@@ -26,54 +29,91 @@ export type ViewFnEntry = Pallet["viewFns"][number] & {
export const [entryChange$, setSelectedFn] = createSignal<ViewFnEntry | null>()
export const selectedEntry$ = state(entryChange$, null)
export const [newViewFnCall$, addViewFnCall] = createSignal<{
type ViewFnCall = {
blockHash: HexString
pallet: string
name: string
type: number
promise: Promise<unknown>
}>()
export const [removeViewFnResult$, removeViewFnResult] =
createKeyedSignal<string>()
args: unknown[]
}
export type ViewFnResult = {
name: string
type: number
} & ({ result: unknown } | { error?: any })
const [getViewFnSubscription$, viewFnKeyChange$] = partitionByKey(
newViewFnCall$,
() => uuid(),
(src$, id) =>
src$.pipe(
switchMap(
({ promise, ...props }): Observable<ViewFnResult> =>
from(promise).pipe(
map((result) => ({
...props,
result,
paused: false,
})),
catchError((ex) => {
console.error(ex)
return of({
...props,
error: ex,
})
}),
startWith(props),
),
),
takeUntil(removeViewFnResult$(id)),
const viewFnCallToId = (
ctx: Pick<RuntimeContext, "dynamicBuilder" | "lookup">,
call: ViewFnCall,
) => {
const codec = ctx.dynamicBuilder.buildViewFn(call.pallet, call.name).args
return [
call.blockHash,
call.pallet,
call.name,
Binary.toHex(codec.enc(call.args)),
].join(":")
}
export const idToViewFnCall = async (id: string): Promise<ViewFnCall> => {
const [blockHash, pallet, name, encodedArgs] = id.split(":")
const ctx = await firstValueFrom(runtimeCtxAt$(blockHash))
const codec = ctx.dynamicBuilder.buildViewFn(pallet, name).args
const args = codec.dec(encodedArgs)
return { blockHash, pallet, name, args }
}
export const viewFnCallToWorkspaceEntry = async (
call: ViewFnCall,
): Promise<WorkspaceEntryData<ViewFnWorkspaceContext>> => {
const [unsafeApi, ctx] = await firstValueFrom(
combineLatest([unsafeApi$, runtimeCtxAt$(call.blockHash)]),
)
const query$ = from(
unsafeApi.view[call.pallet][call.name](...call.args, {
at: call.blockHash,
}),
).pipe(
map((result) => ({
success: true,
value: result,
})),
catchError((ex) => {
console.error(ex)
return of({
success: false,
value: ex,
})
}),
shareLatest(),
)
const result$ = state<ViewFnResult | null>(query$, null)
const context: ViewFnWorkspaceContext = {
pallet: call.pallet,
name: call.name,
result$,
}
const id = viewFnCallToId(ctx, call)
return {
id,
source: "View Functions",
title: [call.pallet, call.name].join("."),
subtitle: `${call.args.map(stringifyArg)}`,
icon: SquareFunction,
link: `/viewFns/${id}`,
status: query$.pipe(
map((v) => (v.success ? ("done" as const) : ("error" as const))),
startWith("pending" as const),
),
)
context,
content: ViewFnWorkspaceEntry,
}
}
export const viewFnResultKeys$ = state(
viewFnKeyChange$.pipe(
toKeySet(),
map((keys) => [...keys].reverse()),
),
[],
)
export type ViewFnResult = ResultPayload<unknown, any>
export const viewFnResult$ = state(
(key: string): Observable<ViewFnResult> => getViewFnSubscription$(key),
null,
)
export const addViewFnCall = async (call: ViewFnCall) => {
const data = await viewFnCallToWorkspaceEntry(call)
pushWorkspaceEntry(data)
return data.id
}
+1
View File
@@ -262,6 +262,7 @@ export const runtimeCtxAt$ = state((atBlock: string) =>
mergeMap((client) => {
const pinned = client.chainHead.pinnedBlocks$.state
return (
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
pinned.runtimes[pinned.blocks.get(atBlock)?.runtime!]?.runtime ||
client.chainHead.getRuntimeContext$(atBlock)
)