feat(dashboard): operator removal UI for stale machines/sessions (SPEC-004 Task 5)
All checks were successful
All checks were successful
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:
112
dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx
Normal file
112
dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
73
dashboard/src/features/machines/RemoveMachineDialog.tsx
Normal file
73
dashboard/src/features/machines/RemoveMachineDialog.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
68
dashboard/src/features/sessions/RemoveSessionDialog.tsx
Normal file
68
dashboard/src/features/sessions/RemoveSessionDialog.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user