diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index 26fb6dac4f..e4a65e76f8 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -1,53 +1,71 @@ import React from "react"; import { ClusterProvider } from "./providers/cluster"; -import { TransactionsProvider } from "./providers/transactions"; +import { + TransactionsProvider, + useTransactionsDispatch, + useTransactions, + ActionType +} from "./providers/transactions"; import { AccountsProvider } from "./providers/accounts"; +import { BlocksProvider } from "./providers/blocks"; import ClusterStatusButton from "./components/ClusterStatusButton"; import AccountsCard from "./components/AccountsCard"; import TransactionsCard from "./components/TransactionsCard"; import ClusterModal from "./components/ClusterModal"; +import TransactionModal from "./components/TransactionModal"; import Logo from "./img/logos-solana/light-explorer-logo.svg"; function App() { - const [showModal, setShowModal] = React.useState(false); + const [showClusterModal, setShowClusterModal] = React.useState(false); return ( - setShowModal(false)} /> -
-
-
-
-
-
- Solana Explorer + + + setShowClusterModal(false)} + /> + +
+
+
+
+
+
+ Solana Explorer +
+
+ setShowClusterModal(true)} + /> +
+
-
- setShowModal(true)} /> +
+
+ +
+
+
+ +
+
+
+
+ + +
-
- -
-
-
- - - -
-
-
-
- - - -
-
-
-
- setShowModal(false)} /> + setShowClusterModal(false)} + /> + + ); } @@ -58,8 +76,19 @@ type OverlayProps = { }; function Overlay({ show, onClick }: OverlayProps) { - if (show) - return
; + const { selected } = useTransactions(); + const dispatch = useTransactionsDispatch(); + + if (show || !!selected) + return ( +
{ + onClick(); + dispatch({ type: ActionType.Deselect }); + }} + >
+ ); return
; } diff --git a/explorer/src/components/TransactionModal.tsx b/explorer/src/components/TransactionModal.tsx new file mode 100644 index 0000000000..0d3759f8d5 --- /dev/null +++ b/explorer/src/components/TransactionModal.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { + useTransactions, + useTransactionsDispatch, + ActionType, + Selected +} from "../providers/transactions"; +import { useBlocks } from "../providers/blocks"; + +function TransactionModal() { + const { selected } = useTransactions(); + const dispatch = useTransactionsDispatch(); + const onClose = () => dispatch({ type: ActionType.Deselect }); + const show = !!selected; + + const renderContent = () => { + if (!selected) return null; + return ( +
+
+
e.stopPropagation()}> + + × + + +

Transaction Details

+ + +
+
+
+ ); + }; + + return ( +
+ {renderContent()} +
+ ); +} + +function TransactionDetails({ selected }: { selected: Selected }) { + const { blocks } = useBlocks(); + const block = blocks[selected.slot]; + if (!block) return {"block not found"}; + + if (!block.transactions) { + return loading; + } + + const tx = block.transactions[selected.signature]; + if (!tx) return {"sig not found"}; + + return {JSON.stringify(tx)}; +} + +export default TransactionModal; diff --git a/explorer/src/components/TransactionsCard.tsx b/explorer/src/components/TransactionsCard.tsx index 8ad0b40956..c032b32773 100644 --- a/explorer/src/components/TransactionsCard.tsx +++ b/explorer/src/components/TransactionsCard.tsx @@ -58,6 +58,7 @@ function TransactionsCard() { Signature Confirmations Slot Number + Details @@ -88,8 +89,11 @@ function TransactionsCard() { - - + - {transactions.map(transaction => renderTransactionRow(transaction))} + {transactions.map(transaction => + renderTransactionRow(transaction, dispatch, url) + )}
@@ -109,7 +113,11 @@ const renderHeader = () => { ); }; -const renderTransactionRow = (transaction: Transaction) => { +const renderTransactionRow = ( + transaction: Transaction, + dispatch: any, + url: string +) => { let statusText; let statusClass; switch (transaction.status) { @@ -140,6 +148,32 @@ const renderTransactionRow = (transaction: Transaction) => { const slotText = `${transaction.slot || "-"}`; const confirmationsText = `${transaction.confirmations || "-"}`; + const renderDetails = () => { + let onClick, icon; + if (transaction.confirmations === "max") { + icon = "more-horizontal"; + onClick = () => + dispatch({ + type: ActionType.Select, + signature: transaction.signature + }); + } else { + icon = "refresh-cw"; + onClick = () => { + checkTransactionStatus(dispatch, transaction.signature, url); + }; + } + + return ( + + ); + }; + return ( @@ -155,6 +189,7 @@ const renderTransactionRow = (transaction: Transaction) => { {confirmationsText} {slotText} + {renderDetails()} ); }; diff --git a/explorer/src/providers/blocks.tsx b/explorer/src/providers/blocks.tsx new file mode 100644 index 0000000000..3462909b70 --- /dev/null +++ b/explorer/src/providers/blocks.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import bs58 from "bs58"; +import { Connection, Transaction } from "@solana/web3.js"; +import { useCluster, ClusterStatus } from "./cluster"; +import { useTransactions } from "./transactions"; + +export enum Status { + Checking, + CheckFailed, + Success +} + +type Transactions = { [signature: string]: Transaction }; +export interface Block { + status: Status; + transactions?: Transactions; +} + +export type Blocks = { [slot: number]: Block }; +interface State { + blocks: Blocks; +} + +export enum ActionType { + Update, + Add, + Remove +} + +interface Update { + type: ActionType.Update; + slot: number; + status: Status; + transactions?: Transactions; +} + +interface Add { + type: ActionType.Add; + slots: number[]; +} + +interface Remove { + type: ActionType.Remove; + slots: number[]; +} + +type Action = Update | Add | Remove; +type Dispatch = (action: Action) => void; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case ActionType.Add: { + if (action.slots.length === 0) return state; + const blocks = { ...state.blocks }; + action.slots.forEach(slot => { + if (!blocks[slot]) { + blocks[slot] = { + status: Status.Checking + }; + } + }); + return { ...state, blocks }; + } + case ActionType.Remove: { + if (action.slots.length === 0) return state; + const blocks = { ...state.blocks }; + action.slots.forEach(slot => { + delete blocks[slot]; + }); + return { ...state, blocks }; + } + case ActionType.Update: { + let block = state.blocks[action.slot]; + if (block) { + block = { + ...block, + status: action.status, + transactions: action.transactions + }; + const blocks = { + ...state.blocks, + [action.slot]: block + }; + return { ...state, blocks }; + } + break; + } + } + return state; +} + +const StateContext = React.createContext(undefined); +const DispatchContext = React.createContext(undefined); + +type BlocksProviderProps = { children: React.ReactNode }; +export function BlocksProvider({ children }: BlocksProviderProps) { + const [state, dispatch] = React.useReducer(reducer, { blocks: {} }); + + const { transactions } = useTransactions(); + const { status, url } = useCluster(); + + // Filter blocks for current transaction slots + React.useEffect(() => { + if (status !== ClusterStatus.Connected) return; + + const remove: number[] = []; + const txSlots = transactions + .map(tx => tx.slot) + .filter(x => x) + .reduce((set, slot) => set.add(slot), new Set()); + Object.keys(state.blocks).forEach(blockKey => { + const slot = parseInt(blockKey); + if (!txSlots.has(slot)) { + remove.push(slot); + } + }); + + dispatch({ type: ActionType.Remove, slots: remove }); + + const fetchSlots = new Set(); + transactions.forEach(tx => { + if (tx.slot && tx.confirmations === "max" && !state.blocks[tx.slot]) + fetchSlots.add(tx.slot); + }); + + const fetchList: number[] = []; + fetchSlots.forEach(s => fetchList.push(s)); + dispatch({ type: ActionType.Add, slots: fetchList }); + + fetchSlots.forEach(slot => { + fetchBlock(dispatch, slot, url); + }); + }, [transactions]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + {children} + + + ); +} + +async function fetchBlock(dispatch: Dispatch, slot: number, url: string) { + dispatch({ + type: ActionType.Update, + status: Status.Checking, + slot + }); + + let status; + let transactions: Transactions = {}; + try { + const block = await new Connection(url).getConfirmedBlock(slot); + block.transactions.forEach(({ transaction }) => { + const signature = transaction.signature; + if (signature) { + const sig = bs58.encode(signature); + transactions[sig] = transaction; + } + }); + status = Status.Success; + } catch (error) { + console.error("Failed to fetch confirmed block", error); + status = Status.CheckFailed; + } + dispatch({ type: ActionType.Update, status, slot, transactions }); +} + +export function useBlocks() { + const context = React.useContext(StateContext); + if (!context) { + throw new Error(`useBlocks must be used within a BlocksProvider`); + } + return context; +} diff --git a/explorer/src/providers/transactions.tsx b/explorer/src/providers/transactions.tsx index 5fb1af1ef2..f68df3a4fd 100644 --- a/explorer/src/providers/transactions.tsx +++ b/explorer/src/providers/transactions.tsx @@ -27,15 +27,32 @@ export interface Transaction { signature: TransactionSignature; } +export interface Selected { + slot: number; + signature: TransactionSignature; +} + type Transactions = { [signature: string]: Transaction }; interface State { idCounter: number; + selected?: Selected; transactions: Transactions; } export enum ActionType { UpdateStatus, - InputSignature + InputSignature, + Select, + Deselect +} + +interface SelectTransaction { + type: ActionType.Select; + signature: TransactionSignature; +} + +interface DeselectTransaction { + type: ActionType.Deselect; } interface UpdateStatus { @@ -51,11 +68,27 @@ interface InputSignature { signature: TransactionSignature; } -type Action = UpdateStatus | InputSignature; +type Action = + | UpdateStatus + | InputSignature + | SelectTransaction + | DeselectTransaction; type Dispatch = (action: Action) => void; function reducer(state: State, action: Action): State { switch (action.type) { + case ActionType.Deselect: { + return { ...state, selected: undefined }; + } + case ActionType.Select: { + const tx = state.transactions[action.signature]; + if (!tx.slot) return state; + const selected = { + slot: tx.slot, + signature: tx.signature + }; + return { ...state, selected }; + } case ActionType.InputSignature: { if (!!state.transactions[action.signature]) return state; @@ -101,8 +134,9 @@ function urlSignatures(): Array { .concat(findGetParameter("txns")?.split(",") || []) .concat(findGetParameter("transaction")?.split(",") || []) .concat(findGetParameter("transactions")?.split(",") || []) - .concat(findPathSegment("transaction")?.split(",") || []) - .concat(findPathSegment("transactions")?.split(",") || []); + .concat(findPathSegment("tx")?.split(",") || []) + .concat(findPathSegment("txn")?.split(",") || []) + .concat(findPathSegment("transaction")?.split(",") || []); } function initState(): State { @@ -227,6 +261,7 @@ export function useTransactions() { } return { idCounter: context.idCounter, + selected: context.selected, transactions: Object.values(context.transactions).sort((a, b) => a.id <= b.id ? 1 : -1 )