feat(elasticsearch): add custom checks and tabular REST results
This commit is contained in:
1 parent
e2b35944ce
commit
a49137ae8b
44 files changed
+917
-74
No files matched your search
@@ -1691,7 +1691,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(item.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch") {
|
||||
await connectionStore.loadElasticsearchIndices(item.connectionId);
|
||||
await connectionStore.openElasticsearchConnectionTree(item.connectionId);
|
||||
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
|
||||
await connectionStore.loadVectorCollections(item.connectionId);
|
||||
} else if (config?.db_type === "mq") {
|
||||
@@ -1716,7 +1716,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(item.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch") {
|
||||
await connectionStore.loadElasticsearchIndices(item.connectionId);
|
||||
await connectionStore.openElasticsearchConnectionTree(item.connectionId);
|
||||
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
|
||||
await connectionStore.loadVectorCollections(item.connectionId);
|
||||
} else if (config?.db_type === "mq") {
|
||||
|
||||
@@ -13,6 +13,12 @@ import JsonTree from "./JsonTree.vue";
|
||||
const props = defineProps<{
|
||||
status: number;
|
||||
body: string;
|
||||
/** When true, render a "Table" button to switch back to the grid view. */
|
||||
canShowTable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
showTable: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -101,6 +107,9 @@ onMounted(() => {
|
||||
>
|
||||
{{ t("redis.jsonView") }}
|
||||
</button>
|
||||
<button v-if="canShowTable" type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors bg-background font-medium text-foreground shadow-sm" @click="emit('showTable')">
|
||||
{{ t("tabs.tableData") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full border px-2 py-0.5 font-mono text-[11px] font-medium tabular-nums" :class="statusClass" role="status" :aria-label="statusLabel">
|
||||
|
||||
@@ -96,7 +96,7 @@ import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { applyHiveKerberosSubmitConfig, hiveKerberosFormConfig, type HiveKerberosAuthMode } from "@/lib/database/hiveKerberosOptions";
|
||||
import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudflareD1Connection } from "@/lib/connection/cloudflareD1";
|
||||
import { buildElasticsearchExternalConfig, elasticsearchConnectionModeFromConfig, elasticsearchKibanaBasePathFromConfig, type ElasticsearchConnectionMode } from "@/lib/connection/elasticsearchKibanaProxy";
|
||||
import { buildElasticsearchExternalConfig, elasticsearchConnectionModeFromConfig, elasticsearchConnectivityCheckPathFromConfig, elasticsearchKibanaBasePathFromConfig, type ElasticsearchConnectionMode } from "@/lib/connection/elasticsearchKibanaProxy";
|
||||
|
||||
type DbOption = { value: string; label: string };
|
||||
type DbCategoryKey = "sql" | "analytics" | "domestic" | "lightweight" | "document" | "graph_ai" | "timeseries" | "mq" | "registry_config";
|
||||
@@ -261,6 +261,7 @@ const defaultForm = (): ConnectionForm => ({
|
||||
|
||||
const elasticsearchConnectionMode = ref<ElasticsearchConnectionMode>("direct");
|
||||
const elasticsearchKibanaBasePath = ref("");
|
||||
const elasticsearchConnectivityCheckPath = ref("");
|
||||
const elasticsearchConnectionPorts = ref<Record<ElasticsearchConnectionMode, number>>({
|
||||
direct: 9200,
|
||||
kibana: 5601,
|
||||
@@ -270,6 +271,7 @@ function resetElasticsearchProxyFields(externalConfig?: unknown) {
|
||||
const mode = elasticsearchConnectionModeFromConfig(externalConfig);
|
||||
elasticsearchConnectionMode.value = mode;
|
||||
elasticsearchKibanaBasePath.value = elasticsearchKibanaBasePathFromConfig(externalConfig);
|
||||
elasticsearchConnectivityCheckPath.value = elasticsearchConnectivityCheckPathFromConfig(externalConfig);
|
||||
elasticsearchConnectionPorts.value = {
|
||||
direct: mode === "direct" ? form.value.port : 9200,
|
||||
kibana: mode === "kibana" ? form.value.port : 5601,
|
||||
@@ -3191,7 +3193,7 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
config.database = config.database?.trim() || undefined;
|
||||
}
|
||||
} else if (config.db_type === "elasticsearch") {
|
||||
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value);
|
||||
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value);
|
||||
} else if (config.db_type === "sqlserver") {
|
||||
config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined;
|
||||
} else {
|
||||
@@ -5874,6 +5876,11 @@ function openExternalUrl(url: string) {
|
||||
<Input v-model="elasticsearchKibanaBasePath" class="col-span-3" placeholder="/kibana/s/default" @input="resetTestState" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.db_type === 'elasticsearch'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.elasticsearchConnectivityCheckPath") }}</Label>
|
||||
<Input v-model="elasticsearchConnectivityCheckPath" class="col-span-3" :placeholder="t('connection.elasticsearchConnectivityCheckPathPlaceholder')" @input="resetTestState" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.driver_profile === 'gbase8s'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.gbaseServer") }}</Label>
|
||||
<Input v-model="form.gbase_server" class="col-span-3" placeholder="gbase01" />
|
||||
|
||||
@@ -368,6 +368,19 @@ const activeStatementExecutionMarkers = computed(() =>
|
||||
),
|
||||
);
|
||||
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
|
||||
/** Whether the active result is an Elasticsearch _source table that also has a raw JSON toggle. */
|
||||
const activeElasticsearchRawBody = computed(() => {
|
||||
if (activeEffectiveDatabaseType.value !== "elasticsearch") return undefined;
|
||||
return props.activeTab.result?.elasticsearch_raw_body;
|
||||
});
|
||||
/** Toggle between the _source table and the raw JSON panel for Elasticsearch REST results. */
|
||||
const showElasticsearchRawJson = ref(false);
|
||||
watch(
|
||||
() => props.activeTab.result?.elasticsearch_raw_body,
|
||||
() => {
|
||||
showElasticsearchRawJson.value = false;
|
||||
},
|
||||
);
|
||||
const resultArchiveExporting = ref(false);
|
||||
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
|
||||
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
|
||||
@@ -1301,6 +1314,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
|
||||
<template v-else>
|
||||
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
|
||||
<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" class="flex-1 min-h-0" :status="200" :body="activeElasticsearchRawBody" can-show-table @show-table="showElasticsearchRawJson = false" />
|
||||
<DataGrid
|
||||
v-else-if="activeTab.result && hasTabularResult"
|
||||
ref="dataGridRef"
|
||||
@@ -1350,6 +1364,18 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
>
|
||||
<template #result-toolbar-leading="{ compact }">
|
||||
<QueryResultViewSwitcher :active-view="activeOutputView" :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData && !activeElasticsearchJsonResponse" :compact="compact" @select-view="emit('update:activeOutputView', $event)" />
|
||||
<template v-if="activeElasticsearchRawBody">
|
||||
<div class="mx-1 h-4 w-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 shrink-0 items-center rounded-sm border border-transparent px-2 text-xs leading-none transition-colors"
|
||||
:class="showElasticsearchRawJson ? 'bg-secondary text-secondary-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="showElasticsearchRawJson"
|
||||
@click="showElasticsearchRawJson = !showElasticsearchRawJson"
|
||||
>
|
||||
{{ showElasticsearchRawJson ? t("tabs.tableData") : t("redis.jsonView") }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
<template #result-toolbar-actions="{ compact }">
|
||||
<QueryResultToolbarActions
|
||||
|
||||
@@ -554,6 +554,7 @@ async function toggle() {
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch") {
|
||||
// Expand: list indices (like other db types list databases).
|
||||
await connectionStore.loadElasticsearchIndices(node.connectionId);
|
||||
} else if (config?.db_type === "milvus") {
|
||||
await connectionStore.loadMilvusDatabases(node.connectionId);
|
||||
|
||||
@@ -122,7 +122,7 @@ function createExportState(
|
||||
context: computed(() => "table-data"),
|
||||
sourceColumns: computed(() => columns),
|
||||
visibleColumnIndexes: computed(() => visibleColumnIndexes ?? columns.map((_, index) => index)),
|
||||
columnTypes: computed(() => columns.map(() => "varchar")),
|
||||
columnTypes: computed(() => columns.map((column) => tableMeta.columns?.find((item) => item.name === column)?.data_type ?? "varchar")),
|
||||
extractorOptions: computed(() => extractorOptions),
|
||||
whereInput: computed(() => undefined),
|
||||
orderBy: computed(() => undefined),
|
||||
@@ -547,6 +547,37 @@ describe("useDataGridExport prepared row statements", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps JSON cells structured in JSON and SQL extractor requests", async () => {
|
||||
const tableMeta: DataGridTableMeta = {
|
||||
tableName: "events",
|
||||
primaryKeys: ["id"],
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
|
||||
{ name: "payload", data_type: "json", is_nullable: true },
|
||||
],
|
||||
};
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0],
|
||||
columnIndexes: [0, 1],
|
||||
columns: ["id", "payload"],
|
||||
rows: [[7, '{"name":"Ada","tags":["admin"]}']],
|
||||
};
|
||||
vi.mocked(extractDataGridSelection).mockResolvedValue({
|
||||
text: "copied",
|
||||
mimeType: "application/json",
|
||||
fileExtension: "json",
|
||||
rowCount: 1,
|
||||
columnCount: 2,
|
||||
});
|
||||
const state = createExportState(tableMeta, ["id", "payload"], matrix, [7, '{"name":"Ada","tags":["admin"]}']);
|
||||
|
||||
await expect(state.copyWithExtractor("json")).resolves.toBe(true);
|
||||
await expect(state.copyWithExtractor("sql-inserts")).resolves.toBe(true);
|
||||
|
||||
expect(extractDataGridSelection).toHaveBeenNthCalledWith(1, expect.objectContaining({ rows: [[7, { name: "Ada", tags: ["admin"] }]] }));
|
||||
expect(extractDataGridSelection).toHaveBeenNthCalledWith(2, expect.objectContaining({ rows: [[7, { name: "Ada", tags: ["admin"] }]] }));
|
||||
});
|
||||
|
||||
it("rejects SQL UPDATE instead of silently skipping a row with a null primary key", async () => {
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0],
|
||||
|
||||
@@ -348,7 +348,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
}
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.value.forEach((col, i) => {
|
||||
obj[col] = item.data[i];
|
||||
const value = item.data[i];
|
||||
if (typeof value === "string" && columnTypes.value?.[i]?.trim().toLowerCase() === "json") {
|
||||
try {
|
||||
obj[col] = JSON.parse(value);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
obj[col] = value;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
@@ -443,6 +450,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
allDisplayItems,
|
||||
allSourceColumns,
|
||||
visibleColumnIndexes,
|
||||
columnTypes,
|
||||
extractorOptions: extractorOptionsOption,
|
||||
databaseType,
|
||||
tableMeta,
|
||||
|
||||
@@ -32,6 +32,7 @@ interface UseDataGridExtractorOptions {
|
||||
allDisplayItems: ComputedRef<ExtractorRowItem[]>;
|
||||
allSourceColumns: ComputedRef<Array<string | undefined> | undefined>;
|
||||
visibleColumnIndexes: ComputedRef<number[]>;
|
||||
columnTypes: ComputedRef<Array<string | undefined> | undefined>;
|
||||
extractorOptions?: ComputedRef<DataGridExtractorOptions>;
|
||||
databaseType: ComputedRef<DatabaseType | undefined>;
|
||||
tableMeta: ComputedRef<DataGridTableMeta | undefined>;
|
||||
@@ -54,6 +55,15 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
const { toast } = useToast();
|
||||
const hasUnsupportedDiscreteSelection = computed(() => options.hasCellSelection.value && options.selectedCellMatrix.value === null);
|
||||
|
||||
function normalizeCellValue(value: unknown, columnType: string | undefined): unknown {
|
||||
if (typeof value !== "string" || columnType?.trim().toLowerCase() !== "json") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function selectionData(): SelectionData | null {
|
||||
if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) {
|
||||
const rows = options.displayItems.value.filter((item) => options.selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data);
|
||||
@@ -116,13 +126,14 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
||||
}
|
||||
}
|
||||
const compactIndexBySource = new Map(requiredSourceIndexes.map((sourceIndex, compactIndex) => [sourceIndex, compactIndex]));
|
||||
const columnTypesBySource = new Map(visibleIndexes.map((sourceIndex, visibleIndex) => [sourceIndex, options.columnTypes.value?.[visibleIndex]]));
|
||||
const columns = requiredSourceIndexes.map((sourceIndex, compactIndex) => ({
|
||||
displayName: fullColumns[sourceIndex],
|
||||
sourceName: sourceNames?.[sourceIndex],
|
||||
sourceIndex: compactIndex,
|
||||
}));
|
||||
const selectedColumnIndexes = selectedSourceIndexes.map((sourceIndex) => compactIndexBySource.get(sourceIndex)).filter((index): index is number => index !== undefined);
|
||||
const rows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => row[sourceIndex]));
|
||||
const rows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => normalizeCellValue(row[sourceIndex], columnTypesBySource.get(sourceIndex))));
|
||||
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
|
||||
const tableMeta =
|
||||
descriptor.category === "sql"
|
||||
|
||||
@@ -225,6 +225,8 @@ export default {
|
||||
elasticsearchKibanaProxyMode: "Kibana Proxy",
|
||||
elasticsearchKibanaHost: "Kibana Host",
|
||||
elasticsearchKibanaBasePath: "Base Path",
|
||||
elasticsearchConnectivityCheckPath: "Connectivity Path",
|
||||
elasticsearchConnectivityCheckPathPlaceholder: "/ or /my-index/_search",
|
||||
version: "Version",
|
||||
driverInstallHintPrefix: "Install the required driver from ",
|
||||
driverInstallHintSuffix: " in the top toolbar before connecting.",
|
||||
|
||||
@@ -623,6 +623,8 @@ export default withEnglishFallback({
|
||||
elasticsearchKibanaProxyMode: "proxy de Kibana",
|
||||
elasticsearchKibanaHost: "host de Kibana",
|
||||
elasticsearchKibanaBasePath: "ruta base",
|
||||
elasticsearchConnectivityCheckPath: "Ruta de conectividad",
|
||||
elasticsearchConnectivityCheckPathPlaceholder: "/ o /my-index/_search",
|
||||
mqSystemRocketMq: "Apache RocketMQ",
|
||||
rocketmqNamesrvAddr: "Dirección de NameServer",
|
||||
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
|
||||
|
||||
@@ -621,6 +621,8 @@ export default withEnglishFallback({
|
||||
elasticsearchKibanaProxyMode: "Proxy Kibana",
|
||||
elasticsearchKibanaHost: "Host Kibana",
|
||||
elasticsearchKibanaBasePath: "Percorso base",
|
||||
elasticsearchConnectivityCheckPath: "Percorso di connettività",
|
||||
elasticsearchConnectivityCheckPathPlaceholder: "/ o /my-index/_search",
|
||||
mqSystemRocketMq: "Apache RocketMQ",
|
||||
rocketmqNamesrvAddr: "Indirizzo NameServer",
|
||||
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
|
||||
|
||||
@@ -621,6 +621,8 @@ export default withEnglishFallback({
|
||||
elasticsearchKibanaProxyMode: "Kibana プロキシ",
|
||||
elasticsearchKibanaHost: "Kibana ホスト",
|
||||
elasticsearchKibanaBasePath: "ベースパス",
|
||||
elasticsearchConnectivityCheckPath: "接続確認パス",
|
||||
elasticsearchConnectivityCheckPathPlaceholder: "/ または /my-index/_search",
|
||||
mqSystemRocketMq: "Apache RocketMQ",
|
||||
rocketmqNamesrvAddr: "NameServer アドレス",
|
||||
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
|
||||
|
||||
@@ -622,6 +622,8 @@ export default withEnglishFallback({
|
||||
elasticsearchKibanaProxyMode: "Proxy do Kibana",
|
||||
elasticsearchKibanaHost: "Host do Kibana",
|
||||
elasticsearchKibanaBasePath: "Caminho Base",
|
||||
elasticsearchConnectivityCheckPath: "Caminho de conectividade",
|
||||
elasticsearchConnectivityCheckPathPlaceholder: "/ ou /my-index/_search",
|
||||
mqSystemRocketMq: "Apache RocketMQ",
|
||||
rocketmqNamesrvAddr: "Endereço do NameServer",
|
||||
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
|
||||
|
||||
@@ -227,6 +227,8 @@ export default withEnglishFallback({
|
||||
elasticsearchKibanaProxyMode: "Kibana 代理",
|
||||
elasticsearchKibanaHost: "Kibana 主机",
|
||||
elasticsearchKibanaBasePath: "基础路径",
|
||||
elasticsearchConnectivityCheckPath: "连通性检查路径",
|
||||
elasticsearchConnectivityCheckPathPlaceholder: "/ 或 /my-index/_search",
|
||||
version: "版本",
|
||||
driverInstallHintPrefix: "需要在顶部导航栏「",
|
||||
driverInstallHintSuffix: "」中安装对应的驱动才能连接。",
|
||||
|
||||
@@ -621,6 +621,8 @@ export default withEnglishFallback({
|
||||
elasticsearchKibanaProxyMode: "Kibana 代理",
|
||||
elasticsearchKibanaHost: "Kibana 主機",
|
||||
elasticsearchKibanaBasePath: "基礎路徑",
|
||||
elasticsearchConnectivityCheckPath: "連通性檢查路徑",
|
||||
elasticsearchConnectivityCheckPathPlaceholder: "/ 或 /my-index/_search",
|
||||
mqSystemRocketMq: "Apache RocketMQ",
|
||||
rocketmqNamesrvAddr: "NameServer 地址",
|
||||
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export type ElasticsearchConnectionMode = "direct" | "kibana";
|
||||
|
||||
export interface ElasticsearchExternalConfig {
|
||||
mode: "kibana";
|
||||
mode?: "kibana" | "direct";
|
||||
kibanaBasePath?: string;
|
||||
/** GET path for connect/test/health. Empty means GET /. */
|
||||
connectivityCheckPath?: string;
|
||||
}
|
||||
|
||||
function externalConfigRecord(value: unknown): Record<string, unknown> {
|
||||
@@ -14,6 +16,16 @@ export function normalizeKibanaBasePath(value: string): string {
|
||||
return path ? `/${path}` : "";
|
||||
}
|
||||
|
||||
/** Normalize a connectivity-check path. Empty → "" (driver defaults to GET /). */
|
||||
export function normalizeElasticsearchConnectivityCheckPath(value: string): string {
|
||||
const raw = value.trim();
|
||||
if (!raw) return "";
|
||||
const line = raw.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
||||
const withoutMethod = line.replace(/^GET\s+/i, "").trim();
|
||||
if (!withoutMethod || withoutMethod === "/") return "";
|
||||
return withoutMethod.startsWith("/") ? withoutMethod : `/${withoutMethod}`;
|
||||
}
|
||||
|
||||
export function elasticsearchConnectionModeFromConfig(value: unknown): ElasticsearchConnectionMode {
|
||||
const config = externalConfigRecord(value);
|
||||
return config.mode === "kibana" ? "kibana" : "direct";
|
||||
@@ -26,8 +38,20 @@ export function elasticsearchKibanaBasePathFromConfig(value: unknown): string {
|
||||
return typeof path === "string" ? normalizeKibanaBasePath(path) : "";
|
||||
}
|
||||
|
||||
export function buildElasticsearchExternalConfig(mode: ElasticsearchConnectionMode, kibanaBasePath: string): ElasticsearchExternalConfig | undefined {
|
||||
if (mode !== "kibana") return undefined;
|
||||
export function elasticsearchConnectivityCheckPathFromConfig(value: unknown): string {
|
||||
const config = externalConfigRecord(value);
|
||||
const path = config.connectivityCheckPath;
|
||||
return typeof path === "string" ? normalizeElasticsearchConnectivityCheckPath(path) : "";
|
||||
}
|
||||
|
||||
export function buildElasticsearchExternalConfig(mode: ElasticsearchConnectionMode, kibanaBasePath: string, connectivityCheckPath = ""): ElasticsearchExternalConfig | undefined {
|
||||
const checkPath = normalizeElasticsearchConnectivityCheckPath(connectivityCheckPath);
|
||||
if (mode !== "kibana") {
|
||||
return checkPath ? { connectivityCheckPath: checkPath } : undefined;
|
||||
}
|
||||
const normalizedPath = normalizeKibanaBasePath(kibanaBasePath);
|
||||
return normalizedPath ? { mode: "kibana", kibanaBasePath: normalizedPath } : { mode: "kibana" };
|
||||
const config: ElasticsearchExternalConfig = { mode: "kibana" };
|
||||
if (normalizedPath) config.kibanaBasePath = normalizedPath;
|
||||
if (checkPath) config.connectivityCheckPath = checkPath;
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 esConnection(): ConnectionConfig {
|
||||
return {
|
||||
id: "es-1",
|
||||
name: "Elasticsearch",
|
||||
db_type: "elasticsearch",
|
||||
host: "127.0.0.1",
|
||||
port: 9200,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "",
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
function seedConnectionNode(store: { treeNodes: TreeNode[]; connectedIds: Set<string> }, id = "es-1") {
|
||||
store.connectedIds.add(id);
|
||||
store.treeNodes.push({
|
||||
id,
|
||||
label: "Elasticsearch",
|
||||
type: "connection",
|
||||
connectionId: id,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe("connectionStore Elasticsearch open/expand", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("openElasticsearchConnectionTree only ensures connectivity, does not expand or list indices", async () => {
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["orders", "users"]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
elasticsearchListIndices,
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.addEphemeralConnection(esConnection());
|
||||
seedConnectionNode(store);
|
||||
|
||||
await store.openElasticsearchConnectionTree("es-1");
|
||||
|
||||
expect(elasticsearchListIndices).not.toHaveBeenCalled();
|
||||
const node = store.treeNodes.find((n) => n.id === "es-1");
|
||||
// openElasticsearchConnectionTree does NOT expand the node
|
||||
expect(node?.isExpanded).toBe(false);
|
||||
expect(node?.children?.some((c) => c.type === "elasticsearch-index")).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshTreeNode lists indices", async () => {
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["orders", "users"]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
elasticsearchListIndices,
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.addEphemeralConnection(esConnection());
|
||||
seedConnectionNode(store);
|
||||
const node = store.treeNodes.find((n) => n.id === "es-1")!;
|
||||
|
||||
await store.refreshTreeNode(node);
|
||||
|
||||
expect(elasticsearchListIndices).toHaveBeenCalledWith("es-1");
|
||||
expect(
|
||||
node.children
|
||||
?.filter((c) => c.type === "elasticsearch-index")
|
||||
.map((c) => c.label)
|
||||
.sort(),
|
||||
).toEqual(["orders", "users"]);
|
||||
});
|
||||
|
||||
it("loadElasticsearchIndices lists indices and expands", async () => {
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["orders", "users"]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
elasticsearchListIndices,
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.addEphemeralConnection(esConnection());
|
||||
seedConnectionNode(store);
|
||||
|
||||
await store.loadElasticsearchIndices("es-1");
|
||||
|
||||
expect(elasticsearchListIndices).toHaveBeenCalledWith("es-1");
|
||||
const node = store.treeNodes.find((n) => n.id === "es-1");
|
||||
expect(
|
||||
node?.children
|
||||
?.filter((c) => c.type === "elasticsearch-index")
|
||||
.map((c) => c.label)
|
||||
.sort(),
|
||||
).toEqual(["orders", "users"]);
|
||||
});
|
||||
});
|
||||
@@ -2324,6 +2324,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
} else if (config.db_type === "mongodb") {
|
||||
await loadMongoDatabases(connectionId);
|
||||
} else if (config.db_type === "elasticsearch") {
|
||||
// Reload: list indices.
|
||||
await loadElasticsearchIndices(connectionId);
|
||||
} else if (config.db_type === "milvus") {
|
||||
await loadMilvusDatabases(connectionId);
|
||||
@@ -3109,6 +3110,25 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect an Elasticsearch root without expanding or listing indices.
|
||||
* Used when first opening a connection (test/connect) — connectivity uses
|
||||
* GET / or the configured check path via ensureConnected/test_connection.
|
||||
* Expanding the node lists indices via loadElasticsearchIndices.
|
||||
*/
|
||||
async function openElasticsearchConnectionTree(connectionId: string) {
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node) return;
|
||||
|
||||
// Only ensure connectivity (GET / or configured path); do not expand or list indices.
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadElasticsearchIndices(connectionId: string) {
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node) return;
|
||||
@@ -6168,6 +6188,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
updateRedisDbKeyStats,
|
||||
loadMongoDatabases,
|
||||
loadMilvusDatabases,
|
||||
openElasticsearchConnectionTree,
|
||||
loadElasticsearchIndices,
|
||||
loadVectorCollections,
|
||||
loadMongoCollections,
|
||||
|
||||
@@ -623,6 +623,25 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function clearResultNavigationState(tab: QueryTab) {
|
||||
tab.resultSortedSql = undefined;
|
||||
tab.resultSortColumn = undefined;
|
||||
tab.resultSortColumnIndex = undefined;
|
||||
tab.resultSortDirection = undefined;
|
||||
tab.resultSortMode = undefined;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
|
||||
tab.orderByInput = undefined;
|
||||
tab.resultPageSql = undefined;
|
||||
tab.resultPageLimit = undefined;
|
||||
tab.resultPageOffset = undefined;
|
||||
tab.resultCountSql = undefined;
|
||||
tab.resultTotalRowCount = undefined;
|
||||
tab.resultTotalRowCountLoading = false;
|
||||
tab.resultSessionId = undefined;
|
||||
}
|
||||
|
||||
function clearResultRunSnapshots(tab: QueryTab) {
|
||||
for (const run of tab.resultRuns ?? []) {
|
||||
if (run.resultCacheKey) void deleteTabResultSnapshot(run.resultCacheKey);
|
||||
@@ -3510,6 +3529,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const current = tabs.value.find((item) => item.id === id);
|
||||
if (current?.executionId === executionId && openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
|
||||
if (current?.executionId === executionId && allResults.length > 0) {
|
||||
clearResultNavigationState(current);
|
||||
const errorResultIndex = allResults.findIndex((result) => result.columns.includes("Error") || elasticsearchHttpErrorStatus(result) !== undefined);
|
||||
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : 0;
|
||||
current.results = allResults.length > 1 ? allResults : undefined;
|
||||
|
||||
@@ -584,6 +584,10 @@ export interface QueryResult {
|
||||
truncated?: boolean;
|
||||
session_id?: string | null;
|
||||
has_more?: boolean;
|
||||
/** For Elasticsearch REST search results parsed into a _source table,
|
||||
* this carries the raw HTTP response body so the UI can toggle between
|
||||
* the tabular view and the original JSON. */
|
||||
elasticsearch_raw_body?: string;
|
||||
sourceLabel?: string;
|
||||
sourceStatement?: string;
|
||||
/** Absolute offsets in the editor document at execution time. */
|
||||
|
||||
Reference in new issue
Block a user