mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 16:04:26 +00:00
Merge branch 't8y2:main' into fix/import-export-connection-icons
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(®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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(®istry));
|
||||
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(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" }],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(®istry));
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user