diff --git a/explorer/package.json b/explorer/package.json index f595dce13..a9c3474a6 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -64,5 +64,6 @@ "last 1 firefox version", "last 1 safari version" ] - } + }, + "devDependencies": {} } diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index f40ec15ba..be665a6be 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -11,6 +11,7 @@ import { AccountDetailsPage } from "pages/AccountDetailsPage"; import { ClusterStatsPage } from "pages/ClusterStatsPage"; import { SupplyPage } from "pages/SupplyPage"; import { TransactionDetailsPage } from "pages/TransactionDetailsPage"; +import { BlockDetailsPage } from "pages/BlockDetailsPage"; const ADDRESS_ALIASES = ["account", "accounts", "addresses"]; const TX_ALIASES = ["txs", "txn", "txns", "transaction", "transactions"]; @@ -43,6 +44,11 @@ function App() { )} /> + } + /> (selectRef.current = ref)} options={buildOptions(search, cluster)} noOptionsMessage={() => "No Results"} - placeholder="Search for accounts, transactions, programs, and tokens" + placeholder="Search for blocks, accounts, transactions, programs, and tokens" value={resetValue} inputValue={search} blurInputOnSelect @@ -189,6 +189,19 @@ function buildOptions(search: string, cluster: Cluster) { options.push(tokenOptions); } + if (!isNaN(Number(search))) { + options.push({ + label: "Block", + options: [ + { + label: `Slot #${search}`, + value: search, + pathname: `/block/${search}`, + }, + ], + }); + } + // Prefer nice suggestions over raw suggestions if (options.length > 0) return options; diff --git a/explorer/src/components/account/BlockHistoryCard.tsx b/explorer/src/components/account/BlockHistoryCard.tsx new file mode 100644 index 000000000..7266001d7 --- /dev/null +++ b/explorer/src/components/account/BlockHistoryCard.tsx @@ -0,0 +1,124 @@ +import bs58 from "bs58"; +import React from "react"; +import { TableCardBody } from "components/common/TableCardBody"; +import { useBlock, useFetchBlock, FetchStatus } from "providers/block"; +import { Signature } from "components/common/Signature"; +import { ErrorCard } from "components/common/ErrorCard"; +import { LoadingCard } from "components/common/LoadingCard"; +import { Slot } from "components/common/Slot"; + +export function BlockHistoryCard({ slot }: { slot: number }) { + const confirmedBlock = useBlock(slot); + const fetchBlock = useFetchBlock(); + const refresh = () => fetchBlock(slot); + + React.useEffect(() => { + if (!confirmedBlock) refresh(); + }, [confirmedBlock, slot]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!confirmedBlock) { + return null; + } + + if (confirmedBlock.data === undefined) { + if (confirmedBlock.status === FetchStatus.Fetching) { + return ; + } + + return ; + } + + if (confirmedBlock.status === FetchStatus.FetchFailed) { + return ; + } + + return ( + <> +
+
+

+ Overview +

+
+ + + Slot + + + + + + Parent Slot + + + + + + Blockhash + + {confirmedBlock.data.blockhash} + + + + Previous Blockhash + + {confirmedBlock.data.previousBlockhash} + + + +
+ +
+
+

Block Transactions

