diff --git a/dashboard/src/api/machines.ts b/dashboard/src/api/machines.ts index 9799863..4371ddc 100644 --- a/dashboard/src/api/machines.ts +++ b/dashboard/src/api/machines.ts @@ -1,5 +1,6 @@ import { http } from "./client"; import type { + BulkRemoveResponse, CreatedKey, DeleteMachineParams, DeleteMachineResponse, @@ -29,12 +30,21 @@ export function getMachineHistory( ); } -/** DELETE /api/machines/:agent_id — remove a machine, optionally uninstall/export. */ +/** + * DELETE /api/machines/:agent_id — remove a machine (admin only). + * + * Two server-side modes, selected by the query flags: + * - `purge: true` → soft-delete + purge the in-memory session (Task 5 + * operator removal of ghost rows). Mutually exclusive with uninstall/export. + * - otherwise → the legacy hard delete, optionally commanding the agent + * to uninstall and/or returning full history in the response. + */ export function deleteMachine( agentId: string, params: DeleteMachineParams = {}, ): Promise { const qs = new URLSearchParams(); + if (params.purge) qs.set("purge", "true"); if (params.uninstall) qs.set("uninstall", "true"); if (params.export) qs.set("export", "true"); const suffix = qs.toString() ? `?${qs.toString()}` : ""; @@ -43,6 +53,22 @@ export function deleteMachine( ); } +/** + * POST /api/machines/bulk-remove — remove many machines at once (admin only). + * Each id is soft-deleted + its session purged when `purge` is true. Invalid or + * unknown ids are reported per-id in the response rather than failing the batch; + * the server caps the batch at 500. + */ +export function bulkRemoveMachines( + ids: string[], + purge = true, +): Promise { + return http.post("/api/machines/bulk-remove", { + ids, + purge, + }); +} + // --- Admin: per-agent keys -------------------------------------------------- /** GET /api/machines/:agent_id/keys — list key metadata (admin only). */ diff --git a/dashboard/src/api/sessions.ts b/dashboard/src/api/sessions.ts index b986750..e47af47 100644 --- a/dashboard/src/api/sessions.ts +++ b/dashboard/src/api/sessions.ts @@ -1,5 +1,9 @@ import { http } from "./client"; -import type { Session, ViewerTokenResponse } from "./types"; +import type { + RemoveSessionResponse, + Session, + ViewerTokenResponse, +} from "./types"; /** * GET /api/sessions — all live sessions known to the relay's in-memory session @@ -27,10 +31,25 @@ export function mintViewerToken( } /** - * DELETE /api/sessions/:id — disconnect/end a live session. The relay sends a - * Disconnect to the agent. Returns 200 on success, 404 if the session is not - * found. Requires an authenticated dashboard JWT (not admin-gated server-side). + * DELETE /api/sessions/:id — disconnect/end a live session (admin only). The + * relay sends a Disconnect to the agent. Returns 200 on success, 404 if the + * session is not live in memory. This is the live-only path (no `purge`); it + * does not soft-delete any persisted row. */ export function endSession(sessionId: string): Promise { return http.del(`/api/sessions/${encodeURIComponent(sessionId)}`); } + +/** + * DELETE /api/sessions/:id?purge=true — operator removal of a session (admin + * only). Soft-deletes the persisted `connect_sessions` row and drops any live + * in-memory session, clearing a ghost/stale session from the console. 404 only + * when neither a live nor a persisted session exists. + */ +export function purgeSession( + sessionId: string, +): Promise { + return http.del( + `/api/sessions/${encodeURIComponent(sessionId)}?purge=true`, + ); +} diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts index 4ce7021..81e3331 100644 --- a/dashboard/src/api/types.ts +++ b/dashboard/src/api/types.ts @@ -164,6 +164,13 @@ export interface DeleteMachineParams { uninstall?: boolean; /** Include full history in the delete response before removal. */ export?: boolean; + /** + * Operator-removal (Task 5): soft-delete the machine and purge its in-memory + * session so a ghost row disappears from the console. Selects the server's + * `?purge=true` path (admin-only). Mutually exclusive with the legacy + * `uninstall`/`export` hard-delete options. + */ + purge?: boolean; } export interface DeleteMachineResponse { @@ -173,6 +180,38 @@ export interface DeleteMachineResponse { history: MachineHistory | null; } +/** + * Per-id outcome in a bulk machine removal. Mirrors + * `api::removal::BulkRemoveItem`. `status` is one of `removed` | `not_found` | + * `invalid` | `error` (widened to string for forward compatibility). + */ +export interface BulkRemoveItem { + agent_id: string; + status: "removed" | "not_found" | "invalid" | "error" | string; +} + +/** + * Body for `POST /api/machines/bulk-remove`. Mirrors + * `api::removal::BulkRemoveRequest`. The server caps the batch at 500 ids and + * defaults `purge` to true; we always send it explicitly for the operator + * removal workflow. + */ +export interface BulkRemoveRequest { + ids: string[]; + purge: boolean; +} + +/** + * Summary body for a bulk removal. Mirrors `api::removal::BulkRemoveResponse`. + * `requested` is the batch size, `removed` the count that actually soft-deleted, + * and `results` the per-id outcomes. + */ +export interface BulkRemoveResponse { + requested: number; + removed: number; + results: BulkRemoveItem[]; +} + // --------------------------------------------------------------------------- // Sessions (live relay state) // --------------------------------------------------------------------------- @@ -221,6 +260,18 @@ export interface Session { consent_state: ConsentState | string; } +/** + * Response from `DELETE /api/sessions/:id?purge=true`. Mirrors + * `api::removal::RemoveSessionResponse`. `soft_deleted` is whether a persisted + * `connect_sessions` row was marked deleted (false when the session was only + * live in memory, e.g. an attended session that never persisted). + */ +export interface RemoveSessionResponse { + success: boolean; + message: string; + soft_deleted: boolean; +} + /** Access mode the relay grants a minted viewer token. */ export type ViewerAccess = "control" | "view_only"; diff --git a/dashboard/src/components/ui/table.css b/dashboard/src/components/ui/table.css index f2ab114..54e410f 100644 --- a/dashboard/src/components/ui/table.css +++ b/dashboard/src/components/ui/table.css @@ -53,6 +53,33 @@ padding-right: 0 !important; } +/* Selection column — fixed narrow rail to the left of the status dot. */ +.dt__select { + width: 34px; + padding-left: 16px !important; + padding-right: 0 !important; +} +/* Generous hit target around the checkbox; the label also stops row-click. */ +.dt__checkwrap { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + margin: -6px; + cursor: pointer; +} +.dt__check { + width: 15px; + height: 15px; + accent-color: var(--accent); + cursor: pointer; +} +.dt__check:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring); + border-radius: 3px; +} + /* Cell affordances. */ .dt__mono { font-family: var(--font-mono); @@ -151,3 +178,19 @@ .toolbar__count .mono { color: var(--text); } + +/* Bulk-action bar: replaces the count readout when rows are selected. */ +.bulkbar { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 10px; +} +.bulkbar__count { + font-size: 12px; + color: var(--text-muted); +} +.bulkbar__count .mono { + color: var(--text); + font-weight: 600; +} diff --git a/dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx b/dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx new file mode 100644 index 0000000..d638ad2 --- /dev/null +++ b/dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx @@ -0,0 +1,112 @@ +import { ApiError } from "../../api/client"; +import type { BulkRemoveItem } from "../../api/types"; +import { ConfirmDialog } from "../../components/ui/ConfirmDialog"; +import { useToast } from "../../components/ui/toast-context"; +import { useBulkRemoveMachines } from "./hooks"; + +interface BulkRemoveMachinesDialogProps { + /** Selected agent_ids to remove, or empty when the dialog is closed. */ + agentIds: string[]; + /** Whether the dialog is open. Kept explicit so an empty list can stay open. */ + open: boolean; + onClose: () => void; + /** Called after a successful batch so the page can clear its selection. */ + onRemoved: () => void; +} + +/** Count outcomes by status for a compact "12 removed, 1 not found" summary. */ +function summarize(results: BulkRemoveItem[]): string { + const counts = new Map(); + for (const r of results) counts.set(r.status, (counts.get(r.status) ?? 0) + 1); + const order = ["removed", "not_found", "invalid", "error"]; + const labels: Record = { + removed: "removed", + not_found: "not found", + invalid: "invalid", + error: "errored", + }; + const parts: string[] = []; + for (const status of order) { + const n = counts.get(status); + if (n) parts.push(`${n} ${labels[status] ?? status}`); + } + // Surface any unexpected status the server may add in the future. + for (const [status, n] of counts) { + if (!order.includes(status)) parts.push(`${n} ${status}`); + } + return parts.join(", "); +} + +/** + * Confirm + bulk-remove the selected machines (Task 5). On confirm the selected + * agent_ids are purged in one request; the per-id summary the server returns is + * surfaced as a toast (e.g. "12 removed, 1 not found") so a partial outcome is + * visible rather than silently swallowed. + */ +export function BulkRemoveMachinesDialog({ + agentIds, + open, + onClose, + onRemoved, +}: BulkRemoveMachinesDialogProps) { + const toast = useToast(); + const bulkRemove = useBulkRemoveMachines(); + const count = agentIds.length; + + function onConfirm() { + if (count === 0) { + onClose(); + return; + } + bulkRemove.mutate(agentIds, { + onSuccess: (res) => { + const summary = summarize(res.results); + if (res.removed === res.requested) { + toast.success( + `Removed ${res.removed} ${res.removed === 1 ? "machine" : "machines"}`, + summary || undefined, + ); + } else { + // Partial: some ids were not found / invalid. Report as info, not an + // error — the requested removals that could happen, did. + toast.info( + `Removed ${res.removed} of ${res.requested}`, + summary || undefined, + ); + } + onRemoved(); + onClose(); + }, + onError: (err) => { + toast.error( + "Could not remove machines", + err instanceof ApiError + ? `${err.message}${err.code ? ` (${err.code})` : ""}` + : "The server did not respond. No machines were removed.", + ); + }, + }); + } + + return ( + + Remove the {count} selected{" "} + {count === 1 ? "machine" : "machines"} from the GuruConnect console. + Their live sessions are dropped and the rows disappear from the list. + Any that are genuinely still in service re-appear when their agents + next check in. +

