Add fleet groups, agent screenshots, deploy guards, and Crucible polish.

This commit is contained in:
AetherForge
2026-06-02 20:51:52 -07:00
parent 01d76b3730
commit 41b5ec7a88
66 changed files with 1773 additions and 388 deletions

View File

@@ -4,7 +4,12 @@ import { api } from '../api/client';
import type { Agent, AgentService } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
import { formatHashrate } from '../help/fleetFilters';
import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -68,12 +73,6 @@ interface RichPostureSummary {
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
interface NodeGroup {
id: string;
name: string;
agentIds: Set<string>;
}
// ── Helpers ────────────────────────────────────────────────────────────────
const AGENT_COLORS = [
@@ -82,7 +81,8 @@ const AGENT_COLORS = [
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
];
export function agentColor(agentId: string, allIds: string[]): string {
export function agentColor(agentId: string, allIds: string[], groupColor?: string): string {
if (groupColor) return groupColor;
const idx = allIds.indexOf(agentId);
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
}
@@ -286,11 +286,12 @@ const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONL
export default function CruciblePage() {
const { agents, commandResults } = useWebSocket();
const { setCrucibleFocus } = useMatrixRain();
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [groups, setGroups] = useState<NodeGroup[]>([]);
const [groupNameInput, setGroupNameInput] = useState('');
const [showGroupModal, setShowGroupModal] = useState(false);
const { groups, addGroup, removeGroup } = useFleetGroups();
// Terminal
const [termLines, setTermLines] = useState<TermLine[]>([]);
@@ -317,6 +318,15 @@ export default function CruciblePage() {
const online = (a: Agent) => a.status === 'online';
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
const crucibleTargetReady =
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
useEffect(() => {
setCrucibleFocus(crucibleTargetReady);
return () => setCrucibleFocus(false);
}, [crucibleTargetReady, setCrucibleFocus]);
// Prune selectedIds when agents are removed (e.g. after roster delete).
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
@@ -427,17 +437,12 @@ export default function CruciblePage() {
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 onlineAgentIds = useMemo(
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
[agents]
);
const activateGroup = (g: NodeGroup) => setSelectedIds(new Set(g.agentIds));
const deleteGroup = (id: string) => setGroups((prev) => prev.filter((g) => g.id !== id));
const activateGroup = (g: { agentIds: string[] }) => setSelectedIds(new Set(g.agentIds));
// ── Dispatch command ───────────────────────────────────────────────────
@@ -464,8 +469,24 @@ export default function CruciblePage() {
: 'exec';
await Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, action, { command }).catch((err) => {
tgts.map(async (a) => {
try {
const res = await api.sendAgentCommand(a.id, action, { command });
if (res.success === false) {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
text: `[ERROR] ${res.error ?? 'command rejected'}`,
ts: new Date(),
success: false,
},
]);
}
} catch (err) {
setTermLines((prev) => [
...prev,
{
@@ -478,8 +499,8 @@ export default function CruciblePage() {
success: false,
},
]);
})
)
}
})
);
setBusy(false);
cmdRef.current?.focus();
@@ -745,9 +766,23 @@ export default function CruciblePage() {
</div>
<button className="button crucible-btn" onClick={selectAll}>Select Online</button>
<button className="button crucible-btn-muted" onClick={clearSel}>Clear</button>
{selectedIds.size > 0 && (
<button className="button crucible-btn" onClick={() => setShowGroupModal(true)}>
Create group
</button>
)}
</div>
</header>
<FleetGroupsStrip
groups={groups}
liveAgentIds={onlineAgentIds}
selectedCount={selectedIds.size}
onSelectGroup={activateGroup}
onDeleteGroup={removeGroup}
onCreateGroup={() => setShowGroupModal(true)}
/>
{/* ── Node Roster ─────────────────────────────────────────────────── */}
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
<div className="crucible-section-title font-tech">
@@ -762,12 +797,16 @@ export default function CruciblePage() {
const isOn = online(a);
const ssh = sshStatus(a);
const posture = postureStatus(a);
const color = agentColor(a.id, allIds);
const pg = primaryGroupForAgent(groups, a.id);
const color = agentColor(a.id, allIds, pg?.color);
return (
<div
key={a.id}
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
style={sel ? { '--sel-color': color } as React.CSSProperties : undefined}
style={{
...(sel ? { '--sel-color': color } : {}),
...(pg ? { borderLeft: `3px solid ${pg.color}` } : {}),
} as React.CSSProperties}
onClick={() => toggle(a.id)}
>
<div className="crucible-node-check">
@@ -775,9 +814,14 @@ export default function CruciblePage() {
style={sel ? { borderColor: color, background: color + '33' } : undefined} />
</div>
<div className="crucible-node-body">
<div className="cn-name" style={sel ? { color } : undefined}>
<div className="cn-name" style={sel || pg ? { color: pg?.color ?? color } : undefined}>
<span className="cn-platform">{platformIcon(a.platform)}</span>
{a.name}
{pg && (
<span className="cn-group-pill" style={{ color: pg.color, borderColor: `${pg.color}66` }}>
{pg.name}
</span>
)}
</div>
<div className="cn-meta">
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
@@ -921,32 +965,43 @@ export default function CruciblePage() {
<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
<p className="form-hint" style={{ margin: '0 0 0.5rem' }}>
Same groups as Fleet Roster click a chip to select all members.
</p>
{groups.length === 0 ? (
<p className="form-hint" style={{ margin: 0 }}>
Select nodes, then <strong>Create group</strong> to name and color them.
</p>
) : (
<div className="crucible-groups-list">
{groups.map((g) => (
<div
key={g.id}
className="crucible-group-item"
style={{
borderColor: `${g.color}55`,
background: `${g.color}14`,
}}
onClick={() => activateGroup(g)}
>
<span className="fleet-group-chip-dot" style={{ background: g.color }} />
<span className="cg-name" style={{ color: g.color }}>{g.name}</span>
<span className="cg-count">{g.agentIds.length} nodes</span>
<button className="cg-del" onClick={(e) => { e.stopPropagation(); removeGroup(g.id); }}>×</button>
</div>
))}
</div>
)}
{selectedIds.size > 0 && (
<button
type="button"
className="button crucible-btn"
style={{ marginTop: '0.75rem' }}
onClick={() => setShowGroupModal(true)}
>
Create group from selection ({selectedIds.size})
</button>
</div>
)}
</NeonCard>
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
@@ -1186,6 +1241,16 @@ export default function CruciblePage() {
</div>
</div>
</NeonCard>
<CreateGroupModal
open={showGroupModal}
agentCount={selectedIds.size}
onClose={() => setShowGroupModal(false)}
onCreate={(name, color) => {
addGroup(name, color, [...selectedIds]);
setShowGroupModal(false);
}}
/>
</div>
);
}