From b75a7d664d1dec646ccaff8d30147d211db73b8b Mon Sep 17 00:00:00 2001 From: Landyn Date: Tue, 12 May 2026 15:19:05 -0500 Subject: [PATCH 01/11] add miner dashboard page, components, and routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /miners route (and /miners/:hotkey detail variant) lands the dashboard described in plans/miner-dashboard.md and matches the miner-dashboard-mock.html visual reference. Components live under components/miners: StickyNetworkHeader (current block + crown holders + halt banner), NetworkOverviewStats (volume / swaps / active miners / pair mix), MinerLeaderboard (rail-crown, range chips, click-row to filter), CrownHistoryGrid (60-col per-block grid, direction tabs, 1h/4h range, uid highlight), CrownRateChart (step-line SVG, hover crosshair, optional miner overlay), FilteredMinerSection (EarningNowBanner + EarningDiagnostic + per-miner rate chart + swap history). URL state is the source of truth — range, pair, crownRange, rateRange, pan all live in useSearchParams. Recent miners are cached in localStorage at allways.recentMiners. --- src/api/MinersDashboardApi.ts | 122 ++++++ src/api/index.ts | 1 + src/api/models/MinersDashboard.ts | 86 ++++ src/api/models/index.ts | 1 + src/components/index.ts | 1 + src/components/miners/CrownHistoryGrid.tsx | 324 +++++++++++++++ src/components/miners/CrownIcon.tsx | 41 ++ src/components/miners/CrownRateChart.tsx | 379 ++++++++++++++++++ src/components/miners/EarningDiagnostic.tsx | 164 ++++++++ .../miners/FilteredMinerSection.tsx | 119 ++++++ src/components/miners/MinerLeaderboard.tsx | 233 +++++++++++ src/components/miners/MinerSwapHistory.tsx | 162 ++++++++ .../miners/NetworkOverviewStats.tsx | 106 +++++ src/components/miners/StickyNetworkHeader.tsx | 110 +++++ src/components/miners/index.ts | 9 + src/components/nav/TopNav.tsx | 3 + src/components/nav/links.ts | 1 + src/pages/MinersPage.tsx | 106 +++++ src/routes.tsx | 3 + 19 files changed, 1971 insertions(+) create mode 100644 src/api/MinersDashboardApi.ts create mode 100644 src/api/models/MinersDashboard.ts create mode 100644 src/components/miners/CrownHistoryGrid.tsx create mode 100644 src/components/miners/CrownIcon.tsx create mode 100644 src/components/miners/CrownRateChart.tsx create mode 100644 src/components/miners/EarningDiagnostic.tsx create mode 100644 src/components/miners/FilteredMinerSection.tsx create mode 100644 src/components/miners/MinerLeaderboard.tsx create mode 100644 src/components/miners/MinerSwapHistory.tsx create mode 100644 src/components/miners/NetworkOverviewStats.tsx create mode 100644 src/components/miners/StickyNetworkHeader.tsx create mode 100644 src/components/miners/index.ts create mode 100644 src/pages/MinersPage.tsx diff --git a/src/api/MinersDashboardApi.ts b/src/api/MinersDashboardApi.ts new file mode 100644 index 0000000..1208ee4 --- /dev/null +++ b/src/api/MinersDashboardApi.ts @@ -0,0 +1,122 @@ +import { useApiQuery } from './ApiUtils'; +import { SSE_FALLBACK_INTERVAL } from './constants'; +import type { + CrownHistoryRow, + CrownRateHistoryRow, + CurrentCrownMap, + DiagnosticRow, + Direction, + HaltState, + LeaderboardRow, + MinerRateHistoryRow, + MinerStats, + NetworkOverview, + Range, +} from './models'; +import type { ActiveSwap } 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; +}) => + useApiQuery( + 'crown-rate-history', + '/crown/rate-history', + CROWN_REFRESH_MS, + { + direction: params.direction, + fromBlock: params.fromBlock, + toBlock: params.toBlock, + }, + ); + +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 useMinerDiagnostic = (hotkey: string) => + useApiQuery( + 'miner-diagnostic', + `/miners/${hotkey}/diagnostic`, + SSE_FALLBACK_INTERVAL, + undefined, + !!hotkey, + ); + +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 } = {}, +) => + 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..ea361c3 --- /dev/null +++ b/src/api/models/MinersDashboard.ts @@ -0,0 +1,86 @@ +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; + credit: number; +}; + +export type CrownRateHistoryRow = { + block: number; + rate: number; + holderHotkey: string; + holderUid: number | null; +}; + +export type LeaderboardRow = { + uid: number; + hotkey: string; + crownShare: number; + successRate: number; + completedSwaps: number; + timedOutSwaps: number; + volumeTao: string; + isActive: boolean; + currentCrownDirections: Direction[]; +}; + +export type MinerStats = { + successRate: number; + totalSwaps: number; + completedSwaps: number; + timedOutSwaps: number; + volumeTao: string; + avgFulfillSec: number | null; + avgCompleteSec: number | null; + crownShare: number; + isActive: boolean; + collateralRao: string; + activatedAt: number | null; +}; + +export type DiagnosticAction = { + kind: 'cli-command' | 'link'; + label: string; + value: string; +}; + +export type DiagnosticRow = { + severity: 'fail' | 'warn' | 'ok'; + code: string; + headline: string; + detail: string; + action?: DiagnosticAction; +}; + +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; + registeredMiners: number; + pairMix: PairMix[]; +}; + +export type HaltState = { halted: boolean; asOfBlock: number }; diff --git a/src/api/models/index.ts b/src/api/models/index.ts index efcc34c..7c2b4fd 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,5 +1,6 @@ export * from './Events'; export * from './Miners'; +export * from './MinersDashboard'; export * from './Protocol'; export * from './Reservations'; export * from './Stats'; 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/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx new file mode 100644 index 0000000..7b58c16 --- /dev/null +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -0,0 +1,324 @@ +import React, { useMemo, useState } from 'react'; +import { + Box, + Button, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { useCrownHistory } from '../../api'; +import type { CrownHistoryRow, Direction } from '../../api'; +import { FONTS } from '../../theme'; + +const ROW_BLOCKS = 60; +const RANGE_BLOCKS: Record = { '1h': 300, '4h': 1200 }; +const TIER_PALETTE = ['#0052ff', '#4d7dff', '#7f9eff', '#aebeff', '#d2dafe']; +const OTHER_COLOR = 'rgba(255,255,255,0.18)'; +const EMPTY_COLOR = 'rgba(255,255,255,0.05)'; + +type CrownRange = '1h' | '4h'; + +type CellState = { + block: number; + holderHotkey: string | null; + holderUid: number | null; + rate: number; + isTie: boolean; +}; + +const buildCells = ( + rows: CrownHistoryRow[], + lo: number, + hi: number, +): CellState[] => { + // Group rows by block. When >1 holder, mark as tie and pick the + // alphabetically-first as the visible representative. + 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, b) => a.hotkey.localeCompare(b.hotkey)); + const winner = here[0]; + cells.push({ + block: b, + holderHotkey: winner?.hotkey ?? null, + holderUid: winner?.uid ?? null, + rate: winner?.rate ?? 0, + isTie: here.length > 1, + }); + } + return cells; +}; + +const buildTiers = (cells: CellState[]): Map => { + const counts = new Map(); + for (const cell of cells) { + if (cell.holderHotkey) { + counts.set(cell.holderHotkey, (counts.get(cell.holderHotkey) ?? 0) + 1); + } + } + const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); + const map = new Map(); + sorted.forEach(([hotkey], idx) => { + map.set(hotkey, TIER_PALETTE[idx] ?? OTHER_COLOR); + }); + return map; +}; + +const CrownHistoryGrid: React.FC<{ + direction: Direction; + onDirectionChange: (d: Direction) => void; + range: CrownRange; + onRangeChange: (r: CrownRange) => void; + pan: number; + onPanChange: (next: number) => void; +}> = ({ + direction, + onDirectionChange, + range, + onRangeChange, + pan, + onPanChange, +}) => { + const [uidSearch, setUidSearch] = useState(''); + const span = RANGE_BLOCKS[range]; + + const { data } = useCrownHistory({ + direction, + // The API resolves missing bounds to "last DEFAULT blocks", so we only + // pass explicit bounds when panning. + toBlock: pan > 0 ? undefined : undefined, + fromBlock: undefined, + }); + + const rows = data ?? []; + const maxBlock = useMemo( + () => (rows.length ? Math.max(...rows.map((r) => r.block)) : 0), + [rows], + ); + const hi = maxBlock - pan; + const lo = Math.max(0, hi - span + 1); + const cells = useMemo(() => buildCells(rows, lo, hi), [rows, lo, hi]); + const tiers = useMemo(() => buildTiers(cells), [cells]); + + const rowsCount = Math.ceil(cells.length / ROW_BLOCKS); + const search = uidSearch.replace(/[^0-9]/g, ''); + const focused = search.length > 0; + + return ( + + + + Crown History · per block + + + v && onDirectionChange(v)} + sx={{ '& .MuiToggleButton-root': { borderColor: 'divider' } }} + > + + BTC → TAO + + + TAO → BTC + + + v && onRangeChange(v)} + > + + 1h + + + 4h + + + + + + + + block #{lo.toLocaleString()} — #{hi.toLocaleString()} · last {span}{' '} + blocks · {range} + + setUidSearch(e.target.value)} + inputProps={{ + style: { + fontFamily: FONTS.mono, + fontSize: '0.75rem', + padding: '6px 10px', + }, + }} + sx={{ width: 180, '& fieldset': { borderColor: 'divider' } }} + /> + + + {Array.from({ length: rowsCount }).map((_, r) => { + const rowStart = lo + r * ROW_BLOCKS; + const rowCells = cells.slice(r * ROW_BLOCKS, (r + 1) * ROW_BLOCKS); + return ( + + + #{rowStart.toLocaleString()} + + {rowCells.map((cell) => { + const isCurrent = cell.block === maxBlock; + const color = cell.holderHotkey + ? (tiers.get(cell.holderHotkey) ?? OTHER_COLOR) + : EMPTY_COLOR; + const matchesSearch = + focused && + cell.holderUid != null && + String(cell.holderUid) === search; + const dimmed = focused && !matchesSearch; + return ( + + ); + })} + + ); + })} + + {cells.length > 0 && cells.every((c) => c.holderHotkey === null) && ( + + no rate activity in this window + + )} + + as of #{maxBlock.toLocaleString()} · each cell = 1 block (12s) · each + row = 60 blocks (12m) + + + ); +}; + +export default CrownHistoryGrid; 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..78ba4ce --- /dev/null +++ b/src/components/miners/CrownRateChart.tsx @@ -0,0 +1,379 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { + Box, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { useCrownRateHistory, useMinerRateHistory } from '../../api'; +import type { + CrownRateHistoryRow, + Direction, + MinerRateHistoryRow, +} from '../../api'; +import { FONTS } from '../../theme'; + +const W = 800; +const H = 200; +const ML = 56; +const MR = 84; +const MT = 14; +const MB = 26; +const INNER_W = W - ML - MR; +const INNER_H = H - MT - MB; + +type CrownRange = '1h' | '4h' | '24h' | '7d'; + +const RANGE_BLOCKS: Record = { + '1h': 300, + '4h': 1200, + '24h': 7200, + '7d': 50_400, +}; + +const niceTicks = (lo: number, hi: number, count = 5): number[] => { + if (hi === lo) return [lo]; + const step = (hi - lo) / (count - 1); + return Array.from({ length: count }, (_, i) => lo + i * step); +}; + +const CrownRateChart: React.FC<{ + direction: Direction; + range: CrownRange; + onRangeChange: (r: CrownRange) => void; + minerHotkey?: string; +}> = ({ direction, range, onRangeChange, minerHotkey }) => { + const blocks = RANGE_BLOCKS[range]; + const { data } = useCrownRateHistory({ direction }); + const { data: minerRates } = useMinerRateHistory(minerHotkey ?? '', {}); + + const points = data ?? []; + const head = points.length ? Math.max(...points.map((p) => p.block)) : 0; + const lo = Math.max(0, head - blocks + 1); + const windowPoints = useMemo( + () => points.filter((p) => p.block >= lo && p.block <= head), + [points, lo, head], + ); + + const minerOverlay = useMemo(() => { + if (!minerHotkey) return []; + return (minerRates ?? []).filter( + (r) => + r.fromChain === (direction === 'BTC-TAO' ? 'btc' : 'tao') && + r.toChain === (direction === 'BTC-TAO' ? 'tao' : 'btc') && + r.block >= lo && + r.block <= head, + ); + }, [minerRates, minerHotkey, direction, lo, head]); + + const allRates = useMemo( + () => [ + ...windowPoints.map((p) => p.rate), + ...minerOverlay.map((m) => m.rate), + ], + [windowPoints, minerOverlay], + ); + const yMin = allRates.length ? Math.min(...allRates) - 1 : 0; + const yMax = allRates.length ? Math.max(...allRates) + 1 : 1; + + 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 crownPath = useMemo(() => { + if (!windowPoints.length) return ''; + let d = `M ${mapX(windowPoints[0].block)} ${mapY(windowPoints[0].rate)}`; + for (let i = 1; i < windowPoints.length; i++) { + d += ` L ${mapX(windowPoints[i].block)} ${mapY(windowPoints[i - 1].rate)} L ${mapX(windowPoints[i].block)} ${mapY(windowPoints[i].rate)}`; + } + return d; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowPoints, head, lo, yMin, yMax]); + + const minerPath = useMemo(() => { + if (!minerOverlay.length) return ''; + let d = `M ${mapX(minerOverlay[0].block)} ${mapY(minerOverlay[0].rate)}`; + for (let i = 1; i < minerOverlay.length; i++) { + d += ` L ${mapX(minerOverlay[i].block)} ${mapY(minerOverlay[i - 1].rate)} L ${mapX(minerOverlay[i].block)} ${mapY(minerOverlay[i].rate)}`; + } + return d; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [minerOverlay, head, lo, yMin, yMax]); + + const svgRef = useRef(null); + const [hover, setHover] = useState<{ + x: number; + y: number; + pt: CrownRateHistoryRow; + } | null>(null); + + const handleMove = (e: React.MouseEvent) => { + if (!svgRef.current || !windowPoints.length) return; + const rect = svgRef.current.getBoundingClientRect(); + const viewX = ((e.clientX - rect.left) / rect.width) * W; + if (viewX < ML || viewX > W - MR) { + setHover(null); + return; + } + let closest = windowPoints[0]; + let bestDist = Infinity; + for (const p of windowPoints) { + const dist = Math.abs(mapX(p.block) - viewX); + if (dist < bestDist) { + bestDist = dist; + closest = p; + } + } + setHover({ x: mapX(closest.block), y: mapY(closest.rate), pt: closest }); + }; + + const yTicks = niceTicks(yMin, yMax, 5); + + return ( + + + + Crown Rate · {direction} · per block + + v && onRangeChange(v)} + > + {(Object.keys(RANGE_BLOCKS) as CrownRange[]).map((r) => ( + + {r} + + ))} + + + + setHover(null)} + > + {yTicks.map((t) => ( + + + + {t.toFixed(0)} + + + ))} + + #{lo.toLocaleString()} + + + #{head.toLocaleString()} + + + {crownPath && ( + + )} + {minerPath && ( + + )} + {hover && ( + + + + + )} + {windowPoints.length > 0 && ( + + crown {windowPoints[windowPoints.length - 1].rate} + + )} + + {hover && ( + +
+ block #{hover.pt.block.toLocaleString()} +
+
+ crown uid {hover.pt.holderUid ?? '?'} @ {hover.pt.rate} +
+
+ )} + {!windowPoints.length && ( + + No rate history yet + + )} +
+ {minerHotkey && ( + + + + crown rate + + + + miner rate + + + )} +
+ ); +}; + +export default CrownRateChart; diff --git a/src/components/miners/EarningDiagnostic.tsx b/src/components/miners/EarningDiagnostic.tsx new file mode 100644 index 0000000..107aa4d --- /dev/null +++ b/src/components/miners/EarningDiagnostic.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { useMinerDiagnostic } from '../../api'; +import type { DiagnosticRow } from '../../api'; +import { FONTS } from '../../theme'; + +const SEVERITY_COLOR: Record = { + fail: 'error.main', + warn: 'secondary.main', + ok: 'success.main', +}; + +const SEVERITY_ICON: Record = { + fail: '✕', + warn: '⚠', + ok: '✓', +}; + +export const EarningNowBanner: React.FC<{ hotkey: string }> = ({ hotkey }) => { + const { data } = useMinerDiagnostic(hotkey); + const rows = data ?? []; + const top = + rows.find((r) => r.severity === 'fail') ?? + rows.find((r) => r.severity === 'warn'); + + const isOk = !top; + const headline = isOk + ? (rows.find((r) => r.severity === 'ok')?.headline ?? 'Earning normally') + : top!.headline; + const detail = isOk + ? (rows.find((r) => r.severity === 'ok')?.detail ?? '') + : top!.detail; + const action = isOk ? undefined : top!.action; + + return ( + + + earning now + + + + {isOk ? 'Earning.' : 'Not earning.'} + {' '} + {headline} + {detail && ( + + — {detail} + + )} + + {action && ( + + )} + + ); +}; + +export const EarningDiagnostic: React.FC<{ hotkey: string }> = ({ hotkey }) => { + const { data } = useMinerDiagnostic(hotkey); + const rows = data ?? []; + + return ( + + + Diagnostic + + {rows.map((row, idx) => ( + + + {SEVERITY_ICON[row.severity]} + + + + {row.headline} + + + {row.detail} + + + + ))} + + ); +}; diff --git a/src/components/miners/FilteredMinerSection.tsx b/src/components/miners/FilteredMinerSection.tsx new file mode 100644 index 0000000..e1afb81 --- /dev/null +++ b/src/components/miners/FilteredMinerSection.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef } from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { useMinerStats } from '../../api'; +import type { Direction, Range } from '../../api'; +import CrownRateChart from './CrownRateChart'; +import MinerSwapHistory from './MinerSwapHistory'; +import { EarningDiagnostic, EarningNowBanner } from './EarningDiagnostic'; +import { FONTS } from '../../theme'; + +const HOTKEY_SHORT = (h: string) => `${h.slice(0, 4)}…${h.slice(-4)}`; + +const FilteredMinerSection: React.FC<{ + hotkey: string; + direction: Direction; + rateRange: '1h' | '4h' | '24h' | '7d'; + onRateRangeChange: (r: '1h' | '4h' | '24h' | '7d') => void; + range: Range; +}> = ({ hotkey, direction, rateRange, onRateRangeChange, range }) => { + const navigate = useNavigate(); + const { data: stats } = useMinerStats(hotkey, range); + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [hotkey]); + + return ( + + + + + Filtered ·{' '} + + uid {stats ? '' : '?'} + + + + {HOTKEY_SHORT(hotkey)} + {stats?.collateralRao && + ` · collateral ${(Number(stats.collateralRao) / 1e9).toFixed(2)} TAO`} + {stats?.activatedAt != null && + ` · activated #${stats.activatedAt.toLocaleString()}`} + + + navigate('/miners')} + sx={{ + fontFamily: FONTS.mono, + fontSize: '0.7rem', + color: 'text.secondary', + background: 'transparent', + border: 'none', + cursor: 'pointer', + '&:hover': { color: 'text.primary' }, + }} + > + clear ✕ + + + + + + + + + + + + + + + + ); +}; + +export default FilteredMinerSection; diff --git a/src/components/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx new file mode 100644 index 0000000..96d1e2b --- /dev/null +++ b/src/components/miners/MinerLeaderboard.tsx @@ -0,0 +1,233 @@ +import React from 'react'; +import { + Box, + Button, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { useMinerLeaderboard } from '../../api'; +import type { LeaderboardRow, Range } from '../../api'; +import CrownIcon from './CrownIcon'; +import { FONTS } from '../../theme'; + +const RANGES: Range[] = ['24h', '7d', '30d', '90d', 'all']; + +const HOTKEY_SHORT = (h: string) => `${h.slice(0, 4)}…${h.slice(-4)}`; + +const formatVolume = (raw: string): string => { + const v = parseFloat(raw); + if (!Number.isFinite(v) || v === 0) return '0.00 TAO'; + return `${v.toFixed(2)} TAO`; +}; + +const formatSuccess = (row: LeaderboardRow): string => { + const total = row.completedSwaps + row.timedOutSwaps; + if (total === 0) return '— / 0'; + return `${row.completedSwaps} / ${total}`; +}; + +const TIER_COLORS = [ + 'primary.main', + '#4d7dff', + '#7f9eff', + '#aebeff', + '#d2dafe', +]; + +const MinerLeaderboard: React.FC<{ + activeHotkey?: string; + range: Range; + onRangeChange: (r: Range) => void; +}> = ({ activeHotkey, range, onRangeChange }) => { + const navigate = useNavigate(); + const { data, isLoading } = useMinerLeaderboard(range); + const rows = data ?? []; + const topShare = rows[0]?.crownShare ?? 0; + + const handleRowClick = (row: LeaderboardRow) => { + navigate(`/miners/${row.hotkey}`); + try { + const RAW = localStorage.getItem('allways.recentMiners'); + const parsed: { uid: number; hotkey: string; viewedAt: number }[] = RAW + ? JSON.parse(RAW) + : []; + const next = [ + { uid: row.uid, hotkey: row.hotkey, viewedAt: Date.now() }, + ...parsed.filter((m) => m.hotkey !== row.hotkey), + ].slice(0, 5); + localStorage.setItem('allways.recentMiners', JSON.stringify(next)); + } catch { + /* ignore — storage disabled */ + } + }; + + return ( + + + + Miner Leaderboard + + + {RANGES.map((r) => ( + + ))} + + + + + + + # + uid + hotkey + crown share + success + volume + active + + + + {rows.length === 0 && !isLoading && ( + + + No miners registered yet + + + )} + {rows.map((row, idx) => { + const highlight = activeHotkey === row.hotkey; + const sharePct = + topShare > 0 ? Math.round((row.crownShare / topShare) * 100) : 0; + const tierColor = + TIER_COLORS[Math.min(idx, TIER_COLORS.length - 1)]; + const successColor = + row.completedSwaps === 0 && row.timedOutSwaps > 0 + ? 'error.main' + : 'text.primary'; + const wearsCrown = row.currentCrownDirections.length > 0; + return ( + handleRowClick(row)} + hover + sx={{ + cursor: 'pointer', + backgroundColor: highlight + ? 'rgba(0,82,255,0.07)' + : 'transparent', + '&:hover td': { backgroundColor: 'surface.elevated' }, + '& td:first-of-type': highlight + ? { boxShadow: 'inset 2px 0 0 var(--color-primary)' } + : undefined, + }} + > + + {wearsCrown && } + + {idx + 1} + {row.uid} + + {HOTKEY_SHORT(row.hotkey)} + + + + + + + + {(row.crownShare * 100).toFixed(0)}% + + + + + {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..88d70cb --- /dev/null +++ b/src/components/miners/MinerSwapHistory.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { + Box, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +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 fmtDate = (raw: string | null): string => { + if (!raw) return '—'; + const d = new Date(raw); + return d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +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 + ext + + + + {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} + + + {fmtDate(row.initiatedAt)} + + + + {row.status.replace('_', ' ').toLowerCase()} + + + + {taoAmount} TAO + + + {row.sourceChain?.toUpperCase()}→ + {row.destChain?.toUpperCase()} + + + {fmtDuration(row.initiatedAt, row.resolvedAt)} + + + {row.timeoutExtensionsUsed} + + + ); + })} + +
+
+ ); +}; + +export default MinerSwapHistory; diff --git a/src/components/miners/NetworkOverviewStats.tsx b/src/components/miners/NetworkOverviewStats.tsx new file mode 100644 index 0000000..8334883 --- /dev/null +++ b/src/components/miners/NetworkOverviewStats.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import { useNetworkOverview } from '../../api'; +import type { Range } from '../../api'; +import { FONTS } from '../../theme'; + +interface Tile { + label: string; + value: string; + sub: string; +} + +const StatTile: React.FC<{ tile: Tile }> = ({ tile }) => ( + + + {tile.label} + + + {tile.value} + + + {tile.sub} + + +); + +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(1) + : '—'; + const activeMiners = + data?.activeMiners != null ? `${data.activeMiners}` : '—'; + const registeredMiners = + data?.registeredMiners != null ? `of ${data.registeredMiners} reg` : ''; + const pairMix = data?.pairMix?.slice(0, 2) ?? []; + const pairValue = pairMix.length + ? pairMix.map((p) => Math.round(p.pct)).join(' / ') + : '—'; + const pairSub = pairMix.length + ? pairMix.map((p) => p.pair).join(' / ') + : 'BTC→TAO / TAO→BTC'; + + const tiles: Tile[] = [ + { label: `Volume ${range}`, value: volume, sub: 'TAO' }, + { label: `Swaps ${range}`, value: swaps, sub: `${successPct}% success` }, + { label: 'Active miners', value: activeMiners, sub: registeredMiners }, + { label: `Pair mix ${range}`, value: pairValue, sub: pairSub }, + ]; + + return ( + + {tiles.map((t) => ( + + + + ))} + + ); +}; + +export default NetworkOverviewStats; diff --git a/src/components/miners/StickyNetworkHeader.tsx b/src/components/miners/StickyNetworkHeader.tsx new file mode 100644 index 0000000..21e19e9 --- /dev/null +++ b/src/components/miners/StickyNetworkHeader.tsx @@ -0,0 +1,110 @@ +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]; + const [from, to] = dir.split('-'); + segments.push( + + + + {from} + + → + + {to} + + {h.uid != null ? ( + + uid {h.uid} + {h.rate != null && <> @ {h.rate}} + + ) : ( + + none + + )} + , + ); + } + } + + const halted = halt?.halted ?? false; + + return ( + + + + + {segments} + + + + + {halted ? 'halted' : 'healthy'} + + + + + ); +}; + +export default StickyNetworkHeader; diff --git a/src/components/miners/index.ts b/src/components/miners/index.ts new file mode 100644 index 0000000..9039d07 --- /dev/null +++ b/src/components/miners/index.ts @@ -0,0 +1,9 @@ +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 CrownRateChart } from './CrownRateChart'; +export { default as MinerSwapHistory } from './MinerSwapHistory'; +export { default as FilteredMinerSection } from './FilteredMinerSection'; +export { EarningDiagnostic, EarningNowBanner } from './EarningDiagnostic'; 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/pages/MinersPage.tsx b/src/pages/MinersPage.tsx new file mode 100644 index 0000000..c16bf66 --- /dev/null +++ b/src/pages/MinersPage.tsx @@ -0,0 +1,106 @@ +import React, { useCallback } from 'react'; +import { Box, Stack } from '@mui/material'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { + CrownHistoryGrid, + CrownRateChart, + FilteredMinerSection, + MinerLeaderboard, + NetworkOverviewStats, + StickyNetworkHeader, + SEO, +} from '../components'; +import type { Direction, Range } from '../api'; + +const isDirection = (v: string | null): v is Direction => + v === 'BTC-TAO' || v === 'TAO-BTC'; + +const isRange = (v: string | null): v is Range => + ['24h', '7d', '30d', '90d', 'all'].includes(v ?? ''); + +const isCrownRange = (v: string | null): v is '1h' | '4h' => + v === '1h' || v === '4h'; + +const isRateRange = (v: string | null): v is '1h' | '4h' | '24h' | '7d' => + ['1h', '4h', '24h', '7d'].includes(v ?? ''); + +const MinersPage: React.FC = () => { + const { hotkey } = useParams<{ hotkey?: string }>(); + const [params, setParams] = useSearchParams(); + + const range: Range = isRange(params.get('range')) + ? (params.get('range') as Range) + : '30d'; + const direction: Direction = isDirection(params.get('pair')) + ? (params.get('pair') as Direction) + : 'BTC-TAO'; + const crownRange = isCrownRange(params.get('crownRange')) + ? (params.get('crownRange') as '1h' | '4h') + : '1h'; + const rateRange = isRateRange(params.get('rateRange')) + ? (params.get('rateRange') as '1h' | '4h' | '24h' | '7d') + : '1h'; + 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)} + /> + {hotkey && ( + setParam('rateRange', r)} + range={range} + /> + )} + + + ); +}; + +export default MinersPage; diff --git a/src/routes.tsx b/src/routes.tsx index 9601e63..741fb39 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -8,6 +8,7 @@ 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 SwapPage = React.lazy(() => import('./pages/SwapPage')); const SwapDetailPage = React.lazy(() => import('./pages/SwapDetailPage')); const ReservationDetailPage = React.lazy( @@ -22,6 +23,8 @@ 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: }, { From 0ddc6b7a857d2a3ae97ef232f4a174c3a73c0246 Mon Sep 17 00:00:00 2001 From: Landyn Date: Tue, 12 May 2026 17:22:03 -0500 Subject: [PATCH 02/11] lint: merge dup imports and stabilize useMemo deps --- src/api/MinersDashboardApi.ts | 2 +- src/components/miners/CrownHistoryGrid.tsx | 9 ++++++--- src/components/miners/CrownRateChart.tsx | 13 +++++++------ src/components/miners/EarningDiagnostic.tsx | 3 +-- src/components/miners/FilteredMinerSection.tsx | 3 +-- src/components/miners/MinerLeaderboard.tsx | 7 +++++-- src/components/miners/NetworkOverviewStats.tsx | 3 +-- 7 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/api/MinersDashboardApi.ts b/src/api/MinersDashboardApi.ts index 1208ee4..1f80532 100644 --- a/src/api/MinersDashboardApi.ts +++ b/src/api/MinersDashboardApi.ts @@ -1,6 +1,7 @@ import { useApiQuery } from './ApiUtils'; import { SSE_FALLBACK_INTERVAL } from './constants'; import type { + ActiveSwap, CrownHistoryRow, CrownRateHistoryRow, CurrentCrownMap, @@ -13,7 +14,6 @@ import type { NetworkOverview, Range, } from './models'; -import type { ActiveSwap } from './models'; const CROWN_REFRESH_MS = 12_000; diff --git a/src/components/miners/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx index 7b58c16..baa0502 100644 --- a/src/components/miners/CrownHistoryGrid.tsx +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -8,8 +8,11 @@ import { ToggleButtonGroup, Typography, } from '@mui/material'; -import { useCrownHistory } from '../../api'; -import type { CrownHistoryRow, Direction } from '../../api'; +import { + useCrownHistory, + type CrownHistoryRow, + type Direction, +} from '../../api'; import { FONTS } from '../../theme'; const ROW_BLOCKS = 60; @@ -98,7 +101,7 @@ const CrownHistoryGrid: React.FC<{ fromBlock: undefined, }); - const rows = data ?? []; + const rows = useMemo(() => data ?? [], [data]); const maxBlock = useMemo( () => (rows.length ? Math.max(...rows.map((r) => r.block)) : 0), [rows], diff --git a/src/components/miners/CrownRateChart.tsx b/src/components/miners/CrownRateChart.tsx index 78ba4ce..12301d2 100644 --- a/src/components/miners/CrownRateChart.tsx +++ b/src/components/miners/CrownRateChart.tsx @@ -6,11 +6,12 @@ import { ToggleButtonGroup, Typography, } from '@mui/material'; -import { useCrownRateHistory, useMinerRateHistory } from '../../api'; -import type { - CrownRateHistoryRow, - Direction, - MinerRateHistoryRow, +import { + useCrownRateHistory, + useMinerRateHistory, + type CrownRateHistoryRow, + type Direction, + type MinerRateHistoryRow, } from '../../api'; import { FONTS } from '../../theme'; @@ -48,7 +49,7 @@ const CrownRateChart: React.FC<{ const { data } = useCrownRateHistory({ direction }); const { data: minerRates } = useMinerRateHistory(minerHotkey ?? '', {}); - const points = data ?? []; + const points = useMemo(() => data ?? [], [data]); const head = points.length ? Math.max(...points.map((p) => p.block)) : 0; const lo = Math.max(0, head - blocks + 1); const windowPoints = useMemo( diff --git a/src/components/miners/EarningDiagnostic.tsx b/src/components/miners/EarningDiagnostic.tsx index 107aa4d..6e306d9 100644 --- a/src/components/miners/EarningDiagnostic.tsx +++ b/src/components/miners/EarningDiagnostic.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Box, Button, Stack, Typography } from '@mui/material'; -import { useMinerDiagnostic } from '../../api'; -import type { DiagnosticRow } from '../../api'; +import { useMinerDiagnostic, type DiagnosticRow } from '../../api'; import { FONTS } from '../../theme'; const SEVERITY_COLOR: Record = { diff --git a/src/components/miners/FilteredMinerSection.tsx b/src/components/miners/FilteredMinerSection.tsx index e1afb81..249d77f 100644 --- a/src/components/miners/FilteredMinerSection.tsx +++ b/src/components/miners/FilteredMinerSection.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useRef } from 'react'; import { Box, Stack, Typography } from '@mui/material'; import { useNavigate } from 'react-router-dom'; -import { useMinerStats } from '../../api'; -import type { Direction, Range } from '../../api'; +import { useMinerStats, type Direction, type Range } from '../../api'; import CrownRateChart from './CrownRateChart'; import MinerSwapHistory from './MinerSwapHistory'; import { EarningDiagnostic, EarningNowBanner } from './EarningDiagnostic'; diff --git a/src/components/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx index 96d1e2b..c90121b 100644 --- a/src/components/miners/MinerLeaderboard.tsx +++ b/src/components/miners/MinerLeaderboard.tsx @@ -11,8 +11,11 @@ import { Typography, } from '@mui/material'; import { useNavigate } from 'react-router-dom'; -import { useMinerLeaderboard } from '../../api'; -import type { LeaderboardRow, Range } from '../../api'; +import { + useMinerLeaderboard, + type LeaderboardRow, + type Range, +} from '../../api'; import CrownIcon from './CrownIcon'; import { FONTS } from '../../theme'; diff --git a/src/components/miners/NetworkOverviewStats.tsx b/src/components/miners/NetworkOverviewStats.tsx index 8334883..f5cb8c8 100644 --- a/src/components/miners/NetworkOverviewStats.tsx +++ b/src/components/miners/NetworkOverviewStats.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Box, Stack, Typography } from '@mui/material'; -import { useNetworkOverview } from '../../api'; -import type { Range } from '../../api'; +import { useNetworkOverview, type Range } from '../../api'; import { FONTS } from '../../theme'; interface Tile { From 2fb9c86d6a465afb9bdd2b8cdb67cf92d493a8ed Mon Sep 17 00:00:00 2001 From: Landyn Date: Tue, 12 May 2026 17:29:25 -0500 Subject: [PATCH 03/11] fix UI bugs: footer overlap, stats grey strip, conjoined empty text, add 2h scoring-window range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MinersPage wraps in Page rather than a 100vh Box so the site Footer renders correctly below the content. NetworkOverviewStats switches from a Stack-with-bg-as-divider trick to a CSS Grid with per-cell borders so a shorter tile never reveals a grey strip beneath the row. CrownHistoryGrid's empty-state + as-of Typography elements get component='div' so they stack on separate lines instead of collapsing inline. Adds a 2h range chip that snaps to multiples of SCORING_WINDOW_BLOCKS (600) so the grid mirrors the validator's actual scoring window; pan moves back one window at a time and a 'latest →' shortcut returns to the in-progress window. --- src/components/miners/CrownHistoryGrid.tsx | 81 +++++++++++++++---- .../miners/NetworkOverviewStats.tsx | 50 +++++++----- src/pages/MinersPage.tsx | 10 ++- 3 files changed, 101 insertions(+), 40 deletions(-) diff --git a/src/components/miners/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx index baa0502..ce5a1ac 100644 --- a/src/components/miners/CrownHistoryGrid.tsx +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -16,12 +16,17 @@ import { import { FONTS } from '../../theme'; const ROW_BLOCKS = 60; -const RANGE_BLOCKS: Record = { '1h': 300, '4h': 1200 }; +const RANGE_BLOCKS: Record = { '1h': 300, '2h': 600, '4h': 1200 }; +// Subtensor scoring cadence in blocks. The validator sets weights once per +// SCORING_WINDOW, so the 2h grid snaps to multiples of this value to show +// "the actual chunk the validator scored on" rather than a rolling trail. +// Mirrors SCORING_WINDOW_BLOCKS in allways/constants.py. +const SCORING_WINDOW_BLOCKS = 600; const TIER_PALETTE = ['#0052ff', '#4d7dff', '#7f9eff', '#aebeff', '#d2dafe']; const OTHER_COLOR = 'rgba(255,255,255,0.18)'; const EMPTY_COLOR = 'rgba(255,255,255,0.05)'; -type CrownRange = '1h' | '4h'; +type CrownRange = '1h' | '2h' | '4h'; type CellState = { block: number; @@ -106,8 +111,19 @@ const CrownHistoryGrid: React.FC<{ () => (rows.length ? Math.max(...rows.map((r) => r.block)) : 0), [rows], ); - const hi = maxBlock - pan; - const lo = Math.max(0, hi - span + 1); + // 2h snaps to the validator's scoring boundary so the grid renders the + // actual chunk weights were set on. 1h and 4h stay as rolling windows. + let hi: number; + let lo: number; + if (range === '2h') { + const anchor = Math.floor(maxBlock / 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 = maxBlock - pan; + lo = Math.max(0, hi - span + 1); + } const cells = useMemo(() => buildCells(rows, lo, hi), [rows, lo, hi]); const tiers = useMemo(() => buildTiers(cells), [cells]); @@ -174,6 +190,12 @@ const CrownHistoryGrid: React.FC<{ > 1h + + 2h + - + + + {range === '2h' && pan > 0 && ( + + )} + - block #{lo.toLocaleString()} — #{hi.toLocaleString()} · last {span}{' '} - blocks · {range} + {range === '2h' ? ( + <> + scoring window · block #{lo.toLocaleString()} — # + {hi.toLocaleString()} + {pan === 0 && ( + + · current + + )} + + ) : ( + <> + block #{lo.toLocaleString()} — #{hi.toLocaleString()} · last{' '} + {span} blocks · {range} + + )} {cells.length > 0 && cells.every((c) => c.holderHotkey === null) && ( )} = ({ tile }) => ( backgroundColor: 'surface.light', px: 2.5, py: 2, + height: '100%', }} > = ({ tile }) => ( ); -const NetworkOverviewStats: React.FC<{ range?: Range }> = ({ - range = '30d', -}) => { +/** + * 4-up network stat tiles. Border lines come from each tile's right/bottom + * border (not a parent-bg-as-divider trick) so a shorter tile never reveals + * a grey strip beneath the row. + */ +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 swaps = data?.totalSwaps != null ? data.totalSwaps.toLocaleString() : '—'; const successPct = - data?.networkSuccessRate != null - ? (data.networkSuccessRate * 100).toFixed(1) - : '—'; - const activeMiners = - data?.activeMiners != null ? `${data.activeMiners}` : '—'; + data?.networkSuccessRate != null ? (data.networkSuccessRate * 100).toFixed(1) : '—'; + const activeMiners = data?.activeMiners != null ? `${data.activeMiners}` : '—'; const registeredMiners = data?.registeredMiners != null ? `of ${data.registeredMiners} reg` : ''; const pairMix = data?.pairMix?.slice(0, 2) ?? []; const pairValue = pairMix.length ? pairMix.map((p) => Math.round(p.pct)).join(' / ') : '—'; - const pairSub = pairMix.length - ? pairMix.map((p) => p.pair).join(' / ') - : 'BTC→TAO / TAO→BTC'; + const pairSub = pairMix.length ? pairMix.map((p) => p.pair).join(' / ') : 'BTC→TAO / TAO→BTC'; const tiles: Tile[] = [ { label: `Volume ${range}`, value: volume, sub: 'TAO' }, @@ -83,22 +81,30 @@ const NetworkOverviewStats: React.FC<{ range?: Range }> = ({ ]; return ( - *': { + borderRight: { sm: '1px solid' }, + borderBottom: { xs: '1px solid', md: 'none' }, + borderColor: 'divider', + }, + // last cell in each row should not show a right border; bottom row + // shouldn't show a bottom border. + '& > *: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) => ( - - - + ))} - + ); }; diff --git a/src/pages/MinersPage.tsx b/src/pages/MinersPage.tsx index c16bf66..28ad359 100644 --- a/src/pages/MinersPage.tsx +++ b/src/pages/MinersPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Box, Stack } from '@mui/material'; +import { Stack } from '@mui/material'; import { useParams, useSearchParams } from 'react-router-dom'; import { CrownHistoryGrid, @@ -7,8 +7,9 @@ import { FilteredMinerSection, MinerLeaderboard, NetworkOverviewStats, - StickyNetworkHeader, + Page, SEO, + StickyNetworkHeader, } from '../components'; import type { Direction, Range } from '../api'; @@ -56,7 +57,7 @@ const MinersPage: React.FC = () => { ); return ( - + { py: { xs: 2, sm: 3, md: 4 }, maxWidth: 1400, mx: 'auto', + width: '100%', }} > @@ -99,7 +101,7 @@ const MinersPage: React.FC = () => { /> )} - + ); }; From 77a68e131aaa9fce0345e98dd15be60b92548a81 Mon Sep 17 00:00:00 2001 From: Landyn Date: Wed, 13 May 2026 14:11:43 -0500 Subject: [PATCH 04/11] miner dashboard UX overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dedicated /miners/:hotkey page; drop FilteredMinerSection - leaderboard: sortable headers, uid/hotkey search, drop rank col - swap history: block-number initiated, swap# links, drop ext col - network overview: pending stripe on current block, swap-direction bars, TileValue suffix pattern (volume τ, success rate) - crown rate chart: miner-mode flips primary line, visible legend, flipped TAO→BTC equation - detail header: hotkey/collateral/activated grid + crown chip - stats strip with range chips on detail page - drop EarningDiagnostic and MinerStatusStrip - ref-based useOnNavigate to stop search-param scroll jumps --- src/api/MinersDashboardApi.ts | 12 +- src/api/models/MinersDashboard.ts | 20 +- src/components/miners/CrownHistoryGrid.tsx | 503 ++++++++++-- src/components/miners/CrownRateChart.tsx | 736 ++++++++++++------ src/components/miners/EarningDiagnostic.tsx | 163 ---- .../miners/FilteredMinerSection.tsx | 118 --- src/components/miners/MinerLeaderboard.tsx | 250 ++++-- src/components/miners/MinerSwapHistory.tsx | 35 +- .../miners/NetworkOverviewStats.tsx | 198 +++-- src/components/miners/index.ts | 2 - src/hooks/useOnNavigate.ts | 12 +- src/pages/MinerDetailPage.tsx | 444 +++++++++++ src/pages/MinersPage.tsx | 25 +- src/routes.tsx | 7 +- 14 files changed, 1795 insertions(+), 730 deletions(-) delete mode 100644 src/components/miners/EarningDiagnostic.tsx delete mode 100644 src/components/miners/FilteredMinerSection.tsx create mode 100644 src/pages/MinerDetailPage.tsx diff --git a/src/api/MinersDashboardApi.ts b/src/api/MinersDashboardApi.ts index 1f80532..e77cebe 100644 --- a/src/api/MinersDashboardApi.ts +++ b/src/api/MinersDashboardApi.ts @@ -5,7 +5,6 @@ import type { CrownHistoryRow, CrownRateHistoryRow, CurrentCrownMap, - DiagnosticRow, Direction, HaltState, LeaderboardRow, @@ -40,6 +39,7 @@ export const useCrownRateHistory = (params: { direction: Direction; fromBlock?: number; toBlock?: number; + blocks?: number; }) => useApiQuery( 'crown-rate-history', @@ -49,6 +49,7 @@ export const useCrownRateHistory = (params: { direction: params.direction, fromBlock: params.fromBlock, toBlock: params.toBlock, + blocks: params.blocks, }, ); @@ -71,15 +72,6 @@ export const useMinerStats = (hotkey: string, range: Range = '30d') => !!hotkey, ); -export const useMinerDiagnostic = (hotkey: string) => - useApiQuery( - 'miner-diagnostic', - `/miners/${hotkey}/diagnostic`, - SSE_FALLBACK_INTERVAL, - undefined, - !!hotkey, - ); - export const useMinerSwaps = ( hotkey: string, params: { limit?: number; offset?: number; status?: string } = {}, diff --git a/src/api/models/MinersDashboard.ts b/src/api/models/MinersDashboard.ts index ea361c3..51857f8 100644 --- a/src/api/models/MinersDashboard.ts +++ b/src/api/models/MinersDashboard.ts @@ -15,14 +15,11 @@ export type CrownHistoryRow = { hotkey: string; uid: number | null; rate: number; - credit: number; }; export type CrownRateHistoryRow = { block: number; rate: number; - holderHotkey: string; - holderUid: number | null; }; export type LeaderboardRow = { @@ -38,10 +35,10 @@ export type LeaderboardRow = { }; export type MinerStats = { - successRate: number; totalSwaps: number; completedSwaps: number; timedOutSwaps: number; + successRate: number; volumeTao: string; avgFulfillSec: number | null; avgCompleteSec: number | null; @@ -51,20 +48,6 @@ export type MinerStats = { activatedAt: number | null; }; -export type DiagnosticAction = { - kind: 'cli-command' | 'link'; - label: string; - value: string; -}; - -export type DiagnosticRow = { - severity: 'fail' | 'warn' | 'ok'; - code: string; - headline: string; - detail: string; - action?: DiagnosticAction; -}; - export type MinerRateHistoryRow = { block: number; rate: number; @@ -79,7 +62,6 @@ export type NetworkOverview = { totalSwaps: number; networkSuccessRate: number; activeMiners: number; - registeredMiners: number; pairMix: PairMix[]; }; diff --git a/src/components/miners/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx index ce5a1ac..fd05e27 100644 --- a/src/components/miners/CrownHistoryGrid.tsx +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -7,6 +7,7 @@ import { ToggleButton, ToggleButtonGroup, Typography, + useTheme, } from '@mui/material'; import { useCrownHistory, @@ -14,17 +15,21 @@ import { type Direction, } from '../../api'; import { FONTS } from '../../theme'; +import CrownIcon from './CrownIcon'; const ROW_BLOCKS = 60; -const RANGE_BLOCKS: Record = { '1h': 300, '2h': 600, '4h': 1200 }; +const CELL_PX = 14; +const RANGE_BLOCKS: Record = { + '1h': 300, + '2h': 600, + '4h': 1200, +}; // Subtensor scoring cadence in blocks. The validator sets weights once per // SCORING_WINDOW, so the 2h grid snaps to multiples of this value to show // "the actual chunk the validator scored on" rather than a rolling trail. // Mirrors SCORING_WINDOW_BLOCKS in allways/constants.py. const SCORING_WINDOW_BLOCKS = 600; const TIER_PALETTE = ['#0052ff', '#4d7dff', '#7f9eff', '#aebeff', '#d2dafe']; -const OTHER_COLOR = 'rgba(255,255,255,0.18)'; -const EMPTY_COLOR = 'rgba(255,255,255,0.05)'; type CrownRange = '1h' | '2h' | '4h'; @@ -34,15 +39,18 @@ type CellState = { holderUid: number | null; rate: number; isTie: boolean; + isCurrent: boolean; + color: string | null; }; const buildCells = ( rows: CrownHistoryRow[], lo: number, hi: number, + maxBlock: number, + tiers: Map, + otherColor: string, ): CellState[] => { - // Group rows by block. When >1 holder, mark as tie and pick the - // alphabetically-first as the visible representative. const byBlock = new Map(); for (const row of rows) { const arr = byBlock.get(row.block) ?? []; @@ -52,7 +60,7 @@ const buildCells = ( const cells: CellState[] = []; for (let b = lo; b <= hi; b++) { const here = byBlock.get(b) ?? []; - here.sort((a, b) => a.hotkey.localeCompare(b.hotkey)); + here.sort((a, c) => a.hotkey.localeCompare(c.hotkey)); const winner = here[0]; cells.push({ block: b, @@ -60,24 +68,44 @@ const buildCells = ( 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; }; -const buildTiers = (cells: CellState[]): Map => { - const counts = new Map(); - for (const cell of cells) { - if (cell.holderHotkey) { - counts.set(cell.holderHotkey, (counts.get(cell.holderHotkey) ?? 0) + 1); - } +const buildTiers = ( + rows: CrownHistoryRow[], + lo: number, + hi: number, +): { + color: Map; + ordered: { + hotkey: string; + uid: number | null; + count: number; + color: string; + }[]; +} => { + 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] - a[1]); - const map = new Map(); - sorted.forEach(([hotkey], idx) => { - map.set(hotkey, TIER_PALETTE[idx] ?? OTHER_COLOR); - }); - return map; + 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 }; }; const CrownHistoryGrid: React.FC<{ @@ -95,13 +123,30 @@ const CrownHistoryGrid: React.FC<{ pan, onPanChange, }) => { + 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 [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 span = RANGE_BLOCKS[range]; const { data } = useCrownHistory({ direction, - // The API resolves missing bounds to "last DEFAULT blocks", so we only - // pass explicit bounds when panning. toBlock: pan > 0 ? undefined : undefined, fromBlock: undefined, }); @@ -111,12 +156,11 @@ const CrownHistoryGrid: React.FC<{ () => (rows.length ? Math.max(...rows.map((r) => r.block)) : 0), [rows], ); - // 2h snaps to the validator's scoring boundary so the grid renders the - // actual chunk weights were set on. 1h and 4h stay as rolling windows. let hi: number; let lo: number; if (range === '2h') { - const anchor = Math.floor(maxBlock / SCORING_WINDOW_BLOCKS) * SCORING_WINDOW_BLOCKS; + const anchor = + Math.floor(maxBlock / 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; @@ -124,20 +168,48 @@ const CrownHistoryGrid: React.FC<{ hi = maxBlock - pan; lo = Math.max(0, hi - span + 1); } - const cells = useMemo(() => buildCells(rows, lo, hi), [rows, lo, hi]); - const tiers = useMemo(() => buildTiers(cells), [cells]); + const { color: tierColors, ordered: tierLegend } = useMemo( + () => buildTiers(rows, lo, hi), + [rows, lo, hi], + ); + const cells = useMemo( + () => buildCells(rows, lo, hi, maxBlock, tierColors, otherColor), + [rows, lo, hi, maxBlock, tierColors, otherColor], + ); const rowsCount = Math.ceil(cells.length / ROW_BLOCKS); const search = uidSearch.replace(/[^0-9]/g, ''); const focused = search.length > 0; + const toggleLegendUid = (uid: number | null) => { + if (uid == null) 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 ( @@ -145,18 +217,29 @@ const CrownHistoryGrid: React.FC<{ direction="row" justifyContent="space-between" alignItems="center" - sx={{ mb: 2 }} + sx={{ mb: 2.5 }} > - - Crown History · per block - + + + Crown History + + + per block · who held the best rate + + ← earlier - {range === '2h' && pan > 0 && ( + {pan > 0 && ( - )} - - ); -}; - -export const EarningDiagnostic: React.FC<{ hotkey: string }> = ({ hotkey }) => { - const { data } = useMinerDiagnostic(hotkey); - const rows = data ?? []; - - return ( - - - Diagnostic - - {rows.map((row, idx) => ( - - - {SEVERITY_ICON[row.severity]} - - - - {row.headline} - - - {row.detail} - - - - ))} - - ); -}; diff --git a/src/components/miners/FilteredMinerSection.tsx b/src/components/miners/FilteredMinerSection.tsx deleted file mode 100644 index 249d77f..0000000 --- a/src/components/miners/FilteredMinerSection.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { Box, Stack, Typography } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; -import { useMinerStats, type Direction, type Range } from '../../api'; -import CrownRateChart from './CrownRateChart'; -import MinerSwapHistory from './MinerSwapHistory'; -import { EarningDiagnostic, EarningNowBanner } from './EarningDiagnostic'; -import { FONTS } from '../../theme'; - -const HOTKEY_SHORT = (h: string) => `${h.slice(0, 4)}…${h.slice(-4)}`; - -const FilteredMinerSection: React.FC<{ - hotkey: string; - direction: Direction; - rateRange: '1h' | '4h' | '24h' | '7d'; - onRateRangeChange: (r: '1h' | '4h' | '24h' | '7d') => void; - range: Range; -}> = ({ hotkey, direction, rateRange, onRateRangeChange, range }) => { - const navigate = useNavigate(); - const { data: stats } = useMinerStats(hotkey, range); - const ref = useRef(null); - - useEffect(() => { - if (ref.current) { - ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [hotkey]); - - return ( - - - - - Filtered ·{' '} - - uid {stats ? '' : '?'} - - - - {HOTKEY_SHORT(hotkey)} - {stats?.collateralRao && - ` · collateral ${(Number(stats.collateralRao) / 1e9).toFixed(2)} TAO`} - {stats?.activatedAt != null && - ` · activated #${stats.activatedAt.toLocaleString()}`} - - - navigate('/miners')} - sx={{ - fontFamily: FONTS.mono, - fontSize: '0.7rem', - color: 'text.secondary', - background: 'transparent', - border: 'none', - cursor: 'pointer', - '&:hover': { color: 'text.primary' }, - }} - > - clear ✕ - - - - - - - - - - - - - - - - ); -}; - -export default FilteredMinerSection; diff --git a/src/components/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx index c90121b..39b496d 100644 --- a/src/components/miners/MinerLeaderboard.tsx +++ b/src/components/miners/MinerLeaderboard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { Box, Button, @@ -8,6 +8,7 @@ import { TableCell, TableHead, TableRow, + TextField, Typography, } from '@mui/material'; import { useNavigate } from 'react-router-dom'; @@ -25,8 +26,8 @@ const HOTKEY_SHORT = (h: string) => `${h.slice(0, 4)}…${h.slice(-4)}`; const formatVolume = (raw: string): string => { const v = parseFloat(raw); - if (!Number.isFinite(v) || v === 0) return '0.00 TAO'; - return `${v.toFixed(2)} TAO`; + if (!Number.isFinite(v) || v === 0) return '0.00'; + return v.toFixed(2); }; const formatSuccess = (row: LeaderboardRow): string => { @@ -35,6 +36,11 @@ const formatSuccess = (row: LeaderboardRow): string => { 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', @@ -43,15 +49,120 @@ const TIER_COLORS = [ '#d2dafe', ]; +type SortKey = 'uid' | 'crownShare' | 'success' | 'volume' | 'active'; +type SortDir = 'asc' | 'desc'; + +const SORT_LABELS: Record = { + uid: 'uid', + crownShare: 'crown share', + 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 '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 SortHeader: React.FC<{ + label: string; + sortKey: SortKey; + active: SortKey; + dir: SortDir; + onSort: (k: SortKey) => void; +}> = ({ label, sortKey, active, dir, onSort }) => { + const isActive = active === sortKey; + return ( + onSort(sortKey)} + sx={{ + cursor: 'pointer', + userSelect: 'none', + color: isActive ? 'text.primary' : undefined, + '&:hover': { color: 'text.primary' }, + }} + > + + {label} + + {isActive ? (dir === 'asc' ? '↑' : '↓') : '↕'} + + + + ); +}; + const MinerLeaderboard: React.FC<{ - activeHotkey?: string; range: Range; onRangeChange: (r: Range) => void; -}> = ({ activeHotkey, range, onRangeChange }) => { +}> = ({ range, onRangeChange }) => { const navigate = useNavigate(); const { data, isLoading } = useMinerLeaderboard(range); - const rows = data ?? []; - const topShare = rows[0]?.crownShare ?? 0; + const [sortKey, setSortKey] = useState('crownShare'); + const [sortDir, setSortDir] = useState('desc'); + const [query, setQuery] = useState(''); + + const baseRows = data ?? []; + const topShare = useMemo( + () => Math.max(0, ...baseRows.map((r) => r.crownShare)), + [baseRows], + ); + + const sortedRows = useMemo(() => { + const sign = sortDir === 'asc' ? 1 : -1; + return [...baseRows].sort((a, b) => sign * compare(a, b, sortKey)); + }, [baseRows, sortKey, sortDir]); + + 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]); + + const queryNorm = query.trim().toLowerCase(); + const matches = (row: LeaderboardRow): boolean => { + if (!queryNorm) return false; + if (String(row.uid) === queryNorm) return true; + return row.hotkey.toLowerCase().includes(queryNorm); + }; + + const onSort = (key: SortKey) => { + if (key === sortKey) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + // Smart default per column: numeric columns default desc, active flag asc + setSortDir(key === 'active' ? 'asc' : 'desc'); + } + }; const handleRowClick = (row: LeaderboardRow) => { navigate(`/miners/${row.hotkey}`); @@ -84,6 +195,7 @@ const MinerLeaderboard: React.FC<{ direction="row" justifyContent="space-between" alignItems="center" + spacing={1.5} sx={{ mb: 1.5 }} > Miner Leaderboard - - {RANGES.map((r) => ( - - ))} + fontSize: '0.7rem', + padding: '5px 9px', + }, + }} + sx={{ + width: 200, + '& .MuiOutlinedInput-root': { backgroundColor: 'surface.main' }, + '& fieldset': { borderColor: 'divider' }, + }} + /> + + {RANGES.map((r) => ( + + ))} + - # - uid + hotkey - crown share - success - volume - active + + + + - {rows.length === 0 && !isLoading && ( + {sortedRows.length === 0 && !isLoading && ( No miners registered yet )} - {rows.map((row, idx) => { - const highlight = activeHotkey === row.hotkey; + {sortedRows.map((row) => { const sharePct = topShare > 0 ? Math.round((row.crownShare / topShare) * 100) : 0; - const tierColor = - TIER_COLORS[Math.min(idx, TIER_COLORS.length - 1)]; + 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; + const matched = matches(row); return ( {wearsCrown && } - {idx + 1} {row.uid} {HOTKEY_SHORT(row.hotkey)} @@ -209,7 +367,7 @@ const MinerLeaderboard: React.FC<{ {formatSuccess(row)} - {formatVolume(row.volumeTao)} + {formatVolume(row.volumeTao)} τ = { TIMED_OUT: 'rgba(185,28,28,0.5)', }; -const fmtDate = (raw: string | null): string => { +const fmtBlock = (raw: string | null): string => { if (!raw) return '—'; - const d = new Date(raw); - return d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); + const n = parseInt(raw, 10); + if (!Number.isFinite(n)) return '—'; + return `#${n.toLocaleString()}`; }; const fmtDuration = ( @@ -80,14 +77,13 @@ const MinerSwapHistory: React.FC<{ hotkey: string }> = ({ hotkey }) => { amount dir dur - ext {rows.length === 0 && ( = ({ hotkey }) => { return ( - #{row.swapId} + + #{row.swapId} + - {fmtDate(row.initiatedAt)} + {fmtBlock(row.initiatedBlock)} = ({ hotkey }) => { - {taoAmount} TAO + {taoAmount} τ {row.sourceChain?.toUpperCase()}→ @@ -147,9 +153,6 @@ const MinerSwapHistory: React.FC<{ hotkey: string }> = ({ hotkey }) => { {fmtDuration(row.initiatedAt, row.resolvedAt)} - - {row.timeoutExtensionsUsed} - ); })} diff --git a/src/components/miners/NetworkOverviewStats.tsx b/src/components/miners/NetworkOverviewStats.tsx index 9be8b71..fc0bb2e 100644 --- a/src/components/miners/NetworkOverviewStats.tsx +++ b/src/components/miners/NetworkOverviewStats.tsx @@ -1,14 +1,101 @@ import React from 'react'; -import { Box, Typography } from '@mui/material'; -import { useNetworkOverview, type Range } from '../../api'; +import { Box, Stack, Typography } from '@mui/material'; +import { useNetworkOverview, type Range, type PairMix } from '../../api'; import { FONTS } from '../../theme'; interface Tile { label: string; - value: string; - sub: string; + body: React.ReactNode; } +const TileValue: React.FC<{ value: string; suffix?: React.ReactNode }> = ({ + value, + suffix, +}) => ( + + + {value} + + {suffix && ( + + {suffix} + + )} + +); + +const formatPair = (raw: string): string => raw.replace('-', '→'); + +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 }) => ( > {tile.label} - - {tile.value} - - - {tile.sub} - + {tile.body} ); -/** - * 4-up network stat tiles. Border lines come from each tile's right/bottom - * border (not a parent-bg-as-divider trick) so a shorter tile never reveals - * a grey strip beneath the row. - */ -const NetworkOverviewStats: React.FC<{ range?: Range }> = ({ range = '30d' }) => { +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 swaps = + data?.totalSwaps != null ? data.totalSwaps.toLocaleString() : '—'; const successPct = - data?.networkSuccessRate != null ? (data.networkSuccessRate * 100).toFixed(1) : '—'; - const activeMiners = data?.activeMiners != null ? `${data.activeMiners}` : '—'; - const registeredMiners = - data?.registeredMiners != null ? `of ${data.registeredMiners} reg` : ''; + data?.networkSuccessRate != null + ? (data.networkSuccessRate * 100).toFixed(0) + : null; + const activeMiners = + data?.activeMiners != null ? `${data.activeMiners}` : '—'; const pairMix = data?.pairMix?.slice(0, 2) ?? []; - const pairValue = pairMix.length - ? pairMix.map((p) => Math.round(p.pct)).join(' / ') - : '—'; - const pairSub = pairMix.length ? pairMix.map((p) => p.pair).join(' / ') : 'BTC→TAO / TAO→BTC'; const tiles: Tile[] = [ - { label: `Volume ${range}`, value: volume, sub: 'TAO' }, - { label: `Swaps ${range}`, value: swaps, sub: `${successPct}% success` }, - { label: 'Active miners', value: activeMiners, sub: registeredMiners }, - { label: `Pair mix ${range}`, value: pairValue, sub: pairSub }, + { + label: `Volume ${range}`, + body: ( + + τ + + } + /> + ), + }, + { + label: `Swaps ${range}`, + body: ( + + · {successPct}% success + + ) : undefined + } + /> + ), + }, + { + label: 'Active miners', + body: , + }, + { + label: `Swap directions ${range}`, + body: + pairMix.length > 0 ? ( + + ) : ( + + ), + }, ]; return ( = ({ range = '30d' }) => borderBottom: { xs: '1px solid', md: 'none' }, borderColor: 'divider', }, - // last cell in each row should not show a right border; bottom row - // shouldn't show a bottom border. - '& > *:nth-of-type(2n)': { borderRight: { sm: 'none', md: '1px solid' } }, + '& > *: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' } }, diff --git a/src/components/miners/index.ts b/src/components/miners/index.ts index 9039d07..cd9b727 100644 --- a/src/components/miners/index.ts +++ b/src/components/miners/index.ts @@ -5,5 +5,3 @@ export { default as MinerLeaderboard } from './MinerLeaderboard'; export { default as CrownHistoryGrid } from './CrownHistoryGrid'; export { default as CrownRateChart } from './CrownRateChart'; export { default as MinerSwapHistory } from './MinerSwapHistory'; -export { default as FilteredMinerSection } from './FilteredMinerSection'; -export { EarningDiagnostic, EarningNowBanner } from './EarningDiagnostic'; 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..efd00cb --- /dev/null +++ b/src/pages/MinerDetailPage.tsx @@ -0,0 +1,444 @@ +import React, { useCallback, useMemo } from 'react'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { + Link as RouterLink, + useParams, + useSearchParams, +} from 'react-router-dom'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { + CrownIcon, + CrownRateChart, + MinerSwapHistory, + Page, + SEO, + StickyNetworkHeader, +} from '../components'; +import { + useMinerLeaderboard, + useMinerStats, + type MinerStats, + type Range, +} from '../api'; +import { FONTS } from '../theme'; +import CopyableAddress from '../components/CopyableAddress'; + +const HOTKEY_SHORT = (h: string) => `${h.slice(0, 4)}…${h.slice(-4)}`; + +const RANGES: Range[] = ['24h', '7d', '30d', '90d', 'all']; + +const isRange = (v: string | null): v is Range => + ['24h', '7d', '30d', '90d', 'all'].includes(v ?? ''); + +const isRateRange = (v: string | null): v is '1h' | '4h' | '24h' | '7d' => + ['1h', '4h', '24h', '7d'].includes(v ?? ''); + +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 StatTile: React.FC<{ + label: string; + value: React.ReactNode; + sub?: React.ReactNode; +}> = ({ label, value, sub }) => ( + + + {label} + + + {value} + + {sub && ( + + {sub} + + )} + +); + +const MinerStatsStrip: React.FC<{ + stats: MinerStats | undefined; + range: Range; + onRangeChange: (r: Range) => void; +}> = ({ stats, range, onRangeChange }) => { + const volume = stats?.volumeTao + ? parseFloat(stats.volumeTao).toFixed(2) + : '—'; + const successPct = + stats && stats.totalSwaps > 0 + ? `${(stats.successRate * 100).toFixed(0)}%` + : '—'; + const crownPct = + stats != null ? `${(stats.crownShare * 100).toFixed(0)}%` : '—'; + const swaps = stats != null ? stats.totalSwaps.toLocaleString() : '—'; + const completedSub = stats + ? `${stats.completedSwaps} ok · ${stats.timedOutSwaps} out` + : undefined; + + return ( + + + + Last + + + {RANGES.map((r) => ( + + ))} + + + *': { + borderRight: '1px solid', + borderColor: 'divider', + }, + '& > *:last-of-type': { borderRight: 'none' }, + '@media (max-width: 899px)': { + '& > *:nth-of-type(3n)': { borderRight: 'none' }, + '& > *:nth-of-type(n + 4)': { + borderTop: '1px solid', + borderColor: 'divider', + }, + }, + }} + > + + + + {volume} + + τ + + + } + /> + + + + + ); +}; + +const MinerDetailPage: React.FC = () => { + const { hotkey = '' } = useParams<{ hotkey: string }>(); + const [params, setParams] = useSearchParams(); + + const range: Range = isRange(params.get('range')) + ? (params.get('range') as Range) + : '30d'; + const rateRange = isRateRange(params.get('rateRange')) + ? (params.get('rateRange') as '1h' | '4h' | '24h' | '7d') + : '4h'; + + 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], + ); + + const { data: stats } = useMinerStats(hotkey, range); + const { data: leaderboard } = useMinerLeaderboard(range); + const leaderboardRow = useMemo( + () => leaderboard?.find((r) => r.hotkey === hotkey) ?? null, + [leaderboard, hotkey], + ); + const uid = leaderboardRow?.uid ?? null; + const crownDirections = leaderboardRow?.currentCrownDirections ?? []; + + return ( + + + + + + + Miners + + + + + + + + Miner uid{' '} + + {uid ?? '?'} + + + {crownDirections.length > 0 && ( + + + {crownDirections.map((d) => d.replace('-', '→')).join(' ')} + + )} + {stats && ( + + + {stats.isActive ? 'active' : 'inactive'} + + )} + + + + + + + {stats?.collateralRao && ( + + {(Number(stats.collateralRao) / 1e9).toFixed(2)} + + τ + + + )} + {stats?.activatedAt != null && ( + + {stats.activatedAt.toLocaleString()} + + )} + + + + + setParam('range', r)} + /> + + + + setParam('rateRange', r)} + minerHotkey={hotkey} + /> + + + + + + + + ); +}; + +export default MinerDetailPage; diff --git a/src/pages/MinersPage.tsx b/src/pages/MinersPage.tsx index 28ad359..7d010bb 100644 --- a/src/pages/MinersPage.tsx +++ b/src/pages/MinersPage.tsx @@ -1,10 +1,9 @@ import React, { useCallback } from 'react'; import { Stack } from '@mui/material'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { CrownHistoryGrid, CrownRateChart, - FilteredMinerSection, MinerLeaderboard, NetworkOverviewStats, Page, @@ -19,14 +18,13 @@ const isDirection = (v: string | null): v is Direction => const isRange = (v: string | null): v is Range => ['24h', '7d', '30d', '90d', 'all'].includes(v ?? ''); -const isCrownRange = (v: string | null): v is '1h' | '4h' => - v === '1h' || v === '4h'; +const isCrownRange = (v: string | null): v is '1h' | '2h' | '4h' => + v === '1h' || v === '2h' || v === '4h'; const isRateRange = (v: string | null): v is '1h' | '4h' | '24h' | '7d' => ['1h', '4h', '24h', '7d'].includes(v ?? ''); const MinersPage: React.FC = () => { - const { hotkey } = useParams<{ hotkey?: string }>(); const [params, setParams] = useSearchParams(); const range: Range = isRange(params.get('range')) @@ -36,11 +34,11 @@ const MinersPage: React.FC = () => { ? (params.get('pair') as Direction) : 'BTC-TAO'; const crownRange = isCrownRange(params.get('crownRange')) - ? (params.get('crownRange') as '1h' | '4h') + ? (params.get('crownRange') as '1h' | '2h' | '4h') : '1h'; const rateRange = isRateRange(params.get('rateRange')) ? (params.get('rateRange') as '1h' | '4h' | '24h' | '7d') - : '1h'; + : '4h'; const pan = Number(params.get('pan') ?? '0') || 0; const setParam = useCallback( @@ -60,7 +58,7 @@ const MinersPage: React.FC = () => { { > setParam('range', r)} /> @@ -87,19 +84,9 @@ const MinersPage: React.FC = () => { onPanChange={(p) => setParam('pan', p === 0 ? undefined : String(p))} /> setParam('rateRange', r)} /> - {hotkey && ( - setParam('rateRange', r)} - range={range} - /> - )} ); diff --git a/src/routes.tsx b/src/routes.tsx index 741fb39..541b20a 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -9,6 +9,7 @@ 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( @@ -24,7 +25,11 @@ 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: 'miner-detail', + path: '/miners/:hotkey', + element: , + }, { name: 'swap', path: '/swap', element: }, { name: 'swap-detail', path: '/swap/:swapId', element: }, { From 8da98cfc4965b28a0f4957065ec4b045d7b3ccb3 Mon Sep 17 00:00:00 2001 From: Landyn Date: Wed, 13 May 2026 14:51:33 -0500 Subject: [PATCH 05/11] miner dashboard review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detail page reads uid + crownDirections from MinerStats; drops the full-leaderboard refetch - CrownHistoryGrid: fix dead-ternary useCrownHistory pan args; hover positioned against a gridRef so wrapping the grid doesn't drift it - CrownRateChart: memoize per-direction data shaping so hover-driven re-renders don't refilter the window every mouse event; cursor dot stroke now theme-aware; reduce-based head computation - theme-aware backgrounds throughout (NetworkOverviewStats track, MinerLeaderboard row tint + progress track, detail page status pill, CrownHistoryGrid active chip) — light theme was rendering invisible - keyboard handlers on sortable headers and leaderboard rows (Enter / Space activate, aria-sort on headers, aria-pressed on chips) - MinerSwapHistory: TableContainer for narrow-viewport scroll; null source/dest chain renders as "—" not "undefined→undefined" - StickyNetworkHeader guards null per-direction crown rows - drop dead localStorage.recentMiners write (no consumer) --- src/api/MinersDashboardApi.ts | 2 +- src/api/models/MinersDashboard.ts | 2 + src/components/miners/CrownHistoryGrid.tsx | 35 ++-- src/components/miners/CrownRateChart.tsx | 61 ++++--- src/components/miners/MinerLeaderboard.tsx | 42 +++-- src/components/miners/MinerSwapHistory.tsx | 164 +++++++++--------- .../miners/NetworkOverviewStats.tsx | 2 +- src/components/miners/StickyNetworkHeader.tsx | 1 + src/pages/MinerDetailPage.tsx | 33 ++-- 9 files changed, 175 insertions(+), 167 deletions(-) diff --git a/src/api/MinersDashboardApi.ts b/src/api/MinersDashboardApi.ts index e77cebe..2d777a1 100644 --- a/src/api/MinersDashboardApi.ts +++ b/src/api/MinersDashboardApi.ts @@ -86,7 +86,7 @@ export const useMinerSwaps = ( export const useMinerRateHistory = ( hotkey: string, - params: { fromBlock?: number; toBlock?: number } = {}, + params: { fromBlock?: number; toBlock?: number; blocks?: number } = {}, ) => useApiQuery( 'miner-rate-history', diff --git a/src/api/models/MinersDashboard.ts b/src/api/models/MinersDashboard.ts index 51857f8..f217629 100644 --- a/src/api/models/MinersDashboard.ts +++ b/src/api/models/MinersDashboard.ts @@ -35,6 +35,7 @@ export type LeaderboardRow = { }; export type MinerStats = { + uid: number | null; totalSwaps: number; completedSwaps: number; timedOutSwaps: number; @@ -46,6 +47,7 @@ export type MinerStats = { isActive: boolean; collateralRao: string; activatedAt: number | null; + currentCrownDirections: Direction[]; }; export type MinerRateHistoryRow = { diff --git a/src/components/miners/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx index fd05e27..234ed2f 100644 --- a/src/components/miners/CrownHistoryGrid.tsx +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { Box, Button, @@ -7,6 +7,7 @@ import { ToggleButton, ToggleButtonGroup, Typography, + alpha, useTheme, } from '@mui/material'; import { @@ -143,13 +144,10 @@ const CrownHistoryGrid: React.FC<{ x: number; y: number; } | null>(null); + const gridRef = useRef(null); const span = RANGE_BLOCKS[range]; - const { data } = useCrownHistory({ - direction, - toBlock: pan > 0 ? undefined : undefined, - fromBlock: undefined, - }); + const { data } = useCrownHistory({ direction }); const rows = useMemo(() => data ?? [], [data]); const maxBlock = useMemo( @@ -365,12 +363,14 @@ const CrownHistoryGrid: React.FC<{ /> setHover(null)} sx={{ display: 'grid', gridTemplateColumns: `72px repeat(${ROW_BLOCKS}, 1fr)`, gridAutoRows: `${CELL_PX}px`, gap: '2px', + position: 'relative', }} > {Array.from({ length: rowsCount }).map((_, r) => { @@ -416,13 +416,11 @@ const CrownHistoryGrid: React.FC<{ const rect = ( e.currentTarget as HTMLElement ).getBoundingClientRect(); - const parent = ( - e.currentTarget as HTMLElement - ).parentElement?.parentElement?.getBoundingClientRect(); + const gridRect = gridRef.current?.getBoundingClientRect(); setHover({ cell, - x: rect.left + rect.width / 2 - (parent?.left ?? 0), - y: rect.top - (parent?.top ?? 0), + x: rect.left + rect.width / 2 - (gridRect?.left ?? 0), + y: rect.top - (gridRect?.top ?? 0), }); }} sx={{ @@ -462,10 +460,9 @@ const CrownHistoryGrid: React.FC<{ ); })} + {hover && } - {hover && } - {tierLegend.length > 0 && ( toggleLegendUid(t.uid)} + aria-pressed={interactive ? active : undefined} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, px: 1, py: 0.4, - cursor: t.uid != null ? 'pointer' : 'default', + cursor: interactive ? 'pointer' : 'default', border: '1px solid', borderColor: active ? 'primary.main' : 'divider', backgroundColor: active - ? 'rgba(0,82,255,0.10)' + ? 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': - t.uid != null ? { borderColor: 'text.primary' } : undefined, + '&:hover': interactive + ? { borderColor: 'text.primary' } + : undefined, }} > = ({ cy={mapY(hover.rate)} r="3.5" fill={meta.color} - stroke="#fff" + stroke={isDark ? '#fff' : '#000'} strokeWidth="1" /> @@ -497,42 +497,39 @@ const CrownRateChart: React.FC<{ direction: 'TAO-BTC', blocks, }); - const { data: minerRates } = useMinerRateHistory(minerHotkey ?? '', {}); + const { data: minerRates } = useMinerRateHistory(minerHotkey ?? ''); + // Use reduce instead of `Math.max(...arr)` to avoid spreading large arrays. const head = useMemo(() => { - const heads = [ - btcTao?.length ? Math.max(...btcTao.map((p) => p.block)) : 0, - taoBtc?.length ? Math.max(...taoBtc.map((p) => p.block)) : 0, - ]; - return Math.max(...heads, 0); + 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); - const filterRange = (arr: T[] | undefined) => - (arr ?? []).filter((p) => p.block >= lo && p.block <= head); - - const minerFor = (direction: Direction): MinerRateHistoryRow[] => { - if (!minerHotkey) return []; - const from = direction === 'BTC-TAO' ? 'btc' : 'tao'; - const to = direction === 'BTC-TAO' ? 'tao' : 'btc'; - return filterRange(minerRates ?? []).filter( - (r) => r.fromChain === from && r.toChain === to, - ); - }; - - const stripFields = (rows: CrownRateHistoryRow[]): RateRow[] => - rows.map((r) => ({ block: r.block, rate: r.rate })); - - const btcTaoCrown = stripFields(filterRange(btcTao)); - const taoBtcCrown = stripFields(filterRange(taoBtc)); - const btcTaoMiner: RateRow[] = minerFor('BTC-TAO').map((r) => ({ - block: r.block, - rate: r.rate, - })); - const taoBtcMiner: RateRow[] = minerFor('TAO-BTC').map((r) => ({ - block: r.block, - rate: r.rate, - })); + // 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); diff --git a/src/components/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx index 39b496d..c220726 100644 --- a/src/components/miners/MinerLeaderboard.tsx +++ b/src/components/miners/MinerLeaderboard.tsx @@ -10,6 +10,8 @@ import { TableRow, TextField, Typography, + alpha, + useTheme, } from '@mui/material'; import { useNavigate } from 'react-router-dom'; import { @@ -90,6 +92,17 @@ const SortHeader: React.FC<{ 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', @@ -120,6 +133,7 @@ const MinerLeaderboard: React.FC<{ 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'); @@ -166,19 +180,6 @@ const MinerLeaderboard: React.FC<{ const handleRowClick = (row: LeaderboardRow) => { navigate(`/miners/${row.hotkey}`); - try { - const RAW = localStorage.getItem('allways.recentMiners'); - const parsed: { uid: number; hotkey: string; viewedAt: number }[] = RAW - ? JSON.parse(RAW) - : []; - const next = [ - { uid: row.uid, hotkey: row.hotkey, viewedAt: Date.now() }, - ...parsed.filter((m) => m.hotkey !== row.hotkey), - ].slice(0, 5); - localStorage.setItem('allways.recentMiners', JSON.stringify(next)); - } catch { - /* ignore — storage disabled */ - } }; return ( @@ -318,15 +319,26 @@ const MinerLeaderboard: React.FC<{ handleRowClick(row)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRowClick(row); + } + }} + tabIndex={0} hover sx={{ cursor: 'pointer', backgroundColor: matched - ? 'rgba(0,82,255,0.08)' + ? alpha(theme.palette.primary.main, 0.08) : 'transparent', borderLeft: matched ? '2px solid' : '2px solid transparent', borderLeftColor: matched ? 'primary.main' : 'transparent', '&:hover td': { backgroundColor: 'surface.elevated' }, + '&:focus-visible': { + outline: `2px solid ${theme.palette.primary.main}`, + outlineOffset: -2, + }, }} > = ({ hotkey }) => { > Swap History -
- - - swap - initiated - status - amount - dir - dur - - - - {rows.length === 0 && ( + +
+ - - - No swaps yet — post a competitive rate to attract them - - + swap + initiated + status + amount + dir + dur - )} - {rows.map((row) => { - const taoAmount = row.taoAmount - ? parseFloat(row.taoAmount).toFixed(4) - : '—'; - return ( - - - - #{row.swapId} - - - - {fmtBlock(row.initiatedBlock)} - - + + + {rows.length === 0 && ( + + - {row.status.replace('_', ' ').toLowerCase()} + No swaps yet — post a competitive rate to attract them - - {taoAmount} τ - - - {row.sourceChain?.toUpperCase()}→ - {row.destChain?.toUpperCase()} - - - {fmtDuration(row.initiatedAt, row.resolvedAt)} - - ); - })} - -
+ )} + {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)} + + + ); + })} + + +
); }; diff --git a/src/components/miners/NetworkOverviewStats.tsx b/src/components/miners/NetworkOverviewStats.tsx index fc0bb2e..3f09134 100644 --- a/src/components/miners/NetworkOverviewStats.tsx +++ b/src/components/miners/NetworkOverviewStats.tsx @@ -66,7 +66,7 @@ const DirectionBars: React.FC<{ segments: PairMix[] }> = ({ segments }) => { sx={{ flex: 1, height: 6, - backgroundColor: 'rgba(255,255,255,0.06)', + backgroundColor: 'action.hover', position: 'relative', overflow: 'hidden', }} diff --git a/src/components/miners/StickyNetworkHeader.tsx b/src/components/miners/StickyNetworkHeader.tsx index 21e19e9..88cbf54 100644 --- a/src/components/miners/StickyNetworkHeader.tsx +++ b/src/components/miners/StickyNetworkHeader.tsx @@ -13,6 +13,7 @@ const StickyNetworkHeader: React.FC = () => { 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( { [params, setParams], ); + const theme = useTheme(); const { data: stats } = useMinerStats(hotkey, range); - const { data: leaderboard } = useMinerLeaderboard(range); - const leaderboardRow = useMemo( - () => leaderboard?.find((r) => r.hotkey === hotkey) ?? null, - [leaderboard, hotkey], - ); - const uid = leaderboardRow?.uid ?? null; - const crownDirections = leaderboardRow?.currentCrownDirections ?? []; + const uid = stats?.uid ?? null; + const crownDirections = stats?.currentCrownDirections ?? []; return ( @@ -336,15 +327,15 @@ const MinerDetailPage: React.FC = () => { px: 1, py: 0.4, border: '1px solid', - borderColor: 'rgba(21,128,61,0.4)', - backgroundColor: 'rgba(21,128,61,0.08)', + borderColor: alpha(theme.palette.success.main, 0.4), + backgroundColor: alpha(theme.palette.success.main, 0.08), fontFamily: FONTS.mono, fontSize: '0.7rem', color: 'success.main', letterSpacing: '0.05em', }} > - + {crownDirections.map((d) => d.replace('-', '→')).join(' ')} )} @@ -361,11 +352,11 @@ const MinerDetailPage: React.FC = () => { letterSpacing: '0.05em', color: stats.isActive ? 'status.active' : 'text.disabled', backgroundColor: stats.isActive - ? 'rgba(0,82,255,0.08)' - : 'rgba(255,255,255,0.04)', + ? alpha(theme.palette.primary.main, 0.08) + : 'action.hover', border: '1px solid', borderColor: stats.isActive - ? 'rgba(0,82,255,0.35)' + ? alpha(theme.palette.primary.main, 0.35) : 'divider', }} > From e4db730b9e1bb9b1f813d99f57ed4ef1cdb20f9a Mon Sep 17 00:00:00 2001 From: Landyn Date: Sat, 16 May 2026 15:20:25 -0500 Subject: [PATCH 06/11] polish: CrownHistoryPanel composite + custom-range inputs + dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New CrownHistoryPanel wraps the crown grid + windowed score factors in one bordered card. Single header + formula; banner + 0.4 dim when the current window's crown share is 0. - CrownHistoryGrid: split label column from cell grid so labels stay sharp under blur; new customFrom/customTo + onCustomRangeChange props with Enter-to-apply inputs (cap = SCORING_WINDOW_BLOCKS); "← earlier" disabled when lo === 0 with "no earlier data" hint. - Leaderboard: replace capacity column with collateral (formatTao τ); search now filters rows and exact-matches numeric uid; "x of y shown" chip. - StickyNetworkHeader: rate.toFixed(2) τ. NetworkOverviewStats: use replaceAll for direction arrows. MinerDetailPage: drop "then Xm to complete" sub; collapse setParam + setParamsBatch into updateParams. - MinersPage default crown range → 2h (was 1h) to match detail page. - Dedup: shortHotkey hoisted to utils/format; ScoreFactorsStrip's dead non-embedded branch dropped now that only the panel renders it; CrownHistoryPanel emits one header instead of two; MAX_CUSTOM_SPAN reuses SCORING_WINDOW_BLOCKS; customInputError moved to useMemo. --- src/api/MinersDashboardApi.ts | 15 + src/api/models/MinersDashboard.ts | 24 + src/components/miners/CrownHistoryGrid.tsx | 652 +++++++++++++----- src/components/miners/CrownHistoryPanel.tsx | 144 ++++ src/components/miners/MinerLeaderboard.tsx | 79 ++- .../miners/NetworkOverviewStats.tsx | 2 +- src/components/miners/ScoreFactorsStrip.tsx | 347 ++++++++++ src/components/miners/StickyNetworkHeader.tsx | 2 +- src/components/miners/index.ts | 2 + src/pages/MinerDetailPage.tsx | 347 ++++++---- src/pages/MinersPage.tsx | 2 +- src/utils/format.ts | 4 + 12 files changed, 1285 insertions(+), 335 deletions(-) create mode 100644 src/components/miners/CrownHistoryPanel.tsx create mode 100644 src/components/miners/ScoreFactorsStrip.tsx diff --git a/src/api/MinersDashboardApi.ts b/src/api/MinersDashboardApi.ts index 2d777a1..c4f7460 100644 --- a/src/api/MinersDashboardApi.ts +++ b/src/api/MinersDashboardApi.ts @@ -12,6 +12,7 @@ import type { MinerStats, NetworkOverview, Range, + ScoreFactors, } from './models'; const CROWN_REFRESH_MS = 12_000; @@ -72,6 +73,20 @@ export const useMinerStats = (hotkey: string, range: Range = '30d') => !!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 } = {}, diff --git a/src/api/models/MinersDashboard.ts b/src/api/models/MinersDashboard.ts index f217629..aa653bf 100644 --- a/src/api/models/MinersDashboard.ts +++ b/src/api/models/MinersDashboard.ts @@ -30,10 +30,31 @@ export type LeaderboardRow = { 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; @@ -48,6 +69,7 @@ export type MinerStats = { collateralRao: string; activatedAt: number | null; currentCrownDirections: Direction[]; + scoreFactors: ScoreFactors; }; export type MinerRateHistoryRow = { @@ -65,6 +87,8 @@ export type NetworkOverview = { networkSuccessRate: number; activeMiners: number; pairMix: PairMix[]; + scoringWindowVolumeTao: string; + maxSwapAmountRao: string; }; export type HaltState = { halted: boolean; asOfBlock: number }; diff --git a/src/components/miners/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx index 234ed2f..75a3546 100644 --- a/src/components/miners/CrownHistoryGrid.tsx +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Box, Button, @@ -18,18 +18,17 @@ import { import { FONTS } from '../../theme'; import CrownIcon from './CrownIcon'; +// 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': 600, - '4h': 1200, + '2h': SCORING_WINDOW_BLOCKS, + '4h': 2 * SCORING_WINDOW_BLOCKS, }; -// Subtensor scoring cadence in blocks. The validator sets weights once per -// SCORING_WINDOW, so the 2h grid snaps to multiples of this value to show -// "the actual chunk the validator scored on" rather than a rolling trail. -// Mirrors SCORING_WINDOW_BLOCKS in allways/constants.py. -const SCORING_WINDOW_BLOCKS = 600; const TIER_PALETTE = ['#0052ff', '#4d7dff', '#7f9eff', '#aebeff', '#d2dafe']; type CrownRange = '1h' | '2h' | '4h'; @@ -51,6 +50,8 @@ const buildCells = ( maxBlock: number, tiers: Map, otherColor: string, + subjectUid: number | null = null, + subjectColor: string | null = null, ): CellState[] => { const byBlock = new Map(); for (const row of rows) { @@ -62,6 +63,19 @@ const buildCells = ( 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, @@ -116,6 +130,19 @@ const CrownHistoryGrid: React.FC<{ 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, @@ -123,6 +150,12 @@ const CrownHistoryGrid: React.FC<{ onRangeChange, pan, onPanChange, + lockedUid = null, + customFrom = null, + customTo = null, + onCustomRangeChange, + embedded = false, + onWindowChange, }) => { const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; @@ -134,6 +167,47 @@ const CrownHistoryGrid: React.FC<{ ? '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; + // Local "draft" state for the from/to inputs; syncs back when the URL- + // driven prop changes (e.g. browser back-button). + const [customFromInput, setCustomFromInput] = useState( + customFrom != null ? String(customFrom) : '', + ); + const [customToInput, setCustomToInput] = useState( + customTo != null ? String(customTo) : '', + ); + useEffect(() => { + setCustomFromInput(customFrom != null ? String(customFrom) : ''); + }, [customFrom]); + useEffect(() => { + setCustomToInput(customTo != null ? String(customTo) : ''); + }, [customTo]); + const customInputError = useMemo(() => { + if (!customFromInput && !customToInput) return null; + if (!customFromInput || !customToInput) return 'set both ends'; + const f = Number(customFromInput); + const t = Number(customToInput); + 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 > SCORING_WINDOW_BLOCKS) + return `range > ${SCORING_WINDOW_BLOCKS} blocks`; + return null; + }, [customFromInput, customToInput]); + const submitCustomRange = () => { + if (customInputError || !customFromInput || !customToInput) return; + onCustomRangeChange?.(Number(customFromInput), Number(customToInput)); + }; + const clearCustomRange = () => { + setCustomFromInput(''); + setCustomToInput(''); + onCustomRangeChange?.(null, null); + }; 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 (×) @@ -156,7 +230,10 @@ const CrownHistoryGrid: React.FC<{ ); let hi: number; let lo: number; - if (range === '2h') { + if (customActive) { + lo = customFrom as number; + hi = customTo as number; + } else if (range === '2h') { const anchor = Math.floor(maxBlock / SCORING_WINDOW_BLOCKS) * SCORING_WINDOW_BLOCKS; const windowsBack = Math.floor(pan / SCORING_WINDOW_BLOCKS); @@ -166,20 +243,53 @@ const CrownHistoryGrid: React.FC<{ hi = maxBlock - pan; lo = Math.max(0, hi - span + 1); } + const atEarliest = lo <= 0; + useEffect(() => { + if (maxBlock > 0) onWindowChange?.(lo, hi); + }, [lo, hi, maxBlock, onWindowChange]); + const isLocked = lockedUid != null; + const subjectColor = theme.palette.primary.main; const { color: tierColors, ordered: tierLegend } = useMemo( - () => buildTiers(rows, lo, hi), - [rows, lo, hi], + () => + isLocked ? { color: new Map(), ordered: [] } : buildTiers(rows, lo, hi), + [rows, lo, hi, isLocked], ); const cells = useMemo( - () => buildCells(rows, lo, hi, maxBlock, tierColors, otherColor), - [rows, lo, hi, maxBlock, tierColors, otherColor], + () => + buildCells( + rows, + lo, + hi, + maxBlock, + tierColors, + otherColor, + isLocked ? lockedUid : null, + isLocked ? subjectColor : null, + ), + [ + rows, + lo, + hi, + maxBlock, + tierColors, + otherColor, + isLocked, + lockedUid, + subjectColor, + ], ); const rowsCount = Math.ceil(cells.length / ROW_BLOCKS); - const search = uidSearch.replace(/[^0-9]/g, ''); + 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) return; + if (uid == null || isLocked) return; const next = String(uid); if (chipFilter === next) { setChipFilter(null); @@ -204,40 +314,42 @@ const CrownHistoryGrid: React.FC<{ - - - Crown History - - - per block · who held the best rate - - + {!embedded && ( + + + Crown History + + + per block · who held the best rate + + + )} onPanChange(pan + (range === '2h' ? SCORING_WINDOW_BLOCKS : span)) } @@ -308,7 +421,15 @@ const CrownHistoryGrid: React.FC<{ > ← earlier - {pan > 0 && ( + {!customActive && atEarliest && ( + + no earlier data + + )} + {!customActive && pan > 0 && ( + )} + {customInputError ? ( + + {customInputError} + + ) : ( + (customFromInput || customToInput) && + !customActive && ( + - #{rowStart.toLocaleString()} - - {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 && } + press enter to apply +
+ ) + )} + + )} + + 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 + + + )} - {tierLegend.length > 0 && ( + {!isLocked && tierLegend.length > 0 && ( { const active = search === String(t.uid); const showClear = - chipFilter !== null && chipFilter === String(t.uid); - const interactive = t.uid != null; + !isLocked && chipFilter !== null && chipFilter === String(t.uid); + const interactive = !isLocked && t.uid != null; return ( )} - {cells.length > 0 && cells.every((c) => c.holderHotkey === null) && ( - - no rate activity in this window - - )} + {!isLocked && + cells.length > 0 && + cells.every((c) => c.holderHotkey === null) && ( + + no rate activity in this window + + )} 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/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx index c220726..620dacc 100644 --- a/src/components/miners/MinerLeaderboard.tsx +++ b/src/components/miners/MinerLeaderboard.tsx @@ -10,7 +10,6 @@ import { TableRow, TextField, Typography, - alpha, useTheme, } from '@mui/material'; import { useNavigate } from 'react-router-dom'; @@ -21,11 +20,10 @@ import { } from '../../api'; import CrownIcon from './CrownIcon'; import { FONTS } from '../../theme'; +import { formatTao, shortHotkey } from '../../utils/format'; const RANGES: Range[] = ['24h', '7d', '30d', '90d', 'all']; -const HOTKEY_SHORT = (h: string) => `${h.slice(0, 4)}…${h.slice(-4)}`; - const formatVolume = (raw: string): string => { const v = parseFloat(raw); if (!Number.isFinite(v) || v === 0) return '0.00'; @@ -51,12 +49,19 @@ const TIER_COLORS = [ '#d2dafe', ]; -type SortKey = 'uid' | 'crownShare' | 'success' | 'volume' | 'active'; +type SortKey = + | 'uid' + | 'crownShare' + | 'collateral' + | 'success' + | 'volume' + | 'active'; type SortDir = 'asc' | 'desc'; const SORT_LABELS: Record = { uid: 'uid', crownShare: 'crown share', + collateral: 'collateral', success: 'success', volume: 'volume', active: 'active', @@ -72,6 +77,8 @@ const compare = ( 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': @@ -145,11 +152,6 @@ const MinerLeaderboard: React.FC<{ [baseRows], ); - const sortedRows = useMemo(() => { - const sign = sortDir === 'asc' ? 1 : -1; - return [...baseRows].sort((a, b) => sign * compare(a, b, sortKey)); - }, [baseRows, sortKey, sortDir]); - 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. @@ -161,19 +163,31 @@ const MinerLeaderboard: React.FC<{ return map; }, [baseRows]); - const queryNorm = query.trim().toLowerCase(); - const matches = (row: LeaderboardRow): boolean => { - if (!queryNorm) return false; - if (String(row.uid) === queryNorm) return true; - return row.hotkey.toLowerCase().includes(queryNorm); - }; + // 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); - // Smart default per column: numeric columns default desc, active flag asc setSortDir(key === 'active' ? 'asc' : 'desc'); } }; @@ -228,6 +242,19 @@ const MinerLeaderboard: React.FC<{ '& fieldset': { borderColor: 'divider' }, }} /> + {queryNorm && ( + + {filteredRows.length} of {baseRows.length} shown + + )} {RANGES.map((r) => ( + ))} + +); + +const PerformanceGrid: React.FC<{ stats: MinerStats | undefined }> = ({ + stats, +}) => { const volume = stats?.volumeTao ? parseFloat(stats.volumeTao).toFixed(2) : '—'; @@ -120,113 +163,44 @@ const MinerStatsStrip: React.FC<{ stats && stats.totalSwaps > 0 ? `${(stats.successRate * 100).toFixed(0)}%` : '—'; - const crownPct = - stats != null ? `${(stats.crownShare * 100).toFixed(0)}%` : '—'; const swaps = stats != null ? stats.totalSwaps.toLocaleString() : '—'; const completedSub = stats - ? `${stats.completedSwaps} ok · ${stats.timedOutSwaps} out` + ? `${stats.completedSwaps} ok · ${stats.timedOutSwaps} failed` : undefined; return ( - - - Last - - - {RANGES.map((r) => ( - - ))} - - - *': { - borderRight: '1px solid', - borderColor: 'divider', - }, - '& > *:last-of-type': { borderRight: 'none' }, - '@media (max-width: 899px)': { - '& > *:nth-of-type(3n)': { borderRight: 'none' }, - '& > *:nth-of-type(n + 4)': { - borderTop: '1px solid', - borderColor: 'divider', - }, - }, - }} - > - - - - {volume} - - τ - - - } - /> - - - + τ + + + } + /> + ); }; @@ -241,27 +215,62 @@ const MinerDetailPage: React.FC = () => { const rateRange = isRateRange(params.get('rateRange')) ? (params.get('rateRange') as '1h' | '4h' | '24h' | '7d') : '4h'; + const crownDirection: Direction = isDirection(params.get('crownDir')) + ? (params.get('crownDir') as Direction) + : 'BTC-TAO'; + const crownGridRange: '1h' | '2h' | '4h' = isCrownRange( + params.get('crownGridRange'), + ) + ? (params.get('crownGridRange') as '1h' | '2h' | '4h') + : '2h'; + const crownGridPan = parseInt(params.get('crownPan') ?? '600', 10) || 0; + 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; + }; + const crownFrom = parseBlockParam(params.get('crownFrom')); + const crownTo = parseBlockParam(params.get('crownTo')); - const setParam = useCallback( - (key: string, value: string | undefined) => { + const updateParams = useCallback( + (updates: Record) => { const next = new URLSearchParams(params); - if (value === undefined || value === '') next.delete(key); - else next.set(key, value); + 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 theme = useTheme(); const { data: stats } = useMinerStats(hotkey, range); + const { data: miners } = useMiners(); + const liveMiner = miners?.find((m) => m.hotkey === hotkey) ?? null; const uid = stats?.uid ?? null; const crownDirections = stats?.currentCrownDirections ?? []; + // The on-chain commitment is canonicalized so TAO is always destChain (see + // Miners.ts). `rate` is source→dest (BTC→TAO when sourceChain='btc'), + // `counterRate` is dest→source (TAO→BTC). + 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 ( { {stats?.collateralRao && ( - {(Number(stats.collateralRao) / 1e9).toFixed(2)} + {formatTao(stats.collateralRao)} { {stats.activatedAt.toLocaleString()} )} + {fwdRate > 0 && fwdLabel && ( + + {fwdRate.toFixed(2)} + + τ + + + )} + {revRate > 0 && revLabel && ( + + {revRate.toFixed(2)} + + τ + + + )} + + + + + + Performance · last {range} + + setParam('range', r)} + /> + + - 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), + }) + } + /> + )} diff --git a/src/pages/MinersPage.tsx b/src/pages/MinersPage.tsx index 7d010bb..b51e0bd 100644 --- a/src/pages/MinersPage.tsx +++ b/src/pages/MinersPage.tsx @@ -35,7 +35,7 @@ const MinersPage: React.FC = () => { : 'BTC-TAO'; const crownRange = isCrownRange(params.get('crownRange')) ? (params.get('crownRange') as '1h' | '2h' | '4h') - : '1h'; + : '2h'; const rateRange = isRateRange(params.get('rateRange')) ? (params.get('rateRange') as '1h' | '4h' | '24h' | '7d') : '4h'; 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); From e214b3fd7bf4c1fb9a3aa38f3398f028a9cba63f Mon Sep 17 00:00:00 2001 From: Landyn Date: Sat, 16 May 2026 15:38:56 -0500 Subject: [PATCH 07/11] refactor: split CrownHistoryGrid + MinerDetailHeader + share guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical extractions to shrink the two largest files in the dashboard without touching behavior: - New api/models/searchParams.ts owns isRange/isDirection/isCrownRange/ isRateRange/parseBlockParam + CrownRange/RateRange types. MinersPage and MinerDetailPage both import instead of redefining identical guards. - New components/miners/MinerDetailHeader.tsx owns the per-miner header card (HeaderField, PerformanceMetric, PerformanceGrid, RangeChips + fmtDuration + the actual card markup). MinerDetailPage drops from 516 → 156 LOC. - New components/miners/SortHeader.tsx is a generic sortable-column header (typed over the consumer's SortKey union). MinerLeaderboard imports it. - CrownHistoryGrid (1054 → 716 LOC) split into siblings: - crownGridCells.ts: TIER_PALETTE, CellState, buildCells, buildTiers - CrownGridHoverCard.tsx: hover tooltip + HoverLine - CrownGridRangeInputs.tsx: from/to inputs + validation + draft state --- src/api/models/index.ts | 1 + src/api/models/searchParams.ts | 28 ++ src/components/miners/CrownGridHoverCard.tsx | 140 ++++++ .../miners/CrownGridRangeInputs.tsx | 134 ++++++ src/components/miners/CrownHistoryGrid.tsx | 362 +-------------- src/components/miners/MinerDetailHeader.tsx | 367 +++++++++++++++ src/components/miners/MinerLeaderboard.tsx | 49 +-- src/components/miners/SortHeader.tsx | 63 +++ src/components/miners/crownGridCells.ts | 106 +++++ src/components/miners/index.ts | 1 + src/pages/MinerDetailPage.tsx | 416 ++---------------- src/pages/MinersPage.tsx | 42 +- 12 files changed, 900 insertions(+), 809 deletions(-) create mode 100644 src/api/models/searchParams.ts create mode 100644 src/components/miners/CrownGridHoverCard.tsx create mode 100644 src/components/miners/CrownGridRangeInputs.tsx create mode 100644 src/components/miners/MinerDetailHeader.tsx create mode 100644 src/components/miners/SortHeader.tsx create mode 100644 src/components/miners/crownGridCells.ts diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 7c2b4fd..8fa6fb6 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,6 +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/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 index 75a3546..9f69527 100644 --- a/src/components/miners/CrownHistoryGrid.tsx +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -10,13 +10,11 @@ import { alpha, useTheme, } from '@mui/material'; -import { - useCrownHistory, - type CrownHistoryRow, - type Direction, -} from '../../api'; +import { useCrownHistory, type Direction } from '../../api'; import { FONTS } from '../../theme'; -import CrownIcon from './CrownIcon'; +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 @@ -29,100 +27,9 @@ const RANGE_BLOCKS: Record = { '2h': SCORING_WINDOW_BLOCKS, '4h': 2 * SCORING_WINDOW_BLOCKS, }; -const TIER_PALETTE = ['#0052ff', '#4d7dff', '#7f9eff', '#aebeff', '#d2dafe']; type CrownRange = '1h' | '2h' | '4h'; -type CellState = { - block: number; - holderHotkey: string | null; - holderUid: number | null; - rate: number; - isTie: boolean; - isCurrent: boolean; - color: string | null; -}; - -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; -}; - -const buildTiers = ( - rows: CrownHistoryRow[], - lo: number, - hi: number, -): { - color: Map; - ordered: { - hotkey: string; - uid: number | null; - count: number; - color: string; - }[]; -} => { - 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 }; -}; - const CrownHistoryGrid: React.FC<{ direction: Direction; onDirectionChange: (d: Direction) => void; @@ -173,41 +80,6 @@ const CrownHistoryGrid: React.FC<{ customFrom >= 0 && customTo > customFrom && customTo - customFrom <= SCORING_WINDOW_BLOCKS; - // Local "draft" state for the from/to inputs; syncs back when the URL- - // driven prop changes (e.g. browser back-button). - const [customFromInput, setCustomFromInput] = useState( - customFrom != null ? String(customFrom) : '', - ); - const [customToInput, setCustomToInput] = useState( - customTo != null ? String(customTo) : '', - ); - useEffect(() => { - setCustomFromInput(customFrom != null ? String(customFrom) : ''); - }, [customFrom]); - useEffect(() => { - setCustomToInput(customTo != null ? String(customTo) : ''); - }, [customTo]); - const customInputError = useMemo(() => { - if (!customFromInput && !customToInput) return null; - if (!customFromInput || !customToInput) return 'set both ends'; - const f = Number(customFromInput); - const t = Number(customToInput); - 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 > SCORING_WINDOW_BLOCKS) - return `range > ${SCORING_WINDOW_BLOCKS} blocks`; - return null; - }, [customFromInput, customToInput]); - const submitCustomRange = () => { - if (customInputError || !customFromInput || !customToInput) return; - onCustomRangeChange?.(Number(customFromInput), Number(customToInput)); - }; - const clearCustomRange = () => { - setCustomFromInput(''); - setCustomToInput(''); - onCustomRangeChange?.(null, null); - }; 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 (×) @@ -507,92 +379,13 @@ const CrownHistoryGrid: React.FC<{ )} {onCustomRangeChange && ( - - - range - - - setCustomFromInput(e.target.value.replace(/[^0-9]/g, '')) - } - onKeyDown={(e) => { - if (e.key === 'Enter') submitCustomRange(); - }} - inputProps={{ - style: { - fontFamily: FONTS.mono, - fontSize: '0.7rem', - padding: '5px 9px', - }, - }} - sx={{ width: 110 }} - /> - - → - - - setCustomToInput(e.target.value.replace(/[^0-9]/g, '')) - } - onKeyDown={(e) => { - if (e.key === 'Enter') submitCustomRange(); - }} - inputProps={{ - style: { - fontFamily: FONTS.mono, - fontSize: '0.7rem', - padding: '5px 9px', - }, - }} - sx={{ width: 110 }} - /> - {customActive && ( - - )} - {customInputError ? ( - - {customInputError} - - ) : ( - (customFromInput || customToInput) && - !customActive && ( - - press enter to apply - - ) - )} - + )} - {hover && } + {hover && } {subjectAbsent && ( = ({ 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 && ( - - )} - - - ); -}; - -const HoverLine: React.FC<{ - label: string; - value: React.ReactNode; - valueColor?: string; -}> = ({ label, value, valueColor }) => ( - - - {label} - - - {value} - - -); - export default CrownHistoryGrid; 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 index 620dacc..c0c7d08 100644 --- a/src/components/miners/MinerLeaderboard.tsx +++ b/src/components/miners/MinerLeaderboard.tsx @@ -19,6 +19,7 @@ import { type Range, } from '../../api'; import CrownIcon from './CrownIcon'; +import SortHeader, { type SortDir } from './SortHeader'; import { FONTS } from '../../theme'; import { formatTao, shortHotkey } from '../../utils/format'; @@ -56,7 +57,6 @@ type SortKey = | 'success' | 'volume' | 'active'; -type SortDir = 'asc' | 'desc'; const SORT_LABELS: Record = { uid: 'uid', @@ -88,53 +88,6 @@ const compare = ( } }; -const SortHeader: React.FC<{ - label: string; - sortKey: SortKey; - active: SortKey; - dir: SortDir; - onSort: (k: SortKey) => void; -}> = ({ label, sortKey, active, dir, onSort }) => { - 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' ? '↑' : '↓') : '↕'} - - - - ); -}; - const MinerLeaderboard: React.FC<{ range: Range; onRangeChange: (r: Range) => void; 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/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 index e5658ee..d5dde86 100644 --- a/src/components/miners/index.ts +++ b/src/components/miners/index.ts @@ -4,6 +4,7 @@ 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/pages/MinerDetailPage.tsx b/src/pages/MinerDetailPage.tsx index b4c59c4..73ffdaf 100644 --- a/src/pages/MinerDetailPage.tsx +++ b/src/pages/MinerDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Box, Button, Stack, Typography, alpha, useTheme } from '@mui/material'; +import { Box, Stack, Typography } from '@mui/material'; import { Link as RouterLink, useParams, @@ -8,8 +8,8 @@ import { import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { CrownHistoryPanel, - CrownIcon, CrownRateChart, + MinerDetailHeader, MinerSwapHistory, Page, SEO, @@ -18,217 +18,35 @@ import { import { useMinerStats, useMiners, - type Direction, - type MinerStats, + isCrownRange, + isDirection, + isRange, + isRateRange, + parseBlockParam, + type CrownRange, type Range, + type RateRange, } from '../api'; import { FONTS } from '../theme'; -import { formatTao, shortHotkey } from '../utils/format'; -import CopyableAddress from '../components/CopyableAddress'; - -const RANGES: Range[] = ['24h', '7d', '30d', '90d', 'all']; - -const isRange = (v: string | null): v is Range => - ['24h', '7d', '30d', '90d', 'all'].includes(v ?? ''); - -const isRateRange = (v: string | null): v is '1h' | '4h' | '24h' | '7d' => - ['1h', '4h', '24h', '7d'].includes(v ?? ''); - -const isDirection = (v: string | null): v is Direction => - v === 'BTC-TAO' || v === 'TAO-BTC'; - -const isCrownRange = (v: string | null): v is '1h' | '2h' | '4h' => - v === '1h' || v === '2h' || v === '4h'; - -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} - - τ - - - } - /> - - - ); -}; +import { shortHotkey } from '../utils/format'; const MinerDetailPage: React.FC = () => { const { hotkey = '' } = useParams<{ hotkey: string }>(); const [params, setParams] = useSearchParams(); - const range: Range = isRange(params.get('range')) - ? (params.get('range') as Range) - : '30d'; - const rateRange = isRateRange(params.get('rateRange')) - ? (params.get('rateRange') as '1h' | '4h' | '24h' | '7d') + 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 crownDirection: Direction = isDirection(params.get('crownDir')) - ? (params.get('crownDir') as Direction) - : 'BTC-TAO'; - const crownGridRange: '1h' | '2h' | '4h' = isCrownRange( - params.get('crownGridRange'), - ) - ? (params.get('crownGridRange') as '1h' | '2h' | '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 parseBlockParam = (v: string | null): number | null => { - if (v == null || v === '') return null; - const n = Number(v); - return Number.isInteger(n) && n >= 0 ? n : null; - }; const crownFrom = parseBlockParam(params.get('crownFrom')); const crownTo = parseBlockParam(params.get('crownTo')); @@ -246,25 +64,10 @@ const MinerDetailPage: React.FC = () => { const setParam = (key: string, value: string | undefined) => updateParams({ [key]: value }); - const theme = useTheme(); const { data: stats } = useMinerStats(hotkey, range); const { data: miners } = useMiners(); const liveMiner = miners?.find((m) => m.hotkey === hotkey) ?? null; const uid = stats?.uid ?? null; - const crownDirections = stats?.currentCrownDirections ?? []; - // The on-chain commitment is canonicalized so TAO is always destChain (see - // Miners.ts). `rate` is source→dest (BTC→TAO when sourceChain='btc'), - // `counterRate` is dest→source (TAO→BTC). - 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 ( @@ -301,177 +104,14 @@ const MinerDetailPage: React.FC = () => {
- - - - - 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} - - setParam('range', r)} - /> - - - - - + setParam('range', r)} + /> {uid != null && ( - v === 'BTC-TAO' || v === 'TAO-BTC'; - -const isRange = (v: string | null): v is Range => - ['24h', '7d', '30d', '90d', 'all'].includes(v ?? ''); - -const isCrownRange = (v: string | null): v is '1h' | '2h' | '4h' => - v === '1h' || v === '2h' || v === '4h'; - -const isRateRange = (v: string | null): v is '1h' | '4h' | '24h' | '7d' => - ['1h', '4h', '24h', '7d'].includes(v ?? ''); +import { + isCrownRange, + isDirection, + isRange, + isRateRange, + type CrownRange, + type Range, + type RateRange, +} from '../api'; const MinersPage: React.FC = () => { const [params, setParams] = useSearchParams(); - const range: Range = isRange(params.get('range')) - ? (params.get('range') as Range) - : '30d'; - const direction: Direction = isDirection(params.get('pair')) - ? (params.get('pair') as Direction) - : 'BTC-TAO'; - const crownRange = isCrownRange(params.get('crownRange')) - ? (params.get('crownRange') as '1h' | '2h' | '4h') + 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 rateRange = isRateRange(params.get('rateRange')) - ? (params.get('rateRange') as '1h' | '4h' | '24h' | '7d') + const rateRangeParam = params.get('rateRange'); + const rateRange: RateRange = isRateRange(rateRangeParam) + ? rateRangeParam : '4h'; const pan = Number(params.get('pan') ?? '0') || 0; From e42a3e499a81081a0777e49cc5dfed8699d26f13 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sat, 16 May 2026 15:44:17 -0500 Subject: [PATCH 08/11] fix: fall back to liveMiner.uid when stats.uid is missing The /miners/:hotkey/stats response only started returning uid in the recent polish commit; before that it was undefined, which rendered as "Miner uid ?" in the header. Fall back to the live-miners list (always populated, uid is non-nullable there) so the page is correct against both API versions and while stats is still loading. --- src/pages/MinerDetailPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/MinerDetailPage.tsx b/src/pages/MinerDetailPage.tsx index 73ffdaf..ccc95b7 100644 --- a/src/pages/MinerDetailPage.tsx +++ b/src/pages/MinerDetailPage.tsx @@ -67,7 +67,10 @@ const MinerDetailPage: React.FC = () => { const { data: stats } = useMinerStats(hotkey, range); const { data: miners } = useMiners(); const liveMiner = miners?.find((m) => m.hotkey === hotkey) ?? null; - const uid = stats?.uid ?? 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 ( From ad660c79743decb97f03e397d0f96ecc56800da2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 16 May 2026 23:52:05 +0000 Subject: [PATCH 09/11] style: auto-format code with prettier/eslint --- src/components/miners/CrownHistoryGrid.tsx | 2 +- src/components/miners/CrownRateChart.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/miners/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx index 9f69527..d7073c7 100644 --- a/src/components/miners/CrownHistoryGrid.tsx +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -498,7 +498,7 @@ const CrownHistoryGrid: React.FC<{ : 'none', outlineOffset: matchesSearch ? '1px' : 0, boxShadow: matchesSearch - ? `inset 0 0 0 1px rgba(255,255,255,0.85)` + ? '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', diff --git a/src/components/miners/CrownRateChart.tsx b/src/components/miners/CrownRateChart.tsx index 8afc714..34696ab 100644 --- a/src/components/miners/CrownRateChart.tsx +++ b/src/components/miners/CrownRateChart.tsx @@ -12,7 +12,6 @@ import { useMinerRateHistory, type CrownRateHistoryRow, type Direction, - type MinerRateHistoryRow, } from '../../api'; import { FONTS } from '../../theme'; From f09c0de1d26869e600c083cbfd813f097ff2d8ed Mon Sep 17 00:00:00 2001 From: Landyn Date: Sat, 16 May 2026 18:58:38 -0500 Subject: [PATCH 10/11] lint: stabilize baseRows via useMemo eslint react-hooks/exhaustive-deps flagged the inline data ?? [] as making the dependent useMemos rerun every render. Hoist to a useMemo so the array identity is stable across renders with no data changes. --- src/components/miners/MinerLeaderboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx index c0c7d08..3287e74 100644 --- a/src/components/miners/MinerLeaderboard.tsx +++ b/src/components/miners/MinerLeaderboard.tsx @@ -99,7 +99,7 @@ const MinerLeaderboard: React.FC<{ const [sortDir, setSortDir] = useState('desc'); const [query, setQuery] = useState(''); - const baseRows = data ?? []; + const baseRows = useMemo(() => data ?? [], [data]); const topShare = useMemo( () => Math.max(0, ...baseRows.map((r) => r.crownShare)), [baseRows], From 9f1b8be8cfcc6e1c996ab22d0e7bb0f9c5d78f06 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sat, 16 May 2026 19:39:00 -0500 Subject: [PATCH 11/11] fix: crown grid fetches the window it renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid called useCrownHistory({ direction }) with no bounds, so it always got the API's default ~300-block window. When the user panned backward (or set a custom range), the grid drew cells for the panned range but only had data for the most-recent window — every cell read "no holder" and the locked-uid subjectAbsent banner kicked in falsely. Meanwhile the score-factors strip queried with explicit bounds and reported the correct crown share, producing the contradictory "100% crown share + empty grid" state. Fix: - Pass fromBlock=lo, toBlock=hi to useCrownHistory so the fetch matches what's drawn. - Anchor pan/snap math on halt.asOfBlock (chain head) instead of the max block of the fetched data. The two diverged once the fetch became window-bounded. - Update "as of #N" footer + isCurrent cell flag to use headBlock. --- src/components/miners/CrownHistoryGrid.tsx | 37 ++++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/components/miners/CrownHistoryGrid.tsx b/src/components/miners/CrownHistoryGrid.tsx index d7073c7..8bfb302 100644 --- a/src/components/miners/CrownHistoryGrid.tsx +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -10,7 +10,7 @@ import { alpha, useTheme, } from '@mui/material'; -import { useCrownHistory, type Direction } from '../../api'; +import { useCrownHistory, useHaltState, type Direction } from '../../api'; import { FONTS } from '../../theme'; import CrownGridHoverCard from './CrownGridHoverCard'; import CrownGridRangeInputs from './CrownGridRangeInputs'; @@ -93,13 +93,12 @@ const CrownHistoryGrid: React.FC<{ const gridRef = useRef(null); const span = RANGE_BLOCKS[range]; - const { data } = useCrownHistory({ direction }); + // 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; - const rows = useMemo(() => data ?? [], [data]); - const maxBlock = useMemo( - () => (rows.length ? Math.max(...rows.map((r) => r.block)) : 0), - [rows], - ); let hi: number; let lo: number; if (customActive) { @@ -107,18 +106,28 @@ const CrownHistoryGrid: React.FC<{ hi = customTo as number; } else if (range === '2h') { const anchor = - Math.floor(maxBlock / SCORING_WINDOW_BLOCKS) * SCORING_WINDOW_BLOCKS; + 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 = maxBlock - pan; + hi = headBlock - pan; lo = Math.max(0, hi - span + 1); } const atEarliest = lo <= 0; useEffect(() => { - if (maxBlock > 0) onWindowChange?.(lo, hi); - }, [lo, hi, maxBlock, onWindowChange]); + 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( @@ -132,7 +141,7 @@ const CrownHistoryGrid: React.FC<{ rows, lo, hi, - maxBlock, + headBlock, tierColors, otherColor, isLocked ? lockedUid : null, @@ -142,7 +151,7 @@ const CrownHistoryGrid: React.FC<{ rows, lo, hi, - maxBlock, + headBlock, tierColors, otherColor, isLocked, @@ -706,7 +715,7 @@ const CrownHistoryGrid: React.FC<{ mt: 2, }} > - as of #{maxBlock.toLocaleString()} · each cell = 1 block (12s) · each + as of #{headBlock.toLocaleString()} · each cell = 1 block (12s) · each row = 60 blocks (12m)