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

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

View File

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

View File

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

View File

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