diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index 1f22f20bf1..26fb6dac4f 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -2,7 +2,9 @@ import React from "react"; import { ClusterProvider } from "./providers/cluster"; import { TransactionsProvider } from "./providers/transactions"; +import { AccountsProvider } from "./providers/accounts"; import ClusterStatusButton from "./components/ClusterStatusButton"; +import AccountsCard from "./components/AccountsCard"; import TransactionsCard from "./components/TransactionsCard"; import ClusterModal from "./components/ClusterModal"; import Logo from "./img/logos-solana/light-explorer-logo.svg"; @@ -36,6 +38,13 @@ function App() { +
+
+ + + +
+
setShowModal(false)} /> diff --git a/explorer/src/components/AccountsCard.tsx b/explorer/src/components/AccountsCard.tsx new file mode 100644 index 0000000000..f540f5ca7a --- /dev/null +++ b/explorer/src/components/AccountsCard.tsx @@ -0,0 +1,184 @@ +import React from "react"; +import { + useAccounts, + useAccountsDispatch, + fetchAccountInfo, + ActionType, + Account, + Status +} from "../providers/accounts"; +import { assertUnreachable } from "../utils"; +import { useCluster } from "../providers/cluster"; +import { PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js"; + +function AccountsCard() { + const { accounts, idCounter } = useAccounts(); + const dispatch = useAccountsDispatch(); + const addressInput = React.useRef(null); + const [error, setError] = React.useState(""); + const [showSOL, setShowSOL] = React.useState(true); + const { url } = useCluster(); + + const onNew = (address: string) => { + if (address.length === 0) return; + let pubkey; + try { + pubkey = new PublicKey(address); + } catch (err) { + setError(`${err}`); + return; + } + + dispatch({ type: ActionType.Input, pubkey }); + fetchAccountInfo(dispatch, idCounter + 1, pubkey, url); + + const inputEl = addressInput.current; + if (inputEl) { + inputEl.value = ""; + } + }; + + return ( +
+ {renderHeader(showSOL, setShowSOL)} + +
+ + + + + + + + + + + + + + + + + + + + + {accounts.map(account => renderAccountRow(account, showSOL))} + +
+ + StatusAddress + Balance ({showSOL ? "SOL" : "lamports"}) + Data (bytes)Owner
+ + {idCounter + 1} + + + New + + setError("")} + onKeyDown={e => + e.keyCode === 13 && onNew(e.currentTarget.value) + } + onSubmit={e => onNew(e.currentTarget.value)} + ref={addressInput} + className={`form-control text-address text-monospace ${ + error ? "is-invalid" : "" + }`} + placeholder="input account address" + /> + {error ?
{error}
: null} +
---
+
+
+ ); +} + +const renderHeader = ( + showSOL: boolean, + setShowSOL: (show: boolean) => void +) => { + return ( +
+
+
+

Look Up Account(s)

+
+ + Display SOL + +
+ + +
+
+
+ ); +}; + +const renderAccountRow = (account: Account, showSOL: boolean) => { + let statusText; + let statusClass; + switch (account.status) { + case Status.CheckFailed: + statusClass = "danger"; + statusText = "Error"; + break; + case Status.Checking: + statusClass = "info"; + statusText = "Fetching"; + break; + case Status.Success: + if (account.details?.executable) { + statusClass = "dark"; + statusText = "Executable"; + } else { + statusClass = "success"; + statusText = "Found"; + } + break; + default: + return assertUnreachable(account.status); + } + + let data = "-"; + let balance = "-"; + let owner = "-"; + if (account.details) { + data = `${account.details.space}`; + if (showSOL) { + balance = `${(1.0 * account.details.lamports) / LAMPORTS_PER_SOL}`; + } else { + balance = `${account.details.lamports}`; + } + owner = `${account.details.owner.toBase58()}`; + } + + return ( + + + {account.id} + + + {statusText} + + + {account.pubkey.toBase58()} + + {balance} + {data} + {owner === "-" ? owner : {owner}} + + ); +}; + +export default AccountsCard; diff --git a/explorer/src/components/TransactionsCard.tsx b/explorer/src/components/TransactionsCard.tsx index 66e22ea8d2..9c10ff2b51 100644 --- a/explorer/src/components/TransactionsCard.tsx +++ b/explorer/src/components/TransactionsCard.tsx @@ -23,7 +23,7 @@ function TransactionsCard() { try { const length = bs58.decode(signature).length; if (length > 64) { - setError("Signature is too short"); + setError("Signature is too long"); return; } else if (length < 64) { setError("Signature is too short"); diff --git a/explorer/src/providers/accounts.tsx b/explorer/src/providers/accounts.tsx new file mode 100644 index 0000000000..d580c4c02a --- /dev/null +++ b/explorer/src/providers/accounts.tsx @@ -0,0 +1,199 @@ +import React from "react"; +import { PublicKey, Connection } from "@solana/web3.js"; +import { findGetParameter, findPathSegment } from "../utils"; +import { useCluster } from "./cluster"; + +export enum Status { + Checking, + CheckFailed, + Success +} + +enum Source { + Url, + Input +} + +export interface Details { + executable: boolean; + owner: PublicKey; + lamports: number; + space: number; +} + +export interface Account { + id: number; + status: Status; + source: Source; + pubkey: PublicKey; + details?: Details; +} + +type Accounts = { [id: number]: Account }; +interface State { + idCounter: number; + accounts: Accounts; +} + +export enum ActionType { + Update, + Input +} + +interface Update { + type: ActionType.Update; + id: number; + status: Status; + details?: Details; +} + +interface Input { + type: ActionType.Input; + pubkey: PublicKey; +} + +type Action = Update | Input; +type Dispatch = (action: Action) => void; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case ActionType.Input: { + const idCounter = state.idCounter + 1; + const accounts = { + ...state.accounts, + [idCounter]: { + id: idCounter, + status: Status.Checking, + source: Source.Input, + pubkey: action.pubkey + } + }; + return { ...state, accounts, idCounter }; + } + case ActionType.Update: { + let account = state.accounts[action.id]; + if (account) { + account = { + ...account, + status: action.status, + details: action.details + }; + const accounts = { + ...state.accounts, + [action.id]: account + }; + return { ...state, accounts }; + } + break; + } + } + return state; +} + +function urlPublicKeys(): Array { + const keys: Array = []; + return keys + .concat(findGetParameter("account")?.split(",") || []) + .concat(findGetParameter("accounts")?.split(",") || []) + .concat(findPathSegment("account")?.split(",") || []) + .concat(findPathSegment("accounts")?.split(",") || []) + .concat(findGetParameter("address")?.split(",") || []) + .concat(findGetParameter("addresses")?.split(",") || []) + .concat(findPathSegment("address")?.split(",") || []) + .concat(findPathSegment("addresses")?.split(",") || []) + .map(key => new PublicKey(key)); +} + +function initState(): State { + let idCounter = 0; + const pubkeys = urlPublicKeys(); + const accounts = pubkeys.reduce((accounts: Accounts, pubkey) => { + const id = ++idCounter; + accounts[id] = { + id, + status: Status.Checking, + source: Source.Url, + pubkey + }; + return accounts; + }, {}); + return { idCounter, accounts }; +} + +const StateContext = React.createContext(undefined); +const DispatchContext = React.createContext(undefined); + +type AccountsProviderProps = { children: React.ReactNode }; +export function AccountsProvider({ children }: AccountsProviderProps) { + const [state, dispatch] = React.useReducer(reducer, undefined, initState); + + const { status, url } = useCluster(); + + // Check account statuses on startup and whenever cluster updates + React.useEffect(() => { + Object.values(state.accounts).forEach(account => { + fetchAccountInfo(dispatch, account.id, account.pubkey, url); + }); + }, [status, url]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + {children} + + + ); +} + +export async function fetchAccountInfo( + dispatch: Dispatch, + id: number, + pubkey: PublicKey, + url: string +) { + dispatch({ + type: ActionType.Update, + status: Status.Checking, + id + }); + + let status; + let details; + try { + const result = await new Connection(url).getAccountInfo(pubkey); + details = { + space: result.data.length, + executable: result.executable, + lamports: result.lamports, + owner: result.owner + }; + status = Status.Success; + } catch (error) { + console.error("Failed to fetch account info", error); + status = Status.CheckFailed; + } + dispatch({ type: ActionType.Update, status, details, id }); +} + +export function useAccounts() { + const context = React.useContext(StateContext); + if (!context) { + throw new Error(`useAccounts must be used within a AccountsProvider`); + } + return { + idCounter: context.idCounter, + accounts: Object.values(context.accounts).sort((a, b) => + a.id <= b.id ? 1 : -1 + ) + }; +} + +export function useAccountsDispatch() { + const context = React.useContext(DispatchContext); + if (!context) { + throw new Error( + `useAccountsDispatch must be used within a AccountsProvider` + ); + } + return context; +} diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss index 995c85ab17..53c3cd441d 100644 --- a/explorer/src/scss/_solana.scss +++ b/explorer/src/scss/_solana.scss @@ -34,10 +34,10 @@ code { } } -.text-signature { +.text-signature, .text-address { font-size: 85%; } -input.text-signature { +input.text-signature, input.text-address { padding: 0 0.75rem } \ No newline at end of file