Files
dbx/apps/desktop/src/components/config/DriverStoreDialog.vue
T

1850 lines
81 KiB
Vue

<script setup lang="ts">
import { ref, 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
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 { formatRuntimeBytes, formatRuntimeCpu, formatRuntimeUptime, runtimeHealthClass, runtimeStatusClass, runtimeStatusDotClass } from "@/lib/connection/driverRuntimePresentation";
import { addDriverInstallQueue, driverInstallProgressPercent, isDriverInstallProgressTarget, removeDriverInstallQueue, takeNextDriverInstallQueue, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { PRESTOSQL_DRIVER_DB_TYPE, prestoSqlBuiltinDriverRow, prestoSqlMavenBundle } from "@/lib/database/prestoSqlBuiltinDriver";
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
const { t } = useI18n();
const { toast } = useToast();
const isWeb = !isTauriRuntime();
const props = withDefaults(
defineProps<{
updateNotificationsEnabled?: boolean;
activeTab?: "agent" | "jdbc" | "storage" | "runtime";
focusTarget?: DriverStoreFocus | null;
}>(),
{
updateNotificationsEnabled: true,
activeTab: "agent",
focusTarget: null,
},
);
const emit = defineEmits<{
"update-count-change": [count: number];
"update:activeTab": [tab: "agent" | "jdbc" | "storage" | "runtime"];
}>();
const driverStoreTab = computed({
get: () => props.activeTab,
set: (tab: "agent" | "jdbc" | "storage" | "runtime") => emit("update:activeTab", tab),
});
// ──────────── Driver store path ────────────
import { useSettingsStore } from "@/stores/settingsStore";
import type { DriverStorePathInfo } from "@/lib/backend/api";
import { driverRuntimeProtocolLabel } from "./driverRuntimeDisplay";
const settingsStore = useSettingsStore();
type DriverStoreDirKind = "plugin" | "agent";
const legacyDriverStoreDir = computed(() => settingsStore.desktopSettings.driver_store_dir ?? null);
const pluginStoreDir = computed(() => settingsStore.desktopSettings.plugin_store_dir ?? null);
const agentStoreDir = computed(() => settingsStore.desktopSettings.agent_store_dir ?? null);
const driverStoreDirMigrating = ref<DriverStoreDirKind | null>(null);
const currentDriverStorePath = ref<DriverStorePathInfo | null>(null);
async function loadDriverStorePath() {
if (isWeb) return;
try {
currentDriverStorePath.value = await api.getDriverStorePath();
} catch {
currentDriverStorePath.value = null;
}
}
function configuredDriverStoreDir(kind: DriverStoreDirKind): string | null {
if (kind === "plugin") {
return pluginStoreDir.value ?? (legacyDriverStoreDir.value ? `${legacyDriverStoreDir.value}/plugins` : null);
}
return agentStoreDir.value ?? (legacyDriverStoreDir.value ? `${legacyDriverStoreDir.value}/agents` : null);
}
function actualDriverStoreDir(kind: DriverStoreDirKind): string | null {
if (!currentDriverStorePath.value) return null;
return kind === "plugin" ? currentDriverStorePath.value.plugins_dir : currentDriverStorePath.value.agents_dir;
}
function driverStoreDirDisplay(kind: DriverStoreDirKind): string {
return actualDriverStoreDir(kind) ?? configuredDriverStoreDir(kind) ?? t("driverStore.driverStoreDirDefault");
}
function driverStoreTargetLabel(kind: DriverStoreDirKind): string {
return kind === "plugin" ? t("driverStore.pluginStoreDir") : t("driverStore.agentStoreDir");
}
const driverStorePathRows = computed(() => [
{
kind: "plugin" as const,
label: t("driverStore.pluginStoreDir"),
description: t("driverStore.pluginStoreDirDescription"),
display: driverStoreDirDisplay("plugin"),
custom: Boolean(pluginStoreDir.value || legacyDriverStoreDir.value),
},
{
kind: "agent" as const,
label: t("driverStore.agentStoreDir"),
description: t("driverStore.agentStoreDirDescription"),
display: driverStoreDirDisplay("agent"),
custom: Boolean(agentStoreDir.value || legacyDriverStoreDir.value),
},
]);
async function chooseDriverStoreDir(kind: DriverStoreDirKind) {
if (isWeb || driverStoreDirMigrating.value) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("driverStore.driverStoreDirDialogTitle", { target: driverStoreTargetLabel(kind) }),
directory: true,
multiple: false,
});
if (typeof selected === "string") {
await applyDriverStoreDir(kind, selected);
}
}
async function resetDriverStoreDir(kind: DriverStoreDirKind) {
if (driverStoreDirMigrating.value) return;
await applyDriverStoreDir(kind, null);
}
async function applyDriverStoreDir(kind: DriverStoreDirKind, newDir: string | null) {
if (driverStoreDirMigrating.value) return;
const target = driverStoreTargetLabel(kind);
const confirmed = window.confirm(t("driverStore.driverStoreDirConfirm", { target }));
if (!confirmed) return;
driverStoreDirMigrating.value = kind;
try {
const result = kind === "plugin" ? await api.setPluginStoreDir(newDir) : await api.setAgentStoreDir(newDir);
settingsStore.desktopSettings.driver_store_dir = result.driver_store_dir;
settingsStore.desktopSettings.plugin_store_dir = result.plugin_store_dir;
settingsStore.desktopSettings.agent_store_dir = result.agent_store_dir;
currentDriverStorePath.value = {
driver_store_dir: result.driver_store_dir,
plugin_store_dir: result.plugin_store_dir,
agent_store_dir: result.agent_store_dir,
plugins_dir: result.plugins_dir,
agents_dir: result.agents_dir,
};
toast(t("driverStore.driverStoreDirSuccess", { target }));
// Restart the app to use the new paths
const { relaunch } = await import("@tauri-apps/plugin-process");
relaunch();
} catch (e: any) {
toast(t("driverStore.driverStoreDirMigrationFailed", { error: e?.message || String(e) }), 5000);
} finally {
driverStoreDirMigrating.value = null;
}
}
// ──────────── Agent drivers ────────────
const drivers = ref<AgentDriverInfo[]>([]);
const agentDriverSearch = ref("");
const installing = ref<string | null>(null);
const upgradingAll = ref(false);
const upgradingCurrent = ref("");
const upgradingIndex = ref(0);
const upgradingTotal = ref(0);
const queuedDriverInstalls = ref<string[]>([]);
const reinstallingJre = ref<string | null>(null);
const refreshing = ref(false);
const progress = ref<DriverInstallProgress | null>(null);
const javaRuntimeConfig = ref<JavaRuntimeConfig>({ mode: "managed", custom_java_path: null });
const customJavaPath = ref("");
const savingJavaRuntime = ref(false);
const driverStoreUsage = ref<DriverStoreUsage | null>(null);
const clearingDownloadCache = ref(false);
const runtimeSummary = ref<DriverRuntimeSummary | null>(null);
const runtimeLoading = ref(false);
const runtimeError = ref("");
const runtimeBusy = ref<string | null>(null);
let runtimeTimer: ReturnType<typeof setInterval> | null = null;
const DRIVER_RUNTIME_POLL_MS = 5000;
const OFFLINE_DRIVER_DOWNLOAD_URL = "https://dbxio.com/cn/drivers";
let unlisten: (() => void) | null = null;
const lastProgressPercent = ref<number | null>(null);
const installedJres = computed(() => {
const jreMap = new Map<string, boolean>();
for (const d of drivers.value) {
if (driverRequiresJavaRuntime(d) && d.jre && !jreMap.has(d.jre)) {
jreMap.set(d.jre, d.jre_installed);
}
}
return [...jreMap.entries()].map(([key, installed]) => ({ key, installed })).sort((a, b) => b.key.localeCompare(a.key));
});
const progressText = computed(() => {
const p = progress.value;
if (!p) return "";
if (p.step === "jre-extract") return t("driverStore.progressJreExtract");
if (p.step === "jdbc-plugin-extract") return t("driverStore.progressJdbcPluginExtract");
const label = p.step === "jre" ? t("driverStore.progressDownloadJre") : p.step === "jdbc-plugin" ? t("driverStore.progressDownloadJdbcPlugin") : t("driverStore.progressDownloadDriver");
if (!p.total) return `${label}...`;
const pct = Math.round(((p.downloaded ?? 0) / p.total) * 100);
const dl = formatSize(p.downloaded ?? 0);
const total = formatSize(p.total);
const prefix = upgradingAll.value && upgradingCurrent.value ? `[${upgradingIndex.value}/${upgradingTotal.value}] ${upgradingCurrent.value} - ` : "";
return `${prefix}${label} ${dl} / ${total} (${pct}%)`;
});
const progressNumber = computed(() => {
const next = driverInstallProgressPercent(progress.value);
if (next !== null) {
lastProgressPercent.value = next;
}
return next ?? lastProgressPercent.value;
});
function resetInstallProgress() {
progress.value = null;
lastProgressPercent.value = null;
}
const downloadCacheBytes = computed(() => Number(driverStoreUsage.value?.download_cache_bytes || 0));
const usageSummary = computed(() => {
const usage = driverStoreUsage.value;
if (!usage) return [];
return [
{ key: "total", label: t("driverStore.usageTotalLabel"), bytes: usage.total_bytes },
{ key: "jre", label: t("driverStore.usageManagedJre"), bytes: usage.jre_bytes },
{ key: "agent", label: t("driverStore.usageAgentDrivers"), bytes: usage.agent_driver_bytes },
{ key: "download-cache", label: t("driverStore.usageDownloadCache"), bytes: usage.download_cache_bytes || 0 },
{ key: "jdbc-plugin", label: t("driverStore.usageJdbcPlugin"), bytes: usage.jdbc_plugin_bytes },
{ key: "jdbc-driver", label: t("driverStore.usageJdbcDriverJars"), bytes: usage.jdbc_driver_bytes },
];
});
const canClearDownloadCache = computed(() => !clearingDownloadCache.value && installing.value === null && !upgradingAll.value && reinstallingJre.value === null && downloadCacheBytes.value > 0);
const downloadSourceBusy = computed(() => refreshing.value || installing.value !== null || upgradingAll.value || queuedDriverInstalls.value.length > 0 || reinstallingJre.value !== null);
const jreUsageByKey = computed(() => {
const map = new Map<string, number>();
for (const item of driverStoreUsage.value?.jres || []) {
map.set(String(item.id), Number(item.bytes || 0));
}
return map;
});
function updateAgentDrivers(nextDrivers: AgentDriverInfo[]) {
drivers.value = nextDrivers;
emitDriverUpdateCount();
}
const agentTabUpdateCount = computed(() => drivers.value.filter((d) => d.update_available).length);
const jdbcTabUpdateCount = computed(() => (jdbcPluginStatus.value?.update_available ? 1 : 0));
function emitDriverUpdateCount() {
if (!props.updateNotificationsEnabled) {
emit("update-count-change", 0);
return;
}
emit("update-count-change", countAvailableDriverUpdates(drivers.value, jdbcPluginStatus.value));
}
function isDriverProgressActive(dbType: string): boolean {
return isDriverInstallProgressTarget(dbType, {
installing: installing.value,
upgradingAll: upgradingAll.value,
progress: progress.value,
});
}
function driverRequiresJavaRuntime(driver: AgentDriverInfo): boolean {
return driver.requires_java_runtime ?? Boolean(driver.jre);
}
function progressTitle(fallback: string): string {
return progressText.value || fallback;
}
function isPrestoSqlBuiltinDriver(dbType: string): boolean {
return dbType === PRESTOSQL_DRIVER_DB_TYPE;
}
const builtinDriverRows = computed<AgentDriverInfo[]>(() => [...drivers.value, prestoSqlBuiltinDriverRow(jdbcMavenBundles.value)]);
function driverLabel(dbType: string): string {
return builtinDriverRows.value.find((d) => d.db_type === dbType)?.label ?? dbType;
}
function isDriverQueued(dbType: string): boolean {
return queuedDriverInstalls.value.includes(dbType);
}
function canInstallOrUpdateDriver(dbType: string): boolean {
const driver = builtinDriverRows.value.find((d) => d.db_type === dbType);
return Boolean(driver && (!driver.installed || driver.update_available));
}
async function openOfflineDriverDownload() {
if (isWeb) {
window.open(OFFLINE_DRIVER_DOWNLOAD_URL, "_blank", "noopener,noreferrer");
return;
}
const { open } = await import("@tauri-apps/plugin-shell");
await open(OFFLINE_DRIVER_DOWNLOAD_URL);
}
function queueDriverInstall(dbType: string) {
queuedDriverInstalls.value = addDriverInstallQueue(queuedDriverInstalls.value, dbType, installing.value);
}
function removeQueuedDriverInstall(dbType: string) {
queuedDriverInstalls.value = removeDriverInstallQueue(queuedDriverInstalls.value, dbType);
}
async function refreshAgents() {
updateAgentDrivers(await api.listInstalledAgents());
void loadDriverStoreUsage();
}
async function forceRefresh() {
refreshing.value = true;
try {
await api.invalidateAgentRegistryCache();
await refreshAgents();
} finally {
refreshing.value = false;
}
}
function setUpdateDownloadSource(value: unknown) {
if (value !== "official" && value !== "cnb") return;
if (value === settingsStore.editorSettings.updateDownloadSource) return;
settingsStore.updateEditorSettings({ updateDownloadSource: value });
void forceRefresh().catch(() => undefined);
}
async function loadJavaRuntimeConfig() {
const config = await api.getAgentJavaRuntimeConfig();
javaRuntimeConfig.value = config;
customJavaPath.value = config.custom_java_path ?? "";
}
function setJavaRuntimeMode(value: any) {
if (value === "managed" || value === "system" || value === "custom") {
javaRuntimeConfig.value.mode = value;
}
}
async function saveJavaRuntimeConfig() {
savingJavaRuntime.value = true;
try {
const config = await api.setAgentJavaRuntimeConfig({
mode: javaRuntimeConfig.value.mode,
custom_java_path: javaRuntimeConfig.value.mode === "custom" ? customJavaPath.value.trim() || null : null,
});
javaRuntimeConfig.value = config;
customJavaPath.value = config.custom_java_path ?? "";
toast(t("driverStore.javaRuntimeSaved"));
} catch (e: any) {
toast(t("driverStore.javaRuntimeSaveFailed", { error: e }));
} finally {
savingJavaRuntime.value = false;
}
}
async function chooseCustomJavaPath() {
if (isWeb) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("driverStore.chooseJavaExecutable"),
multiple: false,
});
if (typeof selected === "string") {
customJavaPath.value = selected;
}
}
async function installDriver(dbType: string) {
if (installing.value !== null || upgradingAll.value) {
queueDriverInstall(dbType);
return;
}
await runDriverInstall(dbType);
await runQueuedDriverInstalls();
}
async function runDriverInstall(dbType: string) {
const label = driverLabel(dbType);
installing.value = dbType;
resetInstallProgress();
try {
if (isPrestoSqlBuiltinDriver(dbType)) {
if (!jdbcPluginStatus.value?.installed || !jdbcPluginStatus.value.compatible) {
jdbcPluginStatus.value = await api.installJdbcPlugin();
emitDriverUpdateCount();
}
jdbcDrivers.value = await api.installPrestoSqlJdbcDriver();
jdbcMavenBundles.value = await api.listJdbcMavenBundles();
void loadDriverStoreUsage();
toast(t("driverStore.driverInstallSuccess", { label }));
return;
}
const blockers = await api.checkAgentUpdateBlockers([dbType]);
if (blockers.length > 0) {
toast(t("driverStore.driverUpdateBlocked", { labels: blockers.map((blocker) => blocker.label).join(", ") }));
return;
}
await api.installAgent(dbType);
await refreshAgents();
toast(t("driverStore.driverInstallSuccess", { label }));
} catch (e: any) {
toast(t("driverStore.driverInstallFailed", { label, error: e }));
} finally {
installing.value = null;
resetInstallProgress();
}
}
async function runQueuedDriverInstalls() {
if (installing.value !== null || upgradingAll.value) return;
const result = takeNextDriverInstallQueue(queuedDriverInstalls.value, canInstallOrUpdateDriver);
queuedDriverInstalls.value = result.queue;
if (!result.next) return;
await runDriverInstall(result.next);
await runQueuedDriverInstalls();
}
async function upgradeAll() {
upgradingAll.value = true;
queuedDriverInstalls.value = [];
resetInstallProgress();
try {
const updatableDbTypes = drivers.value.filter((driver) => driver.update_available).map((driver) => driver.db_type);
const blockers = await api.checkAgentUpdateBlockers(updatableDbTypes);
if (blockers.length > 0) {
toast(t("driverStore.driverUpdateBlocked", { labels: blockers.map((blocker) => blocker.label).join(", ") }));
return;
}
const result = await api.upgradeAllAgents();
await refreshAgents();
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 }));
} else {
toast(t("driverStore.upgradeAllSuccess", { count: result.upgraded }));
}
} catch (e: any) {
toast(t("driverStore.upgradeAllFailed", { error: e }));
} finally {
upgradingAll.value = false;
upgradingCurrent.value = "";
upgradingIndex.value = 0;
upgradingTotal.value = 0;
resetInstallProgress();
}
}
async function uninstallDriver(dbType: string) {
const label = driverLabel(dbType);
try {
if (isPrestoSqlBuiltinDriver(dbType)) {
const bundle = prestoSqlMavenBundle(jdbcMavenBundles.value);
if (!bundle) return;
jdbcDrivers.value = await api.deleteJdbcMavenBundle(bundle.id);
jdbcMavenBundles.value = await api.listJdbcMavenBundles();
void loadDriverStoreUsage();
toast(t("driverStore.driverUninstallSuccess", { label }));
return;
}
await api.uninstallAgent(dbType);
await refreshAgents();
toast(t("driverStore.driverUninstallSuccess", { label }));
} catch (e: any) {
toast(t("driverStore.driverUninstallFailed", { label, error: e }));
}
}
const importingZip = ref(false);
function chooseWebOfflineZip(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip";
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
}
function chooseWebFiles(accept: string, multiple: boolean): Promise<File[] | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.multiple = multiple;
input.onchange = () => {
const files = input.files;
if (!files || files.length === 0) {
resolve(null);
return;
}
resolve(Array.from(files));
};
input.click();
});
}
function chooseWebFile(accept: string): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
}
async function importOfflineZip() {
if (importingZip.value) return;
let selected: string | File | null = null;
if (isWeb) {
selected = await chooseWebOfflineZip();
} else {
const { open } = await import("@tauri-apps/plugin-dialog");
const path = await open({
title: t("driverStore.chooseOfflineDriverPackage"),
multiple: false,
filters: [{ name: "ZIP", extensions: ["zip"] }],
});
selected = typeof path === "string" ? path : null;
}
if (!selected) return;
importingZip.value = true;
resetInstallProgress();
try {
const count = await api.importAgentsFromZip(selected);
await refreshAgents();
toast(t("driverStore.offlineImportSuccess", { count }));
} catch (e: any) {
toast(t("driverStore.offlineImportFailed", { error: e }));
} finally {
importingZip.value = false;
resetInstallProgress();
}
}
async function importDriverJar(dbType: string) {
if (isPrestoSqlBuiltinDriver(dbType)) {
await importJdbcDrivers();
return;
}
const label = driverLabel(dbType);
if (isWeb) {
const file = await chooseWebFile(".jar");
if (!file) return;
try {
await api.importAgentJar(dbType, file);
await refreshAgents();
toast(t("driverStore.driverImportSuccess", { label }));
} catch (e: any) {
toast(t("driverStore.driverImportFailed", { label, error: e }));
}
return;
}
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("driverStore.chooseDriverJar"),
multiple: false,
filters: [{ name: "JAR", extensions: ["jar"] }],
});
if (typeof selected !== "string") return;
try {
await api.importAgentJar(dbType, selected);
await refreshAgents();
toast(t("driverStore.driverImportSuccess", { label }));
} catch (e: any) {
toast(t("driverStore.driverImportFailed", { label, error: e }));
}
}
async function reinstallJre(jreKey: string) {
reinstallingJre.value = jreKey;
resetInstallProgress();
try {
await api.reinstallJre(jreKey);
await refreshAgents();
toast(t("driverStore.jreReinstallSuccess", { jre: jreKey }));
} catch (e: any) {
toast(t("driverStore.jreReinstallFailed", { jre: jreKey, error: e }));
} finally {
reinstallingJre.value = null;
resetInstallProgress();
}
}
async function uninstallJre(jreKey: string) {
try {
await api.uninstallJre(jreKey);
await refreshAgents();
toast(t("driverStore.jreUninstallSuccess", { jre: jreKey }));
} catch (e: any) {
toast(String(e));
}
}
function formatSize(bytes: number): string {
if (!bytes) return "";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// ──────────── JDBC drivers ────────────
const jdbcDrivers = ref<JdbcDriverInfo[]>([]);
const jdbcMavenBundles = ref<JdbcMavenBundleInfo[]>([]);
const jdbcLocalBundles = ref<JdbcLocalBundleInfo[]>([]);
const jdbcDriverSearch = ref("");
const isLoadingJdbcDrivers = ref(false);
const jdbcPluginStatus = ref<JdbcPluginStatus | null>(null);
const isInstallingJdbcPlugin = ref(false);
const isUninstallingJdbcPlugin = ref(false);
const jdbcDriverPathInput = ref("");
const jdbcMavenCoordinateInput = ref("");
const jdbcMavenRepository = ref("https://repo.maven.apache.org/maven2/");
const customJdbcMavenRepository = ref("");
const isInstallingJdbcMavenDriver = ref(false);
const jdbcMavenRepositoryOptions = [
{ label: "Maven Central", value: "https://repo.maven.apache.org/maven2/" },
{ label: "Aliyun", value: "https://maven.aliyun.com/repository/public" },
{ label: "Huawei Cloud", value: "https://repo.huaweicloud.com/repository/maven" },
{ label: "Tencent Cloud", value: "https://mirrors.cloud.tencent.com/nexus/repository/maven-public" },
{ label: "Custom", value: "custom" },
];
type JdbcDriverListItem =
| {
kind: "manual";
id: string;
title: string;
subtitle: string;
source: string;
size: number;
driver: JdbcDriverInfo;
}
| {
kind: "local";
id: string;
title: string;
subtitle: string;
source: string;
size: number;
bundle: JdbcLocalBundleInfo;
}
| {
kind: "maven";
id: string;
title: string;
subtitle: string;
source: string;
size: number;
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));
});
/** 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);
}
}
return [...updatable, ...stable];
});
/** 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;
});
/** 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));
const highlightedFocusKey = ref<string | null>(null);
let focusHighlightTimer: ReturnType<typeof setTimeout> | undefined;
function focusElementKey(focus: DriverStoreFocus): string {
return focus.target === "driver" ? `driver:${focus.driver ?? ""}` : "jre";
}
watch(
[() => props.focusTarget, builtinDriverRows],
async ([focus]) => {
if (!focus || focus.target === "tab") return;
driverStoreTab.value = "agent";
if (focus.target === "driver") {
// 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 = "";
}
const key = focusElementKey(focus);
highlightedFocusKey.value = key;
await nextTick();
document.querySelector(`[data-driver-store-focus="${CSS.escape(key)}"]`)?.scrollIntoView({ block: "center", behavior: "smooth" });
clearTimeout(focusHighlightTimer);
focusHighlightTimer = setTimeout(() => {
if (highlightedFocusKey.value === key) highlightedFocusKey.value = null;
}, 6000);
},
{ immediate: true },
);
const jdbcDriverListItems = computed<JdbcDriverListItem[]>(() => {
const localBundleItems = jdbcLocalBundles.value.map((bundle) => ({
kind: "local" as const,
id: `local:${bundle.id}`,
title: bundle.name,
subtitle: `${bundle.artifacts.length} JARs - ${bundle.artifacts.map((artifact) => artifact.file_name).join(", ")}`,
source: t("driverStore.jdbcSourceManual"),
size: bundle.artifacts.reduce((total, artifact) => total + Number(artifact.size || 0), 0),
bundle,
}));
const bundleItems = jdbcMavenBundles.value.map((bundle) => ({
kind: "maven" as const,
id: `maven:${bundle.id}`,
title: bundle.coordinate,
subtitle: `${bundle.artifacts.length} JARs - ${bundle.repositories.join(", ")}`,
source: t("driverStore.jdbcSourceMaven"),
size: bundle.artifacts.reduce((total, artifact) => total + Number(artifact.size || 0), 0),
bundle,
}));
const manualItems = jdbcDrivers.value
.filter((driver) => !driver.bundle_id)
.map((driver) => ({
kind: "manual" as const,
id: `manual:${driver.path}`,
title: driver.name,
subtitle: driver.path,
source: t("driverStore.jdbcSourceManual"),
size: driver.size,
driver,
}));
return [...localBundleItems, ...bundleItems, ...manualItems].sort((a, b) => a.title.localeCompare(b.title));
});
const filteredJdbcDrivers = computed(() => {
const query = jdbcDriverSearch.value.trim().toLowerCase();
if (!query) return jdbcDriverListItems.value;
return jdbcDriverListItems.value.filter((item) => [item.title, item.subtitle, String(item.size)].join(" ").toLowerCase().includes(query));
});
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
const runtimeOverview = computed(() => {
const summary = runtimeSummary.value;
return [
{
key: "running",
label: t("driverStore.runtimeRunning"),
value: String(summary?.running_count ?? 0),
},
{
key: "memory",
label: t("driverStore.runtimeMemory"),
value: formatRuntimeBytes(summary?.total_memory_bytes),
},
{
key: "health",
label: t("driverStore.runtimeHealth"),
value: t(`driverStore.runtimeHealth_${summary?.health ?? "healthy"}`),
class: runtimeHealthClass(summary?.health ?? "healthy"),
},
];
});
function runtimeKindLabel(runtime: DriverRuntimeInfo) {
return runtime.kind === "plugin" ? t("driverStore.runtimeKindPlugin") : t("driverStore.runtimeKindAgent");
}
function runtimeSourceLabel(runtime: DriverRuntimeInfo) {
return runtime.source === "connection" ? t("driverStore.runtimeSourceConnection") : t("driverStore.runtimeSourceDaemon");
}
function runtimeStatusLabel(status: DriverRuntimeInfo["status"]) {
return t(`driverStore.runtimeStatus_${status}`);
}
function runtimeControlUnavailableReasonLabel(reason: string | null) {
if (reason === "connection-owned") return t("driverStore.runtimeControlConnectionOwned");
return reason || "-";
}
async function loadDriverRuntimeSummary(showLoading = false) {
if (showLoading) runtimeLoading.value = true;
try {
runtimeSummary.value = await api.getDriverRuntimeSummary();
runtimeError.value = "";
} catch (e: any) {
runtimeError.value = String(e?.message || e);
} finally {
runtimeLoading.value = false;
}
}
function startDriverRuntimePolling() {
if (runtimeTimer) return;
void loadDriverRuntimeSummary(true);
runtimeTimer = setInterval(() => {
if (driverStoreTab.value !== "storage") {
stopDriverRuntimePolling();
return;
}
void loadDriverRuntimeSummary(false);
}, DRIVER_RUNTIME_POLL_MS);
}
function stopDriverRuntimePolling() {
if (runtimeTimer) {
clearInterval(runtimeTimer);
runtimeTimer = null;
}
}
async function refreshDriverRuntime() {
if (driverStoreTab.value !== "runtime") return;
await loadDriverRuntimeSummary(true);
}
async function stopRuntime(runtime: DriverRuntimeInfo) {
runtimeBusy.value = runtime.id;
try {
await api.stopDriverRuntime(runtime.id);
await loadDriverRuntimeSummary(false);
toast(t("driverStore.runtimeStopSuccess", { label: runtime.label }));
} catch (e: any) {
toast(t("driverStore.runtimeStopFailed", { label: runtime.label, error: e }));
} finally {
runtimeBusy.value = null;
}
}
async function restartRuntime(runtime: DriverRuntimeInfo) {
runtimeBusy.value = runtime.id;
try {
await api.restartDriverRuntime(runtime.id);
await loadDriverRuntimeSummary(false);
toast(t("driverStore.runtimeRestartSuccess", { label: runtime.label }));
} catch (e: any) {
toast(t("driverStore.runtimeRestartFailed", { label: runtime.label, error: e }));
} finally {
runtimeBusy.value = null;
}
}
function jreUsageLabel(key: string) {
const bytes = jreUsageByKey.value.get(String(key)) || 0;
return bytes > 0 ? formatBytes(bytes) : "";
}
async function loadJdbcDrivers() {
isLoadingJdbcDrivers.value = true;
try {
const [drivers, bundles, localBundles] = await Promise.all([api.listJdbcDrivers(), api.listJdbcMavenBundles(), api.listJdbcLocalBundles()]);
jdbcDrivers.value = drivers;
jdbcMavenBundles.value = bundles;
jdbcLocalBundles.value = localBundles;
} catch (e: any) {
toast(String(e?.message || e), 5000);
} finally {
isLoadingJdbcDrivers.value = false;
void loadDriverStoreUsage();
}
}
async function loadDriverStoreUsage() {
try {
driverStoreUsage.value = await api.getDriverStoreUsage();
} catch {
driverStoreUsage.value = null;
}
}
async function clearDownloadCache() {
if (!canClearDownloadCache.value) return;
clearingDownloadCache.value = true;
try {
await api.clearDriverDownloadCache();
await loadDriverStoreUsage();
toast(t("driverStore.downloadCacheClearSuccess"));
} catch (e: any) {
toast(t("driverStore.downloadCacheClearFailed", { error: e?.message || String(e) }), 5000);
} finally {
clearingDownloadCache.value = false;
}
}
async function loadJdbcPluginStatus() {
try {
jdbcPluginStatus.value = await api.jdbcPluginStatus();
emitDriverUpdateCount();
} catch (e: any) {
toast(String(e?.message || e), 5000);
}
}
async function installJdbcPlugin() {
if (isInstallingJdbcPlugin.value) return;
isInstallingJdbcPlugin.value = true;
resetInstallProgress();
try {
jdbcPluginStatus.value = await api.installJdbcPlugin();
emitDriverUpdateCount();
toast(t("settings.jdbcPluginInstallSuccess"));
await loadJdbcDrivers();
} catch (e: any) {
toast(String(e?.message || e), 5000);
} finally {
isInstallingJdbcPlugin.value = false;
resetInstallProgress();
}
}
async function installJdbcPluginLocal() {
if (isInstallingJdbcPlugin.value) return;
let selected: string | File | null = null;
if (isWeb) {
selected = await chooseWebFile(".zip");
} else {
const { open } = await import("@tauri-apps/plugin-dialog");
const result = await open({
title: t("driverStore.chooseJdbcPluginZip"),
multiple: false,
filters: [{ name: "ZIP", extensions: ["zip"] }],
});
selected = typeof result === "string" ? result : null;
}
if (!selected) return;
isInstallingJdbcPlugin.value = true;
try {
jdbcPluginStatus.value = await api.installJdbcPluginLocal(selected);
emitDriverUpdateCount();
toast(t("settings.jdbcPluginInstallSuccess"));
await loadJdbcDrivers();
} catch (e: any) {
toast(String(e?.message || e), 5000);
} finally {
isInstallingJdbcPlugin.value = false;
}
}
async function uninstallJdbcPlugin() {
if (isUninstallingJdbcPlugin.value) return;
isUninstallingJdbcPlugin.value = true;
try {
jdbcPluginStatus.value = await api.uninstallJdbcPlugin();
emitDriverUpdateCount();
toast(t("settings.jdbcPluginUninstallSuccess"));
await loadJdbcDrivers();
} catch (e: any) {
toast(String(e?.message || e), 5000);
} finally {
isUninstallingJdbcPlugin.value = false;
}
}
async function importJdbcDriverPaths(paths: string[]) {
if (!paths.length) return;
try {
jdbcDrivers.value = await api.importJdbcDrivers(paths);
jdbcLocalBundles.value = await api.listJdbcLocalBundles();
jdbcDriverPathInput.value = "";
void loadDriverStoreUsage();
toast(t("settings.jdbcImportSuccess", { count: paths.length }));
} catch (e: any) {
toast(String(e?.message || e), 5000);
}
}
async function importJdbcDrivers() {
if (isWeb) {
const files = await chooseWebFiles(".jar", true);
if (!files || !files.length) return;
try {
jdbcDrivers.value = await api.importJdbcDrivers(files);
jdbcLocalBundles.value = await api.listJdbcLocalBundles();
void loadDriverStoreUsage();
toast(t("settings.jdbcImportSuccess", { count: files.length }));
} catch (e: any) {
toast(String(e?.message || e), 5000);
}
return;
}
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("settings.jdbcImport"),
multiple: true,
filters: [{ name: "JDBC Driver", extensions: ["jar"] }],
});
if (!selected) return;
const paths = (Array.isArray(selected) ? selected : [selected]).filter((path): path is string => typeof path === "string");
await importJdbcDriverPaths(paths);
}
async function importJdbcDriverPathInput() {
const paths = jdbcDriverPathInput.value
.split(/\r?\n/)
.map((path) => path.trim())
.filter(Boolean);
await importJdbcDriverPaths(paths);
}
async function installJdbcMavenDriver() {
const coordinate = jdbcMavenCoordinateInput.value.trim();
const repository = jdbcMavenRepository.value === "custom" ? customJdbcMavenRepository.value.trim() : jdbcMavenRepository.value;
if (!coordinate || !repository || isInstallingJdbcMavenDriver.value) return;
isInstallingJdbcMavenDriver.value = true;
try {
jdbcDrivers.value = await api.installJdbcDriverFromMaven(coordinate, [repository]);
jdbcMavenBundles.value = await api.listJdbcMavenBundles();
jdbcMavenCoordinateInput.value = "";
void loadDriverStoreUsage();
toast(t("driverStore.jdbcMavenInstallSuccess"));
} catch (e: any) {
toast(String(e?.message || e), 8000);
} finally {
isInstallingJdbcMavenDriver.value = false;
}
}
async function deleteJdbcDriver(path: string) {
try {
jdbcDrivers.value = await api.deleteJdbcDriver(path);
void loadDriverStoreUsage();
toast(t("settings.jdbcDeleteSuccess"));
} catch (e: any) {
toast(String(e?.message || e), 5000);
}
}
async function deleteJdbcMavenBundle(bundleId: string) {
try {
jdbcDrivers.value = await api.deleteJdbcMavenBundle(bundleId);
jdbcMavenBundles.value = await api.listJdbcMavenBundles();
void loadDriverStoreUsage();
toast(t("settings.jdbcDeleteSuccess"));
} catch (e: any) {
toast(String(e?.message || e), 5000);
}
}
async function deleteJdbcLocalBundle(bundleId: string) {
try {
jdbcDrivers.value = await api.deleteJdbcLocalBundle(bundleId);
jdbcLocalBundles.value = await api.listJdbcLocalBundles();
void loadDriverStoreUsage();
toast(t("settings.jdbcDeleteSuccess"));
} catch (e: any) {
toast(String(e?.message || e), 5000);
}
}
// ──────────── Lifecycle ────────────
onMounted(async () => {
updateAgentDrivers(await api.listInstalledAgentsLocal());
void loadJavaRuntimeConfig();
void loadDriverStoreUsage();
void loadDriverStorePath();
void forceRefresh().catch(() => undefined);
unlisten = await api.listenAgentInstallProgress((payload) => {
if (payload.step === "done" || payload.step === "all-done") {
progress.value = null;
} else {
progress.value = payload as DriverInstallProgress;
}
if (payload.db_type && payload.total_drivers) {
upgradingCurrent.value = drivers.value.find((d) => d.db_type === payload.db_type)?.label ?? payload.db_type;
upgradingIndex.value = payload.current ?? 0;
upgradingTotal.value = payload.total_drivers ?? 0;
}
// During a batch upgrade, refresh the list as soon as each driver finishes
// (step="done") so its "Update" button disappears immediately instead of
// staying disabled until the whole batch completes (step="all-done").
// Single-driver installs (upgradingAll=false) are refreshed by runDriverInstall.
if (upgradingAll.value && payload.step === "done") {
void refreshAgents();
}
});
void loadJdbcDrivers();
void loadJdbcPluginStatus();
});
onUnmounted(() => {
unlisten?.();
stopDriverRuntimePolling();
});
watch(driverStoreTab, (tab) => {
if (tab === "storage") {
startDriverRuntimePolling();
} else {
stopDriverRuntimePolling();
}
});
</script>
<template>
<div class="driver-store-view h-full flex flex-col">
<div class="driver-store-scroll flex-1 min-h-0 overflow-y-auto">
<div class="driver-store-container max-w-4xl mx-auto px-6 py-6">
<Tabs v-model="driverStoreTab" default-value="agent" class="driver-store-tabs-root">
<div class="driver-store-header flex items-center justify-between">
<TabsList class="driver-store-tabs grid w-[360px] grid-cols-3">
<TabsTrigger value="agent" class="gap-1.5 relative">
{{ t("driverStore.agentDrivers") }}
<span v-if="agentTabUpdateCount > 0" class="inline-block h-2 w-2 rounded-full bg-red-500" />
</TabsTrigger>
<TabsTrigger value="jdbc" class="gap-1.5 relative">
{{ t("driverStore.jdbcDrivers") }}
<span v-if="jdbcTabUpdateCount > 0" class="inline-block h-2 w-2 rounded-full bg-red-500" />
</TabsTrigger>
<TabsTrigger value="storage" class="gap-1.5">
{{ t("driverStore.storageTab") }}
</TabsTrigger>
</TabsList>
<div v-if="driverStoreTab !== 'storage'" class="flex flex-wrap items-center gap-2">
<div v-if="driverStoreTab === 'agent' && !isWeb" class="flex items-center gap-1.5">
<span class="text-xs text-muted-foreground">{{ t("settings.updateDownloadSource") }}</span>
<Select :model-value="settingsStore.editorSettings.updateDownloadSource" :disabled="downloadSourceBusy" @update:model-value="setUpdateDownloadSource">
<SelectTrigger class="h-7 w-[160px] rounded-[6px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="official">{{ t("settings.updateDownloadSourceOfficial") }}</SelectItem>
<SelectItem value="cnb">{{ t("settings.updateDownloadSourceCnb") }}</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="ghost" size="sm" class="h-7 rounded-[6px] text-xs gap-1 text-muted-foreground" :disabled="importingZip" @click="importOfflineZip">
<FileUp class="h-3.5 w-3.5" />
{{ importingZip ? t("driverStore.importing") : t("driverStore.importOfflinePackage") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 rounded-[6px] text-xs gap-1 text-muted-foreground" :disabled="refreshing" @click="forceRefresh">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': refreshing }" />
{{ t("driverStore.refresh") }}
</Button>
</div>
</div>
<!-- Agent Tab -->
<TabsContent value="agent" class="driver-store-tab driver-store-agent-tab mt-5 space-y-5">
<!-- Java Runtime -->
<div class="rounded-xl border bg-muted/20 p-4 space-y-3" data-driver-store-focus="jre" :class="{ 'driver-store-focus-highlight': highlightedFocusKey === 'jre' }">
<div class="flex flex-wrap items-center gap-2">
<Label class="shrink-0">{{ t("driverStore.javaRuntime") }}</Label>
<Select :model-value="javaRuntimeConfig.mode" @update:model-value="setJavaRuntimeMode">
<SelectTrigger class="h-8 min-w-[112px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="managed">{{ t("driverStore.javaRuntimeManaged") }}</SelectItem>
<SelectItem value="system">{{ t("driverStore.javaRuntimeSystem") }}</SelectItem>
<SelectItem value="custom">{{ t("driverStore.javaRuntimeCustom") }}</SelectItem>
</SelectContent>
</Select>
<Input v-if="javaRuntimeConfig.mode === 'custom'" v-model="customJavaPath" class="h-8 min-w-[180px] flex-1 text-xs" :placeholder="t('driverStore.customJavaPathPlaceholder')" @keydown.enter.prevent="saveJavaRuntimeConfig" />
<span v-else class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ javaRuntimeConfig.mode === "system" ? t("driverStore.systemJavaHint") : t("driverStore.jreRuntimeAutoDownloadHint") }}
</span>
<Button v-if="javaRuntimeConfig.mode === 'custom'" variant="outline" class="h-8 shrink-0 rounded-[6px] text-xs" @click="chooseCustomJavaPath">
<FolderOpen class="h-3.5 w-3.5" />
{{ t("driverStore.choose") }}
</Button>
<Button class="h-8 shrink-0 rounded-[6px] text-xs" :disabled="savingJavaRuntime || (javaRuntimeConfig.mode === 'custom' && !customJavaPath.trim())" @click="saveJavaRuntimeConfig">
{{ savingJavaRuntime ? t("driverStore.saving") : t("settings.save") }}
</Button>
</div>
<div v-if="installedJres.length > 0" class="divide-y rounded-lg border bg-background/50">
<div v-for="jre in installedJres" :key="jre.key" class="flex items-center justify-between gap-3 px-3 py-2.5">
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("driverStore.jreRuntimeTitle", { jre: jre.key }) }}</div>
</div>
<div class="flex shrink-0 items-center gap-3">
<span v-if="jreUsageLabel(jre.key)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{{ jreUsageLabel(jre.key) }}
</span>
<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="progressNumber" :title="progressTitle(jre.installed ? t('driverStore.reinstalling') : t('driverStore.installing'))" />
<Button v-else-if="!jre.installed" type="button" variant="default" size="sm" class="h-8 rounded-[6px] text-xs" :disabled="reinstallingJre !== null || installing !== null" @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-[6px] text-xs" :disabled="reinstallingJre !== null || installing !== null" @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-[6px] text-xs text-muted-foreground hover:text-destructive" :disabled="reinstallingJre !== null || installing !== null" @click="uninstallJre(jre.key)">
{{ t("driverStore.uninstall") }}
</Button>
</div>
</div>
</div>
</div>
<!-- Driver List -->
<div class="relative">
<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-md 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">
<div class="min-w-0">
<div class="text-sm font-semibold">{{ t("driverStore.updatesAvailableTitle") }} ({{ filteredUpdatableCount }})</div>
<p class="text-xs text-muted-foreground">{{ t("driverStore.updatesAvailableDescription") }}</p>
</div>
<Button size="sm" class="h-7 rounded-[6px] text-xs shrink-0 ml-3" :disabled="installing !== null || upgradingAll" @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: upgradingIndex, total: upgradingTotal }) : t("driverStore.upgradeAll") }}
</Button>
</div>
<div
v-for="driver in filteredUpdatableDrivers"
: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-lg 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-[6px] border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15" :disabled="upgradingAll" @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="progressNumber" :title="progressTitle(t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-[6px] text-xs" :disabled="upgradingAll" @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-[6px] text-xs text-muted-foreground"
:title="t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null"
@click="importDriverJar(driver.db_type)"
>
<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-[6px] border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll"
@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="progressNumber" :title="progressTitle(t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-[6px] text-xs" :disabled="upgradingAll" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-[6px] text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
{{ t("driverStore.uninstall") }}
</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-lg 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-[6px] border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15" :disabled="upgradingAll" @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="progressNumber" :title="progressTitle(t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-[6px] text-xs" :disabled="upgradingAll" @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-[6px] text-xs text-muted-foreground"
:title="t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null"
@click="importDriverJar(driver.db_type)"
>
<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-[6px] border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll"
@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="progressNumber" :title="progressTitle(t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-[6px] text-xs" :disabled="upgradingAll" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-[6px] text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
{{ t("driverStore.uninstall") }}
</Button>
</div>
</div>
</div>
</TabsContent>
<!-- JDBC Tab -->
<TabsContent value="jdbc" class="driver-store-tab driver-store-jdbc-tab mt-5 space-y-5">
<!-- JDBC Plugin -->
<div class="rounded-xl border bg-muted/20 p-4">
<div class="flex min-h-12 items-center justify-between gap-3">
<div class="min-w-0 space-y-1">
<Label>{{ t("settings.jdbcPlugin") }}</Label>
<p v-if="!jdbcPluginStatus?.installed" class="text-xs text-muted-foreground">
{{ t("settings.jdbcPluginNotInstalled") }}
</p>
</div>
<div class="flex shrink-0 items-center gap-3">
<DriverInstallProgressCircle v-if="isInstallingJdbcPlugin" :percent="progressNumber" :title="progressTitle(t('driverStore.progressDownloadJdbcPlugin'))" />
<span v-if="jdbcPluginStatus?.installed" class="text-xs" :class="jdbcPluginStatus.compatible ? 'text-green-600' : 'text-destructive'">
{{
jdbcPluginStatus.compatible
? t("settings.jdbcPluginInstalled", {
version: jdbcPluginStatus.version || "-",
})
: t("settings.jdbcPluginIncompatible")
}}
</span>
<span v-if="jdbcPluginStatus?.installed && jdbcPluginStatus.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ jdbcPluginStatus.latest_version }}</span>
<Button v-if="jdbcPluginStatus?.installed && jdbcPluginStatus.update_available" type="button" variant="outline" class="rounded-[6px]" :disabled="isInstallingJdbcPlugin" @click="installJdbcPlugin">
{{ isInstallingJdbcPlugin ? t("common.loading") : t("settings.jdbcPluginUpdate") }}
</Button>
<Button v-if="jdbcPluginStatus?.installed" type="button" variant="outline" class="rounded-[6px]" :disabled="isUninstallingJdbcPlugin" @click="uninstallJdbcPlugin">
{{ isUninstallingJdbcPlugin ? t("common.loading") : t("settings.jdbcPluginUninstall") }}
</Button>
<Button v-else type="button" variant="default" class="rounded-[6px]" :disabled="isInstallingJdbcPlugin" @click="installJdbcPlugin">
{{ isInstallingJdbcPlugin ? t("common.loading") : t("settings.jdbcPluginInstall") }}
</Button>
<Button v-if="!jdbcPluginStatus?.installed" type="button" variant="outline" class="rounded-[6px]" :disabled="isInstallingJdbcPlugin" @click="installJdbcPluginLocal">
<FolderOpen class="h-3.5 w-3.5 mr-1" />
{{ t("driverStore.localInstall") }}
</Button>
</div>
</div>
</div>
<!-- JDBC Drivers -->
<div class="space-y-3">
<div class="space-y-1">
<Label>{{ t("settings.jdbcDrivers") }}</Label>
</div>
<div class="relative">
<Search class="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="jdbcDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchJdbcDrivers')" />
</div>
<div class="flex items-center gap-2">
<Input v-model="jdbcDriverPathInput" class="flex-1" :placeholder="t('settings.jdbcDriverPathPlaceholder')" @keydown.enter.prevent="importJdbcDriverPathInput" />
<Button variant="outline" class="rounded-[6px]" :disabled="!jdbcDriverPathInput.trim()" @click="importJdbcDriverPathInput">
{{ t("settings.jdbcImportPath") }}
</Button>
<Button class="shrink-0 rounded-[6px]" @click="importJdbcDrivers">
<FolderOpen class="h-4 w-4" />
{{ t("settings.jdbcImport") }}
</Button>
</div>
<div class="grid gap-2 md:grid-cols-[minmax(0,1fr)_180px_auto]">
<Input v-model="jdbcMavenCoordinateInput" class="h-8 text-xs" :placeholder="t('driverStore.jdbcMavenCoordinatePlaceholder')" @keydown.enter.prevent="installJdbcMavenDriver" />
<Select v-model="jdbcMavenRepository">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="repo in jdbcMavenRepositoryOptions" :key="repo.value" :value="repo.value">
{{ repo.label }}
</SelectItem>
</SelectContent>
</Select>
<Button class="h-8 shrink-0 rounded-[6px]" :disabled="!jdbcMavenCoordinateInput.trim() || isInstallingJdbcMavenDriver || (jdbcMavenRepository === 'custom' && !customJdbcMavenRepository.trim())" @click="installJdbcMavenDriver">
<Loader2 v-if="isInstallingJdbcMavenDriver" class="h-4 w-4 animate-spin" />
<Download v-else class="h-4 w-4" />
{{ t("driverStore.jdbcMavenInstall") }}
</Button>
</div>
<Input v-if="jdbcMavenRepository === 'custom'" v-model="customJdbcMavenRepository" class="h-8 text-xs" placeholder="https://repo.example.com/repository/maven-public" @keydown.enter.prevent="installJdbcMavenDriver" />
</div>
<div class="driver-store-jdbc-list rounded-md border">
<div v-if="isLoadingJdbcDrivers" class="p-4 text-sm text-muted-foreground">
{{ t("common.loading") }}
</div>
<div v-else-if="jdbcDriverListItems.length === 0" class="p-4 text-sm text-muted-foreground">
{{ t("settings.jdbcNoDrivers") }}
</div>
<div v-else-if="filteredJdbcDrivers.length === 0" class="p-4 text-sm text-muted-foreground">
{{ t("driverStore.noMatchingDrivers") }}
</div>
<div v-else class="divide-y">
<div v-for="item in filteredJdbcDrivers" :key="item.id" class="driver-store-jdbc-row flex items-center gap-3 p-3">
<div class="driver-store-jdbc-name min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<div class="truncate text-sm font-medium">{{ item.title }}</div>
<Badge variant="outline" class="h-5 shrink-0 rounded-full px-2 text-[10px] font-medium">
{{ item.source }}
</Badge>
</div>
<div class="truncate text-xs text-muted-foreground">{{ item.subtitle }}</div>
</div>
<div class="shrink-0 text-xs text-muted-foreground">{{ formatBytes(item.size) }}</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 rounded-[6px]" @click="item.kind === 'maven' ? deleteJdbcMavenBundle(item.bundle.id) : item.kind === 'local' ? deleteJdbcLocalBundle(item.bundle.id) : deleteJdbcDriver(item.driver.path)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</TabsContent>
<!-- Runtime Tab -->
<TabsContent value="storage" class="driver-store-tab driver-store-storage-tab mt-5 space-y-5">
<!-- Storage Usage -->
<div class="rounded-xl border bg-muted/20 p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium">{{ t("driverStore.usageTitle") }}</div>
<div class="flex shrink-0 items-center gap-2">
<div class="text-xs text-muted-foreground">
{{ usageSummary.length ? t("driverStore.usageTotal", { size: formatBytes(usageSummary[0].bytes) }) : t("driverStore.calculating") }}
</div>
<Button variant="outline" size="sm" class="h-7 gap-1.5 rounded-[6px] text-xs" :disabled="!canClearDownloadCache" @click="clearDownloadCache">
<Loader2 v-if="clearingDownloadCache" class="h-3.5 w-3.5 animate-spin" />
<Trash2 v-else class="h-3.5 w-3.5" />
{{ clearingDownloadCache ? t("common.loading") : t("driverStore.clearDownloadCache") }}
</Button>
</div>
</div>
<div v-if="usageSummary.length" class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div v-for="item in usageSummary" :key="item.key" class="rounded-lg border bg-background/50 px-2.5 py-2 text-center">
<div class="text-[11px] text-muted-foreground">{{ item.label }}</div>
<div class="mt-0.5 text-xs font-medium">{{ formatBytes(item.bytes) }}</div>
</div>
</div>
</div>
<!-- Driver Store Path -->
<div v-if="!isWeb" class="rounded-xl border bg-muted/20 p-4 space-y-3">
<div class="text-sm font-medium">{{ t("driverStore.driverStoreDir") }}</div>
<p class="text-xs text-muted-foreground">{{ t("driverStore.driverStoreDirDescription") }}</p>
<div class="space-y-2.5">
<div v-for="row in driverStorePathRows" :key="row.kind" class="rounded-lg border bg-background/50 p-3">
<div class="mb-2 flex items-start justify-between gap-3">
<div class="min-w-0 text-xs leading-5">
<span class="font-medium">{{ row.label }}</span>
<span class="ml-2 text-[11px] text-muted-foreground">{{ row.description }}</span>
</div>
<Loader2 v-if="driverStoreDirMigrating === row.kind" class="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
</div>
<div class="flex items-center gap-2">
<Tooltip>
<TooltipTrigger as-child>
<div class="min-w-0 flex-1 rounded-md border bg-background px-3 py-2 text-xs font-mono truncate">
{{ row.display }}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-100 break-all text-xs">
{{ row.display }}
</TooltipContent>
</Tooltip>
<Button variant="outline" size="sm" class="shrink-0 gap-1" :disabled="Boolean(driverStoreDirMigrating)" @click="chooseDriverStoreDir(row.kind)">
<FolderSync class="h-3.5 w-3.5" />
{{ t("driverStore.driverStoreDirChange") }}
</Button>
<Button v-if="row.custom" variant="ghost" size="sm" class="shrink-0 gap-1 text-muted-foreground" :disabled="Boolean(driverStoreDirMigrating)" @click="resetDriverStoreDir(row.kind)">
{{ t("driverStore.driverStoreDirReset") }}
</Button>
</div>
</div>
</div>
<p v-if="driverStoreDirMigrating" class="text-xs text-muted-foreground flex items-center gap-1.5">
<Loader2 class="h-3 w-3 animate-spin" />
{{ t("driverStore.driverStoreDirMigrating", { target: driverStoreTargetLabel(driverStoreDirMigrating) }) }}
</p>
</div>
<!-- Offline Download -->
<div class="rounded-xl border bg-muted/20 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 text-xs text-muted-foreground">
{{ t("driverStore.offlineDownloadHint") }}
</div>
<Button variant="outline" size="sm" class="shrink-0 gap-1" @click="openOfflineDriverDownload">
<ExternalLink class="h-3.5 w-3.5" />
{{ t("driverStore.offlineDownloadLink") }}
</Button>
</div>
</div>
<!-- Runtime Info -->
<div class="overflow-hidden rounded-md border bg-background">
<div class="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex min-w-0 items-center gap-2.5">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
<Activity class="h-4 w-4 text-muted-foreground" />
</span>
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("driverStore.runtimeTitle") }}</div>
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span v-for="item in runtimeOverview" :key="item.key" class="inline-flex items-center gap-1.5">
<span>{{ item.label }}</span>
<span class="font-medium text-foreground" :class="item.class">{{ item.value }}</span>
</span>
</div>
</div>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 rounded-[6px] text-muted-foreground" :title="t('driverStore.refresh')" :disabled="runtimeLoading" @click="refreshDriverRuntime">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': runtimeLoading }" />
</Button>
</div>
<div v-if="runtimeSummary?.last_error" class="border-b border-amber-500/20 bg-amber-500/10 px-4 py-2.5">
<div class="text-xs font-medium text-amber-700 dark:text-amber-300">
{{ t("driverStore.runtimeLastError") }}
</div>
<pre class="mt-1 max-h-20 overflow-auto whitespace-pre-wrap text-[11px] text-muted-foreground">{{ runtimeSummary.last_error }}</pre>
</div>
<div v-if="runtimeLoading && !runtimeSummary" class="p-6 text-center text-sm text-muted-foreground">
{{ t("common.loading") }}
</div>
<div v-else-if="runtimeError" class="p-6 text-sm text-destructive">
{{ runtimeError }}
</div>
<div v-else-if="!runtimeSummary?.runtimes.length" class="p-6 text-center text-sm text-muted-foreground">
{{ t("driverStore.runtimeEmpty") }}
</div>
<div v-else>
<div class="hidden grid-cols-[minmax(0,1.6fr)_72px_56px_76px_58px_76px_72px] gap-2 border-b bg-muted/30 px-4 py-2 text-[11px] font-medium text-muted-foreground lg:grid">
<div>{{ t("driverStore.runtimeDrivers") }}</div>
<div>{{ t("driverStore.runtimeHealth") }}</div>
<div>{{ t("driverStore.runtimePid") }}</div>
<div>{{ t("driverStore.runtimeMemory") }}</div>
<div>CPU</div>
<div>{{ t("driverStore.runtimeUptime") }}</div>
<div class="text-right">{{ t("driverStore.runtimeActions") }}</div>
</div>
<div class="divide-y">
<div v-for="runtime in runtimeSummary.runtimes" :key="runtime.id" class="grid gap-2 px-4 py-3 transition hover:bg-muted/25 lg:grid-cols-[minmax(0,1.6fr)_72px_56px_76px_58px_76px_72px] lg:items-center">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" :class="runtimeStatusDotClass(runtime.status)" />
<span class="truncate text-sm font-medium">{{ runtime.label }}</span>
<span v-if="runtime.version" class="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> v{{ runtime.version }} </span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<span>{{ runtimeKindLabel(runtime) }}</span>
<span class="text-muted-foreground/50">/</span>
<span>{{ runtimeSourceLabel(runtime) }}</span>
<template v-if="runtime.protocol_mode">
<span class="text-muted-foreground/50">/</span>
<span>{{ driverRuntimeProtocolLabel(runtime) }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-2 lg:block">
<span class="lg:hidden text-[11px] text-muted-foreground">{{ t("driverStore.runtimeHealth") }}</span>
<span class="rounded-full px-2 py-0.5 text-[11px]" :class="runtimeStatusClass(runtime.status)">
{{ runtimeStatusLabel(runtime.status) }}
</span>
</div>
<div class="text-xs text-muted-foreground">
<span class="lg:hidden">{{ t("driverStore.runtimePid") }}: </span>{{ runtime.pid ?? "-" }}
</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<MemoryStick class="h-3.5 w-3.5 lg:hidden" />
{{ formatRuntimeBytes(runtime.memory_bytes) }}
</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<Cpu class="h-3.5 w-3.5 lg:hidden" />
{{ formatRuntimeCpu(runtime.cpu_percent) }}
</div>
<div class="text-xs text-muted-foreground">
<span class="lg:hidden">{{ t("driverStore.runtimeUptime") }}: </span>
{{ formatRuntimeUptime(runtime.uptime_seconds) }}
</div>
<div class="flex min-w-0 items-center gap-1.5 lg:justify-end">
<Button v-if="runtime.can_stop" variant="ghost" size="icon" class="h-7 w-7 rounded-[6px] text-muted-foreground hover:text-destructive" :title="t('driverStore.runtimeStop')" :disabled="runtimeBusy === runtime.id" @click="stopRuntime(runtime)">
<Square class="h-3.5 w-3.5" />
</Button>
<Button v-if="runtime.can_restart" variant="ghost" size="icon" class="h-7 w-7 rounded-[6px] text-muted-foreground" :title="t('driverStore.runtimeRestart')" :disabled="runtimeBusy === runtime.id" @click="restartRuntime(runtime)">
<RotateCcw class="h-3.5 w-3.5" :class="{ 'animate-spin': runtimeBusy === runtime.id }" />
</Button>
<span v-if="!runtime.can_stop && !runtime.can_restart" class="min-w-0 truncate text-[11px] text-muted-foreground lg:text-right" :title="runtimeControlUnavailableReasonLabel(runtime.control_unavailable_reason)">
{{ runtimeControlUnavailableReasonLabel(runtime.control_unavailable_reason) }}
</span>
</div>
<div v-if="runtime.last_error" class="rounded-md bg-muted/60 p-2 lg:col-span-7">
<pre class="max-h-16 overflow-auto whitespace-pre-wrap text-[11px] text-muted-foreground">{{ runtime.last_error }}</pre>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</template>
<style>
.driver-store-focus-highlight {
background-color: hsl(var(--primary) / 0.08);
box-shadow: inset 0 0 0 1.5px hsl(var(--primary) / 0.45);
border-radius: 8px;
}
.driver-store-view,
.driver-store-scroll {
overflow-x: hidden;
}
.driver-store-view {
height: 100%;
min-height: 0;
overflow: hidden;
}
.driver-store-scroll {
height: 100%;
min-height: 0;
overflow-y: auto !important;
}
.driver-store-container {
box-sizing: border-box;
width: 100%;
max-width: none !important;
margin-left: 0 !important;
margin-right: 0 !important;
padding: 1.25rem 1.5rem 1.5rem !important;
}
.driver-store-tabs {
display: grid !important;
width: 360px !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
.driver-store-tabs-root {
display: flex !important;
width: 100%;
min-width: 0;
flex-direction: column !important;
}
.driver-store-tabs-root > [data-slot="tabs-content"] {
width: 100%;
min-width: 0;
}
.driver-store-header {
flex-shrink: 0;
}
.driver-store-tab {
min-height: 0;
overflow: visible;
}
.driver-store-tabs-root > [data-slot="tabs-content"][hidden] {
display: none !important;
}
.driver-store-agent-tab,
.driver-store-jdbc-tab {
flex-direction: column;
gap: 1rem;
}
.driver-store-agent-tab:not([hidden]),
.driver-store-jdbc-tab:not([hidden]) {
display: flex !important;
}
.driver-store-agent-tab > :not([hidden]) ~ :not([hidden]),
.driver-store-jdbc-tab > :not([hidden]) ~ :not([hidden]) {
margin-top: 0 !important;
}
.driver-store-agent-tab > *,
.driver-store-jdbc-tab > * {
flex-shrink: 0;
}
.driver-store-agent-row {
display: flex !important;
align-items: center !important;
min-width: 0;
width: 100%;
}
.driver-store-agent-list,
.driver-store-jdbc-list {
width: 100%;
flex: 0 0 auto !important;
min-height: 0;
overflow-y: visible;
overflow-x: hidden;
}
.driver-store-agent-name,
.driver-store-jdbc-name {
flex: 1 1 auto !important;
min-width: 0 !important;
}
.driver-store-agent-meta,
.driver-store-agent-actions,
.driver-store-jdbc-row > .shrink-0,
.driver-store-jdbc-row > button {
flex-shrink: 0 !important;
}
.driver-store-jdbc-row {
display: flex !important;
align-items: center !important;
min-width: 0;
width: 100%;
}
.driver-store-jdbc-row > button {
width: 2rem !important;
height: 2rem !important;
}
@media (max-width: 900px) {
.driver-store-header {
align-items: flex-start !important;
flex-direction: column !important;
gap: 0.75rem;
}
.driver-store-tabs {
width: 100% !important;
}
.driver-store-agent-row {
align-items: flex-start !important;
flex-wrap: wrap;
}
.driver-store-jdbc-row {
align-items: flex-start !important;
flex-wrap: wrap;
}
.driver-store-agent-meta,
.driver-store-agent-actions {
margin-left: 2.75rem;
}
.driver-store-jdbc-row > .shrink-0,
.driver-store-jdbc-row > button {
margin-left: 2.75rem;
}
}
</style>