@@ -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 && (
{
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"
>
@@ -1089,11 +1311,11 @@ function SessionCell({
- ) : (
+ ) : selectMode ? null : (
{
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"
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/bulkActions.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/bulkActions.ts
new file mode 100644
index 00000000..0e672371
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/bulkActions.ts
@@ -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(
+ items: readonly T[],
+ limit: number,
+ fn: (item: T) => Promise,
+): Promise {
+ 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}).`,
+ };
+}
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts
index 92c2a952..11edf73d 100644
--- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts
+++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts
@@ -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();
+const blockKey = (t: { tenantId: string; serialNumber: string }) => `${t.tenantId}:${t.serialNumber}`;
+
export function useBlockDevice(
getAccessToken: (forceRefresh?: boolean) => Promise,
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(null);
+ // Devices awaiting the user's confirmation — one for the row icon, many for Select mode.
+ const [blockTargets, setBlockTargets] = useState([]);
+ const showBlockConfirm = blockTargets.length > 0;
const [blockingDevice, setBlockingDevice] = useState(false);
const [blockedDevicesSet, setBlockedDevicesSet] = useState>(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();
+ 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 => {
+ 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 => {
+ 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,
};
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDeleteSession.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDeleteSession.ts
index 44cc4722..090a6bc3 100644
--- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDeleteSession.ts
+++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDeleteSession.ts
@@ -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(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([]);
+ 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 => {
+ 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,
};
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx
index de25a61f..5a4daec0 100644
--- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx
@@ -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() {
{/* Delete Confirmation Modal */}
- {showDeleteConfirm && sessionToDelete && (
+ {showDeleteConfirm && (
)}
{/* Block Device Confirmation Modal */}
- {showBlockConfirm && sessionToBlock && (
+ {showBlockConfirm && (
{
+ 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((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");
+ });
+});