+ } + /> + ); +} diff --git a/dashboard/src/features/machines/DeleteMachineDialog.tsx b/dashboard/src/features/machines/DeleteMachineDialog.tsx index d8f508f..e5564fc 100644 --- a/dashboard/src/features/machines/DeleteMachineDialog.tsx +++ b/dashboard/src/features/machines/DeleteMachineDialog.tsx @@ -12,6 +12,13 @@ interface DeleteMachineDialogProps { } /** + * INTENTIONALLY UNWIRED. This legacy per-row delete dialog was superseded by + * the admin-only purge Remove (RemoveMachineDialog / BulkRemoveMachinesDialog) + * and currently has no caller. It is kept — not deleted — because it is the + * only remaining caller pattern for the `uninstall`/`export` machine-delete + * params, pending a future "full uninstall/export" admin action that will + * re-wire it. Do not treat its lack of references as a wiring bug. + * * Destructive machine removal with two options: * - uninstall: also command the agent to uninstall (only meaningful online) * - export: return full history in the delete response before removal diff --git a/dashboard/src/features/machines/MachinesPage.tsx b/dashboard/src/features/machines/MachinesPage.tsx index 78e196d..4cb9245 100644 --- a/dashboard/src/features/machines/MachinesPage.tsx +++ b/dashboard/src/features/machines/MachinesPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ApiError } from "../../api/client"; import type { Machine } from "../../api/types"; import { useAuth } from "../../auth/AuthContext"; @@ -21,9 +21,10 @@ import { Table, type Column } from "../../components/ui/Table"; import { TableSkeleton } from "../../components/ui/TableSkeleton"; import { absoluteTime, relativeTime } from "../../lib/time"; import "./machines.css"; -import { DeleteMachineDialog } from "./DeleteMachineDialog"; +import { BulkRemoveMachinesDialog } from "./BulkRemoveMachinesDialog"; import { MachineDetailDrawer } from "./MachineDetailDrawer"; import { MachineKeysModal } from "./MachineKeysModal"; +import { RemoveMachineDialog } from "./RemoveMachineDialog"; import { useMachines } from "./hooks"; export function MachinesPage() { @@ -33,7 +34,14 @@ export function MachinesPage() { const [detailFor, setDetailFor] = useState(null); const [keysFor, setKeysFor] = useState(null); - const [deleteFor, setDeleteFor] = useState(null); + const [removeFor, setRemoveFor] = useState(null); + + // Bulk-select state (admin only). Keyed by agent_id so selection survives the + // 20s poll re-ordering. A stale id (machine removed elsewhere) is harmless — + // it is reconciled against the current list before any action. + const [selected, setSelected] = useState>(new Set()); + const [bulkOpen, setBulkOpen] = useState(false); + const selectAllRef = useRef(null); const { data } = machinesQuery; const machines = useMemo(() => data ?? [], [data]); @@ -53,7 +61,103 @@ export function MachinesPage() { [machines], ); + // Selection is scoped to the currently-visible (filtered) rows. + const selectedVisible = useMemo( + () => filtered.filter((m) => selected.has(m.agent_id)), + [filtered, selected], + ); + const allVisibleSelected = + filtered.length > 0 && selectedVisible.length === filtered.length; + const someVisibleSelected = + selectedVisible.length > 0 && !allVisibleSelected; + + // Drop selections that no longer exist (removed, or filtered away by a poll + // that dropped the machine) so the "Remove selected (N)" count stays truthful. + useEffect(() => { + setSelected((prev) => { + if (prev.size === 0) return prev; + const live = new Set(machines.map((m) => m.agent_id)); + let changed = false; + const next = new Set(); + for (const id of prev) { + if (live.has(id)) next.add(id); + else changed = true; + } + return changed ? next : prev; + }); + }, [machines]); + + // Reflect the partial state on the header checkbox (indeterminate is DOM-only). + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = someVisibleSelected; + } + }, [someVisibleSelected]); + + function toggleOne(agentId: string, checked: boolean) { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(agentId); + else next.delete(agentId); + return next; + }); + } + + function toggleAllVisible(checked: boolean) { + setSelected((prev) => { + const next = new Set(prev); + for (const m of filtered) { + if (checked) next.add(m.agent_id); + else next.delete(m.agent_id); + } + return next; + }); + } + + function clearSelection() { + setSelected(new Set()); + } + const columns: Column[] = [ + // Admin-only selection rail. Built conditionally so non-admins never see it. + ...(isAdmin + ? [ + { + key: "select", + cellClass: "dt__select", + header: ( + + ), + render: (m: Machine) => ( + + ), + } satisfies Column, + ] + : []), { key: "status", header: "", @@ -132,15 +236,18 @@ export function MachinesPage() { Keys )} - + {/* Removal is admin-only, mirroring the server's 403 for non-admins. */} + {isAdmin && ( + + )} ), }, @@ -177,10 +284,40 @@ export function MachinesPage() { aria-label="Filter machines" /> -
- {onlineCount} online ·{" "} - {machines.length} total -
+ {/* Bar count tracks the VISIBLE selected rows — the exact set the + bulk dialog will state and remove. Under an active filter that + hides some selected rows, this keeps bar == dialog == removed. + Keyed off selectedVisible so a "Remove selected (0)" bar never + shows when the only selections are filtered out of view. */} + {isAdmin && selectedVisible.length > 0 ? ( +
+ + {selectedVisible.length} selected + + + +
+ ) : ( +
+ {onlineCount} online ·{" "} + {machines.length} total +
+ )} @@ -190,7 +327,11 @@ export function MachinesPage() { Loading machines ) : machinesQuery.isError ? ( @@ -239,7 +380,20 @@ export function MachinesPage() { {isAdmin && ( setKeysFor(null)} /> )} - setDeleteFor(null)} /> + {isAdmin && ( + setRemoveFor(null)} + /> + )} + {isAdmin && ( + m.agent_id)} + onClose={() => setBulkOpen(false)} + onRemoved={clearSelection} + /> + )} ); } diff --git a/dashboard/src/features/machines/RemoveMachineDialog.tsx b/dashboard/src/features/machines/RemoveMachineDialog.tsx new file mode 100644 index 0000000..6354aa4 --- /dev/null +++ b/dashboard/src/features/machines/RemoveMachineDialog.tsx @@ -0,0 +1,73 @@ +import { ApiError } from "../../api/client"; +import type { Machine } from "../../api/types"; +import { ConfirmDialog } from "../../components/ui/ConfirmDialog"; +import { useToast } from "../../components/ui/toast-context"; +import { useDeleteMachine } from "./hooks"; + +interface RemoveMachineDialogProps { + /** The machine to remove, or null when the dialog is closed. */ + machine: Machine | null; + onClose: () => void; +} + +/** + * Operator removal (Task 5): purge a single machine from the console. This is + * the soft-delete path (`?purge=true`) used to clear ghost/stale rows — it does + * NOT uninstall the agent or export history (that is the separate full-delete + * dialog). On confirm the row is soft-deleted and its live session dropped; a + * genuinely-reconnecting machine re-appears on its next check-in. + */ +export function RemoveMachineDialog({ + machine, + onClose, +}: RemoveMachineDialogProps) { + const toast = useToast(); + const remove = useDeleteMachine(); + + function onConfirm() { + if (!machine) return; + remove.mutate( + { agentId: machine.agent_id, params: { purge: true } }, + { + onSuccess: () => { + toast.success( + "Machine removed", + `${machine.hostname} was removed from the console.`, + ); + onClose(); + }, + onError: (err) => { + toast.error( + "Could not remove machine", + err instanceof ApiError + ? `${err.message}${err.code ? ` (${err.code})` : ""}` + : "The server did not respond. The machine was not removed.", + ); + }, + }, + ); + } + + return ( + + Remove {machine.hostname} from the GuruConnect + console. Its live session is dropped and the row disappears from the + list. If this machine is genuinely still in service it re-appears the + next time its agent checks in. +

+ ) : null + } + /> + ); +} diff --git a/dashboard/src/features/machines/hooks.ts b/dashboard/src/features/machines/hooks.ts index 68384db..3789f48 100644 --- a/dashboard/src/features/machines/hooks.ts +++ b/dashboard/src/features/machines/hooks.ts @@ -45,6 +45,21 @@ export function useDeleteMachine() { }); } +/** + * Bulk-remove (purge) many machines, then invalidate the list so the removed + * rows drop. Resolves with the per-id summary so the caller can report how many + * actually removed vs. were not found / invalid. + */ +export function useBulkRemoveMachines() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (ids: string[]) => machinesApi.bulkRemoveMachines(ids, true), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: MACHINES_KEY }); + }, + }); +} + // --- Admin: per-agent keys -------------------------------------------------- export function useMachineKeys(agentId: string | null, enabled: boolean) { diff --git a/dashboard/src/features/sessions/RemoveSessionDialog.tsx b/dashboard/src/features/sessions/RemoveSessionDialog.tsx new file mode 100644 index 0000000..7d9f218 --- /dev/null +++ b/dashboard/src/features/sessions/RemoveSessionDialog.tsx @@ -0,0 +1,68 @@ +import { ApiError } from "../../api/client"; +import type { Session } from "../../api/types"; +import { ConfirmDialog } from "../../components/ui/ConfirmDialog"; +import { useToast } from "../../components/ui/toast-context"; +import { usePurgeSession } from "./hooks"; + +interface RemoveSessionDialogProps { + /** The session to remove, or null when the dialog is closed. */ + session: Session | null; + onClose: () => void; +} + +/** + * Operator removal (Task 5): purge a session from the console. Unlike "End" + * (which only disconnects a live agent), this soft-deletes the persisted row and + * drops any in-memory session, clearing a ghost/stale session — including one + * for an agent that is already offline. + */ +export function RemoveSessionDialog({ + session, + onClose, +}: RemoveSessionDialogProps) { + const toast = useToast(); + const purge = usePurgeSession(); + + function onConfirm() { + if (!session) return; + purge.mutate(session.id, { + onSuccess: () => { + toast.success( + "Session removed", + `Cleared the session for ${session.agent_name}.`, + ); + onClose(); + }, + onError: (err) => { + toast.error( + "Could not remove session", + err instanceof ApiError + ? `${err.message}${err.code ? ` (${err.code})` : ""}` + : "The relay did not respond. The session was not removed.", + ); + }, + }); + } + + return ( + + Remove the session for {session.agent_name} from the + console. Any live connection is dropped and the row disappears from + the list. This clears a stale or ghost session and cannot be undone. +

