feat: Build Manager, Crucible ops deck, branding, and portable Cloudflare tunnel

Add Build Manager with pin-to-dropper, Crucible multi-node terminal with SSH probe/wake, Command Deck chart balance and pretty stats, AetherForge logo and sacred geometry UI, Field Guide refresh, and LAUNCH.bat Cloudflare MSI + token service install flow.
This commit is contained in:
AetherForge
2026-05-30 22:38:48 -07:00
parent e6b8d84edf
commit 9232f4c448
45 changed files with 4222 additions and 406 deletions

View File

@@ -0,0 +1,598 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import type { Agent } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import { formatHashrate } from '../help/fleetFilters';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
type ShellType = 'powershell' | 'exec' | 'sh';
interface TermLine {
id: string;
agentId: string;
agentName: string;
isCmd: boolean;
text: string;
ts: Date;
success?: boolean;
}
interface NodeGroup {
id: string;
name: string;
agentIds: Set<string>;
}
// ── Helpers ────────────────────────────────────────────────────────────────
const AGENT_COLORS = [
'#00f5ff', '#39ff14', '#ff2da6', '#b24bf3',
'#ffb020', '#ff6b35', '#00d4aa', '#f72585',
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
];
function agentColor(agentId: string, allIds: string[]): string {
const idx = allIds.indexOf(agentId);
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
}
function sshBadge(agent: Agent) {
if (agent.ssh_available === true) return { label: 'SSH ON', cls: 'ssh-on' };
if (agent.ssh_available === false) return { label: 'SSH OFF', cls: 'ssh-off' };
return { label: 'SSH ?', cls: 'ssh-unk' };
}
function platformIcon(platform?: string): string {
if (!platform) return '⬡';
const p = platform.toLowerCase();
if (p.includes('win')) return '⊞';
if (p.includes('linux')) return '🐧';
if (p.includes('darwin')) return '';
return '⬡';
}
let _lineId = 0;
function mkId() { return `tl-${++_lineId}`; }
// ── Wake SSH commands ──────────────────────────────────────────────────────
const WAKE_SSH_PS = `
$ErrorActionPreference='SilentlyContinue'
$cap=Get-WindowsCapability -Online -Name OpenSSH.Server~~~~* 2>$null
if ($cap -and $cap.State -ne 'Installed'){Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0}
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
$ip=(Get-NetIPAddress -AddressFamily IPv4|Where{$_.InterfaceAlias -notlike '*Loopback*'}|Select -First 1).IPAddress
"SSH_WAKE_OK ip=$ip port=22"
`.trim();
const PROBE_SSH_PS = `
$s=Get-Service sshd -ErrorAction SilentlyContinue
if($s -and $s.Status -eq 'Running'){'SSH_PROBE:ONLINE'}else{'SSH_PROBE:OFFLINE'}
`.trim();
const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONLINE || echo SSH_PROBE:OFFLINE`;
// ── Component ──────────────────────────────────────────────────────────────
export default function CruciblePage() {
const { agents, commandResults } = useWebSocket();
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [groups, setGroups] = useState<NodeGroup[]>([]);
const [groupNameInput, setGroupNameInput] = useState('');
// Terminal
const [termLines, setTermLines] = useState<TermLine[]>([]);
const [cmd, setCmd] = useState('');
const [shellType, setShellType] = useState<ShellType>('powershell');
const [busy, setBusy] = useState(false);
const termEndRef = useRef<HTMLDivElement>(null);
const cmdRef = useRef<HTMLInputElement>(null);
const lastSeqRef = useRef(0);
// Command history
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
// SSH status overrides (from probe results)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
const selectedAgents = useMemo(
() => agents.filter((a) => selectedIds.has(a.id)),
[agents, selectedIds]
);
const online = (a: Agent) => a.status === 'online';
// ── Auto-scroll terminal ───────────────────────────────────────────────
useEffect(() => {
termEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [termLines]);
// ── Process incoming command_result messages ───────────────────────────
useEffect(() => {
if (!commandResults || commandResults.length === 0) return;
const newEntries = commandResults.filter((r) => r._seq > lastSeqRef.current);
if (newEntries.length === 0) return;
lastSeqRef.current = newEntries[newEntries.length - 1]._seq;
const lines: TermLine[] = [];
for (const r of newEntries) {
const aid = r.agent_id;
if (!aid) continue;
// Only show results from agents that are selected (or all if nothing selected)
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
// Parse SSH probe results to update ssh status
const msg = r.message ?? '';
if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true }));
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: false }));
}
// Split multi-line output
const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) {
lines.push({
id: mkId(),
agentId: aid,
agentName: name,
isCmd: false,
text: line,
ts: new Date(),
success: r.success,
});
}
}
if (lines.length > 0) {
setTermLines((prev) => [...prev, ...lines].slice(-2000));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commandResults]);
// ── Selection helpers ──────────────────────────────────────────────────
const toggle = (id: string) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
const clearSel = () => setSelectedIds(new Set());
const addGroup = () => {
if (!groupNameInput.trim() || selectedIds.size === 0) return;
setGroups((prev) => [
...prev,
{ id: mkId(), name: groupNameInput.trim(), agentIds: new Set(selectedIds) },
]);
setGroupNameInput('');
};
const activateGroup = (g: NodeGroup) => setSelectedIds(new Set(g.agentIds));
const deleteGroup = (id: string) => setGroups((prev) => prev.filter((g) => g.id !== id));
// ── Dispatch command ───────────────────────────────────────────────────
const dispatch = useCallback(async (command: string, shell: ShellType, targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0 || !command.trim()) return;
setBusy(true);
// Echo command to terminal
const echoLines: TermLine[] = tgts.map((a) => ({
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: true,
text: command,
ts: new Date(),
}));
setTermLines((prev) => [...prev, ...echoLines].slice(-2000));
setCmdHistory((h) => [command, ...h].slice(0, 50));
setHistIdx(-1);
const action = shell === 'powershell' ? 'powershell'
: shell === 'sh' ? 'exec'
: 'exec';
await Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, action, { command }).catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
text: `[ERROR] ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
},
]);
})
)
);
setBusy(false);
cmdRef.current?.focus();
}, [selectedAgents]);
const sendCmd = () => {
dispatch(cmd, shellType);
setCmd('');
};
const probeSSH = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
for (const a of tgts) {
const isWin = a.platform?.toLowerCase().includes('win') ?? true;
const probeCmd = isWin ? PROBE_SSH_PS : PROBE_SSH_SH;
const shell: ShellType = isWin ? 'powershell' : 'sh';
dispatch(probeCmd, shell, [a]);
}
};
const wakeSSH = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
for (const a of tgts) {
const isWin = a.platform?.toLowerCase().includes('win') ?? true;
if (isWin) {
dispatch(WAKE_SSH_PS, 'powershell', [a]);
} else {
dispatch('which sshd && systemctl start sshd 2>/dev/null || service ssh start 2>/dev/null; echo SSH_WAKE_OK', 'sh', [a]);
}
}
};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = Math.min(histIdx + 1, cmdHistory.length - 1);
setHistIdx(next);
setCmd(cmdHistory[next] ?? '');
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.max(histIdx - 1, -1);
setHistIdx(next);
setCmd(next < 0 ? '' : cmdHistory[next] ?? '');
}
};
// ── Effective SSH status ───────────────────────────────────────────────
const sshStatus = (a: Agent) => {
const override = sshOverride[a.id];
if (override !== undefined) return { label: override ? 'SSH ON' : 'SSH OFF', cls: override ? 'ssh-on' : 'ssh-off' };
return sshBadge({ ...a });
};
// ── Render ─────────────────────────────────────────────────────────────
return (
<div className="page fade-in crucible-page">
<header className="deck-hero" style={{ marginBottom: '1rem' }}>
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">REMOTE OPERATIONS THEATER</p>
<h1>Crucible</h1>
<p className="page-subtitle">select nodes · group them · command them all at once</p>
</div>
<div className="deck-hero-status">
<div className="crucible-sel-summary">
<span className="font-tech" style={{ color: 'var(--neon-cyan)' }}>
{selectedIds.size > 0 ? `${selectedIds.size} selected` : 'none selected'}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
{agents.filter(online).length} online / {agents.length} total
</span>
</div>
<button className="button crucible-btn" onClick={selectAll}>Select Online</button>
<button className="button crucible-btn-muted" onClick={clearSel}>Clear</button>
</div>
</header>
{/* ── Node Roster ─────────────────────────────────────────────────── */}
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> NODE ROSTER
</div>
{agents.length === 0 ? (
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes registered. Forge a build and deploy it to your machines.</p>
) : (
<div className="crucible-roster">
{agents.map((a) => {
const sel = selectedIds.has(a.id);
const isOn = online(a);
const ssh = sshStatus(a);
const color = agentColor(a.id, allIds);
return (
<div
key={a.id}
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
style={sel ? { '--sel-color': color } as React.CSSProperties : undefined}
onClick={() => toggle(a.id)}
>
<div className="crucible-node-check">
<span className={`cn-checkbox ${sel ? 'checked' : ''}`}
style={sel ? { borderColor: color, background: color + '33' } : undefined} />
</div>
<div className="crucible-node-body">
<div className="cn-name" style={sel ? { color } : undefined}>
<span className="cn-platform">{platformIcon(a.platform)}</span>
{a.name}
</div>
<div className="cn-meta">
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
<span className={`cn-status-dot ${isOn ? 'on' : 'off'}`} />
</div>
<div className="cn-ip font-tech">{a.ip || '—'}</div>
<div className="cn-stats">
<span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span>
</div>
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
</div>
</div>
);
})}
</div>
)}
</NeonCard>
{/* ── Groups & Actions ────────────────────────────────────────────── */}
<div className="crucible-row">
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> GROUPS
</div>
<div className="crucible-groups-list">
{groups.length === 0 && (
<p className="form-hint" style={{ margin: 0 }}>
Select nodes above, name a group, save it here.
</p>
)}
{groups.map((g) => (
<div key={g.id} className="crucible-group-item" onClick={() => activateGroup(g)}>
<span className="cg-name">{g.name}</span>
<span className="cg-count">{g.agentIds.size} nodes</span>
<button className="cg-del" onClick={(e) => { e.stopPropagation(); deleteGroup(g.id); }}>×</button>
</div>
))}
</div>
<div className="crucible-group-new">
<input
className="crucible-input"
value={groupNameInput}
onChange={(e) => setGroupNameInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addGroup()}
placeholder={`Name group (${selectedIds.size} selected)…`}
/>
<button className="button crucible-btn" onClick={addGroup} disabled={!groupNameInput.trim() || selectedIds.size === 0}>
Save
</button>
</div>
</NeonCard>
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> OPERATIONS
</div>
<div className="crucible-ops">
<div className="crucible-op-group">
<span className="cop-label">SSH</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probeSSH()}
title="Probe port 22 on selected nodes"
>
Probe SSH
</button>
<button
className="button crucible-op-btn crucible-op-wake"
disabled={selectedIds.size === 0}
onClick={() => wakeSSH()}
title="Install + start OpenSSH server on selected Windows nodes"
>
Wake SSH
</button>
</div>
<div className="crucible-op-group">
<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')))}
>
Resume All
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
>
Pause All
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Recon</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch('whoami', shellType)}
>
whoami
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'Get-ComputerInfo | Select CsName,WindowsVersion,OsArchitecture' : 'uname -a', shellType)}
>
sysinfo
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'ipconfig /all' : 'ip addr', shellType)}
>
ipconfig
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Shell</span>
<div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
<button
key={s}
className={`crucible-shell-tab ${shellType === s ? 'active' : ''}`}
onClick={() => setShellType(s)}
>
{s === 'powershell' ? 'PS' : s === 'exec' ? 'CMD' : 'SH'}
</button>
))}
</div>
</div>
{selectedIds.size > 0 && (
<div className="crucible-sel-chips">
{selectedAgents.map((a) => {
const color = agentColor(a.id, allIds);
const ssh = sshStatus(a);
return (
<span
key={a.id}
className="crucible-chip"
style={{ borderColor: color, color }}
onClick={() => toggle(a.id)}
title={`${a.ip ?? ''} · ${ssh.label}`}
>
{a.name} ×
</span>
);
})}
</div>
)}
</div>
</NeonCard>
</div>
{/* ── Terminal ────────────────────────────────────────────────────── */}
<NeonCard accent="green" className="crucible-term-card" tilt3d={false}>
<div className="crucible-term-header">
<div className="crucible-section-title font-tech" style={{ marginBottom: 0 }}>
<span className="section-ornament"></span> TERMINAL
<span className="crucible-term-target">
{selectedIds.size === 0
? '— select nodes above —'
: `${selectedIds.size} node${selectedIds.size > 1 ? 's' : ''}: ${selectedAgents.slice(0, 3).map((a) => a.name).join(', ')}${selectedAgents.length > 3 ? ` +${selectedAgents.length - 3}` : ''}`
}
</span>
</div>
<button className="crucible-btn-muted" onClick={() => setTermLines([])}>CLEAR</button>
</div>
<div className="crucible-terminal">
{termLines.length === 0 && (
<div className="crucible-term-empty">
Select nodes · type a command · press Enter
</div>
)}
{termLines.map((line) => {
const color = agentColor(line.agentId, allIds);
return (
<div
key={line.id}
className={`crucible-term-line ${line.isCmd ? 'cmd-line' : 'out-line'} ${line.success === false ? 'err-line' : ''}`}
>
<span className="ctl-agent" style={{ color }}>
{line.agentName.slice(0, 12).padEnd(12)}
</span>
<span className="ctl-arrow" style={{ color }}>
{line.isCmd ? '▶' : '◀'}
</span>
<span className="ctl-text">{line.text}</span>
<span className="ctl-ts">{line.ts.toLocaleTimeString()}</span>
</div>
);
})}
<div ref={termEndRef} />
</div>
<div className="crucible-term-input-row">
<span className="crucible-prompt font-tech">
[{shellType === 'powershell' ? 'PS' : shellType === 'exec' ? 'CMD' : 'SH'}]$
</span>
<input
ref={cmdRef}
className="crucible-term-input"
value={cmd}
onChange={(e) => setCmd(e.target.value)}
onKeyDown={handleKey}
placeholder={selectedIds.size === 0 ? 'select a node first…' : 'command…'}
disabled={busy || selectedIds.size === 0}
spellCheck={false}
autoComplete="off"
/>
<button
className={`button crucible-send-btn ${busy ? 'busy' : ''}`}
onClick={sendCmd}
disabled={busy || !cmd.trim() || selectedIds.size === 0}
>
{busy ? '…' : 'SEND'}
</button>
</div>
</NeonCard>
{/* ── SSH Access Info ─────────────────────────────────────────────── */}
<NeonCard accent="brass" className="crucible-ssh-info" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> SSH ACCESS NOTES
</div>
<div className="crucible-ssh-grid">
<div className="crucible-ssh-step">
<span className="css-num">1</span>
<div>
<strong>Probe</strong> click "Probe SSH" to test if port 22 is open on the target machine.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">2</span>
<div>
<strong>Wake SSH (Windows)</strong> installs OpenSSH Server via Windows capability, starts the service, marks it auto-start. Requires admin agent.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">3</span>
<div>
<strong>Connect directly</strong> if you're on the same LAN, <code className="crucible-code">ssh user@&lt;ip&gt;</code>. Node IP is shown on each card.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">4</span>
<div>
<strong>Remote (via tunnel)</strong> run the <code className="crucible-code">start_tunnel</code> action on the agent (use the Agents page), then the node punches out through your Cloudflare tunnel.
</div>
</div>
</div>
</NeonCard>
</div>
);
}