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 { http } from "./client";
|
||||||
import type {
|
import type {
|
||||||
|
BulkRemoveResponse,
|
||||||
CreatedKey,
|
CreatedKey,
|
||||||
DeleteMachineParams,
|
DeleteMachineParams,
|
||||||
DeleteMachineResponse,
|
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(
|
export function deleteMachine(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
params: DeleteMachineParams = {},
|
params: DeleteMachineParams = {},
|
||||||
): Promise<DeleteMachineResponse> {
|
): Promise<DeleteMachineResponse> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
if (params.purge) qs.set("purge", "true");
|
||||||
if (params.uninstall) qs.set("uninstall", "true");
|
if (params.uninstall) qs.set("uninstall", "true");
|
||||||
if (params.export) qs.set("export", "true");
|
if (params.export) qs.set("export", "true");
|
||||||
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
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 --------------------------------------------------
|
// --- Admin: per-agent keys --------------------------------------------------
|
||||||
|
|
||||||
/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */
|
/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { http } from "./client";
|
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
|
* 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
|
* DELETE /api/sessions/:id — disconnect/end a live session (admin only). The
|
||||||
* Disconnect to the agent. Returns 200 on success, 404 if the session is not
|
* relay sends a Disconnect to the agent. Returns 200 on success, 404 if the
|
||||||
* found. Requires an authenticated dashboard JWT (not admin-gated server-side).
|
* 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> {
|
export function endSession(sessionId: string): Promise<void> {
|
||||||
return http.del<void>(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
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;
|
uninstall?: boolean;
|
||||||
/** Include full history in the delete response before removal. */
|
/** Include full history in the delete response before removal. */
|
||||||
export?: boolean;
|
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 {
|
export interface DeleteMachineResponse {
|
||||||
@@ -173,6 +180,38 @@ export interface DeleteMachineResponse {
|
|||||||
history: MachineHistory | null;
|
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)
|
// Sessions (live relay state)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -221,6 +260,18 @@ export interface Session {
|
|||||||
consent_state: ConsentState | string;
|
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. */
|
/** Access mode the relay grants a minted viewer token. */
|
||||||
export type ViewerAccess = "control" | "view_only";
|
export type ViewerAccess = "control" | "view_only";
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,33 @@
|
|||||||
padding-right: 0 !important;
|
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. */
|
/* Cell affordances. */
|
||||||
.dt__mono {
|
.dt__mono {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -151,3 +178,19 @@
|
|||||||
.toolbar__count .mono {
|
.toolbar__count .mono {
|
||||||
color: var(--text);
|
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:
|
* Destructive machine removal with two options:
|
||||||
* - uninstall: also command the agent to uninstall (only meaningful online)
|
* - uninstall: also command the agent to uninstall (only meaningful online)
|
||||||
* - export: return full history in the delete response before removal
|
* - 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 { ApiError } from "../../api/client";
|
||||||
import type { Machine } from "../../api/types";
|
import type { Machine } from "../../api/types";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
@@ -21,9 +21,10 @@ import { Table, type Column } from "../../components/ui/Table";
|
|||||||
import { TableSkeleton } from "../../components/ui/TableSkeleton";
|
import { TableSkeleton } from "../../components/ui/TableSkeleton";
|
||||||
import { absoluteTime, relativeTime } from "../../lib/time";
|
import { absoluteTime, relativeTime } from "../../lib/time";
|
||||||
import "./machines.css";
|
import "./machines.css";
|
||||||
import { DeleteMachineDialog } from "./DeleteMachineDialog";
|
import { BulkRemoveMachinesDialog } from "./BulkRemoveMachinesDialog";
|
||||||
import { MachineDetailDrawer } from "./MachineDetailDrawer";
|
import { MachineDetailDrawer } from "./MachineDetailDrawer";
|
||||||
import { MachineKeysModal } from "./MachineKeysModal";
|
import { MachineKeysModal } from "./MachineKeysModal";
|
||||||
|
import { RemoveMachineDialog } from "./RemoveMachineDialog";
|
||||||
import { useMachines } from "./hooks";
|
import { useMachines } from "./hooks";
|
||||||
|
|
||||||
export function MachinesPage() {
|
export function MachinesPage() {
|
||||||
@@ -33,7 +34,14 @@ export function MachinesPage() {
|
|||||||
|
|
||||||
const [detailFor, setDetailFor] = useState<Machine | null>(null);
|
const [detailFor, setDetailFor] = useState<Machine | null>(null);
|
||||||
const [keysFor, setKeysFor] = 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 { data } = machinesQuery;
|
||||||
const machines = useMemo(() => data ?? [], [data]);
|
const machines = useMemo(() => data ?? [], [data]);
|
||||||
@@ -53,7 +61,103 @@ export function MachinesPage() {
|
|||||||
[machines],
|
[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>[] = [
|
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",
|
key: "status",
|
||||||
header: "",
|
header: "",
|
||||||
@@ -132,15 +236,18 @@ export function MachinesPage() {
|
|||||||
Keys
|
Keys
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{/* Removal is admin-only, mirroring the server's 403 for non-admins. */}
|
||||||
variant="danger"
|
{isAdmin && (
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => setDeleteFor(m)}
|
variant="danger"
|
||||||
aria-label={`Remove ${m.hostname}`}
|
size="sm"
|
||||||
>
|
onClick={() => setRemoveFor(m)}
|
||||||
<TrashIcon width={14} height={14} />
|
aria-label={`Remove ${m.hostname}`}
|
||||||
Delete
|
>
|
||||||
</Button>
|
<TrashIcon width={14} height={14} />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -177,10 +284,40 @@ export function MachinesPage() {
|
|||||||
aria-label="Filter machines"
|
aria-label="Filter machines"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar__count">
|
{/* Bar count tracks the VISIBLE selected rows — the exact set the
|
||||||
<span className="mono">{onlineCount}</span> online ·{" "}
|
bulk dialog will state and remove. Under an active filter that
|
||||||
<span className="mono">{machines.length}</span> total
|
hides some selected rows, this keeps bar == dialog == removed.
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -190,7 +327,11 @@ export function MachinesPage() {
|
|||||||
Loading machines
|
Loading machines
|
||||||
</span>
|
</span>
|
||||||
<TableSkeleton
|
<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 ? (
|
) : machinesQuery.isError ? (
|
||||||
@@ -239,7 +380,20 @@ export function MachinesPage() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<MachineKeysModal machine={keysFor} onClose={() => setKeysFor(null)} />
|
<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>
|
</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 --------------------------------------------------
|
// --- Admin: per-agent keys --------------------------------------------------
|
||||||
|
|
||||||
export function useMachineKeys(agentId: string | null, enabled: boolean) {
|
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,
|
RefreshIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
StopIcon,
|
StopIcon,
|
||||||
|
TrashIcon,
|
||||||
} from "../../components/layout/icons";
|
} from "../../components/layout/icons";
|
||||||
import { Badge } from "../../components/ui/Badge";
|
import { Badge } from "../../components/ui/Badge";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
@@ -21,6 +22,7 @@ import { TableSkeleton } from "../../components/ui/TableSkeleton";
|
|||||||
import { absoluteTime, relativeTime } from "../../lib/time";
|
import { absoluteTime, relativeTime } from "../../lib/time";
|
||||||
import { EndSessionDialog } from "./EndSessionDialog";
|
import { EndSessionDialog } from "./EndSessionDialog";
|
||||||
import { JoinSessionModal } from "./JoinSessionModal";
|
import { JoinSessionModal } from "./JoinSessionModal";
|
||||||
|
import { RemoveSessionDialog } from "./RemoveSessionDialog";
|
||||||
import { useSessions } from "./hooks";
|
import { useSessions } from "./hooks";
|
||||||
import "./sessions.css";
|
import "./sessions.css";
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ export function SessionsPage() {
|
|||||||
|
|
||||||
const [joinFor, setJoinFor] = useState<Session | null>(null);
|
const [joinFor, setJoinFor] = useState<Session | null>(null);
|
||||||
const [endFor, setEndFor] = 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
|
// The same authz split the server enforces at mint time: admin OR the
|
||||||
// `control` permission yields a control token; otherwise the floor is `view`.
|
// `control` permission yields a control token; otherwise the floor is `view`.
|
||||||
@@ -193,6 +196,19 @@ export function SessionsPage() {
|
|||||||
<StopIcon width={14} height={14} />
|
<StopIcon width={14} height={14} />
|
||||||
End
|
End
|
||||||
</Button>
|
</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>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -301,6 +317,12 @@ export function SessionsPage() {
|
|||||||
onClose={() => setJoinFor(null)}
|
onClose={() => setJoinFor(null)}
|
||||||
/>
|
/>
|
||||||
<EndSessionDialog session={endFor} onClose={() => setEndFor(null)} />
|
<EndSessionDialog session={endFor} onClose={() => setEndFor(null)} />
|
||||||
|
{isAdmin && (
|
||||||
|
<RemoveSessionDialog
|
||||||
|
session={removeFor}
|
||||||
|
onClose={() => setRemoveFor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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