feat(agent): export managed drivers for offline use

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-08-22 15:41:21 +08:00
1 parent 6eb8b43701
commit 82e5756325
23 files changed
+3419 -40

No files matched your search

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { FileArchive, Loader2, ShieldCheck } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import type { AgentOfflineExportCandidate, AgentOfflineExportPreview, AgentOfflineExportUnavailableReason } from "@/lib/backend/api";
const props = defineProps<{
open: boolean;
preview: AgentOfflineExportPreview | null;
loading?: boolean;
exporting?: boolean;
error?: string;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
confirm: [driverKeys: string[]];
}>();
const { t } = useI18n();
const selectedDriverKeys = ref<string[]>([]);
const dialogOpen = computed({
get: () => props.open,
set: (value) => {
if (props.exporting && !value) return;
emit("update:open", value);
},
});
const candidates = computed(() => props.preview?.candidates ?? []);
const eligibleCandidates = computed(() => candidates.value.filter((candidate) => candidate.eligible));
const selectedCandidates = computed(() => eligibleCandidates.value.filter((candidate) => selectedDriverKeys.value.includes(candidate.dbType)));
const selectedJres = computed(() => new Set(selectedCandidates.value.map((candidate) => candidate.requiredJre).filter((jre): jre is string => Boolean(jre))));
const selectedCount = computed(() => selectedDriverKeys.value.length);
const canConfirm = computed(() => selectedCount.value > 0 && !props.loading && !props.exporting && !props.error);
watch(
() => [props.open, props.preview] as const,
([open]) => {
if (open && props.preview) {
selectedDriverKeys.value = eligibleCandidates.value.map((candidate) => candidate.dbType);
}
},
{ immediate: true },
);
function isSelected(dbType: string) {
return selectedDriverKeys.value.includes(dbType);
}
function toggle(dbType: string, checked: boolean) {
if (checked) {
if (!selectedDriverKeys.value.includes(dbType)) {
selectedDriverKeys.value = [...selectedDriverKeys.value, dbType];
}
return;
}
selectedDriverKeys.value = selectedDriverKeys.value.filter((selected) => selected !== dbType);
}
function selectAll() {
selectedDriverKeys.value = eligibleCandidates.value.map((candidate) => candidate.dbType);
}
function clearSelection() {
selectedDriverKeys.value = [];
}
function confirm() {
if (!canConfirm.value) return;
emit("confirm", [...selectedDriverKeys.value]);
}
function formatSize(bytes: number) {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function artifactLabel(candidate: AgentOfflineExportCandidate) {
if (candidate.artifactKind === "jar") return t("driverStore.offlineExportArtifactJar");
if (candidate.artifactKind === "native") return t("driverStore.offlineExportArtifactNative");
return t("driverStore.offlineExportArtifactUnknown");
}
function unavailableReason(reason: AgentOfflineExportUnavailableReason | null) {
return reason ? t(`driverStore.offlineExportUnavailable_${reason}`) : "";
}
</script>
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[640px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<FileArchive class="h-5 w-5" />
{{ t("driverStore.offlineExportTitle") }}
</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-2" :aria-busy="loading || exporting">
<p class="text-sm text-muted-foreground">{{ t("driverStore.offlineExportDescription") }}</p>
<div v-if="preview" class="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 px-3 py-2 text-xs">
<span>
{{ t("driverStore.offlineExportPlatform") }}
<Badge variant="outline" class="ml-1 font-mono">{{ preview.platform }}</Badge>
</span>
<span class="text-muted-foreground">
{{ t("driverStore.offlineExportSelectedCount", { selected: selectedCount, total: eligibleCandidates.length }) }}
</span>
</div>
<div v-if="loading" role="status" aria-live="polite" class="flex h-64 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("driverStore.offlineExportPreparing") }}
</div>
<div v-else-if="error" role="alert" class="flex h-48 items-center justify-center rounded-md border border-destructive/30 bg-destructive/5 px-6 text-center text-sm text-destructive">
{{ error }}
</div>
<template v-else>
<div class="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" :disabled="exporting || eligibleCandidates.length === 0" @click="selectAll">
{{ t("driverStore.offlineExportSelectAll") }}
</Button>
<Button type="button" variant="outline" size="sm" :disabled="exporting || selectedCount === 0" @click="clearSelection">
{{ t("driverStore.offlineExportClear") }}
</Button>
</div>
<ScrollArea class="h-72 rounded-md border">
<div v-if="candidates.length === 0" class="px-4 py-12 text-center text-sm text-muted-foreground">
{{ t("driverStore.offlineExportNoDrivers") }}
</div>
<label v-for="candidate in candidates" :key="candidate.dbType" class="flex items-start gap-3 border-b px-3 py-2.5 last:border-b-0" :class="candidate.eligible ? 'cursor-pointer hover:bg-muted/50' : 'cursor-not-allowed bg-muted/20 opacity-70'">
<input
type="checkbox"
class="mt-1 h-3.5 w-3.5 shrink-0 accent-primary"
:checked="isSelected(candidate.dbType)"
:disabled="!candidate.eligible || exporting"
:aria-label="candidate.label"
:aria-describedby="!candidate.eligible ? `offline-export-reason-${candidate.dbType}` : undefined"
@change="toggle(candidate.dbType, ($event.target as HTMLInputElement).checked)"
/>
<DatabaseIcon :db-type="candidate.dbType" class="mt-0.5 h-4 w-4 shrink-0" />
<span class="min-w-0 flex-1">
<span class="flex min-w-0 flex-wrap items-center gap-1.5">
<span class="truncate text-sm font-medium">{{ candidate.label }}</span>
<Badge variant="outline" class="h-5 rounded-full px-2 text-[10px]">v{{ candidate.version }}</Badge>
<Badge v-if="candidate.artifactKind" variant="secondary" class="h-5 rounded-full px-2 text-[10px]">{{ artifactLabel(candidate) }}</Badge>
<Badge v-if="candidate.requiredJre" variant="secondary" class="h-5 rounded-full px-2 text-[10px]">JRE {{ candidate.requiredJre }}</Badge>
<span v-if="candidate.size" class="text-[11px] text-muted-foreground">{{ formatSize(candidate.size) }}</span>
</span>
<span v-if="!candidate.eligible" :id="`offline-export-reason-${candidate.dbType}`" class="mt-0.5 block text-xs text-muted-foreground">
{{ unavailableReason(candidate.unavailableReason) }}
</span>
</span>
</label>
</ScrollArea>
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("driverStore.offlineExportJreSummary", { count: selectedJres.size }) }}
</div>
<div class="flex items-start gap-2 rounded-md border border-green-500/20 bg-green-500/5 px-3 py-2 text-xs text-muted-foreground">
<ShieldCheck class="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
<span>{{ t("driverStore.offlineExportSecurityHint") }}</span>
</div>
</template>
</div>
<DialogFooter>
<Button type="button" variant="outline" :disabled="exporting" @click="dialogOpen = false">
{{ t("common.cancel") }}
</Button>
<Button type="button" :disabled="!canConfirm" @click="confirm">
<Loader2 v-if="exporting" class="mr-1.5 h-4 w-4 animate-spin" />
{{ exporting ? t("driverStore.offlineExporting") : t("driverStore.offlineExportStart", { count: selectedCount }) }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -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, X } from "@lucide/vue";
import { Activity, ExternalLink, Cpu, FolderOpen, FolderSync, MemoryStick, Search, Square, Trash2, Download, RotateCcw, Loader2, RefreshCw, Check, Clock3, FileArchive, FileUp, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DriverInstallProgressCircle from "@/components/config/DriverInstallProgressCircle.vue";
import AgentOfflineExportDialog from "@/components/config/AgentOfflineExportDialog.vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
@@ -17,7 +18,7 @@ import { uuid } from "@/lib/common/utils";
import { countAvailableDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
import type { JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, JdbcPluginStatus } from "@/types/database";
import * as api from "@/lib/backend/api";
import type { AgentDriverInfo, DriverRuntimeInfo, DriverRuntimeSummary, DriverStoreUsage, JavaRuntimeConfig } from "@/lib/backend/api";
import type { AgentDriverInfo, AgentOfflineExportPreview, DriverRuntimeInfo, DriverRuntimeSummary, DriverStoreUsage, JavaRuntimeConfig } from "@/lib/backend/api";
import { formatRuntimeBytes, formatRuntimeCpu, formatRuntimeUptime, runtimeHealthClass, runtimeStatusClass, runtimeStatusDotClass } from "@/lib/connection/driverRuntimePresentation";
import {
addDriverInstallQueue,
@@ -36,6 +37,7 @@ import { installRegisteredManagedJdbcDriver, isManagedJdbcDriver, managedJdbcDri
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import { isOfflineDriverPackage, webDriverImportAccept } from "@/lib/driverStore/driverImportSelection";
import { translateBackendError } from "@/i18n/backend-errors";
import { runAgentOfflineExportAction } from "@/lib/driverStore/agentOfflineExportFlow";
import { DRIVER_CATEGORIES, getCategoryForAgentDriver, assertAgentDriverCategoriesComplete } from "@/lib/connection/driver-category-definitions";
import { selectUpdatableDrivers, selectStableDrivers, hasAnyUpdatableDriverMatching } from "@/lib/connection/driverListFilter";
@@ -205,6 +207,13 @@ const customJavaPath = ref("");
const savingJavaRuntime = ref(false);
const driverStoreUsage = ref<DriverStoreUsage | null>(null);
const clearingDownloadCache = ref(false);
const offlineExportDialogOpen = ref(false);
const offlineExportPreview = ref<AgentOfflineExportPreview | null>(null);
const offlineExportLoading = ref(false);
const offlineExporting = ref(false);
const offlineExportError = ref("");
const uninstallingDriver = ref<string | null>(null);
const uninstallingJre = ref<string | null>(null);
const runtimeSummary = ref<DriverRuntimeSummary | null>(null);
const runtimeLoading = ref(false);
const runtimeError = ref("");
@@ -468,6 +477,7 @@ async function chooseCustomJavaPath() {
}
async function installDriver(dbType: string) {
if (agentPackageBusy.value) return;
if (installing.value !== null || preparingUpgradeAll.value || upgradingAll.value) {
queueDriverInstall(dbType);
return;
@@ -519,7 +529,7 @@ async function runDriverInstall(dbType: string) {
}
async function runQueuedDriverInstalls() {
if (installing.value !== null || preparingUpgradeAll.value || upgradingAll.value) return;
if (agentPackageBusy.value || installing.value !== null || preparingUpgradeAll.value || upgradingAll.value) return;
const result = takeNextDriverInstallQueue(queuedDriverInstalls.value, canInstallOrUpdateDriver);
queuedDriverInstalls.value = result.queue;
@@ -530,7 +540,7 @@ async function runQueuedDriverInstalls() {
}
async function upgradeAll() {
if (preparingUpgradeAll.value || upgradingAll.value) return;
if (agentPackageBusy.value || preparingUpgradeAll.value || upgradingAll.value) return;
preparingUpgradeAll.value = true;
try {
const updatableDbTypes = drivers.value.filter((driver) => driver.update_available).map((driver) => driver.db_type);
@@ -595,7 +605,9 @@ async function cancelUpgradeAll() {
}
async function uninstallDriver(dbType: string) {
if (agentPackageBusy.value) return;
const label = driverLabel(dbType);
uninstallingDriver.value = dbType;
try {
const managedResult = await uninstallRegisteredManagedJdbcDriver(dbType, jdbcMavenBundles.value, api);
if (managedResult) {
@@ -615,12 +627,65 @@ async function uninstallDriver(dbType: string) {
toast(t("driverStore.driverUninstallSuccess", { label }));
} catch (e: any) {
toast(t("driverStore.driverUninstallFailed", { label, error: backendError(e) }));
} finally {
uninstallingDriver.value = null;
}
}
const importingZip = ref(false);
const importingDriver = ref<string | null>(null);
const agentImportBusy = computed(() => importingZip.value || importingDriver.value !== null);
const agentPackageBusy = computed(() => agentImportBusy.value || offlineExportLoading.value || offlineExporting.value || uninstallingDriver.value !== null || uninstallingJre.value !== null);
const agentExportImportBlocked = computed(() => agentPackageBusy.value || installing.value !== null || preparingUpgradeAll.value || upgradingAll.value || reinstallingJre.value !== null || queuedDriverInstalls.value.length > 0);
async function openOfflineExportDialog() {
if (isWeb || agentExportImportBlocked.value) return;
offlineExportDialogOpen.value = true;
offlineExportLoading.value = true;
offlineExportPreview.value = null;
offlineExportError.value = "";
try {
offlineExportPreview.value = await api.previewAgentOfflineExport();
} catch (error) {
offlineExportError.value = t("driverStore.offlineExportPreviewFailed", { error: backendError(error) });
} finally {
offlineExportLoading.value = false;
}
}
async function exportOfflinePackage(driverKeys: string[]) {
if (isWeb || agentExportImportBlocked.value || driverKeys.length === 0) return;
const platform = offlineExportPreview.value?.platform ?? "current-platform";
await runAgentOfflineExportAction({
driverKeys,
setBusy: (busy) => {
offlineExporting.value = busy;
},
chooseDestination: async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
return save({
title: t("driverStore.offlineExportChooseDestination"),
defaultPath: `dbx-agents-offline-${platform}.zip`,
filters: [{ name: "ZIP", extensions: ["zip"] }],
});
},
exportPackage: api.exportAgentsOffline,
onSuccess: ({ destination, result }) => {
offlineExportDialogOpen.value = false;
toast(
t("driverStore.offlineExportSuccess", {
drivers: result.driverCount,
jres: result.jreCount,
size: formatBytes(result.bytes),
file: destination.split(/[/\\]/).pop() || destination,
}),
);
},
onError: (error) => {
toast(t("driverStore.offlineExportFailed", { error: backendError(error) }), 8000);
},
});
}
function chooseWebOfflineZip(): Promise<File | null> {
return new Promise((resolve) => {
@@ -661,7 +726,7 @@ function chooseWebFile(accept: string): Promise<File | null> {
}
async function importOfflineZip() {
if (agentImportBusy.value) return;
if (agentExportImportBlocked.value) return;
let selected: string | File | null = null;
if (isWeb) {
selected = await chooseWebOfflineZip();
@@ -692,7 +757,7 @@ async function importOfflineZip() {
}
async function importDriverFile(driver: AgentDriverInfo) {
if (agentImportBusy.value) return;
if (agentPackageBusy.value) return;
const dbType = driver.db_type;
if (isManagedJdbcBuiltinDriver(dbType)) {
await importJdbcDrivers();
@@ -753,6 +818,7 @@ async function importDriverFile(driver: AgentDriverInfo) {
}
async function reinstallJre(jreKey: string) {
if (agentPackageBusy.value) return;
reinstallingJre.value = jreKey;
activeAgentOperationId.value = uuid();
resetAgentInstallProgress();
@@ -770,12 +836,16 @@ async function reinstallJre(jreKey: string) {
}
async function uninstallJre(jreKey: string) {
if (agentPackageBusy.value) return;
uninstallingJre.value = jreKey;
try {
await api.uninstallJre(jreKey);
await refreshAgents();
toast(t("driverStore.jreUninstallSuccess", { jre: jreKey }));
} catch (e: any) {
toast(String(e));
} finally {
uninstallingJre.value = null;
}
}
@@ -1400,7 +1470,12 @@ watch(driverStoreTab, (tab) => {
</SelectContent>
</Select>
</div>
<Button v-if="driverStoreTab === 'agent'" variant="ghost" size="sm" class="h-7 rounded-md text-xs gap-1 text-muted-foreground" :disabled="agentImportBusy || installing !== null || upgradingAll || reinstallingJre !== null || queuedDriverInstalls.length > 0" @click="importOfflineZip">
<Button v-if="driverStoreTab === 'agent' && !isWeb" data-testid="agent-offline-export-button" variant="ghost" size="sm" class="h-7 rounded-md text-xs gap-1 text-muted-foreground" :disabled="agentExportImportBlocked" @click="openOfflineExportDialog">
<Loader2 v-if="offlineExportLoading || offlineExporting" class="h-3.5 w-3.5 animate-spin" />
<FileArchive v-else class="h-3.5 w-3.5" />
{{ offlineExporting ? t("driverStore.offlineExporting") : t("driverStore.offlineExport") }}
</Button>
<Button v-if="driverStoreTab === 'agent'" data-testid="agent-offline-import-button" variant="ghost" size="sm" class="h-7 rounded-md text-xs gap-1 text-muted-foreground" :disabled="agentExportImportBlocked" @click="importOfflineZip">
<Loader2 v-if="agentImportBusy" class="h-3.5 w-3.5 animate-spin" />
<FileUp v-else class="h-3.5 w-3.5" />
{{ agentImportBusy ? t("driverStore.importing") : t("driverStore.importOfflinePackage") }}
@@ -1453,15 +1528,15 @@ watch(driverStoreTab, (tab) => {
<Check v-if="jre.installed" class="h-4 w-4 text-green-600" />
<span v-else class="text-xs text-muted-foreground">{{ t("driverStore.notInstalled") }}</span>
<DriverInstallProgressCircle v-if="reinstallingJre === jre.key" :percent="getJreReinstallPercent()" :title="getJreReinstallTitle(jre.installed ? t('driverStore.reinstalling') : t('driverStore.installing'))" />
<Button v-else-if="!jre.installed" type="button" variant="default" size="sm" class="h-8 rounded-md text-xs" :disabled="reinstallingJre !== null || installing !== null || agentImportBusy" @click="reinstallJre(jre.key)">
<Button v-else-if="!jre.installed" type="button" variant="default" size="sm" class="h-8 rounded-md text-xs" :disabled="reinstallingJre !== null || installing !== null || agentPackageBusy" @click="reinstallJre(jre.key)">
<Download class="h-3.5 w-3.5 mr-1" />
{{ t("driverStore.install") }}
</Button>
<Button v-else-if="jre.installed" type="button" variant="outline" size="sm" class="h-8 rounded-md text-xs" :disabled="reinstallingJre !== null || installing !== null || agentImportBusy" @click="reinstallJre(jre.key)">
<Button v-else-if="jre.installed" type="button" variant="outline" size="sm" class="h-8 rounded-md text-xs" :disabled="reinstallingJre !== null || installing !== null || agentPackageBusy" @click="reinstallJre(jre.key)">
<RotateCcw class="h-3.5 w-3.5 mr-1" />
{{ t("driverStore.reinstall") }}
</Button>
<Button v-if="jre.installed" type="button" variant="ghost" size="sm" class="h-8 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="reinstallingJre !== null || installing !== null || agentImportBusy" @click="uninstallJre(jre.key)">
<Button v-if="jre.installed" type="button" variant="ghost" size="sm" class="h-8 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="reinstallingJre !== null || installing !== null || agentPackageBusy" @click="uninstallJre(jre.key)">
{{ t("driverStore.uninstall") }}
</Button>
</div>
@@ -1482,7 +1557,7 @@ watch(driverStoreTab, (tab) => {
<p class="text-xs text-muted-foreground">{{ t("driverStore.updatesAvailableDescription") }}</p>
</div>
<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">
<Button size="sm" class="h-7 rounded-md text-xs shrink-0 ml-3" :disabled="installing !== null || preparingUpgradeAll || upgradingAll || agentPackageBusy" @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") }}
@@ -1518,7 +1593,7 @@ 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="preparingUpgradeAll || upgradingAll || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
@@ -1535,7 +1610,7 @@ watch(driverStoreTab, (tab) => {
@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)">
<Button v-else-if="!driver.installed && !isDriverProgressActive(driver.db_type)" size="sm" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy" @click="installDriver(driver.db_type)">
<Download class="h-3 w-3 mr-1" />
{{ t("driverStore.install") }}
</Button>
@@ -1545,7 +1620,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="preparingUpgradeAll || upgradingAll || installing !== null || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || installing !== null || agentPackageBusy"
@click="importDriverFile(driver)"
>
<Loader2 v-if="importingDriver === driver.db_type" class="h-3.5 w-3.5 animate-spin" />
@@ -1557,7 +1632,7 @@ 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="preparingUpgradeAll || upgradingAll || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
@@ -1574,7 +1649,7 @@ watch(driverStoreTab, (tab) => {
@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)">
<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 || agentPackageBusy" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button
@@ -1582,7 +1657,7 @@ watch(driverStoreTab, (tab) => {
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)"
:disabled="installing !== null || preparingUpgradeAll || upgradingAll || agentPackageBusy || isDriverQueued(driver.db_type)"
@click="uninstallDriver(driver.db_type)"
>
{{ t("driverStore.uninstall") }}
@@ -1663,7 +1738,7 @@ 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="preparingUpgradeAll || upgradingAll || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
@@ -1680,7 +1755,7 @@ watch(driverStoreTab, (tab) => {
@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)">
<Button v-else-if="!driver.installed && !isDriverProgressActive(driver.db_type)" size="sm" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy" @click="installDriver(driver.db_type)">
<Download class="h-3 w-3 mr-1" />
{{ t("driverStore.install") }}
</Button>
@@ -1690,7 +1765,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="preparingUpgradeAll || upgradingAll || installing !== null || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || installing !== null || agentPackageBusy"
@click="importDriverFile(driver)"
>
<Loader2 v-if="importingDriver === driver.db_type" class="h-3.5 w-3.5 animate-spin" />
@@ -1702,7 +1777,7 @@ 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="preparingUpgradeAll || upgradingAll || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
@@ -1719,7 +1794,7 @@ watch(driverStoreTab, (tab) => {
@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)">
<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 || agentPackageBusy" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button
@@ -1727,7 +1802,7 @@ watch(driverStoreTab, (tab) => {
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)"
:disabled="installing !== null || preparingUpgradeAll || upgradingAll || agentPackageBusy || isDriverQueued(driver.db_type)"
@click="uninstallDriver(driver.db_type)"
>
{{ t("driverStore.uninstall") }}
@@ -1767,7 +1842,7 @@ 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="preparingUpgradeAll || upgradingAll || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
@@ -1784,7 +1859,7 @@ watch(driverStoreTab, (tab) => {
@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)">
<Button v-else-if="!driver.installed && !isDriverProgressActive(driver.db_type)" size="sm" class="h-7 rounded-md text-xs" :disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy" @click="installDriver(driver.db_type)">
<Download class="h-3 w-3 mr-1" />
{{ t("driverStore.install") }}
</Button>
@@ -1794,7 +1869,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="preparingUpgradeAll || upgradingAll || installing !== null || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || installing !== null || agentPackageBusy"
@click="importDriverFile(driver)"
>
<Loader2 v-if="importingDriver === driver.db_type" class="h-3.5 w-3.5 animate-spin" />
@@ -1806,7 +1881,7 @@ 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="preparingUpgradeAll || upgradingAll || agentImportBusy"
:disabled="preparingUpgradeAll || upgradingAll || agentPackageBusy"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
@@ -1823,7 +1898,7 @@ watch(driverStoreTab, (tab) => {
@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)">
<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 || agentPackageBusy" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button
@@ -1831,7 +1906,7 @@ watch(driverStoreTab, (tab) => {
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)"
:disabled="installing !== null || preparingUpgradeAll || upgradingAll || agentPackageBusy || isDriverQueued(driver.db_type)"
@click="uninstallDriver(driver.db_type)"
>
{{ t("driverStore.uninstall") }}
@@ -2150,6 +2225,7 @@ watch(driverStoreTab, (tab) => {
</div>
</div>
</div>
<AgentOfflineExportDialog v-model:open="offlineExportDialogOpen" :preview="offlineExportPreview" :loading="offlineExportLoading" :exporting="offlineExporting" :error="offlineExportError" @confirm="exportOfflinePackage" />
</template>
<style>
@@ -0,0 +1,183 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { AgentOfflineExportPreview } from "@/lib/backend/api";
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return { Dialog: passthrough, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
props: { disabled: Boolean },
setup(props, { slots, emit }) {
return () => h("button", { disabled: props.disabled, onClick: () => emit("click") }, slots.default?.());
},
}),
};
});
vi.mock("@/components/ui/badge", async () => {
const { defineComponent, h } = await import("vue");
return {
Badge: defineComponent({
setup(_props, { slots }) {
return () => h("span", slots.default?.());
},
}),
};
});
vi.mock("@/components/ui/scroll-area", async () => {
const { defineComponent, h } = await import("vue");
return {
ScrollArea: defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
}),
};
});
vi.mock("@/components/icons/DatabaseIcon.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
setup() {
return () => h("span");
},
}),
};
});
import AgentOfflineExportDialog from "@/components/config/AgentOfflineExportDialog.vue";
const mountedApps: App[] = [];
const preview: AgentOfflineExportPreview = {
platform: "macos-aarch64",
candidates: [
{
dbType: "duckdb",
label: "DuckDB",
version: "1.2.3",
size: 1024,
artifactKind: "jar",
requiredJre: "21",
eligible: true,
unavailableReason: null,
},
{
dbType: "kafka",
label: "Apache Kafka",
version: "4.0.0",
size: 2048,
artifactKind: "jar",
requiredJre: "21",
eligible: false,
unavailableReason: "localInstall",
},
{
dbType: "redis",
label: "Redis",
version: "8.0.0",
size: 4096,
artifactKind: "native",
requiredJre: null,
eligible: true,
unavailableReason: null,
},
],
};
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
});
async function mountDialog(props: Record<string, unknown>, onConfirm = vi.fn()) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () => h(AgentOfflineExportDialog, { ...props, onConfirm });
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
return { onConfirm };
}
function buttonContaining(text: string) {
return [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes(text));
}
describe("AgentOfflineExportDialog", () => {
it("selects eligible drivers by default and explains excluded candidates", async () => {
i18n.global.locale.value = "en";
const { onConfirm } = await mountDialog({ open: true, preview });
expect(document.body.textContent).toContain("2 of 2 selected");
expect(document.body.textContent).toContain("1 managed JRE(s) will be included automatically.");
expect(document.body.textContent).toContain("Locally imported Agent builds are not included.");
const inputs = [...document.body.querySelectorAll<HTMLInputElement>("input[type='checkbox']")];
expect(inputs).toHaveLength(3);
expect(inputs.map((input) => input.checked)).toEqual([true, false, true]);
expect(inputs.map((input) => input.disabled)).toEqual([false, true, false]);
expect(inputs[1]?.getAttribute("aria-describedby")).toBe("offline-export-reason-kafka");
expect(document.getElementById("offline-export-reason-kafka")?.textContent).toContain("Locally imported");
buttonContaining("Export 2 driver(s)")?.click();
expect(onConfirm).toHaveBeenCalledWith(["duckdb", "redis"]);
});
it("disables export after clearing the selection", async () => {
i18n.global.locale.value = "en";
const { onConfirm } = await mountDialog({ open: true, preview });
buttonContaining("Clear")?.click();
await nextTick();
expect(document.body.textContent).toContain("0 of 2 selected");
const confirm = buttonContaining("Export 0 driver(s)");
expect(confirm?.disabled).toBe(true);
confirm?.click();
expect(onConfirm).not.toHaveBeenCalled();
});
it("locks controls while an export is running", async () => {
i18n.global.locale.value = "en";
const { onConfirm } = await mountDialog({ open: true, preview, exporting: true });
expect(buttonContaining("Select all")?.disabled).toBe(true);
expect(buttonContaining("Clear")?.disabled).toBe(true);
expect(buttonContaining("Cancel")?.disabled).toBe(true);
expect(buttonContaining("Exporting")?.disabled).toBe(true);
expect([...document.body.querySelectorAll<HTMLInputElement>("input[type='checkbox']")].every((input) => input.disabled)).toBe(true);
buttonContaining("Exporting")?.click();
expect(onConfirm).not.toHaveBeenCalled();
});
it("shows the empty state when no managed drivers are available", async () => {
i18n.global.locale.value = "en";
await mountDialog({ open: true, preview: { platform: "linux-x64", candidates: [] } satisfies AgentOfflineExportPreview });
expect(document.body.textContent).toContain("No eligible DBX-managed Agent drivers are installed.");
expect(buttonContaining("Export 0 driver(s)")?.disabled).toBe(true);
});
});