-
+
({ ...location, pathname: "/" })}>
diff --git a/explorer/src/components/AccountsCard.tsx b/explorer/src/components/AccountsCard.tsx
index 1b35e21214..3b14310a1c 100644
--- a/explorer/src/components/AccountsCard.tsx
+++ b/explorer/src/components/AccountsCard.tsx
@@ -125,8 +125,8 @@ const renderAccountRow = (
break;
case Status.CheckFailed:
case Status.HistoryFailed:
- statusClass = "danger";
- statusText = "Error";
+ statusClass = "dark";
+ statusText = "Cluster Error";
break;
case Status.Checking:
case Status.FetchingHistory:
diff --git a/explorer/src/components/ClusterModal.tsx b/explorer/src/components/ClusterModal.tsx
index c916015055..5ce8c418fd 100644
--- a/explorer/src/components/ClusterModal.tsx
+++ b/explorer/src/components/ClusterModal.tsx
@@ -1,11 +1,12 @@
import React from "react";
+import { Link, useLocation, useHistory } from "react-router-dom";
+import { Location } from "history";
import {
useCluster,
- useClusterDispatch,
- updateCluster,
ClusterStatus,
clusterUrl,
clusterName,
+ clusterSlug,
CLUSTERS,
Cluster,
useClusterModal
@@ -45,19 +46,35 @@ function ClusterModal() {
type InputProps = { activeSuffix: string; active: boolean };
function CustomClusterInput({ activeSuffix, active }: InputProps) {
const { customUrl } = useCluster();
- const dispatch = useClusterDispatch();
const [editing, setEditing] = React.useState(false);
+ const history = useHistory();
+ const location = useLocation();
const customClass = (prefix: string) =>
active ? `${prefix}-${activeSuffix}` : "";
+ const clusterLocation = (location: Location, url: string) => {
+ const params = new URLSearchParams(location.search);
+ params.set("clusterUrl", url);
+ params.delete("cluster");
+ return {
+ ...location,
+ search: params.toString()
+ };
+ };
+
+ const updateCustomUrl = React.useCallback(
+ (url: string) => {
+ history.push(clusterLocation(location, url));
+ },
+ [history, location]
+ );
+
const inputTextClass = editing ? "" : "text-muted";
return (
-
clusterLocation(location, customUrl)}
className="btn input-group input-group-merge p-0"
- onClick={() =>
- !active && updateCluster(dispatch, Cluster.Custom, customUrl)
- }
>
setEditing(true)}
onBlur={() => setEditing(false)}
- onInput={e =>
- updateCluster(dispatch, Cluster.Custom, e.currentTarget.value)
- }
+ onInput={e => updateCustomUrl(e.currentTarget.value)}
/>
-
+
);
}
function ClusterToggle() {
const { status, cluster, customUrl } = useCluster();
- const dispatch = useClusterDispatch();
let activeSuffix = "";
switch (status) {
@@ -116,21 +130,35 @@ function ClusterToggle() {
? `border-${activeSuffix} text-${activeSuffix}`
: "btn-white text-dark";
+ const clusterLocation = (location: Location) => {
+ const params = new URLSearchParams(location.search);
+ const slug = clusterSlug(net);
+ if (slug && slug !== "mainnet-beta") {
+ params.set("cluster", slug);
+ params.delete("clusterUrl");
+ } else {
+ params.delete("cluster");
+ if (slug === "mainnet-beta") {
+ params.delete("clusterUrl");
+ }
+ }
+ return {
+ ...location,
+ search: params.toString()
+ };
+ };
+
return (
-
+
);
})}
diff --git a/explorer/src/components/Copyable.tsx b/explorer/src/components/Copyable.tsx
index 594afceb05..74863ee6ac 100644
--- a/explorer/src/components/Copyable.tsx
+++ b/explorer/src/components/Copyable.tsx
@@ -3,22 +3,23 @@ import React, { useState, ReactNode } from "react";
type CopyableProps = {
text: string;
children: ReactNode;
+ bottom?: boolean;
};
type State = "hide" | "copy" | "copied";
-function Popover({ state }: { state: State }) {
+function Popover({ state, bottom }: { state: State; bottom?: boolean }) {
if (state === "hide") return null;
const text = state === "copy" ? "Copy" : "Copied!";
return (
-
+
);
}
-function Copyable({ text, children }: CopyableProps) {
+function Copyable({ bottom, text, children }: CopyableProps) {
const [state, setState] = useState
("hide");
const copyToClipboard = () => navigator.clipboard.writeText(text);
@@ -29,15 +30,14 @@ function Copyable({ text, children }: CopyableProps) {
});
return (
-
-
setState("copy")}
- onMouseOut={() => state === "copy" && setState("hide")}
- >
- {children}
-
-
+
setState("copy")}
+ onMouseOut={() => state === "copy" && setState("hide")}
+ >
+ {children}
+
);
}
diff --git a/explorer/src/components/TabbedPage.tsx b/explorer/src/components/TabbedPage.tsx
index 0cc6a72e59..4c1ebba9a3 100644
--- a/explorer/src/components/TabbedPage.tsx
+++ b/explorer/src/components/TabbedPage.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { Link, useLocation } from "react-router-dom";
+import { Link } from "react-router-dom";
import { useClusterModal } from "providers/cluster";
import ClusterStatusButton from "components/ClusterStatusButton";
@@ -54,14 +54,16 @@ function NavLink({
tab: Tab;
current: Tab;
}) {
- const location = useLocation();
let classes = "nav-link";
if (tab === current) {
classes += " active";
}
return (
-
+
({ ...location, pathname: href })}
+ className={classes}
+ >
{tab}
);
diff --git a/explorer/src/components/TransactionDetails.tsx b/explorer/src/components/TransactionDetails.tsx
index 038482a9ee..c482165fe8 100644
--- a/explorer/src/components/TransactionDetails.tsx
+++ b/explorer/src/components/TransactionDetails.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import bs58 from "bs58";
import {
Source,
useTransactionStatus,
@@ -16,7 +17,8 @@ import {
TransactionInstruction,
TransferParams,
CreateAccountParams,
- SystemProgram
+ SystemProgram,
+ SignatureResult
} from "@solana/web3.js";
import ClusterStatusButton from "components/ClusterStatusButton";
import { lamportsToSolString } from "utils";
@@ -123,73 +125,51 @@ function TransactionStatusCard({ signature }: Props) {
let statusClass = "success";
let statusText = "Success";
if (info.result.err) {
- statusClass = "danger";
+ statusClass = "warning";
statusText = "Error";
}
return (
-
{statusText}
+
+ {statusText}
+
);
};
const fee = details?.transaction?.meta?.fee;
return (
-
-
+
+
Transaction Status
+
-
-
-
-
-
-
Result
-
-
{renderResult()}
-
-
-
-
-
-
Block
-
-
{info.slot}
-
-
+
+
+ Result |
+ {renderResult()} |
+
-
-
-
-
Confirmations
-
-
- {info.confirmations}
-
-
-
- {fee && (
-
-
-
-
Fee (SOL)
-
-
{lamportsToSolString(fee)}
-
-
- )}
-
-
+
+ Block |
+ {info.slot} |
+
+
+
+ Confirmations |
+ {info.confirmations} |
+
+
+ {fee && (
+
+ Fee (SOL) |
+ {lamportsToSolString(fee)} |
+
+ )}
+
);
}
@@ -265,7 +245,7 @@ function TransactionAccountsCard({ signature }: Props) {
return (
-
Accounts
+ Transaction Accounts
@@ -284,34 +264,108 @@ function TransactionAccountsCard({ signature }: Props) {
);
}
+function ixResult(result: SignatureResult, index: number) {
+ if (result.err) {
+ const err = result.err as any;
+ const ixError = err["InstructionError"];
+ if (ixError && Array.isArray(ixError)) {
+ const [errorIndex, error] = ixError;
+ if (Number.isInteger(errorIndex) && errorIndex === index) {
+ return ["warning", `"${JSON.stringify(error)}"`];
+ }
+ }
+ return ["dark"];
+ }
+ return ["success"];
+}
+
+type InstructionProps = {
+ title: string;
+ children: React.ReactNode;
+ result: SignatureResult;
+ index: number;
+};
+
+function InstructionCard({ title, children, result, index }: InstructionProps) {
+ const [resultClass, errorString] = ixResult(result, index);
+ return (
+
+
+
+
+ #{index + 1}
+
+ {title} Instruction
+
+
+
+ {errorString}
+
+
+
+
{children}
+
+ );
+}
+
function TransactionInstructionsCard({ signature }: Props) {
+ const status = useTransactionStatus(signature);
const details = useTransactionDetails(signature);
const dispatch = useDetailsDispatch();
const { url } = useCluster();
const refreshDetails = () => fetchDetails(dispatch, signature, url);
- if (!details || !details.transaction) return null;
+ if (!status || !status.info || !details || !details.transaction) return null;
const { transaction } = details.transaction;
if (transaction.instructions.length === 0) {
return ;
}
+ const result = status.info.result;
const instructionDetails = transaction.instructions.map((ix, index) => {
const transfer = decodeTransfer(ix);
- if (transfer)
- return ;
+ if (transfer) {
+ return (
+
+
+
+ );
+ }
+
const create = decodeCreate(ix);
- if (create)
- return ;
- return ;
+ if (create) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
});
return (
<>
-
Transaction Instruction(s)
+
+
Transaction Instruction(s)
+
{instructionDetails}
@@ -320,140 +374,186 @@ function TransactionInstructionsCard({ signature }: Props) {
}
function TransferDetails({
- transfer,
- index
+ ix,
+ transfer
}: {
+ ix: TransactionInstruction;
transfer: TransferParams;
- index: number;
}) {
const from = transfer.fromPubkey.toBase58();
const to = transfer.toPubkey.toBase58();
+ const [fromMeta, toMeta] = ix.keys;
return (
-
-
-
- #{index + 1}
- Transfer
-
-
-
-
-
+ <>
+
+ Program |
+
+
{displayAddress(SystemProgram.programId)}
-
-
-
- {from}
-
-
-
-
- {to}
-
-
-
- {lamportsToSolString(transfer.lamports)}
-
-
-
-
+
+ |
+
+
+
+
+ From Account
+ {!fromMeta.isWritable && (
+ Readonly
+ )}
+ {fromMeta.isSigner && (
+ Signer
+ )}
+ |
+
+
+ {from}
+
+ |
+
+
+
+
+ To Account
+ {!toMeta.isWritable && (
+ Readonly
+ )}
+ {toMeta.isSigner && (
+ Signer
+ )}
+ |
+
+
+ {to}
+
+ |
+
+
+
+ Transfer Amount (SOL) |
+ {lamportsToSolString(transfer.lamports)} |
+
+ >
);
}
function CreateDetails({
- create,
- index
+ ix,
+ create
}: {
+ ix: TransactionInstruction;
create: CreateAccountParams;
- index: number;
}) {
const from = create.fromPubkey.toBase58();
const newKey = create.newAccountPubkey.toBase58();
+ const [fromMeta, newMeta] = ix.keys;
+
return (
-
-
-
- #{index + 1}
- Create Account
-
-
-
-
-
+ <>
+
+ Program |
+
+
{displayAddress(SystemProgram.programId)}
-
-
-
- {from}
-
-
-
-
- {newKey}
-
-
-
- {lamportsToSolString(create.lamports)}
-
- {create.space}
-
+
+ |
+
+
+
+
+ From Account
+ {!fromMeta.isWritable && (
+ Readonly
+ )}
+ {fromMeta.isSigner && (
+ Signer
+ )}
+ |
+
+
+ {from}
+
+ |
+
+
+
+
+ New Account
+ {!newMeta.isWritable && (
+ Readonly
+ )}
+ {newMeta.isSigner && (
+ Signer
+ )}
+ |
+
+
+ {newKey}
+
+ |
+
+
+
+ Transfer Amount (SOL) |
+ {lamportsToSolString(create.lamports)} |
+
+
+
+ Allocated Space (Bytes) |
+ {create.space} |
+
+
+
+ Assigned Owner |
+
+
{displayAddress(create.programId)}
-
-
-
-
+
+ |
+
+ >
);
}
-function InstructionDetails({
- ix,
- index
-}: {
- ix: TransactionInstruction;
- index: number;
-}) {
+function InstructionDetails({ ix }: { ix: TransactionInstruction }) {
return (
-
-
-
- #{index + 1}
-
-
-
-
-
+ <>
+
+ Program |
+
+
{displayAddress(ix.programId)}
-
- {ix.keys.map(({ pubkey }, keyIndex) => (
-
-
- {pubkey.toBase58()}
-
-
- ))}
- {ix.data.length}
-
-
-
- );
-}
+
+ |
+
-function ListGroupItem({
- label,
- children
-}: {
- label: string;
- children: React.ReactNode;
-}) {
- return (
-
-
-
-
{label}
-
-
{children}
-
-
+ {ix.keys.map(({ pubkey, isSigner, isWritable }, keyIndex) => (
+
+
+ Account #{keyIndex + 1}
+ {!isWritable && (
+ Readonly
+ )}
+ {isSigner && (
+ Signer
+ )}
+ |
+
+
+ {pubkey.toBase58()}
+
+ |
+
+ ))}
+
+
+ Raw Data (Base58) |
+
+
+ {bs58.encode(ix.data)}
+
+ |
+
+ >
);
}
@@ -480,3 +580,13 @@ function RetryCard({ retry, text }: { retry: () => void; text: string }) {
);
}
+
+function TableCardBody({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/explorer/src/components/TransactionsCard.tsx b/explorer/src/components/TransactionsCard.tsx
index cbd7b3de8d..1e3e9fad9e 100644
--- a/explorer/src/components/TransactionsCard.tsx
+++ b/explorer/src/components/TransactionsCard.tsx
@@ -13,7 +13,6 @@ 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();
@@ -21,7 +20,6 @@ function TransactionsCard() {
const signatureInput = React.useRef
(null);
const [error, setError] = React.useState("");
const { url } = useCluster();
- const location = useLocation();
const onNew = (signature: string) => {
if (signature.length === 0) return;
@@ -101,7 +99,7 @@ function TransactionsCard() {
|
{transactions.map(transaction =>
- renderTransactionRow(transaction, dispatch, location, url)
+ renderTransactionRow(transaction, dispatch, url)
)}
@@ -125,7 +123,6 @@ const renderHeader = () => {
const renderTransactionRow = (
transactionStatus: TransactionStatus,
dispatch: any,
- location: any,
url: string
) => {
const { fetchStatus, info, signature, id } = transactionStatus;
@@ -169,7 +166,7 @@ const renderTransactionRow = (
if (info?.confirmations === "max") {
return (
({ ...location, pathname: "/tx/" + signature })}
className="btn btn-rounded-circle btn-white btn-sm"
>
diff --git a/explorer/src/providers/cluster.tsx b/explorer/src/providers/cluster.tsx
index 4b6f9af11c..0083f71ec5 100644
--- a/explorer/src/providers/cluster.tsx
+++ b/explorer/src/providers/cluster.tsx
@@ -1,6 +1,6 @@
import React from "react";
import { clusterApiUrl, Connection } from "@solana/web3.js";
-import { findGetParameter } from "../utils/url";
+import { useQuery } from "../utils/url";
export enum ClusterStatus {
Connected,
@@ -22,6 +22,19 @@ export const CLUSTERS = [
Cluster.Custom
];
+export function clusterSlug(cluster: Cluster): string | undefined {
+ switch (cluster) {
+ case Cluster.MainnetBeta:
+ return "mainnet-beta";
+ case Cluster.Testnet:
+ return "testnet";
+ case Cluster.Devnet:
+ return "devnet";
+ case Cluster.Custom:
+ return undefined;
+ }
+}
+
export function clusterName(cluster: Cluster): string {
switch (cluster) {
case Cluster.MainnetBeta:
@@ -77,11 +90,11 @@ function clusterReducer(state: State, action: Action): State {
}
}
-function initState(): State {
- const clusterParam =
- findGetParameter("cluster") || findGetParameter("network");
- const clusterUrlParam =
- findGetParameter("clusterUrl") || findGetParameter("networkUrl");
+function parseQuery(
+ query: URLSearchParams
+): { cluster: Cluster; customUrl: string } {
+ const clusterParam = query.get("cluster");
+ const clusterUrlParam = query.get("clusterUrl");
let cluster;
let customUrl = DEFAULT_CUSTOM_URL;
@@ -120,8 +133,7 @@ function initState(): State {
return {
cluster,
- customUrl,
- status: ClusterStatus.Connecting
+ customUrl
};
}
@@ -134,17 +146,18 @@ const DispatchContext = React.createContext
(undefined);
type ClusterProviderProps = { children: React.ReactNode };
export function ClusterProvider({ children }: ClusterProviderProps) {
- const [state, dispatch] = React.useReducer(
- clusterReducer,
- undefined,
- initState
- );
+ const [state, dispatch] = React.useReducer(clusterReducer, {
+ cluster: DEFAULT_CLUSTER,
+ customUrl: DEFAULT_CUSTOM_URL,
+ status: ClusterStatus.Connecting
+ });
const [showModal, setShowModal] = React.useState(false);
+ const { cluster, customUrl } = parseQuery(useQuery());
+ // Reconnect to cluster when it changes
React.useEffect(() => {
- // Connect to cluster immediately
- updateCluster(dispatch, state.cluster, state.customUrl);
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ updateCluster(dispatch, cluster, customUrl);
+ }, [cluster, customUrl]);
return (
@@ -170,7 +183,7 @@ export function clusterUrl(cluster: Cluster, customUrl: string): string {
}
}
-export async function updateCluster(
+async function updateCluster(
dispatch: Dispatch,
cluster: Cluster,
customUrl: string
@@ -203,14 +216,6 @@ export function useCluster() {
};
}
-export function useClusterDispatch() {
- const context = React.useContext(DispatchContext);
- if (!context) {
- throw new Error(`useClusterDispatch must be used within a ClusterProvider`);
- }
- return context;
-}
-
export function useClusterModal() {
const context = React.useContext(ModalContext);
if (!context) {
diff --git a/explorer/src/providers/transactions/details.tsx b/explorer/src/providers/transactions/details.tsx
index f1d1bcdd33..fa218ca319 100644
--- a/explorer/src/providers/transactions/details.tsx
+++ b/explorer/src/providers/transactions/details.tsx
@@ -4,7 +4,7 @@ import {
TransactionSignature,
ConfirmedTransaction
} from "@solana/web3.js";
-import { useCluster, ClusterStatus } from "../cluster";
+import { useCluster } from "../cluster";
import { useTransactions, FetchStatus } from "./index";
export interface Details {
@@ -94,7 +94,7 @@ export function DetailsProvider({ children }: DetailsProviderProps) {
const [state, dispatch] = React.useReducer(reducer, {});
const { transactions } = useTransactions();
- const { status, url } = useCluster();
+ const { url } = useCluster();
// Filter blocks for current transaction slots
React.useEffect(() => {
@@ -111,15 +111,13 @@ export function DetailsProvider({ children }: DetailsProviderProps) {
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);
});
- }, [status, transactions]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [transactions]); // eslint-disable-line react-hooks/exhaustive-deps
return (
diff --git a/explorer/src/providers/transactions/index.tsx b/explorer/src/providers/transactions/index.tsx
index 9dba7edb59..82e549f486 100644
--- a/explorer/src/providers/transactions/index.tsx
+++ b/explorer/src/providers/transactions/index.tsx
@@ -6,8 +6,8 @@ import {
Account,
SignatureResult
} from "@solana/web3.js";
-import { findGetParameter } from "../../utils/url";
-import { useCluster, ClusterStatus } from "../cluster";
+import { useQuery } from "../../utils/url";
+import { useCluster, Cluster } from "../cluster";
import {
DetailsProvider,
StateContext as DetailsStateContext,
@@ -20,7 +20,6 @@ import {
Dispatch as AccountsDispatch,
ActionType as AccountsActionType
} from "../accounts";
-import { useLocation } from "react-router-dom";
export enum FetchStatus {
Fetching,
@@ -128,14 +127,12 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
transactions: {}
});
- const { status, url } = useCluster();
+ const { cluster, url } = useCluster();
const accountsDispatch = useAccountsDispatch();
- const search = useLocation().search;
+ const query = useQuery();
// Check transaction statuses whenever cluster updates
React.useEffect(() => {
- if (status !== ClusterStatus.Connected) return;
-
Object.keys(state.transactions).forEach(signature => {
dispatch({
type: ActionType.FetchSignature,
@@ -146,19 +143,18 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
});
// Create a test transaction
- if (findGetParameter("test") !== null) {
+ if (cluster === Cluster.Devnet && query.get("test") !== null) {
createTestTransaction(dispatch, accountsDispatch, url);
}
- }, [status, url]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [query, cluster, 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(",") || [])
+ TX_ALIASES.flatMap(key => [query.get(key), query.get(key + "s")])
+ .filter((value): value is string => value !== null)
+ .flatMap(value => value.split(","))
+ // Remove duplicates
+ .filter((item, pos, self) => self.indexOf(item) === pos)
.filter(signature => !state.transactions[signature])
.forEach(signature => {
dispatch({
@@ -168,7 +164,7 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
});
checkTransactionStatus(dispatch, signature, url);
});
- }, [search]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [query]); // eslint-disable-line react-hooks/exhaustive-deps
return (
diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss
index 6d4a27e3be..49a8538032 100644
--- a/explorer/src/scss/_solana.scss
+++ b/explorer/src/scss/_solana.scss
@@ -12,15 +12,19 @@ code {
.copyable {
position: relative;
- width: fit-content;
+ display: inline;
+ cursor: pointer;
- & > div:hover {
- cursor: pointer;
- }
+ .popover {
+ &.bs-popover-top {
+ background-color: $dark;
+ top: -4rem;
+ }
- .popover.bs-popover-top {
- background-color: $dark;
- top: -4rem;
+ &.bs-popover-bottom {
+ background-color: $dark;
+ top: 1.5rem;
+ }
.popover-body {
color: white;
@@ -28,6 +32,7 @@ code {
.arrow::after {
border-top-color: $dark;
+ border-bottom-color: $dark;
}
}
}
diff --git a/explorer/src/utils/url.ts b/explorer/src/utils/url.ts
index 846b7571e3..207d0ce646 100644
--- a/explorer/src/utils/url.ts
+++ b/explorer/src/utils/url.ts
@@ -1,3 +1,9 @@
+import { useLocation } from "react-router-dom";
+
+export function useQuery() {
+ return new URLSearchParams(useLocation().search);
+}
+
export function findGetParameter(parameterName: string): string | null {
let result = null,
tmp = [];