Add transaction details page

This commit is contained in:
Justin Starry 2020-04-29 20:48:38 +08:00 committed by Michael Vines
parent 9f26cbbbeb
commit 484a4db626
14 changed files with 805 additions and 524 deletions

View File

@ -1,99 +1,61 @@
import React from "react";
import { Link, Switch, Route, Redirect } from "react-router-dom";
import ClusterStatusButton from "./components/ClusterStatusButton";
import AccountsCard from "./components/AccountsCard";
import TransactionsCard from "./components/TransactionsCard";
import TransactionDetails from "./components/TransactionDetails";
import ClusterModal from "./components/ClusterModal";
import TransactionModal from "./components/TransactionModal";
import AccountModal from "./components/AccountModal";
import Logo from "./img/logos-solana/light-explorer-logo.svg";
import { useCurrentTab, Tab } from "./providers/tab";
import { TX_PATHS } from "./providers/transactions";
import { TX_ALIASES } from "./providers/transactions";
import { ACCOUNT_PATHS } from "./providers/accounts";
import TabbedPage from "components/TabbedPage";
function App() {
const [showClusterModal, setShowClusterModal] = React.useState(false);
return (
<>
<ClusterModal
show={showClusterModal}
onClose={() => setShowClusterModal(false)}
/>
<TransactionModal />
<ClusterModal />
<AccountModal />
<div className="main-content">
<nav className="navbar navbar-expand-xl navbar-light">
<div className="container">
<div className="row align-items-end">
<div className="col">
<img src={Logo} width="250" alt="Solana Explorer" />
<Link to="/">
<img src={Logo} width="250" alt="Solana Explorer" />
</Link>
</div>
</div>
</div>
</nav>
<div className="header">
<div className="container">
<div className="header-body">
<div className="row align-items-center d-md-none">
<div className="col-12">
<ClusterStatusButton
expand
onClick={() => setShowClusterModal(true)}
/>
</div>
</div>
<div className="row align-items-center">
<div className="col">
<ul className="nav nav-tabs nav-overflow header-tabs">
<li className="nav-item">
<NavLink href="/transactions" tab="Transactions" />
</li>
<li className="nav-item">
<NavLink href="/accounts" tab="Accounts" />
</li>
</ul>
</div>
<div className="col-auto d-none d-md-block">
<ClusterStatusButton
onClick={() => setShowClusterModal(true)}
/>
</div>
</div>
</div>
</div>
</div>
<div className="container">
<Switch>
<Route exact path="/">
<Redirect to="/transactions" />
</Route>
<Route path={TX_PATHS}>
<Switch>
<Route
exact
path={TX_ALIASES.map(tx => `/${tx}/:signature`)}
render={({ match }) => (
<TransactionDetails signature={match.params.signature} />
)}
/>
<Route exact path={TX_ALIASES.map(tx => `/${tx}s`)}>
<TabbedPage tab="Transactions">
<TransactionsCard />
</Route>
<Route path={ACCOUNT_PATHS}>
</TabbedPage>
</Route>
<Route path={ACCOUNT_PATHS}>
<TabbedPage tab="Accounts">
<AccountsCard />
</Route>
</Switch>
</div>
</TabbedPage>
</Route>
<Route
render={({ location }) => (
<Redirect to={{ ...location, pathname: "/transactions" }} />
)}
></Route>
</Switch>
</div>
</>
);
}
function NavLink({ href, tab }: { href: string; tab: Tab }) {
let classes = "nav-link";
if (tab === useCurrentTab()) {
classes += " active";
}
return (
<Link to={href} className={classes}>
{tab}
</Link>
);
}
export default App;

View File

