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:
@@ -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). */
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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