Show account tx history

This commit is contained in:
Justin Starry 2020-04-21 23:30:52 +08:00 committed by Michael Vines
parent 73922609e4
commit e5f69673d7
7 changed files with 357 additions and 40 deletions

View File

@ -1269,9 +1269,8 @@
"integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw=="
},
"@solana/web3.js": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-0.43.0.tgz",
"integrity": "sha512-Zf+UfoAuXdn8WwvYSVb8k4hQ484L2ZK6eXlW5EN048273Nks0+Sf44QrUpq2AhYVm6KAT7siyB8Nhgo0jQAG6A==",
"version": "github:jstarry/solana-web3.js#c7d8fe33877a9eae354b456e019ad5044c25eeb7",
"from": "github:jstarry/solana-web3.js#address-history",
"requires": {
"@babel/runtime": "^7.3.1",
"bn.js": "^5.0.0",
@ -1296,9 +1295,9 @@
"integrity": "sha512-IUTD/REb78Z2eodka1QZyyEk66pciRcP6Sroka0aI3tG/iwIdYLrBD62RsubR7vqdt3WyX8p4jxeatzmRSphtA=="
},
"buffer": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.5.0.tgz",
"integrity": "sha512-9FTEDjLjwoAkEwyMGDjYJQN2gfRgOKBKRfiglhvibGbpeeU/pQn1bJxQqm32OD/AIeEuHxU9roxXxg34Byp/Ww==",
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
"integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
"requires": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4"
@ -1627,9 +1626,9 @@
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
},
"@types/express-serve-static-core": {
"version": "4.17.3",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.3.tgz",
"integrity": "sha512-sHEsvEzjqN+zLbqP+8OXTipc10yH1QLR+hnr5uw29gi9AhCAAAdri8ClNV7iMdrJrIzXIQtlkPvq8tJGhj3QJQ==",
"version": "4.17.5",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz",
"integrity": "sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw==",
"requires": {
"@types/node": "*",
"@types/range-parser": "*"
@ -1686,9 +1685,9 @@
"integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA=="
},
"@types/lodash": {
"version": "4.14.149",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
"integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ=="
"version": "4.14.150",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.150.tgz",
"integrity": "sha512-kMNLM5JBcasgYscD9x/Gvr6lTAv2NVgsKtet/hm93qMyf/D1pt+7jeEZklKJKxMVmXjxbRVQQGfqDSfipYCO6w=="
},
"@types/minimatch": {
"version": "3.0.3",
@ -7205,9 +7204,9 @@
}
},
"jayson": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jayson/-/jayson-3.2.0.tgz",
"integrity": "sha512-DZQnwA57GcStw4soSYB2VntWXFfoWvmSarlaWePDYOWhjxT72PBM4atEBomaTaS1uqk3jFC9UO9AyWjlujo3xw==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/jayson/-/jayson-3.2.1.tgz",
"integrity": "sha512-8YfxxjQdcSVMr0/+7B1+aGUFAI+yQYfTWBOI+rq+PBu35HL7nJ3wayC2fTXeUJZqad/I4UoDwMtBTNVWJ9rzlg==",
"requires": {
"@types/connect": "^3.4.32",
"@types/express-serve-static-core": "^4.16.9",

View File

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@solana/web3.js": "^0.43.0",
"@solana/web3.js": "github:jstarry/solana-web3.js#address-history",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",

View File

@ -8,13 +8,19 @@ import {
useTransactions,
ActionType
} from "./providers/transactions";
import { AccountsProvider } from "./providers/accounts";
import {
AccountsProvider,
useSelectedAccount,
useAccountsDispatch,
ActionType as AccountActionType
} 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 AccountModal from "./components/AccountModal";
import Logo from "./img/logos-solana/light-explorer-logo.svg";
import { useCurrentTab, Tab } from "./providers/tab";
@ -31,6 +37,7 @@ function App() {
onClose={() => setShowClusterModal(false)}
/>
<TransactionModal />
<AccountModal />
<div className="main-content">
<nav className="navbar navbar-expand-xl navbar-light">
<div className="container">
@ -120,15 +127,18 @@ type OverlayProps = {
function Overlay({ show, onClick }: OverlayProps) {
const { selected } = useTransactions();
const dispatch = useTransactionsDispatch();
const selectedAccount = useSelectedAccount();
const txDispatch = useTransactionsDispatch();
const accountDispatch = useAccountsDispatch();
if (show || !!selected)
if (show || !!selected || !!selectedAccount)
return (
<div
className="modal-backdrop fade show"
onClick={() => {
onClick();
dispatch({ type: ActionType.Deselect });
txDispatch({ type: ActionType.Deselect });
accountDispatch({ type: AccountActionType.Select });
}}
></div>
);

View File

@ -0,0 +1,147 @@
import React from "react";
import {
useSelectedAccount,
useAccountsDispatch,
ActionType,
Account,
Status
} from "../providers/accounts";
import { TransactionError } from "@solana/web3.js";
function AccountModal() {
const selected = useSelectedAccount();
const dispatch = useAccountsDispatch();
const onClose = () => dispatch({ type: ActionType.Select });
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">Account Transaction History</h4>
<button type="button" className="close" onClick={onClose}>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="card-body">
<AccountDetails account={selected} />
</div>
</div>
</div>
</div>
);
};
return (
<div className={`modal fade${show ? " show" : ""}`} onClick={onClose}>
{renderContent()}
</div>
);
}
function AccountDetails({ account }: { account: Account }) {
const renderError = (content: React.ReactNode) => {
return <span className="text-info">{content}</span>;
};
if (account.status === Status.FetchingHistory) {
return renderError(
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
);
}
if (account.history === undefined)
return renderError("Failed to fetch account transaction history");
if (account.history.size === 0) return renderError("No transactions found");
const detailsList: React.ReactNode[] = [];
account.history.forEach((slotTransactions, slot) => {
detailsList.push(
<SlotTransactionDetails
slot={slot}
statuses={Array.from(slotTransactions.entries())}
/>
);
});
return (
<>
{detailsList.map((details, i) => {
return (
<React.Fragment key={++i}>
{i > 1 ? <hr className="mt-0 mx-n4 mb-4"></hr> : null}
{details}
</React.Fragment>
);
})}
</>
);
}
function SlotTransactionDetails({
slot,
statuses
}: {
slot: number;
statuses: [string, TransactionError | null][];
}) {
return (
<>
<h4 className="slot-pill">Slot #{slot}</h4>
<div className="list-group list-group-flush">
{statuses.map(([signature, err]) => {
return (
<ListGroupItem
key={signature}
signature={signature}
failed={err !== null}
/>
);
})}
</div>
</>
);
}
function ListGroupItem({
signature,
failed
}: {
signature: string;
failed: boolean;
}) {
let badgeText, badgeColor;
if (failed) {
badgeText = "Error";
badgeColor = "danger";
} else {
badgeText = "Success";
badgeColor = "primary";
}
return (
<div className="list-group-item slot-item">
<div className="row align-items-center justify-content-between flex-nowrap">
<div className="col-auto">
<span className={`badge badge-soft-${badgeColor} badge-pill`}>
{badgeText}
</span>
</div>
<div className="col min-width-0">
<h5 className="mb-0 text-truncate">
<code>{signature}</code>
</h5>
</div>
</div>
</div>
);
}
export default AccountModal;

View File

@ -2,6 +2,7 @@ import React from "react";
import {
useAccounts,
useAccountsDispatch,
Dispatch,
fetchAccountInfo,
ActionType,
Account,
@ -55,6 +56,7 @@ function AccountsCard() {
<th className="text-muted">Balance (SOL)</th>
<th className="text-muted">Data (bytes)</th>
<th className="text-muted">Owner</th>
<th className="text-muted">Details</th>
</tr>
</thead>
<tbody className="list">
@ -86,8 +88,9 @@ function AccountsCard() {
<td>-</td>
<td>-</td>
<td>-</td>
<td></td>
</tr>
{accounts.map(account => renderAccountRow(account))}
{accounts.map(account => renderAccountRow(account, dispatch, url))}
</tbody>
</table>
</div>
@ -107,7 +110,11 @@ const renderHeader = () => {
);
};
const renderAccountRow = (account: Account) => {
const renderAccountRow = (
account: Account,
dispatch: Dispatch,
url: string
) => {
let statusText;
let statusClass;
switch (account.status) {
@ -116,10 +123,12 @@ const renderAccountRow = (account: Account) => {
statusText = "Not Found";
break;
case Status.CheckFailed:
case Status.HistoryFailed:
statusClass = "danger";
statusText = "Error";
break;
case Status.Checking:
case Status.FetchingHistory:
statusClass = "info";
statusText = "Fetching";
break;
@ -148,6 +157,42 @@ const renderAccountRow = (account: Account) => {
balance = `${(1.0 * account.lamports) / LAMPORTS_PER_SOL}`;
}
const renderDetails = () => {
let onClick, icon;
switch (account.status) {
case Status.Success:
icon = "more-horizontal";
onClick = () =>
dispatch({
type: ActionType.Select,
address: account.pubkey.toBase58()
});
break;
case Status.CheckFailed:
case Status.HistoryFailed: {
icon = "refresh-cw";
onClick = () => {
fetchAccountInfo(dispatch, account.pubkey.toBase58(), url);
};
break;
}
default: {
return null;
}
}
return (
<button
className="btn btn-rounded-circle btn-white btn-sm"
onClick={onClick}
>
<span className={`fe fe-${icon}`}></span>
</button>
);
};
const base58AccountPubkey = account.pubkey.toBase58();
return (
<tr key={account.id}>
@ -165,6 +210,7 @@ const renderAccountRow = (account: Account) => {
<td>{balance}</td>
<td>{data}</td>
<td>{owner === "-" ? owner : <code>{owner}</code>}</td>
<td>{renderDetails()}</td>
</tr>
);
};

View File

@ -1,15 +1,28 @@
import React from "react";
import { PublicKey, Connection } from "@solana/web3.js";
import {
PublicKey,
Connection,
TransactionSignature,
TransactionError,
SignatureStatus
} from "@solana/web3.js";
import { findGetParameter, findPathSegment } from "../utils/url";
import { useCluster, ClusterStatus } from "./cluster";
export enum Status {
Checking,
CheckFailed,
FetchingHistory,
HistoryFailed,
NotFound,
Success
}
export type History = Map<
number,
Map<TransactionSignature, TransactionError | null>
>;
enum Source {
Url,
Input
@ -23,30 +36,36 @@ export interface Details {
export interface Account {
id: number;
status: Status;
source: Source;
pubkey: PublicKey;
source: Source;
status: Status;
lamports?: number;
details?: Details;
history?: History;
}
type Accounts = { [address: string]: Account };
interface State {
idCounter: number;
accounts: Accounts;
selected?: string;
}
export enum ActionType {
Update,
Input
Input,
Select
}
interface Update {
type: ActionType.Update;
address: string;
status: Status;
lamports?: number;
details?: Details;
data: {
status: Status;
lamports?: number;
details?: Details;
history?: History;
};
}
interface Input {
@ -54,7 +73,12 @@ interface Input {
pubkey: PublicKey;
}
type Action = Update | Input;
interface Select {
type: ActionType.Select;
address?: string;
}
type Action = Update | Input | Select;
export type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State {
@ -74,14 +98,17 @@ function reducer(state: State, action: Action): State {
};
return { ...state, accounts, idCounter };
}
case ActionType.Select: {
return { ...state, selected: action.address };
}
case ActionType.Update: {
let account = state.accounts[action.address];
if (account) {
account = {
...account,
status: action.status,
details: action.details,
lamports: action.lamports
...action.data
};
const accounts = {
...state.accounts,
@ -167,8 +194,10 @@ export async function fetchAccountInfo(
) {
dispatch({
type: ActionType.Update,
status: Status.Checking,
address
address,
data: {
status: Status.Checking
}
});
let status;
@ -188,13 +217,60 @@ export async function fetchAccountInfo(
executable: result.executable,
owner: result.owner
};
status = Status.Success;
status = Status.FetchingHistory;
fetchAccountHistory(dispatch, address, url);
}
} catch (error) {
console.error("Failed to fetch account info", error);
status = Status.CheckFailed;
}
dispatch({ type: ActionType.Update, status, lamports, details, address });
const data = { status, lamports, details };
dispatch({ type: ActionType.Update, data, address });
}
async function fetchAccountHistory(
dispatch: Dispatch,
address: string,
url: string
) {
let history;
let status;
try {
const connection = new Connection(url);
const currentSlot = await connection.getSlot();
const signatures = await connection.getConfirmedSignaturesForAddress(
new PublicKey(address),
Math.max(0, currentSlot - 10000 + 1),
currentSlot
);
let statuses: (SignatureStatus | null)[] = [];
if (signatures.length > 0) {
statuses = (
await connection.getSignatureStatuses(signatures, {
searchTransactionHistory: true
})
).value;
}
history = new Map();
for (let i = 0; i < statuses.length; i++) {
const status = statuses[i];
if (!status) continue;
let slotSignatures = history.get(status.slot);
if (!slotSignatures) {
slotSignatures = new Map();
history.set(status.slot, slotSignatures);
}
slotSignatures.set(signatures[i], status.err);
}
status = Status.Success;
} catch (error) {
console.error("Failed to fetch account history", error);
status = Status.HistoryFailed;
}
const data = { status, history };
dispatch({ type: ActionType.Update, data, address });
}
export function useAccounts() {
@ -210,6 +286,18 @@ export function useAccounts() {
};
}
export function useSelectedAccount() {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(
`useSelectedAccount must be used within a AccountsProvider`
);
}
if (!context.selected) return undefined;
return context.accounts[context.selected];
}
export function useAccountsDispatch() {
const context = React.useContext(DispatchContext);
if (!context) {

View File

@ -65,6 +65,33 @@ h4.ix-pill {
background-color: theme-color-level(info, $badge-soft-bg-level);
}
.list-group-flush .list-group-item.ix-item:first-child {
border-top-width: 1px;
h4.slot-pill {
display: inline-block;
padding: 5px;
border-radius: $border-radius;
margin-left: -5px;
background-color: theme-color-level(primary, $badge-soft-bg-level);
margin-bottom: 1.5rem;
}
.list-group-item:last-child {
&.ix-item, &.slot-item {
border-bottom-width: 0px;
}
}
.list-group:last-child .list-group-item:last-child {
&.ix-item, &.slot-item {
border-bottom-width: 1px;
}
}
.list-group-item:first-child {
&.ix-item, &.slot-item {
border-top-width: 1px;
}
}
.min-width-0 {
min-width: 0;
}