Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/desktop/src/components/sidebar/TreeItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,19 @@ function currentTableStructureDatabaseType(): DatabaseType | undefined {
return props.node.connectionId ? tableStructureDatabaseTypeForConnection(connectionStore.getConfig(props.node.connectionId)) : undefined;
}

// Database types whose backend size query is implemented (schema.rs
// `database_size_core`): the native MySQL pool (incl. JDBC/agent MySQL) and the
// native PostgreSQL pool family. Agent-backed PG forks (HighGo/Vastbase/
// Kingbase) are NOT dispatched by the backend, so the manual action must not be
// offered for them — otherwise the menu appears but can never return a size.
const DATABASE_SIZE_SUPPORTED_TYPES = new Set<DatabaseType>(["mysql", "postgres", "gaussdb", "kwdb", "opengauss"]);

const canFetchDatabaseSize = computed(() => {
if (props.node.type !== "database" || props.node.catalog) return false;
const dbType = currentDatabaseType();
return !!dbType && DATABASE_SIZE_SUPPORTED_TYPES.has(dbType);
});

function rawDatabaseType(): DatabaseType | undefined {
return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined;
}
Expand Down Expand Up @@ -1782,6 +1795,16 @@ async function clearNodeDefaultDatabase() {
}
}

async function fetchDatabaseSize() {
const node = props.node;
if (!node.connectionId || !node.database) return;
try {
await connectionStore.fetchDatabaseSize(node.connectionId, node.database);
} catch (e: any) {
toast(t("contextMenu.fetchDatabaseSizeFailed", { message: e?.message || String(e) }), 5000);
}
}

