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
)}
+
+
+
+