feat(xugu): expose tablespaces and datafiles in tree

This commit is contained in:
Elias
2026-09-01 00:34:12 +08:00
committed by GitHub
parent afe3b3f3c2
commit 351a8b699b
32 changed files with 811 additions and 4 deletions
+173 -1
View File
@@ -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
@@ -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
}
+61
View File
@@ -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)
}
}
@@ -685,6 +685,8 @@ const groupTypes: Set<TreeNodeType> = 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);
@@ -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;
}
@@ -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<InstanceType<typeof SidebarTreeRuntimeHost> | 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);
});
});
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -3301,6 +3301,8 @@ export default withEnglishFallback({
damengUsers: "ユーザー",
damengRoles: "ロール",
damengJobAdmin: "エージェントジョブ",
xuguTablespaces: "ストレージ領域",
xuguDatafiles: "ファイル",
defaultDatabase: "デフォルトDB",
columns: "列",
attributes: "属性",
+2
View File
@@ -3154,6 +3154,8 @@ export default withEnglishFallback({
damengUsers: "사용자",
damengRoles: "역할",
damengJobAdmin: "에이전트 작업",
xuguTablespaces: "저장 공간",
xuguDatafiles: "파일",
linkedServers: "연결된 서버",
defaultDatabase: "기본 DB",
columns: "컬럼",
+2
View File
@@ -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",
+2
View File
@@ -3290,6 +3290,8 @@ export default withEnglishFallback({
damengUsers: "用户",
damengRoles: "角色",
damengJobAdmin: "代理作业",
xuguTablespaces: "存储空间",
xuguDatafiles: "文件",
linkedServers: "链接服务器",
defaultDatabase: "默认库",
columns: "字段",
+2
View File
@@ -3274,6 +3274,8 @@ export default withEnglishFallback({
damengUsers: "使用者",
damengRoles: "角色",
damengJobAdmin: "代理作業",
xuguTablespaces: "儲存空間",
xuguDatafiles: "檔案",
linkedServers: "連結伺服器",
materializedViews: "具體化檢視",
defaultDatabase: "預設庫",
@@ -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);
});
});
+1
View File
@@ -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");
+5
View File
@@ -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<XuguTablespaceInfo[]> {
return get(`/api/schema/xugu/tablespaces?${qs({ connection_id: connectionId, database })}`);
}
export async function getSqlServerCompletionContext(connectionId: string, database: string): Promise<SqlServerCompletionContext> {
return get(`/api/schema/sqlserver/completion-context?${qs({ connection_id: connectionId, database })}`);
}
+5
View File
@@ -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<XuguTablespaceInfo[]> {
return invoke("list_xugu_tablespaces", { connectionId, database });
}
export async function getSqlServerCompletionContext(connectionId: string, database: string): Promise<SqlServerCompletionContext> {
return invoke("get_sqlserver_completion_context", { connectionId, database });
}
@@ -39,6 +39,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"table-search-control",
"load-more",
"extension",
"datafile",
]);
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
@@ -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;
@@ -22,6 +22,8 @@ const treeGroupNodeTypes = new Set<TreeNodeType>([
"group-types",
"group-partitions",
"group-extensions",
"group-tablespaces",
"group-datafiles",
"type-attributes",
"type-methods",
]);
@@ -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":
@@ -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<TreeNode, "id" | "connectionId" | "database" | "children">, 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;
});
}
+70 -2
View File
@@ -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,
+29
View File
@@ -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;
+15
View File
@@ -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<T: DeserializeOwned + Send + 'static>(
&mut self,
database: Option<&str>,
timeout_duration: Option<Duration>,
) -> Result<T, String> {
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<T: DeserializeOwned + Send + 'static>(
&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");
+34
View File
@@ -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<Vec<db::XuguTablespaceInfo>, 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::<Vec<db::XuguTablespaceInfo>>(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.
+31
View File
@@ -25,6 +25,37 @@ pub struct DatabaseStorageInfo {
pub size_bytes: Option<i64>,
}
/// 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<i64>,
pub step_size: Option<i64>,
pub curr_size: Option<i64>,
pub reserved1: Option<String>,
}
#[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<String>,
pub total_chunk_num: Option<i64>,
pub free_chunk_num: Option<i64>,
#[serde(default)]
pub datafiles: Vec<XuguDatafileInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaInfo {
pub name: String,
+1
View File
@@ -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))
+10
View File
@@ -64,6 +64,16 @@ pub async fn list_database_storage(
Ok(Json(result))
}
pub async fn list_xugu_tablespaces(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<Vec<dbx_core::db::XuguTablespaceInfo>>, 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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
+9
View File
@@ -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<AppState>>,
connection_id: String,
database: Option<String>,
) -> Result<Vec<db::XuguTablespaceInfo>, 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<AppState>>,
+1
View File
@@ -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,