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:
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { Agent, HashrateSample } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import './Pages.css';
|
||||
|
||||
export default function AgentsPage() {
|
||||
@@ -28,29 +29,33 @@ export default function AgentsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in">
|
||||
<div className="page-header">
|
||||
<h1>Agents</h1>
|
||||
<span className="header-count">{agents.length} total</span>
|
||||
</div>
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<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>
|
||||
<span className="header-count font-tech">{agents.length} NODES</span>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="card empty-state">
|
||||
<p>Loading agents...</p>
|
||||
</div>
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<p>Scanning network...</p>
|
||||
</NeonCard>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="card empty-state">
|
||||
<div className="empty-icon">🖥️</div>
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<div className="empty-icon">⚙</div>
|
||||
<h3>No agents registered</h3>
|
||||
<p>Deploy a miner to a Windows machine and it will appear here automatically.</p>
|
||||
</div>
|
||||
</NeonCard>
|
||||
) : (
|
||||
<div className="agents-layout">
|
||||
<div className="agents-list">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
<div className="agent-list-header">
|
||||
@@ -76,8 +81,8 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<div className="agent-detail card">
|
||||
<h2>{selectedAgent.name}</h2>
|
||||
<NeonCard accent="cyan" className="agent-detail" hud>
|
||||
<h2 className="font-display">{selectedAgent.name}</h2>
|
||||
<div className="agent-detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Status</span>
|
||||
@@ -153,11 +158,12 @@ export default function AgentsPage() {
|
||||
|
||||
{hashrateHistory.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h3>Hashrate History</h3>
|
||||
<h3 className="font-tech">HASHRATE TELEMETRY</h3>
|
||||
<HashrateChart
|
||||
title=""
|
||||
color="#22c55e"
|
||||
color="#00f5ff"
|
||||
unit="H/s"
|
||||
height={240}
|
||||
data={[...hashrateHistory].reverse().map((s) => ({
|
||||
time: new Date(s.timestamp).toLocaleTimeString(),
|
||||
value: s.hashrate,
|
||||
@@ -165,25 +171,7 @@ export default function AgentsPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</NeonCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -141,13 +141,17 @@ export default function BuilderPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page fade-in">
|
||||
<div className="page-header">
|
||||
<h1>Miner Builder</h1>
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<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}>
|
||||
Recent Builds
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="builder-layout builder-layout-wide">
|
||||
<div className="card cheat-sheet-panel">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
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 HashrateChart from '../components/Charts/HashrateChart';
|
||||
import GaugeRing from '../components/Charts/GaugeRing';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import './Pages.css';
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -10,6 +12,7 @@ export default function DashboardPage() {
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
|
||||
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
|
||||
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
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 acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 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 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(() => {
|
||||
const now = new Date().toLocaleTimeString();
|
||||
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
|
||||
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(
|
||||
() => [...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]
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1);
|
||||
|
||||
<div className="grid-4 stats-grid">
|
||||
<div className="card stat-card accent-cyan">
|
||||
<div className="stat-label">Fleet Hashrate</div>
|
||||
<div className="stat-value hashrate">{formatHashrate(totalHashrate)}</div>
|
||||
<div className="stat-sub">{onlineCount} miners active</div>
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<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 className="card stat-card accent-green">
|
||||
<div className="stat-label">Online</div>
|
||||
<div className="stat-value">{onlineCount} / {agents.length}</div>
|
||||
<div className="stat-sub">{agents.length - onlineCount} offline</div>
|
||||
<div className="deck-hero-status">
|
||||
<div className={`live-beacon ${isConnected ? 'on' : ''}`}>
|
||||
<span className="beacon-ring" />
|
||||
<span className="beacon-core" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-tech live-label">{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'}</span>
|
||||
<span className="live-sub">{agents.length} nodes registered</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card stat-card accent-purple">
|
||||
<div className="stat-label">Accept Rate</div>
|
||||
<div className="stat-value accepted">{acceptRate}%</div>
|
||||
<div className="stat-sub">{acceptedShares} good · {rejectedShares} bad</div>
|
||||
</div>
|
||||
<div className="card stat-card accent-yellow">
|
||||
<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-sub">{avgMem.toFixed(0)}% RAM fleet average</div>
|
||||
</div>
|
||||
<div className="stat-sub">{avgMem.toFixed(0)}% memory · fleet mean</div>
|
||||
</NeonCard>
|
||||
</div>
|
||||
|
||||
<div className="grid-2 chart-row">
|
||||
<div className="card">
|
||||
<HashrateChart data={hashHistory} title="Fleet Hashrate (15m window)" color="#06b6d4" unit="H/s" />
|
||||
</div>
|
||||
<div className="card">
|
||||
<HashrateChart data={cpuHistory} title="Average CPU Usage" color="#8b5cf6" unit="%" />
|
||||
</div>
|
||||
<NeonCard accent="cyan" tilt3d>
|
||||
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="magenta" tilt3d>
|
||||
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={300} />
|
||||
</NeonCard>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h2>Machine Detail</h2>
|
||||
<NeonCard accent="brass" className="chart-row-full" tilt3d>
|
||||
<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">
|
||||
{agents.length === 0 && (
|
||||
<div className="card empty-state">
|
||||
<h3>No miners connected yet</h3>
|
||||
<p>Build an installer in Miner Builder, run it once on a Windows PC, and it will appear here automatically.</p>
|
||||
</div>
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<div className="empty-icon">⚙</div>
|
||||
<h3>No miners on the wire</h3>
|
||||
<p>Forge an installer, deploy once per machine — nodes appear here with live neon telemetry.</p>
|
||||
</NeonCard>
|
||||
)}
|
||||
{topAgents.map((agent) => (
|
||||
<div key={agent.id} className="card agent-card detailed">
|
||||
{topAgents.map((agent, i) => (
|
||||
<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-name">
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
@@ -98,26 +149,33 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
<div className="agent-detail-lines">
|
||||
<div><span>Hashrate 15m</span><strong>{formatHashrate(agent.hashrate_15m)}</strong></div>
|
||||
<div><span>Hashrate 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>Cores / RAM</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>Network</span><strong>{agent.ip || 'unknown'} · v{agent.version || '?'}</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 className="agent-hash-bar">
|
||||
<div
|
||||
className="agent-hash-fill"
|
||||
style={{ width: `${(agent.hashrate_15m / maxAgentHash) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-detail-lines">
|
||||
<div><span>Hash 15m</span><strong className="neon-glow-cyan">{formatHashrate(agent.hashrate_15m)}</strong></div>
|
||||
<div><span>Hash 1m</span><strong>{formatHashrate(agent.hashrate_1m)}</strong></div>
|
||||
<div><span>CPU / RAM</span><strong>{agent.cpu_usage_pct.toFixed(0)}% / {agent.memory_usage_pct.toFixed(0)}%</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</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>
|
||||
</NeonCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="section">
|
||||
<h2>Recent Shares</h2>
|
||||
<div className="card table-card">
|
||||
<table className="shares-table">
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Share Log
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<NeonCard accent="brass" className="table-card" hud>
|
||||
<table className="shares-table steampunk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
@@ -128,24 +186,24 @@ export default function DashboardPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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) => (
|
||||
<tr key={share.id}>
|
||||
<td className="time-cell">{formatTime(share.timestamp)}</td>
|
||||
<td>{share.agent_id?.substring(0, 8)}...</td>
|
||||
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td>
|
||||
<td className="mono-sm">{share.agent_id?.substring(0, 8)}…</td>
|
||||
<td>
|
||||
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
|
||||
{share.accepted ? 'Accepted' : 'Rejected'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hash-cell">{share.hash?.substring(0, 20)}...</td>
|
||||
<td className="hash-cell mono-sm">{share.hash?.substring(0, 24)}…</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</NeonCard>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -231,16 +231,28 @@
|
||||
|
||||
.agent-list-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.25s ease;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-color: var(--accent-blue);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
border-color: var(--neon-cyan);
|
||||
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 {
|
||||
@@ -692,3 +704,272 @@
|
||||
.table-card {
|
||||
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 type { ServerConfig } from '../types';
|
||||
import { HelpTip } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import './Pages.css';
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -47,9 +48,14 @@ export default function SettingsPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page fade-in">
|
||||
<div className="page-header"><h1>Settings</h1></div>
|
||||
<div className="card"><p>Loading settings...</p></div>
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -64,13 +70,17 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page fade-in">
|
||||
<div className="page-header">
|
||||
<h1>Settings</h1>
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">CALIBRATION</p>
|
||||
<h1>System Calibrate</h1>
|
||||
<p className="page-subtitle">Pool, wallet, defaults, and install paths for the forge.</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? '💾 Saving...' : '💾 Save Settings'}
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{saveMessage && (
|
||||
<div className={`save-message ${saveMessage.includes('✅') ? 'success' : 'error'}`}>
|
||||
|
||||
Reference in New Issue
Block a user