mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user