Add fleet registry removal with confirm modal and agent_removed WS.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Operators can remove machines from Crucible and dashboard rosters with honest messaging that deletion is registry-only; bulk select, oath ledger entries, and Go/Vitest coverage included.
This commit is contained in:
AetherForge
2026-06-07 18:23:41 -07:00
parent e1b1a2aa65
commit 07fdb39b63
21 changed files with 533 additions and 53 deletions

View File

@@ -243,7 +243,9 @@ describe('api client', () => {
.mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' }))
.mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'fresh' }))
.mockResolvedValueOnce(jsonResponse({ success: true, agent: mockAgent() }))
.mockResolvedValueOnce(jsonResponse({ success: true, sent: 2, failed: 0, action: 'pause' }));
.mockResolvedValueOnce(jsonResponse({ success: true, sent: 2, failed: 0, action: 'pause' }))
.mockResolvedValueOnce(jsonResponse({ success: true }))
.mockResolvedValueOnce(jsonResponse({ success: true, deleted: 2 }));
await api.getAlerts();
expect(lastFetch().url).toBe('/api/v1/alerts');
@@ -280,6 +282,14 @@ describe('api client', () => {
agent_ids: ['a1', 'a2'],
action: 'resume',
});
await api.deleteAgent('gone-1');
expect(lastFetch().url).toBe('/api/v1/agents/gone-1');
expect(lastFetch().init.method).toBe('DELETE');
await api.bulkDeleteAgents(['a1', 'a2']);
expect(lastFetch().url).toBe('/api/v1/agents/bulk-delete');
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ ids: ['a1', 'a2'] });
});
it('createUser POSTs credentials', async () => {

View File

@@ -50,14 +50,25 @@ describe('CrucibleAgentMeta', () => {
expect(await screen.findByText('Saved')).toBeInTheDocument();
});
it('deletes agent from roster after confirm', async () => {
it('deletes agent from roster after confirm modal', async () => {
const agent = mockAgent({ id: 'del-1', name: 'Delete Me' });
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<CrucibleAgentMeta agent={agent} />);
await userEvent.setup().click(screen.getByRole('button', { name: 'Delete from Roster' }));
const requestDeleteConfirm = vi.fn().mockResolvedValue(true);
render(<CrucibleAgentMeta agent={agent} requestDeleteConfirm={requestDeleteConfirm} />);
await userEvent.setup().click(screen.getByRole('button', { name: 'Remove from fleet' }));
await waitFor(() => {
expect(requestDeleteConfirm).toHaveBeenCalledWith({ count: 1, agentName: 'Delete Me' });
expect(deleteAgentMock).toHaveBeenCalledWith('del-1');
});
confirmSpy.mockRestore();
});
it('skips delete when operator cancels confirm modal', async () => {
const agent = mockAgent({ id: 'del-2', name: 'Keep Me' });
const requestDeleteConfirm = vi.fn().mockResolvedValue(false);
render(<CrucibleAgentMeta agent={agent} requestDeleteConfirm={requestDeleteConfirm} />);
await userEvent.setup().click(screen.getByRole('button', { name: 'Remove from fleet' }));
await waitFor(() => {
expect(requestDeleteConfirm).toHaveBeenCalled();
});
expect(deleteAgentMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,13 +1,15 @@
import { useState, useEffect } from 'react';
import { api } from '../../api/client';
import type { Agent } from '../../types';
import { HelpTip } from '../HelpTip';
interface Props {
agent: Agent;
onUpdated?: (agent: Agent) => void;
requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise<boolean>;
}
export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
export default function CrucibleAgentMeta({ agent, onUpdated, requestDeleteConfirm }: Props) {
const [notesDraft, setNotesDraft] = useState(agent.notes || '');
const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', '));
const [saving, setSaving] = useState(false);
@@ -36,7 +38,12 @@ export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
};
const deleteFromRoster = async () => {
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
const confirmed = requestDeleteConfirm
? await requestDeleteConfirm({ count: 1, agentName: agent.name })
: window.confirm(
'Remove from fleet — does not uninstall agent on host. Remove this machine from the registry?',
);
if (!confirmed) return;
try {
await api.deleteAgent(agent.id);
} catch (err) {
@@ -111,20 +118,23 @@ export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
className="btn btn-sm"
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
onClick={() => void uninstallAndDelete()}
title="Send uninstall command to agent, then remove from roster"
title="Send uninstall command to agent, then remove from fleet registry"
>
Uninstall + Delete
Uninstall + Remove
</button>
)}
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => void deleteFromRoster()}
title="Remove this machine from the fleet roster permanently"
>
Delete from Roster
</button>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => void deleteFromRoster()}
title="Remove from fleet registry only — does not uninstall on host"
>
Remove from fleet
</button>
<HelpTip field="crucible_delete_roster" />
</span>
{msg && <span className="form-hint">{msg}</span>}
</div>
</div>

View File

@@ -0,0 +1,50 @@
.fleet-delete-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
}
.fleet-delete-modal {
width: 100%;
max-width: 440px;
padding: 1.25rem 1.5rem;
border: 1px solid rgba(255, 68, 68, 0.45);
box-shadow: 0 0 40px rgba(255, 68, 68, 0.12);
}
.fleet-delete-modal h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.fleet-delete-modal-lead {
margin: 0 0 0.75rem;
font-family: var(--font-tech, monospace);
color: var(--text-primary, #e8f4ff);
}
.fleet-delete-modal-honest {
margin: 0 0 1rem;
line-height: 1.45;
}
.fleet-delete-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.fleet-delete-modal-confirm {
background: rgba(255, 40, 40, 0.15);
border: 1px solid #ff4444;
color: #ff6666;
}

View File

@@ -0,0 +1,50 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FleetDeleteConfirmModal from './FleetDeleteConfirmModal';
describe('FleetDeleteConfirmModal', () => {
afterEach(() => {
cleanup();
});
it('shows honest fleet-registry-only messaging', () => {
render(
<FleetDeleteConfirmModal
open
count={1}
agentName="Lab PC"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByRole('heading', { name: /Remove from fleet/i })).toBeInTheDocument();
expect(screen.getByText(/does not uninstall the agent on the host/i)).toBeInTheDocument();
expect(screen.getByText(/Remove "Lab PC" from fleet\?/)).toBeInTheDocument();
});
it('calls onConfirm when operator confirms', async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<FleetDeleteConfirmModal open count={2} onConfirm={onConfirm} onCancel={onCancel} />,
);
await userEvent.setup().click(screen.getByRole('button', { name: 'Remove from fleet' }));
expect(onConfirm).toHaveBeenCalledOnce();
expect(onCancel).not.toHaveBeenCalled();
});
it('calls onCancel when operator dismisses', async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<FleetDeleteConfirmModal open count={1} onConfirm={onConfirm} onCancel={onCancel} />,
);
await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledOnce();
expect(onConfirm).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,61 @@
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
import { HelpTip } from '../HelpTip';
import './FleetDeleteConfirmModal.css';
interface Props {
open: boolean;
count: number;
agentName?: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function FleetDeleteConfirmModal({
open,
count,
agentName,
onConfirm,
onCancel,
}: Props) {
useModalAmbientDuck(open);
if (!open || count < 1) return null;
const title =
count === 1 && agentName
? `Remove "${agentName}" from fleet?`
: `Remove ${count} machine${count === 1 ? '' : 's'} from fleet?`;
return (
<div className="fleet-delete-modal-backdrop" role="presentation" onClick={onCancel}>
<div
className="fleet-delete-modal card"
role="dialog"
aria-labelledby="fleet-delete-modal-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="fleet-delete-modal-title" className="font-display">
Remove from fleet
<HelpTip field="fl_delete_roster" />
</h2>
<p className="fleet-delete-modal-lead">{title}</p>
<p className="form-hint fleet-delete-modal-honest">
Removes the record from the server fleet registry only does not uninstall the agent on
the host. Use Remote Actions Uninstall if you need to remove the miner binary.
</p>
<div className="fleet-delete-modal-actions">
<button type="button" className="btn btn-outline" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className="btn btn-sm fleet-delete-modal-confirm"
onClick={onConfirm}
>
Remove from fleet
</button>
</div>
</div>
</div>
);
}

View File

@@ -125,15 +125,19 @@ export default function FleetToolbar({
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('resume')} title="Fleet health: restore hashing">Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} 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>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
<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')}
title="Remove selected machines from fleet registry — does not uninstall on host"
>
Remove from fleet
</button>
<HelpTip field="fl_delete_roster" />
</span>
</div>
)}
</div>

View File

@@ -164,6 +164,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
);
break;
}
case 'agent_removed':
case 'agent_deleted': {
const { agent_id } = msg.payload as { agent_id: string };
setAgents((prev) => prev.filter((a) => a.id !== agent_id));

View File

@@ -106,6 +106,8 @@ describe('UI_HELP', () => {
'crucible_btn_cf_tunnel',
'fl_filter_chips',
'fl_bulk_actions',
'fl_delete_roster',
'crucible_delete_roster',
'fl_groups',
'set_alerts',
'set_alert_notifications',

View File

@@ -218,7 +218,11 @@ export const UI_HELP: Record<string, string> = {
fl_filter_chips:
'Narrow the roster by name/IP/notes/tags (text search), tag label, subnet prefix, minimum 15m hashrate, or the "needs attention" flag (offline or idle miners below 100 H/s).',
fl_bulk_actions:
'Actions applied to every checked agent at once: pause or resume mining, stop the miner thread, restart only idle workers, take a screenshot (one agent only), or permanently delete from roster.',
'Actions applied to every checked agent at once: pause or resume mining, stop the miner thread, restart only idle workers, take a screenshot (one agent only), or remove from fleet registry (does not uninstall on host).',
fl_delete_roster:
'Remove from fleet deletes the server registry row and disconnects a live session. It does not uninstall the agent binary on the machine — use Remote Actions → Uninstall for that.',
crucible_delete_roster:
'Per-machine fleet registry removal from Crucible notes panel or the active-target bar. Offline agents can be removed; online agents are kicked from the hub first.',
fl_groups:
'Named color-coded subsets of the fleet stored in browser local storage. Click a chip to check-select all members — then apply bulk actions or send Crucible commands to the whole group at once.',

View File

@@ -38,33 +38,41 @@ describe('useFleetBulkActions', () => {
describe('bulk delete confirm path', () => {
it('calls bulkDeleteAgents when operator confirms', async () => {
const agent = mockAgent({ id: 'del-1' });
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { result } = renderBulkHook([agent], ['del-1']);
const requestDeleteConfirm = vi.fn().mockResolvedValue(true);
const { result } = renderHook(() =>
useFleetBulkActions({
agents: [agent],
selectedIds: new Set(['del-1']),
requestDeleteConfirm,
}),
);
await act(async () => {
await result.current.handleBulkAction('delete');
});
expect(confirmSpy).toHaveBeenCalledWith(
'Permanently remove 1 machine(s) from the fleet roster?',
);
expect(requestDeleteConfirm).toHaveBeenCalledWith({ count: 1 });
expect(bulkDeleteMock).toHaveBeenCalledWith(['del-1']);
expect(sendBulkMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('skips bulkDeleteAgents when operator declines', async () => {
const agent = mockAgent({ id: 'del-2' });
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
const { result } = renderBulkHook([agent], ['del-2']);
const requestDeleteConfirm = vi.fn().mockResolvedValue(false);
const { result } = renderHook(() =>
useFleetBulkActions({
agents: [agent],
selectedIds: new Set(['del-2']),
requestDeleteConfirm,
}),
);
await act(async () => {
await result.current.handleBulkAction('delete');
});
expect(confirmSpy).toHaveBeenCalled();
expect(requestDeleteConfirm).toHaveBeenCalled();
expect(bulkDeleteMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
});

View File

@@ -9,9 +9,10 @@ interface Options {
agents: Agent[];
selectedIds: Set<string>;
commandResults?: SeqCommandResult[];
requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise<boolean>;
}
export function useFleetBulkActions({ agents, selectedIds, commandResults }: Options) {
export function useFleetBulkActions({ agents, selectedIds, commandResults, requestDeleteConfirm }: Options) {
const [bulkBusy, setBulkBusy] = useState(false);
const screenshotWatchId = useRef<string | null>(null);
const screenshotSeqRef = useRef(0);
@@ -41,7 +42,12 @@ export function useFleetBulkActions({ agents, selectedIds, commandResults }: Opt
if (ids.length === 0) return;
if (action === 'delete') {
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
const confirmed = requestDeleteConfirm
? await requestDeleteConfirm({ count: ids.length })
: window.confirm(
`Remove ${ids.length} machine(s) from the fleet registry? Does not uninstall agents on their hosts.`,
);
if (!confirmed) return;
setBulkBusy(true);
try {
await api.bulkDeleteAgents(ids);
@@ -108,7 +114,7 @@ export function useFleetBulkActions({ agents, selectedIds, commandResults }: Opt
setBulkBusy(false);
}
},
[agents, commandResults, selectedIds],
[agents, commandResults, requestDeleteConfirm, selectedIds],
);
return { bulkBusy, handleBulkAction };

View File

@@ -0,0 +1,41 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFleetDeleteConfirm } from './useFleetDeleteConfirm';
describe('useFleetDeleteConfirm', () => {
it('resolves true when modal confirms', async () => {
const { result } = renderHook(() => useFleetDeleteConfirm());
let confirmed: boolean | undefined;
act(() => {
void result.current.requestDeleteConfirm({ count: 1, agentName: 'Node A' }).then((v) => {
confirmed = v;
});
});
expect(result.current.modal.props.open).toBe(true);
act(() => {
result.current.modal.props.onConfirm();
});
await vi.waitFor(() => {
expect(confirmed).toBe(true);
});
});
it('resolves false when modal cancels', async () => {
const { result } = renderHook(() => useFleetDeleteConfirm());
let confirmed: boolean | undefined;
act(() => {
void result.current.requestDeleteConfirm({ count: 2 }).then((v) => {
confirmed = v;
});
});
act(() => {
result.current.modal.props.onCancel();
});
await vi.waitFor(() => {
expect(confirmed).toBe(false);
});
});
});

View File

@@ -0,0 +1,36 @@
import { useCallback, useState } from 'react';
import FleetDeleteConfirmModal from '../components/Fleet/FleetDeleteConfirmModal';
interface DeleteConfirmRequest {
count: number;
agentName?: string;
}
export function useFleetDeleteConfirm() {
const [pending, setPending] = useState<
(DeleteConfirmRequest & { resolve: (confirmed: boolean) => void }) | null
>(null);
const requestDeleteConfirm = useCallback((req: DeleteConfirmRequest) => {
return new Promise<boolean>((resolve) => {
setPending({ ...req, resolve });
});
}, []);
const finish = (confirmed: boolean) => {
pending?.resolve(confirmed);
setPending(null);
};
const modal = (
<FleetDeleteConfirmModal
open={pending != null}
count={pending?.count ?? 0}
agentName={pending?.agentName}
onConfirm={() => finish(true)}
onCancel={() => finish(false)}
/>
);
return { requestDeleteConfirm, modal };
}

View File

@@ -17,6 +17,7 @@ import {
type FleetFilterState,
} from '../help/fleetFilters';
import { useFleetBulkActions } from '../hooks/useFleetBulkActions';
import { useFleetDeleteConfirm } from '../hooks/useFleetDeleteConfirm';
import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
@@ -374,10 +375,12 @@ export default function CruciblePage() {
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [showGroupModal, setShowGroupModal] = useState(false);
const { groups, addGroup, removeGroup } = useFleetGroups();
const { requestDeleteConfirm, modal: deleteConfirmModal } = useFleetDeleteConfirm();
const { bulkBusy, handleBulkAction } = useFleetBulkActions({
agents,
selectedIds,
commandResults,
requestDeleteConfirm,
});
// Terminal
@@ -1491,7 +1494,7 @@ export default function CruciblePage() {
</NeonCard>
{focusedAgent && (
<CrucibleAgentMeta agent={focusedAgent} />
<CrucibleAgentMeta agent={focusedAgent} requestDeleteConfirm={requestDeleteConfirm} />
)}
{/* ── Focused machine banner ──────────────────────────────────────── */}
@@ -1520,9 +1523,34 @@ export default function CruciblePage() {
offline commands will fail until it reconnects
</span>
)}
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}>
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
title="Remove from fleet registry — does not uninstall on host"
onClick={() => void (async () => {
const ok = await requestDeleteConfirm({ count: 1, agentName: focusedAgent.name });
if (!ok) return;
try {
await api.deleteAgent(focusedAgent.id);
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(focusedAgent.id);
return next;
});
} catch (err) {
alert(err instanceof Error ? err.message : 'Remove failed');
}
})()}
>
Remove from fleet
</button>
<HelpTip field="crucible_delete_roster" />
</span>
<button
className="button crucible-btn-muted"
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '0.2rem 0.6rem' }}
style={{ fontSize: '0.75rem', padding: '0.2rem 0.6rem' }}
onClick={() => setSelectedIds(new Set())}
>
Deselect
@@ -1812,6 +1840,7 @@ export default function CruciblePage() {
setShowGroupModal(false);
}}
/>
{deleteConfirmModal}
</div>
</div>
</div>

View File

@@ -20,6 +20,7 @@ import {
} from '../components/Fleet/FleetPanels';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import { useFleetDeleteConfirm } from '../hooks/useFleetDeleteConfirm';
import { SpreadFunnelWidget, AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import ErrorBoundary from '../components/ErrorBoundary';
import { HelpTip } from '../components/HelpTip';
@@ -96,6 +97,7 @@ export default function DashboardPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const selectedAgents = useMemo(() => agents.filter((a) => selectedIds.has(a.id)), [agents, selectedIds]);
const [bulkBusy, setBulkBusy] = useState(false);
const { requestDeleteConfirm, modal: deleteConfirmModal } = useFleetDeleteConfirm();
const [showMatrix, setShowMatrix] = useState(false);
const screenshotWatchId = useRef<string | null>(null);
const screenshotSeqRef = useRef(0);
@@ -367,7 +369,8 @@ export default function DashboardPage() {
if (ids.length === 0) return;
if (action === 'delete') {
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
const confirmed = await requestDeleteConfirm({ count: ids.length });
if (!confirmed) return;
setBulkBusy(true);
try {
await api.bulkDeleteAgents(ids);
@@ -1058,6 +1061,7 @@ export default function DashboardPage() {
<MatrixStreamOverlay active onClose={() => setShowMatrix(false)} />
</Suspense>
)}
{deleteConfirmModal}
<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>