Add transaction details modal
This commit is contained in:
committed by
Michael Vines
parent
ef7be97540
commit
611f2ae957
@ -1,19 +1,32 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { ClusterProvider } from "./providers/cluster";
|
import { ClusterProvider } from "./providers/cluster";
|
||||||
import { TransactionsProvider } from "./providers/transactions";
|
import {
|
||||||
|
TransactionsProvider,
|
||||||
|
useTransactionsDispatch,
|
||||||
|
useTransactions,
|
||||||
|
ActionType
|
||||||
|
} from "./providers/transactions";
|
||||||
import { AccountsProvider } from "./providers/accounts";
|
import { AccountsProvider } from "./providers/accounts";
|
||||||
|
import { BlocksProvider } from "./providers/blocks";
|
||||||
import ClusterStatusButton from "./components/ClusterStatusButton";
|
import ClusterStatusButton from "./components/ClusterStatusButton";
|
||||||
import AccountsCard from "./components/AccountsCard";
|
import AccountsCard from "./components/AccountsCard";
|
||||||
import TransactionsCard from "./components/TransactionsCard";
|
import TransactionsCard from "./components/TransactionsCard";
|
||||||
import ClusterModal from "./components/ClusterModal";
|
import ClusterModal from "./components/ClusterModal";
|
||||||
|
import TransactionModal from "./components/TransactionModal";
|
||||||
import Logo from "./img/logos-solana/light-explorer-logo.svg";
|
import Logo from "./img/logos-solana/light-explorer-logo.svg";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [showModal, setShowModal] = React.useState(false);
|
const [showClusterModal, setShowClusterModal] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
<ClusterProvider>
|
<ClusterProvider>
|
||||||
<ClusterModal show={showModal} onClose={() => setShowModal(false)} />
|
<TransactionsProvider>
|
||||||
|
<BlocksProvider>
|
||||||
|
<ClusterModal
|
||||||
|
show={showClusterModal}
|
||||||
|
onClose={() => setShowClusterModal(false)}
|
||||||
|
/>
|
||||||
|
<TransactionModal />
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -23,7 +36,9 @@ function App() {
|
|||||||
<img src={Logo} width="250" alt="Solana Explorer" />
|
<img src={Logo} width="250" alt="Solana Explorer" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<ClusterStatusButton onClick={() => setShowModal(true)} />
|
<ClusterStatusButton
|
||||||
|
onClick={() => setShowClusterModal(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -33,9 +48,7 @@ function App() {
|
|||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<TransactionsProvider>
|
|
||||||
<TransactionsCard />
|
<TransactionsCard />
|
||||||
</TransactionsProvider>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
@ -47,7 +60,12 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Overlay show={showModal} onClick={() => setShowModal(false)} />
|
<Overlay
|
||||||
|
show={showClusterModal}
|
||||||
|
onClick={() => setShowClusterModal(false)}
|
||||||
|
/>
|
||||||
|
</BlocksProvider>
|
||||||
|
</TransactionsProvider>
|
||||||
</ClusterProvider>
|
</ClusterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -58,8 +76,19 @@ type OverlayProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Overlay({ show, onClick }: OverlayProps) {
|
function Overlay({ show, onClick }: OverlayProps) {
|
||||||
if (show)
|
const { selected } = useTransactions();
|
||||||
return <div className="modal-backdrop fade show" onClick={onClick}></div>;
|
const dispatch = useTransactionsDispatch();
|
||||||
|
|
||||||
|
if (show || !!selected)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-backdrop fade show"
|
||||||
|
onClick={() => {
|
||||||
|
onClick();
|
||||||
|
dispatch({ type: ActionType.Deselect });
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
|
||||||
return <div className="fade"></div>;
|
return <div className="fade"></div>;
|
||||||
}
|
}
|
||||||
|
60
explorer/src/components/TransactionModal.tsx
Normal file
60
explorer/src/components/TransactionModal.tsx
Normal file
@ -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 (
|
||||||
|
<div className="modal-dialog modal-dialog-center">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-body" onClick={e => e.stopPropagation()}>
|
||||||
|
<span className="close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h2 className="text-center mb-4 mt-4">Transaction Details</h2>
|
||||||
|
|
||||||
|
<TransactionDetails selected={selected} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`modal fade fixed-right${show ? " show" : ""}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransactionDetails({ selected }: { selected: Selected }) {
|
||||||
|
const { blocks } = useBlocks();
|
||||||
|
const block = blocks[selected.slot];
|
||||||
|
if (!block) return <span>{"block not found"}</span>;
|
||||||
|
|
||||||
|
if (!block.transactions) {
|
||||||
|
return <span>loading</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = block.transactions[selected.signature];
|
||||||
|
if (!tx) return <span>{"sig not found"}</span>;
|
||||||
|
|
||||||
|
return <code>{JSON.stringify(tx)}</code>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TransactionModal;
|
@ -58,6 +58,7 @@ function TransactionsCard() {
|
|||||||
<th className="text-muted">Signature</th>
|
<th className="text-muted">Signature</th>
|
||||||
<th className="text-muted">Confirmations</th>
|
<th className="text-muted">Confirmations</th>
|
||||||
<th className="text-muted">Slot Number</th>
|
<th className="text-muted">Slot Number</th>
|
||||||
|
<th className="text-muted">Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="list">
|
<tbody className="list">
|
||||||
@ -88,8 +89,11 @@ function TransactionsCard() {
|
|||||||
</td>
|
</td>
|
||||||
<td>-</td>
|
<td>-</td>
|
||||||
<td>-</td>
|
<td>-</td>
|
||||||
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
{transactions.map(transaction => renderTransactionRow(transaction))}
|
{transactions.map(transaction =>
|
||||||
|
renderTransactionRow(transaction, dispatch, url)
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -109,7 +113,11 @@ const renderHeader = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTransactionRow = (transaction: Transaction) => {
|
const renderTransactionRow = (
|
||||||
|
transaction: Transaction,
|
||||||
|
dispatch: any,
|
||||||
|
url: string
|
||||||
|
) => {
|
||||||
let statusText;
|
let statusText;
|
||||||
let statusClass;
|
let statusClass;
|
||||||
switch (transaction.status) {
|
switch (transaction.status) {
|
||||||
@ -140,6 +148,32 @@ const renderTransactionRow = (transaction: Transaction) => {
|
|||||||
const slotText = `${transaction.slot || "-"}`;
|
const slotText = `${transaction.slot || "-"}`;
|
||||||
const confirmationsText = `${transaction.confirmations || "-"}`;
|
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 (
|
||||||
|
<button
|
||||||
|
className="btn btn-rounded-circle btn-white btn-sm"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className={`fe fe-${icon}`}></span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={transaction.signature}>
|
<tr key={transaction.signature}>
|
||||||
<td>
|
<td>
|
||||||
@ -155,6 +189,7 @@ const renderTransactionRow = (transaction: Transaction) => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="text-uppercase">{confirmationsText}</td>
|
<td className="text-uppercase">{confirmationsText}</td>
|
||||||
<td>{slotText}</td>
|
<td>{slotText}</td>
|
||||||
|
<td>{renderDetails()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
176
explorer/src/providers/blocks.tsx
Normal file
176
explorer/src/providers/blocks.tsx
Normal file
@ -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<State | undefined>(undefined);
|
||||||
|
const DispatchContext = React.createContext<Dispatch | undefined>(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<number>();
|
||||||
|
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 (
|
||||||
|
<StateContext.Provider value={state}>
|
||||||
|
<DispatchContext.Provider value={dispatch}>
|
||||||
|
{children}
|
||||||
|
</DispatchContext.Provider>
|
||||||
|
</StateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
@ -27,15 +27,32 @@ export interface Transaction {
|
|||||||
signature: TransactionSignature;
|
signature: TransactionSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Selected {
|
||||||
|
slot: number;
|
||||||
|
signature: TransactionSignature;
|
||||||
|
}
|
||||||
|
|
||||||
type Transactions = { [signature: string]: Transaction };
|
type Transactions = { [signature: string]: Transaction };
|
||||||
interface State {
|
interface State {
|
||||||
idCounter: number;
|
idCounter: number;
|
||||||
|
selected?: Selected;
|
||||||
transactions: Transactions;
|
transactions: Transactions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ActionType {
|
export enum ActionType {
|
||||||
UpdateStatus,
|
UpdateStatus,
|
||||||
InputSignature
|
InputSignature,
|
||||||
|
Select,
|
||||||
|
Deselect
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectTransaction {
|
||||||
|
type: ActionType.Select;
|
||||||
|
signature: TransactionSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeselectTransaction {
|
||||||
|
type: ActionType.Deselect;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateStatus {
|
interface UpdateStatus {
|
||||||
@ -51,11 +68,27 @@ interface InputSignature {
|
|||||||
signature: TransactionSignature;
|
signature: TransactionSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action = UpdateStatus | InputSignature;
|
type Action =
|
||||||
|
| UpdateStatus
|
||||||
|
| InputSignature
|
||||||
|
| SelectTransaction
|
||||||
|
| DeselectTransaction;
|
||||||
type Dispatch = (action: Action) => void;
|
type Dispatch = (action: Action) => void;
|
||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
function reducer(state: State, action: Action): State {
|
||||||
switch (action.type) {
|
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: {
|
case ActionType.InputSignature: {
|
||||||
if (!!state.transactions[action.signature]) return state;
|
if (!!state.transactions[action.signature]) return state;
|
||||||
|
|
||||||
@ -101,8 +134,9 @@ function urlSignatures(): Array<string> {
|
|||||||
.concat(findGetParameter("txns")?.split(",") || [])
|
.concat(findGetParameter("txns")?.split(",") || [])
|
||||||
.concat(findGetParameter("transaction")?.split(",") || [])
|
.concat(findGetParameter("transaction")?.split(",") || [])
|
||||||
.concat(findGetParameter("transactions")?.split(",") || [])
|
.concat(findGetParameter("transactions")?.split(",") || [])
|
||||||
.concat(findPathSegment("transaction")?.split(",") || [])
|
.concat(findPathSegment("tx")?.split(",") || [])
|
||||||
.concat(findPathSegment("transactions")?.split(",") || []);
|
.concat(findPathSegment("txn")?.split(",") || [])
|
||||||
|
.concat(findPathSegment("transaction")?.split(",") || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initState(): State {
|
function initState(): State {
|
||||||
@ -227,6 +261,7 @@ export function useTransactions() {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
idCounter: context.idCounter,
|
idCounter: context.idCounter,
|
||||||
|
selected: context.selected,
|
||||||
transactions: Object.values(context.transactions).sort((a, b) =>
|
transactions: Object.values(context.transactions).sort((a, b) =>
|
||||||
a.id <= b.id ? 1 : -1
|
a.id <= b.id ? 1 : -1
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user