From 034aaa03c891cde5dc7a7d5a2e812611f7380dfd Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 2 Sep 2026 01:42:11 +0200 Subject: [PATCH] feat(web): dashboard Select mode for bulk session delete / device block Opt-in Select button (Admin Mode) adds a checkbox column, row-click toggles selection, Shift+click ranges, header checkbox selects the page, Esc/Done leaves. Bulk actions loop over the per-session endpoints with three requests in flight and one summary toast; no bulk endpoint. Confirm modals list the targets. Also translates the block tooltip and the block error alerts to English. Co-Authored-By: Claude Fable 5.1 --- .../components/ConfirmationModals.tsx | 68 ++++- .../app/dashboard/components/SessionTable.tsx | 270 ++++++++++++++++-- .../app/dashboard/hooks/bulkActions.ts | 91 ++++++ .../app/dashboard/hooks/useBlockDevice.ts | 104 ++++--- .../app/dashboard/hooks/useDeleteSession.ts | 172 ++++++----- .../app/dashboard/page.tsx | 20 +- .../lib/__tests__/bulkActions.test.ts | 102 +++++++ 7 files changed, 680 insertions(+), 147 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/hooks/bulkActions.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/bulkActions.test.ts diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/ConfirmationModals.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/ConfirmationModals.tsx index c07190af..b3e85bef 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/ConfirmationModals.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/ConfirmationModals.tsx @@ -1,12 +1,29 @@ "use client"; +/** Names listed verbatim in a bulk confirm; the rest collapse into "and N more". */ +const MAX_LISTED_TARGETS = 10; + +function TargetList({ names }: { names: string[] }) { + const listed = names.slice(0, MAX_LISTED_TARGETS); + const rest = names.length - listed.length; + return ( + + ); +} + interface DeleteConfirmModalProps { - sessionToDelete: { sessionId: string; tenantId: string; deviceName?: string }; + targets: { sessionId: string; tenantId: string; deviceName?: string }[]; onConfirm: () => void; onCancel: () => void; } -export function DeleteConfirmModal({ sessionToDelete, onConfirm, onCancel }: DeleteConfirmModalProps) { +export function DeleteConfirmModal({ targets, onConfirm, onCancel }: DeleteConfirmModalProps) { + const single = targets.length === 1 ? targets[0] : null; return (
e.stopPropagation()}> @@ -17,16 +34,27 @@ export function DeleteConfirmModal({ sessionToDelete, onConfirm, onCancel }: Del
-

Delete Session

+

+ {single ? "Delete Session" : `Delete ${targets.length} Sessions`} +

This action is irreversible!

-

- The session {sessionToDelete.sessionId} for device {sessionToDelete.deviceName || 'Unknown'} and all associated events will be permanently deleted. -

+ {single ? ( +

+ The session {single.sessionId} for device {single.deviceName || 'Unknown'} and all associated events will be permanently deleted. +

+ ) : ( + <> +

+ These {targets.length} sessions and all their associated events will be permanently deleted: +

+ t.deviceName || t.sessionId)} /> + + )}

Do you want to continue?

@@ -43,7 +71,7 @@ export function DeleteConfirmModal({ sessionToDelete, onConfirm, onCancel }: Del onClick={onConfirm} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors" > - Delete + {single ? "Delete" : `Delete ${targets.length}`}
@@ -53,13 +81,14 @@ export function DeleteConfirmModal({ sessionToDelete, onConfirm, onCancel }: Del } interface BlockConfirmModalProps { - sessionToBlock: { serialNumber: string; tenantId: string; deviceName?: string }; + targets: { serialNumber: string; tenantId: string; deviceName?: string }[]; blockingDevice: boolean; onConfirm: () => void; onCancel: () => void; } -export function BlockConfirmModal({ sessionToBlock, blockingDevice, onConfirm, onCancel }: BlockConfirmModalProps) { +export function BlockConfirmModal({ targets, blockingDevice, onConfirm, onCancel }: BlockConfirmModalProps) { + const single = targets.length === 1 ? targets[0] : null; return (
e.stopPropagation()}> @@ -70,13 +99,24 @@ export function BlockConfirmModal({ sessionToBlock, blockingDevice, onConfirm, o
-

Block Device

+

+ {single ? "Block Device" : `Block ${targets.length} Devices`} +

-

- Device {sessionToBlock.deviceName || sessionToBlock.serialNumber} (Serial: {sessionToBlock.serialNumber}) will be blocked for 24 hours. -

+ {single ? ( +

+ Device {single.deviceName || single.serialNumber} (Serial: {single.serialNumber}) will be blocked for 24 hours. +

+ ) : ( + <> +

+ These {targets.length} devices will be blocked for 24 hours: +

+ t.deviceName || t.serialNumber)} /> + + )}

The agent will stop uploading data while blocked. Do you want to continue?

@@ -94,7 +134,7 @@ export function BlockConfirmModal({ sessionToBlock, blockingDevice, onConfirm, o disabled={blockingDevice} className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors disabled:opacity-50" > - {blockingDevice ? 'Blocking...' : 'Block'} + {blockingDevice ? 'Blocking...' : single ? 'Block' : `Block ${targets.length}`}
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index c6576526..c717d77a 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -11,6 +11,8 @@ import { fuzzyContains } from "@/utils/fuzzy"; import { buildUniqueValuesByField } from "./uniqueValuesByField"; import { SessionStatusBadge } from "@/components/SessionStatusBadge"; import { TenantFilterBar } from "./TenantFilterBar"; +import type { DeleteTarget } from "../hooks/useDeleteSession"; +import type { BlockTarget } from "../hooks/useBlockDevice"; // Column definition for the session table interface ColumnDef { @@ -94,7 +96,8 @@ interface SessionTableProps { user: { isGlobalAdmin?: boolean } | null; columnFilters: Record>; onColumnFiltersChange: (filters: Record>) => void; - onDeleteSession: (sessionId: string, tenantId: string, deviceName?: string) => void; + /** Opens the delete confirm for one session (row icon) or many (Select mode). */ + onDeleteSessions: (targets: DeleteTarget[]) => void; /** * Sessions currently awaiting the cascade worker's `sessionDeleted` SignalR notification. * When this set contains a session id, the action cell renders a spinner instead of the @@ -102,7 +105,8 @@ interface SessionTableProps { * drained server-side. */ pendingDeletions: ReadonlySet; - onBlockDevice: (serialNumber: string, tenantId: string, deviceName?: string) => void; + /** Opens the block confirm for one device (row icon) or many (Select mode). */ + onBlockDevices: (targets: BlockTarget[]) => void; fullWidth: boolean; onToggleFullWidth: () => void; /** Builds the row's navigation target. Defaults to `/sessions/{id}`; a cross-tenant viewer overrides it to @@ -146,9 +150,9 @@ export function SessionTable({ user, columnFilters, onColumnFiltersChange, - onDeleteSession, + onDeleteSessions, pendingDeletions, - onBlockDevice, + onBlockDevices, fullWidth, onToggleFullWidth, sessionLinkTarget, @@ -165,6 +169,14 @@ export function SessionTable({ const [showSearchSuggestions, setShowSearchSuggestions] = useState(false); const [searchSelectedIndex, setSearchSelectedIndex] = useState(-1); + // Select mode: opt-in multi-select for bulk delete / block. Off by default so the table + // looks and navigates exactly as before; while on, row clicks toggle selection instead of + // opening the session and a checkbox column appears that is NOT part of the column selector. + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + // Anchor for Shift+click range selection (within the current page). + const lastToggledIdRef = useRef(null); + // Fuzzy-match sessions by multiple fields for search dropdown (two-phase: exact then Levenshtein) interface SearchSuggestion { session: Session; @@ -312,7 +324,99 @@ export function SessionTable({ return true; }); - const colSpan = activeColumns.length; + // Select mode needs the admin-only actions; derive instead of syncing state when admin + // mode flips off underneath an open selection. + const selecting = selectMode && adminMode; + const canBlock = globalAdminMode && !!user?.isGlobalAdmin; + const colSpan = activeColumns.length + (selecting ? 1 : 0); + + const isSelectable = (s: Session) => !pendingDeletions.has(s.sessionId); + + // Effective selection = selected ∩ currently visible (filtered) ∩ not already queued for + // deletion. Derived at read time so rows that vanish (deleted, filtered out) or get queued + // drop out of the count without an effect. + const selectedSessions = useMemo(() => { + if (!selecting || selectedIds.size === 0) return [] as Session[]; + return sortedSessions.filter((s) => selectedIds.has(s.sessionId) && !pendingDeletions.has(s.sessionId)); + }, [selecting, selectedIds, sortedSessions, pendingDeletions]); + + const exitSelectMode = () => { + setSelectMode(false); + setSelectedIds(new Set()); + lastToggledIdRef.current = null; + }; + + const toggleSelection = (session: Session, shiftKey: boolean) => { + if (!isSelectable(session)) return; + const anchor = lastToggledIdRef.current; + setSelectedIds((prev) => { + const next = new Set(prev); + if (shiftKey && anchor) { + const ids = paginatedSessions.map((s) => s.sessionId); + const a = ids.indexOf(anchor); + const b = ids.indexOf(session.sessionId); + if (a >= 0 && b >= 0) { + for (const s of paginatedSessions.slice(Math.min(a, b), Math.max(a, b) + 1)) { + if (isSelectable(s)) next.add(s.sessionId); + } + return next; + } + } + if (next.has(session.sessionId)) next.delete(session.sessionId); + else next.add(session.sessionId); + return next; + }); + lastToggledIdRef.current = session.sessionId; + }; + + const selectablePage = paginatedSessions.filter(isSelectable); + const pageSelectedCount = selectablePage.filter((s) => selectedIds.has(s.sessionId)).length; + const allPageSelected = selectablePage.length > 0 && pageSelectedCount === selectablePage.length; + const togglePage = () => { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const s of selectablePage) { + if (allPageSelected) next.delete(s.sessionId); + else next.add(s.sessionId); + } + return next; + }); + }; + + // Esc leaves Select mode — unless the user is typing in a field (search, filters). + useEffect(() => { + if (!selecting) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + const tag = (e.target as HTMLElement | null)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + setSelectMode(false); + setSelectedIds(new Set()); + lastToggledIdRef.current = null; + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [selecting]); + + const requestBulkDelete = () => { + if (selectedSessions.length === 0) return; + trackEvent("session_bulk_delete_requested", { count: selectedSessions.length }); + onDeleteSessions(selectedSessions.map((s) => ({ + sessionId: s.sessionId, + tenantId: s.tenantId, + deviceName: s.deviceName || s.serialNumber, + }))); + }; + + const requestBulkBlock = () => { + if (selectedSessions.length === 0) return; + trackEvent("session_bulk_block_requested", { count: selectedSessions.length }); + onBlockDevices(selectedSessions.map((s) => ({ + serialNumber: s.serialNumber, + tenantId: s.tenantId, + deviceName: s.deviceName || s.serialNumber, + }))); + }; return (
@@ -348,6 +452,29 @@ export function SessionTable({ ))} + {/* Select mode toggle — admin only, because every bulk action needs the admin actions */} + {adminMode && ( + + )} + {/* Full-width toggle */} + {canBlock && ( + + )} + + + Click rows to select · Shift+click for a range · Esc when done + + +
+ )} +
+ {selecting && ( + + )} {activeColumns.map((col) => { if (col.sortKey) { return ( @@ -681,7 +872,7 @@ export function SessionTable({ })} - + {paginatedSessions.length === 0 ? ( ) : ( - paginatedSessions.map((session) => ( + paginatedSessions.map((session) => { + const isSelected = selecting && selectedIds.has(session.sessionId); + const selectable = isSelectable(session); + return ( { trackEvent("session_opened", { sessionId: session.sessionId, status: session.status ?? "" }); router.push(linkFor(session)); }} - className="hover:bg-gray-50 cursor-pointer transition-colors" + onClick={(e) => { + if (selecting) { toggleSelection(session, e.shiftKey); return; } + trackEvent("session_opened", { sessionId: session.sessionId, status: session.status ?? "" }); + router.push(linkFor(session)); + }} + aria-selected={selecting ? isSelected : undefined} + className={`transition-colors ${ + isSelected ? "bg-blue-50 hover:bg-blue-100" : "hover:bg-gray-50" + } ${selecting && !selectable ? "cursor-default" : "cursor-pointer"}`} > + {selecting && ( + + )} {activeColumns.map((col) => ( ))} - )) + ); + }) )}
+ { if (el) el.indeterminate = pageSelectedCount > 0 && !allPageSelected; }} + onChange={togglePage} + disabled={selectablePage.length === 0} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 h-4 w-4 cursor-pointer disabled:cursor-not-allowed" + title={allPageSelected ? "Deselect this page" : "Select this page"} + aria-label={allPageSelected ? "Deselect all sessions on this page" : "Select all sessions on this page"} + /> +
@@ -699,30 +890,55 @@ export function SessionTable({
+ { e.stopPropagation(); toggleSelection(session, e.shiftKey); }} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 h-4 w-4 cursor-pointer disabled:cursor-not-allowed" + aria-label={`Select ${session.deviceName || session.serialNumber}`} + /> +
@@ -791,7 +1007,9 @@ export function SessionTable({ * tooltips (status badges, tenant copy button, actions) must NOT render this overlay * as it would sit above them and swallow their hover/click. aria-hidden + tabIndex -1 * keep it out of tab order and screen readers — the row's click target is unchanged. */ -function RowLinkOverlay({ href, session }: { href: Route; session: Session }) { +function RowLinkOverlay({ href, session }: { href: Route | null; session: Session }) { + // Select mode passes null: rows toggle selection instead of navigating. + if (!href) return null; return ( ; user: { isGlobalAdmin?: boolean } | null; - onDeleteSession: (sessionId: string, tenantId: string, deviceName?: string) => void; + /** Row-level action icons are hidden while selecting; the bulk bar owns the actions then. */ + selectMode: boolean; + onDeleteSessions: (targets: DeleteTarget[]) => void; /** True when the V2 cascade has been queued for this session and we're awaiting `sessionDeleted`. */ isDeletionPending: boolean; - onBlockDevice: (serialNumber: string, tenantId: string, deviceName?: string) => void; + onBlockDevices: (targets: BlockTarget[]) => void; tenantDomainById: Map; }) { switch (columnKey) { @@ -1061,14 +1283,14 @@ function SessionCell({ return (
- {globalAdminMode && user?.isGlobalAdmin && !isDeletionPending && ( + {globalAdminMode && user?.isGlobalAdmin && !isDeletionPending && !selectMode && (