explorer: Add epoch details page (#20335)

This commit is contained in:
Justin Starry 2021-09-29 23:08:19 -04:00 committed by GitHub
parent 8db9586599
commit 5952b65932
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 454 additions and 41 deletions

View File

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

View File

@ -21,7 +21,7 @@ export function SearchBar() {
const history = useHistory();
const location = useLocation();
const { tokenRegistry } = useTokenRegistry();
const { cluster } = useCluster();
const { cluster, epochInfo } = useCluster();
const onChange = (
{ pathname }: ValueType<any, false>,
@ -44,7 +44,12 @@ export function SearchBar() {
<div className="col">
<Select
ref={(ref) => (selectRef.current = ref)}
options={buildOptions(search, cluster, tokenRegistry)}
options={buildOptions(
search,
cluster,
tokenRegistry,
epochInfo?.epoch
)}
noOptionsMessage={() => "No Results"}
placeholder="Search for blocks, accounts, transactions, programs, and tokens"
value={resetValue}
@ -195,7 +200,8 @@ function buildTokenOptions(
function buildOptions(
rawSearch: string,
cluster: Cluster,
tokenRegistry: TokenInfoMap
tokenRegistry: TokenInfoMap,
currentEpoch?: number
) {
const search = rawSearch.trim();
if (search.length === 0) return [];
@ -238,6 +244,19 @@ function buildOptions(
},
],
});
if (currentEpoch !== undefined && Number(search) <= currentEpoch + 1) {
options.push({
label: "Epoch",
options: [
{
label: `Epoch #${search}`,
value: [search],
pathname: `/epoch/${search}`,
},
],
});
}
}
// Prefer nice suggestions over raw suggestions

View File

@ -8,6 +8,7 @@ import { Slot } from "components/common/Slot";
import { lamportsToSolString } from "utils";
import { useAccountInfo } from "providers/accounts";
import BN from "bn.js";
import { Epoch } from "components/common/Epoch";
const MAX_EPOCH = new BN(2).pow(new BN(64)).sub(new BN(1));
@ -52,7 +53,9 @@ export function RewardsCard({ pubkey }: { pubkey: PublicKey }) {
return (
<tr key={reward.epoch}>
<td>{reward.epoch}</td>
<td>
<Epoch epoch={reward.epoch} link />
</td>
<td>
<Slot slot={reward.effectiveSlot} link />
</td>

View File

@ -11,6 +11,7 @@ import {
} from "validators/accounts/stake";
import BN from "bn.js";
import { StakeActivationData } from "@solana/web3.js";
import { Epoch } from "components/common/Epoch";
const MAX_EPOCH = new BN(2).pow(new BN(64)).sub(new BN(1));
@ -162,12 +163,12 @@ function DelegationCard({
const delegation = stakeAccount?.stake?.delegation;
if (delegation) {
voterPubkey = delegation.voter;
activationEpoch = delegation.activationEpoch.eq(MAX_EPOCH)
? "-"
: delegation.activationEpoch.toString();
deactivationEpoch = delegation.deactivationEpoch.eq(MAX_EPOCH)
? "-"
: delegation.deactivationEpoch.toString();
if (!delegation.activationEpoch.eq(MAX_EPOCH)) {
activationEpoch = delegation.activationEpoch.toNumber();
}
if (!delegation.deactivationEpoch.eq(MAX_EPOCH)) {
deactivationEpoch = delegation.deactivationEpoch.toNumber();
}
}
const { stake } = stakeAccount;
return (
@ -223,12 +224,23 @@ function DelegationCard({
<tr>
<td>Activation Epoch</td>
<td className="text-lg-right">{activationEpoch}</td>
<td className="text-lg-right">
{activationEpoch !== undefined ? (
<Epoch epoch={activationEpoch} link />
) : (
"-"
)}
</td>
</tr>
<tr>
<td>Deactivation Epoch</td>
<td className="text-lg-right">{deactivationEpoch}</td>
<td className="text-lg-right">
{deactivationEpoch !== undefined ? (
<Epoch epoch={deactivationEpoch} link />
) : (
"-"
)}
</td>
</tr>
</>
)}

View File

@ -1,5 +1,6 @@
import React from "react";
import { SolBalance } from "utils";
import { Epoch } from "components/common/Epoch";
import {
SysvarAccount,
StakeHistoryInfo,
@ -55,7 +56,9 @@ export function StakeHistoryCard({
const renderAccountRow = (entry: StakeHistoryEntry, index: number) => {
return (
<tr key={index}>
<td className="w-1 text-monospace">{entry.epoch}</td>
<td className="w-1 text-monospace">
<Epoch epoch={entry.epoch} link />
</td>
<td className="text-monospace">
<SolBalance lamports={entry.stakeHistory.effective} />
</td>

View File

@ -20,6 +20,7 @@ import {
} from "components/common/Account";
import { displayTimestamp } from "utils/date";
import { Slot } from "components/common/Slot";
import { Epoch } from "components/common/Epoch";
export function SysvarAccountSection({
account,
@ -318,13 +319,15 @@ function SysvarAccountClockCard({
<tr>
<td>Epoch</td>
<td className="text-lg-right">{sysvarAccount.info.epoch}</td>
<td className="text-lg-right">
<Epoch epoch={sysvarAccount.info.epoch} link />
</td>
</tr>
<tr>
<td>Leader Schedule Epoch</td>
<td className="text-lg-right">
{sysvarAccount.info.leaderScheduleEpoch}
<Epoch epoch={sysvarAccount.info.leaderScheduleEpoch} link />
</td>
</tr>

View File

@ -79,8 +79,6 @@ export function BlockHistoryCard({ block }: { block: BlockResponse }) {
}, [block]);
const filteredTransactions = React.useMemo(() => {
// console.log("Filter: ", filter);
// console.log("invocations", transactions);
return transactions.filter(({ invocations }) => {
if (filter === ALL_TRANSACTIONS) {
return true;

View File

@ -13,6 +13,7 @@ import { clusterPath } from "utils/url";
import { BlockProgramsCard } from "./BlockProgramsCard";
import { BlockAccountsCard } from "./BlockAccountsCard";
import { displayTimestamp, displayTimestampUtc } from "utils/date";
import { Epoch } from "components/common/Epoch";
export function BlockOverviewCard({
slot,
@ -23,7 +24,7 @@ export function BlockOverviewCard({
}) {
const confirmedBlock = useBlock(slot);
const fetchBlock = useFetchBlock();
const { status } = useCluster();
const { epochSchedule, status } = useCluster();
const refresh = () => fetchBlock(slot);
// Fetch block on load
@ -44,6 +45,7 @@ export function BlockOverviewCard({
const block = confirmedBlock.data.block;
const committedTxs = block.transactions.filter((tx) => tx.meta?.err === null);
const epoch = epochSchedule?.getEpoch(slot);
return (
<>
@ -66,7 +68,7 @@ export function BlockOverviewCard({
<span>{block.blockhash}</span>
</td>
</tr>
{block.blockTime && (
{block.blockTime ? (
<>
<tr>
<td>Timestamp (Local)</td>
@ -85,6 +87,11 @@ export function BlockOverviewCard({
</td>
</tr>
</>
) : (
<tr>
<td className="w-100">Timestamp</td>
<td className="text-lg-right">Unavailable</td>
</tr>
)}
<tr>
<td className="w-100">Parent Slot</td>
@ -92,6 +99,14 @@ export function BlockOverviewCard({
<Slot slot={block.parentSlot} link />
</td>
</tr>
{epoch !== undefined && (
<tr>
<td className="w-100">Epoch</td>
<td className="text-lg-right text-monospace">
<Epoch epoch={epoch} link />
</td>
</tr>
)}
<tr>
<td className="w-100">Parent Blockhash</td>
<td className="text-lg-right text-monospace">

View File

@ -41,7 +41,7 @@ export function Copyable({
return null;
}
let message = "";
let message: string | undefined;
let textColor = "";
if (state === "copied") {
message = "Copied";
@ -56,7 +56,7 @@ export function Copyable({
<>
<span className="font-size-tiny mr-2">
<span className={textColor}>
<span className="mr-2">{message}</span>
{message !== undefined && <span className="mr-2">{message}</span>}
<CopyIcon />
</span>
</span>

View File

@ -0,0 +1,24 @@
import React from "react";
import { Link } from "react-router-dom";
import { clusterPath } from "utils/url";
import { Copyable } from "./Copyable";
type Props = {
epoch: number;
link?: boolean;
};
export function Epoch({ epoch, link }: Props) {
return (
<span className="text-monospace">
{link ? (
<Copyable text={epoch.toString()}>
<Link to={clusterPath(`/epoch/${epoch}`)}>
{epoch.toLocaleString("en-US")}
</Link>
</Copyable>
) : (
epoch.toLocaleString("en-US")
)}
</span>
);
}

View File

@ -8,15 +8,17 @@ type Props = {
link?: boolean;
};
export function Slot({ slot, link }: Props) {
return link ? (
<Copyable text={slot.toString()}>
<span className="text-monospace">
<Link to={clusterPath(`/block/${slot}`)}>
{slot.toLocaleString("en-US")}
</Link>
</span>
</Copyable>
) : (
<span className="text-monospace">{slot.toLocaleString("en-US")}</span>
return (
<span className="text-monospace">
{link ? (
<Copyable text={slot.toString()}>
<Link to={clusterPath(`/block/${slot}`)}>
{slot.toLocaleString("en-US")}
</Link>
</Copyable>
) : (
slot.toLocaleString("en-US")
)}
</span>
);
}

View File

@ -9,6 +9,7 @@ import { InstructionCard } from "../InstructionCard";
import { Address } from "components/common/Address";
import { InitializeInfo } from "./types";
import { displayTimestampUtc } from "utils/date";
import { Epoch } from "components/common/Epoch";
export function InitializeDetailsCard(props: {
ix: ParsedInstruction;
@ -60,7 +61,9 @@ export function InitializeDetailsCard(props: {
{info.lockup.epoch > 0 && (
<tr>
<td>Lockup Expiry Epoch</td>
<td className="text-lg-right">{info.lockup.epoch}</td>
<td className="text-lg-right">
<Epoch epoch={info.lockup.epoch} link />
</td>
</tr>
)}

View File

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

View File

@ -16,6 +16,7 @@ import { ErrorCard } from "components/common/ErrorCard";
import { LoadingCard } from "components/common/LoadingCard";
import { useVoteAccounts } from "providers/accounts/vote-accounts";
import { CoingeckoStatus, useCoinGecko } from "utils/coingecko";
import { Epoch } from "components/common/Epoch";
const CLUSTER_STATS_TIMEOUT = 5000;
@ -225,7 +226,6 @@ function StatsCardBody() {
const hourlySlotTime = Math.round(1000 * avgSlotTime_1h);
const averageSlotTime = Math.round(1000 * avgSlotTime_1min);
const { slotIndex, slotsInEpoch } = epochInfo;
const currentEpoch = epochInfo.epoch.toString();
const epochProgress = ((100 * slotIndex) / slotsInEpoch).toFixed(1) + "%";
const epochTimeRemaining = slotsToHumanString(
slotsInEpoch - slotIndex,
@ -267,7 +267,9 @@ function StatsCardBody() {
</tr>
<tr>
<td className="w-100">Epoch</td>
<td className="text-lg-right text-monospace">{currentEpoch}</td>
<td className="text-lg-right text-monospace">
<Epoch epoch={epochInfo.epoch} link />
</td>
</tr>
<tr>
<td className="w-100">Epoch progress</td>

View File

@ -0,0 +1,158 @@
import React from "react";
import { ErrorCard } from "components/common/ErrorCard";
import { ClusterStatus, useCluster } from "providers/cluster";
import { LoadingCard } from "components/common/LoadingCard";
import { TableCardBody } from "components/common/TableCardBody";
import { Epoch } from "components/common/Epoch";
import { Slot } from "components/common/Slot";
import { useEpoch, useFetchEpoch } from "providers/epoch";
import { displayTimestampUtc } from "utils/date";
type Props = { epoch: string };
export function EpochDetailsPage({ epoch }: Props) {
let output;
if (isNaN(Number(epoch))) {
output = <ErrorCard text={`Epoch ${epoch} is not valid`} />;
} else {
output = <EpochOverviewCard epoch={Number(epoch)} />;
}
return (
<div className="container mt-n3">
<div className="header">
<div className="header-body">
<h6 className="header-pretitle">Details</h6>
<h2 className="header-title">Epoch</h2>
</div>
</div>
{output}
</div>
);
}
type OverviewProps = { epoch: number };
function EpochOverviewCard({ epoch }: OverviewProps) {
const { status, epochSchedule, epochInfo } = useCluster();
const epochState = useEpoch(epoch);
const fetchEpoch = useFetchEpoch();
// Fetch extra epoch info on load
React.useEffect(() => {
if (!epochSchedule || !epochInfo) return;
const currentEpoch = epochInfo.epoch;
if (
epoch <= currentEpoch &&
!epochState &&
status === ClusterStatus.Connected
)
fetchEpoch(epoch, currentEpoch, epochSchedule);
}, [epoch, epochState, epochInfo, epochSchedule, status, fetchEpoch]);
if (!epochSchedule || !epochInfo) {
return <LoadingCard message="Connecting to cluster" />;
}
const currentEpoch = epochInfo.epoch;
if (epoch > currentEpoch) {
return <ErrorCard text={`Epoch ${epoch} hasn't started yet`} />;
} else if (!epochState?.data) {
return <LoadingCard message="Loading epoch" />;
}
const firstSlot = epochSchedule.getFirstSlotInEpoch(epoch);
const lastSlot = epochSchedule.getLastSlotInEpoch(epoch);
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">Epoch</td>
<td className="text-lg-right text-monospace">
<Epoch epoch={epoch} />
</td>
</tr>
{epoch > 0 && (
<tr>
<td className="w-100">Previous Epoch</td>
<td className="text-lg-right text-monospace">
<Epoch epoch={epoch - 1} link />
</td>
</tr>
)}
<tr>
<td className="w-100">Next Epoch</td>
<td className="text-lg-right text-monospace">
{currentEpoch > epoch ? (
<Epoch epoch={epoch + 1} link />
) : (
<span className="text-muted">Epoch in progress</span>
)}
</td>
</tr>
<tr>
<td className="w-100">First Slot</td>
<td className="text-lg-right text-monospace">
<Slot slot={firstSlot} />
</td>
</tr>
<tr>
<td className="w-100">Last Slot</td>
<td className="text-lg-right text-monospace">
<Slot slot={lastSlot} />
</td>
</tr>
{epochState.data.firstTimestamp && (
<tr>
<td className="w-100">First Block Timestamp</td>
<td className="text-lg-right">
<span className="text-monospace">
{displayTimestampUtc(
epochState.data.firstTimestamp * 1000,
true
)}
</span>
</td>
</tr>
)}
<tr>
<td className="w-100">First Block</td>
<td className="text-lg-right text-monospace">
<Slot slot={epochState.data.firstBlock} link />
</td>
</tr>
<tr>
<td className="w-100">Last Block</td>
<td className="text-lg-right text-monospace">
{epochState.data.lastBlock !== undefined ? (
<Slot slot={epochState.data.lastBlock} link />
) : (
<span className="text-muted">Epoch in progress</span>
)}
</td>
</tr>
{epochState.data.lastTimestamp && (
<tr>
<td className="w-100">Last Block Timestamp</td>
<td className="text-lg-right">
<span className="text-monospace">
{displayTimestampUtc(
epochState.data.lastTimestamp * 1000,
true
)}
</span>
</td>
</tr>
)}
</TableCardBody>
</div>
</>
);
}

View File

@ -1,5 +1,10 @@
import React from "react";
import { clusterApiUrl, Connection } from "@solana/web3.js";
import {
clusterApiUrl,
Connection,
EpochInfo,
EpochSchedule,
} from "@solana/web3.js";
import { useQuery } from "../utils/url";
import { useHistory, useLocation } from "react-router-dom";
import { reportError } from "utils/sentry";
@ -74,6 +79,8 @@ interface State {
cluster: Cluster;
customUrl: string;
firstAvailableBlock?: number;
epochSchedule?: EpochSchedule;
epochInfo?: EpochInfo;
status: ClusterStatus;
}
@ -82,6 +89,8 @@ interface Action {
cluster: Cluster;
customUrl: string;
firstAvailableBlock?: number;
epochSchedule?: EpochSchedule;
epochInfo?: EpochInfo;
}
type Dispatch = (action: Action) => void;
@ -191,11 +200,15 @@ async function updateCluster(
try {
const connection = new Connection(clusterUrl(cluster, customUrl));
const firstAvailableBlock = await connection.getFirstAvailableBlock();
const epochSchedule = await connection.getEpochSchedule();
const epochInfo = await connection.getEpochInfo();
dispatch({
status: ClusterStatus.Connected,
cluster,
customUrl,
firstAvailableBlock,
epochSchedule,
epochInfo,
});
} catch (error) {
if (cluster !== Cluster.Custom) {

View File

@ -0,0 +1,149 @@
import React from "react";
import * as Sentry from "@sentry/react";
import * as Cache from "providers/cache";
import { Connection, EpochSchedule } from "@solana/web3.js";
import { useCluster, Cluster } from "./cluster";
export enum FetchStatus {
Fetching,
FetchFailed,
Fetched,
}
export enum ActionType {
Update,
Clear,
}
type Epoch = {
firstBlock: number;
firstTimestamp: number | null;
lastBlock?: number;
lastTimestamp: number | null;
};
type State = Cache.State<Epoch>;
type Dispatch = Cache.Dispatch<Epoch>;
const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type EpochProviderProps = { children: React.ReactNode };
export function EpochProvider({ children }: EpochProviderProps) {
const { url } = useCluster();
const [state, dispatch] = Cache.useReducer<Epoch>(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 useEpoch(key: number): Cache.CacheEntry<Epoch> | undefined {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(`useEpoch must be used within a EpochProvider`);
}
return context.entries[key];
}
export async function fetchEpoch(
dispatch: Dispatch,
url: string,
cluster: Cluster,
epochSchedule: EpochSchedule,
currentEpoch: number,
epoch: number
) {
dispatch({
type: ActionType.Update,
status: FetchStatus.Fetching,
key: epoch,
url,
});
let status: FetchStatus;
let data: Epoch | undefined = undefined;
try {
const connection = new Connection(url, "confirmed");
const firstSlot = epochSchedule.getFirstSlotInEpoch(epoch);
const lastSlot = epochSchedule.getLastSlotInEpoch(epoch);
const [firstBlock, lastBlock] = await Promise.all([
(async () => {
const firstBlocks = await connection.getBlocks(
firstSlot,
firstSlot + 100
);
return firstBlocks.shift();
})(),
(async () => {
const lastBlocks = await connection.getBlocks(
Math.max(0, lastSlot - 100),
lastSlot
);
return lastBlocks.pop();
})(),
]);
if (firstBlock === undefined) {
throw new Error(
`failed to find confirmed block at start of epoch ${epoch}`
);
} else if (epoch < currentEpoch && lastBlock === undefined) {
throw new Error(
`failed to find confirmed block at end of epoch ${epoch}`
);
}
const [firstTimestamp, lastTimestamp] = await Promise.all([
connection.getBlockTime(firstBlock),
lastBlock ? connection.getBlockTime(lastBlock) : null,
]);
data = {
firstBlock,
lastBlock,
firstTimestamp,
lastTimestamp,
};
status = FetchStatus.Fetched;
} catch (err) {
status = FetchStatus.FetchFailed;
if (cluster !== Cluster.Custom) {
Sentry.captureException(err, { tags: { url } });
}
}
dispatch({
type: ActionType.Update,
url,
key: epoch,
status,
data,
});
}
export function useFetchEpoch() {
const dispatch = React.useContext(DispatchContext);
if (!dispatch) {
throw new Error(`useFetchEpoch must be used within a EpochProvider`);
}
const { cluster, url } = useCluster();
return React.useCallback(
(key: number, currentEpoch: number, epochSchedule: EpochSchedule) =>
fetchEpoch(dispatch, url, cluster, epochSchedule, currentEpoch, key),
[dispatch, cluster, url]
);
}