From f5d12307b93778238ed6b4e39f86deafe6a20d98 Mon Sep 17 00:00:00 2001 From: Ami Suzuki Date: Mon, 8 Sep 2025 18:12:01 -0500 Subject: [PATCH 1/3] feat: boot progress setup --- index.html | 6 + src/api/atoms.ts | 7 + src/api/entities.ts | 199 +++++++++++++++++- src/api/types.ts | 9 + src/api/useSetAtomWsData.ts | 18 ++ src/assets/boot_progress_background.svg | 3 + src/atoms.ts | 1 + src/colors.ts | 49 ++++- src/consts.ts | 2 + src/features/Header/index.tsx | 37 +++- .../Overview/SlotPerformance/TileBusy.tsx | 5 +- .../Overview/SlotPerformance/TileCard.tsx | 44 +--- .../SlotPerformance/TileSparkLine.tsx | 159 ++++++++------ .../Overview/SlotPerformance/atoms.ts | 43 +++- .../SlotPerformance/tileSparkline.module.css | 7 + .../SlotPerformance/useTileSparkline.ts | 126 +++++++++++ src/features/StartupProgress/Body.tsx | 3 +- .../StartupProgress/Firedancer/Bars.tsx | 61 ++++++ .../StartupProgress/Firedancer/Body.tsx | 100 +++++++++ .../Firedancer/GossipProgress.tsx | 36 ++++ .../StartupProgress/Firedancer/Header.tsx | 80 +++++++ .../StartupProgress/Firedancer/Logo.tsx | 26 +++ .../Firedancer/ProgressBar.tsx | 66 ++++++ .../Firedancer/SnapshotLoadingCard.tsx | 85 ++++++++ .../Firedancer/SnapshotProgress.tsx | 157 ++++++++++++++ .../Firedancer/SnapshotSparklineCard.tsx | 80 +++++++ .../Firedancer/bars.module.css | 49 +++++ .../Firedancer/body.module.css | 61 ++++++ .../StartupProgress/Firedancer/consts.ts | 91 ++++++++ .../Firedancer/gossip.module.css | 24 +++ .../Firedancer/header.module.css | 17 ++ .../Firedancer/logo.module.css | 35 +++ .../Firedancer/progressBar.module.css | 24 +++ .../Firedancer/snapshot.module.css | 90 ++++++++ src/features/StartupProgress/atoms.ts | 84 ++++++++ src/features/StartupProgress/index.tsx | 28 ++- src/routes/__root.tsx | 21 +- src/utils.ts | 8 +- 38 files changed, 1817 insertions(+), 124 deletions(-) create mode 100644 src/assets/boot_progress_background.svg create mode 100644 src/features/Overview/SlotPerformance/tileSparkline.module.css create mode 100644 src/features/Overview/SlotPerformance/useTileSparkline.ts create mode 100644 src/features/StartupProgress/Firedancer/Bars.tsx create mode 100644 src/features/StartupProgress/Firedancer/Body.tsx create mode 100644 src/features/StartupProgress/Firedancer/GossipProgress.tsx create mode 100644 src/features/StartupProgress/Firedancer/Header.tsx create mode 100644 src/features/StartupProgress/Firedancer/Logo.tsx create mode 100644 src/features/StartupProgress/Firedancer/ProgressBar.tsx create mode 100644 src/features/StartupProgress/Firedancer/SnapshotLoadingCard.tsx create mode 100644 src/features/StartupProgress/Firedancer/SnapshotProgress.tsx create mode 100644 src/features/StartupProgress/Firedancer/SnapshotSparklineCard.tsx create mode 100644 src/features/StartupProgress/Firedancer/bars.module.css create mode 100644 src/features/StartupProgress/Firedancer/body.module.css create mode 100644 src/features/StartupProgress/Firedancer/consts.ts create mode 100644 src/features/StartupProgress/Firedancer/gossip.module.css create mode 100644 src/features/StartupProgress/Firedancer/header.module.css create mode 100644 src/features/StartupProgress/Firedancer/logo.module.css create mode 100644 src/features/StartupProgress/Firedancer/progressBar.module.css create mode 100644 src/features/StartupProgress/Firedancer/snapshot.module.css diff --git a/index.html b/index.html index 90989213..9ed4f4ed 100644 --- a/index.html +++ b/index.html @@ -34,6 +34,12 @@ as="image" href="./src/assets/frankendancer_logo.svg" /> + Firedancer diff --git a/src/api/atoms.ts b/src/api/atoms.ts index 0c9d4a8b..650aaf74 100644 --- a/src/api/atoms.ts +++ b/src/api/atoms.ts @@ -23,6 +23,8 @@ import type { BlockEngineUpdate, VoteBalance, ScheduleStrategy, + BootProgress, + GossipNetworkStats, } from "./types"; import { rafAtom } from "../atomUtils"; @@ -74,8 +76,13 @@ export const liveTilePrimaryMetricAtom = atom< export const tileTimerAtom = atom(undefined); +export const bootProgressAtom = atom(undefined); export const startupProgressAtom = atom(undefined); +export const gossipNetworkStatsAtom = atom( + undefined, +); + export const tpsHistoryAtom = atom(undefined); export const voteStateAtom = atom(undefined); diff --git a/src/api/entities.ts b/src/api/entities.ts index c459fafd..eaca9514 100644 --- a/src/api/entities.ts +++ b/src/api/entities.ts @@ -11,6 +11,10 @@ const epochTopicSchema = z.object({ topic: z.literal("epoch"), }); +const gossipTopicSchema = z.object({ + topic: z.literal("gossip"), +}); + const peersTopicSchema = z.object({ topic: z.literal("peers"), }); @@ -26,6 +30,7 @@ const blockEngineTopicSchema = z.object({ export const topicSchema = z.discriminatedUnion("topic", [ summaryTopicSchema, epochTopicSchema, + gossipTopicSchema, peersTopicSchema, slotTopicSchema, blockEngineTopicSchema, @@ -71,6 +76,9 @@ export const tileTypeSchema = z.enum([ "plugin", "gui", "cswtch", + "snaprd", + "snapdc", + "snapin", ]); export const tileSchema = z.object({ @@ -198,8 +206,8 @@ export const startupProgressSchema = z.object({ downloading_full_snapshot_elapsed_secs: z.number().nullable(), downloading_full_snapshot_remaining_secs: z.number().nullable(), downloading_full_snapshot_throughput: z.number().nullable(), - downloading_full_snapshot_total_bytes: z.number().nullable(), - downloading_full_snapshot_current_bytes: z.number().nullable(), + downloading_full_snapshot_total_bytes: z.coerce.number().nullable(), + downloading_full_snapshot_current_bytes: z.coerce.number().nullable(), // downloading incremental snapshot downloading_incremental_snapshot_slot: z.number().nullable(), @@ -207,8 +215,8 @@ export const startupProgressSchema = z.object({ downloading_incremental_snapshot_elapsed_secs: z.number().nullable(), downloading_incremental_snapshot_remaining_secs: z.number().nullable(), downloading_incremental_snapshot_throughput: z.number().nullable(), - downloading_incremental_snapshot_total_bytes: z.number().nullable(), - downloading_incremental_snapshot_current_bytes: z.number().nullable(), + downloading_incremental_snapshot_total_bytes: z.coerce.number().nullable(), + downloading_incremental_snapshot_current_bytes: z.coerce.number().nullable(), // processing ledger ledger_slot: z.number().nullable(), @@ -219,6 +227,148 @@ export const startupProgressSchema = z.object({ waiting_for_supermajority_stake_percent: z.number().nullable(), }); +export const bootPhaseSchema = z.enum([ + "joining_gossip", + "loading_full_snapshot", + "loading_incr_snapshot", + "catching_up", + "running", +]); + +export const BootPhaseEnum = bootPhaseSchema.enum; + +export const bootProgressSchema = z.object({ + phase: bootPhaseSchema, + total_elapsed: z.number(), + + // joining_gossip + joining_gossip_elapsed: z.number().nullable(), + + // loading_full_snapshot + loading_full_snapshot_reset_cnt: z.number().nullable().optional(), + loading_full_snapshot_slot: z.number().nullable().optional(), + loading_full_snapshot_peer: z.string().nullable().optional(), + loading_full_snapshot_peer_identity: z.string().nullable().optional(), + loading_full_snapshot_total_bytes: z.coerce.number().nullable().optional(), + loading_full_snapshot_elapsed: z.number().nullable().optional(), + loading_full_snapshot_read_bytes: z.coerce.number().nullable().optional(), + loading_full_snapshot_read_throughput: z.number().nullable().optional(), + loading_full_snapshot_current_bytes: z.coerce.number().nullable().optional(), + loading_full_snapshot_read_remaining: z.number().nullable().optional(), + loading_full_snapshot_read_elapsed: z.number().nullable().optional(), + loading_full_snapshot_read_path: z.string().nullable().optional(), + loading_full_snapshot_decompress_bytes: z.number().nullable().optional(), + loading_full_snapshot_decompress_elapsed: z.number().nullable().optional(), + loading_full_snapshot_decompress_throughput: z.number().nullable().optional(), + loading_full_snapshot_decompress_compressed_bytes: z.coerce + .number() + .nullable() + .optional(), + loading_full_snapshot_decompress_decompressed_bytes: z.coerce + .number() + .nullable() + .optional(), + loading_full_snapshot_decompress_remaining: z.number().nullable().optional(), + loading_full_snapshot_insert_bytes: z.coerce.number().nullable().optional(), + loading_full_snapshot_insert_throughput: z.number().nullable().optional(), + loading_full_snapshot_insert_remaining: z.number().nullable().optional(), + loading_full_snapshot_insert_path: z.string().nullable().optional(), + loading_full_snapshot_insert_elapsed: z.number().nullable().optional(), + loading_full_snapshot_insert_accounts_throughput: z + .number() + .nullable() + .optional(), + loading_full_snapshot_insert_accounts_current: z + .number() + .nullable() + .optional(), + + // loading_incremental_snapshot + loading_incremental_snapshot_reset_cnt: z.number().nullable().optional(), + loading_incremental_snapshot_peer_identity: z.string().nullable().optional(), + loading_incremental_snapshot_slot: z.number().nullable().optional(), + loading_incremental_snapshot_peer: z.string().nullable().optional(), + loading_incremental_snapshot_total_bytes: z.coerce + .number() + .nullable() + .optional(), + loading_incremental_snapshot_elapsed: z.number().nullable().optional(), + loading_incremental_snapshot_read_bytes: z.coerce + .number() + .nullable() + .optional(), + loading_incremental_snapshot_read_elapsed: z.number().nullable().optional(), + loading_incremental_snapshot_read_throughput: z + .number() + .nullable() + .optional(), + loading_incremental_snapshot_read_remaining: z.number().nullable().optional(), + loading_incremental_snapshot_read_path: z.string().nullable().optional(), + loading_incremental_snapshot_current_bytes: z.coerce + .number() + .nullable() + .optional(), + loading_incremental_snapshot_decompress_bytes: z.coerce + .number() + .nullable() + .optional(), + + loading_incremental_snapshot_decompress_elapsed: z + .number() + .nullable() + .optional(), + loading_incremental_snapshot_decompress_throughput: z + .number() + .nullable() + .optional(), + loading_incremental_snapshot_decompress_compressed_bytes: z.coerce + .number() + .nullable() + .optional(), + loading_incremental_snapshot_decompress_decompressed_bytes: z.coerce + .number() + .nullable() + .optional(), + loading_incremental_snapshot_decompress_remaining: z + .number() + .nullable() + .optional(), + loading_incremental_snapshot_insert_bytes: z.coerce + .number() + .nullable() + .optional(), + loading_incremental_snapshot_insert_elapsed: z.number().nullable().optional(), + loading_incremental_snapshot_insert_throughput: z + .number() + .nullable() + .optional(), + loading_incremental_snapshot_insert_remaining: z + .number() + .nullable() + .optional(), + loading_incremental_snapshot_insert_path: z.string().nullable().optional(), + loading_incremental_snapshot_insert_accounts_throughput: z + .number() + .nullable() + .optional(), + loading_incremental_snapshot_insert_accounts_current: z + .number() + .nullable() + .optional(), + + // catching_up + catching_up_elapsed: z.number().nullable().optional(), + catching_up_min_turbine_slot: z.number().nullable().optional(), + catching_up_max_turbine_slot: z.number().nullable().optional(), + catching_up_min_repair_slot: z.number().nullable().optional(), + catching_up_max_repair_slot: z.number().nullable().optional(), + catching_up_max_replay_slot: z.number().nullable().optional(), + catching_up_first_turbine_slot: z.number().nullable().optional(), + catching_up_latest_turbine_slot: z.number().nullable().optional(), + catching_up_latest_repair_slot: z.number().nullable().optional(), + catching_up_latest_replay_slot: z.number().nullable().optional(), +}); + export const slotTransactionsSchema = z.object({ start_timestamp_nanos: z.coerce.bigint(), target_end_timestamp_nanos: z.coerce.bigint(), @@ -371,6 +521,10 @@ export const summarySchema = z.discriminatedUnion("key", [ key: z.literal("startup_progress"), value: startupProgressSchema, }), + summaryTopicSchema.extend({ + key: z.literal("boot_progress"), + value: bootProgressSchema, + }), summaryTopicSchema.extend({ key: z.literal("tps_history"), value: tpsHistorySchema, @@ -408,6 +562,43 @@ export const epochSchema = z.discriminatedUnion("key", [ }), ]); +const gossipNetworkHealthSchema = z.object({ + rx_push_pct: z.number().optional(), + duplicate_pct: z.number().optional(), + bad_pct: z.number().optional(), + pull_already_known_pct: z.number().optional(), + total_stake: z.coerce.bigint(), + total_peers: z.coerce.bigint(), + connected_stake: z.coerce.bigint(), + connected_peers: z.number(), +}); + +const gossipNetworkTrafficSchema = z.object({ + total_throughput: z.number().optional(), + peer_names: z.string().array(), + peer_throughputs: z.number().array().optional(), +}); + +const gossipStorageUtilSchema = z.object({ + total_bytes: z.coerce.number(), + peer_names: z.string().array(), + peer_bytes: z.number().array(), +}); + +export const gossipNetworkStatsSchema = z.object({ + health: gossipNetworkHealthSchema, + ingress: gossipNetworkTrafficSchema, + egress: gossipNetworkTrafficSchema, + storage: gossipStorageUtilSchema, +}); + +export const gossipSchema = z.discriminatedUnion("key", [ + gossipTopicSchema.extend({ + key: z.literal("network_stats"), + value: gossipNetworkStatsSchema, + }), +]); + const peerUpdateGossipSchema = z.object({ wallclock: z.number(), shred_version: z.number(), diff --git a/src/api/types.ts b/src/api/types.ts index 2117aada..bfae41ff 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -40,6 +40,9 @@ import type { slotTransactionsSchema, voteBalanceSchema, scheduleStrategySchema, + bootPhaseSchema, + bootProgressSchema, + gossipNetworkStatsSchema, } from "./entities"; export type Client = z.infer; @@ -98,10 +101,14 @@ export type TileType = z.infer; export type TileTimer = z.infer; +export type BootProgress = z.infer; + export type StartupProgress = z.infer; export type StartupPhase = z.infer; +export type BootPhase = z.infer; + export type TpsHistory = z.infer; export type VoteState = z.infer; @@ -114,6 +121,8 @@ export type Epoch = z.infer; export type SlotLevel = z.infer; +export type GossipNetworkStats = z.infer; + export interface Peer extends z.infer { removed?: boolean; } diff --git a/src/api/useSetAtomWsData.ts b/src/api/useSetAtomWsData.ts index 15540e4a..941fc17f 100644 --- a/src/api/useSetAtomWsData.ts +++ b/src/api/useSetAtomWsData.ts @@ -20,10 +20,13 @@ import { voteStateAtom, voteBalanceAtom, scheduleStrategyAtom, + bootProgressAtom, + gossipNetworkStatsAtom, } from "./atoms"; import { blockEngineSchema, epochSchema, + gossipSchema, peersSchema, slotSchema, summarySchema, @@ -120,6 +123,7 @@ export function useSetAtomWsData() { setTileTimer(value); }, tileTimerDebounceMs); + const setBootProgress = useSetAtom(bootProgressAtom); const setStartupProgress = useSetAtom(startupProgressAtom); const setTpsHistory = useSetAtom(tpsHistoryAtom); @@ -135,6 +139,8 @@ export function useSetAtomWsData() { const setSlotStatus = useSetAtom(setSlotStatusAtom); + const setGossipNetworkStats = useSetAtom(gossipNetworkStatsAtom); + const addPeers = useSetAtom(addPeersAtom); const updatePeers = useSetAtom(updatePeersAtom); const removePeers = useSetAtom(removePeersAtom); @@ -226,6 +232,10 @@ export function useSetAtomWsData() { setDbTileTimer(value); break; } + case "boot_progress": { + setBootProgress(value); + break; + } case "startup_progress": { setStartupProgress(value); break; @@ -260,6 +270,14 @@ export function useSetAtomWsData() { setEpoch(value); break; } + } else if (topic === "gossip") { + const { key, value } = gossipSchema.parse(msg); + switch (key) { + case "network_stats": { + setGossipNetworkStats(value); + break; + } + } } else if (topic === "peers") { const { value } = peersSchema.parse(msg); addPeers(value.add); diff --git a/src/assets/boot_progress_background.svg b/src/assets/boot_progress_background.svg new file mode 100644 index 00000000..dd712b41 --- /dev/null +++ b/src/assets/boot_progress_background.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/atoms.ts b/src/atoms.ts index 87fd2aa2..cc560c72 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -35,6 +35,7 @@ export const clientAtom = atom(() => { }); export const containerElAtom = atom(); +export const bootProgressContainerElAtom = atom(); const _epochsAtom = atomWithImmer([]); export const epochAtom = atom( diff --git a/src/colors.ts b/src/colors.ts index 73535e3e..5ad7ecad 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -3,6 +3,7 @@ export const failureColor = "#E5484D"; // radix red.9 export const nextColor = "#C567EA"; export const mySlotsColor = "#2A7EDF"; export const votesColor = "#557AE0"; +export const appTeal = "#1CE7C2"; export const primaryTextColor = "#B2BCC9"; export const secondaryTextColor = "#67696A"; @@ -17,13 +18,56 @@ export const rowSeparatorBackgroundColor = "#333333"; export const navButtonTextColor = "#F7F7F7"; // startup +export const startupBackgroundColor = "#03030C"; export const startupTextColor = "#A7A7A7"; export const startupProgressBackgroundColor = "#121213"; -export const startupProgressTealColor = "#1CE7C2"; +export const startupProgressTealColor = appTeal; export const startupCompleteStepColor = "#3BA158"; +// boot progress +export const bootProgressGossipBackgroundColor = "#030312"; +export const bootProgressFullSnapshotBackgroundColor = "#020C12"; +export const bootProgressIncrSnapshotBackgroundColor = "#120212"; +export const bootProgressCatchupBackgroundColor = "#120212"; + +export const bootProgressGossipBarsColor = "#252D2C"; +export const bootProgressGossipFilledBarColor = "#175E51"; +export const bootProgressGossipMidBarColor = "#2D2B25"; +export const bootProgressGossipMidFilledBarColor = "#6A510C"; +export const bootProgressGossipMidThresholdBarColor = "#E6B11E"; +export const bootProgressGossipHighBarColor = "#2D2525"; +export const bootProgressGossipHighFilledBarColor = "#5E1717"; +export const bootProgressGossipHighThresholdBarColor = "#CE3636"; + +export const bootProgressPrimaryTextColor = "#A2A2A2"; +export const bootProgressSecondaryTextColor = "#454545"; +export const bootProgressSnapshotPctColor = "#C8B3B3"; + +export const progressBarIncompleteGossipColor = "#171765ff"; +export const progressBarInProgressGossipBackground = `linear-gradient(270deg, #1414B8 -1.75%, #090952 101.75%)`; +export const progressBarInProgressGossipBorder = "#8F8FED"; +export const progressBarCompleteGossipColor = "#0E0E8E"; + +export const progressBarIncompleteFullSnapshotColor = "#0C171D"; +export const progressBarInProgressFullSnapshotBackground = `linear-gradient(270deg, #1481B8 0%, #093952 100%)`; +export const progressBarInProgressFullSnapshotBorder = "#47B4EB"; +export const progressBarCompleteFullSnapshotColor = "#0F3F57"; + +export const progressBarIncompleteIncSnapshotColor = "#150915"; +export const progressBarInProgressIncSnapshotBackground = `linear-gradient(270deg, #8B0E8B 0%, #250425 112.5%)`; +export const progressBarInProgressIncSnapshotBorder = "#C06AC0"; +export const progressBarCompleteIncSnapshotColor = "#570F57"; + +export const progressBarIncompleteCatchupColor = "#091515"; +export const progressBarInProgressCatchupBackground = `linear-gradient(270deg, ${appTeal} 0%, #0C6B5A 100%)`; +export const progressBarInProgressCatchupBorder = "#2EC9C9"; +export const progressBarCompleteCatchupColor = "#0C6B5A"; + +export const snapshotAreaChartDark = "rgba(16, 129, 108, 0.53)"; +export const snapshotAreaChartGridLineColor = "#ffffff1a"; + // cluster colors -export const clusterMainnetBetaColor = "#1CE7C2"; +export const clusterMainnetBetaColor = appTeal; export const clusterTestnetColor = "#E7B81C"; export const clusterDevelopmentColor = "#1C96E7"; export const clusterDevnetColor = "#E7601C"; @@ -56,6 +100,7 @@ export const transactionAxisTextColor = "#919191"; export const tileBusyGreenColor = "#55BA83"; export const tileBusyRedColor = "#D94343"; export const tileSparklineBackgroundColor = "#232A38"; +export const tileSparklineRangeTextColor = "#676767"; export const tileBackgroundRedColor = "#E13131"; export const tileBackgroundBlueColor = "#5F6FA9"; export const tileSubHeaderColor = "#A2A2A2"; diff --git a/src/consts.ts b/src/consts.ts index 9a993c07..70b0d4e2 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -5,3 +5,5 @@ export const lamportsPerSol = 1_000_000_000; /** Max compute units is dynamic and pulled from the server, * this default should only be used as a fallback */ export const defaultMaxComputeUnits = 50_000_000; + +export const appMaxWidth = "1920px"; diff --git a/src/features/Header/index.tsx b/src/features/Header/index.tsx index fe1e823f..720ade89 100644 --- a/src/features/Header/index.tsx +++ b/src/features/Header/index.tsx @@ -1,4 +1,4 @@ -import { Box, Flex } from "@radix-ui/themes"; +import { Box, Flex, IconButton } from "@radix-ui/themes"; import IdentityKey from "./IdentityKey"; import Cluster from "./Cluster"; import styles from "./header.module.css"; @@ -10,6 +10,14 @@ import CluserIndicator from "./ClusterIndicator"; import NavLinks from "./NavLinks"; import MenuNavLinks from "./MenuNavLinks"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + expandStartupProgressElAtom, + isStartupProgressExpandedAtom, + showStartupProgressAtom, +} from "../StartupProgress/atoms"; +import { TimerIcon } from "@radix-ui/react-icons"; + export default function Header() { // TODO move somehere it won't trigger re-renders const nav = useNavigateLeaderSlot(); @@ -38,10 +46,35 @@ export default function Header() { - + + + + + ); } + +function ExpandStartupProgressButton() { + const showStartupProgress = useAtomValue(showStartupProgressAtom); + const setIsStartupProgressExpanded = useSetAtom( + isStartupProgressExpandedAtom, + ); + const setExpandStartupProgressEl = useSetAtom(expandStartupProgressElAtom); + + if (!showStartupProgress) return null; + + return ( + setIsStartupProgressExpanded(true)} + > + + + ); +} diff --git a/src/features/Overview/SlotPerformance/TileBusy.tsx b/src/features/Overview/SlotPerformance/TileBusy.tsx index e4720585..495cf002 100644 --- a/src/features/Overview/SlotPerformance/TileBusy.tsx +++ b/src/features/Overview/SlotPerformance/TileBusy.tsx @@ -4,15 +4,16 @@ import { Flex, Text } from "@radix-ui/themes"; interface TileBusyProps { busy?: number; + className?: string; } -export default function TileBusy({ busy }: TileBusyProps) { +export default function TileBusy({ busy, className }: TileBusyProps) { const pct = busy !== undefined ? Math.trunc(busy * 100) : undefined; return ( (); - const selectedSlot = useAtomValue(selectedSlotAtom); - - const tileCountArr = useMemo( - () => new Array(tileCount).fill(0), - [tileCount], - ); - - const liveBusyPerTile = liveIdlePerTile - ?.filter((idle) => idle !== -1) - .map((idle) => 1 - idle); - - const aggQueryBusyPerTs = queryIdlePerTile - ?.map((idlePerTile) => { - const filtered = idlePerTile.filter((idle) => idle !== -1); - if (!filtered.length) return; - return 1 - mean(filtered); - }) - .filter(isDefined); - const aggQueryBusyPerTile = tileCountArr.map((_, i) => { - const queryIdle = queryIdlePerTile - ?.map((idlePerTile) => 1 - idlePerTile[i]) - .filter((b) => b !== undefined && b <= 1); - - if (!queryIdle?.length) return; - - return mean(queryIdle); - }); + const selectedSlot = useAtomValue(selectedSlotAtom); + const isLive = selectedSlot === undefined; - const busy = (!selectedSlot ? liveBusyPerTile : aggQueryBusyPerTile)?.filter( - (b) => b !== undefined && b <= 1, - ); - const avgBusy = busy?.length ? mean(busy) : undefined; + const { avgBusy, aggQueryBusyPerTs, tileCountArr, liveBusyPerTile, busy } = + useTileSparkline({ + isLive, + tileCount, + liveIdlePerTile, + queryIdlePerTile, + }); return ( diff --git a/src/features/Overview/SlotPerformance/TileSparkLine.tsx b/src/features/Overview/SlotPerformance/TileSparkLine.tsx index 357b2edb..44d5fabe 100644 --- a/src/features/Overview/SlotPerformance/TileSparkLine.tsx +++ b/src/features/Overview/SlotPerformance/TileSparkLine.tsx @@ -1,13 +1,14 @@ -import { useInterval, useMeasure } from "react-use"; -import { useMemo, useState } from "react"; -import { isDefined } from "../../../utils"; +import { useMeasure } from "react-use"; +import { useMemo } from "react"; import { tileBusyGreenColor, tileBusyRedColor, tileSparklineBackgroundColor, } from "../../../colors"; - -const dataCount = 160; +import type { UseMeasureRef } from "react-use/lib/useMeasure"; +import type { SparklineRange } from "./useTileSparkline"; +import { strokeLineWidth, useScaledDataPoints } from "./useTileSparkline"; +import styles from "./tileSparkline.module.css"; interface TileParkLineProps { value?: number; @@ -15,71 +16,101 @@ interface TileParkLineProps { } export default function TileSparkLine({ value, queryBusy }: TileParkLineProps) { - const [ref, { width, height }] = useMeasure(); - - const [busyData, setBusyData] = useState<(number | undefined)[]>([]); - - useInterval(() => { - if (queryBusy?.length) return; - - setBusyData((prev) => { - const newState = [...prev, value]; - if (newState.length >= dataCount) { - newState.shift(); - } - return newState; - }); - }, 10); - - const scaledDataPoints = useMemo(() => { - const data = queryBusy ?? busyData; - - const xRatio = width / data.length; + const [svgRef, { width }] = useMeasure(); + const height = 24; - return data - .map((d, i) => { - if (d === undefined) return; + const { scaledDataPoints, range } = useScaledDataPoints({ + value, + queryBusy, + rollingWindowMs: 1600, + height, + width, + updateIntervalMs: 10, + }); - return { - x: i * xRatio, - y: Math.trunc((1 - d) * height), - }; - }) - .filter(isDefined); - }, [queryBusy, busyData, width, height]); + return ( + + ); +} +interface SparklineProps { + svgRef: UseMeasureRef; + scaledDataPoints: { + x: number; + y: number; + }[]; + range: SparklineRange; + showRange?: boolean; + height: number; + background?: string; +} +export function Sparkline({ + svgRef, + scaledDataPoints, + range, + showRange = false, + height, + background = tileSparklineBackgroundColor, +}: SparklineProps) { const points = scaledDataPoints.map(({ x, y }) => `${x},${y}`).join(" "); + // where the gradient colors start / end, given y scale and offset + const gradientRange: SparklineRange = useMemo(() => { + const scale = range[1] - range[0]; + const gradientHeight = (height - strokeLineWidth * 2) / scale; + const top = gradientHeight * (range[1] - 1); + const bottom = top + gradientHeight; + return [bottom, top]; + }, [height, range]); + return ( - - + <> + + + + + + + + + + - - - - - - - + {showRange && ( + <> +
+ {Math.round(range[1] * 100)}% +
+
+ {Math.round(range[0] * 100)}% +
+ + )} + ); } diff --git a/src/features/Overview/SlotPerformance/atoms.ts b/src/features/Overview/SlotPerformance/atoms.ts index 8b5d1c55..05e893cc 100644 --- a/src/features/Overview/SlotPerformance/atoms.ts +++ b/src/features/Overview/SlotPerformance/atoms.ts @@ -4,10 +4,11 @@ import { tilesAtom, tileTimerAtom, } from "../../../api/atoms"; -import type { TxnWaterfall } from "../../../api/types"; +import type { TileType, TxnWaterfall } from "../../../api/types"; import { atomWithImmer } from "jotai-immer"; import { produce } from "immer"; import { countBy } from "lodash"; +import { tileTypeSchema } from "../../../api/entities"; // Note: do not user setter directly as it's derived from search params export const selectedSlotAtom = atom(); @@ -27,6 +28,46 @@ export const liveTileTimerfallAtom = atom((get) => { return get(tileTimerAtom); }); +export const snapshotTimerIndicesAtom = atom( + (get): [TileType, number[]][] | undefined => { + const tiles = get(tilesAtom); + const tileTypes: TileType[] = ["snaprd", "snapdc", "snapin"]; + + if (!tiles) return; + + const grouped = tiles.reduce((acc, tile, i) => { + const parsedTileKind = tileTypeSchema.safeParse(tile.kind); + if (parsedTileKind.error || !tileTypes.includes(parsedTileKind.data)) { + return acc; + } + + const indices = acc.get(parsedTileKind.data) ?? []; + indices.push(i); + acc.set(parsedTileKind.data, indices); + return acc; + }, new Map()); + + return Array.from(grouped.entries()).map<[TileType, number[]]>( + ([type, indices]) => [type, indices], + ); + }, +); + +export const liveSnapshotTimersAtom = atom((get) => { + const timers = get(tileTimerAtom); + const snapshotTimerIndices = get(snapshotTimerIndicesAtom); + + if (!timers || !snapshotTimerIndices) return; + + return snapshotTimerIndices.reduce( + (acc, [tileType, indices]) => { + acc[tileType] = indices.map((i) => timers[i]); + return acc; + }, + {} as Partial>, + ); +}); + export const liveWaterfallAtom = atom((get) => { const selectedSlot = get(selectedSlotAtom); if (selectedSlot) return; diff --git a/src/features/Overview/SlotPerformance/tileSparkline.module.css b/src/features/Overview/SlotPerformance/tileSparkline.module.css new file mode 100644 index 00000000..ba654e37 --- /dev/null +++ b/src/features/Overview/SlotPerformance/tileSparkline.module.css @@ -0,0 +1,7 @@ +.range-label { + position: absolute; + right: 2px; + font-size: 10px; + font-weight: 400; + color: var(--tile-sparkline-range-text-color); +} diff --git a/src/features/Overview/SlotPerformance/useTileSparkline.ts b/src/features/Overview/SlotPerformance/useTileSparkline.ts new file mode 100644 index 00000000..ee2d14a2 --- /dev/null +++ b/src/features/Overview/SlotPerformance/useTileSparkline.ts @@ -0,0 +1,126 @@ +import { mean } from "lodash"; +import { useMemo, useState } from "react"; +import { useInterval } from "react-use"; + +export const strokeLineWidth = 2; + +interface UseTileSparklineProps { + isLive: boolean; + tileCount: number; + liveIdlePerTile?: number[]; + queryIdlePerTile?: number[][]; +} +export function useTileSparkline({ + isLive, + tileCount, + liveIdlePerTile, + queryIdlePerTile, +}: UseTileSparklineProps) { + const tileCountArr = useMemo( + () => new Array(tileCount).fill(0), + [tileCount], + ); + + const liveBusyPerTile = liveIdlePerTile + ?.filter((idle) => idle !== -1) + .map((idle) => 1 - idle); + + const aggQueryBusyPerTs = queryIdlePerTile + ?.map((idlePerTile) => { + const filtered = idlePerTile.filter((idle) => idle !== -1); + if (!filtered.length) return; + return 1 - mean(filtered); + }) + .filter((v) => v !== undefined); + + const aggQueryBusyPerTile = tileCountArr.map((_, i) => { + const queryIdle = queryIdlePerTile + ?.map((idlePerTile) => 1 - idlePerTile[i]) + .filter((b) => b !== undefined && b <= 1); + + if (!queryIdle?.length) return; + + return mean(queryIdle); + }); + + const busy = (isLive ? liveBusyPerTile : aggQueryBusyPerTile)?.filter( + (b) => b !== undefined && b <= 1, + ); + const avgBusy = busy?.length ? mean(busy) : undefined; + + return { + avgBusy, + aggQueryBusyPerTs, + tileCountArr, + liveBusyPerTile, + busy, + }; +} + +export type SparklineRange = [number, number]; +const range: SparklineRange = [0, 1]; + +interface UseScaledDataPointsProps { + value?: number; + queryBusy?: number[]; + rollingWindowMs: number; + height: number; + width: number; + updateIntervalMs: number; + stopShifting?: boolean; +} +export function useScaledDataPoints({ + value, + queryBusy, + rollingWindowMs, + height, + width, + updateIntervalMs, + stopShifting, +}: UseScaledDataPointsProps): { + scaledDataPoints: { + x: number; + y: number; + }[]; + range: SparklineRange; +} { + const [busyData, setBusyData] = useState<(number | undefined)[]>([]); + + useInterval(() => { + if (stopShifting || queryBusy?.length) return; + + setBusyData((prev) => { + const newState = [...prev, value]; + if (newState.length >= Math.trunc(rollingWindowMs / updateIntervalMs)) { + newState.shift(); + } + return newState; + }); + }, updateIntervalMs); + + const scaledDataPoints = useMemo((): { x: number; y: number }[] => { + const data = queryBusy ?? busyData; + + // include all points in x spacing + const xRatio = width / data.length; + + return data.reduce( + (acc, d, i) => { + if (d === undefined) return acc; + + acc.push({ + x: i * xRatio, + // make space for full line width on top and bottom edges + y: (1 - d) * (height - strokeLineWidth) + strokeLineWidth / 2, + }); + return acc; + }, + [] as { x: number; y: number }[], + ); + }, [queryBusy, busyData, width, height]); + + return { + scaledDataPoints, + range, + }; +} diff --git a/src/features/StartupProgress/Body.tsx b/src/features/StartupProgress/Body.tsx index c1b8135c..33aee61c 100644 --- a/src/features/StartupProgress/Body.tsx +++ b/src/features/StartupProgress/Body.tsx @@ -8,7 +8,6 @@ import fdLogo from "../../assets/firedancer.svg"; import frLogo from "../../assets/frankendancer.svg"; import { Box, Flex } from "@radix-ui/themes"; import type { StartupPhase } from "../../api/types"; -import { isDefined } from "../../utils"; import IncompleteStep from "./IncompleteStep"; import InprogressStep from "./InprogressStep"; import CompleteStep from "./CompleteStep"; @@ -160,7 +159,7 @@ export default function Body() { function getLabel(step: string) { return step .split("_") - .filter(isDefined) + .filter((v) => v !== undefined) .map((split, i) => { if (split === "for") return split; if (split === "rpc") return "RPC"; diff --git a/src/features/StartupProgress/Firedancer/Bars.tsx b/src/features/StartupProgress/Firedancer/Bars.tsx new file mode 100644 index 00000000..86799bcc --- /dev/null +++ b/src/features/StartupProgress/Firedancer/Bars.tsx @@ -0,0 +1,61 @@ +import { Flex, Text } from "@radix-ui/themes"; + +import styles from "./bars.module.css"; +import clsx from "clsx"; +import { useMeasure } from "react-use"; + +const barWidth = 4; +const barGap = barWidth * 1.5; +const viewBoxHeight = 1000; + +interface BarsProps { + title?: string; + value: number; + max: number; +} +export function Bars({ title, value, max }: BarsProps) { + const [ref, { width }] = useMeasure(); + + const barCount = Math.trunc(width / (barWidth + barGap)); + + // range from -1 to bar count - 1 + const currentIndex = max + ? Math.min(Math.round((value / max) * barCount) - 1, barCount - 1) + : -1; + + const usedWidth = barCount * (barWidth + barGap); + + return ( + + {title && {title}} + + {Array.from({ length: barCount }, (_, i) => { + const isHigh = i >= barCount * 0.95; + const isMid = !isHigh && i >= barCount * 0.85; + + return ( + + ); + })} + + + ); +} diff --git a/src/features/StartupProgress/Firedancer/Body.tsx b/src/features/StartupProgress/Firedancer/Body.tsx new file mode 100644 index 00000000..7458af52 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/Body.tsx @@ -0,0 +1,100 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { bootProgressAtom } from "../../../api/atoms"; +import styles from "./body.module.css"; +import { useEffect } from "react"; +import { + bootProgressPhaseAtom, + isStartupProgressExpandedAtom, + showStartupProgressAtom, +} from "../atoms"; +import { Box, Container, Flex, Text } from "@radix-ui/themes"; +import clsx from "clsx"; +import { ProgressBar } from "./ProgressBar"; +import { Header } from "./Header"; +import { BootPhaseEnum } from "../../../api/entities"; +import { formatDuration } from "../../../utils"; +import { bootProgressContainerElAtom } from "../../../atoms"; +import { GossipProgress } from "./GossipProgress"; +import { steps } from "./consts"; +import type { BootPhase } from "../../../api/types"; +import Logo from "./Logo"; +import { appMaxWidth } from "../../../consts"; +import { SnapshotProgress } from "./SnapshotProgress"; + +const classNames: { [phase in BootPhase]?: string } = { + [BootPhaseEnum.joining_gossip]: styles.gossip, + [BootPhaseEnum.loading_full_snapshot]: styles.fullSnapshot, + [BootPhaseEnum.loading_incr_snapshot]: styles.incrSnapshot, + [BootPhaseEnum.catching_up]: styles.catchingUp, +}; + +export default function Body() { + const setShowStartupProgress = useSetAtom(showStartupProgressAtom); + const phase = useAtomValue(bootProgressPhaseAtom); + + // close startup when complete + useEffect(() => { + if (phase === "running") { + setShowStartupProgress(false); + } + }, [setShowStartupProgress, phase]); + + return ( + <> + {phase && } + + + ); +} + +interface BootProgressContentProps { + phase: BootPhase; +} +function BootProgressContent({ phase }: BootProgressContentProps) { + const setBootProgressContainerEl = useSetAtom(bootProgressContainerElAtom); + const showStartupProgress = useAtomValue(showStartupProgressAtom); + const isStartupProgressExpanded = useAtomValue(isStartupProgressExpandedAtom); + + const phaseClass = phase ? classNames[phase] : ""; + const step = steps[phase]; + + return ( + setBootProgressContainerEl(el)} + maxWidth={appMaxWidth} + className={clsx(styles.container, phaseClass, { + [styles.collapsed]: !showStartupProgress || !isStartupProgressExpanded, + })} + p="4" + > +
+ + + Elapsed{" "} + + + {step.name} + + 79% + Complete + + + + + + + {phase === BootPhaseEnum.joining_gossip && } + {(phase === BootPhaseEnum.loading_full_snapshot || + phase === BootPhaseEnum.loading_incr_snapshot) && ( + + )} + + + ); +} + +function TotalDuration() { + const totalElapsed = useAtomValue(bootProgressAtom)?.total_elapsed; + const duration = totalElapsed == null ? "--" : formatDuration(totalElapsed); + return {duration}; +} diff --git a/src/features/StartupProgress/Firedancer/GossipProgress.tsx b/src/features/StartupProgress/Firedancer/GossipProgress.tsx new file mode 100644 index 00000000..e493a96d --- /dev/null +++ b/src/features/StartupProgress/Firedancer/GossipProgress.tsx @@ -0,0 +1,36 @@ +import { Card, Flex, Text } from "@radix-ui/themes"; + +import styles from "./gossip.module.css"; +import { Bars } from "./Bars"; + +export function GossipProgress() { + // TODO: use atom + return ( + + + + + + + + + + + + Stake Discovered + + ); +} + +interface GossipCardProps { + title: string; + value: number; +} +function GossipCard({ title, value }: GossipCardProps) { + return ( + + {title} + {value} + + ); +} diff --git a/src/features/StartupProgress/Firedancer/Header.tsx b/src/features/StartupProgress/Firedancer/Header.tsx new file mode 100644 index 00000000..a7004c5a --- /dev/null +++ b/src/features/StartupProgress/Firedancer/Header.tsx @@ -0,0 +1,80 @@ +import { Cross1Icon } from "@radix-ui/react-icons"; +import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; +import fdLogo from "../../../assets/firedancer_logo.svg"; +import Cluster from "../../Header/Cluster"; +import { + isStartupProgressExpandedAtom, + expandStartupProgressElAtom, +} from "../atoms"; +import styles from "./header.module.css"; +import { useSetAtom, useAtomValue } from "jotai"; +import PeerIcon from "../../../components/PeerIcon"; +import { useIdentityPeer } from "../../../hooks/useIdentityPeer"; +import { bootProgressContainerElAtom } from "../../../atoms"; +import { useCallback } from "react"; + +// TODO update with newer header styles +export function Header() { + return ( + + + fd + + + + + + + + + ); +} + +function IdentityKey() { + const { peer, identityKey } = useIdentityPeer(); + return ( + + + + {identityKey} + + + ); +} + +function CollpaseButton() { + const setIsStartupProgressExpanded = useSetAtom( + isStartupProgressExpandedAtom, + ); + const expandStartupProgressEl = useAtomValue(expandStartupProgressElAtom); + + const containerEl = useAtomValue(bootProgressContainerElAtom); + const onClick = useCallback(() => { + if (!expandStartupProgressEl || !containerEl) return; + + const { bottom, left, width, height } = + expandStartupProgressEl.getBoundingClientRect(); + + containerEl.style.setProperty( + "--transform-origin", + `${Math.round(left + width / 2)}px ${Math.round(bottom - height / 2)}px`, + ); + setIsStartupProgressExpanded(false); + }, [containerEl, expandStartupProgressEl, setIsStartupProgressExpanded]); + + return ( + + + + ); +} diff --git a/src/features/StartupProgress/Firedancer/Logo.tsx b/src/features/StartupProgress/Firedancer/Logo.tsx new file mode 100644 index 00000000..c6b368c9 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/Logo.tsx @@ -0,0 +1,26 @@ +import { Flex } from "@radix-ui/themes"; +import clsx from "clsx"; +import { useAtomValue } from "jotai"; +import { useState } from "react"; +import styles from "./logo.module.css"; +import fdLogo from "../../../assets/firedancer.svg"; +import { bootProgressPhaseAtom } from "../atoms"; + +export default function Logo() { + const phase = useAtomValue(bootProgressPhaseAtom); + const [showInitialLogo, setShowInitialLogo] = useState(true); + + if (phase && showInitialLogo) { + setShowInitialLogo(false); + } + + return ( + + fd + + ); +} diff --git a/src/features/StartupProgress/Firedancer/ProgressBar.tsx b/src/features/StartupProgress/Firedancer/ProgressBar.tsx new file mode 100644 index 00000000..b0ec79ac --- /dev/null +++ b/src/features/StartupProgress/Firedancer/ProgressBar.tsx @@ -0,0 +1,66 @@ +import { Flex } from "@radix-ui/themes"; +import { useAtomValue } from "jotai"; +import { bootProgressBarPctAtom } from "../atoms"; +import styles from "./progressBar.module.css"; +import { steps } from "./consts"; + +interface ProgressBarProps { + stepIndex: number; +} + +export function ProgressBar({ stepIndex }: ProgressBarProps) { + const pctComplete = useAtomValue(bootProgressBarPctAtom); + + return ( + + {Object.values(steps).map( + ( + { + name, + estimatedPct, + completeColor, + inProgressBackground, + incompleteColor, + borderColor, + }, + i, + ) => { + const width = `${estimatedPct * 100}%`; + + if (i === stepIndex) { + return ( +
+
+
+ ); + } + + const isComplete = i < stepIndex; + return ( +
+ ); + }, + )} + + ); +} diff --git a/src/features/StartupProgress/Firedancer/SnapshotLoadingCard.tsx b/src/features/StartupProgress/Firedancer/SnapshotLoadingCard.tsx new file mode 100644 index 00000000..f96ec470 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/SnapshotLoadingCard.tsx @@ -0,0 +1,85 @@ +import { Card, Flex, Text } from "@radix-ui/themes"; +import styles from "./snapshot.module.css"; +import type { ByteSizeResult } from "byte-size"; +import byteSize from "byte-size"; +import clsx from "clsx"; +import { Bars } from "./Bars"; + +const MAX_THROUGHPUT = 300000000; + +interface SnapshotLoadingCardProps { + title: string; + estimatedRemaining?: number | null; + throughput?: number | null; + completed?: number | null; + total?: number | null; +} +export function SnapshotLoadingCard({ + title, + throughput, + completed, + total, +}: SnapshotLoadingCardProps) { + const throughputObj = throughput == null ? undefined : byteSize(throughput); + const completedObj = completed == null ? undefined : byteSize(completed); + const totalObj = total == null ? undefined : byteSize(total); + + return ( + + + + {title} + +
+ + + / + + +
+ + {throughputObj ? ( + + + {throughputObj.value} + {" "} + + {throughputObj.unit}/sec + + + ) : ( + + -- + + )} +
+ + +
+
+ ); +} + +function ValueUnitText({ + byteSizeResult, + unitSuffix, +}: { + byteSizeResult?: ByteSizeResult; + unitSuffix?: string; +}) { + return byteSizeResult ? ( + <> + {byteSizeResult.value}{" "} + + {byteSizeResult.unit} + {unitSuffix} + + + ) : ( + "--" + ); +} diff --git a/src/features/StartupProgress/Firedancer/SnapshotProgress.tsx b/src/features/StartupProgress/Firedancer/SnapshotProgress.tsx new file mode 100644 index 00000000..d973dd66 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/SnapshotProgress.tsx @@ -0,0 +1,157 @@ +import { Flex } from "@radix-ui/themes"; +import { SnapshotLoadingCard } from "./SnapshotLoadingCard"; +import { bootProgressAtom } from "../../../api/atoms"; +import { useAtomValue } from "jotai"; +import SnapshotSparklineCard from "./SnapshotSparklineCard"; +import { BootPhaseEnum } from "../../../api/entities"; +import type { BootProgress } from "../../../api/types"; + +const rowGap = "5"; + +function getSnapshotValues(bootProgress: BootProgress) { + const { + loading_full_snapshot_total_bytes, + loading_full_snapshot_read_remaining, + loading_full_snapshot_read_bytes, + loading_full_snapshot_read_throughput, + + loading_full_snapshot_decompress_remaining, + loading_full_snapshot_decompress_throughput, + loading_full_snapshot_decompress_compressed_bytes, + loading_full_snapshot_decompress_decompressed_bytes, + + loading_full_snapshot_insert_remaining, + loading_full_snapshot_insert_bytes, + loading_full_snapshot_insert_throughput, + + loading_incremental_snapshot_total_bytes, + loading_incremental_snapshot_read_remaining, + loading_incremental_snapshot_read_bytes, + loading_incremental_snapshot_read_throughput, + + loading_incremental_snapshot_decompress_remaining, + loading_incremental_snapshot_decompress_throughput, + loading_incremental_snapshot_decompress_compressed_bytes, + loading_incremental_snapshot_decompress_decompressed_bytes, + + loading_incremental_snapshot_insert_remaining, + loading_incremental_snapshot_insert_bytes, + loading_incremental_snapshot_insert_throughput, + } = bootProgress; + + if ( + bootProgress.phase === BootPhaseEnum.loading_full_snapshot || + !loading_incremental_snapshot_total_bytes + ) { + return { + total_bytes: loading_full_snapshot_total_bytes, + read_remaining: loading_full_snapshot_read_remaining, + read_bytes: loading_full_snapshot_read_bytes, + read_throughput: loading_full_snapshot_read_throughput, + decompress_remaining: loading_full_snapshot_decompress_remaining, + decompress_throughput: loading_full_snapshot_decompress_throughput, + decompress_compressed_bytes: + loading_full_snapshot_decompress_compressed_bytes, + decompress_decompressed_bytes: + loading_full_snapshot_decompress_decompressed_bytes, + insert_remaining: loading_full_snapshot_insert_remaining, + insert_bytes: loading_full_snapshot_insert_bytes, + insert_throughput: loading_full_snapshot_insert_throughput, + }; + } + + return { + total_bytes: loading_incremental_snapshot_total_bytes, + read_remaining: loading_incremental_snapshot_read_remaining, + read_bytes: loading_incremental_snapshot_read_bytes, + read_throughput: loading_incremental_snapshot_read_throughput, + decompress_remaining: loading_incremental_snapshot_decompress_remaining, + decompress_throughput: loading_incremental_snapshot_decompress_throughput, + decompress_compressed_bytes: + loading_incremental_snapshot_decompress_compressed_bytes, + decompress_decompressed_bytes: + loading_incremental_snapshot_decompress_decompressed_bytes, + insert_remaining: loading_incremental_snapshot_insert_remaining, + insert_bytes: loading_incremental_snapshot_insert_bytes, + insert_throughput: loading_incremental_snapshot_insert_throughput, + }; +} + +export function SnapshotProgress() { + const bootProgress = useAtomValue(bootProgressAtom); + if (!bootProgress) return; + + const { + total_bytes, + read_remaining, + read_bytes, + read_throughput, + decompress_remaining, + decompress_throughput, + decompress_compressed_bytes, + decompress_decompressed_bytes, + insert_remaining, + insert_bytes, + insert_throughput, + } = getSnapshotValues(bootProgress); + + const insertCompletedBytes = + insert_bytes && decompress_compressed_bytes && decompress_decompressed_bytes + ? insert_bytes * + (decompress_compressed_bytes / decompress_decompressed_bytes) + : 0; + + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/src/features/StartupProgress/Firedancer/SnapshotSparklineCard.tsx b/src/features/StartupProgress/Firedancer/SnapshotSparklineCard.tsx new file mode 100644 index 00000000..419afb81 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/SnapshotSparklineCard.tsx @@ -0,0 +1,80 @@ +import { Card, Flex, Text } from "@radix-ui/themes"; +import { Sparkline } from "../../Overview/SlotPerformance/TileSparkLine"; +import styles from "./snapshot.module.css"; +import clsx from "clsx"; +import { useAtomValue } from "jotai"; +import { + liveSnapshotTimersAtom, + tileCountAtom, +} from "../../Overview/SlotPerformance/atoms"; +import { + useScaledDataPoints, + useTileSparkline, +} from "../../Overview/SlotPerformance/useTileSparkline"; +import type { TileType } from "../../../api/types"; +import TileBusy from "../../Overview/SlotPerformance/TileBusy"; +import { useMeasure } from "react-use"; + +const gridSize = 20; +// add 1 px for the final grid line +const height = gridSize * 5 + 1; +const width = gridSize * 15 + 1; + +const rollingWindowMs = 6000; +const updateIntervalMs = 10; + +interface SnapshotSparklineCardProps { + title: string; + tileType: TileType; + isComplete?: boolean; +} +export default function SnapshotSparklineCard({ + title, + tileType, + isComplete, +}: SnapshotSparklineCardProps) { + const tileCounts = useAtomValue(tileCountAtom); + const timers = useAtomValue(liveSnapshotTimersAtom); + const [svgRef] = useMeasure(); + + const { avgBusy } = useTileSparkline({ + isLive: true, + tileCount: tileCounts[tileType], + liveIdlePerTile: timers?.[tileType], + }); + + const { scaledDataPoints, range } = useScaledDataPoints({ + value: avgBusy, + rollingWindowMs, + height, + width, + updateIntervalMs, + stopShifting: isComplete, + }); + + return ( + + + {title} + + + + + + + + ); +} diff --git a/src/features/StartupProgress/Firedancer/bars.module.css b/src/features/StartupProgress/Firedancer/bars.module.css new file mode 100644 index 00000000..b8197a80 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/bars.module.css @@ -0,0 +1,49 @@ +.bars-container { + color: var(--boot-progress-primary-text-color); + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: normal; + + .title { + font-size: 28px; + font-style: normal; + font-weight: 400; + line-height: normal; + } + + svg { + width: 100%; + height: 77px; + + rect { + fill: var(--boot-progress-gossip-bars-color); + &.threshold { + fill: var(--app-teal); + } + &.filled { + fill: var(--boot-progress-gossip-filled-bar-color); + } + + &.mid { + fill: var(--boot-progress-gossip-mid-bar-color); + &.filled { + fill: var(--boot-progress-gossip-mid-filled-bar-color); + } + &.threshold { + fill: var(--boot-progress-gossip-mid-threshold-bar-color); + } + } + + &.high { + fill: var(--boot-progress-gossip-high-bar-color); + &.filled { + fill: var(--boot-progress-gossip-high-filled-bar-color); + } + &.threshold { + fill: var(--boot-progress-gossip-high-threshold-bar-color); + } + } + } + } +} diff --git a/src/features/StartupProgress/Firedancer/body.module.css b/src/features/StartupProgress/Firedancer/body.module.css new file mode 100644 index 00000000..359ffbfd --- /dev/null +++ b/src/features/StartupProgress/Firedancer/body.module.css @@ -0,0 +1,61 @@ +.container { + --collapse-duration: 0.3s; + --collapse-location-time: 0.2s; + + --transform-origin: top right; + + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 20; + + display: flex; + flex-direction: column; + overflow: auto; + background-color: var(--startup-background-color); + + transform-origin: var(--transform-origin); + transition: + opacity var(--collapse-duration) linear, + transform var(--collapse-duration) linear, + background-color 0.2s linear; + + &.collapsed { + transform: scale(0); + opacity: 0.5; + overflow: hidden; + } + + &.gossip { + background-color: var(--boot-progress-gossip-background-color); + } + + &.full-snapshot { + background-color: var(--boot-progress-full-snapshot-background-color); + } + + &.incr-snapshot { + background-color: var(--boot-progress-incr-snapshot-background-color); + } + + &.catchup { + background-color: var(--boot-progress-catchup-background-color); + } +} + +.secondary-text { + color: var(--boot-progress-secondary-text-color); +} + +.step-container { + color: var(--boot-progress-primary-text-color); + font-size: 28px; + font-weight: 400; + line-height: normal; + + .step-name { + font-weight: 700; + } +} diff --git a/src/features/StartupProgress/Firedancer/consts.ts b/src/features/StartupProgress/Firedancer/consts.ts new file mode 100644 index 00000000..c31606da --- /dev/null +++ b/src/features/StartupProgress/Firedancer/consts.ts @@ -0,0 +1,91 @@ +import { BootPhaseEnum } from "../../../api/entities"; +import type { BootPhase } from "../../../api/types"; +import { + progressBarIncompleteGossipColor, + progressBarInProgressGossipBackground, + progressBarCompleteGossipColor, + progressBarIncompleteFullSnapshotColor, + progressBarInProgressFullSnapshotBackground, + progressBarCompleteFullSnapshotColor, + progressBarIncompleteIncSnapshotColor, + progressBarInProgressIncSnapshotBackground, + progressBarCompleteIncSnapshotColor, + progressBarIncompleteCatchupColor, + progressBarInProgressCatchupBackground, + progressBarCompleteCatchupColor, + progressBarInProgressCatchupBorder, + progressBarInProgressFullSnapshotBorder, + progressBarInProgressGossipBorder, + progressBarInProgressIncSnapshotBorder, +} from "../../../colors"; + +interface PhaseInfo { + name: string; + incompleteColor: string; + inProgressBackground: string; + completeColor: string; + estimatedPct: number; + borderColor: string; +} + +const phases: { + [phase in BootPhase]: PhaseInfo; +} = { + [BootPhaseEnum.joining_gossip]: { + name: "Joining Gossip ...", + incompleteColor: progressBarIncompleteGossipColor, + inProgressBackground: progressBarInProgressGossipBackground, + completeColor: progressBarCompleteGossipColor, + estimatedPct: 0.2, + borderColor: progressBarInProgressGossipBorder, + }, + [BootPhaseEnum.loading_full_snapshot]: { + name: "Loading Full Snapshot ...", + incompleteColor: progressBarIncompleteFullSnapshotColor, + inProgressBackground: progressBarInProgressFullSnapshotBackground, + completeColor: progressBarCompleteFullSnapshotColor, + estimatedPct: 0.5, + borderColor: progressBarInProgressFullSnapshotBorder, + }, + [BootPhaseEnum.loading_incr_snapshot]: { + name: "Loading Incremental Snapshot ...", + incompleteColor: progressBarIncompleteIncSnapshotColor, + inProgressBackground: progressBarInProgressIncSnapshotBackground, + completeColor: progressBarCompleteIncSnapshotColor, + estimatedPct: 0.05, + borderColor: progressBarInProgressIncSnapshotBorder, + }, + [BootPhaseEnum.catching_up]: { + name: "Catching Up ...", + incompleteColor: progressBarIncompleteCatchupColor, + inProgressBackground: progressBarInProgressCatchupBackground, + completeColor: progressBarCompleteCatchupColor, + estimatedPct: 0.25, + borderColor: progressBarInProgressCatchupBorder, + }, + [BootPhaseEnum.running]: { + name: "Running ...", + incompleteColor: "", + inProgressBackground: "", + completeColor: "", + estimatedPct: 0, + borderColor: "transparent", + }, +}; + +interface Step extends PhaseInfo { + index: number; +} + +export const steps = Object.entries(phases).reduce( + (acc, [key, value], i) => { + acc[key as BootPhase] = { + ...value, + index: i, + }; + return acc; + }, + {} as { + [phase in BootPhase]: Step; + }, +); diff --git a/src/features/StartupProgress/Firedancer/gossip.module.css b/src/features/StartupProgress/Firedancer/gossip.module.css new file mode 100644 index 00000000..1c63292b --- /dev/null +++ b/src/features/StartupProgress/Firedancer/gossip.module.css @@ -0,0 +1,24 @@ +.card { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 10px; + height: 172px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + color: var(--boot-progress-primary-text-color); + padding: 20px; + + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: normal; + + .value { + color: var(--boot-progress-snapshot-pct-color); + font-size: 36px; + font-style: normal; + font-weight: 400; + line-height: normal; + } +} diff --git a/src/features/StartupProgress/Firedancer/header.module.css b/src/features/StartupProgress/Firedancer/header.module.css new file mode 100644 index 00000000..da590892 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/header.module.css @@ -0,0 +1,17 @@ +.header-container { + width: 100%; +} + +.identity-key-container { + flex-shrink: 1; + flex-wrap: nowrap; + overflow: hidden; + + .identity-key-text { + color: var(--boot-progress-primary-text-color); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + flex-shrink: 1; + } +} diff --git a/src/features/StartupProgress/Firedancer/logo.module.css b/src/features/StartupProgress/Firedancer/logo.module.css new file mode 100644 index 00000000..b39a35f9 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/logo.module.css @@ -0,0 +1,35 @@ +.logo-container { + --logo-transition-time: 1.5s; + z-index: 20; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + justify-content: center; + align-items: center; + background-color: var(--startup-background-color); + background-image: + url("../../../assets/boot_progress_background.svg"), + radial-gradient( + 160.38% 98.82% at 50% 50%, + rgba(28, 231, 194, 0.16) 0%, + rgba(28, 231, 194, 0.01) 41.16%, + rgba(28, 231, 194, 0) 100% + ); + + background-position: center; + background-repeat: repeat; + + transition: opacity var(--logo-transition-time) linear; + + img { + height: 76px; + } + + &.hidden { + opacity: 0; + pointer-events: none; + user-select: none; + } +} diff --git a/src/features/StartupProgress/Firedancer/progressBar.module.css b/src/features/StartupProgress/Firedancer/progressBar.module.css new file mode 100644 index 00000000..edf37c86 --- /dev/null +++ b/src/features/StartupProgress/Firedancer/progressBar.module.css @@ -0,0 +1,24 @@ +.progress-bar { + height: 25px; + align-items: center; + width: 100%; + + .current-step { + height: 100%; + border-width: 1px; + border-style: solid; + border-radius: 5px; + overflow: hidden; + + .progressing-bar { + width: 100%; + height: 100%; + transform-origin: left; + transition: transform 0.2s linear; + } + } + + div { + height: 50%; + } +} diff --git a/src/features/StartupProgress/Firedancer/snapshot.module.css b/src/features/StartupProgress/Firedancer/snapshot.module.css new file mode 100644 index 00000000..a4e7daab --- /dev/null +++ b/src/features/StartupProgress/Firedancer/snapshot.module.css @@ -0,0 +1,90 @@ +.secondary-color { + color: var(--boot-progress-secondary-text-color); +} + +.card { + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + color: var(--boot-progress-primary-text-color); +} + +.sparkline-card { + display: flex; + flex-direction: column; + padding: 20px; + gap: 20px; + flex-shrink: 0; +} + +.sparkline-container { + position: relative; + flex-shrink: 0; + padding: "10px 0"; + + background-image: + linear-gradient( + to right, + var(--snapshot-area-chart-grid-line-color) 1px, + transparent 1px + ), + linear-gradient( + to bottom, + var(--snapshot-area-chart-grid-line-color) 1px, + transparent 1px + ); +} + +.card-header { + font-size: 28px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.loading-card { + flex-grow: 1; + flex-shrink: 1; + padding: 20px; +} + +.snapshot-pct-text { + color: var(--boot-progress-snapshot-pct-color); + font-size: 28px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.snapshot-loading-row { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: normal; + + > * { + width: 33%; + } + + .center-align { + text-align: center; + } + + .right-align { + text-align: right; + } +} + +.snapshot-tile-title { + color: var(--boot-progress-primary-text-color); + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.snapshot-tile-busy { + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: normal; +} diff --git a/src/features/StartupProgress/atoms.ts b/src/features/StartupProgress/atoms.ts index 6b79ae40..9716c362 100644 --- a/src/features/StartupProgress/atoms.ts +++ b/src/features/StartupProgress/atoms.ts @@ -1,3 +1,87 @@ import { atom } from "jotai"; +import { bootProgressAtom } from "../../api/atoms"; +import { BootPhaseEnum, ClientEnum } from "../../api/entities"; +import type { BootProgress } from "../../api/types"; +import { clientAtom } from "../../atoms"; + +export const bootProgressPhaseAtom = atom( + (get) => get(bootProgressAtom)?.phase, +); export const showStartupProgressAtom = atom(true); +export const isStartupProgressExpandedAtom = atom(true); +export const expandStartupProgressElAtom = atom(null); + +export const isStartupProgressVisibleAtom = atom((get) => { + const showStartupProgress = get(showStartupProgressAtom); + if (!showStartupProgress) return false; + + const client = get(clientAtom); + if (client === ClientEnum.Frankendancer) { + return showStartupProgress; + } else if (client === ClientEnum.Firedancer) { + return showStartupProgress && get(isStartupProgressExpandedAtom); + } + return true; +}); + +export const bootProgressBarPctAtom = atom((get) => { + const bootProgress = get(bootProgressAtom); + if (!bootProgress) return 0; + + switch (bootProgress.phase) { + case BootPhaseEnum.joining_gossip: { + return 0; + } + case BootPhaseEnum.loading_full_snapshot: { + const total = bootProgress.loading_full_snapshot_total_bytes; + const insert = bootProgress.loading_full_snapshot_insert_bytes; + const decompress_compressed = + bootProgress.loading_full_snapshot_decompress_compressed_bytes; + const decompress_decompressed = + bootProgress.loading_full_snapshot_decompress_decompressed_bytes; + + if ( + !insert || + !decompress_compressed || + !decompress_decompressed || + !total + ) { + return 0; + } + + const insertCompleted = + insert * (decompress_compressed / decompress_decompressed); + + return Math.min(100, (insertCompleted / total) * 100); + } + case BootPhaseEnum.loading_incr_snapshot: { + const total = bootProgress.loading_incremental_snapshot_total_bytes; + const insert = bootProgress.loading_incremental_snapshot_insert_bytes; + const decompress_compressed = + bootProgress.loading_incremental_snapshot_decompress_compressed_bytes; + const decompress_decompressed = + bootProgress.loading_incremental_snapshot_decompress_decompressed_bytes; + + if ( + !insert || + !decompress_compressed || + !decompress_decompressed || + !total + ) { + return 0; + } + + const insertCompleted = + insert * (decompress_compressed / decompress_decompressed); + + return Math.min(100, (insertCompleted / total) * 100); + } + case BootPhaseEnum.catching_up: { + return 0; + } + case BootPhaseEnum.running: { + return 0; + } + } +}); diff --git a/src/features/StartupProgress/index.tsx b/src/features/StartupProgress/index.tsx index 4f58161c..b506191a 100644 --- a/src/features/StartupProgress/index.tsx +++ b/src/features/StartupProgress/index.tsx @@ -4,10 +4,29 @@ import { useEffect } from "react"; import { showStartupProgressAtom } from "./atoms"; import Body from "./Body"; import { animated, useSpring } from "@react-spring/web"; +import { ClientEnum } from "../../api/entities"; +import FiredancerBody from "./Firedancer/Body"; +import { clientAtom } from "../../atoms"; export default function StartupProgress({ children }: PropsWithChildren) { - const showStartupProgress = useAtomValue(showStartupProgressAtom); + const client = useAtomValue(clientAtom); + if (!client) return null; + + return client === ClientEnum.Firedancer ? ( + <> + +
{children}
+ + ) : ( + <> + + {children} + + ); +} +function BlurAnimation({ children }: PropsWithChildren) { + const showStartupProgress = useAtomValue(showStartupProgressAtom); const [springs, api] = useSpring(() => ({ from: showStartupProgress ? { filter: "blur(10px)" } : undefined, })); @@ -26,10 +45,5 @@ export default function StartupProgress({ children }: PropsWithChildren) { } }, [api, showStartupProgress]); - return ( - <> - - {children} - - ); + return {children}; } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index d229268f..e1d896c1 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -4,6 +4,9 @@ import Header from "../features/Header"; import { Container } from "@radix-ui/themes"; import StartupProgress from "../features/StartupProgress"; import Toast from "../features/Toast"; +import { useAtomValue } from "jotai"; +import { isStartupProgressVisibleAtom } from "../features/StartupProgress/atoms"; +import { appMaxWidth } from "../consts"; // import { TanStackRouterDevtools } from '@tanstack/router-devtools' // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -20,15 +23,25 @@ const TanStackRouterDevtools = ); export const Route = createRootRoute({ - component: () => ( + component: () => , +}); + +function RootContainer() { + const isStartupProgressVisible = useAtomValue(isStartupProgressVisibleAtom); + return ( <> - +
- ), -}); + ); +} diff --git a/src/utils.ts b/src/utils.ts index 2653dedf..6b94bb24 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,4 @@ -import type { Duration } from "luxon"; -import { DateTime } from "luxon"; +import { DateTime, Duration } from "luxon"; import type { Epoch, Peer } from "./api/types"; import { lamportsPerSol, slotsPerLeader } from "./consts"; @@ -63,6 +62,11 @@ export function getTimeTillText( return text; } +export function formatDuration(seconds: number) { + const duration = Duration.fromObject({ seconds }).rescale(); + return getTimeTillText(duration); +} + export let slowDateTimeNow = DateTime.now(); setInterval(() => { slowDateTimeNow = DateTime.now(); From 5190db83fddf1dc7e4cdc3bcce815acb48e38e8c Mon Sep 17 00:00:00 2001 From: Ami Suzuki Date: Tue, 23 Sep 2025 14:57:48 -0500 Subject: [PATCH 2/3] fix: gossip network_stats schema --- src/api/entities.ts | 55 +++++++++++++------ .../Firedancer/GossipProgress.tsx | 22 ++++++-- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/api/entities.ts b/src/api/entities.ts index eaca9514..1da83748 100644 --- a/src/api/entities.ts +++ b/src/api/entities.ts @@ -563,33 +563,56 @@ export const epochSchema = z.discriminatedUnion("key", [ ]); const gossipNetworkHealthSchema = z.object({ - rx_push_pct: z.number().optional(), - duplicate_pct: z.number().optional(), - bad_pct: z.number().optional(), - pull_already_known_pct: z.number().optional(), - total_stake: z.coerce.bigint(), - total_peers: z.coerce.bigint(), - connected_stake: z.coerce.bigint(), - connected_peers: z.number(), + push_rx_pct: z.number().nullable().optional(), + pull_response_rx_pct: z.number().nullable().optional(), + push_rx_dup_pct: z.number().nullable().optional(), + pull_response_rx_dup_pct: z.number().nullable().optional(), + push_rx_msg_bad_pct: z.number().nullable().optional(), + push_rx_entry_bad_pct: z.number().nullable().optional(), + pull_response_rx_msg_bad_pct: z.number().nullable().optional(), + pull_response_rx_entry_bad_pct: z.number().nullable().optional(), + pull_already_known_pct: z.number().nullable().optional(), + total_stake: z.coerce.bigint().nullable().optional(), + total_staked_peers: z.number().nullable().optional(), + total_unstaked_peers: z.number().nullable().optional(), + connected_stake: z.coerce.bigint().nullable().optional(), + connected_staked_peers: z.number().nullable().optional(), + connected_unstaked_peers: z.number().nullable().optional(), }); const gossipNetworkTrafficSchema = z.object({ - total_throughput: z.number().optional(), - peer_names: z.string().array(), - peer_throughputs: z.number().array().optional(), + total_throughput: z.number().nullable().optional(), + peer_names: z.string().array().nullable().optional(), + peer_identities: z.string().array().nullable().optional(), + peer_throughput: z.number().array().nullable().optional(), }); -const gossipStorageUtilSchema = z.object({ - total_bytes: z.coerce.number(), - peer_names: z.string().array(), - peer_bytes: z.number().array(), +const gossipStorageStatsSchema = z.object({ + capacity: z.number().nullable().optional(), + expired_total: z.number().nullable().optional(), + evicted_total: z.number().nullable().optional(), + count: z.number().array().nullable().optional(), + bps_tx: z.number().array().nullable().optional(), + eps_tx: z.number().array().nullable().optional(), +}); + +const gossipMessageStatsSchema = z.object({ + bytes_rx_total: z.number().array().nullable().optional(), + count_rx_total: z.number().array().nullable().optional(), + bytes_tx_total: z.number().array().nullable().optional(), + count_tx_total: z.number().array().nullable().optional(), + bps_rx: z.number().array().nullable().optional(), + mps_rx: z.number().array().nullable().optional(), + bps_tx: z.number().array().nullable().optional(), + mps_tx: z.number().array().nullable().optional(), }); export const gossipNetworkStatsSchema = z.object({ health: gossipNetworkHealthSchema, ingress: gossipNetworkTrafficSchema, egress: gossipNetworkTrafficSchema, - storage: gossipStorageUtilSchema, + storage: gossipStorageStatsSchema, + messages: gossipMessageStatsSchema, }); export const gossipSchema = z.discriminatedUnion("key", [ diff --git a/src/features/StartupProgress/Firedancer/GossipProgress.tsx b/src/features/StartupProgress/Firedancer/GossipProgress.tsx index e493a96d..e9d27bb5 100644 --- a/src/features/StartupProgress/Firedancer/GossipProgress.tsx +++ b/src/features/StartupProgress/Firedancer/GossipProgress.tsx @@ -2,15 +2,27 @@ import { Card, Flex, Text } from "@radix-ui/themes"; import styles from "./gossip.module.css"; import { Bars } from "./Bars"; +import { useAtomValue } from "jotai"; +import { gossipNetworkStatsAtom } from "../../../api/atoms"; export function GossipProgress() { - // TODO: use atom + const networkStats = useAtomValue(gossipNetworkStatsAtom); + if (!networkStats) return null; + + const { health } = networkStats; + return ( - - + + @@ -24,13 +36,13 @@ export function GossipProgress() { interface GossipCardProps { title: string; - value: number; + value: number | null; } function GossipCard({ title, value }: GossipCardProps) { return ( {title} - {value} + {value ?? "--"} ); } From 43b55559ebb921218923a350c448aa07d5297d6c Mon Sep 17 00:00:00 2001 From: Ami Suzuki Date: Wed, 24 Sep 2025 12:29:34 -0500 Subject: [PATCH 3/3] chore: use ws values for gossip startup --- .../StartupProgress/Firedancer/Bars.tsx | 62 +++++++++--------- .../Firedancer/GossipProgress.tsx | 55 +++++++++++++--- .../Firedancer/SnapshotLoadingCard.tsx | 2 +- .../Firedancer/bars.module.css | 63 +++++++------------ .../Firedancer/gossip.module.css | 16 +++++ src/utils.ts | 25 ++++++++ 6 files changed, 143 insertions(+), 80 deletions(-) diff --git a/src/features/StartupProgress/Firedancer/Bars.tsx b/src/features/StartupProgress/Firedancer/Bars.tsx index 86799bcc..76351353 100644 --- a/src/features/StartupProgress/Firedancer/Bars.tsx +++ b/src/features/StartupProgress/Firedancer/Bars.tsx @@ -1,5 +1,3 @@ -import { Flex, Text } from "@radix-ui/themes"; - import styles from "./bars.module.css"; import clsx from "clsx"; import { useMeasure } from "react-use"; @@ -26,36 +24,34 @@ export function Bars({ title, value, max }: BarsProps) { const usedWidth = barCount * (barWidth + barGap); return ( - - {title && {title}} - - {Array.from({ length: barCount }, (_, i) => { - const isHigh = i >= barCount * 0.95; - const isMid = !isHigh && i >= barCount * 0.85; - - return ( - - ); - })} - - + + {Array.from({ length: barCount }, (_, i) => { + const isHigh = i >= barCount * 0.95; + const isMid = !isHigh && i >= barCount * 0.85; + + return ( + + ); + })} + ); } diff --git a/src/features/StartupProgress/Firedancer/GossipProgress.tsx b/src/features/StartupProgress/Firedancer/GossipProgress.tsx index e9d27bb5..4cfee4f7 100644 --- a/src/features/StartupProgress/Firedancer/GossipProgress.tsx +++ b/src/features/StartupProgress/Firedancer/GossipProgress.tsx @@ -4,12 +4,28 @@ import styles from "./gossip.module.css"; import { Bars } from "./Bars"; import { useAtomValue } from "jotai"; import { gossipNetworkStatsAtom } from "../../../api/atoms"; +import { formatBytesAsBits, getFmtStake } from "../../../utils"; + +const MAX_THROUGHPUT_BYTES = 1_8750_000; // 150Mbit export function GossipProgress() { const networkStats = useAtomValue(gossipNetworkStatsAtom); if (!networkStats) return null; - const { health } = networkStats; + const { health, ingress, egress } = networkStats; + + const connectedStake = + health.connected_stake == null ? null : getFmtStake(health.connected_stake); + + const ingressThroughput = + ingress.total_throughput == null + ? undefined + : formatBytesAsBits(ingress.total_throughput); + + const egressThroughput = + egress.total_throughput == null + ? undefined + : formatBytesAsBits(egress.total_throughput); return ( @@ -17,17 +33,42 @@ export function GossipProgress() { - + - - + + Ingress + + {ingressThroughput + ? `${ingressThroughput.value} ${ingressThroughput.unit}` + : "-- Mbit"} + + + + + + Egress + + {egressThroughput + ? `${egressThroughput.value} ${egressThroughput.unit}` + : "-- Mbit"} + + + Stake Discovered @@ -36,7 +77,7 @@ export function GossipProgress() { interface GossipCardProps { title: string; - value: number | null; + value?: number | string | null; } function GossipCard({ title, value }: GossipCardProps) { return ( diff --git a/src/features/StartupProgress/Firedancer/SnapshotLoadingCard.tsx b/src/features/StartupProgress/Firedancer/SnapshotLoadingCard.tsx index f96ec470..044c4a44 100644 --- a/src/features/StartupProgress/Firedancer/SnapshotLoadingCard.tsx +++ b/src/features/StartupProgress/Firedancer/SnapshotLoadingCard.tsx @@ -5,7 +5,7 @@ import byteSize from "byte-size"; import clsx from "clsx"; import { Bars } from "./Bars"; -const MAX_THROUGHPUT = 300000000; +const MAX_THROUGHPUT = 300_000_000; interface SnapshotLoadingCardProps { title: string; diff --git a/src/features/StartupProgress/Firedancer/bars.module.css b/src/features/StartupProgress/Firedancer/bars.module.css index b8197a80..0d71d30d 100644 --- a/src/features/StartupProgress/Firedancer/bars.module.css +++ b/src/features/StartupProgress/Firedancer/bars.module.css @@ -1,48 +1,33 @@ -.bars-container { - color: var(--boot-progress-primary-text-color); - font-size: 18px; - font-style: normal; - font-weight: 400; - line-height: normal; +.bars { + width: 100%; + height: 77px; - .title { - font-size: 28px; - font-style: normal; - font-weight: 400; - line-height: normal; - } - - svg { - width: 100%; - height: 77px; + rect { + fill: var(--boot-progress-gossip-bars-color); + &.threshold { + fill: var(--app-teal); + } + &.filled { + fill: var(--boot-progress-gossip-filled-bar-color); + } - rect { - fill: var(--boot-progress-gossip-bars-color); - &.threshold { - fill: var(--app-teal); - } + &.mid { + fill: var(--boot-progress-gossip-mid-bar-color); &.filled { - fill: var(--boot-progress-gossip-filled-bar-color); + fill: var(--boot-progress-gossip-mid-filled-bar-color); } - - &.mid { - fill: var(--boot-progress-gossip-mid-bar-color); - &.filled { - fill: var(--boot-progress-gossip-mid-filled-bar-color); - } - &.threshold { - fill: var(--boot-progress-gossip-mid-threshold-bar-color); - } + &.threshold { + fill: var(--boot-progress-gossip-mid-threshold-bar-color); } + } - &.high { - fill: var(--boot-progress-gossip-high-bar-color); - &.filled { - fill: var(--boot-progress-gossip-high-filled-bar-color); - } - &.threshold { - fill: var(--boot-progress-gossip-high-threshold-bar-color); - } + &.high { + fill: var(--boot-progress-gossip-high-bar-color); + &.filled { + fill: var(--boot-progress-gossip-high-filled-bar-color); + } + &.threshold { + fill: var(--boot-progress-gossip-high-threshold-bar-color); } } } diff --git a/src/features/StartupProgress/Firedancer/gossip.module.css b/src/features/StartupProgress/Firedancer/gossip.module.css index 1c63292b..a01e58c7 100644 --- a/src/features/StartupProgress/Firedancer/gossip.module.css +++ b/src/features/StartupProgress/Firedancer/gossip.module.css @@ -22,3 +22,19 @@ line-height: normal; } } + +.bar-title { + color: var(--boot-progress-primary-text-color); + font-size: 28px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.bar-value { + color: var(--boot-progress-primary-text-color); + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: normal; +} diff --git a/src/utils.ts b/src/utils.ts index 6b94bb24..9eee01e9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -145,3 +145,28 @@ export function copyToClipboard(copyValue: string) { document.body.removeChild(copyEl); } } + +export function formatBytesAsBits(bytes: number): { + value: number; + unit: string; +} { + const bits = bytes * 8; + if (bits < 1_000) return { value: bits, unit: "bit" }; + if (bits < 1_000_000) + return { value: getRoundedBitsValue(bits / 1_000), unit: "Kbit" }; + if (bits < 1_000_000_000) { + return { value: getRoundedBitsValue(bits / 1_000_000), unit: "Mbit" }; + } + + return { value: getRoundedBitsValue(bits / 1_000_000_000), unit: "Gbit" }; +} + +/** + * Round to 1 decimal place if value is <= 10, otherwise round to nearest integer + */ +function getRoundedBitsValue(value: number) { + if (value >= 9.5) return Math.round(value); + + // 1 decimal place + return Math.round(value * 10) / 10; +}