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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-02 01:42:11 +02:00
parent 9b6b03dac5
commit 034aaa03c8
7 changed files with 680 additions and 147 deletions
@@ -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 (
<ul className="mb-2 max-h-40 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800">
{listed.map((name, i) => (
<li key={`${name}-${i}`} className="truncate font-medium">{name}</li>
))}
{rest > 0 && <li className="text-gray-500">and {rest} more</li>}
</ul>
);
}
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
@@ -17,16 +34,27 @@ export function DeleteConfirmModal({ sessionToDelete, onConfirm, onCancel }: Del
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Delete Session</h3>
<h3 className="ml-4 text-lg font-semibold text-gray-900">
{single ? "Delete Session" : `Delete ${targets.length} Sessions`}
</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
This action is <span className="font-semibold text-red-600">irreversible</span>!
</p>
<p className="text-sm text-gray-700 mb-2">
The session <span className="font-mono text-xs">{sessionToDelete.sessionId}</span> for device <span className="font-semibold">{sessionToDelete.deviceName || 'Unknown'}</span> and all associated events will be permanently deleted.
</p>
{single ? (
<p className="text-sm text-gray-700 mb-2">
The session <span className="font-mono text-xs">{single.sessionId}</span> for device <span className="font-semibold">{single.deviceName || 'Unknown'}</span> and all associated events will be permanently deleted.
</p>
) : (
<>
<p className="text-sm text-gray-700 mb-2">
These <span className="font-semibold">{targets.length} sessions</span> and all their associated events will be permanently deleted:
</p>
<TargetList names={targets.map((t) => t.deviceName || t.sessionId)} />
</>
)}
<p className="text-sm text-gray-600">
Do you want to continue?
</p>
@@ -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}`}
</button>
</div>
</div>
@@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
@@ -70,13 +99,24 @@ export function BlockConfirmModal({ sessionToBlock, blockingDevice, onConfirm, o
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Block Device</h3>
<h3 className="ml-4 text-lg font-semibold text-gray-900">
{single ? "Block Device" : `Block ${targets.length} Devices`}
</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
Device <span className="font-semibold">{sessionToBlock.deviceName || sessionToBlock.serialNumber}</span> (Serial: <span className="font-mono text-xs">{sessionToBlock.serialNumber}</span>) will be blocked for <span className="font-semibold">24 hours</span>.
</p>
{single ? (
<p className="text-sm text-gray-700 mb-2">
Device <span className="font-semibold">{single.deviceName || single.serialNumber}</span> (Serial: <span className="font-mono text-xs">{single.serialNumber}</span>) will be blocked for <span className="font-semibold">24 hours</span>.
</p>
) : (
<>
<p className="text-sm text-gray-700 mb-2">
These <span className="font-semibold">{targets.length} devices</span> will be blocked for <span className="font-semibold">24 hours</span>:
</p>
<TargetList names={targets.map((t) => t.deviceName || t.serialNumber)} />
</>
)}
<p className="text-sm text-gray-600">
The agent will stop uploading data while blocked. Do you want to continue?
</p>
@@ -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}`}
</button>
</div>
</div>
@@ -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<string, Set<string>>;
onColumnFiltersChange: (filters: Record<string, Set<string>>) => 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<string>;
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<Set<string>>(() => new Set());
// Anchor for Shift+click range selection (within the current page).
const lastToggledIdRef = useRef<string | null>(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 (
<div className="mt-4 bg-white shadow rounded-lg p-6">
@@ -348,6 +452,29 @@ export function SessionTable({
))}
</select>
{/* Select mode toggle — admin only, because every bulk action needs the admin actions */}
{adminMode && (
<button
onClick={() => {
if (selecting) { exitSelectMode(); return; }
trackEvent("session_select_mode_entered", {});
setSelectMode(true);
}}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
selecting
? "text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100"
: "text-gray-600 bg-gray-100 hover:bg-gray-200 border-gray-200"
}`}
title={selecting ? "Leave selection mode" : "Select multiple sessions to delete or block them at once"}
aria-pressed={selecting}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M5 4h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5a1 1 0 011-1z" />
</svg>
{selecting ? "Done" : "Select"}
</button>
)}
{/* Full-width toggle */}
<button
onClick={onToggleFullWidth}
@@ -637,10 +764,74 @@ export function SessionTable({
)}
</div>
{/* Bulk action bar — only while selecting, sits directly above the table so the
status / column filters above stay usable (filter Failed → select page → delete). */}
{selecting && (
<div className="mb-3 flex items-center gap-2 flex-wrap rounded-lg border border-blue-200 bg-blue-50 px-3 py-2">
<span className="text-sm font-medium text-blue-900 mr-1">
{selectedSessions.length} selected
</span>
<button
onClick={requestBulkDelete}
disabled={selectedSessions.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete the selected sessions"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete{selectedSessions.length > 0 ? ` ${selectedSessions.length}` : ""}
</button>
{canBlock && (
<button
onClick={requestBulkBlock}
disabled={selectedSessions.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-white bg-orange-600 hover:bg-orange-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Block the devices of the selected sessions for 24 hours"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Block{selectedSessions.length > 0 ? ` ${selectedSessions.length}` : ""}
</button>
)}
<button
onClick={() => { setSelectedIds(new Set()); lastToggledIdRef.current = null; }}
disabled={selectedIds.size === 0}
className="px-2 py-1.5 text-xs font-medium text-blue-700 hover:text-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Clear
</button>
<span className="hidden md:inline text-xs text-blue-700/70 ml-1">
Click rows to select · Shift+click for a range · Esc when done
</span>
<button
onClick={exitSelectMode}
className="ml-auto px-3 py-1.5 rounded-lg text-xs font-medium text-blue-700 bg-white border border-blue-200 hover:bg-blue-100 transition-colors"
>
Done
</button>
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{selecting && (
<th scope="col" className="w-8 pl-4 pr-1 py-3">
<input
type="checkbox"
checked={allPageSelected}
ref={(el) => { 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"}
/>
</th>
)}
{activeColumns.map((col) => {
if (col.sortKey) {
return (
@@ -681,7 +872,7 @@ export function SessionTable({
})}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className={`bg-white divide-y divide-gray-200 ${selecting ? "select-none" : ""}`}>
{paginatedSessions.length === 0 ? (
<tr>
<td colSpan={colSpan} className="px-6 py-8 text-center text-gray-500">
@@ -699,30 +890,55 @@ export function SessionTable({
</td>
</tr>
) : (
paginatedSessions.map((session) => (
paginatedSessions.map((session) => {
const isSelected = selecting && selectedIds.has(session.sessionId);
const selectable = isSelectable(session);
return (
<tr
key={session.sessionId}
onClick={() => { 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 && (
<td className="w-8 pl-4 pr-1 py-4">
<input
type="checkbox"
checked={isSelected}
readOnly
disabled={!selectable}
onClick={(e) => { 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}`}
/>
</td>
)}
{activeColumns.map((col) => (
<SessionCell
key={col.key}
columnKey={col.key}
session={session}
href={linkFor(session)}
href={selecting ? null : linkFor(session)}
adminMode={adminMode}
globalAdminMode={globalAdminMode}
blockedDevicesSet={blockedDevicesSet}
user={user}
onDeleteSession={onDeleteSession}
selectMode={selecting}
onDeleteSessions={onDeleteSessions}
isDeletionPending={pendingDeletions.has(session.sessionId)}
onBlockDevice={onBlockDevice}
onBlockDevices={onBlockDevices}
tenantDomainById={tenantDomainById}
/>
))}
</tr>
))
);
})
)}
</tbody>
</table>
@@ -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 (
<Link
href={href}
@@ -816,22 +1034,26 @@ function SessionCell({
globalAdminMode,
blockedDevicesSet,
user,
onDeleteSession,
selectMode,
onDeleteSessions,
isDeletionPending,
onBlockDevice,
onBlockDevices,
tenantDomainById,
}: {
columnKey: string;
session: Session;
href: Route;
/** Null while selecting — cells then render no navigation overlay. */
href: Route | null;
adminMode: boolean;
globalAdminMode: boolean;
blockedDevicesSet: Set<string>;
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<string, string>;
}) {
switch (columnKey) {
@@ -1061,14 +1283,14 @@ function SessionCell({
return (
<td className="pl-3 pr-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
{globalAdminMode && user?.isGlobalAdmin && !isDeletionPending && (
{globalAdminMode && user?.isGlobalAdmin && !isDeletionPending && !selectMode && (
<button
onClick={(e) => {
e.stopPropagation();
onBlockDevice(session.serialNumber, session.tenantId, session.deviceName || session.serialNumber);
onBlockDevices([{ serialNumber: session.serialNumber, tenantId: session.tenantId, deviceName: session.deviceName || session.serialNumber }]);
}}
className="text-orange-500 hover:text-orange-700 transition-colors"
title="Device blocken"
title="Block device"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
@@ -1089,11 +1311,11 @@ function SessionCell({
<path d="M4 12a8 8 0 018-8" strokeWidth="3" className="opacity-75" />
</svg>
</span>
) : (
) : selectMode ? null : (
<button
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.sessionId, session.tenantId, session.deviceName || session.serialNumber);
onDeleteSessions([{ sessionId: session.sessionId, tenantId: session.tenantId, deviceName: session.deviceName || session.serialNumber }]);
}}
className="text-red-600 hover:text-red-900 transition-colors"
title="Delete session"
@@ -0,0 +1,91 @@
/**
* Pure helpers shared by the bulk delete / bulk block paths of the dashboard hooks.
* No React, no I/O — the hooks compose these with `authenticatedFetch` and the toast API.
*/
import type { NotificationType } from "@/contexts/NotificationContext";
import type { DeleteResponseAction } from "./deleteSessionResponse";
/**
* Bulk operations run against the per-session endpoints (no bulk API on purpose — the
* default user rate limit of 120 req/min comfortably covers a whole page). Three parallel
* requests keep a 100-row page under a few seconds without bursting the limiter.
*/
export const BULK_CONCURRENCY = 3;
/**
* Run `fn` over `items` with at most `limit` in flight. Results are returned in input
* order. A rejected `fn` rejects the whole run; callers wrap per-item failures themselves
* when they want partial results.
*/
export async function runWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const index = next++;
results[index] = await fn(items[index]);
}
};
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker);
await Promise.all(workers);
return results;
}
export interface BulkSummary {
type: NotificationType;
title: string;
message: string;
}
/**
* One toast for a multi-session delete. `queued` rows show their spinner anyway, so the
* summary only needs to make the non-queued outcomes visible.
*/
export function summarizeDeleteActions(actions: readonly DeleteResponseAction[]): BulkSummary {
const counts = { queued: 0, notFound: 0, conflict: 0, unavailable: 0, error: 0 };
for (const a of actions) counts[a.kind]++;
const parts: string[] = [];
if (counts.queued > 0) parts.push(`${counts.queued} queued`);
if (counts.notFound > 0) parts.push(`${counts.notFound} already deleted`);
if (counts.conflict > 0) parts.push(`${counts.conflict} already in flight`);
if (counts.unavailable > 0) parts.push(`${counts.unavailable} temporarily unavailable`);
if (counts.error > 0) parts.push(`${counts.error} failed`);
const okCount = counts.queued + counts.notFound;
const type: NotificationType =
okCount === actions.length ? "info" : okCount === 0 ? "error" : "warning";
return {
type,
title: `Deleting ${actions.length} sessions`,
message: `${parts.join(", ")}. Queued rows disappear when the cascade worker completes.`,
};
}
export type BlockOutcome = { ok: true } | { ok: false; message: string };
/** One toast for a multi-device block. */
export function summarizeBlockOutcomes(outcomes: readonly BlockOutcome[]): BulkSummary {
const failed = outcomes.filter((o) => !o.ok);
const blocked = outcomes.length - failed.length;
if (failed.length === 0) {
return {
type: "success",
title: "Devices Blocked",
message: `${blocked} devices blocked for 24 hours.`,
};
}
// Surface the first failure reason; the rest are almost always the same cause.
const reason = (failed[0] as { ok: false; message: string }).message;
return {
type: blocked === 0 ? "error" : "warning",
title: blocked === 0 ? "Block failed" : "Devices partially blocked",
message: `${blocked} blocked, ${failed.length} failed (${reason}).`,
};
}
@@ -3,8 +3,9 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { NotificationType } from "@/contexts/NotificationContext";
import { BULK_CONCURRENCY, runWithConcurrency, summarizeBlockOutcomes, type BlockOutcome } from "./bulkActions";
interface BlockTarget {
export interface BlockTarget {
serialNumber: string;
tenantId: string;
deviceName?: string;
@@ -13,14 +14,17 @@ interface BlockTarget {
// Module-level so the masked value returned while scope is off is referentially stable.
const EMPTY_SET = new Set<string>();
const blockKey = (t: { tenantId: string; serialNumber: string }) => `${t.tenantId}:${t.serialNumber}`;
export function useBlockDevice(
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string) => void,
adminMode: boolean,
globalAdminMode: boolean
) {
const [showBlockConfirm, setShowBlockConfirm] = useState(false);
const [sessionToBlock, setSessionToBlock] = useState<BlockTarget | null>(null);
// Devices awaiting the user's confirmation — one for the row icon, many for Select mode.
const [blockTargets, setBlockTargets] = useState<BlockTarget[]>([]);
const showBlockConfirm = blockTargets.length > 0;
const [blockingDevice, setBlockingDevice] = useState(false);
const [blockedDevicesSet, setBlockedDevicesSet] = useState<Set<string>>(new Set());
@@ -30,48 +34,79 @@ export function useBlockDevice(
// it wholesale on every fetch (and empties it while scope is off).
const hasBlockScope = adminMode && globalAdminMode;
const blockDevice = (serialNumber: string, tenantId: string, deviceName?: string) => {
setSessionToBlock({ serialNumber, tenantId, deviceName });
setShowBlockConfirm(true);
/**
* Open the confirm dialog for one or more devices. Blocking is per device, so several
* selected sessions of the same device collapse into one target.
*/
const blockDevices = (targets: BlockTarget[]) => {
const unique = new Map<string, BlockTarget>();
for (const t of targets) if (!unique.has(blockKey(t))) unique.set(blockKey(t), t);
if (unique.size === 0) return;
setBlockTargets([...unique.values()]);
};
const blockOne = async (target: BlockTarget): Promise<BlockOutcome> => {
const response = await authenticatedFetch(api.devices.block(), getAccessToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenantId: target.tenantId,
serialNumber: target.serialNumber,
durationHours: 24,
reason: `Blocked from dashboard by Global Admin`
})
});
if (response.ok) return { ok: true };
const data = await response.json().catch(() => ({}));
return { ok: false, message: data.message || `HTTP ${response.status}` };
};
const confirmBlock = async () => {
if (!sessionToBlock) return;
if (blockTargets.length === 0) return;
const targets = blockTargets;
try {
setBlockingDevice(true);
const response = await authenticatedFetch(api.devices.block(), getAccessToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenantId: sessionToBlock.tenantId,
serialNumber: sessionToBlock.serialNumber,
durationHours: 24,
reason: `Blocked from dashboard by Global Admin`
})
if (targets.length === 1) {
const target = targets[0];
const outcome = await blockOne(target);
if (outcome.ok) {
setBlockTargets([]);
addNotification('success', 'Device Blocked', `Device ${target.deviceName || target.serialNumber} blocked for 24 hours.`);
setBlockedDevicesSet(prev => new Set(prev).add(blockKey(target)));
} else {
addNotification('error', 'Block failed', outcome.message, 'device-block-error');
}
return;
}
// Bulk: keep going past individual failures and report once. Network errors become
// failed outcomes; an expired token is rethrown so the outer catch reports it once.
const outcomes = await runWithConcurrency(targets, BULK_CONCURRENCY, async (target): Promise<BlockOutcome> => {
try {
return await blockOne(target);
} catch (error) {
if (error instanceof TokenExpiredError) throw error;
console.error('Failed to block device:', error);
return { ok: false, message: 'Unable to reach the backend.' };
}
});
if (response.ok) {
console.log(`Device ${sessionToBlock.serialNumber} blocked successfully`);
setShowBlockConfirm(false);
setSessionToBlock(null);
addNotification('success', 'Device Blocked', `Device ${sessionToBlock.deviceName || sessionToBlock.serialNumber} blocked for 24 hours.`);
setBlockedDevicesSet(prev => {
const next = new Set(prev);
next.add(`${sessionToBlock.tenantId}:${sessionToBlock.serialNumber}`);
return next;
});
} else {
const data = await response.json();
alert(`Fehler beim Blocken: ${data.message || 'Unbekannter Fehler'}`);
}
setBlockTargets([]);
setBlockedDevicesSet(prev => {
const next = new Set(prev);
targets.forEach((t, i) => { if (outcomes[i].ok) next.add(blockKey(t)); });
return next;
});
const summary = summarizeBlockOutcomes(outcomes);
addNotification(summary.type, summary.title, summary.message, 'device-bulk-block-summary');
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error('Failed to block device:', error);
alert('Fehler beim Blocken des Geräts');
addNotification('error', 'Block failed', 'Could not block the device. Please try again.', 'device-block-error');
}
} finally {
setBlockingDevice(false);
@@ -79,17 +114,16 @@ export function useBlockDevice(
};
const cancelBlock = () => {
setShowBlockConfirm(false);
setSessionToBlock(null);
setBlockTargets([]);
};
return {
showBlockConfirm,
sessionToBlock,
blockTargets,
blockingDevice,
blockedDevicesSet: hasBlockScope ? blockedDevicesSet : EMPTY_SET,
setBlockedDevicesSet,
blockDevice,
blockDevices,
confirmBlock,
cancelBlock,
};
@@ -5,9 +5,15 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import { trackEvent } from "@/lib/appInsights";
import { NotificationType } from "@/contexts/NotificationContext";
import { useSignalR } from "@/contexts/SignalRContext";
import { classifyDeleteResponse, classifyPollingResponse, dispatchSessionDeleted } from "./deleteSessionResponse";
import {
classifyDeleteResponse,
classifyPollingResponse,
dispatchSessionDeleted,
type DeleteResponseAction,
} from "./deleteSessionResponse";
import { BULK_CONCURRENCY, runWithConcurrency, summarizeDeleteActions } from "./bulkActions";
interface DeleteTarget {
export interface DeleteTarget {
sessionId: string;
tenantId: string;
deviceName?: string;
@@ -19,8 +25,10 @@ export function useDeleteSession(
adminMode: boolean,
onSessionDeleted: (sessionId: string) => void
) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<DeleteTarget | null>(null);
// Sessions awaiting the user's confirmation. One entry for the row trash icon, many for the
// table's Select mode — both go through the same confirm → DELETE → classify path.
const [deleteTargets, setDeleteTargets] = useState<DeleteTarget[]>([]);
const showDeleteConfirm = deleteTargets.length > 0;
// V2 cascade path: sessions awaiting the worker's `sessionDeleted` SignalR push. Surfaced
// to consumers so the dashboard table can render a per-row spinner (plan §5 PR5: "show
@@ -88,93 +96,129 @@ export function useDeleteSession(
return () => clearInterval(intervalId);
}, [pendingDeletions, getAccessToken, onSessionDeleted, removePending]);
const deleteSession = (sessionId: string, tenantId: string, deviceName?: string) => {
setSessionToDelete({ sessionId, tenantId, deviceName });
setShowDeleteConfirm(true);
/** Open the confirm dialog for one or more sessions. Empty input is a no-op. */
const deleteSessions = (targets: DeleteTarget[]) => {
if (targets.length === 0) return;
setDeleteTargets(targets);
};
const confirmDelete = async () => {
if (!sessionToDelete) return;
const { sessionId, tenantId } = sessionToDelete;
try {
const response = await authenticatedFetch(api.sessions.delete(sessionId, tenantId), getAccessToken, {
method: 'DELETE',
});
// Always close the confirm dialog before any async branch — the user already committed.
setShowDeleteConfirm(false);
setSessionToDelete(null);
const action = await classifyDeleteResponse(response, sessionId, tenantId);
switch (action.kind) {
case 'queued':
setPendingDeletions((prev) => {
const next = new Set(prev);
next.add(action.sessionId);
return next;
});
pendingTenantsRef.current.set(action.sessionId, action.tenantId);
if (isConnected) {
joinGroup(`session-${action.tenantId}-${action.sessionId}`).catch(() => { /* best-effort */ });
}
trackEvent("session_deletion_queued", { inAdminMode: adminMode, manifestId: action.manifestId ?? "" });
/**
* Apply the state side-effects of one classified DELETE response. `notify` is off for
* bulk runs, which emit a single summary toast instead of one per session.
*/
const applyDeleteAction = (action: DeleteResponseAction, notify: boolean) => {
switch (action.kind) {
case 'queued':
setPendingDeletions((prev) => {
const next = new Set(prev);
next.add(action.sessionId);
return next;
});
pendingTenantsRef.current.set(action.sessionId, action.tenantId);
if (isConnected) {
joinGroup(`session-${action.tenantId}-${action.sessionId}`).catch(() => { /* best-effort */ });
}
trackEvent("session_deletion_queued", { inAdminMode: adminMode, manifestId: action.manifestId ?? "" });
if (notify) {
addNotification(
'info',
'Deletion queued',
'The cascade worker is draining this session. The row will disappear when it completes.',
`session-delete-queued-${action.sessionId}`,
);
return;
}
return;
case 'conflict':
addNotification('warning', action.title, action.message, `session-delete-conflict-${action.sessionId}`);
return;
case 'conflict':
if (notify) addNotification('warning', action.title, action.message, `session-delete-conflict-${action.sessionId}`);
return;
case 'unavailable':
case 'unavailable':
if (notify) {
addNotification(
'warning',
'Deletion temporarily unavailable',
action.message,
`session-delete-unavailable-${action.sessionId}`,
);
return;
}
return;
case 'notFound':
// Already gone server-side — remove from the table to match reality.
onSessionDeleted(action.sessionId);
return;
case 'notFound':
// Already gone server-side — remove from the table to match reality.
onSessionDeleted(action.sessionId);
return;
case 'error':
addNotification('error', 'Delete failed', action.message, `session-delete-error-${action.sessionId}`);
return;
}
} catch (error) {
// Errors in the catch are network / auth failures, not HTTP-status branches.
setShowDeleteConfirm(false);
setSessionToDelete(null);
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error('Failed to delete session:', error);
addNotification('error', 'Delete failed', 'Unable to reach the backend.', 'session-delete-network-error');
}
case 'error':
if (notify) addNotification('error', 'Delete failed', action.message, `session-delete-error-${action.sessionId}`);
return;
}
};
const confirmDelete = async () => {
if (deleteTargets.length === 0) return;
const targets = deleteTargets;
// Always close the confirm dialog before any async work — the user already committed.
setDeleteTargets([]);
if (targets.length === 1) {
const { sessionId, tenantId } = targets[0];
try {
const response = await authenticatedFetch(api.sessions.delete(sessionId, tenantId), getAccessToken, {
method: 'DELETE',
});
applyDeleteAction(await classifyDeleteResponse(response, sessionId, tenantId), true);
} catch (error) {
// Errors in the catch are network / auth failures, not HTTP-status branches.
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error('Failed to delete session:', error);
addNotification('error', 'Delete failed', 'Unable to reach the backend.', 'session-delete-network-error');
}
}
return;
}
trackEvent("session_bulk_delete_confirmed", { inAdminMode: adminMode, count: targets.length });
// Bulk: per-session network failures become 'error' actions so the run continues and the
// summary reports them. An expired token aborts the remaining requests — retrying each
// one would only produce the same failure N times.
const abort: { tokenExpired: TokenExpiredError | null } = { tokenExpired: null };
const actions = await runWithConcurrency(targets, BULK_CONCURRENCY, async ({ sessionId, tenantId }): Promise<DeleteResponseAction> => {
if (abort.tokenExpired) return { kind: 'error', sessionId, message: abort.tokenExpired.message };
try {
const response = await authenticatedFetch(api.sessions.delete(sessionId, tenantId), getAccessToken, {
method: 'DELETE',
});
return await classifyDeleteResponse(response, sessionId, tenantId);
} catch (error) {
if (error instanceof TokenExpiredError) abort.tokenExpired = error;
else console.error('Failed to delete session:', error);
return { kind: 'error', sessionId, message: error instanceof Error ? error.message : 'Unable to reach the backend.' };
}
});
for (const action of actions) applyDeleteAction(action, false);
if (abort.tokenExpired) {
addNotification('error', 'Session Expired', abort.tokenExpired.message, 'session-expired-error');
}
const summary = summarizeDeleteActions(actions);
addNotification(summary.type, summary.title, summary.message, 'session-bulk-delete-summary');
};
const cancelDelete = () => {
setShowDeleteConfirm(false);
setSessionToDelete(null);
setDeleteTargets([]);
};
return {
showDeleteConfirm,
sessionToDelete,
deleteTargets,
pendingDeletions,
deleteSession,
deleteSessions,
confirmDelete,
cancelDelete,
};
@@ -134,8 +134,8 @@ function HomeContent() {
const joinGlobalAdmins = globalAdminMode && hasGlobalScope;
const {
showBlockConfirm, sessionToBlock, blockingDevice, blockedDevicesSet, setBlockedDevicesSet,
blockDevice, confirmBlock, cancelBlock,
showBlockConfirm, blockTargets, blockingDevice, blockedDevicesSet, setBlockedDevicesSet,
blockDevices, confirmBlock, cancelBlock,
} = useBlockDevice(getAccessToken, addNotification, adminMode, crossTenant);
const {
@@ -147,8 +147,8 @@ function HomeContent() {
});
const {
showDeleteConfirm, sessionToDelete, pendingDeletions,
deleteSession, confirmDelete, cancelDelete,
showDeleteConfirm, deleteTargets, pendingDeletions,
deleteSessions, confirmDelete, cancelDelete,
} = useDeleteSession(getAccessToken, addNotification, adminMode, removeSession);
const {
@@ -542,9 +542,9 @@ function HomeContent() {
user={user}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
onDeleteSession={deleteSession}
onDeleteSessions={deleteSessions}
pendingDeletions={pendingDeletions}
onBlockDevice={blockDevice}
onBlockDevices={blockDevices}
fullWidth={fullWidth}
onToggleFullWidth={toggleFullWidth}
sessionLinkTarget={
@@ -558,18 +558,18 @@ function HomeContent() {
</main>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && sessionToDelete && (
{showDeleteConfirm && (
<DeleteConfirmModal
sessionToDelete={sessionToDelete}
targets={deleteTargets}
onConfirm={confirmDelete}
onCancel={cancelDelete}
/>
)}
{/* Block Device Confirmation Modal */}
{showBlockConfirm && sessionToBlock && (
{showBlockConfirm && (
<BlockConfirmModal
sessionToBlock={sessionToBlock}
targets={blockTargets}
blockingDevice={blockingDevice}
onConfirm={confirmBlock}
onCancel={cancelBlock}
@@ -0,0 +1,102 @@
import { describe, it, expect } from "vitest";
import {
runWithConcurrency,
summarizeDeleteActions,
summarizeBlockOutcomes,
} from "@/app/dashboard/hooks/bulkActions";
import type { DeleteResponseAction } from "@/app/dashboard/hooks/deleteSessionResponse";
/**
* Pure-logic tests for the bulk delete / block helpers. The hooks compose these with
* `authenticatedFetch` and the toast API; the helpers pin the concurrency contract and
* the wording of the single summary toast a bulk run emits.
*/
const T = "11111111-1111-1111-1111-111111111111";
function queued(id: string): DeleteResponseAction {
return { kind: "queued", sessionId: id, tenantId: T, manifestId: null };
}
describe("runWithConcurrency", () => {
it("returns results in input order regardless of completion order", async () => {
const delays = [30, 5, 15];
const out = await runWithConcurrency(delays, 3, (d) => new Promise<number>((r) => setTimeout(() => r(d), d)));
expect(out).toEqual([30, 5, 15]);
});
it("never runs more than `limit` items at once", async () => {
let inFlight = 0;
let peak = 0;
const items = Array.from({ length: 10 }, (_, i) => i);
await runWithConcurrency(items, 3, async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 2));
inFlight--;
});
expect(peak).toBe(3);
});
it("handles an empty input and a limit larger than the input", async () => {
expect(await runWithConcurrency([], 3, async (x) => x)).toEqual([]);
expect(await runWithConcurrency([1, 2], 10, async (x) => x * 2)).toEqual([2, 4]);
});
it("rejects when an item rejects", async () => {
await expect(
runWithConcurrency([1, 2], 2, async (x) => { if (x === 2) throw new Error("boom"); return x; }),
).rejects.toThrow("boom");
});
});
describe("summarizeDeleteActions", () => {
it("is an info toast when everything was queued", () => {
const s = summarizeDeleteActions([queued("a"), queued("b")]);
expect(s.type).toBe("info");
expect(s.title).toBe("Deleting 2 sessions");
expect(s.message).toMatch(/^2 queued\./);
});
it("treats notFound as success (row already gone)", () => {
const s = summarizeDeleteActions([queued("a"), { kind: "notFound", sessionId: "b", message: "gone" }]);
expect(s.type).toBe("info");
expect(s.message).toMatch(/^1 queued, 1 already deleted\./);
});
it("is a warning when some sessions could not be queued", () => {
const s = summarizeDeleteActions([
queued("a"),
{ kind: "conflict", sessionId: "b", title: "Cascade already in flight", message: "m", hint: null },
{ kind: "unavailable", sessionId: "c", message: "m" },
{ kind: "error", sessionId: "d", message: "m" },
]);
expect(s.type).toBe("warning");
expect(s.message).toMatch(/^1 queued, 1 already in flight, 1 temporarily unavailable, 1 failed\./);
});
it("is an error toast when nothing succeeded", () => {
const s = summarizeDeleteActions([{ kind: "error", sessionId: "a", message: "m" }]);
expect(s.type).toBe("error");
});
});
describe("summarizeBlockOutcomes", () => {
it("reports a clean success", () => {
const s = summarizeBlockOutcomes([{ ok: true }, { ok: true }]);
expect(s.type).toBe("success");
expect(s.message).toBe("2 devices blocked for 24 hours.");
});
it("reports partial failure with the first reason", () => {
const s = summarizeBlockOutcomes([{ ok: true }, { ok: false, message: "HTTP 503" }, { ok: false, message: "other" }]);
expect(s.type).toBe("warning");
expect(s.message).toBe("1 blocked, 2 failed (HTTP 503).");
});
it("is an error toast when every block failed", () => {
const s = summarizeBlockOutcomes([{ ok: false, message: "HTTP 403" }]);
expect(s.type).toBe("error");
expect(s.title).toBe("Block failed");
});
});