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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user