feat(meilisearch): add workspace, key, and task management

This commit is contained in:
二丫讲梵
2026-08-23 02:53:52 +08:00
committed by GitHub
parent 93dc455c72
commit e55ee6054d
48 changed files with 3984 additions and 18 deletions
@@ -94,6 +94,7 @@ const ConsulOverview = defineAsyncComponent(() => import("@/components/consul/Co
const ConsulWorkspace = defineAsyncComponent(() => import("@/components/consul/ConsulWorkspace.vue"));
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
const MeilisearchIndexView = defineAsyncComponent(() => import("@/components/meilisearch/MeilisearchIndexView.vue"));
const MeilisearchSystemWorkspace = defineAsyncComponent(() => import("@/components/meilisearch/MeilisearchSystemWorkspace.vue"));
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
@@ -2233,6 +2234,12 @@ defineExpose({
</div>
</template>
<template v-else-if="activeTab.mode === 'meilisearch-system'">
<div class="flex-1 min-h-0">
<MeilisearchSystemWorkspace :key="activeTab.id" :connection-id="activeTab.connectionId" />
</div>
</template>
<template v-else-if="activeTab.mode === 'mongo-gridfs'">
<div class="flex-1 min-h-0">
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" />
@@ -10,13 +10,14 @@ import "dayjs/locale/it";
import "dayjs/locale/ja";
import "dayjs/locale/ko";
import "dayjs/locale/pt-br";
import { Copy, FileText, Settings } from "@lucide/vue";
import { Copy, FileText, ListChecks, Settings } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import type { MeilisearchIndexOverview } from "@/lib/backend/tauri";
import { formatBytes } from "@/lib/database/serverMetrics";
import { useToast } from "@/composables/useToast";
import MeilisearchDocumentsPage from "./MeilisearchDocumentsPage.vue";
import MeilisearchSettingsPage from "./MeilisearchSettingsPage.vue";
import MeilisearchTasksPage from "./MeilisearchTasksPage.vue";
dayjs.extend(relativeTime);
@@ -35,7 +36,7 @@ const props = defineProps<{
index: string;
}>();
type ActiveSection = "documents" | "settings";
type ActiveSection = "documents" | "tasks" | "settings";
const { t, locale } = useI18n();
const { toast } = useToast();
@@ -45,6 +46,7 @@ const overview = ref<MeilisearchIndexOverview | null>(null);
const navSections = computed<Array<{ value: ActiveSection; label: string; icon: typeof FileText }>>(() => [
{ value: "documents", label: t("meilisearch.documents"), icon: FileText },
{ value: "tasks", label: t("meilisearch.tasks"), icon: ListChecks },
{ value: "settings", label: t("meilisearch.settings"), icon: Settings },
]);
@@ -139,6 +141,7 @@ onMounted(() => {
<!-- Content -->
<div class="flex-1 min-h-0 min-w-0 overflow-hidden">
<MeilisearchDocumentsPage v-if="activeSection === 'documents'" :connection-id="connectionId" :index="index" @refresh-stats="refreshStats" />
<MeilisearchTasksPage v-else-if="activeSection === 'tasks'" :connection-id="connectionId" :fixed-index-uid="index" />
<MeilisearchSettingsPage v-else :connection-id="connectionId" :index="index" @refresh-stats="refreshStats" />
</div>
</div>
@@ -0,0 +1,488 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import type { CalendarDateTime } from "@internationalized/date";
import { useI18n } from "vue-i18n";
import { AlertTriangle, CalendarClock, Copy, Eye, Loader2, Pencil, Plus, RefreshCcw, Settings2, Trash2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import DateTimePicker from "@/components/ui/date-time-picker/DateTimePicker.vue";
import { calendarDateTimeToUnixSeconds, unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import MeilisearchMultiSelect from "@/components/meilisearch/MeilisearchMultiSelect.vue";
import * as api from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { loadMeilisearchKeyColumns, saveMeilisearchKeyColumns, type MeilisearchKeyColumnKey } from "@/lib/meilisearch/meilisearchKeyColumns";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { KeyCreateInput, KeyListItem } from "@/types/meilisearchManagement";
const KEY_ACTION_OPTIONS = [
"search",
"documents.add",
"documents.get",
"documents.delete",
"indexes.create",
"indexes.get",
"indexes.update",
"indexes.delete",
"tasks.get",
"settings.get",
"settings.update",
"stats.get",
"dumps.create",
"version",
"keys.get",
"keys.create",
"keys.update",
"keys.delete",
] as const;
interface KeyFormState {
uid: string;
name: string;
description: string;
actions: string[];
indexes: string[];
expiresAt: string;
}
const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const rows = ref<KeyListItem[]>([]);
const offset = ref(0);
const limit = 20;
const total = ref(0);
const loading = ref(false);
const error = ref("");
const working = ref(false);
const indexOptions = ref<string[]>(["*"]);
const indexesLoading = ref(false);
const indexLoadError = ref("");
const visibleColumns = ref<MeilisearchKeyColumnKey[]>(loadMeilisearchKeyColumns());
const formOpen = ref(false);
const editing = ref<KeyListItem | null>(null);
const form = ref<KeyFormState>(emptyForm());
const formError = ref("");
const detailOpen = ref(false);
const detail = ref<KeyListItem | null>(null);
const deleteOpen = ref(false);
const deleting = ref<KeyListItem | null>(null);
const deleteConfirmation = ref("");
const secretOpen = ref(false);
const createdSecret = ref("");
const createdUid = ref("");
const hasPrevious = computed(() => offset.value > 0);
const hasNext = computed(() => offset.value + rows.value.length < total.value);
const canConfirmDelete = computed(() => Boolean(deleting.value) && deleteConfirmation.value === deleting.value?.uid);
const expirationDateTime = computed<CalendarDateTime | null>(() => {
const value = form.value.expiresAt.trim();
if (!value) return null;
const instant = new Date(value);
if (Number.isNaN(instant.getTime())) return null;
return unixSecondsToCalendarDateTime(Math.floor(instant.getTime() / 1_000));
});
const columnOptions = computed<Array<{ key: MeilisearchKeyColumnKey; label: string }>>(() => [
{ key: "name", label: t("meilisearch.name") },
{ key: "key", label: t("meilisearch.key") },
{ key: "uid", label: "UID" },
{ key: "actions", label: t("meilisearch.actions") },
{ key: "indexes", label: t("meilisearch.indexes") },
{ key: "expiresAt", label: t("meilisearch.expiresAt") },
]);
function emptyForm(): KeyFormState {
return { uid: "", name: "", description: "", actions: ["search"], indexes: ["*"], expiresAt: "" };
}
function isColumnVisible(key: MeilisearchKeyColumnKey): boolean {
return visibleColumns.value.includes(key);
}
function toggleColumn(key: MeilisearchKeyColumnKey) {
if (isColumnVisible(key) && visibleColumns.value.length === 1) return;
visibleColumns.value = isColumnVisible(key) ? visibleColumns.value.filter((column) => column !== key) : columnOptions.value.map((option) => option.key).filter((column) => column === key || visibleColumns.value.includes(column));
saveMeilisearchKeyColumns(visibleColumns.value);
}
async function load() {
loading.value = true;
error.value = "";
try {
const page = await api.meilisearchListKeys(props.connectionId, offset.value, limit);
rows.value = page.results;
total.value = page.total;
} catch (cause: any) {
error.value = cause?.message || String(cause);
} finally {
loading.value = false;
}
}
async function loadIndexes() {
indexesLoading.value = true;
indexLoadError.value = "";
try {
const indexes = await api.meilisearchListIndexes(props.connectionId);
indexOptions.value = ["*", ...new Set(indexes.filter(Boolean).sort((left, right) => left.localeCompare(right)))];
} catch (cause: any) {
indexLoadError.value = cause?.message || String(cause);
indexOptions.value = ["*"];
} finally {
indexesLoading.value = false;
}
}
function refresh() {
void load();
void loadIndexes();
}
function openCreate() {
editing.value = null;
form.value = emptyForm();
formError.value = "";
formOpen.value = true;
}
function openEdit(item: KeyListItem) {
editing.value = item;
form.value = { uid: item.uid, name: item.name || "", description: item.description || "", actions: [...item.actions], indexes: [...item.indexes], expiresAt: item.expiresAt || "" };
formError.value = "";
formOpen.value = true;
}
function normalizeExpiration(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
const date = new Date(trimmed);
if (Number.isNaN(date.getTime())) throw new Error(t("meilisearch.invalidExpiresAt"));
return date.toISOString();
}
function updateExpirationFromPicker(value: CalendarDateTime) {
form.value.expiresAt = new Date(calendarDateTimeToUnixSeconds(value) * 1_000).toISOString();
}
async function save() {
if (readOnly.value) return;
working.value = true;
formError.value = "";
try {
if (editing.value) {
await api.meilisearchUpdateKey(props.connectionId, editing.value.uid, { name: form.value.name.trim() || null, description: form.value.description.trim() || null });
toast(t("meilisearch.keyUpdated"));
} else {
const input: KeyCreateInput = {
uid: form.value.uid.trim() || null,
name: form.value.name.trim() || null,
description: form.value.description.trim() || null,
actions: [...form.value.actions],
indexes: [...form.value.indexes],
expiresAt: normalizeExpiration(form.value.expiresAt),
};
if (!input.actions.length || !input.indexes.length) throw new Error(t("meilisearch.actionsIndexesRequired"));
const created = await api.meilisearchCreateKey(props.connectionId, input);
createdSecret.value = created.key;
createdUid.value = created.uid;
secretOpen.value = true;
}
formOpen.value = false;
await load();
} catch (cause: any) {
formError.value = cause?.message || String(cause);
} finally {
working.value = false;
}
}
async function showDetail(item: KeyListItem) {
detailOpen.value = true;
detail.value = null;
try {
detail.value = await api.meilisearchGetKey(props.connectionId, item.uid);
} catch (cause: any) {
toast(cause?.message || String(cause), 5000);
detailOpen.value = false;
}
}
function requestDelete(item: KeyListItem) {
deleting.value = item;
deleteConfirmation.value = "";
deleteOpen.value = true;
}
async function confirmDelete() {
if (!deleting.value || !canConfirmDelete.value || readOnly.value) return;
working.value = true;
try {
await api.meilisearchDeleteKey(props.connectionId, deleting.value.uid);
deleteOpen.value = false;
deleting.value = null;
deleteConfirmation.value = "";
toast(t("meilisearch.keyDeleted"));
if (rows.value.length === 1 && offset.value > 0) offset.value = Math.max(0, offset.value - limit);
await load();
} catch (cause: any) {
toast(cause?.message || String(cause), 5000);
} finally {
working.value = false;
}
}
async function copyKey(value: string) {
if (!value) return;
await copyToClipboard(value);
toast(t("meilisearch.copied"));
}
watch(secretOpen, (open) => {
if (!open) {
createdSecret.value = "";
createdUid.value = "";
}
});
watch(deleteOpen, (open) => {
if (!open && !working.value) {
deleting.value = null;
deleteConfirmation.value = "";
}
});
onMounted(() => {
void load();
void loadIndexes();
});
</script>
<template>
<div class="flex h-full flex-col overflow-hidden p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold">{{ t("meilisearch.apiKeys") }}</h2>
<p class="text-xs text-muted-foreground">{{ t("meilisearch.keysDescription") }}</p>
</div>
<div class="flex gap-2">
<Button size="sm" variant="outline" :disabled="loading" @click="refresh"><RefreshCcw class="mr-1 h-3.5 w-3.5" />{{ t("meilisearch.refresh") }}</Button
><Button size="sm" :disabled="readOnly" :title="readOnly ? t('meilisearch.readOnlyDisabled') : undefined" @click="openCreate"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("meilisearch.createKey") }}</Button>
</div>
</div>
<ErrorBanner v-if="error" class="mb-3" :message="error" />
<div class="min-h-0 flex-1 overflow-auto rounded-lg border">
<QueryLoadingState v-if="loading && !rows.length" class="py-12" />
<table v-else class="w-full table-auto text-left text-xs">
<thead class="sticky top-0 z-10 bg-muted">
<tr>
<th v-if="isColumnVisible('name')" class="px-3 py-2">{{ t("meilisearch.name") }}</th>
<th v-if="isColumnVisible('key')" class="px-3 py-2">{{ t("meilisearch.key") }}</th>
<th v-if="isColumnVisible('uid')" class="px-3 py-2">UID</th>
<th v-if="isColumnVisible('actions')" class="px-3 py-2">{{ t("meilisearch.actions") }}</th>
<th v-if="isColumnVisible('indexes')" class="px-3 py-2">{{ t("meilisearch.indexes") }}</th>
<th v-if="isColumnVisible('expiresAt')" class="px-3 py-2">{{ t("meilisearch.expiresAt") }}</th>
<th class="w-px whitespace-nowrap px-2 py-1.5">
<div class="flex items-center justify-end gap-1">
<span>{{ t("meilisearch.rowActions") }}</span
><Popover
><PopoverTrigger as-child
><Button size="icon-xs" variant="ghost" :title="t('meilisearch.columnSettings')" :aria-label="t('meilisearch.columnSettings')"><Settings2 class="h-3.5 w-3.5" /></Button></PopoverTrigger
><PopoverContent align="end" class="w-56 p-2"
><div class="px-2 pb-2 text-xs font-semibold">{{ t("meilisearch.columnSettings") }}</div>
<label v-for="option in columnOptions" :key="option.key" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-muted"
><input type="checkbox" :checked="isColumnVisible(option.key)" :disabled="isColumnVisible(option.key) && visibleColumns.length === 1" @change="toggleColumn(option.key)" /><span>{{ option.label }}</span></label
></PopoverContent
></Popover
>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in rows" :key="item.uid" class="border-t">
<td v-if="isColumnVisible('name')" class="px-3 py-2">
<div class="font-medium">{{ item.name || "-" }}</div>
<div class="max-w-48 truncate text-muted-foreground">{{ item.description }}</div>
</td>
<td v-if="isColumnVisible('key')" class="px-3 py-2">
<div class="flex items-center gap-1">
<span class="font-mono">{{ item.maskedKey }}</span
><Button size="icon-xs" variant="ghost" :title="t('meilisearch.copyKeyValue')" @click="copyKey(item.key)"><Copy class="h-3.5 w-3.5" /></Button>
</div>
</td>
<td v-if="isColumnVisible('uid')" class="max-w-64 truncate px-3 py-2 font-mono" :title="item.uid">{{ item.uid }}</td>
<td v-if="isColumnVisible('actions')" class="max-w-52 px-3 py-2">{{ item.actions.join(", ") }}</td>
<td v-if="isColumnVisible('indexes')" class="max-w-52 px-3 py-2">{{ item.indexes.join(", ") }}</td>
<td v-if="isColumnVisible('expiresAt')" class="px-3 py-2">{{ item.expiresAt || t("meilisearch.never") }}</td>
<td class="w-px whitespace-nowrap px-2 py-2">
<div class="flex justify-end gap-1">
<Button size="icon-xs" variant="ghost" :title="t('common.view')" @click="showDetail(item)"><Eye class="h-3.5 w-3.5" /></Button><Button size="icon-xs" variant="ghost" :disabled="readOnly" :title="t('common.edit')" @click="openEdit(item)"><Pencil class="h-3.5 w-3.5" /></Button
><Button size="icon-xs" variant="ghost" class="text-destructive" :disabled="readOnly" :title="t('common.delete')" @click="requestDelete(item)"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
</td>
</tr>
<tr v-if="!rows.length">
<td :colspan="visibleColumns.length + 1" class="p-10 text-center text-muted-foreground">{{ t("meilisearch.noKeys") }}</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("meilisearch.paginationSummary", { start: total ? offset + 1 : 0, end: Math.min(offset + rows.length, total), total }) }}</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
:disabled="!hasPrevious || loading"
@click="
offset = Math.max(0, offset - limit);
load();
"
>{{ t("meilisearch.previous") }}</Button
><Button
size="sm"
variant="outline"
:disabled="!hasNext || loading"
@click="
offset += limit;
load();
"
>{{ t("meilisearch.next") }}</Button
>
</div>
</div>
<Dialog v-model:open="formOpen"
><DialogContent class="max-w-2xl"
><DialogHeader
><DialogTitle>{{ editing ? t("meilisearch.editKey") : t("meilisearch.createKey") }}</DialogTitle></DialogHeader
><ErrorBanner v-if="formError" :message="formError" />
<div class="grid max-h-[68vh] gap-4 overflow-y-auto pr-1 text-xs">
<label class="grid gap-1"
><span class="font-medium">UID</span><Input v-model="form.uid" :disabled="Boolean(editing)" :placeholder="t('meilisearch.uidPlaceholder')" /><span class="text-[11px] leading-4 text-muted-foreground">{{ t("meilisearch.uidHelp") }}</span></label
>
<label class="grid gap-1"
><span class="font-medium">{{ t("meilisearch.name") }}</span
><Input v-model="form.name" :placeholder="t('meilisearch.namePlaceholder')" /><span class="text-[11px] leading-4 text-muted-foreground">{{ t("meilisearch.nameHelp") }}</span></label
>
<label class="grid gap-1"
><span class="font-medium">{{ t("meilisearch.description") }}</span
><Input v-model="form.description" :placeholder="t('meilisearch.descriptionPlaceholder')" /><span class="text-[11px] leading-4 text-muted-foreground">{{ t("meilisearch.descriptionHelp") }}</span></label
>
<div class="grid gap-1">
<span class="font-medium">{{ t("meilisearch.indexes") }}</span
><MeilisearchMultiSelect v-model="form.indexes" :options="indexOptions" :disabled="Boolean(editing)" :loading="indexesLoading" :error="indexLoadError" :placeholder="t('meilisearch.indexesPlaceholder')" /><span class="text-[11px] leading-4 text-muted-foreground">{{
t("meilisearch.indexesHelp")
}}</span>
</div>
<div class="grid gap-1">
<span class="font-medium">{{ t("meilisearch.actions") }}</span
><MeilisearchMultiSelect v-model="form.actions" :options="[...KEY_ACTION_OPTIONS]" :disabled="Boolean(editing)" :placeholder="t('meilisearch.actionsPlaceholder')" /><span class="text-[11px] leading-4 text-muted-foreground">{{ t("meilisearch.actionsHelp") }}</span>
</div>
<label class="grid gap-1"
><span class="font-medium">{{ t("meilisearch.expiresAt") }}</span>
<div class="flex gap-2">
<Input v-model="form.expiresAt" :disabled="Boolean(editing)" :placeholder="t('meilisearch.expiresAtPlaceholder')" /><DateTimePicker
:model-value="expirationDateTime"
:disabled="Boolean(editing)"
:placeholder="t('meilisearch.expiresAtPlaceholder')"
@update:model-value="updateExpirationFromPicker"
><template #trigger
><button
type="button"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-input text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
:disabled="Boolean(editing)"
:title="t('meilisearch.openDateTimePicker')"
>
<CalendarClock class="h-4 w-4" /></button></template
></DateTimePicker>
</div>
<span class="text-[11px] leading-4 text-muted-foreground">{{ t("meilisearch.expiresAtHelp") }}</span></label
>
<p v-if="editing" class="rounded-md border bg-muted/30 px-3 py-2 text-muted-foreground">{{ t("meilisearch.immutableKeyFields") }}</p>
</div>
<DialogFooter
><Button variant="outline" @click="formOpen = false">{{ t("common.cancel") }}</Button
><Button :disabled="working || readOnly" @click="save"><Loader2 v-if="working" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("common.save") }}</Button></DialogFooter
></DialogContent
></Dialog
>
<Dialog v-model:open="detailOpen"
><DialogContent class="max-w-2xl"
><DialogHeader
><DialogTitle>{{ t("meilisearch.keyDetails") }}</DialogTitle></DialogHeader
>
<div v-if="detail" class="grid gap-2 text-xs">
<div>
<span class="text-muted-foreground">UID: </span><span class="font-mono">{{ detail.uid }}</span>
</div>
<div>
<span class="text-muted-foreground">{{ t("meilisearch.key") }}: </span><span class="font-mono">{{ detail.maskedKey }}</span>
</div>
<div>
<span class="text-muted-foreground">{{ t("meilisearch.name") }}: </span>{{ detail.name || "-" }}
</div>
<div>
<span class="text-muted-foreground">{{ t("meilisearch.description") }}: </span>{{ detail.description || "-" }}
</div>
<div>
<span class="text-muted-foreground">{{ t("meilisearch.actions") }}: </span>{{ detail.actions.join(", ") }}
</div>
<div>
<span class="text-muted-foreground">{{ t("meilisearch.indexes") }}: </span>{{ detail.indexes.join(", ") }}
</div>
<div>
<span class="text-muted-foreground">{{ t("meilisearch.expiresAt") }}: </span>{{ detail.expiresAt || t("meilisearch.never") }}
</div>
</div>
<DialogFooter
><Button variant="outline" @click="detailOpen = false">{{ t("common.close") }}</Button></DialogFooter
></DialogContent
></Dialog
>
<Dialog v-model:open="secretOpen"
><DialogContent class="max-w-xl"
><DialogHeader
><DialogTitle>{{ t("meilisearch.keyCreated") }}</DialogTitle></DialogHeader
>
<p class="text-sm text-muted-foreground">{{ t("meilisearch.secretShownOnce") }}</p>
<div class="rounded-md border bg-muted p-3">
<div class="mb-1 text-xs text-muted-foreground">{{ createdUid }}</div>
<div class="break-all font-mono text-sm">{{ createdSecret }}</div>
</div>
<DialogFooter
><Button variant="outline" @click="copyKey(createdSecret)"><Copy class="mr-1 h-3.5 w-3.5" />{{ t("common.copy") }}</Button
><Button @click="secretOpen = false">{{ t("common.close") }}</Button></DialogFooter
></DialogContent
></Dialog
>
<Dialog v-model:open="deleteOpen"
><DialogContent class="sm:max-w-[480px]"
><DialogHeader
><DialogTitle class="flex items-center gap-2 text-destructive"><AlertTriangle class="h-5 w-5" />{{ t("meilisearch.deleteKey") }}</DialogTitle></DialogHeader
>
<div class="grid gap-3 py-3 text-sm">
<p class="text-muted-foreground">{{ t("meilisearch.deleteKeyConfirm", { uid: deleting?.uid || "" }) }}</p>
<code class="rounded border bg-muted px-3 py-2 text-xs break-all">{{ deleting?.uid }}</code
><label class="grid gap-1.5"
><span class="text-xs text-muted-foreground">{{ t("meilisearch.deleteKeyTypePrompt") }}</span
><Input v-model="deleteConfirmation" autocomplete="off" :placeholder="t('meilisearch.deleteKeyTypePlaceholder')" @keyup.enter="canConfirmDelete && confirmDelete()"
/></label>
</div>
<DialogFooter
><Button variant="outline" :disabled="working" @click="deleteOpen = false">{{ t("common.cancel") }}</Button
><Button variant="destructive" :disabled="working || !canConfirmDelete" @click="confirmDelete"><Loader2 v-if="working" class="mr-1 h-3.5 w-3.5 animate-spin" />{{ t("meilisearch.deleteKey") }}</Button></DialogFooter
></DialogContent
></Dialog
>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { Check, ChevronDown, Loader2 } from "@lucide/vue";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
const props = withDefaults(
defineProps<{
modelValue: string[];
options: string[];
placeholder: string;
disabled?: boolean;
loading?: boolean;
error?: string;
}>(),
{
disabled: false,
loading: false,
error: "",
},
);
const emit = defineEmits<{ "update:modelValue": [value: string[]] }>();
const open = ref(false);
const selected = computed(() => new Set(props.modelValue));
const displayValue = computed(() => props.modelValue.join(", "));
function toggle(value: string) {
const next = new Set(props.modelValue);
if (next.has(value)) next.delete(value);
else next.add(value);
emit(
"update:modelValue",
props.options.filter((option) => next.has(option)),
);
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<button
type="button"
class="flex min-h-9 w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-sm shadow-sm outline-none hover:bg-muted/30 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
:disabled="disabled"
>
<span class="min-w-0 flex-1 truncate" :class="modelValue.length ? 'font-mono text-xs' : 'text-muted-foreground'">{{ displayValue || placeholder }}</span>
<Loader2 v-if="loading" class="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="start" side="bottom" class="z-[80] w-[var(--reka-popover-trigger-width)] gap-1 p-1.5" @open-auto-focus.prevent>
<div v-if="error" class="px-2 py-2 text-xs text-destructive">{{ error }}</div>
<div v-if="loading" class="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground"><Loader2 class="h-3.5 w-3.5 animate-spin" />{{ placeholder }}</div>
<div v-else class="max-h-60 overflow-y-auto py-0.5">
<button v-for="option in options" :key="option" type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left font-mono text-xs hover:bg-accent focus-visible:bg-accent focus-visible:outline-none" @click="toggle(option)">
<span class="flex h-4 w-4 shrink-0 items-center justify-center rounded border" :class="selected.has(option) ? 'border-primary bg-primary text-primary-foreground' : 'border-input'">
<Check v-if="selected.has(option)" class="h-3 w-3" />
</span>
<span class="min-w-0 flex-1 truncate">{{ option }}</span>
</button>
</div>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Activity, Database, FileText, KeyRound, RefreshCcw, Tags } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import { formatBytes } from "@/lib/database/serverMetrics";
import type { MeilisearchSystemOverview, OverviewSection } from "@/types/meilisearchManagement";
const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const loading = ref(false);
const error = ref("");
const overview = ref<MeilisearchSystemOverview | null>(null);
const stats = computed(() => overview.value?.stats.data ?? null);
const cards = computed(() => [
{ label: t("meilisearch.health"), value: overview.value?.health.data?.status ?? sectionLabel(overview.value?.health), icon: Activity },
{ label: t("meilisearch.version"), value: overview.value?.version.data?.pkgVersion ?? sectionLabel(overview.value?.version), icon: Tags },
{ label: t("meilisearch.indexCount"), value: stats.value?.indexCount ?? sectionLabel(overview.value?.stats), icon: Database },
{ label: t("meilisearch.totalDocuments"), value: stats.value?.documentCount ?? sectionLabel(overview.value?.stats), icon: FileText },
{ label: t("meilisearch.indexingCount"), value: stats.value?.indexingCount ?? sectionLabel(overview.value?.stats), icon: RefreshCcw },
{ label: t("meilisearch.keyCount"), value: overview.value?.keyCount.data ?? sectionLabel(overview.value?.keyCount), icon: KeyRound },
]);
function sectionLabel(section?: OverviewSection<unknown>): string {
if (!section) return "-";
return section.message || t(`meilisearch.sectionStatus.${section.status}`);
}
async function load() {
loading.value = true;
error.value = "";
try {
overview.value = await api.meilisearchGetSystemOverview(props.connectionId);
} catch (cause: any) {
error.value = cause?.message || String(cause);
} finally {
loading.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<div class="h-full overflow-y-auto p-4">
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="text-base font-semibold">{{ t("meilisearch.overview") }}</h2>
<p class="text-xs text-muted-foreground">{{ t("meilisearch.overviewDescription") }}</p>
</div>
<Button size="sm" variant="outline" :disabled="loading" @click="load"><RefreshCcw class="mr-1 h-3.5 w-3.5" />{{ t("meilisearch.refresh") }}</Button>
</div>
<QueryLoadingState v-if="loading && !overview" class="py-12" />
<ErrorBanner v-else-if="error && !overview" :message="error" />
<template v-else-if="overview">
<ErrorBanner v-if="error" class="mb-3" :message="error" />
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div v-for="card in cards" :key="card.label" class="rounded-lg border bg-card p-4">
<div class="flex items-center gap-2 text-xs text-muted-foreground"><component :is="card.icon" class="h-4 w-4" />{{ card.label }}</div>
<div class="mt-2 break-words text-xl font-semibold tabular-nums">{{ card.value }}</div>
</div>
</div>
<div class="mt-4 grid gap-3 xl:grid-cols-2">
<section class="rounded-lg border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold">{{ t("meilisearch.storage") }}</h3>
<div v-if="stats" class="grid grid-cols-2 gap-3 text-xs">
<div>
<div class="text-muted-foreground">{{ t("meilisearch.databaseSize") }}</div>
<div class="mt-1 text-sm font-medium">{{ stats.databaseSize == null ? "-" : formatBytes(stats.databaseSize) }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("meilisearch.usedDatabaseSize") }}</div>
<div class="mt-1 text-sm font-medium">{{ stats.usedDatabaseSize == null ? "-" : formatBytes(stats.usedDatabaseSize) }}</div>
</div>
<div class="col-span-2">
<div class="text-muted-foreground">{{ t("meilisearch.lastUpdate") }}</div>
<div class="mt-1 text-sm font-medium">{{ stats.lastUpdate || "-" }}</div>
</div>
</div>
<p v-else class="text-xs text-muted-foreground">{{ sectionLabel(overview.stats) }}</p>
</section>
<section class="rounded-lg border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold">{{ t("meilisearch.taskStatusDistribution") }}</h3>
<div v-if="overview.taskCounts.data" class="grid grid-cols-2 gap-2 text-xs sm:grid-cols-3">
<div v-for="(count, status) in overview.taskCounts.data" :key="status" class="rounded border px-2 py-2">
<div class="text-muted-foreground">{{ status }}</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ count }}</div>
</div>
</div>
<p v-else class="text-xs text-muted-foreground">{{ sectionLabel(overview.taskCounts) }}</p>
</section>
</div>
<section class="mt-4 rounded-lg border bg-card">
<div class="border-b px-4 py-3 text-sm font-semibold">{{ t("meilisearch.topIndexes") }}</div>
<div v-if="overview.topIndexes.data?.length" class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="bg-muted/40 text-muted-foreground">
<tr>
<th class="px-4 py-2">{{ t("meilisearch.uid") }}</th>
<th class="px-4 py-2">{{ t("meilisearch.documentCountLabel") }}</th>
<th class="px-4 py-2">{{ t("meilisearch.status") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in overview.topIndexes.data" :key="item.uid" class="border-t">
<td class="px-4 py-2 font-mono">{{ item.uid }}</td>
<td class="px-4 py-2 tabular-nums">{{ item.numberOfDocuments }}</td>
<td class="px-4 py-2">{{ item.isIndexing ? t("meilisearch.isIndexing") : t("meilisearch.idle") }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="p-4 text-xs text-muted-foreground">{{ sectionLabel(overview.topIndexes) }}</p>
</section>
</template>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Gauge, KeyRound, ListChecks } from "@lucide/vue";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import MeilisearchOverviewPage from "./MeilisearchOverviewPage.vue";
import MeilisearchKeysPage from "./MeilisearchKeysPage.vue";
import MeilisearchTasksPage from "./MeilisearchTasksPage.vue";
defineProps<{ connectionId: string }>();
type Section = "overview" | "keys" | "tasks";
const { t } = useI18n();
const active = ref<Section>("overview");
const sections = computed(() => [
{ value: "overview" as const, label: t("meilisearch.overview"), icon: Gauge },
{ value: "keys" as const, label: t("meilisearch.apiKeys"), icon: KeyRound },
{ value: "tasks" as const, label: t("meilisearch.tasks"), icon: ListChecks },
]);
</script>
<template>
<Tabs v-model="active" class="flex h-full min-h-0 flex-col bg-background">
<div class="flex h-12 shrink-0 items-center border-b px-4">
<div class="min-w-0 overflow-x-auto overflow-y-hidden">
<TabsList class="h-8 w-max min-w-max justify-start bg-muted/60 p-0.5">
<TabsTrigger v-for="section in sections" :key="section.value" :value="section.value" class="h-7 flex-none gap-1.5 px-3 text-xs"><component :is="section.icon" class="h-3.5 w-3.5" />{{ section.label }}</TabsTrigger>
</TabsList>
</div>
</div>
<TabsContent value="overview" class="m-0 min-h-0 flex-1 overflow-hidden"><MeilisearchOverviewPage :connection-id="connectionId" /></TabsContent>
<TabsContent value="keys" class="m-0 min-h-0 flex-1 overflow-hidden"><MeilisearchKeysPage :connection-id="connectionId" /></TabsContent>
<TabsContent value="tasks" class="m-0 min-h-0 flex-1 overflow-hidden"><MeilisearchTasksPage :connection-id="connectionId" /></TabsContent>
</Tabs>
</template>
@@ -0,0 +1,442 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Ban, ChevronLeft, ChevronRight, Eye, Loader2, RefreshCcw, Search, Settings2, Trash2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import {
formatMeilisearchTaskDateTime,
formatMeilisearchTaskDetails,
formatMeilisearchTaskDuration,
MEILISEARCH_TASK_STATUS_OPTIONS,
meilisearchTaskStatusLabel,
normalizeTaskMutationSelector,
normalizeTaskSelector,
withFixedTaskIndex,
type EnqueuedTaskSummary,
type MeilisearchTask,
type TaskSelector,
} from "@/types/meilisearchManagement";
import { loadMeilisearchTaskColumns, saveMeilisearchTaskColumns, type MeilisearchTaskColumnKey } from "@/lib/meilisearch/meilisearchTaskColumns";
const props = defineProps<{ connectionId: string; fixedIndexUid?: string }>();
const { locale, t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const rows = ref<MeilisearchTask[]>([]);
const total = ref(0);
const next = ref<number | null>(null);
const cursorStack = ref<Array<number | null>>([null]);
const loading = ref(false);
const error = ref("");
const allFilterValue = "__all__";
const filters = ref({ uids: "", indexUid: allFilterValue, status: allFilterValue });
const indexOptions = ref<string[]>([]);
const indexesLoading = ref(false);
const indexLoadError = ref("");
const visibleColumns = ref<MeilisearchTaskColumnKey[]>(loadMeilisearchTaskColumns());
const detailOpen = ref(false);
const detail = ref<MeilisearchTask | null>(null);
const confirmOpen = ref(false);
const pendingAction = ref<"cancel" | "delete" | null>(null);
const pendingSelector = ref<TaskSelector | null>(null);
const pendingMatchCount = ref(0);
const preparingAction = ref(false);
const mutating = ref(false);
const pollingTaskUid = ref<number | null>(null);
const timedOutTaskUid = ref<number | null>(null);
const appliedUserSelector = ref<TaskSelector>({});
let disposed = false;
const currentCursor = computed(() => cursorStack.value[cursorStack.value.length - 1] ?? null);
const hasPrevious = computed(() => cursorStack.value.length > 1);
const hasNext = computed(() => next.value != null);
const taskOperationBusy = computed(() => preparingAction.value || mutating.value || pollingTaskUid.value != null || timedOutTaskUid.value != null);
const activeStatuses = new Set(["enqueued", "processing"]);
const finishedStatuses = new Set(["succeeded", "failed", "canceled"]);
const columnOptions = computed<Array<{ key: MeilisearchTaskColumnKey; label: string }>>(() => [
{ key: "uid", label: "UID" },
{ key: "index", label: t("meilisearch.index") },
{ key: "type", label: t("meilisearch.type") },
{ key: "status", label: t("meilisearch.status") },
{ key: "details", label: t("meilisearch.details") },
{ key: "enqueuedAt", label: t("meilisearch.enqueuedAt") },
{ key: "startedAt", label: t("meilisearch.startedAt") },
{ key: "finishedAt", label: t("meilisearch.finishedAt") },
{ key: "duration", label: t("meilisearch.duration") },
]);
const columnFractions: Record<MeilisearchTaskColumnKey, number> = {
uid: 0.65,
index: 0.8,
type: 1.5,
status: 1.1,
details: 2.2,
enqueuedAt: 1.35,
startedAt: 1.35,
finishedAt: 1.35,
duration: 0.75,
};
const gridTemplateColumns = computed(() => [...visibleColumns.value.map((key) => `minmax(0, ${columnFractions[key]}fr)`), "80px"].join(" "));
const renderedRows = computed(() =>
rows.value.map((task) => ({
task,
statusLabel: meilisearchTaskStatusLabel(task.status),
details: formatMeilisearchTaskDetails(task.details, {
receivedDocuments: t("meilisearch.receivedDocuments"),
indexedDocuments: t("meilisearch.indexedDocuments"),
}),
enqueuedAt: formatMeilisearchTaskDateTime(task.enqueuedAt, locale.value),
startedAt: formatMeilisearchTaskDateTime(task.startedAt, locale.value),
finishedAt: formatMeilisearchTaskDateTime(task.finishedAt, locale.value),
duration: formatMeilisearchTaskDuration(task.duration, locale.value),
})),
);
function isColumnVisible(key: MeilisearchTaskColumnKey): boolean {
return visibleColumns.value.includes(key);
}
function toggleColumn(key: MeilisearchTaskColumnKey) {
const isVisible = isColumnVisible(key);
if (isVisible && visibleColumns.value.length === 1) return;
visibleColumns.value = isVisible ? visibleColumns.value.filter((column) => column !== key) : columnOptions.value.map((option) => option.key).filter((column) => column === key || visibleColumns.value.includes(column));
saveMeilisearchTaskColumns(visibleColumns.value);
}
function detailsJson(task: MeilisearchTask): string | undefined {
return task.details ? JSON.stringify(task.details, null, 2) : undefined;
}
function numbers(value: string): number[] | undefined {
const result = value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map(Number)
.filter(Number.isFinite);
return result.length ? result : undefined;
}
function userSelector(): TaskSelector {
return {
uids: numbers(filters.value.uids),
indexUids: !props.fixedIndexUid && filters.value.indexUid !== allFilterValue ? [filters.value.indexUid] : undefined,
statuses: filters.value.status !== allFilterValue ? [filters.value.status] : undefined,
};
}
function requestSelector(): TaskSelector {
return withFixedTaskIndex(appliedUserSelector.value, props.fixedIndexUid);
}
function normalizedSelector(selector = requestSelector()): TaskSelector {
return normalizeTaskSelector(selector);
}
const selectorPreview = computed(() => JSON.stringify(pendingSelector.value ?? {}, null, 2));
async function load(reset = false) {
if (reset) cursorStack.value = [null];
loading.value = true;
error.value = "";
try {
const page = await api.meilisearchGetTasks(props.connectionId, { selector: normalizedSelector(), from: currentCursor.value, limit: 20 });
rows.value = page.results;
total.value = page.total;
next.value = page.next ?? null;
} catch (cause: any) {
error.value = cause?.message || String(cause);
} finally {
loading.value = false;
}
}
async function loadIndexes() {
if (props.fixedIndexUid) return;
indexesLoading.value = true;
indexLoadError.value = "";
try {
const indexes = await api.meilisearchListIndexes(props.connectionId);
indexOptions.value = [...new Set(indexes.filter(Boolean))].sort((left, right) => left.localeCompare(right));
} catch (cause: any) {
indexLoadError.value = cause?.message || String(cause);
} finally {
indexesLoading.value = false;
}
}
function refresh() {
timedOutTaskUid.value = null;
void load();
void loadIndexes();
}
function resetFilters() {
filters.value = { uids: "", indexUid: allFilterValue, status: allFilterValue };
appliedUserSelector.value = {};
void load(true);
}
function applyFilters() {
appliedUserSelector.value = normalizeTaskSelector(userSelector());
void load(true);
}
function updateIndexFilter(value: unknown) {
filters.value.indexUid = String(value ?? allFilterValue);
applyFilters();
}
function updateStatusFilter(value: unknown) {
filters.value.status = String(value ?? allFilterValue);
applyFilters();
}
function goNext() {
if (next.value == null) return;
cursorStack.value.push(next.value);
void load();
}
function goPrevious() {
if (!hasPrevious.value) return;
cursorStack.value.pop();
void load();
}
async function openDetails(task: MeilisearchTask) {
detailOpen.value = true;
detail.value = null;
try {
const result = await api.meilisearchGetTask(props.connectionId, task.uid, props.fixedIndexUid);
detail.value = result;
} catch (cause: any) {
detailOpen.value = false;
toast(cause?.message || String(cause), 5000);
}
}
async function requestAction(action: "cancel" | "delete", task: MeilisearchTask) {
if (readOnly.value || taskOperationBusy.value) return;
const baseSelector = withFixedTaskIndex({ uids: [task.uid] }, props.fixedIndexUid);
const frozenSelector = normalizeTaskMutationSelector(baseSelector, action);
if (!frozenSelector) {
toast(t("meilisearch.noAllowedTaskStatuses"), 5000);
return;
}
preparingAction.value = true;
try {
const page = await api.meilisearchGetTasks(props.connectionId, { selector: frozenSelector, from: null, limit: 1 });
pendingAction.value = action;
pendingSelector.value = frozenSelector;
pendingMatchCount.value = page.total;
confirmOpen.value = true;
} catch (cause: any) {
toast(cause?.message || String(cause), 5000);
} finally {
preparingAction.value = false;
}
}
async function waitForTask(summary: EnqueuedTaskSummary) {
timedOutTaskUid.value = null;
pollingTaskUid.value = summary.taskUid;
for (let attempt = 0; attempt < 60 && !disposed; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 1000));
if (disposed) return;
try {
const task = await api.meilisearchGetTask(props.connectionId, summary.taskUid);
if (finishedStatuses.has(task.status)) {
pollingTaskUid.value = null;
timedOutTaskUid.value = null;
toast(task.status === "succeeded" ? t("meilisearch.taskOperationCompleted") : t("meilisearch.taskOperationFinished", { status: task.status }), 5000);
await load(true);
return;
}
} catch {
// The management task can be briefly unavailable immediately after enqueue.
}
}
if (!disposed && pollingTaskUid.value != null) {
timedOutTaskUid.value = pollingTaskUid.value;
pollingTaskUid.value = null;
toast(t("meilisearch.taskPollingTimeout", { uid: timedOutTaskUid.value }), 7000);
}
}
async function confirmAction() {
if (!pendingAction.value || !pendingSelector.value || readOnly.value || pollingTaskUid.value != null || timedOutTaskUid.value != null) return;
const action = pendingAction.value;
const selector = pendingSelector.value;
mutating.value = true;
try {
const summary = action === "cancel" ? await api.meilisearchCancelTasks(props.connectionId, selector) : await api.meilisearchDeleteTasks(props.connectionId, selector);
confirmOpen.value = false;
pendingAction.value = null;
pendingSelector.value = null;
pendingMatchCount.value = 0;
void waitForTask(summary);
} catch (cause: any) {
toast(cause?.message || String(cause), 5000);
} finally {
mutating.value = false;
}
}
onMounted(() => {
void load(true);
void loadIndexes();
});
onBeforeUnmount(() => {
disposed = true;
});
</script>
<template>
<div class="flex h-full flex-col overflow-hidden p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold">{{ fixedIndexUid ? t("meilisearch.indexTasks", { index: fixedIndexUid }) : t("meilisearch.tasks") }}</h2>
<p class="text-xs text-muted-foreground">{{ t("meilisearch.tasksDescription") }}</p>
</div>
<Button size="sm" variant="outline" :disabled="loading" @click="refresh"><RefreshCcw class="mr-1 h-3.5 w-3.5" />{{ t("meilisearch.refresh") }}</Button>
</div>
<div class="mb-3 flex items-center gap-2 overflow-x-auto rounded-lg border bg-muted/20 p-2 text-xs">
<div class="relative min-w-[260px] flex-1">
<Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="filters.uids" class="pl-9" :aria-label="t('meilisearch.uidFilter')" :placeholder="t('meilisearch.searchUid')" @keyup.enter="applyFilters" />
</div>
<div v-if="!fixedIndexUid" class="w-[200px] shrink-0">
<Select :model-value="filters.indexUid" :disabled="indexesLoading" @update:model-value="updateIndexFilter">
<SelectTrigger :aria-label="t('meilisearch.index')"><SelectValue :placeholder="indexesLoading ? t('common.loading') : t('meilisearch.allIndexes')" /></SelectTrigger>
<SelectContent>
<SelectItem :value="allFilterValue">{{ t("meilisearch.allIndexes") }}</SelectItem>
<SelectItem v-for="index in indexOptions" :key="index" :value="index">{{ index }}</SelectItem>
</SelectContent>
</Select>
<span v-if="indexLoadError" class="text-[11px] text-destructive" :title="indexLoadError">{{ t("meilisearch.indexListUnavailable") }}</span>
</div>
<div class="w-[200px] shrink-0">
<Select :model-value="filters.status" @update:model-value="updateStatusFilter">
<SelectTrigger :aria-label="t('meilisearch.status')"><SelectValue :placeholder="t('meilisearch.allStatuses')" /></SelectTrigger>
<SelectContent>
<SelectItem :value="allFilterValue">{{ t("meilisearch.allStatuses") }}</SelectItem>
<SelectItem v-for="option in MEILISEARCH_TASK_STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</SelectItem>
</SelectContent>
</Select>
</div>
<Button class="ml-auto shrink-0" size="sm" variant="ghost" @click="resetFilters">{{ t("meilisearch.resetFilters") }}</Button>
</div>
<div v-if="preparingAction || pollingTaskUid != null || timedOutTaskUid != null || readOnly" class="mb-3 flex flex-wrap items-center gap-2">
<span v-if="preparingAction" class="flex items-center gap-1 text-xs text-muted-foreground"><Loader2 class="h-3.5 w-3.5 animate-spin" />{{ t("meilisearch.countingMatchingTasks") }}</span>
<span v-if="pollingTaskUid != null" class="flex items-center gap-1 text-xs text-muted-foreground"><Loader2 class="h-3.5 w-3.5 animate-spin" />{{ t("meilisearch.waitingForTask", { uid: pollingTaskUid }) }}</span>
<span v-if="timedOutTaskUid != null" class="text-xs text-muted-foreground">{{ t("meilisearch.taskPollingTimeout", { uid: timedOutTaskUid }) }}</span>
<span v-if="readOnly" class="text-xs text-muted-foreground">{{ t("meilisearch.readOnlyDisabled") }}</span>
</div>
<ErrorBanner v-if="error" class="mb-3" :message="error" />
<div data-testid="tasks-grid" role="table" class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border">
<QueryLoadingState v-if="loading && !rows.length" class="py-12" />
<template v-else>
<div data-testid="tasks-header" role="rowgroup" class="shrink-0 bg-muted text-xs font-semibold">
<div role="row" class="grid items-center" :style="{ gridTemplateColumns }">
<div v-if="isColumnVisible('uid')" role="columnheader" data-column="uid" class="min-w-0 px-3 py-2">UID</div>
<div v-if="isColumnVisible('index')" role="columnheader" data-column="index" class="min-w-0 px-3 py-2">{{ t("meilisearch.index") }}</div>
<div v-if="isColumnVisible('type')" role="columnheader" data-column="type" class="min-w-0 px-3 py-2">{{ t("meilisearch.type") }}</div>
<div v-if="isColumnVisible('status')" role="columnheader" data-column="status" class="min-w-0 px-3 py-2">{{ t("meilisearch.status") }}</div>
<div v-if="isColumnVisible('details')" role="columnheader" data-column="details" class="min-w-0 px-3 py-2">{{ t("meilisearch.details") }}</div>
<div v-if="isColumnVisible('enqueuedAt')" role="columnheader" data-column="enqueuedAt" class="min-w-0 px-3 py-2">{{ t("meilisearch.enqueuedAt") }}</div>
<div v-if="isColumnVisible('startedAt')" role="columnheader" data-column="startedAt" class="min-w-0 px-3 py-2">{{ t("meilisearch.startedAt") }}</div>
<div v-if="isColumnVisible('finishedAt')" role="columnheader" data-column="finishedAt" class="min-w-0 px-3 py-2">{{ t("meilisearch.finishedAt") }}</div>
<div v-if="isColumnVisible('duration')" role="columnheader" data-column="duration" class="min-w-0 px-3 py-2">{{ t("meilisearch.duration") }}</div>
<div role="columnheader" data-column="actions" class="min-w-0 px-2 py-1.5">
<div class="flex items-center justify-end gap-1 whitespace-nowrap">
<span>{{ t("meilisearch.rowActions") }}</span>
<Popover>
<PopoverTrigger as-child>
<Button size="icon-xs" variant="ghost" :title="t('meilisearch.columnSettings')" :aria-label="t('meilisearch.columnSettings')"><Settings2 class="h-3.5 w-3.5" /></Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-56 p-2">
<div class="px-2 pb-2 text-xs font-semibold">{{ t("meilisearch.columnSettings") }}</div>
<label v-for="option in columnOptions" :key="option.key" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-muted">
<input type="checkbox" :checked="isColumnVisible(option.key)" :disabled="isColumnVisible(option.key) && visibleColumns.length === 1" @change="toggleColumn(option.key)" />
<span>{{ option.label }}</span>
</label>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
<div data-testid="tasks-body" role="rowgroup" class="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [contain:layout_paint] [will-change:scroll-position]">
<div v-for="row in renderedRows" :key="row.task.uid" role="row" class="grid items-center border-t text-xs" :style="{ gridTemplateColumns }">
<div v-if="isColumnVisible('uid')" role="cell" data-column="uid" class="min-w-0 truncate px-3 py-2 font-mono">{{ row.task.uid }}</div>
<div v-if="isColumnVisible('index')" role="cell" data-column="index" class="min-w-0 truncate px-3 py-2 font-mono">{{ row.task.indexUid || "-" }}</div>
<div v-if="isColumnVisible('type')" role="cell" data-column="type" class="min-w-0 truncate px-3 py-2" :title="row.task.type">{{ row.task.type }}</div>
<div v-if="isColumnVisible('status')" role="cell" data-column="status" class="min-w-0 truncate px-3 py-2">
<span class="font-medium">{{ row.statusLabel }}</span>
</div>
<div v-if="isColumnVisible('details')" role="cell" data-column="details" class="min-w-0 px-3 py-2" :title="detailsJson(row.task)">
<div class="truncate font-mono">{{ row.details }}</div>
</div>
<div v-if="isColumnVisible('enqueuedAt')" role="cell" data-column="enqueuedAt" class="min-w-0 truncate px-3 py-2" :title="row.task.enqueuedAt || undefined">{{ row.enqueuedAt }}</div>
<div v-if="isColumnVisible('startedAt')" role="cell" data-column="startedAt" class="min-w-0 truncate px-3 py-2" :title="row.task.startedAt || undefined">{{ row.startedAt }}</div>
<div v-if="isColumnVisible('finishedAt')" role="cell" data-column="finishedAt" class="min-w-0 truncate px-3 py-2" :title="row.task.finishedAt || undefined">{{ row.finishedAt }}</div>
<div v-if="isColumnVisible('duration')" role="cell" data-column="duration" class="min-w-0 truncate px-3 py-2" :title="row.task.duration || undefined">{{ row.duration }}</div>
<div role="cell" data-column="actions" class="min-w-0 px-2 py-2">
<div class="flex justify-end gap-1">
<Button size="icon-xs" variant="ghost" :title="t('common.view')" @click="openDetails(row.task)"><Eye class="h-3.5 w-3.5" /></Button
><Button v-if="activeStatuses.has(row.task.status)" size="icon-xs" variant="ghost" :disabled="readOnly || taskOperationBusy" :title="t('meilisearch.cancelTask')" @click="requestAction('cancel', row.task)"><Ban class="h-3.5 w-3.5" /></Button
><Button v-if="finishedStatuses.has(row.task.status)" size="icon-xs" variant="ghost" class="text-destructive" :disabled="readOnly || taskOperationBusy" :title="t('meilisearch.deleteTaskHistory')" @click="requestAction('delete', row.task)"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>
<div v-if="!rows.length" class="p-10 text-center text-xs text-muted-foreground">{{ t("meilisearch.noTasks") }}</div>
</div>
</template>
</div>
<div class="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("meilisearch.taskCount", { count: total }) }}</span>
<div class="flex gap-2">
<Button size="sm" variant="outline" :disabled="!hasPrevious || loading" @click="goPrevious"><ChevronLeft class="mr-1 h-3.5 w-3.5" />{{ t("meilisearch.previous") }}</Button
><Button size="sm" variant="outline" :disabled="!hasNext || loading" @click="goNext">{{ t("meilisearch.next") }}<ChevronRight class="ml-1 h-3.5 w-3.5" /></Button>
</div>
</div>
<Dialog v-model:open="detailOpen"
><DialogContent class="max-w-3xl"
><DialogHeader
><DialogTitle>{{ t("meilisearch.taskDetails") }} #{{ detail?.uid }}</DialogTitle></DialogHeader
>
<pre v-if="detail" class="max-h-[65vh] overflow-auto rounded-md border bg-muted/30 p-3 text-xs">{{ JSON.stringify(detail, null, 2) }}</pre>
<DialogFooter
><Button variant="outline" @click="detailOpen = false">{{ t("common.close") }}</Button></DialogFooter
></DialogContent
></Dialog
>
<DangerConfirmDialog
v-model:open="confirmOpen"
:title="pendingAction === 'cancel' ? t('meilisearch.cancelTasks') : t('meilisearch.deleteTaskHistory')"
:message="pendingAction === 'cancel' ? t('meilisearch.cancelTasksConfirm', { count: pendingMatchCount }) : t('meilisearch.deleteTasksConfirm', { count: pendingMatchCount })"
:details="selectorPreview"
:confirm-label="pendingAction === 'cancel' ? t('meilisearch.cancelTasks') : t('meilisearch.deleteTaskHistory')"
:loading="mutating"
:close-on-confirm="false"
@confirm="confirmAction"
/>
</div>
</template>
@@ -0,0 +1,224 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listKeys: vi.fn(),
listIndexes: vi.fn(),
createKey: vi.fn(),
updateKey: vi.fn(),
deleteKey: vi.fn(),
getKey: vi.fn(),
copy: vi.fn(),
toast: vi.fn(),
}));
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
function inputComponent(): Component {
return defineComponent({
inheritAttrs: false,
props: { modelValue: { type: String, default: "" } },
emits: ["update:modelValue"],
setup(props, { attrs, emit }) {
return () => h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) });
},
});
}
function multiSelectComponent(): Component {
return defineComponent({
props: { modelValue: { type: Array, default: () => [] }, options: { type: Array, default: () => [] }, placeholder: String, disabled: Boolean },
emits: ["update:modelValue"],
setup(props, { emit }) {
return () =>
h(
"select",
{
multiple: true,
disabled: props.disabled,
"data-placeholder": props.placeholder,
onChange: (event: Event) =>
emit(
"update:modelValue",
Array.from((event.target as HTMLSelectElement).selectedOptions).map((option) => option.value),
),
},
(props.options as string[]).map((option) => h("option", { value: option, selected: (props.modelValue as string[]).includes(option) }, option)),
);
},
});
}
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key) }) }));
vi.mock("@lucide/vue", () => ({
AlertTriangle: passthrough("span"),
CalendarClock: passthrough("span"),
Copy: passthrough("span"),
Eye: passthrough("span"),
Loader2: passthrough("span"),
Pencil: passthrough("span"),
Plus: passthrough("span"),
RefreshCcw: passthrough("span"),
Settings2: passthrough("span"),
Trash2: passthrough("span"),
}));
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/input", () => ({ Input: inputComponent() }));
vi.mock("@/components/ui/dialog", () => ({ Dialog: passthrough("div"), DialogContent: passthrough("div"), DialogFooter: passthrough("div"), DialogHeader: passthrough("div"), DialogTitle: passthrough("div") }));
vi.mock("@/components/ui/popover", () => ({ Popover: passthrough("div"), PopoverContent: passthrough("div"), PopoverTrigger: passthrough("div") }));
vi.mock("@/components/ui/date-time-picker/DateTimePicker.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/meilisearch/MeilisearchMultiSelect.vue", () => ({ default: multiSelectComponent() }));
vi.mock("@/lib/backend/api", () => ({
meilisearchListKeys: mocks.listKeys,
meilisearchListIndexes: mocks.listIndexes,
meilisearchCreateKey: mocks.createKey,
meilisearchUpdateKey: mocks.updateKey,
meilisearchDeleteKey: mocks.deleteKey,
meilisearchGetKey: mocks.getKey,
}));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copy }));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: () => ({ read_only: false }) }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
import MeilisearchKeysPage from "@/components/meilisearch/MeilisearchKeysPage.vue";
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
const keyItem = {
uid: "923675a5-81da-4ea3-813c-f8edb32acd0d",
key: "full-key-value",
name: "Search key",
description: "Frontend",
maskedKey: "abc1...xyz9",
actions: ["search"],
indexes: ["wiki"],
expiresAt: null,
};
beforeEach(() => {
localStorage.removeItem("dbx:meilisearch:key-columns:v1");
mocks.listKeys.mockResolvedValue({ results: [keyItem], total: 1, offset: 0, limit: 20 });
mocks.listIndexes.mockResolvedValue(["wiki", "movies"]);
mocks.createKey.mockResolvedValue({ uid: "new-uid", key: "secret" });
mocks.deleteKey.mockResolvedValue(undefined);
mocks.copy.mockResolvedValue(undefined);
});
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
vi.clearAllMocks();
});
async function mountPage() {
root = document.createElement("div");
document.body.append(root);
app = createApp(MeilisearchKeysPage, { connectionId: "c1" });
app.mount(root);
await vi.waitFor(() => expect(mocks.listKeys).toHaveBeenCalledWith("c1", 0, 20));
await vi.waitFor(() => expect(mocks.listIndexes).toHaveBeenCalledWith("c1"));
await nextTick();
return root;
}
function inputValue(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
describe("MeilisearchKeysPage", () => {
it("loads accessible indexes and exposes the supported action choices", async () => {
const container = await mountPage();
const indexes = container.querySelector<HTMLSelectElement>('select[data-placeholder="meilisearch.indexesPlaceholder"]')!;
const actions = container.querySelector<HTMLSelectElement>('select[data-placeholder="meilisearch.actionsPlaceholder"]')!;
expect(Array.from(indexes.options).map((option) => option.value)).toEqual(["*", "movies", "wiki"]);
expect(Array.from(actions.options).map((option) => option.value)).toEqual([
"search",
"documents.add",
"documents.get",
"documents.delete",
"indexes.create",
"indexes.get",
"indexes.update",
"indexes.delete",
"tasks.get",
"settings.get",
"settings.update",
"stats.get",
"dumps.create",
"version",
"keys.get",
"keys.create",
"keys.update",
"keys.delete",
]);
expect(container.textContent).toContain("meilisearch.uidHelp");
expect(container.textContent).toContain("meilisearch.actionsHelp");
expect(container.textContent).toContain("meilisearch.expiresAtHelp");
});
it("normalizes a manually entered expiration and submits selected defaults", async () => {
const container = await mountPage();
const expiration = container.querySelector<HTMLInputElement>('input[placeholder="meilisearch.expiresAtPlaceholder"]')!;
inputValue(expiration, "2030-01-02 03:04:05Z");
const save = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "common.save")!;
save.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => expect(mocks.createKey).toHaveBeenCalledTimes(1));
expect(mocks.createKey).toHaveBeenCalledWith("c1", expect.objectContaining({ actions: ["search"], indexes: ["*"], expiresAt: "2030-01-02T03:04:05.000Z" }));
expect(container.querySelector('input[type="datetime-local"]')).toBeNull();
});
it("copies the full key while displaying only its masked value, and persists column visibility", async () => {
const container = await mountPage();
container.querySelector<HTMLButtonElement>('button[title="meilisearch.copyKeyValue"]')!.click();
await vi.waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("full-key-value"));
const checkboxes = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
expect(checkboxes).toHaveLength(6);
checkboxes[1].click();
const stored = JSON.parse(localStorage.getItem("dbx:meilisearch:key-columns:v1") || "null");
expect(stored.visible).not.toContain("key");
});
it("copies the full secret only from the one-time creation response", async () => {
const container = await mountPage();
const save = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "common.save")!;
save.click();
await vi.waitFor(() => expect(mocks.createKey).toHaveBeenCalledTimes(1));
const copy = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "common.copy")!;
copy.click();
await vi.waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("secret"));
});
it("requires the complete UID before deleting a key", async () => {
const container = await mountPage();
container.querySelector<HTMLButtonElement>('button[title="common.delete"]')!.click();
await nextTick();
const confirmation = container.querySelector<HTMLInputElement>('input[placeholder="meilisearch.deleteKeyTypePlaceholder"]')!;
const deleteButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent === "meilisearch.deleteKey")!;
expect(deleteButton.disabled).toBe(true);
inputValue(confirmation, keyItem.uid);
await nextTick();
expect(deleteButton.disabled).toBe(false);
deleteButton.click();
await vi.waitFor(() => expect(mocks.deleteKey).toHaveBeenCalledWith("c1", keyItem.uid));
});
});
@@ -0,0 +1,286 @@
// @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(() => ({
getTasks: vi.fn(),
getTask: vi.fn(),
cancelTasks: vi.fn(),
deleteTasks: vi.fn(),
listIndexes: vi.fn(),
toast: vi.fn(),
}));
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
function inputComponent(): Component {
return defineComponent({
inheritAttrs: false,
props: { modelValue: { type: [String, Number], default: "" } },
emits: ["update:modelValue"],
setup(props, { attrs, emit }) {
return () => h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) });
},
});
}
function selectComponent(): Component {
return defineComponent({
inheritAttrs: false,
props: { modelValue: { type: String, default: "" }, disabled: Boolean },
emits: ["update:modelValue"],
setup(props, { attrs, emit, slots }) {
return () => h("select", { ...attrs, disabled: props.disabled, value: props.modelValue, onChange: (event: Event) => emit("update:modelValue", (event.target as HTMLSelectElement).value) }, slots.default?.());
},
});
}
function fragmentComponent(): Component {
return defineComponent({
setup(_, { slots }) {
return () => slots.default?.();
},
});
}
function selectItemComponent(): Component {
return defineComponent({
props: { value: { type: String, required: true } },
setup(props, { slots }) {
return () => h("option", { value: props.value }, slots.default?.());
},
});
}
function confirmDialog(): Component {
return defineComponent({
inheritAttrs: false,
emits: ["confirm", "update:open"],
setup(_, { attrs, emit }) {
return () => (attrs.open ? h("button", { "data-confirm": "", "data-message": String(attrs.message), "data-selector": String(attrs.details), onClick: () => emit("confirm") }, "confirm") : null);
},
});
}
vi.mock("vue-i18n", () => ({
useI18n: () => ({ locale: ref("zh-CN"), t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key) }),
}));
vi.mock("@lucide/vue", () => ({
ArrowDown: passthrough("span"),
ArrowUp: passthrough("span"),
Ban: passthrough("span"),
ChevronLeft: passthrough("span"),
ChevronRight: passthrough("span"),
Eye: passthrough("span"),
Loader2: passthrough("span"),
RefreshCcw: passthrough("span"),
Search: passthrough("span"),
Settings2: passthrough("span"),
Trash2: passthrough("span"),
}));
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/input", () => ({ Input: inputComponent() }));
vi.mock("@/components/ui/select", () => ({ Select: selectComponent(), SelectContent: fragmentComponent(), SelectItem: selectItemComponent(), SelectTrigger: fragmentComponent(), SelectValue: defineComponent({ setup: () => () => null }) }));
vi.mock("@/components/ui/popover", () => ({ Popover: passthrough("div"), PopoverContent: passthrough("div"), PopoverTrigger: passthrough("div") }));
vi.mock("@/components/ui/dialog", () => ({ Dialog: passthrough("div"), DialogContent: passthrough("div"), DialogFooter: passthrough("div"), DialogHeader: passthrough("div"), DialogTitle: passthrough("div") }));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: confirmDialog() }));
vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
vi.mock("@/lib/backend/api", () => ({
meilisearchGetTasks: mocks.getTasks,
meilisearchGetTask: mocks.getTask,
meilisearchCancelTasks: mocks.cancelTasks,
meilisearchDeleteTasks: mocks.deleteTasks,
meilisearchListIndexes: mocks.listIndexes,
}));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: () => ({ read_only: false }) }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
import MeilisearchTasksPage from "@/components/meilisearch/MeilisearchTasksPage.vue";
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
beforeEach(() => {
localStorage.removeItem("dbx:meilisearch:task-columns:v1");
mocks.getTasks.mockResolvedValue({ results: [], total: 7, limit: 20, from: null, next: null });
mocks.getTask.mockResolvedValue({ uid: 1, indexUid: "movies", status: "failed", type: "futureType", enqueuedAt: "2026-01-01T00:00:00Z" });
mocks.cancelTasks.mockImplementation(() => new Promise(() => {}));
mocks.listIndexes.mockResolvedValue(["movies", "books"]);
});
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
vi.clearAllMocks();
});
async function mountPage(props: { fixedIndexUid?: string } = {}) {
root = document.createElement("div");
document.body.append(root);
app = createApp(MeilisearchTasksPage, { connectionId: "c1", ...props });
app.mount(root);
await vi.waitFor(() => expect(mocks.getTasks).toHaveBeenCalledTimes(1));
await nextTick();
return root;
}
function setInput(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function pressEnter(input: HTMLInputElement) {
input.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true }));
}
function setSelect(select: HTMLSelectElement, value: string) {
select.value = value;
select.dispatchEvent(new Event("change", { bubbles: true }));
}
describe("MeilisearchTasksPage mutation safety", () => {
it("counts, displays, and submits the same frozen selector for a single task", async () => {
mocks.getTasks.mockResolvedValueOnce({ results: [{ uid: 9, indexUid: "movies", status: "processing", type: "futureType", enqueuedAt: "2026-01-01T00:00:00Z" }], total: 1, limit: 20, from: null, next: null });
const container = await mountPage();
mocks.getTasks.mockResolvedValueOnce({ results: [], total: 2, limit: 1, from: null, next: null });
container.querySelector<HTMLButtonElement>('button[title="meilisearch.cancelTask"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => expect(mocks.getTasks).toHaveBeenCalledTimes(2));
const frozen = { uids: [9], statuses: ["enqueued", "processing"] };
expect(mocks.getTasks).toHaveBeenLastCalledWith("c1", { selector: frozen, from: null, limit: 1 });
await vi.waitFor(() => expect(container.querySelector("[data-confirm]")).not.toBeNull());
const confirm = container.querySelector<HTMLButtonElement>("[data-confirm]")!;
expect(confirm.dataset.selector).toBe(JSON.stringify(frozen, null, 2));
expect(confirm.dataset.message).toContain('"count":2');
confirm.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => expect(mocks.cancelTasks).toHaveBeenCalledTimes(1));
expect(mocks.cancelTasks).toHaveBeenCalledWith("c1", frozen);
});
it("blocks another task mutation while its management task is still being polled", async () => {
mocks.getTasks.mockResolvedValueOnce({
results: [
{ uid: 9, indexUid: "movies", status: "processing", type: "futureType", enqueuedAt: "2026-01-01T00:00:00Z" },
{ uid: 10, indexUid: "movies", status: "processing", type: "futureType", enqueuedAt: "2026-01-01T00:00:00Z" },
],
total: 2,
limit: 20,
from: null,
next: null,
});
mocks.cancelTasks.mockResolvedValueOnce({ taskUid: 99, indexUid: null, status: "enqueued", type: "taskCancelation", enqueuedAt: "2026-01-01T00:00:00Z" });
const container = await mountPage();
mocks.getTasks.mockResolvedValueOnce({ results: [], total: 1, limit: 1, from: null, next: null });
container.querySelectorAll<HTMLButtonElement>('button[title="meilisearch.cancelTask"]')[0].click();
await vi.waitFor(() => expect(container.querySelector("[data-confirm]")).not.toBeNull());
container.querySelector<HTMLButtonElement>("[data-confirm]")!.click();
await vi.waitFor(() => expect(mocks.cancelTasks).toHaveBeenCalledTimes(1));
await vi.waitFor(() => {
const buttons = container.querySelectorAll<HTMLButtonElement>('button[title="meilisearch.cancelTask"]');
expect(Array.from(buttons).every((button) => button.disabled)).toBe(true);
});
});
it("runs a UID search only when the user presses Enter", async () => {
const container = await mountPage();
const input = container.querySelectorAll("input")[0] as HTMLInputElement;
setInput(input, "9, 10");
await nextTick();
expect(mocks.getTasks).toHaveBeenCalledTimes(1);
pressEnter(input);
await vi.waitFor(() => expect(mocks.getTasks).toHaveBeenCalledTimes(2));
expect(mocks.getTasks).toHaveBeenLastCalledWith("c1", { selector: { uids: [9, 10] }, from: null, limit: 20 });
});
it("loads accessible indexes and applies the selected index and status", async () => {
const container = await mountPage();
await vi.waitFor(() => expect(mocks.listIndexes).toHaveBeenCalledWith("c1"));
const selects = container.querySelectorAll("select");
expect(Array.from(selects[0].options).map((option) => option.value)).toEqual(["__all__", "books", "movies"]);
setSelect(selects[0], "movies");
await vi.waitFor(() => expect(mocks.getTasks).toHaveBeenCalledTimes(2));
setSelect(selects[1], "succeeded");
await vi.waitFor(() => expect(mocks.getTasks).toHaveBeenCalledTimes(3));
expect(mocks.getTasks).toHaveBeenLastCalledWith("c1", {
selector: { indexUids: ["movies"], statuses: ["succeeded"] },
from: null,
limit: 20,
});
});
it("keeps compact filters visible and removes bulk actions and unsupported sorting", async () => {
const container = await mountPage();
expect(container.querySelector('input[aria-label="meilisearch.uidFilter"]')).not.toBeNull();
expect(container.querySelector('input[placeholder="meilisearch.searchUid"]')).not.toBeNull();
expect(container.textContent).not.toContain("meilisearch.cancelFilteredTasks");
expect(container.textContent).not.toContain("meilisearch.deleteFilteredHistory");
expect(container.querySelector('button[title="meilisearch.sortOldestFirst"]')).toBeNull();
expect(container.querySelector('button[title="meilisearch.sortNewestFirst"]')).toBeNull();
const reset = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("meilisearch.resetFilters"));
expect(reset?.parentElement?.className).toContain("items-center");
expect(mocks.getTasks).toHaveBeenLastCalledWith("c1", { selector: {}, from: null, limit: 20 });
});
it("passes the fixed index to the backend task-detail guard", async () => {
mocks.getTasks.mockResolvedValueOnce({ results: [{ uid: 1, indexUid: "movies", status: "failed", type: "futureType", enqueuedAt: "2026-01-01T00:00:00Z" }], total: 1, limit: 20, from: null, next: null });
const container = await mountPage({ fixedIndexUid: "movies" });
container.querySelector<HTMLButtonElement>('button[title="common.view"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => expect(mocks.getTask).toHaveBeenCalledTimes(1));
expect(mocks.getTask).toHaveBeenCalledWith("c1", 1, "movies");
});
it("renders task status, enqueue time, and duration in readable form", async () => {
mocks.getTasks.mockResolvedValueOnce({
results: [{ uid: 8, indexUid: "movies", status: "failed", type: "futureType", details: { receivedDocuments: 8, indexedDocuments: 8 }, enqueuedAt: "2026-01-01T00:00:00Z", startedAt: "2026-01-01T00:00:01Z", finishedAt: "2026-01-01T00:00:02Z", duration: "PT2.16344321S" }],
total: 1,
limit: 20,
from: null,
next: null,
});
const container = await mountPage();
expect(container.textContent).toContain("❌ Failed");
expect(container.textContent).toContain("meilisearch.receivedDocuments: 8 · meilisearch.indexedDocuments: 8");
expect(container.textContent).toContain("meilisearch.startedAt");
expect(container.textContent).toContain("meilisearch.finishedAt");
expect(container.textContent).toContain("2.16");
expect(container.textContent).not.toContain("PT2.16344321S");
expect(container.textContent).not.toContain("2026-01-01T00:00:00Z");
expect(container.querySelector('[data-testid="tasks-header"]')?.className).not.toContain("sticky");
expect(container.querySelector('[data-testid="tasks-body"]')?.className).toContain("overflow-y-auto");
});
it("persists task column visibility changes", async () => {
const container = await mountPage();
expect(container.querySelector('[data-testid="tasks-header"] button[aria-label="meilisearch.columnSettings"]')).not.toBeNull();
expect(container.querySelector('[data-testid="tasks-grid"]')?.className).toContain("overflow-hidden");
const checkboxes = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
expect(checkboxes).toHaveLength(9);
checkboxes[4].checked = false;
checkboxes[4].dispatchEvent(new Event("change", { bubbles: true }));
await nextTick();
const stored = JSON.parse(localStorage.getItem("dbx:meilisearch:task-columns:v1") || "null");
expect(stored.visible).not.toContain("details");
expect(container.querySelector('[role="columnheader"][data-column="details"]')).toBeNull();
});
});
@@ -656,6 +656,8 @@ async function openDirectNavigationNode(node: TreeNode, requestId: number) {
queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label });
} else if (node.type === "nacos-access-control") {
queryStore.createTab(node.connectionId, "", `${connectionName}:access-control`, "nacos-access-control");
} else if (node.type === "meilisearch-system") {
queryStore.createTab(node.connectionId, "default", t("meilisearch.systemManagement"), "meilisearch-system");
}
}
@@ -325,6 +325,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: TableProperties, colorClass: "text-cyan-400" };
case "elasticsearch-index":
return { icon: Table, colorClass: "text-emerald-400" };
case "meilisearch-system":
return { icon: Gauge, colorClass: "text-emerald-500" };
case "procedure":
return { icon: ScrollText, colorClass: "text-blue-500" };
case "function":
@@ -386,7 +388,7 @@ function displayLabel(node: TreeNode): string {
// Use the canonical key for persisted trees created before this label was
// internationalized; those nodes may still contain the old Chinese text.
if (node.type === "nacos-access-control") return t("nacos.accessControlSidebarLabel");
if (node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin") return t(node.label);
if (node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "meilisearch-system") return t(node.label);
if (node.type === "linked-server-root") return t(node.label);
if (node.type === "saved-sql-root") return t(node.label);
if (node.type === "mqtt-topic" && node.id.endsWith(":mqtt-topic:__console__")) return t(node.label);
@@ -192,6 +192,38 @@ describe("SidebarTreeRuntimeHost expansion", () => {
expect(queryStore.openNacosAdmin).not.toHaveBeenCalled();
});
it("opens the Meilisearch system workspace from its direct-navigation node", async () => {
connectionStore.getConfig.mockReturnValue({ db_type: "meilisearch", name: "local-meilisearch" });
const systemNode: TreeNode = {
id: "meili:meilisearch-system",
label: "meilisearch.systemManagement",
type: "meilisearch-system",
connectionId: "meili",
isExpanded: true,
children: [],
};
connectionStore.treeNodes = [systemNode];
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: systemNode, depth: 0 }),
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
await nextTick();
host.value!.handleRowClick(systemNode, 1);
await vi.waitFor(() => expect(connectionStore.ensureConnected).toHaveBeenCalledWith("meili", undefined));
expect(queryStore.createTab).toHaveBeenCalledWith("meili", "default", expect.any(String), "meilisearch-system");
expect(systemNode.isExpanded).toBe(true);
});
it("expands an unloaded object group without leaking the regex source as a search filter", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
+2
View File
@@ -1,6 +1,7 @@
import docs from "./docs/en";
import { consulUiMessages } from "./consulUi";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
import { meilisearchManagementEn } from "./meilisearchManagement";
const consul = {
...consulUiMessages,
@@ -4732,6 +4733,7 @@ export default {
sortPlaceholder: "Sort...",
},
meilisearch: {
...meilisearchManagementEn,
documents: "Documents",
settings: "Settings",
search: "Search",
+2
View File
@@ -1,4 +1,5 @@
import { withEnglishFallback } from "./fallback";
import { meilisearchManagementEs } from "./meilisearchManagement";
import docs from "./docs/es";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
@@ -4561,6 +4562,7 @@ export default withEnglishFallback({
indexPropertiesUnavailable: "El controlador Legacy de MongoDB no puede informar disperso, expiración, background o tamaño de bucket. Conéctese con el controlador nativo para verlos.",
},
meilisearch: {
...meilisearchManagementEs,
documents: "Documentos",
settings: "Ajustes",
search: "Buscar",
+2
View File
@@ -1,4 +1,5 @@
import { withEnglishFallback } from "./fallback";
import { meilisearchManagementIt } from "./meilisearchManagement";
import docs from "./docs/it";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
@@ -4559,6 +4560,7 @@ export default withEnglishFallback({
indexPropertiesUnavailable: "Il driver Legacy di MongoDB non può riportare sparsità, scadenza, background o dimensione bucket. Connettersi con il driver nativo per vederli.",
},
meilisearch: {
...meilisearchManagementIt,
documents: "Documenti",
settings: "Impostazioni",
search: "Cerca",
+2
View File
@@ -3,6 +3,7 @@ import docs from "./docs/ja";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
import { meilisearchManagementJa } from "./meilisearchManagement";
const consul = {
...consulUiMessages,
@@ -4588,6 +4589,7 @@ export default withEnglishFallback({
indexPropertiesUnavailable: "MongoDB Legacy ドライバーはスパース、有効期限、バックグラウンド、バケットサイズを報告できません。ネイティブドライバーで接続して確認してください。",
},
meilisearch: {
...meilisearchManagementJa,
documents: "ドキュメント",
settings: "設定",
search: "検索",
+2
View File
@@ -1,4 +1,5 @@
import { withEnglishFallback } from "./fallback";
import { meilisearchManagementKo } from "./meilisearchManagement";
import docs from "./docs/ko";
import { consulUiMessages } from "./consulUi";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
@@ -4179,6 +4180,7 @@ export default withEnglishFallback({
indexPropertiesUnavailable: "MongoDB Legacy 드라이버는 희소, 만료, 백그라운드 또는 버킷 크기를 보고할 수 없습니다. 네이티브 드라이버로 연결하여 확인하세요.",
},
meilisearch: {
...meilisearchManagementKo,
documents: "문서",
settings: "설정",
search: "검색",
@@ -0,0 +1,397 @@
const en = {
systemManagement: "System management",
overview: "Overview",
overviewDescription: "Live health, capacity, indexes, keys, and task activity.",
apiKeys: "API Keys",
keysDescription: "Create and manage scoped Meilisearch API keys.",
tasks: "Tasks",
tasksDescription: "Inspect asynchronous operations, cancel active tasks, and clean completed history.",
health: "Health",
version: "Version",
indexCount: "Indexes",
totalDocuments: "Total documents",
indexingCount: "Indexing",
keyCount: "Keys",
storage: "Storage",
usedDatabaseSize: "Used database size",
lastUpdate: "Last update",
taskStatusDistribution: "Task status distribution",
topIndexes: "Top indexes by documents",
status: "Status",
idle: "Idle",
sectionStatus: { available: "Available", forbidden: "Permission denied", unsupported: "Unsupported by this server", error: "Unavailable" },
refresh: "Refresh",
rowActions: "Actions",
previous: "Previous",
next: "Next",
name: "Name",
key: "Key",
description: "Description",
actions: "Actions",
indexes: "Indexes",
expiresAt: "Expires at",
never: "Never",
createKey: "Create key",
editKey: "Edit key",
deleteKey: "Delete key",
keyDetails: "Key details",
keyCreated: "API key created",
keyUpdated: "API key updated",
keyDeleted: "API key deleted",
noKeys: "No API keys found",
deleteKeyConfirm: "Delete API key {uid}? This cannot be undone.",
deleteKeyTypePrompt: "Enter the complete key UID to confirm deletion.",
deleteKeyTypePlaceholder: "Enter key UID",
immutableKeyFields: "Actions, indexes, expiration, UID, and secret are immutable. Recreate the key to change them.",
secretShownOnce: "Copy this secret now. DBX never persists API key values.",
actionsIndexesRequired: "At least one action and one index are required.",
uidPlaceholder: "Leave empty to generate a random UID",
uidHelp: "Optional. When empty, Meilisearch generates a random UID.",
namePlaceholder: "Name your key",
nameHelp: "Optional name used to identify this key.",
descriptionPlaceholder: "Add an optional description",
descriptionHelp: "Optional note describing where or why the key is used.",
indexesPlaceholder: "Select permitted indexes",
indexesHelp: "Choose one or more accessible indexes. * grants access to every index.",
actionsPlaceholder: "Select permitted actions",
actionsHelp: "Choose the API operations this key is allowed to perform.",
expiresAtPlaceholder: "Select or enter a date and time",
expiresAtHelp: "Optional. Leave empty for no expiration, or enter a valid date and time.",
openDateTimePicker: "Open date and time picker",
invalidExpiresAt: "Enter a valid expiration date and time.",
copyKeyValue: "Copy API key",
paginationSummary: "{start}-{end} of {total}",
readOnlyDisabled: "This connection is read-only; management actions are disabled.",
filters: "Filters",
applyFilters: "Apply filters",
resetFilters: "Reset filters",
uidFilter: "UID filter",
searchUid: "Search UID",
allIndexes: "All indexes",
allStatuses: "All statuses",
indexListUnavailable: "Unable to load the accessible index list",
reverseOrder: "Reverse order",
columnSettings: "Set columns",
index: "Index",
type: "Type",
details: "Details",
receivedDocuments: "Received documents",
indexedDocuments: "Indexed documents",
enqueuedAt: "Enqueued at",
startedAt: "Started at",
finishedAt: "Finished at",
duration: "Duration",
afterEnqueuedAt: "Enqueued after",
beforeEnqueuedAt: "Enqueued before",
afterStartedAt: "Started after",
beforeStartedAt: "Started before",
afterFinishedAt: "Finished after",
beforeFinishedAt: "Finished before",
indexTasks: "Tasks · {index}",
noTasks: "No tasks found",
taskCount: "{count} matching tasks",
taskDetails: "Task details",
cancelTask: "Cancel task",
cancelTasks: "Cancel tasks",
deleteTaskHistory: "Delete task history",
cancelFilteredTasks: "Cancel filtered active tasks",
deleteFilteredHistory: "Delete filtered completed history",
explicitFilterRequired: "Choose at least one explicit filter before a batch operation.",
noAllowedTaskStatuses: "The applied filter contains no task status allowed for this operation.",
countingMatchingTasks: "Counting matching tasks...",
cancelTasksConfirm: "Cancel active tasks matching this selector ({count} current matches)?",
deleteTasksConfirm: "Delete completed task history matching this selector ({count} current matches)?",
waitingForTask: "Waiting for management task #{uid}",
taskOperationCompleted: "Task operation completed",
taskOperationFinished: "Task operation finished with status {status}",
taskPollingTimeout: "Task #{uid} is still pending after 60 seconds. Refresh manually to check it.",
taskOutsideIndex: "This task does not belong to the current index.",
};
const zhCN = {
...en,
systemManagement: "系统管理",
overview: "总览",
overviewDescription: "实时查看健康状态、容量、索引、密钥与任务活动。",
apiKeys: "API 密钥",
keysDescription: "创建和管理限定权限的 Meilisearch API 密钥。",
tasks: "任务",
tasksDescription: "查看异步操作、取消活动任务并清理已完成历史。",
health: "健康状态",
version: "版本",
indexCount: "索引数",
totalDocuments: "文档总数",
indexingCount: "正在索引",
keyCount: "密钥数",
storage: "存储",
usedDatabaseSize: "已用数据库大小",
lastUpdate: "最后更新",
taskStatusDistribution: "任务状态分布",
topIndexes: "文档数 Top 索引",
status: "状态",
idle: "空闲",
sectionStatus: { available: "可用", forbidden: "权限不足", unsupported: "当前服务不支持", error: "不可用" },
refresh: "刷新",
rowActions: "操作",
previous: "上一页",
next: "下一页",
name: "名称",
key: "密钥",
description: "描述",
actions: "权限动作",
indexes: "索引范围",
expiresAt: "过期时间",
never: "永不过期",
createKey: "创建密钥",
editKey: "编辑密钥",
deleteKey: "删除密钥",
keyDetails: "密钥详情",
keyCreated: "API 密钥已创建",
keyUpdated: "API 密钥已更新",
keyDeleted: "API 密钥已删除",
noKeys: "没有 API 密钥",
deleteKeyConfirm: "确定删除 API 密钥 {uid}?此操作无法撤销。",
deleteKeyTypePrompt: "请输入完整的密钥 UID 以确认删除。",
deleteKeyTypePlaceholder: "输入密钥 UID",
immutableKeyFields: "权限动作、索引范围、过期时间、UID 和密钥不可修改;如需变更请重新创建。",
secretShownOnce: "请立即复制此密钥。DBX 不会持久化 API 密钥值。",
actionsIndexesRequired: "至少需要一个权限动作和一个索引范围。",
uidPlaceholder: "留空以自动生成随机 UID",
uidHelp: "可选。留空时由 Meilisearch 自动生成随机 UID。",
namePlaceholder: "为密钥命名",
nameHelp: "可选,用于帮助识别这条密钥。",
descriptionPlaceholder: "添加密钥用途说明",
descriptionHelp: "可选,用于记录密钥的使用位置或用途。",
indexesPlaceholder: "选择允许访问的索引",
indexesHelp: "可选择一个或多个当前有权访问的索引;* 表示所有索引。",
actionsPlaceholder: "选择允许执行的权限动作",
actionsHelp: "选择该密钥可以调用的 API 操作,可多选。",
expiresAtPlaceholder: "选择或输入日期时间",
expiresAtHelp: "可选。留空表示永不过期,也可直接输入有效的日期时间。",
openDateTimePicker: "打开日期时间选择器",
invalidExpiresAt: "请输入有效的过期日期和时间。",
copyKeyValue: "复制 API 密钥",
paginationSummary: "第 {start}-{end} 条,共 {total} 条",
readOnlyDisabled: "当前连接为只读模式,管理操作已禁用。",
filters: "筛选",
applyFilters: "应用筛选",
resetFilters: "重置筛选",
uidFilter: "UID 过滤",
searchUid: "搜索 UID",
allIndexes: "全部索引",
allStatuses: "全部状态",
indexListUnavailable: "无法加载当前连接有权限访问的索引",
reverseOrder: "倒序",
columnSettings: "设置列",
index: "索引",
type: "类型",
details: "详情",
receivedDocuments: "接收文档数",
indexedDocuments: "已索引文档数",
enqueuedAt: "入列时间",
startedAt: "开始时间",
finishedAt: "结束时间",
duration: "耗时",
afterEnqueuedAt: "入列晚于",
beforeEnqueuedAt: "入列早于",
afterStartedAt: "开始晚于",
beforeStartedAt: "开始早于",
afterFinishedAt: "完成晚于",
beforeFinishedAt: "完成早于",
indexTasks: "任务 · {index}",
noTasks: "没有匹配的任务",
taskCount: "匹配 {count} 个任务",
taskDetails: "任务详情",
cancelTask: "取消任务",
cancelTasks: "取消任务",
deleteTaskHistory: "删除任务历史",
cancelFilteredTasks: "取消筛选出的活动任务",
deleteFilteredHistory: "删除筛选出的已完成历史",
explicitFilterRequired: "批量操作前必须明确设置至少一个筛选条件。",
noAllowedTaskStatuses: "已应用的筛选条件中没有此操作允许的任务状态。",
countingMatchingTasks: "正在统计匹配任务...",
cancelTasksConfirm: "确定取消此筛选器匹配的活动任务(当前匹配 {count} 条)?",
deleteTasksConfirm: "确定删除此筛选器匹配的已完成任务历史(当前匹配 {count} 条)?",
waitingForTask: "正在等待管理任务 #{uid}",
taskOperationCompleted: "任务操作已完成",
taskOperationFinished: "任务操作结束,状态为 {status}",
taskPollingTimeout: "任务 #{uid} 在 60 秒后仍未完成,请手动刷新查看。",
taskOutsideIndex: "该任务不属于当前索引。",
};
export const meilisearchManagementMessages = {
en,
"zh-CN": zhCN,
"zh-TW": {
...en,
...zhCN,
systemManagement: "系統管理",
overview: "總覽",
keysDescription: "建立和管理限定權限的 Meilisearch API 金鑰。",
tasks: "任務",
health: "健康狀態",
totalDocuments: "文件總數",
keyCount: "金鑰數",
refresh: "重新整理",
previous: "上一頁",
next: "下一頁",
rowActions: "操作",
createKey: "建立金鑰",
editKey: "編輯金鑰",
deleteKey: "刪除金鑰",
receivedDocuments: "接收文件數",
indexedDocuments: "已索引文件數",
noTasks: "沒有符合的任務",
readOnlyDisabled: "目前連線為唯讀模式,管理操作已停用。",
},
ja: {
...en,
systemManagement: "システム管理",
overview: "概要",
overviewDescription: "ヘルス、容量、インデックス、キー、タスクをリアルタイムで確認します。",
apiKeys: "API キー",
tasks: "タスク",
health: "ヘルス",
version: "バージョン",
indexCount: "インデックス",
totalDocuments: "ドキュメント合計",
keyCount: "キー",
refresh: "更新",
rowActions: "操作",
previous: "前へ",
next: "次へ",
createKey: "キーを作成",
editKey: "キーを編集",
deleteKey: "キーを削除",
noKeys: "API キーがありません",
filters: "フィルター",
applyFilters: "適用",
receivedDocuments: "受信ドキュメント数",
indexedDocuments: "インデックス済みドキュメント数",
noTasks: "タスクがありません",
cancelTask: "タスクをキャンセル",
deleteTaskHistory: "タスク履歴を削除",
readOnlyDisabled: "読み取り専用接続では管理操作を実行できません。",
},
ko: {
...en,
systemManagement: "시스템 관리",
overview: "개요",
apiKeys: "API 키",
tasks: "작업",
health: "상태",
version: "버전",
indexCount: "인덱스",
totalDocuments: "전체 문서",
keyCount: "키",
refresh: "새로 고침",
rowActions: "작업",
previous: "이전",
next: "다음",
createKey: "키 만들기",
editKey: "키 편집",
deleteKey: "키 삭제",
noKeys: "API 키가 없습니다",
filters: "필터",
applyFilters: "필터 적용",
receivedDocuments: "수신 문서 수",
indexedDocuments: "인덱싱된 문서 수",
noTasks: "작업이 없습니다",
cancelTask: "작업 취소",
deleteTaskHistory: "작업 기록 삭제",
readOnlyDisabled: "읽기 전용 연결에서는 관리 작업이 비활성화됩니다.",
},
es: {
...en,
systemManagement: "Administración del sistema",
overview: "Resumen",
apiKeys: "Claves API",
tasks: "Tareas",
health: "Estado",
version: "Versión",
indexCount: "Índices",
totalDocuments: "Documentos totales",
keyCount: "Claves",
refresh: "Actualizar",
rowActions: "Acciones",
previous: "Anterior",
next: "Siguiente",
createKey: "Crear clave",
editKey: "Editar clave",
deleteKey: "Eliminar clave",
noKeys: "No hay claves API",
filters: "Filtros",
applyFilters: "Aplicar filtros",
receivedDocuments: "Documentos recibidos",
indexedDocuments: "Documentos indexados",
noTasks: "No hay tareas",
cancelTask: "Cancelar tarea",
deleteTaskHistory: "Eliminar historial",
readOnlyDisabled: "Las acciones están deshabilitadas en conexiones de solo lectura.",
},
it: {
...en,
systemManagement: "Gestione sistema",
overview: "Panoramica",
apiKeys: "Chiavi API",
tasks: "Attività",
health: "Stato",
version: "Versione",
indexCount: "Indici",
totalDocuments: "Documenti totali",
keyCount: "Chiavi",
refresh: "Aggiorna",
rowActions: "Azioni",
previous: "Precedente",
next: "Successivo",
createKey: "Crea chiave",
editKey: "Modifica chiave",
deleteKey: "Elimina chiave",
noKeys: "Nessuna chiave API",
filters: "Filtri",
applyFilters: "Applica filtri",
receivedDocuments: "Documenti ricevuti",
indexedDocuments: "Documenti indicizzati",
noTasks: "Nessuna attività",
cancelTask: "Annulla attività",
deleteTaskHistory: "Elimina cronologia",
readOnlyDisabled: "Le azioni sono disabilitate per le connessioni di sola lettura.",
},
"pt-BR": {
...en,
systemManagement: "Gerenciamento do sistema",
overview: "Visão geral",
apiKeys: "Chaves de API",
tasks: "Tarefas",
health: "Saúde",
version: "Versão",
indexCount: "Índices",
totalDocuments: "Total de documentos",
keyCount: "Chaves",
refresh: "Atualizar",
rowActions: "Ações",
previous: "Anterior",
next: "Próxima",
createKey: "Criar chave",
editKey: "Editar chave",
deleteKey: "Excluir chave",
noKeys: "Nenhuma chave de API",
filters: "Filtros",
applyFilters: "Aplicar filtros",
receivedDocuments: "Documentos recebidos",
indexedDocuments: "Documentos indexados",
noTasks: "Nenhuma tarefa",
cancelTask: "Cancelar tarefa",
deleteTaskHistory: "Excluir histórico",
readOnlyDisabled: "As ações ficam desativadas em conexões somente leitura.",
},
} as const;
export const meilisearchManagementEn = meilisearchManagementMessages.en;
export const meilisearchManagementZhCN = meilisearchManagementMessages["zh-CN"];
export const meilisearchManagementZhTW = meilisearchManagementMessages["zh-TW"];
export const meilisearchManagementJa = meilisearchManagementMessages.ja;
export const meilisearchManagementKo = meilisearchManagementMessages.ko;
export const meilisearchManagementEs = meilisearchManagementMessages.es;
export const meilisearchManagementIt = meilisearchManagementMessages.it;
export const meilisearchManagementPtBR = meilisearchManagementMessages["pt-BR"];
+2
View File
@@ -1,4 +1,5 @@
import { withEnglishFallback } from "./fallback";
import { meilisearchManagementPtBR } from "./meilisearchManagement";
import docs from "./docs/pt-BR";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
@@ -4561,6 +4562,7 @@ export default withEnglishFallback({
indexPropertiesUnavailable: "O driver Legacy do MongoDB não pode informar esparso, expiração, background ou tamanho do bucket. Conecte-se com o driver nativo para vê-los.",
},
meilisearch: {
...meilisearchManagementPtBR,
documents: "Documentos",
settings: "Configurações",
search: "Buscar",
+2
View File
@@ -1,6 +1,7 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/zh-CN";
import { consul } from "./consulZhCN";
import { meilisearchManagementZhCN } from "./meilisearchManagement";
export default withEnglishFallback({
customType: {
@@ -4719,6 +4720,7 @@ export default withEnglishFallback({
sortPlaceholder: "排序条件...",
},
meilisearch: {
...meilisearchManagementZhCN,
documents: "文档",
settings: "设置",
search: "搜索",
+2
View File
@@ -1,4 +1,5 @@
import { withEnglishFallback } from "./fallback";
import { meilisearchManagementZhTW } from "./meilisearchManagement";
import docs from "./docs/zh-TW";
import { consulUiMessages } from "./consulUi";
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
@@ -3885,6 +3886,7 @@ export default withEnglishFallback({
nativeDocumentRequired: "原生 JSON 的最上層必須是文件物件。",
},
meilisearch: {
...meilisearchManagementZhTW,
documents: "文件",
settings: "設定",
search: "搜尋",
@@ -11,6 +11,12 @@ describe("sidebar active-tab targets", () => {
expect(matchesTarget({ id: "connection:pg-2", label: "Other", type: "connection", connectionId: "pg-2" } as TreeNode, target!)).toBe(false);
});
it("selects the Meilisearch system-management leaf for its dedicated tab", () => {
const target = activeTabSidebarTarget({ mode: "meilisearch-system", connectionId: "meili-1" } as QueryTab);
expect(target).toEqual({ type: "meilisearch-system", connectionId: "meili-1" });
expect(matchesTarget({ id: "meili-system", label: "meilisearch.systemManagement", type: "meilisearch-system", connectionId: "meili-1" }, target!)).toBe(true);
});
it("locates a query context only in its exact catalog", () => {
const target = activeTabSidebarTarget({ mode: "query", connectionId: "doris-1", catalog: "hive", database: "analytics" } as QueryTab);
const hiveDatabase = { id: "hive:analytics", label: "analytics", type: "database", connectionId: "doris-1", catalog: "hive", database: "analytics" } as TreeNode;
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
import type { TreeNode } from "@/types/database";
describe("Meilisearch sidebar ordering", () => {
it("keeps the single system-management node after all indexes", () => {
const children: TreeNode[] = [
{ id: "system", label: "meilisearch.systemManagement", type: "meilisearch-system", connectionId: "c1" },
{ id: "z", label: "zebra", type: "elasticsearch-index", connectionId: "c1" },
{ id: "a", label: "alpha", type: "elasticsearch-index", connectionId: "c1" },
];
const result = sortSidebarTreeChildrenForParent({ type: "connection" }, children, "meilisearch");
expect(result.map((node) => node.id)).toEqual(["a", "z", "system"]);
expect(result.filter((node) => node.type === "meilisearch-system")).toHaveLength(1);
});
});
@@ -59,6 +59,12 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("consul-overview", false, "double")).toBe("toggle");
});
it("opens Meilisearch system management as a direct leaf navigation entry", () => {
expect(isDirectNavigationTreeNode("meilisearch-system")).toBe(true);
expect(shouldActivateTreeNodeOnSingleClick("meilisearch-system", "double")).toBe(true);
expect(treeNodeRowAction("meilisearch-system", false, "double")).toBe("toggle");
});
it("keeps Nacos namespace and access-control navigation responsive during rapid row switching", () => {
for (const type of ["nacos-namespace", "nacos-access-control"] as const) {
expect(isDirectNavigationTreeNode(type), type).toBe(true);
+11
View File
@@ -754,6 +754,17 @@ export const meilisearchGetIndexStats = forward("meilisearchGetIndexStats");
export const meilisearchGetIndexOverview = forward("meilisearchGetIndexOverview");
export const meilisearchDeleteIndex = forward("meilisearchDeleteIndex");
export const meilisearchDeleteAllDocuments = forward("meilisearchDeleteAllDocuments");
export const meilisearchGetSystemOverview = forward("meilisearchGetSystemOverview");
export const meilisearchListIndexes = forward("meilisearchListIndexes");
export const meilisearchListKeys = forward("meilisearchListKeys");
export const meilisearchGetKey = forward("meilisearchGetKey");
export const meilisearchCreateKey = forward("meilisearchCreateKey");
export const meilisearchUpdateKey = forward("meilisearchUpdateKey");
export const meilisearchDeleteKey = forward("meilisearchDeleteKey");
export const meilisearchGetTasks = forward("meilisearchGetTasks");
export const meilisearchGetTask = forward("meilisearchGetTask");
export const meilisearchCancelTasks = forward("meilisearchCancelTasks");
export const meilisearchDeleteTasks = forward("meilisearchDeleteTasks");
export const mongoDeleteDocument = forward("mongoDeleteDocument");
export const mongoDeleteDocuments = forward("mongoDeleteDocuments");
export const mongoFindOneAndUpdate = forward("mongoFindOneAndUpdate");
+47
View File
@@ -48,6 +48,7 @@ import type {
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement";
import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
@@ -3659,6 +3660,12 @@ export async function elasticsearchListIndices(connectionId: string): Promise<st
return collections.map((c) => c.name);
}
/** Lists every Meilisearch index visible to the current connection credentials. */
export async function meilisearchListIndexes(connectionId: string): Promise<string[]> {
const collections = await documentListCollections(connectionId, "default");
return collections.map((collection) => collection.name);
}
export async function vectorListCollections(connectionId: string, database?: string): Promise<CollectionInfo[]> {
return documentListCollections(connectionId, database || "default");
}
@@ -4073,6 +4080,46 @@ export async function meilisearchDeleteAllDocuments(connectionId: string, index:
});
}
export async function meilisearchGetSystemOverview(connectionId: string): Promise<MeilisearchSystemOverview> {
return post("/api/document-store/meilisearch/system/overview", { connectionId });
}
export async function meilisearchListKeys(connectionId: string, offset = 0, limit = 20): Promise<KeyPage> {
return post("/api/document-store/meilisearch/keys/list", { connectionId, offset, limit });
}
export async function meilisearchGetKey(connectionId: string, uid: string): Promise<KeyListItem> {
return post("/api/document-store/meilisearch/keys/get", { connectionId, uid });
}
export async function meilisearchCreateKey(connectionId: string, input: KeyCreateInput): Promise<CreatedKey> {
return post("/api/document-store/meilisearch/keys/create", { connectionId, input });
}
export async function meilisearchUpdateKey(connectionId: string, uid: string, input: KeyUpdateInput): Promise<KeyListItem> {
return post("/api/document-store/meilisearch/keys/update", { connectionId, uid, input });
}
export async function meilisearchDeleteKey(connectionId: string, uid: string): Promise<void> {
return post("/api/document-store/meilisearch/keys/delete", { connectionId, uid });
}
export async function meilisearchGetTasks(connectionId: string, input: TaskListInput): Promise<TaskPage> {
return post("/api/document-store/meilisearch/tasks/list", { connectionId, input });
}
export async function meilisearchGetTask(connectionId: string, uid: number, expectedIndexUid?: string): Promise<MeilisearchTask> {
return post("/api/document-store/meilisearch/tasks/get", { connectionId, uid, expectedIndexUid: expectedIndexUid ?? null });
}
export async function meilisearchCancelTasks(connectionId: string, selector: TaskSelector): Promise<EnqueuedTaskSummary> {
return post("/api/document-store/meilisearch/tasks/cancel", { connectionId, selector });
}
export async function meilisearchDeleteTasks(connectionId: string, selector: TaskSelector): Promise<EnqueuedTaskSummary> {
return post("/api/document-store/meilisearch/tasks/delete", { connectionId, selector });
}
export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> {
return post("/api/mongo/delete-documents", {
connectionId,
+47
View File
@@ -5,6 +5,7 @@ import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoS
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement";
/** Normalize Tauri rejections once at the public backend boundary. */
async function invokeBackend<T>(command: string, args?: Record<string, unknown>): Promise<T> {
@@ -3645,6 +3646,12 @@ export async function elasticsearchListIndices(connectionId: string): Promise<st
return collections.map((c) => c.name);
}
/** Lists every Meilisearch index visible to the current connection credentials. */
export async function meilisearchListIndexes(connectionId: string): Promise<string[]> {
const collections = await documentListCollections(connectionId, "default");
return collections.map((collection) => collection.name);
}
export async function vectorListCollections(connectionId: string, database?: string): Promise<CollectionInfo[]> {
return documentListCollections(connectionId, database || "default");
}
@@ -4043,6 +4050,46 @@ export async function meilisearchDeleteAllDocuments(connectionId: string, index:
});
}
export async function meilisearchGetSystemOverview(connectionId: string): Promise<MeilisearchSystemOverview> {
return invokeBackend("meilisearch_get_system_overview", { connectionId });
}
export async function meilisearchListKeys(connectionId: string, offset = 0, limit = 20): Promise<KeyPage> {
return invokeBackend("meilisearch_list_keys", { connectionId, offset, limit });
}
export async function meilisearchGetKey(connectionId: string, uid: string): Promise<KeyListItem> {
return invokeBackend("meilisearch_get_key", { connectionId, uid });
}
export async function meilisearchCreateKey(connectionId: string, input: KeyCreateInput): Promise<CreatedKey> {
return invokeBackend("meilisearch_create_key", { connectionId, input });
}
export async function meilisearchUpdateKey(connectionId: string, uid: string, input: KeyUpdateInput): Promise<KeyListItem> {
return invokeBackend("meilisearch_update_key", { connectionId, uid, input });
}
export async function meilisearchDeleteKey(connectionId: string, uid: string): Promise<void> {
return invokeBackend("meilisearch_delete_key", { connectionId, uid });
}
export async function meilisearchGetTasks(connectionId: string, input: TaskListInput): Promise<TaskPage> {
return invokeBackend("meilisearch_get_tasks", { connectionId, input });
}
export async function meilisearchGetTask(connectionId: string, uid: number, expectedIndexUid?: string): Promise<MeilisearchTask> {
return invokeBackend("meilisearch_get_task", { connectionId, uid, expectedIndexUid: expectedIndexUid ?? null });
}
export async function meilisearchCancelTasks(connectionId: string, selector: TaskSelector): Promise<EnqueuedTaskSummary> {
return invokeBackend("meilisearch_cancel_tasks", { connectionId, selector });
}
export async function meilisearchDeleteTasks(connectionId: string, selector: TaskSelector): Promise<EnqueuedTaskSummary> {
return invokeBackend("meilisearch_delete_tasks", { connectionId, selector });
}
export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> {
const affectedRows = await invoke<number>("mongo_delete_documents", {
connectionId,
@@ -0,0 +1,22 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { loadMeilisearchKeyColumns, MEILISEARCH_KEY_COLUMN_KEYS, MEILISEARCH_KEY_COLUMN_STORAGE_KEY, saveMeilisearchKeyColumns } from "./meilisearchKeyColumns";
describe("Meilisearch key column persistence", () => {
beforeEach(() => localStorage.removeItem(MEILISEARCH_KEY_COLUMN_STORAGE_KEY));
it("uses all columns by default", () => {
expect(loadMeilisearchKeyColumns()).toEqual(MEILISEARCH_KEY_COLUMN_KEYS);
});
it("stores valid columns in canonical order", () => {
saveMeilisearchKeyColumns(["expiresAt", "name", "key"]);
expect(loadMeilisearchKeyColumns()).toEqual(["name", "key", "expiresAt"]);
});
it("falls back safely for invalid persisted data", () => {
localStorage.setItem(MEILISEARCH_KEY_COLUMN_STORAGE_KEY, "invalid");
expect(loadMeilisearchKeyColumns()).toEqual(MEILISEARCH_KEY_COLUMN_KEYS);
});
});
@@ -0,0 +1,24 @@
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
export const MEILISEARCH_KEY_COLUMN_STORAGE_KEY = "dbx:meilisearch:key-columns:v1";
export const MEILISEARCH_KEY_COLUMN_KEYS = ["name", "key", "uid", "actions", "indexes", "expiresAt"] as const;
export type MeilisearchKeyColumnKey = (typeof MEILISEARCH_KEY_COLUMN_KEYS)[number];
function isKeyColumn(value: unknown): value is MeilisearchKeyColumnKey {
return typeof value === "string" && MEILISEARCH_KEY_COLUMN_KEYS.includes(value as MeilisearchKeyColumnKey);
}
export function loadMeilisearchKeyColumns(): MeilisearchKeyColumnKey[] {
try {
const parsed = JSON.parse(safeLocalStorageGet(MEILISEARCH_KEY_COLUMN_STORAGE_KEY) || "null") as { visible?: unknown } | null;
const visible = Array.isArray(parsed?.visible) ? parsed.visible.filter(isKeyColumn) : [];
return visible.length ? MEILISEARCH_KEY_COLUMN_KEYS.filter((key) => visible.includes(key)) : [...MEILISEARCH_KEY_COLUMN_KEYS];
} catch {
return [...MEILISEARCH_KEY_COLUMN_KEYS];
}
}
export function saveMeilisearchKeyColumns(visible: readonly MeilisearchKeyColumnKey[]): void {
const normalized = MEILISEARCH_KEY_COLUMN_KEYS.filter((key) => visible.includes(key));
safeLocalStorageSet(MEILISEARCH_KEY_COLUMN_STORAGE_KEY, JSON.stringify({ version: 1, visible: normalized.length ? normalized : [...MEILISEARCH_KEY_COLUMN_KEYS] }));
}
@@ -0,0 +1,24 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { loadMeilisearchTaskColumns, MEILISEARCH_TASK_COLUMN_KEYS, MEILISEARCH_TASK_COLUMN_STORAGE_KEY, saveMeilisearchTaskColumns } from "./meilisearchTaskColumns";
describe("Meilisearch task column persistence", () => {
beforeEach(() => localStorage.removeItem(MEILISEARCH_TASK_COLUMN_STORAGE_KEY));
it("shows every supported column by default", () => {
expect(loadMeilisearchTaskColumns()).toEqual(MEILISEARCH_TASK_COLUMN_KEYS);
});
it("persists known visible columns in stable table order", () => {
saveMeilisearchTaskColumns(["finishedAt", "uid", "details"]);
expect(loadMeilisearchTaskColumns()).toEqual(["uid", "details", "finishedAt"]);
});
it("ignores corrupt and unknown stored values", () => {
localStorage.setItem(MEILISEARCH_TASK_COLUMN_STORAGE_KEY, JSON.stringify({ visible: ["unknown", "status"] }));
expect(loadMeilisearchTaskColumns()).toEqual(["status"]);
localStorage.setItem(MEILISEARCH_TASK_COLUMN_STORAGE_KEY, "not-json");
expect(loadMeilisearchTaskColumns()).toEqual(MEILISEARCH_TASK_COLUMN_KEYS);
});
});
@@ -0,0 +1,26 @@
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
export const MEILISEARCH_TASK_COLUMN_STORAGE_KEY = "dbx:meilisearch:task-columns:v1";
export const MEILISEARCH_TASK_COLUMN_KEYS = ["uid", "index", "type", "status", "details", "enqueuedAt", "startedAt", "finishedAt", "duration"] as const;
export type MeilisearchTaskColumnKey = (typeof MEILISEARCH_TASK_COLUMN_KEYS)[number];
function isTaskColumnKey(value: unknown): value is MeilisearchTaskColumnKey {
return typeof value === "string" && MEILISEARCH_TASK_COLUMN_KEYS.includes(value as MeilisearchTaskColumnKey);
}
export function loadMeilisearchTaskColumns(): MeilisearchTaskColumnKey[] {
try {
const parsed = JSON.parse(safeLocalStorageGet(MEILISEARCH_TASK_COLUMN_STORAGE_KEY) || "null") as { visible?: unknown } | null;
const visible = Array.isArray(parsed?.visible) ? parsed.visible.filter(isTaskColumnKey) : [];
return visible.length ? MEILISEARCH_TASK_COLUMN_KEYS.filter((key) => visible.includes(key)) : [...MEILISEARCH_TASK_COLUMN_KEYS];
} catch {
return [...MEILISEARCH_TASK_COLUMN_KEYS];
}
}
export function saveMeilisearchTaskColumns(visible: readonly MeilisearchTaskColumnKey[]): void {
const normalized = MEILISEARCH_TASK_COLUMN_KEYS.filter((key) => visible.includes(key));
safeLocalStorageSet(MEILISEARCH_TASK_COLUMN_STORAGE_KEY, JSON.stringify({ version: 1, visible: normalized.length ? normalized : [...MEILISEARCH_TASK_COLUMN_KEYS] }));
}
@@ -64,6 +64,10 @@ export type ActiveTabSidebarTarget =
type: "consul-overview";
connectionId: string;
}
| {
type: "meilisearch-system";
connectionId: string;
}
| {
type: "mq-tenant";
connectionId: string;
@@ -143,6 +147,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
};
}
if (tab.mode === "meilisearch-system") {
return { type: "meilisearch-system", connectionId: tab.connectionId };
}
if (tab.mode === "mongo-bucket") {
return {
type: "mongo-gridfs",
@@ -255,6 +263,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "mongo-collection" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.collectionName;
}
if (target.type === "meilisearch-system") {
return node.type === "meilisearch-system" && node.connectionId === target.connectionId;
}
if (target.type === "mongo-gridfs") {
return node.type === "mongo-gridfs" && node.connectionId === target.connectionId && node.database === target.database;
}
@@ -41,7 +41,9 @@ export function sortSidebarTreeChildrenForParent(parent: Pick<TreeNode, "type">,
const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlNodes, ...children, ...userAdminNodes];
if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
return withConnectionUtilityOrder(sortByLabel(regularChildren));
const meilisearchSystem = regularChildren.filter((child) => child.type === "meilisearch-system");
const databaseObjects = regularChildren.filter((child) => child.type !== "meilisearch-system");
return withConnectionUtilityOrder([...sortByLabel(databaseObjects), ...meilisearchSystem]);
}
if (databaseType === "duckdb") {
@@ -30,6 +30,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"dynamodb-table",
"vector-collection",
"elasticsearch-index",
"meilisearch-system",
"user-admin",
"dameng-users",
"dameng-roles",
@@ -27,6 +27,7 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
"dynamodb-table",
"vector-collection",
"elasticsearch-index",
"meilisearch-system",
"user-admin",
"dameng-users",
"dameng-roles",
@@ -34,7 +35,7 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
// These are application entry points rather than database objects. They should
// always navigate on a single click, even when the user prefers double-click
// activation for ordinary tree objects.
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "nacos-namespace", "nacos-access-control"]);
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "nacos-namespace", "nacos-access-control", "meilisearch-system"]);
const repeatableNavigationTreeNodeTypes = new Set<TreeNodeType>(["nacos-namespace", "nacos-access-control"]);
export function isDirectNavigationTreeNode(type: TreeNodeType): boolean {
@@ -102,6 +102,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
return { icon: TableProperties, colorClass: "text-cyan-400" };
case "elasticsearch-index":
return { icon: Table, colorClass: "text-emerald-400" };
case "meilisearch-system":
return { icon: Gauge, colorClass: "text-emerald-500" };
case "procedure":
return { icon: ScrollText, colorClass: "text-blue-500" };
case "function":
@@ -167,4 +167,39 @@ describe("connectionStore Elasticsearch open/expand", () => {
.sort(),
).toEqual(["orders", "users"]);
});
it("uses the shared Meilisearch index-list method for the Meilisearch tree", async () => {
const elasticsearchListIndices = vi.fn().mockResolvedValue(["wrong-index"]);
const meilisearchListIndexes = vi.fn().mockResolvedValue(["movies", "books"]);
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth,
elasticsearchListIndices,
meilisearchListIndexes,
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(), id: "meili-1", name: "Meilisearch", db_type: "meilisearch", port: 7700 });
seedConnectionNode(store, "meili-1", "Meilisearch");
await store.loadElasticsearchIndices("meili-1");
expect(meilisearchListIndexes).toHaveBeenCalledWith("meili-1");
expect(elasticsearchListIndices).not.toHaveBeenCalled();
expect(
store.treeNodes
.find((node) => node.id === "meili-1")
?.children?.filter((node) => node.type === "elasticsearch-index")
.map((node) => node.label)
.sort(),
).toEqual(["books", "movies"]);
});
});
+47 -9
View File
@@ -4504,26 +4504,64 @@ export const useConnectionStore = defineStore("connection", () => {
try {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
const indices = await withMetadataLoadTimeout(connectionId, api.elasticsearchListIndices(connectionId), "Elasticsearch indices");
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 targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(
targetNode,
withSavedSqlRoot(
connectionId,
sortSidebarNames(indices).map((index) => ({
id: `${connectionId}:__collection:${index}`,
label: index,
type: "elasticsearch-index" as const,
connectionId,
database: "default",
isExpanded: false,
})),
[
...sortSidebarNames(indices).map((index) => ({
id: `${connectionId}:__collection:${index}`,
label: index,
type: "elasticsearch-index" as const,
connectionId,
database: "default",
isExpanded: false,
})),
...(isMeilisearch
? [
{
id: `${connectionId}:__meilisearch_system`,
label: "meilisearch.systemManagement",
type: "meilisearch-system" as const,
connectionId,
database: "default",
isExpanded: false,
},
]
: []),
],
targetNode,
),
);
targetNode.isExpanded = true;
} catch (e) {
const targetNode = treeNodeLoadTarget(load);
if (targetNode && getConfig(connectionId)?.db_type === "meilisearch") {
const existingChildren = (targetNode.children || []).filter((child) => child.type !== "meilisearch-system");
setChildren(
targetNode,
withSavedSqlRoot(
connectionId,
[
...existingChildren,
{
id: `${connectionId}:__meilisearch_system`,
label: "meilisearch.systemManagement",
type: "meilisearch-system",
connectionId,
database: "default",
isExpanded: false,
},
],
targetNode,
),
);
targetNode.isExpanded = true;
}
recordMetadataLoadError(connectionId, e, load);
throw e;
} finally {
+3
View File
@@ -1744,6 +1744,9 @@ export const useQueryStore = defineStore("query", () => {
}
function findTabByIdentity(connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string, catalog?: string) {
if (mode === "meilisearch-system") {
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.mode === mode);
}
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || ""));
}
+2
View File
@@ -975,6 +975,7 @@ export type TreeNodeType =
| "vector-database"
| "vector-collection"
| "elasticsearch-index"
| "meilisearch-system"
| "mqtt-topic";
export interface ConnectionGroup {
@@ -1195,6 +1196,7 @@ export interface QueryTab {
| "redis-dashboard"
| "mongo"
| "meilisearch"
| "meilisearch-system"
| "mongo-gridfs"
| "mongo-bucket"
| "vector"
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { formatMeilisearchTaskDateTime, formatMeilisearchTaskDetails, formatMeilisearchTaskDuration, hasExplicitTaskSelector, meilisearchTaskStatusLabel, normalizeTaskMutationSelector, normalizeTaskSelector, withFixedTaskIndex } from "./meilisearchManagement";
describe("Meilisearch task selector safety", () => {
it("requires a concrete selector field for batch mutations", () => {
expect(hasExplicitTaskSelector({})).toBe(false);
expect(hasExplicitTaskSelector({ statuses: [] })).toBe(false);
expect(hasExplicitTaskSelector({ indexUids: ["movies"] })).toBe(true);
expect(hasExplicitTaskSelector({ afterEnqueuedAt: "2026-01-01T00:00:00Z" })).toBe(true);
});
it("overrides any caller-provided index with the index-detail constraint", () => {
expect(withFixedTaskIndex({ indexUids: ["other"], statuses: ["failed"] }, "movies")).toEqual({
indexUids: ["movies"],
statuses: ["failed"],
});
});
it("freezes the same normalized status selector enforced by the backend", () => {
expect(normalizeTaskMutationSelector({ indexUids: [" movies ", "movies"], statuses: ["FAILED", "processing"] }, "cancel")).toEqual({
indexUids: ["movies"],
statuses: ["processing"],
});
expect(normalizeTaskMutationSelector({ uids: [2] }, "delete")?.statuses).toEqual(["succeeded", "failed", "canceled"]);
expect(normalizeTaskMutationSelector({ types: ["documentAdditionOrUpdate"], statuses: ["failed"] }, "cancel")).toBeNull();
});
it("normalizes task selector values deterministically", () => {
expect(normalizeTaskSelector({ uids: [3, 1, 3], indexUids: [" books ", ""], statuses: ["FAILED"] })).toEqual({
uids: [1, 3],
indexUids: ["books"],
statuses: ["failed"],
});
});
it("formats task statuses, timestamps, and ISO durations for display", () => {
expect(meilisearchTaskStatusLabel("succeeded")).toBe("✅ Succeeded");
expect(meilisearchTaskStatusLabel("processing")).toBe("⚡ Processing");
expect(meilisearchTaskStatusLabel("futureStatus")).toBe("futureStatus");
expect(formatMeilisearchTaskDateTime("2026-08-13T16:37:28Z", "en-US")).not.toContain("T16:37:28Z");
expect(formatMeilisearchTaskDateTime("not-a-date", "en-US")).toBe("not-a-date");
expect(formatMeilisearchTaskDuration("PT2.16344321S", "en-US")).toBe("2.16 sec");
expect(formatMeilisearchTaskDuration("PT1H2M3S", "en-US")).toBe("1 hr 2 min");
expect(formatMeilisearchTaskDuration("unknown", "en-US")).toBe("unknown");
expect(formatMeilisearchTaskDetails({ receivedDocuments: 8, indexedDocuments: 8, futureField: true }, { receivedDocuments: "接收文档数", indexedDocuments: "已索引文档数" })).toBe("接收文档数: 8 · 已索引文档数: 8 · futureField: true");
expect(formatMeilisearchTaskDetails(null)).toBe("-");
});
});
@@ -0,0 +1,250 @@
export type MeilisearchOverviewSectionStatus = "available" | "forbidden" | "unsupported" | "error";
export interface OverviewSection<T> {
status: MeilisearchOverviewSectionStatus;
data?: T | null;
message?: string | null;
}
export interface MeilisearchHealthOverview {
status: string;
}
export interface MeilisearchVersionOverview {
pkgVersion?: string | null;
commitSha?: string | null;
commitDate?: string | null;
}
export interface MeilisearchStatsOverview {
databaseSize?: number | null;
usedDatabaseSize?: number | null;
lastUpdate?: string | null;
indexCount: number;
documentCount: number;
indexingCount: number;
}
export interface MeilisearchTopIndex {
uid: string;
numberOfDocuments: number;
isIndexing: boolean;
}
export interface MeilisearchSystemOverview {
health: OverviewSection<MeilisearchHealthOverview>;
version: OverviewSection<MeilisearchVersionOverview>;
stats: OverviewSection<MeilisearchStatsOverview>;
taskCounts: OverviewSection<Record<string, number>>;
keyCount: OverviewSection<number>;
topIndexes: OverviewSection<MeilisearchTopIndex[]>;
refreshedAt: string;
}
export interface KeyListItem {
uid: string;
/** Plaintext key returned by Meilisearch. Keep it in memory only. */
key: string;
name?: string | null;
description?: string | null;
maskedKey: string;
actions: string[];
indexes: string[];
expiresAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface CreatedKey extends KeyListItem {}
export interface KeyPage {
results: KeyListItem[];
offset: number;
limit: number;
total: number;
}
export interface KeyCreateInput {
uid?: string | null;
name?: string | null;
description?: string | null;
actions: string[];
indexes: string[];
expiresAt: string | null;
}
export interface KeyUpdateInput {
name?: string | null;
description?: string | null;
}
export interface TaskSelector {
uids?: number[];
batchUids?: number[];
canceledBy?: number[];
indexUids?: string[];
statuses?: string[];
types?: string[];
afterEnqueuedAt?: string;
beforeEnqueuedAt?: string;
afterStartedAt?: string;
beforeStartedAt?: string;
afterFinishedAt?: string;
beforeFinishedAt?: string;
}
export interface MeilisearchTask {
uid: number;
batchUid?: number | null;
indexUid?: string | null;
status: string;
type: string;
canceledBy?: number | null;
details?: Record<string, unknown> | null;
error?: Record<string, unknown> | null;
duration?: string | null;
enqueuedAt?: string | null;
startedAt?: string | null;
finishedAt?: string | null;
[key: string]: unknown;
}
export interface TaskPage {
results: MeilisearchTask[];
total: number;
limit: number;
from?: number | null;
next?: number | null;
}
export interface EnqueuedTaskSummary {
taskUid: number;
indexUid?: string | null;
status: string;
type: string;
enqueuedAt?: string | null;
}
export interface TaskListInput {
selector: TaskSelector;
from?: number | null;
limit?: number;
}
export const MEILISEARCH_TASK_STATUS_OPTIONS = [
{ value: "succeeded", label: "✅ Succeeded" },
{ value: "processing", label: "⚡ Processing" },
{ value: "failed", label: "❌ Failed" },
{ value: "enqueued", label: "🔀 Enqueued" },
{ value: "canceled", label: "🚫 Canceled" },
] as const;
export function meilisearchTaskStatusLabel(status: string): string {
return MEILISEARCH_TASK_STATUS_OPTIONS.find((option) => option.value === status.toLowerCase())?.label ?? status;
}
export function formatMeilisearchTaskDateTime(value: string | null | undefined, locale?: string): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value;
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
}
function formatDurationUnit(value: number, unit: "day" | "hour" | "minute" | "second" | "millisecond", locale?: string, maximumFractionDigits = 0): string {
return new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "short", maximumFractionDigits }).format(value);
}
export function formatMeilisearchTaskDuration(value: string | null | undefined, locale?: string): string {
if (!value) return "-";
const match = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(value);
if (!match) return value;
const totalSeconds = Number(match[1] || 0) * 86_400 + Number(match[2] || 0) * 3_600 + Number(match[3] || 0) * 60 + Number(match[4] || 0);
if (!Number.isFinite(totalSeconds)) return value;
if (totalSeconds < 1) return formatDurationUnit(Math.round(totalSeconds * 1_000), "millisecond", locale);
if (totalSeconds < 60) return formatDurationUnit(totalSeconds, "second", locale, 2);
if (totalSeconds < 3_600) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.round(totalSeconds % 60);
return seconds ? `${formatDurationUnit(minutes, "minute", locale)} ${formatDurationUnit(seconds, "second", locale)}` : formatDurationUnit(minutes, "minute", locale);
}
if (totalSeconds < 86_400) {
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.round((totalSeconds % 3_600) / 60);
return minutes ? `${formatDurationUnit(hours, "hour", locale)} ${formatDurationUnit(minutes, "minute", locale)}` : formatDurationUnit(hours, "hour", locale);
}
const days = Math.floor(totalSeconds / 86_400);
const hours = Math.round((totalSeconds % 86_400) / 3_600);
return hours ? `${formatDurationUnit(days, "day", locale)} ${formatDurationUnit(hours, "hour", locale)}` : formatDurationUnit(days, "day", locale);
}
export function formatMeilisearchTaskDetails(details: Record<string, unknown> | null | undefined, labels: Partial<Record<string, string>> = {}): string {
if (!details || !Object.keys(details).length) return "-";
return Object.entries(details)
.map(([key, value]) => `${labels[key] || key}: ${typeof value === "string" ? value : JSON.stringify(value)}`)
.join(" · ");
}
export function hasExplicitTaskSelector(selector: TaskSelector): boolean {
return Object.entries(selector).some(([, value]) => {
if (Array.isArray(value)) return value.length > 0;
return typeof value === "string" ? value.trim().length > 0 : value !== null && value !== undefined;
});
}
export function withFixedTaskIndex(selector: TaskSelector, fixedIndexUid?: string): TaskSelector {
return fixedIndexUid ? { ...selector, indexUids: [fixedIndexUid] } : selector;
}
export type TaskMutationKind = "cancel" | "delete";
function normalizeStrings(values: string[] | undefined, lowercase = false): string[] | undefined {
if (!values) return undefined;
const normalized = values
.map((value) => value.trim())
.filter(Boolean)
.map((value) => (lowercase ? value.toLowerCase() : value))
.sort();
const unique = [...new Set(normalized)];
return unique.length ? unique : undefined;
}
function normalizeNumbers(values: number[] | undefined): number[] | undefined {
if (!values) return undefined;
const unique = [...new Set(values.filter(Number.isFinite))].sort((left, right) => left - right);
return unique.length ? unique : undefined;
}
export function normalizeTaskSelector(selector: TaskSelector): TaskSelector {
const normalized: TaskSelector = {
uids: normalizeNumbers(selector.uids),
batchUids: normalizeNumbers(selector.batchUids),
canceledBy: normalizeNumbers(selector.canceledBy),
indexUids: normalizeStrings(selector.indexUids),
statuses: normalizeStrings(selector.statuses, true),
types: normalizeStrings(selector.types),
afterEnqueuedAt: selector.afterEnqueuedAt?.trim() || undefined,
beforeEnqueuedAt: selector.beforeEnqueuedAt?.trim() || undefined,
afterStartedAt: selector.afterStartedAt?.trim() || undefined,
beforeStartedAt: selector.beforeStartedAt?.trim() || undefined,
afterFinishedAt: selector.afterFinishedAt?.trim() || undefined,
beforeFinishedAt: selector.beforeFinishedAt?.trim() || undefined,
};
return Object.fromEntries(Object.entries(normalized).filter(([, value]) => value !== undefined)) as TaskSelector;
}
/** Mirrors the backend's final mutation selector, including its status safety intersection. */
export function normalizeTaskMutationSelector(selector: TaskSelector, kind: TaskMutationKind): TaskSelector | null {
const normalized = normalizeTaskSelector(selector);
if (!hasExplicitTaskSelector(normalized)) return null;
const allowed = kind === "cancel" ? ["enqueued", "processing"] : ["succeeded", "failed", "canceled"];
const statuses = normalized.statuses?.length ? allowed.filter((status) => normalized.statuses?.includes(status)) : allowed;
if (!statuses.length) return null;
return { ...normalized, statuses };
}
+815 -4
View File
@@ -1,6 +1,6 @@
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::{Client as HttpClient, Method, RequestBuilder, StatusCode};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
@@ -28,6 +28,8 @@ const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
const QUERY_VALUE_ENCODE_SET: &AsciiSet = &PATH_SEGMENT_ENCODE_SET.add(b'&').add(b'=').add(b'+');
const INDEX_PAGE_SIZE: u64 = 1_000;
const FIELD_SAMPLE_SIZE: u64 = 100;
const TASK_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -1098,6 +1100,663 @@ pub async fn delete_all_documents(client: &MeilisearchClient, index: &str) -> Re
wait_for_task(client, task.task_uid).await
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchTaskSelector {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub uids: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub batch_uids: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub canceled_by: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub index_uids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub statuses: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub types: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_enqueued_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_enqueued_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_started_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_started_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_finished_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_finished_at: Option<String>,
}
impl MeilisearchTaskSelector {
fn has_filter(&self) -> bool {
!self.uids.is_empty()
|| !self.batch_uids.is_empty()
|| !self.canceled_by.is_empty()
|| self.index_uids.iter().any(|value| non_empty(value))
|| self.statuses.iter().any(|value| non_empty(value))
|| self.types.iter().any(|value| non_empty(value))
|| self.after_enqueued_at.as_deref().is_some_and(non_empty)
|| self.before_enqueued_at.as_deref().is_some_and(non_empty)
|| self.after_started_at.as_deref().is_some_and(non_empty)
|| self.before_started_at.as_deref().is_some_and(non_empty)
|| self.after_finished_at.as_deref().is_some_and(non_empty)
|| self.before_finished_at.as_deref().is_some_and(non_empty)
}
fn with_allowed_statuses(&self, allowed: &[&str], action: &str) -> Result<Self, String> {
let mut selector = self.clone();
selector.index_uids = normalized_strings(&selector.index_uids, false);
selector.types = normalized_strings(&selector.types, false);
selector.statuses = normalized_strings(&selector.statuses, true);
if !selector.has_filter() {
return Err(format!("Meilisearch task {action} requires at least one explicit filter"));
}
selector.statuses = if selector.statuses.is_empty() {
allowed.iter().map(|status| (*status).to_string()).collect()
} else {
allowed
.iter()
.filter(|allowed_status| selector.statuses.iter().any(|status| status == **allowed_status))
.map(|status| (*status).to_string())
.collect()
};
if selector.statuses.is_empty() {
return Err(format!("Meilisearch task {action} selector does not match any allowed task status"));
}
Ok(selector)
}
}
fn non_empty(value: &str) -> bool {
!value.trim().is_empty()
}
fn normalized_strings(values: &[String], lowercase: bool) -> Vec<String> {
let mut normalized = values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|value| if lowercase { value.to_ascii_lowercase() } else { value.to_string() })
.collect::<Vec<_>>();
normalized.sort();
normalized.dedup();
normalized
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchTask {
pub uid: u64,
#[serde(default)]
pub batch_uid: Option<u64>,
#[serde(default)]
pub index_uid: Option<String>,
pub status: String,
#[serde(rename = "type")]
pub task_type: String,
#[serde(default)]
pub canceled_by: Option<u64>,
#[serde(default)]
pub details: Option<Value>,
#[serde(default)]
pub error: Option<Value>,
#[serde(default)]
pub duration: Option<String>,
pub enqueued_at: String,
#[serde(default)]
pub started_at: Option<String>,
#[serde(default)]
pub finished_at: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
pub fn ensure_task_index(task: &MeilisearchTask, expected_index_uid: Option<&str>) -> Result<(), String> {
let Some(expected_index_uid) = expected_index_uid else {
return Ok(());
};
match task.index_uid.as_deref() {
Some(actual_index_uid) if actual_index_uid == expected_index_uid => Ok(()),
Some(actual_index_uid) => Err(format!(
"Meilisearch task {} belongs to index '{}', expected index '{}'",
task.uid, actual_index_uid, expected_index_uid
)),
None => Err(format!(
"Meilisearch task {} is not associated with an index; expected index '{}'",
task.uid, expected_index_uid
)),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchTaskPage {
pub results: Vec<MeilisearchTask>,
pub total: u64,
pub limit: u64,
#[serde(default)]
pub from: Option<u64>,
#[serde(default)]
pub next: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchTaskListInput {
#[serde(default)]
pub selector: MeilisearchTaskSelector,
#[serde(default)]
pub from: Option<u64>,
#[serde(default)]
pub limit: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchEnqueuedTaskSummary {
pub task_uid: u64,
#[serde(default)]
pub index_uid: Option<String>,
pub status: String,
#[serde(rename = "type")]
pub task_type: String,
pub enqueued_at: String,
}
fn push_csv<T: ToString>(query: &mut Vec<(String, String)>, key: &str, values: &[T]) {
if !values.is_empty() {
query.push((key.to_string(), values.iter().map(ToString::to_string).collect::<Vec<_>>().join(",")));
}
}
fn push_optional(query: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
query.push((key.to_string(), value.to_string()));
}
}
fn task_selector_query(selector: &MeilisearchTaskSelector) -> String {
let mut query = Vec::<(String, String)>::new();
push_csv(&mut query, "uids", &selector.uids);
push_csv(&mut query, "batchUids", &selector.batch_uids);
push_csv(&mut query, "canceledBy", &selector.canceled_by);
push_csv(&mut query, "indexUids", &selector.index_uids);
push_csv(&mut query, "statuses", &selector.statuses);
push_csv(&mut query, "types", &selector.types);
push_optional(&mut query, "afterEnqueuedAt", selector.after_enqueued_at.as_deref());
push_optional(&mut query, "beforeEnqueuedAt", selector.before_enqueued_at.as_deref());
push_optional(&mut query, "afterStartedAt", selector.after_started_at.as_deref());
push_optional(&mut query, "beforeStartedAt", selector.before_started_at.as_deref());
push_optional(&mut query, "afterFinishedAt", selector.after_finished_at.as_deref());
push_optional(&mut query, "beforeFinishedAt", selector.before_finished_at.as_deref());
query
.into_iter()
.map(|(key, value)| format!("{key}={}", utf8_percent_encode(&value, QUERY_VALUE_ENCODE_SET)))
.collect::<Vec<_>>()
.join("&")
}
pub async fn get_tasks(
client: &MeilisearchClient,
selector: &MeilisearchTaskSelector,
from: Option<u64>,
limit: u64,
) -> Result<MeilisearchTaskPage, String> {
let mut query = task_selector_query(selector);
if !query.is_empty() {
query.push('&');
}
query.push_str(&format!("limit={}", limit.clamp(1, 100)));
if let Some(from) = from {
query.push_str(&format!("&from={from}"));
}
let response = client
.get(&format!("/tasks?{query}"))
.send()
.await
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
let value = response_json(response, "task listing").await?;
serde_json::from_value(value).map_err(|error| format!("Meilisearch task list parse error: {error}"))
}
pub async fn get_task(client: &MeilisearchClient, uid: u64) -> Result<MeilisearchTask, String> {
let response = client
.get(&format!("/tasks/{uid}"))
.send()
.await
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
let value = response_json(response, "task lookup").await?;
serde_json::from_value(value).map_err(|error| format!("Meilisearch task parse error: {error}"))
}
async fn mutate_tasks(
client: &MeilisearchClient,
selector: &MeilisearchTaskSelector,
cancel: bool,
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
let (selector, action) = if cancel {
(selector.with_allowed_statuses(&["enqueued", "processing"], "cancellation")?, "cancellation")
} else {
(selector.with_allowed_statuses(&["succeeded", "failed", "canceled"], "deletion")?, "deletion")
};
let query = task_selector_query(&selector);
let path = if cancel { format!("/tasks/cancel?{query}") } else { format!("/tasks?{query}") };
let request = if cancel { client.post(&path) } else { client.delete(&path) };
let response = request.send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?;
let value = response_json(response, &format!("task {action}")).await?;
serde_json::from_value(value).map_err(|error| format!("Meilisearch task {action} parse error: {error}"))
}
pub async fn cancel_tasks(
client: &MeilisearchClient,
selector: &MeilisearchTaskSelector,
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
mutate_tasks(client, selector, true).await
}
pub async fn delete_tasks(
client: &MeilisearchClient,
selector: &MeilisearchTaskSelector,
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
mutate_tasks(client, selector, false).await
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchKeyCreateInput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub actions: Vec<String>,
pub indexes: Vec<String>,
pub expires_at: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchKeyUpdateInput {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchKeyListItem {
pub uid: String,
/// Meilisearch returns the secret in its key-management responses. Keep it
/// in memory so callers can copy the credential while still rendering the
/// separately masked value in tables.
pub key: String,
pub masked_key: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
pub actions: Vec<String>,
pub indexes: Vec<String>,
pub expires_at: Value,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchCreatedKey {
#[serde(flatten)]
pub metadata: MeilisearchKeyListItem,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchKeyPage {
pub results: Vec<MeilisearchKeyListItem>,
pub total: u64,
pub offset: u64,
pub limit: u64,
}
fn mask_key(secret: &str) -> String {
let chars = secret.chars().collect::<Vec<_>>();
if chars.len() < 12 {
return "••••••••".to_string();
}
format!("{}…{}", chars[..4].iter().collect::<String>(), chars[chars.len() - 4..].iter().collect::<String>())
}
fn parse_key(value: Value) -> Result<(MeilisearchKeyListItem, String), String> {
let object = value.as_object().ok_or_else(|| "Meilisearch key response must be an object".to_string())?;
let string = |field: &str| {
object
.get(field)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| format!("Missing key field: {field}"))
};
let secret = string("key")?;
let strings = |field: &str| {
object
.get(field)
.and_then(Value::as_array)
.ok_or_else(|| format!("Missing key field: {field}"))?
.iter()
.map(|value| value.as_str().map(str::to_string).ok_or_else(|| format!("Invalid key field: {field}")))
.collect::<Result<Vec<_>, String>>()
};
Ok((
MeilisearchKeyListItem {
uid: string("uid")?,
key: secret.clone(),
masked_key: mask_key(&secret),
name: object.get("name").and_then(Value::as_str).map(str::to_string),
description: object.get("description").and_then(Value::as_str).map(str::to_string),
actions: strings("actions")?,
indexes: strings("indexes")?,
expires_at: object.get("expiresAt").cloned().unwrap_or(Value::Null),
created_at: string("createdAt")?,
updated_at: string("updatedAt")?,
},
secret,
))
}
pub async fn list_keys(client: &MeilisearchClient, offset: u64, limit: u64) -> Result<MeilisearchKeyPage, String> {
let limit = limit.clamp(1, 100);
let response = client
.get(&format!("/keys?offset={offset}&limit={limit}"))
.send()
.await
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
let value = response_json(response, "key listing").await?;
let object = value.as_object().ok_or_else(|| "Meilisearch key list response must be an object".to_string())?;
let results = object
.get("results")
.and_then(Value::as_array)
.ok_or_else(|| "Meilisearch key list response is missing results".to_string())?
.iter()
.cloned()
.map(parse_key)
.map(|result| result.map(|(key, _)| key))
.collect::<Result<Vec<_>, _>>()?;
Ok(MeilisearchKeyPage {
results,
total: object.get("total").and_then(Value::as_u64).unwrap_or(0),
offset: object.get("offset").and_then(Value::as_u64).unwrap_or(offset),
limit: object.get("limit").and_then(Value::as_u64).unwrap_or(limit),
})
}
pub async fn get_key(client: &MeilisearchClient, uid: &str) -> Result<MeilisearchKeyListItem, String> {
let response = client
.get(&format!("/keys/{}", encode_path_segment(uid)))
.send()
.await
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
let value = response_json(response, "key lookup").await?;
parse_key(value).map(|(key, _)| key)
}
pub async fn create_key(
client: &MeilisearchClient,
input: &MeilisearchKeyCreateInput,
) -> Result<MeilisearchCreatedKey, String> {
if input.actions.is_empty() || input.indexes.is_empty() {
return Err("Meilisearch key actions and indexes must not be empty".to_string());
}
let response = client
.post("/keys")
.json(input)
.send()
.await
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
let value = response_json(response, "key creation").await?;
let (metadata, _key) = parse_key(value)?;
Ok(MeilisearchCreatedKey { metadata })
}
pub async fn update_key(
client: &MeilisearchClient,
uid: &str,
input: &MeilisearchKeyUpdateInput,
) -> Result<MeilisearchKeyListItem, String> {
let response = client
.request(Method::PATCH, &format!("/keys/{}", encode_path_segment(uid)))
.json(input)
.send()
.await
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
let value = response_json(response, "key update").await?;
parse_key(value).map(|(key, _)| key)
}
pub async fn delete_key(client: &MeilisearchClient, uid: &str) -> Result<(), String> {
let response = client
.delete(&format!("/keys/{}", encode_path_segment(uid)))
.send()
.await
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
response_json(response, "key deletion").await.map(|_| ())
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MeilisearchOverviewSectionStatus {
Available,
Forbidden,
Unsupported,
Error,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchOverviewSection<T> {
pub status: MeilisearchOverviewSectionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl<T> MeilisearchOverviewSection<T> {
fn available(data: T) -> Self {
Self { status: MeilisearchOverviewSectionStatus::Available, data: Some(data), message: None }
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchSystemStats {
pub database_size: Option<u64>,
pub used_database_size: Option<u64>,
pub last_update: Option<String>,
pub index_count: u64,
pub document_count: u64,
pub indexing_count: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchIndexSummary {
pub uid: String,
pub number_of_documents: u64,
pub is_indexing: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchSystemOverview {
pub health: MeilisearchOverviewSection<Value>,
pub version: MeilisearchOverviewSection<Value>,
pub stats: MeilisearchOverviewSection<MeilisearchSystemStats>,
pub task_counts: MeilisearchOverviewSection<BTreeMap<String, u64>>,
pub key_count: MeilisearchOverviewSection<u64>,
pub top_indexes: MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
pub refreshed_at: String,
}
async fn overview_value(client: &MeilisearchClient, path: &str, context: &str) -> MeilisearchOverviewSection<Value> {
let response = match client.get(path).send().await {
Ok(response) => response,
Err(error) => {
return MeilisearchOverviewSection {
status: MeilisearchOverviewSectionStatus::Error,
data: None,
message: Some(format!("Meilisearch request failed: {error}")),
};
}
};
let status = response.status();
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
return MeilisearchOverviewSection {
status: MeilisearchOverviewSectionStatus::Forbidden,
data: None,
message: Some(format!("Meilisearch {context} is forbidden")),
};
}
if matches!(status, StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED) {
return MeilisearchOverviewSection {
status: MeilisearchOverviewSectionStatus::Unsupported,
data: None,
message: Some(format!("Meilisearch {context} is unsupported")),
};
}
match response_json(response, context).await {
Ok(value) => MeilisearchOverviewSection::available(value),
Err(error) => MeilisearchOverviewSection {
status: MeilisearchOverviewSectionStatus::Error,
data: None,
message: Some(error),
},
}
}
fn unavailable_stats(
status: MeilisearchOverviewSectionStatus,
message: Option<String>,
) -> (
MeilisearchOverviewSection<MeilisearchSystemStats>,
MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
MeilisearchOverviewSection<Option<String>>,
) {
(
MeilisearchOverviewSection { status: status.clone(), data: None, message: message.clone() },
MeilisearchOverviewSection { status: status.clone(), data: None, message: message.clone() },
MeilisearchOverviewSection { status, data: None, message },
)
}
fn parse_system_stats(
section: MeilisearchOverviewSection<Value>,
) -> (
MeilisearchOverviewSection<MeilisearchSystemStats>,
MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
MeilisearchOverviewSection<Option<String>>,
) {
let Some(value) = section.data else {
return unavailable_stats(section.status, section.message);
};
let Some(indexes) = value.get("indexes").and_then(Value::as_object) else {
return unavailable_stats(
MeilisearchOverviewSectionStatus::Error,
Some("Meilisearch stats response is missing indexes".to_string()),
);
};
let mut summaries = indexes
.iter()
.map(|(uid, stats)| MeilisearchIndexSummary {
uid: uid.clone(),
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),
})
.collect::<Vec<_>>();
let document_count = summaries.iter().map(|summary| summary.number_of_documents).sum();
let indexing_index_count = summaries.iter().filter(|summary| summary.is_indexing).count() as u64;
summaries.sort_by(|left, right| {
right.number_of_documents.cmp(&left.number_of_documents).then_with(|| left.uid.cmp(&right.uid))
});
let index_count = summaries.len() as u64;
summaries.truncate(10);
let last_update = value.get("lastUpdate").and_then(Value::as_str).map(str::to_string);
(
MeilisearchOverviewSection::available(MeilisearchSystemStats {
database_size: value.get("databaseSize").and_then(Value::as_u64),
used_database_size: value.get("usedDatabaseSize").and_then(Value::as_u64),
last_update: last_update.clone(),
index_count,
document_count,
indexing_count: indexing_index_count,
}),
MeilisearchOverviewSection::available(summaries),
MeilisearchOverviewSection::available(last_update),
)
}
fn parse_count(section: MeilisearchOverviewSection<Value>) -> Result<u64, MeilisearchOverviewSection<Value>> {
if let Some(total) = section.data.as_ref().and_then(|value| value.get("total")).and_then(Value::as_u64) {
Ok(total)
} else if section.data.is_some() {
Err(MeilisearchOverviewSection {
status: MeilisearchOverviewSectionStatus::Error,
data: None,
message: Some("Meilisearch count response is missing total".to_string()),
})
} else {
Err(section)
}
}
pub async fn get_system_overview(client: &MeilisearchClient) -> MeilisearchSystemOverview {
let (health, version, stats, enqueued, processing, succeeded, failed, canceled, key_count) = tokio::join!(
overview_value(client, "/health", "health lookup"),
overview_value(client, "/version", "version lookup"),
overview_value(client, "/stats", "stats lookup"),
overview_value(client, "/tasks?statuses=enqueued&limit=1", "enqueued task count"),
overview_value(client, "/tasks?statuses=processing&limit=1", "processing task count"),
overview_value(client, "/tasks?statuses=succeeded&limit=1", "succeeded task count"),
overview_value(client, "/tasks?statuses=failed&limit=1", "failed task count"),
overview_value(client, "/tasks?statuses=canceled&limit=1", "canceled task count"),
overview_value(client, "/keys?offset=0&limit=1", "key count"),
);
let task_sections = [
("enqueued", enqueued),
("processing", processing),
("succeeded", succeeded),
("failed", failed),
("canceled", canceled),
];
let mut counts = BTreeMap::new();
let mut task_error = None;
for (status, section) in task_sections {
match parse_count(section) {
Ok(total) => {
counts.insert(status.to_string(), total);
}
Err(section) => {
task_error = Some(section);
break;
}
}
}
let task_counts = if let Some(section) = task_error {
MeilisearchOverviewSection { status: section.status, data: None, message: section.message }
} else {
MeilisearchOverviewSection::available(counts)
};
let key_count = match parse_count(key_count) {
Ok(total) => MeilisearchOverviewSection::available(total),
Err(section) => MeilisearchOverviewSection { status: section.status, data: None, message: section.message },
};
let (stats, top_indexes, _) = parse_system_stats(stats);
let refreshed_at = chrono::Utc::now().to_rfc3339();
MeilisearchSystemOverview { health, version, stats, task_counts, key_count, top_indexes, refreshed_at }
}
async fn read_bounded_rest_response(mut response: reqwest::Response) -> Result<(String, bool), String> {
let read_limit = REST_RESPONSE_MAX_BYTES.saturating_add(1);
let capacity = response.content_length().unwrap_or(0).min(read_limit as u64) as usize;
@@ -1193,14 +1852,166 @@ fn raw_response_result(status: u16, body: String, start: Instant, truncated: boo
#[cfg(test)]
mod tests {
use super::{
decode_bounded_rest_body, decoded_identity, filter_expression, identity_path, meilisearch_base_url,
meilisearch_filter_from_request, meilisearch_search_body, meilisearch_sort_from_request,
parse_meilisearch_search_response, parse_rest_request_line, MeilisearchClient,
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,
};
use reqwest::Method;
use serde_json::{json, Value};
use std::time::Duration;
#[test]
fn task_selector_encodes_all_filters_and_cursor_safe_values() {
let selector = MeilisearchTaskSelector {
uids: vec![3, 7],
batch_uids: vec![9],
canceled_by: vec![11],
index_uids: vec!["movies / 2026&sort=false".to_string()],
statuses: vec!["failed".to_string()],
types: vec!["documentAdditionOrUpdate".to_string()],
after_enqueued_at: Some("2026-08-22T00:00:00Z".to_string()),
..Default::default()
};
let query = task_selector_query(&selector);
assert!(query.contains("uids=3,7"));
assert!(query.contains("batchUids=9"));
assert!(query.contains("canceledBy=11"));
assert!(query.contains("indexUids=movies%20%2F%202026%26sort%3Dfalse"));
assert!(query.contains("statuses=failed"));
assert!(query.contains("types=documentAdditionOrUpdate"));
assert!(!query.contains("reverse="));
assert!(query.contains("afterEnqueuedAt=2026-08-22T00:00:00Z"));
}
#[test]
fn task_mutations_require_filters_and_intersect_allowed_statuses() {
let empty = MeilisearchTaskSelector::default();
assert!(empty.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
let blank = MeilisearchTaskSelector { index_uids: vec![" ".to_string()], ..Default::default() };
assert!(blank.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
let selector = MeilisearchTaskSelector {
index_uids: vec!["movies".to_string()],
statuses: vec!["processing".to_string(), "failed".to_string()],
..Default::default()
};
let cancel = selector.with_allowed_statuses(&["enqueued", "processing"], "cancellation").unwrap();
assert_eq!(cancel.statuses, vec!["processing"]);
let delete = selector.with_allowed_statuses(&["succeeded", "failed", "canceled"], "deletion").unwrap();
assert_eq!(delete.statuses, vec!["failed"]);
let succeeded_only = MeilisearchTaskSelector { statuses: vec!["succeeded".to_string()], ..Default::default() };
assert!(succeeded_only.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
}
#[test]
fn key_dto_keeps_copyable_secret_and_masked_display_value() {
let secret = "0123456789abcdef";
let (item, parsed_secret) = parse_key(json!({
"uid": "key-uid",
"key": secret,
"name": "Search",
"description": null,
"actions": ["search"],
"indexes": ["movies"],
"expiresAt": null,
"createdAt": "2026-08-22T00:00:00Z",
"updatedAt": "2026-08-22T00:00:00Z"
}))
.unwrap();
assert_eq!(parsed_secret, secret);
assert_eq!(item.key, secret);
assert_eq!(item.masked_key, "0123…cdef");
let wire = serde_json::to_string(&item).unwrap();
assert!(wire.contains(secret));
assert!(wire.contains("maskedKey"));
}
#[test]
fn task_dto_preserves_unknown_fields_and_task_types() {
let task: MeilisearchTask = serde_json::from_value(json!({
"uid": 42,
"batchUid": 7,
"indexUid": "movies",
"status": "futureStatus",
"type": "futureTaskType",
"details": {"receivedDocuments": 10},
"error": null,
"duration": null,
"enqueuedAt": "2026-08-22T00:00:00Z",
"startedAt": null,
"finishedAt": null,
"futureField": {"kept": true}
}))
.unwrap();
assert_eq!(task.task_type, "futureTaskType");
assert_eq!(task.extra["futureField"], json!({"kept": true}));
}
#[test]
fn task_index_guard_accepts_global_and_matching_index_details() {
let task: MeilisearchTask = serde_json::from_value(json!({
"uid": 42,
"indexUid": "movies",
"status": "succeeded",
"type": "documentAdditionOrUpdate",
"enqueuedAt": "2026-08-22T00:00:00Z"
}))
.unwrap();
assert!(ensure_task_index(&task, None).is_ok());
assert!(ensure_task_index(&task, Some("movies")).is_ok());
}
#[test]
fn task_index_guard_rejects_mismatched_and_indexless_details() {
let mut task: MeilisearchTask = serde_json::from_value(json!({
"uid": 42,
"indexUid": "movies",
"status": "succeeded",
"type": "documentAdditionOrUpdate",
"enqueuedAt": "2026-08-22T00:00:00Z"
}))
.unwrap();
let mismatch = ensure_task_index(&task, Some("books")).unwrap_err();
assert!(mismatch.contains("belongs to index 'movies'"));
assert!(mismatch.contains("expected index 'books'"));
task.index_uid = None;
let indexless = ensure_task_index(&task, Some("books")).unwrap_err();
assert!(indexless.contains("not associated with an index"));
}
#[test]
fn system_stats_compute_totals_and_top_ten_deterministically() {
let indexes = (0..12)
.map(|index| {
(format!("index-{index:02}"), json!({ "numberOfDocuments": index, "isIndexing": index % 2 == 0 }))
})
.collect::<serde_json::Map<_, _>>();
let (stats, top, refreshed_at) = parse_system_stats(MeilisearchOverviewSection::available(json!({
"databaseSize": 1000,
"usedDatabaseSize": 900,
"lastUpdate": "2026-08-22T00:00:00Z",
"indexes": indexes
})));
let stats = stats.data.unwrap();
assert_eq!(stats.index_count, 12);
assert_eq!(stats.document_count, 66);
assert_eq!(stats.indexing_count, 6);
let top = top.data.unwrap();
assert_eq!(top.len(), 10);
assert_eq!(top[0].uid, "index-11");
assert_eq!(top[9].uid, "index-02");
assert_eq!(refreshed_at.data.flatten().as_deref(), Some("2026-08-22T00:00:00Z"));
}
#[test]
fn normalizes_canonical_and_legacy_proxy_base_paths() {
assert_eq!(
+104
View File
@@ -940,6 +940,110 @@ pub async fn meilisearch_delete_all_documents_core(
}
}
async fn meilisearch_client_core(
state: &AppState,
connection_id: &str,
) -> Result<crate::db::meilisearch_driver::MeilisearchClient, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Meilisearch(client) => Ok(client.clone()),
_ => Err("Not a Meilisearch connection".to_string()),
}
}
pub async fn meilisearch_get_system_overview_core(
state: &AppState,
connection_id: &str,
) -> Result<crate::db::meilisearch_driver::MeilisearchSystemOverview, String> {
let client = meilisearch_client_core(state, connection_id).await?;
Ok(crate::db::meilisearch_driver::get_system_overview(&client).await)
}
pub async fn meilisearch_list_keys_core(
state: &AppState,
connection_id: &str,
offset: u64,
limit: u64,
) -> Result<crate::db::meilisearch_driver::MeilisearchKeyPage, String> {
let client = meilisearch_client_core(state, connection_id).await?;
crate::db::meilisearch_driver::list_keys(&client, offset, limit).await
}
pub async fn meilisearch_get_key_core(
state: &AppState,
connection_id: &str,
uid: &str,
) -> Result<crate::db::meilisearch_driver::MeilisearchKeyListItem, String> {
let client = meilisearch_client_core(state, connection_id).await?;
crate::db::meilisearch_driver::get_key(&client, uid).await
}
pub async fn meilisearch_create_key_core(
state: &AppState,
connection_id: &str,
input: &crate::db::meilisearch_driver::MeilisearchKeyCreateInput,
) -> Result<crate::db::meilisearch_driver::MeilisearchCreatedKey, String> {
let client = meilisearch_client_core(state, connection_id).await?;
crate::db::meilisearch_driver::create_key(&client, input).await
}
pub async fn meilisearch_update_key_core(
state: &AppState,
connection_id: &str,
uid: &str,
input: &crate::db::meilisearch_driver::MeilisearchKeyUpdateInput,
) -> Result<crate::db::meilisearch_driver::MeilisearchKeyListItem, String> {
let client = meilisearch_client_core(state, connection_id).await?;
crate::db::meilisearch_driver::update_key(&client, uid, input).await
}
pub async fn meilisearch_delete_key_core(state: &AppState, connection_id: &str, uid: &str) -> Result<(), String> {
let client = meilisearch_client_core(state, connection_id).await?;
crate::db::meilisearch_driver::delete_key(&client, uid).await
}
pub async fn meilisearch_get_tasks_core(
state: &AppState,
connection_id: &str,
selector: &crate::db::meilisearch_driver::MeilisearchTaskSelector,
from: Option<u64>,
limit: u64,
) -> Result<crate::db::meilisearch_driver::MeilisearchTaskPage, String> {
let client = meilisearch_client_core(state, connection_id).await?;
crate::db::meilisearch_driver::get_tasks(&client, selector, from, limit).await
}
pub async fn meilisearch_get_task_core(
state: &AppState,
connection_id: &str,
uid: u64,
expected_index_uid: Option<&str>,
) -> Result<crate::db::meilisearch_driver::MeilisearchTask, String> {
let client = meilisearch_client_core(state, connection_id).await?;
let task = crate::db::meilisearch_driver::get_task(&client, uid).await?;
crate::db::meilisearch_driver::ensure_task_index(&task, expected_index_uid)?;
Ok(task)
}
pub async fn meilisearch_cancel_tasks_core(
state: &AppState,
connection_id: &str,
selector: &crate::db::meilisearch_driver::MeilisearchTaskSelector,
) -> Result<crate::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary, String> {
let client = meilisearch_client_core(state, connection_id).await?;
crate::db::meilisearch_driver::cancel_tasks(&client, selector).await
}
pub async fn meilisearch_delete_tasks_core(
state: &AppState,
connection_id: &str,
selector: &crate::db::meilisearch_driver::MeilisearchTaskSelector,
) -> Result<crate::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary, String> {
let client = meilisearch_client_core(state, connection_id).await?;
crate::db::meilisearch_driver::delete_tasks(&client, selector).await
}
#[cfg(test)]
mod tests {
use super::{
+13
View File
@@ -829,6 +829,19 @@ async fn main() {
.route("/document-store/meilisearch/stats", post(routes::document_store::meilisearch_get_stats))
.route("/document-store/meilisearch/overview", post(routes::document_store::meilisearch_get_overview))
.route("/document-store/meilisearch/index/delete", post(routes::document_store::meilisearch_delete_index))
.route(
"/document-store/meilisearch/system/overview",
post(routes::document_store::meilisearch_get_system_overview),
)
.route("/document-store/meilisearch/keys/list", post(routes::document_store::meilisearch_list_keys))
.route("/document-store/meilisearch/keys/get", post(routes::document_store::meilisearch_get_key))
.route("/document-store/meilisearch/keys/create", post(routes::document_store::meilisearch_create_key))
.route("/document-store/meilisearch/keys/update", post(routes::document_store::meilisearch_update_key))
.route("/document-store/meilisearch/keys/delete", post(routes::document_store::meilisearch_delete_key))
.route("/document-store/meilisearch/tasks/list", post(routes::document_store::meilisearch_get_tasks))
.route("/document-store/meilisearch/tasks/get", post(routes::document_store::meilisearch_get_task))
.route("/document-store/meilisearch/tasks/cancel", post(routes::document_store::meilisearch_cancel_tasks))
.route("/document-store/meilisearch/tasks/delete", post(routes::document_store::meilisearch_delete_tasks))
.route(
"/document-store/meilisearch/documents/delete-all",
post(routes::document_store::meilisearch_delete_all_documents),
+180
View File
@@ -189,6 +189,64 @@ pub struct MeilisearchSettingsUpdateRequest {
pub settings: serde_json::Value,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchConnectionRequest {
pub connection_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchKeyListRequest {
pub connection_id: String,
pub offset: Option<u64>,
pub limit: Option<u64>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchKeyRequest {
pub connection_id: String,
pub uid: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchKeyCreateRequest {
pub connection_id: String,
pub input: dbx_core::db::meilisearch_driver::MeilisearchKeyCreateInput,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchKeyUpdateRequest {
pub connection_id: String,
pub uid: String,
pub input: dbx_core::db::meilisearch_driver::MeilisearchKeyUpdateInput,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchTaskListRequest {
pub connection_id: String,
pub input: dbx_core::db::meilisearch_driver::MeilisearchTaskListInput,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchTaskRequest {
pub connection_id: String,
pub uid: u64,
pub expected_index_uid: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeilisearchTaskMutationRequest {
pub connection_id: String,
pub selector: dbx_core::db::meilisearch_driver::MeilisearchTaskSelector,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridFsBucketRequest {
@@ -532,6 +590,128 @@ pub async fn meilisearch_delete_all_documents(
Ok(Json(()))
}
pub async fn meilisearch_get_system_overview(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchConnectionRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchSystemOverview>, AppError> {
let result = dbx_core::document_ops::meilisearch_get_system_overview_core(&state.app, &req.connection_id)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn meilisearch_list_keys(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchKeyListRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchKeyPage>, AppError> {
let result = dbx_core::document_ops::meilisearch_list_keys_core(
&state.app,
&req.connection_id,
req.offset.unwrap_or(0),
req.limit.unwrap_or(20),
)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn meilisearch_get_key(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchKeyRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchKeyListItem>, AppError> {
let result = dbx_core::document_ops::meilisearch_get_key_core(&state.app, &req.connection_id, &req.uid)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn meilisearch_create_key(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchKeyCreateRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchCreatedKey>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Create API key").await?;
let result = dbx_core::document_ops::meilisearch_create_key_core(&state.app, &req.connection_id, &req.input)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn meilisearch_update_key(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchKeyUpdateRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchKeyListItem>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Update API key").await?;
let result =
dbx_core::document_ops::meilisearch_update_key_core(&state.app, &req.connection_id, &req.uid, &req.input)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn meilisearch_delete_key(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchKeyRequest>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Delete API key").await?;
dbx_core::document_ops::meilisearch_delete_key_core(&state.app, &req.connection_id, &req.uid)
.await
.map_err(AppError::from)?;
Ok(Json(()))
}
pub async fn meilisearch_get_tasks(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchTaskListRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchTaskPage>, AppError> {
let result = dbx_core::document_ops::meilisearch_get_tasks_core(
&state.app,
&req.connection_id,
&req.input.selector,
req.input.from,
req.input.limit.unwrap_or(20),
)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn meilisearch_get_task(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchTaskRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchTask>, AppError> {
let result = dbx_core::document_ops::meilisearch_get_task_core(
&state.app,
&req.connection_id,
req.uid,
req.expected_index_uid.as_deref(),
)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn meilisearch_cancel_tasks(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchTaskMutationRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Cancel tasks").await?;
let result = dbx_core::document_ops::meilisearch_cancel_tasks_core(&state.app, &req.connection_id, &req.selector)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn meilisearch_delete_tasks(
State(state): State<Arc<WebState>>,
Json(req): Json<MeilisearchTaskMutationRequest>,
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary>, AppError> {
ensure_writable(&state.app, &req.connection_id, "Delete tasks").await?;
let result = dbx_core::document_ops::meilisearch_delete_tasks_core(&state.app, &req.connection_id, &req.selector)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn list_gridfs_files(
State(state): State<Arc<WebState>>,
Json(req): Json<GridFsFileListRequest>,
+104
View File
@@ -355,6 +355,110 @@ pub async fn meilisearch_delete_all_documents(
dbx_core::document_ops::meilisearch_delete_all_documents_core(&state, &connection_id, &index).await
}
#[tauri::command]
pub async fn meilisearch_get_system_overview(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchSystemOverview, String> {
dbx_core::document_ops::meilisearch_get_system_overview_core(&state, &connection_id).await
}
#[tauri::command]
pub async fn meilisearch_list_keys(
state: State<'_, Arc<AppState>>,
connection_id: String,
offset: u64,
limit: u64,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchKeyPage, String> {
dbx_core::document_ops::meilisearch_list_keys_core(&state, &connection_id, offset, limit).await
}
#[tauri::command]
pub async fn meilisearch_get_key(
state: State<'_, Arc<AppState>>,
connection_id: String,
uid: String,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchKeyListItem, String> {
dbx_core::document_ops::meilisearch_get_key_core(&state, &connection_id, &uid).await
}
#[tauri::command]
pub async fn meilisearch_create_key(
state: State<'_, Arc<AppState>>,
connection_id: String,
input: dbx_core::db::meilisearch_driver::MeilisearchKeyCreateInput,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchCreatedKey, String> {
ensure_connection_writable(&state, &connection_id, "Create API key").await?;
dbx_core::document_ops::meilisearch_create_key_core(&state, &connection_id, &input).await
}
#[tauri::command]
pub async fn meilisearch_update_key(
state: State<'_, Arc<AppState>>,
connection_id: String,
uid: String,
input: dbx_core::db::meilisearch_driver::MeilisearchKeyUpdateInput,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchKeyListItem, String> {
ensure_connection_writable(&state, &connection_id, "Update API key").await?;
dbx_core::document_ops::meilisearch_update_key_core(&state, &connection_id, &uid, &input).await
}
#[tauri::command]
pub async fn meilisearch_delete_key(
state: State<'_, Arc<AppState>>,
connection_id: String,
uid: String,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Delete API key").await?;
dbx_core::document_ops::meilisearch_delete_key_core(&state, &connection_id, &uid).await
}
#[tauri::command]
pub async fn meilisearch_get_tasks(
state: State<'_, Arc<AppState>>,
connection_id: String,
input: dbx_core::db::meilisearch_driver::MeilisearchTaskListInput,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchTaskPage, String> {
dbx_core::document_ops::meilisearch_get_tasks_core(
&state,
&connection_id,
&input.selector,
input.from,
input.limit.unwrap_or(20),
)
.await
}
#[tauri::command]
pub async fn meilisearch_get_task(
state: State<'_, Arc<AppState>>,
connection_id: String,
uid: u64,
expected_index_uid: Option<String>,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchTask, String> {
dbx_core::document_ops::meilisearch_get_task_core(&state, &connection_id, uid, expected_index_uid.as_deref()).await
}
#[tauri::command]
pub async fn meilisearch_cancel_tasks(
state: State<'_, Arc<AppState>>,
connection_id: String,
selector: dbx_core::db::meilisearch_driver::MeilisearchTaskSelector,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary, String> {
ensure_connection_writable(&state, &connection_id, "Cancel tasks").await?;
dbx_core::document_ops::meilisearch_cancel_tasks_core(&state, &connection_id, &selector).await
}
#[tauri::command]
pub async fn meilisearch_delete_tasks(
state: State<'_, Arc<AppState>>,
connection_id: String,
selector: dbx_core::db::meilisearch_driver::MeilisearchTaskSelector,
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary, String> {
ensure_connection_writable(&state, &connection_id, "Delete tasks").await?;
dbx_core::document_ops::meilisearch_delete_tasks_core(&state, &connection_id, &selector).await
}
#[tauri::command]
pub async fn document_list_gridfs_buckets(
state: State<'_, Arc<AppState>>,
+10
View File
@@ -2078,6 +2078,16 @@ pub fn run() {
commands::document_cmd::meilisearch_get_index_overview,
commands::document_cmd::meilisearch_delete_index,
commands::document_cmd::meilisearch_delete_all_documents,
commands::document_cmd::meilisearch_get_system_overview,
commands::document_cmd::meilisearch_list_keys,
commands::document_cmd::meilisearch_get_key,
commands::document_cmd::meilisearch_create_key,
commands::document_cmd::meilisearch_update_key,
commands::document_cmd::meilisearch_delete_key,
commands::document_cmd::meilisearch_get_tasks,
commands::document_cmd::meilisearch_get_task,
commands::document_cmd::meilisearch_cancel_tasks,
commands::document_cmd::meilisearch_delete_tasks,
commands::hbase_cmd::hbase_get_table_schema,
commands::hbase_cmd::hbase_scan_rows,
commands::hbase_cmd::hbase_get_row,