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..25a8045c
--- /dev/null
+++ b/src/features/Navigation/SlotsRenderer.tsx
@@ -0,0 +1,418 @@
+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" &&
+ !queryPublish.publish?.skipped &&
+ (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..4391ffb8
--- /dev/null
+++ b/src/features/Navigation/slotsRenderer.module.css
@@ -0,0 +1,209 @@
+.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: 100%;
+ transform-origin: bottom;
+ will-change: transform;
+ 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 {
+ transform: scaleY(0);
+ }
+ to {
+ transform: scaleY(1);
+ }
+}
+
+@keyframes shimmer {
+ from {
+ transform: translateX(0%);
+ }
+ to {
+ transform: translateX(200%);
+ }
+}
+
+.scroll-slots-placeholder {
+ background: var(--slots-list-slot-background-color);
+
+ .absolute-full-size {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ .shimmer {
+ margin-left: -100%;
+ will-change: transform;
+ 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;
+ }
+}
+
+.scroll-placeholder-item {
+ height: 46px;
+ margin-bottom: 5px;
+ border-radius: 5px;
+ box-shadow: 0 0 0 4px var(--slot-nav-background-color);
+}
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/LiveNetworkMetrics/consts.ts b/src/features/Overview/LiveNetworkMetrics/consts.ts
new file mode 100644
index 00000000..7829beb1
--- /dev/null
+++ b/src/features/Overview/LiveNetworkMetrics/consts.ts
@@ -0,0 +1,7 @@
+export const networkProtocols = [
+ "turbine",
+ "gossip",
+ "tpu",
+ "repair",
+ "metrics",
+];
diff --git a/src/features/Overview/LiveNetworkMetrics/index.tsx b/src/features/Overview/LiveNetworkMetrics/index.tsx
new file mode 100644
index 00000000..357a1e1b
--- /dev/null
+++ b/src/features/Overview/LiveNetworkMetrics/index.tsx
@@ -0,0 +1,137 @@
+import { useAtomValue } from "jotai";
+import { liveNetworkMetricsAtom } from "../../../api/atoms";
+import Card from "../../../components/Card";
+import { Flex, Table, Text } from "@radix-ui/themes";
+import tableStyles from "../../Gossip/table.module.css";
+import { useEmaValue } from "../../../hooks/useEma";
+import { networkProtocols } from "./consts";
+import { formatBytesAsBits } from "../../../utils";
+import { Bars } from "../../StartupProgress/Firedancer/Bars";
+import TileSparkLine from "../SlotPerformance/TileSparkLine";
+import { headerGap } from "../../Gossip/consts";
+import type { CSSProperties } from "react";
+import styles from "./liveNetworkMetrics.module.css";
+import { sum } from "lodash";
+import { tileChartDarkBackground } from "../../../colors";
+
+const chartHeight = 18;
+
+export default function LiveNetworkMetrics() {
+ const liveNetworkMetrics = useAtomValue(liveNetworkMetricsAtom);
+ if (!liveNetworkMetrics) return;
+
+ return (
+
+
+
+
+ );
+}
+
+interface NetworkMetricsCardProps {
+ metrics: number[];
+ type: "Ingress" | "Egress";
+}
+
+function NetworkMetricsCard({ metrics, type }: NetworkMetricsCardProps) {
+ return (
+
+
+ Network {type}
+
+
+
+
+ Protocol
+
+
+ Current
+
+
+ Utilization
+
+
+ History (1m)
+
+
+
+
+
+ {metrics.map((value, i) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+const maxValue = 100_000_000;
+
+interface TableRowProps {
+ value: number;
+ idx?: number;
+ label?: string;
+}
+
+function TableRow({
+ value,
+ idx,
+ label,
+ ...props
+}: TableRowProps & Table.RootProps) {
+ const emaValue = useEmaValue(value);
+ const formattedValue = formatBytesAsBits(emaValue);
+
+ return (
+
+
+ {label ?? networkProtocols[idx ?? -1]}
+
+
+ {formattedValue.value} {formattedValue.unit}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/Overview/LiveNetworkMetrics/liveNetworkMetrics.module.css b/src/features/Overview/LiveNetworkMetrics/liveNetworkMetrics.module.css
new file mode 100644
index 00000000..1443dc4a
--- /dev/null
+++ b/src/features/Overview/LiveNetworkMetrics/liveNetworkMetrics.module.css
@@ -0,0 +1,9 @@
+.chart {
+ padding-top: 0;
+ padding-bottom: 0;
+ vertical-align: middle;
+}
+
+.total-row {
+ border-top: 1px solid rgba(250, 250, 250, 0.5);
+}
diff --git a/src/features/Overview/LiveTileMetrics/consts.ts b/src/features/Overview/LiveTileMetrics/consts.ts
new file mode 100644
index 00000000..c8637298
--- /dev/null
+++ b/src/features/Overview/LiveTileMetrics/consts.ts
@@ -0,0 +1,22 @@
+/** Tile regimes are the cartesian product of the following two state vectors:
+ State vector 1:
+ running: means that at the time the run loop executed, there was no upstream message I/O for the tile to handle.
+ processing: means that at the time the run loop executed, there was one or more messages for the tile to consume.
+ stalled: means that at the time the run loop executed, a downstream consumer of the messages produced by this tile is slow or stalled, and the message link for that consumer has filled up. This state causes the tile to stop processing upstream messages.
+
+ State Vector 2:
+ maintenance: the portion of the run loop that executes infrequent, potentially CPU heavy tasks
+ routine: the portion of the run loop that executes regularly, regardless of the presence of incoming messages
+ handling: the portion of the run loop that executes as a side effect of an incoming message from an upstream producer tile
+ */
+export const regimes = [
+ "running_maintenance",
+ "processing_maintenance",
+ "stalled_maintenance",
+ "running_routine",
+ "processing_routine",
+ "stalled_routine",
+ "running_handling",
+ "processing_handling",
+ // "stalled_handling" is an impossible state, and is therefore excluded
+];
diff --git a/src/features/Overview/LiveTileMetrics/index.tsx b/src/features/Overview/LiveTileMetrics/index.tsx
new file mode 100644
index 00000000..72690abf
--- /dev/null
+++ b/src/features/Overview/LiveTileMetrics/index.tsx
@@ -0,0 +1,311 @@
+import { useAtomValue } from "jotai";
+import {
+ liveTileMetricsAtom,
+ tilesAtom,
+ tileTimerAtom,
+} from "../../../api/atoms";
+import Card from "../../../components/Card";
+import { Flex, Table, Text } from "@radix-ui/themes";
+import tableStyles from "../../Gossip/table.module.css";
+import styles from "./liveTileMetrics.module.css";
+import { Bars } from "../../StartupProgress/Firedancer/Bars";
+import TileSparkLine from "../SlotPerformance/TileSparkLine";
+import { headerGap } from "../../Gossip/consts";
+import type { Tile, TileMetrics } from "../../../api/types";
+import clsx from "clsx";
+import {
+ useHarmonicIntervalFn,
+ usePrevious,
+ usePreviousDistinct,
+} from "react-use";
+import { memo, useEffect, useRef, useState, type CSSProperties } from "react";
+import type { CellProps } from "@radix-ui/themes/components/table";
+import { tileChartDarkBackground } from "../../../colors";
+import { isEqual } from "lodash";
+
+const chartHeight = 18;
+
+export default function LiveTileMetrics() {
+ return (
+
+
+ Tiles
+
+
+
+ );
+}
+
+const MLiveMetricTable = memo(function LiveMetricsTable() {
+ const tiles = useAtomValue(tilesAtom);
+ const liveTileMetrics = useAtomValue(liveTileMetricsAtom);
+
+ if (!tiles || !liveTileMetrics) return;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+ Heartbeat
+ Nivcsw
+ Nvcsw
+ Backp
+
+ Backp Count
+
+ Utilization
+ History (1m)
+ % Hkeep
+ % Wait
+ % Backp
+ % Work
+
+
+
+
+ {tiles.map((tile, i) => (
+
+ ))}
+
+
+ );
+});
+
+interface TableRowProps {
+ tile: Tile;
+ liveTileMetrics: TileMetrics;
+ idx: number;
+}
+
+function TableRow({ tile, liveTileMetrics, idx }: TableRowProps) {
+ const prevLiveTileMetricsIdx = usePreviousDistinct(
+ liveTileMetrics,
+ (prev, next) => {
+ if (!prev) return false;
+ if (!next) return true;
+
+ return Object.keys(next).every((key) => {
+ return isEqual(
+ prev[key as keyof typeof prev]?.[idx],
+ next[key as keyof typeof next]?.[idx],
+ );
+ });
+ },
+ );
+
+ const alive =
+ liveTileMetrics.alive[idx] ?? prevLiveTileMetricsIdx?.alive[idx];
+ const nivcsw =
+ liveTileMetrics.nivcsw[idx] ?? prevLiveTileMetricsIdx?.nivcsw[idx];
+ const nvcsw =
+ liveTileMetrics.nvcsw[idx] ?? prevLiveTileMetricsIdx?.nvcsw[idx];
+ const inBackpressure =
+ liveTileMetrics.in_backp[idx] ?? prevLiveTileMetricsIdx?.in_backp[idx];
+ const backPressureCount =
+ liveTileMetrics.backp_msgs[idx] ?? prevLiveTileMetricsIdx?.backp_msgs[idx];
+
+ const prevNivcsw = usePrevious(nivcsw);
+ const prevNvcsw = usePrevious(nvcsw);
+ const prevBackPressureCount = usePrevious(backPressureCount);
+
+ // Meaning tile has shut down, no need to list it in the table
+ if (alive === 2) return;
+
+ const timers =
+ liveTileMetrics.timers[idx] || prevLiveTileMetricsIdx?.timers[idx];
+
+ if (!timers) return;
+
+ for (let i = 0; i < timers.length; i++) {
+ if (timers[i] === -1) timers[i] = 0;
+ }
+
+ const hKeepPct = timers[0] + timers[1] + timers[2];
+ const waitPct = timers[3] + timers[6];
+ const backpPct = timers[5];
+ const workPct = timers[4] + timers[7];
+
+ return (
+
+
+ {tile.kind}:{tile.kind_id}
+
+
+ {alive ? "Live" : "Dead"}
+
+
+ {nivcsw?.toLocaleString() ?? "0"} |
+
+
+
+ {nvcsw?.toLocaleString() ?? "0"} |
+
+
+
+ {inBackpressure ? "Yes" : "-"}
+
+
+ {backPressureCount?.toLocaleString() ?? "0"} |
+
+ +
+ {(backPressureCount != null && prevBackPressureCount != null
+ ? backPressureCount - prevBackPressureCount
+ : 0
+ ).toLocaleString()}
+
+
+
+ 1 })}
+ />
+
+ 0 })}
+ />
+
+
+ );
+}
+
+interface IncrementTextProps {
+ value: number;
+}
+function IncrementText({ value }: IncrementTextProps) {
+ const formatted = value.toLocaleString();
+ return (
+ = 101,
+ })}
+ >
+ +{formatted}
+
+ );
+}
+
+interface PctCellProps {
+ pct: number;
+}
+
+function PctCell({ pct, ...props }: PctCellProps & CellProps) {
+ return (
+
+ {pct.toFixed(2)}%
+
+ );
+}
+
+interface UtilizationProps {
+ idx: number;
+}
+
+const updateIntervalMs = 300;
+
+const MUtilization = memo(function Utilization({ idx }: UtilizationProps) {
+ const tileTimers = useAtomValue(tileTimerAtom);
+ const pct =
+ tileTimers?.[idx] && tileTimers[idx] >= 0
+ ? 1 - Math.max(0, tileTimers[idx])
+ : -1;
+ const prevPct = usePreviousDistinct(
+ pct,
+ (prev, next) =>
+ !(
+ next != null &&
+ prev != null &&
+ next >= 0 &&
+ prev >= 0 &&
+ next !== prev
+ ),
+ );
+ const rollingSum = useRef({ count: 0, sum: 0 });
+ const [avgValue, setAvgValue] = useState(pct);
+
+ useEffect(() => {
+ if (pct >= 0) {
+ rollingSum.current.count++;
+ rollingSum.current.sum += pct;
+ }
+ }, [pct]);
+
+ useHarmonicIntervalFn(() => {
+ if (rollingSum.current.count === 0) return;
+
+ setAvgValue(rollingSum.current.sum / rollingSum.current.count);
+ rollingSum.current = { count: 0, sum: 0 };
+ }, updateIntervalMs);
+
+ return (
+ <>
+
+
+ = 0 ? pct : (prevPct ?? 0)} max={1} barWidth={2} />
+
+
+
+
+
+ >
+ );
+});
diff --git a/src/features/Overview/LiveTileMetrics/liveTileMetrics.module.css b/src/features/Overview/LiveTileMetrics/liveTileMetrics.module.css
new file mode 100644
index 00000000..6130b8ef
--- /dev/null
+++ b/src/features/Overview/LiveTileMetrics/liveTileMetrics.module.css
@@ -0,0 +1,58 @@
+.row {
+ .green {
+ color: var(--green-9);
+ }
+
+ .red {
+ color: var(--red-9);
+ }
+
+ td {
+ text-align: left;
+ &[align="right"] {
+ text-align: right;
+ }
+ vertical-align: middle;
+
+ &.pct-gradient {
+ color: color-mix(
+ in srgb,
+ var(--tile-busy-green-color),
+ var(--tile-busy-red-color) var(--pct)
+ );
+ }
+
+ &.no-padding {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ .increment-text {
+ min-width: 5ch;
+ display: inline-block;
+ font-variant-numeric: tabular-nums;
+
+ &.low-increment {
+ color: var(--low-increment-text-color);
+ }
+ &.mid-increment {
+ color: var(--mid-increment-text-color);
+ }
+ &.high-increment {
+ color: var(--high-increment-text-color);
+ }
+ }
+ }
+}
+
+.header {
+ position: sticky;
+ top: 0;
+ background: rgb(19, 23, 32);
+}
+
+.table {
+ table {
+ table-layout: fixed;
+ }
+}
diff --git a/src/features/Overview/ShredsProgression/ShredsChart.tsx b/src/features/Overview/ShredsProgression/ShredsChart.tsx
new file mode 100644
index 00000000..4eff0cec
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/ShredsChart.tsx
@@ -0,0 +1,180 @@
+import UplotReact from "../../../uplotReact/UplotReact";
+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 { useMeasure, useMedia, useRafLoop } from "react-use";
+import {
+ shredsProgressionPlugin,
+ shredsXScaleKey,
+} from "./shredsProgressionPlugin";
+import { Box, Flex } from "@radix-ui/themes";
+import ShredsSlotLabels from "./ShredsSlotLabels";
+
+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 [measureRef, measureRect] = useMeasure();
+
+ 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: {
+ [shredsXScaleKey]: { time: false },
+ y: {
+ time: false,
+ range: [0, 1],
+ },
+ },
+ series: [{ scale: shredsXScaleKey }, {}],
+ cursor: {
+ show: false,
+ drag: {
+ // disable zoom
+ [shredsXScaleKey]: false,
+ y: false,
+ },
+ },
+ legend: { show: false },
+ axes: [
+ {
+ scale: shredsXScaleKey,
+ 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]);
+
+ options.width = measureRect.width;
+ options.height = measureRect.height;
+
+ 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 (
+
+ {!isOnStartupScreen && }
+
+
+
+
+ );
+}
diff --git a/src/features/Overview/ShredsProgression/ShredsChartLegend.tsx b/src/features/Overview/ShredsProgression/ShredsChartLegend.tsx
new file mode 100644
index 00000000..8bf56a13
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/ShredsChartLegend.tsx
@@ -0,0 +1,21 @@
+import { Flex, Text } from "@radix-ui/themes";
+import { legend } from "./const";
+import styles from "./shreds.module.css";
+
+export function ShredsChartLegend() {
+ return (
+
+ {Object.entries(legend).map(([label, color]) => {
+ return (
+
+
+ {label}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx
new file mode 100644
index 00000000..30594859
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx
@@ -0,0 +1,109 @@
+import { useAtomValue } from "jotai";
+import { Flex, Text } from "@radix-ui/themes";
+import { getSlotGroupLabelId, getSlotLabelId } from "./utils";
+import styles from "./shreds.module.css";
+import { useMemo } from "react";
+import { slotsPerLeader } from "../../../consts";
+import { shredsAtoms } from "./atoms";
+import { useSlotInfo } from "../../../hooks/useSlotInfo";
+import clsx from "clsx";
+import PeerIcon from "../../../components/PeerIcon";
+import { skippedClusterSlotsAtom } from "../../../atoms";
+import { isStartupProgressVisibleAtom } from "../../StartupProgress/atoms";
+
+/**
+ * Labels for shreds slots.
+ * Don't render during startup, because there will be multiple overlapping slots
+ * during the catching up phase.
+ */
+export default function ShredsSlotLabels() {
+ const isStartup = useAtomValue(isStartupProgressVisibleAtom);
+ const groupLeaderSlots = useAtomValue(shredsAtoms.groupLeaderSlots);
+
+ if (isStartup) return;
+
+ return (
+
+ {groupLeaderSlots.map((slot) => (
+
+ ))}
+
+ );
+}
+
+interface SlotGroupLabelProps {
+ firstSlot: number;
+}
+function SlotGroupLabel({ firstSlot }: SlotGroupLabelProps) {
+ const { peer, name, isLeader } = useSlotInfo(firstSlot);
+ const slots = useMemo(() => {
+ return Array.from({ length: slotsPerLeader }, (_, i) => firstSlot + i);
+ }, [firstSlot]);
+
+ const skippedClusterSlots = useAtomValue(skippedClusterSlotsAtom);
+ const skippedSlots = useMemo(() => {
+ const skipped = new Set();
+ for (const slot of slots) {
+ if (skippedClusterSlots.has(slot)) {
+ skipped.add(slot);
+ }
+ }
+ return skipped;
+ }, [slots, skippedClusterSlots]);
+
+ return (
+
+ 0,
+ })}
+ >
+
+
+ {name}
+
+
+
+
+ {slots.map((slot) => (
+
+ ))}
+
+
+ );
+}
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..c3037f0e
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/__tests__/atoms.test.tsx
@@ -0,0 +1,457 @@
+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 before 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 before chart min x
+ ts: chartRangeNs + 1_000_000,
+ e: ShredEvent.shred_repair_request,
+ },
+ {
+ // max slot number that is complete before chart min X
+ // keep this and delete 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([
+ [
+ 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: 3,
+ 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..48dcade4
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/atoms.ts
@@ -0,0 +1,328 @@
+import { atom } from "jotai";
+import type { LiveShreds } from "../../../api/types";
+import { ShredEvent } from "../../../api/entities";
+import { delayMs, xRangeMs } from "./const";
+import { nsPerMs, slotsPerLeader } from "../../../consts";
+import { getSlotGroupLeader } from "../../../utils";
+import { startupFinalTurbineHeadAtom } from "../../StartupProgress/atoms";
+import { serverTimeMsAtom } from "../../../atoms";
+
+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[];
+
+export type Slot = {
+ shreds: (ShredEventTsDeltas | undefined)[];
+ /**
+ * earliest event (start) of the slot
+ */
+ 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;
+ }>();
+ const rangeAfterStartupAtom = atom((get) => {
+ const range = get(_slotRangeAtom);
+ const startupFinalTurbineHead = get(startupFinalTurbineHeadAtom);
+ if (!range || startupFinalTurbineHead == null) return;
+
+ // no slots after final turbine head
+ if (startupFinalTurbineHead + 1 > range.max) return;
+
+ return {
+ min: Math.max(startupFinalTurbineHead + 1, range.min),
+ max: range.max,
+ };
+ });
+ return {
+ /**
+ * min completed slot we've seen since we started collecting data
+ */
+ minCompletedSlot: atom((get) => get(_minCompletedSlotAtom)),
+ range: atom((get) => get(_slotRangeAtom)),
+ rangeAfterStartup: rangeAfterStartupAtom,
+ // leader slots after turbine head at the end of startup
+ groupLeaderSlots: atom((get) => {
+ const range = get(rangeAfterStartupAtom);
+ const startupFinalTurbineHead = get(startupFinalTurbineHeadAtom);
+ if (!range || startupFinalTurbineHead == null) return [];
+
+ const min = Math.max(startupFinalTurbineHead + 1, range.min);
+
+ const slots = [getSlotGroupLeader(min)];
+ while (slots[slots.length - 1] + slotsPerLeader - 1 < range.max) {
+ slots.push(
+ getSlotGroupLeader(slots[slots.length - 1] + slotsPerLeader),
+ );
+ }
+ return slots;
+ }),
+ 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 in ShredEvent)) {
+ console.debug(`received unsupported shred event type ${ev}`);
+ 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);
+ const now = get(serverTimeMsAtom) ?? Date.now();
+
+ if (!prev || !slotRange) return prev;
+
+ 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 but keep this one for label spacing reference
+ shouldDeleteSlot = true;
+ continue;
+ }
+
+ 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;
+ }
+ return {
+ min: Math.min(...remainingSlotNumbers),
+ max: prevRange.max,
+ };
+ });
+
+ 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..89a93e73
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/const.ts
@@ -0,0 +1,40 @@
+import { ShredEvent } from "../../../api/entities";
+import {
+ shredPublishedColor,
+ shredReceivedRepairColor,
+ shredReceivedTurbineColor,
+ shredRepairRequestedColor,
+ shredReplayedNothingColor,
+ shredReplayedRepairColor,
+ shredReplayedTurbineColor,
+ shredSkippedColor,
+} from "../../../colors";
+
+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_received_repair,
+ ShredEvent.shred_received_turbine,
+ ShredEvent.shred_repair_request,
+];
+
+export const legend = {
+ "Repair Requested": shredRepairRequestedColor,
+ "Received Turbine": shredReceivedTurbineColor,
+ "Received Repair": shredReceivedRepairColor,
+ "Replayed Turbine": shredReplayedTurbineColor,
+ "Replayed Repair": shredReplayedRepairColor,
+ "Replayed Nothing": shredReplayedNothingColor,
+ Skipped: shredSkippedColor,
+ Published: shredPublishedColor,
+};
diff --git a/src/features/Overview/ShredsProgression/index.tsx b/src/features/Overview/ShredsProgression/index.tsx
new file mode 100644
index 00000000..b50089f7
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/index.tsx
@@ -0,0 +1,33 @@
+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";
+import { ShredsChartLegend } from "./ShredsChartLegend";
+
+export default function ShredsProgression() {
+ const client = useAtomValue(clientAtom);
+
+ if (client !== ClientEnum.Firedancer) return;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/Overview/ShredsProgression/shreds.module.css b/src/features/Overview/ShredsProgression/shreds.module.css
new file mode 100644
index 00000000..4bc904e7
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/shreds.module.css
@@ -0,0 +1,81 @@
+.slot-group-label {
+ --group-x: -100000px;
+ --group-name-opacity: 0;
+
+ opacity: 0.8;
+ background-color: #080b13;
+ border-radius: 2px;
+ border: 1px solid #3c4652;
+ border-top-width: 0;
+ will-change: transform;
+ transform: translate(var(--group-x));
+
+ &.you {
+ border: 1px solid #2a7edf;
+ }
+
+ .slot-group-top-container {
+ width: 100%;
+ background-color: #101318;
+
+ &.skipped {
+ background-color: var(--red-2);
+ }
+
+ .slot-group-name-container {
+ opacity: var(--group-name-opacity);
+ transition: opacity 0.8s;
+ will-change: opacity;
+
+ .name {
+ font-size: 10px;
+ line-height: normal;
+ color: #949494;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+
+ .slot-bars-container {
+ flex-shrink: 0;
+ .slot-bar {
+ --slot-x: 0;
+
+ position: absolute;
+ will-change: transform;
+ transform: translate(var(--slot-x));
+
+ height: 100%;
+ border-radius: 3px;
+ &:nth-child(1) {
+ background-color: var(--blue-7);
+ }
+ &:nth-child(2) {
+ background-color: var(--blue-6);
+ }
+ &:nth-child(3) {
+ background-color: var(--blue-5);
+ }
+ &:nth-child(4) {
+ background-color: var(--blue-4);
+ }
+
+ &.skipped {
+ background-color: var(--red-7);
+ }
+ }
+ }
+}
+
+.legend-color-box {
+ width: 10px;
+ height: 10px;
+ border-radius: 1px;
+}
+
+.legend-label {
+ font-size: 10px;
+ color: var(--header-label-text-color);
+}
diff --git a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts
new file mode 100644
index 00000000..08c9bbb4
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts
@@ -0,0 +1,750 @@
+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,
+ shredSkippedColor,
+} from "../../../colors";
+import { serverTimeMsAtom, skippedClusterSlotsAtom } from "../../../atoms";
+import { clamp } from "lodash";
+import { ShredEvent } from "../../../api/entities";
+import { getSlotGroupLabelId, getSlotLabelId } from "./utils";
+import { slotsPerLeader } from "../../../consts";
+
+const store = getDefaultStore();
+export const shredsXScaleKey = "shredsXScaleKey";
+
+type Coordinates = [x: number, y: number, width?: number];
+type EventsByFillStyle = {
+ [fillStyle: string]: Array;
+};
+export type Position = [xPos: number, cssWidth: number | undefined];
+export type LabelPositions = {
+ groups: {
+ [leaderSlotNumber: number]: Position;
+ };
+ slots: {
+ [slotNumber: number]: Position;
+ };
+};
+
+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);
+ const rangeAfterStartup = store.get(atoms.rangeAfterStartup);
+ const serverTimeMs = store.get(serverTimeMsAtom) ?? Date.now();
+
+ const maxX = u.scales[shredsXScaleKey].max;
+
+ if (!liveShreds || !slotRange || maxX == null) {
+ 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;
+
+ if (!rangeAfterStartup) return;
+ }
+
+ // Offset to convert shred event delta to chart x value
+ const delayedNow = serverTimeMs - 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, shredsXScaleKey, true);
+
+ const { maxShreds, orderedSlotNumbers } = getDrawInfo(
+ minSlot,
+ maxSlot,
+ liveShreds,
+ u.scales[shredsXScaleKey],
+ 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_replayed: {
+ return canvasHeight * 2;
+ }
+ }
+ }
+ : undefined;
+
+ // each row is at least 1 px
+ const rowPxHeight = clamp(canvasHeight / maxShreds, 1, 10);
+ const gapPxHeight = 1;
+
+ const dotSize = Math.max(rowPxHeight, 3);
+
+ // 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: Coordinates,
+ ) => {
+ eventsByFillStyle[fillStyle] ??= [];
+ eventsByFillStyle[fillStyle].push(position);
+ };
+
+ const slot = liveShreds.slots.get(slotNumber);
+ if (slot?.minEventTsDelta == null) 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,
+ scaleX: u.scales[shredsXScaleKey],
+ 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]) {
+ if (width == null) {
+ // dot
+ u.ctx.rect(x, y, dotSize, dotSize);
+ } else {
+ u.ctx.rect(x, y, width, rowPxHeight);
+ }
+ }
+ u.ctx.fill();
+ }
+ }
+
+ u.ctx.restore();
+
+ if (!isOnStartupScreen && rangeAfterStartup) {
+ updateLabels(
+ rangeAfterStartup,
+ liveShreds.slots,
+ skippedSlotsCluster,
+ u,
+ maxX,
+ tsXValueOffset,
+ );
+ }
+ },
+ ],
+ },
+ };
+}
+
+/**
+ * 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: Coordinates) => 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;
+ 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,
+ 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
+ : getXPos(slotCompletionTsDelta - tsXValueOffset);
+
+ const eventPositions = new Map<
+ Exclude,
+ Coordinates
+ >();
+
+ // 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]
+ : [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_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;
+}
+
+function updateLabels(
+ slotRange: {
+ min: number;
+ max: number;
+ },
+ slots: SlotsShreds["slots"],
+ skippedSlotsCluster: Set,
+ u: uPlot,
+ maxX: number,
+ tsXValueOffset: number,
+) {
+ const slotBlocks = getSlotBlocks(slotRange, slots);
+ const slotTsDeltas = estimateSlotTsDeltas(slotBlocks, skippedSlotsCluster);
+ const groupLeaderSlots = store.get(shredsAtoms.groupLeaderSlots);
+ const groupTsDeltas = getGroupTsDeltas(slotTsDeltas, groupLeaderSlots);
+
+ const xValToCssPos = (xVal: number) =>
+ u.valToPos(xVal, shredsXScaleKey, false);
+ const maxXPos = xValToCssPos(maxX);
+
+ for (let groupIdx = 0; groupIdx < groupLeaderSlots.length; groupIdx++) {
+ const leaderSlot = groupLeaderSlots[groupIdx];
+ const leaderElId = getSlotGroupLabelId(leaderSlot);
+ const leaderEl = document.getElementById(leaderElId);
+ if (!leaderEl) continue;
+
+ const groupRange = groupTsDeltas[leaderSlot];
+
+ const groupPos = getPosFromTsDeltaRange(
+ groupRange,
+ tsXValueOffset,
+ xValToCssPos,
+ );
+ moveLabelPosition(true, groupPos, maxXPos, leaderEl);
+
+ for (
+ let slotNumber = leaderSlot;
+ slotNumber < leaderSlot + slotsPerLeader;
+ slotNumber++
+ ) {
+ const slotElId = getSlotLabelId(slotNumber);
+ const slotEl = document.getElementById(slotElId);
+ if (!slotEl) continue;
+
+ const slotRange = slotTsDeltas[slotNumber];
+ const slotPos = getPosFromTsDeltaRange(
+ slotRange,
+ tsXValueOffset,
+ xValToCssPos,
+ );
+
+ // position slot relative to its slot group
+ const relativeSlotPos =
+ slotPos && groupPos
+ ? ([slotPos[0] - groupPos[0], slotPos[1]] satisfies Position)
+ : undefined;
+
+ moveLabelPosition(false, relativeSlotPos, maxXPos, slotEl);
+ }
+ }
+}
+
+interface CompleteBlock {
+ type: "complete";
+ startTsDelta: number;
+ endTsDelta: number;
+ slotNumber: number;
+}
+interface IncompleteBlock {
+ type: "incomplete";
+ startTsDelta: number;
+ endTsDelta: number | undefined;
+ slotNumbers: number[];
+}
+/**
+ * Group ordered slots into blocks that are complete / incomplete.
+ * Each block has a slot or array of slots sharing the same
+ * start and end ts
+ */
+function getSlotBlocks(
+ slotRange: {
+ min: number;
+ max: number;
+ },
+ slots: SlotsShreds["slots"],
+): Array {
+ const blocks: Array = [];
+ let incompleteBlockSlotNumbers: number[] = [];
+
+ for (
+ let slotNumber = slotRange.min;
+ slotNumber <= slotRange.max;
+ slotNumber++
+ ) {
+ const slot = slots.get(slotNumber);
+
+ if (slot?.minEventTsDelta == null) {
+ // We don't want incomplete blocks with unknown start ts, so
+ // don't collect incomplete blocks until we have at least one block stored
+ if (blocks.length === 0) continue;
+
+ // add missing slot to incomplete block
+ incompleteBlockSlotNumbers.push(slotNumber);
+ continue;
+ }
+
+ // mark incomplete block's end with current slot's start
+ if (incompleteBlockSlotNumbers.length) {
+ const blockStart = getIncompleteBlockStart(
+ incompleteBlockSlotNumbers,
+ slots,
+ blocks[blocks.length - 1],
+ );
+ if (!blockStart) break;
+
+ blocks.push({
+ type: "incomplete",
+ startTsDelta: blockStart,
+ endTsDelta: slot.minEventTsDelta,
+ slotNumbers: incompleteBlockSlotNumbers,
+ });
+
+ // reset current incomplete block
+ incompleteBlockSlotNumbers = [];
+ }
+
+ if (slot.completionTsDelta != null) {
+ blocks.push({
+ type: "complete",
+ startTsDelta: slot.minEventTsDelta,
+ endTsDelta: slot.completionTsDelta,
+ slotNumber,
+ });
+ } else {
+ // incomplete
+ incompleteBlockSlotNumbers.push(slotNumber);
+ }
+ }
+
+ // add final incomplete block
+ if (incompleteBlockSlotNumbers.length) {
+ const blockStart = getIncompleteBlockStart(
+ incompleteBlockSlotNumbers,
+ slots,
+ blocks[blocks.length - 1],
+ );
+ if (!blockStart) return blocks;
+
+ blocks.push({
+ type: "incomplete",
+ startTsDelta: blockStart,
+ endTsDelta: undefined,
+ slotNumbers: incompleteBlockSlotNumbers,
+ });
+ }
+ return blocks;
+}
+
+/**
+ *
+ * incomplete block starts at either start of first
+ * slot in the block, or end of the previous block
+ */
+function getIncompleteBlockStart(
+ blockSlotNumbers: number[],
+ slots: SlotsShreds["slots"],
+ previousBlock: CompleteBlock | IncompleteBlock,
+) {
+ const firstSlotNumber = blockSlotNumbers[0];
+ const startFirstSlotNumber = slots.get(firstSlotNumber)?.minEventTsDelta;
+
+ if (startFirstSlotNumber != null) return startFirstSlotNumber;
+
+ const prevBlockEnd = previousBlock.endTsDelta;
+ if (prevBlockEnd == null) {
+ console.error(
+ `Missing block start ts for incomplete block beginning at ${firstSlotNumber}`,
+ );
+ return;
+ }
+
+ return prevBlockEnd;
+}
+
+type TsDeltaRange = [startTsDelta: number, endTsDelta: number | undefined];
+
+/**
+ * Get each slot's start and end ts deltas.
+ * Some slots will not have end ts deltas, and would extend to the max X axis value
+ * Incomplete blocks:
+ * - split the range (incomplete block start ts to next start ts) equally among the slots
+ * - skipped slots will have the above range, offset by its index in the incomplete block
+ * - non-skipped slots will extend from the incomplete block start to the max X axis value
+ * - if there is no next start ts, only include the first slot in the block, ending at max X ts
+ */
+function estimateSlotTsDeltas(
+ slotBlocks: Array,
+ skippedSlotsCluster: Set,
+) {
+ const slotTsDeltas: {
+ [slotNumber: number]: TsDeltaRange;
+ } = {};
+
+ for (const block of slotBlocks) {
+ if (block.type === "complete") {
+ slotTsDeltas[block.slotNumber] = [block.startTsDelta, block.endTsDelta];
+ continue;
+ }
+
+ if (block.endTsDelta == null) {
+ // unknown incomplete block end time
+ // only include first slot, because we don't have a good estimate for when the others would have started
+ slotTsDeltas[block.slotNumbers[0]] = [block.startTsDelta, undefined];
+ continue;
+ }
+
+ // known block end time
+ // split block range equally to determine slot start ts
+ const singleSlotTsRange =
+ (block.endTsDelta - block.startTsDelta) / block.slotNumbers.length;
+ for (let i = 0; i < block.slotNumbers.length; i++) {
+ const slotNumber = block.slotNumbers[i];
+ const slotStart = block.startTsDelta + i * singleSlotTsRange;
+
+ const slotEnd = skippedSlotsCluster.has(slotNumber)
+ ? slotStart + singleSlotTsRange
+ : undefined;
+ slotTsDeltas[slotNumber] = [slotStart, slotEnd];
+ }
+ }
+
+ return slotTsDeltas;
+}
+
+/**
+ * Get start and end ts deltas for group, from its slots ts deltas
+ * Undefined end indicates the group extends to max X
+ */
+function getGroupTsDeltas(
+ slotTsDeltas: {
+ [slotNumber: number]: TsDeltaRange;
+ },
+ groupLeaderSlots: number[],
+) {
+ const tsDeltasByGroup: {
+ [leaderSlotNumber: number]: TsDeltaRange;
+ } = {};
+
+ for (const leaderSlot of groupLeaderSlots) {
+ let minStart = Infinity;
+ let maxEnd = -Infinity;
+ for (let slot = leaderSlot; slot < leaderSlot + slotsPerLeader; slot++) {
+ const slotStart = slotTsDeltas[slot]?.[0];
+ const slotEnd = slotTsDeltas[slot]?.[1];
+
+ if (slotStart !== undefined) {
+ minStart = Math.min(slotStart, minStart);
+ }
+
+ // don't track end times for initial undefined slots
+ const hasSeenDefinedSlot = minStart !== Infinity;
+ if (!hasSeenDefinedSlot) continue;
+
+ // undefind slotEnd means the slot extends to the max X
+ maxEnd = Math.max(slotEnd ?? Infinity, maxEnd);
+ }
+
+ // no defined slots
+ if (minStart === Infinity || maxEnd === -Infinity) {
+ continue;
+ }
+
+ tsDeltasByGroup[leaderSlot] = [
+ minStart,
+ // convert back to undefined
+ maxEnd === Infinity ? undefined : maxEnd,
+ ];
+ }
+ return tsDeltasByGroup;
+}
+
+/**
+ * If missing range end, set width as undefined
+ */
+function getPosFromTsDeltaRange(
+ tsDeltaRange: TsDeltaRange,
+ tsXValueOffset: number,
+ valToCssPos: (val: number) => number,
+): Position | undefined {
+ if (!tsDeltaRange) return undefined;
+ const xStartVal = tsDeltaRange[0] - tsXValueOffset;
+ const xStartPos = valToCssPos(xStartVal);
+
+ if (tsDeltaRange[1] == null) {
+ return [xStartPos, undefined];
+ }
+
+ const xEndVal = tsDeltaRange[1] - tsXValueOffset;
+ const xEndPos = valToCssPos(xEndVal);
+ return [xStartPos, xEndPos - xStartPos];
+}
+
+/**
+ * Update label element styles
+ */
+function moveLabelPosition(
+ isGroup: boolean,
+ position: Position | undefined,
+ maxXPos: number,
+ el: HTMLElement,
+) {
+ const groupBorderOffset = 1;
+ const xPosProp = isGroup ? "--group-x" : "--slot-x";
+
+ const isVisible = !!position;
+ if (!isVisible) {
+ // hide
+ el.style.setProperty(xPosProp, "-100000px");
+ return;
+ }
+
+ const [xPos, width] = position;
+ el.style.setProperty(
+ xPosProp,
+ `${xPos + (isGroup ? groupBorderOffset : 0)}px`,
+ );
+
+ // If missing width, extend to max width (with extra px to hide right border)
+ const newWidth = width ?? maxXPos - xPos + 1;
+ el.style.width = `${newWidth + (isGroup ? groupBorderOffset * 2 : 0)}px`;
+
+ const isExtended = width == null;
+ if (isGroup) {
+ // Extended groups don't have a defined end, so we don't know where to center the name text.
+ // Set to opacity 0, and transition to 1 when the group end becomes defined.
+ el.style.setProperty("--group-name-opacity", isExtended ? "0" : "1");
+ }
+}
diff --git a/src/features/Overview/ShredsProgression/utils.ts b/src/features/Overview/ShredsProgression/utils.ts
new file mode 100644
index 00000000..4551d2ee
--- /dev/null
+++ b/src/features/Overview/ShredsProgression/utils.ts
@@ -0,0 +1,9 @@
+import { getSlotGroupLeader } from "../../../utils";
+
+export function getSlotGroupLabelId(slot: number) {
+ return `slot-group-label-${getSlotGroupLeader(slot)}`;
+}
+
+export function getSlotLabelId(slot: number) {
+ return `slot-label-${slot}`;
+}
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChart.tsx b/src/features/Overview/SlotPerformance/ComputeUnitsCard/CuChart.tsx
index 003b15d2..efaf1002 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]: {
@@ -226,6 +226,7 @@ export default function CuChart({
return ticks.map((rawValue) => rawValue / 1_000_000 + "ms");
},
space: 100,
+ scale: banksXScaleKey,
},
{
scale: computeUnitsScaleKey,
@@ -322,14 +323,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..2ecd5384 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx
@@ -3,7 +3,7 @@ import { useSlotQueryResponseTransactions } from "../../../../hooks/useSlotQuery
import { selectedSlotAtom, tileCountAtom } from "../atoms";
import Card from "../../../../components/Card";
import CardHeader from "../../../../components/CardHeader";
-import { Flex } from "@radix-ui/themes";
+import { Flex, Text } from "@radix-ui/themes";
import styles from "./computeUnits.module.css";
import CuChart from "./CuChart";
import CuChartTooltip from "./CuChartTooltip";
@@ -14,6 +14,8 @@ import { defaultMaxComputeUnits } from "../../../../consts";
import CuChartInfoIcon from "./CuChartStartLineIcon";
import CuChartProjectionsToggle from "./CuChartRefAreaToggle";
+const height = "500px";
+
export default function ComputeUnitsCard() {
const slot = useAtomValue(selectedSlotAtom);
const query = useSlotQueryResponseTransactions(slot);
@@ -31,12 +33,13 @@ export default function ComputeUnitsCard() {
[],
);
- if (!slot || !query.response?.transactions) return null;
+ if (!slot || !query.response?.transactions)
+ return ;
return (
<>
-
-
+
+
@@ -52,11 +55,27 @@ export default function ComputeUnitsCard() {
bankTileCount={bankTileCount}
onCreate={handleCreate}
/>
+