diff --git a/src/api/atoms.ts b/src/api/atoms.ts index 0c9d4a8..92782ab 100644 --- a/src/api/atoms.ts +++ b/src/api/atoms.ts @@ -23,6 +23,7 @@ import type { BlockEngineUpdate, VoteBalance, ScheduleStrategy, + SlotRankings, } from "./types"; import { rafAtom } from "../atomUtils"; @@ -85,3 +86,5 @@ export const voteDistanceAtom = atom(undefined); export const skippedSlotsAtom = atom(undefined); export const blockEngineAtom = atom(undefined); + +export const slotRankingsAtom = atom(undefined); diff --git a/src/api/entities.ts b/src/api/entities.ts index d7fbfc0..7bd1c4c 100644 --- a/src/api/entities.ts +++ b/src/api/entities.ts @@ -474,6 +474,33 @@ export const slotResponseSchema = z.object({ export const slotSkippedHistorySchema = z.number().array(); +export const slotRankingsSchema = z.object({ + slots_largest_tips: z.number().array(), + vals_largest_tips: z.coerce.bigint().array(), + slots_smallest_tips: z.number().array(), + vals_smallest_tips: z.coerce.bigint().array(), + slots_largest_fees: z.number().array(), + vals_largest_fees: z.coerce.bigint().array(), + slots_smallest_fees: z.number().array(), + vals_smallest_fees: z.coerce.bigint().array(), + slots_largest_rewards: z.number().array(), + vals_largest_rewards: z.coerce.bigint().array(), + slots_smallest_rewards: z.number().array(), + vals_smallest_rewards: z.coerce.bigint().array(), + slots_largest_duration: z.number().array(), + vals_largest_duration: z.coerce.bigint().array(), + slots_smallest_duration: z.number().array(), + vals_smallest_duration: z.coerce.bigint().array(), + slots_largest_compute_units: z.number().array(), + vals_largest_compute_units: z.coerce.bigint().array(), + slots_smallest_compute_units: z.number().array(), + vals_smallest_compute_units: z.coerce.bigint().array(), + slots_largest_skipped: z.number().array(), + vals_largest_skipped: z.coerce.bigint().array(), + slots_smallest_skipped: z.number().array(), + vals_smallest_skipped: z.coerce.bigint().array(), +}); + export const slotSchema = z.discriminatedUnion("key", [ slotTopicSchema.extend({ key: z.literal("skipped_history"), @@ -487,6 +514,10 @@ export const slotSchema = z.discriminatedUnion("key", [ key: z.literal("query"), value: slotResponseSchema.nullable(), }), + slotTopicSchema.extend({ + key: z.literal("query_rankings"), + value: slotRankingsSchema, + }), ]); export const blockEngineStatusSchema = z.enum([ diff --git a/src/api/types.ts b/src/api/types.ts index 2117aad..8b4c372 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -40,6 +40,7 @@ import type { slotTransactionsSchema, voteBalanceSchema, scheduleStrategySchema, + slotRankingsSchema, } from "./entities"; export type Client = z.infer; @@ -131,3 +132,5 @@ export type SkippedSlots = z.infer; export type BlockEngineUpdate = z.infer; export type BlockEngineStatus = z.infer; + +export type SlotRankings = z.infer; diff --git a/src/api/useSetAtomWsData.ts b/src/api/useSetAtomWsData.ts index 15540e4..6679fef 100644 --- a/src/api/useSetAtomWsData.ts +++ b/src/api/useSetAtomWsData.ts @@ -20,6 +20,7 @@ import { voteStateAtom, voteBalanceAtom, scheduleStrategyAtom, + slotRankingsAtom, } from "./atoms"; import { blockEngineSchema, @@ -130,6 +131,7 @@ export function useSetAtomWsData() { const setSkippedSlots = useSetAtom(skippedSlotsAtom); const setSlotResponse = useSetAtom(setSlotResponseAtom); + const setSlotRankings = useSetAtom(slotRankingsAtom); const [epoch, setEpoch] = useAtom(epochAtom); @@ -280,6 +282,10 @@ export function useSetAtomWsData() { } break; } + case "query_rankings": { + setSlotRankings(value); + break; + } } } else if (topic === "block_engine") { const { key, value } = blockEngineSchema.parse(msg); diff --git a/src/atoms.ts b/src/atoms.ts index 4f66d6e..944fc3f 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -288,30 +288,34 @@ export const firstProcessedSlotAtom = atom((get) => { return startupProgress.ledger_max_slot + 1; }); -export const earliestProcessedSlotLeaderAtom = atom((get) => { - const firstProcessedSlot = get(firstProcessedSlotAtom); - const leaderSlots = get(leaderSlotsAtom); - - if (firstProcessedSlot === undefined || !leaderSlots?.length) return; - return leaderSlots.find((s) => s >= firstProcessedSlot); -}); - -export const mostRecentSlotLeaderAtom = atom((get) => { - const earliestProcessedSlotLeader = get(earliestProcessedSlotLeaderAtom); +export const processedLeaderSlotsAtom = atom((get) => { const leaderSlots = get(leaderSlotsAtom); + const firstProcessedSlot = get(firstProcessedSlotAtom); const currentLeaderSlot = get(currentLeaderSlotAtom); if ( - earliestProcessedSlotLeader === undefined || - currentLeaderSlot === undefined || - !leaderSlots?.length + !leaderSlots?.length || + firstProcessedSlot === undefined || + currentLeaderSlot === undefined ) return; - return leaderSlots.findLast( - (s) => earliestProcessedSlotLeader <= s && s <= currentLeaderSlot, + return leaderSlots.filter( + (slot) => firstProcessedSlot <= slot && slot <= currentLeaderSlot, ); }); +export const earliestProcessedSlotLeaderAtom = atom((get) => { + const processedLeaderSlots = get(processedLeaderSlotsAtom); + return processedLeaderSlots?.[0]; +}); + +export const mostRecentSlotLeaderAtom = atom((get) => { + const processedLeaderSlots = get(processedLeaderSlotsAtom); + return processedLeaderSlots + ? processedLeaderSlots[processedLeaderSlots.length - 1] + : undefined; +}); + const _currentSlotAtom = atom(undefined); export const currentSlotAtom = atom( (get) => get(_currentSlotAtom), diff --git a/src/colors.ts b/src/colors.ts index f9133d0..4d534a7 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -164,11 +164,15 @@ export const circularProgressPathColor = slotStatusBlue; // slot details export const slotDetailsMySlotsColor = "#0080e6"; -export const slotDetailsQuickSearchTextPrimaryColor = "#cecece"; -export const slotDetailsQuickSearchTextSecondaryColor = "#646464"; -export const slotDetailsEarliestSlotColor = "#00A2C7"; -export const slotDetailsMostRecentSlotColor = "#1D863B"; -export const slotDetailsLastSkippedSlotColor = "#EB6262"; +export const slotDetailsSearchLabelColor = "#FFF"; +export const slotDetailsQuickSearchTextColor = "var(--gray-10)"; +export const slotDetailsEarliestSlotColor = "var(--teal-9)"; +export const slotDetailsRecentSlotColor = "var(--cyan-9)"; +export const slotDetailsSkippedSlotColor = "var(--red-8)"; +export const slotDetailsFeesSlotColor = "var(--sky-8)"; +export const slotDetailsTipsSlotColor = "var(--green-9)"; +export const slotDetailsRewardsSlotColor = "var(--indigo-10)"; +export const slotDetailsClickableSlotColor = "var(--blue-9)"; export const slotDetailsBackgroundColor = "#15181e"; export const slotDetailsColor = "#9aabc3"; export const slotDetailsSkippedBackgroundColor = "#250f0f"; diff --git a/src/consts.ts b/src/consts.ts index 336319c..b61a449 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -77,3 +77,12 @@ export const slotNavWidth = export const narrowNavMedia = "(max-width: 768px)"; export const maxZIndex = 110; + +export const numQuickSearchSlots = 3; +export const quickSearchCardWidth = 226; +export const slotSearchGap = 40; +export const slotSearchPadding = 20; +export const slotSearchMaxWidth = + 2 * slotSearchPadding + + numQuickSearchSlots * quickSearchCardWidth + + (numQuickSearchSlots - 1) * slotSearchGap; diff --git a/src/features/SlotDetails/SlotSearch.tsx b/src/features/SlotDetails/SlotSearch.tsx new file mode 100644 index 0000000..6a8f235 --- /dev/null +++ b/src/features/SlotDetails/SlotSearch.tsx @@ -0,0 +1,308 @@ +import { useCallback, useMemo, useState, type CSSProperties } from "react"; +import { useSlotSearchParam } from "./useSearchParams"; +import { epochAtom, processedLeaderSlotsAtom } from "../../atoms"; +import { useAtomValue } from "jotai"; +import { + baseSelectedSlotAtom, + SelectedSlotValidityState, +} from "../Overview/SlotPerformance/atoms"; +import { Label } from "radix-ui"; +import { Flex, IconButton, TextField, Text, Grid } from "@radix-ui/themes"; +import { + numQuickSearchSlots, + quickSearchCardWidth, + slotSearchGap, + slotSearchMaxWidth, + slotSearchPadding, +} from "../../consts"; +import styles from "./slotSearch.module.css"; +import { + CounterClockwiseClockIcon, + DoubleArrowUpIcon, + MagnifyingGlassIcon, + PlusCircledIcon, + TextAlignTopIcon, + TimerIcon, +} from "@radix-ui/react-icons"; +import Skipped from "../../assets/Skipped.svg?react"; +import { getSolString } from "../../utils"; +import useSlotRankings from "../../hooks/useSlotRankings"; +import { slotRankingsAtom } from "../../api/atoms"; +import { + slotDetailsEarliestSlotColor, + slotDetailsFeesSlotColor, + slotDetailsRecentSlotColor, + slotDetailsRewardsSlotColor, + slotDetailsSkippedSlotColor, + slotDetailsTipsSlotColor, +} from "../../colors"; +import clsx from "clsx"; +import { useTimeAgo } from "../../hooks/useTimeAgo"; + +export function SlotSearch() { + const { selectedSlot, setSelectedSlot } = useSlotSearchParam(); + const [searchSlot, setSearchSlot] = useState( + selectedSlot === undefined ? "" : String(selectedSlot), + ); + const epoch = useAtomValue(epochAtom); + const { isValid } = useAtomValue(baseSelectedSlotAtom); + + const submitSearch = useCallback(() => { + if (searchSlot === "") setSelectedSlot(undefined); + else setSelectedSlot(Number(searchSlot)); + }, [searchSlot, setSelectedSlot]); + + return ( + + +
{ + e.preventDefault(); + submitSearch(); + }} + > + + Search Slot ID + + setSearchSlot(e.target.value)} + size="3" + color={isValid ? "teal" : "red"} + autoFocus + > + + + + + + + {!isValid && } + +
+ +
+ ); +} + +function getSolStringWithFourDecimals(lamportAmount: bigint) { + return getSolString(lamportAmount, 4); +} + +function QuickSearch() { + useSlotRankings(true); + const slotRankings = useAtomValue(slotRankingsAtom); + const processedLeaderSlots = useAtomValue(processedLeaderSlotsAtom); + const reversedProcessedLeaderSlots = useMemo( + () => processedLeaderSlots?.toReversed(), + [processedLeaderSlots], + ); + + return ( + <> + } + label="Earliest Slots" + color={slotDetailsEarliestSlotColor} + slots={processedLeaderSlots} + /> + } + label="Most Recent Slots" + color={slotDetailsRecentSlotColor} + slots={reversedProcessedLeaderSlots} + /> + } + label="Last Skipped Slots" + color={slotDetailsSkippedSlotColor} + slots={slotRankings?.slots_largest_skipped} + /> + } + label="Highest Fees" + color={slotDetailsFeesSlotColor} + slots={slotRankings?.slots_largest_fees} + metricOptions={{ + metrics: slotRankings?.vals_largest_fees, + metricsFmt: getSolStringWithFourDecimals, + unit: "SOL", + }} + /> + } + label="Highest Tips" + color={slotDetailsTipsSlotColor} + slots={slotRankings?.slots_largest_tips} + metricOptions={{ + metrics: slotRankings?.vals_largest_tips, + metricsFmt: getSolStringWithFourDecimals, + unit: "SOL", + }} + /> + } + label="Highest Rewards" + color={slotDetailsRewardsSlotColor} + slots={slotRankings?.slots_largest_rewards} + metricOptions={{ + metrics: slotRankings?.vals_largest_rewards, + metricsFmt: getSolStringWithFourDecimals, + unit: "SOL", + }} + /> + + ); +} + +interface MetricOptions { + metrics?: T[]; + metricsFmt?: (m: T) => string | undefined; + unit?: string; +} + +function QuickSearchCard({ + icon, + label, + color, + slots, + metricOptions, +}: { + icon: React.ReactNode; + label: string; + color: string; + slots?: number[]; + metricOptions?: MetricOptions; +}) { + return ( + + + {icon} + {label} + + + + ); +} + +function QuickSearchSlots({ + slots, + metricOptions, +}: { + slots?: number[]; + metricOptions?: MetricOptions; +}) { + const { setSelectedSlot } = useSlotSearchParam(); + + return ( + + {Array.from({ length: numQuickSearchSlots }).map((_, i) => { + const slot = slots?.[i]; + return ( + + {slots === undefined ? ( + -- + ) : ( + setSelectedSlot(slot)} + > + {slot} + + )} + + + + + ); + })} + + ); +} + +function QuickSearchMetric({ + index, + slots, + metricOptions, +}: { + index: number; + slots?: number[]; + metricOptions?: MetricOptions; +}) { + if (slots?.[index] === undefined) return "--"; + if (!metricOptions) return ; + + const { metrics, metricsFmt, unit } = metricOptions; + const formattedMetric = + metrics && metrics[index] !== undefined + ? metricsFmt + ? metricsFmt(metrics[index]) + : metrics[index].toLocaleString() + : undefined; + + if (formattedMetric === undefined) return "--"; + return unit ? `${formattedMetric} ${unit}` : formattedMetric; +} + +function TimeAgo({ slot }: { slot: number }) { + const { timeAgoText } = useTimeAgo(slot, { + showOnlyTwoSignificantUnits: true, + }); + + return timeAgoText; +} + +function Errors() { + const { slot, state } = useAtomValue(baseSelectedSlotAtom); + + const epoch = useAtomValue(epochAtom); + const message = useMemo(() => { + switch (state) { + case SelectedSlotValidityState.NotReady: + return `Slot ${slot} validity cannot be determined because epoch and leader slot data is not available yet.`; + case SelectedSlotValidityState.OutsideEpoch: + return `Slot ${slot} is outside this epoch. Please try again with a different ID between ${epoch?.start_slot} - ${epoch?.end_slot}.`; + case SelectedSlotValidityState.NotYou: + return `Slot ${slot} belongs to another validator. Please try again with a slot number processed by you.`; + case SelectedSlotValidityState.BeforeFirstProcessed: + return `Slot ${slot} is in this epoch but its details are unavailable because it was processed before the restart.`; + case SelectedSlotValidityState.Future: + return `Slot ${slot} is valid but in the future. To view details, check again after it has been processed.`; + case SelectedSlotValidityState.Valid: + return ""; + } + }, [epoch?.end_slot, epoch?.start_slot, slot, state]); + + return ( + + {message} + + ); +} diff --git a/src/features/SlotDetails/index.tsx b/src/features/SlotDetails/index.tsx index ca7cb7c..d2faeef 100644 --- a/src/features/SlotDetails/index.tsx +++ b/src/features/SlotDetails/index.tsx @@ -1,4 +1,4 @@ -import { Flex, TextField, Text, IconButton } from "@radix-ui/themes"; +import { Flex, Text } from "@radix-ui/themes"; import SlotPerformance from "../Overview/SlotPerformance"; import ComputeUnitsCard from "../Overview/SlotPerformance/ComputeUnitsCard"; import TransactionBarsCard from "../Overview/SlotPerformance/TransactionBarsCard"; @@ -7,12 +7,9 @@ import { useAtomValue, useSetAtom } from "jotai"; import { selectedSlotAtom, baseSelectedSlotAtom, - SelectedSlotValidityState, } from "../Overview/SlotPerformance/atoms"; -import type { FC, SVGProps } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; import { useMedia, useUnmount } from "react-use"; -import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; import { useSlotInfo } from "../../hooks/useSlotInfo"; import styles from "./slotDetails.module.css"; import PeerIcon from "../../components/PeerIcon"; @@ -31,28 +28,15 @@ import { useSlotQueryPublish } from "../../hooks/useSlotQuery"; import { getLeaderSlots, getSlotGroupLeader } from "../../utils"; import { SkippedIcon, StatusIcon } from "../../components/StatusIcon"; import clsx from "clsx"; -import { - slotDetailsEarliestSlotColor, - slotDetailsLastSkippedSlotColor, - slotDetailsMostRecentSlotColor, - slotDetailsQuickSearchTextPrimaryColor, - slotDetailsQuickSearchTextSecondaryColor, -} from "../../colors"; - -import skippedIcon from "../../assets/Skipped.svg?react"; -import history from "../../assets/history.svg?react"; -import checkFill from "../../assets/checkFill.svg?react"; - -import { skippedSlotsAtom } from "../../api/atoms"; -import { useTimeAgo } from "../../hooks/useTimeAgo"; import SlotClient from "../../components/SlotClient"; import { Link } from "@tanstack/react-router"; +import { SlotSearch } from "./SlotSearch"; export default function SlotDetails() { const selectedSlot = useAtomValue(selectedSlotAtom); return ( - + {selectedSlot === undefined ? : } @@ -76,201 +60,6 @@ function Setup() { return null; } -function Errors() { - const { slot, state } = useAtomValue(baseSelectedSlotAtom); - - const epoch = useAtomValue(epochAtom); - const message = useMemo(() => { - switch (state) { - case SelectedSlotValidityState.NotReady: - return `Slot ${slot} validity cannot be determined because epoch and leader slot data is not available yet.`; - case SelectedSlotValidityState.OutsideEpoch: - return `Slot ${slot} is outside this epoch. Please try again with a different ID between ${epoch?.start_slot} - ${epoch?.end_slot}.`; - case SelectedSlotValidityState.NotYou: - return `Slot ${slot} belongs to another validator. Please try again with a slot number processed by you.`; - case SelectedSlotValidityState.BeforeFirstProcessed: - return `Slot ${slot} is in this epoch but its details are unavailable because it was processed before the restart.`; - case SelectedSlotValidityState.Future: - return `Slot ${slot} is valid but in the future. To view details, check again after it has been processed.`; - case SelectedSlotValidityState.Valid: - return ""; - } - }, [epoch?.end_slot, epoch?.start_slot, slot, state]); - - return ( - - {message} - - ); -} - -function SlotSearch() { - const { selectedSlot, setSelectedSlot } = useSlotSearchParam(); - const [searchSlot, setSearchSlot] = useState(""); - const epoch = useAtomValue(epochAtom); - const { isValid } = useAtomValue(baseSelectedSlotAtom); - - const submitSearch = useCallback(() => { - if (searchSlot === "") setSelectedSlot(undefined); - else setSelectedSlot(Number(searchSlot)); - }, [searchSlot, setSelectedSlot]); - - useEffect(() => { - if (selectedSlot === undefined) setSearchSlot(""); - else setSearchSlot(String(selectedSlot)); - }, [selectedSlot]); - - return ( - -
{ - e.preventDefault(); - submitSearch(); - }} - > - setSearchSlot(e.target.value)} - size="3" - color={isValid ? "teal" : "red"} - > - - - - - - -
- {!isValid && } - -
- ); -} - -function QuickSearch() { - return ( - - - - - - - - ); -} - -function EarliestProcessedSlotSearch() { - const earliestProcessedSlotLeader = useAtomValue( - earliestProcessedSlotLeaderAtom, - ); - return ( - - ); -} - -function MostRecentSlotSearch() { - const mostRecentSlotLeader = useAtomValue(mostRecentSlotLeaderAtom); - - return ( - - ); -} - -function LastSkippedSlotSearch() { - const skippedSlots = useAtomValue(skippedSlotsAtom); - const slot = useMemo( - () => (skippedSlots ? skippedSlots[skippedSlots?.length - 1] : undefined), - [skippedSlots], - ); - - return ( - - ); -} - -function QuickSearchCard({ - Icon, - label, - color, - slot, - disabled = false, -}: { - Icon: FC>; - label: string; - color: string; - slot?: number; - disabled?: boolean; -}) { - const { setSelectedSlot } = useSlotSearchParam(); - - return ( - setSelectedSlot(slot)} - aria-disabled={disabled} - > - - - {label} - - - - {slot ?? "--"} - - {slot && } - - - ); -} - -function TimeAgo({ slot }: { slot: number }) { - const { timeAgoText } = useTimeAgo(slot, { - showOnlyTwoSignificantUnits: true, - }); - - return ( - - {timeAgoText} - - ); -} - const navigationTop = clusterIndicatorHeight + headerHeight; function SlotNavigation({ slot }: { slot: number }) { @@ -452,7 +241,7 @@ function SlotContent() { const slotGroupLeader = getSlotGroupLeader(slot); return ( - + diff --git a/src/features/SlotDetails/slotDetails.module.css b/src/features/SlotDetails/slotDetails.module.css index 03b1850..084d46b 100644 --- a/src/features/SlotDetails/slotDetails.module.css +++ b/src/features/SlotDetails/slotDetails.module.css @@ -1,38 +1,3 @@ -.search { - text-align: center; -} - -.error-text { - color: var(--failure-color); -} - -.clickable { - cursor: pointer; -} - -.quick-search-row { - justify-content: center; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.quick-search { - width: 164px; - padding: 15px; - flex-direction: column; - gap: 10px; - border-radius: 8px; - border: 1px solid var(--container-border-color); - background: var(--container-background-color); - - &:not(.clickable) { - cursor: not-allowed; - pointer-events: none; - opacity: 0.6; - } -} - .slot-name { font-size: 18px; font-weight: 600; diff --git a/src/features/SlotDetails/slotSearch.module.css b/src/features/SlotDetails/slotSearch.module.css new file mode 100644 index 0000000..ee1ce93 --- /dev/null +++ b/src/features/SlotDetails/slotSearch.module.css @@ -0,0 +1,49 @@ +.search-label { + font-size: 16px; + font-weight: 510; + color: var(--slot-details-search-label-color); +} + +.search-field { + width: 100%; +} + +.error-text { + color: var(--failure-color); +} + +.quick-search-card { + border-radius: 8px; + border: 1px solid var(--container-border-color); + background: var(--container-background-color); + color: var(--slot-details-quick-search-text-color); +} + +.quick-search-header { + color: var(--quick-search-color); + font-size: 18px; + + svg { + width: 32px; + height: 32px; + fill: var(--quick-search-color); + } +} + +.quick-search-slot { + font-size: 12px; + + &.clickable { + cursor: pointer; + color: var(--slot-details-clickable-slot-color); + } + + &:not(.clickable) { + cursor: not-allowed; + pointer-events: none; + } +} + +.quick-search-metric { + font-size: 12px; +} diff --git a/src/hooks/useSlotRankings.ts b/src/hooks/useSlotRankings.ts new file mode 100644 index 0000000..0b6347d --- /dev/null +++ b/src/hooks/useSlotRankings.ts @@ -0,0 +1,15 @@ +import { useWebSocketSend } from "../api/ws/utils"; +import { useInterval } from "react-use"; + +export default function useSlotRankings(mine: boolean = false) { + const wsSend = useWebSocketSend(); + + useInterval(() => { + wsSend({ + topic: "slot", + key: "query_rankings", + id: 32, + params: { mine }, + }); + }, 5_000); +}