Merge branch 't8y2:main' into fix/import-export-connection-icons

This commit is contained in:
DengQingNian
2026-06-03 15:52:01 +08:00
committed by GitHub
23 changed files with 495 additions and 36 deletions
@@ -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 {
@@ -103,6 +103,7 @@ const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
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<ConnectionConfig, "db_type" | "username">): 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) {
<div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">SYSDBA</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.sysdba" class="mr-0" />
<input type="checkbox" v-model="form.sysdba" class="mr-0" :disabled="isOracleSysUser(form)" />
<span class="text-xs text-muted-foreground">as SYSDBA</span>
</label>
</div>
@@ -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<ContextMenuItem[]>(() => {
.data-grid-scroller {
overflow-anchor: none;
scrollbar-gutter: stable;
will-change: scroll-position;
contain: strict;
}
@@ -15,10 +15,11 @@ export interface UseDataGridColumnResizeOptions {
sourceRows: ComputedRef<CellValue[][]>;
columnIndexes: ComputedRef<number[]>;
gridRef: Ref<HTMLDivElement | undefined>;
scrollbarGutter?: Ref<number>;
}
export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) {
const { columns, sourceRows, columnIndexes, gridRef } = options;
const { columns, sourceRows, columnIndexes, gridRef, scrollbarGutter } = options;
const columnWidths = ref<number[]>([]);
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;
+2
View File
@@ -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}",
+2
View File
@@ -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}",
+2
View File
@@ -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}",
+2
View File
@@ -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}",
+1
View File
@@ -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");
+74 -8
View File
@@ -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") {
+8 -3
View File
@@ -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<void> {
await post("/api/agents/install", { dbType });
}
export async function upgradeAllAgents(): Promise<number> {
const result: { count: number } = await post("/api/agents/upgrade-all", {});
return result.count;
export async function upgradeAllAgents(): Promise<UpgradeAllAgentDriversResult> {
return post("/api/agents/upgrade-all", {});
}
export async function checkAgentUpdateBlockers(_dbTypes: string[]): Promise<AgentUpdateBlocker[]> {
return [];
}
export async function uninstallAgent(dbType: string): Promise<void> {
+9 -2
View File
@@ -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,
+21 -1
View File
@@ -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<void> {
return invoke("install_agent", { dbType });
}
export async function upgradeAllAgents(): Promise<number> {
export async function upgradeAllAgents(): Promise<UpgradeAllAgentDriversResult> {
return invoke("upgrade_all_agents");
}
export async function checkAgentUpdateBlockers(dbTypes: string[]): Promise<AgentUpdateBlocker[]> {
return invoke("check_agent_update_blockers", { dbTypes });
}
export async function uninstallAgent(dbType: string): Promise<void> {
return invoke("uninstall_agent", { dbType });
}
+40 -1
View File
@@ -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"));
+19
View File
@@ -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<DriverStoreUsageItem>,
@@ -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<String> {
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)
}
+94 -4
View File
@@ -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<u32>,
}
#[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<AgentDriverUpdateIssue>,
}
impl AgentProgressEvent {
pub fn step(step: impl Into<String>) -> 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<u32, String> {
) -> Result<UpgradeAllAgentDriversResult, String> {
let registry = fetch_registry().await?;
let agents = build_agent_list(am, Some(&registry));
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,
&registry,
&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 {
+45 -2
View File
@@ -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<String> {
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<bool, String> {
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<String, ConnectionConfig>,
) -> 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<DatabaseType>,
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");
}
+4 -4
View File
@@ -145,6 +145,9 @@ fn mysql_printable_binary_preview(bytes: &[u8]) -> Option<String> {
}
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]
+31 -2
View File
@@ -55,6 +55,7 @@ pub async fn install_agent(
State(state): State<Arc<WebState>>,
Json(req): Json<AgentTypeRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&req.db_type)).await.map_err(AppError)?;
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<Arc<WebState>>) -> Result<Json<serde_json::Value>, AppError> {
let registry = fetch_registry().await.map_err(AppError)?;
let agents = build_agent_list(&state.app.agent_manager, Some(&registry));
let updatable: Vec<String> =
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
ensure_no_agent_update_blockers(&state.app, &updatable).await.map_err(AppError)?;
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<String>, 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::<Vec<_>>();
blockers.sort();
if blockers.is_empty() {
Ok(())
} else {
Err(format!("请先关闭以下数据库连接后再更新驱动: {}", blockers.join(", ")))
}
}
+19 -1
View File
@@ -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", () => {
+23
View File
@@ -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" }],
);
});
+52 -2
View File
@@ -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<AppState>>) -> Result<Vec<AgentDriverInfo>, String> {
Ok(build_agent_list(&state.agent_manager, None))
@@ -32,16 +38,33 @@ pub async fn install_agent(
state: State<'_, Arc<AppState>>,
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<AppState>>) -> Result<u32, String> {
pub async fn upgrade_all_agents(
app: tauri::AppHandle,
state: State<'_, Arc<AppState>>,
) -> Result<UpgradeAllAgentDriversResult, String> {
let registry = fetch_registry().await?;
let agents = build_agent_list(&state.agent_manager, Some(&registry));
let updatable: Vec<String> =
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
ensure_no_agent_update_blockers(state.inner().as_ref(), &updatable).await?;
let app_handle = app.clone();
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<AppState>>,
db_types: Vec<String>,
) -> Result<Vec<AgentUpdateBlocker>, String> {
Ok(agent_update_blockers(state.inner().as_ref(), &db_types).await)
}
#[tauri::command]
pub async fn uninstall_agent(state: State<'_, Arc<AppState>>, 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::<Vec<_>>().join(", ");
Err(format!("请先关闭以下数据库连接后再更新驱动: {labels}"))
}
async fn agent_update_blockers(state: &AppState, db_types: &[String]) -> Vec<AgentUpdateBlocker> {
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::<Vec<_>>();
blockers.sort_by(|left, right| left.label.cmp(&right.label));
blockers
}
+1
View File
@@ -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,