Improve fleet control, Crucible ops, and multi-machine identity.

Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
This commit is contained in:
AetherForge
2026-06-02 19:19:50 -07:00
parent 5222f4ad39
commit 01d76b3730
32 changed files with 737 additions and 229 deletions

View File

@@ -6,21 +6,31 @@ const API_BASE = '/api/v1';
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const { headers: extraHeaders, ...rest } = options ?? {};
const res = await fetch(`${API_BASE}${url}`, {
...rest,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(extraHeaders as Record<string, string> | undefined),
},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> {
const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
if (callerSignal) {
callerSignal.addEventListener('abort', () => controller.abort());
}
try {
const res = await fetch(`${API_BASE}${url}`, {
...rest,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(extraHeaders as Record<string, string> | undefined),
},
});
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json();
} finally {
clearTimeout(timer);
}
return res.json();
}
export const api = {
@@ -154,6 +164,15 @@ export const api = {
body: JSON.stringify({ agent_ids: agentIds, action }),
}),
deleteAgent: (id: string) =>
fetchJSON<{ success: boolean }>(`/agents/${id}`, { method: 'DELETE' }),
bulkDeleteAgents: (ids: string[]) =>
fetchJSON<{ success: boolean; deleted: number }>('/agents/bulk-delete', {
method: 'POST',
body: JSON.stringify({ ids }),
}),
createUser: (username: string, password: string) =>
fetchJSON<{ success: boolean }>('/users', {
method: 'POST',

View File

@@ -2,6 +2,17 @@ import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import type { Agent } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext';
import LatencyBadge from './LatencyBadge';
function formatRelTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 2) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
interface Props {
agent: Agent;
@@ -64,7 +75,10 @@ export default function AgentListItem({
</span>
)}
</div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
<LatencyBadge ms={agent.status === 'online' ? agent.latency_ms : undefined} />
</div>
</div>
{(agent.tags?.length ?? 0) > 0 && (
@@ -78,7 +92,12 @@ export default function AgentListItem({
<div className="agent-list-details">
<span>{formatHashrate(agent.hashrate_15m)}</span>
<span>{agent.ip || '—'}</span>
{!expanded && <span className="form-hint">click for details</span>}
{agent.status !== 'online' && agent.last_seen && (
<span className="form-hint" title={new Date(agent.last_seen).toLocaleString()}>
last seen {formatRelTime(agent.last_seen)}
</span>
)}
{!expanded && agent.status === 'online' && <span className="form-hint">click for details</span>}
</div>
{!expanded && agent.notes?.trim() && (

View File

@@ -96,7 +96,16 @@ export default function FleetToolbar({
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle miners</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
<button
type="button"
className="btn btn-sm"
disabled={bulkBusy}
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => onBulkAction('delete')}
>
🗑 Delete selected
</button>
</div>
)}
</div>

View File

@@ -0,0 +1,83 @@
/**
* LatencyBadge — 4-bar cell-signal style indicator for WebSocket RTT.
*
* Bar fill thresholds:
* 4 bars (green) : < 50 ms — excellent
* 3 bars (cyan) : < 150 ms — good
* 2 bars (amber) : < 400 ms — fair
* 1 bar (red) : ≥ 400 ms — poor
* 0 bars (grey) : no data — waiting for first pong
*/
interface Props {
ms?: number;
/** Compact variant — bars only, no ms label */
compact?: boolean;
}
function latencyLevel(ms: number): 0 | 1 | 2 | 3 | 4 {
if (ms < 50) return 4;
if (ms < 150) return 3;
if (ms < 400) return 2;
return 1;
}
const LEVEL_COLORS: Record<number, string> = {
4: '#39ff14', // neon green
3: '#00f5ff', // cyan
2: '#ffb020', // amber
1: '#ff4466', // red
0: '#444', // grey
};
const BAR_HEIGHTS = [5, 8, 11, 14]; // px, bottom-aligned
export default function LatencyBadge({ ms, compact = false }: Props) {
const level = ms !== undefined ? latencyLevel(ms) : 0;
const color = LEVEL_COLORS[level];
const label = ms !== undefined ? `${ms}ms` : '—';
return (
<span
title={ms !== undefined ? `Latency: ${ms} ms` : 'Latency unknown — waiting for ping'}
style={{
display: 'inline-flex',
alignItems: 'flex-end',
gap: '2px',
verticalAlign: 'middle',
lineHeight: 1,
}}
>
{BAR_HEIGHTS.map((h, i) => {
const filled = (i + 1) <= level;
return (
<span
key={i}
style={{
display: 'inline-block',
width: 3,
height: h,
borderRadius: 1,
background: filled ? color : 'rgba(255,255,255,0.12)',
transition: 'background 0.4s ease',
}}
/>
);
})}
{!compact && ms !== undefined && (
<span
style={{
fontSize: '0.7rem',
fontFamily: 'monospace',
color,
marginLeft: 3,
lineHeight: 1,
letterSpacing: '-0.02em',
}}
>
{label}
</span>
)}
</span>
);
}

View File

@@ -18,8 +18,6 @@ interface PoolPresetPickerProps {
pass: string;
backups?: BackupPool[];
onChange: (next: PoolForgeFields) => void;
/** Show manual host/port fields below presets (Forge advanced). */
showManualFields?: boolean;
}
export default function PoolPresetPicker({
@@ -29,7 +27,6 @@ export default function PoolPresetPicker({
pass,
backups = [],
onChange,
showManualFields = false,
}: PoolPresetPickerProps) {
const [selectedIds, setSelectedIds] = useState<string[]>(() => {
const detected = detectPresetIds(host, port, tls, backups);
@@ -185,60 +182,6 @@ export default function PoolPresetPicker({
</div>
)}
{showManualFields && (
<div className="pool-preset-manual form-row">
<div className="form-group">
<label className="label">Pool Host</label>
<input
type="text"
className="input mono"
value={host}
onChange={(e) =>
onChange({
pool_host: e.target.value,
pool_port: port,
pool_tls: tls,
backup_pools: backups,
})
}
/>
</div>
<div className="form-group">
<label className="label">Port</label>
<input
type="number"
className="input"
min={1}
max={65535}
value={port}
onChange={(e) =>
onChange({
pool_host: host,
pool_port: e.target.valueAsNumber || 3333,
pool_tls: tls,
backup_pools: backups,
})
}
/>
</div>
<label className="checkbox-label" style={{ alignSelf: 'flex-end' }}>
<input
type="checkbox"
className="checkbox"
checked={tls}
onChange={(e) =>
onChange({
pool_host: host,
pool_port: port,
pool_tls: e.target.checked,
backup_pools: backups,
})
}
/>
<span>TLS</span>
</label>
</div>
)}
</div>
);
}

View File

@@ -57,9 +57,6 @@ export default function SessionGate({ children }: { children: ReactNode }) {
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
First run: password is in the LAUNCH console or <code className="mono-sm">data\login-credentials.json</code> next to the server data folder.
</p>
<label className="label" htmlFor="session-user">Username</label>
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
<label className="label" htmlFor="session-pass">Password</label>

View File

@@ -105,6 +105,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
);
break;
}
case 'agent_deleted': {
const { agent_id } = msg.payload as { agent_id: string };
setAgents((prev) => prev.filter((a) => a.id !== agent_id));
break;
}
case 'stats_update': {
const update = msg.payload as WSStatsUpdate;
setAgents((prev) =>
@@ -152,6 +157,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
...(update.services !== undefined ? { services: update.services } : {}),
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
}
: a
)

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { api } from '../api/client';
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 NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
@@ -139,9 +140,19 @@ export default function AgentsPage() {
}
}, [selectedAgent?.id, agentLogs]);
// Sort: online first, then by last_seen desc, then alphabetical
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
if (a.status === 'online' && b.status !== 'online') return -1;
if (a.status !== 'online' && b.status === 'online') return 1;
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
if (tb !== ta) return tb - ta;
return a.name.localeCompare(b.name);
}), [agents]);
const filteredAgents = useMemo(
() => filterFleetAgents(agents, filters),
[agents, filters]
() => filterFleetAgents(sortedAgents, filters),
[sortedAgents, filters]
);
const refreshLog = async (refresh = false) => {
@@ -199,10 +210,60 @@ export default function AgentsPage() {
});
}, []);
const handleDeleteAgent = async (agentId: string) => {
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
try {
await api.deleteAgent(agentId);
setAgents((prev) => prev.filter((a) => a.id !== agentId));
if (selectedAgent?.id === agentId) setSelectedAgent(null);
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agentId); return next; });
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleUninstallAndDelete = async (agent: Agent) => {
const label = agent.status === 'online'
? `Uninstall the miner from "${agent.name}" and remove it from the roster?`
: `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`;
if (!window.confirm(label)) return;
if (agent.status === 'online') {
try {
await api.sendAgentCommand(agent.id, 'uninstall', {});
} catch {
// Non-fatal — proceed to delete the record regardless
}
}
try {
await api.deleteAgent(agent.id);
setAgents((prev) => prev.filter((a) => a.id !== agent.id));
if (selectedAgent?.id === agent.id) setSelectedAgent(null);
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agent.id); return next; });
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleBulkAction = async (action: string) => {
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);
setAgents((prev) => prev.filter((a) => !ids.includes(a.id)));
if (selectedAgent && ids.includes(selectedAgent.id)) setSelectedAgent(null);
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) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
@@ -323,16 +384,57 @@ export default function AgentsPage() {
value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)}
/>
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
{metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
{metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
{selectedAgent.status === 'online' && (
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
onClick={() => void handleUninstallAndDelete(selectedAgent)}
title="Send uninstall command to agent, then remove from roster"
>
Uninstall + Delete
</button>
)}
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => void handleDeleteAgent(selectedAgent.id)}
title="Remove this machine from the fleet roster permanently"
>
🗑 Delete from Roster
</button>
</div>
{metaMsg && <span className="form-hint">{metaMsg}</span>}
</div>
<div className="agent-detail-grid">
<div className="detail-item">
<span className="detail-label">Status</span>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
{selectedAgent.status === 'online' && (
<LatencyBadge ms={selectedAgent.latency_ms} />
)}
</span>
</div>
{selectedAgent.hostname && selectedAgent.hostname !== selectedAgent.name && (
<div className="detail-item">
<span className="detail-label">Hostname</span>
<span className="detail-value mono">{selectedAgent.hostname}</span>
</div>
)}
<div className="detail-item">
<span className="detail-label">Last Seen</span>
<span className="detail-value" title={selectedAgent.last_seen}>
{selectedAgent.last_seen
? new Date(selectedAgent.last_seen).toLocaleString()
: '—'}
</span>
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>

View File

@@ -217,9 +217,7 @@
font-family: 'Courier New', monospace;
font-size: 0.7rem;
color: #b8e0d0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
min-width: 0;
}

