mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(elasticsearch): show index aliases on sidebar rows
This commit is contained in:
@@ -90,6 +90,7 @@ import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
|
||||
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
|
||||
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { elasticsearchIndexAliasLabel } from "@/lib/sidebar/elasticsearchIndexActions";
|
||||
import { isXuguPublicSynonymTreeNode, isXuguSchedulerJobTreeNode, xuguSchemaDisplayName } from "@/lib/sidebar/xuguPublicSynonyms";
|
||||
import { xuguDatafileDetailRows, xuguTablespaceDetailRows } from "@/lib/sidebar/xuguTablespaces";
|
||||
// --- Drag and Drop ---
|
||||
@@ -442,6 +443,7 @@ function treeNodeSecondaryValue(node: TreeNode): string | undefined {
|
||||
if (node.type === "type" && node.customTypeKind) return t(`customType.kinds.${node.customTypeKind}`);
|
||||
if (node.type === "type-member") return (node.meta as CustomTypeTreeMemberMeta | undefined)?.displayValue;
|
||||
if (node.type === "datafile") return node.xuguDatafilePath;
|
||||
if (node.type === "elasticsearch-index") return elasticsearchIndexAliasLabel(node);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -604,6 +606,16 @@ const detailTooltip = computed(() => {
|
||||
}));
|
||||
return rows.length ? { rows } : null;
|
||||
}
|
||||
if (node.type === "elasticsearch-index") {
|
||||
const aliases = elasticsearchIndexAliasLabel(node);
|
||||
if (!aliases) return null;
|
||||
return {
|
||||
rows: [
|
||||
{ label: t("objects.name"), value: visibleLabel(node) },
|
||||
{ label: t("tree.elasticsearchAlias"), value: aliases },
|
||||
],
|
||||
};
|
||||
}
|
||||
const comment = node.type === "column" && node.meta && "comment" in node.meta ? (node.meta as ColumnInfo).comment : node.comment;
|
||||
if (!comment || (node.type !== "schema" && node.type !== "table" && node.type !== "view" && node.type !== "column")) return null;
|
||||
const rows: DetailTooltipRow[] = [
|
||||
@@ -1567,7 +1579,10 @@ function onKeydown(event: KeyboardEvent) {
|
||||
]"
|
||||
>{{ visibleLabel(node) }}</span
|
||||
>
|
||||
<span v-if="treeNodeSecondaryValue(node)" class="min-w-0 max-w-[55%] shrink truncate text-xs text-muted-foreground" :title="treeNodeSecondaryValue(node)">{{ treeNodeSecondaryValue(node) }}</span>
|
||||
<span v-if="treeNodeSecondaryValue(node)" class="flex min-w-0 max-w-[55%] shrink items-center gap-1 text-xs text-muted-foreground" :title="node.type === 'elasticsearch-index' ? undefined : treeNodeSecondaryValue(node)">
|
||||
<Link2 v-if="node.type === 'elasticsearch-index'" class="h-3 w-3 shrink-0 text-sky-400" />
|
||||
<span class="min-w-0 truncate">{{ treeNodeSecondaryValue(node) }}</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="canDragPinnedOrder()"
|
||||
type="button"
|
||||
|
||||
@@ -3637,6 +3637,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
linkedServers: "Əlaqələndirilmiş serverlər",
|
||||
defaultDatabase: "Standart verilənlər bazası",
|
||||
elasticsearchAlias: "Təxəllüs",
|
||||
columns: "Sütunlar",
|
||||
attributes: "Atributlar",
|
||||
methods: "Metodlar",
|
||||
|
||||
@@ -3636,6 +3636,7 @@ export default {
|
||||
},
|
||||
linkedServers: "Linked Servers",
|
||||
defaultDatabase: "Default DB",
|
||||
elasticsearchAlias: "Alias",
|
||||
columns: "Columns",
|
||||
attributes: "Attributes",
|
||||
methods: "Methods",
|
||||
|
||||
@@ -3518,6 +3518,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
linkedServers: "Servidores vinculados",
|
||||
defaultDatabase: "Base predeterminada",
|
||||
elasticsearchAlias: "Alias",
|
||||
columns: "Columnas",
|
||||
attributes: "Atributos",
|
||||
methods: "Métodos",
|
||||
|
||||
@@ -3516,6 +3516,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
linkedServers: "Server Collegati",
|
||||
defaultDatabase: "DB Predefinito",
|
||||
elasticsearchAlias: "Alias",
|
||||
columns: "Colonne",
|
||||
attributes: "Attributi",
|
||||
methods: "Metodi",
|
||||
|
||||
@@ -3541,6 +3541,7 @@ export default withEnglishFallback({
|
||||
no: "いいえ",
|
||||
},
|
||||
defaultDatabase: "デフォルトDB",
|
||||
elasticsearchAlias: "エイリアス",
|
||||
columns: "列",
|
||||
attributes: "属性",
|
||||
methods: "メソッド",
|
||||
|
||||
@@ -3395,6 +3395,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
linkedServers: "연결된 서버",
|
||||
defaultDatabase: "기본 DB",
|
||||
elasticsearchAlias: "별칭",
|
||||
columns: "컬럼",
|
||||
attributes: "속성",
|
||||
methods: "메서드",
|
||||
|
||||
@@ -3519,6 +3519,7 @@ export default withEnglishFallback({
|
||||
linkedServers: "Servidores Vinculados",
|
||||
materializedViews: "Visualizações Materializadas",
|
||||
defaultDatabase: "Banco padrão",
|
||||
elasticsearchAlias: "Alias",
|
||||
columns: "Colunas",
|
||||
attributes: "Atributos",
|
||||
methods: "Métodos",
|
||||
|
||||
@@ -3609,6 +3609,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
linkedServers: "Bağlı Sunucular",
|
||||
defaultDatabase: "Varsayılan VT",
|
||||
elasticsearchAlias: "Takma ad",
|
||||
columns: "Sütunlar",
|
||||
attributes: "Öznitelikler",
|
||||
methods: "Yöntemler",
|
||||
|
||||
@@ -3557,6 +3557,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
linkedServers: "链接服务器",
|
||||
defaultDatabase: "默认库",
|
||||
elasticsearchAlias: "别名",
|
||||
columns: "字段",
|
||||
attributes: "属性",
|
||||
methods: "方法",
|
||||
|
||||
@@ -3516,6 +3516,7 @@ export default withEnglishFallback({
|
||||
linkedServers: "連結伺服器",
|
||||
materializedViews: "具體化檢視",
|
||||
defaultDatabase: "預設庫",
|
||||
elasticsearchAlias: "別名",
|
||||
columns: "欄位",
|
||||
attributes: "屬性",
|
||||
methods: "方法",
|
||||
|
||||
@@ -3863,7 +3863,7 @@ export async function mongoCloneCollection(connectionId: string, database: strin
|
||||
|
||||
export async function elasticsearchListIndices(connectionId: string): Promise<string[]> {
|
||||
const collections = await documentListCollections(connectionId, "default");
|
||||
return collections.map((c) => c.name);
|
||||
return [...new Set(collections.flatMap((collection) => [collection.name, ...(collection.aliases ?? [])].filter((name) => name.trim())))];
|
||||
}
|
||||
|
||||
/** Lists every Meilisearch index visible to the current connection credentials. */
|
||||
|
||||
@@ -3951,7 +3951,7 @@ export async function vectorRenameCollection(connectionId: string, database: str
|
||||
|
||||
export async function elasticsearchListIndices(connectionId: string): Promise<string[]> {
|
||||
const collections = await documentListCollections(connectionId, "default");
|
||||
return collections.map((c) => c.name);
|
||||
return [...new Set(collections.flatMap((collection) => [collection.name, ...(collection.aliases ?? [])].filter((name) => name.trim())))];
|
||||
}
|
||||
|
||||
/** Lists every Meilisearch index visible to the current connection credentials. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern, isElasticsearchProtocolIndex, isPartialElasticsearchClear, matchesElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
|
||||
import { elasticsearchClearIndexPreview, elasticsearchIndexAliasLabel, isElasticsearchClearConfirmed, isElasticsearchIndexPattern, isElasticsearchProtocolIndex, isPartialElasticsearchClear, matchesElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
|
||||
import type { ElasticsearchDeleteByQueryResult } from "@/lib/backend/tauri";
|
||||
|
||||
function clearResult(overrides: Partial<ElasticsearchDeleteByQueryResult> = {}): ElasticsearchDeleteByQueryResult {
|
||||
@@ -16,6 +16,12 @@ describe("Elasticsearch index actions", () => {
|
||||
expect(isElasticsearchProtocolIndex("elasticsearch-index", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("renders aliases on the same row as the index name", () => {
|
||||
expect(elasticsearchIndexAliasLabel({ searchAliases: ["orders-write", "orders-read"] })).toBe("orders-write, orders-read");
|
||||
expect(elasticsearchIndexAliasLabel({ searchAliases: ["", " "] })).toBeUndefined();
|
||||
expect(elasticsearchIndexAliasLabel({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("previews the exact request the clear action sends", () => {
|
||||
const preview = elasticsearchClearIndexPreview("logs-2026.08");
|
||||
expect(preview).toContain("POST /logs-2026.08/_delete_by_query?conflicts=proceed&refresh=true");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import type { DatabaseType, TreeNode } from "@/types/database";
|
||||
import type { ElasticsearchDeleteByQueryResult } from "@/lib/backend/tauri";
|
||||
|
||||
/**
|
||||
@@ -10,6 +10,11 @@ export function isElasticsearchProtocolIndex(nodeType: string, dbType: DatabaseT
|
||||
return nodeType === "elasticsearch-index" && (dbType === "elasticsearch" || dbType === "easysearch");
|
||||
}
|
||||
|
||||
export function elasticsearchIndexAliasLabel(node: Pick<TreeNode, "searchAliases">): string | undefined {
|
||||
const aliases = node.searchAliases?.filter((alias) => alias.trim()) ?? [];
|
||||
return aliases.length ? aliases.join(", ") : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* With an `index_grouping` regex configured, a sidebar index node is a collapsed
|
||||
* pattern (`logs-2026.08.*`) rather than one index, and clearing it hits every
|
||||
|
||||
+71
-13
@@ -45,13 +45,17 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
});
|
||||
|
||||
it("openElasticsearchConnectionTree only ensures connectivity, does not expand or list indices", async () => {
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["orders", "users"]);
|
||||
const documentListCollections = vi.fn().mockResolvedValue([
|
||||
{ name: "orders", id: "orders", kind: "index" },
|
||||
{ name: "users", id: "users", kind: "index" },
|
||||
]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
elasticsearchListIndices,
|
||||
documentListCollections,
|
||||
elasticsearchListIndices: vi.fn(),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -66,7 +70,7 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
|
||||
await store.openElasticsearchConnectionTree("es-1");
|
||||
|
||||
expect(elasticsearchListIndices).not.toHaveBeenCalled();
|
||||
expect(documentListCollections).not.toHaveBeenCalled();
|
||||
const node = store.treeNodes.find((n) => n.id === "es-1");
|
||||
// openElasticsearchConnectionTree does NOT expand the node
|
||||
expect(node?.isExpanded).toBe(false);
|
||||
@@ -74,13 +78,17 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
});
|
||||
|
||||
it("refreshTreeNode lists indices", async () => {
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["orders", "users"]);
|
||||
const documentListCollections = vi.fn().mockResolvedValue([
|
||||
{ name: "orders", id: "orders", kind: "index" },
|
||||
{ name: "users", id: "users", kind: "index" },
|
||||
]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
elasticsearchListIndices,
|
||||
documentListCollections,
|
||||
elasticsearchListIndices: vi.fn(),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -96,7 +104,7 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
|
||||
await store.refreshTreeNode(node);
|
||||
|
||||
expect(elasticsearchListIndices).toHaveBeenCalledWith("es-1");
|
||||
expect(documentListCollections).toHaveBeenCalledWith("es-1", "default");
|
||||
expect(
|
||||
node.children
|
||||
?.filter((c) => c.type === "elasticsearch-index")
|
||||
@@ -106,13 +114,17 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
});
|
||||
|
||||
it("loadElasticsearchIndices lists indices and expands", async () => {
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["orders", "users"]);
|
||||
const documentListCollections = vi.fn().mockResolvedValue([
|
||||
{ name: "orders", id: "orders", kind: "index" },
|
||||
{ name: "users", id: "users", kind: "index" },
|
||||
]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
elasticsearchListIndices,
|
||||
documentListCollections,
|
||||
elasticsearchListIndices: vi.fn(),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -127,7 +139,7 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
|
||||
await store.loadElasticsearchIndices("es-1");
|
||||
|
||||
expect(elasticsearchListIndices).toHaveBeenCalledWith("es-1");
|
||||
expect(documentListCollections).toHaveBeenCalledWith("es-1", "default");
|
||||
const node = store.treeNodes.find((n) => n.id === "es-1");
|
||||
expect(
|
||||
node?.children
|
||||
@@ -138,13 +150,17 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
});
|
||||
|
||||
it("loads Easysearch indices through the Elasticsearch-compatible tree", async () => {
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["orders", "users"]);
|
||||
const documentListCollections = vi.fn().mockResolvedValue([
|
||||
{ name: "orders", id: "orders", kind: "index" },
|
||||
{ name: "users", id: "users", kind: "index" },
|
||||
]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
elasticsearchListIndices,
|
||||
documentListCollections,
|
||||
elasticsearchListIndices: vi.fn(),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -159,16 +175,56 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
|
||||
await store.loadElasticsearchIndices("easysearch-1");
|
||||
|
||||
expect(elasticsearchListIndices).toHaveBeenCalledWith("easysearch-1");
|
||||
expect(documentListCollections).toHaveBeenCalledWith("easysearch-1", "default");
|
||||
expect(
|
||||
store.treeNodes
|
||||
.find((node) => node.id === "easysearch-1")
|
||||
?.children?.map((node) => node.label)
|
||||
?.children?.filter((node) => node.type === "elasticsearch-index")
|
||||
.map((node) => node.label)
|
||||
.sort(),
|
||||
).toEqual(["orders", "users"]);
|
||||
});
|
||||
|
||||
it("keeps aliases on the same index row instead of adding a second node", async () => {
|
||||
const documentListCollections = vi.fn().mockResolvedValue([
|
||||
{ name: "orders", id: "orders", aliases: ["orders-write"] },
|
||||
{ name: "users", id: "users", aliases: [] },
|
||||
]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
documentListCollections,
|
||||
elasticsearchListIndices: vi.fn(),
|
||||
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");
|
||||
|
||||
const nodes = store.treeNodes.find((node) => node.id === "es-1")?.children?.filter((node) => node.type === "elasticsearch-index");
|
||||
expect(
|
||||
nodes?.map((node) => ({
|
||||
label: node.label,
|
||||
searchAliases: node.searchAliases,
|
||||
})),
|
||||
).toEqual([
|
||||
{ label: "orders", searchAliases: ["orders-write"] },
|
||||
{ label: "users", searchAliases: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the shared Meilisearch index-list method for the Meilisearch tree", async () => {
|
||||
const documentListCollections = vi.fn().mockResolvedValue([{ name: "wrong-index", id: "wrong-index", kind: "index" }]);
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["wrong-index"]);
|
||||
const meilisearchListIndexes = vi.fn().mockResolvedValue(["movies", "books"]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -176,6 +232,7 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
documentListCollections,
|
||||
elasticsearchListIndices,
|
||||
meilisearchListIndexes,
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -193,6 +250,7 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
await store.loadElasticsearchIndices("meili-1");
|
||||
|
||||
expect(meilisearchListIndexes).toHaveBeenCalledWith("meili-1");
|
||||
expect(documentListCollections).not.toHaveBeenCalled();
|
||||
expect(elasticsearchListIndices).not.toHaveBeenCalled();
|
||||
expect(
|
||||
store.treeNodes
|
||||
|
||||
@@ -4989,7 +4989,21 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await ensureConnected(connectionId);
|
||||
load = reclaimTreeNodeLoad(load, node);
|
||||
const isMeilisearch = getConfig(connectionId)?.db_type === "meilisearch";
|
||||
const indices = await withMetadataLoadTimeout(connectionId, isMeilisearch ? api.meilisearchListIndexes(connectionId) : api.elasticsearchListIndices(connectionId), isMeilisearch ? "Meilisearch indexes" : "Elasticsearch indices");
|
||||
const collections = isMeilisearch
|
||||
? sortSidebarNames(await withMetadataLoadTimeout(connectionId, api.meilisearchListIndexes(connectionId), "Meilisearch indexes")).map((name) => ({ name, aliases: [] as string[] }))
|
||||
: [...(await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, "default"), "Elasticsearch indices"))].sort((left, right) => compareSidebarNames(left.name, right.name));
|
||||
const indexNodes = collections.map((collection) => {
|
||||
const aliases = collection.aliases?.filter((alias) => alias.trim());
|
||||
return {
|
||||
id: `${connectionId}:__collection:${collection.name}`,
|
||||
label: collection.name,
|
||||
type: "elasticsearch-index" as const,
|
||||
connectionId,
|
||||
database: "default",
|
||||
isExpanded: false,
|
||||
...(aliases?.length ? { searchAliases: aliases } : {}),
|
||||
};
|
||||
});
|
||||
const targetNode = treeNodeLoadTarget(load);
|
||||
if (!targetNode) return;
|
||||
setChildren(
|
||||
@@ -4997,14 +5011,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
withSavedSqlRoot(
|
||||
connectionId,
|
||||
[
|
||||
...sortSidebarNames(indices).map((index) => ({
|
||||
id: `${connectionId}:__collection:${index}`,
|
||||
label: index,
|
||||
type: "elasticsearch-index" as const,
|
||||
connectionId,
|
||||
database: "default",
|
||||
isExpanded: false,
|
||||
})),
|
||||
...indexNodes,
|
||||
...(isMeilisearch
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1562,4 +1562,5 @@ export interface CollectionInfo {
|
||||
milvusSchema?: MilvusCollectionSchema;
|
||||
kind?: MongoCollectionKind | "bucket";
|
||||
bucketName?: string;
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ pub async fn list_indices(client: &EasysearchClient) -> Result<Vec<String>, Stri
|
||||
elasticsearch_driver::list_indices(&client.inner).await.map_err(easysearch_error)
|
||||
}
|
||||
|
||||
pub async fn list_indices_with_aliases(
|
||||
client: &EasysearchClient,
|
||||
) -> Result<Vec<elasticsearch_driver::ElasticsearchIndexEntry>, String> {
|
||||
elasticsearch_driver::list_indices_with_aliases(&client.inner).await.map_err(easysearch_error)
|
||||
}
|
||||
|
||||
pub async fn get_columns(client: &EasysearchClient, index: &str) -> Result<Vec<ColumnInfo>, String> {
|
||||
elasticsearch_driver::get_columns(&client.inner, index).await.map_err(easysearch_error)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use regex::Regex;
|
||||
use reqwest::{Client as HttpClient, Method, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -489,6 +489,17 @@ fn format_reqwest_error(err: &reqwest::Error) -> String {
|
||||
parts.join(": ")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ElasticsearchIndexEntry {
|
||||
pub name: String,
|
||||
pub aliases: Vec<String>,
|
||||
}
|
||||
|
||||
struct ListedIndexNames {
|
||||
indices: Vec<String>,
|
||||
index_aliases: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CatIndex {
|
||||
index: String,
|
||||
@@ -499,6 +510,8 @@ struct ResolveIndexResponse {
|
||||
#[serde(default)]
|
||||
indices: Vec<ResolveNamed>,
|
||||
#[serde(default)]
|
||||
aliases: Vec<ResolveAlias>,
|
||||
#[serde(default)]
|
||||
data_streams: Vec<ResolveNamed>,
|
||||
}
|
||||
|
||||
@@ -507,20 +520,87 @@ struct ResolveNamed {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResolveAlias {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
indices: Vec<String>,
|
||||
}
|
||||
|
||||
/// 去掉 ES 内部索引(以 `.` 开头),排序并去重后返回可见索引名。
|
||||
fn normalize_index_names(names: impl Iterator<Item = String>) -> Vec<String> {
|
||||
let mut names: Vec<String> = names.filter(|name| !name.starts_with('.')).collect();
|
||||
let mut names: Vec<String> = names.filter(|name| !name.starts_with('.') && !name.is_empty()).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
pub async fn list_indices(client: &EsClient) -> Result<Vec<String>, String> {
|
||||
let names = list_raw_index_names(client).await?;
|
||||
let names = list_raw_indices(client, false).await?.indices;
|
||||
Ok(group_index_names(names, client.index_grouping.as_ref()))
|
||||
}
|
||||
|
||||
async fn list_raw_index_names(client: &EsClient) -> Result<Vec<String>, String> {
|
||||
pub async fn list_indices_with_aliases(client: &EsClient) -> Result<Vec<ElasticsearchIndexEntry>, String> {
|
||||
let listed = list_raw_indices(client, true).await?;
|
||||
Ok(merge_index_entries(listed.indices, &listed.index_aliases, client.index_grouping.as_ref()))
|
||||
}
|
||||
|
||||
fn merge_index_entries(
|
||||
indices: Vec<String>,
|
||||
index_aliases: &BTreeMap<String, Vec<String>>,
|
||||
grouping: Option<&Regex>,
|
||||
) -> Vec<ElasticsearchIndexEntry> {
|
||||
let Some(re) = grouping else {
|
||||
return indices
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let aliases = index_aliases.get(&name).into_iter().flatten().cloned();
|
||||
elasticsearch_index_entry(name, aliases)
|
||||
})
|
||||
.collect();
|
||||
};
|
||||
|
||||
let mut buckets: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for name in indices {
|
||||
let key = re.replace(&name, "${1}*").into_owned();
|
||||
buckets.entry(key).or_default().push(name);
|
||||
}
|
||||
buckets
|
||||
.into_iter()
|
||||
.map(|(name, members)| {
|
||||
let aliases = members.iter().flat_map(|member| index_aliases.get(member).into_iter().flatten().cloned());
|
||||
elasticsearch_index_entry(name, aliases)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn elasticsearch_index_entry(name: String, aliases: impl IntoIterator<Item = String>) -> ElasticsearchIndexEntry {
|
||||
let aliases = normalize_alias_names(aliases, &name);
|
||||
ElasticsearchIndexEntry { name, aliases }
|
||||
}
|
||||
|
||||
fn normalize_alias_names(aliases: impl IntoIterator<Item = String>, index_name: &str) -> Vec<String> {
|
||||
let mut aliases: Vec<String> = aliases
|
||||
.into_iter()
|
||||
.filter(|alias| !alias.is_empty() && !alias.starts_with('.') && alias != index_name)
|
||||
.collect();
|
||||
aliases.sort();
|
||||
aliases.dedup();
|
||||
aliases
|
||||
}
|
||||
|
||||
fn index_aliases_from_pairs(pairs: impl Iterator<Item = (String, String)>) -> BTreeMap<String, Vec<String>> {
|
||||
let mut map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for (index, alias) in pairs {
|
||||
if index.is_empty() || alias.is_empty() || alias.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
map.entry(index).or_default().insert(alias);
|
||||
}
|
||||
map.into_iter().map(|(index, aliases)| (index, aliases.into_iter().collect())).collect()
|
||||
}
|
||||
|
||||
async fn list_raw_indices(client: &EsClient, include_aliases: bool) -> Result<ListedIndexNames, String> {
|
||||
// 主路径 `_cat/indices` 需要集群级 `monitor` 权限。仅有索引级权限的账号
|
||||
// (例如日志采集用户)会在这里拿到 401/403,此时降级到索引级元数据端点。
|
||||
let resp = client
|
||||
@@ -531,7 +611,10 @@ async fn list_raw_index_names(client: &EsClient) -> Result<Vec<String>, String>
|
||||
let status = client.response_status(&resp);
|
||||
if status.is_success() {
|
||||
let indices: Vec<CatIndex> = resp.json().await.map_err(|e| format!("Elasticsearch parse error: {e}"))?;
|
||||
return Ok(normalize_index_names(indices.into_iter().map(|i| i.index)));
|
||||
return Ok(ListedIndexNames {
|
||||
indices: normalize_index_names(indices.into_iter().map(|i| i.index)),
|
||||
index_aliases: if include_aliases { alias_map_or_empty(client).await } else { BTreeMap::new() },
|
||||
});
|
||||
}
|
||||
if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
|
||||
return list_indices_via_metadata(client).await;
|
||||
@@ -543,18 +626,24 @@ async fn list_raw_index_names(client: &EsClient) -> Result<Vec<String>, String>
|
||||
/// 集群 `monitor` 不可用时的降级:`_resolve/index` 与 `_alias` 属于
|
||||
/// `indices:admin/*` 动作,`view_index_metadata`/`read` 索引权限即可访问,
|
||||
/// 且 ES 安全层会把结果过滤为当前账号可见的索引。
|
||||
async fn list_indices_via_metadata(client: &EsClient) -> Result<Vec<String>, String> {
|
||||
// 优先 `_resolve/index`:同时覆盖普通索引与数据流(data stream)。
|
||||
if let Some(names) = resolve_index_names(client).await? {
|
||||
return Ok(names);
|
||||
async fn list_indices_via_metadata(client: &EsClient) -> Result<ListedIndexNames, String> {
|
||||
// 优先 `_resolve/index`:同时覆盖普通索引、数据流(data stream)和别名。
|
||||
if let Some(listed) = resolve_index_entries(client).await? {
|
||||
return Ok(listed);
|
||||
}
|
||||
// 再退回 `_alias`:以对象 key 形式返回具体索引名。
|
||||
alias_index_names(client).await
|
||||
alias_endpoint_entries(client).await
|
||||
}
|
||||
|
||||
/// 通过 `GET /_resolve/index/*` 列举索引。该端点缺权限时返回 `Ok(None)`,
|
||||
async fn alias_map_or_empty(client: &EsClient) -> BTreeMap<String, Vec<String>> {
|
||||
match alias_endpoint_entries(client).await {
|
||||
Ok(listed) => listed.index_aliases,
|
||||
Err(_) => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 `GET /_resolve/index/*` 列举索引与别名。该端点缺权限时返回 `Ok(None)`,
|
||||
/// 以便继续尝试 `_alias`;其它错误如实上抛。
|
||||
async fn resolve_index_names(client: &EsClient) -> Result<Option<Vec<String>>, String> {
|
||||
async fn resolve_index_entries(client: &EsClient) -> Result<Option<ListedIndexNames>, String> {
|
||||
let resp =
|
||||
client.get("/_resolve/index/*").send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
|
||||
let status = client.response_status(&resp);
|
||||
@@ -566,12 +655,17 @@ async fn resolve_index_names(client: &EsClient) -> Result<Option<Vec<String>>, S
|
||||
return Err(format!("Elasticsearch error: {body}"));
|
||||
}
|
||||
let body: ResolveIndexResponse = resp.json().await.map_err(|e| format!("Elasticsearch parse error: {e}"))?;
|
||||
let names = body.indices.into_iter().chain(body.data_streams).map(|item| item.name);
|
||||
Ok(Some(normalize_index_names(names)))
|
||||
let indices = body.indices.into_iter().chain(body.data_streams).map(|item| item.name);
|
||||
let index_aliases = index_aliases_from_pairs(
|
||||
body.aliases
|
||||
.into_iter()
|
||||
.flat_map(|alias| alias.indices.into_iter().map(move |index| (index, alias.name.clone()))),
|
||||
);
|
||||
Ok(Some(ListedIndexNames { indices: normalize_index_names(indices), index_aliases }))
|
||||
}
|
||||
|
||||
/// 通过 `GET /_alias` 列举索引(对象 key 即索引名)。
|
||||
async fn alias_index_names(client: &EsClient) -> Result<Vec<String>, String> {
|
||||
/// 通过 `GET /_alias` 列举索引(对象 key)和嵌套别名。
|
||||
async fn alias_endpoint_entries(client: &EsClient) -> Result<ListedIndexNames, String> {
|
||||
let resp = client.get("/_alias").send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
|
||||
if !client.response_status(&resp).is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
@@ -579,7 +673,18 @@ async fn alias_index_names(client: &EsClient) -> Result<Vec<String>, String> {
|
||||
}
|
||||
let body: serde_json::Map<String, Value> =
|
||||
resp.json().await.map_err(|e| format!("Elasticsearch parse error: {e}"))?;
|
||||
Ok(normalize_index_names(body.into_iter().map(|(name, _)| name)))
|
||||
let mut indices = Vec::new();
|
||||
let mut pairs = Vec::new();
|
||||
for (index, value) in body {
|
||||
if let Some(alias_map) = value.get("aliases").and_then(Value::as_object) {
|
||||
pairs.extend(alias_map.keys().cloned().map(|alias| (index.clone(), alias)));
|
||||
}
|
||||
indices.push(index);
|
||||
}
|
||||
Ok(ListedIndexNames {
|
||||
indices: normalize_index_names(indices.into_iter()),
|
||||
index_aliases: index_aliases_from_pairs(pairs.into_iter()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_columns(client: &EsClient, index: &str) -> Result<Vec<crate::db::ColumnInfo>, String> {
|
||||
@@ -3020,8 +3125,8 @@ fn parse_aggregations(aggs: &serde_json::Map<String, serde_json::Value>) -> (Vec
|
||||
mod tests {
|
||||
use super::{
|
||||
build_count_documents_body, build_find_documents_body, elasticsearch_accept_invalid_certs,
|
||||
elasticsearch_base_url_fallbacks, elasticsearch_index_grouping, group_index_names, normalize_index_names,
|
||||
redact_elasticsearch_url, EsClient, SearchResponse,
|
||||
elasticsearch_base_url_fallbacks, elasticsearch_index_grouping, group_index_names, merge_index_entries,
|
||||
normalize_index_names, redact_elasticsearch_url, ElasticsearchIndexEntry, EsClient, SearchResponse,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
@@ -3170,6 +3275,159 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn entry(name: &str, aliases: &[&str]) -> ElasticsearchIndexEntry {
|
||||
ElasticsearchIndexEntry {
|
||||
name: name.to_string(),
|
||||
aliases: aliases.iter().map(|alias| alias.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn alias_map(pairs: &[(&str, &[&str])]) -> std::collections::BTreeMap<String, Vec<String>> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(index, aliases)| (index.to_string(), aliases.iter().map(|alias| alias.to_string()).collect()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_index_entries_attaches_aliases_to_the_same_index_row() {
|
||||
let entries = merge_index_entries(
|
||||
vec!["orders".to_string(), "users".to_string()],
|
||||
&alias_map(&[("orders", &["orders-write", "orders"]), ("users", &[".hidden"])]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(entries, vec![entry("orders", &["orders-write"]), entry("users", &[])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_index_entries_collects_grouped_index_aliases_onto_the_pattern_row() {
|
||||
let cfg = serde_json::json!({ "indexGroupingPattern": r"[-_.@]\d{4}[-_.]?\d{2}[-_.]?\d{2}.*$" });
|
||||
let re = elasticsearch_index_grouping(Some(&cfg));
|
||||
let entries = merge_index_entries(
|
||||
vec!["logs-2026.08.06".to_string(), "logs-2026.08.07".to_string()],
|
||||
&alias_map(&[("logs-2026.08.06", &["logs"]), ("logs-2026.08.07", &["logs", "logs-write"])]),
|
||||
re.as_ref(),
|
||||
);
|
||||
assert_eq!(entries, vec![entry("logs*", &["logs", "logs-write"])]);
|
||||
}
|
||||
|
||||
async fn write_json_http_response(socket: &mut tokio::net::TcpStream, status: u16, body: &str) {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
401 => "Unauthorized",
|
||||
403 => "Forbidden",
|
||||
_ => "Error",
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
async fn serve_elasticsearch_json_routes(
|
||||
listener: tokio::net::TcpListener,
|
||||
routes: Vec<(&'static str, u16, &'static str)>,
|
||||
) {
|
||||
for _ in 0..routes.len() {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let request = read_http_request(&mut socket).await;
|
||||
let (status, body) = routes
|
||||
.iter()
|
||||
.find(|(prefix, _, _)| {
|
||||
let encoded = prefix.replace('*', "%2A");
|
||||
request.starts_with(&format!("GET {prefix}")) || request.starts_with(&format!("GET {encoded}"))
|
||||
})
|
||||
.map(|(_, status, body)| (*status, *body))
|
||||
.unwrap_or((404, "{}"));
|
||||
write_json_http_response(&mut socket, status, body).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_indices_lists_aliases_from_existing_alias_endpoint() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(serve_elasticsearch_json_routes(
|
||||
listener,
|
||||
vec![
|
||||
("/_cat/indices", 200, r#"[{"index":"orders"},{"index":".security"}]"#),
|
||||
(
|
||||
"/_alias",
|
||||
200,
|
||||
r#"{"orders":{"aliases":{"orders-write":{},".kibana":{}}},"hidden":{"aliases":{"orders-write":{}}}}"#,
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(2));
|
||||
let entries = super::list_indices_with_aliases(&client).await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(entries, vec![entry("orders", &["orders-write"])]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_indices_keeps_indices_when_alias_endpoint_is_forbidden() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(serve_elasticsearch_json_routes(
|
||||
listener,
|
||||
vec![("/_cat/indices", 200, r#"[{"index":"orders"}]"#), ("/_alias", 403, r#"{"error":"forbidden"}"#)],
|
||||
));
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(2));
|
||||
let entries = super::list_indices_with_aliases(&client).await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(entries, vec![entry("orders", &[])]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_indices_reads_aliases_from_resolve_index_fallback() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(serve_elasticsearch_json_routes(
|
||||
listener,
|
||||
vec![
|
||||
("/_cat/indices", 403, r#"{"error":"forbidden"}"#),
|
||||
(
|
||||
"/_resolve/index/*",
|
||||
200,
|
||||
r#"{"indices":[{"name":"orders"}],"aliases":[{"name":"orders-write","indices":["orders"]}],"data_streams":[{"name":"logs"}]}"#,
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(2));
|
||||
let entries = super::list_indices_with_aliases(&client).await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(entries, vec![entry("logs", &[]), entry("orders", &["orders-write"])]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_indices_reads_nested_aliases_from_alias_endpoint_fallback() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(serve_elasticsearch_json_routes(
|
||||
listener,
|
||||
vec![
|
||||
("/_cat/indices", 403, r#"{"error":"forbidden"}"#),
|
||||
("/_resolve/index/*", 403, r#"{"error":"forbidden"}"#),
|
||||
("/_alias", 200, r#"{"orders":{"aliases":{"orders-write":{}}},"users":{"aliases":{}}}"#),
|
||||
],
|
||||
));
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(2));
|
||||
let entries = super::list_indices_with_aliases(&client).await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(entries, vec![entry("orders", &["orders-write"]), entry("users", &[])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rest_request_after_multiline_block_comment() {
|
||||
let request = super::parse_elasticsearch_rest_request(
|
||||
|
||||
@@ -62,6 +62,8 @@ pub struct CollectionInfo {
|
||||
pub milvus_schema: Option<MilvusCollectionSchema>,
|
||||
pub kind: Option<String>,
|
||||
pub bucket_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub aliases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -47,6 +47,21 @@ fn sort_names(mut names: Vec<String>) -> Vec<String> {
|
||||
names
|
||||
}
|
||||
|
||||
fn elasticsearch_collection_infos(
|
||||
mut entries: Vec<elasticsearch_driver::ElasticsearchIndexEntry>,
|
||||
) -> Vec<CollectionInfo> {
|
||||
entries.sort_by(|left, right| cmp_names(&left.name, &right.name));
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|entry| CollectionInfo {
|
||||
name: entry.name.clone(),
|
||||
id: entry.name,
|
||||
aliases: entry.aliases,
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn ensure_document_pool(state: &AppState, connection_id: &str) -> Result<(), String> {
|
||||
state.get_or_create_pool(connection_id, None).await.map(|_| ())
|
||||
}
|
||||
@@ -270,12 +285,10 @@ pub async fn list_collections_core(
|
||||
.collect())
|
||||
}
|
||||
PoolKind::Elasticsearch(client) => {
|
||||
let names = sort_names(elasticsearch_driver::list_indices(client).await?);
|
||||
Ok(names.into_iter().map(|n| CollectionInfo { name: n.clone(), id: n, ..Default::default() }).collect())
|
||||
Ok(elasticsearch_collection_infos(elasticsearch_driver::list_indices_with_aliases(client).await?))
|
||||
}
|
||||
PoolKind::Easysearch(client) => {
|
||||
let names = sort_names(easysearch_driver::list_indices(client).await?);
|
||||
Ok(names.into_iter().map(|n| CollectionInfo { name: n.clone(), id: n, ..Default::default() }).collect())
|
||||
Ok(elasticsearch_collection_infos(easysearch_driver::list_indices_with_aliases(client).await?))
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let names = sort_names(crate::db::meilisearch_driver::list_indexes(client).await?);
|
||||
|
||||
Reference in New Issue
Block a user