feat(elasticsearch): show index aliases on sidebar rows

This commit is contained in:
onenewcode
2026-09-11 13:24:41 +08:00
committed by GitHub
parent 6299d62125
commit 61bd3cdf62
22 changed files with 432 additions and 51 deletions
@@ -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"
+1
View File
@@ -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",
+1
View File
@@ -3636,6 +3636,7 @@ export default {
},
linkedServers: "Linked Servers",
defaultDatabase: "Default DB",
elasticsearchAlias: "Alias",
columns: "Columns",
attributes: "Attributes",
methods: "Methods",
+1
View File
@@ -3518,6 +3518,7 @@ export default withEnglishFallback({
},
linkedServers: "Servidores vinculados",
defaultDatabase: "Base predeterminada",
elasticsearchAlias: "Alias",
columns: "Columnas",
attributes: "Atributos",
methods: "Métodos",
+1
View File
@@ -3516,6 +3516,7 @@ export default withEnglishFallback({
},
linkedServers: "Server Collegati",
defaultDatabase: "DB Predefinito",
elasticsearchAlias: "Alias",
columns: "Colonne",
attributes: "Attributi",
methods: "Metodi",
+1
View File
@@ -3541,6 +3541,7 @@ export default withEnglishFallback({
no: "いいえ",
},
defaultDatabase: "デフォルトDB",
elasticsearchAlias: "エイリアス",
columns: "列",
attributes: "属性",
methods: "メソッド",
+1
View File
@@ -3395,6 +3395,7 @@ export default withEnglishFallback({
},
linkedServers: "연결된 서버",
defaultDatabase: "기본 DB",
elasticsearchAlias: "별칭",
columns: "컬럼",
attributes: "속성",
methods: "메서드",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -3557,6 +3557,7 @@ export default withEnglishFallback({
},
linkedServers: "链接服务器",
defaultDatabase: "默认库",
elasticsearchAlias: "别名",
columns: "字段",
attributes: "属性",
methods: "方法",
+1
View File
@@ -3516,6 +3516,7 @@ export default withEnglishFallback({
linkedServers: "連結伺服器",
materializedViews: "具體化檢視",
defaultDatabase: "預設庫",
elasticsearchAlias: "別名",
columns: "欄位",
attributes: "屬性",
methods: "方法",
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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
@@ -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
+16 -9
View File
@@ -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
? [
{
+1
View File
@@ -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)
}
+278 -20
View File
@@ -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(
+2
View File
@@ -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)]
+17 -4
View File
@@ -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?);