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

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-23 02:53:52 +08:00
1 parent 93dc455c72
commit e55ee6054d
48 files changed
+3984 -18

No files matched your search

@@ -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",