diff --git a/src/api/MinersDashboardApi.ts b/src/api/MinersDashboardApi.ts new file mode 100644 index 0000000..c4f7460 --- /dev/null +++ b/src/api/MinersDashboardApi.ts @@ -0,0 +1,129 @@ +import { useApiQuery } from './ApiUtils'; +import { SSE_FALLBACK_INTERVAL } from './constants'; +import type { + ActiveSwap, + CrownHistoryRow, + CrownRateHistoryRow, + CurrentCrownMap, + Direction, + HaltState, + LeaderboardRow, + MinerRateHistoryRow, + MinerStats, + NetworkOverview, + Range, + ScoreFactors, +} from './models'; + +const CROWN_REFRESH_MS = 12_000; + +export const useCurrentCrown = () => + useApiQuery('crown', '/crown', CROWN_REFRESH_MS); + +export const useCrownHistory = (params: { + direction: Direction; + fromBlock?: number; + toBlock?: number; +}) => + useApiQuery( + 'crown-history', + '/crown/history', + CROWN_REFRESH_MS, + { + direction: params.direction, + fromBlock: params.fromBlock, + toBlock: params.toBlock, + }, + ); + +export const useCrownRateHistory = (params: { + direction: Direction; + fromBlock?: number; + toBlock?: number; + blocks?: number; +}) => + useApiQuery( + 'crown-rate-history', + '/crown/rate-history', + CROWN_REFRESH_MS, + { + direction: params.direction, + fromBlock: params.fromBlock, + toBlock: params.toBlock, + blocks: params.blocks, + }, + ); + +export const useMinerLeaderboard = (range: Range = '30d') => + useApiQuery( + 'miners-leaderboard', + '/miners/leaderboard', + SSE_FALLBACK_INTERVAL, + { + range, + }, + ); + +export const useMinerStats = (hotkey: string, range: Range = '30d') => + useApiQuery( + 'miner-stats', + `/miners/${hotkey}/stats`, + SSE_FALLBACK_INTERVAL, + { range }, + !!hotkey, + ); + +export const useScoreFactorsWindow = ( + hotkey: string, + direction: Direction, + fromBlock: number | undefined, + toBlock: number | undefined, +) => + useApiQuery( + 'miner-score-factors-window', + `/miners/${hotkey}/score-factors`, + SSE_FALLBACK_INTERVAL, + { direction, fromBlock, toBlock }, + !!hotkey && fromBlock != null && toBlock != null && toBlock >= fromBlock, + ); + +export const useMinerSwaps = ( + hotkey: string, + params: { limit?: number; offset?: number; status?: string } = {}, +) => + useApiQuery<{ rows: ActiveSwap[]; totalCount: number }>( + 'miner-swaps', + `/miners/${hotkey}/swaps`, + SSE_FALLBACK_INTERVAL, + params, + !!hotkey, + ); + +export const useMinerRateHistory = ( + hotkey: string, + params: { fromBlock?: number; toBlock?: number; blocks?: number } = {}, +) => + useApiQuery( + 'miner-rate-history', + `/miners/${hotkey}/rate-history`, + SSE_FALLBACK_INTERVAL, + params, + !!hotkey, + ); + +export const useNetworkOverview = (range: Range = '30d') => + useApiQuery( + 'network-overview', + '/network/overview', + SSE_FALLBACK_INTERVAL, + { + range, + }, + ); + +export const useHaltState = () => + useApiQuery( + 'network-halt-state', + '/network/halt-state', + CROWN_REFRESH_MS, + ); diff --git a/src/api/index.ts b/src/api/index.ts index 236c127..f4e9e92 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,7 @@ export * from './ApiUtils'; export * from './EventsApi'; export * from './MinersApi'; +export * from './MinersDashboardApi'; export * from './ProtocolApi'; export * from './ReservationsApi'; export * from './StatsApi'; diff --git a/src/api/models/MinersDashboard.ts b/src/api/models/MinersDashboard.ts new file mode 100644 index 0000000..aa653bf --- /dev/null +++ b/src/api/models/MinersDashboard.ts @@ -0,0 +1,94 @@ +export type Direction = 'BTC-TAO' | 'TAO-BTC'; +export type Range = '24h' | '7d' | '30d' | '90d' | 'all'; + +export type CurrentCrown = { + uid: number | null; + hotkey: string | null; + rate: number | null; + sinceBlock: number | null; +}; + +export type CurrentCrownMap = Record; + +export type CrownHistoryRow = { + block: number; + hotkey: string; + uid: number | null; + rate: number; +}; + +export type CrownRateHistoryRow = { + block: number; + rate: number; +}; + +export type LeaderboardRow = { + uid: number; + hotkey: string; + crownShare: number; + successRate: number; + completedSwaps: number; + timedOutSwaps: number; + volumeTao: string; + collateralRao: string; + isActive: boolean; + currentCrownDirections: Direction[]; +}; + +export type ScoreFactors = { + capacityFactor: number; + collateralRao: string; + maxSwapAmountRao: string; + + volumeFactor: number; + volumeShareWindow: number; + crownShareWindow: number; + volumeTaoWindow: string; + networkVolumeTaoWindow: string; + previousCrownShareWindow: number; + previousVolumeFactor: number; + + closedSwaps: number; + credibilityRamp: number; + credibilityRampTarget: number; + successRate30d: number; + successMultiplier: number; +}; + +export type MinerStats = { + uid: number | null; + totalSwaps: number; + completedSwaps: number; + timedOutSwaps: number; + successRate: number; + volumeTao: string; + avgFulfillSec: number | null; + avgCompleteSec: number | null; + crownShare: number; + isActive: boolean; + collateralRao: string; + activatedAt: number | null; + currentCrownDirections: Direction[]; + scoreFactors: ScoreFactors; +}; + +export type MinerRateHistoryRow = { + block: number; + rate: number; + fromChain: string; + toChain: string; +}; + +export type PairMix = { pair: string; pct: number }; + +export type NetworkOverview = { + volumeTao: string; + totalSwaps: number; + networkSuccessRate: number; + activeMiners: number; + pairMix: PairMix[]; + scoringWindowVolumeTao: string; + maxSwapAmountRao: string; +}; + +export type HaltState = { halted: boolean; asOfBlock: number }; diff --git a/src/api/models/index.ts b/src/api/models/index.ts index efcc34c..8fa6fb6 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,5 +1,7 @@ export * from './Events'; export * from './Miners'; +export * from './MinersDashboard'; +export * from './searchParams'; export * from './Protocol'; export * from './Reservations'; export * from './Stats'; diff --git a/src/api/models/searchParams.ts b/src/api/models/searchParams.ts new file mode 100644 index 0000000..2935115 --- /dev/null +++ b/src/api/models/searchParams.ts @@ -0,0 +1,28 @@ +import type { Direction, Range } from './MinersDashboard'; + +// Crown-grid window mode lives only on the URL — not on any API contract — +// so the type lives here next to the search-param guards that read it. +export type CrownRange = '1h' | '2h' | '4h'; +export type RateRange = '1h' | '4h' | '24h' | '7d'; + +const RANGES: readonly Range[] = ['24h', '7d', '30d', '90d', 'all']; +const CROWN_RANGES: readonly CrownRange[] = ['1h', '2h', '4h']; +const RATE_RANGES: readonly RateRange[] = ['1h', '4h', '24h', '7d']; + +export const isRange = (v: string | null): v is Range => + RANGES.includes((v ?? '') as Range); + +export const isDirection = (v: string | null): v is Direction => + v === 'BTC-TAO' || v === 'TAO-BTC'; + +export const isCrownRange = (v: string | null): v is CrownRange => + CROWN_RANGES.includes((v ?? '') as CrownRange); + +export const isRateRange = (v: string | null): v is RateRange => + RATE_RANGES.includes((v ?? '') as RateRange); + +export const parseBlockParam = (v: string | null): number | null => { + if (v == null || v === '') return null; + const n = Number(v); + return Number.isInteger(n) && n >= 0 ? n : null; +}; diff --git a/src/components/index.ts b/src/components/index.ts index d634208..455221f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -15,4 +15,5 @@ export * from './nav'; export * from './animated'; export * from './landing'; export * from './agents'; +export * from './miners'; export * from './swap'; diff --git a/src/components/miners/CrownGridHoverCard.tsx b/src/components/miners/CrownGridHoverCard.tsx new file mode 100644 index 0000000..343559a --- /dev/null +++ b/src/components/miners/CrownGridHoverCard.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { Box, Stack } from '@mui/material'; +import { FONTS } from '../../theme'; +import CrownIcon from './CrownIcon'; +import type { CellState } from './crownGridCells'; + +const HoverLine: React.FC<{ + label: string; + value: React.ReactNode; + valueColor?: string; +}> = ({ label, value, valueColor }) => ( + + + {label} + + + {value} + + +); + +// Hovering a cell positions this card relative to the grid's `position: +// relative` parent. `x`/`y` are the cell center relative to that parent. +const CrownGridHoverCard: React.FC<{ + hover: { cell: CellState; x: number; y: number }; + isDark: boolean; +}> = ({ hover, isDark }) => { + const { cell, x, y } = hover; + const bg = isDark ? 'rgba(8,10,14,0.97)' : 'rgba(255,255,255,0.98)'; + const border = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(9,11,13,0.18)'; + const shadow = isDark + ? '0 12px 28px -8px rgba(0,0,0,0.7)' + : '0 12px 28px -8px rgba(9,11,13,0.25)'; + const dotBg = + cell.color ?? (isDark ? 'rgba(255,255,255,0.18)' : 'rgba(9,11,13,0.22)'); + return ( + + + + {cell.holderHotkey ? ( + + ) : ( + + )} + {cell.holderHotkey ? ( + + uid {cell.holderUid ?? '?'} + + ) : ( + + no holder + + )} + {cell.holderHotkey && ( + + + crown + + )} + + + {cell.holderHotkey && ( + + )} + {cell.isTie && ( + + )} + {cell.isCurrent && ( + + )} + + + ); +}; + +export default CrownGridHoverCard; diff --git a/src/components/miners/CrownGridRangeInputs.tsx b/src/components/miners/CrownGridRangeInputs.tsx new file mode 100644 index 0000000..948841a --- /dev/null +++ b/src/components/miners/CrownGridRangeInputs.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Stack, TextField, Typography } from '@mui/material'; +import { FONTS } from '../../theme'; + +// From/to block-number inputs for the crown grid's custom-range mode. +// Owns its own draft state (resets when the URL-driven props change), and +// commits via onChange on Enter when valid. Validation: both ends set, +// non-negative integers, to > from, span <= maxSpan. +const CrownGridRangeInputs: React.FC<{ + customFrom: number | null; + customTo: number | null; + customActive: boolean; + maxSpan: number; + onChange: (from: number | null, to: number | null) => void; +}> = ({ customFrom, customTo, customActive, maxSpan, onChange }) => { + const [fromInput, setFromInput] = useState( + customFrom != null ? String(customFrom) : '', + ); + const [toInput, setToInput] = useState( + customTo != null ? String(customTo) : '', + ); + useEffect(() => { + setFromInput(customFrom != null ? String(customFrom) : ''); + }, [customFrom]); + useEffect(() => { + setToInput(customTo != null ? String(customTo) : ''); + }, [customTo]); + + const error = useMemo(() => { + if (!fromInput && !toInput) return null; + if (!fromInput || !toInput) return 'set both ends'; + const f = Number(fromInput); + const t = Number(toInput); + if (!Number.isInteger(f) || !Number.isInteger(t) || f < 0 || t < 0) + return 'block #s must be non-negative integers'; + if (t <= f) return 'to must be > from'; + if (t - f > maxSpan) return `range > ${maxSpan} blocks`; + return null; + }, [fromInput, toInput, maxSpan]); + + const submit = () => { + if (error || !fromInput || !toInput) return; + onChange(Number(fromInput), Number(toInput)); + }; + const clear = () => { + setFromInput(''); + setToInput(''); + onChange(null, null); + }; + + const onlyDigits = (raw: string) => raw.replace(/[^0-9]/g, ''); + const inputProps = { + style: { + fontFamily: FONTS.mono, + fontSize: '0.7rem', + padding: '5px 9px', + }, + }; + + return ( + + + range + + setFromInput(onlyDigits(e.target.value))} + onKeyDown={(e) => { + if (e.key === 'Enter') submit(); + }} + inputProps={inputProps} + sx={{ width: 110 }} + /> + + → + + setToInput(onlyDigits(e.target.value))} + onKeyDown={(e) => { + if (e.key === 'Enter') submit(); + }} + inputProps={inputProps} + sx={{ width: 110 }} + /> + {customActive && ( + + )} + {error ? ( + + {error} + + ) : ( + (fromInput || toInput) && + !customActive && ( + + press enter to apply + + ) + )} + + ); +}; + +export default CrownGridRangeInputs; diff --git a/src/components/miners/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx new file mode 100644 index 0000000..8bfb302 --- /dev/null +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -0,0 +1,725 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Box, + Button, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, + alpha, + useTheme, +} from '@mui/material'; +import { useCrownHistory, useHaltState, type Direction } from '../../api'; +import { FONTS } from '../../theme'; +import CrownGridHoverCard from './CrownGridHoverCard'; +import CrownGridRangeInputs from './CrownGridRangeInputs'; +import { buildCells, buildTiers, type CellState } from './crownGridCells'; + +// Mirrors SCORING_WINDOW_BLOCKS in allways/constants.py — validator sets +// weights once per cadence, so the 2h grid snaps to multiples and the +// custom-range input caps at the same span. +const SCORING_WINDOW_BLOCKS = 600; +const ROW_BLOCKS = 60; +const CELL_PX = 14; +const RANGE_BLOCKS: Record = { + '1h': 300, + '2h': SCORING_WINDOW_BLOCKS, + '4h': 2 * SCORING_WINDOW_BLOCKS, +}; + +type CrownRange = '1h' | '2h' | '4h'; + +const CrownHistoryGrid: React.FC<{ + direction: Direction; + onDirectionChange: (d: Direction) => void; + range: CrownRange; + onRangeChange: (r: CrownRange) => void; + pan: number; + onPanChange: (next: number) => void; + // When set, the grid permanently filters to this uid — search input is + // replaced with a static label, legend chips become non-interactive. + lockedUid?: number | null; + // Optional manual block range. When both are set and valid, the grid + // ignores range/pan and renders the exact [customFrom, customTo] window. + customFrom?: number | null; + customTo?: number | null; + onCustomRangeChange?: (from: number | null, to: number | null) => void; + // Drop the outer card chrome + header so the parent panel can wrap it. + embedded?: boolean; + // Publish the resolved [lo, hi] so the panel can fetch windowed factors + // that match what's drawn. + onWindowChange?: (lo: number, hi: number) => void; +}> = ({ + direction, + onDirectionChange, + range, + onRangeChange, + pan, + onPanChange, + lockedUid = null, + customFrom = null, + customTo = null, + onCustomRangeChange, + embedded = false, + onWindowChange, +}) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + // Theme-aware neutrals — without these, empty/other cells render as + // near-white on light surfaces and disappear. + const emptyColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(9,11,13,0.07)'; + const otherColor = isDark ? 'rgba(255,255,255,0.20)' : 'rgba(9,11,13,0.22)'; + const cellHoverOutline = isDark + ? 'rgba(255,255,255,0.85)' + : 'rgba(9,11,13,0.85)'; + + const customActive = + customFrom != null && + customTo != null && + customFrom >= 0 && + customTo > customFrom && + customTo - customFrom <= SCORING_WINDOW_BLOCKS; + const [uidSearch, setUidSearch] = useState(''); + // Track whether the active filter came from clicking a legend chip vs. + // typing in the search box. Only chip-driven filters surface a clear (×) + // affordance next to the chip itself. + const [chipFilter, setChipFilter] = useState(null); + const [hover, setHover] = useState<{ + cell: CellState; + x: number; + y: number; + } | null>(null); + const gridRef = useRef(null); + const span = RANGE_BLOCKS[range]; + + // Anchor pan/snap math on the actual chain head, not on the fetched-data + // tip — otherwise panning backward shows an empty grid because + // useCrownHistory only fetched the most-recent default window. + const { data: halt } = useHaltState(); + const headBlock = halt?.asOfBlock ?? 0; + + let hi: number; + let lo: number; + if (customActive) { + lo = customFrom as number; + hi = customTo as number; + } else if (range === '2h') { + const anchor = + Math.floor(headBlock / SCORING_WINDOW_BLOCKS) * SCORING_WINDOW_BLOCKS; + const windowsBack = Math.floor(pan / SCORING_WINDOW_BLOCKS); + lo = Math.max(0, anchor - windowsBack * SCORING_WINDOW_BLOCKS); + hi = lo + SCORING_WINDOW_BLOCKS - 1; + } else { + hi = headBlock - pan; + lo = Math.max(0, hi - span + 1); + } + const atEarliest = lo <= 0; + useEffect(() => { + if (headBlock > 0) onWindowChange?.(lo, hi); + }, [lo, hi, headBlock, onWindowChange]); + + // Fetch the exact window we're rendering — without this the grid asked for + // the API's default range and showed empty cells whenever the user panned + // backward past the default. + const { data } = useCrownHistory({ + direction, + fromBlock: headBlock > 0 ? lo : undefined, + toBlock: headBlock > 0 ? hi : undefined, + }); + const rows = useMemo(() => data ?? [], [data]); + const isLocked = lockedUid != null; + const subjectColor = theme.palette.primary.main; + const { color: tierColors, ordered: tierLegend } = useMemo( + () => + isLocked ? { color: new Map(), ordered: [] } : buildTiers(rows, lo, hi), + [rows, lo, hi, isLocked], + ); + const cells = useMemo( + () => + buildCells( + rows, + lo, + hi, + headBlock, + tierColors, + otherColor, + isLocked ? lockedUid : null, + isLocked ? subjectColor : null, + ), + [ + rows, + lo, + hi, + headBlock, + tierColors, + otherColor, + isLocked, + lockedUid, + subjectColor, + ], + ); + + const rowsCount = Math.ceil(cells.length / ROW_BLOCKS); + const subjectCellCount = isLocked + ? cells.reduce((n, c) => n + (c.holderUid === lockedUid ? 1 : 0), 0) + : 0; + const subjectAbsent = isLocked && subjectCellCount === 0; + const search = isLocked + ? String(lockedUid) + : uidSearch.replace(/[^0-9]/g, ''); + const focused = search.length > 0; + const toggleLegendUid = (uid: number | null) => { + if (uid == null || isLocked) return; + const next = String(uid); + if (chipFilter === next) { + setChipFilter(null); + setUidSearch(''); + return; + } + setChipFilter(next); + setUidSearch(next); + }; + const onSearchInput = (raw: string) => { + setUidSearch(raw); + // Typing breaks the chip-source link — if the user lands on the same uid + // by hand we still want a plain text filter without the × affordance. + setChipFilter(null); + }; + const clearChipFilter = () => { + setChipFilter(null); + setUidSearch(''); + }; + + return ( + + + {!embedded && ( + + + Crown History + + + per block · who held the best rate + + + )} + + v && onDirectionChange(v)} + sx={{ '& .MuiToggleButton-root': { borderColor: 'divider' } }} + > + + BTC → TAO + + + TAO → BTC + + + v && onRangeChange(v)} + > + + 1h + + + 2h + + + 4h + + + + + + + + {!customActive && atEarliest && ( + + no earlier data + + )} + {!customActive && pan > 0 && ( + + )} + + + {range === '2h' ? ( + <> + scoring window · block #{lo.toLocaleString()} — # + {hi.toLocaleString()} + {pan === 0 && ( + + · current + + )} + + ) : ( + <> + block #{lo.toLocaleString()} — #{hi.toLocaleString()} · last{' '} + {span} blocks · {range} + + )} + + {isLocked ? ( + + uid {lockedUid} ·{' '} + {subjectAbsent + ? 'no crown' + : `${subjectCellCount}/${cells.length} blocks`} + + ) : ( + onSearchInput(e.target.value)} + inputProps={{ + style: { + fontFamily: FONTS.mono, + fontSize: '0.75rem', + padding: '6px 10px', + }, + }} + sx={{ + width: 180, + '& .MuiOutlinedInput-root': { + backgroundColor: 'surface.main', + }, + '& fieldset': { borderColor: 'divider' }, + }} + /> + )} + + {onCustomRangeChange && ( + + )} + + setHover(null)} + sx={{ + display: 'grid', + gridTemplateColumns: '72px 1fr', + gap: '2px', + position: 'relative', + }} + > + + {Array.from({ length: rowsCount }).map((_, r) => { + const rowStart = lo + r * ROW_BLOCKS; + return ( + + #{rowStart.toLocaleString()} + + ); + })} + + + {Array.from({ length: rowsCount }).map((_, r) => { + const rowCells = cells.slice( + r * ROW_BLOCKS, + (r + 1) * ROW_BLOCKS, + ); + return ( + + {rowCells.map((cell) => { + const empty = cell.color === null; + const matchesSearch = + focused && + cell.holderUid != null && + String(cell.holderUid) === search; + const dimmed = focused && !matchesSearch; + // Current block renders as pending — striped grey, not the + // provisional winner's tier color. The validator hasn't fully + // scored this block yet, so the holder is still in flux. + const pendingStripe = isDark + ? 'repeating-linear-gradient(45deg, rgba(255,255,255,0.10) 0, rgba(255,255,255,0.10) 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 5px)' + : 'repeating-linear-gradient(45deg, rgba(9,11,13,0.14) 0, rgba(9,11,13,0.14) 2px, rgba(9,11,13,0.04) 2px, rgba(9,11,13,0.04) 5px)'; + const baseBg = cell.isCurrent + ? pendingStripe + : empty + ? emptyColor + : (cell.color as string); + return ( + { + const rect = ( + e.currentTarget as HTMLElement + ).getBoundingClientRect(); + const gridRect = + gridRef.current?.getBoundingClientRect(); + setHover({ + cell, + x: + rect.left + + rect.width / 2 - + (gridRect?.left ?? 0), + y: rect.top - (gridRect?.top ?? 0), + }); + }} + sx={{ + position: 'relative', + background: baseBg, + opacity: dimmed + ? 0.18 + : empty + ? 1 + : cell.isTie + ? 0.78 + : 1, + // Search match: bright inner glow + primary outline so the + // hit is unmistakable on any tier color. Plain + // `background: primary.main` collapsed the cell into the + // outline color in the prior version. + outline: matchesSearch + ? `1.5px solid ${theme.palette.primary.main}` + : 'none', + outlineOffset: matchesSearch ? '1px' : 0, + boxShadow: matchesSearch + ? 'inset 0 0 0 1px rgba(255,255,255,0.85)' + : 'none', + transition: + 'transform 0.06s, box-shadow 0.06s, background-color 0.22s ease, opacity 0.22s ease', + cursor: 'pointer', + '&:hover': { + outline: `1px solid ${cellHoverOutline}`, + outlineOffset: '1px', + transform: 'scale(1.6)', + zIndex: 5, + }, + }} + /> + ); + })} + + ); + })} + + {hover && } + + {subjectAbsent && ( + + + no crown time in this scoring window + + + uid {lockedUid} didn't hold the best rate for any block here + + + )} + + + {!isLocked && tierLegend.length > 0 && ( + + + Holders + + {tierLegend.map((t) => { + const active = search === String(t.uid); + const showClear = + !isLocked && chipFilter !== null && chipFilter === String(t.uid); + const interactive = !isLocked && t.uid != null; + return ( + toggleLegendUid(t.uid)} + aria-pressed={interactive ? active : undefined} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.75, + px: 1, + py: 0.4, + cursor: interactive ? 'pointer' : 'default', + border: '1px solid', + borderColor: active ? 'primary.main' : 'divider', + backgroundColor: active + ? alpha(theme.palette.primary.main, 0.1) + : 'transparent', + fontFamily: FONTS.mono, + fontSize: '0.65rem', + color: 'text.secondary', + transition: 'background-color 0.15s, border-color 0.15s', + '&:hover': interactive + ? { borderColor: 'text.primary' } + : undefined, + }} + > + + + uid {t.uid ?? '?'} + + + {Math.round((t.count / cells.length) * 100)}% + + {showClear && ( + { + e.stopPropagation(); + clearChipFilter(); + }} + aria-label="clear filter" + sx={{ + ml: 0.25, + px: 0.5, + py: 0, + lineHeight: 1, + fontSize: '0.75rem', + fontFamily: FONTS.mono, + color: 'primary.main', + background: 'transparent', + border: 'none', + cursor: 'pointer', + borderLeft: '1px solid', + borderLeftColor: 'rgba(0,82,255,0.4)', + '&:hover': { color: 'text.primary' }, + }} + > + clear × + + )} + + ); + })} + + + no holder + + + )} + + {!isLocked && + cells.length > 0 && + cells.every((c) => c.holderHotkey === null) && ( + + no rate activity in this window + + )} + + as of #{headBlock.toLocaleString()} · each cell = 1 block (12s) · each + row = 60 blocks (12m) + + + ); +}; + +export default CrownHistoryGrid; diff --git a/src/components/miners/CrownHistoryPanel.tsx b/src/components/miners/CrownHistoryPanel.tsx new file mode 100644 index 0000000..8c5e55e --- /dev/null +++ b/src/components/miners/CrownHistoryPanel.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, useState } from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import { useScoreFactorsWindow, type Direction } from '../../api'; +import { FONTS } from '../../theme'; +import CrownHistoryGrid from './CrownHistoryGrid'; +import ScoreFactorsStrip from './ScoreFactorsStrip'; + +type CrownRange = '1h' | '2h' | '4h'; + +const CrownHistoryPanel: React.FC<{ + hotkey: string; + lockedUid: number | null; + direction: Direction; + onDirectionChange: (d: Direction) => void; + range: CrownRange; + onRangeChange: (r: CrownRange) => void; + pan: number; + onPanChange: (next: number) => void; + customFrom: number | null; + customTo: number | null; + onCustomRangeChange: (from: number | null, to: number | null) => void; +}> = ({ + hotkey, + lockedUid, + direction, + onDirectionChange, + range, + onRangeChange, + pan, + onPanChange, + customFrom, + customTo, + onCustomRangeChange, +}) => { + // Grid owns lo/hi math (snap, pan, custom range) and reports the resolved + // window so we can fetch factors that match what's drawn above. + const [window, setWindow] = useState<{ lo: number; hi: number } | null>(null); + const onWindowChange = useCallback((lo: number, hi: number) => { + setWindow((prev) => + prev && prev.lo === lo && prev.hi === hi ? prev : { lo, hi }, + ); + }, []); + + const { data: windowFactors } = useScoreFactorsWindow( + hotkey, + direction, + window?.lo, + window?.hi, + ); + const noCrown = windowFactors != null && windowFactors.crownShareWindow <= 0; + + return ( + + + + + Crown History + + + · scoring factors for window + + + + pool × crown × cap × vol × rate³ × ramp + + + + + + + + {noCrown && ( + + no crown share — factors below don't contribute to score + + )} + + + + ); +}; + +export default CrownHistoryPanel; diff --git a/src/components/miners/CrownIcon.tsx b/src/components/miners/CrownIcon.tsx new file mode 100644 index 0000000..18c910e --- /dev/null +++ b/src/components/miners/CrownIcon.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Box, type SxProps, type Theme } from '@mui/material'; + +/** + * Outline-only crown glyph. Inherits the surrounding text color via + * currentColor so callers can tint it (e.g. BTC-orange) without prop drilling. + */ +const CrownIcon: React.FC<{ + size?: number; + color?: string; + sx?: SxProps; +}> = ({ size = 12, color, sx }) => ( + + + + + + +); + +export default CrownIcon; diff --git a/src/components/miners/CrownRateChart.tsx b/src/components/miners/CrownRateChart.tsx new file mode 100644 index 0000000..34696ab --- /dev/null +++ b/src/components/miners/CrownRateChart.tsx @@ -0,0 +1,672 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { + Box, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, + useTheme, +} from '@mui/material'; +import { + useCrownRateHistory, + useMinerRateHistory, + type CrownRateHistoryRow, + type Direction, +} from '../../api'; +import { FONTS } from '../../theme'; + +const PANEL_W = 800; +const PANEL_H = 140; +const ML = 56; +const MR = 56; +const MT = 14; +const MB = 22; +const INNER_W = PANEL_W - ML - MR; +const INNER_H = PANEL_H - MT - MB; + +type CrownRange = '1h' | '4h' | '24h' | '7d'; + +const RANGE_BLOCKS: Record = { + '1h': 300, + '4h': 1200, + '24h': 7200, + '7d': 50_400, +}; + +const DIRECTION_META: Record< + Direction, + { + label: string; + color: string; + referenceColor: string; + gradId: string; + from: string; + to: string; + caption: string; + valueLeft: boolean; + } +> = { + 'BTC-TAO': { + label: 'BTC → TAO', + color: '#0052ff', + referenceColor: '#7f9eff', + gradId: 'btctaoFill', + from: 'BTC', + to: 'TAO', + caption: 'TAO returned for 1 BTC', + valueLeft: false, + }, + 'TAO-BTC': { + label: 'TAO → BTC', + color: '#f7931a', + referenceColor: '#fbc77a', + gradId: 'taobtcFill', + from: 'TAO', + to: 'BTC', + caption: 'TAO needed for 1 BTC', + valueLeft: true, + }, +}; + +const niceTicks = (lo: number, hi: number, count = 4): number[] => { + if (hi === lo) return [lo]; + const step = (hi - lo) / (count - 1); + return Array.from({ length: count }, (_, i) => lo + i * step); +}; + +const fmt = (n: number): string => { + if (n === 0) return '0'; + const abs = Math.abs(n); + if (abs >= 100) return n.toFixed(0); + if (abs >= 1) return n.toFixed(2); + if (abs >= 0.001) return n.toFixed(4); + return n.toExponential(1); +}; + +type RateRow = { block: number; rate: number }; +type SharedCursor = { block: number; x: number } | null; + +type PanelProps = { + direction: Direction; + primary: RateRow[]; + reference: RateRow[]; + lo: number; + head: number; + isDark: boolean; + cursor: SharedCursor; + onCursor: (next: SharedCursor) => void; +}; + +const RatePanel: React.FC = ({ + direction, + primary, + reference, + lo, + head, + isDark, + cursor, + onCursor, +}) => { + const meta = DIRECTION_META[direction]; + + const { yMin, yMax } = useMemo(() => { + const vals = [ + ...primary.map((p) => p.rate), + ...reference.map((p) => p.rate), + ]; + if (!vals.length) return { yMin: 0, yMax: 1 }; + const lo = Math.min(...vals); + const hi = Math.max(...vals); + const span = hi - lo; + const pad = span > 0 ? span * 0.1 : Math.max(Math.abs(hi), 1) * 0.08; + return { yMin: lo - pad, yMax: hi + pad }; + }, [primary, reference]); + + const mapX = (block: number) => + head === lo ? ML : ML + ((block - lo) / (head - lo)) * INNER_W; + const mapY = (rate: number) => + yMax === yMin + ? MT + INNER_H / 2 + : MT + ((yMax - rate) / (yMax - yMin)) * INNER_H; + + const linePath = (rows: RateRow[]): string => { + if (!rows.length) return ''; + let d = `M ${mapX(rows[0].block)} ${mapY(rows[0].rate)}`; + for (let i = 1; i < rows.length; i++) { + d += ` L ${mapX(rows[i].block)} ${mapY(rows[i].rate)}`; + } + return d; + }; + + const areaPath = (rows: RateRow[]): string => { + if (rows.length < 2) return ''; + const top = linePath(rows); + const lastX = mapX(rows[rows.length - 1].block); + const firstX = mapX(rows[0].block); + const baselineY = MT + INNER_H; + return `${top} L ${lastX} ${baselineY} L ${firstX} ${baselineY} Z`; + }; + + const primaryArea = areaPath(primary); + const primaryLine = linePath(primary); + const referenceLine = linePath(reference); + + const svgRef = useRef(null); + + const handleMove = (e: React.MouseEvent) => { + if (!svgRef.current || !primary.length) return; + const rect = svgRef.current.getBoundingClientRect(); + const viewX = ((e.clientX - rect.left) / rect.width) * PANEL_W; + if (viewX < ML || viewX > PANEL_W - MR) { + onCursor(null); + return; + } + const targetBlock = lo + ((viewX - ML) / INNER_W) * (head - lo); + let best = primary[0]; + let bestDist = Math.abs(best.block - targetBlock); + for (const p of primary) { + const dist = Math.abs(p.block - targetBlock); + if (dist < bestDist) { + best = p; + bestDist = dist; + } + } + onCursor({ block: best.block, x: mapX(best.block) }); + }; + + const ticks = niceTicks(yMin, yMax, 4); + + const hover = useMemo(() => { + if (!cursor || !primary.length) return null; + let best = primary[0]; + let bestDist = Math.abs(best.block - cursor.block); + for (const p of primary) { + const d = Math.abs(p.block - cursor.block); + if (d < bestDist) { + best = p; + bestDist = d; + } + } + return { block: best.block, rate: best.rate }; + }, [cursor, primary]); + + const latest = primary.length ? primary[primary.length - 1].rate : null; + + return ( + + + + + + + {meta.label} + + + {meta.caption} + + + + {latest != null && ( + + {meta.valueLeft ? ( + <> + + {fmt(latest)} + + + {meta.from} + + + = 1 {meta.to} + + + ) : ( + <> + + 1 {meta.from} = + + + {fmt(latest)} + + + {meta.to} + + + )} + + )} + + + onCursor(null)} + > + + + + + + + {ticks.map((t, i) => ( + + + + {fmt(t)} + + + ))} + + {primaryArea && ( + + )} + {primaryLine && ( + + )} + {referenceLine && ( + + )} + {cursor && hover && ( + + + + + )} + + {cursor && hover && ( + PANEL_W / 2 + ? 'translate(calc(-100% - 6px), 0)' + : 'translate(6px, 0)', + backgroundColor: isDark + ? 'rgba(8,10,14,0.97)' + : 'rgba(255,255,255,0.98)', + border: '1px solid', + borderColor: 'divider', + px: 1, + py: 0.5, + fontFamily: FONTS.mono, + fontSize: '0.65rem', + pointerEvents: 'none', + zIndex: 5, + whiteSpace: 'nowrap', + color: 'text.primary', + }} + > + + #{hover.block.toLocaleString()} + + + {meta.valueLeft ? ( + <> + + {fmt(hover.rate)} + + + {meta.from} + + + = 1 {meta.to} + + + ) : ( + <> + + 1 {meta.from} = + + + {fmt(hover.rate)} + + + {meta.to} + + + )} + + + )} + {!primary.length && ( + + no rate history yet + + )} + + + ); +}; + +const CrownRateChart: React.FC<{ + range: CrownRange; + onRangeChange: (r: CrownRange) => void; + minerHotkey?: string; +}> = ({ range, onRangeChange, minerHotkey }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const blocks = RANGE_BLOCKS[range]; + const minerMode = !!minerHotkey; + + const { data: btcTao } = useCrownRateHistory({ + direction: 'BTC-TAO', + blocks, + }); + const { data: taoBtc } = useCrownRateHistory({ + direction: 'TAO-BTC', + blocks, + }); + const { data: minerRates } = useMinerRateHistory(minerHotkey ?? ''); + + // Use reduce instead of `Math.max(...arr)` to avoid spreading large arrays. + const head = useMemo(() => { + const maxBlock = (arr: { block: number }[] | undefined) => + (arr ?? []).reduce((m, p) => (p.block > m ? p.block : m), 0); + return Math.max(maxBlock(btcTao), maxBlock(taoBtc)); + }, [btcTao, taoBtc]); + const lo = Math.max(0, head - blocks + 1); + + // One memo over all the per-render data shaping so a hover cursor change + // (which lifts state up here) doesn't re-filter the full window every + // mouse move. + const { btcTaoCrown, taoBtcCrown, btcTaoMiner, taoBtcMiner } = useMemo(() => { + const inRange = (arr: T[] | undefined) => + (arr ?? []).filter((p) => p.block >= lo && p.block <= head); + const strip = (rows: CrownRateHistoryRow[]): RateRow[] => + rows.map((r) => ({ block: r.block, rate: r.rate })); + const minerFor = (direction: Direction): RateRow[] => { + if (!minerHotkey) return []; + const from = direction === 'BTC-TAO' ? 'btc' : 'tao'; + const to = direction === 'BTC-TAO' ? 'tao' : 'btc'; + return inRange(minerRates ?? []) + .filter((r) => r.fromChain === from && r.toChain === to) + .map((r) => ({ block: r.block, rate: r.rate })); + }; + return { + btcTaoCrown: strip(inRange(btcTao)), + taoBtcCrown: strip(inRange(taoBtc)), + btcTaoMiner: minerFor('BTC-TAO'), + taoBtcMiner: minerFor('TAO-BTC'), + }; + }, [btcTao, taoBtc, minerRates, minerHotkey, lo, head]); + + const [cursor, setCursor] = useState(null); + + const title = minerMode ? 'Miner Rate' : 'Crown Rate'; + const tagline = minerMode + ? 'this miner · over time · crown shown for reference' + : 'best rate per direction · over time'; + + return ( + + + + + {title} + + + {tagline} + + + v && onRangeChange(v)} + > + {(Object.keys(RANGE_BLOCKS) as CrownRange[]).map((r) => ( + + {r} + + ))} + + + + + + + + + + #{lo.toLocaleString()} + {minerMode && ( + + + + + + + miner + + + + + + + + crown + + + + )} + #{head.toLocaleString()} + + + ); +}; + +export default CrownRateChart; diff --git a/src/components/miners/MinerDetailHeader.tsx b/src/components/miners/MinerDetailHeader.tsx new file mode 100644 index 0000000..ab9c126 --- /dev/null +++ b/src/components/miners/MinerDetailHeader.tsx @@ -0,0 +1,367 @@ +import React from 'react'; +import { Box, Button, Stack, Typography, alpha, useTheme } from '@mui/material'; +import type { MinerStats, Range } from '../../api'; +import type { Miner } from '../../api/models/Miners'; +import { FONTS } from '../../theme'; +import { formatTao } from '../../utils/format'; +import CopyableAddress from '../CopyableAddress'; +import CrownIcon from './CrownIcon'; + +const RANGES: Range[] = ['24h', '7d', '30d', '90d', 'all']; + +const HeaderField: React.FC<{ label: string; children: React.ReactNode }> = ({ + label, + children, +}) => ( + + + {label} + + + {children} + + +); + +const fmtDuration = (sec: number | null): string => { + if (sec == null || !Number.isFinite(sec)) return '—'; + if (sec < 60) return `${Math.round(sec)}s`; + const mins = Math.round(sec / 60); + if (mins < 60) return `${mins}m`; + const hrs = Math.floor(mins / 60); + return `${hrs}h ${mins % 60}m`; +}; + +const PerformanceMetric: React.FC<{ + label: string; + value: React.ReactNode; + sub?: React.ReactNode; +}> = ({ label, value, sub }) => ( + + + {value} + + + {label} + + {sub && ( + + {sub} + + )} + +); + +const RangeChips: React.FC<{ + range: Range; + onRangeChange: (r: Range) => void; +}> = ({ range, onRangeChange }) => ( + + {RANGES.map((r) => ( + + ))} + +); + +const PerformanceGrid: React.FC<{ stats: MinerStats | undefined }> = ({ + stats, +}) => { + const volume = stats?.volumeTao + ? parseFloat(stats.volumeTao).toFixed(2) + : '—'; + const successPct = + stats && stats.totalSwaps > 0 + ? `${(stats.successRate * 100).toFixed(0)}%` + : '—'; + const swaps = stats != null ? stats.totalSwaps.toLocaleString() : '—'; + const completedSub = stats + ? `${stats.completedSwaps} ok · ${stats.timedOutSwaps} failed` + : undefined; + + return ( + + + + + {volume} + + τ + + + } + /> + + + ); +}; + +// Top card on the per-miner page: identity (uid + crown chips + active dot), +// header fields (hotkey, collateral, activation, quote rates), and the +// performance grid scoped to the selected range. +const MinerDetailHeader: React.FC<{ + hotkey: string; + uid: number | null; + stats: MinerStats | undefined; + liveMiner: Miner | null; + range: Range; + onRangeChange: (r: Range) => void; +}> = ({ hotkey, uid, stats, liveMiner, range, onRangeChange }) => { + const theme = useTheme(); + const crownDirections = stats?.currentCrownDirections ?? []; + // On-chain commitment is canonicalized so TAO is always destChain. `rate` + // is source→dest (BTC→TAO when sourceChain='btc'); `counterRate` is the + // reverse leg. + const fwdRate = parseFloat(liveMiner?.rate ?? '0'); + const revRate = parseFloat(liveMiner?.counterRate ?? '0'); + const fwdLabel = + liveMiner?.sourceChain && liveMiner?.destChain + ? `${liveMiner.sourceChain.toUpperCase()} → ${liveMiner.destChain.toUpperCase()}` + : null; + const revLabel = + liveMiner?.sourceChain && liveMiner?.destChain + ? `${liveMiner.destChain.toUpperCase()} → ${liveMiner.sourceChain.toUpperCase()}` + : null; + + return ( + + + + + Miner uid{' '} + + {uid ?? '?'} + + + {crownDirections.length > 0 && ( + + + {crownDirections.map((d) => d.replace('-', '→')).join(' ')} + + )} + {stats && ( + + + {stats.isActive ? 'active' : 'inactive'} + + )} + + + + + + + {stats?.collateralRao && ( + + {formatTao(stats.collateralRao)} + + τ + + + )} + {stats?.activatedAt != null && ( + + {stats.activatedAt.toLocaleString()} + + )} + {fwdRate > 0 && fwdLabel && ( + + {fwdRate.toFixed(2)} + + τ + + + )} + {revRate > 0 && revLabel && ( + + {revRate.toFixed(2)} + + τ + + + )} + + + + + + Performance · last {range} + + + + + + + + ); +}; + +export default MinerDetailHeader; diff --git a/src/components/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx new file mode 100644 index 0000000..3287e74 --- /dev/null +++ b/src/components/miners/MinerLeaderboard.tsx @@ -0,0 +1,390 @@ +import React, { useMemo, useState } from 'react'; +import { + Box, + Button, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, + useTheme, +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { + useMinerLeaderboard, + type LeaderboardRow, + type Range, +} from '../../api'; +import CrownIcon from './CrownIcon'; +import SortHeader, { type SortDir } from './SortHeader'; +import { FONTS } from '../../theme'; +import { formatTao, shortHotkey } from '../../utils/format'; + +const RANGES: Range[] = ['24h', '7d', '30d', '90d', 'all']; + +const formatVolume = (raw: string): string => { + const v = parseFloat(raw); + if (!Number.isFinite(v) || v === 0) return '0.00'; + return v.toFixed(2); +}; + +const formatSuccess = (row: LeaderboardRow): string => { + const total = row.completedSwaps + row.timedOutSwaps; + if (total === 0) return '— / 0'; + return `${row.completedSwaps} / ${total}`; +}; + +const successRatio = (row: LeaderboardRow): number => { + const total = row.completedSwaps + row.timedOutSwaps; + return total === 0 ? 0 : row.completedSwaps / total; +}; + +const TIER_COLORS = [ + 'primary.main', + '#4d7dff', + '#7f9eff', + '#aebeff', + '#d2dafe', +]; + +type SortKey = + | 'uid' + | 'crownShare' + | 'collateral' + | 'success' + | 'volume' + | 'active'; + +const SORT_LABELS: Record = { + uid: 'uid', + crownShare: 'crown share', + collateral: 'collateral', + success: 'success', + volume: 'volume', + active: 'active', +}; + +const compare = ( + a: LeaderboardRow, + b: LeaderboardRow, + key: SortKey, +): number => { + switch (key) { + case 'uid': + return a.uid - b.uid; + case 'crownShare': + return a.crownShare - b.crownShare; + case 'collateral': + return parseFloat(a.collateralRao) - parseFloat(b.collateralRao); + case 'success': + return successRatio(a) - successRatio(b); + case 'volume': + return parseFloat(a.volumeTao) - parseFloat(b.volumeTao); + case 'active': + return Number(a.isActive) - Number(b.isActive); + } +}; + +const MinerLeaderboard: React.FC<{ + range: Range; + onRangeChange: (r: Range) => void; +}> = ({ range, onRangeChange }) => { + const navigate = useNavigate(); + const theme = useTheme(); + const { data, isLoading } = useMinerLeaderboard(range); + const [sortKey, setSortKey] = useState('crownShare'); + const [sortDir, setSortDir] = useState('desc'); + const [query, setQuery] = useState(''); + + const baseRows = useMemo(() => data ?? [], [data]); + const topShare = useMemo( + () => Math.max(0, ...baseRows.map((r) => r.crownShare)), + [baseRows], + ); + + const tierByHotkey = useMemo(() => { + // Crown-share-desc tier coloring stays stable regardless of active sort — + // tier is a property of the miner's standing, not the table view order. + const ranked = [...baseRows].sort((a, b) => b.crownShare - a.crownShare); + const map = new Map(); + ranked.forEach((row, idx) => { + map.set(row.hotkey, TIER_COLORS[Math.min(idx, TIER_COLORS.length - 1)]); + }); + return map; + }, [baseRows]); + + // Numeric query → exact uid match (typing "3" shouldn't surface uid 30, + // 31, ...). Anything non-numeric falls through to hotkey substring. + const queryRaw = query.trim(); + const queryNorm = queryRaw.toLowerCase(); + const numericQuery = /^\d+$/.test(queryRaw); + const filteredRows = useMemo(() => { + if (!queryNorm) return baseRows; + if (numericQuery) { + return baseRows.filter((row) => String(row.uid) === queryRaw); + } + return baseRows.filter((row) => + row.hotkey.toLowerCase().includes(queryNorm), + ); + }, [baseRows, queryNorm, queryRaw, numericQuery]); + + const sortedRows = useMemo(() => { + const sign = sortDir === 'asc' ? 1 : -1; + return [...filteredRows].sort((a, b) => sign * compare(a, b, sortKey)); + }, [filteredRows, sortKey, sortDir]); + + const onSort = (key: SortKey) => { + if (key === sortKey) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDir(key === 'active' ? 'asc' : 'desc'); + } + }; + + const handleRowClick = (row: LeaderboardRow) => { + navigate(`/miners/${row.hotkey}`); + }; + + return ( + + + + Miner Leaderboard + + + setQuery(e.target.value)} + inputProps={{ + style: { + fontFamily: FONTS.mono, + fontSize: '0.7rem', + padding: '5px 9px', + }, + }} + sx={{ + width: 200, + '& .MuiOutlinedInput-root': { backgroundColor: 'surface.main' }, + '& fieldset': { borderColor: 'divider' }, + }} + /> + {queryNorm && ( + + {filteredRows.length} of {baseRows.length} shown + + )} + + {RANGES.map((r) => ( + + ))} + + + + + + + + + hotkey + + + + + + + + + {sortedRows.length === 0 && !isLoading && ( + + + No miners registered yet + + + )} + {sortedRows.map((row) => { + const sharePct = + topShare > 0 ? Math.round((row.crownShare / topShare) * 100) : 0; + const tierColor = tierByHotkey.get(row.hotkey) ?? TIER_COLORS[4]; + const successColor = + row.completedSwaps === 0 && row.timedOutSwaps > 0 + ? 'error.main' + : 'text.primary'; + const wearsCrown = row.currentCrownDirections.length > 0; + return ( + handleRowClick(row)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRowClick(row); + } + }} + tabIndex={0} + hover + sx={{ + cursor: 'pointer', + '&:hover td': { backgroundColor: 'surface.elevated' }, + '&:focus-visible': { + outline: `2px solid ${theme.palette.primary.main}`, + outlineOffset: -2, + }, + }} + > + + {wearsCrown && } + + {row.uid} + + {shortHotkey(row.hotkey)} + + + + + + + + {(row.crownShare * 100).toFixed(0)}% + + + + + {formatTao(row.collateralRao)} τ + + + {formatSuccess(row)} + + + {formatVolume(row.volumeTao)} τ + + + + + + ); + })} + +
+
+ ); +}; + +export default MinerLeaderboard; diff --git a/src/components/miners/MinerSwapHistory.tsx b/src/components/miners/MinerSwapHistory.tsx new file mode 100644 index 0000000..41251cb --- /dev/null +++ b/src/components/miners/MinerSwapHistory.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import { useMinerSwaps } from '../../api'; +import { FONTS } from '../../theme'; + +const STATUS_COLOR: Record = { + COMPLETED: 'success.main', + TIMED_OUT: 'error.main', + FULFILLED: 'status.fulfilled', + ACTIVE: 'status.active', +}; + +const PILL_BORDER: Record = { + COMPLETED: 'rgba(21,128,61,0.5)', + TIMED_OUT: 'rgba(185,28,28,0.5)', +}; + +const fmtBlock = (raw: string | null): string => { + if (!raw) return '—'; + const n = parseInt(raw, 10); + if (!Number.isFinite(n)) return '—'; + return `#${n.toLocaleString()}`; +}; + +const fmtDuration = ( + initiated: string | null, + resolved: string | null, +): string => { + if (!initiated || !resolved) return '—'; + const ms = new Date(resolved).getTime() - new Date(initiated).getTime(); + if (!Number.isFinite(ms) || ms < 0) return '—'; + const mins = Math.round(ms / 60_000); + if (mins < 60) return `${mins}m`; + const hrs = Math.floor(mins / 60); + return `${hrs}h ${mins % 60}m`; +}; + +const MinerSwapHistory: React.FC<{ hotkey: string }> = ({ hotkey }) => { + const { data } = useMinerSwaps(hotkey, { limit: 25 }); + const rows = data?.rows ?? []; + + return ( + + + Swap History + + + + + + swap + initiated + status + amount + dir + dur + + + + {rows.length === 0 && ( + + + + No swaps yet — post a competitive rate to attract them + + + + )} + {rows.map((row) => { + const taoAmount = row.taoAmount + ? parseFloat(row.taoAmount).toFixed(4) + : '—'; + return ( + + + + #{row.swapId} + + + + {fmtBlock(row.initiatedBlock)} + + + + {row.status.replace('_', ' ').toLowerCase()} + + + + {taoAmount} τ + + + {row.sourceChain && row.destChain + ? `${row.sourceChain.toUpperCase()}→${row.destChain.toUpperCase()}` + : '—'} + + + {fmtDuration(row.initiatedAt, row.resolvedAt)} + + + ); + })} + +
+
+
+ ); +}; + +export default MinerSwapHistory; diff --git a/src/components/miners/NetworkOverviewStats.tsx b/src/components/miners/NetworkOverviewStats.tsx new file mode 100644 index 0000000..b7cd15b --- /dev/null +++ b/src/components/miners/NetworkOverviewStats.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import { useNetworkOverview, type Range, type PairMix } from '../../api'; +import { FONTS } from '../../theme'; + +interface Tile { + label: string; + body: React.ReactNode; +} + +const TileValue: React.FC<{ value: string; suffix?: React.ReactNode }> = ({ + value, + suffix, +}) => ( + + + {value} + + {suffix && ( + + {suffix} + + )} + +); + +const formatPair = (raw: string): string => raw.replaceAll('-', '→'); + +const DirectionBars: React.FC<{ segments: PairMix[] }> = ({ segments }) => { + const total = segments.reduce((sum, s) => sum + s.pct, 0) || 1; + return ( + + {segments.map((s) => { + const pct = (s.pct / total) * 100; + return ( + + + {formatPair(s.pair)} + + + + + + {Math.round(s.pct)}% + + + ); + })} + + ); +}; + +const StatTile: React.FC<{ tile: Tile }> = ({ tile }) => ( + + + {tile.label} + + {tile.body} + +); + +const NetworkOverviewStats: React.FC<{ range?: Range }> = ({ + range = '30d', +}) => { + const { data } = useNetworkOverview(range); + + const volume = data?.volumeTao ? parseFloat(data.volumeTao).toFixed(1) : '—'; + const swaps = + data?.totalSwaps != null ? data.totalSwaps.toLocaleString() : '—'; + const successPct = + data?.networkSuccessRate != null + ? (data.networkSuccessRate * 100).toFixed(0) + : null; + const activeMiners = + data?.activeMiners != null ? `${data.activeMiners}` : '—'; + const pairMix = data?.pairMix?.slice(0, 2) ?? []; + + const tiles: Tile[] = [ + { + label: `Volume ${range}`, + body: ( + + τ +
+ } + /> + ), + }, + { + label: `Swaps ${range}`, + body: ( + + · {successPct}% success + + ) : undefined + } + /> + ), + }, + { + label: 'Active miners', + body: , + }, + { + label: `Swap directions ${range}`, + body: + pairMix.length > 0 ? ( + + ) : ( + + ), + }, + ]; + + return ( + *': { + borderRight: { sm: '1px solid' }, + borderBottom: { xs: '1px solid', md: 'none' }, + borderColor: 'divider', + }, + '& > *:nth-of-type(2n)': { + borderRight: { sm: 'none', md: '1px solid' }, + }, + '& > *:nth-of-type(4n)': { borderRight: { md: 'none' } }, + '& > *:last-of-type': { borderBottom: 'none', borderRight: 'none' }, + '& > *:nth-last-of-type(2)': { borderBottom: { md: 'none' } }, + }} + > + {tiles.map((t) => ( + + ))} + + ); +}; + +export default NetworkOverviewStats; diff --git a/src/components/miners/ScoreFactorsStrip.tsx b/src/components/miners/ScoreFactorsStrip.tsx new file mode 100644 index 0000000..ee14b25 --- /dev/null +++ b/src/components/miners/ScoreFactorsStrip.tsx @@ -0,0 +1,347 @@ +import React from 'react'; +import { Box, Stack, Typography, alpha, useTheme } from '@mui/material'; +import type { ScoreFactors } from '../../api'; +import { FONTS } from '../../theme'; +import { formatTao } from '../../utils/format'; + +const fmtMultiplier = (factor: number): string => `${factor.toFixed(2)}×`; + +const fmtPct = (pct: number, digits = 0): string => + `${(pct * 100).toFixed(digits)}%`; + +type Delta = { value: number; format: 'pct' | 'mult' } | null; + +type Card = { + label: string; + window: string; + headline: string; + fill: number; // 0..1 + description: string; + delta?: Delta; + weak?: boolean; +}; + +const buildCards = (sf: ScoreFactors): Card[] => { + const credibilityRamped = sf.closedSwaps >= sf.credibilityRampTarget; + return [ + { + label: 'Crown share', + window: 'previous round', + headline: fmtPct(sf.crownShareWindow), + fill: sf.crownShareWindow, + description: 'your slice of the crown over that 2-hour window', + delta: { + value: sf.crownShareWindow - sf.previousCrownShareWindow, + format: 'pct', + }, + weak: sf.crownShareWindow < 0.05, + }, + { + label: 'Capacity', + window: 'snapshot', + headline: fmtMultiplier(sf.capacityFactor), + fill: sf.capacityFactor, + description: `${formatTao(sf.collateralRao)} / ${formatTao( + sf.maxSwapAmountRao, + )} τ collateral`, + weak: sf.capacityFactor < 0.5, + }, + { + label: 'Volume factor', + window: 'previous round', + headline: fmtMultiplier(sf.volumeFactor), + fill: sf.volumeFactor, + description: `served ${fmtPct(sf.volumeShareWindow)} of network volume`, + delta: { + value: sf.volumeFactor - sf.previousVolumeFactor, + format: 'mult', + }, + weak: sf.volumeFactor <= 0.5, + }, + { + label: 'Success rate', + window: 'last 30d', + headline: sf.closedSwaps === 0 ? '—' : fmtPct(sf.successRate30d), + fill: sf.successRate30d, + description: + sf.closedSwaps === 0 + ? 'no closed swaps in window' + : `${sf.closedSwaps} closed · raw fulfillment rate`, + weak: sf.closedSwaps > 0 && sf.successRate30d < 0.5, + }, + { + label: 'Credibility', + window: 'last 30d', + headline: credibilityRamped + ? fmtMultiplier(1.0) + : fmtMultiplier(sf.credibilityRamp), + fill: sf.credibilityRamp, + description: credibilityRamped + ? `fully ramped · ${sf.closedSwaps} of ${sf.credibilityRampTarget} closed` + : `${sf.closedSwaps} / ${sf.credibilityRampTarget} closed · resets if you fall below`, + weak: !credibilityRamped, + }, + ]; +}; + +const PLACEHOLDER_CARDS: Card[] = [ + { + label: 'Crown share', + window: 'previous round', + headline: '—', + fill: 0, + description: '', + }, + { + label: 'Capacity', + window: 'snapshot', + headline: '—', + fill: 0, + description: '', + }, + { + label: 'Volume factor', + window: 'previous round', + headline: '—', + fill: 0, + description: '', + }, + { + label: 'Success rate', + window: 'last 30d', + headline: '—', + fill: 0, + description: '', + }, + { + label: 'Credibility', + window: 'last 30d', + headline: '—', + fill: 0, + description: '', + }, +]; + +const DeltaBadge: React.FC<{ delta: Delta }> = ({ delta }) => { + const theme = useTheme(); + if (!delta) return null; + const epsilon = delta.format === 'pct' ? 0.005 : 0.01; + if (Math.abs(delta.value) < epsilon) { + return ( + + ─ flat + + ); + } + const up = delta.value > 0; + const formatted = + delta.format === 'pct' + ? `${up ? '+' : '−'}${(Math.abs(delta.value) * 100).toFixed(1)}%` + : `${up ? '+' : '−'}${Math.abs(delta.value).toFixed(2)}×`; + return ( + + {up ? '▲' : '▼'} {formatted} + + ); +}; + +const FactorCard: React.FC<{ card: Card }> = ({ card }) => { + const theme = useTheme(); + const fill = Math.max(0, Math.min(1, card.fill)); + const barColor = card.weak + ? alpha(theme.palette.primary.main, 0.45) + : theme.palette.primary.main; + const headlineColor = card.weak ? 'text.secondary' : 'text.primary'; + + return ( + + + {card.label} + + · {card.window} + + + + + {card.headline} + + {card.delta && } + + + + + + {card.description} + + + ); +}; + +const composeMultiplier = (sf: ScoreFactors): number => + sf.crownShareWindow * + sf.capacityFactor * + sf.volumeFactor * + // TODO: display reads rate³ × ramp; underlying math is (rate × ramp)³ — fix later + (sf.successRate30d * sf.credibilityRamp) ** 3; + +const CompositeFooter: React.FC<{ sf: ScoreFactors | undefined }> = ({ + sf, +}) => { + const theme = useTheme(); + if (!sf) return null; + const m = composeMultiplier(sf); + return ( + + + share of pool captured this round + + + {fmtMultiplier(m)} + + = crown × cap × vol × rate³ × ramp + + + + ); +}; + +// Renders the 5 factor cards + composite footer. When `windowCrownShare` +// is 0 the row dims to 0.4 — the factors compose multiplicatively against +// crown_share, so none of them contribute to emission when crown is zero. +const ScoreFactorsStrip: React.FC<{ + scoreFactors: ScoreFactors | undefined; + windowCrownShare?: number; +}> = ({ scoreFactors, windowCrownShare }) => { + const cards = scoreFactors ? buildCards(scoreFactors) : PLACEHOLDER_CARDS; + const dim = + windowCrownShare != null && scoreFactors != null && windowCrownShare <= 0; + + return ( + + *': { + borderRight: { sm: '1px solid' }, + borderColor: 'divider', + }, + '& > *:nth-of-type(2n)': { + borderRight: { sm: 'none', md: '1px solid' }, + }, + '& > *:nth-of-type(5n)': { borderRight: { md: 'none' } }, + '& > *:not(:first-of-type)': { + borderTop: { xs: '1px solid', sm: 'none' }, + borderColor: 'divider', + }, + '& > *:nth-of-type(n + 3)': { + borderTop: { sm: '1px solid', md: 'none' }, + borderColor: 'divider', + }, + }} + > + {cards.map((c) => ( + + ))} + + + + ); +}; + +export default ScoreFactorsStrip; diff --git a/src/components/miners/SortHeader.tsx b/src/components/miners/SortHeader.tsx new file mode 100644 index 0000000..6fc5360 --- /dev/null +++ b/src/components/miners/SortHeader.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Box, Stack, TableCell } from '@mui/material'; +import { FONTS } from '../../theme'; + +export type SortDir = 'asc' | 'desc'; + +// Generic over the consumer's SortKey union. The caller owns the union, +// passes the current active key + dir, and gets a click/Enter/Space-driven +// sort toggle with the right aria-sort wiring. +function SortHeader({ + label, + sortKey, + active, + dir, + onSort, +}: { + label: string; + sortKey: K; + active: K; + dir: SortDir; + onSort: (k: K) => void; +}) { + const isActive = active === sortKey; + return ( + onSort(sortKey)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSort(sortKey); + } + }} + tabIndex={0} + role="button" + aria-sort={ + isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none' + } + sx={{ + cursor: 'pointer', + userSelect: 'none', + color: isActive ? 'text.primary' : undefined, + '&:hover': { color: 'text.primary' }, + }} + > + + {label} + + {isActive ? (dir === 'asc' ? '↑' : '↓') : '↕'} + + + + ); +} + +export default SortHeader; diff --git a/src/components/miners/StickyNetworkHeader.tsx b/src/components/miners/StickyNetworkHeader.tsx new file mode 100644 index 0000000..b8cf919 --- /dev/null +++ b/src/components/miners/StickyNetworkHeader.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import { BlockIndicator } from '../index'; +import { useCurrentCrown, useHaltState } from '../../api'; +import CrownIcon from './CrownIcon'; +import { FONTS } from '../../theme'; + +const StickyNetworkHeader: React.FC = () => { + const { data: crown } = useCurrentCrown(); + const { data: halt } = useHaltState(); + + const segments: React.ReactNode[] = []; + if (crown) { + for (const dir of ['BTC-TAO', 'TAO-BTC'] as const) { + const h = crown[dir]; + if (!h) continue; + const [from, to] = dir.split('-'); + segments.push( + + + + {from} + + → + + {to} + + {h.uid != null ? ( + + uid {h.uid} + {h.rate != null && <> @ {h.rate.toFixed(2)} τ} + + ) : ( + + none + + )} + , + ); + } + } + + const halted = halt?.halted ?? false; + + return ( + + + + + {segments} + + + + + {halted ? 'halted' : 'healthy'} + + + + + ); +}; + +export default StickyNetworkHeader; diff --git a/src/components/miners/crownGridCells.ts b/src/components/miners/crownGridCells.ts new file mode 100644 index 0000000..3931205 --- /dev/null +++ b/src/components/miners/crownGridCells.ts @@ -0,0 +1,106 @@ +import type { CrownHistoryRow } from '../../api'; + +export const TIER_PALETTE = [ + '#0052ff', + '#4d7dff', + '#7f9eff', + '#aebeff', + '#d2dafe', +]; + +export type CellState = { + block: number; + holderHotkey: string | null; + holderUid: number | null; + rate: number; + isTie: boolean; + isCurrent: boolean; + color: string | null; +}; + +export type TierEntry = { + hotkey: string; + uid: number | null; + count: number; + color: string; +}; + +// Build per-block cell rows for [lo, hi]. When `subjectUid` is set, every +// cell shows whether *that* uid held the crown (subjectColor or null), not +// the actual winner — used on the per-miner page where the page locks to +// its own uid. Otherwise the top alphabetical holder wins and gets tier +// color. +export const buildCells = ( + rows: CrownHistoryRow[], + lo: number, + hi: number, + maxBlock: number, + tiers: Map, + otherColor: string, + subjectUid: number | null = null, + subjectColor: string | null = null, +): CellState[] => { + const byBlock = new Map(); + for (const row of rows) { + const arr = byBlock.get(row.block) ?? []; + arr.push(row); + byBlock.set(row.block, arr); + } + const cells: CellState[] = []; + for (let b = lo; b <= hi; b++) { + const here = byBlock.get(b) ?? []; + here.sort((a, c) => a.hotkey.localeCompare(c.hotkey)); + if (subjectUid != null) { + const mine = here.find((r) => r.uid === subjectUid); + cells.push({ + block: b, + holderHotkey: mine?.hotkey ?? null, + holderUid: mine?.uid ?? null, + rate: mine?.rate ?? 0, + isTie: mine != null && here.length > 1, + isCurrent: b === maxBlock, + color: mine ? subjectColor : null, + }); + continue; + } + const winner = here[0]; + cells.push({ + block: b, + holderHotkey: winner?.hotkey ?? null, + holderUid: winner?.uid ?? null, + rate: winner?.rate ?? 0, + isTie: here.length > 1, + isCurrent: b === maxBlock, + color: winner?.hotkey ? (tiers.get(winner.hotkey) ?? otherColor) : null, + }); + } + return cells; +}; + +// Tier coloring is stable per (hotkey, window) — most-crown-blocks wins the +// top color, ties broken by sort order. The legend renders `ordered`; the +// cells look up `color`. +export const buildTiers = ( + rows: CrownHistoryRow[], + lo: number, + hi: number, +): { color: Map; ordered: TierEntry[] } => { + const counts = new Map(); + for (const row of rows) { + if (row.block < lo || row.block > hi) continue; + const entry = counts.get(row.hotkey); + if (entry) entry.count += 1; + else counts.set(row.hotkey, { uid: row.uid ?? null, count: 1 }); + } + const sorted = Array.from(counts.entries()) + .sort((a, b) => b[1].count - a[1].count) + .map(([hotkey, { uid, count }], idx) => ({ + hotkey, + uid, + count, + color: TIER_PALETTE[idx] ?? '#6b7280', + })); + const colorMap = new Map(); + for (const { hotkey, color } of sorted) colorMap.set(hotkey, color); + return { color: colorMap, ordered: sorted }; +}; diff --git a/src/components/miners/index.ts b/src/components/miners/index.ts new file mode 100644 index 0000000..d5dde86 --- /dev/null +++ b/src/components/miners/index.ts @@ -0,0 +1,10 @@ +export { default as CrownIcon } from './CrownIcon'; +export { default as StickyNetworkHeader } from './StickyNetworkHeader'; +export { default as NetworkOverviewStats } from './NetworkOverviewStats'; +export { default as MinerLeaderboard } from './MinerLeaderboard'; +export { default as CrownHistoryGrid } from './CrownHistoryGrid'; +export { default as CrownHistoryPanel } from './CrownHistoryPanel'; +export { default as MinerDetailHeader } from './MinerDetailHeader'; +export { default as CrownRateChart } from './CrownRateChart'; +export { default as MinerSwapHistory } from './MinerSwapHistory'; +export { default as ScoreFactorsStrip } from './ScoreFactorsStrip'; diff --git a/src/components/nav/TopNav.tsx b/src/components/nav/TopNav.tsx index 0dd43b8..762155d 100644 --- a/src/components/nav/TopNav.tsx +++ b/src/components/nav/TopNav.tsx @@ -67,6 +67,9 @@ const TopNav: React.FC = () => { location.pathname.startsWith('/swap/') ); } + if (to === '/miners') { + return location.pathname.startsWith('/miners'); + } return location.pathname === to; }; diff --git a/src/components/nav/links.ts b/src/components/nav/links.ts index 227d887..d58a9c9 100644 --- a/src/components/nav/links.ts +++ b/src/components/nav/links.ts @@ -18,6 +18,7 @@ export const docsUrl = (): string => export const NAV_ITEMS: NavItem[] = [ { label: 'Dashboard', to: '/dashboard' }, + { label: 'Miners', to: '/miners' }, { label: 'Exchange', to: '/swap' }, { label: 'Agents', to: '/agents' }, ]; diff --git a/src/hooks/useOnNavigate.ts b/src/hooks/useOnNavigate.ts index 2503e4c..7ef015e 100644 --- a/src/hooks/useOnNavigate.ts +++ b/src/hooks/useOnNavigate.ts @@ -1,11 +1,17 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; +// Fires `callback` only when the route pathname changes — not when search +// params or hash change. The callback is held in a ref so its identity +// doesn't pull the effect into firing on every parent re-render (which +// happens whenever location.search updates). export const useOnNavigate = (callback: () => void) => { const { pathname } = useLocation(); + const cbRef = useRef(callback); + cbRef.current = callback; useEffect(() => { - callback(); - }, [callback, pathname]); + cbRef.current(); + }, [pathname]); }; export default useOnNavigate; diff --git a/src/pages/MinerDetailPage.tsx b/src/pages/MinerDetailPage.tsx new file mode 100644 index 0000000..ccc95b7 --- /dev/null +++ b/src/pages/MinerDetailPage.tsx @@ -0,0 +1,159 @@ +import React, { useCallback } from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import { + Link as RouterLink, + useParams, + useSearchParams, +} from 'react-router-dom'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { + CrownHistoryPanel, + CrownRateChart, + MinerDetailHeader, + MinerSwapHistory, + Page, + SEO, + StickyNetworkHeader, +} from '../components'; +import { + useMinerStats, + useMiners, + isCrownRange, + isDirection, + isRange, + isRateRange, + parseBlockParam, + type CrownRange, + type Range, + type RateRange, +} from '../api'; +import { FONTS } from '../theme'; +import { shortHotkey } from '../utils/format'; + +const MinerDetailPage: React.FC = () => { + const { hotkey = '' } = useParams<{ hotkey: string }>(); + const [params, setParams] = useSearchParams(); + + const rangeParam = params.get('range'); + const range: Range = isRange(rangeParam) ? rangeParam : '30d'; + const rateRangeParam = params.get('rateRange'); + const rateRange: RateRange = isRateRange(rateRangeParam) + ? rateRangeParam + : '4h'; + const crownDirParam = params.get('crownDir'); + const crownDirection = isDirection(crownDirParam) ? crownDirParam : 'BTC-TAO'; + const crownGridRangeParam = params.get('crownGridRange'); + const crownGridRange: CrownRange = isCrownRange(crownGridRangeParam) + ? crownGridRangeParam + : '2h'; + const crownGridPan = parseInt(params.get('crownPan') ?? '600', 10) || 0; + const crownFrom = parseBlockParam(params.get('crownFrom')); + const crownTo = parseBlockParam(params.get('crownTo')); + + const updateParams = useCallback( + (updates: Record) => { + const next = new URLSearchParams(params); + for (const [k, v] of Object.entries(updates)) { + if (v === undefined || v === '') next.delete(k); + else next.set(k, v); + } + setParams(next, { replace: true }); + }, + [params, setParams], + ); + const setParam = (key: string, value: string | undefined) => + updateParams({ [key]: value }); + + const { data: stats } = useMinerStats(hotkey, range); + const { data: miners } = useMiners(); + const liveMiner = miners?.find((m) => m.hotkey === hotkey) ?? null; + // Prefer stats.uid; fall back to the live-miners list (always populated) + // so the page header doesn't render "uid ?" while stats is loading or on + // an older API that doesn't include uid in the stats response. + const uid = stats?.uid ?? liveMiner?.uid ?? null; + + return ( + + + + + + + Miners + + + + setParam('range', r)} + /> + + {uid != null && ( + setParam('crownDir', d)} + range={crownGridRange} + onRangeChange={(r) => setParam('crownGridRange', r)} + pan={crownGridPan} + onPanChange={(p) => + setParam('crownPan', p === 0 ? undefined : String(p)) + } + customFrom={crownFrom} + customTo={crownTo} + onCustomRangeChange={(from, to) => + updateParams({ + crownFrom: from == null ? undefined : String(from), + crownTo: to == null ? undefined : String(to), + }) + } + /> + )} + + + + setParam('rateRange', r)} + minerHotkey={hotkey} + /> + + + + + + + + ); +}; + +export default MinerDetailPage; diff --git a/src/pages/MinersPage.tsx b/src/pages/MinersPage.tsx new file mode 100644 index 0000000..0b853ff --- /dev/null +++ b/src/pages/MinersPage.tsx @@ -0,0 +1,91 @@ +import React, { useCallback } from 'react'; +import { Stack } from '@mui/material'; +import { useSearchParams } from 'react-router-dom'; +import { + CrownHistoryGrid, + CrownRateChart, + MinerLeaderboard, + NetworkOverviewStats, + Page, + SEO, + StickyNetworkHeader, +} from '../components'; +import { + isCrownRange, + isDirection, + isRange, + isRateRange, + type CrownRange, + type Range, + type RateRange, +} from '../api'; + +const MinersPage: React.FC = () => { + const [params, setParams] = useSearchParams(); + + const rangeParam = params.get('range'); + const range: Range = isRange(rangeParam) ? rangeParam : '30d'; + const pairParam = params.get('pair'); + const direction = isDirection(pairParam) ? pairParam : 'BTC-TAO'; + const crownRangeParam = params.get('crownRange'); + const crownRange: CrownRange = isCrownRange(crownRangeParam) + ? crownRangeParam + : '2h'; + const rateRangeParam = params.get('rateRange'); + const rateRange: RateRange = isRateRange(rateRangeParam) + ? rateRangeParam + : '4h'; + const pan = Number(params.get('pan') ?? '0') || 0; + + const setParam = useCallback( + (key: string, value: string | undefined) => { + const next = new URLSearchParams(params); + if (value === undefined || value === '') { + next.delete(key); + } else { + next.set(key, value); + } + setParams(next, { replace: true }); + }, + [params, setParams], + ); + + return ( + + + + + + setParam('range', r)} + /> + setParam('pair', d)} + range={crownRange} + onRangeChange={(r) => setParam('crownRange', r)} + pan={pan} + onPanChange={(p) => setParam('pan', p === 0 ? undefined : String(p))} + /> + setParam('rateRange', r)} + /> + + + ); +}; + +export default MinersPage; diff --git a/src/routes.tsx b/src/routes.tsx index 9601e63..541b20a 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -8,6 +8,8 @@ export type AppRoute = Omit & { const LandingPage = React.lazy(() => import('./pages/LandingPage')); const DashboardPage = React.lazy(() => import('./pages/DashboardPage')); +const MinersPage = React.lazy(() => import('./pages/MinersPage')); +const MinerDetailPage = React.lazy(() => import('./pages/MinerDetailPage')); const SwapPage = React.lazy(() => import('./pages/SwapPage')); const SwapDetailPage = React.lazy(() => import('./pages/SwapDetailPage')); const ReservationDetailPage = React.lazy( @@ -22,6 +24,12 @@ const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage')); const routesArray: AppRoute[] = [ { name: 'landing', path: '/', element: }, { name: 'dashboard', path: '/dashboard', element: }, + { name: 'miners', path: '/miners', element: }, + { + name: 'miner-detail', + path: '/miners/:hotkey', + element: , + }, { name: 'swap', path: '/swap', element: }, { name: 'swap-detail', path: '/swap/:swapId', element: }, { diff --git a/src/utils/format.ts b/src/utils/format.ts index ff9363c..a3ef045 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,6 +1,10 @@ export const shortAddr = (addr: string) => addr.length > 10 ? `${addr.slice(0, 4)}..${addr.slice(-4)}` : addr; +// SS58 hotkeys are always > 40 chars; format as 4…4 with an ellipsis to +// match the leaderboard / detail-header convention. +export const shortHotkey = (h: string) => `${h.slice(0, 4)}…${h.slice(-4)}`; + export const formatTao = (rao: string | number) => { const val = typeof rao === 'string' ? parseInt(rao, 10) : rao; return (val / 1e9).toFixed(2);