Add fleet groups, agent screenshots, deploy guards, and Crucible polish.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user