diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 62aae9b838..570be6ed57 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -91,6 +91,7 @@ import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFi import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { isXuguPublicSynonymTreeNode, isXuguSchedulerJobTreeNode, xuguSchemaDisplayName } from "@/lib/sidebar/xuguPublicSynonyms"; +import { xuguDatafileDetailRows, xuguTablespaceDetailRows } from "@/lib/sidebar/xuguTablespaces"; // --- Drag and Drop --- import { useDragSort } from "@/composables/useDragSort"; import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime"; @@ -478,6 +479,22 @@ function cleanTooltipValue(value: string | number | null | undefined): string { return String(value ?? "").trim(); } +function formatXuguStorageDetailValue(key: string, value: string): string { + if (key === "currentSize" || key === "maxSize" || key === "stepSize") { + const numeric = Number(value); + if (Number.isFinite(numeric)) { + if (key === "maxSize" && numeric === -1) return t("tree.xuguStorage.unlimited"); + return `${numeric} MB`; + } + } + if (key === "mediaError") { + const normalized = value.toUpperCase(); + if (["F", "FALSE", "0", "N"].includes(normalized)) return t("tree.xuguStorage.no"); + if (["T", "TRUE", "1", "Y"].includes(normalized)) return t("tree.xuguStorage.yes"); + } + return value; +} + function isLocalFileConnection(config: Pick): boolean { return config.db_type === "sqlite" || config.db_type === "duckdb" || config.db_type === "access" || (config.db_type === "h2" && config.port === 0); } @@ -571,6 +588,22 @@ const detailTooltip = computed(() => { ].filter((row) => row.value); return rows.length ? { rows } : null; } + if (node.type === "tablespace" && node.xuguTablespace && node.connectionId && effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)) === "xugu") { + const rows: DetailTooltipRow[] = xuguTablespaceDetailRows(node.xuguTablespace).map((row) => ({ + label: t(`tree.xuguStorage.${row.key}`), + value: formatXuguStorageDetailValue(row.key, row.value), + multiline: row.multiline, + })); + return rows.length ? { rows } : null; + } + if (node.type === "datafile" && node.xuguDatafile && node.connectionId && effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)) === "xugu") { + const rows: DetailTooltipRow[] = xuguDatafileDetailRows(node.xuguDatafile).map((row) => ({ + label: t(`tree.xuguStorage.${row.key}`), + value: formatXuguStorageDetailValue(row.key, row.value), + multiline: row.multiline, + })); + return rows.length ? { rows } : null; + } const comment = node.type === "column" && node.meta && "comment" in node.meta ? (node.meta as ColumnInfo).comment : node.comment; if (!comment || (node.type !== "schema" && node.type !== "table" && node.type !== "view" && node.type !== "column")) return null; const rows: DetailTooltipRow[] = [ diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d036e4990a..c6efefef89 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3411,6 +3411,27 @@ export default { damengJobAdmin: "Agent Jobs", xuguTablespaces: "Storage Spaces", xuguDatafiles: "Files", + xuguStorage: { + name: "Name", + nodeId: "Node ID", + spaceId: "Tablespace ID", + spaceType: "Tablespace type", + datafileCount: "Data files", + totalChunks: "Total chunks", + freeChunks: "Free chunks", + usedChunks: "Used chunks", + usage: "Usage", + mediaError: "Media error", + path: "Path", + fileNo: "File number", + currentSize: "Current size", + maxSize: "Maximum size", + stepSize: "Growth step", + reserved: "Reserved", + unlimited: "Unlimited", + yes: "Yes", + no: "No", + }, linkedServers: "Linked Servers", defaultDatabase: "Default DB", columns: "Columns", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 9ed09eed28..34ddfe27bd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3319,6 +3319,27 @@ export default withEnglishFallback({ damengJobAdmin: "Trabajos del agente", xuguTablespaces: "Espacios de almacenamiento", xuguDatafiles: "Archivos", + xuguStorage: { + name: "Nombre", + nodeId: "ID del nodo", + spaceId: "ID del espacio", + spaceType: "Tipo de espacio", + datafileCount: "Archivos de datos", + totalChunks: "Fragmentos totales", + freeChunks: "Fragmentos libres", + usedChunks: "Fragmentos usados", + usage: "Uso", + mediaError: "Error de medio", + path: "Ruta", + fileNo: "Número de archivo", + currentSize: "Tamaño actual", + maxSize: "Tamaño máximo", + stepSize: "Paso de crecimiento", + reserved: "Reservado", + unlimited: "Sin límite", + yes: "Sí", + no: "No", + }, linkedServers: "Servidores vinculados", defaultDatabase: "Base predeterminada", columns: "Columnas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 76c56daf09..818ded1e66 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3317,6 +3317,27 @@ export default withEnglishFallback({ damengJobAdmin: "Job agente", xuguTablespaces: "Spazi di archiviazione", xuguDatafiles: "File", + xuguStorage: { + name: "Nome", + nodeId: "ID nodo", + spaceId: "ID tablespace", + spaceType: "Tipo tablespace", + datafileCount: "File dati", + totalChunks: "Chunk totali", + freeChunks: "Chunk liberi", + usedChunks: "Chunk usati", + usage: "Utilizzo", + mediaError: "Errore supporto", + path: "Percorso", + fileNo: "Numero file", + currentSize: "Dimensione attuale", + maxSize: "Dimensione massima", + stepSize: "Incremento", + reserved: "Riservato", + unlimited: "Illimitato", + yes: "Sì", + no: "No", + }, linkedServers: "Server Collegati", defaultDatabase: "DB Predefinito", columns: "Colonne", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 285c3eae59..4a17fb4193 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3343,6 +3343,27 @@ export default withEnglishFallback({ damengJobAdmin: "エージェントジョブ", xuguTablespaces: "ストレージ領域", xuguDatafiles: "ファイル", + xuguStorage: { + name: "名前", + nodeId: "ノード ID", + spaceId: "表領域 ID", + spaceType: "表領域タイプ", + datafileCount: "データファイル数", + totalChunks: "総チャンク数", + freeChunks: "空きチャンク数", + usedChunks: "使用チャンク数", + usage: "使用率", + mediaError: "メディアエラー", + path: "パス", + fileNo: "ファイル番号", + currentSize: "現在のサイズ", + maxSize: "最大サイズ", + stepSize: "拡張ステップ", + reserved: "予約フィールド", + unlimited: "無制限", + yes: "はい", + no: "いいえ", + }, defaultDatabase: "デフォルトDB", columns: "列", attributes: "属性", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 8d3bb2b469..12b5f2ad50 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3196,6 +3196,27 @@ export default withEnglishFallback({ damengJobAdmin: "에이전트 작업", xuguTablespaces: "저장 공간", xuguDatafiles: "파일", + xuguStorage: { + name: "이름", + nodeId: "노드 ID", + spaceId: "테이블스페이스 ID", + spaceType: "테이블스페이스 유형", + datafileCount: "데이터 파일 수", + totalChunks: "전체 청크", + freeChunks: "사용 가능한 청크", + usedChunks: "사용된 청크", + usage: "사용률", + mediaError: "미디어 오류", + path: "경로", + fileNo: "파일 번호", + currentSize: "현재 크기", + maxSize: "최대 크기", + stepSize: "증가 단위", + reserved: "예약 필드", + unlimited: "무제한", + yes: "예", + no: "아니요", + }, linkedServers: "연결된 서버", defaultDatabase: "기본 DB", columns: "컬럼", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e84cde10df..ef5ea50638 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3319,6 +3319,27 @@ export default withEnglishFallback({ damengJobAdmin: "Jobs do agente", xuguTablespaces: "Espaços de armazenamento", xuguDatafiles: "Arquivos", + xuguStorage: { + name: "Nome", + nodeId: "ID do nó", + spaceId: "ID do tablespace", + spaceType: "Tipo de tablespace", + datafileCount: "Arquivos de dados", + totalChunks: "Chunks totais", + freeChunks: "Chunks livres", + usedChunks: "Chunks usados", + usage: "Uso", + mediaError: "Erro de mídia", + path: "Caminho", + fileNo: "Número do arquivo", + currentSize: "Tamanho atual", + maxSize: "Tamanho máximo", + stepSize: "Etapa de crescimento", + reserved: "Reservado", + unlimited: "Ilimitado", + yes: "Sim", + no: "Não", + }, linkedServers: "Servidores Vinculados", materializedViews: "Visualizações Materializadas", defaultDatabase: "Banco padrão", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 42cbb03cb5..b7c0e101ed 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3332,6 +3332,27 @@ export default withEnglishFallback({ damengJobAdmin: "代理作业", xuguTablespaces: "存储空间", xuguDatafiles: "文件", + xuguStorage: { + name: "名称", + nodeId: "节点 ID", + spaceId: "表空间 ID", + spaceType: "表空间类型", + datafileCount: "数据文件数", + totalChunks: "总数据片", + freeChunks: "空闲数据片", + usedChunks: "已用数据片", + usage: "使用率", + mediaError: "介质错误", + path: "路径", + fileNo: "文件序号", + currentSize: "当前大小", + maxSize: "最大大小", + stepSize: "增长步长", + reserved: "保留字段", + unlimited: "不限制", + yes: "是", + no: "否", + }, linkedServers: "链接服务器", defaultDatabase: "默认库", columns: "字段", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9d50469a3f..61f4ea84f1 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3316,6 +3316,27 @@ export default withEnglishFallback({ damengJobAdmin: "代理作業", xuguTablespaces: "儲存空間", xuguDatafiles: "檔案", + xuguStorage: { + name: "名稱", + nodeId: "節點 ID", + spaceId: "表空間 ID", + spaceType: "表空間類型", + datafileCount: "資料檔案數", + totalChunks: "總資料片", + freeChunks: "空閒資料片", + usedChunks: "已用資料片", + usage: "使用率", + mediaError: "媒體錯誤", + path: "路徑", + fileNo: "檔案序號", + currentSize: "目前大小", + maxSize: "最大大小", + stepSize: "增長步長", + reserved: "保留欄位", + unlimited: "不限制", + yes: "是", + no: "否", + }, linkedServers: "連結伺服器", materializedViews: "具體化檢視", defaultDatabase: "預設庫", diff --git a/apps/desktop/src/lib/__tests__/sidebar/xuguTablespaces.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/xuguTablespaces.spec.ts index 85f1c275f9..ca4e981735 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/xuguTablespaces.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/xuguTablespaces.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildXuguTablespaceChildren, xuguDatafileDisplayName } from "@/lib/sidebar/xuguTablespaces"; +import { buildXuguTablespaceChildren, xuguDatafileDetailRows, xuguDatafileDisplayName, xuguTablespaceDetailRows } from "@/lib/sidebar/xuguTablespaces"; describe("Xugu tablespace tree", () => { it("uses the physical file basename while retaining the full path", () => { @@ -25,10 +25,62 @@ describe("Xugu tablespace tree", () => { id: "conn:storage:tablespace:7:files:1:/data/DATA1.DBF", type: "datafile", label: "DATA1.DBF", + xuguDatafile: { node_id: "1", space_id: 7, path: "/data/DATA1.DBF", file_no: 1, curr_size: 1024 }, xuguDatafilePath: "/data/DATA1.DBF", }); }); + it("builds tablespace details and derives chunk usage", () => { + expect( + xuguTablespaceDetailRows({ + node_id: "1", + space_id: 7, + space_name: "DATA1", + datafile_num: 2, + space_type: "DATA_SPACE", + media_error: "F", + total_chunk_num: 64, + free_chunk_num: 16, + datafiles: [], + }), + ).toEqual([ + { key: "name", value: "DATA1" }, + { key: "nodeId", value: "1" }, + { key: "spaceId", value: "7" }, + { key: "spaceType", value: "DATA_SPACE" }, + { key: "datafileCount", value: "2" }, + { key: "totalChunks", value: "64" }, + { key: "freeChunks", value: "16" }, + { key: "usedChunks", value: "48" }, + { key: "usage", value: "75.0%" }, + { key: "mediaError", value: "F" }, + ]); + }); + + it("builds datafile details while preserving nullable size fields", () => { + expect( + xuguDatafileDetailRows({ + node_id: "1", + space_id: 7, + path: "/data/DATA1.DBF", + file_no: 1, + max_size: -1, + step_size: 64, + curr_size: 512, + reserved1: null, + }), + ).toEqual([ + { key: "name", value: "DATA1.DBF" }, + { key: "path", value: "/data/DATA1.DBF", multiline: true }, + { key: "nodeId", value: "1" }, + { key: "spaceId", value: "7" }, + { key: "fileNo", value: "1" }, + { key: "currentSize", value: "512" }, + { key: "maxSize", value: "-1" }, + { key: "stepSize", value: "64" }, + ]); + }); + it("preserves expanded state on refresh", () => { const first = buildXuguTablespaceChildren({ id: "conn:storage", connectionId: "conn", database: "APP", children: [] }, [{ node_id: "1", space_id: 7, space_name: "DATA1", datafile_num: 0, space_type: "PERMANENT", datafiles: [] }]); first[0].isExpanded = true; diff --git a/apps/desktop/src/lib/sidebar/xuguTablespaces.ts b/apps/desktop/src/lib/sidebar/xuguTablespaces.ts index ce28910355..59aaa6c404 100644 --- a/apps/desktop/src/lib/sidebar/xuguTablespaces.ts +++ b/apps/desktop/src/lib/sidebar/xuguTablespaces.ts @@ -1,5 +1,54 @@ import type { TreeNode, XuguTablespaceInfo } from "@/types/database"; +export type XuguStorageDetailEntry = { + key: string; + value: string; + multiline?: boolean; +}; + +function detailValue(value: string | number | null | undefined): string { + return value == null ? "" : String(value).trim(); +} + +function detailEntries(entries: XuguStorageDetailEntry[]): XuguStorageDetailEntry[] { + return entries.filter((entry) => entry.value.length > 0); +} + +/** Build the fields shown by the sidebar's Xugu tablespace detail popover. */ +export function xuguTablespaceDetailRows(tablespace: XuguTablespaceInfo): XuguStorageDetailEntry[] { + const total = tablespace.total_chunk_num; + const free = tablespace.free_chunk_num; + const used = total != null && free != null ? Math.max(total - free, 0) : null; + const usage = total != null && total > 0 && used != null ? `${((used / total) * 100).toFixed(1)}%` : ""; + return detailEntries([ + { key: "name", value: detailValue(tablespace.space_name) }, + { key: "nodeId", value: detailValue(tablespace.node_id) }, + { key: "spaceId", value: detailValue(tablespace.space_id) }, + { key: "spaceType", value: detailValue(tablespace.space_type) }, + { key: "datafileCount", value: detailValue(tablespace.datafile_num) }, + { key: "totalChunks", value: detailValue(total) }, + { key: "freeChunks", value: detailValue(free) }, + { key: "usedChunks", value: detailValue(used) }, + { key: "usage", value: usage }, + { key: "mediaError", value: detailValue(tablespace.media_error) }, + ]); +} + +/** Build the fields shown by the sidebar's Xugu datafile detail popover. */ +export function xuguDatafileDetailRows(datafile: NonNullable): XuguStorageDetailEntry[] { + return detailEntries([ + { key: "name", value: xuguDatafileDisplayName(datafile.path, datafile.file_no) }, + { key: "path", value: detailValue(datafile.path), multiline: true }, + { key: "nodeId", value: detailValue(datafile.node_id) }, + { key: "spaceId", value: detailValue(datafile.space_id) }, + { key: "fileNo", value: detailValue(datafile.file_no) }, + { key: "currentSize", value: detailValue(datafile.curr_size) }, + { key: "maxSize", value: detailValue(datafile.max_size) }, + { key: "stepSize", value: detailValue(datafile.step_size) }, + { key: "reserved", value: detailValue(datafile.reserved1) }, + ]); +} + export function xuguDatafileDisplayName(path: string, fileNo: number): string { const normalized = path.trim().replaceAll("\\", "/"); const parts = normalized.split("/").filter(Boolean); @@ -21,6 +70,7 @@ export function buildXuguTablespaceChildren(parent: Pick