feat(admin): shift-select multiple sessions in process list

This commit is contained in:
zipg authored and GitHub committed 2026-09-20 16:49:57 +08:00
1 parent 08cb9287de
commit ec7bc617c4
8 files changed
+144 -9

No files matched your search

@@ -13,6 +13,7 @@ import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, processListExecutionError, processListSessionCount } from "@/lib/database/mysqlProcessList";
import { resolveProcessListDriverForConnection, type ProcessRow } from "@/lib/database/processListDrivers";
import { processListSelectionAfterClick } from "@/lib/database/processListSelection";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const props = defineProps<{
@@ -55,6 +56,8 @@ const cancelTarget = ref<ProcessRow | null>(null);
const canceling = ref(false);
const batchSupported = computed(() => driver.value?.supportsBatchCancel === true);
const selectedIds = ref(new Set<number>());
// Anchor row for Shift+click range selection, in the current display order.
const selectionAnchorId = ref<number | null>(null);
const batchTargets = ref<ProcessRow[] | null>(null);
const batchResult = ref<{ succeeded: number; failures: { id: number; message: string }[] } | null>(null);
const refreshing = ref(false);
@@ -108,21 +111,31 @@ watch(
(visible) => {
const available = new Set(visible.map((row) => row.id));
selectedIds.value = new Set([...selectedIds.value].filter((id) => available.has(id)));
if (selectionAnchorId.value !== null && !available.has(selectionAnchorId.value)) selectionAnchorId.value = null;
},
{ flush: "sync" },
);
function toggleSelection(id: number, checked: boolean) {
function clearSelection() {
selectedIds.value = new Set();
selectionAnchorId.value = null;
}
/** Row checkbox click: a plain click toggles one row, Shift+click extends from the anchor. */
function toggleSelection(id: number, event: MouseEvent) {
if (actionsLocked.value || refreshing.value) return;
const next = new Set(selectedIds.value);
if (checked && selectableRows.value.some((row) => row.id === id)) next.add(id);
else next.delete(id);
selectedIds.value = next;
// The browser toggles the clicked checkbox before this handler runs, so the
// next state comes from the selection; both agree because the click is never
// cancelled (cancelling it would leave the DOM checkbox out of sync).
const next = processListSelectionAfterClick(selectableRows.value, { selected: selectedIds.value, anchorId: selectionAnchorId.value }, id, event);
selectedIds.value = next.selected;
selectionAnchorId.value = next.anchorId;
}
function toggleAll(checked: boolean) {
if (actionsLocked.value || refreshing.value) return;
selectedIds.value = new Set(checked ? selectableRows.value.map((row) => row.id) : []);
selectionAnchorId.value = null;
}
function requestBatchCancel() {
@@ -168,7 +181,7 @@ async function confirmBatchCancel() {
if (result === undefined || !isCurrent()) return;
batchResult.value = result;
batchTargets.value = null;
selectedIds.value = new Set();
clearSelection();
} catch (error: unknown) {
if (isCurrent()) toast(t("processList.killFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
} finally {
@@ -327,7 +340,7 @@ watch(
() => props.connection.id,
() => {
connectionGeneration++;
selectedIds.value = new Set();
clearSelection();
batchTargets.value = null;
batchResult.value = null;
cancelTarget.value = null;
@@ -432,7 +445,7 @@ onBeforeUnmount(() => {
:checked="selectedIds.has(row.id)"
:disabled="actionsLocked || refreshing || ownSessionId === null || isOwnSession(row) || !Number.isInteger(row.id) || row.id <= 0"
:aria-label="t('processList.selectSession', { id: row.id })"
@change="toggleSelection(row.id, ($event.target as HTMLInputElement).checked)"
@click="toggleSelection(row.id, $event as MouseEvent)"
/>
</td>
<td