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..1da83748 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,66 @@ export const epochSchema = z.discriminatedUnion("key", [
}),
]);
+const gossipNetworkHealthSchema = z.object({
+ 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().nullable().optional(),
+ peer_names: z.string().array().nullable().optional(),
+ peer_identities: z.string().array().nullable().optional(),
+ peer_throughput: z.number().array().nullable().optional(),
+});
+
+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: gossipStorageStatsSchema,
+ messages: gossipMessageStatsSchema,
+});
+
+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..76351353
--- /dev/null
+++ b/src/features/StartupProgress/Firedancer/Bars.tsx
@@ -0,0 +1,57 @@
+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 (
+
+ {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..4cfee4f7
--- /dev/null
+++ b/src/features/StartupProgress/Firedancer/GossipProgress.tsx
@@ -0,0 +1,89 @@
+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";
+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, 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 (
+
+
+
+
+
+
+
+
+
+ Ingress
+
+ {ingressThroughput
+ ? `${ingressThroughput.value} ${ingressThroughput.unit}`
+ : "-- Mbit"}
+
+
+
+
+
+ Egress
+
+ {egressThroughput
+ ? `${egressThroughput.value} ${egressThroughput.unit}`
+ : "-- Mbit"}
+
+
+
+
+ Stake Discovered
+
+ );
+}
+
+interface GossipCardProps {
+ title: string;
+ value?: number | string | null;
+}
+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 (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
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..044c4a44
--- /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 = 300_000_000;
+
+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..0d71d30d
--- /dev/null
+++ b/src/features/StartupProgress/Firedancer/bars.module.css
@@ -0,0 +1,34 @@
+.bars {
+ 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..a01e58c7
--- /dev/null
+++ b/src/features/StartupProgress/Firedancer/gossip.module.css
@@ -0,0 +1,40 @@
+.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;
+ }
+}
+
+.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/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..9eee01e9 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();
@@ -141,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;
+}