feat(dashboard): operator removal UI for stale machines/sessions (SPEC-004 Task 5)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m13s
Build and Test / Build Server (Linux) (push) Successful in 11m21s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 11s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 14:14:49 -07:00
parent 5ee6675337
commit 96f9c0ab45
12 changed files with 627 additions and 23 deletions

View File

@@ -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<DeleteMachineResponse> {
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<BulkRemoveResponse> {
return http.post<BulkRemoveResponse>("/api/machines/bulk-remove", {
ids,
purge,
});
}
// --- Admin: per-agent keys --------------------------------------------------
/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */

View File

@@ -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<void> {
return http.del<void>(`/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<RemoveSessionResponse> {
return http.del<RemoveSessionResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}?purge=true`,
);
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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<string, number>();
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<string, string> = {
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 (
<ConfirmDialog
open={open}
title={`Remove ${count} ${count === 1 ? "machine" : "machines"}?`}
danger
busy={bulkRemove.isPending}
confirmLabel={`Remove ${count}`}
cancelLabel="Keep machines"
onConfirm={onConfirm}
onCancel={onClose}
body={
<p style={{ marginTop: 0 }}>
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.
</p>
}
/>
);
}

View File

@@ -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

View File

@@ -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<Machine | null>(null);
const [keysFor, setKeysFor] = useState<Machine | null>(null);
const [deleteFor, setDeleteFor] = useState<Machine | null>(null);
const [removeFor, setRemoveFor] = useState<Machine | null>(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<Set<string>>(new Set());
const [bulkOpen, setBulkOpen] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(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<string>();
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<Machine>[] = [
// Admin-only selection rail. Built conditionally so non-admins never see it.
...(isAdmin
? [
{
key: "select",
cellClass: "dt__select",
header: (
<label className="dt__checkwrap">
<input
ref={selectAllRef}
type="checkbox"
className="dt__check"
checked={allVisibleSelected}
onChange={(e) => toggleAllVisible(e.target.checked)}
aria-label={
allVisibleSelected
? "Deselect all machines"
: "Select all machines"
}
/>
</label>
),
render: (m: Machine) => (
<label
className="dt__checkwrap"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
className="dt__check"
checked={selected.has(m.agent_id)}
onChange={(e) => toggleOne(m.agent_id, e.target.checked)}
aria-label={`Select ${m.hostname}`}
/>
</label>
),
} satisfies Column<Machine>,
]
: []),
{
key: "status",
header: "",
@@ -132,15 +236,18 @@ export function MachinesPage() {
Keys
</Button>
)}
<Button
variant="danger"
size="sm"
onClick={() => setDeleteFor(m)}
aria-label={`Remove ${m.hostname}`}
>
<TrashIcon width={14} height={14} />
Delete
</Button>
{/* Removal is admin-only, mirroring the server's 403 for non-admins. */}
{isAdmin && (
<Button
variant="danger"
size="sm"
onClick={() => setRemoveFor(m)}
aria-label={`Remove ${m.hostname}`}
>
<TrashIcon width={14} height={14} />
Remove
</Button>
)}
</span>
),
},
@@ -177,10 +284,40 @@ export function MachinesPage() {
aria-label="Filter machines"
/>
</div>
<div className="toolbar__count">
<span className="mono">{onlineCount}</span> online ·{" "}
<span className="mono">{machines.length}</span> total
</div>
{/* 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 ? (
<div className="bulkbar" role="status">
<span className="bulkbar__count">
<span className="mono">{selectedVisible.length}</span> selected
</span>
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
aria-label="Clear selection"
>
Clear
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setBulkOpen(true)}
aria-label={`Remove ${selectedVisible.length} selected machines`}
>
<TrashIcon width={14} height={14} />
Remove selected ({selectedVisible.length})
</Button>
</div>
) : (
<div className="toolbar__count">
<span className="mono">{onlineCount}</span> online ·{" "}
<span className="mono">{machines.length}</span> total
</div>
)}
</div>
</div>
@@ -190,7 +327,11 @@ export function MachinesPage() {
Loading machines
</span>
<TableSkeleton
headers={["", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]}
headers={
isAdmin
? ["", "", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]
: ["", "Hostname", "OS", "Mode", "Last seen", "Agent ID", ""]
}
/>
</>
) : machinesQuery.isError ? (
@@ -239,7 +380,20 @@ export function MachinesPage() {
{isAdmin && (
<MachineKeysModal machine={keysFor} onClose={() => setKeysFor(null)} />
)}
<DeleteMachineDialog machine={deleteFor} onClose={() => setDeleteFor(null)} />
{isAdmin && (
<RemoveMachineDialog
machine={removeFor}
onClose={() => setRemoveFor(null)}
/>
)}
{isAdmin && (
<BulkRemoveMachinesDialog
open={bulkOpen}
agentIds={selectedVisible.map((m) => m.agent_id)}
onClose={() => setBulkOpen(false)}
onRemoved={clearSelection}
/>
)}
</div>
);
}

View File

@@ -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 (
<ConfirmDialog
open={machine != null}
title="Remove machine?"
danger
busy={remove.isPending}
confirmLabel="Remove machine"
cancelLabel="Keep machine"
onConfirm={onConfirm}
onCancel={onClose}
body={
machine ? (
<p style={{ marginTop: 0 }}>
Remove <strong>{machine.hostname}</strong> 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.
</p>
) : null
}
/>
);
}

View File

@@ -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) {

View File

@@ -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 (
<ConfirmDialog
open={session != null}
title="Remove session?"
danger
busy={purge.isPending}
confirmLabel="Remove session"
cancelLabel="Keep session"
onConfirm={onConfirm}
onCancel={onClose}
body={
session ? (
<p style={{ marginTop: 0 }}>
Remove the session for <strong>{session.agent_name}</strong> 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.
</p>
) : null
}
/>
);
}

View File

@@ -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<Session | null>(null);
const [endFor, setEndFor] = useState<Session | null>(null);
const [removeFor, setRemoveFor] = useState<Session | null>(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() {
<StopIcon width={14} height={14} />
End
</Button>
{/* Operator removal (admin only): purge a stale/ghost session,
including offline ones the live-only "End" cannot clear. */}
{isAdmin && (
<Button
variant="danger"
size="sm"
onClick={() => setRemoveFor(s)}
aria-label={`Remove session on ${s.agent_name}`}
>
<TrashIcon width={14} height={14} />
Remove
</Button>
)}
</span>
);
},
@@ -301,6 +317,12 @@ export function SessionsPage() {
onClose={() => setJoinFor(null)}
/>
<EndSessionDialog session={endFor} onClose={() => setEndFor(null)} />
{isAdmin && (
<RemoveSessionDialog
session={removeFor}
onClose={() => setRemoveFor(null)}
/>
)}
</div>
);
}

View File

@@ -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 });
},
});
}