ScuttleBot
feat: bulk delete agents + auto-reap setting in UI Add POST /v1/agents/bulk-delete endpoint accepting {nicks: [...]} for deleting multiple agents at once. Web UI agents tab gets selection checkboxes and a "delete selected" button. Auto-reap was already implemented (registry.Reap + background goroutine) but the reap_after_days setting was already in the agent policy UI card. No backend changes needed — just documenting that it works.
Commit
9a6fd4796a38bb95b29ac144d86ac6d1eee9496e06636103470d3f4a54b0e65a
Parent
a1cd907f8751b86…
3 files changed
+29
+1
+19
-1
+29
| --- internal/api/agents.go | ||
| +++ internal/api/agents.go | ||
| @@ -149,10 +149,39 @@ | ||
| 149 | 149 | writeError(w, http.StatusInternalServerError, "deletion failed") |
| 150 | 150 | return |
| 151 | 151 | } |
| 152 | 152 | w.WriteHeader(http.StatusNoContent) |
| 153 | 153 | } |
| 154 | + | |
| 155 | +// handleBulkDeleteAgents handles POST /v1/agents/bulk-delete. | |
| 156 | +func (s *Server) handleBulkDeleteAgents(w http.ResponseWriter, r *http.Request) { | |
| 157 | + var req struct { | |
| 158 | + Nicks []string `json:"nicks"` | |
| 159 | + } | |
| 160 | + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 161 | + writeError(w, http.StatusBadRequest, "invalid request body") | |
| 162 | + return | |
| 163 | + } | |
| 164 | + if len(req.Nicks) == 0 { | |
| 165 | + writeError(w, http.StatusBadRequest, "nicks list is required") | |
| 166 | + return | |
| 167 | + } | |
| 168 | + | |
| 169 | + var deleted, failed int | |
| 170 | + for _, nick := range req.Nicks { | |
| 171 | + if agent, err := s.registry.Get(nick); err == nil { | |
| 172 | + s.removeAgentModes(nick, agent.Channels) | |
| 173 | + } | |
| 174 | + if err := s.registry.Delete(nick); err != nil { | |
| 175 | + s.log.Warn("bulk delete: failed", "nick", nick, "err", err) | |
| 176 | + failed++ | |
| 177 | + } else { | |
| 178 | + deleted++ | |
| 179 | + } | |
| 180 | + } | |
| 181 | + writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted, "failed": failed}) | |
| 182 | +} | |
| 154 | 183 | |
| 155 | 184 | func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) { |
| 156 | 185 | nick := r.PathValue("nick") |
| 157 | 186 | var req struct { |
| 158 | 187 | Channels []string `json:"channels"` |
| 159 | 188 |
| --- internal/api/agents.go | |
| +++ internal/api/agents.go | |
| @@ -149,10 +149,39 @@ | |
| 149 | writeError(w, http.StatusInternalServerError, "deletion failed") |
| 150 | return |
| 151 | } |
| 152 | w.WriteHeader(http.StatusNoContent) |
| 153 | } |
| 154 | |
| 155 | func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) { |
| 156 | nick := r.PathValue("nick") |
| 157 | var req struct { |
| 158 | Channels []string `json:"channels"` |
| 159 |
| --- internal/api/agents.go | |
| +++ internal/api/agents.go | |
| @@ -149,10 +149,39 @@ | |
| 149 | writeError(w, http.StatusInternalServerError, "deletion failed") |
| 150 | return |
| 151 | } |
| 152 | w.WriteHeader(http.StatusNoContent) |
| 153 | } |
| 154 | |
| 155 | // handleBulkDeleteAgents handles POST /v1/agents/bulk-delete. |
| 156 | func (s *Server) handleBulkDeleteAgents(w http.ResponseWriter, r *http.Request) { |
| 157 | var req struct { |
| 158 | Nicks []string `json:"nicks"` |
| 159 | } |
| 160 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 161 | writeError(w, http.StatusBadRequest, "invalid request body") |
| 162 | return |
| 163 | } |
| 164 | if len(req.Nicks) == 0 { |
| 165 | writeError(w, http.StatusBadRequest, "nicks list is required") |
| 166 | return |
| 167 | } |
| 168 | |
| 169 | var deleted, failed int |
| 170 | for _, nick := range req.Nicks { |
| 171 | if agent, err := s.registry.Get(nick); err == nil { |
| 172 | s.removeAgentModes(nick, agent.Channels) |
| 173 | } |
| 174 | if err := s.registry.Delete(nick); err != nil { |
| 175 | s.log.Warn("bulk delete: failed", "nick", nick, "err", err) |
| 176 | failed++ |
| 177 | } else { |
| 178 | deleted++ |
| 179 | } |
| 180 | } |
| 181 | writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted, "failed": failed}) |
| 182 | } |
| 183 | |
| 184 | func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) { |
| 185 | nick := r.PathValue("nick") |
| 186 | var req struct { |
| 187 | Channels []string `json:"channels"` |
| 188 |
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -75,10 +75,11 @@ | ||
| 75 | 75 | apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister)) |
| 76 | 76 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate)) |
| 77 | 77 | apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt)) |
| 78 | 78 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke)) |
| 79 | 79 | apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete)) |
| 80 | + apiMux.HandleFunc("POST /v1/agents/bulk-delete", s.requireScope(auth.ScopeAgents, s.handleBulkDeleteAgents)) | |
| 80 | 81 | |
| 81 | 82 | // Channels — channels scope (read), chat scope (send). |
| 82 | 83 | if s.bridge != nil { |
| 83 | 84 | apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels)) |
| 84 | 85 | apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel)) |
| 85 | 86 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -75,10 +75,11 @@ | |
| 75 | apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister)) |
| 76 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate)) |
| 77 | apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt)) |
| 78 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke)) |
| 79 | apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete)) |
| 80 | |
| 81 | // Channels — channels scope (read), chat scope (send). |
| 82 | if s.bridge != nil { |
| 83 | apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels)) |
| 84 | apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel)) |
| 85 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -75,10 +75,11 @@ | |
| 75 | apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister)) |
| 76 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate)) |
| 77 | apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt)) |
| 78 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke)) |
| 79 | apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete)) |
| 80 | apiMux.HandleFunc("POST /v1/agents/bulk-delete", s.requireScope(auth.ScopeAgents, s.handleBulkDeleteAgents)) |
| 81 | |
| 82 | // Channels — channels scope (read), chat scope (send). |
| 83 | if s.bridge != nil { |
| 84 | apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels)) |
| 85 | apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel)) |
| 86 |
+19
-1
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -457,10 +457,11 @@ | ||
| 457 | 457 | <option value="revoked">revoked</option> |
| 458 | 458 | </select> |
| 459 | 459 | <div class="spacer"></div> |
| 460 | 460 | <span class="badge" id="agent-count" style="margin-right:4px">0</span> |
| 461 | 461 | <button class="sm" onclick="loadAgents()">↻ refresh</button> |
| 462 | + <button class="sm danger" id="bulk-delete-btn" style="display:none" onclick="bulkDeleteAgents()">delete selected</button> | |
| 462 | 463 | <button class="sm primary" onclick="openDrawer()">+ register agent</button> |
| 463 | 464 | </div> |
| 464 | 465 | <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px"> |
| 465 | 466 | <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button> |
| 466 | 467 | <span id="agent-page-info"></span> |
| @@ -1677,11 +1678,11 @@ | ||
| 1677 | 1678 | const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join(''); |
| 1678 | 1679 | const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : ''; |
| 1679 | 1680 | const seen = a.last_seen ? relTime(a.last_seen) : 'never'; |
| 1680 | 1681 | const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e'; |
| 1681 | 1682 | return `<tr${a.revoked?' style="opacity:0.5"':''}> |
| 1682 | - <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td> | |
| 1683 | + <td><input type="checkbox" class="agent-select" value="${esc(a.nick)}" onchange="updateBulkBtn()" style="margin-right:6px">${presenceDot(a)} <strong>${esc(a.nick)}</strong></td> | |
| 1683 | 1684 | <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td> |
| 1684 | 1685 | <td>${chs||'<span style="color:#8b949e">—</span>'}</td> |
| 1685 | 1686 | <td style="white-space:nowrap;${seenStyle}">${seen}</td> |
| 1686 | 1687 | <td><div class="actions">${!a.revoked?` |
| 1687 | 1688 | <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button> |
| @@ -1702,10 +1703,27 @@ | ||
| 1702 | 1703 | async function deleteAgent(nick) { |
| 1703 | 1704 | if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return; |
| 1704 | 1705 | try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); } |
| 1705 | 1706 | catch(e) { alert('Delete failed: '+e.message); } |
| 1706 | 1707 | } |
| 1708 | +function updateBulkBtn() { | |
| 1709 | + const checked = document.querySelectorAll('.agent-select:checked'); | |
| 1710 | + const btn = document.getElementById('bulk-delete-btn'); | |
| 1711 | + btn.style.display = checked.length > 0 ? '' : 'none'; | |
| 1712 | + btn.textContent = `delete selected (${checked.length})`; | |
| 1713 | +} | |
| 1714 | +async function bulkDeleteAgents() { | |
| 1715 | + const nicks = [...document.querySelectorAll('.agent-select:checked')].map(cb => cb.value); | |
| 1716 | + if (!nicks.length) return; | |
| 1717 | + if (!confirm(`Delete ${nicks.length} agent(s)? This permanently removes them from the registry.\n\n${nicks.join(', ')}`)) return; | |
| 1718 | + try { | |
| 1719 | + const result = await api('POST', '/v1/agents/bulk-delete', {nicks}); | |
| 1720 | + await loadAgents(); | |
| 1721 | + await loadStatus(); | |
| 1722 | + if (result.failed > 0) alert(`Deleted ${result.deleted}, failed ${result.failed}`); | |
| 1723 | + } catch(e) { alert('Bulk delete failed: ' + e.message); } | |
| 1724 | +} | |
| 1707 | 1725 | async function rotateAgent(nick) { |
| 1708 | 1726 | try { |
| 1709 | 1727 | const creds = await api('POST', `/v1/agents/${nick}/rotate`); |
| 1710 | 1728 | // Show result in whichever drawer is relevant. |
| 1711 | 1729 | showCredentials(nick, creds, null, 'rotate'); |
| 1712 | 1730 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -457,10 +457,11 @@ | |
| 457 | <option value="revoked">revoked</option> |
| 458 | </select> |
| 459 | <div class="spacer"></div> |
| 460 | <span class="badge" id="agent-count" style="margin-right:4px">0</span> |
| 461 | <button class="sm" onclick="loadAgents()">↻ refresh</button> |
| 462 | <button class="sm primary" onclick="openDrawer()">+ register agent</button> |
| 463 | </div> |
| 464 | <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px"> |
| 465 | <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button> |
| 466 | <span id="agent-page-info"></span> |
| @@ -1677,11 +1678,11 @@ | |
| 1677 | const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join(''); |
| 1678 | const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : ''; |
| 1679 | const seen = a.last_seen ? relTime(a.last_seen) : 'never'; |
| 1680 | const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e'; |
| 1681 | return `<tr${a.revoked?' style="opacity:0.5"':''}> |
| 1682 | <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td> |
| 1683 | <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td> |
| 1684 | <td>${chs||'<span style="color:#8b949e">—</span>'}</td> |
| 1685 | <td style="white-space:nowrap;${seenStyle}">${seen}</td> |
| 1686 | <td><div class="actions">${!a.revoked?` |
| 1687 | <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button> |
| @@ -1702,10 +1703,27 @@ | |
| 1702 | async function deleteAgent(nick) { |
| 1703 | if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return; |
| 1704 | try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); } |
| 1705 | catch(e) { alert('Delete failed: '+e.message); } |
| 1706 | } |
| 1707 | async function rotateAgent(nick) { |
| 1708 | try { |
| 1709 | const creds = await api('POST', `/v1/agents/${nick}/rotate`); |
| 1710 | // Show result in whichever drawer is relevant. |
| 1711 | showCredentials(nick, creds, null, 'rotate'); |
| 1712 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -457,10 +457,11 @@ | |
| 457 | <option value="revoked">revoked</option> |
| 458 | </select> |
| 459 | <div class="spacer"></div> |
| 460 | <span class="badge" id="agent-count" style="margin-right:4px">0</span> |
| 461 | <button class="sm" onclick="loadAgents()">↻ refresh</button> |
| 462 | <button class="sm danger" id="bulk-delete-btn" style="display:none" onclick="bulkDeleteAgents()">delete selected</button> |
| 463 | <button class="sm primary" onclick="openDrawer()">+ register agent</button> |
| 464 | </div> |
| 465 | <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px"> |
| 466 | <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button> |
| 467 | <span id="agent-page-info"></span> |
| @@ -1677,11 +1678,11 @@ | |
| 1678 | const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join(''); |
| 1679 | const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : ''; |
| 1680 | const seen = a.last_seen ? relTime(a.last_seen) : 'never'; |
| 1681 | const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e'; |
| 1682 | return `<tr${a.revoked?' style="opacity:0.5"':''}> |
| 1683 | <td><input type="checkbox" class="agent-select" value="${esc(a.nick)}" onchange="updateBulkBtn()" style="margin-right:6px">${presenceDot(a)} <strong>${esc(a.nick)}</strong></td> |
| 1684 | <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td> |
| 1685 | <td>${chs||'<span style="color:#8b949e">—</span>'}</td> |
| 1686 | <td style="white-space:nowrap;${seenStyle}">${seen}</td> |
| 1687 | <td><div class="actions">${!a.revoked?` |
| 1688 | <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button> |
| @@ -1702,10 +1703,27 @@ | |
| 1703 | async function deleteAgent(nick) { |
| 1704 | if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return; |
| 1705 | try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); } |
| 1706 | catch(e) { alert('Delete failed: '+e.message); } |
| 1707 | } |
| 1708 | function updateBulkBtn() { |
| 1709 | const checked = document.querySelectorAll('.agent-select:checked'); |
| 1710 | const btn = document.getElementById('bulk-delete-btn'); |
| 1711 | btn.style.display = checked.length > 0 ? '' : 'none'; |
| 1712 | btn.textContent = `delete selected (${checked.length})`; |
| 1713 | } |
| 1714 | async function bulkDeleteAgents() { |
| 1715 | const nicks = [...document.querySelectorAll('.agent-select:checked')].map(cb => cb.value); |
| 1716 | if (!nicks.length) return; |
| 1717 | if (!confirm(`Delete ${nicks.length} agent(s)? This permanently removes them from the registry.\n\n${nicks.join(', ')}`)) return; |
| 1718 | try { |
| 1719 | const result = await api('POST', '/v1/agents/bulk-delete', {nicks}); |
| 1720 | await loadAgents(); |
| 1721 | await loadStatus(); |
| 1722 | if (result.failed > 0) alert(`Deleted ${result.deleted}, failed ${result.failed}`); |
| 1723 | } catch(e) { alert('Bulk delete failed: ' + e.message); } |
| 1724 | } |
| 1725 | async function rotateAgent(nick) { |
| 1726 | try { |
| 1727 | const creds = await api('POST', `/v1/agents/${nick}/rotate`); |
| 1728 | // Show result in whichever drawer is relevant. |
| 1729 | showCredentials(nick, creds, null, 'rotate'); |
| 1730 |