mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(xugu): expose tablespaces and datafiles in tree
This commit is contained in:
+173
-1
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+61
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3301,6 +3301,8 @@ export default withEnglishFallback({
|
||||
damengUsers: "ユーザー",
|
||||
damengRoles: "ロール",
|
||||
damengJobAdmin: "エージェントジョブ",
|
||||
xuguTablespaces: "ストレージ領域",
|
||||
xuguDatafiles: "ファイル",
|
||||
defaultDatabase: "デフォルトDB",
|
||||
columns: "列",
|
||||
attributes: "属性",
|
||||
|
||||
@@ -3154,6 +3154,8 @@ export default withEnglishFallback({
|
||||
damengUsers: "사용자",
|
||||
damengRoles: "역할",
|
||||
damengJobAdmin: "에이전트 작업",
|
||||
xuguTablespaces: "저장 공간",
|
||||
xuguDatafiles: "파일",
|
||||
linkedServers: "연결된 서버",
|
||||
defaultDatabase: "기본 DB",
|
||||
columns: "컬럼",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3290,6 +3290,8 @@ export default withEnglishFallback({
|
||||
damengUsers: "用户",
|
||||
damengRoles: "角色",
|
||||
damengJobAdmin: "代理作业",
|
||||
xuguTablespaces: "存储空间",
|
||||
xuguDatafiles: "文件",
|
||||
linkedServers: "链接服务器",
|
||||
defaultDatabase: "默认库",
|
||||
columns: "字段",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user