diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index e9fc7da17..3eb7192c1 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -57,6 +57,33 @@ SELECT SCHEMA_NAME FROM ALL_SCHEMAS WHERE DB_ID = CURRENT_DB_ID ORDER BY SCHEMA_NAME` + +// Xugu exposes storage metadata through SYS_* views scoped to the current +// database. Keep these statements independent from the generic object +// catalog so ordinary schema browsing remains unchanged for every driver. +const xuguListTablespacesSQL = ` +SELECT NODEID, SPACE_ID, SPACE_NAME, DATAFILE_NUM, SPACE_TYPE, MEDIA_ERROR, + TOTAL_CHUNK_NUM, FREE_CHUNK_NUM +FROM SYS_TABLESPACES +ORDER BY SPACE_ID` + +// ALL_* is the ordinary-account view exposed by Xugu. SYS_* is retained as +// the primary query because it is the stable shape used by the native agent; +// this fallback lets DBA/normal logins browse the same read-only metadata when +// their account is not allowed to read the SYS_* views. +const xuguListAllTablespacesSQL = ` +SELECT NODE_ID, SPACE_ID, SPACE_NAME, DATAFILE_NUM, SPACE_TYPE, MEDIA_ERROR, + TOTAL_CHUNK_NUM, FREE_CHUNK_NUM +FROM ALL_TABLESPACES +ORDER BY SPACE_ID` +const xuguListDatafilesSQL = ` +SELECT NODEID, SPACE_ID, PATH, FILE_NO, MAX_SIZE, STEP_SIZE, CURR_SIZE, RESERVED1 +FROM SYS_DATAFILES +ORDER BY SPACE_ID, FILE_NO` +const xuguListAllDatafilesSQL = ` +SELECT NODEID, SPACE_ID, PATH, FILE_NO, MAX_SIZE, STEP_SIZE, CURR_SIZE, RESERVED1 +FROM ALL_DATAFILES +ORDER BY SPACE_ID, FILE_NO` const xuguCatalogTableNameSelectSQL = ` SELECT s.SCHEMA_NAME, t.TABLE_NAME FROM ALL_TABLES t @@ -389,6 +416,29 @@ type databaseInfo struct { Name string `json:"name"` } +type xuguDatafileInfo struct { + NodeID string `json:"node_id"` + SpaceID int64 `json:"space_id"` + Path string `json:"path"` + FileNo int64 `json:"file_no"` + MaxSize *int64 `json:"max_size"` + StepSize *int64 `json:"step_size"` + CurrSize *int64 `json:"curr_size"` + Reserved1 *string `json:"reserved1"` +} + +type xuguTablespaceInfo struct { + NodeID string `json:"node_id"` + SpaceID int64 `json:"space_id"` + SpaceName string `json:"space_name"` + DatafileNum int64 `json:"datafile_num"` + SpaceType string `json:"space_type"` + MediaError *string `json:"media_error"` + TotalChunkNum *int64 `json:"total_chunk_num"` + FreeChunkNum *int64 `json:"free_chunk_num"` + Datafiles []xuguDatafileInfo `json:"datafiles"` +} + type tableInfo struct { Name string `json:"name"` TableType string `json:"table_type"` @@ -1035,6 +1085,14 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any case "list_databases": result, err := s.listDatabases() return result, false, err + case "list_xugu_tablespaces": + if database := stringParam(params, "database"); database != "" { + if err := s.useDatabase(database); err != nil { + return nil, false, err + } + } + result, err := s.listTablespaces() + return result, false, err case "list_schemas": if err := s.useDatabase(stringParam(params, "database")); err != nil { return nil, false, err @@ -1699,6 +1757,84 @@ func fallbackDatabasesFromParams(params connectParams) []databaseInfo { return nil } +func (s *server) listTablespaces() ([]xuguTablespaceInfo, error) { + spaceRows, err := s.queryRows(xuguListTablespacesSQL, nil) + if err != nil && isXuguMetadataUnavailableError(err) { + spaceRows, err = s.queryRows(xuguListAllTablespacesSQL, nil) + } + if err != nil { + return nil, err + } + defer s.closeRows(spaceRows) + + spaces := make([]xuguTablespaceInfo, 0) + spaceIndex := make(map[int64]int) + for spaceRows.Next() { + values, err := scanRow(spaceRows, 8) + if err != nil { + return nil, err + } + space := xuguTablespaceInfo{ + NodeID: xuguString(values[0]), + SpaceID: xuguInt64(values[1]), + SpaceName: xuguString(values[2]), + DatafileNum: xuguInt64(values[3]), + SpaceType: xuguString(values[4]), + MediaError: optionalStringPtr(values[5]), + TotalChunkNum: optionalInt64(values[6]), + FreeChunkNum: optionalInt64(values[7]), + Datafiles: []xuguDatafileInfo{}, + } + spaceIndex[space.SpaceID] = len(spaces) + spaces = append(spaces, space) + } + if err := spaceRows.Err(); err != nil { + return nil, err + } + if len(spaces) == 0 { + return spaces, nil + } + + fileRows, err := s.queryRows(xuguListDatafilesSQL, nil) + if err != nil { + // Accounts may inspect the parent view without being allowed to read + // physical file paths. Keep the parent rows usable in that case. + if isXuguMetadataUnavailableError(err) { + fileRows, err = s.queryRows(xuguListAllDatafilesSQL, nil) + if err != nil && isXuguMetadataUnavailableError(err) { + return spaces, nil + } + } + if err != nil { + return nil, err + } + } + defer s.closeRows(fileRows) + for fileRows.Next() { + values, err := scanRow(fileRows, 8) + if err != nil { + return nil, err + } + file := xuguDatafileInfo{ + NodeID: xuguString(values[0]), + SpaceID: xuguInt64(values[1]), + Path: xuguString(values[2]), + FileNo: xuguInt64(values[3]), + MaxSize: optionalInt64(values[4]), + StepSize: optionalInt64(values[5]), + CurrSize: optionalInt64(values[6]), + Reserved1: optionalStringPtr(values[7]), + } + if index, ok := spaceIndex[file.SpaceID]; ok { + spaces[index].Datafiles = append(spaces[index].Datafiles, file) + } + } + if err := fileRows.Err(); err != nil { + return nil, err + } + return spaces, nil +} + func configuredDatabaseName(params connectParams) string { if name := strings.TrimSpace(params.Database); name != "" { return name @@ -1731,7 +1867,7 @@ func isXuguMetadataAccessError(err error) bool { catalogObject := false for _, object := range []string{ "DATABASES", "SCHEMAS", "TABLES", "VIEWS", "COLUMNS", "CONSTRAINTS", "INDEXES", - "TRIGGERS", "PARTIS", "SUBPARTIS", "IDX_PARTIS", "IDX_SUBPARTIS", "SEQUENCES", "SYNONYMS", "JOBS", "PROCEDURES", "PACKAGES", "TYPES", + "TRIGGERS", "PARTIS", "SUBPARTIS", "IDX_PARTIS", "IDX_SUBPARTIS", "SEQUENCES", "SYNONYMS", "JOBS", "PROCEDURES", "PACKAGES", "TYPES", "TABLESPACES", "DATAFILES", } { if strings.Contains(message, "ALL_"+object) || strings.Contains(message, "SYS_"+object) { catalogObject = true @@ -5578,6 +5714,42 @@ func xuguInt(value any) int { } } +func xuguInt64(value any) int64 { + value = normalizeValue(value) + switch v := value.(type) { + case int64: + return v + case int: + return int64(v) + case uint64: + return int64(v) + case float64: + return int64(v) + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + return parsed + default: + parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(v)), 10, 64) + return parsed + } +} + +func optionalInt64(value any) *int64 { + if normalizeValue(value) == nil { + return nil + } + parsed := xuguInt64(value) + return &parsed +} + +func optionalStringPtr(value any) *string { + if normalizeValue(value) == nil { + return nil + } + parsed := xuguString(value) + return &parsed +} + func stringPtr(value string) *string { if strings.TrimSpace(value) == "" { return nil diff --git a/agents/drivers/xugu/tablespaces_live_test.go b/agents/drivers/xugu/tablespaces_live_test.go new file mode 100644 index 000000000..2fd3fac59 --- /dev/null +++ b/agents/drivers/xugu/tablespaces_live_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "os" + "strconv" + "strings" + "testing" + "time" +) + +// TestLiveXuguTablespaces exercises the read-only storage metadata path with +// the same database/sql driver used by the agent. It is opt-in because CI does +// not provide a XuguDB service and it never creates or changes database data. +func TestLiveXuguTablespaces(t *testing.T) { + if os.Getenv("XUGU_LIVE_TEST") != "1" { + t.Skip("set XUGU_LIVE_TEST=1 with XUGU_LIVE_* connection settings to run the XuguDB integration test") + } + params := connectParams{ + Host: os.Getenv("XUGU_LIVE_HOST"), + Port: defaultXuguPort, + Database: os.Getenv("XUGU_LIVE_DATABASE"), + Username: os.Getenv("XUGU_LIVE_USERNAME"), + Password: os.Getenv("XUGU_LIVE_PASSWORD"), + } + if port, err := strconv.Atoi(os.Getenv("XUGU_LIVE_PORT")); err == nil && port > 0 { + params.Port = port + } + if params.Host == "" || params.Database == "" || params.Username == "" || params.Password == "" { + t.Skip("XUGU_LIVE_HOST, XUGU_LIVE_DATABASE, XUGU_LIVE_USERNAME, and XUGU_LIVE_PASSWORD are required") + } + db, err := openDB(params) + if err != nil { + t.Skipf("live XuguDB is unavailable: %v", err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + tablespaceRows, err := db.QueryContext(ctx, xuguListTablespacesSQL) + if err != nil { + t.Fatalf("query SYS_TABLESPACES through go-xugu-driver: %v", err) + } + defer tablespaceRows.Close() + columns, err := tablespaceRows.Columns() + if err != nil { + t.Fatalf("read SYS_TABLESPACES columns: %v", err) + } + if !hasColumns(columns, []string{"NODEID", "SPACE_ID", "SPACE_NAME", "DATAFILE_NUM", "SPACE_TYPE"}) { + t.Fatalf("SYS_TABLESPACES is missing required columns: %v", columns) + } + spaceIDs := make(map[int64]bool) + for tablespaceRows.Next() { + values, err := scanRow(tablespaceRows, len(columns)) + if err != nil { + t.Fatalf("scan SYS_TABLESPACES: %v", err) + } + spaceID := xuguInt64(values[columnIndex(columns, "SPACE_ID")]) + spaceIDs[spaceID] = true + } + if err := tablespaceRows.Err(); err != nil { + t.Fatalf("iterate SYS_TABLESPACES: %v", err) + } + if len(spaceIDs) == 0 { + t.Fatal("SYS_TABLESPACES returned no rows") + } + + datafileRows, err := db.QueryContext(ctx, xuguListDatafilesSQL) + if err != nil { + if isXuguMetadataUnavailableError(err) { + t.Skipf("SYS_DATAFILES is unavailable for this account: %v", err) + } + t.Fatalf("query SYS_DATAFILES through go-xugu-driver: %v", err) + } + defer datafileRows.Close() + datafileColumns, err := datafileRows.Columns() + if err != nil { + t.Fatalf("read SYS_DATAFILES columns: %v", err) + } + if !hasColumns(datafileColumns, []string{"NODEID", "SPACE_ID", "PATH", "FILE_NO", "CURR_SIZE"}) { + t.Fatalf("SYS_DATAFILES is missing required columns: %v", datafileColumns) + } + datafileCount := 0 + for datafileRows.Next() { + values, err := scanRow(datafileRows, len(datafileColumns)) + if err != nil { + t.Fatalf("scan SYS_DATAFILES: %v", err) + } + spaceID := xuguInt64(values[columnIndex(datafileColumns, "SPACE_ID")]) + if !spaceIDs[spaceID] { + t.Fatalf("SYS_DATAFILES references unknown SPACE_ID %d", spaceID) + } + path := optionalStringPtr(values[columnIndex(datafileColumns, "PATH")]) + if path == nil || strings.TrimSpace(*path) == "" { + t.Fatal("SYS_DATAFILES returned an empty PATH") + } + datafileCount++ + } + if err := datafileRows.Err(); err != nil { + t.Fatalf("iterate SYS_DATAFILES: %v", err) + } + if datafileCount == 0 { + t.Fatal("SYS_DATAFILES returned no rows") + } + + server := newServer() + server.db = db + server.params = params + server.currentDatabase = params.Database + spaces, err := server.listTablespaces() + if err != nil { + t.Fatalf("listTablespaces mapping through the agent: %v", err) + } + if len(spaces) != len(spaceIDs) { + t.Fatalf("agent returned %d tablespaces, direct query returned %d", len(spaces), len(spaceIDs)) + } + attachedFiles := 0 + for _, space := range spaces { + attachedFiles += len(space.Datafiles) + } + if attachedFiles != datafileCount { + t.Fatalf("agent attached %d datafiles, direct query returned %d", attachedFiles, datafileCount) + } +} + +func hasColumns(columns []string, required []string) bool { + seen := make(map[string]bool, len(columns)) + for _, column := range columns { + seen[strings.ToUpper(column)] = true + } + for _, column := range required { + if !seen[strings.ToUpper(column)] { + return false + } + } + return true +} + +func columnIndex(columns []string, name string) int { + for index, column := range columns { + if strings.EqualFold(column, name) { + return index + } + } + return -1 +} diff --git a/agents/drivers/xugu/tablespaces_test.go b/agents/drivers/xugu/tablespaces_test.go new file mode 100644 index 000000000..89fe4b096 --- /dev/null +++ b/agents/drivers/xugu/tablespaces_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestXuguTablespaceValueHelpers(t *testing.T) { + if got := xuguInt64(int32(12)); got != 12 { + t.Fatalf("xuguInt64(int32) = %d, want 12", got) + } + if got := xuguInt64(" 4096 "); got != 4096 { + t.Fatalf("xuguInt64(string) = %d, want 4096", got) + } + if got := optionalInt64(nil); got != nil { + t.Fatalf("optionalInt64(nil) = %v, want nil", *got) + } + if got := optionalStringPtr(nil); got != nil { + t.Fatalf("optionalStringPtr(nil) = %q, want nil", *got) + } + if got := optionalStringPtr([]byte("F")); got == nil || *got != "F" { + t.Fatalf("optionalStringPtr([]byte) = %v, want F", got) + } +} + +func TestXuguTablespaceNestedJSONShape(t *testing.T) { + value := xuguTablespaceInfo{ + NodeID: "1", + SpaceID: 7, + SpaceName: "DATA1", + DatafileNum: 1, + SpaceType: "PERMANENT", + MediaError: nil, + TotalChunkNum: optionalInt64("8"), + FreeChunkNum: optionalInt64("3"), + Datafiles: []xuguDatafileInfo{{ + NodeID: "1", + SpaceID: 7, + Path: "/data/DATA1.DBF", + FileNo: 1, + MaxSize: nil, + StepSize: optionalInt64(64), + CurrSize: optionalInt64(1024), + Reserved1: nil, + }}, + } + payload, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var decoded xuguTablespaceInfo + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded.Datafiles) != 1 || decoded.Datafiles[0].Path != "/data/DATA1.DBF" { + t.Fatalf("nested datafile was not preserved: %+v", decoded) + } + if decoded.TotalChunkNum == nil || *decoded.TotalChunkNum != 8 || decoded.Datafiles[0].MaxSize != nil { + t.Fatalf("nullable storage fields changed shape: %+v", decoded) + } +} diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 44a83815f..85ebff839 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -685,6 +685,8 @@ const groupTypes: Set = new Set([ "group-types", "group-partitions", "group-extensions", + "group-tablespaces", + "group-datafiles", ]); function isGroupLabel(node: TreeNode): boolean { @@ -807,6 +809,35 @@ async function toggle(requestId = beginNavigationRequest()) { return; } + // Xugu tablespace rows and their datafile groups are eagerly materialized + // with the tablespace listing; expanding them is a pure local toggle. + if (node.type === "tablespace" || node.type === "group-datafiles") { + node.isExpanded = !node.isExpanded; + emitNodeToggled(node, wasExpanded); + return; + } + + if (node.type === "group-tablespaces") { + if (connectionStore.canUseLoadedTreeNodeToggle(node)) { + node.isExpanded = !node.isExpanded; + if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id); + emitNodeToggled(node, wasExpanded); + return; + } + try { + await connectionStore.loadXuguTablespaces(node, { preserveCollapsedChildren: !shouldReleaseCollapsedTreeNodeChildren() }); + emitNodeToggled(node, wasExpanded); + } catch (e: any) { + if (!isCurrentNavigationRequest(requestId)) return; + if (!wasExpanded) node.isExpanded = false; + const errMsg = e?.message || String(e); + if (errMsg.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return; + toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000); + openDriverStoreForInstallError(errMsg); + } + return; + } + if (isXuguTypeMemberContainer(node, connectionStore.getConfig(node.connectionId || "")?.db_type)) { await connectionStore.loadXuguTypeMembers(node, { preserveCollapsedChildren: !shouldReleaseCollapsedTreeNodeChildren() }); emitNodeToggled(node, wasExpanded); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 510c31b99..1a4843a9b 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -240,6 +240,10 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-amber-500" }; case "database": return { icon: Database, colorClass: "text-yellow-500" }; + case "tablespace": + return { icon: Database, colorClass: "text-orange-500" }; + case "datafile": + return { icon: FileCode, colorClass: "text-slate-500" }; case "linked-server-root": return { icon: Network, colorClass: "text-blue-500" }; case "linked-server": @@ -395,6 +399,10 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" }; case "group-extensions": return { icon: Package, colorClass: "text-violet-500" }; + case "group-tablespaces": + return { icon: Database, colorClass: "text-orange-500" }; + case "group-datafiles": + return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-slate-500" }; case "extension": return { icon: Package, colorClass: "text-violet-400" }; case "load-more": @@ -432,6 +440,7 @@ function displayLabel(node: TreeNode): string { function treeNodeSecondaryValue(node: TreeNode): string | undefined { if (node.type === "type" && node.customTypeKind) return t(`customType.kinds.${node.customTypeKind}`); if (node.type === "type-member") return (node.meta as CustomTypeTreeMemberMeta | undefined)?.displayValue; + if (node.type === "datafile") return node.xuguDatafilePath; return undefined; } diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts index 475428089..3972d1a9e 100644 --- a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts @@ -21,6 +21,9 @@ const connectionStore = { loadObjectGroupChildren: vi.fn(async (node: TreeNode) => { node.isExpanded = true; }), + loadXuguTablespaces: vi.fn(async (node: TreeNode) => { + node.isExpanded = true; + }), }; const queryStore = { @@ -421,4 +424,62 @@ describe("SidebarTreeRuntimeHost expansion", () => { expect(packageFunctionGroup.isExpanded).toBe(true); expect(packageFunctionGroup.children).toHaveLength(401); }); + + it("toggles Xugu tablespace and datafile group nodes locally", async () => { + const datafileGroup: TreeNode = { + id: "xugu:SHOP_DEMO:tablespace:SYSTEM:datafiles", + label: "tree.datafiles", + type: "group-datafiles", + parentType: "tablespace", + parentName: "SYSTEM", + connectionId: "xugu", + database: "SHOP_DEMO", + objectCount: 2, + isExpanded: false, + children: [ + { id: "xugu:SHOP_DEMO:tablespace:SYSTEM:datafile:1", label: "system_01.dbf", type: "datafile", parentType: "tablespace", parentName: "SYSTEM", connectionId: "xugu", database: "SHOP_DEMO", objectName: "system_01.dbf" }, + { id: "xugu:SHOP_DEMO:tablespace:SYSTEM:datafile:2", label: "system_02.dbf", type: "datafile", parentType: "tablespace", parentName: "SYSTEM", connectionId: "xugu", database: "SHOP_DEMO", objectName: "system_02.dbf" }, + ], + }; + const tablespaceNode: TreeNode = { + id: "xugu:SHOP_DEMO:tablespace:SYSTEM", + label: "SYSTEM", + type: "tablespace", + connectionId: "xugu", + database: "SHOP_DEMO", + objectName: "SYSTEM", + isExpanded: false, + children: [datafileGroup], + }; + connectionStore.treeNodes = [tablespaceNode]; + connectionStore.getConfig.mockReturnValue({ db_type: "xugu" }); + connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(false); + + const host = ref | null>(null); + const toggled = vi.fn(); + const app = createApp( + defineComponent({ + setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: tablespaceNode, depth: 0, onNodeToggled: toggled }), + }), + ); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + + host.value?.toggleNode(tablespaceNode); + await nextTick(); + + expect(tablespaceNode.isExpanded).toBe(true); + expect(connectionStore.loadXuguTablespaces).not.toHaveBeenCalled(); + expect(toggled).toHaveBeenCalledWith(tablespaceNode, true); + + host.value?.toggleNode(datafileGroup); + await nextTick(); + + expect(datafileGroup.isExpanded).toBe(true); + expect(connectionStore.loadXuguTablespaces).not.toHaveBeenCalled(); + expect(toggled).toHaveBeenCalledWith(datafileGroup, true); + }); }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ab7e17776..27a2b05a2 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3369,6 +3369,8 @@ export default { damengUsers: "Users", damengRoles: "Roles", damengJobAdmin: "Agent Jobs", + xuguTablespaces: "Storage Spaces", + xuguDatafiles: "Files", 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 b54a19bf4..66d83a403 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3277,6 +3277,8 @@ export default withEnglishFallback({ damengUsers: "Usuarios", damengRoles: "Roles", damengJobAdmin: "Trabajos del agente", + xuguTablespaces: "Espacios de almacenamiento", + xuguDatafiles: "Archivos", 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 85a104bfa..a91d5aeba 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3275,6 +3275,8 @@ export default withEnglishFallback({ damengUsers: "Utenti", damengRoles: "Ruoli", damengJobAdmin: "Job agente", + xuguTablespaces: "Spazi di archiviazione", + xuguDatafiles: "File", 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 edd68ec78..a59f3d9b3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3301,6 +3301,8 @@ export default withEnglishFallback({ damengUsers: "ユーザー", damengRoles: "ロール", damengJobAdmin: "エージェントジョブ", + xuguTablespaces: "ストレージ領域", + xuguDatafiles: "ファイル", defaultDatabase: "デフォルトDB", columns: "列", attributes: "属性", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index cad10bfe3..35551d463 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3154,6 +3154,8 @@ export default withEnglishFallback({ damengUsers: "사용자", damengRoles: "역할", damengJobAdmin: "에이전트 작업", + xuguTablespaces: "저장 공간", + xuguDatafiles: "파일", 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 d226f30a0..7cbe103f1 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3277,6 +3277,8 @@ export default withEnglishFallback({ damengUsers: "Usuários", damengRoles: "Funções", damengJobAdmin: "Jobs do agente", + xuguTablespaces: "Espaços de armazenamento", + xuguDatafiles: "Arquivos", 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 562b96237..8e796853e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3290,6 +3290,8 @@ export default withEnglishFallback({ damengUsers: "用户", damengRoles: "角色", damengJobAdmin: "代理作业", + xuguTablespaces: "存储空间", + xuguDatafiles: "文件", linkedServers: "链接服务器", defaultDatabase: "默认库", columns: "字段", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0748e3d59..a802ed72f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3274,6 +3274,8 @@ export default withEnglishFallback({ damengUsers: "使用者", damengRoles: "角色", damengJobAdmin: "代理作業", + xuguTablespaces: "儲存空間", + xuguDatafiles: "檔案", 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 new file mode 100644 index 000000000..85f1c275f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/xuguTablespaces.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { buildXuguTablespaceChildren, xuguDatafileDisplayName } from "@/lib/sidebar/xuguTablespaces"; + +describe("Xugu tablespace tree", () => { + it("uses the physical file basename while retaining the full path", () => { + expect(xuguDatafileDisplayName("/data/DATA1.DBF", 1)).toBe("DATA1.DBF"); + expect(xuguDatafileDisplayName("", 2)).toBe("file-2"); + }); + + it("builds tablespace -> files -> datafile nodes with stable ids", () => { + const parent = { id: "conn:storage", connectionId: "conn", database: "APP", children: [] }; + const [node] = buildXuguTablespaceChildren(parent, [ + { + node_id: "1", + space_id: 7, + space_name: "DATA1", + datafile_num: 1, + space_type: "PERMANENT", + datafiles: [{ node_id: "1", space_id: 7, path: "/data/DATA1.DBF", file_no: 1, curr_size: 1024 }], + }, + ]); + expect(node.id).toBe("conn:storage:tablespace:7"); + expect(node.children?.[0].type).toBe("group-datafiles"); + expect(node.children?.[0].children?.[0]).toMatchObject({ + id: "conn:storage:tablespace:7:files:1:/data/DATA1.DBF", + type: "datafile", + label: "DATA1.DBF", + xuguDatafilePath: "/data/DATA1.DBF", + }); + }); + + 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; + first[0].children![0].isExpanded = true; + const second = buildXuguTablespaceChildren({ id: "conn:storage", connectionId: "conn", database: "APP", children: first }, [{ node_id: "1", space_id: 7, space_name: "DATA1", datafile_num: 0, space_type: "PERMANENT", datafiles: [] }]); + expect(second[0].isExpanded).toBe(true); + expect(second[0].children?.[0].isExpanded).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 1ad55aa63..2944a0007 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -151,6 +151,7 @@ export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory"); export const listDatabases = forward("listDatabases"); export const listDatabaseMetadata = forward("listDatabaseMetadata"); export const listDatabaseStorage = forward("listDatabaseStorage"); +export const listXuguTablespaces = forward("listXuguTablespaces"); export const getSqlServerCompletionContext = forward("getSqlServerCompletionContext"); export const listDorisCatalogs = forward("listDorisCatalogs"); export const listDorisCatalogDatabases = forward("listDorisCatalogDatabases"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index bbfa1206e..459419dd6 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -4,6 +4,7 @@ import type { DatabaseConnectionInfo, DatabaseInfo, DatabaseStorageInfo, + XuguTablespaceInfo, SqlServerCompletionContext, SchemaInfo, LinkedServerInfo, @@ -796,6 +797,10 @@ export async function listDatabaseStorage(connectionId: string, databases: strin }); } +export async function listXuguTablespaces(connectionId: string, database?: string): Promise { + return get(`/api/schema/xugu/tablespaces?${qs({ connection_id: connectionId, database })}`); +} + export async function getSqlServerCompletionContext(connectionId: string, database: string): Promise { return get(`/api/schema/sqlserver/completion-context?${qs({ connection_id: connectionId, database })}`); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 87a786ed8..898407a19 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -5,6 +5,7 @@ import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoS import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen"; import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport"; +import type { XuguTablespaceInfo } from "@/types/database"; import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement"; /** Normalize Tauri rejections once at the public backend boundary. */ @@ -1160,6 +1161,10 @@ export async function listDatabaseStorage(connectionId: string, databases: strin return invoke("list_database_storage", { connectionId, databases }); } +export async function listXuguTablespaces(connectionId: string, database?: string): Promise { + return invoke("list_xugu_tablespaces", { connectionId, database }); +} + export async function getSqlServerCompletionContext(connectionId: string, database: string): Promise { return invoke("get_sqlserver_completion_context", { connectionId, database }); } diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts index 080cb3f91..794a708f7 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts @@ -39,6 +39,7 @@ const leafTypes: Set = new Set([ "table-search-control", "load-more", "extension", + "datafile", ]); const fullWidthLabelTypes: Set = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "dynamodb-table", "vector-collection", "elasticsearch-index"]); diff --git a/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts b/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts index 7ce543f41..6f56b9cd4 100644 --- a/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts +++ b/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts @@ -13,7 +13,7 @@ export function treeNodeLoadedChildrenContentPresent(node: TreeNode, sidebarObje // Grouped mode always materializes object-group placeholders; empty means a stale shell. return sidebarObjectDisplay === "simple"; } - if (objectTypesForGroupNode(node.type) || TABLE_STRUCTURE_GROUP_TYPES.has(node.type) || node.type === "group-extensions") { + if (objectTypesForGroupNode(node.type) || TABLE_STRUCTURE_GROUP_TYPES.has(node.type) || node.type === "group-extensions" || node.type === "group-tablespaces" || node.type === "group-datafiles") { return true; } return childCount > 0; diff --git a/apps/desktop/src/lib/sidebar/treeNodeGroup.ts b/apps/desktop/src/lib/sidebar/treeNodeGroup.ts index 675a13cc2..d0fc127ff 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeGroup.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeGroup.ts @@ -22,6 +22,8 @@ const treeGroupNodeTypes = new Set([ "group-types", "group-partitions", "group-extensions", + "group-tablespaces", + "group-datafiles", "type-attributes", "type-methods", ]); diff --git a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts index cab51de55..cfb4de71f 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts @@ -15,6 +15,10 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { case "database": case "mongo-db": return { icon: Database, colorClass: "text-yellow-500" }; + case "tablespace": + return { icon: Database, colorClass: "text-orange-500" }; + case "datafile": + return { icon: FileCode, colorClass: "text-slate-500" }; case "vector-database": return { icon: Database, colorClass: "text-cyan-500" }; case "linked-server-root": @@ -138,6 +142,10 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { return { icon: Clock, colorClass: "text-orange-400" }; case "group-packages": return { icon: Package, colorClass: "text-cyan-500" }; + case "group-tablespaces": + return { icon: Database, colorClass: "text-orange-500" }; + case "group-datafiles": + return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-slate-500" }; case "group-partitions": return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" }; case "group-extensions": diff --git a/apps/desktop/src/lib/sidebar/xuguTablespaces.ts b/apps/desktop/src/lib/sidebar/xuguTablespaces.ts new file mode 100644 index 000000000..ce2891035 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/xuguTablespaces.ts @@ -0,0 +1,50 @@ +import type { TreeNode, XuguTablespaceInfo } from "@/types/database"; + +export function xuguDatafileDisplayName(path: string, fileNo: number): string { + const normalized = path.trim().replaceAll("\\", "/"); + const parts = normalized.split("/").filter(Boolean); + const basename = parts.length > 0 ? parts[parts.length - 1] : undefined; + return basename || path.trim() || `file-${fileNo}`; +} + +export function buildXuguTablespaceChildren(parent: Pick, tablespaces: readonly XuguTablespaceInfo[]): TreeNode[] { + const existing = new Map((parent.children ?? []).map((child) => [child.id, child] as const)); + return tablespaces.map((tablespace) => { + const tablespaceId = `${parent.id}:tablespace:${tablespace.space_id}`; + const previousTablespace = existing.get(tablespaceId); + const filesGroupId = `${tablespaceId}:files`; + const previousFilesGroup = previousTablespace?.children?.find((child) => child.id === filesGroupId); + const fileNodes = tablespace.datafiles.map((file) => ({ + id: `${filesGroupId}:${file.file_no}:${file.path}`, + label: xuguDatafileDisplayName(file.path, file.file_no), + type: "datafile" as const, + connectionId: parent.connectionId, + database: parent.database, + objectName: file.path, + xuguDatafilePath: file.path, + comment: file.path, + })); + const filesGroup: TreeNode = { + id: filesGroupId, + label: "tree.xuguDatafiles", + type: "group-datafiles", + connectionId: parent.connectionId, + database: parent.database, + objectCount: fileNodes.length, + isExpanded: previousFilesGroup?.isExpanded ?? false, + children: fileNodes, + }; + return { + id: tablespaceId, + label: tablespace.space_name, + type: "tablespace" as const, + connectionId: parent.connectionId, + database: parent.database, + objectName: tablespace.space_name, + xuguTablespace: tablespace, + objectCount: tablespace.datafiles.length, + isExpanded: previousTablespace?.isExpanded ?? false, + children: [filesGroup], + } satisfies TreeNode; + }); +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d76677e3e..d38584764 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -164,6 +164,7 @@ import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsC import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; import { buildCustomTypeTreeChildren } from "@/lib/sidebar/customTypeTree"; import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle"; +import { buildXuguTablespaceChildren } from "@/lib/sidebar/xuguTablespaces"; import i18n from "@/i18n"; import type { MqAdminConfig } from "@/types/mq"; import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults"; @@ -1364,7 +1365,7 @@ export const useConnectionStore = defineStore("connection", () => { // as metadata children, withConnectionUtilityNodes would keep the old copies // AND append fresh ones on every useCachedChildren pass, duplicating the // 用户/角色 menus once per refresh cycle. - return node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "saved-sql-root"; + return node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "group-tablespaces" || node.type === "saved-sql-root"; } function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] { @@ -1653,13 +1654,33 @@ export const useConnectionStore = defineStore("connection", () => { }; } + function buildXuguTablespacesNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined { + const config = getConfig(connectionId); + if (effectiveDatabaseTypeForConnection(config) !== "xugu") return undefined; + const existing = existingConnectionNode?.children?.find((child) => child.type === "group-tablespaces"); + return { + id: `${connectionId}:__xugu_tablespaces`, + label: "tree.xuguTablespaces", + type: "group-tablespaces", + connectionId, + // SYS_TABLESPACES/SYS_DATAFILES are scoped to the current database. The + // optional value lets the agent switch to the configured database while + // retaining compatibility with connections that have no default DB. + database: config?.database || "", + objectCount: existing?.objectCount, + isExpanded: existing?.isExpanded ?? false, + children: existing?.children ?? [], + }; + } + function withConnectionUtilityNodes(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { const nonUtilityChildren = connectionMetadataChildren(children); const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode); const damengUserNode = buildDamengUserNode(connectionId, existingConnectionNode); const damengRoleNode = buildDamengRoleNode(connectionId, existingConnectionNode); const damengJobAdminNode = buildDamengJobAdminNode(connectionId, existingConnectionNode); - return [...nonUtilityChildren, userAdminNode, damengUserNode, damengRoleNode, damengJobAdminNode].filter(Boolean) as TreeNode[]; + const xuguTablespacesNode = buildXuguTablespacesNode(connectionId, existingConnectionNode); + return [...nonUtilityChildren, userAdminNode, damengUserNode, damengRoleNode, damengJobAdminNode, xuguTablespacesNode].filter(Boolean) as TreeNode[]; } function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { @@ -4137,6 +4158,50 @@ export const useConnectionStore = defineStore("connection", () => { { flush: "post" }, ); + async function loadXuguTablespaces(node: TreeNode, options?: LoadTreeOptions) { + if (node.type !== "group-tablespaces" || !node.connectionId) return; + const connectionId = node.connectionId; + const config = getConfig(connectionId); + if (effectiveDatabaseTypeForConnection(config) !== "xugu") return; + return runTreeMetadataLoad( + { + kind: "xugu-tablespaces", + connectionId, + database: node.database || undefined, + driverProfile: metadataDriverProfile(config), + }, + async () => { + let load = beginTreeNodeLoad(node); + try { + await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; + const tablespaces = await withMetadataLoadTimeout(connectionId, api.listXuguTablespaces(connectionId, node.database || undefined), "Xugu tablespaces"); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + const children = buildXuguTablespaceChildren(targetNode, tablespaces); + setChildren(targetNode, children); + targetNode.objectCount = children.length; + targetNode.isExpanded = true; + } catch (error) { + // Storage metadata is an optional, read-only enhancement. A user + // without SYS_* view access should see an empty group rather than a + // connection-level RPC error that blocks the rest of the tree. + console.debug("[DBX][xugu-tablespaces:unavailable]", { connectionId, error }); + const targetNode = treeNodeLoadTarget(load); + if (targetNode) { + setChildren(targetNode, []); + targetNode.objectCount = 0; + targetNode.isExpanded = true; + } + } finally { + finishTreeNodeLoad(load); + } + }, + options, + ); + } + async function loadDatabases(connectionId: string, options?: LoadTreeOptions) { const configForScope = getConfig(connectionId); const searchFilter = activeTreeLoadSearchFilter(options); @@ -6549,6 +6614,8 @@ export const useConnectionStore = defineStore("connection", () => { node.isExpanded = true; } else if (node.type === "doris-catalog" && node.connectionId) { await loadDorisCatalogDatabases(node, options); + } else if (node.type === "group-tablespaces" && node.connectionId) { + await loadXuguTablespaces(node, options); } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { if (node.catalog && node.catalog !== "internal") { await loadDorisCatalogTables(node, options); @@ -8863,6 +8930,7 @@ export const useConnectionStore = defineStore("connection", () => { loadObjectGroupChildren, loadCustomTypeChildren, loadPackageMembers, + loadXuguTablespaces, loadXuguTypeMembers, loadMoreObjectGroupChildren, loadAllObjectGroupChildren, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index b06d7e93d..99a49d4e0 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -344,6 +344,29 @@ export interface DatabaseStorageInfo { size_bytes: number | null; } +export interface XuguDatafileInfo { + node_id: string; + space_id: number; + path: string; + file_no: number; + max_size?: number | null; + step_size?: number | null; + curr_size?: number | null; + reserved1?: string | null; +} + +export interface XuguTablespaceInfo { + node_id: string; + space_id: number; + space_name: string; + datafile_num: number; + space_type: string; + media_error?: string | null; + total_chunk_num?: number | null; + free_chunk_num?: number | null; + datafiles: XuguDatafileInfo[]; +} + export interface SqlServerCompletionContext { default_schema: string; supports_session_database_switch: boolean; @@ -862,6 +885,8 @@ export type TreeNodeType = | "connection" | "connection-group" | "database" + | "tablespace" + | "datafile" | "doris-catalog" | "linked-server-root" | "linked-server" @@ -902,6 +927,8 @@ export type TreeNodeType = | "group-packages" | "group-partitions" | "group-extensions" + | "group-tablespaces" + | "group-datafiles" | "extension" | "object-browser" | "user-admin" @@ -1001,6 +1028,8 @@ export interface TreeNode { comment?: string | null; valid?: boolean | null; sizeBytes?: number | null; + xuguTablespace?: XuguTablespaceInfo; + xuguDatafilePath?: string; objectCount?: number; loadedKeyCount?: number; totalKeyCount?: number; diff --git a/crates/dbx-core/src/db/agent_driver.rs b/crates/dbx-core/src/db/agent_driver.rs index 4f052b435..c37b3164e 100644 --- a/crates/dbx-core/src/db/agent_driver.rs +++ b/crates/dbx-core/src/db/agent_driver.rs @@ -1196,6 +1196,7 @@ pub enum AgentMethod { ListTables, ListObjects, ListDataTypes, + ListXuguTablespaces, CompletionAssistantSearchV1, GetObjectSource, GetColumns, @@ -1283,6 +1284,7 @@ impl AgentMethod { Self::ListTables => "list_tables", Self::ListObjects => "list_objects", Self::ListDataTypes => "list_data_types", + Self::ListXuguTablespaces => "list_xugu_tablespaces", Self::CompletionAssistantSearchV1 => "completion_assistant_search_v1", Self::GetObjectSource => "get_object_source", Self::GetTableDdl => "get_table_ddl", @@ -2109,6 +2111,18 @@ impl AgentDriverClient { .await } + pub async fn list_xugu_tablespaces( + &mut self, + database: Option<&str>, + timeout_duration: Option, + ) -> Result { + let params = database + .filter(|database| !database.trim().is_empty()) + .map(|database| serde_json::json!({ "database": database })) + .unwrap_or_else(|| serde_json::json!({})); + self.call_method_with_timeout(AgentMethod::ListXuguTablespaces, params, timeout_duration).await + } + pub async fn completion_assistant_search( &mut self, request: &crate::types::CompletionAssistantRequest, @@ -4873,6 +4887,7 @@ for line in sys.stdin: assert_eq!(AgentMethod::ListTables.as_str(), "list_tables"); assert_eq!(AgentMethod::ListObjects.as_str(), "list_objects"); assert_eq!(AgentMethod::ListDataTypes.as_str(), "list_data_types"); + assert_eq!(AgentMethod::ListXuguTablespaces.as_str(), "list_xugu_tablespaces"); assert_eq!(AgentMethod::CompletionAssistantSearchV1.as_str(), "completion_assistant_search_v1"); assert_eq!(AgentMethod::GetObjectSource.as_str(), "get_object_source"); assert_eq!(AgentMethod::GetColumns.as_str(), "get_columns"); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 674c3fd1b..f3e9e5e73 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -197,6 +197,40 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul retry_metadata_connection(state, connection_id, None, || list_databases_once(state, connection_id)).await } +/// Lists XuguDB tablespaces and their physical data files for the selected +/// database. This is deliberately a no-op for every other database type so +/// the common schema surface and non-Xugu drivers remain untouched. +pub async fn list_xugu_tablespaces_core( + state: &AppState, + connection_id: &str, + database: Option<&str>, +) -> Result, String> { + let config = connection_config(state, connection_id).await; + if !config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Xugu) { + return Ok(Vec::new()); + } + retry_metadata_connection(state, connection_id, database.filter(|database| !database.is_empty()), || async { + let pool_key = state + .get_or_create_metadata_pool_for_session( + connection_id, + database.filter(|database| !database.is_empty()), + None, + ) + .await?; + let config = connection_config(state, connection_id).await; + let connections = state.connections.read().await; + if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { + drop(connections); + let mut client = client.lock().await; + return client + .list_xugu_tablespaces::>(database, agent_metadata_timeout(config.as_ref())) + .await; + } + Ok(Vec::new()) + }) + .await +} + /// Loads the more expensive database-level properties needed only by the /// connection resource browser. General metadata paths keep using /// `list_databases_core`, which only enumerates names. diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index 438eb056e..56a7e5331 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -25,6 +25,37 @@ pub struct DatabaseStorageInfo { pub size_bytes: Option, } +/// XuguDB storage metadata exposed by the read-only schema browser. +/// +/// Xugu stores tablespaces and their data files inside the selected database, +/// so these types intentionally remain driver-specific instead of widening +/// the common database model used by other engines. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct XuguDatafileInfo { + pub node_id: String, + pub space_id: i64, + pub path: String, + pub file_no: i64, + pub max_size: Option, + pub step_size: Option, + pub curr_size: Option, + pub reserved1: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct XuguTablespaceInfo { + pub node_id: String, + pub space_id: i64, + pub space_name: String, + pub datafile_num: i64, + pub space_type: String, + pub media_error: Option, + pub total_chunk_num: Option, + pub free_chunk_num: Option, + #[serde(default)] + pub datafiles: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchemaInfo { pub name: String, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index cf3749865..dcaf70462 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -457,6 +457,7 @@ async fn main() { .route("/schema/databases", get(routes::schema::list_databases)) .route("/schema/database-metadata", get(routes::schema::list_database_metadata)) .route("/schema/database-storage", post(routes::schema::list_database_storage)) + .route("/schema/xugu/tablespaces", get(routes::schema::list_xugu_tablespaces)) .route("/schema/sqlserver/completion-context", get(routes::schema::get_sqlserver_completion_context)) .route("/schema/doris/catalogs", get(routes::schema::list_doris_catalogs)) .route("/schema/doris/catalog-databases", get(routes::schema::list_doris_catalog_databases)) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 4be630cd0..7c63f6fff 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -64,6 +64,16 @@ pub async fn list_database_storage( Ok(Json(result)) } +pub async fn list_xugu_tablespaces( + State(state): State>, + Query(q): Query, +) -> Result>, AppError> { + let result = dbx_core::schema::list_xugu_tablespaces_core(&state.app, &q.connection_id, q.database.as_deref()) + .await + .map_err(AppError::from)?; + Ok(Json(result)) +} + pub async fn get_sqlserver_completion_context( State(state): State>, Query(q): Query, diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 508cc0b27..e0a1c5e4b 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -36,6 +36,15 @@ pub async fn list_database_storage( dbx_core::schema::list_database_storage_core(&state, &connection_id, &databases).await } +#[tauri::command] +pub async fn list_xugu_tablespaces( + state: State<'_, Arc>, + connection_id: String, + database: Option, +) -> Result, String> { + dbx_core::schema::list_xugu_tablespaces_core(&state, &connection_id, database.as_deref()).await +} + #[tauri::command] pub async fn get_sqlserver_completion_context( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62b721e8b..701e33864 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1812,6 +1812,7 @@ pub fn run() { commands::schema::list_databases, commands::schema::list_database_metadata, commands::schema::list_database_storage, + commands::schema::list_xugu_tablespaces, commands::schema::get_sqlserver_completion_context, commands::schema::list_doris_catalogs, commands::schema::list_doris_catalog_databases,