diff --git a/src/api/MinersDashboardApi.ts b/src/api/MinersDashboardApi.ts new file mode 100644 index 0000000..1f80532 --- /dev/null +++ b/src/api/MinersDashboardApi.ts @@ -0,0 +1,122 @@ +import { useApiQuery } from './ApiUtils'; +import { SSE_FALLBACK_INTERVAL } from './constants'; +import type { + ActiveSwap, + CrownHistoryRow, + CrownRateHistoryRow, + CurrentCrownMap, + DiagnosticRow, + Direction, + HaltState, + LeaderboardRow, + MinerRateHistoryRow, + MinerStats, + NetworkOverview, + Range, +} 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..bab7338 --- /dev/null +++ b/src/components/miners/CrownHistoryGrid.tsx @@ -0,0 +1,385 @@ +import React, { useMemo, useState } from 'react'; +import { + Box, + Button, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { + useCrownHistory, + type CrownHistoryRow, + type Direction, +} from '../../api'; +import { FONTS } from '../../theme'; + +const ROW_BLOCKS = 60; +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'; + +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 = useMemo(() => data ?? [], [data]); + const maxBlock = useMemo( + () => (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 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]); + + 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 + + + 2h + + + 4h + + + + + + + + {range === '2h' && pan > 0 && ( + + )} + + + {range === '2h' ? ( + <> + scoring window · block #{lo.toLocaleString()} — # + {hi.toLocaleString()} + {pan === 0 && ( + + · current + + )} + + ) : ( + <> + 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..12301d2 --- /dev/null +++ b/src/components/miners/CrownRateChart.tsx @@ -0,0 +1,380 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { + Box, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { + useCrownRateHistory, + useMinerRateHistory, + type CrownRateHistoryRow, + type Direction, + type 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 = 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( + () => 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..6e306d9 --- /dev/null +++ b/src/components/miners/EarningDiagnostic.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { useMinerDiagnostic, 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..249d77f --- /dev/null +++ b/src/components/miners/FilteredMinerSection.tsx @@ -0,0 +1,118 @@ +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 new file mode 100644 index 0000000..c90121b --- /dev/null +++ b/src/components/miners/MinerLeaderboard.tsx @@ -0,0 +1,236 @@ +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, + type LeaderboardRow, + type 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..648811a --- /dev/null +++ b/src/components/miners/NetworkOverviewStats.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import { useNetworkOverview, 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} + + +); + +/** + * 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 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 ( + *': { + 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) => ( + + ))} + + ); +}; + +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..28ad359 --- /dev/null +++ b/src/pages/MinersPage.tsx @@ -0,0 +1,108 @@ +import React, { useCallback } from 'react'; +import { Stack } from '@mui/material'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { + CrownHistoryGrid, + CrownRateChart, + FilteredMinerSection, + MinerLeaderboard, + NetworkOverviewStats, + Page, + SEO, + StickyNetworkHeader, +} 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: }, {