diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 887520c85..2e09dca6a 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -5272,15 +5272,18 @@ function toggleCellDetailPanelLayout() { const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(props.databaseType)); const tableInfoTabs = computed(() => { - const tabs: TableInfoTabItem[] = [ - { + const tabs: TableInfoTabItem[] = []; + if (tableMetadataCapabilities.value.columns) { + tabs.push({ id: "columns", label: t("grid.tableInfoColumns"), icon: ListTree, count: props.tableMeta?.columns.length, - }, - { id: "indexes", label: t("grid.tableInfoIndexes"), icon: KeyRound, count: indexes.value.length }, - ]; + }); + } + if (tableMetadataCapabilities.value.indexes) { + tabs.push({ id: "indexes", label: t("grid.tableInfoIndexes"), icon: KeyRound, count: indexes.value.length }); + } if (tableMetadataCapabilities.value.foreignKeys) { tabs.push({ id: "foreignKeys", @@ -5292,7 +5295,9 @@ const tableInfoTabs = computed(() => { if (tableMetadataCapabilities.value.triggers) { tabs.push({ id: "triggers", label: t("grid.tableInfoTriggers"), icon: RotateCcw, count: triggers.value.length }); } - tabs.push({ id: "ddl", label: "DDL", icon: Code2 }); + if (tableMetadataCapabilities.value.ddl) { + tabs.push({ id: "ddl", label: "DDL", icon: Code2 }); + } return tabs; }); const tableInfoTabListStyle = computed(() => ({ @@ -5309,7 +5314,8 @@ async function toggleTableInfo(tab: TableInfoTab = activeTableInfoTab.value) { } async function selectTableInfoTab(tab: TableInfoTab) { - const nextTab = tableInfoTabs.value.some((item) => item.id === tab) ? tab : "columns"; + const nextTab = tableInfoTabs.value.some((item) => item.id === tab) ? tab : tableInfoTabs.value[0]?.id; + if (!nextTab) return; activeTableInfoTab.value = nextTab; if (nextTab === "ddl") await fetchDdl(); else if (nextTab === "indexes") await fetchIndexes(); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 114ada640..47d4e08af 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -820,10 +820,10 @@ defineExpose({ focusSearch, refreshData, handleModRTarget }); - + diff --git a/apps/desktop/src/components/mongo/MongoDocBrowser.vue b/apps/desktop/src/components/mongo/MongoDocBrowser.vue index a89908af2..7b105cb1c 100644 --- a/apps/desktop/src/components/mongo/MongoDocBrowser.vue +++ b/apps/desktop/src/components/mongo/MongoDocBrowser.vue @@ -16,7 +16,7 @@ import { normalizeResultPageSize } from "@/lib/paginationPageSize"; import { useSettingsStore } from "@/stores/settingsStore"; import JsonEditNode from "./JsonEditNode.vue"; import type { EditNode } from "@/types/editor"; -import type { QueryResult } from "@/types/database"; +import type { DatabaseType, QueryResult } from "@/types/database"; import { Splitpanes, Pane } from "splitpanes"; import "splitpanes/dist/splitpanes.css"; @@ -27,6 +27,7 @@ const props = defineProps<{ connectionId: string; database: string; collection: string; + databaseType?: DatabaseType; }>(); type JsonRecord = Record; @@ -64,6 +65,17 @@ const tableFindPaneStyle = computed(() => { if (tableFindPaneWidth.value == null) return {}; return { flex: `0 0 ${tableFindPaneWidth.value}px` }; }); +const documentStoreLabels = computed(() => + props.databaseType === "elasticsearch" + ? { + documentsLabel: "Documents", + queryPreview: `${props.collection}/_search`, + } + : { + documentsLabel: t("mongo.documents", { count: total.value }), + queryPreview: mongoQueryPreview.value, + }, +); type PendingDelete = { kind: "document"; index: number } | { kind: "field"; index: number; name: string }; type LocalFilterSummary = { @@ -114,6 +126,7 @@ const deleteDetails = computed(() => { if (!pending) return ""; if (pending.kind === "document") { const id = documents.value[pending.index]?._id ?? ""; + if (props.databaseType === "elasticsearch") return `Elasticsearch index: ${props.collection}\nDocument _id: ${String(id)}`; return t("dangerDialog.mongoDocumentDetails", { collection: props.collection, id: String(id) }); } return t("dangerDialog.mongoFieldDetails", { field: pending.name || t("mongo.field") }); @@ -675,7 +688,7 @@ function resetTableSearchSplitWidth() { - {{ t("mongo.documents", { count: total }) }} + {{ documentStoreLabels.documentsLabel }} @@ -767,7 +780,7 @@ function resetTableSearchSplitWidth() { editable :custom-save="gridSave" :loading="loading" - :sql="mongoQueryPreview" + :sql="documentStoreLabels.queryPreview" :page-offset="page * pageSize" :page-limit="pageSize" :total-row-count="total" diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 8e8965da8..67bb8e1e3 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -57,7 +57,7 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record = { connection: ["connection"], database: ["database", "redis-db", "mongo-db"], schema: ["schema"], - table: ["table", "mongo-collection"], + table: ["table", "mongo-collection", "elasticsearch-index"], view: ["view"], }; @@ -275,8 +275,10 @@ async function ensureTreeLoadedForTab(tab: QueryTab, opts?: { force?: boolean }) try { if (config.db_type === "redis") { await store.loadRedisDatabases(connId); - } else if (config.db_type === "mongodb" || config.db_type === "elasticsearch") { + } else if (config.db_type === "mongodb") { await store.loadMongoDatabases(connId); + } else if (config.db_type === "elasticsearch") { + await store.loadElasticsearchIndices(connId); } else { await store.loadDatabases(connId, loadOptions); } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 94e2fd8bd..a8f93f231 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -219,6 +219,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: Database, colorClass: "text-yellow-500" }; case "mongo-collection": return { icon: Table, colorClass: "text-green-400" }; + case "elasticsearch-index": + return { icon: Table, colorClass: "text-emerald-400" }; case "procedure": return { icon: ScrollText, colorClass: "text-blue-500" }; case "function": @@ -249,7 +251,7 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { } const groupTypes: Set = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions"]); -const pinnableTypes: Set = new Set(["connection-group", "database", "schema", "table", "view", "redis-db", "mongo-db", "mongo-collection"]); +const pinnableTypes: Set = new Set(["connection-group", "database", "schema", "table", "view", "redis-db", "mongo-db", "mongo-collection", "elasticsearch-index"]); function isGroupLabel(node: TreeNode): boolean { return groupTypes.has(node.type); @@ -263,7 +265,7 @@ function displayLabel(node: TreeNode): string { } function visibleLabel(node: TreeNode): string { - if (node.type === "table" || node.type === "view" || node.type === "mongo-collection") { + if (node.type === "table" || node.type === "view" || node.type === "mongo-collection" || node.type === "elasticsearch-index") { return sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes); } return displayLabel(node); @@ -312,8 +314,10 @@ async function toggle() { await connectionStore.loadRedisDatabases(node.connectionId); } else if (config?.db_type === "etcd") { await connectionStore.loadEtcdRoot(node.connectionId); - } else if (config?.db_type === "mongodb" || config?.db_type === "elasticsearch") { + } else if (config?.db_type === "mongodb") { await connectionStore.loadMongoDatabases(node.connectionId); + } else if (config?.db_type === "elasticsearch") { + await connectionStore.loadElasticsearchIndices(node.connectionId); } else { await connectionStore.loadDatabases(node.connectionId); } @@ -331,6 +335,9 @@ async function toggle() { const tabTitle = `${node.database}.${node.label}`; const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo"); queryStore.updateSql(tab, node.label); + } else if (node.type === "elasticsearch-index" && node.connectionId) { + const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo"); + queryStore.updateSql(tab, node.label); } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { const config = connectionStore.getConfig(node.connectionId); if (config?.db_type === "sqlserver") { @@ -2237,7 +2244,7 @@ const hasTypeMenu = computed(() => { return t === "connection" || t === "database" || t === "schema" || t === "table" || t === "view" || t === "column" || t === "procedure" || t === "function" || t === "package" || t === "package-body" || isGroupLabel(props.node); }); const columnComment = computed(() => (props.node.type === "column" && props.node.meta && "comment" in props.node.meta ? (props.node.meta as any).comment : null)); -const tableComment = computed(() => ((props.node.type === "table" || props.node.type === "view" || props.node.type === "mongo-collection") && props.node.comment ? props.node.comment : null)); +const tableComment = computed(() => ((props.node.type === "table" || props.node.type === "view" || props.node.type === "mongo-collection" || props.node.type === "elasticsearch-index") && props.node.comment ? props.node.comment : null)); const paddingLeft = computed(() => treeItemPaddingLeft(props.depth)); const isConnected = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId)); const isConnectionReadonly = computed(() => props.node.type === "connection" && !!props.node.connectionId && (connectionStore.getConfig(props.node.connectionId)?.read_only ?? false)); @@ -2788,6 +2795,14 @@ function treeItemMenuItems(): ContextMenuItem[] { return items; } + if (node.type === "elasticsearch-index") { + items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + items.push({ label: "", separator: true }); + items.push({ label: t("contextMenu.viewData"), action: toggle, icon: TableProperties }); + items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + return items; + } + // 6. Table / View if (node.type === "table" || node.type === "view") { items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 48f836442..34d0092fe 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -73,7 +73,7 @@ const ddlLoading = ref(false); const ddlFetched = ref(false); async function fetchDdl() { - if (!props.connectionId || !props.database || !props.tableName || ddlFetched.value) return; + if (!props.connectionId || !props.database || !props.tableName || ddlFetched.value || !tableMetadataCapabilities.value.ddl) return; ddlLoading.value = true; try { ddlContent.value = await api.getTableDdl(props.connectionId, props.database, metadataSchema.value, props.tableName); @@ -373,7 +373,7 @@ async function loadStructure(silent = false) { await store.ensureConnected(props.connectionId); const nextColumns = await api.getColumns(props.connectionId, props.database, metadataSchema.value, props.tableName); const [nextIndexes, nextForeignKeys, nextTriggers] = await Promise.all([ - api.listIndexes(props.connectionId, props.database, metadataSchema.value, props.tableName).catch(() => []), + tableMetadataCapabilities.value.indexes ? api.listIndexes(props.connectionId, props.database, metadataSchema.value, props.tableName).catch(() => []) : Promise.resolve([]), tableMetadataCapabilities.value.foreignKeys ? api.listForeignKeys(props.connectionId, props.database, metadataSchema.value, props.tableName).catch(() => []) : Promise.resolve([]), tableMetadataCapabilities.value.triggers ? api.listTriggers(props.connectionId, props.database, metadataSchema.value, props.tableName).catch(() => []) : Promise.resolve([]), ]); @@ -672,9 +672,23 @@ onBeforeUnmount(() => { unregisterStructureEditorShortcuts(); }); +function firstStructureMetadataTab(capabilities = tableMetadataCapabilities.value) { + if (capabilities.columns) return "columns"; + if (capabilities.indexes) return "indexes"; + if (capabilities.foreignKeys) return "foreignKeys"; + if (capabilities.triggers) return "triggers"; + if (capabilities.ddl && !isCreateMode.value) return "ddl"; + return "columns"; +} + watch(tableMetadataCapabilities, (capabilities) => { - if (activeTab.value === "foreignKeys" && !capabilities.foreignKeys) activeTab.value = "columns"; - if (activeTab.value === "triggers" && !capabilities.triggers) activeTab.value = "columns"; + const supported = + (activeTab.value === "columns" && capabilities.columns) || + (activeTab.value === "indexes" && capabilities.indexes) || + (activeTab.value === "foreignKeys" && capabilities.foreignKeys) || + (activeTab.value === "triggers" && capabilities.triggers) || + (activeTab.value === "ddl" && capabilities.ddl && !isCreateMode.value); + if (!supported) activeTab.value = firstStructureMetadataTab(capabilities); }); watch( @@ -735,11 +749,11 @@ watch(activeTab, (tab) => {
- {{ t("structureEditor.columns") }} - {{ t("structureEditor.indexes") }} + {{ t("structureEditor.columns") }} + {{ t("structureEditor.indexes") }} {{ t("structureEditor.foreignKeys") }} {{ t("structureEditor.triggers") }} - DDL + DDL
@@ -766,7 +780,7 @@ watch(activeTab, (tab) => {
- + @@ -984,7 +998,7 @@ watch(activeTab, (tab) => {
- + @@ -1108,7 +1122,7 @@ watch(activeTab, (tab) => { - +
{{ t("common.loading") }} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 190dfdd4c..a43d7cb00 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -266,6 +266,9 @@ export const mongoUpdateDocuments = forward("mongoUpdateDocuments"); export const mongoDeleteDocument = forward("mongoDeleteDocument"); export const mongoDeleteDocuments = forward("mongoDeleteDocuments"); +// Elasticsearch +export const elasticsearchListIndices = forward("elasticsearchListIndices"); + // History export const saveHistory = forward("saveHistory"); export const loadHistory = forward("loadHistory"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index a3ce75eec..7f25491d5 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -1251,6 +1251,10 @@ export async function mongoListCollections(connectionId: string, database: strin return post("/api/mongo/list-collections", { connectionId, database }); } +export async function elasticsearchListIndices(connectionId: string): Promise { + return mongoListCollections(connectionId, "default"); +} + export async function mongoFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, sort?: string): Promise { return post("/api/mongo/find-documents", { connectionId, database, collection, skip, limit, filter, sort }); } diff --git a/apps/desktop/src/lib/sidebarActiveTabTarget.ts b/apps/desktop/src/lib/sidebarActiveTabTarget.ts index 36b8b0c5c..775d83d50 100644 --- a/apps/desktop/src/lib/sidebarActiveTabTarget.ts +++ b/apps/desktop/src/lib/sidebarActiveTabTarget.ts @@ -84,6 +84,9 @@ function schemaMatches(node: TreeNode, schema: string | undefined): boolean { export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): boolean { if (target.type === "mongo-collection") { + if (node.type === "elasticsearch-index") { + return node.connectionId === target.connectionId && node.label === target.collectionName; + } return node.type === "mongo-collection" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.collectionName; } diff --git a/apps/desktop/src/lib/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebarTreeItemLayout.ts index a53840a5e..291babd0d 100644 --- a/apps/desktop/src/lib/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebarTreeItemLayout.ts @@ -1,8 +1,8 @@ import type { TreeNodeType } from "@/types/database"; -const leafTypes: Set = new Set(["column", "index", "fkey", "trigger", "procedure", "function", "package", "package-body", "object-browser", "redis-db", "mongo-collection", "user-admin", "saved-sql-file"]); +const leafTypes: Set = new Set(["column", "index", "fkey", "trigger", "procedure", "function", "package", "package-body", "object-browser", "redis-db", "mongo-collection", "elasticsearch-index", "user-admin", "saved-sql-file"]); -const fullWidthLabelTypes: Set = new Set(["table", "view", "mongo-collection"]); +const fullWidthLabelTypes: Set = new Set(["table", "view", "mongo-collection", "elasticsearch-index"]); const emptyContainerTypes: Set = new Set(["saved-sql-root", "saved-sql-folder"]); diff --git a/apps/desktop/src/lib/tableMetadataCapabilities.ts b/apps/desktop/src/lib/tableMetadataCapabilities.ts index 35ea8dece..ba761bbe8 100644 --- a/apps/desktop/src/lib/tableMetadataCapabilities.ts +++ b/apps/desktop/src/lib/tableMetadataCapabilities.ts @@ -21,6 +21,12 @@ const capabilityByType: Partial { + return mongoListCollections(connectionId, "default"); +} + export async function mongoFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, sort?: string): Promise { return invoke("mongo_find_documents", { connectionId, database, collection, skip, limit, filter, sort }); } diff --git a/apps/desktop/src/lib/treeNodeClick.ts b/apps/desktop/src/lib/treeNodeClick.ts index cbe32f605..eb39a5994 100644 --- a/apps/desktop/src/lib/treeNodeClick.ts +++ b/apps/desktop/src/lib/treeNodeClick.ts @@ -7,7 +7,7 @@ export type SidebarSelectionCopyAction = "copy-name" | "none"; export type SidebarActivation = "single" | "double"; const dataNodeTypes = new Set(["table", "view"]); -const toggleLeafNodeTypes = new Set(["redis-db", "mongo-collection", "user-admin"]); +const toggleLeafNodeTypes = new Set(["redis-db", "mongo-collection", "elasticsearch-index", "user-admin"]); const objectBrowserNodeTypes = new Set(["database", "schema", "object-browser"]); const sourceNodeTypes = new Set(["procedure", "function", "sequence", "package", "package-body"]); const savedSqlNodeTypes = new Set(["saved-sql-file"]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index de7b4f2fd..6ca7e2179 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -522,16 +522,16 @@ export const useConnectionStore = defineStore("connection", () => { for (const key of Object.keys(elasticsearchCompletionIndicesCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key]; } - for (const key of [...completionTableIndex.keys()]) { + for (const key of completionTableIndex.keys()) { if (key.startsWith(cachePrefix)) completionTableIndex.delete(key); } - for (const key of [...completionObjectIndex.keys()]) { + for (const key of completionObjectIndex.keys()) { if (key.startsWith(cachePrefix)) completionObjectIndex.delete(key); } - for (const key of [...completionColumnIndex.keys()]) { + for (const key of completionColumnIndex.keys()) { if (key.startsWith(cachePrefix)) completionColumnIndex.delete(key); } - for (const key of [...completionInFlight.keys()]) { + for (const key of completionInFlight.keys()) { if (key.startsWith(cachePrefix)) completionInFlight.delete(key); } } @@ -645,6 +645,8 @@ export const useConnectionStore = defineStore("connection", () => { await loadEtcdRoot(connectionId); } else if (config.db_type === "mongodb") { await loadMongoDatabases(connectionId); + } else if (config.db_type === "elasticsearch") { + await loadElasticsearchIndices(connectionId); } else { await loadDatabases(connectionId, { force: true }); } @@ -975,6 +977,38 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function loadElasticsearchIndices(connectionId: string) { + const node = findNode(treeNodes.value, connectionId); + if (!node) return; + + node.isLoading = true; + try { + await ensureConnected(connectionId); + const indices = await api.elasticsearchListIndices(connectionId); + setChildren( + node, + withSavedSqlRoot( + connectionId, + sortSidebarNames(indices).map((index) => ({ + id: `${connectionId}:__es_index:${index}`, + label: index, + type: "elasticsearch-index" as const, + connectionId, + database: "default", + isExpanded: false, + })), + node, + ), + ); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + async function loadMongoCollections(connectionId: string, database: string) { const nodeId = `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); @@ -1408,8 +1442,10 @@ export const useConnectionStore = defineStore("connection", () => { await loadRedisDatabases(node.connectionId); } else if (config?.db_type === "etcd") { await loadEtcdRoot(node.connectionId); - } else if (config?.db_type === "mongodb" || config?.db_type === "elasticsearch") { + } else if (config?.db_type === "mongodb") { await loadMongoDatabases(node.connectionId); + } else if (config?.db_type === "elasticsearch") { + await loadElasticsearchIndices(node.connectionId); } else { await loadDatabases(node.connectionId, options); } @@ -1700,7 +1736,7 @@ export const useConnectionStore = defineStore("connection", () => { return elasticsearchCompletionIndicesCache.value[cacheKey]; } await ensureConnected(connectionId); - const indices = await api.mongoListCollections(connectionId, database); + const indices = await api.elasticsearchListIndices(connectionId); elasticsearchCompletionIndicesCache.value[cacheKey] = indices; evictOldestCacheEntries(elasticsearchCompletionIndicesCache.value, COMPLETION_CACHE_MAX); return elasticsearchCompletionIndicesCache.value[cacheKey]; @@ -2435,6 +2471,7 @@ export const useConnectionStore = defineStore("connection", () => { loadEtcdRoot, updateRedisDbKeyStats, loadMongoDatabases, + loadElasticsearchIndices, loadMongoCollections, loadSchemas, loadSqlServerDatabaseObjects, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 895db871d..c83dfe5c8 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -325,7 +325,8 @@ export type TreeNodeType = | "redis-db" | "etcd-root" | "mongo-db" - | "mongo-collection"; + | "mongo-collection" + | "elasticsearch-index"; export interface ConnectionGroup { id: string;