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:
598
server/web/src/pages/CruciblePage.tsx
Normal file
598
server/web/src/pages/CruciblePage.tsx
Normal 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@<ip></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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user