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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./WorkspaceDrawer"
|
||||
export * from "./workspace.state"
|
||||
@@ -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!),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
])
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user