mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(meilisearch): add workspace, key, and task management
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import docs from "./docs/en";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
import { meilisearchManagementEn } from "./meilisearchManagement";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
@@ -4732,6 +4733,7 @@ export default {
|
||||
sortPlaceholder: "Sort...",
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementEn,
|
||||
documents: "Documents",
|
||||
settings: "Settings",
|
||||
search: "Search",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import { meilisearchManagementEs } from "./meilisearchManagement";
|
||||
import docs from "./docs/es";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
|
||||
@@ -4561,6 +4562,7 @@ export default withEnglishFallback({
|
||||
indexPropertiesUnavailable: "El controlador Legacy de MongoDB no puede informar disperso, expiración, background o tamaño de bucket. Conéctese con el controlador nativo para verlos.",
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementEs,
|
||||
documents: "Documentos",
|
||||
settings: "Ajustes",
|
||||
search: "Buscar",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import { meilisearchManagementIt } from "./meilisearchManagement";
|
||||
import docs from "./docs/it";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
|
||||
@@ -4559,6 +4560,7 @@ export default withEnglishFallback({
|
||||
indexPropertiesUnavailable: "Il driver Legacy di MongoDB non può riportare sparsità, scadenza, background o dimensione bucket. Connettersi con il driver nativo per vederli.",
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementIt,
|
||||
documents: "Documenti",
|
||||
settings: "Impostazioni",
|
||||
search: "Cerca",
|
||||
|
||||
@@ -3,6 +3,7 @@ import docs from "./docs/ja";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
import { meilisearchManagementJa } from "./meilisearchManagement";
|
||||
|
||||
const consul = {
|
||||
...consulUiMessages,
|
||||
@@ -4588,6 +4589,7 @@ export default withEnglishFallback({
|
||||
indexPropertiesUnavailable: "MongoDB Legacy ドライバーはスパース、有効期限、バックグラウンド、バケットサイズを報告できません。ネイティブドライバーで接続して確認してください。",
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementJa,
|
||||
documents: "ドキュメント",
|
||||
settings: "設定",
|
||||
search: "検索",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import { meilisearchManagementKo } from "./meilisearchManagement";
|
||||
import docs from "./docs/ko";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
|
||||
@@ -4179,6 +4180,7 @@ export default withEnglishFallback({
|
||||
indexPropertiesUnavailable: "MongoDB Legacy 드라이버는 희소, 만료, 백그라운드 또는 버킷 크기를 보고할 수 없습니다. 네이티브 드라이버로 연결하여 확인하세요.",
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementKo,
|
||||
documents: "문서",
|
||||
settings: "설정",
|
||||
search: "검색",
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
const en = {
|
||||
systemManagement: "System management",
|
||||
overview: "Overview",
|
||||
overviewDescription: "Live health, capacity, indexes, keys, and task activity.",
|
||||
apiKeys: "API Keys",
|
||||
keysDescription: "Create and manage scoped Meilisearch API keys.",
|
||||
tasks: "Tasks",
|
||||
tasksDescription: "Inspect asynchronous operations, cancel active tasks, and clean completed history.",
|
||||
health: "Health",
|
||||
version: "Version",
|
||||
indexCount: "Indexes",
|
||||
totalDocuments: "Total documents",
|
||||
indexingCount: "Indexing",
|
||||
keyCount: "Keys",
|
||||
storage: "Storage",
|
||||
usedDatabaseSize: "Used database size",
|
||||
lastUpdate: "Last update",
|
||||
taskStatusDistribution: "Task status distribution",
|
||||
topIndexes: "Top indexes by documents",
|
||||
status: "Status",
|
||||
idle: "Idle",
|
||||
sectionStatus: { available: "Available", forbidden: "Permission denied", unsupported: "Unsupported by this server", error: "Unavailable" },
|
||||
refresh: "Refresh",
|
||||
rowActions: "Actions",
|
||||
previous: "Previous",
|
||||
next: "Next",
|
||||
name: "Name",
|
||||
key: "Key",
|
||||
description: "Description",
|
||||
actions: "Actions",
|
||||
indexes: "Indexes",
|
||||
expiresAt: "Expires at",
|
||||
never: "Never",
|
||||
createKey: "Create key",
|
||||
editKey: "Edit key",
|
||||
deleteKey: "Delete key",
|
||||
keyDetails: "Key details",
|
||||
keyCreated: "API key created",
|
||||
keyUpdated: "API key updated",
|
||||
keyDeleted: "API key deleted",
|
||||
noKeys: "No API keys found",
|
||||
deleteKeyConfirm: "Delete API key {uid}? This cannot be undone.",
|
||||
deleteKeyTypePrompt: "Enter the complete key UID to confirm deletion.",
|
||||
deleteKeyTypePlaceholder: "Enter key UID",
|
||||
immutableKeyFields: "Actions, indexes, expiration, UID, and secret are immutable. Recreate the key to change them.",
|
||||
secretShownOnce: "Copy this secret now. DBX never persists API key values.",
|
||||
actionsIndexesRequired: "At least one action and one index are required.",
|
||||
uidPlaceholder: "Leave empty to generate a random UID",
|
||||
uidHelp: "Optional. When empty, Meilisearch generates a random UID.",
|
||||
namePlaceholder: "Name your key",
|
||||
nameHelp: "Optional name used to identify this key.",
|
||||
descriptionPlaceholder: "Add an optional description",
|
||||
descriptionHelp: "Optional note describing where or why the key is used.",
|
||||
indexesPlaceholder: "Select permitted indexes",
|
||||
indexesHelp: "Choose one or more accessible indexes. * grants access to every index.",
|
||||
actionsPlaceholder: "Select permitted actions",
|
||||
actionsHelp: "Choose the API operations this key is allowed to perform.",
|
||||
expiresAtPlaceholder: "Select or enter a date and time",
|
||||
expiresAtHelp: "Optional. Leave empty for no expiration, or enter a valid date and time.",
|
||||
openDateTimePicker: "Open date and time picker",
|
||||
invalidExpiresAt: "Enter a valid expiration date and time.",
|
||||
copyKeyValue: "Copy API key",
|
||||
paginationSummary: "{start}-{end} of {total}",
|
||||
readOnlyDisabled: "This connection is read-only; management actions are disabled.",
|
||||
filters: "Filters",
|
||||
applyFilters: "Apply filters",
|
||||
resetFilters: "Reset filters",
|
||||
uidFilter: "UID filter",
|
||||
searchUid: "Search UID",
|
||||
allIndexes: "All indexes",
|
||||
allStatuses: "All statuses",
|
||||
indexListUnavailable: "Unable to load the accessible index list",
|
||||
reverseOrder: "Reverse order",
|
||||
columnSettings: "Set columns",
|
||||
index: "Index",
|
||||
type: "Type",
|
||||
details: "Details",
|
||||
receivedDocuments: "Received documents",
|
||||
indexedDocuments: "Indexed documents",
|
||||
enqueuedAt: "Enqueued at",
|
||||
startedAt: "Started at",
|
||||
finishedAt: "Finished at",
|
||||
duration: "Duration",
|
||||
afterEnqueuedAt: "Enqueued after",
|
||||
beforeEnqueuedAt: "Enqueued before",
|
||||
afterStartedAt: "Started after",
|
||||
beforeStartedAt: "Started before",
|
||||
afterFinishedAt: "Finished after",
|
||||
beforeFinishedAt: "Finished before",
|
||||
indexTasks: "Tasks · {index}",
|
||||
noTasks: "No tasks found",
|
||||
taskCount: "{count} matching tasks",
|
||||
taskDetails: "Task details",
|
||||
cancelTask: "Cancel task",
|
||||
cancelTasks: "Cancel tasks",
|
||||
deleteTaskHistory: "Delete task history",
|
||||
cancelFilteredTasks: "Cancel filtered active tasks",
|
||||
deleteFilteredHistory: "Delete filtered completed history",
|
||||
explicitFilterRequired: "Choose at least one explicit filter before a batch operation.",
|
||||
noAllowedTaskStatuses: "The applied filter contains no task status allowed for this operation.",
|
||||
countingMatchingTasks: "Counting matching tasks...",
|
||||
cancelTasksConfirm: "Cancel active tasks matching this selector ({count} current matches)?",
|
||||
deleteTasksConfirm: "Delete completed task history matching this selector ({count} current matches)?",
|
||||
waitingForTask: "Waiting for management task #{uid}",
|
||||
taskOperationCompleted: "Task operation completed",
|
||||
taskOperationFinished: "Task operation finished with status {status}",
|
||||
taskPollingTimeout: "Task #{uid} is still pending after 60 seconds. Refresh manually to check it.",
|
||||
taskOutsideIndex: "This task does not belong to the current index.",
|
||||
};
|
||||
|
||||
const zhCN = {
|
||||
...en,
|
||||
systemManagement: "系统管理",
|
||||
overview: "总览",
|
||||
overviewDescription: "实时查看健康状态、容量、索引、密钥与任务活动。",
|
||||
apiKeys: "API 密钥",
|
||||
keysDescription: "创建和管理限定权限的 Meilisearch API 密钥。",
|
||||
tasks: "任务",
|
||||
tasksDescription: "查看异步操作、取消活动任务并清理已完成历史。",
|
||||
health: "健康状态",
|
||||
version: "版本",
|
||||
indexCount: "索引数",
|
||||
totalDocuments: "文档总数",
|
||||
indexingCount: "正在索引",
|
||||
keyCount: "密钥数",
|
||||
storage: "存储",
|
||||
usedDatabaseSize: "已用数据库大小",
|
||||
lastUpdate: "最后更新",
|
||||
taskStatusDistribution: "任务状态分布",
|
||||
topIndexes: "文档数 Top 索引",
|
||||
status: "状态",
|
||||
idle: "空闲",
|
||||
sectionStatus: { available: "可用", forbidden: "权限不足", unsupported: "当前服务不支持", error: "不可用" },
|
||||
refresh: "刷新",
|
||||
rowActions: "操作",
|
||||
previous: "上一页",
|
||||
next: "下一页",
|
||||
name: "名称",
|
||||
key: "密钥",
|
||||
description: "描述",
|
||||
actions: "权限动作",
|
||||
indexes: "索引范围",
|
||||
expiresAt: "过期时间",
|
||||
never: "永不过期",
|
||||
createKey: "创建密钥",
|
||||
editKey: "编辑密钥",
|
||||
deleteKey: "删除密钥",
|
||||
keyDetails: "密钥详情",
|
||||
keyCreated: "API 密钥已创建",
|
||||
keyUpdated: "API 密钥已更新",
|
||||
keyDeleted: "API 密钥已删除",
|
||||
noKeys: "没有 API 密钥",
|
||||
deleteKeyConfirm: "确定删除 API 密钥 {uid}?此操作无法撤销。",
|
||||
deleteKeyTypePrompt: "请输入完整的密钥 UID 以确认删除。",
|
||||
deleteKeyTypePlaceholder: "输入密钥 UID",
|
||||
immutableKeyFields: "权限动作、索引范围、过期时间、UID 和密钥不可修改;如需变更请重新创建。",
|
||||
secretShownOnce: "请立即复制此密钥。DBX 不会持久化 API 密钥值。",
|
||||
actionsIndexesRequired: "至少需要一个权限动作和一个索引范围。",
|
||||
uidPlaceholder: "留空以自动生成随机 UID",
|
||||
uidHelp: "可选。留空时由 Meilisearch 自动生成随机 UID。",
|
||||
namePlaceholder: "为密钥命名",
|
||||
nameHelp: "可选,用于帮助识别这条密钥。",
|
||||
descriptionPlaceholder: "添加密钥用途说明",
|
||||
descriptionHelp: "可选,用于记录密钥的使用位置或用途。",
|
||||
indexesPlaceholder: "选择允许访问的索引",
|
||||
indexesHelp: "可选择一个或多个当前有权访问的索引;* 表示所有索引。",
|
||||
actionsPlaceholder: "选择允许执行的权限动作",
|
||||
actionsHelp: "选择该密钥可以调用的 API 操作,可多选。",
|
||||
expiresAtPlaceholder: "选择或输入日期时间",
|
||||
expiresAtHelp: "可选。留空表示永不过期,也可直接输入有效的日期时间。",
|
||||
openDateTimePicker: "打开日期时间选择器",
|
||||
invalidExpiresAt: "请输入有效的过期日期和时间。",
|
||||
copyKeyValue: "复制 API 密钥",
|
||||
paginationSummary: "第 {start}-{end} 条,共 {total} 条",
|
||||
readOnlyDisabled: "当前连接为只读模式,管理操作已禁用。",
|
||||
filters: "筛选",
|
||||
applyFilters: "应用筛选",
|
||||
resetFilters: "重置筛选",
|
||||
uidFilter: "UID 过滤",
|
||||
searchUid: "搜索 UID",
|
||||
allIndexes: "全部索引",
|
||||
allStatuses: "全部状态",
|
||||
indexListUnavailable: "无法加载当前连接有权限访问的索引",
|
||||
reverseOrder: "倒序",
|
||||
columnSettings: "设置列",
|
||||
index: "索引",
|
||||
type: "类型",
|
||||
details: "详情",
|
||||
receivedDocuments: "接收文档数",
|
||||
indexedDocuments: "已索引文档数",
|
||||
enqueuedAt: "入列时间",
|
||||
startedAt: "开始时间",
|
||||
finishedAt: "结束时间",
|
||||
duration: "耗时",
|
||||
afterEnqueuedAt: "入列晚于",
|
||||
beforeEnqueuedAt: "入列早于",
|
||||
afterStartedAt: "开始晚于",
|
||||
beforeStartedAt: "开始早于",
|
||||
afterFinishedAt: "完成晚于",
|
||||
beforeFinishedAt: "完成早于",
|
||||
indexTasks: "任务 · {index}",
|
||||
noTasks: "没有匹配的任务",
|
||||
taskCount: "匹配 {count} 个任务",
|
||||
taskDetails: "任务详情",
|
||||
cancelTask: "取消任务",
|
||||
cancelTasks: "取消任务",
|
||||
deleteTaskHistory: "删除任务历史",
|
||||
cancelFilteredTasks: "取消筛选出的活动任务",
|
||||
deleteFilteredHistory: "删除筛选出的已完成历史",
|
||||
explicitFilterRequired: "批量操作前必须明确设置至少一个筛选条件。",
|
||||
noAllowedTaskStatuses: "已应用的筛选条件中没有此操作允许的任务状态。",
|
||||
countingMatchingTasks: "正在统计匹配任务...",
|
||||
cancelTasksConfirm: "确定取消此筛选器匹配的活动任务(当前匹配 {count} 条)?",
|
||||
deleteTasksConfirm: "确定删除此筛选器匹配的已完成任务历史(当前匹配 {count} 条)?",
|
||||
waitingForTask: "正在等待管理任务 #{uid}",
|
||||
taskOperationCompleted: "任务操作已完成",
|
||||
taskOperationFinished: "任务操作结束,状态为 {status}",
|
||||
taskPollingTimeout: "任务 #{uid} 在 60 秒后仍未完成,请手动刷新查看。",
|
||||
taskOutsideIndex: "该任务不属于当前索引。",
|
||||
};
|
||||
|
||||
export const meilisearchManagementMessages = {
|
||||
en,
|
||||
"zh-CN": zhCN,
|
||||
"zh-TW": {
|
||||
...en,
|
||||
...zhCN,
|
||||
systemManagement: "系統管理",
|
||||
overview: "總覽",
|
||||
keysDescription: "建立和管理限定權限的 Meilisearch API 金鑰。",
|
||||
tasks: "任務",
|
||||
health: "健康狀態",
|
||||
totalDocuments: "文件總數",
|
||||
keyCount: "金鑰數",
|
||||
refresh: "重新整理",
|
||||
previous: "上一頁",
|
||||
next: "下一頁",
|
||||
rowActions: "操作",
|
||||
createKey: "建立金鑰",
|
||||
editKey: "編輯金鑰",
|
||||
deleteKey: "刪除金鑰",
|
||||
receivedDocuments: "接收文件數",
|
||||
indexedDocuments: "已索引文件數",
|
||||
noTasks: "沒有符合的任務",
|
||||
readOnlyDisabled: "目前連線為唯讀模式,管理操作已停用。",
|
||||
},
|
||||
ja: {
|
||||
...en,
|
||||
systemManagement: "システム管理",
|
||||
overview: "概要",
|
||||
overviewDescription: "ヘルス、容量、インデックス、キー、タスクをリアルタイムで確認します。",
|
||||
apiKeys: "API キー",
|
||||
tasks: "タスク",
|
||||
health: "ヘルス",
|
||||
version: "バージョン",
|
||||
indexCount: "インデックス",
|
||||
totalDocuments: "ドキュメント合計",
|
||||
keyCount: "キー",
|
||||
refresh: "更新",
|
||||
rowActions: "操作",
|
||||
previous: "前へ",
|
||||
next: "次へ",
|
||||
createKey: "キーを作成",
|
||||
editKey: "キーを編集",
|
||||
deleteKey: "キーを削除",
|
||||
noKeys: "API キーがありません",
|
||||
filters: "フィルター",
|
||||
applyFilters: "適用",
|
||||
receivedDocuments: "受信ドキュメント数",
|
||||
indexedDocuments: "インデックス済みドキュメント数",
|
||||
noTasks: "タスクがありません",
|
||||
cancelTask: "タスクをキャンセル",
|
||||
deleteTaskHistory: "タスク履歴を削除",
|
||||
readOnlyDisabled: "読み取り専用接続では管理操作を実行できません。",
|
||||
},
|
||||
ko: {
|
||||
...en,
|
||||
systemManagement: "시스템 관리",
|
||||
overview: "개요",
|
||||
apiKeys: "API 키",
|
||||
tasks: "작업",
|
||||
health: "상태",
|
||||
version: "버전",
|
||||
indexCount: "인덱스",
|
||||
totalDocuments: "전체 문서",
|
||||
keyCount: "키",
|
||||
refresh: "새로 고침",
|
||||
rowActions: "작업",
|
||||
previous: "이전",
|
||||
next: "다음",
|
||||
createKey: "키 만들기",
|
||||
editKey: "키 편집",
|
||||
deleteKey: "키 삭제",
|
||||
noKeys: "API 키가 없습니다",
|
||||
filters: "필터",
|
||||
applyFilters: "필터 적용",
|
||||
receivedDocuments: "수신 문서 수",
|
||||
indexedDocuments: "인덱싱된 문서 수",
|
||||
noTasks: "작업이 없습니다",
|
||||
cancelTask: "작업 취소",
|
||||
deleteTaskHistory: "작업 기록 삭제",
|
||||
readOnlyDisabled: "읽기 전용 연결에서는 관리 작업이 비활성화됩니다.",
|
||||
},
|
||||
es: {
|
||||
...en,
|
||||
systemManagement: "Administración del sistema",
|
||||
overview: "Resumen",
|
||||
apiKeys: "Claves API",
|
||||
tasks: "Tareas",
|
||||
health: "Estado",
|
||||
version: "Versión",
|
||||
indexCount: "Índices",
|
||||
totalDocuments: "Documentos totales",
|
||||
keyCount: "Claves",
|
||||
refresh: "Actualizar",
|
||||
rowActions: "Acciones",
|
||||
previous: "Anterior",
|
||||
next: "Siguiente",
|
||||
createKey: "Crear clave",
|
||||
editKey: "Editar clave",
|
||||
deleteKey: "Eliminar clave",
|
||||
noKeys: "No hay claves API",
|
||||
filters: "Filtros",
|
||||
applyFilters: "Aplicar filtros",
|
||||
receivedDocuments: "Documentos recibidos",
|
||||
indexedDocuments: "Documentos indexados",
|
||||
noTasks: "No hay tareas",
|
||||
cancelTask: "Cancelar tarea",
|
||||
deleteTaskHistory: "Eliminar historial",
|
||||
readOnlyDisabled: "Las acciones están deshabilitadas en conexiones de solo lectura.",
|
||||
},
|
||||
it: {
|
||||
...en,
|
||||
systemManagement: "Gestione sistema",
|
||||
overview: "Panoramica",
|
||||
apiKeys: "Chiavi API",
|
||||
tasks: "Attività",
|
||||
health: "Stato",
|
||||
version: "Versione",
|
||||
indexCount: "Indici",
|
||||
totalDocuments: "Documenti totali",
|
||||
keyCount: "Chiavi",
|
||||
refresh: "Aggiorna",
|
||||
rowActions: "Azioni",
|
||||
previous: "Precedente",
|
||||
next: "Successivo",
|
||||
createKey: "Crea chiave",
|
||||
editKey: "Modifica chiave",
|
||||
deleteKey: "Elimina chiave",
|
||||
noKeys: "Nessuna chiave API",
|
||||
filters: "Filtri",
|
||||
applyFilters: "Applica filtri",
|
||||
receivedDocuments: "Documenti ricevuti",
|
||||
indexedDocuments: "Documenti indicizzati",
|
||||
noTasks: "Nessuna attività",
|
||||
cancelTask: "Annulla attività",
|
||||
deleteTaskHistory: "Elimina cronologia",
|
||||
readOnlyDisabled: "Le azioni sono disabilitate per le connessioni di sola lettura.",
|
||||
},
|
||||
"pt-BR": {
|
||||
...en,
|
||||
systemManagement: "Gerenciamento do sistema",
|
||||
overview: "Visão geral",
|
||||
apiKeys: "Chaves de API",
|
||||
tasks: "Tarefas",
|
||||
health: "Saúde",
|
||||
version: "Versão",
|
||||
indexCount: "Índices",
|
||||
totalDocuments: "Total de documentos",
|
||||
keyCount: "Chaves",
|
||||
refresh: "Atualizar",
|
||||
rowActions: "Ações",
|
||||
previous: "Anterior",
|
||||
next: "Próxima",
|
||||
createKey: "Criar chave",
|
||||
editKey: "Editar chave",
|
||||
deleteKey: "Excluir chave",
|
||||
noKeys: "Nenhuma chave de API",
|
||||
filters: "Filtros",
|
||||
applyFilters: "Aplicar filtros",
|
||||
receivedDocuments: "Documentos recebidos",
|
||||
indexedDocuments: "Documentos indexados",
|
||||
noTasks: "Nenhuma tarefa",
|
||||
cancelTask: "Cancelar tarefa",
|
||||
deleteTaskHistory: "Excluir histórico",
|
||||
readOnlyDisabled: "As ações ficam desativadas em conexões somente leitura.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const meilisearchManagementEn = meilisearchManagementMessages.en;
|
||||
export const meilisearchManagementZhCN = meilisearchManagementMessages["zh-CN"];
|
||||
export const meilisearchManagementZhTW = meilisearchManagementMessages["zh-TW"];
|
||||
export const meilisearchManagementJa = meilisearchManagementMessages.ja;
|
||||
export const meilisearchManagementKo = meilisearchManagementMessages.ko;
|
||||
export const meilisearchManagementEs = meilisearchManagementMessages.es;
|
||||
export const meilisearchManagementIt = meilisearchManagementMessages.it;
|
||||
export const meilisearchManagementPtBR = meilisearchManagementMessages["pt-BR"];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import { meilisearchManagementPtBR } from "./meilisearchManagement";
|
||||
import docs from "./docs/pt-BR";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
|
||||
@@ -4561,6 +4562,7 @@ export default withEnglishFallback({
|
||||
indexPropertiesUnavailable: "O driver Legacy do MongoDB não pode informar esparso, expiração, background ou tamanho do bucket. Conecte-se com o driver nativo para vê-los.",
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementPtBR,
|
||||
documents: "Documentos",
|
||||
settings: "Configurações",
|
||||
search: "Buscar",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import docs from "./docs/zh-CN";
|
||||
import { consul } from "./consulZhCN";
|
||||
import { meilisearchManagementZhCN } from "./meilisearchManagement";
|
||||
|
||||
export default withEnglishFallback({
|
||||
customType: {
|
||||
@@ -4719,6 +4720,7 @@ export default withEnglishFallback({
|
||||
sortPlaceholder: "排序条件...",
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementZhCN,
|
||||
documents: "文档",
|
||||
settings: "设置",
|
||||
search: "搜索",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withEnglishFallback } from "./fallback";
|
||||
import { meilisearchManagementZhTW } from "./meilisearchManagement";
|
||||
import docs from "./docs/zh-TW";
|
||||
import { consulUiMessages } from "./consulUi";
|
||||
import { nacosAccessControlMessages, nacosAccessControlTabs } from "./nacosAccessControl";
|
||||
@@ -3885,6 +3886,7 @@ export default withEnglishFallback({
|
||||
nativeDocumentRequired: "原生 JSON 的最上層必須是文件物件。",
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementZhTW,
|
||||
documents: "文件",
|
||||
settings: "設定",
|
||||
search: "搜尋",
|
||||
|
||||
@@ -11,6 +11,12 @@ describe("sidebar active-tab targets", () => {
|
||||
expect(matchesTarget({ id: "connection:pg-2", label: "Other", type: "connection", connectionId: "pg-2" } as TreeNode, target!)).toBe(false);
|
||||
});
|
||||
|
||||
it("selects the Meilisearch system-management leaf for its dedicated tab", () => {
|
||||
const target = activeTabSidebarTarget({ mode: "meilisearch-system", connectionId: "meili-1" } as QueryTab);
|
||||
expect(target).toEqual({ type: "meilisearch-system", connectionId: "meili-1" });
|
||||
expect(matchesTarget({ id: "meili-system", label: "meilisearch.systemManagement", type: "meilisearch-system", connectionId: "meili-1" }, target!)).toBe(true);
|
||||
});
|
||||
|
||||
it("locates a query context only in its exact catalog", () => {
|
||||
const target = activeTabSidebarTarget({ mode: "query", connectionId: "doris-1", catalog: "hive", database: "analytics" } as QueryTab);
|
||||
const hiveDatabase = { id: "hive:analytics", label: "analytics", type: "database", connectionId: "doris-1", catalog: "hive", database: "analytics" } as TreeNode;
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
describe("Meilisearch sidebar ordering", () => {
|
||||
it("keeps the single system-management node after all indexes", () => {
|
||||
const children: TreeNode[] = [
|
||||
{ id: "system", label: "meilisearch.systemManagement", type: "meilisearch-system", connectionId: "c1" },
|
||||
{ id: "z", label: "zebra", type: "elasticsearch-index", connectionId: "c1" },
|
||||
{ id: "a", label: "alpha", type: "elasticsearch-index", connectionId: "c1" },
|
||||
];
|
||||
const result = sortSidebarTreeChildrenForParent({ type: "connection" }, children, "meilisearch");
|
||||
expect(result.map((node) => node.id)).toEqual(["a", "z", "system"]);
|
||||
expect(result.filter((node) => node.type === "meilisearch-system")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,12 @@ describe("treeNodeClick", () => {
|
||||
expect(treeNodeRowAction("consul-overview", false, "double")).toBe("toggle");
|
||||
});
|
||||
|
||||
it("opens Meilisearch system management as a direct leaf navigation entry", () => {
|
||||
expect(isDirectNavigationTreeNode("meilisearch-system")).toBe(true);
|
||||
expect(shouldActivateTreeNodeOnSingleClick("meilisearch-system", "double")).toBe(true);
|
||||
expect(treeNodeRowAction("meilisearch-system", false, "double")).toBe("toggle");
|
||||
});
|
||||
|
||||
it("keeps Nacos namespace and access-control navigation responsive during rapid row switching", () => {
|
||||
for (const type of ["nacos-namespace", "nacos-access-control"] as const) {
|
||||
expect(isDirectNavigationTreeNode(type), type).toBe(true);
|
||||
|
||||
@@ -754,6 +754,17 @@ export const meilisearchGetIndexStats = forward("meilisearchGetIndexStats");
|
||||
export const meilisearchGetIndexOverview = forward("meilisearchGetIndexOverview");
|
||||
export const meilisearchDeleteIndex = forward("meilisearchDeleteIndex");
|
||||
export const meilisearchDeleteAllDocuments = forward("meilisearchDeleteAllDocuments");
|
||||
export const meilisearchGetSystemOverview = forward("meilisearchGetSystemOverview");
|
||||
export const meilisearchListIndexes = forward("meilisearchListIndexes");
|
||||
export const meilisearchListKeys = forward("meilisearchListKeys");
|
||||
export const meilisearchGetKey = forward("meilisearchGetKey");
|
||||
export const meilisearchCreateKey = forward("meilisearchCreateKey");
|
||||
export const meilisearchUpdateKey = forward("meilisearchUpdateKey");
|
||||
export const meilisearchDeleteKey = forward("meilisearchDeleteKey");
|
||||
export const meilisearchGetTasks = forward("meilisearchGetTasks");
|
||||
export const meilisearchGetTask = forward("meilisearchGetTask");
|
||||
export const meilisearchCancelTasks = forward("meilisearchCancelTasks");
|
||||
export const meilisearchDeleteTasks = forward("meilisearchDeleteTasks");
|
||||
export const mongoDeleteDocument = forward("mongoDeleteDocument");
|
||||
export const mongoDeleteDocuments = forward("mongoDeleteDocuments");
|
||||
export const mongoFindOneAndUpdate = forward("mongoFindOneAndUpdate");
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
|
||||
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
|
||||
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
|
||||
import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
@@ -3659,6 +3660,12 @@ export async function elasticsearchListIndices(connectionId: string): Promise<st
|
||||
return collections.map((c) => c.name);
|
||||
}
|
||||
|
||||
/** Lists every Meilisearch index visible to the current connection credentials. */
|
||||
export async function meilisearchListIndexes(connectionId: string): Promise<string[]> {
|
||||
const collections = await documentListCollections(connectionId, "default");
|
||||
return collections.map((collection) => collection.name);
|
||||
}
|
||||
|
||||
export async function vectorListCollections(connectionId: string, database?: string): Promise<CollectionInfo[]> {
|
||||
return documentListCollections(connectionId, database || "default");
|
||||
}
|
||||
@@ -4073,6 +4080,46 @@ export async function meilisearchDeleteAllDocuments(connectionId: string, index:
|
||||
});
|
||||
}
|
||||
|
||||
export async function meilisearchGetSystemOverview(connectionId: string): Promise<MeilisearchSystemOverview> {
|
||||
return post("/api/document-store/meilisearch/system/overview", { connectionId });
|
||||
}
|
||||
|
||||
export async function meilisearchListKeys(connectionId: string, offset = 0, limit = 20): Promise<KeyPage> {
|
||||
return post("/api/document-store/meilisearch/keys/list", { connectionId, offset, limit });
|
||||
}
|
||||
|
||||
export async function meilisearchGetKey(connectionId: string, uid: string): Promise<KeyListItem> {
|
||||
return post("/api/document-store/meilisearch/keys/get", { connectionId, uid });
|
||||
}
|
||||
|
||||
export async function meilisearchCreateKey(connectionId: string, input: KeyCreateInput): Promise<CreatedKey> {
|
||||
return post("/api/document-store/meilisearch/keys/create", { connectionId, input });
|
||||
}
|
||||
|
||||
export async function meilisearchUpdateKey(connectionId: string, uid: string, input: KeyUpdateInput): Promise<KeyListItem> {
|
||||
return post("/api/document-store/meilisearch/keys/update", { connectionId, uid, input });
|
||||
}
|
||||
|
||||
export async function meilisearchDeleteKey(connectionId: string, uid: string): Promise<void> {
|
||||
return post("/api/document-store/meilisearch/keys/delete", { connectionId, uid });
|
||||
}
|
||||
|
||||
export async function meilisearchGetTasks(connectionId: string, input: TaskListInput): Promise<TaskPage> {
|
||||
return post("/api/document-store/meilisearch/tasks/list", { connectionId, input });
|
||||
}
|
||||
|
||||
export async function meilisearchGetTask(connectionId: string, uid: number, expectedIndexUid?: string): Promise<MeilisearchTask> {
|
||||
return post("/api/document-store/meilisearch/tasks/get", { connectionId, uid, expectedIndexUid: expectedIndexUid ?? null });
|
||||
}
|
||||
|
||||
export async function meilisearchCancelTasks(connectionId: string, selector: TaskSelector): Promise<EnqueuedTaskSummary> {
|
||||
return post("/api/document-store/meilisearch/tasks/cancel", { connectionId, selector });
|
||||
}
|
||||
|
||||
export async function meilisearchDeleteTasks(connectionId: string, selector: TaskSelector): Promise<EnqueuedTaskSummary> {
|
||||
return post("/api/document-store/meilisearch/tasks/delete", { connectionId, selector });
|
||||
}
|
||||
|
||||
export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> {
|
||||
return post("/api/mongo/delete-documents", {
|
||||
connectionId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoS
|
||||
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
|
||||
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
|
||||
import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement";
|
||||
|
||||
/** Normalize Tauri rejections once at the public backend boundary. */
|
||||
async function invokeBackend<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
@@ -3645,6 +3646,12 @@ export async function elasticsearchListIndices(connectionId: string): Promise<st
|
||||
return collections.map((c) => c.name);
|
||||
}
|
||||
|
||||
/** Lists every Meilisearch index visible to the current connection credentials. */
|
||||
export async function meilisearchListIndexes(connectionId: string): Promise<string[]> {
|
||||
const collections = await documentListCollections(connectionId, "default");
|
||||
return collections.map((collection) => collection.name);
|
||||
}
|
||||
|
||||
export async function vectorListCollections(connectionId: string, database?: string): Promise<CollectionInfo[]> {
|
||||
return documentListCollections(connectionId, database || "default");
|
||||
}
|
||||
@@ -4043,6 +4050,46 @@ export async function meilisearchDeleteAllDocuments(connectionId: string, index:
|
||||
});
|
||||
}
|
||||
|
||||
export async function meilisearchGetSystemOverview(connectionId: string): Promise<MeilisearchSystemOverview> {
|
||||
return invokeBackend("meilisearch_get_system_overview", { connectionId });
|
||||
}
|
||||
|
||||
export async function meilisearchListKeys(connectionId: string, offset = 0, limit = 20): Promise<KeyPage> {
|
||||
return invokeBackend("meilisearch_list_keys", { connectionId, offset, limit });
|
||||
}
|
||||
|
||||
export async function meilisearchGetKey(connectionId: string, uid: string): Promise<KeyListItem> {
|
||||
return invokeBackend("meilisearch_get_key", { connectionId, uid });
|
||||
}
|
||||
|
||||
export async function meilisearchCreateKey(connectionId: string, input: KeyCreateInput): Promise<CreatedKey> {
|
||||
return invokeBackend("meilisearch_create_key", { connectionId, input });
|
||||
}
|
||||
|
||||
export async function meilisearchUpdateKey(connectionId: string, uid: string, input: KeyUpdateInput): Promise<KeyListItem> {
|
||||
return invokeBackend("meilisearch_update_key", { connectionId, uid, input });
|
||||
}
|
||||
|
||||
export async function meilisearchDeleteKey(connectionId: string, uid: string): Promise<void> {
|
||||
return invokeBackend("meilisearch_delete_key", { connectionId, uid });
|
||||
}
|
||||
|
||||
export async function meilisearchGetTasks(connectionId: string, input: TaskListInput): Promise<TaskPage> {
|
||||
return invokeBackend("meilisearch_get_tasks", { connectionId, input });
|
||||
}
|
||||
|
||||
export async function meilisearchGetTask(connectionId: string, uid: number, expectedIndexUid?: string): Promise<MeilisearchTask> {
|
||||
return invokeBackend("meilisearch_get_task", { connectionId, uid, expectedIndexUid: expectedIndexUid ?? null });
|
||||
}
|
||||
|
||||
export async function meilisearchCancelTasks(connectionId: string, selector: TaskSelector): Promise<EnqueuedTaskSummary> {
|
||||
return invokeBackend("meilisearch_cancel_tasks", { connectionId, selector });
|
||||
}
|
||||
|
||||
export async function meilisearchDeleteTasks(connectionId: string, selector: TaskSelector): Promise<EnqueuedTaskSummary> {
|
||||
return invokeBackend("meilisearch_delete_tasks", { connectionId, selector });
|
||||
}
|
||||
|
||||
export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> {
|
||||
const affectedRows = await invoke<number>("mongo_delete_documents", {
|
||||
connectionId,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadMeilisearchKeyColumns, MEILISEARCH_KEY_COLUMN_KEYS, MEILISEARCH_KEY_COLUMN_STORAGE_KEY, saveMeilisearchKeyColumns } from "./meilisearchKeyColumns";
|
||||
|
||||
describe("Meilisearch key column persistence", () => {
|
||||
beforeEach(() => localStorage.removeItem(MEILISEARCH_KEY_COLUMN_STORAGE_KEY));
|
||||
|
||||
it("uses all columns by default", () => {
|
||||
expect(loadMeilisearchKeyColumns()).toEqual(MEILISEARCH_KEY_COLUMN_KEYS);
|
||||
});
|
||||
|
||||
it("stores valid columns in canonical order", () => {
|
||||
saveMeilisearchKeyColumns(["expiresAt", "name", "key"]);
|
||||
expect(loadMeilisearchKeyColumns()).toEqual(["name", "key", "expiresAt"]);
|
||||
});
|
||||
|
||||
it("falls back safely for invalid persisted data", () => {
|
||||
localStorage.setItem(MEILISEARCH_KEY_COLUMN_STORAGE_KEY, "invalid");
|
||||
expect(loadMeilisearchKeyColumns()).toEqual(MEILISEARCH_KEY_COLUMN_KEYS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
export const MEILISEARCH_KEY_COLUMN_STORAGE_KEY = "dbx:meilisearch:key-columns:v1";
|
||||
export const MEILISEARCH_KEY_COLUMN_KEYS = ["name", "key", "uid", "actions", "indexes", "expiresAt"] as const;
|
||||
export type MeilisearchKeyColumnKey = (typeof MEILISEARCH_KEY_COLUMN_KEYS)[number];
|
||||
|
||||
function isKeyColumn(value: unknown): value is MeilisearchKeyColumnKey {
|
||||
return typeof value === "string" && MEILISEARCH_KEY_COLUMN_KEYS.includes(value as MeilisearchKeyColumnKey);
|
||||
}
|
||||
|
||||
export function loadMeilisearchKeyColumns(): MeilisearchKeyColumnKey[] {
|
||||
try {
|
||||
const parsed = JSON.parse(safeLocalStorageGet(MEILISEARCH_KEY_COLUMN_STORAGE_KEY) || "null") as { visible?: unknown } | null;
|
||||
const visible = Array.isArray(parsed?.visible) ? parsed.visible.filter(isKeyColumn) : [];
|
||||
return visible.length ? MEILISEARCH_KEY_COLUMN_KEYS.filter((key) => visible.includes(key)) : [...MEILISEARCH_KEY_COLUMN_KEYS];
|
||||
} catch {
|
||||
return [...MEILISEARCH_KEY_COLUMN_KEYS];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMeilisearchKeyColumns(visible: readonly MeilisearchKeyColumnKey[]): void {
|
||||
const normalized = MEILISEARCH_KEY_COLUMN_KEYS.filter((key) => visible.includes(key));
|
||||
safeLocalStorageSet(MEILISEARCH_KEY_COLUMN_STORAGE_KEY, JSON.stringify({ version: 1, visible: normalized.length ? normalized : [...MEILISEARCH_KEY_COLUMN_KEYS] }));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadMeilisearchTaskColumns, MEILISEARCH_TASK_COLUMN_KEYS, MEILISEARCH_TASK_COLUMN_STORAGE_KEY, saveMeilisearchTaskColumns } from "./meilisearchTaskColumns";
|
||||
|
||||
describe("Meilisearch task column persistence", () => {
|
||||
beforeEach(() => localStorage.removeItem(MEILISEARCH_TASK_COLUMN_STORAGE_KEY));
|
||||
|
||||
it("shows every supported column by default", () => {
|
||||
expect(loadMeilisearchTaskColumns()).toEqual(MEILISEARCH_TASK_COLUMN_KEYS);
|
||||
});
|
||||
|
||||
it("persists known visible columns in stable table order", () => {
|
||||
saveMeilisearchTaskColumns(["finishedAt", "uid", "details"]);
|
||||
expect(loadMeilisearchTaskColumns()).toEqual(["uid", "details", "finishedAt"]);
|
||||
});
|
||||
|
||||
it("ignores corrupt and unknown stored values", () => {
|
||||
localStorage.setItem(MEILISEARCH_TASK_COLUMN_STORAGE_KEY, JSON.stringify({ visible: ["unknown", "status"] }));
|
||||
expect(loadMeilisearchTaskColumns()).toEqual(["status"]);
|
||||
localStorage.setItem(MEILISEARCH_TASK_COLUMN_STORAGE_KEY, "not-json");
|
||||
expect(loadMeilisearchTaskColumns()).toEqual(MEILISEARCH_TASK_COLUMN_KEYS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
export const MEILISEARCH_TASK_COLUMN_STORAGE_KEY = "dbx:meilisearch:task-columns:v1";
|
||||
|
||||
export const MEILISEARCH_TASK_COLUMN_KEYS = ["uid", "index", "type", "status", "details", "enqueuedAt", "startedAt", "finishedAt", "duration"] as const;
|
||||
|
||||
export type MeilisearchTaskColumnKey = (typeof MEILISEARCH_TASK_COLUMN_KEYS)[number];
|
||||
|
||||
function isTaskColumnKey(value: unknown): value is MeilisearchTaskColumnKey {
|
||||
return typeof value === "string" && MEILISEARCH_TASK_COLUMN_KEYS.includes(value as MeilisearchTaskColumnKey);
|
||||
}
|
||||
|
||||
export function loadMeilisearchTaskColumns(): MeilisearchTaskColumnKey[] {
|
||||
try {
|
||||
const parsed = JSON.parse(safeLocalStorageGet(MEILISEARCH_TASK_COLUMN_STORAGE_KEY) || "null") as { visible?: unknown } | null;
|
||||
const visible = Array.isArray(parsed?.visible) ? parsed.visible.filter(isTaskColumnKey) : [];
|
||||
return visible.length ? MEILISEARCH_TASK_COLUMN_KEYS.filter((key) => visible.includes(key)) : [...MEILISEARCH_TASK_COLUMN_KEYS];
|
||||
} catch {
|
||||
return [...MEILISEARCH_TASK_COLUMN_KEYS];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMeilisearchTaskColumns(visible: readonly MeilisearchTaskColumnKey[]): void {
|
||||
const normalized = MEILISEARCH_TASK_COLUMN_KEYS.filter((key) => visible.includes(key));
|
||||
safeLocalStorageSet(MEILISEARCH_TASK_COLUMN_STORAGE_KEY, JSON.stringify({ version: 1, visible: normalized.length ? normalized : [...MEILISEARCH_TASK_COLUMN_KEYS] }));
|
||||
}
|
||||
@@ -64,6 +64,10 @@ export type ActiveTabSidebarTarget =
|
||||
type: "consul-overview";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "meilisearch-system";
|
||||
connectionId: string;
|
||||
}
|
||||
| {
|
||||
type: "mq-tenant";
|
||||
connectionId: string;
|
||||
@@ -143,6 +147,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
|
||||
};
|
||||
}
|
||||
|
||||
if (tab.mode === "meilisearch-system") {
|
||||
return { type: "meilisearch-system", connectionId: tab.connectionId };
|
||||
}
|
||||
|
||||
if (tab.mode === "mongo-bucket") {
|
||||
return {
|
||||
type: "mongo-gridfs",
|
||||
@@ -255,6 +263,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
|
||||
return node.type === "mongo-collection" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.collectionName;
|
||||
}
|
||||
|
||||
if (target.type === "meilisearch-system") {
|
||||
return node.type === "meilisearch-system" && node.connectionId === target.connectionId;
|
||||
}
|
||||
|
||||
if (target.type === "mongo-gridfs") {
|
||||
return node.type === "mongo-gridfs" && node.connectionId === target.connectionId && node.database === target.database;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ export function sortSidebarTreeChildrenForParent(parent: Pick<TreeNode, "type">,
|
||||
const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlNodes, ...children, ...userAdminNodes];
|
||||
|
||||
if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
|
||||
return withConnectionUtilityOrder(sortByLabel(regularChildren));
|
||||
const meilisearchSystem = regularChildren.filter((child) => child.type === "meilisearch-system");
|
||||
const databaseObjects = regularChildren.filter((child) => child.type !== "meilisearch-system");
|
||||
return withConnectionUtilityOrder([...sortByLabel(databaseObjects), ...meilisearchSystem]);
|
||||
}
|
||||
|
||||
if (databaseType === "duckdb") {
|
||||
|
||||
@@ -30,6 +30,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
|
||||
"dynamodb-table",
|
||||
"vector-collection",
|
||||
"elasticsearch-index",
|
||||
"meilisearch-system",
|
||||
"user-admin",
|
||||
"dameng-users",
|
||||
"dameng-roles",
|
||||
|
||||
@@ -27,6 +27,7 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
|
||||
"dynamodb-table",
|
||||
"vector-collection",
|
||||
"elasticsearch-index",
|
||||
"meilisearch-system",
|
||||
"user-admin",
|
||||
"dameng-users",
|
||||
"dameng-roles",
|
||||
@@ -34,7 +35,7 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
|
||||
// These are application entry points rather than database objects. They should
|
||||
// always navigate on a single click, even when the user prefers double-click
|
||||
// activation for ordinary tree objects.
|
||||
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "nacos-namespace", "nacos-access-control"]);
|
||||
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "nacos-namespace", "nacos-access-control", "meilisearch-system"]);
|
||||
const repeatableNavigationTreeNodeTypes = new Set<TreeNodeType>(["nacos-namespace", "nacos-access-control"]);
|
||||
|
||||
export function isDirectNavigationTreeNode(type: TreeNodeType): boolean {
|
||||
|
||||
@@ -102,6 +102,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
|
||||
return { icon: TableProperties, colorClass: "text-cyan-400" };
|
||||
case "elasticsearch-index":
|
||||
return { icon: Table, colorClass: "text-emerald-400" };
|
||||
case "meilisearch-system":
|
||||
return { icon: Gauge, colorClass: "text-emerald-500" };
|
||||
case "procedure":
|
||||
return { icon: ScrollText, colorClass: "text-blue-500" };
|
||||
case "function":
|
||||
|
||||
@@ -167,4 +167,39 @@ describe("connectionStore Elasticsearch open/expand", () => {
|
||||
.sort(),
|
||||
).toEqual(["orders", "users"]);
|
||||
});
|
||||
|
||||
it("uses the shared Meilisearch index-list method for the Meilisearch tree", async () => {
|
||||
const elasticsearchListIndices = vi.fn().mockResolvedValue(["wrong-index"]);
|
||||
const meilisearchListIndexes = vi.fn().mockResolvedValue(["movies", "books"]);
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
elasticsearchListIndices,
|
||||
meilisearchListIndexes,
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.addEphemeralConnection({ ...esConnection(), id: "meili-1", name: "Meilisearch", db_type: "meilisearch", port: 7700 });
|
||||
seedConnectionNode(store, "meili-1", "Meilisearch");
|
||||
|
||||
await store.loadElasticsearchIndices("meili-1");
|
||||
|
||||
expect(meilisearchListIndexes).toHaveBeenCalledWith("meili-1");
|
||||
expect(elasticsearchListIndices).not.toHaveBeenCalled();
|
||||
expect(
|
||||
store.treeNodes
|
||||
.find((node) => node.id === "meili-1")
|
||||
?.children?.filter((node) => node.type === "elasticsearch-index")
|
||||
.map((node) => node.label)
|
||||
.sort(),
|
||||
).toEqual(["books", "movies"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4504,26 +4504,64 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
load = reclaimTreeNodeLoad(load, node);
|
||||
const indices = await withMetadataLoadTimeout(connectionId, api.elasticsearchListIndices(connectionId), "Elasticsearch indices");
|
||||
const isMeilisearch = getConfig(connectionId)?.db_type === "meilisearch";
|
||||
const indices = await withMetadataLoadTimeout(connectionId, isMeilisearch ? api.meilisearchListIndexes(connectionId) : api.elasticsearchListIndices(connectionId), isMeilisearch ? "Meilisearch indexes" : "Elasticsearch indices");
|
||||
const targetNode = treeNodeLoadTarget(load);
|
||||
if (!targetNode) return;
|
||||
setChildren(
|
||||
targetNode,
|
||||
withSavedSqlRoot(
|
||||
connectionId,
|
||||
sortSidebarNames(indices).map((index) => ({
|
||||
id: `${connectionId}:__collection:${index}`,
|
||||
label: index,
|
||||
type: "elasticsearch-index" as const,
|
||||
connectionId,
|
||||
database: "default",
|
||||
isExpanded: false,
|
||||
})),
|
||||
[
|
||||
...sortSidebarNames(indices).map((index) => ({
|
||||
id: `${connectionId}:__collection:${index}`,
|
||||
label: index,
|
||||
type: "elasticsearch-index" as const,
|
||||
connectionId,
|
||||
database: "default",
|
||||
isExpanded: false,
|
||||
})),
|
||||
...(isMeilisearch
|
||||
? [
|
||||
{
|
||||
id: `${connectionId}:__meilisearch_system`,
|
||||
label: "meilisearch.systemManagement",
|
||||
type: "meilisearch-system" as const,
|
||||
connectionId,
|
||||
database: "default",
|
||||
isExpanded: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
targetNode,
|
||||
),
|
||||
);
|
||||
targetNode.isExpanded = true;
|
||||
} catch (e) {
|
||||
const targetNode = treeNodeLoadTarget(load);
|
||||
if (targetNode && getConfig(connectionId)?.db_type === "meilisearch") {
|
||||
const existingChildren = (targetNode.children || []).filter((child) => child.type !== "meilisearch-system");
|
||||
setChildren(
|
||||
targetNode,
|
||||
withSavedSqlRoot(
|
||||
connectionId,
|
||||
[
|
||||
...existingChildren,
|
||||
{
|
||||
id: `${connectionId}:__meilisearch_system`,
|
||||
label: "meilisearch.systemManagement",
|
||||
type: "meilisearch-system",
|
||||
connectionId,
|
||||
database: "default",
|
||||
isExpanded: false,
|
||||
},
|
||||
],
|
||||
targetNode,
|
||||
),
|
||||
);
|
||||
targetNode.isExpanded = true;
|
||||
}
|
||||
recordMetadataLoadError(connectionId, e, load);
|
||||
throw e;
|
||||
} finally {
|
||||
|
||||
@@ -1744,6 +1744,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
function findTabByIdentity(connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string, catalog?: string) {
|
||||
if (mode === "meilisearch-system") {
|
||||
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.mode === mode);
|
||||
}
|
||||
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || ""));
|
||||
}
|
||||
|
||||
|
||||
@@ -975,6 +975,7 @@ export type TreeNodeType =
|
||||
| "vector-database"
|
||||
| "vector-collection"
|
||||
| "elasticsearch-index"
|
||||
| "meilisearch-system"
|
||||
| "mqtt-topic";
|
||||
|
||||
export interface ConnectionGroup {
|
||||
@@ -1195,6 +1196,7 @@ export interface QueryTab {
|
||||
| "redis-dashboard"
|
||||
| "mongo"
|
||||
| "meilisearch"
|
||||
| "meilisearch-system"
|
||||
| "mongo-gridfs"
|
||||
| "mongo-bucket"
|
||||
| "vector"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatMeilisearchTaskDateTime, formatMeilisearchTaskDetails, formatMeilisearchTaskDuration, hasExplicitTaskSelector, meilisearchTaskStatusLabel, normalizeTaskMutationSelector, normalizeTaskSelector, withFixedTaskIndex } from "./meilisearchManagement";
|
||||
|
||||
describe("Meilisearch task selector safety", () => {
|
||||
it("requires a concrete selector field for batch mutations", () => {
|
||||
expect(hasExplicitTaskSelector({})).toBe(false);
|
||||
expect(hasExplicitTaskSelector({ statuses: [] })).toBe(false);
|
||||
expect(hasExplicitTaskSelector({ indexUids: ["movies"] })).toBe(true);
|
||||
expect(hasExplicitTaskSelector({ afterEnqueuedAt: "2026-01-01T00:00:00Z" })).toBe(true);
|
||||
});
|
||||
|
||||
it("overrides any caller-provided index with the index-detail constraint", () => {
|
||||
expect(withFixedTaskIndex({ indexUids: ["other"], statuses: ["failed"] }, "movies")).toEqual({
|
||||
indexUids: ["movies"],
|
||||
statuses: ["failed"],
|
||||
});
|
||||
});
|
||||
|
||||
it("freezes the same normalized status selector enforced by the backend", () => {
|
||||
expect(normalizeTaskMutationSelector({ indexUids: [" movies ", "movies"], statuses: ["FAILED", "processing"] }, "cancel")).toEqual({
|
||||
indexUids: ["movies"],
|
||||
statuses: ["processing"],
|
||||
});
|
||||
expect(normalizeTaskMutationSelector({ uids: [2] }, "delete")?.statuses).toEqual(["succeeded", "failed", "canceled"]);
|
||||
expect(normalizeTaskMutationSelector({ types: ["documentAdditionOrUpdate"], statuses: ["failed"] }, "cancel")).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes task selector values deterministically", () => {
|
||||
expect(normalizeTaskSelector({ uids: [3, 1, 3], indexUids: [" books ", ""], statuses: ["FAILED"] })).toEqual({
|
||||
uids: [1, 3],
|
||||
indexUids: ["books"],
|
||||
statuses: ["failed"],
|
||||
});
|
||||
});
|
||||
|
||||
it("formats task statuses, timestamps, and ISO durations for display", () => {
|
||||
expect(meilisearchTaskStatusLabel("succeeded")).toBe("✅ Succeeded");
|
||||
expect(meilisearchTaskStatusLabel("processing")).toBe("⚡ Processing");
|
||||
expect(meilisearchTaskStatusLabel("futureStatus")).toBe("futureStatus");
|
||||
expect(formatMeilisearchTaskDateTime("2026-08-13T16:37:28Z", "en-US")).not.toContain("T16:37:28Z");
|
||||
expect(formatMeilisearchTaskDateTime("not-a-date", "en-US")).toBe("not-a-date");
|
||||
expect(formatMeilisearchTaskDuration("PT2.16344321S", "en-US")).toBe("2.16 sec");
|
||||
expect(formatMeilisearchTaskDuration("PT1H2M3S", "en-US")).toBe("1 hr 2 min");
|
||||
expect(formatMeilisearchTaskDuration("unknown", "en-US")).toBe("unknown");
|
||||
expect(formatMeilisearchTaskDetails({ receivedDocuments: 8, indexedDocuments: 8, futureField: true }, { receivedDocuments: "接收文档数", indexedDocuments: "已索引文档数" })).toBe("接收文档数: 8 · 已索引文档数: 8 · futureField: true");
|
||||
expect(formatMeilisearchTaskDetails(null)).toBe("-");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
export type MeilisearchOverviewSectionStatus = "available" | "forbidden" | "unsupported" | "error";
|
||||
|
||||
export interface OverviewSection<T> {
|
||||
status: MeilisearchOverviewSectionStatus;
|
||||
data?: T | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface MeilisearchHealthOverview {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MeilisearchVersionOverview {
|
||||
pkgVersion?: string | null;
|
||||
commitSha?: string | null;
|
||||
commitDate?: string | null;
|
||||
}
|
||||
|
||||
export interface MeilisearchStatsOverview {
|
||||
databaseSize?: number | null;
|
||||
usedDatabaseSize?: number | null;
|
||||
lastUpdate?: string | null;
|
||||
indexCount: number;
|
||||
documentCount: number;
|
||||
indexingCount: number;
|
||||
}
|
||||
|
||||
export interface MeilisearchTopIndex {
|
||||
uid: string;
|
||||
numberOfDocuments: number;
|
||||
isIndexing: boolean;
|
||||
}
|
||||
|
||||
export interface MeilisearchSystemOverview {
|
||||
health: OverviewSection<MeilisearchHealthOverview>;
|
||||
version: OverviewSection<MeilisearchVersionOverview>;
|
||||
stats: OverviewSection<MeilisearchStatsOverview>;
|
||||
taskCounts: OverviewSection<Record<string, number>>;
|
||||
keyCount: OverviewSection<number>;
|
||||
topIndexes: OverviewSection<MeilisearchTopIndex[]>;
|
||||
refreshedAt: string;
|
||||
}
|
||||
|
||||
export interface KeyListItem {
|
||||
uid: string;
|
||||
/** Plaintext key returned by Meilisearch. Keep it in memory only. */
|
||||
key: string;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
maskedKey: string;
|
||||
actions: string[];
|
||||
indexes: string[];
|
||||
expiresAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreatedKey extends KeyListItem {}
|
||||
|
||||
export interface KeyPage {
|
||||
results: KeyListItem[];
|
||||
offset: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface KeyCreateInput {
|
||||
uid?: string | null;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
actions: string[];
|
||||
indexes: string[];
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface KeyUpdateInput {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskSelector {
|
||||
uids?: number[];
|
||||
batchUids?: number[];
|
||||
canceledBy?: number[];
|
||||
indexUids?: string[];
|
||||
statuses?: string[];
|
||||
types?: string[];
|
||||
afterEnqueuedAt?: string;
|
||||
beforeEnqueuedAt?: string;
|
||||
afterStartedAt?: string;
|
||||
beforeStartedAt?: string;
|
||||
afterFinishedAt?: string;
|
||||
beforeFinishedAt?: string;
|
||||
}
|
||||
|
||||
export interface MeilisearchTask {
|
||||
uid: number;
|
||||
batchUid?: number | null;
|
||||
indexUid?: string | null;
|
||||
status: string;
|
||||
type: string;
|
||||
canceledBy?: number | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
error?: Record<string, unknown> | null;
|
||||
duration?: string | null;
|
||||
enqueuedAt?: string | null;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TaskPage {
|
||||
results: MeilisearchTask[];
|
||||
total: number;
|
||||
limit: number;
|
||||
from?: number | null;
|
||||
next?: number | null;
|
||||
}
|
||||
|
||||
export interface EnqueuedTaskSummary {
|
||||
taskUid: number;
|
||||
indexUid?: string | null;
|
||||
status: string;
|
||||
type: string;
|
||||
enqueuedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskListInput {
|
||||
selector: TaskSelector;
|
||||
from?: number | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export const MEILISEARCH_TASK_STATUS_OPTIONS = [
|
||||
{ value: "succeeded", label: "✅ Succeeded" },
|
||||
{ value: "processing", label: "⚡ Processing" },
|
||||
{ value: "failed", label: "❌ Failed" },
|
||||
{ value: "enqueued", label: "🔀 Enqueued" },
|
||||
{ value: "canceled", label: "🚫 Canceled" },
|
||||
] as const;
|
||||
|
||||
export function meilisearchTaskStatusLabel(status: string): string {
|
||||
return MEILISEARCH_TASK_STATUS_OPTIONS.find((option) => option.value === status.toLowerCase())?.label ?? status;
|
||||
}
|
||||
|
||||
export function formatMeilisearchTaskDateTime(value: string | null | undefined, locale?: string): string {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.valueOf())) return value;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatDurationUnit(value: number, unit: "day" | "hour" | "minute" | "second" | "millisecond", locale?: string, maximumFractionDigits = 0): string {
|
||||
return new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "short", maximumFractionDigits }).format(value);
|
||||
}
|
||||
|
||||
export function formatMeilisearchTaskDuration(value: string | null | undefined, locale?: string): string {
|
||||
if (!value) return "-";
|
||||
const match = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(value);
|
||||
if (!match) return value;
|
||||
const totalSeconds = Number(match[1] || 0) * 86_400 + Number(match[2] || 0) * 3_600 + Number(match[3] || 0) * 60 + Number(match[4] || 0);
|
||||
if (!Number.isFinite(totalSeconds)) return value;
|
||||
if (totalSeconds < 1) return formatDurationUnit(Math.round(totalSeconds * 1_000), "millisecond", locale);
|
||||
if (totalSeconds < 60) return formatDurationUnit(totalSeconds, "second", locale, 2);
|
||||
if (totalSeconds < 3_600) {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.round(totalSeconds % 60);
|
||||
return seconds ? `${formatDurationUnit(minutes, "minute", locale)} ${formatDurationUnit(seconds, "second", locale)}` : formatDurationUnit(minutes, "minute", locale);
|
||||
}
|
||||
if (totalSeconds < 86_400) {
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.round((totalSeconds % 3_600) / 60);
|
||||
return minutes ? `${formatDurationUnit(hours, "hour", locale)} ${formatDurationUnit(minutes, "minute", locale)}` : formatDurationUnit(hours, "hour", locale);
|
||||
}
|
||||
const days = Math.floor(totalSeconds / 86_400);
|
||||
const hours = Math.round((totalSeconds % 86_400) / 3_600);
|
||||
return hours ? `${formatDurationUnit(days, "day", locale)} ${formatDurationUnit(hours, "hour", locale)}` : formatDurationUnit(days, "day", locale);
|
||||
}
|
||||
|
||||
export function formatMeilisearchTaskDetails(details: Record<string, unknown> | null | undefined, labels: Partial<Record<string, string>> = {}): string {
|
||||
if (!details || !Object.keys(details).length) return "-";
|
||||
return Object.entries(details)
|
||||
.map(([key, value]) => `${labels[key] || key}: ${typeof value === "string" ? value : JSON.stringify(value)}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
export function hasExplicitTaskSelector(selector: TaskSelector): boolean {
|
||||
return Object.entries(selector).some(([, value]) => {
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return typeof value === "string" ? value.trim().length > 0 : value !== null && value !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export function withFixedTaskIndex(selector: TaskSelector, fixedIndexUid?: string): TaskSelector {
|
||||
return fixedIndexUid ? { ...selector, indexUids: [fixedIndexUid] } : selector;
|
||||
}
|
||||
|
||||
export type TaskMutationKind = "cancel" | "delete";
|
||||
|
||||
function normalizeStrings(values: string[] | undefined, lowercase = false): string[] | undefined {
|
||||
if (!values) return undefined;
|
||||
const normalized = values
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => (lowercase ? value.toLowerCase() : value))
|
||||
.sort();
|
||||
const unique = [...new Set(normalized)];
|
||||
return unique.length ? unique : undefined;
|
||||
}
|
||||
|
||||
function normalizeNumbers(values: number[] | undefined): number[] | undefined {
|
||||
if (!values) return undefined;
|
||||
const unique = [...new Set(values.filter(Number.isFinite))].sort((left, right) => left - right);
|
||||
return unique.length ? unique : undefined;
|
||||
}
|
||||
|
||||
export function normalizeTaskSelector(selector: TaskSelector): TaskSelector {
|
||||
const normalized: TaskSelector = {
|
||||
uids: normalizeNumbers(selector.uids),
|
||||
batchUids: normalizeNumbers(selector.batchUids),
|
||||
canceledBy: normalizeNumbers(selector.canceledBy),
|
||||
indexUids: normalizeStrings(selector.indexUids),
|
||||
statuses: normalizeStrings(selector.statuses, true),
|
||||
types: normalizeStrings(selector.types),
|
||||
afterEnqueuedAt: selector.afterEnqueuedAt?.trim() || undefined,
|
||||
beforeEnqueuedAt: selector.beforeEnqueuedAt?.trim() || undefined,
|
||||
afterStartedAt: selector.afterStartedAt?.trim() || undefined,
|
||||
beforeStartedAt: selector.beforeStartedAt?.trim() || undefined,
|
||||
afterFinishedAt: selector.afterFinishedAt?.trim() || undefined,
|
||||
beforeFinishedAt: selector.beforeFinishedAt?.trim() || undefined,
|
||||
};
|
||||
return Object.fromEntries(Object.entries(normalized).filter(([, value]) => value !== undefined)) as TaskSelector;
|
||||
}
|
||||
|
||||
/** Mirrors the backend's final mutation selector, including its status safety intersection. */
|
||||
export function normalizeTaskMutationSelector(selector: TaskSelector, kind: TaskMutationKind): TaskSelector | null {
|
||||
const normalized = normalizeTaskSelector(selector);
|
||||
if (!hasExplicitTaskSelector(normalized)) return null;
|
||||
const allowed = kind === "cancel" ? ["enqueued", "processing"] : ["succeeded", "failed", "canceled"];
|
||||
const statuses = normalized.statuses?.length ? allowed.filter((status) => normalized.statuses?.includes(status)) : allowed;
|
||||
if (!statuses.length) return null;
|
||||
return { ...normalized, statuses };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use reqwest::{Client as HttpClient, Method, RequestBuilder, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -28,6 +28,8 @@ const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
const QUERY_VALUE_ENCODE_SET: &AsciiSet = &PATH_SEGMENT_ENCODE_SET.add(b'&').add(b'=').add(b'+');
|
||||
|
||||
const INDEX_PAGE_SIZE: u64 = 1_000;
|
||||
const FIELD_SAMPLE_SIZE: u64 = 100;
|
||||
const TASK_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
@@ -1098,6 +1100,663 @@ pub async fn delete_all_documents(client: &MeilisearchClient, index: &str) -> Re
|
||||
wait_for_task(client, task.task_uid).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchTaskSelector {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub uids: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub batch_uids: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub canceled_by: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub index_uids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub statuses: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub types: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after_enqueued_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub before_enqueued_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after_started_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub before_started_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after_finished_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub before_finished_at: Option<String>,
|
||||
}
|
||||
|
||||
impl MeilisearchTaskSelector {
|
||||
fn has_filter(&self) -> bool {
|
||||
!self.uids.is_empty()
|
||||
|| !self.batch_uids.is_empty()
|
||||
|| !self.canceled_by.is_empty()
|
||||
|| self.index_uids.iter().any(|value| non_empty(value))
|
||||
|| self.statuses.iter().any(|value| non_empty(value))
|
||||
|| self.types.iter().any(|value| non_empty(value))
|
||||
|| self.after_enqueued_at.as_deref().is_some_and(non_empty)
|
||||
|| self.before_enqueued_at.as_deref().is_some_and(non_empty)
|
||||
|| self.after_started_at.as_deref().is_some_and(non_empty)
|
||||
|| self.before_started_at.as_deref().is_some_and(non_empty)
|
||||
|| self.after_finished_at.as_deref().is_some_and(non_empty)
|
||||
|| self.before_finished_at.as_deref().is_some_and(non_empty)
|
||||
}
|
||||
|
||||
fn with_allowed_statuses(&self, allowed: &[&str], action: &str) -> Result<Self, String> {
|
||||
let mut selector = self.clone();
|
||||
selector.index_uids = normalized_strings(&selector.index_uids, false);
|
||||
selector.types = normalized_strings(&selector.types, false);
|
||||
selector.statuses = normalized_strings(&selector.statuses, true);
|
||||
if !selector.has_filter() {
|
||||
return Err(format!("Meilisearch task {action} requires at least one explicit filter"));
|
||||
}
|
||||
selector.statuses = if selector.statuses.is_empty() {
|
||||
allowed.iter().map(|status| (*status).to_string()).collect()
|
||||
} else {
|
||||
allowed
|
||||
.iter()
|
||||
.filter(|allowed_status| selector.statuses.iter().any(|status| status == **allowed_status))
|
||||
.map(|status| (*status).to_string())
|
||||
.collect()
|
||||
};
|
||||
if selector.statuses.is_empty() {
|
||||
return Err(format!("Meilisearch task {action} selector does not match any allowed task status"));
|
||||
}
|
||||
Ok(selector)
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(value: &str) -> bool {
|
||||
!value.trim().is_empty()
|
||||
}
|
||||
|
||||
fn normalized_strings(values: &[String], lowercase: bool) -> Vec<String> {
|
||||
let mut normalized = values
|
||||
.iter()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| if lowercase { value.to_ascii_lowercase() } else { value.to_string() })
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
normalized
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchTask {
|
||||
pub uid: u64,
|
||||
#[serde(default)]
|
||||
pub batch_uid: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub index_uid: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(rename = "type")]
|
||||
pub task_type: String,
|
||||
#[serde(default)]
|
||||
pub canceled_by: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub details: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub error: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub duration: Option<String>,
|
||||
pub enqueued_at: String,
|
||||
#[serde(default)]
|
||||
pub started_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub finished_at: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
pub fn ensure_task_index(task: &MeilisearchTask, expected_index_uid: Option<&str>) -> Result<(), String> {
|
||||
let Some(expected_index_uid) = expected_index_uid else {
|
||||
return Ok(());
|
||||
};
|
||||
match task.index_uid.as_deref() {
|
||||
Some(actual_index_uid) if actual_index_uid == expected_index_uid => Ok(()),
|
||||
Some(actual_index_uid) => Err(format!(
|
||||
"Meilisearch task {} belongs to index '{}', expected index '{}'",
|
||||
task.uid, actual_index_uid, expected_index_uid
|
||||
)),
|
||||
None => Err(format!(
|
||||
"Meilisearch task {} is not associated with an index; expected index '{}'",
|
||||
task.uid, expected_index_uid
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchTaskPage {
|
||||
pub results: Vec<MeilisearchTask>,
|
||||
pub total: u64,
|
||||
pub limit: u64,
|
||||
#[serde(default)]
|
||||
pub from: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub next: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchTaskListInput {
|
||||
#[serde(default)]
|
||||
pub selector: MeilisearchTaskSelector,
|
||||
#[serde(default)]
|
||||
pub from: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchEnqueuedTaskSummary {
|
||||
pub task_uid: u64,
|
||||
#[serde(default)]
|
||||
pub index_uid: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(rename = "type")]
|
||||
pub task_type: String,
|
||||
pub enqueued_at: String,
|
||||
}
|
||||
|
||||
fn push_csv<T: ToString>(query: &mut Vec<(String, String)>, key: &str, values: &[T]) {
|
||||
if !values.is_empty() {
|
||||
query.push((key.to_string(), values.iter().map(ToString::to_string).collect::<Vec<_>>().join(",")));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_optional(query: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
|
||||
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
query.push((key.to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn task_selector_query(selector: &MeilisearchTaskSelector) -> String {
|
||||
let mut query = Vec::<(String, String)>::new();
|
||||
push_csv(&mut query, "uids", &selector.uids);
|
||||
push_csv(&mut query, "batchUids", &selector.batch_uids);
|
||||
push_csv(&mut query, "canceledBy", &selector.canceled_by);
|
||||
push_csv(&mut query, "indexUids", &selector.index_uids);
|
||||
push_csv(&mut query, "statuses", &selector.statuses);
|
||||
push_csv(&mut query, "types", &selector.types);
|
||||
push_optional(&mut query, "afterEnqueuedAt", selector.after_enqueued_at.as_deref());
|
||||
push_optional(&mut query, "beforeEnqueuedAt", selector.before_enqueued_at.as_deref());
|
||||
push_optional(&mut query, "afterStartedAt", selector.after_started_at.as_deref());
|
||||
push_optional(&mut query, "beforeStartedAt", selector.before_started_at.as_deref());
|
||||
push_optional(&mut query, "afterFinishedAt", selector.after_finished_at.as_deref());
|
||||
push_optional(&mut query, "beforeFinishedAt", selector.before_finished_at.as_deref());
|
||||
query
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}={}", utf8_percent_encode(&value, QUERY_VALUE_ENCODE_SET)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
pub async fn get_tasks(
|
||||
client: &MeilisearchClient,
|
||||
selector: &MeilisearchTaskSelector,
|
||||
from: Option<u64>,
|
||||
limit: u64,
|
||||
) -> Result<MeilisearchTaskPage, String> {
|
||||
let mut query = task_selector_query(selector);
|
||||
if !query.is_empty() {
|
||||
query.push('&');
|
||||
}
|
||||
query.push_str(&format!("limit={}", limit.clamp(1, 100)));
|
||||
if let Some(from) = from {
|
||||
query.push_str(&format!("&from={from}"));
|
||||
}
|
||||
let response = client
|
||||
.get(&format!("/tasks?{query}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "task listing").await?;
|
||||
serde_json::from_value(value).map_err(|error| format!("Meilisearch task list parse error: {error}"))
|
||||
}
|
||||
|
||||
pub async fn get_task(client: &MeilisearchClient, uid: u64) -> Result<MeilisearchTask, String> {
|
||||
let response = client
|
||||
.get(&format!("/tasks/{uid}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "task lookup").await?;
|
||||
serde_json::from_value(value).map_err(|error| format!("Meilisearch task parse error: {error}"))
|
||||
}
|
||||
|
||||
async fn mutate_tasks(
|
||||
client: &MeilisearchClient,
|
||||
selector: &MeilisearchTaskSelector,
|
||||
cancel: bool,
|
||||
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
|
||||
let (selector, action) = if cancel {
|
||||
(selector.with_allowed_statuses(&["enqueued", "processing"], "cancellation")?, "cancellation")
|
||||
} else {
|
||||
(selector.with_allowed_statuses(&["succeeded", "failed", "canceled"], "deletion")?, "deletion")
|
||||
};
|
||||
let query = task_selector_query(&selector);
|
||||
let path = if cancel { format!("/tasks/cancel?{query}") } else { format!("/tasks?{query}") };
|
||||
let request = if cancel { client.post(&path) } else { client.delete(&path) };
|
||||
let response = request.send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, &format!("task {action}")).await?;
|
||||
serde_json::from_value(value).map_err(|error| format!("Meilisearch task {action} parse error: {error}"))
|
||||
}
|
||||
|
||||
pub async fn cancel_tasks(
|
||||
client: &MeilisearchClient,
|
||||
selector: &MeilisearchTaskSelector,
|
||||
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
|
||||
mutate_tasks(client, selector, true).await
|
||||
}
|
||||
|
||||
pub async fn delete_tasks(
|
||||
client: &MeilisearchClient,
|
||||
selector: &MeilisearchTaskSelector,
|
||||
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
|
||||
mutate_tasks(client, selector, false).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchKeyCreateInput {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub uid: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub actions: Vec<String>,
|
||||
pub indexes: Vec<String>,
|
||||
pub expires_at: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchKeyUpdateInput {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchKeyListItem {
|
||||
pub uid: String,
|
||||
/// Meilisearch returns the secret in its key-management responses. Keep it
|
||||
/// in memory so callers can copy the credential while still rendering the
|
||||
/// separately masked value in tables.
|
||||
pub key: String,
|
||||
pub masked_key: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub actions: Vec<String>,
|
||||
pub indexes: Vec<String>,
|
||||
pub expires_at: Value,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchCreatedKey {
|
||||
#[serde(flatten)]
|
||||
pub metadata: MeilisearchKeyListItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchKeyPage {
|
||||
pub results: Vec<MeilisearchKeyListItem>,
|
||||
pub total: u64,
|
||||
pub offset: u64,
|
||||
pub limit: u64,
|
||||
}
|
||||
|
||||
fn mask_key(secret: &str) -> String {
|
||||
let chars = secret.chars().collect::<Vec<_>>();
|
||||
if chars.len() < 12 {
|
||||
return "••••••••".to_string();
|
||||
}
|
||||
format!("{}…{}", chars[..4].iter().collect::<String>(), chars[chars.len() - 4..].iter().collect::<String>())
|
||||
}
|
||||
|
||||
fn parse_key(value: Value) -> Result<(MeilisearchKeyListItem, String), String> {
|
||||
let object = value.as_object().ok_or_else(|| "Meilisearch key response must be an object".to_string())?;
|
||||
let string = |field: &str| {
|
||||
object
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("Missing key field: {field}"))
|
||||
};
|
||||
let secret = string("key")?;
|
||||
let strings = |field: &str| {
|
||||
object
|
||||
.get(field)
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| format!("Missing key field: {field}"))?
|
||||
.iter()
|
||||
.map(|value| value.as_str().map(str::to_string).ok_or_else(|| format!("Invalid key field: {field}")))
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
};
|
||||
Ok((
|
||||
MeilisearchKeyListItem {
|
||||
uid: string("uid")?,
|
||||
key: secret.clone(),
|
||||
masked_key: mask_key(&secret),
|
||||
name: object.get("name").and_then(Value::as_str).map(str::to_string),
|
||||
description: object.get("description").and_then(Value::as_str).map(str::to_string),
|
||||
actions: strings("actions")?,
|
||||
indexes: strings("indexes")?,
|
||||
expires_at: object.get("expiresAt").cloned().unwrap_or(Value::Null),
|
||||
created_at: string("createdAt")?,
|
||||
updated_at: string("updatedAt")?,
|
||||
},
|
||||
secret,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_keys(client: &MeilisearchClient, offset: u64, limit: u64) -> Result<MeilisearchKeyPage, String> {
|
||||
let limit = limit.clamp(1, 100);
|
||||
let response = client
|
||||
.get(&format!("/keys?offset={offset}&limit={limit}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "key listing").await?;
|
||||
let object = value.as_object().ok_or_else(|| "Meilisearch key list response must be an object".to_string())?;
|
||||
let results = object
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "Meilisearch key list response is missing results".to_string())?
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(parse_key)
|
||||
.map(|result| result.map(|(key, _)| key))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(MeilisearchKeyPage {
|
||||
results,
|
||||
total: object.get("total").and_then(Value::as_u64).unwrap_or(0),
|
||||
offset: object.get("offset").and_then(Value::as_u64).unwrap_or(offset),
|
||||
limit: object.get("limit").and_then(Value::as_u64).unwrap_or(limit),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_key(client: &MeilisearchClient, uid: &str) -> Result<MeilisearchKeyListItem, String> {
|
||||
let response = client
|
||||
.get(&format!("/keys/{}", encode_path_segment(uid)))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "key lookup").await?;
|
||||
parse_key(value).map(|(key, _)| key)
|
||||
}
|
||||
|
||||
pub async fn create_key(
|
||||
client: &MeilisearchClient,
|
||||
input: &MeilisearchKeyCreateInput,
|
||||
) -> Result<MeilisearchCreatedKey, String> {
|
||||
if input.actions.is_empty() || input.indexes.is_empty() {
|
||||
return Err("Meilisearch key actions and indexes must not be empty".to_string());
|
||||
}
|
||||
let response = client
|
||||
.post("/keys")
|
||||
.json(input)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "key creation").await?;
|
||||
let (metadata, _key) = parse_key(value)?;
|
||||
Ok(MeilisearchCreatedKey { metadata })
|
||||
}
|
||||
|
||||
pub async fn update_key(
|
||||
client: &MeilisearchClient,
|
||||
uid: &str,
|
||||
input: &MeilisearchKeyUpdateInput,
|
||||
) -> Result<MeilisearchKeyListItem, String> {
|
||||
let response = client
|
||||
.request(Method::PATCH, &format!("/keys/{}", encode_path_segment(uid)))
|
||||
.json(input)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "key update").await?;
|
||||
parse_key(value).map(|(key, _)| key)
|
||||
}
|
||||
|
||||
pub async fn delete_key(client: &MeilisearchClient, uid: &str) -> Result<(), String> {
|
||||
let response = client
|
||||
.delete(&format!("/keys/{}", encode_path_segment(uid)))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
response_json(response, "key deletion").await.map(|_| ())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MeilisearchOverviewSectionStatus {
|
||||
Available,
|
||||
Forbidden,
|
||||
Unsupported,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchOverviewSection<T> {
|
||||
pub status: MeilisearchOverviewSectionStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<T>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> MeilisearchOverviewSection<T> {
|
||||
fn available(data: T) -> Self {
|
||||
Self { status: MeilisearchOverviewSectionStatus::Available, data: Some(data), message: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchSystemStats {
|
||||
pub database_size: Option<u64>,
|
||||
pub used_database_size: Option<u64>,
|
||||
pub last_update: Option<String>,
|
||||
pub index_count: u64,
|
||||
pub document_count: u64,
|
||||
pub indexing_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchIndexSummary {
|
||||
pub uid: String,
|
||||
pub number_of_documents: u64,
|
||||
pub is_indexing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchSystemOverview {
|
||||
pub health: MeilisearchOverviewSection<Value>,
|
||||
pub version: MeilisearchOverviewSection<Value>,
|
||||
pub stats: MeilisearchOverviewSection<MeilisearchSystemStats>,
|
||||
pub task_counts: MeilisearchOverviewSection<BTreeMap<String, u64>>,
|
||||
pub key_count: MeilisearchOverviewSection<u64>,
|
||||
pub top_indexes: MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
|
||||
pub refreshed_at: String,
|
||||
}
|
||||
|
||||
async fn overview_value(client: &MeilisearchClient, path: &str, context: &str) -> MeilisearchOverviewSection<Value> {
|
||||
let response = match client.get(path).send().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
return MeilisearchOverviewSection {
|
||||
status: MeilisearchOverviewSectionStatus::Error,
|
||||
data: None,
|
||||
message: Some(format!("Meilisearch request failed: {error}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||
return MeilisearchOverviewSection {
|
||||
status: MeilisearchOverviewSectionStatus::Forbidden,
|
||||
data: None,
|
||||
message: Some(format!("Meilisearch {context} is forbidden")),
|
||||
};
|
||||
}
|
||||
if matches!(status, StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED) {
|
||||
return MeilisearchOverviewSection {
|
||||
status: MeilisearchOverviewSectionStatus::Unsupported,
|
||||
data: None,
|
||||
message: Some(format!("Meilisearch {context} is unsupported")),
|
||||
};
|
||||
}
|
||||
match response_json(response, context).await {
|
||||
Ok(value) => MeilisearchOverviewSection::available(value),
|
||||
Err(error) => MeilisearchOverviewSection {
|
||||
status: MeilisearchOverviewSectionStatus::Error,
|
||||
data: None,
|
||||
message: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable_stats(
|
||||
status: MeilisearchOverviewSectionStatus,
|
||||
message: Option<String>,
|
||||
) -> (
|
||||
MeilisearchOverviewSection<MeilisearchSystemStats>,
|
||||
MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
|
||||
MeilisearchOverviewSection<Option<String>>,
|
||||
) {
|
||||
(
|
||||
MeilisearchOverviewSection { status: status.clone(), data: None, message: message.clone() },
|
||||
MeilisearchOverviewSection { status: status.clone(), data: None, message: message.clone() },
|
||||
MeilisearchOverviewSection { status, data: None, message },
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_system_stats(
|
||||
section: MeilisearchOverviewSection<Value>,
|
||||
) -> (
|
||||
MeilisearchOverviewSection<MeilisearchSystemStats>,
|
||||
MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
|
||||
MeilisearchOverviewSection<Option<String>>,
|
||||
) {
|
||||
let Some(value) = section.data else {
|
||||
return unavailable_stats(section.status, section.message);
|
||||
};
|
||||
let Some(indexes) = value.get("indexes").and_then(Value::as_object) else {
|
||||
return unavailable_stats(
|
||||
MeilisearchOverviewSectionStatus::Error,
|
||||
Some("Meilisearch stats response is missing indexes".to_string()),
|
||||
);
|
||||
};
|
||||
let mut summaries = indexes
|
||||
.iter()
|
||||
.map(|(uid, stats)| MeilisearchIndexSummary {
|
||||
uid: uid.clone(),
|
||||
number_of_documents: stats.get("numberOfDocuments").and_then(Value::as_u64).unwrap_or(0),
|
||||
is_indexing: stats.get("isIndexing").and_then(Value::as_bool).unwrap_or(false),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let document_count = summaries.iter().map(|summary| summary.number_of_documents).sum();
|
||||
let indexing_index_count = summaries.iter().filter(|summary| summary.is_indexing).count() as u64;
|
||||
summaries.sort_by(|left, right| {
|
||||
right.number_of_documents.cmp(&left.number_of_documents).then_with(|| left.uid.cmp(&right.uid))
|
||||
});
|
||||
let index_count = summaries.len() as u64;
|
||||
summaries.truncate(10);
|
||||
let last_update = value.get("lastUpdate").and_then(Value::as_str).map(str::to_string);
|
||||
(
|
||||
MeilisearchOverviewSection::available(MeilisearchSystemStats {
|
||||
database_size: value.get("databaseSize").and_then(Value::as_u64),
|
||||
used_database_size: value.get("usedDatabaseSize").and_then(Value::as_u64),
|
||||
last_update: last_update.clone(),
|
||||
index_count,
|
||||
document_count,
|
||||
indexing_count: indexing_index_count,
|
||||
}),
|
||||
MeilisearchOverviewSection::available(summaries),
|
||||
MeilisearchOverviewSection::available(last_update),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_count(section: MeilisearchOverviewSection<Value>) -> Result<u64, MeilisearchOverviewSection<Value>> {
|
||||
if let Some(total) = section.data.as_ref().and_then(|value| value.get("total")).and_then(Value::as_u64) {
|
||||
Ok(total)
|
||||
} else if section.data.is_some() {
|
||||
Err(MeilisearchOverviewSection {
|
||||
status: MeilisearchOverviewSectionStatus::Error,
|
||||
data: None,
|
||||
message: Some("Meilisearch count response is missing total".to_string()),
|
||||
})
|
||||
} else {
|
||||
Err(section)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_system_overview(client: &MeilisearchClient) -> MeilisearchSystemOverview {
|
||||
let (health, version, stats, enqueued, processing, succeeded, failed, canceled, key_count) = tokio::join!(
|
||||
overview_value(client, "/health", "health lookup"),
|
||||
overview_value(client, "/version", "version lookup"),
|
||||
overview_value(client, "/stats", "stats lookup"),
|
||||
overview_value(client, "/tasks?statuses=enqueued&limit=1", "enqueued task count"),
|
||||
overview_value(client, "/tasks?statuses=processing&limit=1", "processing task count"),
|
||||
overview_value(client, "/tasks?statuses=succeeded&limit=1", "succeeded task count"),
|
||||
overview_value(client, "/tasks?statuses=failed&limit=1", "failed task count"),
|
||||
overview_value(client, "/tasks?statuses=canceled&limit=1", "canceled task count"),
|
||||
overview_value(client, "/keys?offset=0&limit=1", "key count"),
|
||||
);
|
||||
let task_sections = [
|
||||
("enqueued", enqueued),
|
||||
("processing", processing),
|
||||
("succeeded", succeeded),
|
||||
("failed", failed),
|
||||
("canceled", canceled),
|
||||
];
|
||||
let mut counts = BTreeMap::new();
|
||||
let mut task_error = None;
|
||||
for (status, section) in task_sections {
|
||||
match parse_count(section) {
|
||||
Ok(total) => {
|
||||
counts.insert(status.to_string(), total);
|
||||
}
|
||||
Err(section) => {
|
||||
task_error = Some(section);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let task_counts = if let Some(section) = task_error {
|
||||
MeilisearchOverviewSection { status: section.status, data: None, message: section.message }
|
||||
} else {
|
||||
MeilisearchOverviewSection::available(counts)
|
||||
};
|
||||
let key_count = match parse_count(key_count) {
|
||||
Ok(total) => MeilisearchOverviewSection::available(total),
|
||||
Err(section) => MeilisearchOverviewSection { status: section.status, data: None, message: section.message },
|
||||
};
|
||||
let (stats, top_indexes, _) = parse_system_stats(stats);
|
||||
let refreshed_at = chrono::Utc::now().to_rfc3339();
|
||||
MeilisearchSystemOverview { health, version, stats, task_counts, key_count, top_indexes, refreshed_at }
|
||||
}
|
||||
|
||||
async fn read_bounded_rest_response(mut response: reqwest::Response) -> Result<(String, bool), String> {
|
||||
let read_limit = REST_RESPONSE_MAX_BYTES.saturating_add(1);
|
||||
let capacity = response.content_length().unwrap_or(0).min(read_limit as u64) as usize;
|
||||
@@ -1193,14 +1852,166 @@ fn raw_response_result(status: u16, body: String, start: Instant, truncated: boo
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
decode_bounded_rest_body, decoded_identity, filter_expression, identity_path, meilisearch_base_url,
|
||||
meilisearch_filter_from_request, meilisearch_search_body, meilisearch_sort_from_request,
|
||||
parse_meilisearch_search_response, parse_rest_request_line, MeilisearchClient,
|
||||
decode_bounded_rest_body, decoded_identity, ensure_task_index, filter_expression, identity_path,
|
||||
meilisearch_base_url, meilisearch_filter_from_request, meilisearch_search_body, meilisearch_sort_from_request,
|
||||
parse_key, parse_meilisearch_search_response, parse_rest_request_line, parse_system_stats, task_selector_query,
|
||||
MeilisearchClient, MeilisearchOverviewSection, MeilisearchTask, MeilisearchTaskSelector,
|
||||
};
|
||||
use reqwest::Method;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn task_selector_encodes_all_filters_and_cursor_safe_values() {
|
||||
let selector = MeilisearchTaskSelector {
|
||||
uids: vec![3, 7],
|
||||
batch_uids: vec![9],
|
||||
canceled_by: vec![11],
|
||||
index_uids: vec!["movies / 2026&sort=false".to_string()],
|
||||
statuses: vec!["failed".to_string()],
|
||||
types: vec!["documentAdditionOrUpdate".to_string()],
|
||||
after_enqueued_at: Some("2026-08-22T00:00:00Z".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let query = task_selector_query(&selector);
|
||||
assert!(query.contains("uids=3,7"));
|
||||
assert!(query.contains("batchUids=9"));
|
||||
assert!(query.contains("canceledBy=11"));
|
||||
assert!(query.contains("indexUids=movies%20%2F%202026%26sort%3Dfalse"));
|
||||
assert!(query.contains("statuses=failed"));
|
||||
assert!(query.contains("types=documentAdditionOrUpdate"));
|
||||
assert!(!query.contains("reverse="));
|
||||
assert!(query.contains("afterEnqueuedAt=2026-08-22T00:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_mutations_require_filters_and_intersect_allowed_statuses() {
|
||||
let empty = MeilisearchTaskSelector::default();
|
||||
assert!(empty.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
|
||||
let blank = MeilisearchTaskSelector { index_uids: vec![" ".to_string()], ..Default::default() };
|
||||
assert!(blank.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
|
||||
|
||||
let selector = MeilisearchTaskSelector {
|
||||
index_uids: vec!["movies".to_string()],
|
||||
statuses: vec!["processing".to_string(), "failed".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let cancel = selector.with_allowed_statuses(&["enqueued", "processing"], "cancellation").unwrap();
|
||||
assert_eq!(cancel.statuses, vec!["processing"]);
|
||||
let delete = selector.with_allowed_statuses(&["succeeded", "failed", "canceled"], "deletion").unwrap();
|
||||
assert_eq!(delete.statuses, vec!["failed"]);
|
||||
|
||||
let succeeded_only = MeilisearchTaskSelector { statuses: vec!["succeeded".to_string()], ..Default::default() };
|
||||
assert!(succeeded_only.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_dto_keeps_copyable_secret_and_masked_display_value() {
|
||||
let secret = "0123456789abcdef";
|
||||
let (item, parsed_secret) = parse_key(json!({
|
||||
"uid": "key-uid",
|
||||
"key": secret,
|
||||
"name": "Search",
|
||||
"description": null,
|
||||
"actions": ["search"],
|
||||
"indexes": ["movies"],
|
||||
"expiresAt": null,
|
||||
"createdAt": "2026-08-22T00:00:00Z",
|
||||
"updatedAt": "2026-08-22T00:00:00Z"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(parsed_secret, secret);
|
||||
assert_eq!(item.key, secret);
|
||||
assert_eq!(item.masked_key, "0123…cdef");
|
||||
let wire = serde_json::to_string(&item).unwrap();
|
||||
assert!(wire.contains(secret));
|
||||
assert!(wire.contains("maskedKey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_dto_preserves_unknown_fields_and_task_types() {
|
||||
let task: MeilisearchTask = serde_json::from_value(json!({
|
||||
"uid": 42,
|
||||
"batchUid": 7,
|
||||
"indexUid": "movies",
|
||||
"status": "futureStatus",
|
||||
"type": "futureTaskType",
|
||||
"details": {"receivedDocuments": 10},
|
||||
"error": null,
|
||||
"duration": null,
|
||||
"enqueuedAt": "2026-08-22T00:00:00Z",
|
||||
"startedAt": null,
|
||||
"finishedAt": null,
|
||||
"futureField": {"kept": true}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(task.task_type, "futureTaskType");
|
||||
assert_eq!(task.extra["futureField"], json!({"kept": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_index_guard_accepts_global_and_matching_index_details() {
|
||||
let task: MeilisearchTask = serde_json::from_value(json!({
|
||||
"uid": 42,
|
||||
"indexUid": "movies",
|
||||
"status": "succeeded",
|
||||
"type": "documentAdditionOrUpdate",
|
||||
"enqueuedAt": "2026-08-22T00:00:00Z"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(ensure_task_index(&task, None).is_ok());
|
||||
assert!(ensure_task_index(&task, Some("movies")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_index_guard_rejects_mismatched_and_indexless_details() {
|
||||
let mut task: MeilisearchTask = serde_json::from_value(json!({
|
||||
"uid": 42,
|
||||
"indexUid": "movies",
|
||||
"status": "succeeded",
|
||||
"type": "documentAdditionOrUpdate",
|
||||
"enqueuedAt": "2026-08-22T00:00:00Z"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let mismatch = ensure_task_index(&task, Some("books")).unwrap_err();
|
||||
assert!(mismatch.contains("belongs to index 'movies'"));
|
||||
assert!(mismatch.contains("expected index 'books'"));
|
||||
|
||||
task.index_uid = None;
|
||||
let indexless = ensure_task_index(&task, Some("books")).unwrap_err();
|
||||
assert!(indexless.contains("not associated with an index"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_stats_compute_totals_and_top_ten_deterministically() {
|
||||
let indexes = (0..12)
|
||||
.map(|index| {
|
||||
(format!("index-{index:02}"), json!({ "numberOfDocuments": index, "isIndexing": index % 2 == 0 }))
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
let (stats, top, refreshed_at) = parse_system_stats(MeilisearchOverviewSection::available(json!({
|
||||
"databaseSize": 1000,
|
||||
"usedDatabaseSize": 900,
|
||||
"lastUpdate": "2026-08-22T00:00:00Z",
|
||||
"indexes": indexes
|
||||
})));
|
||||
|
||||
let stats = stats.data.unwrap();
|
||||
assert_eq!(stats.index_count, 12);
|
||||
assert_eq!(stats.document_count, 66);
|
||||
assert_eq!(stats.indexing_count, 6);
|
||||
let top = top.data.unwrap();
|
||||
assert_eq!(top.len(), 10);
|
||||
assert_eq!(top[0].uid, "index-11");
|
||||
assert_eq!(top[9].uid, "index-02");
|
||||
assert_eq!(refreshed_at.data.flatten().as_deref(), Some("2026-08-22T00:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_canonical_and_legacy_proxy_base_paths() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -940,6 +940,110 @@ pub async fn meilisearch_delete_all_documents_core(
|
||||
}
|
||||
}
|
||||
|
||||
async fn meilisearch_client_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchClient, String> {
|
||||
ensure_document_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::Meilisearch(client) => Ok(client.clone()),
|
||||
_ => Err("Not a Meilisearch connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn meilisearch_get_system_overview_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchSystemOverview, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
Ok(crate::db::meilisearch_driver::get_system_overview(&client).await)
|
||||
}
|
||||
|
||||
pub async fn meilisearch_list_keys_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchKeyPage, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
crate::db::meilisearch_driver::list_keys(&client, offset, limit).await
|
||||
}
|
||||
|
||||
pub async fn meilisearch_get_key_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
uid: &str,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchKeyListItem, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
crate::db::meilisearch_driver::get_key(&client, uid).await
|
||||
}
|
||||
|
||||
pub async fn meilisearch_create_key_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
input: &crate::db::meilisearch_driver::MeilisearchKeyCreateInput,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchCreatedKey, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
crate::db::meilisearch_driver::create_key(&client, input).await
|
||||
}
|
||||
|
||||
pub async fn meilisearch_update_key_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
uid: &str,
|
||||
input: &crate::db::meilisearch_driver::MeilisearchKeyUpdateInput,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchKeyListItem, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
crate::db::meilisearch_driver::update_key(&client, uid, input).await
|
||||
}
|
||||
|
||||
pub async fn meilisearch_delete_key_core(state: &AppState, connection_id: &str, uid: &str) -> Result<(), String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
crate::db::meilisearch_driver::delete_key(&client, uid).await
|
||||
}
|
||||
|
||||
pub async fn meilisearch_get_tasks_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
selector: &crate::db::meilisearch_driver::MeilisearchTaskSelector,
|
||||
from: Option<u64>,
|
||||
limit: u64,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchTaskPage, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
crate::db::meilisearch_driver::get_tasks(&client, selector, from, limit).await
|
||||
}
|
||||
|
||||
pub async fn meilisearch_get_task_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
uid: u64,
|
||||
expected_index_uid: Option<&str>,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchTask, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
let task = crate::db::meilisearch_driver::get_task(&client, uid).await?;
|
||||
crate::db::meilisearch_driver::ensure_task_index(&task, expected_index_uid)?;
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
pub async fn meilisearch_cancel_tasks_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
selector: &crate::db::meilisearch_driver::MeilisearchTaskSelector,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
crate::db::meilisearch_driver::cancel_tasks(&client, selector).await
|
||||
}
|
||||
|
||||
pub async fn meilisearch_delete_tasks_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
selector: &crate::db::meilisearch_driver::MeilisearchTaskSelector,
|
||||
) -> Result<crate::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary, String> {
|
||||
let client = meilisearch_client_core(state, connection_id).await?;
|
||||
crate::db::meilisearch_driver::delete_tasks(&client, selector).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
||||
@@ -829,6 +829,19 @@ async fn main() {
|
||||
.route("/document-store/meilisearch/stats", post(routes::document_store::meilisearch_get_stats))
|
||||
.route("/document-store/meilisearch/overview", post(routes::document_store::meilisearch_get_overview))
|
||||
.route("/document-store/meilisearch/index/delete", post(routes::document_store::meilisearch_delete_index))
|
||||
.route(
|
||||
"/document-store/meilisearch/system/overview",
|
||||
post(routes::document_store::meilisearch_get_system_overview),
|
||||
)
|
||||
.route("/document-store/meilisearch/keys/list", post(routes::document_store::meilisearch_list_keys))
|
||||
.route("/document-store/meilisearch/keys/get", post(routes::document_store::meilisearch_get_key))
|
||||
.route("/document-store/meilisearch/keys/create", post(routes::document_store::meilisearch_create_key))
|
||||
.route("/document-store/meilisearch/keys/update", post(routes::document_store::meilisearch_update_key))
|
||||
.route("/document-store/meilisearch/keys/delete", post(routes::document_store::meilisearch_delete_key))
|
||||
.route("/document-store/meilisearch/tasks/list", post(routes::document_store::meilisearch_get_tasks))
|
||||
.route("/document-store/meilisearch/tasks/get", post(routes::document_store::meilisearch_get_task))
|
||||
.route("/document-store/meilisearch/tasks/cancel", post(routes::document_store::meilisearch_cancel_tasks))
|
||||
.route("/document-store/meilisearch/tasks/delete", post(routes::document_store::meilisearch_delete_tasks))
|
||||
.route(
|
||||
"/document-store/meilisearch/documents/delete-all",
|
||||
post(routes::document_store::meilisearch_delete_all_documents),
|
||||
|
||||
@@ -189,6 +189,64 @@ pub struct MeilisearchSettingsUpdateRequest {
|
||||
pub settings: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchConnectionRequest {
|
||||
pub connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchKeyListRequest {
|
||||
pub connection_id: String,
|
||||
pub offset: Option<u64>,
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchKeyRequest {
|
||||
pub connection_id: String,
|
||||
pub uid: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchKeyCreateRequest {
|
||||
pub connection_id: String,
|
||||
pub input: dbx_core::db::meilisearch_driver::MeilisearchKeyCreateInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchKeyUpdateRequest {
|
||||
pub connection_id: String,
|
||||
pub uid: String,
|
||||
pub input: dbx_core::db::meilisearch_driver::MeilisearchKeyUpdateInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchTaskListRequest {
|
||||
pub connection_id: String,
|
||||
pub input: dbx_core::db::meilisearch_driver::MeilisearchTaskListInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchTaskRequest {
|
||||
pub connection_id: String,
|
||||
pub uid: u64,
|
||||
pub expected_index_uid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchTaskMutationRequest {
|
||||
pub connection_id: String,
|
||||
pub selector: dbx_core::db::meilisearch_driver::MeilisearchTaskSelector,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridFsBucketRequest {
|
||||
@@ -532,6 +590,128 @@ pub async fn meilisearch_delete_all_documents(
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_get_system_overview(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchConnectionRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchSystemOverview>, AppError> {
|
||||
let result = dbx_core::document_ops::meilisearch_get_system_overview_core(&state.app, &req.connection_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_list_keys(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchKeyListRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchKeyPage>, AppError> {
|
||||
let result = dbx_core::document_ops::meilisearch_list_keys_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
req.offset.unwrap_or(0),
|
||||
req.limit.unwrap_or(20),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_get_key(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchKeyRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchKeyListItem>, AppError> {
|
||||
let result = dbx_core::document_ops::meilisearch_get_key_core(&state.app, &req.connection_id, &req.uid)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_create_key(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchKeyCreateRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchCreatedKey>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Create API key").await?;
|
||||
let result = dbx_core::document_ops::meilisearch_create_key_core(&state.app, &req.connection_id, &req.input)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_update_key(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchKeyUpdateRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchKeyListItem>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Update API key").await?;
|
||||
let result =
|
||||
dbx_core::document_ops::meilisearch_update_key_core(&state.app, &req.connection_id, &req.uid, &req.input)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_delete_key(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchKeyRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete API key").await?;
|
||||
dbx_core::document_ops::meilisearch_delete_key_core(&state.app, &req.connection_id, &req.uid)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_get_tasks(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchTaskListRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchTaskPage>, AppError> {
|
||||
let result = dbx_core::document_ops::meilisearch_get_tasks_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.input.selector,
|
||||
req.input.from,
|
||||
req.input.limit.unwrap_or(20),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_get_task(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchTaskRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchTask>, AppError> {
|
||||
let result = dbx_core::document_ops::meilisearch_get_task_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
req.uid,
|
||||
req.expected_index_uid.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_cancel_tasks(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchTaskMutationRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Cancel tasks").await?;
|
||||
let result = dbx_core::document_ops::meilisearch_cancel_tasks_core(&state.app, &req.connection_id, &req.selector)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn meilisearch_delete_tasks(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchTaskMutationRequest>,
|
||||
) -> Result<Json<dbx_core::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete tasks").await?;
|
||||
let result = dbx_core::document_ops::meilisearch_delete_tasks_core(&state.app, &req.connection_id, &req.selector)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_gridfs_files(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<GridFsFileListRequest>,
|
||||
|
||||
@@ -355,6 +355,110 @@ pub async fn meilisearch_delete_all_documents(
|
||||
dbx_core::document_ops::meilisearch_delete_all_documents_core(&state, &connection_id, &index).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_get_system_overview(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchSystemOverview, String> {
|
||||
dbx_core::document_ops::meilisearch_get_system_overview_core(&state, &connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_list_keys(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchKeyPage, String> {
|
||||
dbx_core::document_ops::meilisearch_list_keys_core(&state, &connection_id, offset, limit).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_get_key(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
uid: String,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchKeyListItem, String> {
|
||||
dbx_core::document_ops::meilisearch_get_key_core(&state, &connection_id, &uid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_create_key(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
input: dbx_core::db::meilisearch_driver::MeilisearchKeyCreateInput,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchCreatedKey, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Create API key").await?;
|
||||
dbx_core::document_ops::meilisearch_create_key_core(&state, &connection_id, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_update_key(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
uid: String,
|
||||
input: dbx_core::db::meilisearch_driver::MeilisearchKeyUpdateInput,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchKeyListItem, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Update API key").await?;
|
||||
dbx_core::document_ops::meilisearch_update_key_core(&state, &connection_id, &uid, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_delete_key(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
uid: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete API key").await?;
|
||||
dbx_core::document_ops::meilisearch_delete_key_core(&state, &connection_id, &uid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_get_tasks(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
input: dbx_core::db::meilisearch_driver::MeilisearchTaskListInput,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchTaskPage, String> {
|
||||
dbx_core::document_ops::meilisearch_get_tasks_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
&input.selector,
|
||||
input.from,
|
||||
input.limit.unwrap_or(20),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_get_task(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
uid: u64,
|
||||
expected_index_uid: Option<String>,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchTask, String> {
|
||||
dbx_core::document_ops::meilisearch_get_task_core(&state, &connection_id, uid, expected_index_uid.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_cancel_tasks(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
selector: dbx_core::db::meilisearch_driver::MeilisearchTaskSelector,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Cancel tasks").await?;
|
||||
dbx_core::document_ops::meilisearch_cancel_tasks_core(&state, &connection_id, &selector).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn meilisearch_delete_tasks(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
selector: dbx_core::db::meilisearch_driver::MeilisearchTaskSelector,
|
||||
) -> Result<dbx_core::db::meilisearch_driver::MeilisearchEnqueuedTaskSummary, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete tasks").await?;
|
||||
dbx_core::document_ops::meilisearch_delete_tasks_core(&state, &connection_id, &selector).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn document_list_gridfs_buckets(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
||||
@@ -2078,6 +2078,16 @@ pub fn run() {
|
||||
commands::document_cmd::meilisearch_get_index_overview,
|
||||
commands::document_cmd::meilisearch_delete_index,
|
||||
commands::document_cmd::meilisearch_delete_all_documents,
|
||||
commands::document_cmd::meilisearch_get_system_overview,
|
||||
commands::document_cmd::meilisearch_list_keys,
|
||||
commands::document_cmd::meilisearch_get_key,
|
||||
commands::document_cmd::meilisearch_create_key,
|
||||
commands::document_cmd::meilisearch_update_key,
|
||||
commands::document_cmd::meilisearch_delete_key,
|
||||
commands::document_cmd::meilisearch_get_tasks,
|
||||
commands::document_cmd::meilisearch_get_task,
|
||||
commands::document_cmd::meilisearch_cancel_tasks,
|
||||
commands::document_cmd::meilisearch_delete_tasks,
|
||||
commands::hbase_cmd::hbase_get_table_schema,
|
||||
commands::hbase_cmd::hbase_scan_rows,
|
||||
commands::hbase_cmd::hbase_get_row,
|
||||
|
||||
Reference in New Issue
Block a user