setIsNavCollapsed(true)}
+ className="blur"
+ style={{
+ zIndex: maxZIndex - 2,
+ }}
+ />
+ );
+}
diff --git a/src/features/Navigation/NavCollapseToggle.tsx b/src/features/Navigation/NavCollapseToggle.tsx
new file mode 100644
index 00000000..253b785f
--- /dev/null
+++ b/src/features/Navigation/NavCollapseToggle.tsx
@@ -0,0 +1,53 @@
+import { IconButton } from "@radix-ui/themes";
+import clsx from "clsx";
+import styles from "./navigation.module.css";
+import ReadMore from "@material-design-icons/svg/filled/read_more.svg?react";
+import { largeNavToggleHeight, navToggleHeight } from "../../consts";
+import { useSlotsNavigation } from "../../hooks/useSlotsNavigation";
+
+interface NavCollapseToggleProps {
+ isFloating?: boolean;
+ isLarge?: boolean;
+}
+
+export default function NavCollapseToggle({
+ isFloating,
+ isLarge,
+}: NavCollapseToggleProps) {
+ const { showNav, setIsNavCollapsed, showOnlyEpochBar } = useSlotsNavigation();
+
+ const buttonSize = `${isLarge ? largeNavToggleHeight : navToggleHeight}px`;
+
+ if (showOnlyEpochBar) {
+ // Don't allow collapsing when only the epoch bar is shown
+ return (
+
+ );
+ }
+
+ return (
+
setIsNavCollapsed((prev) => !prev)}
+ className={clsx(styles.toggleButton, {
+ [styles.floating]: isFloating,
+ })}
+ style={{
+ height: buttonSize,
+ width: buttonSize,
+ }}
+ >
+
+
+ );
+}
diff --git a/src/features/Navigation/NavFilterToggles.tsx b/src/features/Navigation/NavFilterToggles.tsx
new file mode 100644
index 00000000..2ab5bdc3
--- /dev/null
+++ b/src/features/Navigation/NavFilterToggles.tsx
@@ -0,0 +1,49 @@
+import { Flex, Text } from "@radix-ui/themes";
+import { ToggleGroup } from "radix-ui";
+import { useCallback } from "react";
+
+import { useAtom } from "jotai";
+import { SlotNavFilter, slotNavFilterAtom } from "../../atoms";
+import styles from "./navigation.module.css";
+import { navToggleHeight } from "../../consts";
+
+export default function NavFilterToggles() {
+ const [navFilter, setNavFilter] = useAtom(slotNavFilterAtom);
+
+ const onValueChange = useCallback(
+ (value: SlotNavFilter) => {
+ if (!value) return;
+
+ setNavFilter(value);
+ },
+ [setNavFilter],
+ );
+
+ return (
+
+
+
+ All Slots
+
+
+
+ My Slots
+
+
+
+ );
+}
diff --git a/src/features/Navigation/ResetLive.tsx b/src/features/Navigation/ResetLive.tsx
new file mode 100644
index 00000000..63d6edf9
--- /dev/null
+++ b/src/features/Navigation/ResetLive.tsx
@@ -0,0 +1,29 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { slotOverrideAtom, statusAtom } from "../../atoms";
+import styles from "./resetLive.module.css";
+import { Button, Text } from "@radix-ui/themes";
+import { ArrowDownIcon, ArrowUpIcon } from "@radix-ui/react-icons";
+
+export default function ResetLive() {
+ const setSlotOverride = useSetAtom(slotOverrideAtom);
+ const status = useAtomValue(statusAtom);
+
+ if (status === "Live") return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/features/Navigation/SlotsList.tsx b/src/features/Navigation/SlotsList.tsx
new file mode 100644
index 00000000..cf948b06
--- /dev/null
+++ b/src/features/Navigation/SlotsList.tsx
@@ -0,0 +1,371 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import {
+ autoScrollAtom,
+ currentLeaderSlotAtom,
+ epochAtom,
+ leaderSlotsAtom,
+ SlotNavFilter,
+ slotNavFilterAtom,
+ slotOverrideAtom,
+} from "../../atoms";
+import { Box, Flex, Text } from "@radix-ui/themes";
+import type { RefObject } from "react";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import styles from "./slotsList.module.css";
+import { slotsListPinnedSlotOffset, slotsPerLeader } from "../../consts";
+import { throttle } from "lodash";
+import SlotsRenderer, { SlotsPlaceholder } from "./SlotsRenderer";
+import type { ScrollSeekConfiguration, VirtuosoHandle } from "react-virtuoso";
+import { Virtuoso } from "react-virtuoso";
+import { baseSelectedSlotAtom } from "../Overview/SlotPerformance/atoms";
+import ResetLive from "./ResetLive";
+import type { DebouncedState } from "use-debounce";
+import { useDebouncedCallback } from "use-debounce";
+import { useCurrentRoute } from "../../hooks/useCurrentRoute";
+import { getSlotGroupLeader } from "../../utils";
+import clsx from "clsx";
+
+const computeItemKey = (slot: number) => slot;
+
+// Add one future slot to prevent current leader transition from flickering
+const increaseViewportBy = { top: 24, bottom: 0 };
+
+interface SlotsListProps {
+ width: number;
+ height: number;
+}
+
+export default function SlotsList({ width, height }: SlotsListProps) {
+ const currentRoute = useCurrentRoute();
+ const navFilter = useAtomValue(slotNavFilterAtom);
+ const epoch = useAtomValue(epochAtom);
+ const isSelectionInitialized =
+ useAtomValue(baseSelectedSlotAtom).isInitialized;
+
+ if (!epoch || (currentRoute === "Slot Details" && !isSelectionInitialized)) {
+ return null;
+ }
+
+ return navFilter === SlotNavFilter.MySlots ? (
+
+ ) : (
+
+ );
+}
+
+interface InnerSlotsListProps {
+ width: number;
+ height: number;
+ slotGroupsDescending: number[];
+ getSlotAtIndex: (index: number) => number;
+ getIndexForSlot: (slot: number) => number;
+}
+function InnerSlotsList({
+ width,
+ height,
+ slotGroupsDescending,
+ getSlotAtIndex,
+ getIndexForSlot,
+}: InnerSlotsListProps) {
+ const listContainerRef = useRef
(null);
+ const listRef = useRef(null);
+ const visibleStartIndexRef = useRef(null);
+
+ const [hideList, setHideList] = useState(true);
+ const [totalListHeight, setTotalListHeight] = useState(0);
+
+ useEffect(() => {
+ // initially hide list to
+ const timeout = setTimeout(() => {
+ setHideList(false);
+ }, 100);
+
+ return () => clearTimeout(timeout);
+ }, []);
+
+ const setSlotOverride = useSetAtom(slotOverrideAtom);
+ const slotsCount = slotGroupsDescending.length;
+
+ const debouncedScroll = useDebouncedCallback(() => {}, 100);
+
+ const { rangeChanged, scrollSeekConfiguration } = useMemo(() => {
+ const rangeChangedFn = ({ startIndex }: { startIndex: number }) => {
+ // account for increaseViewportBy
+ visibleStartIndexRef.current = startIndex + 1;
+ };
+
+ const config: ScrollSeekConfiguration = {
+ enter: (velocity) => Math.abs(velocity) > 1500,
+ exit: (velocity) => Math.abs(velocity) < 500,
+ change: (_, range) => rangeChangedFn(range),
+ };
+ return { rangeChanged: rangeChangedFn, scrollSeekConfiguration: config };
+ }, [visibleStartIndexRef]);
+
+ // Setup user scroll handling
+ useEffect(() => {
+ if (!listContainerRef.current) return;
+ const container = listContainerRef.current;
+
+ const handleSlotOverride = throttle(
+ () => {
+ if (visibleStartIndexRef.current === null) return;
+
+ debouncedScroll();
+
+ const slotIndex = Math.min(
+ visibleStartIndexRef.current + slotsListPinnedSlotOffset,
+ slotsCount - 1,
+ );
+
+ const slot = getSlotAtIndex(slotIndex);
+ setSlotOverride(slot);
+ },
+ 50,
+ { leading: true, trailing: true },
+ );
+
+ const handleScroll = () => {
+ handleSlotOverride();
+ };
+
+ container.addEventListener("wheel", handleScroll);
+ container.addEventListener("touchmove", handleScroll);
+
+ return () => {
+ container.removeEventListener("wheel", handleScroll);
+ container.removeEventListener("touchmove", handleScroll);
+ };
+ }, [
+ getSlotAtIndex,
+ debouncedScroll,
+ setSlotOverride,
+ slotsCount,
+ visibleStartIndexRef,
+ ]);
+
+ return (
+
+
+
+
+
+ }
+ rangeChanged={rangeChanged}
+ components={{ ScrollSeekPlaceholder: MScrollSeekPlaceHolder }}
+ scrollSeekConfiguration={scrollSeekConfiguration}
+ totalListHeightChanged={(height) => setTotalListHeight(height)}
+ />
+
+ );
+}
+
+// Render nothing when scrolling quickly to improve performance
+const MScrollSeekPlaceHolder = memo(function ScrollSeekPlaceholder() {
+ return null;
+});
+
+interface RTAutoScrollProps {
+ listRef: RefObject;
+ getIndexForSlot: (slot: number) => number;
+}
+function RTAutoScroll({ listRef, getIndexForSlot }: RTAutoScrollProps) {
+ const currentLeaderSlot = useAtomValue(currentLeaderSlotAtom);
+ const autoScroll = useAtomValue(autoScrollAtom);
+
+ useEffect(() => {
+ if (!autoScroll || currentLeaderSlot === undefined || !listRef.current)
+ return;
+
+ // scroll to new current leader slot
+ const slotIndex = getIndexForSlot(currentLeaderSlot);
+ const visibleStartIndex = slotIndex - slotsListPinnedSlotOffset;
+
+ listRef.current.scrollToIndex({
+ index: visibleStartIndex > 0 ? visibleStartIndex : 0,
+ align: "start",
+ });
+ }, [autoScroll, currentLeaderSlot, getIndexForSlot, listRef]);
+
+ return null;
+}
+
+interface SlotOverrideScrollProps {
+ listRef: RefObject;
+ getIndexForSlot: (slot: number) => number;
+ debouncedScroll: DebouncedState<() => void>;
+}
+function SlotOverrideScroll({
+ listRef,
+ getIndexForSlot,
+ debouncedScroll,
+}: SlotOverrideScrollProps) {
+ const rafIdRef = useRef(null);
+ const slotOverride = useAtomValue(slotOverrideAtom);
+
+ useEffect(() => {
+ if (
+ slotOverride === undefined ||
+ !listRef.current ||
+ debouncedScroll.isPending()
+ )
+ return;
+
+ const targetIndex = Math.max(
+ 0,
+ getIndexForSlot(slotOverride) - slotsListPinnedSlotOffset,
+ );
+
+ const prevRafId = rafIdRef.current;
+ rafIdRef.current = requestAnimationFrame(() => {
+ if (prevRafId !== null) {
+ cancelAnimationFrame(prevRafId);
+ }
+
+ listRef.current?.scrollToIndex({
+ index: targetIndex,
+ align: "start",
+ });
+ });
+
+ return () => {
+ if (rafIdRef.current !== null) {
+ cancelAnimationFrame(rafIdRef.current);
+ rafIdRef.current = null;
+ }
+ };
+ }, [getIndexForSlot, slotOverride, listRef, debouncedScroll]);
+
+ return null;
+}
+
+function AllSlotsList({ width, height }: SlotsListProps) {
+ const epoch = useAtomValue(epochAtom);
+
+ const slotGroupsDescending = useMemo(() => {
+ if (!epoch) return [];
+
+ const numSlotsInEpoch = epoch.end_slot - epoch.start_slot + 1;
+ return Array.from(
+ { length: Math.ceil(numSlotsInEpoch / slotsPerLeader) },
+ (_, i) => epoch.end_slot - i * slotsPerLeader - (slotsPerLeader - 1),
+ );
+ }, [epoch]);
+
+ const getSlotAtIndex = useCallback(
+ (index: number) => slotGroupsDescending[index],
+ [slotGroupsDescending],
+ );
+
+ const getIndexForSlot = useCallback(
+ (slot: number) => {
+ if (!epoch || slot < epoch.start_slot || slot > epoch.end_slot) return -1;
+ return Math.trunc((epoch.end_slot - slot) / slotsPerLeader);
+ },
+ [epoch],
+ );
+
+ return (
+
+ );
+}
+
+function MySlotsList({ width, height }: SlotsListProps) {
+ const mySlots = useAtomValue(leaderSlotsAtom);
+
+ const slotGroupsDescending = useMemo(
+ () => mySlots?.toReversed() ?? [],
+ [mySlots],
+ );
+
+ const slotToIndexMapping = useMemo(() => {
+ return slotGroupsDescending.reduce<{ [slot: number]: number }>(
+ (acc, slot, index) => {
+ acc[slot] = index;
+ return acc;
+ },
+ {},
+ );
+ }, [slotGroupsDescending]);
+
+ const getSlotAtIndex = useCallback(
+ (index: number) => slotGroupsDescending[index],
+ [slotGroupsDescending],
+ );
+
+ // Get the slot index, or if unavailable, the closest past index
+ const getClosestIndexForSlot = useCallback(
+ (slot: number) => {
+ if (!slotGroupsDescending.length) return 0;
+ if (slot >= slotGroupsDescending[0]) return 0;
+ if (slot <= slotGroupsDescending[slotGroupsDescending.length - 1])
+ return slotGroupsDescending.length - 1;
+
+ return (
+ slotToIndexMapping[getSlotGroupLeader(slot)] ??
+ slotGroupsDescending.findIndex((s) => s <= slot)
+ );
+ },
+ [slotGroupsDescending, slotToIndexMapping],
+ );
+
+ if (!mySlots) return null;
+
+ if (mySlots.length === 0) {
+ return (
+
+
+ No Slots
+
+ Available
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/features/Navigation/SlotsRenderer.tsx b/src/features/Navigation/SlotsRenderer.tsx
new file mode 100644
index 00000000..d53143fc
--- /dev/null
+++ b/src/features/Navigation/SlotsRenderer.tsx
@@ -0,0 +1,417 @@
+import { atom, useAtomValue } from "jotai";
+import {
+ currentLeaderSlotAtom,
+ currentSlotAtom,
+ firstProcessedSlotAtom,
+ leaderSlotsAtom,
+ nextLeaderSlotAtom,
+ slotDurationAtom,
+} from "../../atoms";
+import { Box, Flex, Progress, Text } from "@radix-ui/themes";
+import { useSlotQueryPublish } from "../../hooks/useSlotQuery";
+import type React from "react";
+import { memo, useMemo } from "react";
+import type { CSSProperties } from "react";
+import styles from "./slotsRenderer.module.css";
+import PeerIcon from "../../components/PeerIcon";
+import { slotsPerLeader } from "../../consts";
+import { useSlotInfo } from "../../hooks/useSlotInfo";
+import clsx from "clsx";
+import { Link } from "@tanstack/react-router";
+import { getSlotGroupLeader } from "../../utils";
+import { selectedSlotAtom } from "../Overview/SlotPerformance/atoms";
+import {
+ slotStatusBlue,
+ slotStatusDullTeal,
+ slotStatusGreen,
+ slotStatusRed,
+ slotStatusTeal,
+} from "../../colors";
+import SlotClient from "../../components/SlotClient";
+import { useIsLeaderGroupSkipped } from "../../hooks/useIsLeaderGroupSkipped";
+import { isScrollingAtom } from "./atoms";
+import useNextSlot from "../../hooks/useNextSlot";
+import type { SlotPublish } from "../../api/types";
+
+export default function SlotsRenderer(props: { leaderSlotForGroup: number }) {
+ const isScrolling = useAtomValue(isScrollingAtom);
+
+ if (isScrolling) return ;
+
+ return ;
+}
+
+const getStatusAtom = atom((get) => {
+ const currentLeaderSlot = get(currentLeaderSlotAtom);
+ const firstProcessedSlot = get(firstProcessedSlotAtom);
+ const leaderSlots = get(leaderSlotsAtom);
+
+ if (
+ !leaderSlots ||
+ currentLeaderSlot === undefined ||
+ firstProcessedSlot === undefined
+ )
+ return;
+
+ const nextLeaderSlot = get(nextLeaderSlotAtom);
+
+ return function getStatus(slot: number) {
+ return {
+ isCurrentSlotGroup:
+ currentLeaderSlot <= slot && slot < currentLeaderSlot + slotsPerLeader,
+ isFutureSlotGroup: currentLeaderSlot + slotsPerLeader <= slot,
+ isProcessedSlotGroup:
+ firstProcessedSlot <= slot && slot <= currentLeaderSlot,
+ isYourNextLeaderGroup:
+ nextLeaderSlot &&
+ nextLeaderSlot <= slot &&
+ slot < nextLeaderSlot + slotsPerLeader,
+ };
+ };
+});
+
+const MSlotsRenderer = memo(function SlotsRenderer({
+ leaderSlotForGroup,
+}: {
+ leaderSlotForGroup: number;
+}) {
+ const getStatus = useAtomValue(getStatusAtom);
+ const status = getStatus?.(leaderSlotForGroup);
+ if (!status) return ;
+
+ const { isFutureSlotGroup, isCurrentSlotGroup, isYourNextLeaderGroup } =
+ status;
+
+ return (
+
+ {isCurrentSlotGroup ? (
+
+ ) : isYourNextLeaderGroup ? (
+
+ ) : isFutureSlotGroup ? (
+
+ ) : (
+
+ )}
+
+ );
+});
+
+function YourNextLeaderSlotGroup({ firstSlot }: { firstSlot: number }) {
+ const { progressSinceLastLeader, nextSlotText } = useNextSlot({
+ showNowIfCurrent: false,
+ durationOptions: {
+ showOnlyTwoSignificantUnits: true,
+ },
+ });
+
+ return (
+
+
+
+
+
+ {nextSlotText}
+
+
+
+
+
+
+ );
+}
+
+interface SlotGroupProps {
+ firstSlot: number;
+}
+
+function FutureSlotGroup({ firstSlot }: SlotGroupProps) {
+ const { isLeader: isYou } = useSlotInfo(firstSlot);
+ return (
+
+
+
+
+ );
+}
+
+function CurrentLeaderSlotGroup({ firstSlot }: { firstSlot: number }) {
+ const { isLeader: isYou, countryFlag } = useSlotInfo(firstSlot);
+ const hasSkipped = useIsLeaderGroupSkipped(firstSlot);
+ const currentSlot = useAtomValue(currentSlotAtom);
+ return (
+
+
+
+
+
+ {currentSlot}
+ {countryFlag && {countryFlag}}
+
+
+
+
+
+ );
+}
+
+function PastSlotGroup({ firstSlot }: SlotGroupProps) {
+ const { isLeader: isYou } = useSlotInfo(firstSlot);
+ const getStatus = useAtomValue(getStatusAtom);
+ const status = getStatus?.(firstSlot);
+ const hasSkipped = useIsLeaderGroupSkipped(firstSlot);
+
+ if (!status) return;
+ const { isProcessedSlotGroup } = status;
+
+ return isYou && isProcessedSlotGroup ? (
+
+ ) : (
+
+
+
+
+ );
+}
+
+function YourProcessedSlotGroup({ firstSlot }: { firstSlot: number }) {
+ const selectedSlot = useAtomValue(selectedSlotAtom);
+ const hasSkipped = useIsLeaderGroupSkipped(firstSlot);
+
+ const isSelected =
+ selectedSlot !== undefined &&
+ getSlotGroupLeader(selectedSlot) === firstSlot;
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+function SlotContent({ firstSlot }: SlotGroupProps) {
+ const { countryFlag } = useSlotInfo(firstSlot);
+ return (
+
+
+
+
+ {firstSlot}
+ {countryFlag && {countryFlag}}
+
+
+ );
+}
+
+export function SlotsPlaceholder({
+ width,
+ height,
+ totalListHeight,
+}: {
+ width: number;
+ height: number;
+ totalListHeight: number;
+}) {
+ const items = useMemo(() => Math.ceil(height / 46), [height]);
+ if (totalListHeight < height) return;
+
+ return (
+
+ {Array.from({ length: items }, (_, index) => (
+
+ ))}
+
+ );
+}
+
+export const MScrollPlaceholderItem = memo(function ScrollPlaceholderItem() {
+ return (
+
+
+
+ );
+});
+
+function SlotIconName({
+ slot,
+ iconSize = 15,
+}: {
+ slot: number;
+ iconSize?: number;
+}) {
+ const { peer, isLeader, name } = useSlotInfo(slot);
+ return (
+
+
+ {name}
+
+ );
+}
+
+interface SlotStatusesProps {
+ firstSlot: number;
+ isCurrentSlot?: boolean;
+ isPastSlot?: boolean;
+}
+
+function SlotStatuses({
+ firstSlot,
+ isCurrentSlot = false,
+ isPastSlot = false,
+}: SlotStatusesProps) {
+ return (
+
+ {Array.from({ length: slotsPerLeader }).map((_, slotIdx) => {
+ const slot = firstSlot + (slotsPerLeader - 1) - slotIdx;
+
+ if (isCurrentSlot) {
+ return ;
+ }
+
+ if (isPastSlot) {
+ return ;
+ }
+
+ return ;
+ })}
+
+ );
+}
+
+function SlotStatus({
+ borderColor,
+ backgroundColor,
+ slotDuration,
+}: {
+ borderColor?: string;
+ backgroundColor?: string;
+ slotDuration?: number;
+}) {
+ return (
+
+ {slotDuration && (
+
+ )}
+
+ );
+}
+
+function getSlotStatusColorStyles(publish?: SlotPublish): CSSProperties {
+ if (!publish) return {};
+ if (publish.skipped) return { backgroundColor: slotStatusRed };
+ switch (publish.level) {
+ case "incomplete":
+ return {};
+ case "completed":
+ return { borderColor: slotStatusGreen };
+ case "optimistically_confirmed":
+ return { backgroundColor: slotStatusGreen };
+ case "finalized":
+ case "rooted":
+ return { backgroundColor: slotStatusTeal };
+ }
+}
+
+function CurrentSlotStatus({ slot }: { slot: number }) {
+ const currentSlot = useAtomValue(currentSlotAtom);
+ const queryPublish = useSlotQueryPublish(slot);
+ const slotDuration = useAtomValue(slotDurationAtom);
+
+ const isCurrent = useMemo(() => slot === currentSlot, [slot, currentSlot]);
+ const colorStyle = useMemo(() => {
+ if (isCurrent) return { borderColor: slotStatusBlue };
+ return getSlotStatusColorStyles(queryPublish.publish);
+ }, [isCurrent, queryPublish.publish]);
+
+ return (
+
+ );
+}
+
+function PastSlotStatus({ slot }: { slot: number }) {
+ const queryPublish = useSlotQueryPublish(slot);
+ const selectedSlot = useAtomValue(selectedSlotAtom);
+ const colorStyle = useMemo(() => {
+ const style = getSlotStatusColorStyles(queryPublish.publish);
+ if (
+ queryPublish?.publish?.level === "rooted" &&
+ (selectedSlot === undefined ||
+ getSlotGroupLeader(slot) !== getSlotGroupLeader(selectedSlot))
+ ) {
+ style.backgroundColor = slotStatusDullTeal;
+ }
+ return style;
+ }, [queryPublish.publish, selectedSlot, slot]);
+
+ return (
+
+ );
+}
diff --git a/src/features/Navigation/Status.tsx b/src/features/Navigation/Status.tsx
new file mode 100644
index 00000000..a0f1cce2
--- /dev/null
+++ b/src/features/Navigation/Status.tsx
@@ -0,0 +1,77 @@
+import { useAtomValue } from "jotai";
+import type { Status } from "../../atoms";
+import { statusAtom } from "../../atoms";
+import { useMemo } from "react";
+import historyIcon from "../../assets/history.svg";
+import futureIcon from "../../assets/future.svg";
+import { Flex, Text, Tooltip } from "@radix-ui/themes";
+import styles from "./status.module.css";
+import clsx from "clsx";
+
+const statusToLabel: Record = {
+ Live: "RT",
+ Past: "PT",
+ Current: "CT",
+ Future: "FT",
+};
+
+export function StatusIndicator() {
+ const status = useAtomValue(statusAtom);
+
+ const text = useMemo(() => {
+ if (!status) return null;
+ return status === "Live" ? (
+
+ {statusToLabel[status]}
+
+ ) : (
+
+ {statusToLabel[status]}
+
+ );
+ }, [status]);
+
+ const icon = useMemo(() => {
+ if (!status) return null;
+ return (
+
+ {status === "Live" ? (
+
+ ) : (
+
+ )}
+
+ );
+ }, [status]);
+
+ if (!status) return null;
+
+ return (
+
+ {text}
+ {icon}
+
+ );
+}
diff --git a/src/features/Navigation/atoms.ts b/src/features/Navigation/atoms.ts
new file mode 100644
index 00000000..942ae2cd
--- /dev/null
+++ b/src/features/Navigation/atoms.ts
@@ -0,0 +1,3 @@
+import { atom } from "jotai";
+
+export const isScrollingAtom = atom(false);
diff --git a/src/features/EpochBar/epochSlider.module.css b/src/features/Navigation/epochSlider.module.css
similarity index 53%
rename from src/features/EpochBar/epochSlider.module.css
rename to src/features/Navigation/epochSlider.module.css
index d22ac335..0b282305 100644
--- a/src/features/EpochBar/epochSlider.module.css
+++ b/src/features/Navigation/epochSlider.module.css
@@ -1,25 +1,19 @@
-@import "@radix-ui/colors/black-alpha.css";
-@import "@radix-ui/colors/violet.css";
-
-.container {
- /* width: "100%"; */
- position: relative;
- flex-grow: 1;
-}
-
.epoch-progress {
- height: 100%;
+ width: 100%;
background: var(--epoch-slider-progress-color);
- border-top-left-radius: 9999px;
- border-bottom-left-radius: 9999px;
+ position: absolute;
+ bottom: 0;
+}
+
+.clickable {
+ cursor: pointer;
}
.leader-slot {
- height: 100%;
- background: var(--my-slots-color);
- width: 5px;
+ width: 100%;
+ background: #2a7edf;
+ height: 5px;
position: absolute;
- top: 0px;
opacity: 0.5;
&:hover {
filter: brightness(1.5);
@@ -30,40 +24,39 @@
}
.skipped-slot {
- height: 100%;
- background: var(--epoch-skipped-slot-color);
- width: 3px;
+ width: 100%;
+ background: #ff5353;
+ height: 3px;
position: absolute;
- top: 0px;
&:hover {
filter: brightness(1.5);
}
}
.skipped-slot-icon {
- width: 12px;
+ height: 10px;
position: absolute;
- top: -15px;
+ left: 11px;
&:hover {
filter: brightness(1.5);
}
}
.first-processed-slot {
- height: 100%;
- background: var(--header-color);
- width: 3px;
+ width: 100%;
+ background: #bdf3ff;
+ height: 3px;
position: absolute;
- top: 0px;
+ right: 0px;
&:hover {
filter: brightness(1.5);
}
}
.first-processed-slot-icon {
- width: 12px;
+ height: 10px;
position: absolute;
- top: -15px;
+ left: 11px;
&:hover {
filter: brightness(1.5);
}
@@ -71,37 +64,55 @@
.slider-root {
position: relative;
+ flex-grow: 1;
+ width: 10px;
display: flex;
+ flex-direction: column;
align-items: center;
user-select: none;
touch-action: none;
}
.slider-track {
- background: var(--dropdown-background-color);
- position: relative;
+ background: #24262b;
flex-grow: 1;
- border-radius: 9999px;
- height: 10px;
- /* To round leader slots markers at beginning/end of epoch slider */
- overflow: hidden;
+ width: 100%;
}
.slider-thumb {
display: block;
- width: 10px;
- height: 20px;
- box-shadow: 0 2px 8px var(--black-a7);
- background: var(--gray-7);
- border: 1px solid var(--gray-12);
+ position: relative;
+ height: 10px;
+ width: 20px;
+ background: rgba(100, 101, 101, 0.5);
+ border: 1px solid #a4a4a4;
border-radius: 2px;
- opacity: 0.5;
+ cursor: grab;
+
+ &.collapsed {
+ border-left-width: 0;
+ transition: border-width 0s linear 0.2s;
+ }
}
.slider-thumb:hover {
- background: var(--gray-3);
+ background: rgba(100, 101, 101, 0.3);
}
.slider-thumb:focus {
outline: none;
box-shadow: 0 0 0 2px var(--gray-a8);
}
+
+.hide {
+ opacity: 0;
+ display: none;
+ transition:
+ opacity 0.5s ease-out 1s,
+ display 0s 1.5s;
+ transition-behavior: allow-discrete;
+}
+
+.show {
+ opacity: 1;
+ display: block;
+}
diff --git a/src/features/Navigation/index.tsx b/src/features/Navigation/index.tsx
new file mode 100644
index 00000000..06c0cb13
--- /dev/null
+++ b/src/features/Navigation/index.tsx
@@ -0,0 +1,111 @@
+import { Flex } from "@radix-ui/themes";
+import { useMemo } from "react";
+
+import SlotsList from "./SlotsList";
+
+import {
+ clusterIndicatorHeight,
+ headerHeight,
+ logoRightSpacing,
+ logoWidth,
+ narrowNavMedia,
+ slotsNavSpacing,
+ navToggleHeight,
+ maxZIndex,
+ slotsListWidth,
+ epochThumbPadding,
+ slotNavWidth,
+ slotNavWithoutListWidth,
+} from "../../consts";
+import { StatusIndicator } from "./Status";
+import AutoSizer from "react-virtualized-auto-sizer";
+import NavFilterToggles from "./NavFilterToggles";
+import EpochSlider from "./EpochSlider";
+import clsx from "clsx";
+import styles from "./navigation.module.css";
+import NavCollapseToggle from "./NavCollapseToggle";
+import { useMedia } from "react-use";
+import { useSlotsNavigation } from "../../hooks/useSlotsNavigation";
+
+const top = clusterIndicatorHeight + headerHeight;
+
+/**
+ * On narrow screens, container width is 0
+ * On collapse, content width shrinks to 0
+ */
+export default function Navigation() {
+ const isNarrow = useMedia(narrowNavMedia);
+
+ const { showNav, occupyRowWidth, showOnlyEpochBar } = useSlotsNavigation();
+
+ // padding to make sure epoch thumb is visible,
+ // as it is positioned slightly outside of the container
+ const thumbPadding = showNav ? epochThumbPadding : 0;
+
+ const width = useMemo(() => {
+ return showOnlyEpochBar ? slotNavWithoutListWidth : slotNavWidth;
+ }, [showOnlyEpochBar]);
+
+ return (
+
+
+
+ {isNarrow && (
+
+
+
+ )}
+
+
+
+
+
+ {!showOnlyEpochBar && (
+
+
+
+
+ {({ height, width }) => (
+
+ )}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/features/Navigation/navigation.module.css b/src/features/Navigation/navigation.module.css
new file mode 100644
index 00000000..ebe79260
--- /dev/null
+++ b/src/features/Navigation/navigation.module.css
@@ -0,0 +1,88 @@
+.nav-filter-toggle-group {
+ display: flex;
+ flex-wrap: nowrap;
+ width: 100%;
+
+ button {
+ cursor: pointer;
+ flex-grow: 1;
+ height: 21px;
+ border: none;
+ padding: 3px 5px;
+ color: var(--nav-button-inactive-text-color);
+ background-color: rgba(255, 255, 255, 0.1);
+ &:first-child {
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+ }
+
+ &:last-child {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ }
+
+ &[data-state="on"] {
+ background-color: var(--slot-nav-filter-background-color);
+ color: var(--nav-button-text-color);
+ }
+
+ &:hover {
+ filter: brightness(1.2);
+ }
+
+ span {
+ cursor: inherit;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ }
+ }
+}
+
+.toggle-button-size {
+ height: 15px;
+ width: 15px;
+
+ &.lg {
+ height: 18px;
+ width: 18px;
+ }
+}
+
+.toggle-button {
+ border-radius: 5px;
+ background-color: var(--epoch-slider-progress-color);
+
+ &:hover {
+ filter: brightness(1.2);
+ }
+
+ &.floating {
+ box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.75);
+ }
+
+ svg {
+ fill: var(--nav-button-text-color);
+ height: 15px;
+ width: 15px;
+
+ &.lg {
+ height: 18px;
+ width: 18px;
+ }
+
+ &.mirror {
+ transform: scaleX(-1);
+ }
+ }
+}
+
+.slot-nav-container {
+ transition: width 0.3s;
+ box-sizing: border-box;
+
+ &.nav-background {
+ background-color: var(--slot-nav-background-color);
+ }
+}
diff --git a/src/features/Navigation/resetLive.module.css b/src/features/Navigation/resetLive.module.css
new file mode 100644
index 00000000..6489c341
--- /dev/null
+++ b/src/features/Navigation/resetLive.module.css
@@ -0,0 +1,17 @@
+.container {
+ display: flex;
+ justify-content: center;
+
+ .button {
+ position: absolute;
+ width: 100px;
+ height: 18px;
+ padding: 2px 4px 2px 6px;
+ align-items: center;
+ border-radius: 40px;
+ background: #174e45;
+ box-shadow: 0 4px 4px 0 rgba(28, 82, 73, 0.4);
+ font-size: 12px;
+ font-weight: 600;
+ }
+}
diff --git a/src/features/Navigation/scrollbar.module.css b/src/features/Navigation/scrollbar.module.css
new file mode 100644
index 00000000..b76b0dcf
--- /dev/null
+++ b/src/features/Navigation/scrollbar.module.css
@@ -0,0 +1,11 @@
+.icon {
+ position: absolute;
+ line-height: 0;
+ &:hover {
+ filter: brightness(1.5);
+ }
+}
+
+&.icon:hover {
+ filter: brightness(1.5);
+}
diff --git a/src/features/Navigation/slotsList.module.css b/src/features/Navigation/slotsList.module.css
new file mode 100644
index 00000000..79035284
--- /dev/null
+++ b/src/features/Navigation/slotsList.module.css
@@ -0,0 +1,12 @@
+.slots-list {
+ scrollbar-width: none;
+ &.hidden {
+ visibility: hidden;
+ }
+}
+
+.no-slots-text {
+ font-size: 12px;
+ color: var(--regular-text-color);
+ text-align: center;
+}
diff --git a/src/features/Navigation/slotsRenderer.module.css b/src/features/Navigation/slotsRenderer.module.css
new file mode 100644
index 00000000..4d3a51d3
--- /dev/null
+++ b/src/features/Navigation/slotsRenderer.module.css
@@ -0,0 +1,198 @@
+.slot-group-container {
+ padding-bottom: 5px;
+ background: var(--slot-nav-background-color);
+}
+
+.slot-group {
+ column-gap: 4px;
+ row-gap: 3px;
+ border-radius: 5px;
+ background: var(--slots-list-slot-background-color);
+}
+
+.left-column {
+ flex-grow: 1;
+ min-width: 0;
+ gap: 4px;
+}
+
+.future {
+ padding: 3px;
+ background: var(--slots-list-future-slot-background-color);
+ color: var(--slots-list-future-slot-color);
+ img {
+ filter: grayscale(100%);
+ }
+
+ &.you {
+ border: solid var(--slots-list-not-processed-my-slots-border-color);
+ border-width: 2px 1px 1px 1px;
+ padding: 2px 3px 3px 3px;
+ background: var(--slots-list-my-slot-background-color);
+ }
+}
+
+.current {
+ padding: 2px;
+ border: 1px solid var(--container-border-color);
+ background-color: var(--container-background-color);
+ color: var(--slots-list-slot-color);
+ box-shadow: 0 0 16px 0 var(--slots-list-current-slot-box-shadow-color) inset;
+
+ .slot-name {
+ font-size: 18px;
+ }
+
+ &.skipped {
+ background: var(--slots-list-skipped-background-color);
+ }
+
+ &.you {
+ border-width: 3px 1px 1px 1px;
+ border-color: var(--slots-list-my-slots-selected-border-color);
+ }
+}
+
+.current-slot-row {
+ background-color: var(--slots-list-current-slot-number-background-color);
+ border-radius: 5px;
+ padding: 3px 5px;
+}
+
+.past {
+ padding: 3px;
+ color: var(--slots-list-past-slot-color);
+
+ &.skipped {
+ background: var(--slots-list-skipped-background-color);
+ }
+
+ &.you {
+ background: var(--slots-list-my-slot-background-color);
+
+ &.processed {
+ text-decoration: none;
+ padding: 2px;
+ border: solid var(--slots-list-my-slots-border-color);
+ border-width: 3px 1px 1px 1px;
+ background: var(--slots-list-my-slot-background-color);
+ color: var(--slots-list-past-slot-color);
+
+ &:hover,
+ &:active {
+ border-color: var(--slots-list-my-slots-selected-border-color);
+ }
+
+ &.selected {
+ background: var(--slots-list-selected-background-color);
+ border-color: var(--slots-list-my-slots-selected-border-color);
+ }
+
+ &.skipped,
+ &.selected.skipped {
+ background: var(--slots-list-skipped-selected-background-color);
+ }
+ }
+ }
+}
+
+.slot-name {
+ font-size: 12px;
+ font-weight: 400;
+}
+
+.ellipsis {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 2px;
+ div {
+ background-color: var(--slots-list-next-leader-progress-bar-color);
+ }
+}
+
+.slot-item-content {
+ align-items: center;
+ gap: 4px;
+ font-size: 10px;
+ font-weight: 400;
+ color: var(--slots-list-past-slot-number-color);
+}
+
+.placeholder {
+ height: 42px;
+}
+
+.slot-statuses {
+ .slot-status {
+ width: 4px;
+ height: 6px;
+ background: var(--slot-status-gray);
+ border: 1px solid transparent;
+ border-radius: 2px;
+ align-items: flex-end;
+
+ .slot-status-progress {
+ width: 100%;
+ height: 0;
+ animation: fillProgress var(--slot-duration) ease-in-out forwards;
+ background-color: var(--slot-status-blue);
+ }
+ }
+
+ &.tall {
+ gap: 3px;
+ .slot-status {
+ flex-grow: 1;
+ }
+ }
+
+ &.short .slot-status {
+ height: 3px;
+ border-radius: 1px;
+ }
+}
+
+@keyframes fillProgress {
+ from {
+ height: 0;
+ }
+ to {
+ height: 100%;
+ }
+}
+
+.scroll-placeholder-item {
+ position: relative;
+ overflow: hidden;
+ height: 100%;
+}
+
+.scroll-placeholder-item::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: -150%;
+ width: 200%;
+ height: 100%;
+ background: linear-gradient(
+ to right,
+ rgba(255, 255, 255, 0) 0%,
+ rgba(255, 246, 246, 0.05) 50%,
+ rgba(255, 255, 255, 0) 100%
+ );
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ from {
+ left: -100%;
+ }
+ to {
+ left: 100%;
+ }
+}
diff --git a/src/features/Navigation/status.module.css b/src/features/Navigation/status.module.css
new file mode 100644
index 00000000..2e482a6d
--- /dev/null
+++ b/src/features/Navigation/status.module.css
@@ -0,0 +1,27 @@
+.status-indicator {
+ font-size: 12px;
+ font-weight: 400;
+}
+
+.status-indicator-live {
+ color: var(--green-live);
+}
+
+.status-indicator-not-live {
+ color: #3cb4ff;
+}
+
+.status-reset {
+ background-color: transparent;
+ color: #3cb4ff;
+ width: unset;
+ height: unset;
+ padding: 0;
+}
+
+.dot-icon {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background-color: var(--green-live);
+}
diff --git a/src/features/Overview/EpochCard/epochCard.module.css b/src/features/Overview/EpochCard/epochCard.module.css
new file mode 100644
index 00000000..9183ad0a
--- /dev/null
+++ b/src/features/Overview/EpochCard/epochCard.module.css
@@ -0,0 +1,21 @@
+.progress {
+ max-height: 11px;
+ min-width: 140px;
+ background: var(--dropdown-background-color);
+
+ div {
+ background: var(--progress-background-color);
+ }
+}
+
+.stat-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ width: 100%;
+
+ > div {
+ flex: 1 1 auto;
+ min-width: 180px;
+ }
+}
diff --git a/src/features/Overview/EpochCard/index.tsx b/src/features/Overview/EpochCard/index.tsx
new file mode 100644
index 00000000..95ee4587
--- /dev/null
+++ b/src/features/Overview/EpochCard/index.tsx
@@ -0,0 +1,83 @@
+import { Flex, Progress, Box } from "@radix-ui/themes";
+import CardHeader from "../../../components/CardHeader";
+import Card from "../../../components/Card";
+import CardStat from "../../../components/CardStat";
+import { useAtomValue } from "jotai";
+import styles from "./epochCard.module.css";
+import { currentSlotAtom, epochAtom, slotDurationAtom } from "../../../atoms";
+import { headerColor } from "../../../colors";
+import { useMemo } from "react";
+import { getDurationText } from "../../../utils";
+import { Duration } from "luxon";
+
+export default function EpochCard() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function CurrentSlotText() {
+ const epoch = useAtomValue(epochAtom);
+
+ return (
+
+
+
+ );
+}
+
+function NextEpochTimeText() {
+ const slot = useAtomValue(currentSlotAtom);
+ const epoch = useAtomValue(epochAtom);
+ const slotDuration = useAtomValue(slotDurationAtom);
+
+ const nextEpochText = useMemo(() => {
+ if (epoch === undefined || slot === undefined) return "";
+
+ const endDiffMs = (epoch.end_slot - slot) * slotDuration;
+
+ const durationLeft = Duration.fromMillis(endDiffMs).rescale();
+ return getDurationText(durationLeft);
+ }, [epoch, slot, slotDuration]);
+
+ const progressSinceLastEpoch = useMemo(() => {
+ if (epoch === undefined || slot === undefined) return 0;
+ const currentSlotDiff = slot - epoch.start_slot;
+ const epochDiff = epoch.end_slot - epoch.start_slot;
+ const progress = (currentSlotDiff / epochDiff) * 100;
+ if (progress < 0 || progress > 100) return 0;
+ return progress;
+ }, [epoch, slot]);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/features/Overview/ShredsProgression/ShredsChart.tsx b/src/features/Overview/ShredsProgression/ShredsChart.tsx
new file mode 100644
index 00000000..ba3d0c90
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/ShredsChart.tsx
@@ -0,0 +1,172 @@
+import UplotReact from "../../../uplotReact/UplotReact";
+import AutoSizer from "react-virtualized-auto-sizer";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import type uPlot from "uplot";
+import { chartAxisColor, gridLineColor, gridTicksColor } from "../../../colors";
+import type { AlignedData } from "uplot";
+import { xRangeMs } from "./const";
+import { shredsProgressionPlugin } from "./shredsProgressionPlugin";
+import { useMedia, useRafLoop } from "react-use";
+import { Box } from "@radix-ui/themes";
+
+const REDRAW_INTERVAL_MS = 40;
+
+// prevent x axis tick labels from being cut off
+const chartXPadding = 15;
+
+const minXIncrRange = {
+ min: 200,
+ max: 1_600,
+};
+
+/**
+ * Get dynamic x axis tick increments based on chart scale
+ */
+const getXIncrs = (scale: number) => {
+ const scaledIncr = scale * minXIncrRange.max;
+ // round to multiples of minimum increment
+ const minIncrMultiple =
+ Math.trunc(scaledIncr / minXIncrRange.min) * minXIncrRange.min;
+
+ const incrs = [minIncrMultiple];
+ while (incrs[incrs.length - 1] < xRangeMs * scale) {
+ incrs.push(incrs[incrs.length - 1] * 2);
+ }
+ return incrs;
+};
+
+interface ShredsChartProps {
+ chartId: string;
+ isOnStartupScreen: boolean;
+}
+export default function ShredsChart({
+ chartId,
+ isOnStartupScreen,
+}: ShredsChartProps) {
+ const isXL = useMedia("(max-width: 2100px)");
+ const isL = useMedia("(max-width: 1800px)");
+ const isM = useMedia("(max-width: 1500px)");
+ const isS = useMedia("(max-width: 1200px)");
+ const isXS = useMedia("(max-width: 900px)");
+ const isXXS = useMedia("(max-width: 600px)");
+ const scale = isXXS
+ ? 1 / 7
+ : isXS
+ ? 2 / 7
+ : isS
+ ? 3 / 7
+ : isM
+ ? 4 / 7
+ : isL
+ ? 5 / 7
+ : isXL
+ ? 6 / 7
+ : 1;
+
+ const uplotRef = useRef();
+ const lastRedrawRef = useRef(0);
+
+ const handleCreate = useCallback((u: uPlot) => {
+ uplotRef.current = u;
+ }, []);
+
+ const [chartData, xIncrs] = useMemo(() => {
+ return [
+ [[Math.trunc(scale * -xRangeMs), 0], new Array(2)] satisfies AlignedData,
+ getXIncrs(scale),
+ ];
+ }, [scale]);
+
+ useEffect(() => {
+ if (!uplotRef.current) return;
+ uplotRef.current.axes[0].incrs = () => xIncrs;
+ uplotRef.current.setData(chartData, true);
+ }, [chartData, xIncrs]);
+
+ const options = useMemo(() => {
+ return {
+ padding: [0, chartXPadding, 0, chartXPadding],
+ width: 0,
+ height: 0,
+ scales: {
+ x: { time: false },
+ y: {
+ time: false,
+ range: [0, 1],
+ },
+ },
+ series: [{}, {}],
+ cursor: {
+ show: false,
+ drag: {
+ // disable zoom
+ x: false,
+ y: false,
+ },
+ },
+ legend: { show: false },
+ axes: [
+ {
+ incrs: xIncrs,
+ size: 30,
+ ticks: {
+ opacity: 0.2,
+ stroke: chartAxisColor,
+ size: 5,
+ width: 1 / devicePixelRatio,
+ },
+ values: (_, ticks) =>
+ // special label for right-most tick
+ ticks.map((val) =>
+ val === 0 ? "now" : `${(val / 1_000).toFixed(1)}s`,
+ ),
+ grid: {
+ stroke: gridLineColor,
+ width: 1 / devicePixelRatio,
+ },
+ stroke: gridTicksColor,
+ },
+ {
+ size: 0,
+ values: () => [],
+ grid: {
+ filter: () => [0],
+ stroke: gridTicksColor,
+ width: 1,
+ },
+ },
+ ],
+ plugins: [shredsProgressionPlugin(isOnStartupScreen)],
+ };
+ }, [isOnStartupScreen, xIncrs]);
+
+ useRafLoop((time: number) => {
+ if (!uplotRef) return;
+ if (
+ lastRedrawRef.current == null ||
+ time - lastRedrawRef.current >= REDRAW_INTERVAL_MS
+ ) {
+ lastRedrawRef.current = time;
+ uplotRef.current?.redraw(true, false);
+ }
+ });
+
+ return (
+
+
+ {({ height, width }) => {
+ options.width = width;
+ options.height = height;
+ return (
+
+ );
+ }}
+
+
+ );
+}
diff --git a/src/features/Overview/ShredsProgression/ShredsTiles.tsx b/src/features/Overview/ShredsProgression/ShredsTiles.tsx
new file mode 100644
index 00000000..131dda64
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/ShredsTiles.tsx
@@ -0,0 +1,48 @@
+import { useState } from "react";
+import type { TileType } from "../../../api/types";
+import TileCard from "../SlotPerformance/TileCard";
+import styles from "../SlotPerformance/tilesPerformance.module.css";
+import { useTilesPerformance } from "../SlotPerformance/useTilesPerformance";
+import { useAtomValue } from "jotai";
+import { isStartupProgressVisibleAtom } from "../../StartupProgress/atoms";
+
+const tiles: TileType[] = [
+ "netlnk",
+ "metric",
+ "ipecho",
+ "gossvf",
+ "gossip",
+ "repair",
+ "replay",
+ "exec",
+ "tower",
+ "send",
+ "sign",
+ "rpc",
+ "gui",
+];
+export default function ShredTiles() {
+ const [_isExpanded, setIsExpanded] = useState(false);
+ const isStartupVisible = useAtomValue(isStartupProgressVisibleAtom);
+ const isExpanded = _isExpanded && !isStartupVisible;
+
+ const { tileCounts, groupedLiveIdlePerTile, showLive, queryIdleData } =
+ useTilesPerformance();
+
+ return (
+
+ {tiles.map((tile) => (
+
+ ))}
+
+ );
+}
diff --git a/src/features/Overview/ShredsProgression/__tests__/atoms.test.tsx b/src/features/Overview/ShredsProgression/__tests__/atoms.test.tsx
new file mode 100644
index 00000000..084f9109
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/__tests__/atoms.test.tsx
@@ -0,0 +1,448 @@
+import { expect, describe, it, afterEach, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { createLiveShredsAtoms } from "../atoms";
+import { Provider } from "jotai";
+import type { PropsWithChildren } from "react";
+import { ShredEvent } from "../../../../api/entities";
+import { xRangeMs, delayMs } from "../const";
+import { nsPerMs } from "../../../../consts";
+
+const emptyStoreWrapper = ({ children }: PropsWithChildren) => (
+ {children}
+);
+
+describe("live shreds atoms with reference ts and ts deltas", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("adds live shred events for single shred, replacing duplicates with min ts and ignoring unsupported event types", () => {
+ const atoms = createLiveShredsAtoms();
+
+ const { result } = renderHook(
+ () => {
+ const slotsShreds = useAtomValue(atoms.slotsShreds);
+ const range = useAtomValue(atoms.range);
+ const addShredEvents = useSetAtom(atoms.addShredEvents);
+ return { slotsShreds, range, addShredEvents };
+ },
+ { wrapper: emptyStoreWrapper },
+ );
+
+ // initial state
+ expect(result.current.slotsShreds).toBeUndefined();
+ expect(result.current.range).toBeUndefined();
+
+ // add initial shreds
+ act(() => {
+ result.current.addShredEvents({
+ reference_slot: 2000,
+ reference_ts: 123_000_000n,
+ slot_delta: [3, 3, 3, 3, 3, 3, 3],
+ shred_idx: [2, null, 2, 2, null, 2, 1],
+ event: [
+ ShredEvent.shred_received_repair,
+ ShredEvent.slot_complete,
+ ShredEvent.shred_repair_request,
+ ShredEvent.shred_repair_request,
+ ShredEvent.slot_complete,
+ ShredEvent.shred_replayed,
+ 99999, // unsupported event type
+ ],
+ event_ts_delta: [
+ 2_000_030, 4_123_456, 5_678_234, 8_000_000, 3_234_123, 7_345_456,
+ ],
+ });
+ });
+
+ expect(result.current.slotsShreds).toEqual({
+ referenceTs: 123,
+ slots: new Map([
+ [
+ 2003,
+ {
+ minEventTsDelta: 2,
+ maxEventTsDelta: 8,
+ completionTsDelta: 3,
+ shreds: [undefined, undefined, [6, undefined, 2, 7]],
+ },
+ ],
+ ]),
+ });
+ expect(result.current.range).toEqual({
+ min: 2003,
+ max: 2003,
+ });
+
+ act(() => {
+ result.current.addShredEvents({
+ reference_slot: 2002,
+ reference_ts: 124_100_000n,
+ slot_delta: [1, 0, 1],
+ shred_idx: [2, 1, 2],
+ event: [
+ ShredEvent.shred_repair_request,
+ ShredEvent.shred_received_turbine,
+ ShredEvent.shred_replayed,
+ ],
+ event_ts_delta: [1_000_030, 5_123_345, 2_345_231],
+ });
+ });
+
+ // uses inital reference ts
+ // update shred events with min ts
+ expect(result.current.slotsShreds).toEqual({
+ referenceTs: 123,
+ slots: new Map([
+ [
+ 2002,
+ {
+ minEventTsDelta: 6,
+ maxEventTsDelta: 6,
+ shreds: [undefined, [undefined, 6]],
+ },
+ ],
+ [
+ 2003,
+ {
+ minEventTsDelta: 2,
+ maxEventTsDelta: 8,
+ completionTsDelta: 3,
+ shreds: [undefined, undefined, [2, undefined, 2, 3]],
+ },
+ ],
+ ]),
+ });
+ expect(result.current.range).toEqual({
+ min: 2002,
+ max: 2003,
+ });
+ });
+
+ it("for non-startup: deletes slot numbers before max completed slot number that was completed after chart min X", () => {
+ vi.useFakeTimers({
+ toFake: ["Date"],
+ });
+ const chartRangeMs = xRangeMs + delayMs;
+ const chartRangeNs = chartRangeMs / nsPerMs;
+ const date = new Date(chartRangeMs);
+ vi.setSystemTime(date);
+
+ const atoms = createLiveShredsAtoms();
+
+ const { result } = renderHook(
+ () => {
+ const slotsShreds = useAtomValue(atoms.slotsShreds);
+ const range = useAtomValue(atoms.range);
+ const addShredEvents = useSetAtom(atoms.addShredEvents);
+ const deleteSlots = useSetAtom(atoms.deleteSlots);
+ return {
+ slotsShreds,
+ range,
+ addShredEvents,
+ deleteSlots,
+ };
+ },
+ { wrapper: emptyStoreWrapper },
+ );
+
+ const events = [
+ {
+ slot: 0,
+ ts: chartRangeNs - 1_000_000,
+ e: ShredEvent.shred_repair_request,
+ },
+ {
+ slot: 1,
+ ts: chartRangeNs + 1_000_000,
+ e: ShredEvent.slot_complete,
+ },
+ {
+ slot: 2,
+ // this will be deleted even if it has an event in chart range,
+ // because a slot number larger than it is marked as completed and being deleted
+ ts: chartRangeNs + 1_000_000,
+ e: ShredEvent.shred_repair_request,
+ },
+ {
+ // max slot number that is complete before chart min X
+ // delete this and all slot numbers before it
+ slot: 3,
+ ts: chartRangeNs - 1_000_000,
+ e: ShredEvent.slot_complete,
+ },
+ {
+ slot: 4,
+ // threshold of not being deleted
+ ts: chartRangeNs,
+ e: ShredEvent.slot_complete,
+ },
+ {
+ slot: 6,
+ ts: chartRangeNs + 2_000_000,
+ e: ShredEvent.shred_repair_request,
+ },
+ ];
+
+ // add initial shreds
+ act(() => {
+ result.current.addShredEvents({
+ reference_slot: 0,
+ reference_ts: 0n,
+ slot_delta: Object.values(events).map((v) => v.slot),
+ shred_idx: Object.values(events).map((v) => 0),
+ event: Object.values(events).map((v) => v.e),
+ event_ts_delta: Object.values(events).map((v) => v.ts),
+ });
+ });
+
+ expect(result.current.slotsShreds).toEqual({
+ referenceTs: 0,
+ slots: new Map([
+ [0, { shreds: [[-1]], minEventTsDelta: -1, maxEventTsDelta: -1 }],
+ [
+ 1,
+ {
+ shreds: [],
+ minEventTsDelta: 1,
+ maxEventTsDelta: 1,
+ completionTsDelta: 1,
+ },
+ ],
+ [2, { shreds: [[1]], minEventTsDelta: 1, maxEventTsDelta: 1 }],
+ [
+ 3,
+ {
+ shreds: [],
+ minEventTsDelta: -1,
+ maxEventTsDelta: -1,
+ completionTsDelta: -1,
+ },
+ ],
+ [
+ 4,
+ {
+ shreds: [],
+ minEventTsDelta: 0,
+ maxEventTsDelta: 0,
+ completionTsDelta: 0,
+ },
+ ],
+ [
+ 6,
+ {
+ shreds: [[2]],
+ minEventTsDelta: 2,
+ maxEventTsDelta: 2,
+ },
+ ],
+ ]),
+ });
+ expect(result.current.range).toEqual({
+ min: 0,
+ max: 6,
+ });
+
+ // delete old slots
+ act(() => {
+ result.current.deleteSlots(false, false);
+ });
+
+ expect(result.current.slotsShreds).toEqual({
+ referenceTs: 0,
+ slots: new Map([
+ [
+ 4,
+ {
+ shreds: [],
+ minEventTsDelta: 0,
+ maxEventTsDelta: 0,
+ completionTsDelta: 0,
+ },
+ ],
+ [
+ 6,
+ {
+ shreds: [[2]],
+ minEventTsDelta: 2,
+ maxEventTsDelta: 2,
+ },
+ ],
+ ]),
+ });
+ expect(result.current.range).toEqual({
+ min: 4,
+ max: 6,
+ });
+
+ // delete all
+ act(() => {
+ result.current.deleteSlots(true, false);
+ });
+
+ expect(result.current.slotsShreds).toBeUndefined();
+ expect(result.current.range).toBeUndefined();
+ });
+
+ it("for startup: deletes slots with events before chart x range", () => {
+ vi.useFakeTimers({
+ toFake: ["Date"],
+ });
+ const chartRangeMs = xRangeMs + delayMs;
+ const chartRangeNs = chartRangeMs / nsPerMs;
+ const date = new Date(chartRangeMs);
+ vi.setSystemTime(date);
+
+ const atoms = createLiveShredsAtoms();
+
+ const { result } = renderHook(
+ () => {
+ const slotsShreds = useAtomValue(atoms.slotsShreds);
+ const range = useAtomValue(atoms.range);
+ const addShredEvents = useSetAtom(atoms.addShredEvents);
+ const deleteSlots = useSetAtom(atoms.deleteSlots);
+ return {
+ slotsShreds,
+ range,
+ addShredEvents,
+ deleteSlots,
+ };
+ },
+ { wrapper: emptyStoreWrapper },
+ );
+
+ const events = [
+ {
+ slot: 0,
+ // deleted
+ ts: chartRangeNs - 1_000_000,
+ e: ShredEvent.shred_repair_request,
+ },
+ {
+ slot: 1,
+ // not deleted
+ ts: chartRangeNs + 1_000_000,
+ e: ShredEvent.slot_complete,
+ },
+ {
+ slot: 2,
+ // not deleted
+ ts: chartRangeNs + 1_000_000,
+ e: ShredEvent.shred_repair_request,
+ },
+ {
+ // deleted
+ slot: 3,
+ ts: chartRangeNs - 1_000_000,
+ e: ShredEvent.slot_complete,
+ },
+ {
+ slot: 4,
+ // threshold of not being deleted
+ ts: chartRangeNs,
+ e: ShredEvent.slot_complete,
+ },
+ ];
+
+ // add initial shreds
+ act(() => {
+ result.current.addShredEvents({
+ reference_slot: 0,
+ reference_ts: 0n,
+ slot_delta: Object.values(events).map((v) => v.slot),
+ shred_idx: Object.values(events).map((v) => 0),
+ event: Object.values(events).map((v) => v.e),
+ event_ts_delta: Object.values(events).map((v) => v.ts),
+ });
+ });
+
+ expect(result.current.slotsShreds).toEqual({
+ referenceTs: 0,
+ slots: new Map([
+ [0, { shreds: [[-1]], minEventTsDelta: -1, maxEventTsDelta: -1 }],
+ [
+ 1,
+ {
+ shreds: [],
+ minEventTsDelta: 1,
+ maxEventTsDelta: 1,
+ completionTsDelta: 1,
+ },
+ ],
+ [2, { shreds: [[1]], minEventTsDelta: 1, maxEventTsDelta: 1 }],
+ [
+ 3,
+ {
+ shreds: [],
+ minEventTsDelta: -1,
+ maxEventTsDelta: -1,
+ completionTsDelta: -1,
+ },
+ ],
+ [
+ 4,
+ {
+ shreds: [],
+ minEventTsDelta: 0,
+ maxEventTsDelta: 0,
+ completionTsDelta: 0,
+ },
+ ],
+ ]),
+ });
+ expect(result.current.range).toEqual({
+ min: 0,
+ max: 4,
+ });
+
+ // delete old slots
+ act(() => {
+ result.current.deleteSlots(false, true);
+ });
+
+ expect(result.current.slotsShreds).toEqual({
+ referenceTs: 0,
+ slots: new Map([
+ [
+ 1,
+ {
+ shreds: [],
+ minEventTsDelta: 1,
+ maxEventTsDelta: 1,
+ completionTsDelta: 1,
+ },
+ ],
+ [
+ 2,
+ {
+ shreds: [[1]],
+ minEventTsDelta: 1,
+ maxEventTsDelta: 1,
+ },
+ ],
+ [
+ 4,
+ {
+ shreds: [],
+ minEventTsDelta: 0,
+ maxEventTsDelta: 0,
+ completionTsDelta: 0,
+ },
+ ],
+ ]),
+ });
+ expect(result.current.range).toEqual({
+ min: 1,
+ max: 4,
+ });
+
+ // delete all
+ act(() => {
+ result.current.deleteSlots(true, true);
+ });
+
+ expect(result.current.slotsShreds).toBeUndefined();
+ expect(result.current.range).toBeUndefined();
+ });
+});
diff --git a/src/features/Overview/ShredsProgression/atoms.ts b/src/features/Overview/ShredsProgression/atoms.ts
new file mode 100644
index 00000000..c0f53d1c
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/atoms.ts
@@ -0,0 +1,286 @@
+import { atom } from "jotai";
+import type { LiveShreds } from "../../../api/types";
+import { maxShredEvent, ShredEvent } from "../../../api/entities";
+import { delayMs, xRangeMs } from "./const";
+import { nsPerMs } from "../../../consts";
+
+type ShredEventTsDeltaMs = number | undefined;
+/**
+ * Array of .
+ * Array index, i corresponds to the shred event type.
+ * The ts delta is relative to the referenceTs.
+ */
+export type ShredEventTsDeltas = ShredEventTsDeltaMs[];
+
+type Slot = {
+ shreds: (ShredEventTsDeltas | undefined)[];
+ minEventTsDelta?: number;
+ maxEventTsDelta?: number;
+ completionTsDelta?: number;
+};
+
+export type SlotsShreds = {
+ referenceTs: number;
+ // slot number to Slot
+ slots: Map;
+};
+
+/**
+ * Store live shreds
+ * Use reference / delta slot number and timestamp to minimize memory usage
+ */
+export function createLiveShredsAtoms() {
+ const _minCompletedSlotAtom = atom();
+ const _liveShredsAtom = atom();
+ const _slotRangeAtom = atom<{
+ min: number;
+ max: number;
+ }>();
+ return {
+ /**
+ * min completed slot we've seen since we started collecting data
+ */
+ minCompletedSlot: atom((get) => get(_minCompletedSlotAtom)),
+ range: atom((get) => get(_slotRangeAtom)),
+ slotsShreds: atom((get) => get(_liveShredsAtom)),
+ addShredEvents: atom(
+ null,
+ (
+ get,
+ set,
+ {
+ reference_slot,
+ reference_ts,
+ slot_delta,
+ shred_idx,
+ event,
+ event_ts_delta,
+ }: LiveShreds,
+ ) => {
+ let slotRange = get(_slotRangeAtom);
+ const minCompletedSlot = get(_minCompletedSlotAtom);
+ let newMinCompletedSlot = minCompletedSlot;
+
+ set(_liveShredsAtom, (prev) => {
+ const updated: SlotsShreds = prev ?? {
+ referenceTs: Math.round(Number(reference_ts) / nsPerMs),
+ slots: new Map(),
+ };
+
+ for (let i = 0; i < event.length; i++) {
+ const ev = event[i];
+ // unsupported event type
+ if (ev > maxShredEvent) continue;
+
+ if (slot_delta[i] == null || event_ts_delta[i] == null) {
+ console.error(`invalid shred data arrays, missing index ${i}`);
+ break;
+ }
+
+ const slotNumber = reference_slot + slot_delta[i];
+ const shredIdx = shred_idx[i];
+
+ // convert to current reference and delta
+ const eventTsDelta = Math.round(
+ (Number(reference_ts) + event_ts_delta[i]) / nsPerMs -
+ updated.referenceTs,
+ );
+
+ // add event to slot shred
+ updated.slots.set(
+ slotNumber,
+ addEventToSlot(
+ shredIdx,
+ ev,
+ eventTsDelta,
+ updated.slots.get(slotNumber),
+ ),
+ );
+
+ if (ev === ShredEvent.slot_complete) {
+ newMinCompletedSlot = Math.min(
+ slotNumber,
+ minCompletedSlot ?? slotNumber,
+ );
+ }
+
+ // update range
+ slotRange = {
+ min: Math.min(slotNumber, slotRange?.min ?? slotNumber),
+ max: Math.max(slotNumber, slotRange?.max ?? slotNumber),
+ };
+ }
+
+ return updated;
+ });
+
+ set(_slotRangeAtom, slotRange);
+ set(_minCompletedSlotAtom, newMinCompletedSlot);
+ },
+ ),
+
+ deleteSlots:
+ /**
+ * Delete slots that completed before the chart x-axis starting time, or with dots outside visible x range
+ * Update the min slot
+ */
+ atom(null, (get, set, deleteAll: boolean, isStartup: boolean) => {
+ if (deleteAll) {
+ set(_slotRangeAtom, undefined);
+ set(_minCompletedSlotAtom, undefined);
+ set(_liveShredsAtom, undefined);
+ return;
+ }
+
+ set(_liveShredsAtom, (prev) => {
+ const slotRange = get(_slotRangeAtom);
+
+ if (!prev || !slotRange) return prev;
+
+ const now = new Date().getTime();
+
+ if (isStartup) {
+ // During startup, we only show event dots, not spans. Delete slots without events in chart view
+ for (
+ let slotNumber = slotRange.min;
+ slotNumber <= slotRange.max;
+ slotNumber++
+ ) {
+ const slot = prev.slots.get(slotNumber);
+ if (!slot) continue;
+ if (
+ slot.maxEventTsDelta == null ||
+ isBeforeChartX(slot.maxEventTsDelta, now, prev.referenceTs)
+ ) {
+ prev.slots.delete(slotNumber);
+ }
+ }
+ } else {
+ // After startup complete
+ let minSlot = slotRange.min;
+ if (slotRange.max - slotRange.min > 50) {
+ // only keep 50 slots
+ for (
+ let slotNumber = minSlot;
+ slotNumber <= slotRange.max - 50;
+ slotNumber++
+ ) {
+ const slot = prev.slots.get(slotNumber);
+ if (!slot) continue;
+ prev.slots.delete(slotNumber);
+ }
+
+ minSlot = slotRange.max - 50;
+ }
+
+ let shouldDeleteSlot = false;
+ for (
+ let slotNumber = slotRange.max;
+ slotNumber >= minSlot;
+ slotNumber--
+ ) {
+ const slot = prev.slots.get(slotNumber);
+ if (slot?.maxEventTsDelta == null) continue;
+
+ if (
+ !shouldDeleteSlot &&
+ slot.completionTsDelta != null &&
+ isBeforeChartX(slot.completionTsDelta, now, prev.referenceTs)
+ ) {
+ // once we find a slot that is complete and far enough in the past, delete all slot numbers less it
+ shouldDeleteSlot = true;
+ }
+
+ if (shouldDeleteSlot) {
+ prev.slots.delete(slotNumber);
+ }
+ }
+ }
+
+ // update range to reflect remaining slots
+ const remainingSlotNumbers = prev.slots.keys();
+ set(_slotRangeAtom, (prevRange) => {
+ if (!prevRange || !prev.slots.size) {
+ return;
+ }
+ prevRange.min = Math.min(...remainingSlotNumbers);
+ return prevRange;
+ });
+
+ return prev;
+ });
+ }),
+ };
+}
+
+function isBeforeChartX(tsDelta: number, now: number, referenceTs: number) {
+ const nowDelta = now - referenceTs;
+ const chartXRange = xRangeMs + delayMs;
+ return nowDelta - tsDelta > chartXRange;
+}
+
+export const shredsAtoms = createLiveShredsAtoms();
+
+/**
+ * Mutate shred by adding an event ts to event index
+ */
+function addEventToShred(
+ event: Exclude,
+ eventTsDelta: number,
+ shredToMutate: ShredEventTsDeltas | undefined,
+): ShredEventTsDeltas {
+ const shred = shredToMutate ?? new Array();
+
+ // in case of duplicate events, keep the min ts
+ shred[event] = Math.min(eventTsDelta, shred[event] ?? eventTsDelta);
+
+ return shred;
+}
+
+/**
+ * Mutate slot by marking as complete, or adding an event to the shreds array
+ */
+function addEventToSlot(
+ shredIdx: number | null,
+ event: ShredEvent,
+ eventTsDelta: number,
+ slotToMutate: Slot | undefined,
+): Slot {
+ const slot = slotToMutate ?? {
+ shreds: [],
+ };
+
+ // update slot min event ts
+ slot.minEventTsDelta = Math.min(
+ eventTsDelta,
+ slot.minEventTsDelta ?? eventTsDelta,
+ );
+
+ // update slot max event ts
+ slot.maxEventTsDelta = Math.max(
+ eventTsDelta,
+ slot.maxEventTsDelta ?? eventTsDelta,
+ );
+
+ if (event === ShredEvent.slot_complete) {
+ slot.completionTsDelta = Math.min(
+ eventTsDelta,
+ slot.completionTsDelta ?? eventTsDelta,
+ );
+ return slot;
+ }
+
+ if (shredIdx == null) {
+ console.error("Missing shred ID");
+ return slot;
+ }
+
+ // update shred
+ slot.shreds[shredIdx] = addEventToShred(
+ event,
+ eventTsDelta,
+ slot.shreds[shredIdx],
+ );
+
+ return slot;
+}
diff --git a/src/features/Overview/ShredsProgression/const.ts b/src/features/Overview/ShredsProgression/const.ts
new file mode 100644
index 00000000..4110c751
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/const.ts
@@ -0,0 +1,20 @@
+import { ShredEvent } from "../../../api/entities";
+
+export const xRangeMs = 10_000;
+export const delayMs = 50;
+
+/**
+ * Draw highest to lowest priority events.
+ * Ignore lower priority events that overlap.
+ */
+export const shredEventDescPriorities: Exclude<
+ ShredEvent,
+ ShredEvent.slot_complete
+>[] = [
+ ShredEvent.shred_published,
+ ShredEvent.shred_replayed,
+ ShredEvent.shred_replay_start,
+ ShredEvent.shred_received_repair,
+ ShredEvent.shred_received_turbine,
+ ShredEvent.shred_repair_request,
+];
diff --git a/src/features/Overview/ShredsProgression/index.tsx b/src/features/Overview/ShredsProgression/index.tsx
new file mode 100644
index 00000000..bc006e3a
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/index.tsx
@@ -0,0 +1,29 @@
+import { Box, Flex } from "@radix-ui/themes";
+import Card from "../../../components/Card";
+import CardHeader from "../../../components/CardHeader";
+import ShredsTiles from "./ShredsTiles";
+import { useAtomValue } from "jotai";
+import { ClientEnum } from "../../../api/entities";
+import { clientAtom } from "../../../atoms";
+import ShredsChart from "./ShredsChart";
+
+export default function ShredsProgression() {
+ const client = useAtomValue(clientAtom);
+
+ if (client !== ClientEnum.Firedancer) return;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts
new file mode 100644
index 00000000..9e8518b0
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts
@@ -0,0 +1,386 @@
+import type uPlot from "uplot";
+import { getDefaultStore } from "jotai";
+import {
+ shredsAtoms,
+ type ShredEventTsDeltas,
+ type SlotsShreds,
+} from "./atoms";
+import { delayMs, shredEventDescPriorities } from "./const";
+import { showStartupProgressAtom } from "../../StartupProgress/atoms";
+import {
+ shredPublishedColor,
+ shredReceivedRepairColor,
+ shredReceivedTurbineColor,
+ shredRepairRequestedColor,
+ shredReplayedNothingColor,
+ shredReplayedRepairColor,
+ shredReplayedTurbineColor,
+ shredReplayStartedColor,
+ shredSkippedColor,
+} from "../../../colors";
+import { skippedClusterSlotsAtom } from "../../../atoms";
+import { clamp } from "lodash";
+import { ShredEvent } from "../../../api/entities";
+
+const store = getDefaultStore();
+const xScaleKey = "x";
+
+type EventsByFillStyle = {
+ [fillStyle: string]: Array<[x: number, y: number, width: number]>;
+};
+
+export function shredsProgressionPlugin(
+ isOnStartupScreen: boolean,
+): uPlot.Plugin {
+ return {
+ hooks: {
+ draw: [
+ (u) => {
+ const atoms = shredsAtoms;
+
+ const liveShreds = store.get(atoms.slotsShreds);
+ const slotRange = store.get(atoms.range);
+ const minCompletedSlot = store.get(atoms.minCompletedSlot);
+ const skippedSlotsCluster = store.get(skippedClusterSlotsAtom);
+
+ if (!liveShreds || !slotRange) {
+ return;
+ }
+
+ if (!isOnStartupScreen) {
+ // if startup is running, prevent drawing non-startup screen chart
+ if (store.get(showStartupProgressAtom)) return;
+ // Sometimes we've missed the completion event for the first slots
+ // depending on connection time. Ignore those slots, and only draw slots
+ // from min completed.
+ if (minCompletedSlot == null) return;
+ }
+
+ // Offset to convert shred event delta to chart x value
+ const delayedNow = new Date().getTime() - delayMs;
+ const tsXValueOffset = delayedNow - liveShreds.referenceTs;
+
+ const minSlot = isOnStartupScreen
+ ? slotRange.min
+ : Math.max(slotRange.min, minCompletedSlot ?? slotRange.min);
+ const maxSlot = slotRange.max;
+
+ u.ctx.save();
+ u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
+ u.ctx.clip();
+
+ // helper to get x pos
+ const getXPos = (xVal: number) => u.valToPos(xVal, xScaleKey, true);
+
+ const { maxShreds, orderedSlotNumbers } = getDrawInfo(
+ minSlot,
+ maxSlot,
+ liveShreds,
+ u.scales[xScaleKey],
+ tsXValueOffset,
+ );
+
+ const canvasHeight = isOnStartupScreen
+ ? Math.trunc(u.bbox.height / 3)
+ : u.bbox.height;
+
+ const getYOffset = isOnStartupScreen
+ ? (eventType: Exclude) => {
+ switch (eventType) {
+ case ShredEvent.shred_received_turbine:
+ case ShredEvent.shred_published: {
+ return 0;
+ }
+ case ShredEvent.shred_repair_request:
+ case ShredEvent.shred_received_repair: {
+ return canvasHeight;
+ }
+ case ShredEvent.shred_replay_start:
+ case ShredEvent.shred_replayed: {
+ return canvasHeight * 2;
+ }
+ }
+ }
+ : undefined;
+
+ // each row is at least 1 px
+ const rowPxHeight = clamp(canvasHeight / maxShreds, 1, 10);
+ const gapPxHeight = 1;
+
+ // n rows, n-1 gaps
+ const rowsCount = Math.trunc(
+ (canvasHeight + gapPxHeight) / (rowPxHeight + gapPxHeight),
+ );
+ const shredsPerRow = maxShreds / rowsCount;
+
+ for (const slotNumber of orderedSlotNumbers) {
+ const eventsByFillStyle: EventsByFillStyle = {};
+ const addEventPosition = (
+ fillStyle: string,
+ position: [x: number, y: number, width: number],
+ ) => {
+ eventsByFillStyle[fillStyle] ??= [];
+ eventsByFillStyle[fillStyle].push(position);
+ };
+
+ const slot = liveShreds.slots.get(slotNumber);
+ if (!slot) continue;
+
+ const isSlotSkipped = skippedSlotsCluster.has(slotNumber);
+
+ for (let rowIdx = 0; rowIdx < rowsCount; rowIdx++) {
+ const shredsAboveRow = rowIdx * shredsPerRow;
+ const firstShredIdx = Math.trunc(shredsAboveRow);
+
+ const shredsAboveOrInRow = (rowIdx + 1) * shredsPerRow;
+ const lastShredIdx = Math.min(
+ maxShreds,
+ Math.ceil(shredsAboveOrInRow) - 1,
+ );
+
+ addEventsForRow({
+ addEventPosition,
+ u,
+ firstShredIdx,
+ lastShredIdx,
+ shreds: slot.shreds,
+ slotCompletionTsDelta: slot.completionTsDelta,
+ isSlotSkipped,
+ drawOnlyDots: isOnStartupScreen,
+ tsXValueOffset,
+ y: (rowPxHeight + gapPxHeight) * rowIdx + u.bbox.top,
+ getYOffset,
+ dotWidth: rowPxHeight,
+ scaleX: u.scales[xScaleKey],
+ getXPos,
+ });
+ }
+
+ // draw events, one fillStyle at a time for this slot
+ for (const fillStyle of Object.keys(eventsByFillStyle)) {
+ u.ctx.beginPath();
+ u.ctx.fillStyle = fillStyle;
+ for (const [x, y, width] of eventsByFillStyle[fillStyle]) {
+ u.ctx.rect(x, y, width, rowPxHeight);
+ }
+ u.ctx.fill();
+ }
+ }
+
+ u.ctx.restore();
+ },
+ ],
+ },
+ };
+}
+
+/**
+ * Get slots in draw order
+ * and max shreds count per slot for scaling
+ */
+const getDrawInfo = (
+ minSlotNumber: number,
+ maxSlotNumber: number,
+ liveShreds: SlotsShreds,
+ scaleX: uPlot.Scale,
+ tsXValueOffset: number,
+) => {
+ const orderedSlotNumbers = [];
+ let maxShreds = 0;
+
+ for (
+ let slotNumber = minSlotNumber;
+ slotNumber <= maxSlotNumber;
+ slotNumber++
+ ) {
+ const slot = liveShreds.slots.get(slotNumber);
+ if (!slot || !slot.shreds.length || slot.minEventTsDelta == null) {
+ // slot has no events
+ continue;
+ }
+
+ if (
+ scaleX.max != null &&
+ slot.minEventTsDelta - tsXValueOffset > scaleX.max
+ ) {
+ // slot started after chart max X
+ continue;
+ }
+
+ if (
+ scaleX.min != null &&
+ slot.completionTsDelta != null &&
+ slot.completionTsDelta - tsXValueOffset < scaleX.min
+ ) {
+ // slot completed before chart min X
+ continue;
+ }
+
+ orderedSlotNumbers.push(slotNumber);
+ maxShreds = Math.max(maxShreds, slot.shreds.length);
+ }
+
+ return {
+ maxShreds,
+ orderedSlotNumbers,
+ };
+};
+
+interface AddEventsForRowArgs {
+ addEventPosition: (
+ fillStyle: string,
+ position: [x: number, y: number, width: number],
+ ) => void;
+ u: uPlot;
+ firstShredIdx: number;
+ lastShredIdx: number;
+ shreds: (ShredEventTsDeltas | undefined)[];
+ slotCompletionTsDelta: number | undefined;
+ isSlotSkipped: boolean;
+ drawOnlyDots: boolean;
+ tsXValueOffset: number;
+ y: number;
+ getYOffset?: (
+ eventType: Exclude,
+ ) => number;
+ dotWidth: number;
+ scaleX: uPlot.Scale;
+ getXPos: (xVal: number) => number;
+}
+/**
+ * Draw rows for shreds, with rectangles or dots for events.
+ * Each row may represent partial or multiple shreds. Use the most completed shred.
+ */
+function addEventsForRow({
+ addEventPosition,
+ u,
+ firstShredIdx,
+ lastShredIdx,
+ shreds,
+ slotCompletionTsDelta,
+ tsXValueOffset,
+ drawOnlyDots,
+ isSlotSkipped,
+ y,
+ getYOffset,
+ dotWidth,
+ scaleX,
+ getXPos,
+}: AddEventsForRowArgs) {
+ if (scaleX.max == null || scaleX.min == null) return;
+
+ const shredIdx = getMostCompletedShredIdx(
+ firstShredIdx,
+ lastShredIdx,
+ shreds,
+ );
+
+ const eventTsDeltas = shreds[shredIdx];
+ if (!eventTsDeltas) return;
+
+ const maxXPos = u.bbox.left + u.bbox.width;
+ let endXPos: number =
+ slotCompletionTsDelta == null
+ ? // event goes to max x
+ maxXPos
+ : // event goes to slot completion or max x
+ Math.min(getXPos(slotCompletionTsDelta - tsXValueOffset), maxXPos);
+
+ const eventPositions = new Map<
+ Exclude,
+ [x: number, y: number, width: number]
+ >();
+
+ // draw events from highest to lowest priority
+ for (const eventType of shredEventDescPriorities) {
+ const tsDelta = eventTsDeltas[eventType];
+ if (tsDelta == null) continue;
+
+ const startXVal = tsDelta - tsXValueOffset;
+ const startXPos = getXPos(startXVal);
+
+ // ignore overlapping events with lower priority
+ if (startXPos >= endXPos) continue;
+
+ const yOffset = getYOffset?.(eventType) ?? 0;
+
+ eventPositions.set(
+ eventType,
+ drawOnlyDots || isSlotSkipped
+ ? [startXPos, y + yOffset, dotWidth]
+ : [startXPos, y + yOffset, endXPos - startXPos],
+ );
+ endXPos = startXPos;
+ }
+
+ for (const [eventType, position] of eventPositions.entries()) {
+ if (isSlotSkipped) {
+ addEventPosition(shredSkippedColor, position);
+ continue;
+ }
+ switch (eventType) {
+ case ShredEvent.shred_repair_request: {
+ addEventPosition(shredRepairRequestedColor, position);
+ break;
+ }
+ case ShredEvent.shred_received_turbine: {
+ addEventPosition(shredReceivedTurbineColor, position);
+ break;
+ }
+ case ShredEvent.shred_received_repair: {
+ addEventPosition(shredReceivedRepairColor, position);
+ break;
+ }
+ case ShredEvent.shred_replay_start: {
+ addEventPosition(shredReplayStartedColor, position);
+ break;
+ }
+ case ShredEvent.shred_replayed: {
+ if (eventPositions.has(ShredEvent.shred_received_repair)) {
+ addEventPosition(shredReplayedRepairColor, position);
+ } else if (eventPositions.has(ShredEvent.shred_received_turbine)) {
+ addEventPosition(shredReplayedTurbineColor, position);
+ } else {
+ addEventPosition(shredReplayedNothingColor, position);
+ }
+ break;
+ }
+ case ShredEvent.shred_published: {
+ addEventPosition(shredPublishedColor, position);
+ }
+ }
+ }
+}
+
+function getMostCompletedShredIdx(
+ firstShredIdx: number,
+ lastShredIdx: number,
+ shreds: (ShredEventTsDeltas | undefined)[],
+): number {
+ for (const shredEvent of shredEventDescPriorities) {
+ const shredIdx = findShredIdx(
+ firstShredIdx,
+ lastShredIdx,
+ shreds,
+ (shred: ShredEventTsDeltas | undefined) => shred?.[shredEvent] != null,
+ );
+ if (shredIdx !== -1) return shredIdx;
+ }
+ return firstShredIdx;
+}
+
+/**
+ * Find first shred index that satisfies the condition.
+ * Returns -1 if no shred passes the condition.
+ */
+function findShredIdx(
+ firstShredIdx: number,
+ lastShredIdx: number,
+ shreds: (ShredEventTsDeltas | undefined)[],
+ condition: (shred: ShredEventTsDeltas | undefined) => boolean,
+) {
+ for (let shredIdx = firstShredIdx; shredIdx < lastShredIdx; shredIdx++) {
+ if (condition(shreds[shredIdx])) return shredIdx;
+ }
+ return -1;
+}
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChart.tsx b/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChart.tsx
index 003b15d2..4a8a19a6 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChart.tsx
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChart.tsx
@@ -10,10 +10,10 @@ import { timeScaleDragPlugin } from "../TransactionBarsCard/scaleDragPlugin";
import { cuRefAreaPlugin } from "./cuRefAreaPlugin";
import { startLinePlugin } from "./startLinePlugin";
import {
- bankScaleKey,
+ bankCountScaleKey,
computeUnitsScaleKey,
lamportsScaleKey,
- xScaleKey,
+ banksXScaleKey,
} from "./consts";
import {
cuChartTooltipDataAtom,
@@ -176,12 +176,12 @@ export default function CuChart({
drawOrder: ["axes", "series"] as uPlot.DrawOrderKey[],
cursor: {
sync: {
- key: xScaleKey,
+ key: banksXScaleKey,
},
points: { show: false },
},
scales: {
- x: {
+ [banksXScaleKey]: {
time: false,
},
[computeUnitsScaleKey]: {
@@ -197,7 +197,7 @@ export default function CuChart({
];
},
},
- [bankScaleKey]: {
+ [bankCountScaleKey]: {
range: [0, maxBankCount + 1],
},
[lamportsScaleKey]: {
@@ -322,14 +322,16 @@ export default function CuChart({
},
],
series: [
- {},
+ {
+ scale: banksXScaleKey,
+ },
{
label: "Active Bank",
stroke: "rgba(117, 77, 18, 1)",
paths,
points: { show: false },
width: 2 / devicePixelRatio,
- scale: bankScaleKey,
+ scale: bankCountScaleKey,
},
{
label: "Compute Units",
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChartActions.tsx b/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChartActions.tsx
index fd77f326..63c9510e 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChartActions.tsx
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChartActions.tsx
@@ -1,7 +1,7 @@
import { Button, Flex, IconButton, Separator } from "@radix-ui/themes";
import { ZoomInIcon, ZoomOutIcon, ResetIcon } from "@radix-ui/react-icons";
import styles from "./cuChartActions.module.css";
-import { xScaleKey } from "./consts";
+import { banksXScaleKey } from "./consts";
import { useAtomValue } from "jotai";
import { isFullXRangeAtom } from "./atoms";
@@ -20,14 +20,14 @@ export default function CuChartActions({ onUplot }: CuChartActionsProps) {
variant="soft"
onClick={() =>
onUplot((u) => {
- const min = u.scales[xScaleKey].min ?? 0;
- const max = u.scales[xScaleKey].max ?? 0;
+ const min = u.scales[banksXScaleKey].min ?? 0;
+ const max = u.scales[banksXScaleKey].max ?? 0;
const range = max - min;
if (range <= 0) return;
const zoomDiff = range * 0.2;
- u.setScale(xScaleKey, {
+ u.setScale(banksXScaleKey, {
min: min + zoomDiff,
max: max - zoomDiff,
});
@@ -43,14 +43,14 @@ export default function CuChartActions({ onUplot }: CuChartActionsProps) {
onUplot((u) => {
const scaleMin = u.data[0][0];
const scaleMax = u.data[0].at(-1) ?? scaleMin;
- const min = u.scales[xScaleKey].min ?? 0;
- const max = u.scales[xScaleKey].max ?? 0;
+ const min = u.scales[banksXScaleKey].min ?? 0;
+ const max = u.scales[banksXScaleKey].max ?? 0;
const range = max - min;
if (range <= 0) return;
const zoomDiff = range * 0.2;
- u.setScale(xScaleKey, {
+ u.setScale(banksXScaleKey, {
min: Math.max(min - zoomDiff, scaleMin),
max: Math.min(max + zoomDiff, scaleMax),
});
@@ -65,7 +65,7 @@ export default function CuChartActions({ onUplot }: CuChartActionsProps) {
variant="soft"
onClick={() =>
onUplot((u) =>
- u.setScale(xScaleKey, {
+ u.setScale(banksXScaleKey, {
min: u.data[0][0],
max: u.data[0].at(-1) ?? 0,
}),
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChartStartLineIcon.tsx b/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChartStartLineIcon.tsx
index edda4898..12840a59 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChartStartLineIcon.tsx
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChartStartLineIcon.tsx
@@ -1,8 +1,6 @@
-import { Tooltip } from "@radix-ui/themes";
import styles from "./cuChartIcon.module.css";
-import { startLineColor } from "../../../../colors";
import { ScheduleStrategyEnum } from "../../../../api/entities";
-import { scheduleStrategyIcons } from "../../../../strategyIcons";
+import { ScheduleStrategyIcon } from "../../../../components/ScheduleStrategyIcon";
export const startLineIconId = "cu-chart-info-icon";
@@ -11,18 +9,11 @@ export const iconSize = 16;
export default function CuChartStartLineIcon() {
return (
-
-
- {scheduleStrategyIcons[ScheduleStrategyEnum.revenue]}
-
-
+
);
}
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/consts.ts b/src/features/Overview/SlotPerformance/ComputeUnitsCard/consts.ts
index 790dc8ae..6b9acb1a 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/consts.ts
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/consts.ts
@@ -1,4 +1,4 @@
-export const bankScaleKey = "banks";
+export const bankCountScaleKey = "banks";
export const lamportsScaleKey = "lamports";
export const computeUnitsScaleKey = "computeUnits";
-export const xScaleKey = "x";
+export const banksXScaleKey = "banksXScale";
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/cuIsFullXRangePlugin.ts b/src/features/Overview/SlotPerformance/ComputeUnitsCard/cuIsFullXRangePlugin.ts
index d65d2402..83595c40 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/cuIsFullXRangePlugin.ts
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/cuIsFullXRangePlugin.ts
@@ -1,6 +1,7 @@
import { getDefaultStore } from "jotai";
import type uPlot from "uplot";
import { isFullXRangeAtom } from "./atoms";
+import { banksXScaleKey } from "./consts";
const store = getDefaultStore();
@@ -10,13 +11,14 @@ export function cuIsFullXRangePlugin(): uPlot.Plugin {
return {
hooks: {
ready(u) {
- xMin = u.scales.x.min ?? 0;
- xMax = u.scales.x.max ?? 0;
+ xMin = u.scales[banksXScaleKey].min ?? 0;
+ xMax = u.scales[banksXScaleKey].max ?? 0;
},
setScale(u) {
store.set(
isFullXRangeAtom,
- u.scales.x.min === xMin && u.scales.x.max === xMax,
+ u.scales[banksXScaleKey].min === xMin &&
+ u.scales[banksXScaleKey].max === xMax,
);
},
},
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/cuRefAreaPlugin.ts b/src/features/Overview/SlotPerformance/ComputeUnitsCard/cuRefAreaPlugin.ts
index 3dea4bcd..dd035a41 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/cuRefAreaPlugin.ts
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/cuRefAreaPlugin.ts
@@ -1,7 +1,7 @@
import uPlot from "uplot";
import type { SlotTransactions } from "../../../../api/types";
import type { RefObject } from "react";
-import { computeUnitsScaleKey, xScaleKey } from "./consts";
+import { computeUnitsScaleKey, banksXScaleKey } from "./consts";
import { round } from "lodash";
import { getDefaultStore } from "jotai";
import { showChartProjectionsAtom } from "./atoms";
@@ -330,7 +330,7 @@ export function cuRefAreaPlugin({
const refLines = onlyMaxCu
? []
: getRefLinesWithinScales(
- u.scales[xScaleKey],
+ u.scales[banksXScaleKey],
u.scales[computeUnitsScaleKey],
slotTransactions,
refLineMaxComputeUnits,
@@ -340,8 +340,11 @@ export function cuRefAreaPlugin({
// Adding a max CU line unrelated to bank count
refLines.unshift({
line: [
- { x: u.scales[xScaleKey].min ?? 0, y: maxComputeUnits },
- { x: u.scales[xScaleKey].max ?? 450_000_000, y: maxComputeUnits },
+ { x: u.scales[banksXScaleKey].min ?? 0, y: maxComputeUnits },
+ {
+ x: u.scales[banksXScaleKey].max ?? 450_000_000,
+ y: maxComputeUnits,
+ },
],
bankCount: 0,
});
@@ -358,11 +361,11 @@ export function cuRefAreaPlugin({
// draw lines and labels
for (let i = 0; i < refLines.length; i++) {
const { line, bankCount } = refLines[i];
- const x0 = Math.round(u.valToPos(line[0].x, xScaleKey, true));
+ const x0 = Math.round(u.valToPos(line[0].x, banksXScaleKey, true));
const y0 = Math.round(
u.valToPos(line[0].y, computeUnitsScaleKey, true),
);
- const x1 = Math.round(u.valToPos(line[1].x, xScaleKey, true));
+ const x1 = Math.round(u.valToPos(line[1].x, banksXScaleKey, true));
const y1 = Math.round(
u.valToPos(line[1].y, computeUnitsScaleKey, true),
);
@@ -474,7 +477,8 @@ export function cuRefAreaPlugin({
u.scales[computeUnitsScaleKey].max ??
0 - (u.scales[computeUnitsScaleKey].min ?? 0);
const midTs =
- u.scales[xScaleKey].max ?? 0 - (u.scales[xScaleKey].min ?? 0);
+ u.scales[banksXScaleKey].max ??
+ 0 - (u.scales[banksXScaleKey].min ?? 0);
// scale shown is between reference lines
const tEnd = Number(
slotTransactions.target_end_timestamp_nanos -
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx b/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx
index e265fd0b..524616a6 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx
@@ -35,7 +35,7 @@ export default function ComputeUnitsCard() {
return (
<>
-
+
@@ -52,11 +52,11 @@ export default function ComputeUnitsCard() {
bankTileCount={bankTileCount}
onCreate={handleCreate}
/>
+