diff --git a/src/api/atoms.ts b/src/api/atoms.ts index 7228c5f3..0b2ddea3 100644 --- a/src/api/atoms.ts +++ b/src/api/atoms.ts @@ -40,6 +40,7 @@ import type { RepairSlot, LiveProgramCache, SlotCaughtUp, + AccountsStats, } from "./types"; import { rafAtom } from "../atomUtils"; import type { ValuesWithHistory } from "./worker/types"; @@ -191,3 +192,5 @@ export const slotRankingsAtom = atom(undefined); export const liveProgramCacheAtom = atom( undefined, ); + +export const accountsStatsAtom = rafAtom(undefined); diff --git a/src/api/entities.ts b/src/api/entities.ts index 4907c0f0..e0e35c34 100644 --- a/src/api/entities.ts +++ b/src/api/entities.ts @@ -31,6 +31,10 @@ const supermajorityTopicSchema = z.object({ topic: z.literal("wait_for_supermajority"), }); +const accountsTopicSchema = z.object({ + topic: z.literal("accounts"), +}); + export const topicSchema = z.discriminatedUnion("topic", [ summaryTopicSchema, epochTopicSchema, @@ -39,6 +43,7 @@ export const topicSchema = z.discriminatedUnion("topic", [ slotTopicSchema, blockEngineTopicSchema, supermajorityTopicSchema, + accountsTopicSchema, ]); export const versionSchema = z.string(); @@ -988,3 +993,135 @@ export const supermajoritySchema = z.discriminatedUnion("key", [ value: supermajorityPeerRemoveSchema, }), ]); + +export const accountsDiskSchema = z.object({ + accounts_total: z.number(), + accounts_capacity: z.number(), + allocated_bytes: z.number(), + current_bytes: z.number(), + used_bytes: z.number(), +}); + +export const accountsCompactionSchema = z.object({ + in_compaction: z.number(), + compactions_requested: z.number(), + compactions_completed: z.number(), + accounts_relocated_bytes: z.number(), + relocated_bytes_per_sec: z.number(), +}); + +export const accountsCacheClassSchema = z.object({ + class: z.number(), + used_slots: z.number(), + max_slots: z.number(), + reserved_slots: z.number(), + target_used_slots: z.number(), + low_water_used_slots: z.number(), + not_found: z.number(), + evicted: z.number(), + preevicted: z.number(), + committed_new: z.number(), + committed_overwrite: z.number(), + not_found_per_sec: z.number(), + evicted_per_sec: z.number(), + preevicted_per_sec: z.number(), + committed_new_per_sec: z.number(), + committed_overwrite_per_sec: z.number(), + reads_per_sec: z.number(), + writes_per_sec: z.number(), + hit_rate_ema: z.number(), +}); + +export const accountsCacheSchema = z.object({ + hit_rate_ema: z.number(), + size_bytes: z.number(), + classes: accountsCacheClassSchema.array(), +}); + +export const accountsIoSchema = z.object({ + acquired: z.number(), + acquired_writable: z.number(), + bytes_read: z.number(), + bytes_copied: z.number(), + bytes_written: z.number(), + bytes_written_accdb: z.number(), + read_ops: z.number(), + write_ops: z.number(), + acquired_per_sec: z.number(), + acquired_writable_per_sec: z.number(), + bytes_read_per_sec: z.number(), + bytes_copied_per_sec: z.number(), + bytes_written_per_sec: z.number(), + read_ops_per_sec: z.number(), + write_ops_per_sec: z.number(), + prewrite_ratio: z.number(), +}); + +export const accountsPartitionSchema = z.object({ + partition_idx: z.number(), + file_offset: z.number(), + tier: z.number(), + write_offset: z.number(), + bytes_freed: z.number(), + read_ops: z.number(), + bytes_read: z.number(), + write_ops: z.number(), + bytes_written: z.number(), + read_ops_per_sec: z.number(), + bytes_read_per_sec: z.number(), + write_ops_per_sec: z.number(), + bytes_written_per_sec: z.number(), + utilization: z.number(), + fragmentation: z.number(), + used_frac: z.number(), + fragmented_frac: z.number(), + compaction_trigger_frac: z.number(), + age_seconds: z.number(), + filled_seconds: z.number(), + /* 0 = idle, 1 = queued, 2 = compacting */ + compaction_state: z.number(), + compaction_frac: z.number(), + is_write_head: z.boolean(), +}); + +export const accountsTileSchema = z.object({ + name: z.string(), + kind_id: z.number(), + joiner_type: z.enum(["RO", "RW"]), + /* 1 = running, 2 = shutdown */ + status: z.number(), + acquired: z.number(), + bytes_read: z.number(), + bytes_written: z.number(), + acquired_per_sec: z.number(), + acquired_writable_per_sec: z.number(), + bytes_read_per_sec: z.number(), + bytes_copied_per_sec: z.number(), + bytes_written_per_sec: z.number(), + read_ops_per_sec: z.number(), + write_ops_per_sec: z.number(), + not_found_per_sec: z.number(), + evicted_per_sec: z.number(), + committed_per_sec: z.number(), + acquire_calls_per_sec: z.number(), + hit_rate_ema: z.number(), + acquired_history: z.array(z.number()), + acquired_writable_history: z.array(z.number()), +}); + +export const accountsStatsSchema = z.object({ + sample_time_nanos: z.number(), + disk: accountsDiskSchema, + compaction: accountsCompactionSchema, + cache: accountsCacheSchema, + io: accountsIoSchema, + tiles: accountsTileSchema.array(), + partitions: accountsPartitionSchema.array(), +}); + +export const accountsSchema = z.discriminatedUnion("key", [ + accountsTopicSchema.extend({ + key: z.literal("stats"), + value: accountsStatsSchema, + }), +]); diff --git a/src/api/types.ts b/src/api/types.ts index 8c6f9f40..e33d5d5c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -67,6 +67,9 @@ import type { peerUpdateInfoSchema, liveProgramCacheSchema, slotCaughtUpSchema, + accountsStatsSchema, + accountsPartitionSchema, + accountsTileSchema, } from "./entities"; export type Client = z.infer; @@ -191,3 +194,9 @@ export type SlotRankings = z.infer; export type LiveShreds = z.infer; export type LiveProgramCache = z.infer; + +export type AccountsStats = z.infer; + +export type AccountsPartition = z.infer; + +export type AccountsTile = z.infer; diff --git a/src/api/useSetAtomWsData.ts b/src/api/useSetAtomWsData.ts index ac731925..11a6edb0 100644 --- a/src/api/useSetAtomWsData.ts +++ b/src/api/useSetAtomWsData.ts @@ -89,6 +89,7 @@ import { optimisticallyConfirmedSlotAtom, liveProgramCacheAtom, slotCaughtUpAtom, + accountsStatsAtom, } from "./atoms"; import { estimatedTpsDebounceMs, @@ -433,6 +434,7 @@ function useUpdateAtoms() { const addLiveShreds = useSetAtom(shredsAtoms.addShredEvents); const setLiveProgramCache = useSetAtom(liveProgramCacheAtom); + const setAccountsStats = useSetAtom(accountsStatsAtom); const peersBuffer = useRef(new Map()); const removePeersBuffer = useRef(new Map()); @@ -752,6 +754,15 @@ function useUpdateAtoms() { } break; } + case "accounts": { + switch (key) { + case "stats": { + setAccountsStats(value); + break; + } + } + break; + } } }, [ @@ -784,6 +795,7 @@ function useUpdateAtoms() { setIdentityBalance, setIdentityKey, setLateVoteHistory, + setAccountsStats, setLiveProgramCache, setOptimisticallyConfirmedSlot, setResetSlot, diff --git a/src/api/worker/types.ts b/src/api/worker/types.ts index e39bfa92..f28a88c2 100644 --- a/src/api/worker/types.ts +++ b/src/api/worker/types.ts @@ -1,5 +1,6 @@ import z from "zod"; import { + accountsSchema, blockEngineSchema, epochSchema, gossipSchema, @@ -18,6 +19,7 @@ export const WsMessageSchema = z.discriminatedUnion("topic", [ slotSchema, blockEngineSchema, supermajoritySchema, + accountsSchema, ]); export type WsMessage = z.infer; @@ -41,7 +43,8 @@ export type WsEntity = | KvFrom | KvFrom | KvFrom - | KvFrom; + | KvFrom + | KvFrom; export type FromWorkerMessage = | { type: "connecting" } diff --git a/src/features/Accounts/CacheTable.tsx b/src/features/Accounts/CacheTable.tsx new file mode 100644 index 00000000..11fa3fea --- /dev/null +++ b/src/features/Accounts/CacheTable.tsx @@ -0,0 +1,242 @@ +import { Flex, Table, Text } from "@radix-ui/themes"; +import type { AccountsStats } from "../../api/types"; +import styles from "./accounts.module.css"; + +interface CacheTableProps { + stats: AccountsStats; +} + +const CACHE_CLASS_NAMES = [ + "128 B", + "512 B", + "2 KiB", + "8 KiB", + "32 KiB", + "128 KiB", + "1 MiB", + "10 MiB", +]; + +function fmtRate(v: number) { + if (v === 0) return "—"; + if (v < 10) return v.toFixed(1); + return Math.round(v).toLocaleString(); +} + +export function CacheTable({ stats }: CacheTableProps) { + return ( + + Cache classes + + + + + Size + + + Capacity + + + Current + + + Usage + + + Reserved + + + Hit rate + + + Reads/s + + + Writes/s + + + Commits/s + + + Misses/s + + + Evicts/s + + + Preevicts/s + + + + + {stats.cache.classes.map((c) => { + const pct = c.max_slots ? (c.used_slots / c.max_slots) * 100 : 0; + const targetPct = c.max_slots + ? (c.target_used_slots / c.max_slots) * 100 + : 0; + const lwmPct = c.max_slots + ? (c.low_water_used_slots / c.max_slots) * 100 + : 0; + const overTarget = targetPct > 0 && pct > targetPct; + const hitPct = c.hit_rate_ema * 100; + const commitRate = + c.committed_new_per_sec + c.committed_overwrite_per_sec; + return ( + + + {CACHE_CLASS_NAMES[c.class] ?? `class ${c.class}`} + + + {c.max_slots.toLocaleString()} + + + {c.used_slots.toLocaleString()} + + + +
+
+
+
+
+ {targetPct > 0 && targetPct < 100 && ( +
+
+
+ )} + {lwmPct > 0 && lwmPct < 100 && ( +
+
+
+ )} +
+ + {pct.toFixed(0)}% + + + + + {c.reserved_slots >= Number.MAX_SAFE_INTEGER + ? "—" + : c.reserved_slots.toLocaleString()} + + + {c.reads_per_sec + c.writes_per_sec > 0 ? ( + = 100 + ? styles.statHitRateGood + : hitPct > 99.9 + ? styles.statHitRateWarn + : styles.statHitRateBad + } + > + {hitPct.toFixed(2)}% + + ) : ( + "—" + )} + + + {fmtRate(c.reads_per_sec)} + + + {fmtRate(c.writes_per_sec)} + + + + {fmtRate(commitRate)} + + + {(() => { + const pctStr = + c.writes_per_sec > 0 + ? `${Math.round((commitRate / c.writes_per_sec) * 100)}%` + : ""; + const shownStr = pctStr || "100%"; + const padCount = Math.max(0, 4 - shownStr.length); + const pad = + padCount > 0 ? ( + + {"0".repeat(padCount)} + + ) : null; + const body = pctStr ? ( + shownStr + ) : ( + + {shownStr} + + ); + return ( + <> + ({pad} + {body}) + + ); + })()} + + + + {fmtRate(c.not_found_per_sec)} + + + {fmtRate(c.evicted_per_sec)} + + + {fmtRate(c.preevicted_per_sec)} + + + ); + })} + + + + ); +} diff --git a/src/features/Accounts/PartitionTable.tsx b/src/features/Accounts/PartitionTable.tsx new file mode 100644 index 00000000..98f20463 --- /dev/null +++ b/src/features/Accounts/PartitionTable.tsx @@ -0,0 +1,336 @@ +import { useRef } from "react"; +import { Flex, Table, Text } from "@radix-ui/themes"; +import type { AccountsStats } from "../../api/types"; +import { formatBytes } from "../../utils"; +import styles from "./accounts.module.css"; + +function UsageBar({ + usedFrac, + fragmentedFrac, + compactionTriggerFrac, + isWriteHead, + compactionFrac, + compactionState, +}: { + usedFrac: number; + fragmentedFrac: number; + compactionTriggerFrac: number; + isWriteHead: boolean; + compactionFrac: number; + compactionState: number; +}) { + const usedPct = Math.min(100, Math.max(0, (usedFrac || 0) * 100)); + const fragPct = Math.min( + 100 - usedPct, + Math.max(0, (fragmentedFrac || 0) * 100), + ); + const headPct = Math.min(100, usedPct + fragPct); + const unusedPct = Math.max(0, 100 - headPct); + const triggerFracPct = Math.max(0, (compactionTriggerFrac || 0) * 100); + const triggerPct = Math.max(0, headPct - triggerFracPct); + const showTrigger = triggerFracPct > 0 && triggerPct < headPct; + + const prevUsedRef = useRef(usedPct); + const prevUsed = prevUsedRef.current; + const deltaStart = Math.min(prevUsed, usedPct); + const deltaWidth = Math.max(0, usedPct - prevUsed); + prevUsedRef.current = usedPct; + const fadeKey = `${deltaStart.toFixed(3)}-${deltaWidth.toFixed(3)}`; + + const prevFragRef = useRef(fragPct); + const prevFrag = prevFragRef.current; + const fragSolidWidth = Math.min(prevFrag, fragPct); + const fragDeltaWidth = Math.max(0, fragPct - prevFrag); + prevFragRef.current = fragPct; + const fragFadeKey = `${usedPct.toFixed(3)}-${fragSolidWidth.toFixed(3)}-${fragDeltaWidth.toFixed(3)}`; + + const isCompacting = compactionState === 2; + const compactionPct = Math.min(100, Math.max(0, (compactionFrac || 0) * 100)); + + return ( +
+
+
+ {deltaWidth > 0 && ( +
+ )} + {fragDeltaWidth > 0 && ( +
+ )} +
+ {isCompacting && ( +
+ )} +
+
+
+ {isWriteHead && unusedPct > 0 && ( +
+ )} + {showTrigger && ( +
+
+
+ )} + {isWriteHead && ( +
+
+
+ )} + {isCompacting && ( +
+
+
+ )} +
+ ); +} + +interface PartitionTableProps { + stats: AccountsStats; +} + +const TIER_NAMES = ["Hot", "Warm", "Cold"]; +const TIER_CLASSES = [styles.tierHot, styles.tierWarm, styles.tierCold]; +const TIER_OFF = 255; +const TIER_OFF_NAME = "Off"; +const GIB = 1024 * 1024 * 1024; + +function fmtOffsetGiB(bytes: number) { + return `${Math.floor(bytes / GIB)} GiB`; +} + +function rateStr(bytesPerSec: number) { + if (!bytesPerSec) return "—"; + const b = formatBytes(bytesPerSec); + return `${b.value} ${b.unit}/s`; +} + +function fmtOps(v: number) { + if (v === 0) return "—"; + if (v >= 1e6) return `${(v / 1e6).toFixed(1)} M`; + if (v >= 1e3) return `${(v / 1e3).toFixed(1)} K`; + if (v < 10) return v.toFixed(1); + return Math.round(v).toLocaleString(); +} + +function fmtPct(frac: number) { + return `${Math.min(100, Math.max(0, frac * 100)).toFixed(0)}%`; +} + +function fmtSeconds(sec: number) { + if (!sec || sec < 0) return "—"; + if (sec < 60) return `${sec.toFixed(0)}s`; + if (sec < 3600) return `${(sec / 60).toFixed(0)}m`; + if (sec < 86400) return `${(sec / 3600).toFixed(1)}h`; + return `${(sec / 86400).toFixed(1)}d`; +} + +function compactionLabel(state: number) { + if (state === 2) return "compacting"; + if (state === 1) return "queued"; + return "—"; +} + +export function PartitionTable({ stats }: PartitionTableProps) { + const partitions = [...(stats.partitions ?? [])].sort( + (a, b) => b.partition_idx - a.partition_idx, + ); + + return ( + + Partitions + + + + + Index + + + Offset + + + Tier + + + Utilization + + + + Fragmentation + + + Reads/s + + + Writes/s + + + Read IO + + + Write IO + + + Created + + + Filled + + Compacting + + + + {partitions.map((p) => { + const isOff = p.tier === TIER_OFF; + const tierName = isOff + ? TIER_OFF_NAME + : (TIER_NAMES[p.tier] ?? `tier ${p.tier}`); + const tierClass = isOff + ? styles.tierOff + : (TIER_CLASSES[p.tier] ?? ""); + const isCompacting = p.compaction_state === 2; + return ( + + + {p.partition_idx} + + + {fmtOffsetGiB(p.file_offset)} + + + {tierName} + + + + + + {fmtPct(p.utilization)} + + + {fmtPct(p.fragmentation)} + + + {fmtOps(p.read_ops_per_sec)} + + + {fmtOps(p.write_ops_per_sec)} + + + {rateStr(p.bytes_read_per_sec)} + + + {rateStr(p.bytes_written_per_sec)} + + + {fmtSeconds(p.age_seconds)} + + + {fmtSeconds(p.filled_seconds)} + + {compactionLabel(p.compaction_state)} + + ); + })} + + + + ); +} diff --git a/src/features/Accounts/StatCards.tsx b/src/features/Accounts/StatCards.tsx new file mode 100644 index 00000000..ebd713f8 --- /dev/null +++ b/src/features/Accounts/StatCards.tsx @@ -0,0 +1,229 @@ +import { Card, Flex, Text } from "@radix-ui/themes"; +import type { ReactNode } from "react"; +import type { AccountsStats } from "../../api/types"; +import { formatBytes } from "../../utils"; +import styles from "./accounts.module.css"; + +interface StatCardsProps { + stats: AccountsStats; +} + +function bstr(bytes: number) { + const b = formatBytes(bytes); + return `${b.value} ${b.unit}`; +} + +function rateStr(bytesPerSec: number) { + if (!bytesPerSec) return "0 B/s"; + const b = formatBytes(bytesPerSec); + return `${b.value} ${b.unit}/s`; +} + +function fmtOps(v: number) { + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`; + if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`; + return Math.round(v).toLocaleString(); +} + +function StatCard({ + label, + value, + sub, + bar, + valueTone, +}: { + label: ReactNode; + value: ReactNode; + sub?: ReactNode; + bar?: number; + valueTone?: "warn" | "danger"; +}) { + const valueClass = + valueTone === "danger" + ? `${styles.statValue} ${styles.statValueDanger}` + : valueTone === "warn" + ? `${styles.statValue} ${styles.statValueWarn}` + : styles.statValue; + return ( + + + {label} + {value} + {bar !== undefined && ( +
+
+
+ )} + {sub !== undefined && {sub}} + + + ); +} + +export function StatCards({ stats }: StatCardsProps) { + const { disk, compaction, cache, io, partitions } = stats; + + const compactingPartition = partitions?.find((p) => p.compaction_state === 2); + const compactingRegion = compactingPartition + ? compactingPartition.used_frac + compactingPartition.fragmented_frac + : 0; + const compactionProgress = + compactingPartition && compactingRegion > 0 + ? Math.min( + 100, + (compactingPartition.compaction_frac / compactingRegion) * 100, + ) + : 0; + + const diskPct = disk.allocated_bytes + ? (disk.used_bytes / disk.allocated_bytes) * 100 + : 0; + const accountsPct = disk.accounts_capacity + ? (disk.accounts_total / disk.accounts_capacity) * 100 + : 0; + + const cacheUsedSlots = cache.classes.reduce((a, c) => a + c.used_slots, 0); + const cacheMaxSlots = cache.classes.reduce((a, c) => a + c.max_slots, 0); + const cachePct = cacheMaxSlots ? (cacheUsedSlots / cacheMaxSlots) * 100 : 0; + + const CACHE_CLASS_BYTES = [ + 128, 512, 2048, 8192, 32768, 131072, 1048576, 10485760, + ]; + const cacheUsedBytes = cache.classes.reduce( + (a, c) => a + c.used_slots * (CACHE_CLASS_BYTES[c.class] ?? 0), + 0, + ); + + const readsPerSec = Math.max( + 0, + io.acquired_per_sec - io.acquired_writable_per_sec, + ); + const writesPerSec = io.acquired_writable_per_sec; + + const fragBytes = + disk.current_bytes > disk.used_bytes + ? disk.current_bytes - disk.used_bytes + : 0; + const fragPct = disk.current_bytes + ? (fragBytes / disk.current_bytes) * 100 + : 0; + + const hitRatePct = cache.hit_rate_ema * 100; + const prewriteRatioPct = io.prewrite_ratio * 100; + + return ( + + + {bstr(disk.used_bytes)} + + {" / "} + {bstr(disk.allocated_bytes)} + + + } + bar={diskPct} + sub={`${diskPct.toFixed(1)}% used · ${bstr(fragBytes)} (${fragPct.toFixed(1)}%) fragmentation`} + /> + + {(disk.accounts_total / 1e6).toFixed(1)} + + {" / "} + {(disk.accounts_capacity / 1e6).toFixed(1)} M + + + } + valueTone={ + accountsPct > 98 ? "danger" : accountsPct > 90 ? "warn" : undefined + } + bar={accountsPct} + sub={`${accountsPct.toFixed(1)}% used`} + /> + + = 100 + ? styles.statHitRateGood + : hitRatePct > 99.9 + ? styles.statHitRateWarn + : styles.statHitRateBad + } + > + {hitRatePct.toFixed(2)} + + % hit · + + {fmtOps(readsPerSec)} + + r/s · + + {fmtOps(writesPerSec)} + + w/s + + } + bar={cachePct} + sub={`${(cache.size_bytes / 1024 ** 3).toFixed(0)} GiB cache · ${(cacheUsedBytes / 1024 ** 3).toFixed(1)} GiB used · ${(cacheUsedSlots / 1e6).toFixed(2)} / ${(cacheMaxSlots / 1e6).toFixed(2)} M slots`} + /> + + Compaction + + {compaction.in_compaction ? "(IN PROGRESS)" : "(IDLE)"} + + + } + value={ + <> + {compaction.compactions_completed.toLocaleString()} + + {" / "} + {compaction.compactions_requested.toLocaleString()} completed + + + } + bar={compactionProgress} + sub={`${rateStr(compaction.relocated_bytes_per_sec)} relocated`} + /> + + {rateStr(io.bytes_read_per_sec)} + read · + + {rateStr(io.bytes_written_per_sec)} + + write + + } + sub={ + <> + {rateStr(io.bytes_copied_per_sec)} copied ·{" "} + {io.read_ops_per_sec.toFixed(0)} r/s ·{" "} + {io.write_ops_per_sec.toFixed(0)} w/s · prewrite{" "} + {prewriteRatioPct.toFixed(1)}% + + } + /> + + ); +} diff --git a/src/features/Accounts/TileTable.tsx b/src/features/Accounts/TileTable.tsx new file mode 100644 index 00000000..37832d47 --- /dev/null +++ b/src/features/Accounts/TileTable.tsx @@ -0,0 +1,261 @@ +import { Flex, Table, Text } from "@radix-ui/themes"; +import { useMemo } from "react"; +import type { AccountsStats, AccountsTile } from "../../api/types"; +import { formatBytes } from "../../utils"; +import TileSparkLine from "../Overview/SlotPerformance/TileSparkLine"; +import styles from "./accounts.module.css"; + +const sparkReadColor = "#5b9bd5"; +const sparkWriteColor = "#8e4ec6"; +const sparkWindowMs = 60_000; +const sparkUpdateIntervalMs = 250; +const sparkTickMs = 250; + +function TileSparkCell({ tile }: { tile: AccountsTile }) { + const { read, write, latestRead, latestWrite } = useMemo(() => { + const r = tile.acquired_history; + const w = tile.acquired_writable_history; + let max = 0; + for (let i = 0; i < r.length; i++) if (r[i] > max) max = r[i]; + for (let i = 0; i < w.length; i++) if (w[i] > max) max = w[i]; + if (max <= 0) { + return { + read: r.map(() => 0), + write: w.map(() => 0), + latestRead: 0, + latestWrite: 0, + }; + } + return { + read: r.map((v) => v / max), + write: w.map((v) => v / max), + latestRead: (r[r.length - 1] ?? 0) / max, + latestWrite: (w[w.length - 1] ?? 0) / max, + }; + }, [tile.acquired_history, tile.acquired_writable_history]); + + return ( + + ); +} + +interface TileTableProps { + stats: AccountsStats; +} + +function fmtRate(v: number) { + if (v === 0) return "—"; + if (v < 10) return v.toFixed(1); + return Math.round(v).toLocaleString(); +} + +function fmtBytesRate(v: number) { + if (v === 0) return "—"; + const f = formatBytes(v, 1); + return `${f.value} ${f.unit}/s`; +} + +function fmtBytes(v: number) { + if (v === 0) return "—"; + const f = formatBytes(v, 1); + return `${f.value} ${f.unit}`; +} + +function fmtCount(v: number) { + if (v === 0) return "—"; + return v.toLocaleString(); +} + +export function TileTable({ stats }: TileTableProps) { + const nameCounts = stats.tiles.reduce>((acc, t) => { + acc[t.name] = (acc[t.name] ?? 0) + 1; + return acc; + }, {}); + return ( + + Tiles + + + + + Tile + + Type + + Hit rate + + + Acquire/s + + + Acc Reads/s + + + Acc Writes/s + + + + + Acc R/W (60s) + + + + + + R + + + + + + W + + + + + + + Acc Commits/s + + + Acc Misses/s + + + Acc Evicts/s + + + Disk Read/s + + + Disk Write/s + + + Disk Read + + + Disk Write + + + Acc Acquired + + + + + {stats.tiles.map((t) => { + const hitPct = t.hit_rate_ema * 100; + const readsPerSec = t.acquired_per_sec; + const writesPerSec = t.acquired_writable_per_sec; + const hasReads = + readsPerSec > 0 || writesPerSec > 0 || t.read_ops_per_sec > 0; + const rowKey = `${t.name}-${t.kind_id}`; + return ( + + + {t.name} + {(nameCounts[t.name] ?? 0) > 1 ? `:${t.kind_id}` : ""} + + {t.joiner_type} + + {hasReads ? ( + = 100 + ? styles.statHitRateGood + : hitPct > 99.9 + ? styles.statHitRateWarn + : styles.statHitRateBad + } + > + {hitPct.toFixed(2)}% + + ) : ( + "—" + )} + + + {fmtRate(t.acquire_calls_per_sec)} + + + {fmtRate(readsPerSec)} + + + {fmtRate(writesPerSec)} + + + + + + {fmtRate(t.committed_per_sec)} + + + {fmtRate(t.not_found_per_sec)} + + + {fmtRate(t.evicted_per_sec)} + + + {fmtBytesRate(t.bytes_read_per_sec)} + + + {fmtBytesRate(t.bytes_written_per_sec)} + + + {fmtBytes(t.bytes_read)} + + + {fmtBytes(t.bytes_written)} + + + {fmtCount(t.acquired)} + + + ); + })} + + + + ); +} diff --git a/src/features/Accounts/accounts.module.css b/src/features/Accounts/accounts.module.css new file mode 100644 index 00000000..3cef9988 --- /dev/null +++ b/src/features/Accounts/accounts.module.css @@ -0,0 +1,432 @@ +.statCard { + flex: 1 1 220px; + min-width: 220px; +} + +.statLabel { + color: var(--gray-10); + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.statValue { + color: var(--gray-12); + font-size: 24px; + font-weight: 500; + line-height: 1.1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + +.statValueSuffix { + color: var(--gray-10); + font-size: 15px; + font-weight: 400; + margin-left: 2px; +} + +.statValueSecondary { + color: var(--gray-12); + font-size: 18px; + font-weight: 500; +} + +.statValueActive { + color: var(--accent-9); + font-size: 24px; + font-weight: 500; +} + +.statLabelStatusIdle { + color: var(--gray-9); + margin-left: 6px; + font-weight: 500; +} + +.statLabelStatusActive { + color: var(--amber-10); + margin-left: 6px; + font-weight: 600; +} + +.statValueWarn { + color: var(--amber-10) !important; +} + +.statValueDanger { + color: var(--red-10) !important; +} + +.statHitRateGood { + color: var(--green-10); +} + +.statHitRateWarn { + color: var(--amber-10); +} + +.statHitRateBad { + color: var(--red-10); +} + +.statBar { + width: 100%; + height: 4px; + background-color: var(--gray-4); + border-radius: 2px; + overflow: hidden; + margin-top: 4px; +} + +.statBarFill { + height: 100%; + background: var(--accent-9); + border-radius: 2px; +} + +.statSub { + color: var(--gray-10); + font-size: 12px; + line-height: 1.4; + margin-top: auto; +} + +.sectionTitle { + color: var(--primary-text-color); + font-size: 16px; + font-weight: 500; +} + +.table { + font-variant-numeric: tabular-nums; + width: 100%; +} + +.table th, +.table td { + white-space: nowrap; + width: 1%; +} + +.table th.usageCol, +.table td.usageCol { + width: auto; +} + +.table th.hitRateCol, +.table td.hitRateCol { + width: 8ch; + min-width: 8ch; + max-width: 8ch; + font-variant-numeric: tabular-nums; +} + +.table th.rateCol, +.table td.rateCol { + width: 13ch; + min-width: 13ch; + max-width: 13ch; + font-variant-numeric: tabular-nums; +} + +.table th.sparkCol, +.table td.sparkCol { + width: auto; + min-width: 180px; + padding-left: 12px; + padding-right: 12px; +} + +.table th.sparkCol { + vertical-align: middle; +} + +.table td.sparkCol { + vertical-align: middle; +} + +.sparkLegend { + color: var(--gray-10); +} + +.sparkHeaderSub { + color: var(--gray-10); + font-weight: 400; + margin-left: 4px; +} + +.sparkSwatchRead, +.sparkSwatchWrite { + display: inline-block; + width: 10px; + height: 2px; + border-radius: 1px; +} + +.sparkSwatchRead { + background: #5b9bd5; +} + +.sparkSwatchWrite { + background: #8e4ec6; +} + +.table td.usageCol { + vertical-align: middle; +} + +.mono { + font-variant-numeric: tabular-nums; +} + +.primaryCol { + font-weight: 600; +} + +.dividerCol { + border-right: 1px solid var(--gray-a5); +} + +.boldCol { + font-weight: 600; +} + +.tierHot { + color: var(--red-8); +} + +.tierWarm { + color: var(--amber-8); +} + +.tierCold { + color: var(--blue-8); +} + +.tierOff { + color: var(--gray-8); +} + +.rowCompacting { + background-color: var(--amber-a2); +} + +.table th.commitCol, +.table td.commitCol { + width: 16ch; + min-width: 16ch; + max-width: 16ch; + font-variant-numeric: tabular-nums; +} + +.commitRate { + display: inline-block; + width: 7ch; + text-align: right; +} + +.commitPct { + color: var(--gray-10); + display: inline-block; + width: 8ch; + text-align: left; + white-space: pre; + margin-left: 6px; +} + +.commitPctHidden { + visibility: hidden; +} + +.barWrap { + position: relative; + flex: 1; + min-width: 80px; + display: flex; + align-items: center; +} + +.bar { + position: relative; + width: 100%; + height: 6px; + background-color: var(--gray-4); + border-radius: 3px; + overflow: hidden; +} + +.barFill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent-9); + border-radius: 3px; +} + +.barFillFade { + animation: barFillFadeIn 250ms ease-out; +} + +@keyframes barFillFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.barFillFrag { + position: absolute; + top: 0; + height: 100%; + background: var(--orange-9); +} + +.barHit { + position: absolute; + top: 0; + bottom: 0; + pointer-events: auto; + background: transparent; +} + +.barTriggerHit { + position: absolute; + top: -6px; + bottom: -6px; + width: 12px; + margin-left: -6px; + pointer-events: auto; + display: flex; + justify-content: center; + align-items: center; +} + +.barTrigger { + width: 3px; + height: 6px; + background: var(--amber-10); +} + +.barWriteHeadHit { + position: absolute; + top: -6px; + bottom: -6px; + width: 12px; + margin-left: -6px; + pointer-events: auto; + display: flex; + justify-content: center; + align-items: center; +} + +.barWriteHead { + position: relative; + width: 3px; + height: 6px; + background: var(--gray-12); +} + +.barWriteHead::before { + content: ""; + position: absolute; + top: -5px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid var(--gray-12); +} + +.barCompactedOverlay { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--gray-4); + pointer-events: none; +} + +.barCompactionHeadHit { + position: absolute; + top: -6px; + bottom: -6px; + width: 12px; + margin-left: -6px; + pointer-events: auto; + display: flex; + justify-content: center; + align-items: center; +} + +.barCompactionHead { + position: relative; + width: 3px; + height: 6px; + background: var(--gray-11); +} + +.barCompactionHead::before { + content: ""; + position: absolute; + top: -5px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid var(--gray-11); +} + +.barFillWarn { + background: var(--amber-8); + opacity: 0.7; +} + +.pctLabelWarn { + color: var(--amber-10); +} + +.barMarkerHit { + position: absolute; + top: -6px; + bottom: -6px; + width: 12px; + margin-left: -6px; + pointer-events: auto; + display: flex; + justify-content: center; + align-items: center; +} + +.barMarker { + width: 3px; + height: 6px; +} + +.barMarkerTarget { + background: var(--gray-9); + opacity: 0.6; +} + +.barMarkerLwm { + background: var(--amber-9); + opacity: 0.55; +} + +.pctLabel { + color: var(--gray-11); + font-size: 12px; + font-variant-numeric: tabular-nums; + min-width: 36px; + text-align: right; +} diff --git a/src/features/Accounts/index.tsx b/src/features/Accounts/index.tsx new file mode 100644 index 00000000..c8a07d9b --- /dev/null +++ b/src/features/Accounts/index.tsx @@ -0,0 +1,24 @@ +import { Flex } from "@radix-ui/themes"; +import { useAtomValue } from "jotai"; +import { accountsStatsAtom } from "../../api/atoms"; +import { isFrankendancer } from "../../client"; +import { StatCards } from "./StatCards"; +import { CacheTable } from "./CacheTable"; +import { TileTable } from "./TileTable"; +import { PartitionTable } from "./PartitionTable"; + +export default function Accounts() { + const stats = useAtomValue(accountsStatsAtom); + + if (isFrankendancer) return; + if (!stats) return; + + return ( + + + + + + + ); +} diff --git a/src/features/Header/Nav.tsx b/src/features/Header/Nav.tsx index 418e3088..32dc423e 100644 --- a/src/features/Header/Nav.tsx +++ b/src/features/Header/Nav.tsx @@ -6,6 +6,7 @@ import SsidChartIcon from "@material-design-icons/svg/filled/ssid_chart.svg?reac import AssessmentIcon from "@material-design-icons/svg/filled/assessment.svg?react"; import CalendarMonthIcon from "@material-design-icons/svg/filled/calendar_month.svg?react"; import CampaignIcon from "@material-design-icons/svg/filled/campaign.svg?react"; +import StorageIcon from "@material-design-icons/svg/filled/storage.svg?react"; import KeyboardArrowDownIcon from "@material-design-icons/svg/filled/keyboard_arrow_down.svg?react"; import { Link } from "@tanstack/react-router"; import clsx from "clsx"; @@ -31,6 +32,7 @@ const RouteLabelToIcon: Record>> = { Overview: SsidChartIcon, Schedule: CalendarMonthIcon, Gossip: CampaignIcon, + Accounts: StorageIcon, "Slot Details": AssessmentIcon, }; @@ -97,7 +99,11 @@ export function NavLinks() { {Object.keys(RouteLabelToPath).map((label) => { const routeLabel = label as RouteLabel; - if (routeLabel === "Gossip" && isFrankendancer) return; + if ( + (routeLabel === "Gossip" || routeLabel === "Accounts") && + isFrankendancer + ) + return; return ( {Object.keys(RouteLabelToPath).map((label) => { const routeLabel = label as RouteLabel; - if (routeLabel === "Gossip" && isFrankendancer) return; + if ( + (routeLabel === "Gossip" || routeLabel === "Accounts") && + isFrankendancer + ) + return; return ( diff --git a/src/features/Overview/SlotPerformance/TileSparkLine.tsx b/src/features/Overview/SlotPerformance/TileSparkLine.tsx index b23e9ff0..b64f1800 100644 --- a/src/features/Overview/SlotPerformance/TileSparkLine.tsx +++ b/src/features/Overview/SlotPerformance/TileSparkLine.tsx @@ -21,42 +21,61 @@ const _updateIntervalMs = 80; interface TileParkLineProps { value?: number; + value2?: number; history?: number[] | { ts: number; value: number }[]; + history2?: number[] | { ts: number; value: number }[]; height?: number; background?: string; windowMs?: number; strokeWidth?: number; updateIntervalMs?: number; tickMs?: number; + stroke?: string; + stroke2?: string; + fill?: boolean; } export default function TileSparkLine({ value, + value2, history, + history2, height = 24, background, windowMs = leaderGroupWindowMs, strokeWidth = strokeLineWidth, updateIntervalMs = _updateIntervalMs, tickMs, + stroke, + stroke2, + fill = true, }: TileParkLineProps) { const [svgRef, { width }] = useMeasure(); - const { scaledDataPoints, range, pxPerTick, chartTickMs, isLive } = - useScaledDataPoints({ - value, - history, - windowMs, - height, - width, - updateIntervalMs, - tickMs, - }); + const { + scaledDataPoints, + scaledDataPoints2, + range, + pxPerTick, + chartTickMs, + isLive, + } = useScaledDataPoints({ + value, + value2, + history, + history2, + windowMs, + height, + width, + updateIntervalMs, + tickMs, + }); return ( ); } @@ -74,6 +96,10 @@ interface SparklineProps { x: number; y: number; }[]; + scaledDataPoints2?: { + x: number; + y: number; + }[]; range?: SparklineRange; showRange?: boolean; height: number; @@ -82,10 +108,14 @@ interface SparklineProps { tickMs: number; isLive: boolean; strokeWidth?: number; + stroke?: string; + stroke2?: string; + fill?: boolean; } export function Sparkline({ svgRef, scaledDataPoints, + scaledDataPoints2, range = sparkLineRange, showRange = false, height, @@ -94,9 +124,13 @@ export function Sparkline({ tickMs, isLive, strokeWidth = strokeLineWidth, + stroke, + stroke2, + fill = true, }: SparklineProps) { const gRef = useRef(null); const polyRef = useRef(null); + const polyRef2 = useRef(null); const animateRef = useRef(null); // where the gradient colors start / end, given y scale and offset @@ -113,6 +147,14 @@ export function Sparkline({ [scaledDataPoints], ); + const points2 = useMemo( + () => + scaledDataPoints2 + ? scaledDataPoints2.map(({ x, y }) => `${x},${y}`).join(" ") + : "", + [scaledDataPoints2], + ); + useLayoutEffect(() => { const el = gRef.current; if (!el) return; @@ -133,6 +175,7 @@ export function Sparkline({ animateRef.current.finish(); polyRef.current?.setAttribute("points", points); + polyRef2.current?.setAttribute("points", points2); const effect = animateRef.current.effect as KeyframeEffect; effect.setKeyframes([ @@ -149,9 +192,12 @@ export function Sparkline({ animateRef.current.play(); } else { polyRef.current?.setAttribute("points", points); + polyRef2.current?.setAttribute("points", points2); animateRef.current?.cancel(); } - }, [isLive, points, pxPerTick, tickMs]); + }, [isLive, points, points2, pxPerTick, tickMs]); + + const stroke1 = stroke ?? "url(#paint0_linear_2971_11300)"; return ( <> @@ -167,27 +213,39 @@ export function Sparkline({ + {scaledDataPoints2 && ( + + )} - - - - - - + {fill && ( + + + + + + + )} {showRange && ( diff --git a/src/features/Overview/SlotPerformance/useTileSparkline.ts b/src/features/Overview/SlotPerformance/useTileSparkline.ts index 694b7d29..141df138 100644 --- a/src/features/Overview/SlotPerformance/useTileSparkline.ts +++ b/src/features/Overview/SlotPerformance/useTileSparkline.ts @@ -105,7 +105,9 @@ function isNumberHistory( interface UseScaledDataPointsProps { value?: number; + value2?: number; history?: number[] | { ts: number; value: number }[]; + history2?: number[] | { ts: number; value: number }[]; windowMs: number; height: number; width: number; @@ -116,7 +118,9 @@ interface UseScaledDataPointsProps { export function useScaledDataPoints({ value, + value2, history, + history2, windowMs: _windowMs, height, width: _width, @@ -127,6 +131,11 @@ export function useScaledDataPoints({ const [scaledDataPoints, setScaledDataPoints] = useState< { x: number; y: number }[] >([]); + const [scaledDataPoints2, setScaledDataPoints2] = useState< + { x: number; y: number }[] + >([]); + + const hasSeries2 = value2 !== undefined || !!history2?.length; const isStatic = !!(history?.length && value === undefined); @@ -141,6 +150,16 @@ export function useScaledDataPoints({ return history.map((value, i) => ({ value, ts: tStart + i * ratio })); }, [_windowMs, history]); + const normalizedHistory2 = useMemo(() => { + if (!history2?.length) return; + if (!isNumberHistory(history2)) return history2; + + const now = performance.now(); + const ratio = _windowMs / (history2.length - 1); + const tStart = now - _windowMs; + return history2.map((value, i) => ({ value, ts: tStart + i * ratio })); + }, [_windowMs, history2]); + const { pxPerTick, width, windowMs } = useMemo(() => { let windowMs = _windowMs; let width = _width; @@ -161,8 +180,13 @@ export function useScaledDataPoints({ { value: undefined, ts: performance.now() - windowMs }, { value: undefined, ts: performance.now() }, ]); + const dataRef2 = useRef([ + { value: undefined, ts: performance.now() - windowMs }, + { value: undefined, ts: performance.now() }, + ]); const isSeededRef = useRef(false); + const isSeededRef2 = useRef(false); useEffect(() => { if (isSeededRef.current || !normalizedHistory?.length) return; @@ -177,11 +201,25 @@ export function useScaledDataPoints({ })); }, [normalizedHistory]); + useEffect(() => { + if (isSeededRef2.current || !normalizedHistory2?.length) return; + isSeededRef2.current = true; + + const now = performance.now(); + const newestTs = normalizedHistory2[normalizedHistory2.length - 1].ts; + + dataRef2.current = normalizedHistory2.map(({ ts, value }) => ({ + value, + ts: now - (newestTs - ts), + })); + }, [normalizedHistory2]); + useEffect(() => { if (stopShifting || isStatic) return; setDataWindow(dataRef.current, windowMs, value); - }, [isStatic, windowMs, stopShifting, value]); + if (hasSeries2) setDataWindow(dataRef2.current, windowMs, value2); + }, [isStatic, windowMs, stopShifting, value, value2, hasSeries2]); useInterval(() => { if (stopShifting || isStatic) return; @@ -193,15 +231,13 @@ export function useScaledDataPoints({ } setDataWindow(dataRef.current, windowMs, value); + if (hasSeries2) setDataWindow(dataRef2.current, windowMs, value2); }, updateIntervalMs); useEffect(() => { - function tick(data: (PointSample | undefined)[], tEnd: number) { + function buildPoints(data: (PointSample | undefined)[], tEnd: number) { const size = data.length; - if (size === 0) { - setScaledDataPoints([]); - return; - } + if (size === 0) return []; const tStart = tEnd - windowMs; const scale = width / windowMs; @@ -233,7 +269,16 @@ export function useScaledDataPoints({ points[i] = { x: x, y: y }; } - setScaledDataPoints(points); + return points; + } + + function tick( + data: (PointSample | undefined)[], + data2: (PointSample | undefined)[] | undefined, + tEnd: number, + ) { + setScaledDataPoints(buildPoints(data, tEnd)); + if (data2) setScaledDataPoints2(buildPoints(data2, tEnd)); } if (isStatic) { @@ -245,8 +290,14 @@ export function useScaledDataPoints({ value, ts: tEnd - (newestTs - ts), })); - - tick(data, tEnd); + const data2 = normalizedHistory2?.length + ? normalizedHistory2.map(({ ts, value }) => ({ + value, + ts: tEnd - (newestTs - ts), + })) + : undefined; + + tick(data, data2, tEnd); } // live else { @@ -256,16 +307,30 @@ export function useScaledDataPoints({ const clock = clocks.get(tickMs); if (clock) { const unsub = clock.subscribeClock((tEnd) => { - tick(dataRef.current, tEnd); + tick( + dataRef.current, + hasSeries2 ? dataRef2.current : undefined, + tEnd, + ); }); return unsub; } } - }, [height, normalizedHistory, isStatic, tickMs, width, windowMs]); + }, [ + height, + normalizedHistory, + normalizedHistory2, + isStatic, + tickMs, + width, + windowMs, + hasSeries2, + ]); return { scaledDataPoints, + scaledDataPoints2: hasSeries2 ? scaledDataPoints2 : undefined, range: sparkLineRange, pxPerTick, chartTickMs: tickMs, diff --git a/src/hooks/useCurrentRoute.ts b/src/hooks/useCurrentRoute.ts index 7de088e4..5d7c7bf3 100644 --- a/src/hooks/useCurrentRoute.ts +++ b/src/hooks/useCurrentRoute.ts @@ -1,12 +1,18 @@ import { useLocation } from "@tanstack/react-router"; import { useMemo } from "react"; -export type RouteLabel = "Overview" | "Schedule" | "Gossip" | "Slot Details"; +export type RouteLabel = + | "Overview" + | "Schedule" + | "Gossip" + | "Accounts" + | "Slot Details"; export const RouteLabelToPath: Record = { Overview: "/", "Slot Details": "/slotDetails", Schedule: "/leaderSchedule", Gossip: "/gossip", + Accounts: "/accounts", }; export function useCurrentRoute() { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1ce9871e..188f49a1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -8,130 +8,161 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as SlotDetailsRouteImport } from './routes/slotDetails' -import { Route as LeaderScheduleRouteImport } from './routes/leaderSchedule' -import { Route as GossipRouteImport } from './routes/gossip' -import { Route as AboutRouteImport } from './routes/about' -import { Route as IndexRouteImport } from './routes/index' +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as SlotDetailsRouteImport } from "./routes/slotDetails"; +import { Route as LeaderScheduleRouteImport } from "./routes/leaderSchedule"; +import { Route as GossipRouteImport } from "./routes/gossip"; +import { Route as AccountsRouteImport } from "./routes/accounts"; +import { Route as AboutRouteImport } from "./routes/about"; +import { Route as IndexRouteImport } from "./routes/index"; const SlotDetailsRoute = SlotDetailsRouteImport.update({ - id: '/slotDetails', - path: '/slotDetails', + id: "/slotDetails", + path: "/slotDetails", getParentRoute: () => rootRouteImport, -} as any) +} as any); const LeaderScheduleRoute = LeaderScheduleRouteImport.update({ - id: '/leaderSchedule', - path: '/leaderSchedule', + id: "/leaderSchedule", + path: "/leaderSchedule", getParentRoute: () => rootRouteImport, -} as any) +} as any); const GossipRoute = GossipRouteImport.update({ - id: '/gossip', - path: '/gossip', + id: "/gossip", + path: "/gossip", getParentRoute: () => rootRouteImport, -} as any) +} as any); +const AccountsRoute = AccountsRouteImport.update({ + id: "/accounts", + path: "/accounts", + getParentRoute: () => rootRouteImport, +} as any); const AboutRoute = AboutRouteImport.update({ - id: '/about', - path: '/about', + id: "/about", + path: "/about", getParentRoute: () => rootRouteImport, -} as any) +} as any); const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', + id: "/", + path: "/", getParentRoute: () => rootRouteImport, -} as any) +} as any); export interface FileRoutesByFullPath { - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/gossip': typeof GossipRoute - '/leaderSchedule': typeof LeaderScheduleRoute - '/slotDetails': typeof SlotDetailsRoute + "/": typeof IndexRoute; + "/about": typeof AboutRoute; + "/accounts": typeof AccountsRoute; + "/gossip": typeof GossipRoute; + "/leaderSchedule": typeof LeaderScheduleRoute; + "/slotDetails": typeof SlotDetailsRoute; } export interface FileRoutesByTo { - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/gossip': typeof GossipRoute - '/leaderSchedule': typeof LeaderScheduleRoute - '/slotDetails': typeof SlotDetailsRoute + "/": typeof IndexRoute; + "/about": typeof AboutRoute; + "/accounts": typeof AccountsRoute; + "/gossip": typeof GossipRoute; + "/leaderSchedule": typeof LeaderScheduleRoute; + "/slotDetails": typeof SlotDetailsRoute; } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/gossip': typeof GossipRoute - '/leaderSchedule': typeof LeaderScheduleRoute - '/slotDetails': typeof SlotDetailsRoute + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/about": typeof AboutRoute; + "/accounts": typeof AccountsRoute; + "/gossip": typeof GossipRoute; + "/leaderSchedule": typeof LeaderScheduleRoute; + "/slotDetails": typeof SlotDetailsRoute; } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' | '/gossip' | '/leaderSchedule' | '/slotDetails' - fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' | '/gossip' | '/leaderSchedule' | '/slotDetails' + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: + | "/" + | "/about" + | "/accounts" + | "/gossip" + | "/leaderSchedule" + | "/slotDetails"; + fileRoutesByTo: FileRoutesByTo; + to: + | "/" + | "/about" + | "/accounts" + | "/gossip" + | "/leaderSchedule" + | "/slotDetails"; id: - | '__root__' - | '/' - | '/about' - | '/gossip' - | '/leaderSchedule' - | '/slotDetails' - fileRoutesById: FileRoutesById + | "__root__" + | "/" + | "/about" + | "/accounts" + | "/gossip" + | "/leaderSchedule" + | "/slotDetails"; + fileRoutesById: FileRoutesById; } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute - AboutRoute: typeof AboutRoute - GossipRoute: typeof GossipRoute - LeaderScheduleRoute: typeof LeaderScheduleRoute - SlotDetailsRoute: typeof SlotDetailsRoute + IndexRoute: typeof IndexRoute; + AboutRoute: typeof AboutRoute; + AccountsRoute: typeof AccountsRoute; + GossipRoute: typeof GossipRoute; + LeaderScheduleRoute: typeof LeaderScheduleRoute; + SlotDetailsRoute: typeof SlotDetailsRoute; } -declare module '@tanstack/react-router' { +declare module "@tanstack/react-router" { interface FileRoutesByPath { - '/slotDetails': { - id: '/slotDetails' - path: '/slotDetails' - fullPath: '/slotDetails' - preLoaderRoute: typeof SlotDetailsRouteImport - parentRoute: typeof rootRouteImport - } - '/leaderSchedule': { - id: '/leaderSchedule' - path: '/leaderSchedule' - fullPath: '/leaderSchedule' - preLoaderRoute: typeof LeaderScheduleRouteImport - parentRoute: typeof rootRouteImport - } - '/gossip': { - id: '/gossip' - path: '/gossip' - fullPath: '/gossip' - preLoaderRoute: typeof GossipRouteImport - parentRoute: typeof rootRouteImport - } - '/about': { - id: '/about' - path: '/about' - fullPath: '/about' - preLoaderRoute: typeof AboutRouteImport - parentRoute: typeof rootRouteImport - } - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } + "/slotDetails": { + id: "/slotDetails"; + path: "/slotDetails"; + fullPath: "/slotDetails"; + preLoaderRoute: typeof SlotDetailsRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/leaderSchedule": { + id: "/leaderSchedule"; + path: "/leaderSchedule"; + fullPath: "/leaderSchedule"; + preLoaderRoute: typeof LeaderScheduleRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/gossip": { + id: "/gossip"; + path: "/gossip"; + fullPath: "/gossip"; + preLoaderRoute: typeof GossipRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/accounts": { + id: "/accounts"; + path: "/accounts"; + fullPath: "/accounts"; + preLoaderRoute: typeof AccountsRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/about": { + id: "/about"; + path: "/about"; + fullPath: "/about"; + preLoaderRoute: typeof AboutRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, + AccountsRoute: AccountsRoute, GossipRoute: GossipRoute, LeaderScheduleRoute: LeaderScheduleRoute, SlotDetailsRoute: SlotDetailsRoute, -} +}; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes() + ._addFileTypes(); diff --git a/src/routes/accounts.tsx b/src/routes/accounts.tsx new file mode 100644 index 00000000..ebb4d300 --- /dev/null +++ b/src/routes/accounts.tsx @@ -0,0 +1,14 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import Accounts from "../features/Accounts"; +import { isFrankendancer } from "../client"; + +export const Route = createFileRoute("/accounts")({ + component: Accounts, + beforeLoad: () => { + if (isFrankendancer) { + throw redirect({ + to: "/", + }); + } + }, +});