From be055a60cc5d0458a2b8b9938565d55a52d8cf90 Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 21 Sep 2026 18:34:28 +0800 Subject: [PATCH] fix(meilisearch): show per-index size in the index sidebar --- .../meilisearch/MeilisearchIndexView.vue | 13 ++- .../__tests__/MeilisearchIndexView.spec.ts | 107 ++++++++++++++++++ apps/desktop/src/i18n/locales/az.ts | 3 + apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/tr.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + apps/desktop/src/lib/backend/tauri.ts | 5 + .../dbx-drivers/src/db/meilisearch_driver.rs | 102 ++++++++++++++--- 14 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/components/meilisearch/__tests__/MeilisearchIndexView.spec.ts diff --git a/apps/desktop/src/components/meilisearch/MeilisearchIndexView.vue b/apps/desktop/src/components/meilisearch/MeilisearchIndexView.vue index 013d83cd03..df6f2a3ead 100644 --- a/apps/desktop/src/components/meilisearch/MeilisearchIndexView.vue +++ b/apps/desktop/src/components/meilisearch/MeilisearchIndexView.vue @@ -100,8 +100,17 @@ onMounted(() => {
{{ t("meilisearch.documentCountLabel") }}
{{ overview ? overview.numberOfDocuments : "-" }}
-
-
{{ t("meilisearch.databaseSize") }}
+
+
{{ t("meilisearch.documentSize") }}
+
{{ formatBytes(overview.documentSize) }}
+
+
+
{{ t("meilisearch.avgDocumentSize") }}
+
{{ formatBytes(overview.avgDocumentSize) }}
+
+ +
+
{{ t("meilisearch.instanceDatabaseSize") }}
{{ formatBytes(overview.databaseSize) }}
diff --git a/apps/desktop/src/components/meilisearch/__tests__/MeilisearchIndexView.spec.ts b/apps/desktop/src/components/meilisearch/__tests__/MeilisearchIndexView.spec.ts new file mode 100644 index 0000000000..56f591f761 --- /dev/null +++ b/apps/desktop/src/components/meilisearch/__tests__/MeilisearchIndexView.spec.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, ref, type Component } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ getIndexOverview: vi.fn() })); + +function passthrough(tag: string): Component { + return defineComponent({ + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h(tag, attrs, slots.default?.()); + }, + }); +} + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ locale: ref("zh-CN"), t: (key: string) => key }), +})); +vi.mock("@lucide/vue", () => ({ + Copy: passthrough("span"), + FileText: passthrough("span"), + ListChecks: passthrough("span"), + Settings: passthrough("span"), +})); +vi.mock("@/lib/backend/api", () => ({ meilisearchGetIndexOverview: mocks.getIndexOverview })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +vi.mock("@/lib/tabs/tabUiState", () => ({ useTabUiState: () => ({ initialState: {}, track: () => {} }) })); +vi.mock("@/components/meilisearch/MeilisearchDocumentsPage.vue", () => ({ default: passthrough("div") })); +vi.mock("@/components/meilisearch/MeilisearchSettingsPage.vue", () => ({ default: passthrough("div") })); +vi.mock("@/components/meilisearch/MeilisearchTasksPage.vue", () => ({ default: passthrough("div") })); + +import MeilisearchIndexView from "@/components/meilisearch/MeilisearchIndexView.vue"; + +let app: ReturnType | undefined; +let root: HTMLDivElement | undefined; + +async function mountIndexView() { + root = document.createElement("div"); + document.body.append(root); + app = createApp(MeilisearchIndexView, { connectionId: "c1", index: "report_template" }); + app.mount(root); + await vi.waitFor(() => expect(mocks.getIndexOverview).toHaveBeenCalledTimes(1)); + await nextTick(); + return root; +} + +beforeEach(() => { + mocks.getIndexOverview.mockReset(); +}); + +afterEach(() => { + app?.unmount(); + root?.remove(); + app = undefined; + root = undefined; + vi.clearAllMocks(); +}); + +describe("MeilisearchIndexView storage rows", () => { + // Meilisearch >= 1.14 reports the index's own sizes, so the sidebar must show + // them. Reporting the instance-wide `databaseSize` here made every index look + // like it owned the whole instance (issue #9880). + it("shows the index's own size instead of the instance-wide number", async () => { + mocks.getIndexOverview.mockResolvedValue({ + uid: "report_template", + primaryKey: "id", + createdAt: "2026-09-21T06:30:00Z", + updatedAt: "2026-09-21T06:30:46Z", + numberOfDocuments: 256, + isIndexing: false, + documentSize: 309_329_920, + avgDocumentSize: 1_208_320, + databaseSize: 4_194_304_000, + }); + + const mounted = await mountIndexView(); + const text = mounted.textContent ?? ""; + + expect(text).toContain("meilisearch.documentSize"); + expect(text).toContain("meilisearch.avgDocumentSize"); + expect(text).not.toContain("meilisearch.instanceDatabaseSize"); + expect(text).toContain("295.0 MB"); + expect(text).not.toContain("3.9 GB"); + }); + + it("labels the instance-wide size as such on servers without per-index sizes", async () => { + mocks.getIndexOverview.mockResolvedValue({ + uid: "report_template", + primaryKey: "id", + createdAt: null, + updatedAt: null, + numberOfDocuments: 256, + isIndexing: false, + documentSize: null, + avgDocumentSize: null, + databaseSize: 26_935_296, + }); + + const mounted = await mountIndexView(); + const text = mounted.textContent ?? ""; + + expect(text).toContain("meilisearch.instanceDatabaseSize"); + expect(text).not.toContain("meilisearch.documentSize"); + expect(text).toContain("25.7 MB"); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 652f108743..b6e9f9a929 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -5610,6 +5610,9 @@ export default withEnglishFallback({ primaryKey: "Əsas açar", documentCountLabel: "Sənədlər", databaseSize: "Verilənlər bazasının ölçüsü", + documentSize: "Sənəd ölçüsü", + avgDocumentSize: "Orta sənəd ölçüsü", + instanceDatabaseSize: "İnstansiya verilənlər bazasının ölçüsü", copyDocument: "JSON-u kopyala", copied: "Kopyalandı", autoRefresh: "Avtomatik yeniləmə", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 2ddfc9f260..294f3c660c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6029,6 +6029,9 @@ export default { primaryKey: "Primary key", documentCountLabel: "Documents", databaseSize: "Database size", + documentSize: "Document size", + avgDocumentSize: "Average document size", + instanceDatabaseSize: "Instance database size", copyDocument: "Copy JSON", copied: "Copied", autoRefresh: "Auto refresh", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f5da14c118..f3bcf9800c 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5571,6 +5571,9 @@ export default withEnglishFallback({ primaryKey: "Clave primaria", documentCountLabel: "Documentos", databaseSize: "Tamaño de la base de datos", + documentSize: "Tamaño del documento", + avgDocumentSize: "Tamaño medio del documento", + instanceDatabaseSize: "Tamaño de la base de datos de la instancia", copyDocument: "Copiar JSON", copied: "Copiado", autoRefresh: "Actualización automática", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 369d28d0ae..3a45613782 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5568,6 +5568,9 @@ export default withEnglishFallback({ primaryKey: "Chiave primaria", documentCountLabel: "Documenti", databaseSize: "Dimensione del database", + documentSize: "Dimensione del documento", + avgDocumentSize: "Dimensione media del documento", + instanceDatabaseSize: "Dimensione del database dell'istanza", copyDocument: "Copia JSON", copied: "Copiato", autoRefresh: "Aggiornamento automatico", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a016314fa9..dfb3b2451d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5596,6 +5596,9 @@ export default withEnglishFallback({ primaryKey: "主キー", documentCountLabel: "ドキュメント数", databaseSize: "データベースサイズ", + documentSize: "ドキュメントサイズ", + avgDocumentSize: "平均ドキュメントサイズ", + instanceDatabaseSize: "インスタンスのデータベースサイズ", copyDocument: "JSON をコピー", copied: "コピーしました", autoRefresh: "自動更新", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 714721e356..1e15bf7a05 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5215,6 +5215,9 @@ export default withEnglishFallback({ primaryKey: "기본 키", documentCountLabel: "문서 수", databaseSize: "데이터베이스 크기", + documentSize: "문서 크기", + avgDocumentSize: "평균 문서 크기", + instanceDatabaseSize: "인스턴스 데이터베이스 크기", copyDocument: "JSON 복사", copied: "복사됨", autoRefresh: "자동 새로고침", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index dbc14da35a..624661440d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5570,6 +5570,9 @@ export default withEnglishFallback({ primaryKey: "Chave primária", documentCountLabel: "Documentos", databaseSize: "Tamanho do banco de dados", + documentSize: "Tamanho do documento", + avgDocumentSize: "Tamanho médio do documento", + instanceDatabaseSize: "Tamanho do banco de dados da instância", copyDocument: "Copiar JSON", copied: "Copiado", autoRefresh: "Atualização automática", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index d20465e484..e502af9afa 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -5504,6 +5504,9 @@ export default withEnglishFallback({ primaryKey: "Birincil anahtar", documentCountLabel: "Belgeler", databaseSize: "Veritabanı boyutu", + documentSize: "Belge boyutu", + avgDocumentSize: "Ortalama belge boyutu", + instanceDatabaseSize: "Örnek veritabanı boyutu", copyDocument: "JSON Kopyala", copied: "Kopyalandı", autoRefresh: "Otomatik yenile", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index cb4ee58d49..9a33737e8d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6002,6 +6002,9 @@ export default withEnglishFallback({ primaryKey: "主键", documentCountLabel: "文档数", databaseSize: "数据库大小", + documentSize: "文档大小", + avgDocumentSize: "平均文档大小", + instanceDatabaseSize: "实例数据库大小", copyDocument: "复制 JSON", copied: "已复制", autoRefresh: "自动刷新", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index f26c554b64..974982a7fb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4885,6 +4885,9 @@ export default withEnglishFallback({ primaryKey: "主鍵", documentCountLabel: "文件數", databaseSize: "資料庫大小", + documentSize: "文件大小", + avgDocumentSize: "平均文件大小", + instanceDatabaseSize: "實例資料庫大小", copyDocument: "複製 JSON", copied: "已複製", autoRefresh: "自動重新整理", diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 76e639c572..0606ebf22c 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -4739,6 +4739,11 @@ export interface MeilisearchIndexOverview { updatedAt: string | null; numberOfDocuments: number; isIndexing: boolean; + /** Raw document store size of this index (Meilisearch >= 1.14); null on older servers. */ + documentSize: number | null; + /** Average document size of this index (Meilisearch >= 1.14); null on older servers. */ + avgDocumentSize: number | null; + /** Instance-wide database size; every index shares it, so it is only a fallback. */ databaseSize: number | null; } diff --git a/crates/dbx-drivers/src/db/meilisearch_driver.rs b/crates/dbx-drivers/src/db/meilisearch_driver.rs index 9e17e94f8f..d0b99add62 100644 --- a/crates/dbx-drivers/src/db/meilisearch_driver.rs +++ b/crates/dbx-drivers/src/db/meilisearch_driver.rs @@ -1093,10 +1093,39 @@ pub struct MeilisearchIndexOverview { pub updated_at: Option, pub number_of_documents: u64, pub is_indexing: bool, + /// Raw document store size of *this* index in bytes (`rawDocumentDbSize`, + /// Meilisearch >= 1.14). `None` on servers that do not report per-index sizes. + pub document_size: Option, + /// Average size of one document in this index in bytes (`avgDocumentSize`, + /// Meilisearch >= 1.14). `None` on older servers. + pub avg_document_size: Option, /// Instance-wide database size in bytes; `None` when the API key cannot read instance stats. + /// It counts every index of the instance, so the UI only uses it as a fallback. pub database_size: Option, } +/// Builds the index overview from the three responses the tab needs. Kept pure +/// so the per-index size fields (Meilisearch >= 1.14) and the older-server +/// fallback can be covered without a live server. +fn parse_index_overview( + index: &str, + info: &Value, + stats: &Value, + instance_stats: Option<&Value>, +) -> MeilisearchIndexOverview { + MeilisearchIndexOverview { + uid: info.get("uid").and_then(Value::as_str).unwrap_or(index).to_string(), + primary_key: info.get("primaryKey").and_then(Value::as_str).map(str::to_string), + created_at: info.get("createdAt").and_then(Value::as_str).map(str::to_string), + updated_at: info.get("updatedAt").and_then(Value::as_str).map(str::to_string), + number_of_documents: stats.get("numberOfDocuments").and_then(Value::as_u64).unwrap_or(0), + is_indexing: stats.get("isIndexing").and_then(Value::as_bool).unwrap_or(false), + document_size: stats.get("rawDocumentDbSize").and_then(Value::as_u64), + avg_document_size: stats.get("avgDocumentSize").and_then(Value::as_u64), + database_size: instance_stats.and_then(|value| value.get("databaseSize")).and_then(Value::as_u64), + } +} + pub async fn get_index_overview(client: &MeilisearchClient, index: &str) -> Result { let info = response_json( client @@ -1109,22 +1138,11 @@ pub async fn get_index_overview(client: &MeilisearchClient, index: &str) -> Resu .await?; let stats = get_index_stats(client, index).await?; // Instance stats require broader key permissions, so keep them best-effort. - let database_size = match client.get("/stats").send().await { - Ok(response) => response_json(response, "instance stats lookup") - .await - .ok() - .and_then(|value| value.get("databaseSize").and_then(Value::as_u64)), + let instance_stats = match client.get("/stats").send().await { + Ok(response) => response_json(response, "instance stats lookup").await.ok(), Err(_) => None, }; - Ok(MeilisearchIndexOverview { - uid: info.get("uid").and_then(Value::as_str).unwrap_or(index).to_string(), - primary_key: info.get("primaryKey").and_then(Value::as_str).map(str::to_string), - created_at: info.get("createdAt").and_then(Value::as_str).map(str::to_string), - updated_at: info.get("updatedAt").and_then(Value::as_str).map(str::to_string), - number_of_documents: stats.get("numberOfDocuments").and_then(Value::as_u64).unwrap_or(0), - is_indexing: stats.get("isIndexing").and_then(Value::as_bool).unwrap_or(false), - database_size, - }) + Ok(parse_index_overview(index, &info, &stats, instance_stats.as_ref())) } pub async fn delete_index(client: &MeilisearchClient, index: &str) -> Result<(), String> { @@ -1904,8 +1922,9 @@ mod tests { use super::{ decode_bounded_rest_body, decoded_identity, ensure_task_index, filter_expression, identity_path, meilisearch_base_url, meilisearch_filter_from_request, meilisearch_search_body, meilisearch_sort_from_request, - parse_key, parse_meilisearch_search_response, parse_rest_request_line, parse_system_stats, task_selector_query, - MeilisearchClient, MeilisearchOverviewSection, MeilisearchTask, MeilisearchTaskSelector, + parse_index_overview, parse_key, parse_meilisearch_search_response, parse_rest_request_line, + parse_system_stats, task_selector_query, MeilisearchClient, MeilisearchOverviewSection, MeilisearchTask, + MeilisearchTaskSelector, }; use reqwest::Method; use serde_json::{json, Value}; @@ -2037,6 +2056,57 @@ mod tests { assert!(indexless.contains("not associated with an index")); } + #[test] + fn index_overview_prefers_the_per_index_size_when_the_server_reports_one() { + // Meilisearch >= 1.14: /indexes/{uid}/stats carries the index's own sizes. + let overview = parse_index_overview( + "movies", + &json!({ "uid": "movies", "primaryKey": "id", "createdAt": "2026-08-01T00:00:00Z" }), + &json!({ + "numberOfDocuments": 256, + "indexSize": 90_000_000, + "rawDocumentDbSize": 26_935_296, + "avgDocumentSize": 105_216, + "isIndexing": false + }), + Some(&json!({ "databaseSize": 999_999_999 })), + ); + + assert_eq!(overview.document_size, Some(26_935_296)); + assert_eq!(overview.avg_document_size, Some(105_216)); + // The instance-wide number stays available, but the UI must not present it as this index's size. + assert_eq!(overview.database_size, Some(999_999_999)); + assert_eq!(overview.number_of_documents, 256); + } + + #[test] + fn index_overview_leaves_per_index_sizes_unset_on_older_servers() { + // Meilisearch <= 1.13 answers index stats without rawDocumentDbSize / avgDocumentSize. + let overview = parse_index_overview( + "movies", + &json!({ "uid": "movies" }), + &json!({ "numberOfDocuments": 12, "isIndexing": true, "fieldDistribution": { "id": 12 } }), + Some(&json!({ "databaseSize": 4096 })), + ); + + assert_eq!(overview.document_size, None); + assert_eq!(overview.avg_document_size, None); + assert_eq!(overview.database_size, Some(4096)); + assert_eq!(overview.number_of_documents, 12); + assert!(overview.is_indexing); + } + + #[test] + fn index_overview_survives_a_forbidden_instance_stats_lookup() { + let overview = parse_index_overview("movies", &json!({}), &json!({ "numberOfDocuments": 1 }), None); + + assert_eq!(overview.database_size, None); + assert_eq!(overview.document_size, None); + assert_eq!(overview.uid, "movies"); + assert_eq!(overview.number_of_documents, 1); + assert!(!overview.is_indexing); + } + #[test] fn system_stats_compute_totals_and_top_ten_deterministically() { let indexes = (0..12)