From 96f9c0ab45001d604cb07ad6ec6baceac582f63f Mon Sep 17 00:00:00 2001
From: Mike Swanson
Date: Sun, 31 May 2026 14:14:49 -0700
Subject: [PATCH] feat(dashboard): operator removal UI for stale
machines/sessions (SPEC-004 Task 5)
Admin-only per-row Remove + multi-select bulk removal on the machines view, plus
per-row purge Remove on the sessions view, wired to the Task-5 admin API
(DELETE /api/machines|sessions/:id?purge=true, POST /api/machines/bulk-remove).
Confirm modals (danger-styled, focus-trapped), TanStack refetch so purged rows
leave the console, structured ApiError surfacing, honest partial-bulk summary,
and admin-gating via useAuth().isAdmin as defense-in-depth over the server 403.
Replaces the legacy all-user delete trigger. typecheck/lint/build clean.
Implements specs/v2-stable-identity/plan.md Task 5 (dashboard portion).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
dashboard/src/api/machines.ts | 28 ++-
dashboard/src/api/sessions.ts | 27 ++-
dashboard/src/api/types.ts | 51 +++++
dashboard/src/components/ui/table.css | 43 ++++
.../machines/BulkRemoveMachinesDialog.tsx | 112 +++++++++++
.../features/machines/DeleteMachineDialog.tsx | 7 +
.../src/features/machines/MachinesPage.tsx | 190 ++++++++++++++++--
.../features/machines/RemoveMachineDialog.tsx | 73 +++++++
dashboard/src/features/machines/hooks.ts | 15 ++
.../features/sessions/RemoveSessionDialog.tsx | 68 +++++++
.../src/features/sessions/SessionsPage.tsx | 22 ++
dashboard/src/features/sessions/hooks.ts | 14 ++
12 files changed, 627 insertions(+), 23 deletions(-)
create mode 100644 dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx
create mode 100644 dashboard/src/features/machines/RemoveMachineDialog.tsx
create mode 100644 dashboard/src/features/sessions/RemoveSessionDialog.tsx
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 });
+ },
+ });
+}