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
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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
OathSpreadDiscoveredHost = "spread_discovered_host"
|
||||
OathStrainHospice = "strain_hospice"
|
||||
OathReconScan = "recon_scan"
|
||||
OathAgentRemoved = "agent_removed"
|
||||
)
|
||||
|
||||
// Oath outcomes.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
server/web/src/components/Fleet/FleetDeleteConfirmModal.css
Normal file
50
server/web/src/components/Fleet/FleetDeleteConfirmModal.css
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
61
server/web/src/components/Fleet/FleetDeleteConfirmModal.tsx
Normal file
61
server/web/src/components/Fleet/FleetDeleteConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
41
server/web/src/hooks/useFleetDeleteConfirm.test.ts
Normal file
41
server/web/src/hooks/useFleetDeleteConfirm.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
server/web/src/hooks/useFleetDeleteConfirm.tsx
Normal file
36
server/web/src/hooks/useFleetDeleteConfirm.tsx
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user