async function refresh() {
try {
await connectionStore.refreshTreeNode(props.node);
Expand Down Expand Up @@ -4227,6 +4250,14 @@ const tableComment = computed(() =>
: null,
);
const labelWidthClass = computed(() => treeLabelWidthClass({ fullWidth: usesFullWidthLabel.value, hasTrailingComment: !!columnComment.value || !!tableComment.value }));
const formattedSize = computed(() => {
const bytes = props.node.sizeBytes;
if (bytes == null || bytes < 0) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
});
const paddingLeft = computed(() => treeItemPaddingLeft(props.depth));
const tableSearchParentId = computed(() => props.node.tableSearchParentId || "");
const tableSearchValue = computed(() => {
Expand Down Expand Up @@ -5227,6 +5258,9 @@ function treeItemMenuItems(): ContextMenuItem[] {
items.push({ label: t("contextMenu.clearDefaultDatabase"), action: clearNodeDefaultDatabase, icon: Database });
}
}
if (canFetchDatabaseSize.value) {
items.push({ label: t("contextMenu.fetchDatabaseSize"), action: fetchDatabaseSize, icon: Database });
}
if (canEditDatabaseProperties.value) {
items.push({ label: t("contextMenu.editDatabaseProperties"), action: openEditDatabasePropertiesDialog, icon: SquarePen });
}
Expand Down Expand Up @@ -5751,6 +5785,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
</Badge>
<span v-if="columnComment" class="sidebar-object-comment ml-auto max-w-[20%] shrink-0 truncate text-right" :class="{ 'sidebar-object-comment--windows': useWindowsSidebarCommentFont }">{{ columnComment }}</span>
<span v-if="tableComment" class="sidebar-object-comment ml-auto max-w-[20%] shrink-0 truncate text-right" :class="{ 'sidebar-object-comment--windows': useWindowsSidebarCommentFont }">{{ tableComment }}</span>
<span v-if="formattedSize" class="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground/70">{{ formattedSize }}</span>
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<span v-if="showsDatabaseOpenIndicator" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"><Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }}</Badge>
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,8 @@ export default {
finalProxyPortCopied: "Final proxy port {port} copied",
setDefaultDatabase: "Set as Default Database",
clearDefaultDatabase: "Clear Default Database",
fetchDatabaseSize: "Fetch Database Size",
fetchDatabaseSizeFailed: "Failed to fetch database size: {message}",
exportDatabase: "Export Database",
exportAllDatabases: "Export All Databases",
exportDatabaseSuccess: "Exported {count} tables, up to {limit} rows each",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,8 @@ export default withEnglishFallback({
finalProxyPortCopied: "Puerto proxy final {port} copiado",
setDefaultDatabase: "Establecer como base de datos predeterminada",
clearDefaultDatabase: "Limpiar base de datos predeterminada",
fetchDatabaseSize: "Obtener tamaño de la base de datos",
fetchDatabaseSizeFailed: "Error al obtener el tamaño de la base de datos: {message}",
exportDatabase: "Exportar base de datos",
exportAllDatabases: "Exportar todas las bases de datos",
exportDatabaseSuccess: "Se exportaron {count} tablas, hasta {limit} filas cada una",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,8 @@ export default withEnglishFallback({
finalProxyPortCopied: "Porta proxy finale {port} copiata",
setDefaultDatabase: "Imposta come Database Predefinito",
clearDefaultDatabase: "Cancella Database Predefinito",
fetchDatabaseSize: "Ottieni Dimensione Database",
fetchDatabaseSizeFailed: "Impossibile ottenere la dimensione del database: {message}",
exportDatabase: "Esporta Database",
exportAllDatabases: "Esporta tutti i database",
exportDatabaseSuccess: "Esportate {count} tabelle, fino a {limit} righe ciascuna",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,8 @@ export default withEnglishFallback({
finalProxyPortCopied: "最終プロキシポート {port} をコピーしました",
setDefaultDatabase: "デフォルトデータベースに設定",
clearDefaultDatabase: "デフォルトデータベースをクリア",
fetchDatabaseSize: "データベースサイズを取得",
fetchDatabaseSizeFailed: "データベースサイズの取得に失敗しました:{message}",
exportDatabase: "データベースをエクスポート",
exportAllDatabases: "すべてのデータベースをエクスポート",
exportDatabaseSuccess: "{count}テーブルをエクスポートしました(各{limit}行まで)",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,8 @@ export default withEnglishFallback({
copyName: "Copiar Nome",
setDefaultDatabase: "Definir como Banco de Dados Padrão",
clearDefaultDatabase: "Limpar Banco de Dados Padrão",
fetchDatabaseSize: "Obter Tamanho do Banco de Dados",
fetchDatabaseSizeFailed: "Falha ao obter o tamanho do banco de dados: {message}",
exportDatabase: "Exportar Banco de Dados",
exportAllDatabases: "Exportar todos os bancos de dados",
exportDatabaseSuccess: "{count} tabelas exportadas, até {limit} linhas cada",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,8 @@ export default withEnglishFallback({
finalProxyPortCopied: "最终代理端口 {port} 已复制",
setDefaultDatabase: "设为默认数据库",
clearDefaultDatabase: "取消默认数据库",
fetchDatabaseSize: "获取数据库大小",
fetchDatabaseSizeFailed: "获取数据库大小失败:{message}",
exportDatabase: "导出数据库",
exportAllDatabases: "导出全部数据库",
exportDatabaseSuccess: "已导出 {count} 张表,每表最多 {limit} 行",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,8 @@ export default withEnglishFallback({
finalProxyPortCopied: "最終代理連接埠 {port} 已複製",
setDefaultDatabase: "設為預設資料庫",
clearDefaultDatabase: "取消預設資料庫",
fetchDatabaseSize: "取得資料庫大小",
fetchDatabaseSizeFailed: "取得資料庫大小失敗:{message}",
exportDatabase: "匯出資料庫",
exportAllDatabases: "匯出全部資料庫",
exportDatabaseSuccess: "已匯出 {count} 張資料表,每張最多 {limit} 行",
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/lib/__tests__/databaseTree.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { buildDatabaseTreeNodes, sortSidebarDatabases } from "@/lib/database/databaseTree";

describe("buildDatabaseTreeNodes", () => {
it("passes size_bytes from DatabaseInfo to tree node sizeBytes", () => {
const databases = [
{ name: "app_db", size_bytes: 1048576 },
{ name: "metrics", size_bytes: 5368709120 },
];
const nodes = buildDatabaseTreeNodes("conn-1", databases);
expect(nodes).toHaveLength(2);
expect(nodes.find((n) => n.label === "app_db")?.sizeBytes).toBe(1048576);
expect(nodes.find((n) => n.label === "metrics")?.sizeBytes).toBe(5368709120);
});

it("sets sizeBytes to null when size_bytes is undefined (drivers without size)", () => {
const databases = [{ name: "my_db" }];
const nodes = buildDatabaseTreeNodes("conn-1", databases);
expect(nodes).toHaveLength(1);
expect(nodes[0].sizeBytes).toBeNull();
});

it("sets sizeBytes to null when size_bytes is null (no CONNECT privilege)", () => {
const databases = [{ name: "restricted_db", size_bytes: null }];
const nodes = buildDatabaseTreeNodes("conn-1", databases as any);
expect(nodes).toHaveLength(1);
expect(nodes[0].sizeBytes).toBeNull();
});

it("still builds nodes when size_bytes is missing for all databases", () => {
const databases = [{ name: "db1" }, { name: "db2" }, { name: "db3" }];
const nodes = buildDatabaseTreeNodes("conn-1", databases);
expect(nodes).toHaveLength(3);
expect(nodes.every((n) => n.sizeBytes === null)).toBe(true);
});
});

describe("sortSidebarDatabases", () => {
it("sorts databases alphabetically with numeric awareness", () => {
const databases = [{ name: "db10" }, { name: "db2" }, { name: "db1" }];
const sorted = sortSidebarDatabases(databases);
expect(sorted.map((d) => d.name)).toEqual(["db1", "db2", "db10"]);
});
});
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/backend/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");

// Schema
export const listDatabases = forward("listDatabases");
export const listDatabaseStatistics = forward("listDatabaseStatistics");
export const databaseSize = forward("databaseSize");
export const listDorisCatalogs = forward("listDorisCatalogs");
export const listDorisCatalogDatabases = forward("listDorisCatalogDatabases");
export const listSqlServerLinkedServers = forward("listSqlServerLinkedServers");
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/lib/backend/http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
ConnectionConfig,
DatabaseInfo,
DatabaseStatistics,
SchemaInfo,
LinkedServerInfo,
CatalogInfo,
Expand Down Expand Up @@ -521,6 +522,14 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`);
}

export async function listDatabaseStatistics(connectionId: string): Promise<DatabaseStatistics[]> {
return get(`/api/schema/database-statistics?${qs({ connection_id: connectionId })}`);
}

export async function databaseSize(connectionId: string, database: string): Promise<number | null> {
return get(`/api/schema/database-size?${qs({ connection_id: connectionId, database })}`);
}

export async function listDorisCatalogs(connectionId: string): Promise<CatalogInfo[]> {
return get(`/api/schema/doris/catalogs?${qs({ connection_id: connectionId })}`);
}
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/lib/backend/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
ConnectionConfig,
DatabaseInfo,
DatabaseStatistics,
SchemaInfo,
LinkedServerInfo,
CatalogInfo,
Expand Down Expand Up @@ -665,6 +666,14 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return invoke("list_databases", { connectionId });
}

export async function listDatabaseStatistics(connectionId: string): Promise<DatabaseStatistics[]> {
return invoke("list_database_statistics", { connectionId });
}

export async function databaseSize(connectionId: string, database: string): Promise<number | null> {
return invoke("database_size", { connectionId, database });
}

export async function listDorisCatalogs(connectionId: string): Promise<CatalogInfo[]> {
return invoke("list_doris_catalogs", { connectionId });
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/database/databaseTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function buildDatabaseTreeNodes(connectionId: string, databases: Database
type: "database" as const,
connectionId,
database: name,
sizeBytes: db.size_bytes ?? null,
isExpanded: false,
children: [],
},
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/stores/__tests__/connectionStore.databaseSize.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, TreeNode } from "@/types/database";

function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}

function mysqlConnection(): ConnectionConfig {
return {
id: "mysql-1",
name: "MySQL",
db_type: "mysql",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "",
database: "app",
} as ConnectionConfig;
}

function treeWithDatabases(connection: ConnectionConfig, databases: { name: string; sizeBytes: number | null }[]): TreeNode[] {
return [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: databases.map((db) => ({
id: `${connection.id}:${db.name}`,
label: db.name,
type: "database",
connectionId: connection.id,
database: db.name,
sizeBytes: db.sizeBytes,
isExpanded: false,
children: [],
})),
},
];
}

async function setupStore(databaseSizeImpl: (connectionId: string, database: string) => Promise<number | null>) {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
databaseSize: vi.fn(databaseSizeImpl),
listDatabaseStatistics: vi.fn().mockResolvedValue([]),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
return useConnectionStore();
}

describe("connectionStore database size (manual)", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});

it("fetchDatabaseSize updates only the targeted database node", async () => {
const store = await setupStore(async () => 2048);
const connection = mysqlConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = treeWithDatabases(connection, [
{ name: "app", sizeBytes: null },
{ name: "other", sizeBytes: 111 },
]);

const result = await store.fetchDatabaseSize(connection.id, "app");

expect(result).toBe(2048);
const children = store.treeNodes[0].children!;
expect(children.find((n) => n.database === "app")?.sizeBytes).toBe(2048);
expect(children.find((n) => n.database === "other")?.sizeBytes).toBe(111);
});

it("automatic statistics with null does not overwrite a manually fetched size", async () => {
const store = await setupStore(async () => 4096);
const connection = mysqlConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = treeWithDatabases(connection, [{ name: "app", sizeBytes: 4096 }]);

// Automatic pass timed out for "app" (size_bytes null) — must keep the manual value.
const changed = store.applyDatabaseStatisticsToConnectionTree(connection.id, [{ name: "app", size_bytes: null }]);

expect(changed).toBe(false);
expect(store.treeNodes[0].children![0].sizeBytes).toBe(4096);
});

it("automatic statistics still fills sizes that are not yet known", async () => {
const store = await setupStore(async () => 4096);
const connection = mysqlConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = treeWithDatabases(connection, [{ name: "app", sizeBytes: null }]);

const changed = store.applyDatabaseStatisticsToConnectionTree(connection.id, [{ name: "app", size_bytes: 9000 }]);

expect(changed).toBe(true);
expect(store.treeNodes[0].children![0].sizeBytes).toBe(9000);
});
});
Loading
Loading