View File

@@ -300,23 +300,22 @@ export default function BuildManagerPage() {
const loadBuilds = useCallback(async () => {
try {
const [list, info] = await Promise.all([
api.listBuilds(),
api.getServerInfo().catch(() => null),
]);
const list = await api.listBuilds();
setBuilds(list);
if (info) {
const pub = info.suggested_url?.replace(/\/$/, '') || window.location.origin;
setServerBase(pub);
} else {
setServerBase(window.location.origin);
}
setError('');
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load builds');
} finally {
setLoading(false);
}
// Load server base URL separately so a slow/hung server-info call
// never blocks the builds list from rendering.
api.getServerInfo()
.then((info) => {
const pub = info?.suggested_url?.trim().replace(/\/$/, '');
if (pub) setServerBase(pub);
})
.catch(() => {/* use window.location.origin fallback already set */});
}, []);
useEffect(() => { loadBuilds(); }, [loadBuilds]);

View File

@@ -207,18 +207,26 @@ export default function BuilderPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])])
Promise.all([
api.getConfig(),
api.getServerInfo().catch(() => null),
api.listBuilds().catch(() => []),
])
.then(([config, info, builds]) => {
setCalibrateConfig(config);
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
setForm(applySmartForgeDefaults(base, { builds, endpointCandidates: candidates }));
if (info) {
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
} else {
setListenPort(config.port || 8989);
}
const candidates = info ? lanEndpointCandidates(info, config.port || info.port) : [];
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, builds as BuildRecord[]);
setForm(applySmartForgeDefaults(base, { builds: builds as BuildRecord[], endpointCandidates: candidates }));
})
.catch((err) => {
console.error(err);
setError('Failed to load server info — is the control server running?');
setError('Failed to load server config — is the control server running?');
})
.finally(() => setLoadingDefaults(false));
}, []);
@@ -1096,7 +1104,6 @@ export default function BuilderPage() {
tls={form.pool_tls}
pass={form.pool_pass || 'x'}
backups={form.backup_pools}
showManualFields={!simpleMode}
onChange={(next) => {
updateField('pool_host', next.pool_host);
updateField('pool_port', next.pool_port);

View File

@@ -3,6 +3,7 @@ import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import type { Agent, AgentService } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import { formatHashrate } from '../help/fleetFilters';
import './CruciblePage.css';
@@ -18,6 +19,8 @@ interface TermLine {
text: string;
ts: Date;
success?: boolean;
// Whether this agent was in the active selection when the command was dispatched
targeted?: boolean;
// Structured data for rich terminal renderers
richData?: RichTermData;
}
@@ -314,6 +317,15 @@ export default function CruciblePage() {
const online = (a: Agent) => a.status === 'online';
// Prune selectedIds when agents are removed (e.g. after roster delete).
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
// ── Auto-scroll terminal ───────────────────────────────────────────────
useEffect(() => {
@@ -377,23 +389,22 @@ export default function CruciblePage() {
} catch { /* malformed JSON — fall through to plain text */ }
}
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
if (richData) {
// Single rich-rendered line (table/block replaces raw JSON)
lines.push({
id: mkId(), agentId: aid, agentName: name,
isCmd: false, text: '', ts: new Date(),
success: r.success, richData,
success: r.success, richData, targeted,
});
} else {
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,
isCmd: false, text: line, ts: new Date(), success: r.success, targeted,
});
}
}
@@ -503,19 +514,16 @@ export default function CruciblePage() {
const probePosture = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0) { alert('No online agents selected.'); return; }
Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, 'posture').catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
},
]);
})
@@ -523,10 +531,13 @@ export default function CruciblePage() {
);
};
// Fires posture + listen_ports + patch_status in parallel for all selected online nodes.
// Fires posture + listen_ports + patch_status in parallel.
// When agents are selected, targets only selection. Otherwise targets all online.
const scanSelected = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0) return;
const tgts = targets ?? (selectedAgents.filter(online).length > 0
? selectedAgents.filter(online)
: agents.filter(online));
if (tgts.length === 0) { alert('No online agents available.'); return; }
const cmds = ['posture', 'listen_ports', 'patch_status'] as const;
for (const a of tgts) {
for (const cmd of cmds) {
@@ -536,7 +547,7 @@ export default function CruciblePage() {
{
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,
ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
},
]);
});
@@ -544,6 +555,9 @@ export default function CruciblePage() {
}
};
// Focused agent — when exactly one is selected show its details prominently.
const focusedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') {
@@ -773,6 +787,7 @@ export default function CruciblePage() {
<div className="cn-stats">
<span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span>
<LatencyBadge ms={isOn ? a.latency_ms : undefined} compact />
</div>
<div className="cn-badges">
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
@@ -866,6 +881,40 @@ export default function CruciblePage() {
)}
</NeonCard>
{/* ── Focused machine banner ──────────────────────────────────────── */}
{focusedAgent && (
<div className="crucible-focus-bar" style={{
display: 'flex', alignItems: 'center', gap: '1.5rem', flexWrap: 'wrap',
background: 'rgba(0,245,255,0.06)', border: '1px solid rgba(0,245,255,0.25)',
borderRadius: '8px', padding: '0.65rem 1rem', marginBottom: '1rem',
fontSize: '0.85rem',
}}>
<span style={{ color: 'var(--neon-cyan)', fontFamily: 'monospace', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.1em' }}>
ACTIVE TARGET
</span>
<span style={{ fontFamily: 'monospace', color: agentColor(focusedAgent.id, allIds), fontWeight: 600 }}>
{platformIcon(focusedAgent.platform)} {focusedAgent.name}
</span>
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.ip || '—'}</span>
<span className={`status-badge ${focusedAgent.status}`}>{focusedAgent.status}</span>
<LatencyBadge ms={focusedAgent.status === 'online' ? focusedAgent.latency_ms : undefined} />
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''}</span>
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB</span>
{focusedAgent.status !== 'online' && (
<span style={{ color: '#ff6666', fontFamily: 'monospace', fontSize: '0.8rem' }}>
offline commands will fail until it reconnects
</span>
)}
<button
className="button crucible-btn-muted"
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '0.2rem 0.6rem' }}
onClick={() => setSelectedIds(new Set())}
>
Deselect
</button>
</div>
)}
{/* ── Groups & Actions ────────────────────────────────────────────── */}
<div className="crucible-row">
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
@@ -903,36 +952,40 @@ export default function CruciblePage() {
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> OPERATIONS
{selectedIds.size > 0 && (
<span style={{ marginLeft: '0.75rem', color: 'var(--neon-cyan)', fontSize: '0.75rem', fontWeight: 400 }}>
{selectedIds.size === 1 ? selectedAgents[0]?.name ?? '1 node' : `${selectedIds.size} nodes`}
</span>
)}
</div>
<div className="crucible-ops">
{/* ── Posture ──────────────────────────────────── */}
<div className="crucible-op-group">
<span className="cop-label">Posture</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probePosture()}
title="Probe posture (AV, firewall, SSH, patch) on selected nodes"
>
Probe Posture
</button>
<button
className="button crucible-op-btn crucible-op-wake"
disabled={agents.filter(online).length === 0}
onClick={() => probePosture(agents.filter(online))}
title="Probe ALL online nodes at once"
>
Fleet Posture Scan
</button>
<span className="cop-label">Posture &amp; Recon</span>
<button
className="button crucible-op-btn crucible-op-scan"
disabled={selectedIds.size === 0}
disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
onClick={() => scanSelected()}
title="Fire posture + listen_ports + patch_status in parallel on selected nodes"
title={selectedIds.size > 0
? `Deep scan ${selectedIds.size} selected node(s): posture + ports + patch`
: 'Deep scan ALL online nodes: posture + ports + patch'}
>
Scan All Selected
{selectedIds.size > 0
? `⬡ Deep Scan (${selectedIds.size} selected)`
: `⬡ Deep Scan Fleet (${agents.filter(online).length} online)`}
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
onClick={() => probePosture(selectedIds.size > 0 ? selectedAgents.filter(online) : agents.filter(online))}
title="Posture only (AV, firewall, SSH state)"
>
Posture Only
</button>
</div>
{/* ── SSH ──────────────────────────────────────── */}
<div className="crucible-op-group">
<span className="cop-label">SSH</span>
<button
@@ -953,6 +1006,7 @@ export default function CruciblePage() {
</button>
</div>
{/* ── Mining ───────────────────────────────────── */}
<div className="crucible-op-group">
<span className="cop-label">Mining</span>
<button
@@ -960,44 +1014,36 @@ export default function CruciblePage() {
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
>
Resume All
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')))}
>
Pause All
Pause
</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)}
title="Run whoami on selected"
>
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)}
title="Network adapter info"
>
ipconfig
</button>
</div>
{/* ── Shell type ───────────────────────────────── */}
<div className="crucible-op-group">
<span className="cop-label">Shell</span>
<span className="cop-label">Shell Mode</span>
<div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
<button
@@ -1011,6 +1057,7 @@ export default function CruciblePage() {
</div>
</div>
{/* ── Selection chips ──────────────────────────── */}
{selectedIds.size > 0 && (
<div className="crucible-sel-chips">
{selectedAgents.map((a) => {
@@ -1057,10 +1104,12 @@ export default function CruciblePage() {
)}
{termLines.map((line) => {
const color = agentColor(line.agentId, allIds);
const dim = line.targeted === false;
return (
<div
key={line.id}
className={`crucible-term-line ${line.isCmd ? 'cmd-line' : 'out-line'} ${line.success === false ? 'err-line' : ''} ${line.richData ? 'rich-line' : ''}`}
style={dim ? { opacity: 0.45 } : undefined}
>
<span className="ctl-agent" style={{ color }}>
{line.agentName.slice(0, 12).padEnd(12)}

View File

@@ -67,6 +67,10 @@ export interface Agent {
reboot_pending?: boolean;
agent_elevated?: boolean;
services?: AgentService[];
hostname?: string;
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
latency_ms?: number;
}
export interface AgentService {

View File

@@ -52,6 +52,7 @@ export interface WSStatsUpdate {
reboot_pending?: boolean;
agent_elevated?: boolean;
services?: AgentService[];
latency_ms?: number;
}
export interface WSCommandResult {