feat(elasticsearch): add custom checks and tabular REST results
This commit is contained in:
parent
e2b35944ce
commit
a49137ae8b
|
|
@ -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;
|
||||
const normalizedPath = normalizeKibanaBasePath(kibanaBasePath);
|
||||
return normalizedPath ? { mode: "kibana", kibanaBasePath: normalizedPath } : { mode: "kibana" };
|
||||
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);
|
||||
const config: ElasticsearchExternalConfig = { mode: "kibana" };
|
||||
if (normalizedPath) config.kibanaBasePath = normalizedPath;
|
||||
if (checkPath) config.connectivityCheckPath = checkPath;
|
||||
return config;
|
||||
}
|
||||
|
|
|
|||
139
apps/desktop/src/stores/__tests__/connectionStore.elasticsearchOpen.spec.ts
vendored
Normal file
139
apps/desktop/src/stores/__tests__/connectionStore.elasticsearchOpen.spec.ts
vendored
Normal file
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -908,6 +908,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2967,6 +2967,26 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_insert_keeps_json_cells_as_single_json_literals() {
|
||||
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
|
||||
database_type: Some(DatabaseType::Elasticsearch),
|
||||
table_meta: None,
|
||||
columns: vec!["id".to_string(), "active".to_string(), "profile".to_string()],
|
||||
column_types: Some(vec![Some("number".to_string()), Some("boolean".to_string()), Some("json".to_string())]),
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!(7), json!(true), json!(r#"{"name":"Ada","roles":["admin"]}"#)]],
|
||||
exclude_primary_keys: false,
|
||||
include_computed_columns: false,
|
||||
insert_mode: DataGridCopyInsertMode::Merged,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
statement.as_deref(),
|
||||
Some("INSERT INTO table_name (\"id\", \"active\", \"profile\") VALUES (7, TRUE, '{\"name\":\"Ada\",\"roles\":[\"admin\"]}');")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_copy_insert_without_primary_keys_when_primary_keys_are_hidden() {
|
||||
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ fn limited_query_result(result: ChJsonResult, execution_time_ms: u128, max_rows:
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -661,6 +662,7 @@ pub async fn execute_query_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ fn query_result(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTRO
|
|||
use reqwest::{Client as HttpClient, Method, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -31,6 +31,9 @@ const ELASTICSEARCH_QUERY_VALUE_ENCODE_SET: &AsciiSet =
|
|||
&CONTROLS.add(b' ').add(b'"').add(b'#').add(b'%').add(b'&').add(b'+').add(b'/').add(b'=').add(b'?');
|
||||
|
||||
const KIBANA_PROXY_STATUS_HEADER: &str = "x-console-proxy-status-code";
|
||||
const ELASTICSEARCH_REST_TABLE_MAX_BODY_BYTES: usize = 8 * 1024 * 1024;
|
||||
const ELASTICSEARCH_REST_TABLE_MAX_ROWS: usize = 2_000;
|
||||
const ELASTICSEARCH_REST_TABLE_MAX_CELLS: usize = 200_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ElasticsearchTransportMode {
|
||||
|
|
@ -44,6 +47,8 @@ pub struct EsClient {
|
|||
fallback_base_urls: Vec<String>,
|
||||
auth: Option<(String, String)>,
|
||||
transport_mode: ElasticsearchTransportMode,
|
||||
/// GET path used for connect / health / test (default "/").
|
||||
connectivity_check_path: String,
|
||||
}
|
||||
|
||||
impl EsClient {
|
||||
|
|
@ -54,7 +59,15 @@ impl EsClient {
|
|||
accept_invalid_certs: bool,
|
||||
timeout: Duration,
|
||||
) -> Self {
|
||||
Self::new_with_mode(url, username, password, accept_invalid_certs, timeout, ElasticsearchTransportMode::Direct)
|
||||
Self::new_with_mode(
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
accept_invalid_certs,
|
||||
timeout,
|
||||
ElasticsearchTransportMode::Direct,
|
||||
"/".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn new_with_mode(
|
||||
|
|
@ -64,6 +77,7 @@ impl EsClient {
|
|||
accept_invalid_certs: bool,
|
||||
timeout: Duration,
|
||||
transport_mode: ElasticsearchTransportMode,
|
||||
connectivity_check_path: String,
|
||||
) -> Self {
|
||||
let base_url = url.trim_end_matches('/').to_string();
|
||||
let auth = match (username, password) {
|
||||
|
|
@ -73,7 +87,7 @@ impl EsClient {
|
|||
let builder = http_client_builder(timeout).danger_accept_invalid_certs(accept_invalid_certs);
|
||||
let http = builder.build().unwrap_or_else(|_| HttpClient::new());
|
||||
let fallback_base_urls = elasticsearch_base_url_fallbacks(&base_url);
|
||||
Self { http, base_url, fallback_base_urls, auth, transport_mode }
|
||||
Self { http, base_url, fallback_base_urls, auth, transport_mode, connectivity_check_path }
|
||||
}
|
||||
|
||||
pub fn from_config(
|
||||
|
|
@ -92,6 +106,7 @@ impl EsClient {
|
|||
ElasticsearchTransportMode::Direct
|
||||
};
|
||||
let base_url = format!("{}{}", url.trim_end_matches('/'), kibana_base_path.as_deref().unwrap_or(""));
|
||||
let connectivity_check_path = elasticsearch_connectivity_check_path(external_config);
|
||||
Self::new_with_mode(
|
||||
&base_url,
|
||||
username,
|
||||
|
|
@ -99,6 +114,7 @@ impl EsClient {
|
|||
elasticsearch_accept_invalid_certs(tls_enabled, url_params),
|
||||
timeout,
|
||||
transport_mode,
|
||||
connectivity_check_path,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +178,7 @@ impl Clone for EsClient {
|
|||
fallback_base_urls: self.fallback_base_urls.clone(),
|
||||
auth: self.auth.clone(),
|
||||
transport_mode: self.transport_mode,
|
||||
connectivity_check_path: self.connectivity_check_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -177,17 +194,52 @@ fn elasticsearch_kibana_base_path(external_config: Option<&Value>) -> Option<Str
|
|||
Some(if base_path.is_empty() { String::new() } else { format!("/{base_path}") })
|
||||
}
|
||||
|
||||
/// Path used for connectivity checks (test / open / health). Defaults to `/`.
|
||||
/// Accepts bare paths (`my-index/_search`) or a single-line `GET path` paste.
|
||||
pub fn elasticsearch_connectivity_check_path(external_config: Option<&Value>) -> String {
|
||||
let raw = external_config
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|config| config.get("connectivityCheckPath"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if raw.is_empty() {
|
||||
return "/".to_string();
|
||||
}
|
||||
|
||||
// First line only — ignore accidental body lines from console paste.
|
||||
let line = raw.lines().next().unwrap_or("").trim();
|
||||
let without_method = line
|
||||
.strip_prefix("GET ")
|
||||
.or_else(|| line.strip_prefix("get "))
|
||||
.or_else(|| line.strip_prefix("Get "))
|
||||
.unwrap_or(line)
|
||||
.trim();
|
||||
if without_method.is_empty() || without_method == "/" {
|
||||
return "/".to_string();
|
||||
}
|
||||
|
||||
if without_method.starts_with('/') {
|
||||
without_method.to_string()
|
||||
} else {
|
||||
format!("/{without_method}")
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test_connection(client: &mut EsClient, timeout: Duration) -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
let urls = std::iter::once(client.base_url.clone()).chain(client.fallback_base_urls.clone());
|
||||
let check_path = client.connectivity_check_path.clone();
|
||||
|
||||
for base_url in urls {
|
||||
client.base_url = base_url.clone();
|
||||
let path = check_path.clone();
|
||||
let resp = with_connection_timeout("Elasticsearch", timeout, async {
|
||||
client.get("/").send().await.map_err(|e| {
|
||||
client.get(&path).send().await.map_err(|e| {
|
||||
format!(
|
||||
"Elasticsearch connection failed for {}: {}",
|
||||
"Elasticsearch connection failed for {} ({}): {}",
|
||||
redact_elasticsearch_url(&base_url),
|
||||
path,
|
||||
format_reqwest_error(&e)
|
||||
)
|
||||
})
|
||||
|
|
@ -205,7 +257,7 @@ pub async fn test_connection(client: &mut EsClient, timeout: Duration) -> Result
|
|||
let status = client.response_status(&resp);
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Elasticsearch error ({status}): {body}"));
|
||||
return Err(format!("Elasticsearch error ({status}) for {check_path}: {body}"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -1167,7 +1219,7 @@ async fn execute_search_query(
|
|||
|
||||
fn parse_elasticsearch_response(
|
||||
status: u16,
|
||||
body: serde_json::Value,
|
||||
mut body: serde_json::Value,
|
||||
start: std::time::Instant,
|
||||
) -> Result<crate::types::QueryResult, String> {
|
||||
if let Some(result) = parse_sql_response(&body, start) {
|
||||
|
|
@ -1186,64 +1238,22 @@ fn parse_elasticsearch_response(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
} else {
|
||||
Ok(json_response_result(status, &body, start))
|
||||
}
|
||||
} else if let Some(hits) = body.pointer("/hits/hits").and_then(|v| v.as_array()) {
|
||||
} else if let Some(hits) = body.pointer_mut("/hits/hits").and_then(serde_json::Value::as_array_mut) {
|
||||
// Treat any `_search`-shaped body as the hits result, even when empty —
|
||||
// a 0-row match is a valid empty result, not a reason to fall back to
|
||||
// the raw-JSON status/response view.
|
||||
let mut all_keys = Vec::<String>::new();
|
||||
let docs: Vec<serde_json::Map<String, serde_json::Value>> = hits
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
let mut row = serde_json::Map::new();
|
||||
if let Some(source) = hit.get("_source").and_then(|s| s.as_object()) {
|
||||
for (k, v) in source {
|
||||
row.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
row.insert("_id".to_string(), hit.get("_id").cloned().unwrap_or(serde_json::Value::Null));
|
||||
if let Some(routing) = hit.get("_routing") {
|
||||
row.insert("_routing".to_string(), routing.clone());
|
||||
}
|
||||
for k in row.keys() {
|
||||
if !all_keys.contains(k) {
|
||||
all_keys.push(k.clone());
|
||||
}
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
if all_keys.is_empty() {
|
||||
// 0 hits → there's no doc to derive columns from; surface `_id`
|
||||
// so the grid at least shows a column header for the empty set.
|
||||
all_keys.push("_id".to_string());
|
||||
}
|
||||
|
||||
let rows: Vec<Vec<serde_json::Value>> = docs
|
||||
.iter()
|
||||
.map(|doc| {
|
||||
all_keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
doc.get(k)
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
|
||||
other => serde_json::Value::String(other.to_string()),
|
||||
})
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let hits = std::mem::take(hits);
|
||||
let (columns, column_types, rows) = parse_elasticsearch_search_hits(hits);
|
||||
let row_count = rows.len() as u64;
|
||||
|
||||
Ok(crate::types::QueryResult {
|
||||
columns: all_keys,
|
||||
column_types: Vec::new(),
|
||||
columns,
|
||||
column_types,
|
||||
column_sortables: vec![],
|
||||
rows,
|
||||
affected_rows: row_count,
|
||||
|
|
@ -1251,12 +1261,155 @@ fn parse_elasticsearch_response(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
} else {
|
||||
Ok(json_response_result(status, &body, start))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_elasticsearch_search_hits(
|
||||
hits: Vec<serde_json::Value>,
|
||||
) -> (Vec<String>, Vec<String>, Vec<Vec<serde_json::Value>>) {
|
||||
let mut columns = Vec::<String>::new();
|
||||
let mut column_indexes = HashMap::<String, usize>::new();
|
||||
let mut json_column_indexes = HashSet::<usize>::new();
|
||||
let mut rows = Vec::<Vec<serde_json::Value>>::with_capacity(hits.len());
|
||||
|
||||
for mut hit in hits {
|
||||
let mut row = vec![serde_json::Value::Null; columns.len()];
|
||||
if let Some(source) = hit.get_mut("_source").and_then(serde_json::Value::as_object_mut) {
|
||||
for (key, value) in std::mem::take(source) {
|
||||
append_elasticsearch_json_cell(
|
||||
&mut columns,
|
||||
&mut column_indexes,
|
||||
&mut json_column_indexes,
|
||||
&mut rows,
|
||||
&mut row,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
let id = hit.get_mut("_id").map(serde_json::Value::take).unwrap_or(serde_json::Value::Null);
|
||||
append_elasticsearch_json_cell(
|
||||
&mut columns,
|
||||
&mut column_indexes,
|
||||
&mut json_column_indexes,
|
||||
&mut rows,
|
||||
&mut row,
|
||||
"_id".to_string(),
|
||||
id,
|
||||
);
|
||||
if let Some(routing) = hit.get_mut("_routing") {
|
||||
append_elasticsearch_json_cell(
|
||||
&mut columns,
|
||||
&mut column_indexes,
|
||||
&mut json_column_indexes,
|
||||
&mut rows,
|
||||
&mut row,
|
||||
"_routing".to_string(),
|
||||
routing.take(),
|
||||
);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
if columns.is_empty() {
|
||||
columns.push("_id".to_string());
|
||||
}
|
||||
let column_types = infer_elasticsearch_json_column_types(&rows, columns.len(), &json_column_indexes);
|
||||
(columns, column_types, rows)
|
||||
}
|
||||
|
||||
fn append_elasticsearch_json_cell(
|
||||
columns: &mut Vec<String>,
|
||||
column_indexes: &mut HashMap<String, usize>,
|
||||
json_column_indexes: &mut HashSet<usize>,
|
||||
previous_rows: &mut [Vec<serde_json::Value>],
|
||||
row: &mut Vec<serde_json::Value>,
|
||||
column: String,
|
||||
value: serde_json::Value,
|
||||
) {
|
||||
let is_json_cell = matches!(value, serde_json::Value::Array(_) | serde_json::Value::Object(_));
|
||||
let value = if is_json_cell { serde_json::Value::String(value.to_string()) } else { value };
|
||||
if let Some(index) = column_indexes.get(&column).copied() {
|
||||
if is_json_cell {
|
||||
json_column_indexes.insert(index);
|
||||
}
|
||||
row[index] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
let index = columns.len();
|
||||
if is_json_cell {
|
||||
json_column_indexes.insert(index);
|
||||
}
|
||||
column_indexes.insert(column.clone(), index);
|
||||
columns.push(column);
|
||||
for previous_row in previous_rows {
|
||||
previous_row.push(serde_json::Value::Null);
|
||||
}
|
||||
row.push(value);
|
||||
}
|
||||
|
||||
fn infer_elasticsearch_json_column_types(
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
column_count: usize,
|
||||
json_column_indexes: &HashSet<usize>,
|
||||
) -> Vec<String> {
|
||||
(0..column_count)
|
||||
.map(|column_index| {
|
||||
if json_column_indexes.contains(&column_index) {
|
||||
return "json".to_string();
|
||||
}
|
||||
let mut inferred = None;
|
||||
for value in rows.iter().filter_map(|row| row.get(column_index)) {
|
||||
let value_type = match value {
|
||||
serde_json::Value::Null => continue,
|
||||
serde_json::Value::Bool(_) => "boolean",
|
||||
serde_json::Value::Number(_) => "number",
|
||||
serde_json::Value::String(_) => "text",
|
||||
serde_json::Value::Array(_) | serde_json::Value::Object(_) => "json",
|
||||
};
|
||||
inferred = match inferred {
|
||||
None => Some(value_type),
|
||||
Some(existing) if existing == value_type => Some(existing),
|
||||
Some(_) => Some("json"),
|
||||
};
|
||||
if inferred == Some("json") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
inferred.unwrap_or("unknown").to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn elasticsearch_rest_search_exceeds_table_limits(body: &serde_json::Value) -> bool {
|
||||
let Some(hits) = body.pointer("/hits/hits").and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
if hits.len() > ELASTICSEARCH_REST_TABLE_MAX_ROWS {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut columns = HashSet::<&str>::new();
|
||||
columns.insert("_id");
|
||||
for hit in hits {
|
||||
if hit.get("_routing").is_some() {
|
||||
columns.insert("_routing");
|
||||
}
|
||||
if let Some(source) = hit.get("_source").and_then(serde_json::Value::as_object) {
|
||||
columns.extend(source.keys().map(String::as_str));
|
||||
}
|
||||
if hits.len().saturating_mul(columns.len()) > ELASTICSEARCH_REST_TABLE_MAX_CELLS {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn json_response_result(status: u16, body: &serde_json::Value, start: std::time::Instant) -> crate::types::QueryResult {
|
||||
let body_text = serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string());
|
||||
raw_json_response_result(status, body_text, start)
|
||||
|
|
@ -1277,6 +1430,7 @@ fn raw_json_response_result(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1293,12 +1447,28 @@ fn parse_elasticsearch_rest_response(
|
|||
return Ok(raw_json_response_result(status, body_text, start));
|
||||
}
|
||||
|
||||
if serde_json::from_str::<serde_json::Value>(body_text).is_ok() {
|
||||
// Validate the payload as JSON, but retain the HTTP body verbatim so
|
||||
// numeric literals are not changed by a parse/serialize round trip.
|
||||
if body_text.len() > ELASTICSEARCH_REST_TABLE_MAX_BODY_BYTES {
|
||||
return Ok(raw_json_response_result(status, body_text, start));
|
||||
}
|
||||
|
||||
if let Ok(body) = serde_json::from_str::<serde_json::Value>(body_text) {
|
||||
if elasticsearch_rest_search_exceeds_table_limits(&body) {
|
||||
return Ok(raw_json_response_result(status, body_text, start));
|
||||
}
|
||||
// Prefer a tabular view for search hits (_source columns), SQL API
|
||||
// responses, and aggregations so the desktop data grid can display and
|
||||
// copy rows like relational results. Other JSON (mapping, cluster
|
||||
// info, …) stays as a lossless status/response panel with the raw body.
|
||||
let mut result = parse_elasticsearch_response(status, body, start)?;
|
||||
if result.columns == ["status".to_string(), "response".to_string()] {
|
||||
return Ok(raw_json_response_result(status, body_text, start));
|
||||
}
|
||||
// Attach the raw response body so the UI can toggle between the
|
||||
// table and the original JSON for Elasticsearch REST results.
|
||||
result.elasticsearch_raw_body = Some(body_text.to_string());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// CAT APIs default to text/plain for human-readable output. Keep those
|
||||
// responses visible instead of dropping them when JSON parsing is not valid.
|
||||
let rows: Vec<Vec<serde_json::Value>> =
|
||||
|
|
@ -1314,6 +1484,7 @@ fn parse_elasticsearch_rest_response(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1732,6 +1903,7 @@ fn parse_sql_response(body: &serde_json::Value, start: std::time::Instant) -> Op
|
|||
truncated: false,
|
||||
session_id: body.get("cursor").and_then(|cursor| cursor.as_str()).map(str::to_string),
|
||||
has_more: body.get("cursor").and_then(|cursor| cursor.as_str()).is_some(),
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1958,6 +2130,73 @@ mod tests {
|
|||
|
||||
assert_eq!(client.base_url, "https://localhost:9200");
|
||||
assert_eq!(client.fallback_base_urls, vec!["https://127.0.0.1:9200"]);
|
||||
assert_eq!(client.connectivity_check_path, "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connectivity_check_path_normalizes_get_path_and_defaults() {
|
||||
assert_eq!(super::elasticsearch_connectivity_check_path(None), "/");
|
||||
assert_eq!(super::elasticsearch_connectivity_check_path(Some(&json!({ "connectivityCheckPath": "" }))), "/");
|
||||
assert_eq!(
|
||||
super::elasticsearch_connectivity_check_path(Some(&json!({
|
||||
"connectivityCheckPath": "GET pro-jmsau-nwm-applog-*/_search"
|
||||
}))),
|
||||
"/pro-jmsau-nwm-applog-*/_search"
|
||||
);
|
||||
assert_eq!(
|
||||
super::elasticsearch_connectivity_check_path(Some(&json!({
|
||||
"connectivityCheckPath": "my-index/_search\n{\"query\":{\"match_all\":{}}}"
|
||||
}))),
|
||||
"/my-index/_search"
|
||||
);
|
||||
|
||||
let client = EsClient::from_config(
|
||||
"https://localhost:5601/",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
Some(&json!({
|
||||
"mode": "kibana",
|
||||
"connectivityCheckPath": "GET pro-logs-*/_search"
|
||||
})),
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
assert_eq!(client.connectivity_check_path, "/pro-logs-*/_search");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_uses_configured_connectivity_check_path() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = [0_u8; 2048];
|
||||
let read = socket.read(&mut request).await.unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]);
|
||||
assert!(request.starts_with("GET /pro-logs-*/_search "), "unexpected request: {request}");
|
||||
let body = r#"{"hits":{"total":{"value":0,"relation":"eq"},"hits":[]}}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
|
||||
let mut client = EsClient::from_config(
|
||||
&format!("http://{addr}"),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
Some(&json!({ "connectivityCheckPath": "/pro-logs-*/_search" })),
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
super::test_connection(&mut client, Duration::from_secs(2)).await.unwrap();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2814,6 +3053,70 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_search_rest_response_as_source_table() {
|
||||
let body = r#"{"took":1,"hits":{"total":{"value":2,"relation":"eq"},"hits":[{"_id":"1","_source":{"name":"Alice","age":30,"active":true,"deleted_at":null,"profile":{"team":"core"},"tags":["admin","reader"]}},{"_id":"2","_routing":"shard-a","_source":{"name":"Bob","city":"NYC"}}]}}"#;
|
||||
let result = super::parse_elasticsearch_rest_response(200, body, std::time::Instant::now()).unwrap();
|
||||
|
||||
assert_ne!(result.columns, vec!["status", "response"]);
|
||||
assert!(result.columns.contains(&"name".to_string()));
|
||||
assert!(result.columns.contains(&"_id".to_string()));
|
||||
assert_eq!(result.rows.len(), 2);
|
||||
let name_idx = result.columns.iter().position(|c| c == "name").unwrap();
|
||||
let id_idx = result.columns.iter().position(|c| c == "_id").unwrap();
|
||||
assert_eq!(result.rows[0][name_idx], json!("Alice"));
|
||||
assert_eq!(result.rows[0][id_idx], json!("1"));
|
||||
let age_idx = result.columns.iter().position(|c| c == "age").unwrap();
|
||||
let active_idx = result.columns.iter().position(|c| c == "active").unwrap();
|
||||
let deleted_at_idx = result.columns.iter().position(|c| c == "deleted_at").unwrap();
|
||||
let profile_idx = result.columns.iter().position(|c| c == "profile").unwrap();
|
||||
let tags_idx = result.columns.iter().position(|c| c == "tags").unwrap();
|
||||
assert_eq!(result.rows[0][age_idx], json!(30));
|
||||
assert_eq!(result.rows[0][active_idx], json!(true));
|
||||
assert_eq!(result.rows[0][deleted_at_idx], serde_json::Value::Null);
|
||||
assert_eq!(result.rows[0][profile_idx], json!(r#"{"team":"core"}"#));
|
||||
assert_eq!(result.rows[0][tags_idx], json!(r#"["admin","reader"]"#));
|
||||
assert_eq!(result.column_types[age_idx], "number");
|
||||
assert_eq!(result.column_types[active_idx], "boolean");
|
||||
assert_eq!(result.column_types[profile_idx], "json");
|
||||
assert_eq!(result.column_types[tags_idx], "json");
|
||||
let city_idx = result.columns.iter().position(|c| c == "city").unwrap();
|
||||
assert_eq!(result.rows[1][city_idx], json!("NYC"));
|
||||
let routing_idx = result.columns.iter().position(|c| c == "_routing").unwrap();
|
||||
assert_eq!(result.rows[1][routing_idx], json!("shard-a"));
|
||||
assert_eq!(result.affected_rows, 2);
|
||||
assert_eq!(result.elasticsearch_raw_body.as_deref(), Some(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_empty_search_rest_response_as_empty_table() {
|
||||
let body = r#"{"took":1,"hits":{"total":{"value":0,"relation":"eq"},"hits":[]}}"#;
|
||||
let result = super::parse_elasticsearch_rest_response(200, body, std::time::Instant::now()).unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["_id"]);
|
||||
assert!(result.rows.is_empty());
|
||||
assert_eq!(result.affected_rows, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_search_response_over_table_cell_limit_falls_back_to_raw_json() {
|
||||
let hits = (0..450)
|
||||
.map(|index| {
|
||||
let mut source = serde_json::Map::new();
|
||||
source.insert(format!("field_{index}"), json!(index));
|
||||
json!({ "_id": index.to_string(), "_source": source })
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let body = json!({ "hits": { "hits": hits } }).to_string();
|
||||
|
||||
let result = super::parse_elasticsearch_rest_response(200, &body, std::time::Instant::now()).unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["status", "response"]);
|
||||
assert_eq!(result.rows[0][0], json!(200));
|
||||
assert_eq!(result.rows[0][1], json!(body));
|
||||
assert_eq!(result.elasticsearch_raw_body, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rest_query_keeps_json_error_response() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
|
@ -2919,11 +3222,13 @@ mod tests {
|
|||
super::execute_rest_query(&client, "POST /products/_search\n{\"query\":{\"match_all\":{}}}").await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(result.columns, vec!["status", "response"]);
|
||||
assert_eq!(result.rows[0][0], json!(200));
|
||||
let response = result.rows[0][1].as_str().unwrap();
|
||||
assert_eq!(response, response_body);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(response).unwrap(), body);
|
||||
// The response now parses hits+aggs into a tabular result (aggregation
|
||||
// columns) instead of the raw status/response JSON panel.
|
||||
assert!(result.columns.contains(&"key".to_string()));
|
||||
assert!(result.columns.contains(&"doc_count".to_string()));
|
||||
assert_ne!(result.columns, vec!["status", "response"]);
|
||||
// Raw body is attached for JSON toggle in the UI.
|
||||
assert!(result.elasticsearch_raw_body.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -482,6 +482,7 @@ pub async fn execute_query(client: &InfluxdbClient, database: &str, sql: &str) -
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}),
|
||||
None => Ok(QueryResult {
|
||||
columns: vec![],
|
||||
|
|
@ -493,6 +494,7 @@ pub async fn execute_query(client: &InfluxdbClient, database: &str, sql: &str) -
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -636,6 +638,7 @@ fn parse_flux_csv(text: &str, start: Instant) -> Result<QueryResult, String> {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3393,6 +3393,7 @@ async fn execute_result_set_with_text_protocol_on_conn(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
}
|
||||
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
|
||||
|
|
@ -3417,6 +3418,7 @@ async fn execute_result_set_with_text_protocol_on_conn(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3451,6 +3453,7 @@ async fn execute_result_set_with_text_protocol_on_conn(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -3507,6 +3510,7 @@ async fn execute_result_set_with_prepared_protocol_on_conn(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -3748,6 +3752,7 @@ pub async fn execute_query_on_conn_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -872,6 +872,7 @@ async fn execute_select_prepared(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -934,6 +935,7 @@ async fn execute_select_text(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2771,6 +2773,7 @@ pub async fn execute_query_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3278,6 +3281,7 @@ async fn execute_query_with_max_rows_inner(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ pub async fn execute_query_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -352,6 +353,7 @@ fn query_result_from_rqlite_result(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2374,6 +2374,7 @@ fn execute_query_blocking(pool: &SqliteHandle, sql: &str, max_rows: Option<usize
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
} else {
|
||||
conn.execute_batch(sql).map_err(|e| e.to_string())?;
|
||||
|
|
@ -2387,6 +2388,7 @@ fn execute_query_blocking(pool: &SqliteHandle, sql: &str, max_rows: Option<usize
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ fn server_messages_query_result(messages: Vec<String>, start: Instant) -> Option
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
},
|
||||
messages,
|
||||
))
|
||||
|
|
@ -394,6 +395,7 @@ async fn collect_first_result_limited(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -836,6 +838,7 @@ fn push_sqlserver_result_set(results: &mut Vec<QueryResult>, result: Option<SqlS
|
|||
truncated: result.truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2011,6 +2014,7 @@ pub async fn execute_query_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
},
|
||||
messages,
|
||||
))
|
||||
|
|
@ -2028,6 +2032,7 @@ pub async fn execute_query_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
},
|
||||
messages,
|
||||
))
|
||||
|
|
@ -2058,6 +2063,7 @@ pub async fn execute_batch_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
},
|
||||
messages,
|
||||
)]);
|
||||
|
|
@ -2118,6 +2124,7 @@ pub async fn execute_simple_batch_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2327,6 +2334,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
let result = query_result_with_server_messages(empty, vec!["DBCC execution completed".to_string()]);
|
||||
assert_eq!(result.columns, vec!["Message"]);
|
||||
|
|
@ -2342,6 +2350,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
let result = query_result_with_server_messages(select, vec!["informational".to_string()]);
|
||||
assert_eq!(result.columns, vec!["id"]);
|
||||
|
|
@ -3016,6 +3025,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
strip_dbx_sqlserver_row_number_column(&mut result, sql);
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ pub async fn execute_query_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -367,6 +368,7 @@ pub async fn execute_query_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
} else {
|
||||
// Batch multiple statements into a single pipeline for transactional integrity
|
||||
|
|
@ -382,6 +384,7 @@ pub async fn execute_query_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -564,6 +567,7 @@ fn query_result_from_turso_result(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -707,6 +707,7 @@ fn json_to_query_result(status: u16, body: Value, start: Instant) -> QueryResult
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -737,6 +738,7 @@ fn values_to_query_result(items: Vec<Value>, start: Instant) -> QueryResult {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -672,6 +672,7 @@ pub fn duckdb_execute_with_max_rows(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
} else {
|
||||
let affected = con.execute(sql, []).map_err(|e| e.to_string())?;
|
||||
|
|
@ -685,6 +686,7 @@ pub fn duckdb_execute_with_max_rows(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2284,6 +2286,7 @@ fn error_query_result(message: String) -> db::QueryResult {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2298,6 +2301,7 @@ fn empty_query_result(execution_time_ms: u128) -> db::QueryResult {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2329,6 +2333,7 @@ async fn execute_multi_sqlserver(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -2380,6 +2385,7 @@ async fn execute_multi_sqlserver(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
if matches!(action, PoolErrorAction::Discard | PoolErrorAction::ReconnectAndRetry) {
|
||||
state.remove_pool_by_key(pool_key).await;
|
||||
|
|
@ -2402,6 +2408,7 @@ async fn execute_multi_sqlserver(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2519,6 +2526,7 @@ pub async fn execute_statements(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2708,6 +2716,7 @@ async fn exec_tx_pg_inner(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}),
|
||||
(Err(e), Ok(_)) => Err(e),
|
||||
(Ok(_), Err(reset_err)) => Err(reset_err),
|
||||
|
|
@ -2787,6 +2796,7 @@ async fn exec_tx_mysql_inner(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2850,6 +2860,7 @@ async fn exec_tx_sqlite_inner(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -2939,6 +2950,7 @@ async fn exec_tx_explicit_inner(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2981,6 +2993,7 @@ async fn exec_tx_none_inner(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -3444,6 +3457,7 @@ async fn execute_manual_txn_postgres_statement(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3483,6 +3497,7 @@ async fn execute_manual_txn_mysql_statement(
|
|||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
} else {
|
||||
let result = conn.query_iter(sql).await.map_err(|e| format!("Query failed: {e}"))?;
|
||||
|
|
@ -3498,6 +3513,7 @@ async fn execute_manual_txn_mysql_statement(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3530,6 +3546,7 @@ pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) -
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -3554,6 +3571,7 @@ pub async fn rollback_manual_transaction(state: &AppState, txn_session_id: &str)
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -4050,6 +4068,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
|
@ -4071,6 +4090,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
|
@ -4992,6 +5012,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_query_result_for_js(result);
|
||||
|
|
|
|||
|
|
@ -3397,6 +3397,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
let tables = presto_like_tables_from_query_result(&result);
|
||||
|
|
@ -3441,6 +3442,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
let columns = presto_like_columns_from_query_result(&result);
|
||||
|
|
@ -3727,6 +3729,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
assert_eq!(oracle_table_comment_from_query_result(result).unwrap().as_deref(), Some("Customer table"));
|
||||
|
|
@ -3741,6 +3744,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
assert_eq!(oracle_table_comment_from_query_result(empty).unwrap(), None);
|
||||
|
|
@ -3773,6 +3777,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
let comments = oracle_table_comments_from_query_result(result);
|
||||
|
|
@ -3892,6 +3897,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
let columns = oracle_columns_from_query_result(result);
|
||||
|
|
@ -3993,6 +3999,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
let stats = oracle_object_statistics_from_query_result(result);
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
};
|
||||
|
||||
let extensions = extension_infos_from_query_result(result, true);
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ async fn fetch_table_export_batch(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -685,6 +685,7 @@ fn execute_change_transaction(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2978,6 +2978,7 @@ async fn execute_on_pool_once(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
} else {
|
||||
let affected = con.execute(&sql, []).map_err(|e| e.to_string())?;
|
||||
|
|
@ -2991,6 +2992,7 @@ async fn execute_on_pool_once(
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -5010,6 +5012,7 @@ mod tests {
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -245,6 +245,11 @@ pub struct QueryResult {
|
|||
pub session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub has_more: bool,
|
||||
/// For Elasticsearch REST search results parsed into a table from _source,
|
||||
/// this carries the raw HTTP response body so the UI can offer a toggle
|
||||
/// between the tabular view and the original JSON.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub elasticsearch_raw_body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1235,6 +1235,7 @@ fn query_result(columns: Vec<String>, rows: Vec<Vec<Value>>, affected_rows: u64)
|
|||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ test("sortDataGridRows sorts ISO date strings by time", () => {
|
|||
assert.deepEqual(sortDataGridRows(rows, 0, "asc"), [["2025-12-31"], ["2026-01-01"], ["2026-02-01"]]);
|
||||
});
|
||||
|
||||
test("sortDataGridRows keeps scalar types and orders JSON cells by canonical text", () => {
|
||||
assert.deepEqual(sortDataGridRows([[10], [2], [1]], 0, "asc"), [[1], [2], [10]]);
|
||||
assert.deepEqual(sortDataGridRows([['{"rank":10}'], ['{"rank":2}'], ['{"rank":1}']], 0, "asc"), [['{"rank":1}'], ['{"rank":2}'], ['{"rank":10}']]);
|
||||
});
|
||||
|
||||
test("sortDataGridRowIndexes preserves stable source ordering", () => {
|
||||
const rows = [["item-10"], ["item-2"], ["item-2"]];
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { test } from "vitest";
|
|||
import {
|
||||
buildElasticsearchExternalConfig,
|
||||
elasticsearchConnectionModeFromConfig,
|
||||
elasticsearchConnectivityCheckPathFromConfig,
|
||||
elasticsearchKibanaBasePathFromConfig,
|
||||
normalizeElasticsearchConnectivityCheckPath,
|
||||
normalizeKibanaBasePath,
|
||||
} from "../../apps/desktop/src/lib/connection/elasticsearchKibanaProxy.ts";
|
||||
|
||||
|
|
@ -21,3 +23,21 @@ test("round trips Kibana proxy mode and normalizes its base path", () => {
|
|||
assert.equal(elasticsearchKibanaBasePathFromConfig(config), "/kibana/s/analytics");
|
||||
assert.equal(normalizeKibanaBasePath("/"), "");
|
||||
});
|
||||
|
||||
test("stores connectivity check path for direct and kibana modes", () => {
|
||||
assert.equal(normalizeElasticsearchConnectivityCheckPath(""), "");
|
||||
assert.equal(normalizeElasticsearchConnectivityCheckPath("/"), "");
|
||||
assert.equal(normalizeElasticsearchConnectivityCheckPath("GET pro-logs-*/_search"), "/pro-logs-*/_search");
|
||||
assert.equal(normalizeElasticsearchConnectivityCheckPath("pro-logs-*/_search"), "/pro-logs-*/_search");
|
||||
|
||||
const direct = buildElasticsearchExternalConfig("direct", "", "GET pro-logs-*/_search");
|
||||
assert.deepEqual(direct, { connectivityCheckPath: "/pro-logs-*/_search" });
|
||||
assert.equal(elasticsearchConnectivityCheckPathFromConfig(direct), "/pro-logs-*/_search");
|
||||
|
||||
const kibana = buildElasticsearchExternalConfig("kibana", "/kibana", "my-index/_search");
|
||||
assert.deepEqual(kibana, {
|
||||
mode: "kibana",
|
||||
kibanaBasePath: "/kibana",
|
||||
connectivityCheckPath: "/my-index/_search",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5544,6 +5544,108 @@ POST /dbx-orders/_search
|
|||
}
|
||||
});
|
||||
|
||||
test("Elasticsearch REST result clears previous SQL pagination and sort state", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
settingsStore.updateEditorSettings({ autoCalculateTotalRows: false });
|
||||
connectionStore.addEphemeralConnection(elasticsearchConn("es-rest-state"));
|
||||
const tabId = store.createTab("es-rest-state", "", "Elasticsearch query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.ok(tab);
|
||||
|
||||
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sqlToExecute: "SELECT * FROM logs LIMIT 100 OFFSET 0",
|
||||
pageSql: "SELECT * FROM logs LIMIT 100 OFFSET 0",
|
||||
pageLimit: 100,
|
||||
pageOffset: 0,
|
||||
countSql: "SELECT COUNT(*) FROM logs",
|
||||
useAgentResultSession: false,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
return new Response(JSON.stringify([{ columns: ["id"], rows: [[1]], affected_rows: 1, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/analyze-editability") {
|
||||
return new Response(JSON.stringify({ editable: false, reason: "unsupported" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/close-session") {
|
||||
return new Response("true", { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (url === "/api/query/execute") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
assert.equal(body.sql, 'POST /logs/_search\n{"size":1}');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
columns: ["id", "profile"],
|
||||
column_types: ["number", "json"],
|
||||
rows: [[1, '{"team":"core"}']],
|
||||
affected_rows: 1,
|
||||
execution_time_ms: 1,
|
||||
elasticsearch_raw_body: '{"hits":{"hits":[]}}',
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
});
|
||||
|
||||
try {
|
||||
await store.executeTabSql(tabId, "SELECT * FROM logs");
|
||||
assert.equal(tab.resultPageLimit, 100);
|
||||
assert.equal(tab.resultPageOffset, 0);
|
||||
assert.equal(tab.resultCountSql, "SELECT COUNT(*) FROM logs");
|
||||
|
||||
tab.resultSortColumn = "id";
|
||||
tab.resultSortColumnIndex = 0;
|
||||
tab.resultSortDirection = "desc";
|
||||
tab.resultSortMode = "local";
|
||||
tab.resultSortedSql = "SELECT * FROM logs ORDER BY id DESC";
|
||||
tab.resultLocalSortOriginalRows = [[1]];
|
||||
tab.orderByInput = "id DESC";
|
||||
tab.resultTotalRowCount = 500;
|
||||
tab.resultTotalRowCountLoading = true;
|
||||
tab.resultSessionId = "old-session";
|
||||
|
||||
await store.executeTabSql(tabId, 'POST /logs/_search\n{"size":1}');
|
||||
|
||||
assert.deepEqual(tab.result?.rows, [[1, '{"team":"core"}']]);
|
||||
assert.equal(tab.resultPageSql, undefined);
|
||||
assert.equal(tab.resultPageLimit, undefined);
|
||||
assert.equal(tab.resultPageOffset, undefined);
|
||||
assert.equal(tab.resultCountSql, undefined);
|
||||
assert.equal(tab.resultTotalRowCount, undefined);
|
||||
assert.equal(tab.resultTotalRowCountLoading, false);
|
||||
assert.equal(tab.resultSortColumn, undefined);
|
||||
assert.equal(tab.resultSortColumnIndex, undefined);
|
||||
assert.equal(tab.resultSortDirection, undefined);
|
||||
assert.equal(tab.resultSortMode, undefined);
|
||||
assert.equal(tab.resultSortedSql, undefined);
|
||||
assert.equal(tab.resultLocalSortOriginalRows, undefined);
|
||||
assert.equal(tab.orderByInput, undefined);
|
||||
assert.equal(tab.resultSessionId, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("Elasticsearch execute all stops after an HTTP error by default", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
|
|
|
|||
Loading…
Reference in New Issue