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:
191
server/web/src/pages/AgentsPage.tsx
Normal file
191
server/web/src/pages/AgentsPage.tsx
Normal 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`;
|
||||
}
|
||||
419
server/web/src/pages/BuilderPage.tsx
Normal file
419
server/web/src/pages/BuilderPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
server/web/src/pages/DashboardPage.tsx
Normal file
163
server/web/src/pages/DashboardPage.tsx
Normal 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();
|
||||
}
|
||||
616
server/web/src/pages/Pages.css
Normal file
616
server/web/src/pages/Pages.css
Normal 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;
|
||||
}
|
||||
363
server/web/src/pages/SettingsPage.tsx
Normal file
363
server/web/src/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user