feat(meilisearch): add workspace, key, and task management
This commit is contained in:
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>
|
||||
+224
@@ -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));
|
||||
});
|
||||
});
|
||||
+286
@@ -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);
|
||||
|
||||
+32
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user