feat(elasticsearch): improve REST commands and visual filters

This commit is contained in:
zipg authored and GitHub committed 2026-07-16 16:31:15 +08:00
1 parent bfcd7593ee
commit da7eedb49c
15 files changed
+1151 -115

No files matched your search

@@ -18,6 +18,7 @@ import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
import {
buildDocumentFilterCondition,
buildElasticsearchQueryFromRules,
combineDocumentFilterConditions,
currentDocumentFilterJson,
currentDocumentSortJson,
@@ -25,8 +26,14 @@ import {
documentFilterModeNeedsValue,
documentFilterModeOptions,
documentStoreProviderFor,
elasticsearchBoolClauseOptions,
elasticsearchQueryTypeNeedsValue,
elasticsearchQueryTypeOptions,
elasticsearchStructuredFilter,
type DocumentFilterMode,
type DocumentFilterRule,
type ElasticsearchBoolClause,
type ElasticsearchQueryType,
} from "@/lib/app/documentStoreProvider";
import { documentStoreValueForGrid, parseDocumentStoreInputValue, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues";
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
@@ -35,7 +42,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { useSettingsStore } from "@/stores/settingsStore";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -114,6 +121,7 @@ type DocumentGridChanges = {
const documentFilterBuilderOpen = ref(false);
const documentFilterRules = ref<DocumentFilterRule[]>([]);
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
const pendingDelete = ref<PendingDelete | null>(null);
@@ -175,13 +183,34 @@ const gridResult = computed<QueryResult>(() => {
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
});
const documentFilterFieldOptions = computed(() => gridResult.value.columns);
const documentStructuredFilterCount = computed(() => (appliedDocumentFilter.value ? 1 : 0));
const documentFilterFieldOptions = computed(() => {
if (documentStoreProvider.value.kind !== "elasticsearch") return gridResult.value.columns;
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
return [...new Set(names.filter(Boolean))];
});
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
const documentStructuredFilterCount = computed(() => {
if (!appliedDocumentFilter.value) return 0;
if (documentStoreProvider.value.kind !== "elasticsearch") return 1;
const query = appliedDocumentFilter.value.$esQuery;
if (!query || typeof query !== "object" || Array.isArray(query)) return 0;
const bool = (query as Record<string, unknown>).bool;
if (!bool || typeof bool !== "object" || Array.isArray(bool)) return 0;
return elasticsearchBoolClauseOptions.reduce((count, clause) => {
const rules = (bool as Record<string, unknown>)[clause];
return count + (Array.isArray(rules) ? rules.length : 0);
}, 0);
});
const documentLoadingLabelKey = computed(() => (documentLoadCancelling.value ? "common.stopping" : "common.loading"));
let documentLoadingTimer: ReturnType<typeof setInterval> | undefined;
function createDocumentFilterRule(): DocumentFilterRule {
return defaultDocumentFilterRule(uuid(), documentFilterFieldOptions.value[0] ?? "");
const fieldName = documentFilterFieldOptions.value[0] ?? "";
const rule = defaultDocumentFilterRule(uuid(), fieldName);
if (documentStoreProvider.value.kind === "elasticsearch") {
rule.elasticsearchQueryType = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(fieldName))[0];
}
return rule;
}
function ensureDocumentFilterRule() {
@@ -204,11 +233,33 @@ function updateDocumentFilterRule(ruleId: string, patch: Partial<DocumentFilterR
documentFilterRules.value = documentFilterRules.value.map((rule) => {
if (rule.id !== ruleId) return rule;
const next = { ...rule, ...patch };
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
if (documentStoreProvider.value.kind === "elasticsearch") {
const queryTypes = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(next.fieldName));
if (!next.elasticsearchQueryType || !queryTypes.includes(next.elasticsearchQueryType)) {
next.elasticsearchQueryType = queryTypes[0];
}
if (!elasticsearchQueryTypeNeedsValue(next.elasticsearchQueryType)) next.rawValue = "";
} else if (!documentFilterModeNeedsValue(next.mode)) {
next.rawValue = "";
}
return next;
});
}
function elasticsearchRuleQueryTypes(rule: DocumentFilterRule): ElasticsearchQueryType[] {
return elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(rule.fieldName));
}
function elasticsearchQueryTypeLabel(queryType: ElasticsearchQueryType): string {
const rangeOperator: Partial<Record<ElasticsearchQueryType, string>> = {
range_gt: "range >",
range_gte: "range >=",
range_lt: "range <",
range_lte: "range <=",
};
return rangeOperator[queryType] ?? queryType;
}
function resetDocumentFilterBuilder() {
appliedDocumentFilter.value = null;
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
@@ -235,6 +286,12 @@ const documentQueryPreview = computed(() => {
});
async function applyDocumentStructuredFilters() {
if (documentStoreProvider.value.kind === "elasticsearch") {
appliedDocumentFilter.value = elasticsearchStructuredFilter(buildElasticsearchQueryFromRules(documentFilterRules.value));
documentFilterBuilderOpen.value = false;
applyFilter();
return;
}
const items = documentFilterRules.value
.map((rule) => ({
rule,
@@ -250,6 +307,15 @@ async function applyDocumentStructuredFilters() {
applyFilter();
}
async function loadElasticsearchMappingFields() {
if (documentStoreProvider.value.kind !== "elasticsearch") return;
try {
elasticsearchMappingFields.value = await api.getColumns(props.connectionId, props.database, "", props.collection);
} catch {
elasticsearchMappingFields.value = [];
}
}
function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) {
appliedDocumentFilter.value = null;
resetDocumentFilterBuilder();
@@ -819,7 +885,10 @@ onMounted(async () => {
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
load();
// Mapping metadata enriches the filter builder, but it must not delay the
// first page of documents when the mapping endpoint is slow.
void loadElasticsearchMappingFields();
void load();
});
onBeforeUnmount(() => {
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
@@ -1014,7 +1083,7 @@ function resetTableSearchSplitWidth() {
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3">
<PopoverContent align="start" class="max-w-[calc(100vw-32px)] gap-3 p-3" :class="documentStoreProvider.kind === 'elasticsearch' ? 'w-[680px]' : 'w-[360px]'">
<div class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addDocumentFilterRule">
@@ -1056,7 +1125,7 @@ function resetTableSearchSplitWidth() {
<div v-if="documentFilterRules.length" class="space-y-2">
<template v-for="(rule, index) in documentFilterRules" :key="rule.id">
<div v-if="index > 0" class="flex justify-center">
<div v-if="index > 0 && documentStoreProvider.kind !== 'elasticsearch'" class="flex justify-center">
<Button
variant="ghost"
size="sm"
@@ -1070,19 +1139,44 @@ function resetTableSearchSplitWidth() {
{{ rule.conjunction }}
</Button>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto] items-center gap-1.5">
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
<div class="grid items-center gap-1.5" :class="documentStoreProvider.kind === 'elasticsearch' ? 'grid-cols-[minmax(0,0.75fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto]'">
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchClause || 'filter'" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchClause: value as ElasticsearchBoolClause })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue :placeholder="t('grid.filterBuilderColumn')" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
{{ fieldName }}
<SelectItem v-for="clause in elasticsearchBoolClauseOptions" :key="clause" :value="clause">
{{ clause }}
</SelectItem>
</SelectContent>
</Select>
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue :placeholder="t('grid.filterBuilderColumn')"> {{ rule.fieldName }}{{ elasticsearchFieldTypes.get(rule.fieldName) ? ` (${elasticsearchFieldTypes.get(rule.fieldName)})` : "" }} </SelectValue>
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
<span class="flex min-w-0 items-center gap-2">
<span class="truncate">{{ fieldName }}</span>
<span v-if="elasticsearchFieldTypes.get(fieldName)" class="shrink-0 text-[10px] text-muted-foreground">({{ elasticsearchFieldTypes.get(fieldName) }})</span>
</span>
</SelectItem>
</SelectContent>
</Select>
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchQueryType || elasticsearchRuleQueryTypes(rule)[0]" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchQueryType: value as ElasticsearchQueryType })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="queryType in elasticsearchRuleQueryTypes(rule)" :key="queryType" :value="queryType">
{{ elasticsearchQueryTypeLabel(queryType) }}
</SelectItem>
</SelectContent>
</Select>
<Select v-else :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
@@ -1094,7 +1188,7 @@ function resetTableSearchSplitWidth() {
</Select>
<Input
v-if="documentFilterModeNeedsValue(rule.mode)"
v-if="documentStoreProvider.kind === 'elasticsearch' ? elasticsearchQueryTypeNeedsValue(rule.elasticsearchQueryType) : documentFilterModeNeedsValue(rule.mode)"
:model-value="rule.rawValue"
class="h-8 min-w-0 text-xs"
:placeholder="t('grid.filterBuilderValue')"