+ ) : null + } + /> + ); +} diff --git a/dashboard/src/features/sessions/SessionsPage.tsx b/dashboard/src/features/sessions/SessionsPage.tsx index c35d23f..f4ffc12 100644 --- a/dashboard/src/features/sessions/SessionsPage.tsx +++ b/dashboard/src/features/sessions/SessionsPage.tsx @@ -8,6 +8,7 @@ import { RefreshIcon, SearchIcon, StopIcon, + TrashIcon, } from "../../components/layout/icons"; import { Badge } from "../../components/ui/Badge"; import { Button } from "../../components/ui/Button"; @@ -21,6 +22,7 @@ import { TableSkeleton } from "../../components/ui/TableSkeleton"; import { absoluteTime, relativeTime } from "../../lib/time"; import { EndSessionDialog } from "./EndSessionDialog"; import { JoinSessionModal } from "./JoinSessionModal"; +import { RemoveSessionDialog } from "./RemoveSessionDialog"; import { useSessions } from "./hooks"; import "./sessions.css"; @@ -44,6 +46,7 @@ export function SessionsPage() { const [joinFor, setJoinFor] = useState(null); const [endFor, setEndFor] = useState(null); + const [removeFor, setRemoveFor] = useState(null); // The same authz split the server enforces at mint time: admin OR the // `control` permission yields a control token; otherwise the floor is `view`. @@ -193,6 +196,19 @@ export function SessionsPage() { End + {/* Operator removal (admin only): purge a stale/ghost session, + including offline ones the live-only "End" cannot clear. */} + {isAdmin && ( + + )} ); }, @@ -301,6 +317,12 @@ export function SessionsPage() { onClose={() => setJoinFor(null)} /> setEndFor(null)} /> + {isAdmin && ( + setRemoveFor(null)} + /> + )} ); } diff --git a/dashboard/src/features/sessions/hooks.ts b/dashboard/src/features/sessions/hooks.ts index c0c4c11..dcf25a1 100644 --- a/dashboard/src/features/sessions/hooks.ts +++ b/dashboard/src/features/sessions/hooks.ts @@ -44,3 +44,17 @@ export function useEndSession() { }, }); } + +/** + * Operator-remove (purge) a session: soft-delete the persisted row + drop any + * live in-memory session, then invalidate the list so the ghost row disappears. + */ +export function usePurgeSession() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (sessionId: string) => sessionsApi.purgeSession(sessionId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: SESSIONS_KEY }); + }, + }); +}