@ -11,8 +11,9 @@ import {
import { assertUnreachable } from "../utils";
import { displayAddress } from "../utils/tx";
import { useCluster } from "../providers/cluster";
import { PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { PublicKey } from "@solana/web3.js";
import Copyable from "./Copyable";
import { lamportsToSolString } from "utils";
function AccountsCard() {
const { accounts, idCounter } = useAccounts();
@ -154,7 +155,7 @@ const renderAccountRow = (
let balance = "-";
if (account.lamports !== undefined) {
balance = `${(1.0 * account.lamports) / LAMPORTS_PER_SOL}`;
balance = lamportsToSolString(account.lamports);
}
const renderDetails = () => {

View File

@ -7,17 +7,15 @@ import {
clusterUrl,
clusterName,
CLUSTERS,
Cluster
Cluster,
useClusterModal
} from "../providers/cluster";
import { assertUnreachable } from "../utils";
import Overlay from "./Overlay";
type Props = {
show: boolean;
onClose: () => void;
};
function ClusterModal({ show, onClose }: Props) {
function ClusterModal() {
const [show, setShow] = useClusterModal();
const onClose = () => setShow(false);
return (
<>
<div

View File

@ -0,0 +1,68 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { useClusterModal } from "providers/cluster";
import ClusterStatusButton from "components/ClusterStatusButton";
export type Tab = "Transactions" | "Accounts";
type Props = { children: React.ReactNode; tab: Tab };
export default function TabbedPage({ children, tab }: Props) {
const [, setShow] = useClusterModal();
return (
<div className="container">
<div className="header">
<div className="header-body">
<div className="row align-items-center d-md-none">
<div className="col-12">
<ClusterStatusButton expand onClick={() => setShow(true)} />
</div>
</div>
<div className="row align-items-center">
<div className="col">
<ul className="nav nav-tabs nav-overflow header-tabs">
<li className="nav-item">
<NavLink
href="/transactions"
tab="Transactions"
current={tab}
/>
</li>
<li className="nav-item">
<NavLink href="/accounts" tab="Accounts" current={tab} />
</li>
</ul>
</div>
<div className="col-auto d-none d-md-block">
<ClusterStatusButton onClick={() => setShow(true)} />
</div>
</div>
</div>
</div>
{children}
</div>
);
}
function NavLink({
href,
tab,
current
}: {
href: string;
tab: Tab;
current: Tab;
}) {
const location = useLocation();
let classes = "nav-link";
if (tab === current) {
classes += " active";
}
return (
<Link to={{ ...location, pathname: href }} className={classes}>
{tab}
</Link>
);
}

View File

@ -0,0 +1,482 @@
import React from "react";
import {
Source,
useTransactionStatus,
useTransactionDetails,
useTransactionsDispatch,
useDetailsDispatch,
checkTransactionStatus,
ActionType,
FetchStatus
} from "../providers/transactions";
import { fetchDetails } from "providers/transactions/details";
import { useCluster, useClusterModal } from "providers/cluster";
import {
TransactionSignature,
TransactionInstruction,
TransferParams,
CreateAccountParams,
SystemProgram
} from "@solana/web3.js";
import ClusterStatusButton from "components/ClusterStatusButton";
import { lamportsToSolString } from "utils";
import { displayAddress, decodeCreate, decodeTransfer } from "utils/tx";
import Copyable from "./Copyable";
import { useHistory, useLocation } from "react-router-dom";
type Props = { signature: TransactionSignature };
export default function TransactionDetails({ signature }: Props) {
const dispatch = useTransactionsDispatch();
const { url } = useCluster();
const [, setShow] = useClusterModal();
const [search, setSearch] = React.useState(signature);
const history = useHistory();
const location = useLocation();
const updateSignature = () => {
history.push({ ...location, pathname: "/tx/" + search });
};
// Fetch transaction on load
React.useEffect(() => {
dispatch({
type: ActionType.FetchSignature,
signature,
source: Source.Url
});
checkTransactionStatus(dispatch, signature, url);
}, [signature, dispatch, url]);
const searchInput = (
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
onKeyUp={e => e.key === "Enter" && updateSignature()}
className="form-control form-control-prepended search text-monospace"
placeholder="Search for signature"
/>
);
return (
<div className="container">
<div className="header">
<div className="header-body">
<div className="row align-items-center">
<div className="col">
<h6 className="header-pretitle">Details</h6>
<h3 className="header-title">Transaction</h3>
</div>
<div className="col-auto">
<ClusterStatusButton onClick={() => setShow(true)} />
</div>
</div>
</div>
</div>
<div className="row mb-4 mt-n2 align-items-center">
<div className="col d-none d-md-block">
<div className="input-group input-group-merge">
{searchInput}
<div className="input-group-prepend">
<div className="input-group-text">
<span className="fe fe-search"></span>
</div>
</div>
</div>
</div>
<div className="col d-block d-md-none">{searchInput}</div>
<div className="col-auto ml-n3 d-block d-md-none">
<button className="btn btn-white" onClick={updateSignature}>
<span className="fe fe-search"></span>
</button>
</div>
</div>
<TransactionStatusCard signature={signature} />
<TransactionAccountsCard signature={signature} />
<TransactionInstructionsCard signature={signature} />
</div>
);
}
function TransactionStatusCard({ signature }: Props) {
const status = useTransactionStatus(signature);
const dispatch = useTransactionsDispatch();
const details = useTransactionDetails(signature);
const { url } = useCluster();
const refreshStatus = () => {
checkTransactionStatus(dispatch, signature, url);
};
if (!status || status.fetchStatus === FetchStatus.Fetching) {
return <LoadingCard />;
} else if (status?.fetchStatus === FetchStatus.FetchFailed) {
return <RetryCard retry={refreshStatus} text="Fetch Failed" />;
} else if (!status.info) {
return <RetryCard retry={refreshStatus} text="Not Found" />;
}
const { info } = status;
const renderResult = () => {
let statusClass = "success";
let statusText = "Success";
if (info.result.err) {
statusClass = "danger";
statusText = "Error";
}
return (
<span className={`badge badge-soft-${statusClass}`}>{statusText}</span>
);
};
const fee = details?.transaction?.meta?.fee;
return (
<div className="card">
<div className="card-header">
<div className="row align-items-center">
<div className="col">
<div className="card-header-title">Status</div>
</div>
<div className="col-auto">
<button className="btn btn-white btn-sm" onClick={refreshStatus}>
<span className="fe fe-refresh-cw mr-2"></span>
Refresh
</button>
</div>
</div>
</div>
<div className="card-body">
<div className="list-group list-group-flush my-n3">
<div className="list-group-item">
<div className="row align-items-center">
<div className="col">
<h5 className="mb-0">Result</h5>
</div>
<div className="col-auto">{renderResult()}</div>
</div>
</div>
<div className="list-group-item">
<div className="row align-items-center">
<div className="col">
<h5 className="mb-0">Block</h5>
</div>
<div className="col-auto">{info.slot}</div>
</div>
</div>
<div className="list-group-item">
<div className="row align-items-center">
<div className="col">
<h5 className="mb-0">Confirmations</h5>
</div>
<div className="col-auto text-uppercase">
{info.confirmations}
</div>
</div>
</div>
{fee && (
<div className="list-group-item">
<div className="row align-items-center">
<div className="col">
<h5 className="mb-0">Fee (SOL)</h5>
</div>
<div className="col-auto">{lamportsToSolString(fee)}</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function TransactionAccountsCard({ signature }: Props) {
const details = useTransactionDetails(signature);
const dispatch = useDetailsDispatch();
const { url } = useCluster();
const refreshDetails = () => fetchDetails(dispatch, signature, url);
const transaction = details?.transaction?.transaction;
const message = React.useMemo(() => {
return transaction?.compileMessage();
}, [transaction]);
if (!details) {
return null;
} else if (details.fetchStatus === FetchStatus.Fetching) {
return <LoadingCard />;
} else if (details?.fetchStatus === FetchStatus.FetchFailed) {
return <RetryCard retry={refreshDetails} text="Fetch Failed" />;
} else if (!details.transaction || !message) {
return <RetryCard retry={refreshDetails} text="Not Found" />;
}
const { meta } = details.transaction;
if (!meta) {
return <RetryCard retry={refreshDetails} text="Metadata Missing" />;
}
const accountRows = message.accountKeys.map((pubkey, index) => {
const pre = meta.preBalances[index];
const post = meta.postBalances[index];
const key = pubkey.toBase58();
const renderChange = () => {
const change = post - pre;
if (change === 0) return "";
const sols = lamportsToSolString(change);
if (change > 0) {
return <span className="badge badge-soft-success">{"+" + sols}</span>;
} else {
return <span className="badge badge-soft-warning">{"-" + sols}</span>;
}
};
return (
<tr key={key}>
<td>
<Copyable text={key}>
<code>{displayAddress(pubkey)}</code>
</Copyable>
</td>
<td>{renderChange()}</td>
<td>{lamportsToSolString(post)}</td>
<td>
{index === 0 && (
<span className="badge badge-soft-dark mr-1">Fee Payer</span>
)}
{!message.isAccountWritable(index) && (
<span className="badge badge-soft-dark mr-1">Readonly</span>
)}
{index < message.header.numRequiredSignatures && (
<span className="badge badge-soft-dark mr-1">Signer</span>
)}
{message.instructions.find(ix => ix.programIdIndex === index) && (
<span className="badge badge-soft-dark mr-1">Program</span>
)}
</td>
</tr>
);
});
return (
<div className="card">
<div className="card-header">
<h4 className="card-header-title">Accounts</h4>
</div>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted">Address</th>
<th className="text-muted">Change (SOL)</th>
<th className="text-muted">Post Balance (SOL)</th>
<th className="text-muted">Details</th>
</tr>
</thead>
<tbody className="list">{accountRows}</tbody>
</table>
</div>
</div>
);
}
function TransactionInstructionsCard({ signature }: Props) {
const details = useTransactionDetails(signature);
const dispatch = useDetailsDispatch();
const { url } = useCluster();
const refreshDetails = () => fetchDetails(dispatch, signature, url);
if (!details || !details.transaction) return null;
const { transaction } = details.transaction;
if (transaction.instructions.length === 0) {
return <RetryCard retry={refreshDetails} text="No instructions found" />;
}
const instructionDetails = transaction.instructions.map((ix, index) => {
const transfer = decodeTransfer(ix);
if (transfer)
return <TransferDetails key={index} transfer={transfer} index={index} />;
const create = decodeCreate(ix);
if (create)
return <CreateDetails key={index} create={create} index={index} />;
return <InstructionDetails key={index} ix={ix} index={index} />;
});
return (
<>
<div className="container">
<div className="header">
<div className="header-body">Transaction Instruction(s)</div>
</div>
</div>
{instructionDetails}
</>
);
}
function TransferDetails({
transfer,
index
}: {
transfer: TransferParams;
index: number;
}) {
const from = transfer.fromPubkey.toBase58();
const to = transfer.toPubkey.toBase58();
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">
<span className="badge badge-soft-dark mr-2">#{index + 1}</span>
Transfer
</h3>
</div>
<div className="card-body">
<div className="list-group list-group-flush my-n3">
<ListGroupItem label="Program">
<code>{displayAddress(SystemProgram.programId)}</code>
</ListGroupItem>
<ListGroupItem label="From">
<Copyable text={from}>
<code>{from}</code>
</Copyable>
</ListGroupItem>
<ListGroupItem label="To">
<Copyable text={to}>
<code>{to}</code>
</Copyable>
</ListGroupItem>
<ListGroupItem label="Amount (SOL)">
{lamportsToSolString(transfer.lamports)}
</ListGroupItem>
</div>
</div>
</div>
);
}
function CreateDetails({
create,
index
}: {
create: CreateAccountParams;
index: number;
}) {
const from = create.fromPubkey.toBase58();
const newKey = create.newAccountPubkey.toBase58();
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">
<span className="badge badge-soft-dark mr-2">#{index + 1}</span>
Create Account
</h3>
</div>
<div className="card-body">
<div className="list-group list-group-flush my-n3">
<ListGroupItem label="Program">
<code>{displayAddress(SystemProgram.programId)}</code>
</ListGroupItem>
<ListGroupItem label="From">
<Copyable text={from}>
<code>{from}</code>
</Copyable>
</ListGroupItem>
<ListGroupItem label="New Account">
<Copyable text={newKey}>
<code>{newKey}</code>
</Copyable>
</ListGroupItem>
<ListGroupItem label="Amount (SOL)">
{lamportsToSolString(create.lamports)}
</ListGroupItem>
<ListGroupItem label="Data (Bytes)">{create.space}</ListGroupItem>
<ListGroupItem label="Owner">
<code>{displayAddress(create.programId)}</code>
</ListGroupItem>
</div>
</div>
</div>
);
}
function InstructionDetails({
ix,
index
}: {
ix: TransactionInstruction;
index: number;
}) {
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">
<span className="badge badge-soft-dark mr-2">#{index + 1}</span>
</h3>
</div>
<div className="card-body">
<div className="list-group list-group-flush my-n3">
<ListGroupItem label="Program">
<code>{displayAddress(ix.programId)}</code>
</ListGroupItem>
{ix.keys.map(({ pubkey }, keyIndex) => (
<ListGroupItem key={keyIndex} label={`Address #${keyIndex + 1}`}>
<Copyable text={pubkey.toBase58()}>
<code>{pubkey.toBase58()}</code>
</Copyable>
</ListGroupItem>
))}
<ListGroupItem label="Data (Bytes)">{ix.data.length}</ListGroupItem>
</div>
</div>
</div>
);
}
function ListGroupItem({
label,
children
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="list-group-item">
<div className="row align-items-center">
<div className="col">
<h5 className="mb-0">{label}</h5>
</div>
<div className="col-auto">{children}</div>
</div>
</div>
);
}
function LoadingCard() {
return (
<div className="card">
<div className="card-body">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</div>
</div>
);
}
function RetryCard({ retry, text }: { retry: () => void; text: string }) {
return (
<div className="card">
<div className="card-body">
{text}
<span className="btn btn-white ml-3" onClick={retry}>
Try Again
</span>
</div>
</div>
);
}

View File

@ -1,223 +0,0 @@
import React from "react";
import {
useDetails,
useTransactions,
useTransactionsDispatch,
ActionType
} from "../providers/transactions";
import { displayAddress, decodeCreate, decodeTransfer } from "../utils/tx";
import {
LAMPORTS_PER_SOL,
TransferParams,
CreateAccountParams,
TransactionInstruction,
TransactionSignature
} from "@solana/web3.js";
import Copyable from "./Copyable";
import Overlay from "./Overlay";
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-centered">
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-card card">
<div className="card-header">
<h4 className="card-header-title">Transaction Details</h4>
<button type="button" className="close" onClick={onClose}>
<span aria-hidden="true">&times;</span>
</button>
</div>
<TransactionDetails signature={selected} />
</div>
</div>
</div>
);
};
return (
<>
<div className={`modal fade${show ? " show" : ""}`} onClick={onClose}>
{renderContent()}
</div>
<Overlay show={show} />
</>
);
}
function TransactionDetails({
signature
}: {
signature: TransactionSignature;
}) {
const details = useDetails(signature);
const renderError = (content: React.ReactNode) => {
return (
<div className="card-body">
<span className="text-info">{content}</span>
</div>
);
};
if (!details) return renderError("Transaction details not found");
if (!details.transaction) {
return renderError(
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
);
}
const { transaction } = details.transaction;
if (!transaction) return renderError("Transaction not found");
if (transaction.instructions.length === 0)
return renderError("No instructions found");
const instructionDetails = transaction.instructions.map((ix, index) => {
const transfer = decodeTransfer(ix);
if (transfer) return <TransferDetails transfer={transfer} index={index} />;
const create = decodeCreate(ix);
if (create) return <CreateDetails create={create} index={index} />;
return <InstructionDetails ix={ix} index={index} />;
});
return (
<>
{instructionDetails.map((details, i) => {
return (
<div key={++i}>
{i > 1 ? <hr className="mt-0 mb-0"></hr> : null}
{details}
</div>
);
})}
</>
);
}
function TransferDetails({
transfer,
index
}: {
transfer: TransferParams;
index: number;
}) {
const from = transfer.fromPubkey.toBase58();
const to = transfer.toPubkey.toBase58();
return (
<div className="card-body">
<h4 className="ix-pill">{`Instruction #${index + 1} (Transfer)`}</h4>
<div className="list-group list-group-flush my-n3">
<ListGroupItem label="From">
<Copyable text={from}>
<code>{from}</code>
</Copyable>
</ListGroupItem>
<ListGroupItem label="To">
<Copyable text={to}>
<code>{to}</code>
</Copyable>
</ListGroupItem>
<ListGroupItem label="Amount (SOL)">
{`${(1.0 * transfer.lamports) / LAMPORTS_PER_SOL}`}
</ListGroupItem>
</div>
</div>
);
}
function CreateDetails({
create,
index
}: {
create: CreateAccountParams;
index: number;
}) {
const from = create.fromPubkey.toBase58();
const newKey = create.newAccountPubkey.toBase58();
return (
<div className="card-body">
<h4 className="ix-pill">{`Instruction #${index +
1} (Create Account)`}</h4>
<div className="list-group list-group-flush my-n3">
<ListGroupItem label="From">
<Copyable text={from}>
<code>{from}</code>
</Copyable>
</ListGroupItem>
<ListGroupItem label="New Account">
<Copyable text={newKey}>
<code>{newKey}</code>
</Copyable>
</ListGroupItem>
<ListGroupItem label="Amount (SOL)">
{`${(1.0 * create.lamports) / LAMPORTS_PER_SOL}`}
</ListGroupItem>
<ListGroupItem label="Data (Bytes)">{create.space}</ListGroupItem>
<ListGroupItem label="Owner">
<code>{displayAddress(create.programId)}</code>
</ListGroupItem>
</div>
</div>
);
}
function InstructionDetails({
ix,
index
}: {
ix: TransactionInstruction;
index: number;
}) {
return (
<div className="card-body">
<h4 className="ix-pill">{`Instruction #${index + 1}`}</h4>
<div className="list-group list-group-flush my-n3">
{ix.keys.map(({ pubkey }, keyIndex) => (
<ListGroupItem key={keyIndex} label={`Address #${keyIndex + 1}`}>
<Copyable text={pubkey.toBase58()}>
<code>{pubkey.toBase58()}</code>
</Copyable>
</ListGroupItem>
))}
<ListGroupItem label="Data (Bytes)">{ix.data.length}</ListGroupItem>
<ListGroupItem label="Program">
<code>{displayAddress(ix.programId)}</code>
</ListGroupItem>
</div>
</div>
);
}
function ListGroupItem({
label,
children
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="list-group-item ix-item">
<div className="row align-items-center">
<div className="col">
<h5 className="mb-0">{label}</h5>
</div>
<div className="col-auto">{children}</div>
</div>
</div>
);
}
export default TransactionModal;

View File

@ -1,16 +1,19 @@
import React from "react";
import { Link } from "react-router-dom";
import {
useTransactions,
useTransactionsDispatch,
checkTransactionStatus,
ActionType,
TransactionState,
TransactionStatus,
Source,
FetchStatus
} from "../providers/transactions";
import bs58 from "bs58";
import { assertUnreachable } from "../utils";
import { useCluster } from "../providers/cluster";
import Copyable from "./Copyable";
import { useHistory, useLocation } from "react-router-dom";
function TransactionsCard() {
const { transactions, idCounter } = useTransactions();
@ -18,6 +21,7 @@ function TransactionsCard() {
const signatureInput = React.useRef<HTMLInputElement>(null);
const [error, setError] = React.useState("");
const { url } = useCluster();
const location = useLocation();
const onNew = (signature: string) => {
if (signature.length === 0) return;
@ -35,7 +39,11 @@ function TransactionsCard() {
return;
}
dispatch({ type: ActionType.InputSignature, signature });
dispatch({
type: ActionType.FetchSignature,
signature,
source: Source.Input
});
checkTransactionStatus(dispatch, signature, url);
const inputEl = signatureInput.current;
@ -93,7 +101,7 @@ function TransactionsCard() {
<td></td>
</tr>
{transactions.map(transaction =>
renderTransactionRow(transaction, dispatch, url)
renderTransactionRow(transaction, dispatch, location, url)
)}
</tbody>
</table>
@ -115,11 +123,12 @@ const renderHeader = () => {
};
const renderTransactionRow = (
transaction: TransactionState,
transactionStatus: TransactionStatus,
dispatch: any,
location: any,
url: string
) => {
const { fetchStatus, transactionStatus } = transaction;
const { fetchStatus, info, signature, id } = transactionStatus;
let statusText;
let statusClass;
@ -133,10 +142,10 @@ const renderTransactionRow = (
statusText = "Fetching";
break;
case FetchStatus.Fetched: {
if (!transactionStatus) {
if (!info) {
statusClass = "warning";
statusText = "Not Found";
} else if (transactionStatus.result.err) {
} else if (info.result.err) {
statusClass = "danger";
statusText = "Failed";
} else {
@ -151,50 +160,46 @@ const renderTransactionRow = (
let slotText = "-";
let confirmationsText = "-";
if (transactionStatus) {
slotText = `${transactionStatus.slot}`;
confirmationsText = `${transactionStatus.confirmations}`;
if (info) {
slotText = `${info.slot}`;
confirmationsText = `${info.confirmations}`;
}
const renderDetails = () => {
let onClick, icon;
if (transactionStatus?.confirmations === "max") {
icon = "more-horizontal";
onClick = () =>
dispatch({
type: ActionType.Select,
signature: transaction.signature
});
if (info?.confirmations === "max") {
return (
<Link
to={{ ...location, pathname: "/tx/" + signature }}
className="btn btn-rounded-circle btn-white btn-sm"
>
<span className="fe fe-arrow-right"></span>
</Link>
);
} else {
icon = "refresh-cw";
onClick = () => {
checkTransactionStatus(dispatch, transaction.signature, url);
};
return (
<button
className="btn btn-rounded-circle btn-white btn-sm"
onClick={() => {
checkTransactionStatus(dispatch, signature, url);
}}
>
<span className="fe fe-refresh-cw"></span>
</button>
);
}
return (
<button
className="btn btn-rounded-circle btn-white btn-sm"
onClick={onClick}
>
<span className={`fe fe-${icon}`}></span>
</button>
);
};
return (
<tr key={transaction.signature}>
<tr key={signature}>
<td>
<span className="badge badge-soft-dark badge-pill">
{transaction.id}
</span>
<span className="badge badge-soft-dark badge-pill">{id}</span>
</td>
<td>
<span className={`badge badge-soft-${statusClass}`}>{statusText}</span>
</td>
<td>
<Copyable text={transaction.signature}>
<code>{transaction.signature}</code>
<Copyable text={signature}>
<code>{signature}</code>
</Copyable>
</td>
<td className="text-uppercase">{confirmationsText}</td>

View File

@ -7,19 +7,16 @@ import * as serviceWorker from "./serviceWorker";
import { ClusterProvider } from "./providers/cluster";
import { TransactionsProvider } from "./providers/transactions";
import { AccountsProvider } from "./providers/accounts";
import { TabProvider } from "./providers/tab";
ReactDOM.render(
<Router>
<TabProvider>
<ClusterProvider>
<AccountsProvider>
<TransactionsProvider>
<App />
</TransactionsProvider>
</AccountsProvider>
</ClusterProvider>
</TabProvider>
<ClusterProvider>
<AccountsProvider>
<TransactionsProvider>
<App />
</TransactionsProvider>
</AccountsProvider>
</ClusterProvider>
</Router>,
document.getElementById("root")
);

View File

@ -125,6 +125,10 @@ function initState(): State {
};
}
type SetShowModal = React.Dispatch<React.SetStateAction<boolean>>;
const ModalContext = React.createContext<[boolean, SetShowModal] | undefined>(
undefined
);
const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
@ -135,6 +139,7 @@ export function ClusterProvider({ children }: ClusterProviderProps) {
undefined,
initState
);
const [showModal, setShowModal] = React.useState(false);
React.useEffect(() => {
// Connect to cluster immediately
@ -144,7 +149,9 @@ export function ClusterProvider({ children }: ClusterProviderProps) {
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
<ModalContext.Provider value={[showModal, setShowModal]}>
{children}
</ModalContext.Provider>
</DispatchContext.Provider>
</StateContext.Provider>
);
@ -203,3 +210,11 @@ export function useClusterDispatch() {
}
return context;
}
export function useClusterModal() {
const context = React.useContext(ModalContext);
if (!context) {
throw new Error(`useClusterModal must be used within a ClusterProvider`);
}
return context;
}

View File

@ -1,30 +0,0 @@
import React from "react";
import { useLocation } from "react-router-dom";
import { ACCOUNT_PATHS } from "./accounts";
export type Tab = "Transactions" | "Accounts";
const StateContext = React.createContext<Tab | undefined>(undefined);
type TabProviderProps = { children: React.ReactNode };
export function TabProvider({ children }: TabProviderProps) {
const location = useLocation();
const paths = location.pathname
.slice(1)
.split("/")
.map(name => `/${name}`);
let tab: Tab = "Transactions";
if (ACCOUNT_PATHS.includes(paths[0].toLowerCase())) {
tab = "Accounts";
}
return <StateContext.Provider value={tab}>{children}</StateContext.Provider>;
}
export function useCurrentTab() {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(`useCurrentTab must be used within a TabProvider`);
}
return context;
}

View File

@ -5,17 +5,10 @@ import {
ConfirmedTransaction
} from "@solana/web3.js";
import { useCluster, ClusterStatus } from "../cluster";
import { useTransactions } from "./index";
export enum Status {
Checking,
CheckFailed,
NotFound,
Found
}
import { useTransactions, FetchStatus } from "./index";
export interface Details {
status: Status;
fetchStatus: FetchStatus;
transaction: ConfirmedTransaction | null;
}
@ -23,13 +16,14 @@ type State = { [signature: string]: Details };
export enum ActionType {
Update,
Add
Add,
Remove
}
interface Update {
type: ActionType.Update;
signature: string;
status: Status;
fetchStatus: FetchStatus;
transaction: ConfirmedTransaction | null;
}
@ -38,7 +32,12 @@ interface Add {
signatures: TransactionSignature[];
}
type Action = Update | Add;
interface Remove {
type: ActionType.Remove;
signatures: TransactionSignature[];
}
type Action = Update | Add | Remove;
type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State {
@ -49,7 +48,7 @@ function reducer(state: State, action: Action): State {
action.signatures.forEach(signature => {
if (!details[signature]) {
details[signature] = {
status: Status.Checking,
fetchStatus: FetchStatus.Fetching,
transaction: null
};
}
@ -57,21 +56,26 @@ function reducer(state: State, action: Action): State {
return details;
}
case ActionType.Remove: {
if (action.signatures.length === 0) return state;
const details = { ...state };
action.signatures.forEach(signature => {
delete details[signature];
});
return details;
}
case ActionType.Update: {
let details = state[action.signature];
if (details) {
details = {
...details,
status: action.status
fetchStatus: action.fetchStatus,
transaction: action.transaction
};
if (action.transaction !== null) {
details.transaction = action.transaction;
}
return {
...state,
...{
[action.signature]: details
}
[action.signature]: details
};
}
break;
@ -94,22 +98,28 @@ export function DetailsProvider({ children }: DetailsProviderProps) {
// Filter blocks for current transaction slots
React.useEffect(() => {
if (status !== ClusterStatus.Connected) return;
const removeSignatures = new Set<string>();
const fetchSignatures = new Set<string>();
transactions.forEach(({ signature, transactionStatus }) => {
if (transactionStatus?.confirmations === "max" && !state[signature])
transactions.forEach(({ signature, info }) => {
if (info?.confirmations === "max" && !state[signature])
fetchSignatures.add(signature);
else if (info?.confirmations !== "max" && state[signature])
removeSignatures.add(signature);
});
const removeList: string[] = [];
removeSignatures.forEach(s => removeList.push(s));
dispatch({ type: ActionType.Remove, signatures: removeList });
if (status !== ClusterStatus.Connected) return;
const fetchList: string[] = [];
fetchSignatures.forEach(s => fetchList.push(s));
dispatch({ type: ActionType.Add, signatures: fetchList });
fetchSignatures.forEach(signature => {
fetchDetails(dispatch, signature, url);
});
}, [transactions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [status, transactions]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<StateContext.Provider value={state}>
@ -120,30 +130,26 @@ export function DetailsProvider({ children }: DetailsProviderProps) {
);
}
async function fetchDetails(
export async function fetchDetails(
dispatch: Dispatch,
signature: TransactionSignature,
url: string
) {
dispatch({
type: ActionType.Update,
status: Status.Checking,
fetchStatus: FetchStatus.Fetching,
transaction: null,
signature
});
let status;
let fetchStatus;
let transaction = null;
try {
transaction = await new Connection(url).getConfirmedTransaction(signature);
if (transaction) {
status = Status.Found;
} else {
status = Status.NotFound;
}
fetchStatus = FetchStatus.Fetched;
} catch (error) {
console.error("Failed to fetch confirmed transaction", error);
status = Status.CheckFailed;
fetchStatus = FetchStatus.FetchFailed;
}
dispatch({ type: ActionType.Update, status, signature, transaction });
dispatch({ type: ActionType.Update, fetchStatus, signature, transaction });
}

View File

@ -6,7 +6,7 @@ import {
Account,
SignatureResult
} from "@solana/web3.js";
import { findGetParameter, findPathSegment } from "../../utils/url";
import { findGetParameter } from "../../utils/url";
import { useCluster, ClusterStatus } from "../cluster";
import {
DetailsProvider,
@ -20,6 +20,7 @@ import {
Dispatch as AccountsDispatch,
ActionType as AccountsActionType
} from "../accounts";
import { useLocation } from "react-router-dom";
export enum FetchStatus {
Fetching,
@ -27,28 +28,29 @@ export enum FetchStatus {
Fetched
}
enum Source {
export enum Source {
Url,
Input
Input,
Test
}
export type Confirmations = number | "max";
export interface TransactionStatus {
export interface TransactionStatusInfo {
slot: number;
result: SignatureResult;
confirmations: Confirmations;
}
export interface TransactionState {
export interface TransactionStatus {
id: number;
source: Source;
fetchStatus: FetchStatus;
signature: TransactionSignature;
transactionStatus?: TransactionStatus;
info?: TransactionStatusInfo;
}
type Transactions = { [signature: string]: TransactionState };
type Transactions = { [signature: string]: TransactionStatus };
interface State {
idCounter: number;
selected?: TransactionSignature;
@ -57,50 +59,29 @@ interface State {
export enum ActionType {
UpdateStatus,
InputSignature,
Select,
Deselect
}
interface SelectTransaction {
type: ActionType.Select;
signature: TransactionSignature;
}
interface DeselectTransaction {
type: ActionType.Deselect;
FetchSignature
}
interface UpdateStatus {
type: ActionType.UpdateStatus;
signature: TransactionSignature;
fetchStatus: FetchStatus;
transactionStatus?: TransactionStatus;
info?: TransactionStatusInfo;
}
interface InputSignature {
type: ActionType.InputSignature;
interface FetchSignature {
type: ActionType.FetchSignature;
signature: TransactionSignature;
source: Source;
}
type Action =
| UpdateStatus
| InputSignature
| SelectTransaction
| DeselectTransaction;
type Action = UpdateStatus | FetchSignature;
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];
return { ...state, selected: tx.signature };
}
case ActionType.InputSignature: {
case ActionType.FetchSignature: {
if (!!state.transactions[action.signature]) return state;
const nextId = state.idCounter + 1;
@ -108,7 +89,7 @@ function reducer(state: State, action: Action): State {
...state.transactions,
[action.signature]: {
id: nextId,
source: Source.Input,
source: action.source,
signature: action.signature,
fetchStatus: FetchStatus.Fetching
}
@ -120,11 +101,9 @@ function reducer(state: State, action: Action): State {
if (transaction) {
transaction = {
...transaction,
fetchStatus: action.fetchStatus
fetchStatus: action.fetchStatus,
info: action.info
};
if (action.transactionStatus) {
transaction.transactionStatus = action.transactionStatus;
}
const transactions = {
...state.transactions,
[action.signature]: transaction
@ -137,74 +116,60 @@ function reducer(state: State, action: Action): State {
return state;
}
export const TX_PATHS = [
"/tx",
"/txs",
"/txn",
"/txns",
"/transaction",
"/transactions"
];
function urlSignatures(): Array<string> {
const signatures: Array<string> = [];
TX_PATHS.forEach(path => {
const name = path.slice(1);
const params = findGetParameter(name)?.split(",") || [];
const segments = findPathSegment(name)?.split(",") || [];
signatures.push(...params);
signatures.push(...segments);
});
return signatures.filter(s => s.length > 0);
}
function initState(): State {
let idCounter = 0;
const signatures = urlSignatures();
const transactions = signatures.reduce(
(transactions: Transactions, signature) => {
if (!!transactions[signature]) return transactions;
const nextId = idCounter + 1;
transactions[signature] = {
id: nextId,
source: Source.Url,
signature,
fetchStatus: FetchStatus.Fetching
};
idCounter++;
return transactions;
},
{}
);
return { idCounter, transactions };
}
export const TX_ALIASES = ["tx", "txn", "transaction"];
const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type TransactionsProviderProps = { children: React.ReactNode };
export function TransactionsProvider({ children }: TransactionsProviderProps) {
const [state, dispatch] = React.useReducer(reducer, undefined, initState);
const [state, dispatch] = React.useReducer(reducer, {
idCounter: 0,
transactions: {}
});
const { status, url } = useCluster();
const accountsDispatch = useAccountsDispatch();
const search = useLocation().search;
// Check transaction statuses on startup and whenever cluster updates
// Check transaction statuses whenever cluster updates
React.useEffect(() => {
if (status !== ClusterStatus.Connected) return;
Object.keys(state.transactions).forEach(signature => {
dispatch({
type: ActionType.FetchSignature,
signature,
source: Source.Url
});
checkTransactionStatus(dispatch, signature, url);
});
// Create a test transaction
if (findGetParameter("test") !== null) {
createTestTransaction(dispatch, accountsDispatch, url);
}
Object.keys(state.transactions).forEach(signature => {
checkTransactionStatus(dispatch, signature, url);
});
}, [status, url]); // eslint-disable-line react-hooks/exhaustive-deps
// Check for transactions in the url params
React.useEffect(() => {
TX_ALIASES.flatMap(key =>
(findGetParameter(key)?.split(",") || []).concat(
findGetParameter(key + "s")?.split(",") || []
)
)
.flatMap(paramValue => paramValue?.split(",") || [])
.filter(signature => !state.transactions[signature])
.forEach(signature => {
dispatch({
type: ActionType.FetchSignature,
signature,
source: Source.Url
});
checkTransactionStatus(dispatch, signature, url);
});
}, [search]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
@ -232,7 +197,11 @@ async function createTestTransaction(
100000,
"recent"
);
dispatch({ type: ActionType.InputSignature, signature });
dispatch({
type: ActionType.FetchSignature,
signature,
source: Source.Test
});
checkTransactionStatus(dispatch, signature, url);
accountsDispatch({
type: AccountsActionType.Input,
@ -251,7 +220,11 @@ async function createTestTransaction(
lamports: 1
});
const signature = await connection.sendTransaction(tx, testAccount);
dispatch({ type: ActionType.InputSignature, signature });
dispatch({
type: ActionType.FetchSignature,
signature,
source: Source.Test
});
checkTransactionStatus(dispatch, signature, url);
} catch (error) {
console.error("Failed to create test failure transaction", error);
@ -270,7 +243,7 @@ export async function checkTransactionStatus(
});
let fetchStatus;
let transactionStatus: TransactionStatus | undefined;
let info: TransactionStatusInfo | undefined;
try {
const { value } = await new Connection(url).getSignatureStatus(signature, {
searchTransactionHistory: true
@ -284,7 +257,7 @@ export async function checkTransactionStatus(
confirmations = "max";
}
transactionStatus = {
info = {
slot: value.slot,
confirmations,
result: { err: value.err }
@ -295,11 +268,12 @@ export async function checkTransactionStatus(
console.error("Failed to fetch transaction status", error);
fetchStatus = FetchStatus.FetchFailed;
}
dispatch({
type: ActionType.UpdateStatus,
signature,
fetchStatus,
transactionStatus
info
});
}
@ -312,13 +286,36 @@ export function useTransactions() {
}
return {
idCounter: context.idCounter,
selected: context.selected,
transactions: Object.values(context.transactions).sort((a, b) =>
a.id <= b.id ? 1 : -1
)
};
}
export function useTransactionStatus(signature: TransactionSignature) {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(
`useTransactionStatus must be used within a TransactionsProvider`
);
}
return context.transactions[signature];
}
export function useTransactionDetails(signature: TransactionSignature) {
const context = React.useContext(DetailsStateContext);
if (!context) {
throw new Error(
`useTransactionDetails must be used within a TransactionsProvider`
);
}
return context[signature];
}
export function useTransactionsDispatch() {
const context = React.useContext(DispatchContext);
if (!context) {
@ -338,11 +335,3 @@ export function useDetailsDispatch() {
}
return context;
}
export function useDetails(signature: TransactionSignature) {
const context = React.useContext(DetailsStateContext);
if (!context) {
throw new Error(`useDetails must be used within a TransactionsProvider`);
}
return context[signature];
}

View File

@ -1,4 +1,4 @@
//
//
// solana.scss
// Use this to write your custom SCSS
//
@ -12,6 +12,7 @@ code {
.copyable {
position: relative;
width: fit-content;
& > div:hover {
cursor: pointer;
@ -20,7 +21,6 @@ code {
.popover.bs-popover-top {
background-color: $dark;
top: -4rem;
left: 40%;
.popover-body {
color: white;
@ -60,12 +60,14 @@ code {
}
}
.text-signature, .text-address {
.text-signature,
.text-address {
font-size: 85%;
}
input.text-signature, input.text-address {
padding: 0 0.75rem
input.text-signature,
input.text-address {
padding: 0 0.75rem;
}
h4.ix-pill {
@ -87,19 +89,22 @@ h4.slot-pill {
}
.list-group-item:last-child {
&.ix-item, &.slot-item {
&.ix-item,
&.slot-item {
border-bottom-width: 0px;
}
}
.list-group:last-child .list-group-item:last-child {
&.ix-item, &.slot-item {
&.ix-item,
&.slot-item {
border-bottom-width: 1px;
}
}
.list-group-item:first-child {
&.ix-item, &.slot-item {
&.ix-item,
&.slot-item {
border-top-width: 1px;
}
}

View File

@ -1,3 +1,9 @@
import { LAMPORTS_PER_SOL } from "@solana/web3.js";
export function assertUnreachable(x: never): never {
throw new Error("Unreachable!");
}
export function lamportsToSolString(lamports: number): string {
return `${(1.0 * Math.abs(lamports)) / LAMPORTS_PER_SOL}`;
}