mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(agent): support cancelling installs and upgrades
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted, computed, watch, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Activity, ExternalLink, Cpu, FolderOpen, FolderSync, MemoryStick, Search, Square, Trash2, Download, RotateCcw, Loader2, RefreshCw, Check, Clock3, FileUp } from "@lucide/vue";
|
||||
import { Activity, ExternalLink, Cpu, FolderOpen, FolderSync, MemoryStick, Search, Square, Trash2, Download, RotateCcw, Loader2, RefreshCw, Check, Clock3, FileUp, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
addDriverInstallQueue,
|
||||
driverInstallProgressChannel,
|
||||
driverInstallProgressPercent,
|
||||
isDriverInstallCanceledError,
|
||||
isDriverInstallCancellationTarget,
|
||||
isDriverInstallProgressForOperation,
|
||||
isDriverInstallProgressTarget,
|
||||
removeDriverInstallQueue,
|
||||
@@ -188,11 +190,13 @@ const agentDriverSearch = ref("");
|
||||
const selectedDriverCategory = ref<string>("all");
|
||||
const installing = ref<string | null>(null);
|
||||
const upgradingAll = ref(false);
|
||||
const preparingUpgradeAll = ref(false);
|
||||
const upgradingCompletedCount = ref(0);
|
||||
const upgradingTotal = ref(0);
|
||||
const queuedDriverInstalls = ref<string[]>([]);
|
||||
const reinstallingJre = ref<string | null>(null);
|
||||
const activeAgentOperationId = ref<string | null>(null);
|
||||
const cancellableAgentInstallDbType = ref<string | null>(null);
|
||||
const refreshing = ref(false);
|
||||
const agentProgressByDbType = reactive<Record<string, DriverInstallProgress | null | undefined>>({});
|
||||
const jdbcPluginProgress = ref<DriverInstallProgress | null>(null);
|
||||
@@ -329,6 +333,15 @@ function isDriverProgressActive(dbType: string): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
function isDriverInstallCancellable(dbType: string): boolean {
|
||||
return isDriverInstallCancellationTarget(dbType, {
|
||||
activeOperationId: activeAgentOperationId.value,
|
||||
cancellableDbType: cancellableAgentInstallDbType.value,
|
||||
upgradingAll: upgradingAll.value,
|
||||
progressMap: agentProgressByDbType,
|
||||
});
|
||||
}
|
||||
|
||||
function driverRequiresJavaRuntime(driver: AgentDriverInfo): boolean {
|
||||
return driver.requires_java_runtime ?? Boolean(driver.jre);
|
||||
}
|
||||
@@ -455,7 +468,7 @@ async function chooseCustomJavaPath() {
|
||||
}
|
||||
|
||||
async function installDriver(dbType: string) {
|
||||
if (installing.value !== null || upgradingAll.value) {
|
||||
if (installing.value !== null || preparingUpgradeAll.value || upgradingAll.value) {
|
||||
queueDriverInstall(dbType);
|
||||
return;
|
||||
}
|
||||
@@ -466,7 +479,6 @@ async function installDriver(dbType: string) {
|
||||
async function runDriverInstall(dbType: string) {
|
||||
const label = driverLabel(dbType);
|
||||
installing.value = dbType;
|
||||
activeAgentOperationId.value = uuid();
|
||||
resetAgentInstallProgress();
|
||||
try {
|
||||
const managedResult = await installRegisteredManagedJdbcDriver(dbType, jdbcMavenBundles.value, jdbcPluginStatus.value, api);
|
||||
@@ -486,20 +498,28 @@ async function runDriverInstall(dbType: string) {
|
||||
toast(t("driverStore.driverUpdateBlocked", { labels: blockers.map((blocker) => blocker.label).join(", ") }));
|
||||
return;
|
||||
}
|
||||
await api.installAgent(dbType, activeAgentOperationId.value);
|
||||
const operationId = uuid();
|
||||
activeAgentOperationId.value = operationId;
|
||||
cancellableAgentInstallDbType.value = dbType;
|
||||
await api.installAgent(dbType, operationId);
|
||||
await refreshAgents();
|
||||
toast(t("driverStore.driverInstallSuccess", { label }));
|
||||
} catch (e: any) {
|
||||
if (isDriverInstallCanceledError(e)) {
|
||||
toast(t("driverStore.driverInstallCancelled", { label }));
|
||||
return;
|
||||
}
|
||||
toast(t("driverStore.driverInstallFailed", { label, error: backendError(e) }));
|
||||
} finally {
|
||||
installing.value = null;
|
||||
activeAgentOperationId.value = null;
|
||||
cancellableAgentInstallDbType.value = null;
|
||||
resetAgentInstallProgress();
|
||||
}
|
||||
}
|
||||
|
||||
async function runQueuedDriverInstalls() {
|
||||
if (installing.value !== null || upgradingAll.value) return;
|
||||
if (installing.value !== null || preparingUpgradeAll.value || upgradingAll.value) return;
|
||||
|
||||
const result = takeNextDriverInstallQueue(queuedDriverInstalls.value, canInstallOrUpdateDriver);
|
||||
queuedDriverInstalls.value = result.queue;
|
||||
@@ -510,21 +530,29 @@ async function runQueuedDriverInstalls() {
|
||||
}
|
||||
|
||||
async function upgradeAll() {
|
||||
upgradingAll.value = true;
|
||||
activeAgentOperationId.value = uuid();
|
||||
upgradingCompletedCount.value = 0;
|
||||
queuedDriverInstalls.value = [];
|
||||
resetAgentInstallProgress();
|
||||
if (preparingUpgradeAll.value || upgradingAll.value) return;
|
||||
preparingUpgradeAll.value = true;
|
||||
try {
|
||||
const updatableDbTypes = drivers.value.filter((driver) => driver.update_available).map((driver) => driver.db_type);
|
||||
upgradingTotal.value = updatableDbTypes.length;
|
||||
const blockers = await api.checkAgentUpdateBlockers(updatableDbTypes);
|
||||
if (blockers.length > 0) {
|
||||
toast(t("driverStore.driverUpdateBlocked", { labels: blockers.map((blocker) => blocker.label).join(", ") }));
|
||||
return;
|
||||
}
|
||||
// Do not expose Cancel until the backend operation has registered its token.
|
||||
// Otherwise a cancel during this client-side blocker check is silently lost.
|
||||
upgradingAll.value = true;
|
||||
activeAgentOperationId.value = uuid();
|
||||
upgradingCompletedCount.value = 0;
|
||||
upgradingTotal.value = updatableDbTypes.length;
|
||||
queuedDriverInstalls.value = [];
|
||||
resetAgentInstallProgress();
|
||||
const result = await api.upgradeAllAgents(activeAgentOperationId.value);
|
||||
await refreshAgents();
|
||||
if (result.cancelled > 0 && result.failed.length === 0) {
|
||||
toast(t("driverStore.upgradeAllCancelled", { count: result.cancelled }));
|
||||
return;
|
||||
}
|
||||
if (result.failed.length > 0) {
|
||||
const failedLabels = result.failed.map((item) => drivers.value.find((driver) => driver.db_type === item.db_type)?.label ?? item.db_type).join(", ");
|
||||
toast(t("driverStore.upgradeAllPartial", { count: result.upgraded, failed: failedLabels }));
|
||||
@@ -532,8 +560,13 @@ async function upgradeAll() {
|
||||
toast(t("driverStore.upgradeAllSuccess", { count: result.upgraded }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (isDriverInstallCanceledError(e)) {
|
||||
toast(t("driverStore.upgradeAllCancelled", { count: 0 }));
|
||||
return;
|
||||
}
|
||||
toast(t("driverStore.upgradeAllFailed", { error: backendError(e) }));
|
||||
} finally {
|
||||
preparingUpgradeAll.value = false;
|
||||
upgradingAll.value = false;
|
||||
activeAgentOperationId.value = null;
|
||||
upgradingCompletedCount.value = 0;
|
||||
@@ -542,6 +575,25 @@ async function upgradeAll() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Abort a single driver's install, whether started alone or inside a batch. */
|
||||
async function cancelDriverInstall(dbType: string) {
|
||||
if (!isDriverInstallCancellable(dbType) || !activeAgentOperationId.value) return;
|
||||
try {
|
||||
await api.cancelAgentInstall(dbType, activeAgentOperationId.value);
|
||||
} catch {
|
||||
// A cancel that arrives after the download finished is a no-op.
|
||||
}
|
||||
}
|
||||
|
||||
/** Abort the whole batch upgrade: in-flight downloads stop, queued ones never start. */
|
||||
async function cancelUpgradeAll() {
|
||||
try {
|
||||
await api.cancelAgentUpgradeAll(activeAgentOperationId.value ?? undefined);
|
||||
} catch {
|
||||
// The batch may have just finished; treat a failed cancel as a no-op.
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallDriver(dbType: string) {
|
||||
const label = driverLabel(dbType);
|
||||
try {
|
||||
@@ -1429,11 +1481,16 @@ watch(driverStoreTab, (tab) => {
|
||||
<div class="text-sm font-semibold">{{ t("driverStore.updatesAvailableTitle") }} ({{ globalUpdatableDrivers.length }})</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("driverStore.updatesAvailableDescription") }}</p>
|
||||
</div>
|
||||
<Button size="sm" class="h-7 rounded-md text-xs shrink-0 ml-3" :disabled="installing !== null || upgradingAll || agentImportBusy" @click="upgradeAll">
|
||||
<Loader2 v-if="upgradingAll" class="h-3 w-3 animate-spin mr-1" />
|
||||
<Download v-else class="h-3 w-3 mr-1" />
|
||||
{{ upgradingAll ? t("driverStore.upgradingProgress", { current: upgradingCompletedCount, total: upgradingTotal }) : t("driverStore.upgradeAll") }}
|
||||
</Button>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" class="h-7 rounded-md text-xs shrink-0 ml-3" :disabled="installing !== null || preparingUpgradeAll || upgradingAll || agentImportBusy" @click="upgradeAll">
|
||||
<Loader2 v-if="upgradingAll" class="h-3 w-3 animate-spin mr-1" />
|
||||
<Download v-else class="h-3 w-3 mr-1" />
|
||||
{{ upgradingAll ? t("driverStore.upgradingProgress", { current: upgradingCompletedCount, total: upgradingTotal }) : t("driverStore.upgradeAll") }}
|
||||
</Button>
|
||||
<Button v-if="upgradingAll" type="button" variant="outline" size="icon-sm" class="h-7 w-7 rounded-md shrink-0 text-muted-foreground hover:text-destructive" :title="t('driverStore.cancelUpgradeAll')" :aria-label="t('driverStore.cancelUpgradeAll')" @click="cancelUpgradeAll">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="driver in globalUpdatableDrivers"
|
||||
@@ -1461,14 +1518,24 @@ watch(driverStoreTab, (tab) => {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
|
||||
:disabled="upgradingAll || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || agentImportBusy"
|
||||
@click="removeQueuedDriverInstall(driver.db_type)"
|
||||
>
|
||||
<Clock3 class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.queued") }}
|
||||
</Button>
|
||||
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" />
|
||||
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" /><Button
|
||||
v-if="!driver.installed && isDriverProgressActive(driver.db_type) && isDriverInstallCancellable(driver.db_type)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="h-7 w-7 rounded-md text-muted-foreground hover:text-destructive"
|
||||
:title="t('driverStore.cancelInstall')"
|
||||
:aria-label="t('driverStore.cancelInstall')"
|
||||
@click="cancelDriverInstall(driver.db_type)"
|
||||
><X class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button v-else-if="!driver.installed && !isDriverProgressActive(driver.db_type)" size="sm" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<Download class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.install") }}
|
||||
</Button>
|
||||
@@ -1478,7 +1545,7 @@ watch(driverStoreTab, (tab) => {
|
||||
variant="ghost"
|
||||
class="driver-store-local-import-button h-7 w-7 rounded-md text-xs text-muted-foreground"
|
||||
:title="importingDriver === driver.db_type ? t('driverStore.importing') : t('driverStore.importLocalJar')"
|
||||
:disabled="upgradingAll || installing !== null || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || installing !== null || agentImportBusy"
|
||||
@click="importDriverFile(driver)"
|
||||
>
|
||||
<Loader2 v-if="importingDriver === driver.db_type" class="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -1490,17 +1557,34 @@ watch(driverStoreTab, (tab) => {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
|
||||
:disabled="upgradingAll || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || agentImportBusy"
|
||||
@click="removeQueuedDriverInstall(driver.db_type)"
|
||||
>
|
||||
<Clock3 class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.queued") }}
|
||||
</Button>
|
||||
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" />
|
||||
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" /><Button
|
||||
v-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type) && isDriverInstallCancellable(driver.db_type)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="h-7 w-7 rounded-md text-muted-foreground hover:text-destructive"
|
||||
:title="t('driverStore.cancelInstall')"
|
||||
:aria-label="t('driverStore.cancelInstall')"
|
||||
@click="cancelDriverInstall(driver.db_type)"
|
||||
><X class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button v-else-if="driver.installed && driver.update_available && !isDriverProgressActive(driver.db_type)" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
{{ t("driverStore.update") }}
|
||||
</Button>
|
||||
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || agentImportBusy || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
|
||||
<Button
|
||||
v-if="driver.installed"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive"
|
||||
:disabled="installing !== null || preparingUpgradeAll || upgradingAll || agentImportBusy || isDriverQueued(driver.db_type)"
|
||||
@click="uninstallDriver(driver.db_type)"
|
||||
>
|
||||
{{ t("driverStore.uninstall") }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1579,14 +1663,24 @@ watch(driverStoreTab, (tab) => {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
|
||||
:disabled="upgradingAll || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || agentImportBusy"
|
||||
@click="removeQueuedDriverInstall(driver.db_type)"
|
||||
>
|
||||
<Clock3 class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.queued") }}
|
||||
</Button>
|
||||
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" />
|
||||
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" /><Button
|
||||
v-if="!driver.installed && isDriverProgressActive(driver.db_type) && isDriverInstallCancellable(driver.db_type)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="h-7 w-7 rounded-md text-muted-foreground hover:text-destructive"
|
||||
:title="t('driverStore.cancelInstall')"
|
||||
:aria-label="t('driverStore.cancelInstall')"
|
||||
@click="cancelDriverInstall(driver.db_type)"
|
||||
><X class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button v-else-if="!driver.installed && !isDriverProgressActive(driver.db_type)" size="sm" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<Download class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.install") }}
|
||||
</Button>
|
||||
@@ -1596,7 +1690,7 @@ watch(driverStoreTab, (tab) => {
|
||||
variant="ghost"
|
||||
class="driver-store-local-import-button h-7 w-7 rounded-md text-xs text-muted-foreground"
|
||||
:title="importingDriver === driver.db_type ? t('driverStore.importing') : t('driverStore.importLocalJar')"
|
||||
:disabled="upgradingAll || installing !== null || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || installing !== null || agentImportBusy"
|
||||
@click="importDriverFile(driver)"
|
||||
>
|
||||
<Loader2 v-if="importingDriver === driver.db_type" class="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -1608,14 +1702,24 @@ watch(driverStoreTab, (tab) => {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
|
||||
:disabled="upgradingAll || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || agentImportBusy"
|
||||
@click="removeQueuedDriverInstall(driver.db_type)"
|
||||
>
|
||||
<Clock3 class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.queued") }}
|
||||
</Button>
|
||||
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" />
|
||||
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" /><Button
|
||||
v-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type) && isDriverInstallCancellable(driver.db_type)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="h-7 w-7 rounded-md text-muted-foreground hover:text-destructive"
|
||||
:title="t('driverStore.cancelInstall')"
|
||||
:aria-label="t('driverStore.cancelInstall')"
|
||||
@click="cancelDriverInstall(driver.db_type)"
|
||||
><X class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button v-else-if="driver.installed && driver.update_available && !isDriverProgressActive(driver.db_type)" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
{{ t("driverStore.update") }}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1623,7 +1727,7 @@ watch(driverStoreTab, (tab) => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive"
|
||||
:disabled="installing !== null || upgradingAll || agentImportBusy || isDriverQueued(driver.db_type)"
|
||||
:disabled="installing !== null || preparingUpgradeAll || upgradingAll || agentImportBusy || isDriverQueued(driver.db_type)"
|
||||
@click="uninstallDriver(driver.db_type)"
|
||||
>
|
||||
{{ t("driverStore.uninstall") }}
|
||||
@@ -1663,14 +1767,24 @@ watch(driverStoreTab, (tab) => {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
|
||||
:disabled="upgradingAll || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || agentImportBusy"
|
||||
@click="removeQueuedDriverInstall(driver.db_type)"
|
||||
>
|
||||
<Clock3 class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.queued") }}
|
||||
</Button>
|
||||
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" />
|
||||
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" /><Button
|
||||
v-if="!driver.installed && isDriverProgressActive(driver.db_type) && isDriverInstallCancellable(driver.db_type)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="h-7 w-7 rounded-md text-muted-foreground hover:text-destructive"
|
||||
:title="t('driverStore.cancelInstall')"
|
||||
:aria-label="t('driverStore.cancelInstall')"
|
||||
@click="cancelDriverInstall(driver.db_type)"
|
||||
><X class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button v-else-if="!driver.installed && !isDriverProgressActive(driver.db_type)" size="sm" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<Download class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.install") }}
|
||||
</Button>
|
||||
@@ -1680,7 +1794,7 @@ watch(driverStoreTab, (tab) => {
|
||||
variant="ghost"
|
||||
class="driver-store-local-import-button h-7 w-7 rounded-md text-xs text-muted-foreground"
|
||||
:title="importingDriver === driver.db_type ? t('driverStore.importing') : t('driverStore.importLocalJar')"
|
||||
:disabled="upgradingAll || installing !== null || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || installing !== null || agentImportBusy"
|
||||
@click="importDriverFile(driver)"
|
||||
>
|
||||
<Loader2 v-if="importingDriver === driver.db_type" class="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -1692,17 +1806,34 @@ watch(driverStoreTab, (tab) => {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
|
||||
:disabled="upgradingAll || agentImportBusy"
|
||||
:disabled="preparingUpgradeAll || upgradingAll || agentImportBusy"
|
||||
@click="removeQueuedDriverInstall(driver.db_type)"
|
||||
>
|
||||
<Clock3 class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.queued") }}
|
||||
</Button>
|
||||
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" />
|
||||
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" /><Button
|
||||
v-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type) && isDriverInstallCancellable(driver.db_type)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="h-7 w-7 rounded-md text-muted-foreground hover:text-destructive"
|
||||
:title="t('driverStore.cancelInstall')"
|
||||
:aria-label="t('driverStore.cancelInstall')"
|
||||
@click="cancelDriverInstall(driver.db_type)"
|
||||
><X class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button v-else-if="driver.installed && driver.update_available && !isDriverProgressActive(driver.db_type)" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentImportBusy" @click="installDriver(driver.db_type)">
|
||||
{{ t("driverStore.update") }}
|
||||
</Button>
|
||||
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || agentImportBusy || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
|
||||
<Button
|
||||
v-if="driver.installed"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive"
|
||||
:disabled="installing !== null || preparingUpgradeAll || upgradingAll || agentImportBusy || isDriverQueued(driver.db_type)"
|
||||
@click="uninstallDriver(driver.db_type)"
|
||||
>
|
||||
{{ t("driverStore.uninstall") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ import { loadConnectionPickerView, saveConnectionPickerView, type DbPickerView }
|
||||
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
|
||||
import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses";
|
||||
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
|
||||
import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
|
||||
import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, requestAgentInstallCancellation, resolveAgentInstallOutcome, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
|
||||
import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
|
||||
import { normalizeNacosEndpoint, normalizeNacosMetricsUrl, parseNacosManagedNamespaces } from "@/lib/nacos/nacosAdmin";
|
||||
import { loadReadableNacosNamespaces, nacosNamespaceIdentity, normalizeNacosNamespaceSelection } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
@@ -259,6 +259,11 @@ const agentInstallDriverKey = ref("");
|
||||
const agentInstallLabel = ref("");
|
||||
const agentInstallProgress = ref<DriverInstallProgress | null>(null);
|
||||
const agentInstallError = ref("");
|
||||
const agentInstallCancelError = ref("");
|
||||
const agentInstallCancelling = ref(false);
|
||||
/** Set when the user cancels from the modal, so the pending promise's
|
||||
* "canceled by user" error is treated as a non-failure by its caller. */
|
||||
const agentInstallCancelRequested = ref(false);
|
||||
const showConnectionErrorDialog = ref(false);
|
||||
const connectionErrorRawDetail = ref("");
|
||||
const connectionErrorDetail = ref("");
|
||||
@@ -1890,31 +1895,67 @@ async function refreshLocalAgentDrivers(): Promise<AgentDriverInstallState[]> {
|
||||
return drivers;
|
||||
}
|
||||
|
||||
function beginAgentDriverInstall(driverKey: string, label: string) {
|
||||
function beginAgentDriverInstall(driverKey: string, label: string): string {
|
||||
agentInstallOperationId.value = uuid();
|
||||
agentInstallDriverKey.value = driverKey;
|
||||
agentInstallLabel.value = label;
|
||||
agentInstallProgress.value = null;
|
||||
agentInstallError.value = "";
|
||||
agentInstallCancelError.value = "";
|
||||
agentInstallCancelling.value = false;
|
||||
agentInstallCancelRequested.value = false;
|
||||
agentInstallRunning.value = true;
|
||||
showAgentInstallDialog.value = true;
|
||||
return agentInstallOperationId.value;
|
||||
}
|
||||
|
||||
function finishAgentDriverInstall() {
|
||||
/**
|
||||
* Clear the install dialog, unless a newer operation now owns it. Stale
|
||||
* operation promises (cancelled, then retried before settling) must not
|
||||
* finish/reset the retry's dialog state.
|
||||
*/
|
||||
function finishAgentDriverInstall(operationId?: string | null) {
|
||||
if (operationId !== undefined && operationId !== null && agentInstallOperationId.value !== operationId) return;
|
||||
agentInstallOperationId.value = null;
|
||||
agentInstallRunning.value = false;
|
||||
agentInstallProgress.value = null;
|
||||
agentInstallError.value = "";
|
||||
agentInstallCancelError.value = "";
|
||||
agentInstallCancelling.value = false;
|
||||
showAgentInstallDialog.value = false;
|
||||
}
|
||||
|
||||
function failAgentDriverInstall(error: unknown) {
|
||||
function failAgentDriverInstall(operationId: string | null | undefined, error: unknown) {
|
||||
if (operationId !== undefined && operationId !== null && agentInstallOperationId.value !== operationId) return;
|
||||
agentInstallOperationId.value = null;
|
||||
agentInstallRunning.value = false;
|
||||
agentInstallCancelError.value = "";
|
||||
agentInstallCancelling.value = false;
|
||||
agentInstallError.value = translateBackendError(t, error);
|
||||
showAgentInstallDialog.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-flight agent driver install from the modal's Cancel button.
|
||||
* The backend stops the download; the pending `installAgent` promise resolves
|
||||
* with a "canceled by user" error, which callers treat as a non-failure.
|
||||
*/
|
||||
async function cancelActiveAgentInstall() {
|
||||
const operationId = agentInstallOperationId.value;
|
||||
if (!agentInstallDriverKey.value || !operationId || agentInstallCancelling.value) return;
|
||||
agentInstallCancelling.value = true;
|
||||
agentInstallCancelError.value = "";
|
||||
const result = await requestAgentInstallCancellation(() => api.cancelAgentInstall(agentInstallDriverKey.value, operationId));
|
||||
if (agentInstallOperationId.value !== operationId) return;
|
||||
agentInstallCancelling.value = false;
|
||||
if (!result.ok) {
|
||||
agentInstallCancelError.value = translateBackendError(t, result.error);
|
||||
return;
|
||||
}
|
||||
agentInstallCancelRequested.value = true;
|
||||
finishAgentDriverInstall(operationId);
|
||||
}
|
||||
|
||||
function showConnectionError(message: string) {
|
||||
connectionErrorRawDetail.value = message;
|
||||
connectionErrorDetail.value = translateBackendError(t, message);
|
||||
@@ -1964,14 +2005,35 @@ async function ensureRequiredAgentDriverInstalled(config: ConnectionConfig): Pro
|
||||
|
||||
const label = config.driver_label || driverKey;
|
||||
testResult.value = { ok: true, message: `Installing ${label} driver...` };
|
||||
beginAgentDriverInstall(driverKey, label);
|
||||
const operationId = beginAgentDriverInstall(driverKey, label);
|
||||
try {
|
||||
await api.installAgent(driverKey, agentInstallOperationId.value ?? undefined);
|
||||
await api.installAgent(driverKey, operationId);
|
||||
await refreshLocalAgentDrivers();
|
||||
finishAgentDriverInstall();
|
||||
// A stale promise (cancelled then retried) must not close the retry's dialog.
|
||||
if (agentInstallOperationId.value === operationId) finishAgentDriverInstall(operationId);
|
||||
} catch (error) {
|
||||
const outcome = resolveAgentInstallOutcome(
|
||||
{ ok: false, error },
|
||||
{
|
||||
operationId,
|
||||
currentOperationId: agentInstallOperationId.value,
|
||||
cancelRequested: agentInstallCancelRequested.value,
|
||||
},
|
||||
);
|
||||
if (outcome.kind === "cancelled") {
|
||||
// User cancelled: close silently, do not surface a failure.
|
||||
if (outcome.ownsState) {
|
||||
testResult.value = null;
|
||||
finishAgentDriverInstall(operationId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!outcome.ownsState) {
|
||||
// A newer operation owns the dialog; leave its state untouched.
|
||||
return;
|
||||
}
|
||||
testResult.value = { ok: false, message: translateBackendError(t, error) };
|
||||
failAgentDriverInstall(error);
|
||||
failAgentDriverInstall(operationId, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2019,14 +2081,32 @@ async function installSqlServerLegacyCompatibilityComponentIfNeeded(): Promise<b
|
||||
if (await api.isAgentInstalled(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY)) return true;
|
||||
|
||||
const label = t("connection.sqlServerLegacyCompatibilityComponent");
|
||||
beginAgentDriverInstall(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, label);
|
||||
const operationId = beginAgentDriverInstall(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, label);
|
||||
try {
|
||||
await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, agentInstallOperationId.value ?? undefined);
|
||||
await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, operationId);
|
||||
await refreshLocalAgentDrivers();
|
||||
finishAgentDriverInstall();
|
||||
// A stale promise (cancelled then retried) must not close the retry's dialog.
|
||||
if (agentInstallOperationId.value === operationId) finishAgentDriverInstall(operationId);
|
||||
} catch (error) {
|
||||
const outcome = resolveAgentInstallOutcome(
|
||||
{ ok: false, error },
|
||||
{
|
||||
operationId,
|
||||
currentOperationId: agentInstallOperationId.value,
|
||||
cancelRequested: agentInstallCancelRequested.value,
|
||||
},
|
||||
);
|
||||
if (outcome.kind === "cancelled") {
|
||||
// User cancelled the download: the toggle's caller falls back to `auto`.
|
||||
if (outcome.ownsState) finishAgentDriverInstall(operationId);
|
||||
return false;
|
||||
}
|
||||
if (!outcome.ownsState) {
|
||||
// A newer operation owns the dialog; leave its state untouched.
|
||||
return false;
|
||||
}
|
||||
testResult.value = { ok: false, message: translateBackendError(t, error) };
|
||||
failAgentDriverInstall(error);
|
||||
failAgentDriverInstall(operationId, error);
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
@@ -2042,7 +2122,13 @@ async function setSqlServerDriverMode(mode: "auto" | "legacy") {
|
||||
}
|
||||
|
||||
try {
|
||||
await installSqlServerLegacyCompatibilityComponentIfNeeded();
|
||||
const installed = await installSqlServerLegacyCompatibilityComponentIfNeeded();
|
||||
if (!installed) {
|
||||
// User cancelled the download: never leave the form in legacy mode
|
||||
// without the component installed.
|
||||
setSqlServerLegacyCompatibilityConfig(form.value, false);
|
||||
return;
|
||||
}
|
||||
setSqlServerLegacyCompatibilityConfig(form.value, true);
|
||||
testResult.value = null;
|
||||
} catch {
|
||||
@@ -8535,6 +8621,9 @@ function openExternalUrl(url: string) {
|
||||
<div class="text-sm font-medium text-destructive">{{ t("connection.driverInstall.fullError") }}</div>
|
||||
<pre class="max-h-56 min-w-0 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-all [overflow-wrap:anywhere] rounded-md border bg-muted/30 p-3 text-xs leading-5 text-destructive">{{ agentInstallError }}</pre>
|
||||
</div>
|
||||
<div v-else-if="agentInstallCancelError" class="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{{ agentInstallCancelError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
@@ -8542,6 +8631,9 @@ function openExternalUrl(url: string) {
|
||||
<Copy class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("connection.copyError") }}
|
||||
</Button>
|
||||
<Button v-if="agentInstallRunning && !agentInstallError" variant="outline" :disabled="agentInstallCancelling" @click="cancelActiveAgentInstall">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
<Button :disabled="!canCloseAgentInstallDialog" @click="showAgentInstallDialog = false">
|
||||
{{ agentInstallError ? t("common.close") : t("connection.driverInstall.installingButton") }}
|
||||
</Button>
|
||||
|
||||
@@ -6445,10 +6445,14 @@ export default {
|
||||
chooseJavaExecutable: "Choose Java executable",
|
||||
driverInstallSuccess: "{label} driver installed",
|
||||
driverInstallFailed: "Failed to install {label} driver: {error}",
|
||||
driverInstallCancelled: "{label} driver install cancelled",
|
||||
driverUpdateBlocked: "Close these database connections before updating drivers: {labels}",
|
||||
upgradeAllSuccess: "{count} driver(s) upgraded",
|
||||
upgradeAllPartial: "{count} driver(s) upgraded. Skipped: {failed}",
|
||||
upgradeAllFailed: "Batch upgrade failed: {error}",
|
||||
upgradeAllCancelled: "Upgrade cancelled ({count} driver(s) aborted)",
|
||||
cancelInstall: "Cancel install",
|
||||
cancelUpgradeAll: "Cancel batch upgrade",
|
||||
driverUninstallSuccess: "{label} driver uninstalled",
|
||||
driverUninstallFailed: "Failed to uninstall {label} driver: {error}",
|
||||
chooseOfflineDriverPackage: "Choose offline driver package",
|
||||
|
||||
@@ -6267,6 +6267,10 @@ export default withEnglishFallback({
|
||||
driverStoreDirMigrationFailed: "Error en la migración de controladores: {error}",
|
||||
driverStoreDirDialogTitle: "Seleccionar {target}",
|
||||
driverStoreDirSuccess: "{target} cambiado, reiniciando…",
|
||||
driverInstallCancelled: "Instalación del controlador {label} cancelada",
|
||||
upgradeAllCancelled: "Actualización masiva cancelada ({count} controladores interrumpidos)",
|
||||
cancelInstall: "Cancelar instalación",
|
||||
cancelUpgradeAll: "Cancelar actualización masiva",
|
||||
},
|
||||
databaseExport: {
|
||||
title: "Exportar base de datos",
|
||||
|
||||
@@ -6267,6 +6267,10 @@ export default withEnglishFallback({
|
||||
driverStoreDirMigrationFailed: "Migrazione driver fallita: {error}",
|
||||
driverStoreDirDialogTitle: "Seleziona {target}",
|
||||
driverStoreDirSuccess: "{target} cambiato, riavvio in corso…",
|
||||
driverInstallCancelled: "{label} installazione del driver annullata",
|
||||
upgradeAllCancelled: "Aggiornamento batch annullato ({count} driver interrotti)",
|
||||
cancelInstall: "Annulla installazione",
|
||||
cancelUpgradeAll: "Annulla aggiornamento batch",
|
||||
},
|
||||
databaseExport: {
|
||||
title: "Esporta Database",
|
||||
|
||||
@@ -6320,6 +6320,10 @@ export default withEnglishFallback({
|
||||
driverStoreDirMigrationFailed: "ドライバーストアの移行に失敗しました: {error}",
|
||||
driverStoreDirDialogTitle: "{target}を選択",
|
||||
driverStoreDirSuccess: "{target}を変更しました。再起動中…",
|
||||
driverInstallCancelled: "{label} ドライバーのインストールはキャンセルされました",
|
||||
upgradeAllCancelled: "一括アップグレードはキャンセルされました({count} 個のドライバーが中止されました)",
|
||||
cancelInstall: "インストールをキャンセル",
|
||||
cancelUpgradeAll: "一括アップグレードをキャンセル",
|
||||
},
|
||||
databaseExport: {
|
||||
runInBackground: "バックグラウンドで実行",
|
||||
|
||||
@@ -6269,6 +6269,10 @@ export default withEnglishFallback({
|
||||
driverStoreDirMigrationFailed: "Falha na migração de drivers: {error}",
|
||||
driverStoreDirDialogTitle: "Selecionar {target}",
|
||||
driverStoreDirSuccess: "{target} alterado, reiniciando…",
|
||||
driverInstallCancelled: "{label} instalação do driver cancelada",
|
||||
upgradeAllCancelled: "Atualização em lote cancelada ({count} drivers interrompidos)",
|
||||
cancelInstall: "Cancelar instalação",
|
||||
cancelUpgradeAll: "Cancelar atualização em lote",
|
||||
},
|
||||
databaseExport: {
|
||||
title: "Exportar banco de dados",
|
||||
|
||||
@@ -6430,10 +6430,14 @@ export default withEnglishFallback({
|
||||
chooseJavaExecutable: "选择 Java 可执行文件",
|
||||
driverInstallSuccess: "{label} 驱动安装成功",
|
||||
driverInstallFailed: "{label} 驱动安装失败: {error}",
|
||||
driverInstallCancelled: "{label} 驱动安装已取消",
|
||||
driverUpdateBlocked: "请先关闭以下数据库连接后再更新驱动: {labels}",
|
||||
upgradeAllSuccess: "{count} 个驱动升级完成",
|
||||
upgradeAllPartial: "{count} 个驱动升级完成,以下驱动已跳过: {failed}",
|
||||
upgradeAllFailed: "批量升级失败: {error}",
|
||||
upgradeAllCancelled: "批量升级已取消({count} 个驱动中止)",
|
||||
cancelInstall: "取消安装",
|
||||
cancelUpgradeAll: "取消批量升级",
|
||||
driverUninstallSuccess: "{label} 驱动已卸载",
|
||||
driverUninstallFailed: "{label} 驱动卸载失败: {error}",
|
||||
chooseOfflineDriverPackage: "选择离线驱动包",
|
||||
|
||||
@@ -5591,6 +5591,10 @@ export default withEnglishFallback({
|
||||
jdbcSourceMaven: "Maven",
|
||||
jdbcSourceManual: "手動",
|
||||
noMatchingDrivers: "沒有相符的驅動程式。",
|
||||
driverInstallCancelled: "{label} 驅動安裝已取消",
|
||||
upgradeAllCancelled: "批次升級已取消({count} 個驅動中止)",
|
||||
cancelInstall: "取消安裝",
|
||||
cancelUpgradeAll: "取消批次升級",
|
||||
},
|
||||
databaseExport: {
|
||||
title: "匯出資料庫",
|
||||
|
||||
@@ -106,6 +106,8 @@ export async function upgradeAllAgents(operationId?: string) {
|
||||
const backend = await getBackend();
|
||||
return backend.upgradeAllAgents(useSettingsStore().editorSettings.updateDownloadSource, operationId);
|
||||
}
|
||||
export const cancelAgentInstall = forward("cancelAgentInstall");
|
||||
export const cancelAgentUpgradeAll = forward("cancelAgentUpgradeAll");
|
||||
export const checkAgentUpdateBlockers = forward("checkAgentUpdateBlockers");
|
||||
export const uninstallAgent = forward("uninstallAgent");
|
||||
export const getAgentJavaRuntimeConfig = forward("getAgentJavaRuntimeConfig");
|
||||
|
||||
@@ -582,6 +582,14 @@ export async function upgradeAllAgents(_source?: UpdateDownloadSource, operation
|
||||
return post("/api/agents/upgrade-all", { operationId });
|
||||
}
|
||||
|
||||
export async function cancelAgentInstall(dbType: string, operationId?: string): Promise<void> {
|
||||
await post("/api/agents/cancel-install", { dbType, operationId });
|
||||
}
|
||||
|
||||
export async function cancelAgentUpgradeAll(operationId?: string): Promise<void> {
|
||||
await post("/api/agents/cancel-upgrade-all", { operationId });
|
||||
}
|
||||
|
||||
export async function checkAgentUpdateBlockers(dbTypes: string[]): Promise<AgentUpdateBlocker[]> {
|
||||
return post("/api/agents/update-blockers", { dbTypes });
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ export interface AgentDriverUpdateIssue {
|
||||
|
||||
export interface UpgradeAllAgentDriversResult {
|
||||
upgraded: number;
|
||||
/** Drivers whose install was aborted by a user cancel (single-driver or batch). */
|
||||
cancelled: number;
|
||||
failed: AgentDriverUpdateIssue[];
|
||||
}
|
||||
|
||||
@@ -1915,6 +1917,14 @@ export async function upgradeAllAgents(source?: UpdateDownloadSource, operationI
|
||||
return invoke("upgrade_all_agents", { source, operationId });
|
||||
}
|
||||
|
||||
export async function cancelAgentInstall(dbType: string, operationId?: string): Promise<void> {
|
||||
return invoke("cancel_agent_install", { dbType, operationId });
|
||||
}
|
||||
|
||||
export async function cancelAgentUpgradeAll(operationId?: string): Promise<void> {
|
||||
return invoke("cancel_agent_upgrade_all", { operationId });
|
||||
}
|
||||
|
||||
export async function checkAgentUpdateBlockers(dbTypes: string[]): Promise<AgentUpdateBlocker[]> {
|
||||
return invoke("check_agent_update_blockers", { dbTypes });
|
||||
}
|
||||
|
||||
@@ -16,16 +16,74 @@ export interface DriverInstallProgressTargetState {
|
||||
progressMap: Record<string, DriverInstallProgress | null | undefined>;
|
||||
}
|
||||
|
||||
export interface DriverInstallCancellationTargetState {
|
||||
activeOperationId: string | null;
|
||||
cancellableDbType: string | null;
|
||||
upgradingAll: boolean;
|
||||
progressMap: Record<string, DriverInstallProgress | null | undefined>;
|
||||
}
|
||||
|
||||
export type DriverInstallProgressChannel = "agent" | "jdbc-plugin";
|
||||
|
||||
const AGENT_PROGRESS_STEPS = new Set(["driver", "jre", "jre-extract", "all-done"]);
|
||||
|
||||
/** Substring the backend uses for a user-cancelled driver install/upgrade. */
|
||||
const AGENT_DOWNLOAD_CANCELED_ERROR = "Agent download canceled by user.";
|
||||
|
||||
/**
|
||||
* True when a driver-install error is a user cancel rather than a genuine
|
||||
* failure. Callers use this to close install modals silently and to avoid
|
||||
* reporting a cancelled driver/upgrade as failed.
|
||||
*/
|
||||
export function isDriverInstallCanceledError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String((error as { message?: string } | null)?.message ?? error ?? "");
|
||||
return message.includes(AGENT_DOWNLOAD_CANCELED_ERROR);
|
||||
}
|
||||
|
||||
export function isDriverInstallProgressForOperation(progress: DriverInstallProgress, operationId: string | null): boolean {
|
||||
// Keep accepting legacy unscoped events, while preventing another active
|
||||
// install's terminal/progress event from mutating this dialog.
|
||||
return !progress.operation_id || progress.operation_id === operationId;
|
||||
}
|
||||
|
||||
/** How an agent install promise settled relative to the dialog's state. */
|
||||
export interface AgentInstallOperationContext {
|
||||
/** Operation id captured when the install promise was created. */
|
||||
operationId: string;
|
||||
/** Operation id the dialog is currently tracking (null once finished). */
|
||||
currentOperationId: string | null;
|
||||
/** Whether the user pressed Cancel on the dialog's tracked operation. */
|
||||
cancelRequested: boolean;
|
||||
}
|
||||
|
||||
export type AgentInstallOutcome = { kind: "succeeded"; ownsState: boolean } | { kind: "cancelled"; ownsState: boolean } | { kind: "failed"; ownsState: boolean };
|
||||
|
||||
export type AgentInstallCancelRequestResult = { ok: true } | { ok: false; error: unknown };
|
||||
|
||||
/**
|
||||
* Classify the settlement of an agent install operation promise. When the
|
||||
* dialog no longer tracks the promise's operation id (the user cancelled and
|
||||
* immediately retried, so a newer operation owns the state), `ownsState` is
|
||||
* false and the stale promise must not finish/fail the new operation's UI.
|
||||
*/
|
||||
export function resolveAgentInstallOutcome(result: { ok: true } | { ok: false; error: unknown }, context: AgentInstallOperationContext): AgentInstallOutcome {
|
||||
const ownsState = context.currentOperationId === context.operationId;
|
||||
if (result.ok) return { kind: "succeeded", ownsState };
|
||||
if (isDriverInstallCanceledError(result.error) || context.cancelRequested) {
|
||||
return { kind: "cancelled", ownsState };
|
||||
}
|
||||
return { kind: "failed", ownsState };
|
||||
}
|
||||
|
||||
export async function requestAgentInstallCancellation(request: () => Promise<void>): Promise<AgentInstallCancelRequestResult> {
|
||||
try {
|
||||
await request();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
export function driverInstallProgressChannel(progress: DriverInstallProgress): DriverInstallProgressChannel | null {
|
||||
if (progress.step === "jdbc-plugin" || progress.step === "jdbc-plugin-extract") return "jdbc-plugin";
|
||||
if (progress.db_type || AGENT_PROGRESS_STEPS.has(progress.step)) return "agent";
|
||||
@@ -73,6 +131,13 @@ export function isDriverInstallProgressTarget(dbType: string, state: DriverInsta
|
||||
return progress !== null && progress !== undefined;
|
||||
}
|
||||
|
||||
export function isDriverInstallCancellationTarget(dbType: string, state: DriverInstallCancellationTargetState): boolean {
|
||||
if (!state.activeOperationId) return false;
|
||||
if (!state.upgradingAll) return state.cancellableDbType === dbType;
|
||||
const progress = state.progressMap[dbType];
|
||||
return progress !== null && progress !== undefined;
|
||||
}
|
||||
|
||||
export function addDriverInstallQueue(queue: string[], dbType: string, activeDbType: string | null): string[] {
|
||||
if (activeDbType === dbType || queue.includes(dbType)) return queue;
|
||||
return [...queue, dbType];
|
||||
|
||||
@@ -16,6 +16,43 @@ pub const DEFAULT_JRE_KEY: &str = "21";
|
||||
pub const DOWNLOAD_CACHE_DIR_NAME: &str = "download-cache";
|
||||
pub const DOWNLOAD_CACHE_MAX_AGE_DAYS: u64 = 7;
|
||||
|
||||
/// Cooperative cancellation token for an agent driver install/upgrade.
|
||||
///
|
||||
/// Mirrors the updater's `DownloadCancellation` (src-tauri/src/commands/update.rs)
|
||||
/// so the same `watch<bool>` pattern can abort an in-flight JRE/driver download.
|
||||
#[derive(Debug)]
|
||||
pub struct AgentInstallCancellation {
|
||||
canceled: tokio::sync::watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl AgentInstallCancellation {
|
||||
fn new() -> Self {
|
||||
let (canceled, _) = tokio::sync::watch::channel(false);
|
||||
Self { canceled }
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
let _ = self.canceled.send_replace(true);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
*self.canceled.borrow()
|
||||
}
|
||||
|
||||
/// Completes once cancellation is requested (immediately if already cancelled).
|
||||
pub async fn cancelled(&self) {
|
||||
let mut receiver = self.canceled.subscribe();
|
||||
if *receiver.borrow_and_update() {
|
||||
return;
|
||||
}
|
||||
while receiver.changed().await.is_ok() {
|
||||
if *receiver.borrow_and_update() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_jre_key() -> String {
|
||||
DEFAULT_JRE_KEY.to_string()
|
||||
}
|
||||
@@ -511,6 +548,11 @@ pub struct AgentManager {
|
||||
/// Driver operations may run concurrently, but JRE replacement/removal
|
||||
/// must exclude them until their dependent driver state is persisted.
|
||||
pub(crate) installation_operation_lock: tokio::sync::RwLock<()>,
|
||||
/// Per-operation cancellation tokens keyed by operation id (single installs
|
||||
/// use `install:<op>`, batches `batch:<op>` and `batch:<op>:<db>`). Downloads
|
||||
/// observe the exact token threaded to them to abort promptly.
|
||||
pub(crate) install_cancellations:
|
||||
tokio::sync::Mutex<std::collections::HashMap<String, Arc<AgentInstallCancellation>>>,
|
||||
}
|
||||
|
||||
impl Default for AgentManager {
|
||||
@@ -540,6 +582,7 @@ impl AgentManager {
|
||||
jre_install_locks: Mutex::new(std::collections::HashMap::new()),
|
||||
driver_operation_locks: Mutex::new(std::collections::HashMap::new()),
|
||||
installation_operation_lock: tokio::sync::RwLock::new(()),
|
||||
install_cancellations: Mutex::new(std::collections::HashMap::new()),
|
||||
};
|
||||
mgr.migrate_legacy_jre();
|
||||
mgr.cleanup_pending_jre_dirs();
|
||||
@@ -560,6 +603,39 @@ impl AgentManager {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Register a fresh cancellation token for an operation keyed by `key` (an
|
||||
/// operation-scoped key; see the `agent_service` key helpers). Any existing
|
||||
/// token for the same key is replaced; each install registers under a unique
|
||||
/// operation id, so a stale cancelled token cannot leak into another
|
||||
/// operation, and concurrent same-driver installs stay isolated.
|
||||
pub async fn begin_install_cancellation(&self, key: &str) -> Arc<AgentInstallCancellation> {
|
||||
let token = Arc::new(AgentInstallCancellation::new());
|
||||
self.install_cancellations.lock().await.insert(key.to_string(), Arc::clone(&token));
|
||||
token
|
||||
}
|
||||
|
||||
/// Remove the token for `key` only if it is still `token` (guarded by
|
||||
/// `Arc::ptr_eq`) so a concurrently registered fresh token is not removed.
|
||||
pub async fn finish_install_cancellation(&self, key: &str, token: &Arc<AgentInstallCancellation>) {
|
||||
let mut cancellations = self.install_cancellations.lock().await;
|
||||
if cancellations.get(key).is_some_and(|current| Arc::ptr_eq(current, token)) {
|
||||
cancellations.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Request cancellation for the operation registered under `key`.
|
||||
pub async fn cancel_install(&self, key: &str) {
|
||||
if let Some(token) = self.install_cancellations.lock().await.get(key).cloned() {
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the operation registered under `key` (an operation-scoped key,
|
||||
/// see `agent_service` key helpers) has been cancelled.
|
||||
pub async fn is_install_cancelled(&self, key: &str) -> bool {
|
||||
self.install_cancellations.lock().await.get(key).is_some_and(|token| token.is_cancelled())
|
||||
}
|
||||
|
||||
fn migrate_legacy_jre(&self) {
|
||||
let legacy = self.base_dir.join("jre");
|
||||
let versioned = self.jre_dir(DEFAULT_JRE_KEY);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -378,7 +378,9 @@ async fn main() {
|
||||
.route("/agents/runtime/stop", post(routes::agents::stop_driver_runtime))
|
||||
.route("/agents/runtime/restart", post(routes::agents::restart_driver_runtime))
|
||||
.route("/agents/install", post(routes::agents::install_agent))
|
||||
.route("/agents/cancel-install", post(routes::agents::cancel_install))
|
||||
.route("/agents/upgrade-all", post(routes::agents::upgrade_all_agents))
|
||||
.route("/agents/cancel-upgrade-all", post(routes::agents::cancel_upgrade_all))
|
||||
.route("/agents/update-blockers", post(routes::agents::check_agent_update_blockers))
|
||||
.route("/agents/uninstall", post(routes::agents::uninstall_agent))
|
||||
.route("/agents/import-offline", post(routes::agents::import_agents_from_zip))
|
||||
|
||||
@@ -7,10 +7,12 @@ use dbx_core::agent_manager::{
|
||||
AgentDriverInfo, AgentState, DriverStoreUsage, JavaRuntimeConfig, JavaRuntimeMode, DEFAULT_JRE_KEY,
|
||||
};
|
||||
use dbx_core::agent_service::{
|
||||
build_agent_list, clear_agent_download_cache, fetch_registry, import_agent_driver,
|
||||
import_agents_from_package as import_agents_from_package_core, inspect_offline_package, install_agent_driver,
|
||||
invalidate_registry_cache, reinstall_agent_jre, uninstall_agent_driver, uninstall_agent_jre,
|
||||
upgrade_all_agent_drivers, AgentProgressEvent, OfflineImportPlan,
|
||||
batch_cancellation_key, build_agent_list, cancel_agent_batch_upgrade, cancel_agent_driver_install,
|
||||
clear_agent_download_cache, fetch_registry, fetch_registry_from_claimed, import_agent_driver,
|
||||
import_agents_from_package as import_agents_from_package_core, inspect_offline_package,
|
||||
install_agent_driver_claimed, install_cancellation_key, invalidate_registry_cache, reinstall_agent_jre,
|
||||
uninstall_agent_driver, uninstall_agent_jre, upgrade_all_agent_drivers_claimed, AgentProgressEvent,
|
||||
OfflineImportPlan,
|
||||
};
|
||||
use dbx_core::driver_runtime::DriverRuntimeSummary;
|
||||
use futures::Stream;
|
||||
@@ -120,34 +122,86 @@ pub async fn install_agent(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<AgentTypeRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&req.db_type)).await.map_err(AppError::from)?;
|
||||
let operation_id = req.operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let tx = progress_sender(&state, "global").await;
|
||||
install_agent_driver(&state.app.agent_manager, &req.db_type, |event| {
|
||||
send_progress_event(&tx, event.with_operation_id(&operation_id))
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
// Resolve the operation id first, then register the cancellation token under
|
||||
// it BEFORE any awaitable setup (blocker check, lock wait, registry fetch)
|
||||
// so a cancel fired while the modal is showing is observed by this exact
|
||||
// install instead of being silently lost or crossing into a second
|
||||
// same-driver install.
|
||||
let operation_id = req.operation_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let cancellation =
|
||||
state.app.agent_manager.begin_install_cancellation(&install_cancellation_key(&operation_id)).await;
|
||||
let result = async {
|
||||
ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&req.db_type))
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let tx = progress_sender(&state, "global").await;
|
||||
install_agent_driver_claimed(
|
||||
&state.app.agent_manager,
|
||||
&req.db_type,
|
||||
|event| send_progress_event(&tx, event.with_operation_id(&operation_id)),
|
||||
&cancellation,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
.await;
|
||||
state.app.agent_manager.finish_install_cancellation(&install_cancellation_key(&operation_id), &cancellation).await;
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn upgrade_all_agents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<AgentOperationRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let registry = fetch_registry().await.map_err(AppError::from)?;
|
||||
let agents = build_agent_list(&state.app.agent_manager, Some(®istry));
|
||||
let updatable: Vec<String> =
|
||||
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
|
||||
ensure_no_agent_update_blockers(&state.app, &updatable).await.map_err(AppError::from)?;
|
||||
let operation_id = req.operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let tx = progress_sender(&state, "global").await;
|
||||
let result = upgrade_all_agent_drivers(&state.app.agent_manager, |event| {
|
||||
send_progress_event(&tx, event.with_operation_id(&operation_id))
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|err| AppError::from(err.to_string()))?))
|
||||
// Resolve the batch operation id first, then register the batch token under
|
||||
// it BEFORE the registry fetch + blocker check so a cancel fired while the
|
||||
// batch is still setting up aborts it.
|
||||
let operation_id = req.operation_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let cancellation = state.app.agent_manager.begin_install_cancellation(&batch_cancellation_key(&operation_id)).await;
|
||||
let result = async {
|
||||
// The batch token is registered above; race the pre-blocker registry
|
||||
// fetch against it so a cancel fired during batch setup aborts promptly
|
||||
// instead of waiting out the 10s client timeout.
|
||||
let registry = fetch_registry_from_claimed(dbx_core::DownloadSource::Official, &[cancellation.as_ref()])
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let agents = build_agent_list(&state.app.agent_manager, Some(®istry));
|
||||
let updatable: Vec<String> =
|
||||
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
|
||||
ensure_no_agent_update_blockers(&state.app, &updatable).await.map_err(AppError::from)?;
|
||||
let tx = progress_sender(&state, "global").await;
|
||||
let result = upgrade_all_agent_drivers_claimed(
|
||||
&state.app.agent_manager,
|
||||
|event| send_progress_event(&tx, event.with_operation_id(&operation_id)),
|
||||
&cancellation,
|
||||
&operation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|err| AppError::from(err.to_string()))?))
|
||||
}
|
||||
.await;
|
||||
state.app.agent_manager.finish_install_cancellation(&batch_cancellation_key(&operation_id), &cancellation).await;
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn cancel_install(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<AgentTypeRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
cancel_agent_driver_install(&state.app.agent_manager, &req.db_type, req.operation_id.as_deref())
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn cancel_upgrade_all(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<AgentOperationRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
cancel_agent_batch_upgrade(&state.app.agent_manager, req.operation_id.as_deref()).await.map_err(AppError::from)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn check_agent_update_blockers(
|
||||
|
||||
+141
-1
@@ -1,7 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import { addDriverInstallQueue, driverInstallProgressPercent, isDriverInstallProgressTarget, removeDriverInstallQueue, takeNextDriverInstallQueue } from "../../apps/desktop/src/lib/connection/driverInstallProgressUi.ts";
|
||||
import {
|
||||
addDriverInstallQueue,
|
||||
driverInstallProgressPercent,
|
||||
isDriverInstallCanceledError,
|
||||
isDriverInstallCancellationTarget,
|
||||
isDriverInstallProgressTarget,
|
||||
removeDriverInstallQueue,
|
||||
requestAgentInstallCancellation,
|
||||
resolveAgentInstallOutcome,
|
||||
takeNextDriverInstallQueue,
|
||||
} from "../../apps/desktop/src/lib/connection/driverInstallProgressUi.ts";
|
||||
|
||||
test("formats driver install progress as a bounded whole percent", () => {
|
||||
assert.equal(driverInstallProgressPercent({ step: "driver", downloaded: 3_900_000, total: 10_500_000 }), 37);
|
||||
@@ -28,6 +38,57 @@ test("targets only the row currently installing or upgrading", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("shows single-driver cancel only after the cancellable agent operation starts", () => {
|
||||
assert.equal(
|
||||
isDriverInstallCancellationTarget("mysql", {
|
||||
activeOperationId: null,
|
||||
cancellableDbType: null,
|
||||
upgradingAll: false,
|
||||
progressMap: {},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isDriverInstallCancellationTarget("mysql", {
|
||||
activeOperationId: "operation-a",
|
||||
cancellableDbType: "mysql",
|
||||
upgradingAll: false,
|
||||
progressMap: {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isDriverInstallCancellationTarget("postgres", {
|
||||
activeOperationId: "operation-a",
|
||||
cancellableDbType: "mysql",
|
||||
upgradingAll: false,
|
||||
progressMap: {},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps batch row cancellation scoped to registered progress entries", () => {
|
||||
assert.equal(
|
||||
isDriverInstallCancellationTarget("postgres", {
|
||||
activeOperationId: "batch-a",
|
||||
cancellableDbType: null,
|
||||
upgradingAll: true,
|
||||
progressMap: { postgres: { step: "driver", db_type: "postgres" } },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isDriverInstallCancellationTarget("mysql", {
|
||||
activeOperationId: "batch-a",
|
||||
cancellableDbType: null,
|
||||
upgradingAll: true,
|
||||
progressMap: { postgres: { step: "driver", db_type: "postgres" } },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("adds queued driver installs without duplicating the active or queued driver", () => {
|
||||
assert.deepEqual(addDriverInstallQueue(["postgres"], "mysql", "sqlite"), ["postgres", "mysql"]);
|
||||
assert.deepEqual(addDriverInstallQueue(["postgres"], "postgres", "sqlite"), ["postgres"]);
|
||||
@@ -44,3 +105,82 @@ test("takes the next installable queued driver and drops stale queued drivers",
|
||||
assert.equal(result.next, "mysql");
|
||||
assert.deepEqual(result.queue, ["sqlite"]);
|
||||
});
|
||||
|
||||
test("recognizes a user-cancelled driver install error", () => {
|
||||
assert.equal(isDriverInstallCanceledError(new Error("Agent download canceled by user.")), true);
|
||||
assert.equal(isDriverInstallCanceledError("prefix Agent download canceled by user. suffix"), true);
|
||||
assert.equal(isDriverInstallCanceledError({ message: "Agent download canceled by user." }), true);
|
||||
assert.equal(isDriverInstallCanceledError("Failed to install driver: 404"), false);
|
||||
assert.equal(isDriverInstallCanceledError(null), false);
|
||||
assert.equal(isDriverInstallCanceledError(undefined), false);
|
||||
});
|
||||
|
||||
test("a failed cancel request keeps the install outcome as a real failure", async () => {
|
||||
const result = await requestAgentInstallCancellation(async () => {
|
||||
throw new Error("cancel transport failed");
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) assert.fail("cancel request must fail");
|
||||
assert.match(String(result.error), /cancel transport failed/);
|
||||
assert.deepEqual(
|
||||
resolveAgentInstallOutcome(
|
||||
{ ok: false, error: new Error("download failed") },
|
||||
{
|
||||
operationId: "operation-a",
|
||||
currentOperationId: "operation-a",
|
||||
cancelRequested: result.ok,
|
||||
},
|
||||
),
|
||||
{ kind: "failed", ownsState: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("cancel then immediate retry: the stale operation never owns the dialog state", () => {
|
||||
// The dialog flow: operation A begins, the user cancels (the cancel handler
|
||||
// finishes the dialog, clearing the tracked id), then retries - operation B
|
||||
// begins - before A's install promise settles.
|
||||
const staleContext = {
|
||||
operationId: "operation-a",
|
||||
currentOperationId: "operation-b",
|
||||
cancelRequested: false,
|
||||
};
|
||||
|
||||
const cancelled = resolveAgentInstallOutcome({ ok: false, error: new Error("Agent download canceled by user.") }, staleContext);
|
||||
assert.equal(cancelled.kind, "cancelled");
|
||||
assert.equal(cancelled.ownsState, false);
|
||||
|
||||
const succeeded = resolveAgentInstallOutcome({ ok: true }, staleContext);
|
||||
assert.equal(succeeded.kind, "succeeded");
|
||||
assert.equal(succeeded.ownsState, false);
|
||||
|
||||
const failed = resolveAgentInstallOutcome({ ok: false, error: new Error("Download failed: 404") }, staleContext);
|
||||
assert.equal(failed.kind, "failed");
|
||||
assert.equal(failed.ownsState, false);
|
||||
});
|
||||
|
||||
test("cancel without retry: the cleared dialog is not re-failed by the old promise", () => {
|
||||
// After the cancel handler ran, no operation is tracked, so the old
|
||||
// promise's settlement must not own state either.
|
||||
const clearedContext = {
|
||||
operationId: "operation-a",
|
||||
currentOperationId: null,
|
||||
cancelRequested: true,
|
||||
};
|
||||
|
||||
const cancelled = resolveAgentInstallOutcome({ ok: false, error: new Error("Download failed: 404") }, clearedContext);
|
||||
assert.equal(cancelled.kind, "cancelled");
|
||||
assert.equal(cancelled.ownsState, false);
|
||||
});
|
||||
|
||||
test("the active operation owns the dialog state on every outcome", () => {
|
||||
const context = {
|
||||
operationId: "operation-a",
|
||||
currentOperationId: "operation-a",
|
||||
cancelRequested: false,
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveAgentInstallOutcome({ ok: true }, context), { kind: "succeeded", ownsState: true });
|
||||
assert.deepEqual(resolveAgentInstallOutcome({ ok: false, error: new Error("Agent download canceled by user.") }, context), { kind: "cancelled", ownsState: true });
|
||||
assert.deepEqual(resolveAgentInstallOutcome({ ok: false, error: new Error("Download failed: 404") }, context), { kind: "failed", ownsState: true });
|
||||
});
|
||||
|
||||
@@ -4,10 +4,12 @@ use tauri::{Emitter, State};
|
||||
|
||||
use dbx_core::agent_manager::{AgentDriverInfo, DriverStoreUsage, JavaRuntimeConfig, JavaRuntimeMode, DEFAULT_JRE_KEY};
|
||||
use dbx_core::agent_service::{
|
||||
build_agent_list, clear_agent_download_cache, fetch_registry_from, import_agent_driver,
|
||||
import_agents_from_package as import_agents_from_package_core, inspect_offline_package, install_agent_driver_from,
|
||||
invalidate_registry_cache, reinstall_agent_jre_from, uninstall_agent_driver, uninstall_agent_jre,
|
||||
upgrade_all_agent_drivers_from, AgentProgressEvent, OfflineImportPlan, UpgradeAllAgentDriversResult,
|
||||
batch_cancellation_key, build_agent_list, cancel_agent_batch_upgrade, cancel_agent_driver_install,
|
||||
clear_agent_download_cache, fetch_registry_from, fetch_registry_from_claimed, import_agent_driver,
|
||||
import_agents_from_package as import_agents_from_package_core, inspect_offline_package,
|
||||
install_agent_driver_from_claimed, install_cancellation_key, invalidate_registry_cache, reinstall_agent_jre_from,
|
||||
uninstall_agent_driver, uninstall_agent_jre, upgrade_all_agent_drivers_from_claimed, AgentProgressEvent,
|
||||
OfflineImportPlan, UpgradeAllAgentDriversResult,
|
||||
};
|
||||
use dbx_core::connection::AppState;
|
||||
use dbx_core::driver_runtime::DriverRuntimeSummary;
|
||||
@@ -71,13 +73,29 @@ pub async fn install_agent(
|
||||
source: Option<DownloadSource>,
|
||||
operation_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?;
|
||||
let app_handle = app.clone();
|
||||
// Resolve the operation id first, then register the cancellation token
|
||||
// under it BEFORE any awaitable setup (blocker check, lock wait, registry
|
||||
// fetch) so a cancel fired while the UI shows the modal is observed by this
|
||||
// exact install instead of being silently lost or crossing into a second
|
||||
// same-driver install.
|
||||
let operation_id = operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
install_agent_driver_from(&state.agent_manager, &db_type, source.unwrap_or_default(), move |event| {
|
||||
emit_agent_progress(&app_handle, &operation_id, event)
|
||||
})
|
||||
.await
|
||||
let cancellation = state.agent_manager.begin_install_cancellation(&install_cancellation_key(&operation_id)).await;
|
||||
let result = async {
|
||||
ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?;
|
||||
let app_handle = app.clone();
|
||||
let progress_operation_id = operation_id.clone();
|
||||
install_agent_driver_from_claimed(
|
||||
&state.agent_manager,
|
||||
&db_type,
|
||||
source.unwrap_or_default(),
|
||||
move |event| emit_agent_progress(&app_handle, &progress_operation_id, event),
|
||||
&cancellation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
state.agent_manager.finish_install_cancellation(&install_cancellation_key(&operation_id), &cancellation).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -88,17 +106,48 @@ pub async fn upgrade_all_agents(
|
||||
operation_id: Option<String>,
|
||||
) -> Result<UpgradeAllAgentDriversResult, String> {
|
||||
let source = source.unwrap_or_default();
|
||||
let registry = fetch_registry_from(source).await?;
|
||||
let agents = build_agent_list(&state.agent_manager, Some(®istry));
|
||||
let updatable: Vec<String> =
|
||||
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
|
||||
ensure_no_agent_update_blockers(state.inner().as_ref(), &updatable).await?;
|
||||
let app_handle = app.clone();
|
||||
// Resolve the batch operation id first, then register the batch token under
|
||||
// it BEFORE the registry fetch + blocker check so a cancel fired while the
|
||||
// batch is still setting up aborts it instead of being lost.
|
||||
let operation_id = operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
upgrade_all_agent_drivers_from(&state.agent_manager, source, move |event| {
|
||||
emit_agent_progress(&app_handle, &operation_id, event)
|
||||
})
|
||||
.await
|
||||
let cancellation = state.agent_manager.begin_install_cancellation(&batch_cancellation_key(&operation_id)).await;
|
||||
let result = async {
|
||||
let registry = fetch_registry_from_claimed(source, &[cancellation.as_ref()]).await?;
|
||||
let agents = build_agent_list(&state.agent_manager, Some(®istry));
|
||||
let updatable: Vec<String> =
|
||||
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
|
||||
ensure_no_agent_update_blockers(state.inner().as_ref(), &updatable).await?;
|
||||
let app_handle = app.clone();
|
||||
let progress_operation_id = operation_id.clone();
|
||||
upgrade_all_agent_drivers_from_claimed(
|
||||
&state.agent_manager,
|
||||
source,
|
||||
move |event| emit_agent_progress(&app_handle, &progress_operation_id, event),
|
||||
&cancellation,
|
||||
&operation_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
state.agent_manager.finish_install_cancellation(&batch_cancellation_key(&operation_id), &cancellation).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_agent_install(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
db_type: String,
|
||||
operation_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
cancel_agent_driver_install(&state.agent_manager, &db_type, operation_id.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_agent_upgrade_all(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
operation_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
cancel_agent_batch_upgrade(&state.agent_manager, operation_id.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -2256,7 +2256,9 @@ pub fn run() {
|
||||
commands::agents::stop_driver_runtime,
|
||||
commands::agents::restart_driver_runtime,
|
||||
commands::agents::install_agent,
|
||||
commands::agents::cancel_agent_install,
|
||||
commands::agents::upgrade_all_agents,
|
||||
commands::agents::cancel_agent_upgrade_all,
|
||||
commands::agents::check_agent_update_blockers,
|
||||
commands::agents::uninstall_agent,
|
||||
commands::agents::check_jre_installed,
|
||||
|
||||
Reference in New Issue
Block a user