feat(desktop): add Driver Store category navigation
This commit is contained in:
1 parent
c7e7b7c9d9
commit
06ed8dc5f6
12 files changed
+802
-105
No files matched your search
@@ -32,6 +32,8 @@ import {
|
||||
import { PRESTOSQL_DRIVER_DB_TYPE, prestoSqlBuiltinDriverRow, prestoSqlMavenBundle } from "@/lib/database/prestoSqlBuiltinDriver";
|
||||
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { isOfflineDriverPackage, webDriverImportAccept } from "@/lib/driverStore/driverImportSelection";
|
||||
import { DRIVER_CATEGORIES, getCategoryForAgentDriver, assertAgentDriverCategoriesComplete } from "@/lib/connection/driver-category-definitions";
|
||||
import { selectUpdatableDrivers, selectStableDrivers, hasAnyUpdatableDriverMatching } from "@/lib/connection/driverListFilter";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -174,6 +176,7 @@ async function applyDriverStoreDir(kind: DriverStoreDirKind, newDir: string | nu
|
||||
|
||||
const drivers = ref<AgentDriverInfo[]>([]);
|
||||
const agentDriverSearch = ref("");
|
||||
const selectedDriverCategory = ref<string>("all");
|
||||
const installing = ref<string | null>(null);
|
||||
const upgradingAll = ref(false);
|
||||
const upgradingCompletedCount = ref(0);
|
||||
@@ -374,6 +377,14 @@ function removeQueuedDriverInstall(dbType: string) {
|
||||
async function refreshAgents() {
|
||||
updateAgentDrivers(await api.listInstalledAgents());
|
||||
void loadDriverStoreUsage();
|
||||
// DEV: validate category coverage
|
||||
if (import.meta.env.DEV) {
|
||||
try {
|
||||
assertAgentDriverCategoriesComplete(drivers.value.map((d) => d.db_type));
|
||||
} catch (e) {
|
||||
console.warn("[DriverStore] Agent driver category mapping incomplete:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function forceRefresh() {
|
||||
@@ -756,39 +767,86 @@ type JdbcDriverListItem =
|
||||
bundle: JdbcMavenBundleInfo;
|
||||
};
|
||||
|
||||
const filteredAgentDrivers = computed(() => {
|
||||
const query = agentDriverSearch.value.trim().toLowerCase();
|
||||
if (!query) return builtinDriverRows.value;
|
||||
return builtinDriverRows.value.filter((driver) => [driver.label, driver.db_type, driver.version, driver.installed_version, driverRequiresJavaRuntime(driver) ? driver.jre : ""].filter(Boolean).join(" ").toLowerCase().includes(query));
|
||||
// Resolved category list with i18n
|
||||
const driverCategories = computed(() =>
|
||||
DRIVER_CATEGORIES.map((cat) => ({
|
||||
key: cat.key,
|
||||
title: t(cat.titleKey),
|
||||
})),
|
||||
);
|
||||
|
||||
const driverSearchQuery = computed(() => agentDriverSearch.value.trim().toLowerCase());
|
||||
const isDriverSearchActive = computed(() => !!driverSearchQuery.value);
|
||||
|
||||
// Category label helper (for search results badges) — defined before searchedDrivers
|
||||
function driverCategoryLabel(dbType: string): string {
|
||||
const catKey = getCategoryForAgentDriver(dbType);
|
||||
if (catKey === "all") return t("driverStore.driverCategoryAll");
|
||||
const catDef = DRIVER_CATEGORIES.find((c) => c.key === catKey);
|
||||
return catDef ? t(catDef.titleKey) : t("driverStore.driverCategoryAll");
|
||||
}
|
||||
|
||||
// Search only stable drivers — updatable drivers already appear in the global update section.
|
||||
const stableBuiltinDrivers = computed(() => selectStableDrivers(builtinDriverRows.value));
|
||||
|
||||
// Filter stable drivers through search (or pass all through if no search)
|
||||
const searchedDrivers = computed(() => {
|
||||
const query = driverSearchQuery.value;
|
||||
if (!query) return stableBuiltinDrivers.value;
|
||||
return stableBuiltinDrivers.value.filter((driver) => [driver.label, driver.db_type, driver.version, driver.installed_version, driverRequiresJavaRuntime(driver) ? driver.jre : "", driverCategoryLabel(driver.db_type)].filter(Boolean).join(" ").toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
/** filteredAgentDrivers reordered: updatable drivers first, then the rest (installed + not-installed). */
|
||||
const orderedFilteredDrivers = computed(() => {
|
||||
const updatable: AgentDriverInfo[] = [];
|
||||
const stable: AgentDriverInfo[] = [];
|
||||
for (const driver of filteredAgentDrivers.value) {
|
||||
if (driver.update_available) {
|
||||
updatable.push(driver);
|
||||
} else {
|
||||
stable.push(driver);
|
||||
}
|
||||
// When search is active: group by category
|
||||
const searchedDriversByCategory = computed(() => {
|
||||
if (!isDriverSearchActive.value) return [];
|
||||
const grouped = new Map<string, AgentDriverInfo[]>();
|
||||
for (const driver of searchedDrivers.value) {
|
||||
const cat = getCategoryForAgentDriver(driver.db_type);
|
||||
if (!grouped.has(cat)) grouped.set(cat, []);
|
||||
grouped.get(cat)!.push(driver);
|
||||
}
|
||||
return [...updatable, ...stable];
|
||||
return [...grouped.entries()].map(([catKey, drivers]) => {
|
||||
const catDef = DRIVER_CATEGORIES.find((c) => c.key === catKey);
|
||||
return {
|
||||
key: catKey,
|
||||
title: catKey === "all" ? t("driverStore.driverCategoryAll") : catDef ? t(catDef.titleKey) : t("driverStore.driverCategoryAll"),
|
||||
drivers,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Number of updatable drivers in the currently filtered list. */
|
||||
const filteredUpdatableCount = computed(() => {
|
||||
let count = 0;
|
||||
for (const driver of orderedFilteredDrivers.value) {
|
||||
if (driver.update_available) count++;
|
||||
else break;
|
||||
}
|
||||
return count;
|
||||
// When search is NOT active: filter by selected category
|
||||
const categoryFilteredDrivers = computed(() => {
|
||||
if (isDriverSearchActive.value || selectedDriverCategory.value === "all") return searchedDrivers.value;
|
||||
return searchedDrivers.value.filter((driver) => getCategoryForAgentDriver(driver.db_type) === selectedDriverCategory.value);
|
||||
});
|
||||
|
||||
/** Filtered driver rows split: updatable-only and stable-only (no <template> needed in markup). */
|
||||
const filteredUpdatableDrivers = computed(() => orderedFilteredDrivers.value.slice(0, filteredUpdatableCount.value));
|
||||
const filteredStableDrivers = computed(() => orderedFilteredDrivers.value.slice(filteredUpdatableCount.value));
|
||||
// Global updatable drivers — always shown above category navigation, regardless of filter.
|
||||
const globalUpdatableDrivers = computed(() => selectUpdatableDrivers(builtinDriverRows.value));
|
||||
|
||||
// Whether any global updatable driver matches the current search query or
|
||||
// selected category. Used to decide whether the empty‑state message should be
|
||||
// suppressed: the global update section always renders *all* updatable drivers
|
||||
// (unfiltered), so the empty‑state must only be hidden when at least one of
|
||||
// those drivers is actually relevant to the user's current view.
|
||||
const hasMatchingUpdatableDrivers = computed(() =>
|
||||
hasAnyUpdatableDriverMatching(globalUpdatableDrivers.value, {
|
||||
searchQuery: driverSearchQuery.value,
|
||||
selectedCategory: selectedDriverCategory.value,
|
||||
driverMatchesSearch: (driver, query) => [driver.label, driver.db_type, driver.version, driver.installed_version, driverRequiresJavaRuntime(driver) ? driver.jre : "", driverCategoryLabel(driver.db_type)].filter(Boolean).join(" ").toLowerCase().includes(query),
|
||||
driverCategory: (driver) => getCategoryForAgentDriver(driver.db_type),
|
||||
}),
|
||||
);
|
||||
|
||||
// Category-filtered drivers, excluding those already shown in the global update banner.
|
||||
const categoryStableDrivers = computed(() => selectStableDrivers(categoryFilteredDrivers.value));
|
||||
|
||||
// Category selection handler
|
||||
function selectDriverCategory(key: string) {
|
||||
selectedDriverCategory.value = key;
|
||||
// Clear search when switching categories
|
||||
agentDriverSearch.value = "";
|
||||
}
|
||||
|
||||
const highlightedFocusKey = ref<string | null>(null);
|
||||
let focusHighlightTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -806,6 +864,7 @@ watch(
|
||||
// Wait until the requested driver row is loaded before scrolling to it.
|
||||
if (!focus.driver || !builtinDriverRows.value.some((driver) => driver.db_type === focus.driver)) return;
|
||||
agentDriverSearch.value = "";
|
||||
selectedDriverCategory.value = "all";
|
||||
}
|
||||
const key = focusElementKey(focus);
|
||||
highlightedFocusKey.value = key;
|
||||
@@ -1343,17 +1402,11 @@ watch(driverStoreTab, (tab) => {
|
||||
<Search class="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="agentDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchDrivers')" />
|
||||
</div>
|
||||
<div v-if="drivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="filteredAgentDrivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t("driverStore.noMatchingDrivers") }}
|
||||
</div>
|
||||
<div v-else class="driver-store-agent-list rounded-lg border divide-y">
|
||||
<!-- Updates Available header -->
|
||||
<div v-if="filteredUpdatableDrivers.length > 0" class="flex items-center justify-between px-4 py-2.5 bg-amber-500/10">
|
||||
<!-- Global update section — always above category navigation -->
|
||||
<div v-if="globalUpdatableDrivers.length > 0" class="rounded-lg border divide-y">
|
||||
<div class="flex items-center justify-between bg-amber-500/10 px-4 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">{{ t("driverStore.updatesAvailableTitle") }} ({{ filteredUpdatableCount }})</div>
|
||||
<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 || importingZip" @click="upgradeAll">
|
||||
@@ -1363,7 +1416,7 @@ watch(driverStoreTab, (tab) => {
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="driver in filteredUpdatableDrivers"
|
||||
v-for="driver in globalUpdatableDrivers"
|
||||
:key="driver.db_type"
|
||||
:data-driver-store-focus="`driver:${driver.db_type}`"
|
||||
class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30"
|
||||
@@ -1431,77 +1484,199 @@ watch(driverStoreTab, (tab) => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Section divider between updatable and stable -->
|
||||
<div v-if="filteredUpdatableDrivers.length > 0 && filteredStableDrivers.length > 0" class="px-4 py-1.5 bg-muted/20 text-xs font-medium text-muted-foreground">
|
||||
{{ t("driverStore.allDriversTitle") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="driver in filteredStableDrivers"
|
||||
:key="driver.db_type"
|
||||
:data-driver-store-focus="`driver:${driver.db_type}`"
|
||||
class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30"
|
||||
:class="{ 'driver-store-focus-highlight': highlightedFocusKey === `driver:${driver.db_type}` }"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted/60 shrink-0">
|
||||
<DatabaseIcon :db-type="driver.db_type" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="driver-store-agent-name min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ driver.label }}</div>
|
||||
</div>
|
||||
<!-- Category nav + driver list container -->
|
||||
<div class="min-h-0 flex flex-1 flex-col gap-3 overflow-hidden sm:flex-row sm:gap-0">
|
||||
<!-- Category navigation sidebar -->
|
||||
<nav v-if="!isDriverSearchActive" data-driver-category-nav class="flex shrink-0 gap-1 overflow-x-auto border-b px-0.5 pt-0.5 pb-2 sm:w-40 sm:flex-col sm:overflow-y-auto sm:border-b-0 sm:border-r sm:py-0.5 sm:pr-3.5" :aria-label="t('driverStore.driverCategoryLabel')">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 whitespace-nowrap rounded-[4px] px-3 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-full"
|
||||
:class="selectedDriverCategory === 'all' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted/70'"
|
||||
:aria-current="selectedDriverCategory === 'all' ? 'page' : undefined"
|
||||
@click="selectDriverCategory('all')"
|
||||
>
|
||||
{{ t("driverStore.driverCategoryAll") }}
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in driverCategories"
|
||||
:key="cat.key"
|
||||
type="button"
|
||||
class="shrink-0 whitespace-nowrap rounded-[4px] px-3 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-full"
|
||||
:class="selectedDriverCategory === cat.key ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted/70'"
|
||||
:aria-current="selectedDriverCategory === cat.key ? 'page' : undefined"
|
||||
@click="selectDriverCategory(cat.key)"
|
||||
>
|
||||
{{ cat.title }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Driver list area -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto sm:pl-4">
|
||||
<!-- Empty: still loading -->
|
||||
<div v-if="drivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div class="driver-store-agent-meta flex shrink-0 items-center gap-1.5">
|
||||
<span v-if="driverRequiresJavaRuntime(driver) && driver.jre" class="rounded-full px-2 py-0.5 text-[11px]" :class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'">JRE {{ driver.jre }}</span>
|
||||
<span v-if="driver.installed" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.installed_version }}</span>
|
||||
<span v-if="driver.installed && driver.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ driver.version }}</span>
|
||||
<span v-if="!driver.installed && driver.version" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.version }}</span>
|
||||
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
|
||||
<!-- Empty: no search results (suppressed when updatable drivers match the search) -->
|
||||
<div v-else-if="isDriverSearchActive && searchedDrivers.length === 0 && !hasMatchingUpdatableDrivers" class="py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t("driverStore.noMatchingDrivers") }}
|
||||
</div>
|
||||
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
v-if="!driver.installed && isDriverQueued(driver.db_type)"
|
||||
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 || importingZip"
|
||||
@click="removeQueuedDriverInstall(driver.db_type)"
|
||||
<!-- Empty: no drivers in selected category (suppressed when updatable drivers match the category) -->
|
||||
<div v-else-if="!isDriverSearchActive && categoryFilteredDrivers.length === 0 && !hasMatchingUpdatableDrivers" class="py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t("driverStore.noMatchingDrivers") }}
|
||||
</div>
|
||||
|
||||
<!-- Search results: cross-category view -->
|
||||
<div v-else-if="isDriverSearchActive && searchedDriversByCategory.length > 0" class="space-y-4">
|
||||
<div v-for="group in searchedDriversByCategory" :key="group.key" class="space-y-2">
|
||||
<h3 class="px-4 text-sm font-medium text-muted-foreground">{{ group.title }}</h3>
|
||||
<div class="rounded-lg border divide-y">
|
||||
<div
|
||||
v-for="driver in group.drivers"
|
||||
:key="driver.db_type"
|
||||
:data-driver-store-focus="`driver:${driver.db_type}`"
|
||||
class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30"
|
||||
:class="{ 'driver-store-focus-highlight': highlightedFocusKey === `driver:${driver.db_type}` }"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted/60 shrink-0">
|
||||
<DatabaseIcon :db-type="driver.db_type" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="driver-store-agent-name min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ driver.label }}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ driverCategoryLabel(driver.db_type) }}</span>
|
||||
<div class="driver-store-agent-meta flex shrink-0 items-center gap-1.5">
|
||||
<span v-if="driverRequiresJavaRuntime(driver) && driver.jre" class="rounded-full px-2 py-0.5 text-[11px]" :class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'">JRE {{ driver.jre }}</span>
|
||||
<span v-if="driver.installed" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.installed_version }}</span>
|
||||
<span v-if="driver.installed && driver.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ driver.version }}</span>
|
||||
<span v-if="!driver.installed && driver.version" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.version }}</span>
|
||||
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
|
||||
</div>
|
||||
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
v-if="!driver.installed && isDriverQueued(driver.db_type)"
|
||||
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 || importingZip"
|
||||
@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 || importingZip" @click="installDriver(driver.db_type)">
|
||||
<Download class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.install") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!driver.installed && !isPrestoSqlBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
|
||||
:title="t('driverStore.importLocalJar')"
|
||||
:disabled="upgradingAll || installing !== null || importingZip"
|
||||
@click="importDriverFile(driver)"
|
||||
>
|
||||
<FileUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Check v-if="driver.installed && !(driver.update_available && isDriverProgressActive(driver.db_type))" class="h-4 w-4 text-green-600" />
|
||||
<Button
|
||||
v-if="driver.installed && driver.update_available && isDriverQueued(driver.db_type)"
|
||||
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 || importingZip"
|
||||
@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 || importingZip" @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 || importingZip || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
|
||||
{{ t("driverStore.uninstall") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal view: category-filtered stable drivers (updatable drivers are in the global banner above) -->
|
||||
<div v-else class="driver-store-agent-list rounded-lg border divide-y">
|
||||
<!-- Stable driver rows -->
|
||||
<div
|
||||
v-for="driver in categoryStableDrivers"
|
||||
:key="driver.db_type"
|
||||
:data-driver-store-focus="`driver:${driver.db_type}`"
|
||||
class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30"
|
||||
:class="{ 'driver-store-focus-highlight': highlightedFocusKey === `driver:${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 || importingZip" @click="installDriver(driver.db_type)">
|
||||
<Download class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.install") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!driver.installed && !isPrestoSqlBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
|
||||
:title="t('driverStore.importLocalJar')"
|
||||
:disabled="upgradingAll || installing !== null || importingZip"
|
||||
@click="importDriverFile(driver)"
|
||||
>
|
||||
<FileUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Check v-if="driver.installed && !(driver.update_available && isDriverProgressActive(driver.db_type))" class="h-4 w-4 text-green-600" />
|
||||
<Button
|
||||
v-if="driver.installed && driver.update_available && isDriverQueued(driver.db_type)"
|
||||
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 || importingZip"
|
||||
@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 || importingZip" @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 || importingZip || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
|
||||
{{ t("driverStore.uninstall") }}
|
||||
</Button>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted/60 shrink-0">
|
||||
<DatabaseIcon :db-type="driver.db_type" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="driver-store-agent-name min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ driver.label }}</div>
|
||||
</div>
|
||||
<div class="driver-store-agent-meta flex shrink-0 items-center gap-1.5">
|
||||
<span v-if="driverRequiresJavaRuntime(driver) && driver.jre" class="rounded-full px-2 py-0.5 text-[11px]" :class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'">JRE {{ driver.jre }}</span>
|
||||
<span v-if="driver.installed" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.installed_version }}</span>
|
||||
<span v-if="driver.installed && driver.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ driver.version }}</span>
|
||||
<span v-if="!driver.installed && driver.version" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.version }}</span>
|
||||
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
|
||||
</div>
|
||||
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
v-if="!driver.installed && isDriverQueued(driver.db_type)"
|
||||
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 || importingZip"
|
||||
@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 || importingZip" @click="installDriver(driver.db_type)">
|
||||
<Download class="h-3 w-3 mr-1" />
|
||||
{{ t("driverStore.install") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!driver.installed && !isPrestoSqlBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
|
||||
:title="t('driverStore.importLocalJar')"
|
||||
:disabled="upgradingAll || installing !== null || importingZip"
|
||||
@click="importDriverFile(driver)"
|
||||
>
|
||||
<FileUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Check v-if="driver.installed && !(driver.update_available && isDriverProgressActive(driver.db_type))" class="h-4 w-4 text-green-600" />
|
||||
<Button
|
||||
v-if="driver.installed && driver.update_available && isDriverQueued(driver.db_type)"
|
||||
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 || importingZip"
|
||||
@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 || importingZip" @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 || importingZip || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
|
||||
{{ t("driverStore.uninstall") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4593,6 +4593,8 @@ export default {
|
||||
clearDownloadCache: "Clear cache",
|
||||
downloadCacheClearSuccess: "Download cache cleared",
|
||||
downloadCacheClearFailed: "Failed to clear download cache: {error}",
|
||||
driverCategoryAll: "All Drivers",
|
||||
driverCategoryLabel: "Category",
|
||||
offlineDownloadHint: "For air-gapped environments, download offline driver packages on an internet-connected machine, then import them here.",
|
||||
offlineDownloadLink: "Offline driver downloads",
|
||||
searchDrivers: "Search driver name, type, version...",
|
||||
|
||||
@@ -4360,6 +4360,8 @@ export default withEnglishFallback({
|
||||
clearDownloadCache: "Limpiar caché",
|
||||
downloadCacheClearSuccess: "Caché de descargas limpiada",
|
||||
downloadCacheClearFailed: "Error al limpiar la caché de descargas: {error}",
|
||||
driverCategoryAll: "All Drivers",
|
||||
driverCategoryLabel: "Category",
|
||||
offlineDownloadHint: "Para entornos sin conexión a internet, descarga los paquetes de drivers offline en una máquina con conexión, luego impórtalos aquí.",
|
||||
offlineDownloadLink: "Descargas de drivers offline",
|
||||
searchDrivers: "Buscar nombre de driver, tipo, versión...",
|
||||
|
||||
@@ -4358,6 +4358,8 @@ export default withEnglishFallback({
|
||||
clearDownloadCache: "Pulisci cache",
|
||||
downloadCacheClearSuccess: "Cache download pulita",
|
||||
downloadCacheClearFailed: "Pulizia cache download non riuscita: {error}",
|
||||
driverCategoryAll: "All Drivers",
|
||||
driverCategoryLabel: "Category",
|
||||
offlineDownloadHint: "Per ambienti air-gapped, scarica i pacchetti driver offline su una macchina connessa a Internet, quindi importali qui.",
|
||||
offlineDownloadLink: "Download driver offline",
|
||||
searchDrivers: "Cerca nome driver, tipo, versione...",
|
||||
|
||||
@@ -4358,6 +4358,8 @@ export default withEnglishFallback({
|
||||
clearDownloadCache: "キャッシュを削除",
|
||||
downloadCacheClearSuccess: "ダウンロードキャッシュを削除しました",
|
||||
downloadCacheClearFailed: "ダウンロードキャッシュの削除に失敗しました: {error}",
|
||||
driverCategoryAll: "All Drivers",
|
||||
driverCategoryLabel: "Category",
|
||||
offlineDownloadHint: "エアギャップ環境の場合は、インターネット接続されたマシンでオフラインドライバーパッケージをダウンロードし、ここでインポートしてください。",
|
||||
offlineDownloadLink: "オフラインドライバーダウンロード",
|
||||
searchDrivers: "ドライバー名、タイプ、バージョンを検索...",
|
||||
|
||||
@@ -4360,6 +4360,8 @@ export default withEnglishFallback({
|
||||
clearDownloadCache: "Limpar cache",
|
||||
downloadCacheClearSuccess: "Cache de downloads limpo",
|
||||
downloadCacheClearFailed: "Falha ao limpar cache de downloads: {error}",
|
||||
driverCategoryAll: "All Drivers",
|
||||
driverCategoryLabel: "Category",
|
||||
offlineDownloadHint: "Para ambientes sem internet, baixe pacotes de driver offline em uma máquina com acesso à internet e importe-os aqui.",
|
||||
offlineDownloadLink: "Downloads de driver offline",
|
||||
searchDrivers: "Pesquisar nome, tipo, versão do driver...",
|
||||
|
||||
@@ -4592,6 +4592,8 @@ export default withEnglishFallback({
|
||||
clearDownloadCache: "清理缓存",
|
||||
downloadCacheClearSuccess: "下载缓存已清理",
|
||||
downloadCacheClearFailed: "下载缓存清理失败: {error}",
|
||||
driverCategoryAll: "全部驱动",
|
||||
driverCategoryLabel: "分类",
|
||||
offlineDownloadHint: "内网环境可先在有网机器下载离线驱动包,再回到这里导入。",
|
||||
offlineDownloadLink: "离线驱动下载",
|
||||
searchDrivers: "搜索驱动名称、类型、版本...",
|
||||
|
||||
@@ -4027,6 +4027,8 @@ export default withEnglishFallback({
|
||||
clearDownloadCache: "清理快取",
|
||||
downloadCacheClearSuccess: "下載快取已清理",
|
||||
downloadCacheClearFailed: "下載快取清理失敗: {error}",
|
||||
driverCategoryAll: "全部驅動",
|
||||
driverCategoryLabel: "分類",
|
||||
javaRuntimeSaved: "Java 執行環境設定已儲存",
|
||||
javaRuntimeSaveFailed: "Java 執行環境設定失敗: {error}",
|
||||
chooseJavaExecutable: "選擇 Java 可執行檔",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AGENT_DRIVER_CATEGORY_MAP, DRIVER_CATEGORIES, assertAgentDriverCategoriesComplete, getCategoryForAgentDriver } from "@/lib/connection/driver-category-definitions";
|
||||
|
||||
describe("getCategoryForAgentDriver", () => {
|
||||
it("returns correct category for one key from each of the 9 categories", () => {
|
||||
// sql
|
||||
expect(getCategoryForAgentDriver("oracle")).toBe("sql");
|
||||
// analytics
|
||||
expect(getCategoryForAgentDriver("snowflake")).toBe("analytics");
|
||||
// domestic
|
||||
expect(getCategoryForAgentDriver("dameng")).toBe("domestic");
|
||||
// lightweight
|
||||
expect(getCategoryForAgentDriver("access")).toBe("lightweight");
|
||||
// document
|
||||
expect(getCategoryForAgentDriver("mongodb")).toBe("document");
|
||||
// graph_ai
|
||||
expect(getCategoryForAgentDriver("neo4j")).toBe("graph_ai");
|
||||
// timeseries
|
||||
expect(getCategoryForAgentDriver("tdengine")).toBe("timeseries");
|
||||
// mq
|
||||
expect(getCategoryForAgentDriver("kafka")).toBe("mq");
|
||||
// registry_config
|
||||
expect(getCategoryForAgentDriver("etcd")).toBe("registry_config");
|
||||
});
|
||||
|
||||
it('returns "all" for unknown keys', () => {
|
||||
expect(getCategoryForAgentDriver("")).toBe("all");
|
||||
expect(getCategoryForAgentDriver("unknown_driver_xyz")).toBe("all");
|
||||
expect(getCategoryForAgentDriver("nosuchdriver")).toBe("all");
|
||||
expect(getCategoryForAgentDriver("random-string-123")).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertAgentDriverCategoriesComplete", () => {
|
||||
it("does not throw when all keys mapped", () => {
|
||||
const mappedKeys = Object.keys(AGENT_DRIVER_CATEGORY_MAP);
|
||||
|
||||
expect(() => assertAgentDriverCategoriesComplete(mappedKeys)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when a key is missing", () => {
|
||||
const mappedKeys = Object.keys(AGENT_DRIVER_CATEGORY_MAP);
|
||||
|
||||
expect(() => assertAgentDriverCategoriesComplete([...mappedKeys, "no_such_driver"])).toThrow("unmapped=no_such_driver");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AGENT_DRIVER_CATEGORY_MAP integrity", () => {
|
||||
it("has no agent driver key mapped to more than one category (i.e. no duplicate keys)", () => {
|
||||
const entries = Object.entries(AGENT_DRIVER_CATEGORY_MAP);
|
||||
const keys = entries.map(([key]) => key);
|
||||
|
||||
// Each key in a Record is already unique by definition, but verify
|
||||
// there are no unexpected dupes in the data.
|
||||
const seen = new Set<string>();
|
||||
for (const k of keys) {
|
||||
expect(seen.has(k)).toBe(false);
|
||||
seen.add(k);
|
||||
}
|
||||
});
|
||||
|
||||
it("has all category values listed in DRIVER_CATEGORIES", () => {
|
||||
const validCategoryKeys = new Set(DRIVER_CATEGORIES.map((c) => c.key));
|
||||
|
||||
for (const [driverKey, category] of Object.entries(AGENT_DRIVER_CATEGORY_MAP)) {
|
||||
expect(validCategoryKeys.has(category), `Driver "${driverKey}" maps to unknown category "${category}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers all known agent driver keys with no stale entries", () => {
|
||||
// Golden set — mirrors the store-visible entries in agent_catalog.rs
|
||||
// plus EXTRA_DRIVER_STORE_ENTRIES (kafka, rocketmq, rabbitmq,
|
||||
// sqlserver-legacy) and the PrestoSQL built-in driver.
|
||||
// When an Agent driver is added to the catalog its key MUST appear here;
|
||||
// removing a key from the map without removing it from the list below
|
||||
// will fail this test.
|
||||
const expectedKeys = new Set([
|
||||
// sql
|
||||
"db2",
|
||||
"firebird",
|
||||
"informix",
|
||||
"iris",
|
||||
"oracle",
|
||||
"sqlserver-legacy",
|
||||
// analytics
|
||||
"bigquery",
|
||||
"databend",
|
||||
"databricks",
|
||||
"exasol",
|
||||
"hive",
|
||||
"kylin",
|
||||
"prestosql",
|
||||
"saphana",
|
||||
"snowflake",
|
||||
"spark",
|
||||
"teradata",
|
||||
"trino",
|
||||
"vertica",
|
||||
// domestic
|
||||
"dameng",
|
||||
"gbase8a",
|
||||
"gbase8s",
|
||||
"goldendb",
|
||||
"highgo",
|
||||
"kingbase",
|
||||
"oceanbase-oracle",
|
||||
"oscar",
|
||||
"sundb",
|
||||
"uxdb",
|
||||
"vastbase",
|
||||
"xugu",
|
||||
"yashandb",
|
||||
// lightweight
|
||||
"access",
|
||||
"h2",
|
||||
"h2-legacy",
|
||||
// document
|
||||
"cassandra",
|
||||
"mongodb",
|
||||
// graph_ai
|
||||
"neo4j",
|
||||
// timeseries
|
||||
"influxdb",
|
||||
"iotdb",
|
||||
"tdengine",
|
||||
// mq
|
||||
"kafka",
|
||||
"rabbitmq",
|
||||
"rocketmq",
|
||||
// registry_config
|
||||
"etcd",
|
||||
"zookeeper",
|
||||
]);
|
||||
const actualKeys = Object.keys(AGENT_DRIVER_CATEGORY_MAP);
|
||||
|
||||
expect(actualKeys).toHaveLength(expectedKeys.size);
|
||||
|
||||
const actualSet = new Set(actualKeys);
|
||||
const missing = [...expectedKeys].filter((k) => !actualSet.has(k));
|
||||
const extra = actualKeys.filter((k) => !expectedKeys.has(k));
|
||||
|
||||
expect(missing, `Missing from AGENT_DRIVER_CATEGORY_MAP: ${missing.join(", ")}`).toEqual([]);
|
||||
expect(extra, `Extra keys in AGENT_DRIVER_CATEGORY_MAP not in expected set: ${extra.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prestosql matches ConnectionDialog category", () => {
|
||||
it("is mapped to analytics (same as ConnectionDialog dbCategoryDefinitions)", () => {
|
||||
expect(getCategoryForAgentDriver("prestosql")).toBe("analytics");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectStableDrivers, selectUpdatableDrivers, hasAnyUpdatableDriverMatching } from "@/lib/connection/driverListFilter";
|
||||
import type { AgentDriverInfo } from "@/lib/backend/api";
|
||||
|
||||
function driver(overrides: Partial<AgentDriverInfo> = {}): AgentDriverInfo {
|
||||
return {
|
||||
db_type: "test",
|
||||
label: "Test",
|
||||
version: "1.0",
|
||||
size: 0,
|
||||
installed: true,
|
||||
installed_version: "1.0",
|
||||
update_available: false,
|
||||
jre: "",
|
||||
jre_installed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectUpdatableDrivers", () => {
|
||||
it("returns only drivers with update_available === true", () => {
|
||||
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: true }), driver({ db_type: "postgres", update_available: false }), driver({ db_type: "sqlite", update_available: true })];
|
||||
|
||||
const result = selectUpdatableDrivers(drivers);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((d) => d.db_type)).toEqual(["mysql", "sqlite"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no drivers have updates available", () => {
|
||||
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: false }), driver({ db_type: "postgres", update_available: false })];
|
||||
|
||||
expect(selectUpdatableDrivers(drivers)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for an empty input", () => {
|
||||
expect(selectUpdatableDrivers([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectStableDrivers", () => {
|
||||
it("returns only drivers with update_available === false", () => {
|
||||
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: true }), driver({ db_type: "postgres", update_available: false }), driver({ db_type: "sqlite", update_available: false })];
|
||||
|
||||
const result = selectStableDrivers(drivers);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((d) => d.db_type)).toEqual(["postgres", "sqlite"]);
|
||||
});
|
||||
|
||||
it("returns all drivers when none have updates", () => {
|
||||
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: false }), driver({ db_type: "postgres", update_available: false })];
|
||||
|
||||
expect(selectStableDrivers(drivers)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when all drivers have updates", () => {
|
||||
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: true }), driver({ db_type: "sqlite", update_available: true })];
|
||||
|
||||
expect(selectStableDrivers(drivers)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partition invariant", () => {
|
||||
it("updatable + stable drivers cover the full input with no overlap", () => {
|
||||
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: true }), driver({ db_type: "postgres", update_available: false }), driver({ db_type: "sqlite", update_available: true }), driver({ db_type: "oracle", update_available: false })];
|
||||
|
||||
const updatable = selectUpdatableDrivers(drivers);
|
||||
const stable = selectStableDrivers(drivers);
|
||||
|
||||
// No duplicate db_types between the two sets.
|
||||
const updatableKeys = new Set(updatable.map((d) => d.db_type));
|
||||
for (const d of stable) {
|
||||
expect(updatableKeys.has(d.db_type)).toBe(false);
|
||||
}
|
||||
|
||||
// Union is the full set.
|
||||
expect(updatable.length + stable.length).toBe(drivers.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search deduplication", () => {
|
||||
it("updatable drivers are excluded from stable so they only appear in the global update section", () => {
|
||||
const drivers: AgentDriverInfo[] = [driver({ db_type: "neo4j", label: "Neo4j", update_available: true }), driver({ db_type: "oracle", label: "Oracle", update_available: true }), driver({ db_type: "postgres", label: "PostgreSQL", update_available: false })];
|
||||
|
||||
const stable = selectStableDrivers(drivers);
|
||||
|
||||
// Searching for "Neo4j" in stable should produce no results — the driver
|
||||
// already appears in the global update section above and must not be
|
||||
// duplicated in the category-filtered search results below.
|
||||
const searchHit = stable.some((d) => d.db_type === "neo4j");
|
||||
expect(searchHit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hasAnyUpdatableDriverMatching — regression tests for empty‑state suppression
|
||||
// (DriverStoreDialog.vue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Simple matchers mirroring the component's real predicates. */
|
||||
function labelSearch(d: AgentDriverInfo, q: string) {
|
||||
return d.label.toLowerCase().includes(q);
|
||||
}
|
||||
function categoryOf(d: AgentDriverInfo) {
|
||||
// Map db_type prefixes to categories — the real mapping lives in
|
||||
// getCategoryForAgentDriver; this simplified version is sufficient for tests.
|
||||
if (d.db_type === "neo4j") return "graph";
|
||||
return "relational";
|
||||
}
|
||||
|
||||
describe("hasAnyUpdatableDriverMatching", () => {
|
||||
const neo4j = driver({ db_type: "neo4j", label: "Neo4j", update_available: true });
|
||||
const oracle = driver({ db_type: "oracle", label: "Oracle", update_available: true });
|
||||
|
||||
it("returns false when there are no updatable drivers", () => {
|
||||
expect(
|
||||
hasAnyUpdatableDriverMatching([], {
|
||||
searchQuery: "",
|
||||
selectedCategory: "all",
|
||||
driverMatchesSearch: labelSearch,
|
||||
driverCategory: categoryOf,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for 'all' category with no search when updatable drivers exist", () => {
|
||||
expect(
|
||||
hasAnyUpdatableDriverMatching([neo4j], {
|
||||
searchQuery: "",
|
||||
selectedCategory: "all",
|
||||
driverMatchesSearch: labelSearch,
|
||||
driverCategory: categoryOf,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when an updatable driver matches the search", () => {
|
||||
expect(
|
||||
hasAnyUpdatableDriverMatching([neo4j, oracle], {
|
||||
searchQuery: "neo4j",
|
||||
selectedCategory: "all",
|
||||
driverMatchesSearch: labelSearch,
|
||||
driverCategory: categoryOf,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no updatable driver matches the search (regression: search 'zzzz' with Neo4j update)", () => {
|
||||
expect(
|
||||
hasAnyUpdatableDriverMatching([neo4j], {
|
||||
searchQuery: "zzzz",
|
||||
selectedCategory: "all",
|
||||
driverMatchesSearch: labelSearch,
|
||||
driverCategory: categoryOf,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when an updatable driver belongs to the selected category", () => {
|
||||
expect(
|
||||
hasAnyUpdatableDriverMatching([neo4j, oracle], {
|
||||
searchQuery: "",
|
||||
selectedCategory: "graph",
|
||||
driverMatchesSearch: labelSearch,
|
||||
driverCategory: categoryOf,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no updatable driver belongs to the selected category (regression: Neo4j update, 'relational' category)", () => {
|
||||
expect(
|
||||
hasAnyUpdatableDriverMatching([neo4j], {
|
||||
searchQuery: "",
|
||||
selectedCategory: "relational",
|
||||
driverMatchesSearch: labelSearch,
|
||||
driverCategory: categoryOf,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("search takes precedence over category when both are active", () => {
|
||||
// Neo4j is in "graph" category; user selected "graph" but searched "oracle".
|
||||
expect(
|
||||
hasAnyUpdatableDriverMatching([neo4j, oracle], {
|
||||
searchQuery: "oracle",
|
||||
selectedCategory: "graph",
|
||||
driverMatchesSearch: labelSearch,
|
||||
driverCategory: categoryOf,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
export const DRIVER_CATEGORIES = [
|
||||
{ key: "sql", order: 1, titleKey: "connection.databaseCategorySql" },
|
||||
{ key: "analytics", order: 2, titleKey: "connection.databaseCategoryAnalytics" },
|
||||
{ key: "domestic", order: 3, titleKey: "connection.databaseCategoryDomestic" },
|
||||
{ key: "lightweight", order: 4, titleKey: "connection.databaseCategoryLightweight" },
|
||||
{ key: "document", order: 5, titleKey: "connection.databaseCategoryDocument" },
|
||||
{ key: "graph_ai", order: 6, titleKey: "connection.databaseCategoryGraphAi" },
|
||||
{ key: "timeseries", order: 7, titleKey: "connection.databaseCategoryTimeseries" },
|
||||
{ key: "mq", order: 8, titleKey: "connection.databaseCategoryMq" },
|
||||
{ key: "registry_config", order: 9, titleKey: "connection.databaseCategoryRegistryConfig" },
|
||||
] as const;
|
||||
|
||||
export type DriverCategoryKey = (typeof DRIVER_CATEGORIES)[number]["key"];
|
||||
|
||||
const VALID_CATEGORY_KEYS: ReadonlySet<string> = new Set(DRIVER_CATEGORIES.map((cat) => cat.key));
|
||||
|
||||
const EMPTY = 0;
|
||||
|
||||
export const AGENT_DRIVER_CATEGORY_MAP: Readonly<Record<string, DriverCategoryKey>> = {
|
||||
access: "lightweight",
|
||||
bigquery: "analytics",
|
||||
cassandra: "document",
|
||||
dameng: "domestic",
|
||||
databend: "analytics",
|
||||
databricks: "analytics",
|
||||
db2: "sql",
|
||||
etcd: "registry_config",
|
||||
exasol: "analytics",
|
||||
firebird: "sql",
|
||||
gbase8a: "domestic",
|
||||
gbase8s: "domestic",
|
||||
goldendb: "domestic",
|
||||
h2: "lightweight",
|
||||
"h2-legacy": "lightweight",
|
||||
highgo: "domestic",
|
||||
hive: "analytics",
|
||||
influxdb: "timeseries",
|
||||
informix: "sql",
|
||||
iotdb: "timeseries",
|
||||
iris: "sql",
|
||||
kafka: "mq",
|
||||
kingbase: "domestic",
|
||||
kylin: "analytics",
|
||||
mongodb: "document",
|
||||
neo4j: "graph_ai",
|
||||
"oceanbase-oracle": "domestic",
|
||||
oracle: "sql",
|
||||
oscar: "domestic",
|
||||
prestosql: "analytics",
|
||||
rabbitmq: "mq",
|
||||
rocketmq: "mq",
|
||||
saphana: "analytics",
|
||||
snowflake: "analytics",
|
||||
spark: "analytics",
|
||||
"sqlserver-legacy": "sql",
|
||||
sundb: "domestic",
|
||||
tdengine: "timeseries",
|
||||
teradata: "analytics",
|
||||
trino: "analytics",
|
||||
uxdb: "domestic",
|
||||
vastbase: "domestic",
|
||||
vertica: "analytics",
|
||||
xugu: "domestic",
|
||||
yashandb: "domestic",
|
||||
zookeeper: "registry_config",
|
||||
};
|
||||
|
||||
export const getCategoryForAgentDriver = (dbType: string): DriverCategoryKey | "all" => AGENT_DRIVER_CATEGORY_MAP[dbType] ?? "all";
|
||||
|
||||
const collectUnmapped = (driverKeys: string[]): string[] => driverKeys.filter((key) => !(key in AGENT_DRIVER_CATEGORY_MAP));
|
||||
|
||||
const collectUnknownCategories = (): string[] =>
|
||||
Object.entries(AGENT_DRIVER_CATEGORY_MAP)
|
||||
.filter(([, category]) => !VALID_CATEGORY_KEYS.has(category))
|
||||
.map(([key, category]) => `${key}->${category}`);
|
||||
|
||||
const collectDuplicateKeys = (driverKeys: string[]): string[] => driverKeys.filter((key, index) => driverKeys.indexOf(key) !== index).filter((key, index, arr) => arr.indexOf(key) === index);
|
||||
|
||||
const formatErrorMessage = (unmapped: string[], unknownCategories: string[], duplicateKeys: string[]): string => {
|
||||
const parts: string[] = [];
|
||||
if (unmapped.length > EMPTY) {
|
||||
parts.push(`unmapped=${unmapped.join(",")}`);
|
||||
}
|
||||
if (unknownCategories.length > EMPTY) {
|
||||
parts.push(`unknownCategories=${unknownCategories.join(",")}`);
|
||||
}
|
||||
if (duplicateKeys.length > EMPTY) {
|
||||
parts.push(`duplicateKeys=${duplicateKeys.join(",")}`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that every driver key in {@link agentDriverDbTypes} has a category
|
||||
* mapping and that all mapped categories are valid. Throws if any driver is
|
||||
* unmapped, duplicates appear, or an unknown category is referenced.
|
||||
*/
|
||||
export const assertAgentDriverCategoriesComplete = (agentDriverDbTypes: string[]): void => {
|
||||
const unmapped = collectUnmapped(agentDriverDbTypes);
|
||||
const unknownCategories = collectUnknownCategories();
|
||||
const duplicateKeys = collectDuplicateKeys(agentDriverDbTypes);
|
||||
|
||||
if (unmapped.length > EMPTY || unknownCategories.length > EMPTY || duplicateKeys.length > EMPTY) {
|
||||
throw new Error(formatErrorMessage(unmapped, unknownCategories, duplicateKeys));
|
||||
}
|
||||
|
||||
// Warn in dev if the map has stale entries for drivers not in the catalog.
|
||||
if (import.meta.env.DEV) {
|
||||
const driverSet = new Set(agentDriverDbTypes);
|
||||
const mappedButNotListed = Object.keys(AGENT_DRIVER_CATEGORY_MAP).filter((key) => !driverSet.has(key));
|
||||
if (mappedButNotListed.length > EMPTY) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[driver-category-definitions] agent driver category map has entries for drivers not in the supplied list:", mappedButNotListed.join(", "));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AgentDriverInfo } from "@/lib/backend/api";
|
||||
|
||||
/**
|
||||
* Drivers that have an update available — always rendered above category
|
||||
* navigation so the user can see and act on them regardless of the active
|
||||
* category filter.
|
||||
*/
|
||||
export function selectUpdatableDrivers(drivers: readonly AgentDriverInfo[]): AgentDriverInfo[] {
|
||||
return drivers.filter((d) => d.update_available);
|
||||
}
|
||||
|
||||
/**
|
||||
* Category-filtered drivers *excluding* updatable ones — the per‑item
|
||||
* "Update" action lives in the global update section, so the category
|
||||
* list only renders stable (non‑updatable) rows.
|
||||
*/
|
||||
export function selectStableDrivers(drivers: readonly AgentDriverInfo[]): AgentDriverInfo[] {
|
||||
return drivers.filter((d) => !d.update_available);
|
||||
}
|
||||
|
||||
export interface UpdatableDriverMatchOptions {
|
||||
/** Lowercased search query (empty string when search is inactive). */
|
||||
searchQuery: string;
|
||||
/** Currently selected category key ("all" when no specific category). */
|
||||
selectedCategory: string;
|
||||
/** Predicate: does `driver` match `query` (already lowercased)? */
|
||||
driverMatchesSearch: (driver: AgentDriverInfo, query: string) => boolean;
|
||||
/** Returns the category key for `driver`. */
|
||||
driverCategory: (driver: AgentDriverInfo) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when at least one driver in `updatableDrivers` is relevant to
|
||||
* the current view (search query or selected category). Used to decide whether
|
||||
* an empty‑state message should be suppressed: the global update section always
|
||||
* renders *all* updatable drivers, so the empty‑state must only be hidden when
|
||||
* at least one of those drivers is actually relevant.
|
||||
*/
|
||||
export function hasAnyUpdatableDriverMatching(updatableDrivers: readonly AgentDriverInfo[], opts: UpdatableDriverMatchOptions): boolean {
|
||||
if (updatableDrivers.length === 0) return false;
|
||||
if (opts.searchQuery) {
|
||||
return updatableDrivers.some((d) => opts.driverMatchesSearch(d, opts.searchQuery));
|
||||
}
|
||||
if (opts.selectedCategory !== "all") {
|
||||
return updatableDrivers.some((d) => opts.driverCategory(d) === opts.selectedCategory);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user