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

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