+
+ +
+ + + + + + + + + {confirmedBlock.data.transactions.map((tx, i) => { + let statusText; + let statusClass; + let signature: React.ReactNode; + if (tx.meta?.err || !tx.transaction.signature) { + statusClass = "warning"; + statusText = "Failed"; + } else { + statusClass = "success"; + statusText = "Success"; + } + + if (tx.transaction.signature) { + signature = ( + + ); + } + + return ( + + + + + + ); + })} + +
ResultTransaction Signature
+ + {statusText} + + {signature}
+
+
+ + ); +} diff --git a/explorer/src/components/common/Slot.tsx b/explorer/src/components/common/Slot.tsx index 96703d1a4..1e1d680ad 100644 --- a/explorer/src/components/common/Slot.tsx +++ b/explorer/src/components/common/Slot.tsx @@ -1,8 +1,40 @@ -import React from "react"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +type CopyState = "copy" | "copied"; type Props = { slot: number; + link?: boolean; }; -export function Slot({ slot }: Props) { - return {slot.toLocaleString("en-US")}; +export function Slot({ slot, link }: Props) { + const [state, setState] = useState("copy"); + + const copyToClipboard = () => navigator.clipboard.writeText(slot.toString()); + const handleClick = () => + copyToClipboard().then(() => { + setState("copied"); + setTimeout(() => setState("copy"), 1000); + }); + + const copyIcon = + state === "copy" ? ( + + ) : ( + + ); + + const copyButton = ( + {copyIcon} + ); + + return link ? ( + + {copyButton} + + {slot.toLocaleString("en-US")} + + + ) : ( + {slot.toLocaleString("en-US")} + ); } diff --git a/explorer/src/index.tsx b/explorer/src/index.tsx index 68f45863e..d4d7b8261 100644 --- a/explorer/src/index.tsx +++ b/explorer/src/index.tsx @@ -9,6 +9,7 @@ import { RichListProvider } from "./providers/richList"; import { SupplyProvider } from "./providers/supply"; import { TransactionsProvider } from "./providers/transactions"; import { AccountsProvider } from "./providers/accounts"; +import { BlockProvider } from "./providers/block"; import { StatsProvider } from "providers/stats"; import { MintsProvider } from "providers/mints"; @@ -26,11 +27,13 @@ ReactDOM.render( - - - - - + + + + + + + diff --git a/explorer/src/pages/BlockDetailsPage.tsx b/explorer/src/pages/BlockDetailsPage.tsx new file mode 100644 index 000000000..f207bf30c --- /dev/null +++ b/explorer/src/pages/BlockDetailsPage.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +import { BlockHistoryCard } from "components/account/BlockHistoryCard"; +import { ErrorCard } from "components/common/ErrorCard"; + +type Props = { slot: string }; + +export function BlockDetailsPage({ slot }: Props) { + let output = ; + + if (!isNaN(Number(slot))) { + output = ; + } + + return ( +
+
+
+
Details
+

Block

+
+
+ {output} +
+ ); +} diff --git a/explorer/src/pages/TransactionDetailsPage.tsx b/explorer/src/pages/TransactionDetailsPage.tsx index a81e9c1cd..b27ac1970 100644 --- a/explorer/src/pages/TransactionDetailsPage.tsx +++ b/explorer/src/pages/TransactionDetailsPage.tsx @@ -20,7 +20,6 @@ import { StakeDetailsCard } from "components/instruction/stake/StakeDetailsCard" import { ErrorCard } from "components/common/ErrorCard"; import { LoadingCard } from "components/common/LoadingCard"; import { TableCardBody } from "components/common/TableCardBody"; -import { Slot } from "components/common/Slot"; import { displayTimestamp } from "utils/date"; import { InfoTooltip } from "components/common/InfoTooltip"; import { Address } from "components/common/Address"; @@ -29,6 +28,7 @@ import { intoTransactionInstruction, isSerumInstruction } from "utils/tx"; import { TokenDetailsCard } from "components/instruction/token/TokenDetailsCard"; import { FetchStatus } from "providers/cache"; import { SerumDetailsCard } from "components/instruction/SerumDetailsCard"; +import { Slot } from "components/common/Slot"; const AUTO_REFRESH_INTERVAL = 2000; const ZERO_CONFIRMATION_BAILOUT = 5; @@ -249,7 +249,7 @@ function StatusCard({ Block - + @@ -264,9 +264,7 @@ function StatusCard({ )} - - {blockhash} - + {blockhash} )} diff --git a/explorer/src/providers/block.tsx b/explorer/src/providers/block.tsx new file mode 100644 index 000000000..1427acde5 --- /dev/null +++ b/explorer/src/providers/block.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import * as Sentry from "@sentry/react"; +import * as Cache from "providers/cache"; +import { Connection, ConfirmedBlock } from "@solana/web3.js"; +import { useCluster, Cluster } from "./cluster"; + +export enum FetchStatus { + Fetching, + FetchFailed, + Fetched, +} + +export enum ActionType { + Update, + Clear, +} + +type State = Cache.State; +type Dispatch = Cache.Dispatch; + +const StateContext = React.createContext(undefined); +const DispatchContext = React.createContext(undefined); + +type BlockProviderProps = { children: React.ReactNode }; + +export function BlockProvider({ children }: BlockProviderProps) { + const { url } = useCluster(); + const [state, dispatch] = Cache.useReducer(url); + + React.useEffect(() => { + dispatch({ type: ActionType.Clear, url }); + }, [dispatch, url]); + + return ( + + + {children} + + + ); +} + +export function useBlock( + key: number +): Cache.CacheEntry | undefined { + const context = React.useContext(StateContext); + + if (!context) { + throw new Error(`useBlock must be used within a BlockProvider`); + } + + return context.entries[key]; +} + +export async function fetchBlock( + dispatch: Dispatch, + url: string, + cluster: Cluster, + key: number +) { + dispatch({ + type: ActionType.Update, + status: FetchStatus.Fetching, + key, + url, + }); + let status = FetchStatus.Fetching; + let data: ConfirmedBlock = { + blockhash: "", + previousBlockhash: "", + parentSlot: 0, + transactions: [], + }; + + try { + const connection = new Connection(url, "max"); + data = await connection.getConfirmedBlock(Number(key)); + status = FetchStatus.Fetched; + } catch (error) { + console.log(error); + if (cluster !== Cluster.Custom) { + Sentry.captureException(error, { tags: { url } }); + } + status = FetchStatus.FetchFailed; + } + + dispatch({ + type: ActionType.Update, + url, + key, + status, + data, + }); +} + +export function useFetchBlock() { + const { cluster, url } = useCluster(); + const state = React.useContext(StateContext); + const dispatch = React.useContext(DispatchContext); + + if (!state || !dispatch) { + throw new Error(`useFetchBlock must be used within a BlockProvider`); + } + + return React.useCallback( + (key: number) => { + const entry = state.entries[key]; + if (!entry) { + fetchBlock(dispatch, url, cluster, key); + } + }, + [state, dispatch, cluster, url] + ); +} diff --git a/explorer/src/providers/cache.tsx b/explorer/src/providers/cache.tsx index 27484166c..476526dc1 100644 --- a/explorer/src/providers/cache.tsx +++ b/explorer/src/providers/cache.tsx @@ -26,7 +26,7 @@ export enum ActionType { export type Update = { type: ActionType.Update; url: string; - key: string; + key: string | number; status: FetchStatus; data?: T; }; diff --git a/explorer/src/providers/cluster.tsx b/explorer/src/providers/cluster.tsx index 95c2416f9..e2bf1f231 100644 --- a/explorer/src/providers/cluster.tsx +++ b/explorer/src/providers/cluster.tsx @@ -186,7 +186,11 @@ async function updateCluster( if (cluster !== Cluster.Custom) { reportError(error, { clusterUrl: clusterUrl(cluster, customUrl) }); } - dispatch({ status: ClusterStatus.Failure, cluster, customUrl }); + dispatch({ + status: ClusterStatus.Failure, + cluster, + customUrl, + }); } }