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

@@ -663,6 +663,7 @@ func EstimateXMRPerDay(hashrate float64) map[string]interface{} {
// DeleteAgent removes an agent record from the database.
// If the agent is currently online it is also disconnected (kicked).
// This is fleet-registry removal only — it does not uninstall the agent binary on the host.
func (f *FleetHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
@@ -674,20 +675,29 @@ func (f *FleetHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
_ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})})
f.ws.RemoveAgent(id)
}
if err := f.db.DeleteAgent(id); err != nil {
if removed, err := f.db.DeleteAgent(id); err != nil {
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
} else if !removed {
http.Error(w, "agent not found", http.StatusNotFound)
return
}
// Clear stale in-memory alerts so the machine stops showing up in the
// dashboard alert banner after deletion.
if f.alerts != nil {
f.alerts.ClearAgent(id)
}
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
AuthUsername(r), db.OathAgentRemoved, id, "", db.OathOutcomeSuccess,
map[string]string{"scope": "fleet_registry"},
map[string]string{"agent_id": id},
)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
// BulkDeleteAgents deletes multiple agents from the database in one call.
// Fleet-registry removal only — does not uninstall agent binaries on hosts.
func (f *FleetHandler) BulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
@@ -702,11 +712,16 @@ func (f *FleetHandler) BulkDeleteAgents(w http.ResponseWriter, r *http.Request)
_ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})})
f.ws.RemoveAgent(id)
}
if err := f.db.DeleteAgent(id); err == nil {
if removed, err := f.db.DeleteAgent(id); err == nil && removed {
deleted++
if f.alerts != nil {
f.alerts.ClearAgent(id)
}
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
AuthUsername(r), db.OathAgentRemoved, id, "", db.OathOutcomeSuccess,
map[string]string{"scope": "fleet_registry", "bulk": "true"},
map[string]string{"agent_id": id},
)
}
}
w.Header().Set("Content-Type", "application/json")

View File

@@ -78,6 +78,8 @@ func fleetChiRoute(method, pattern string, handler http.HandlerFunc) http.Handle
r.Post(pattern, handler)
case http.MethodPut:
r.Put(pattern, handler)
case http.MethodDelete:
r.Delete(pattern, handler)
default:
panic("unsupported method " + method)
}
@@ -989,3 +991,134 @@ func TestFleetPostAgentCommandEmptyAgentID(t *testing.T) {
t.Fatalf("expected 400 for empty id, got %d", rec.Code)
}
}
func readDashboardWSMessage(t *testing.T, conn *websocket.Conn, wantType string) Message {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
t.Fatalf("read %s: %v", wantType, err)
}
if msg.Type == wantType {
return msg
}
}
t.Fatalf("timed out waiting for %s", wantType)
return Message{}
}
func TestFleetDeleteAgentSuccess(t *testing.T) {
fh, database, ws, _ := newTestFleetHandler(t)
agent := &models.Agent{ID: "del-agent-1", Name: "rig", Status: "offline", LastSeen: time.Now()}
if err := database.UpsertAgent(agent); err != nil {
t.Fatal(err)
}
dashConn := connectTestDashboard(t, ws)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/agents/del-agent-1", nil)
fleetChiRoute(http.MethodDelete, "/agents/{id}", fh.DeleteAgent).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["success"] != true {
t.Fatalf("unexpected body: %v", body)
}
if _, err := database.GetAgent("del-agent-1"); err == nil {
t.Fatal("expected agent removed from database")
}
removed := readDashboardWSMessage(t, dashConn, "agent_removed")
var payload map[string]string
if err := json.Unmarshal(removed.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["agent_id"] != "del-agent-1" {
t.Fatalf("agent_id = %q", payload["agent_id"])
}
}
func TestFleetDeleteAgentEmptyID(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
fh.DeleteAgent(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestFleetDeleteAgentDisconnectsOnline(t *testing.T) {
fh, database, ws, _ := newTestFleetHandler(t)
agentID := "del-online"
connectTestAgent(t, ws, agentID)
if !ws.isAgentConnected(agentID) {
t.Fatal("agent should be connected")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/agents/"+agentID, nil)
fleetChiRoute(http.MethodDelete, "/agents/{id}", fh.DeleteAgent).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if ws.isAgentConnected(agentID) {
t.Fatal("agent should be disconnected after delete")
}
if _, err := database.GetAgent(agentID); err == nil {
t.Fatal("expected agent removed from database")
}
}
func TestFleetBulkDeleteAgents(t *testing.T) {
fh, database, ws, _ := newTestFleetHandler(t)
for _, id := range []string{"bulk-del-a", "bulk-del-b", "bulk-del-missing"} {
if id == "bulk-del-missing" {
continue
}
agent := &models.Agent{ID: id, Name: id, Status: "offline", LastSeen: time.Now()}
if err := database.UpsertAgent(agent); err != nil {
t.Fatal(err)
}
}
connectTestAgent(t, ws, "bulk-del-a")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-delete",
strings.NewReader(`{"ids":["bulk-del-a","bulk-del-b","bulk-del-missing"]}`))
fh.BulkDeleteAgents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["deleted"].(float64) != 2 {
t.Fatalf("deleted count = %v", body["deleted"])
}
if _, err := database.GetAgent("bulk-del-a"); err == nil {
t.Fatal("bulk-del-a should be gone")
}
if _, err := database.GetAgent("bulk-del-b"); err == nil {
t.Fatal("bulk-del-b should be gone")
}
}
func TestFleetBulkDeleteAgentsRequiresIDs(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-delete", strings.NewReader(`{"ids":[]}`))
fh.BulkDeleteAgents(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}

View File

@@ -2043,7 +2043,7 @@ func (h *WSHub) SendToAgent(agentID string, msg Message) error {
}
// RemoveAgent forcibly disconnects an agent and removes it from the live map.
// It then broadcasts agent_deleted to all dashboard clients so the UI removes
// It then broadcasts agent_removed to all dashboard clients so the UI removes
// the agent immediately without waiting for the disconnect goroutine to fire.
func (h *WSHub) RemoveAgent(agentID string) {
h.mu.Lock()
@@ -2062,9 +2062,9 @@ func (h *WSHub) RemoveAgent(agentID string) {
ac.Conn.Close()
}
h.mu.Unlock()
// Broadcast deletion so every connected dashboard removes the agent immediately.
// Broadcast removal so every connected dashboard drops the agent immediately.
h.broadcastDashboard(Message{
Type: "agent_deleted",
Type: "agent_removed",
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
})
}

View File

@@ -18,6 +18,7 @@ const (
OathSpreadDiscoveredHost = "spread_discovered_host"
OathStrainHospice = "strain_hospice"
OathReconScan = "recon_scan"
OathAgentRemoved = "agent_removed"
)
// Oath outcomes.

View File

@@ -389,9 +389,13 @@ func (d *Database) MarkStaleAgentsOffline(olderThan time.Duration) (int, error)
return int(n), nil
}
func (d *Database) DeleteAgent(id string) error {
_, err := d.Exec("DELETE FROM agents WHERE id = ?", id)
return err
func (d *Database) DeleteAgent(id string) (bool, error) {
res, err := d.Exec("DELETE FROM agents WHERE id = ?", id)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// FindAgentByMAC returns the agent ID for an agent whose MAC address matches.

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>
)}
<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 this machine from the fleet roster permanently"
title="Remove from fleet registry only — does not uninstall on host"
>
Delete from Roster
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>
<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"
>
🗑 Delete selected
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>