Redesign dashboard with steampunk neon theme and live gauges.
Ambient background, 3D neon cards, brass HUD styling, glowing charts, gauge rings, and refreshed Command Deck, Fleet Roster, Forge, and Calibrate pages.
This commit is contained in:
91
run.bat
91
run.bat
@@ -15,7 +15,14 @@ echo [1/5] Checking dependencies...
|
|||||||
|
|
||||||
where go >nul 2>nul
|
where go >nul 2>nul
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo Go not found. Downloading and installing Go...
|
echo Go not found. Checking for existing installation...
|
||||||
|
|
||||||
|
:: Check common install paths
|
||||||
|
if exist "C:\Program Files\Go\bin\go.exe" (
|
||||||
|
echo Found Go at C:\Program Files\Go\bin
|
||||||
|
set "PATH=C:\Program Files\Go\bin;%PATH%"
|
||||||
|
) else (
|
||||||
|
echo Downloading and installing Go...
|
||||||
|
|
||||||
:: Detect architecture
|
:: Detect architecture
|
||||||
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
|
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
|
||||||
@@ -54,10 +61,11 @@ if %ERRORLEVEL% neq 0 (
|
|||||||
del "%GO_MSI%" 2>nul
|
del "%GO_MSI%" 2>nul
|
||||||
|
|
||||||
:: Add Go to PATH for this session
|
:: Add Go to PATH for this session
|
||||||
set PATH=%PATH%;C:\Program Files\Go\bin
|
set "PATH=C:\Program Files\Go\bin;%PATH%"
|
||||||
set PATH=%PATH%;%USERPROFILE%\go\bin
|
set "PATH=%USERPROFILE%\go\bin;%PATH%"
|
||||||
|
|
||||||
echo Go installed successfully!
|
echo Go installed successfully!
|
||||||
|
)
|
||||||
) else (
|
) else (
|
||||||
echo Go found:
|
echo Go found:
|
||||||
call go version
|
call go version
|
||||||
@@ -68,7 +76,13 @@ if %ERRORLEVEL% neq 0 (
|
|||||||
:: ============================================================
|
:: ============================================================
|
||||||
where node >nul 2>nul
|
where node >nul 2>nul
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo Node.js not found. Downloading and installing Node.js...
|
echo Node.js not found. Checking for existing installation...
|
||||||
|
|
||||||
|
if exist "C:\Program Files\nodejs\node.exe" (
|
||||||
|
echo Found Node.js at C:\Program Files\nodejs
|
||||||
|
set "PATH=C:\Program Files\nodejs;%PATH%"
|
||||||
|
) else (
|
||||||
|
echo Downloading and installing Node.js...
|
||||||
|
|
||||||
set NODE_VERSION=20.12.2
|
set NODE_VERSION=20.12.2
|
||||||
set NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi
|
set NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi
|
||||||
@@ -92,12 +106,13 @@ if %ERRORLEVEL% neq 0 (
|
|||||||
set SKIP_FRONTEND=1
|
set SKIP_FRONTEND=1
|
||||||
) else (
|
) else (
|
||||||
:: Add Node to PATH for this session
|
:: Add Node to PATH for this session
|
||||||
set PATH=%PATH%;C:\Program Files\nodejs
|
set "PATH=C:\Program Files\nodejs;%PATH%"
|
||||||
|
|
||||||
del "%NODE_MSI%" 2>nul
|
del "%NODE_MSI%" 2>nul
|
||||||
echo Node.js installed successfully!
|
echo Node.js installed successfully!
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
) else (
|
) else (
|
||||||
echo Node.js found:
|
echo Node.js found:
|
||||||
call node --version
|
call node --version
|
||||||
@@ -109,32 +124,35 @@ if %ERRORLEVEL% neq 0 (
|
|||||||
echo [2/5] Creating data directories...
|
echo [2/5] Creating data directories...
|
||||||
if not exist "data\builds" mkdir "data\builds"
|
if not exist "data\builds" mkdir "data\builds"
|
||||||
if not exist "data\logs" mkdir "data\logs"
|
if not exist "data\logs" mkdir "data\logs"
|
||||||
echo Done.
|
if not exist "data\blueprints" mkdir "data\blueprints"
|
||||||
|
echo Created: data\builds, data\logs, data\blueprints
|
||||||
|
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
:: STEP 4: Build frontend
|
:: STEP 4: Build frontend
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
if "%SKIP_FRONTEND%"=="" (
|
if "%SKIP_FRONTEND%"=="" (
|
||||||
echo [3/5] Building frontend dashboard...
|
echo [3/5] Building frontend dashboard...
|
||||||
|
echo Running: npm install ^&^& npm run build in server\web\
|
||||||
cd server\web
|
cd server\web
|
||||||
if not exist "node_modules" (
|
if not exist "node_modules" (
|
||||||
echo Installing npm dependencies...
|
echo Installing npm dependencies...
|
||||||
call npm install
|
call npm install
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo ERROR: npm install failed
|
||||||
|
cd ..\..
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
echo npm install completed successfully.
|
||||||
|
)
|
||||||
|
echo Building frontend with Vite...
|
||||||
call npm run build
|
call npm run build
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo WARNING: Frontend build failed, server will run without dashboard.
|
echo WARNING: Frontend build failed, server will run without dashboard.
|
||||||
) else (
|
) else (
|
||||||
echo Frontend built: server\web\dist
|
echo Frontend built successfully: server\web\dist
|
||||||
)
|
)
|
||||||
cd ..\..
|
cd ..\..
|
||||||
|
|
||||||
:: Copy frontend dist to server's webroot directory
|
|
||||||
if exist "server\web\dist" (
|
|
||||||
if not exist "server\webroot" mkdir "server\webroot"
|
|
||||||
xcopy /E /I /Y "server\web\dist\*" "server\webroot\" >nul
|
|
||||||
echo Frontend files copied to server\webroot
|
|
||||||
)
|
|
||||||
) else (
|
) else (
|
||||||
echo [3/5] Skipping frontend build (Node.js not available)
|
echo [3/5] Skipping frontend build (Node.js not available)
|
||||||
)
|
)
|
||||||
@@ -143,6 +161,8 @@ if "%SKIP_FRONTEND%"=="" (
|
|||||||
:: STEP 5: Build server
|
:: STEP 5: Build server
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
echo [4/5] Building server...
|
echo [4/5] Building server...
|
||||||
|
echo Running: go build in server\...
|
||||||
|
|
||||||
cd server
|
cd server
|
||||||
|
|
||||||
:: Download Go module dependencies
|
:: Download Go module dependencies
|
||||||
@@ -152,6 +172,7 @@ if %ERRORLEVEL% neq 0 (
|
|||||||
echo WARNING: go mod download failed, trying build anyway...
|
echo WARNING: go mod download failed, trying build anyway...
|
||||||
)
|
)
|
||||||
|
|
||||||
|
echo Compiling server binary...
|
||||||
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
|
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo ERROR: Build failed
|
echo ERROR: Build failed
|
||||||
@@ -159,36 +180,52 @@ if %ERRORLEVEL% neq 0 (
|
|||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
cd ..
|
cd ..
|
||||||
echo Server built: bin\miner-server.exe
|
echo Server built successfully: bin\miner-server.exe
|
||||||
|
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
:: LAUNCH
|
:: LAUNCH
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
echo [5/5] Starting server on port 8989...
|
echo [5/5] Starting server on port 8989...
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
|
:: Detect LAN IP for display
|
||||||
for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } ^| Select-Object -First 1 -ExpandProperty IPAddress)"`) do set LAN_IP=%%I
|
for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } ^| Select-Object -First 1 -ExpandProperty IPAddress)"`) do set LAN_IP=%%I
|
||||||
if not defined LAN_IP set LAN_IP=localhost
|
if not defined LAN_IP set LAN_IP=localhost
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ╔══════════════════════════════════════════════════╗
|
echo ╔══════════════════════════════════════════════════════════════╗
|
||||||
echo ║ Crypto Miner Control Server ║
|
echo ║ SERVER IS NOW LIVE ║
|
||||||
echo ║ ║
|
echo ║ ║
|
||||||
echo ║ Dashboard: http://%LAN_IP%:8989 ║
|
echo ║ Dashboard URL: http://0.0.0.0:8989 ║
|
||||||
|
echo ║ LAN Access: http://%LAN_IP%:8989 ║
|
||||||
echo ║ Local: http://localhost:8989 ║
|
echo ║ Local: http://localhost:8989 ║
|
||||||
echo ║ WebSocket: ws://%LAN_IP%:8989/ws/agent ║
|
|
||||||
echo ║ ║
|
echo ║ ║
|
||||||
echo ║ 1. Open dashboard on your LAN ║
|
echo ║ WebSocket (agents): ws://%LAN_IP%:8989/ws/agent ║
|
||||||
echo ║ 2. Configure Settings ║
|
echo ║ WebSocket (dash): ws://%LAN_IP%:8989/ws/dashboard ║
|
||||||
echo ║ 3. Build install-{worker}.exe in Builder ║
|
|
||||||
echo ║ 4. Run that exe once on each Windows machine ║
|
|
||||||
echo ║ ║
|
echo ║ ║
|
||||||
echo ║ Data: %CD%\data\ ║
|
echo ║ Quick Start: ║
|
||||||
echo ║ Press Ctrl+C to stop the server ║
|
echo ║ 1. Open Dashboard in your browser ║
|
||||||
echo ╚══════════════════════════════════════════════════╝
|
echo ║ 2. Go to Settings - configure pool ^& wallet ║
|
||||||
|
echo ║ 3. Go to Builder - build your miner installer .exe ║
|
||||||
|
echo ║ 4. Run that .exe on each Windows machine ║
|
||||||
|
echo ║ ║
|
||||||
|
echo ║ Data Directory: %CD%\data\ ║
|
||||||
|
echo ║ Config File: %CD%\data\config.json ║
|
||||||
|
echo ║ Blueprints: %CD%\data\blueprints\ ║
|
||||||
|
echo ║ ║
|
||||||
|
echo ║ Press Ctrl+C in this window to stop the server ║
|
||||||
|
echo ╚══════════════════════════════════════════════════════════════╝
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
|
:: Open browser
|
||||||
start http://localhost:8989
|
start http://localhost:8989
|
||||||
|
|
||||||
:: Run server
|
:: Run server (this blocks until Ctrl+C)
|
||||||
|
echo [Server] Starting miner-server.exe on 0.0.0.0:8989...
|
||||||
|
echo [Server] Log output below (Ctrl+C to stop):
|
||||||
|
echo.
|
||||||
.\bin\miner-server.exe -port 8989 -data ".\data"
|
.\bin\miner-server.exe -port 8989 -data ".\data"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [Server] Server stopped.
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Crypto Miner Command Deck</title>
|
<title>AetherForge — LAN Mining Command Deck</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
103
server/web/src/components/Ambient/AmbientBackground.css
Normal file
103
server/web/src/components/Ambient/AmbientBackground.css
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
.ambient-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: -50%;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(201, 162, 39, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(201, 162, 39, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(0, 245, 255, 0.02) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0, 245, 255, 0.02) 1px, transparent 1px);
|
||||||
|
background-size: 80px 80px, 80px 80px, 20px 20px, 20px 20px;
|
||||||
|
animation: grid-drift 40s linear infinite;
|
||||||
|
transform: perspective(500px) rotateX(60deg) scale(2);
|
||||||
|
transform-origin: center top;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-vignette {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse at center, transparent 0%, var(--bg-void) 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
animation: float-orb 12s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-orb-cyan {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: rgba(0, 245, 255, 0.12);
|
||||||
|
top: 10%;
|
||||||
|
right: 5%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-orb-magenta {
|
||||||
|
width: 350px;
|
||||||
|
height: 350px;
|
||||||
|
background: rgba(255, 45, 166, 0.08);
|
||||||
|
bottom: 20%;
|
||||||
|
left: 10%;
|
||||||
|
animation-delay: -4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-orb-amber {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: rgba(255, 176, 32, 0.06);
|
||||||
|
top: 50%;
|
||||||
|
left: 40%;
|
||||||
|
animation-delay: -8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-scanline {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(0, 245, 255, 0.15), transparent);
|
||||||
|
animation: scanline 8s linear infinite;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-gear {
|
||||||
|
position: absolute;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border: 3px dashed rgba(201, 162, 39, 0.15);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-gear::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 20%;
|
||||||
|
border: 2px solid rgba(201, 162, 39, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-gear-1 {
|
||||||
|
top: 15%;
|
||||||
|
left: 5%;
|
||||||
|
animation: gear-spin 60s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-gear-2 {
|
||||||
|
bottom: 10%;
|
||||||
|
right: 8%;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
animation: gear-spin 45s linear infinite reverse;
|
||||||
|
}
|
||||||
16
server/web/src/components/Ambient/AmbientBackground.tsx
Normal file
16
server/web/src/components/Ambient/AmbientBackground.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import './AmbientBackground.css';
|
||||||
|
|
||||||
|
export default function AmbientBackground() {
|
||||||
|
return (
|
||||||
|
<div className="ambient-bg" aria-hidden>
|
||||||
|
<div className="ambient-grid" />
|
||||||
|
<div className="ambient-vignette" />
|
||||||
|
<div className="ambient-orb ambient-orb-cyan" />
|
||||||
|
<div className="ambient-orb ambient-orb-magenta" />
|
||||||
|
<div className="ambient-orb ambient-orb-amber" />
|
||||||
|
<div className="ambient-scanline" />
|
||||||
|
<div className="ambient-gear ambient-gear-1" />
|
||||||
|
<div className="ambient-gear ambient-gear-2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
server/web/src/components/Charts/GaugeRing.css
Normal file
56
server/web/src/components/Charts/GaugeRing.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.gauge-ring {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-ring-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-ring-track {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(201, 162, 39, 0.15);
|
||||||
|
stroke-width: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-ring-fill {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.8s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-ring-center {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-ring-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
text-shadow: 0 0 12px rgba(0, 245, 255, 0.5);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-ring-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--brass-light);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-ring-sub {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
66
server/web/src/components/Charts/GaugeRing.tsx
Normal file
66
server/web/src/components/Charts/GaugeRing.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import './GaugeRing.css';
|
||||||
|
|
||||||
|
interface GaugeRingProps {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
color?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GaugeRing({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
color = 'var(--neon-cyan)',
|
||||||
|
size = 100,
|
||||||
|
}: GaugeRingProps) {
|
||||||
|
const pct = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
const circumference = 2 * Math.PI * 42;
|
||||||
|
const offset = circumference - (pct / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gauge-ring" style={{ width: size, height: size }}>
|
||||||
|
<svg viewBox="0 0 100 100" className="gauge-ring-svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`gauge-grad-${label.replace(/\s/g, '')}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
||||||
|
<stop offset="100%" stopColor="var(--brass)" stopOpacity="0.8" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="gauge-glow">
|
||||||
|
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle className="gauge-ring-track" cx="50" cy="50" r="42" />
|
||||||
|
<circle
|
||||||
|
className="gauge-ring-fill"
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="42"
|
||||||
|
stroke={`url(#gauge-grad-${label.replace(/\s/g, '')})`}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
filter="url(#gauge-glow)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="gauge-ring-center">
|
||||||
|
<span className="gauge-ring-value font-tech">{formatValue(value, max)}</span>
|
||||||
|
<span className="gauge-ring-label">{label}</span>
|
||||||
|
{sublabel && <span className="gauge-ring-sub">{sublabel}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(v: number, max: number): string {
|
||||||
|
if (max <= 100 && max > 1) return `${v.toFixed(0)}%`;
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
|
||||||
|
return v.toFixed(0);
|
||||||
|
}
|
||||||
74
server/web/src/components/Charts/HashrateChart.css
Normal file
74
server/web/src/components/Charts/HashrateChart.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
.neon-chart-panel {
|
||||||
|
position: relative;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(201, 162, 39, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brass-light);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-live {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--neon-green);
|
||||||
|
text-shadow: 0 0 8px rgba(57, 255, 20, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 260px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--brass);
|
||||||
|
opacity: 0.5;
|
||||||
|
animation: pulse-glow 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-tooltip {
|
||||||
|
background: linear-gradient(145deg, rgba(20, 16, 12, 0.98), rgba(8, 6, 4, 0.99));
|
||||||
|
border: 1px solid var(--border-brass);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 245, 255, 0.15), var(--shadow-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-tooltip-time {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-tooltip-value {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-tooltip-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--brass-light);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import './HashrateChart.css';
|
||||||
|
|
||||||
export interface ChartPoint {
|
export interface ChartPoint {
|
||||||
time: string;
|
time: string;
|
||||||
@@ -19,48 +20,149 @@ interface HashrateChartProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HashrateChart({ data, title, color = '#06b6d4', unit = 'H/s' }: HashrateChartProps) {
|
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
||||||
|
|
||||||
|
function colorToId(color: string): string {
|
||||||
|
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff') return 'cyan';
|
||||||
|
if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta';
|
||||||
|
if (color.includes('b020') || color.includes('eab308')) return 'amber';
|
||||||
|
if (color.includes('39ff') || color.includes('22c55e')) return 'green';
|
||||||
|
return 'purple';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HashrateChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
color = '#00f5ff',
|
||||||
|
unit = 'H/s',
|
||||||
|
height = 280,
|
||||||
|
}: HashrateChartProps) {
|
||||||
|
const gradId = colorToId(color);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="chart-empty card">
|
<div className="chart-empty neon-chart-panel">
|
||||||
<p>{title || 'Chart'} — waiting for data...</p>
|
<div className="chart-empty-icon">◈</div>
|
||||||
|
<p className="font-tech">{title || 'Telemetry'}</p>
|
||||||
|
<span>Awaiting signal from fleet...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart-wrap">
|
<div className="chart-wrap neon-chart-panel">
|
||||||
{title && <h3 className="chart-title">{title}</h3>}
|
{title && (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<div className="chart-header">
|
||||||
<AreaChart data={data}>
|
<h3 className="chart-title font-display">{title}</h3>
|
||||||
|
<span className="chart-live pulse">● LIVE</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`grad-${color}`} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={`area-fill-${gradId}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.35} />
|
<stop offset="0%" stopColor={color} stopOpacity={0.55} />
|
||||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
<stop offset="50%" stopColor={color} stopOpacity={0.15} />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<filter id={`glow-${gradId}`}>
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#243049" />
|
<CartesianGrid strokeDasharray="4 8" stroke="rgba(201, 162, 39, 0.12)" vertical={false} />
|
||||||
<XAxis dataKey="time" stroke="#64748b" fontSize={11} tickLine={false} />
|
<XAxis
|
||||||
<YAxis stroke="#64748b" fontSize={11} tickLine={false} tickFormatter={(v) => formatShort(v, unit)} />
|
dataKey="time"
|
||||||
<Tooltip
|
stroke="rgba(196, 181, 160, 0.5)"
|
||||||
contentStyle={{ background: '#111827', border: '1px solid #2a3a5c', borderRadius: 8 }}
|
fontSize={10}
|
||||||
labelStyle={{ color: '#94a3b8' }}
|
fontFamily="Orbitron, monospace"
|
||||||
formatter={(value: number) => [formatShort(value, unit), title || 'Value']}
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: 'rgba(201, 162, 39, 0.25)' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="rgba(196, 181, 160, 0.5)"
|
||||||
|
fontSize={10}
|
||||||
|
fontFamily="Orbitron, monospace"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v) => formatShort(v, unit)}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={(props) => (
|
||||||
|
<NeonTooltip
|
||||||
|
active={props.active}
|
||||||
|
payload={props.payload as { value: number }[] | undefined}
|
||||||
|
label={props.label as string | undefined}
|
||||||
|
unit={unit}
|
||||||
|
title={title}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fill={`url(#area-fill-${gradId})`}
|
||||||
|
filter={`url(#glow-${gradId})`}
|
||||||
|
animationDuration={800}
|
||||||
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
<Area type="monotone" dataKey="value" stroke={color} fill={`url(#grad-${color})`} strokeWidth={2} />
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NeonTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
unit,
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: { value: number }[];
|
||||||
|
label?: string;
|
||||||
|
unit: string;
|
||||||
|
title?: string;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const v = payload[0].value;
|
||||||
|
return (
|
||||||
|
<div className="neon-tooltip">
|
||||||
|
<div className="neon-tooltip-time font-tech">{label}</div>
|
||||||
|
<div className="neon-tooltip-value" style={{ color, textShadow: `0 0 12px ${color}` }}>
|
||||||
|
{formatFull(v, unit)}
|
||||||
|
</div>
|
||||||
|
<div className="neon-tooltip-label">{title || 'Reading'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatShort(v: number, unit: string): string {
|
function formatShort(v: number, unit: string): string {
|
||||||
if (unit === 'H/s') {
|
if (unit === 'H/s') {
|
||||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
|
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
|
||||||
return `${v.toFixed(0)}`;
|
return `${v.toFixed(0)}`;
|
||||||
}
|
}
|
||||||
return `${v.toFixed(1)}${unit === '%' ? '%' : ''}`;
|
return `${v.toFixed(0)}${unit === '%' ? '%' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFull(v: number, unit: string): string {
|
||||||
|
if (unit === 'H/s') {
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(3)} MH/s`;
|
||||||
|
if (v >= 1_000) return `${(v / 1_000).toFixed(2)} KH/s`;
|
||||||
|
return `${v.toFixed(0)} H/s`;
|
||||||
|
}
|
||||||
|
return `${v.toFixed(1)}${unit}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,34 +12,28 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
border-radius: 999px;
|
border-radius: 2px;
|
||||||
background: rgba(6, 182, 212, 0.15);
|
background: rgba(0, 245, 255, 0.1);
|
||||||
color: var(--accent-cyan);
|
border: 1px solid rgba(0, 245, 255, 0.35);
|
||||||
|
color: var(--neon-cyan);
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
box-shadow: 0 0 8px rgba(0, 245, 255, 0.2);
|
||||||
|
|
||||||
.help-tip-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cheat-sheet {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cheat-sheet-item {
|
.cheat-sheet-item {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--bg-secondary);
|
background: rgba(8, 6, 4, 0.6);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-brass);
|
||||||
border-radius: 8px;
|
border-radius: 2px;
|
||||||
|
border-left: 3px solid var(--neon-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cheat-sheet-item h4 {
|
.cheat-sheet-item h4 {
|
||||||
font-size: 0.8125rem;
|
font-family: var(--font-tech);
|
||||||
color: var(--accent-cyan);
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--neon-cyan);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
.layout {
|
.layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 240px;
|
width: 260px;
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, rgba(16, 12, 8, 0.97) 0%, rgba(8, 6, 4, 0.98) 100%);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-brass);
|
||||||
|
box-shadow: 4px 0 40px rgba(0, 0, 0, 0.5), inset -1px 0 0 rgba(0, 245, 255, 0.08);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -14,114 +16,217 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, var(--neon-cyan), transparent 30%, transparent 70%, var(--neon-magenta));
|
||||||
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 1.25rem 1rem;
|
padding: 1.5rem 1.25rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-brass);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.logo-emblem {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-gear {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px dashed rgba(201, 162, 39, 0.4);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: gear-spin 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-core {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
filter: drop-shadow(0 0 8px var(--neon-amber));
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 1.25rem;
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
background: linear-gradient(135deg, var(--brass-light) 0%, var(--neon-cyan) 50%, var(--brass) 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-tagline {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75rem 0.5rem;
|
padding: 1rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.85rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 2px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.875rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
transition: all 0.2s ease;
|
letter-spacing: 0.04em;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: var(--bg-hover);
|
background: rgba(201, 162, 39, 0.08);
|
||||||
color: var(--text-primary);
|
color: var(--brass-light);
|
||||||
|
border-color: rgba(201, 162, 39, 0.2);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
background: rgba(59, 130, 246, 0.15);
|
background: linear-gradient(90deg, rgba(0, 245, 255, 0.12), transparent);
|
||||||
color: var(--accent-blue);
|
color: var(--neon-cyan);
|
||||||
|
border-color: rgba(0, 245, 255, 0.35);
|
||||||
|
box-shadow: inset 0 0 20px rgba(0, 245, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
font-size: 1.125rem;
|
width: 1.35rem;
|
||||||
width: 1.5rem;
|
height: 1.35rem;
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-icon svg {
|
||||||
white-space: nowrap;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-glow {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 60%;
|
||||||
|
background: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 10px var(--neon-cyan);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 1rem;
|
padding: 1.25rem;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-meter {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(201, 162, 39, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 78%;
|
||||||
|
background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan));
|
||||||
|
box-shadow: 0 0 8px rgba(0, 245, 255, 0.4);
|
||||||
|
animation: shimmer 3s ease-in-out infinite;
|
||||||
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-badge {
|
.version-badge {
|
||||||
font-size: 0.75rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 240px;
|
margin-left: 260px;
|
||||||
padding: 1.5rem 2rem;
|
padding: 2rem 2.5rem;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 60px;
|
width: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text,
|
.logo-text-block,
|
||||||
.nav-label,
|
.nav-label,
|
||||||
.sidebar-footer {
|
.power-meter,
|
||||||
|
.version-badge {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.75rem;
|
padding: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
margin-left: 60px;
|
margin-left: 72px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,100 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import AmbientBackground from '../Ambient/AmbientBackground';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||||
|
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
||||||
|
{ to: '/builder', label: 'Forge', icon: 'forge' },
|
||||||
|
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function NavIcon({ type }: { type: string }) {
|
||||||
|
switch (type) {
|
||||||
|
case 'deck':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M4 19V5h16v14M4 9h16M8 5v4M16 5v4" />
|
||||||
|
<circle cx="12" cy="14" r="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'fleet':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<rect x="2" y="6" width="20" height="12" rx="1" />
|
||||||
|
<path d="M6 10h4M14 10h4M6 14h2M16 14h2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'forge':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M14 4l6 6-8 8H6v-6l8-8z" />
|
||||||
|
<path d="M8 16l-2 4 4-2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
|
<AmbientBackground />
|
||||||
<nav className="sidebar">
|
<nav className="sidebar">
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
<span className="logo-icon">⛏️</span>
|
<div className="logo-emblem">
|
||||||
<span className="logo-text">MinerCMD</span>
|
<span className="logo-gear" />
|
||||||
|
<span className="logo-core">⛏</span>
|
||||||
|
</div>
|
||||||
|
<div className="logo-text-block">
|
||||||
|
<span className="logo-text">AetherForge</span>
|
||||||
|
<span className="logo-tagline font-tech">LAN MINING COMMAND</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-nav">
|
<div className="sidebar-nav">
|
||||||
<NavLink to="/dashboard" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
{NAV.map((item) => (
|
||||||
<span className="nav-icon">📊</span>
|
<NavLink
|
||||||
<span className="nav-label">Dashboard</span>
|
key={item.to}
|
||||||
</NavLink>
|
to={item.to}
|
||||||
<NavLink to="/agents" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||||
<span className="nav-icon">🖥️</span>
|
>
|
||||||
<span className="nav-label">Agents</span>
|
<span className="nav-icon">
|
||||||
</NavLink>
|
<NavIcon type={item.icon} />
|
||||||
<NavLink to="/builder" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
</span>
|
||||||
<span className="nav-icon">🔨</span>
|
<span className="nav-label">{item.label}</span>
|
||||||
<span className="nav-label">Miner Builder</span>
|
{location.pathname === item.to && <span className="nav-glow" />}
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
|
||||||
<span className="nav-icon">⚙️</span>
|
|
||||||
<span className="nav-label">Settings</span>
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div className="version-badge">v1.0.0</div>
|
<div className="power-meter">
|
||||||
|
<span className="power-label font-tech">SYSTEM</span>
|
||||||
|
<div className="power-bar">
|
||||||
|
<div className="power-fill" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="version-badge font-tech">MK.I · v1.0</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
53
server/web/src/components/NeonCard/NeonCard.css
Normal file
53
server/web/src/components/NeonCard/NeonCard.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
.neon-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-rim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--brass-light) 0%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
var(--neon-cyan) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-cyan .neon-card-rim {
|
||||||
|
background: linear-gradient(135deg, var(--neon-cyan), transparent 40%, var(--brass) 100%);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-magenta .neon-card-rim {
|
||||||
|
background: linear-gradient(135deg, var(--neon-magenta), transparent 50%, var(--brass) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-amber .neon-card-rim {
|
||||||
|
background: linear-gradient(135deg, var(--neon-amber), var(--brass-dark) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-green .neon-card-rim {
|
||||||
|
background: linear-gradient(135deg, var(--neon-green), transparent 50%, var(--brass) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-purple .neon-card-rim {
|
||||||
|
background: linear-gradient(135deg, var(--neon-purple), var(--neon-cyan) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-cyan:hover {
|
||||||
|
box-shadow: var(--shadow-panel), var(--shadow-neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-magenta:hover {
|
||||||
|
box-shadow: var(--shadow-panel), var(--shadow-neon-magenta);
|
||||||
|
}
|
||||||
32
server/web/src/components/NeonCard/NeonCard.tsx
Normal file
32
server/web/src/components/NeonCard/NeonCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ReactNode, CSSProperties } from 'react';
|
||||||
|
import './NeonCard.css';
|
||||||
|
|
||||||
|
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass';
|
||||||
|
|
||||||
|
interface NeonCardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
accent?: Accent;
|
||||||
|
tilt3d?: boolean;
|
||||||
|
hud?: boolean;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NeonCard({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
accent = 'brass',
|
||||||
|
tilt3d = true,
|
||||||
|
hud = false,
|
||||||
|
style,
|
||||||
|
}: NeonCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`neon-card neon-card-${accent} ${tilt3d ? 'neon-card-3d' : ''} ${hud ? 'hud-corners' : ''} ${className}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className="neon-card-rim" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
|
import './styles/steampunk-theme.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Agent, HashrateSample } from '../types';
|
import type { Agent, HashrateSample } from '../types';
|
||||||
import HashrateChart from '../components/Charts/HashrateChart';
|
import HashrateChart from '../components/Charts/HashrateChart';
|
||||||
|
import NeonCard from '../components/NeonCard/NeonCard';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
@@ -28,29 +29,33 @@ export default function AgentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page fade-in">
|
<div className="page fade-in command-deck">
|
||||||
<div className="page-header">
|
<header className="deck-hero">
|
||||||
<h1>Agents</h1>
|
<div className="deck-hero-text">
|
||||||
<span className="header-count">{agents.length} total</span>
|
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
|
||||||
|
<h1>Fleet Roster</h1>
|
||||||
|
<p className="page-subtitle">Inspect each node — hashrate history, hardware, share ledger.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="header-count font-tech">{agents.length} NODES</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="card empty-state">
|
<NeonCard accent="brass" className="empty-state">
|
||||||
<p>Loading agents...</p>
|
<p>Scanning network...</p>
|
||||||
</div>
|
</NeonCard>
|
||||||
) : agents.length === 0 ? (
|
) : agents.length === 0 ? (
|
||||||
<div className="card empty-state">
|
<NeonCard accent="brass" className="empty-state">
|
||||||
<div className="empty-icon">🖥️</div>
|
<div className="empty-icon">⚙</div>
|
||||||
<h3>No agents registered</h3>
|
<h3>No agents registered</h3>
|
||||||
<p>Deploy a miner to a Windows machine and it will appear here automatically.</p>
|
<p>Deploy a miner to a Windows machine and it will appear here automatically.</p>
|
||||||
</div>
|
</NeonCard>
|
||||||
) : (
|
) : (
|
||||||
<div className="agents-layout">
|
<div className="agents-layout">
|
||||||
<div className="agents-list">
|
<div className="agents-list">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<div
|
<div
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
className={`card agent-list-item ${selectedAgent?.id === agent.id ? 'selected' : ''}`}
|
className={`neon-card agent-list-item ${selectedAgent?.id === agent.id ? 'selected' : ''}`}
|
||||||
onClick={() => selectAgent(agent)}
|
onClick={() => selectAgent(agent)}
|
||||||
>
|
>
|
||||||
<div className="agent-list-header">
|
<div className="agent-list-header">
|
||||||
@@ -76,8 +81,8 @@ export default function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedAgent && (
|
{selectedAgent && (
|
||||||
<div className="agent-detail card">
|
<NeonCard accent="cyan" className="agent-detail" hud>
|
||||||
<h2>{selectedAgent.name}</h2>
|
<h2 className="font-display">{selectedAgent.name}</h2>
|
||||||
<div className="agent-detail-grid">
|
<div className="agent-detail-grid">
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="detail-label">Status</span>
|
<span className="detail-label">Status</span>
|
||||||
@@ -153,11 +158,12 @@ export default function AgentsPage() {
|
|||||||
|
|
||||||
{hashrateHistory.length > 0 && (
|
{hashrateHistory.length > 0 && (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Hashrate History</h3>
|
<h3 className="font-tech">HASHRATE TELEMETRY</h3>
|
||||||
<HashrateChart
|
<HashrateChart
|
||||||
title=""
|
title=""
|
||||||
color="#22c55e"
|
color="#00f5ff"
|
||||||
unit="H/s"
|
unit="H/s"
|
||||||
|
height={240}
|
||||||
data={[...hashrateHistory].reverse().map((s) => ({
|
data={[...hashrateHistory].reverse().map((s) => ({
|
||||||
time: new Date(s.timestamp).toLocaleTimeString(),
|
time: new Date(s.timestamp).toLocaleTimeString(),
|
||||||
value: s.hashrate,
|
value: s.hashrate,
|
||||||
@@ -165,25 +171,7 @@ export default function AgentsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</NeonCard>
|
||||||
{hashrateHistory.length > 0 && (
|
|
||||||
<div className="detail-section">
|
|
||||||
<h3>Raw Samples ({hashrateHistory.length})</h3>
|
|
||||||
<div className="hashrate-chart">
|
|
||||||
{hashrateHistory.reverse().map((sample, i) => (
|
|
||||||
<div
|
|
||||||
key={sample.id}
|
|
||||||
className="chart-bar"
|
|
||||||
style={{
|
|
||||||
height: `${Math.max(5, (sample.hashrate / Math.max(...hashrateHistory.map(s => s.hashrate))) * 100)}%`,
|
|
||||||
}}
|
|
||||||
title={`${formatHashrate(sample.hashrate)} at ${new Date(sample.timestamp).toLocaleTimeString()}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -141,13 +141,17 @@ export default function BuilderPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page fade-in">
|
<div className="page fade-in command-deck">
|
||||||
<div className="page-header">
|
<header className="deck-hero">
|
||||||
<h1>Miner Builder</h1>
|
<div className="deck-hero-text">
|
||||||
|
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||||
|
<h1>The Forge</h1>
|
||||||
|
<p className="page-subtitle">Craft fused or standalone miners for your LAN fleet.</p>
|
||||||
|
</div>
|
||||||
<button className="btn btn-outline" onClick={loadRecentBuilds}>
|
<button className="btn btn-outline" onClick={loadRecentBuilds}>
|
||||||
Recent Builds
|
Recent Builds
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div className="builder-layout builder-layout-wide">
|
<div className="builder-layout builder-layout-wide">
|
||||||
<div className="card cheat-sheet-panel">
|
<div className="card cheat-sheet-panel">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
|
||||||
import type { Share } from '../types';
|
import type { Share } from '../types';
|
||||||
import HashrateChart from '../components/Charts/HashrateChart';
|
import HashrateChart from '../components/Charts/HashrateChart';
|
||||||
|
import GaugeRing from '../components/Charts/GaugeRing';
|
||||||
|
import NeonCard from '../components/NeonCard/NeonCard';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
@@ -10,6 +12,7 @@ export default function DashboardPage() {
|
|||||||
const [shares, setShares] = useState<Share[]>([]);
|
const [shares, setShares] = useState<Share[]>([]);
|
||||||
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
|
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
|
||||||
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
|
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
|
||||||
|
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getRecentShares(20).then(setShares).catch(console.error);
|
api.getRecentShares(20).then(setShares).catch(console.error);
|
||||||
@@ -20,77 +23,125 @@ export default function DashboardPage() {
|
|||||||
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
|
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
|
||||||
const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0);
|
const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0);
|
||||||
const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0);
|
const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0);
|
||||||
const acceptRate = totalShares > 0 ? ((acceptedShares / totalShares) * 100).toFixed(1) : '0.0';
|
const acceptRate = totalShares > 0 ? (acceptedShares / totalShares) * 100 : 0;
|
||||||
const avgCpu = agents.length > 0 ? agents.reduce((s, a) => s + a.cpu_usage_pct, 0) / agents.length : 0;
|
const avgCpu = agents.length > 0 ? agents.reduce((s, a) => s + a.cpu_usage_pct, 0) / agents.length : 0;
|
||||||
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
|
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
|
||||||
|
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = new Date().toLocaleTimeString();
|
const now = new Date().toLocaleTimeString();
|
||||||
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
|
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
|
||||||
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
|
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
|
||||||
}, [totalHashrate, avgCpu]);
|
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
|
||||||
|
}, [totalHashrate, avgCpu, avgMem]);
|
||||||
|
|
||||||
const topAgents = useMemo(
|
const topAgents = useMemo(
|
||||||
() => [...agents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 6),
|
() => [...agents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 8),
|
||||||
[agents]
|
[agents]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1);
|
||||||
<div className="page fade-in">
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<h1>Fleet Dashboard</h1>
|
|
||||||
<p className="page-subtitle">Live monitoring for every installed miner on your network</p>
|
|
||||||
</div>
|
|
||||||
<div className="header-status">
|
|
||||||
<span className={`status-dot ${isConnected ? 'online' : 'offline'}`} />
|
|
||||||
<span className="status-text">{isConnected ? 'Live feed' : 'Reconnecting...'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid-4 stats-grid">
|
return (
|
||||||
<div className="card stat-card accent-cyan">
|
<div className="page fade-in command-deck">
|
||||||
<div className="stat-label">Fleet Hashrate</div>
|
<header className="deck-hero">
|
||||||
<div className="stat-value hashrate">{formatHashrate(totalHashrate)}</div>
|
<div className="deck-hero-text">
|
||||||
<div className="stat-sub">{onlineCount} miners active</div>
|
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
|
||||||
|
<h1>Command Deck</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
Your private mining fleet across the LAN — brass gauges, neon pulse, real-time hashrate.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card stat-card accent-green">
|
<div className="deck-hero-status">
|
||||||
<div className="stat-label">Online</div>
|
<div className={`live-beacon ${isConnected ? 'on' : ''}`}>
|
||||||
<div className="stat-value">{onlineCount} / {agents.length}</div>
|
<span className="beacon-ring" />
|
||||||
<div className="stat-sub">{agents.length - onlineCount} offline</div>
|
<span className="beacon-core" />
|
||||||
</div>
|
</div>
|
||||||
<div className="card stat-card accent-purple">
|
<div>
|
||||||
<div className="stat-label">Accept Rate</div>
|
<span className="font-tech live-label">{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'}</span>
|
||||||
<div className="stat-value accepted">{acceptRate}%</div>
|
<span className="live-sub">{agents.length} nodes registered</span>
|
||||||
<div className="stat-sub">{acceptedShares} good · {rejectedShares} bad</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="card stat-card accent-yellow">
|
</div>
|
||||||
<div className="stat-label">Resource Avg</div>
|
</header>
|
||||||
|
|
||||||
|
<section className="gauge-row">
|
||||||
|
<NeonCard accent="cyan" className="gauge-card" hud>
|
||||||
|
<GaugeRing
|
||||||
|
value={totalHashrate}
|
||||||
|
max={Math.max(totalHashrate * 1.2, 1000)}
|
||||||
|
label="Fleet Hash"
|
||||||
|
sublabel="15m avg"
|
||||||
|
color="var(--neon-cyan)"
|
||||||
|
size={110}
|
||||||
|
/>
|
||||||
|
</NeonCard>
|
||||||
|
<NeonCard accent="green" className="gauge-card" hud>
|
||||||
|
<GaugeRing value={onlinePct} label="Online" sublabel={`${onlineCount}/${agents.length}`} color="var(--neon-green)" size={110} />
|
||||||
|
</NeonCard>
|
||||||
|
<NeonCard accent="purple" className="gauge-card" hud>
|
||||||
|
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
|
||||||
|
</NeonCard>
|
||||||
|
<NeonCard accent="amber" className="gauge-card" hud>
|
||||||
|
<GaugeRing value={avgCpu} label="CPU" sublabel={`RAM ${avgMem.toFixed(0)}%`} color="var(--neon-amber)" size={110} />
|
||||||
|
</NeonCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid-4 stats-grid steampunk-stats">
|
||||||
|
<NeonCard accent="cyan" className="stat-card-wrap">
|
||||||
|
<div className="stat-label font-tech">Total Hashrate</div>
|
||||||
|
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
|
||||||
|
<div className="stat-sub">{onlineCount} engines firing</div>
|
||||||
|
</NeonCard>
|
||||||
|
<NeonCard accent="green" className="stat-card-wrap">
|
||||||
|
<div className="stat-label font-tech">Fleet Online</div>
|
||||||
|
<div className="stat-value accepted">{onlineCount} <span className="stat-dim">/ {agents.length}</span></div>
|
||||||
|
<div className="stat-sub">{agents.length - onlineCount} dormant</div>
|
||||||
|
</NeonCard>
|
||||||
|
<NeonCard accent="purple" className="stat-card-wrap">
|
||||||
|
<div className="stat-label font-tech">Accept Rate</div>
|
||||||
|
<div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
|
||||||
|
<div className="stat-sub">{acceptedShares} valid · {rejectedShares} rejected</div>
|
||||||
|
</NeonCard>
|
||||||
|
<NeonCard accent="amber" className="stat-card-wrap">
|
||||||
|
<div className="stat-label font-tech">Resources</div>
|
||||||
<div className="stat-value">{avgCpu.toFixed(0)}% CPU</div>
|
<div className="stat-value">{avgCpu.toFixed(0)}% CPU</div>
|
||||||
<div className="stat-sub">{avgMem.toFixed(0)}% RAM fleet average</div>
|
<div className="stat-sub">{avgMem.toFixed(0)}% memory · fleet mean</div>
|
||||||
</div>
|
</NeonCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-2 chart-row">
|
<div className="grid-2 chart-row">
|
||||||
<div className="card">
|
<NeonCard accent="cyan" tilt3d>
|
||||||
<HashrateChart data={hashHistory} title="Fleet Hashrate (15m window)" color="#06b6d4" unit="H/s" />
|
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
|
||||||
</div>
|
</NeonCard>
|
||||||
<div className="card">
|
<NeonCard accent="magenta" tilt3d>
|
||||||
<HashrateChart data={cpuHistory} title="Average CPU Usage" color="#8b5cf6" unit="%" />
|
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={300} />
|
||||||
</div>
|
</NeonCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section">
|
<NeonCard accent="brass" className="chart-row-full" tilt3d>
|
||||||
<h2>Machine Detail</h2>
|
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
|
||||||
|
</NeonCard>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<h2 className="section-title font-display">
|
||||||
|
<span className="section-ornament">◆</span> Machine Roster
|
||||||
|
<span className="section-line" />
|
||||||
|
</h2>
|
||||||
<div className="agent-grid">
|
<div className="agent-grid">
|
||||||
{agents.length === 0 && (
|
{agents.length === 0 && (
|
||||||
<div className="card empty-state">
|
<NeonCard accent="brass" className="empty-state">
|
||||||
<h3>No miners connected yet</h3>
|
<div className="empty-icon">⚙</div>
|
||||||
<p>Build an installer in Miner Builder, run it once on a Windows PC, and it will appear here automatically.</p>
|
<h3>No miners on the wire</h3>
|
||||||
</div>
|
<p>Forge an installer, deploy once per machine — nodes appear here with live neon telemetry.</p>
|
||||||
|
</NeonCard>
|
||||||
)}
|
)}
|
||||||
{topAgents.map((agent) => (
|
{topAgents.map((agent, i) => (
|
||||||
<div key={agent.id} className="card agent-card detailed">
|
<NeonCard
|
||||||
|
key={agent.id}
|
||||||
|
accent={agent.status === 'online' ? 'cyan' : 'brass'}
|
||||||
|
className="agent-card detailed machine-panel"
|
||||||
|
style={{ animationDelay: `${i * 0.05}s` } as CSSProperties}
|
||||||
|
>
|
||||||
<div className="agent-card-header">
|
<div className="agent-card-header">
|
||||||
<div className="agent-name">
|
<div className="agent-name">
|
||||||
<span className={`status-dot ${agent.status}`} />
|
<span className={`status-dot ${agent.status}`} />
|
||||||
@@ -98,26 +149,33 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="agent-hash-bar">
|
||||||
|
<div
|
||||||
|
className="agent-hash-fill"
|
||||||
|
style={{ width: `${(agent.hashrate_15m / maxAgentHash) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="agent-detail-lines">
|
<div className="agent-detail-lines">
|
||||||
<div><span>Hashrate 15m</span><strong>{formatHashrate(agent.hashrate_15m)}</strong></div>
|
<div><span>Hash 15m</span><strong className="neon-glow-cyan">{formatHashrate(agent.hashrate_15m)}</strong></div>
|
||||||
<div><span>Hashrate 1m</span><strong>{formatHashrate(agent.hashrate_1m)}</strong></div>
|
<div><span>Hash 1m</span><strong>{formatHashrate(agent.hashrate_1m)}</strong></div>
|
||||||
<div><span>Hashrate 15s</span><strong>{formatHashrate(agent.hashrate_15s)}</strong></div>
|
|
||||||
<div><span>CPU / RAM</span><strong>{agent.cpu_usage_pct.toFixed(0)}% / {agent.memory_usage_pct.toFixed(0)}%</strong></div>
|
<div><span>CPU / RAM</span><strong>{agent.cpu_usage_pct.toFixed(0)}% / {agent.memory_usage_pct.toFixed(0)}%</strong></div>
|
||||||
<div><span>Cores / RAM</span><strong>{agent.cpu_cores} cores · {agent.memory_gb} GB</strong></div>
|
<div><span>Hardware</span><strong>{agent.cpu_cores} cores · {agent.memory_gb} GB</strong></div>
|
||||||
<div><span>Shares</span><strong>{agent.shares_good} ok · {agent.shares_bad} bad · {agent.shares_total} total</strong></div>
|
<div><span>Shares</span><strong>{agent.shares_good} ok · {agent.shares_bad} bad</strong></div>
|
||||||
<div><span>Network</span><strong>{agent.ip || 'unknown'} · v{agent.version || '?'}</strong></div>
|
<div><span>Node</span><strong className="mono-sm">{agent.ip || '—'} · {agent.id.slice(0, 8)}</strong></div>
|
||||||
<div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
|
<div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
|
||||||
<div><span>Last seen</span><strong>{new Date(agent.last_seen).toLocaleString()}</strong></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</NeonCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="section">
|
<section className="section">
|
||||||
<h2>Recent Shares</h2>
|
<h2 className="section-title font-display">
|
||||||
<div className="card table-card">
|
<span className="section-ornament">◆</span> Share Log
|
||||||
<table className="shares-table">
|
<span className="section-line" />
|
||||||
|
</h2>
|
||||||
|
<NeonCard accent="brass" className="table-card" hud>
|
||||||
|
<table className="shares-table steampunk-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
@@ -128,24 +186,24 @@ export default function DashboardPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{shares.length === 0 && (
|
{shares.length === 0 && (
|
||||||
<tr><td colSpan={4} className="empty-table">No shares submitted yet</td></tr>
|
<tr><td colSpan={4} className="empty-table">No shares yet — awaiting proof of work...</td></tr>
|
||||||
)}
|
)}
|
||||||
{shares.map((share) => (
|
{shares.map((share) => (
|
||||||
<tr key={share.id}>
|
<tr key={share.id}>
|
||||||
<td className="time-cell">{formatTime(share.timestamp)}</td>
|
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td>
|
||||||
<td>{share.agent_id?.substring(0, 8)}...</td>
|
<td className="mono-sm">{share.agent_id?.substring(0, 8)}…</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
|
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
|
||||||
{share.accepted ? 'Accepted' : 'Rejected'}
|
{share.accepted ? 'Accepted' : 'Rejected'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="hash-cell">{share.hash?.substring(0, 20)}...</td>
|
<td className="hash-cell mono-sm">{share.hash?.substring(0, 24)}…</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</NeonCard>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,16 +231,28 @@
|
|||||||
|
|
||||||
.agent-list-item {
|
.agent-list-item {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.25s ease;
|
||||||
|
padding: 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-list-item:hover {
|
.agent-list-item:hover {
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 16px rgba(0, 245, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-list-item.selected {
|
.agent-list-item.selected {
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--neon-cyan);
|
||||||
background: rgba(59, 130, 246, 0.05);
|
background: rgba(0, 245, 255, 0.06);
|
||||||
|
box-shadow: inset 0 0 24px rgba(0, 245, 255, 0.08), 0 0 20px rgba(0, 245, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border-brass);
|
||||||
|
background: rgba(8, 6, 4, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-list-header {
|
.agent-list-header {
|
||||||
@@ -692,3 +704,272 @@
|
|||||||
.table-card {
|
.table-card {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ Steampunk Command Deck ═══ */
|
||||||
|
.command-deck {
|
||||||
|
max-width: 1600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-brass);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-hero::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
width: 120px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--neon-cyan), transparent);
|
||||||
|
box-shadow: 0 0 12px var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-eyebrow {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
color: var(--brass);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-hero h1 {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
background: linear-gradient(180deg, var(--brass-light) 20%, var(--text-primary) 80%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-hero-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: rgba(12, 10, 8, 0.8);
|
||||||
|
border: 1px solid var(--border-brass);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-beacon {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beacon-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px solid var(--text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-beacon.on .beacon-ring {
|
||||||
|
border-color: var(--neon-green);
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 20px rgba(57, 255, 20, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beacon-core {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-beacon.on .beacon-core {
|
||||||
|
background: var(--neon-green);
|
||||||
|
box-shadow: 0 0 12px var(--neon-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-sub {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.25rem !important;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steampunk-stats .stat-card-wrap {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steampunk-stats .stat-label {
|
||||||
|
color: var(--brass-light);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steampunk-stats .stat-value {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 1.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-dim {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-glow-cyan {
|
||||||
|
color: var(--neon-cyan) !important;
|
||||||
|
text-shadow: 0 0 20px rgba(0, 245, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-glow-purple {
|
||||||
|
color: var(--neon-purple) !important;
|
||||||
|
text-shadow: 0 0 20px rgba(178, 75, 243, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-row-full {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--brass-light);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-ornament {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-shadow: 0 0 8px var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--border-brass), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-panel {
|
||||||
|
animation: fadeInSteampunk 0.5s ease backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-hash-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(201, 162, 39, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-hash-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan));
|
||||||
|
box-shadow: 0 0 10px rgba(0, 245, 255, 0.4);
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-detail-lines div {
|
||||||
|
border-bottom-color: rgba(201, 162, 39, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steampunk-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steampunk-table th {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--brass);
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steampunk-table td {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(201, 162, 39, 0.08);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steampunk-table tbody tr:hover {
|
||||||
|
background: rgba(0, 245, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono-sm {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--brass);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
animation: gear-spin 30s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--brass-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.gauge-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.deck-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.gauge-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { ServerConfig } from '../types';
|
import type { ServerConfig } from '../types';
|
||||||
import { HelpTip } from '../components/HelpTip';
|
import { HelpTip } from '../components/HelpTip';
|
||||||
|
import NeonCard from '../components/NeonCard/NeonCard';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -47,9 +48,14 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="page fade-in">
|
<div className="page fade-in command-deck">
|
||||||
<div className="page-header"><h1>Settings</h1></div>
|
<header className="deck-hero">
|
||||||
<div className="card"><p>Loading settings...</p></div>
|
<div className="deck-hero-text">
|
||||||
|
<p className="deck-eyebrow font-tech">CALIBRATION</p>
|
||||||
|
<h1>System Calibrate</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<NeonCard accent="brass"><p>Loading settings...</p></NeonCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,13 +70,17 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page fade-in">
|
<div className="page fade-in command-deck">
|
||||||
<div className="page-header">
|
<header className="deck-hero">
|
||||||
<h1>Settings</h1>
|
<div className="deck-hero-text">
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
<p className="deck-eyebrow font-tech">CALIBRATION</p>
|
||||||
{saving ? '💾 Saving...' : '💾 Save Settings'}
|
<h1>System Calibrate</h1>
|
||||||
</button>
|
<p className="page-subtitle">Pool, wallet, defaults, and install paths for the forge.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
{saveMessage && (
|
{saveMessage && (
|
||||||
<div className={`save-message ${saveMessage.includes('✅') ? 'success' : 'error'}`}>
|
<div className={`save-message ${saveMessage.includes('✅') ? 'success' : 'error'}`}>
|
||||||
|
|||||||
300
server/web/src/styles/steampunk-theme.css
Normal file
300
server/web/src/styles/steampunk-theme.css
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/* Steampunk Neon Command Deck — design tokens */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=Orbitron:wght@400;500;600;700&family=Rajdhani:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-void: #060504;
|
||||||
|
--bg-deep: #0c0a08;
|
||||||
|
--bg-panel: rgba(18, 14, 10, 0.85);
|
||||||
|
--bg-panel-solid: #14100c;
|
||||||
|
--bg-hover: rgba(40, 32, 24, 0.9);
|
||||||
|
|
||||||
|
--brass: #c9a227;
|
||||||
|
--brass-light: #e8c547;
|
||||||
|
--brass-dark: #6b4f12;
|
||||||
|
--copper: #b87333;
|
||||||
|
--copper-glow: rgba(184, 115, 51, 0.4);
|
||||||
|
|
||||||
|
--neon-cyan: #00f5ff;
|
||||||
|
--neon-magenta: #ff2da6;
|
||||||
|
--neon-amber: #ffb020;
|
||||||
|
--neon-green: #39ff14;
|
||||||
|
--neon-purple: #b24bf3;
|
||||||
|
|
||||||
|
--text-primary: #f4ebe0;
|
||||||
|
--text-secondary: #c4b5a0;
|
||||||
|
--text-muted: #7a6f62;
|
||||||
|
|
||||||
|
--border-brass: rgba(201, 162, 39, 0.35);
|
||||||
|
--border-neon: rgba(0, 245, 255, 0.25);
|
||||||
|
--shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 1px rgba(201, 162, 39, 0.3);
|
||||||
|
--shadow-neon-cyan: 0 0 20px rgba(0, 245, 255, 0.35), 0 0 60px rgba(0, 245, 255, 0.1);
|
||||||
|
--shadow-neon-magenta: 0 0 20px rgba(255, 45, 166, 0.3);
|
||||||
|
|
||||||
|
--font-display: 'Cinzel Decorative', Georgia, serif;
|
||||||
|
--font-tech: 'Orbitron', monospace;
|
||||||
|
--font-body: 'Rajdhani', 'Segoe UI', sans-serif;
|
||||||
|
|
||||||
|
--perspective: 1200px;
|
||||||
|
--card-tilt: 2deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy variable bridge */
|
||||||
|
:root {
|
||||||
|
--bg-primary: var(--bg-void);
|
||||||
|
--bg-secondary: var(--bg-deep);
|
||||||
|
--bg-card: var(--bg-panel-solid);
|
||||||
|
--border-color: var(--border-brass);
|
||||||
|
--accent-green: var(--neon-green);
|
||||||
|
--accent-red: #ff4466;
|
||||||
|
--accent-yellow: var(--neon-amber);
|
||||||
|
--accent-blue: var(--neon-cyan);
|
||||||
|
--accent-purple: var(--neon-purple);
|
||||||
|
--accent-cyan: var(--neon-cyan);
|
||||||
|
--online-color: var(--neon-green);
|
||||||
|
--offline-color: var(--text-muted);
|
||||||
|
--error-color: #ff4466;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, .font-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-tech {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brass rivet texture overlay on cards */
|
||||||
|
.card,
|
||||||
|
.neon-card {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(145deg, rgba(28, 22, 16, 0.95) 0%, rgba(12, 10, 8, 0.98) 100%);
|
||||||
|
border: 1px solid var(--border-brass);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before,
|
||||||
|
.neon-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 245, 255, 0.08), transparent 50%),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
transparent 48px,
|
||||||
|
rgba(201, 162, 39, 0.03) 48px,
|
||||||
|
rgba(201, 162, 39, 0.03) 49px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card > *,
|
||||||
|
.neon-card > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3D panel tilt on hover */
|
||||||
|
.neon-card-3d {
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-card-3d:hover {
|
||||||
|
transform: perspective(var(--perspective)) rotateX(2deg) translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-panel), var(--shadow-neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner brackets (steampunk HUD) */
|
||||||
|
.hud-corners {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-corners::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-image: linear-gradient(135deg, var(--brass) 0%, transparent 30%, transparent 70%, var(--neon-cyan) 100%) 1;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons — brass + neon */
|
||||||
|
.btn {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(180deg, #1a4a52 0%, #0d2a30 100%);
|
||||||
|
border: 1px solid var(--neon-cyan);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 245, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
box-shadow: var(--shadow-neon-cyan);
|
||||||
|
background: linear-gradient(180deg, #1f5a64 0%, #103540 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: linear-gradient(180deg, #1a3d1a 0%, #0d200d 100%);
|
||||||
|
border: 1px solid var(--neon-green);
|
||||||
|
color: var(--neon-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
box-shadow: 0 0 20px rgba(57, 255, 20, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
border: 1px solid var(--border-brass);
|
||||||
|
color: var(--brass-light);
|
||||||
|
background: rgba(20, 16, 12, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 12px rgba(0, 245, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs — gauge panel style */
|
||||||
|
.input,
|
||||||
|
.select {
|
||||||
|
background: rgba(8, 6, 4, 0.9);
|
||||||
|
border: 1px solid var(--border-brass);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus,
|
||||||
|
.select:focus {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 245, 255, 0.15), inset 0 0 20px rgba(0, 245, 255, 0.05);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges — neon glow */
|
||||||
|
.status-badge.online {
|
||||||
|
background: rgba(57, 255, 20, 0.12);
|
||||||
|
color: var(--neon-green);
|
||||||
|
border: 1px solid rgba(57, 255, 20, 0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(57, 255, 20, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background: rgba(122, 111, 98, 0.15);
|
||||||
|
border: 1px solid rgba(122, 111, 98, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.error {
|
||||||
|
background: rgba(255, 68, 102, 0.12);
|
||||||
|
color: #ff6688;
|
||||||
|
border: 1px solid rgba(255, 68, 102, 0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(255, 68, 102, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
background: var(--neon-green);
|
||||||
|
box-shadow: 0 0 8px var(--neon-green), 0 0 16px rgba(57, 255, 20, 0.5);
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.offline {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.7; transform: scale(0.92); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanline {
|
||||||
|
0% { transform: translateY(-100%); }
|
||||||
|
100% { transform: translateY(100vh); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gear-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-orb {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.4; }
|
||||||
|
50% { transform: translate(20px, -30px) scale(1.1); opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes grid-drift {
|
||||||
|
0% { background-position: 0 0; }
|
||||||
|
100% { background-position: 60px 60px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% center; }
|
||||||
|
100% { background-position: 200% center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeInSteampunk 0.6s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInSteampunk {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px) perspective(800px) rotateX(4deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) perspective(800px) rotateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, var(--brass-dark), var(--copper));
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--brass);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user