diff --git a/apps/desktop/src/components/config/DriverStoreDialog.vue b/apps/desktop/src/components/config/DriverStoreDialog.vue index cd53545f91..4ea68570c1 100644 --- a/apps/desktop/src/components/config/DriverStoreDialog.vue +++ b/apps/desktop/src/components/config/DriverStoreDialog.vue @@ -212,6 +212,11 @@ async function runDriverInstall(dbType: string) { installing.value = dbType; progress.value = null; try { + 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 })); @@ -239,9 +244,22 @@ async function upgradeAll() { queuedDriverInstalls.value = []; progress.value = null; try { - const count = await api.upgradeAllAgents(); + 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(); - toast(t("driverStore.upgradeAllSuccess", { count })); + 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 { diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 90ba1e08a4..c35023eed5 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -103,6 +103,7 @@ const defaultForm = (): Omit => ({ proxy_password: "", ssl: false, ca_cert_path: "", + sysdba: false, oracle_connection_type: "service_name", connection_string: undefined, jdbc_driver_class: undefined, @@ -447,6 +448,7 @@ watch( proxy_password: config.proxy_password || "", ssl: config.ssl || false, ca_cert_path: config.ca_cert_path || "", + sysdba: config.sysdba || isOracleSysUser(config), oracle_connection_type: config.oracle_connection_type || "service_name", connection_string: config.connection_string, jdbc_driver_class: config.jdbc_driver_class, @@ -898,8 +900,10 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { config.connection_string = normalizeMongoConnectionString(config.connection_string?.trim() || ""); } if (config.db_type !== "oracle") { + config.sysdba = undefined; config.oracle_connection_type = undefined; } else { + config.sysdba = !!config.sysdba || isOracleSysUser(config); config.oracle_connection_type = config.oracle_connection_type || "service_name"; } if (config.db_type !== "redis") { @@ -1123,6 +1127,10 @@ function parseRedisEndpoint(value: string, defaultPort: number): { host: string; return { host: endpoint, port: defaultPort }; } +function isOracleSysUser(config: Pick): boolean { + return config.db_type === "oracle" && config.username.trim().toLowerCase() === "sys"; +} + function resetTestState() { testRunId += 1; isTesting.value = false; @@ -1257,6 +1265,10 @@ watch( }, ); +watch([() => form.value.db_type, () => form.value.username], () => { + if (isOracleSysUser(form.value)) form.value.sysdba = true; +}); + watch(canUseSsh, (value) => { if (!value && configTab.value === "ssh") { configTab.value = "connection"; @@ -2247,7 +2259,7 @@ function openExternalUrl(url: string) {
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 6aa847174b..c9bc9116d7 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -109,6 +109,7 @@ import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/ import { displayCellValue, type CellValue } from "@/lib/cellValue"; import { BINARY_CELL_DOWNLOAD_MODES, + binaryCellDisplayText, binaryCellDownloadFileName, binaryCellDownloadPayload, canDownloadBinaryCellValue, @@ -1573,6 +1574,7 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to sourceRows: computed(() => props.result.rows), columnIndexes: visibleColumnIndexes, gridRef, + scrollbarGutter: gridScrollbarGutter, }); const gridStyle = computed(() => ({ ...columnVars.value, @@ -2975,6 +2977,11 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string function formatCell(value: CellValue, columnIndex?: number): string { const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; + const columnName = columnIndex === undefined ? undefined : props.result.columns[columnIndex]; + const binaryDisplay = formatter + ? null + : binaryCellDisplayText(value, columnName ? columnTypeMap.value.get(columnName) : undefined); + if (binaryDisplay) return binaryDisplay; const s = applyColumnFormatter(value, formatter); return s.length > CELL_DISPLAY_MAX_LENGTH ? s.slice(0, CELL_DISPLAY_MAX_LENGTH) : s; } @@ -4018,7 +4025,7 @@ function canDownloadDetailBinaryValue(detail: DataGridCellDetail | null): boolea async function downloadDetailBinaryValue(detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) { if (!detail || !canDownloadDetailBinaryValue(detail)) return; try { - const payload = binaryCellDownloadPayload(detail.value, mode); + const payload = binaryCellDownloadPayload(detail.value, mode, detail.type); const fileName = binaryCellDownloadFileName({ column: detail.column, rowNumber: detail.rowNumber, @@ -7763,6 +7770,7 @@ const gridContextMenuItems = computed(() => { .data-grid-scroller { overflow-anchor: none; + scrollbar-gutter: stable; will-change: scroll-position; contain: strict; } diff --git a/apps/desktop/src/composables/useDataGridColumnResize.ts b/apps/desktop/src/composables/useDataGridColumnResize.ts index 4d1efdbd67..9f7fb20fe3 100644 --- a/apps/desktop/src/composables/useDataGridColumnResize.ts +++ b/apps/desktop/src/composables/useDataGridColumnResize.ts @@ -15,10 +15,11 @@ export interface UseDataGridColumnResizeOptions { sourceRows: ComputedRef; columnIndexes: ComputedRef; gridRef: Ref; + scrollbarGutter?: Ref; } export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) { - const { columns, sourceRows, columnIndexes, gridRef } = options; + const { columns, sourceRows, columnIndexes, gridRef, scrollbarGutter } = options; const columnWidths = ref([]); const { width: gridWidth } = useElementSize(gridRef); @@ -80,7 +81,8 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) const widths = columnWidths.value; if (widths.length === 0) return widths; - const extraWidth = Math.max(0, gridWidth.value - DATA_GRID_ROW_NUM_WIDTH - baseTotalWidth.value); + const availableWidth = Math.max(0, gridWidth.value - (scrollbarGutter?.value ?? 0)); + const extraWidth = Math.max(0, availableWidth - DATA_GRID_ROW_NUM_WIDTH - baseTotalWidth.value); if (extraWidth === 0) return widths; const extraPerColumn = extraWidth / widths.length; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 6c459500ee..7c16ee3f78 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1764,7 +1764,9 @@ export default { chooseJavaExecutable: "Choose Java executable", driverInstallSuccess: "{label} driver installed", driverInstallFailed: "Failed to install {label} driver: {error}", + driverUpdateBlocked: "Close these database connections before updating drivers: {labels}", upgradeAllSuccess: "{count} driver(s) upgraded", + upgradeAllPartial: "{count} driver(s) upgraded. Skipped: {failed}", upgradeAllFailed: "Batch upgrade failed: {error}", driverUninstallSuccess: "{label} driver uninstalled", driverUninstallFailed: "Failed to uninstall {label} driver: {error}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d983624620..8d624e1d40 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1655,7 +1655,9 @@ export default { chooseJavaExecutable: "Seleccionar ejecutable Java", driverInstallSuccess: "Driver {label} instalado", driverInstallFailed: "Error al instalar el driver {label}: {error}", + driverUpdateBlocked: "Cierra estas conexiones de base de datos antes de actualizar drivers: {labels}", upgradeAllSuccess: "{count} driver(s) actualizados", + upgradeAllPartial: "{count} driver(s) actualizados. Omitidos: {failed}", upgradeAllFailed: "Error al actualizar en lote: {error}", driverUninstallSuccess: "Driver {label} desinstalado", driverUninstallFailed: "Error al desinstalar el driver {label}: {error}", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d13f7a9560..b22f7cfffd 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1725,7 +1725,9 @@ export default { chooseJavaExecutable: "选择 Java 可执行文件", driverInstallSuccess: "{label} 驱动安装成功", driverInstallFailed: "{label} 驱动安装失败: {error}", + driverUpdateBlocked: "请先关闭以下数据库连接后再更新驱动: {labels}", upgradeAllSuccess: "{count} 个驱动升级完成", + upgradeAllPartial: "{count} 个驱动升级完成,以下驱动已跳过: {failed}", upgradeAllFailed: "批量升级失败: {error}", driverUninstallSuccess: "{label} 驱动已卸载", driverUninstallFailed: "{label} 驱动卸载失败: {error}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e7a80922f8..90cc15c87a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1691,7 +1691,9 @@ export default { chooseJavaExecutable: "選擇 Java 可執行檔", driverInstallSuccess: "{label} 驅動程式安裝成功", driverInstallFailed: "{label} 驅動程式安裝失敗: {error}", + driverUpdateBlocked: "請先關閉以下資料庫連線後再更新驅動程式: {labels}", upgradeAllSuccess: "{count} 個驅動程式更新完成", + upgradeAllPartial: "{count} 個驅動程式更新完成,以下驅動程式已略過: {failed}", upgradeAllFailed: "批次更新失敗: {error}", driverUninstallSuccess: "{label} 驅動程式已解除安裝", driverUninstallFailed: "{label} 驅動程式解除安裝失敗: {error}", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index a77715a8a1..5709d21a7f 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -51,6 +51,7 @@ export const listInstalledAgents = forward("listInstalledAgents"); export const getDriverStoreUsage = forward("getDriverStoreUsage"); export const installAgent = forward("installAgent"); export const upgradeAllAgents = forward("upgradeAllAgents"); +export const checkAgentUpdateBlockers = forward("checkAgentUpdateBlockers"); export const uninstallAgent = forward("uninstallAgent"); export const getAgentJavaRuntimeConfig = forward("getAgentJavaRuntimeConfig"); export const setAgentJavaRuntimeConfig = forward("setAgentJavaRuntimeConfig"); diff --git a/apps/desktop/src/lib/binaryCellDownload.ts b/apps/desktop/src/lib/binaryCellDownload.ts index c7ee149cda..e6a6e6b132 100644 --- a/apps/desktop/src/lib/binaryCellDownload.ts +++ b/apps/desktop/src/lib/binaryCellDownload.ts @@ -18,6 +18,8 @@ export interface BinaryCellDownloadResult { export const BINARY_CELL_DOWNLOAD_MODES: BinaryCellDownloadMode[] = ["binary", "utf8", "gbk"]; const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]+)$/; +const BARE_HEX_RE = /^[0-9a-fA-F\s]+$/; +const HEX_ESCAPE_RE = /^(?:\\x[0-9a-fA-F]{2}|\s)+$/; const BINARY_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i; @@ -30,8 +32,12 @@ export function parseBinaryCellHexValue(value: CellValue): Uint8Array | null { const match = value.trim().match(HEX_VALUE_RE); if (!match) return null; - const hex = match[1].replace(/\s+/g, ""); - if (!hex || hex.length % 2 !== 0) return null; + return bytesFromHex(match[1]); +} + +function bytesFromHex(value: string): Uint8Array | null { + const hex = value.replace(/\s+/g, ""); + if (!hex || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null; const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { @@ -42,20 +48,80 @@ export function parseBinaryCellHexValue(value: CellValue): Uint8Array | null { return bytes; } +function bytesFromByteArray(value: unknown): Uint8Array | null { + if (!Array.isArray(value)) return null; + const bytes = new Uint8Array(value.length); + for (let i = 0; i < value.length; i++) { + const item = value[i]; + if (!Number.isInteger(item) || item < 0 || item > 255) return null; + bytes[i] = item; + } + return bytes; +} + +function bytesFromBufferLikeObject(value: unknown): Uint8Array | null { + if (!value || typeof value !== "object") return null; + const data = (value as { data?: unknown }).data; + return bytesFromByteArray(data); +} + +export function parseBinaryCellBytes(value: unknown, columnType?: string): Uint8Array | null { + if (typeof value === "string") { + const prefixed = parseBinaryCellHexValue(value); + if (prefixed) return prefixed; + + const trimmed = value.trim(); + if (HEX_ESCAPE_RE.test(trimmed)) { + return bytesFromHex(trimmed.replace(/\\x/gi, "")); + } + + if (isBinaryCellColumnType(columnType) && BARE_HEX_RE.test(trimmed)) { + return bytesFromHex(trimmed); + } + } + + return bytesFromByteArray(value) ?? bytesFromBufferLikeObject(value); +} + export function isBinaryCellColumnType(columnType?: string): boolean { const type = (columnType ?? "").trim(); return !!type && BINARY_TYPE_RE.test(type); } -export function canDownloadBinaryCellValue(value: CellValue, columnType?: string): boolean { - if (!parseBinaryCellHexValue(value)) return false; - return isBinaryCellColumnType(columnType) || typeof value === "string"; +export function canDownloadBinaryCellValue(value: unknown, columnType?: string): boolean { + return !!parseBinaryCellBytes(value, columnType); } -export function binaryCellDownloadPayload(value: CellValue, mode: BinaryCellDownloadMode): BinaryCellDownloadPayload { - const bytes = parseBinaryCellHexValue(value); +export function binaryCellDisplayText(value: unknown, columnType?: string): string | null { + const bytes = parseBinaryCellBytes(value, columnType); + if (!bytes || !isBinaryCellColumnType(columnType)) return null; + return `${binaryCellDisplayLabel(columnType)} [${formatBinaryCellByteSize(bytes.length)}]`; +} + +function binaryCellDisplayLabel(columnType?: string): string { + const base = (columnType ?? "") + .trim() + .split(/[(:\s]/)[0] + .toUpperCase(); + if (!base) return "BLOB"; + if (base.includes("BLOB")) return "BLOB"; + return base; +} + +function formatBinaryCellByteSize(bytes: number): string { + if (bytes < 1024) return `${bytes} bytes`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; +} + +export function binaryCellDownloadPayload( + value: unknown, + mode: BinaryCellDownloadMode, + columnType?: string, +): BinaryCellDownloadPayload { + const bytes = parseBinaryCellBytes(value, columnType); if (!bytes) { - throw new Error("Cell value is not a hexadecimal binary value."); + throw new Error("Cell value is not a downloadable binary value."); } if (mode === "binary") { diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 08c0920a28..d6b430cb8b 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -28,6 +28,8 @@ import type { AiConversation, AiModelInfo, DriverStoreUsage, + UpgradeAllAgentDriversResult, + AgentUpdateBlocker, DesktopSettings, DriverInstallProgress, JavaRuntimeConfig, @@ -242,9 +244,12 @@ export async function installAgent(dbType: string): Promise { await post("/api/agents/install", { dbType }); } -export async function upgradeAllAgents(): Promise { - const result: { count: number } = await post("/api/agents/upgrade-all", {}); - return result.count; +export async function upgradeAllAgents(): Promise { + return post("/api/agents/upgrade-all", {}); +} + +export async function checkAgentUpdateBlockers(_dbTypes: string[]): Promise { + return []; } export async function uninstallAgent(dbType: string): Promise { diff --git a/apps/desktop/src/lib/tableTree.ts b/apps/desktop/src/lib/tableTree.ts index acf0baa122..a6c54b474d 100644 --- a/apps/desktop/src/lib/tableTree.ts +++ b/apps/desktop/src/lib/tableTree.ts @@ -231,13 +231,20 @@ export function mergeTableInfosIntoObjects( if (objectType !== "TABLE" && objectType !== "VIEW") continue; const name = normalizeDatabaseObjectName(table.name); if (!name) continue; - const key = `${objectType}\0${(schema || "").toLowerCase()}\0${name.toLowerCase()}`; + const matchingObject = objects.find((obj) => { + const objName = normalizeDatabaseObjectName(obj.name); + if (objName.toLowerCase() !== name.toLowerCase()) return false; + return normalizeObjectType(obj.object_type) === objectType; + }); + const tableSchema = + schema ?? (matchingObject?.schema ? normalizeDatabaseObjectName(matchingObject.schema) : undefined); + const key = `${objectType}\0${(tableSchema || "").toLowerCase()}\0${name.toLowerCase()}`; if (seen.has(key)) continue; seen.add(key); merged.push({ name, object_type: objectType, - schema, + schema: tableSchema, comment: table.comment, created_at: undefined, updated_at: undefined, diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 9769d3a5fb..f528767e66 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -74,6 +74,21 @@ export interface AgentDriverInfo { jre_installed: boolean; } +export interface AgentDriverUpdateIssue { + db_type: string; + error: string; +} + +export interface UpgradeAllAgentDriversResult { + upgraded: number; + failed: AgentDriverUpdateIssue[]; +} + +export interface AgentUpdateBlocker { + db_type: string; + label: string; +} + export type JavaRuntimeMode = "managed" | "system" | "custom"; export interface JavaRuntimeConfig { @@ -90,6 +105,7 @@ export interface DriverStoreUsage { total_bytes: number; jre_bytes: number; agent_driver_bytes: number; + download_cache_bytes?: number; jdbc_plugin_bytes: number; jdbc_driver_bytes: number; jres: DriverStoreUsageItem[]; @@ -847,10 +863,14 @@ export async function installAgent(dbType: string): Promise { return invoke("install_agent", { dbType }); } -export async function upgradeAllAgents(): Promise { +export async function upgradeAllAgents(): Promise { return invoke("upgrade_all_agents"); } +export async function checkAgentUpdateBlockers(dbTypes: string[]): Promise { + return invoke("check_agent_update_blockers", { dbTypes }); +} + export async function uninstallAgent(dbType: string): Promise { return invoke("uninstall_agent", { dbType }); } diff --git a/crates/dbx-core/src/agent_connection.rs b/crates/dbx-core/src/agent_connection.rs index 0af703ac59..8bad59d820 100644 --- a/crates/dbx-core/src/agent_connection.rs +++ b/crates/dbx-core/src/agent_connection.rs @@ -5,6 +5,8 @@ use crate::models::connection::{ConnectionConfig, DatabaseType}; pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> serde_json::Value { let agent_database = if config.db_type == DatabaseType::MongoDb { mongo_agent_database(config, database) + } else if config.db_type == DatabaseType::Oracle { + oracle_agent_database(config, database) } else { database.to_string() }; @@ -26,12 +28,24 @@ pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, da "database": agent_database, "username": config.username, "password": config.password, - "sysdba": config.sysdba, + "sysdba": oracle_uses_sysdba(config), "url_params": config.url_params.as_deref().unwrap_or(""), "connection_string": connection_string, }) } +fn oracle_uses_sysdba(config: &ConnectionConfig) -> bool { + config.sysdba || (config.db_type == DatabaseType::Oracle && config.username.trim().eq_ignore_ascii_case("sys")) +} + +fn oracle_agent_database(config: &ConnectionConfig, database: &str) -> String { + let database = database.trim(); + if database.is_empty() || !oracle_uses_sysdba(config) || database.to_uppercase().starts_with("SYSDBA:") { + return database.to_string(); + } + format!("SYSDBA:{database}") +} + fn mongo_agent_database(config: &ConnectionConfig, database: &str) -> String { if let Some(database) = non_empty_database(database) { return database.to_string(); @@ -278,6 +292,31 @@ mod tests { assert_eq!(service["connection_string"], "jdbc:oracle:thin:@//oracle.example.com:1521/ORCL"); } + #[test] + fn oracle_sys_user_connects_as_sysdba_for_agent_protocol() { + let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1")); + cfg.username = "SYS".to_string(); + cfg.oracle_connection_type = Some("service_name".to_string()); + + let params = agent_connect_params(&cfg, "oracle.example.com", 1521, "ORCLPDB1"); + + assert_eq!(params["database"], "SYSDBA:ORCLPDB1"); + assert_eq!(params["sysdba"], true); + assert_eq!(params["connection_string"], "jdbc:oracle:thin:@//oracle.example.com:1521/ORCLPDB1"); + } + + #[test] + fn oracle_sysdba_checkbox_connects_as_sysdba_for_agent_protocol() { + let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1")); + cfg.username = "system".to_string(); + cfg.sysdba = true; + + let params = agent_connect_params(&cfg, "oracle.example.com", 1521, "ORCLPDB1"); + + assert_eq!(params["database"], "SYSDBA:ORCLPDB1"); + assert_eq!(params["sysdba"], true); + } + #[test] fn oracle_url_preserves_custom_jdbc_descriptor_and_rewrites_host_port() { let mut cfg = config(DatabaseType::Oracle, Some("ORCL")); diff --git a/crates/dbx-core/src/agent_manager.rs b/crates/dbx-core/src/agent_manager.rs index 7e1805f23b..afb667cda2 100644 --- a/crates/dbx-core/src/agent_manager.rs +++ b/crates/dbx-core/src/agent_manager.rs @@ -8,6 +8,8 @@ use crate::db::agent_driver::{AgentDriverClient, AgentMethod}; use crate::models::connection::DatabaseType; pub const DEFAULT_JRE_KEY: &str = "21"; +pub const DOWNLOAD_CACHE_DIR_NAME: &str = "download-cache"; +pub const DOWNLOAD_CACHE_MAX_AGE_DAYS: u64 = 7; fn default_jre_key() -> String { DEFAULT_JRE_KEY.to_string() @@ -245,6 +247,8 @@ pub struct DriverStoreUsage { pub total_bytes: u64, pub jre_bytes: u64, pub agent_driver_bytes: u64, + #[serde(default)] + pub download_cache_bytes: u64, pub jdbc_plugin_bytes: u64, pub jdbc_driver_bytes: u64, pub jres: Vec, @@ -320,6 +324,14 @@ impl AgentManager { self.base_dir.join("drivers").join(db_type).join("agent.jar") } + pub fn download_cache_dir(&self) -> PathBuf { + self.base_dir.join(DOWNLOAD_CACHE_DIR_NAME) + } + + pub fn download_cache_max_age_days(&self) -> u64 { + DOWNLOAD_CACHE_MAX_AGE_DAYS + } + fn state_path(&self) -> PathBuf { self.base_dir.join("state.json") } @@ -390,14 +402,17 @@ impl AgentManager { let jdbc_driver_bytes = path_size_bytes(&jdbc_driver_root); let jdbc_total_bytes = path_size_bytes(&jdbc_root); let jdbc_plugin_bytes = jdbc_total_bytes.saturating_sub(jdbc_driver_bytes); + let download_cache_bytes = path_size_bytes(&self.download_cache_dir()); DriverStoreUsage { total_bytes: jre_bytes .saturating_add(agent_driver_bytes) + .saturating_add(download_cache_bytes) .saturating_add(jdbc_plugin_bytes) .saturating_add(jdbc_driver_bytes), jre_bytes, agent_driver_bytes, + download_cache_bytes, jdbc_plugin_bytes, jdbc_driver_bytes, jres, @@ -440,6 +455,10 @@ impl AgentManager { crate::agent_runtime::stop_daemon_by_key(self, agent_key).await; } + pub async fn active_daemon_keys(&self) -> Vec { + self.daemons.lock().await.keys().cloned().collect() + } + pub fn db_type_to_agent_key(db_type: &DatabaseType, driver_profile: Option<&str>) -> Option<&'static str> { crate::agent_runtime::db_type_to_agent_key(db_type, driver_profile) } diff --git a/crates/dbx-core/src/agent_service.rs b/crates/dbx-core/src/agent_service.rs index 8ae606d86e..8647420ecc 100644 --- a/crates/dbx-core/src/agent_service.rs +++ b/crates/dbx-core/src/agent_service.rs @@ -1,3 +1,4 @@ +use std::hash::{Hash, Hasher}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -27,6 +28,18 @@ pub struct AgentProgressEvent { pub total_drivers: Option, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct AgentDriverUpdateIssue { + pub db_type: String, + pub error: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] +pub struct UpgradeAllAgentDriversResult { + pub upgraded: u32, + pub failed: Vec, +} + impl AgentProgressEvent { pub fn step(step: impl Into) -> Self { Self { step: step.into(), downloaded: None, total: None, db_type: None, current: None, total_drivers: None } @@ -163,14 +176,15 @@ pub async fn install_agent_driver( pub async fn upgrade_all_agent_drivers( am: &AgentManager, progress: impl Fn(AgentProgressEvent), -) -> Result { +) -> Result { let registry = fetch_registry().await?; let agents = build_agent_list(am, Some(®istry)); let updatable: Vec<&AgentDriverInfo> = agents.iter().filter(|agent| agent.update_available).collect(); let total = updatable.len() as u32; + let mut result = UpgradeAllAgentDriversResult::default(); for (index, agent) in updatable.iter().enumerate() { - install_agent_driver_from_registry( + match install_agent_driver_from_registry( am, ®istry, &agent.db_type, @@ -178,11 +192,18 @@ pub async fn upgrade_all_agent_drivers( Some((index + 1) as u32), Some(total), ) - .await?; + .await + { + Ok(()) => result.upgraded += 1, + Err(error) => { + log::warn!("Failed to update {} agent driver: {}", agent.db_type, error); + result.failed.push(AgentDriverUpdateIssue { db_type: agent.db_type.clone(), error }); + } + } } progress(AgentProgressEvent::step("all-done")); - Ok(total) + Ok(result) } pub async fn uninstall_agent_driver(am: &AgentManager, db_type: &str) -> Result<(), String> { @@ -238,6 +259,7 @@ pub async fn reinstall_agent_jre( .ok_or_else(|| format!("No JRE {jre_key} available for platform: {platform}"))?; let jre_archive = am.base_dir().join("jre-download.tar.gz"); download_with_progress( + am, &progress, "jre", &platform_jre.url, @@ -338,6 +360,7 @@ async fn install_agent_driver_from_registry( total_drivers, )); download_with_progress( + am, progress, "jre", &platform_jre.url, @@ -365,6 +388,7 @@ async fn install_agent_driver_from_registry( total_drivers, )); download_with_progress( + am, progress, "driver", &driver.jar.url, @@ -396,6 +420,7 @@ async fn install_agent_driver_from_registry( } async fn download_with_progress( + am: &AgentManager, progress: &impl Fn(AgentProgressEvent), step: &str, url: &str, @@ -410,6 +435,18 @@ async fn download_with_progress( std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; } let tmp = download_temp_path(dest); + let cache_path = cached_download_path(am, url, total_size, dest); + prune_download_cache(am).ok(); + if cached_download_is_valid(am, &cache_path, total_size) { + std::fs::copy(&cache_path, &tmp).map_err(|err| format!("Failed to copy cached download: {err}"))?; + progress(AgentProgressEvent::transfer(step, total_size, total_size).with_batch( + db_type, + current, + total_drivers, + )); + return replace_download(&tmp, dest); + } + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(300)) .build() @@ -431,9 +468,62 @@ async fn download_with_progress( } std::io::Write::flush(&mut file).map_err(|err| format!("Failed to flush temp file: {err}"))?; drop(file); + if let Some(parent) = cache_path.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + log::warn!("Failed to create agent download cache directory: {err}"); + } else if let Err(err) = std::fs::copy(&tmp, &cache_path) { + log::warn!("Failed to cache agent download: {err}"); + } + } replace_download(&tmp, dest) } +fn cached_download_path(am: &AgentManager, url: &str, total_size: u64, dest: &std::path::Path) -> std::path::PathBuf { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut hasher); + total_size.hash(&mut hasher); + let hash = hasher.finish(); + let file_name = dest.file_name().and_then(|name| name.to_str()).unwrap_or("download"); + am.download_cache_dir().join(format!("{hash:016x}-{file_name}")) +} + +fn cached_download_is_valid(am: &AgentManager, path: &std::path::Path, expected_size: u64) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + if expected_size > 0 && meta.len() != expected_size { + let _ = std::fs::remove_file(path); + return false; + } + let max_age = std::time::Duration::from_secs(am.download_cache_max_age_days() * 24 * 60 * 60); + if meta.modified().ok().and_then(|modified| modified.elapsed().ok()).is_some_and(|age| age > max_age) { + let _ = std::fs::remove_file(path); + return false; + } + true +} + +fn prune_download_cache(am: &AgentManager) -> Result<(), String> { + let cache_dir = am.download_cache_dir(); + let max_age = std::time::Duration::from_secs(am.download_cache_max_age_days() * 24 * 60 * 60); + let Ok(entries) = std::fs::read_dir(&cache_dir) else { + return Ok(()); + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + continue; + }; + if meta.modified().ok().and_then(|modified| modified.elapsed().ok()).is_some_and(|age| age > max_age) { + let _ = if meta.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }; + } + } + Ok(()) +} + pub fn github_url_to_r2_path(github_url: &str, category: &str) -> String { let filename = github_url.rsplit('/').next().unwrap_or(github_url); match category { diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index f34ce52965..c1e60f4054 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; @@ -650,6 +650,36 @@ impl AppState { Ok(closed) } + pub async fn active_agent_driver_keys(&self) -> HashSet { + let configs = self.configs.read().await; + let connections = self.connections.read().await; + let mut keys = HashSet::new(); + + for (pool_key, pool) in connections.iter() { + if !matches!(pool, PoolKind::Agent(_)) { + continue; + } + let Some(config) = config_for_pool_key(pool_key, &configs) else { + continue; + }; + if let Some(agent_key) = crate::agent_manager::AgentManager::db_type_to_agent_key( + &config.db_type, + config.driver_profile.as_deref(), + ) { + keys.insert(agent_key.to_string()); + } + } + + drop(connections); + drop(configs); + + for key in self.agent_manager.active_daemon_keys().await { + keys.insert(key); + } + + keys + } + pub async fn duckdb_existing_pool_is_usable_for_config(&self, config: &ConnectionConfig) -> Result { if config.db_type != DatabaseType::DuckDb { return Ok(false); @@ -800,6 +830,19 @@ fn session_scoped_pool_key(base_pool_key: String, client_session_id: Option<&str .unwrap_or(base_pool_key) } +fn config_for_pool_key<'a>( + pool_key: &str, + configs: &'a HashMap, +) -> Option<&'a ConnectionConfig> { + configs + .iter() + .filter(|(connection_id, _)| { + pool_key.strip_prefix(connection_id.as_str()).is_some_and(|rest| rest.is_empty() || rest.starts_with(':')) + }) + .max_by_key(|(connection_id, _)| connection_id.len()) + .map(|(_, config)| config) +} + fn session_scoped_pool_key_for( db_type: Option, base_pool_key: String, @@ -1140,7 +1183,7 @@ mod tests { let params = agent_connect_params(&config, "oracle.example.com", 1521, "ORCLPDB1"); - assert_eq!(params["database"], "ORCLPDB1"); + assert_eq!(params["database"], "SYSDBA:ORCLPDB1"); assert_eq!(params["sysdba"], true); assert_eq!(params["connection_string"], "jdbc:oracle:thin:@//oracle.example.com:1521/ORCLPDB1"); } diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 2a17413fe1..404e05dca6 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -145,6 +145,9 @@ fn mysql_printable_binary_preview(bytes: &[u8]) -> Option { } fn mysql_blob_preview(bytes: &[u8], label: &str) -> serde_json::Value { + if label == "BLOB" { + return super::binary_value_to_json(bytes); + } serde_json::Value::String(format!("({label}) {} bytes", bytes.len())) } @@ -1400,10 +1403,7 @@ mod tests { let blob_column = mysql_test_column(ColumnType::MYSQL_TYPE_BLOB, 63, ColumnFlags::BLOB_FLAG, 65_535); assert_eq!(mysql_bytes_to_json(b"hello".to_vec(), &text_column), serde_json::json!("hello")); - assert_eq!( - mysql_bytes_to_json(vec![0x00, 0x01, 0xab, 0xff], &blob_column), - serde_json::json!("(BLOB) 4 bytes") - ); + assert_eq!(mysql_bytes_to_json(vec![0x00, 0x01, 0xab, 0xff], &blob_column), serde_json::json!("0x0001abff")); } #[test] diff --git a/crates/dbx-web/src/routes/agents.rs b/crates/dbx-web/src/routes/agents.rs index a1e9e081a6..d86c3c9d88 100644 --- a/crates/dbx-web/src/routes/agents.rs +++ b/crates/dbx-web/src/routes/agents.rs @@ -55,6 +55,7 @@ pub async fn install_agent( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&req.db_type)).await.map_err(AppError)?; let tx = progress_sender(&state, "global").await; install_agent_driver(&state.app.agent_manager, &req.db_type, |event| send_progress_event(&tx, event)) .await @@ -63,11 +64,16 @@ pub async fn install_agent( } pub async fn upgrade_all_agents(State(state): State>) -> Result, AppError> { + let registry = fetch_registry().await.map_err(AppError)?; + let agents = build_agent_list(&state.app.agent_manager, Some(®istry)); + let updatable: Vec = + agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect(); + ensure_no_agent_update_blockers(&state.app, &updatable).await.map_err(AppError)?; let tx = progress_sender(&state, "global").await; - let total = upgrade_all_agent_drivers(&state.app.agent_manager, |event| send_progress_event(&tx, event)) + let result = upgrade_all_agent_drivers(&state.app.agent_manager, |event| send_progress_event(&tx, event)) .await .map_err(AppError)?; - Ok(Json(serde_json::json!({ "count": total }))) + Ok(Json(serde_json::to_value(result).map_err(|err| AppError(err.to_string()))?)) } pub async fn uninstall_agent( @@ -224,3 +230,26 @@ fn send_progress_event(tx: &broadcast::Sender, event: AgentProgressEvent let _ = tx.send(payload); } } + +async fn ensure_no_agent_update_blockers( + state: &dbx_core::connection::AppState, + db_types: &[String], +) -> Result<(), String> { + let candidate_keys: std::collections::HashSet<&str> = db_types.iter().map(String::as_str).collect(); + if candidate_keys.is_empty() { + return Ok(()); + } + let mut blockers = state + .active_agent_driver_keys() + .await + .into_iter() + .filter(|key| candidate_keys.contains(key.as_str())) + .map(|key| dbx_core::agent_catalog::label_for_key(&key).unwrap_or(&key).to_string()) + .collect::>(); + blockers.sort(); + if blockers.is_empty() { + Ok(()) + } else { + Err(format!("请先关闭以下数据库连接后再更新驱动: {}", blockers.join(", "))) + } +} diff --git a/packages/app-tests/binaryCellDownload.test.ts b/packages/app-tests/binaryCellDownload.test.ts index cfc80bc925..365afd52a7 100644 --- a/packages/app-tests/binaryCellDownload.test.ts +++ b/packages/app-tests/binaryCellDownload.test.ts @@ -2,10 +2,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + binaryCellDisplayText, binaryCellDownloadFileName, binaryCellDownloadPayload, canDownloadBinaryCellValue, isBinaryCellColumnType, + parseBinaryCellBytes, parseBinaryCellHexValue, } from "../../apps/desktop/src/lib/binaryCellDownload.ts"; @@ -20,6 +22,15 @@ test("parseBinaryCellHexValue rejects non-hex and odd-length payloads", () => { assert.equal(parseBinaryCellHexValue(null), null); }); +test("parseBinaryCellBytes accepts common driver binary shapes", () => { + assert.deepEqual(Array.from(parseBinaryCellBytes("89504e47", "BLOB") ?? []), [137, 80, 78, 71]); + assert.deepEqual(Array.from(parseBinaryCellBytes("\\x89\\x50\\x4e\\x47") ?? []), [137, 80, 78, 71]); + assert.deepEqual(Array.from(parseBinaryCellBytes([0, 1, 171, 255]) ?? []), [0, 1, 171, 255]); + assert.deepEqual(Array.from(parseBinaryCellBytes({ type: "Buffer", data: [222, 173, 190, 239] }) ?? []), [ + 222, 173, 190, 239, + ]); +}); + test("binary cell download detects common blob column types", () => { assert.equal(isBinaryCellColumnType("BLOB"), true); assert.equal(isBinaryCellColumnType("RAW(2000)"), true); @@ -30,7 +41,14 @@ test("binary cell download detects common blob column types", () => { test("canDownloadBinaryCellValue allows displayed binary hex strings", () => { assert.equal(canDownloadBinaryCellValue("0x89504e47", "BLOB"), true); assert.equal(canDownloadBinaryCellValue("0x89504e47"), true); - assert.equal(canDownloadBinaryCellValue("89504e47", "BLOB"), false); + assert.equal(canDownloadBinaryCellValue("89504e47", "BLOB"), true); + assert.equal(canDownloadBinaryCellValue("89504e47"), false); +}); + +test("binaryCellDisplayText summarizes binary values for grid display", () => { + assert.equal(binaryCellDisplayText("0x89504e47", "BLOB"), "BLOB [4 bytes]"); + assert.equal(binaryCellDisplayText(`0x${"00".repeat(2048)}`, "VARBINARY(2048)"), "VARBINARY [2.0 KB]"); + assert.equal(binaryCellDisplayText("0x89504e47"), null); }); test("binaryCellDownloadPayload builds raw and decoded payloads", () => { diff --git a/packages/app-tests/tableTree.test.ts b/packages/app-tests/tableTree.test.ts index 3c148040aa..193fbcf5d3 100644 --- a/packages/app-tests/tableTree.test.ts +++ b/packages/app-tests/tableTree.test.ts @@ -193,3 +193,26 @@ test("mergeTableInfosIntoObjects restores views missing from object metadata", ( ], ); }); + +test("mergeTableInfosIntoObjects dedupes MySQL tables when object metadata carries database as schema", () => { + const merged = mergeTableInfosIntoObjects( + [ + { + name: "orders", + object_type: "TABLE", + schema: "app", + comment: null, + created_at: null, + updated_at: null, + parent_schema: null, + parent_name: null, + }, + ], + [table("orders")], + ); + + assert.deepEqual( + merged.map((item) => ({ name: item.name, type: item.object_type, schema: item.schema })), + [{ name: "orders", type: "TABLE", schema: "app" }], + ); +}); diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 8a0fe51af2..34a69c7cde 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -6,10 +6,16 @@ use dbx_core::agent_manager::{AgentDriverInfo, DriverStoreUsage, JavaRuntimeConf use dbx_core::agent_service::{ build_agent_list, fetch_registry, import_agent_jar, import_agents_from_zip as import_agents_from_zip_core, install_agent_driver, invalidate_registry_cache, reinstall_agent_jre, uninstall_agent_driver, uninstall_agent_jre, - upgrade_all_agent_drivers, AgentProgressEvent, + upgrade_all_agent_drivers, AgentProgressEvent, UpgradeAllAgentDriversResult, }; use dbx_core::connection::AppState; +#[derive(Debug, Clone, serde::Serialize)] +pub struct AgentUpdateBlocker { + pub db_type: String, + pub label: String, +} + #[tauri::command] pub async fn list_installed_agents_local(state: State<'_, Arc>) -> Result, String> { Ok(build_agent_list(&state.agent_manager, None)) @@ -32,16 +38,33 @@ pub async fn install_agent( state: State<'_, Arc>, db_type: String, ) -> Result<(), String> { + ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?; let app_handle = app.clone(); install_agent_driver(&state.agent_manager, &db_type, move |event| emit_agent_progress(&app_handle, event)).await } #[tauri::command] -pub async fn upgrade_all_agents(app: tauri::AppHandle, state: State<'_, Arc>) -> Result { +pub async fn upgrade_all_agents( + app: tauri::AppHandle, + state: State<'_, Arc>, +) -> Result { + let registry = fetch_registry().await?; + let agents = build_agent_list(&state.agent_manager, Some(®istry)); + let updatable: Vec = + agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect(); + ensure_no_agent_update_blockers(state.inner().as_ref(), &updatable).await?; let app_handle = app.clone(); upgrade_all_agent_drivers(&state.agent_manager, move |event| emit_agent_progress(&app_handle, event)).await } +#[tauri::command] +pub async fn check_agent_update_blockers( + state: State<'_, Arc>, + db_types: Vec, +) -> Result, String> { + Ok(agent_update_blockers(state.inner().as_ref(), &db_types).await) +} + #[tauri::command] pub async fn uninstall_agent(state: State<'_, Arc>, db_type: String) -> Result<(), String> { uninstall_agent_driver(&state.agent_manager, &db_type).await @@ -131,3 +154,30 @@ pub async fn reinstall_jre( fn emit_agent_progress(app: &tauri::AppHandle, event: AgentProgressEvent) { let _ = app.emit("agent-install-progress", event); } + +async fn ensure_no_agent_update_blockers(state: &AppState, db_types: &[String]) -> Result<(), String> { + let blockers = agent_update_blockers(state, db_types).await; + if blockers.is_empty() { + return Ok(()); + } + let labels = blockers.into_iter().map(|blocker| blocker.label).collect::>().join(", "); + Err(format!("请先关闭以下数据库连接后再更新驱动: {labels}")) +} + +async fn agent_update_blockers(state: &AppState, db_types: &[String]) -> Vec { + let candidate_keys: std::collections::HashSet<&str> = db_types.iter().map(String::as_str).collect(); + if candidate_keys.is_empty() { + return Vec::new(); + } + let active_keys = state.active_agent_driver_keys().await; + let mut blockers = active_keys + .into_iter() + .filter(|key| candidate_keys.contains(key.as_str())) + .map(|db_type| AgentUpdateBlocker { + label: dbx_core::agent_catalog::label_for_key(&db_type).unwrap_or(&db_type).to_string(), + db_type, + }) + .collect::>(); + blockers.sort_by(|left, right| left.label.cmp(&right.label)); + blockers +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5855c200be..208ae82bef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -512,6 +512,7 @@ pub fn run() { commands::agents::get_driver_store_usage, commands::agents::install_agent, commands::agents::upgrade_all_agents, + commands::agents::check_agent_update_blockers, commands::agents::uninstall_agent, commands::agents::check_jre_installed, commands::agents::get_agent_java_runtime_config,