Explorer Block Page (#12854)

* Solarweave Implementation

* Fixed formatting

* Revisions and QA

* Added block links to transaction page

* Create Blockpage

* QA and Revisions

* Finalized QA & Revisions

* QA & Revisions
This commit is contained in:
The Lone Rōnin 2020-10-28 12:16:27 -04:00 committed by GitHub
parent 5919e67c2a
commit 49e11e1f9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 338 additions and 17 deletions

View File

@ -64,5 +64,6 @@
"last 1 firefox version",
"last 1 safari version"
]
}
},
"devDependencies": {}
}

View File

@ -11,6 +11,7 @@ import { AccountDetailsPage } from "pages/AccountDetailsPage";
import { ClusterStatsPage } from "pages/ClusterStatsPage";
import { SupplyPage } from "pages/SupplyPage";
import { TransactionDetailsPage } from "pages/TransactionDetailsPage";
import { BlockDetailsPage } from "pages/BlockDetailsPage";
const ADDRESS_ALIASES = ["account", "accounts", "addresses"];
const TX_ALIASES = ["txs", "txn", "txns", "transaction", "transactions"];
@ -43,6 +44,11 @@ function App() {
<TransactionDetailsPage signature={match.params.signature} />
)}
/>
<Route
exact
path={"/block/:id"}
render={({ match }) => <BlockDetailsPage slot={match.params.id} />}
/>
<Route
exact
path={[

View File

@ -40,7 +40,7 @@ export function SearchBar() {
ref={(ref) => (selectRef.current = ref)}
options={buildOptions(search, cluster)}
noOptionsMessage={() => "No Results"}
placeholder="Search for accounts, transactions, programs, and tokens"
placeholder="Search for blocks, accounts, transactions, programs, and tokens"
value={resetValue}
inputValue={search}
blurInputOnSelect
@ -189,6 +189,19 @@ function buildOptions(search: string, cluster: Cluster) {
options.push(tokenOptions);
}
if (!isNaN(Number(search))) {
options.push({
label: "Block",
options: [
{
label: `Slot #${search}`,
value: search,
pathname: `/block/${search}`,
},
],
});
}
// Prefer nice suggestions over raw suggestions
if (options.length > 0) return options;

View File

@ -0,0 +1,124 @@
import bs58 from "bs58";
import React from "react";
import { TableCardBody } from "components/common/TableCardBody";
import { useBlock, useFetchBlock, FetchStatus } from "providers/block";
import { Signature } from "components/common/Signature";
import { ErrorCard } from "components/common/ErrorCard";
import { LoadingCard } from "components/common/LoadingCard";
import { Slot } from "components/common/Slot";
export function BlockHistoryCard({ slot }: { slot: number }) {
const confirmedBlock = useBlock(slot);
const fetchBlock = useFetchBlock();
const refresh = () => fetchBlock(slot);
React.useEffect(() => {
if (!confirmedBlock) refresh();
}, [confirmedBlock, slot]); // eslint-disable-line react-hooks/exhaustive-deps
if (!confirmedBlock) {
return null;
}
if (confirmedBlock.data === undefined) {
if (confirmedBlock.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading block" />;
}
return <ErrorCard retry={refresh} text="Failed to fetch block" />;
}
if (confirmedBlock.status === FetchStatus.FetchFailed) {
return <ErrorCard retry={refresh} text="Failed to fetch block" />;
}
return (
<>
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">
Overview
</h3>
</div>
<TableCardBody>
<tr>
<td className="w-100">Slot</td>
<td className="text-lg-right text-monospace">
<Slot slot={Number(slot)} />
</td>
</tr>
<tr>
<td className="w-100">Parent Slot</td>
<td className="text-lg-right text-monospace">
<Slot slot={confirmedBlock.data.parentSlot} link />
</td>
</tr>
<tr>
<td className="w-100">Blockhash</td>
<td className="text-lg-right text-monospace">
<span>{confirmedBlock.data.blockhash}</span>
</td>
</tr>
<tr>
<td className="w-100">Previous Blockhash</td>
<td className="text-lg-right text-monospace">
<span>{confirmedBlock.data.previousBlockhash}</span>
</td>
</tr>
</TableCardBody>
</div>
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Block Transactions</h3>
</div>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted">Result</th>
<th className="text-muted">Transaction Signature</th>
</tr>
</thead>
<tbody className="list">
{confirmedBlock.data.transactions.map((tx, i) => {
let statusText;
let statusClass;
let signature: React.ReactNode;
if (tx.meta?.err || !tx.transaction.signature) {
statusClass = "warning";
statusText = "Failed";
} else {
statusClass = "success";
statusText = "Success";
}
if (tx.transaction.signature) {
signature = (
<Signature
signature={bs58.encode(tx.transaction.signature)}
link
/>
);
}
return (
<tr key={i}>
<td>
<span className={`badge badge-soft-${statusClass}`}>
{statusText}
</span>
</td>
<td>{signature}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</>
);
}

View File

@ -1,8 +1,40 @@
import React from "react";
import React, { useState } from "react";
import { Link } from "react-router-dom";
type CopyState = "copy" | "copied";
type Props = {
slot: number;
link?: boolean;
};
export function Slot({ slot }: Props) {
return <span className="text-monospace">{slot.toLocaleString("en-US")}</span>;
export function Slot({ slot, link }: Props) {
const [state, setState] = useState<CopyState>("copy");
const copyToClipboard = () => navigator.clipboard.writeText(slot.toString());
const handleClick = () =>
copyToClipboard().then(() => {
setState("copied");
setTimeout(() => setState("copy"), 1000);
});
const copyIcon =
state === "copy" ? (
<span className="fe fe-copy" onClick={handleClick}></span>
) : (
<span className="fe fe-check-circle"></span>
);
const copyButton = (
<span className="c-pointer font-size-tiny mr-2">{copyIcon}</span>
);
return link ? (
<span className="text-monospace">
{copyButton}
<Link className="" to={`/block/${slot}`}>
{slot.toLocaleString("en-US")}
</Link>
</span>
) : (
<span className="text-monospace">{slot.toLocaleString("en-US")}</span>
);
}

View File

@ -9,6 +9,7 @@ import { RichListProvider } from "./providers/richList";
import { SupplyProvider } from "./providers/supply";
import { TransactionsProvider } from "./providers/transactions";
import { AccountsProvider } from "./providers/accounts";
import { BlockProvider } from "./providers/block";
import { StatsProvider } from "providers/stats";
import { MintsProvider } from "providers/mints";
@ -26,11 +27,13 @@ ReactDOM.render(
<SupplyProvider>
<RichListProvider>
<AccountsProvider>
<MintsProvider>
<TransactionsProvider>
<App />
</TransactionsProvider>
</MintsProvider>
<BlockProvider>
<MintsProvider>
<TransactionsProvider>
<App />
</TransactionsProvider>
</MintsProvider>
</BlockProvider>
</AccountsProvider>
</RichListProvider>
</SupplyProvider>

View File

@ -0,0 +1,26 @@
import React from "react";
import { BlockHistoryCard } from "components/account/BlockHistoryCard";
import { ErrorCard } from "components/common/ErrorCard";
type Props = { slot: string };
export function BlockDetailsPage({ slot }: Props) {
let output = <ErrorCard text={`Block ${slot} is not valid`} />;
if (!isNaN(Number(slot))) {
output = <BlockHistoryCard slot={Number(slot)} />;
}
return (
<div className="container mt-n3">
<div className="header">
<div className="header-body">
<h6 className="header-pretitle">Details</h6>
<h2 className="header-title">Block</h2>
</div>
</div>
{output}
</div>
);
}

View File

@ -20,7 +20,6 @@ import { StakeDetailsCard } from "components/instruction/stake/StakeDetailsCard"
import { ErrorCard } from "components/common/ErrorCard";
import { LoadingCard } from "components/common/LoadingCard";
import { TableCardBody } from "components/common/TableCardBody";
import { Slot } from "components/common/Slot";
import { displayTimestamp } from "utils/date";
import { InfoTooltip } from "components/common/InfoTooltip";
import { Address } from "components/common/Address";
@ -29,6 +28,7 @@ import { intoTransactionInstruction, isSerumInstruction } from "utils/tx";
import { TokenDetailsCard } from "components/instruction/token/TokenDetailsCard";
import { FetchStatus } from "providers/cache";
import { SerumDetailsCard } from "components/instruction/SerumDetailsCard";
import { Slot } from "components/common/Slot";
const AUTO_REFRESH_INTERVAL = 2000;
const ZERO_CONFIRMATION_BAILOUT = 5;
@ -249,7 +249,7 @@ function StatusCard({
<tr>
<td>Block</td>
<td className="text-lg-right">
<Slot slot={info.slot} />
<Slot slot={info.slot} link />
</td>
</tr>
@ -264,9 +264,7 @@ function StatusCard({
</InfoTooltip>
)}
</td>
<td className="text-lg-right">
<code>{blockhash}</code>
</td>
<td className="text-lg-right">{blockhash}</td>
</tr>
)}

View File

@ -0,0 +1,114 @@
import React from "react";
import * as Sentry from "@sentry/react";
import * as Cache from "providers/cache";
import { Connection, ConfirmedBlock } from "@solana/web3.js";
import { useCluster, Cluster } from "./cluster";
export enum FetchStatus {
Fetching,
FetchFailed,
Fetched,
}
export enum ActionType {
Update,
Clear,
}
type State = Cache.State<ConfirmedBlock>;
type Dispatch = Cache.Dispatch<ConfirmedBlock>;
const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type BlockProviderProps = { children: React.ReactNode };
export function BlockProvider({ children }: BlockProviderProps) {
const { url } = useCluster();
const [state, dispatch] = Cache.useReducer<ConfirmedBlock>(url);
React.useEffect(() => {
dispatch({ type: ActionType.Clear, url });
}, [dispatch, url]);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
export function useBlock(
key: number
): Cache.CacheEntry<ConfirmedBlock> | undefined {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(`useBlock must be used within a BlockProvider`);
}
return context.entries[key];
}
export async function fetchBlock(
dispatch: Dispatch,
url: string,
cluster: Cluster,
key: number
) {
dispatch({
type: ActionType.Update,
status: FetchStatus.Fetching,
key,
url,
});
let status = FetchStatus.Fetching;
let data: ConfirmedBlock = {
blockhash: "",
previousBlockhash: "",
parentSlot: 0,
transactions: [],
};
try {
const connection = new Connection(url, "max");
data = await connection.getConfirmedBlock(Number(key));
status = FetchStatus.Fetched;
} catch (error) {
console.log(error);
if (cluster !== Cluster.Custom) {
Sentry.captureException(error, { tags: { url } });
}
status = FetchStatus.FetchFailed;
}
dispatch({
type: ActionType.Update,
url,
key,
status,
data,
});
}
export function useFetchBlock() {
const { cluster, url } = useCluster();
const state = React.useContext(StateContext);
const dispatch = React.useContext(DispatchContext);
if (!state || !dispatch) {
throw new Error(`useFetchBlock must be used within a BlockProvider`);
}
return React.useCallback(
(key: number) => {
const entry = state.entries[key];
if (!entry) {
fetchBlock(dispatch, url, cluster, key);
}
},
[state, dispatch, cluster, url]
);
}

View File

@ -26,7 +26,7 @@ export enum ActionType {
export type Update<T> = {
type: ActionType.Update;
url: string;
key: string;
key: string | number;
status: FetchStatus;
data?: T;
};

View File

@ -186,7 +186,11 @@ async function updateCluster(
if (cluster !== Cluster.Custom) {
reportError(error, { clusterUrl: clusterUrl(cluster, customUrl) });
}
dispatch({ status: ClusterStatus.Failure, cluster, customUrl });
dispatch({
status: ClusterStatus.Failure,
cluster,
customUrl,
});
}
}