diff --git a/apps/desktop/src/components/admin/ProcessListPanel.vue b/apps/desktop/src/components/admin/ProcessListPanel.vue index 21612ae3b1..54bed30072 100644 --- a/apps/desktop/src/components/admin/ProcessListPanel.vue +++ b/apps/desktop/src/components/admin/ProcessListPanel.vue @@ -44,6 +44,14 @@ let timer: ReturnType | undefined; const cancelTarget = ref(null); const canceling = ref(false); +const batchSupported = computed(() => driver.value?.supportsBatchCancel === true); +const selectedIds = ref(new Set()); +const batchTargets = ref(null); +const batchResult = ref<{ succeeded: number; failures: { id: number; message: string }[] } | null>(null); +const refreshing = ref(false); +const actionsLocked = computed(() => canceling.value || cancelTarget.value !== null || batchTargets.value !== null); +let connectionGeneration = 0; +let disposed = false; const fallbackListSql = ref(null); // Full-text preview for long cells (SQL statement / info), opened by clicking them. @@ -80,6 +88,84 @@ const filteredRows = computed(() => { }); }); +const selectableRows = computed(() => (ownSessionId.value === null ? [] : filteredRows.value.filter((row) => !isOwnSession(row) && Number.isInteger(row.id) && row.id > 0))); +const allSelected = computed(() => selectableRows.value.length > 0 && selectableRows.value.every((row) => selectedIds.value.has(row.id))); +const partiallySelected = computed(() => selectedIds.value.size > 0 && !allSelected.value); + +watch( + selectableRows, + (visible) => { + const available = new Set(visible.map((row) => row.id)); + selectedIds.value = new Set([...selectedIds.value].filter((id) => available.has(id))); + }, + { flush: "sync" }, +); + +function toggleSelection(id: number, checked: boolean) { + 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; +} + +function toggleAll(checked: boolean) { + if (actionsLocked.value || refreshing.value) return; + selectedIds.value = new Set(checked ? selectableRows.value.map((row) => row.id) : []); +} + +function requestBatchCancel() { + if (!batchSupported.value || actionsLocked.value || refreshing.value || ownSessionId.value === null) return; + const targets = selectableRows.value.filter((row) => selectedIds.value.has(row.id)); + if (targets.length) batchTargets.value = targets.map((row) => ({ ...row })); +} + +async function confirmBatchCancel() { + const targets = batchTargets.value; + const activeDriver = driver.value; + if (!targets?.length || !activeDriver?.supportsBatchCancel || ownSessionId.value === null || canceling.value) return; + const connection = { ...props.connection }; + const generation = connectionGeneration; + const isCurrent = () => !disposed && generation === connectionGeneration; + canceling.value = true; + let executed = false; + try { + const statements = targets.map((row) => ({ id: row.id, sql: activeDriver.buildCancelQuerySql(row.id) })); + const result = await executeWithProductionSqlGuard({ + connection, + database: "", + sql: statements.map((statement) => statement.sql).join(";\n"), + source: t("production.sourceAdmin"), + execute: async () => { + const summary = { succeeded: 0, failures: [] as { id: number; message: string }[] }; + for (const statement of statements) { + // A closed tab or changed connection must not dispatch remaining work. + if (!isCurrent()) break; + executed = true; + try { + const results = await api.executeMulti(connection.id, "", statement.sql, undefined, undefined, { maxRows: 1 }); + const error = processListExecutionError(results) || activeDriver.cancelQueryResultError?.(results); + if (error) throw new Error(error); + summary.succeeded++; + } catch (error: unknown) { + summary.failures.push({ id: statement.id, message: error instanceof Error ? error.message : String(error) }); + } + } + return summary; + }, + }); + if (result === undefined || !isCurrent()) return; + batchResult.value = result; + batchTargets.value = null; + selectedIds.value = new Set(); + } catch (error: unknown) { + if (isCurrent()) toast(t("processList.killFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + } finally { + canceling.value = false; + if (!disposed && (executed || !isCurrent())) void load({ silent: true }); + } +} + function toggleSort(key: string) { if (sortKey.value === key) { sortDir.value = sortDir.value === "asc" ? "desc" : "asc"; @@ -91,45 +177,54 @@ function toggleSort(key: string) { async function load(options: { silent?: boolean } = {}) { const activeDriver = driver.value; - if (!activeDriver) return; + if (!activeDriver || disposed || actionsLocked.value) return; if (!loadCoordinator.tryStart()) return; + const connectionId = props.connection.id; + const generation = connectionGeneration; + const isCurrent = () => !disposed && generation === connectionGeneration; + refreshing.value = true; if (!options.silent) loading.value = true; loadError.value = ""; try { - await connectionStore.ensureConnected(props.connection.id); + await connectionStore.ensureConnected(connectionId); + if (!isCurrent()) return; if (ownSessionId.value === null) { try { let idResult; try { - idResult = await api.executeQuery(props.connection.id, "", activeDriver.ownSessionSql, undefined, undefined, { maxRows: 1 }); + idResult = await api.executeQuery(connectionId, "", activeDriver.ownSessionSql, undefined, undefined, { maxRows: 1 }); } catch (error) { if (!activeDriver.fallbackOwnSessionSql || !activeDriver.shouldUseFallbackOwnSessionSql?.(error)) throw error; - idResult = await api.executeQuery(props.connection.id, "", activeDriver.fallbackOwnSessionSql, undefined, undefined, { maxRows: 1 }); + idResult = await api.executeQuery(connectionId, "", activeDriver.fallbackOwnSessionSql, undefined, undefined, { maxRows: 1 }); } const raw = idResult?.rows?.[0]?.[0]; const parsed = Number(raw); - if (Number.isFinite(parsed)) ownSessionId.value = parsed; + if (isCurrent() && Number.isInteger(parsed) && parsed > 0) ownSessionId.value = parsed; } catch { // Non-fatal: without our own id we simply cannot dim the self row. } } + if (!isCurrent()) return; const listSql = fallbackListSql.value ?? activeDriver.listSql; let result; try { - result = await api.executeQuery(props.connection.id, "", listSql, undefined, undefined, { maxRows: activeDriver.maxRows }); + result = await api.executeQuery(connectionId, "", listSql, undefined, undefined, { maxRows: activeDriver.maxRows }); } catch (error) { if (fallbackListSql.value || !activeDriver.fallbackListSql || !activeDriver.shouldUseFallbackListSql?.(error)) throw error; - result = await api.executeQuery(props.connection.id, "", activeDriver.fallbackListSql, undefined, undefined, { maxRows: activeDriver.maxRows }); + result = await api.executeQuery(connectionId, "", activeDriver.fallbackListSql, undefined, undefined, { maxRows: activeDriver.maxRows }); // Cache the compatible query so old servers do not fail once per refresh. - fallbackListSql.value = activeDriver.fallbackListSql; + if (isCurrent()) fallbackListSql.value = activeDriver.fallbackListSql; } + if (!isCurrent()) return; rows.value = activeDriver.mapRows(result); truncated.value = result.truncated === true; } catch (error: any) { - loadError.value = error?.message || String(error); + if (isCurrent()) loadError.value = error?.message || String(error); } finally { loading.value = false; + refreshing.value = false; loadCoordinator.finish(); + if (!disposed && !isCurrent()) void load(); } } @@ -138,26 +233,30 @@ function isOwnSession(row: ProcessRow): boolean { } function requestCancel(row: ProcessRow) { - if (isOwnSession(row)) return; + if (isOwnSession(row) || actionsLocked.value || refreshing.value) return; cancelTarget.value = row; } async function confirmCancel() { const target = cancelTarget.value; const activeDriver = driver.value; - if (!target || !activeDriver) return; + if (!target || !activeDriver || canceling.value) return; + const connection = { ...props.connection }; + const generation = connectionGeneration; + const isCurrent = () => !disposed && generation === connectionGeneration; canceling.value = true; try { const cancelSql = activeDriver.buildCancelQuerySql(target.id); let usedFallbackCancelSql = false; const executeCancelSql = async (sql: string) => { - const results = await api.executeMulti(props.connection.id, "", sql, undefined, undefined, { maxRows: 1 }); + if (!isCurrent()) return undefined; + const results = await api.executeMulti(connection.id, "", sql, undefined, undefined, { maxRows: 1 }); const executionError = processListExecutionError(results); if (executionError) throw new Error(executionError); return results; }; const result = await executeWithProductionSqlGuard({ - connection: props.connection, + connection, database: "", sql: cancelSql, source: t("production.sourceAdmin"), @@ -171,16 +270,16 @@ async function confirmCancel() { } }, }); - if (result === undefined) return; + if (result === undefined || !isCurrent()) return; const cancelResultError = usedFallbackCancelSql ? activeDriver.fallbackCancelQueryResultError?.(result) : activeDriver.cancelQueryResultError?.(result); if (cancelResultError) throw new Error(cancelResultError); toast(t("processList.killSuccess", { id: target.id }), 2500); cancelTarget.value = null; - await load({ silent: true }); } catch (error: any) { - toast(t("processList.killFailed", { message: error?.message || String(error) }), 5000); + if (isCurrent()) toast(t("processList.killFailed", { message: error?.message || String(error) }), 5000); } finally { canceling.value = false; + if (cancelTarget.value === null) void load({ silent: true }); } } @@ -216,6 +315,12 @@ watch(intervalSeconds, () => { watch( () => props.connection.id, () => { + connectionGeneration++; + selectedIds.value = new Set(); + batchTargets.value = null; + batchResult.value = null; + cancelTarget.value = null; + fallbackListSql.value = null; rows.value = []; truncated.value = false; ownSessionId.value = null; @@ -227,22 +332,39 @@ watch( ); onMounted(() => void load()); -onBeforeUnmount(stopTimer); +onBeforeUnmount(() => { + disposed = true; + connectionGeneration++; + stopTimer(); +});