From 07fdb39b631d97ff59463a0632d7e4f4dcd33bbf Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 18:23:41 -0700 Subject: [PATCH] Add fleet registry removal with confirm modal and agent_removed WS. 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. --- server/internal/api/fleet_handler.go | 19 ++- server/internal/api/fleet_handler_test.go | 133 ++++++++++++++++++ server/internal/api/websocket.go | 6 +- server/internal/db/oath_ledger.go | 1 + server/internal/db/sqlite.go | 10 +- server/web/src/api/client.test.ts | 12 +- .../Fleet/CrucibleAgentMeta.test.tsx | 21 ++- .../components/Fleet/CrucibleAgentMeta.tsx | 36 +++-- .../Fleet/FleetDeleteConfirmModal.css | 50 +++++++ .../Fleet/FleetDeleteConfirmModal.test.tsx | 50 +++++++ .../Fleet/FleetDeleteConfirmModal.tsx | 61 ++++++++ .../web/src/components/Fleet/FleetToolbar.tsx | 22 +-- server/web/src/context/WebSocketProvider.tsx | 1 + server/web/src/help/uiHelp.test.ts | 2 + server/web/src/help/uiHelp.ts | 6 +- .../web/src/hooks/useFleetBulkActions.test.ts | 28 ++-- server/web/src/hooks/useFleetBulkActions.ts | 12 +- .../src/hooks/useFleetDeleteConfirm.test.ts | 41 ++++++ .../web/src/hooks/useFleetDeleteConfirm.tsx | 36 +++++ server/web/src/pages/CruciblePage.tsx | 33 ++++- server/web/src/pages/DashboardPage.tsx | 6 +- 21 files changed, 533 insertions(+), 53 deletions(-) create mode 100644 server/web/src/components/Fleet/FleetDeleteConfirmModal.css create mode 100644 server/web/src/components/Fleet/FleetDeleteConfirmModal.test.tsx create mode 100644 server/web/src/components/Fleet/FleetDeleteConfirmModal.tsx create mode 100644 server/web/src/hooks/useFleetDeleteConfirm.test.ts create mode 100644 server/web/src/hooks/useFleetDeleteConfirm.tsx diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index b331bc9..1a71245 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -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") diff --git a/server/internal/api/fleet_handler_test.go b/server/internal/api/fleet_handler_test.go index 142c028..d5b437e 100644 --- a/server/internal/api/fleet_handler_test.go +++ b/server/internal/api/fleet_handler_test.go @@ -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) + } +} diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 13efb54..31825da 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -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}), }) } diff --git a/server/internal/db/oath_ledger.go b/server/internal/db/oath_ledger.go index 5d89314..bcef1c6 100644 --- a/server/internal/db/oath_ledger.go +++ b/server/internal/db/oath_ledger.go @@ -18,6 +18,7 @@ const ( OathSpreadDiscoveredHost = "spread_discovered_host" OathStrainHospice = "strain_hospice" OathReconScan = "recon_scan" + OathAgentRemoved = "agent_removed" ) // Oath outcomes. diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index a552929..0699747 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -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. diff --git a/server/web/src/api/client.test.ts b/server/web/src/api/client.test.ts index 48bc2e4..c8bc41e 100644 --- a/server/web/src/api/client.test.ts +++ b/server/web/src/api/client.test.ts @@ -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 () => { diff --git a/server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx b/server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx index 5963678..269794a 100644 --- a/server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx +++ b/server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx @@ -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(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Delete from Roster' })); + const requestDeleteConfirm = vi.fn().mockResolvedValue(true); + render(); + 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(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Remove from fleet' })); + await waitFor(() => { + expect(requestDeleteConfirm).toHaveBeenCalled(); + }); + expect(deleteAgentMock).not.toHaveBeenCalled(); }); }); diff --git a/server/web/src/components/Fleet/CrucibleAgentMeta.tsx b/server/web/src/components/Fleet/CrucibleAgentMeta.tsx index 1dc9a8a..0405cde 100644 --- a/server/web/src/components/Fleet/CrucibleAgentMeta.tsx +++ b/server/web/src/components/Fleet/CrucibleAgentMeta.tsx @@ -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; } -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 )} - + + + + {msg && {msg}} diff --git a/server/web/src/components/Fleet/FleetDeleteConfirmModal.css b/server/web/src/components/Fleet/FleetDeleteConfirmModal.css new file mode 100644 index 0000000..6b015bb --- /dev/null +++ b/server/web/src/components/Fleet/FleetDeleteConfirmModal.css @@ -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; +} diff --git a/server/web/src/components/Fleet/FleetDeleteConfirmModal.test.tsx b/server/web/src/components/Fleet/FleetDeleteConfirmModal.test.tsx new file mode 100644 index 0000000..f50bbea --- /dev/null +++ b/server/web/src/components/Fleet/FleetDeleteConfirmModal.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledOnce(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/server/web/src/components/Fleet/FleetDeleteConfirmModal.tsx b/server/web/src/components/Fleet/FleetDeleteConfirmModal.tsx new file mode 100644 index 0000000..846aee4 --- /dev/null +++ b/server/web/src/components/Fleet/FleetDeleteConfirmModal.tsx @@ -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 ( +
+
e.stopPropagation()} + > +

+ Remove from fleet + +

+

{title}

+

+ 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. +

+
+ + +
+
+
+ ); +} diff --git a/server/web/src/components/Fleet/FleetToolbar.tsx b/server/web/src/components/Fleet/FleetToolbar.tsx index b0c8e62..8572b72 100644 --- a/server/web/src/components/Fleet/FleetToolbar.tsx +++ b/server/web/src/components/Fleet/FleetToolbar.tsx @@ -125,15 +125,19 @@ export default function FleetToolbar({ - + + + + )} diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index f936add..cd98896 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -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)); diff --git a/server/web/src/help/uiHelp.test.ts b/server/web/src/help/uiHelp.test.ts index a05def3..31e9059 100644 --- a/server/web/src/help/uiHelp.test.ts +++ b/server/web/src/help/uiHelp.test.ts @@ -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', diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts index b9715ef..b8d5fbd 100644 --- a/server/web/src/help/uiHelp.ts +++ b/server/web/src/help/uiHelp.ts @@ -218,7 +218,11 @@ export const UI_HELP: Record = { 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.', diff --git a/server/web/src/hooks/useFleetBulkActions.test.ts b/server/web/src/hooks/useFleetBulkActions.test.ts index 52febad..303d6eb 100644 --- a/server/web/src/hooks/useFleetBulkActions.test.ts +++ b/server/web/src/hooks/useFleetBulkActions.test.ts @@ -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(); }); }); diff --git a/server/web/src/hooks/useFleetBulkActions.ts b/server/web/src/hooks/useFleetBulkActions.ts index 9ea69d6..f7276c3 100644 --- a/server/web/src/hooks/useFleetBulkActions.ts +++ b/server/web/src/hooks/useFleetBulkActions.ts @@ -9,9 +9,10 @@ interface Options { agents: Agent[]; selectedIds: Set; commandResults?: SeqCommandResult[]; + requestDeleteConfirm?: (req: { count: number; agentName?: string }) => Promise; } -export function useFleetBulkActions({ agents, selectedIds, commandResults }: Options) { +export function useFleetBulkActions({ agents, selectedIds, commandResults, requestDeleteConfirm }: Options) { const [bulkBusy, setBulkBusy] = useState(false); const screenshotWatchId = useRef(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 }; diff --git a/server/web/src/hooks/useFleetDeleteConfirm.test.ts b/server/web/src/hooks/useFleetDeleteConfirm.test.ts new file mode 100644 index 0000000..c9567e9 --- /dev/null +++ b/server/web/src/hooks/useFleetDeleteConfirm.test.ts @@ -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); + }); + }); +}); diff --git a/server/web/src/hooks/useFleetDeleteConfirm.tsx b/server/web/src/hooks/useFleetDeleteConfirm.tsx new file mode 100644 index 0000000..7b51471 --- /dev/null +++ b/server/web/src/hooks/useFleetDeleteConfirm.tsx @@ -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((resolve) => { + setPending({ ...req, resolve }); + }); + }, []); + + const finish = (confirmed: boolean) => { + pending?.resolve(confirmed); + setPending(null); + }; + + const modal = ( + finish(true)} + onCancel={() => finish(false)} + /> + ); + + return { requestDeleteConfirm, modal }; +} diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index 5e67dbf..9711594 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -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(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() { {focusedAgent && ( - + )} {/* ── Focused machine banner ──────────────────────────────────────── */} @@ -1520,9 +1523,34 @@ export default function CruciblePage() { ⚠ offline — commands will fail until it reconnects )} + + + +