feat(nacos): improve configuration batch management

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-11 21:32:46 +08:00
1 parent 9808660559
commit da6e2738ce
16 files changed
+847 -95

No files matched your search

@@ -3,13 +3,15 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId,
import { Compartment, type Extension } from "@codemirror/state";
import { StreamLanguage } from "@codemirror/language";
import type { EditorView } from "@codemirror/view";
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, Clipboard, Download, FileClock, FileInput, FileText, Loader2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Columns3, Download, FileClock, FileInput, FileText, Loader2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
@@ -17,7 +19,7 @@ import NacosConfigHistoryDialog from "@/components/nacos/NacosConfigHistoryDialo
import NacosConfigBatchDialog, { type NacosBatchDialogMode, type NacosConfigTransferTarget } from "@/components/nacos/NacosConfigBatchDialog.vue";
import NacosContentSearchDialog from "@/components/nacos/NacosContentSearchDialog.vue";
import { useToast } from "@/composables/useToast";
import { useNacosConfigListColumnResize } from "@/composables/useNacosConfigListColumnResize";
import { useNacosConfigListColumnResize, type ToggleableNacosConfigListColumnKey } from "@/composables/useNacosConfigListColumnResize";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useI18n } from "vue-i18n";
@@ -47,6 +49,7 @@ import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes";
import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
@@ -110,7 +113,10 @@ const configGroup = ref("");
const configDataId = ref("");
const configAppName = ref("");
const configPageNo = ref(1);
const configPageSize = ref(20);
const NACOS_CONFIG_PAGE_SIZE_OPTIONS = [20, 50, 100, 200, 500] as const;
const NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY = "dbx-nacos-config-page-size";
const savedNacosConfigPageSize = Number(safeLocalStorageGet(NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY));
const configPageSize = ref<number>(NACOS_CONFIG_PAGE_SIZE_OPTIONS.find((size) => size === savedNacosConfigPageSize) ?? 20);
const configs = ref<NacosConfigItem[]>([]);
const configTotal = ref(0);
const selectedConfig = ref<NacosConfigItem | null>(null);
@@ -126,6 +132,12 @@ const configAdvancedOpen = ref(false);
const configSaveNotice = ref("");
const pendingConfigSave = ref(false);
const pendingDeleteConfig = ref<NacosConfigDeleteSnapshot | null>(null);
interface NacosBatchDeleteSnapshot {
connectionId: string;
namespace: string;
keys: NacosConfigKey[];
}
const pendingBatchDelete = ref<NacosBatchDeleteSnapshot | null>(null);
const historyOpen = ref(false);
const historyLoading = ref(false);
const historyError = ref("");
@@ -154,6 +166,11 @@ const configFormatOptions = ["text", "json", "xml", "yaml", "html", "properties"
const configEditorHost = ref<HTMLDivElement | null>(null);
const configEditorView = shallowRef<EditorView | null>(null);
const configSearchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const configEditorFontSize = ref(clampEditorFontSize(settingsStore.editorSettings.fontSize));
const configEditorZoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => {
if (settingsStore.editorSettings.fontSize === fontSize) return;
settingsStore.updateEditorSettings({ fontSize });
});
const knownConfigFormats = ref<Record<string, string>>({});
const selectedConfigKeys = ref<string[]>([]);
const searchOpen = ref(false);
@@ -237,7 +254,26 @@ const CONNECTION_NOT_FOUND_RETRY_DELAYS_MS = [150, 350, 700];
const configListViewport = ref<HTMLElement | null>(null);
const configListViewportWidth = ref(0);
let configListResizeObserver: ResizeObserver | null = null;
const { gridTemplateColumns: configListGridTemplate, minWidth: configListMinWidth, resizingColumnIndex: configListResizingColumnIndex, onResizeStart: onConfigListColumnResizeStart } = useNacosConfigListColumnResize(configListViewportWidth);
const {
visibleColumns: configListColumns,
toggleableColumns: configListToggleableColumns,
gridTemplateColumns: configListGridTemplate,
minWidth: configListMinWidth,
resizingColumnIndex: configListResizingColumnIndex,
onResizeStart: onConfigListColumnResizeStart,
isColumnVisible: isConfigListColumnVisible,
setColumnVisible: setConfigListColumnVisible,
} = useNacosConfigListColumnResize(configListViewportWidth);
function configListColumnLabel(column: ToggleableNacosConfigListColumnKey) {
if (column === "group") return t("nacos.group");
if (column === "application") return t("nacos.configListApplication");
return t("nacos.configListFormat");
}
function isSelectedConfigListItem(item: NacosConfigItem) {
return selectedConfig.value?.dataId === item.dataId && selectedConfig.value?.group === item.group && (selectedConfig.value?.namespace || namespace.value) === (item.namespace || namespace.value);
}
const namespace = computed(() => props.namespace ?? connectionInfo.value?.namespace ?? "");
const nacosProductionContext = computed(() => productionContextForDatabase(connectionStore.getConfig(props.connectionId), namespace.value));
@@ -343,9 +379,12 @@ const configMutationGuardState = computed(() => ({
hasPendingDelete: !!pendingDeleteConfig.value,
hasPendingSave: pendingConfigSave.value,
}));
const selectedConfigCount = computed(() => selectedConfigKeys.value.length);
const canRequestConfigSave = computed(() => canStartNacosConfigSave(configMutationGuardState.value));
const canRequestConfigDelete = computed(() => canStartNacosConfigDelete(configMutationGuardState.value, selectedConfigOriginalKey.value));
const pendingDeleteDetails = computed(() => (pendingDeleteConfig.value ? buildNacosConfigDeleteConfirm(pendingDeleteConfig.value.config, pendingDeleteConfig.value.key.namespace || "") : ""));
const canRequestBatchDeleteConfigs = computed(() => !props.readOnly && !savingConfig.value && !deletingConfig.value && !pendingConfigSave.value && !pendingDeleteConfig.value && selectedConfigCount.value > 0);
const pendingBatchDeleteDetails = computed(() => pendingBatchDelete.value?.keys.map((key) => `namespace=${key.namespace || "public"}\ndataId=${key.dataId}\ngroup=${key.group || "DEFAULT_GROUP"}`).join("\n\n") || "");
const pendingHistoryRollbackDetails = computed(() => (pendingHistoryRollback.value ? buildNacosConfigHistoryRollbackConfirm(pendingHistoryRollback.value, namespace.value) : ""));
const pendingInstanceDetails = computed(() => (pendingInstanceUpdate.value && selectedService.value ? buildNacosInstanceConfirm(selectedService.value, pendingInstanceUpdate.value.instance, pendingInstanceUpdate.value.patch, serviceGroup.value, namespace.value) : ""));
const pendingInstanceDeregisterDetails = computed(() => {
@@ -361,13 +400,12 @@ const pendingInstanceDeregisterDetails = computed(() => {
type: lifetime,
});
});
const selectedConfigCount = computed(() => selectedConfigKeys.value.length);
const hasSearchSession = computed(() => !!(searchResult.value || searchProgress.value || searchError.value));
const retainedSearchMatchCount = computed(() => searchResult.value?.matches.length ?? searchProgress.value?.matches.length ?? 0);
const currentPageConfigKeys = computed(() => configs.value.map((item) => configIdentityKey(item)));
const allCurrentPageSelected = computed(() => currentPageConfigKeys.value.length > 0 && currentPageConfigKeys.value.every((key) => selectedConfigKeys.value.includes(key)));
function configIdentityKey(item: Pick<NacosConfigItem, "namespace" | "group" | "dataId">): string {
function configIdentityKey(item: { namespace?: string; group?: string; dataId: string }): string {
return [item.namespace || namespace.value || "", item.group || "DEFAULT_GROUP", item.dataId].join("\u0000");
}
@@ -395,6 +433,10 @@ function selectedKeys(): NacosConfigKey[] {
});
}
function isBatchDeleteSnapshotInScope(snapshot: NacosBatchDeleteSnapshot) {
return snapshot.connectionId === props.connectionId && snapshot.namespace === namespace.value;
}
function buildConfigSelector(scope: NacosConfigSelectionScope): NacosConfigSelector {
return {
namespace: namespace.value,
@@ -472,6 +514,7 @@ async function mountConfigEditor() {
configLanguageExtension(format),
]);
const editorSettings = settingsStore.editorSettings;
configEditorFontSize.value = clampEditorFontSize(editorSettings.fontSize);
const theme = await loadEditorTheme(editorSettings.theme, editorThemeAppearance(), currentCustomThemeColors(), themePalette.value);
if (generation !== configEditorGeneration || editorSessionId !== configEditorSessionId || host !== configEditorHost.value || configEditorView.value || !selectedConfig.value) return;
const view = new EditorView({
@@ -492,6 +535,21 @@ async function mountConfigEditor() {
trimmedSelectionLayer(),
Prec.highest(keymap.of([{ key: "Mod-f", run: () => configSearchPanelRef.value?.openSearch() ?? false, preventDefault: true }, { key: "Mod-h", run: () => configSearchPanelRef.value?.openReplace() ?? false, preventDefault: true }, indentWithTab])),
keymap.of([...defaultKeymap, ...historyKeymap]),
EditorView.domEventHandlers({
wheel(event, eventView) {
if (!event.metaKey && !event.ctrlKey) return false;
event.preventDefault();
const next = fontSizeFromWheelDelta(configEditorFontSize.value, event.deltaY);
if (next !== configEditorFontSize.value) {
configEditorFontSize.value = next;
eventView.dispatch({
effects: configEditorFontTheme.reconfigure(editorFontTheme(EditorView, next, settingsStore.editorSettings.fontFamily, { fixedHeight: true, scrollable: true })),
});
}
configEditorZoomCommitScheduler.schedule(next);
return true;
},
}),
configEditorLanguage.of(language),
configEditorTheme.of(theme),
configEditorFontTheme.of(editorFontTheme(EditorView, editorSettings.fontSize, editorSettings.fontFamily, { fixedHeight: true, scrollable: true })),
@@ -703,6 +761,8 @@ async function loadConfigs(page = configPageNo.value): Promise<boolean> {
pageSize: requestPageSize,
});
if (!isCurrentRequest()) return false;
const lastPage = Math.max(1, Math.ceil(result.totalCount / Math.max(1, requestPageSize)));
if (page > lastPage) return loadConfigs(lastPage);
configs.value = applyKnownConfigFormats(result.items.map(normalizeConfigItemFormat));
configTotal.value = result.totalCount;
return true;
@@ -736,9 +796,20 @@ function clearConfigFilter(filter: "dataId" | "group" | "appName") {
void loadConfigsWithRetry(1);
}
function setConfigPageSize(value: string) {
const nextPageSize = Number(value);
if (!NACOS_CONFIG_PAGE_SIZE_OPTIONS.some((size) => size === nextPageSize) || nextPageSize === configPageSize.value) return;
configPageSize.value = nextPageSize;
safeLocalStorageSet(NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY, String(nextPageSize));
void loadConfigsWithRetry(1);
}
function closePendingConfigMutationConfirmations() {
pendingConfigSave.value = false;
if (!deletingConfig.value) pendingDeleteConfig.value = null;
if (!deletingConfig.value) {
pendingDeleteConfig.value = null;
pendingBatchDelete.value = null;
}
}
async function selectConfig(item: NacosConfigItem) {
@@ -1519,6 +1590,11 @@ function requestDeleteConfig() {
pendingDeleteConfig.value = createNacosConfigDeleteSnapshot(props.connectionId, key, selectedConfig.value);
}
function reconcileDeletedConfigSelection(deletedKeys: ReadonlySet<string>) {
if (!deletedKeys.size) return;
selectedConfigKeys.value = selectedConfigKeys.value.filter((key) => !deletedKeys.has(key));
}
async function deleteConfig() {
const snapshot = pendingDeleteConfig.value;
if (!snapshot || !isNacosConfigDeleteSnapshotInScope(snapshot, props.connectionId, namespace.value)) {
@@ -1544,7 +1620,10 @@ async function deleteConfig() {
try {
await api.nacosDeleteConfig(snapshot.connectionId, snapshot.key);
const remainsInDeletedScope = isNacosConfigDeleteSnapshotInScope(snapshot, props.connectionId, namespace.value);
if (remainsInDeletedScope) await loadConfigs();
if (remainsInDeletedScope) {
reconcileDeletedConfigSelection(new Set([configIdentityKey(snapshot.key)]));
await loadConfigs();
}
const stillViewingDeletedConfig =
remainsInDeletedScope &&
editorSessionId === configEditorSessionId &&
@@ -1568,6 +1647,89 @@ async function deleteConfig() {
}
}
function requestBatchDeleteConfigs() {
if (!canRequestBatchDeleteConfigs.value) return;
const keys = selectedKeys();
if (!keys.length) return;
pendingBatchDelete.value = {
connectionId: props.connectionId,
namespace: namespace.value,
keys: keys.map((key) => ({
namespace: key.namespace || undefined,
dataId: key.dataId,
group: key.group || "DEFAULT_GROUP",
})),
};
}
async function deleteSelectedConfigs() {
const snapshot = pendingBatchDelete.value;
if (!snapshot || !isBatchDeleteSnapshotInScope(snapshot)) {
pendingBatchDelete.value = null;
return;
}
if (!canRequestBatchDeleteConfigs.value) return;
if (!(await confirmNacosMutation(t("nacos.batchDelete"), snapshot.connectionId, snapshot.namespace))) return;
if (!isBatchDeleteSnapshotInScope(snapshot)) {
pendingBatchDelete.value = null;
return;
}
const editorSessionId = configEditorSessionId;
pendingBatchDelete.value = null;
deletingConfig.value = true;
configError.value = "";
configSaveNotice.value = "";
const deletedKeys = new Set<string>();
let firstError = "";
let interrupted = false;
try {
// Reuse the version-aware single-config delete path rather than relying on a batch API that differs across Nacos implementations.
for (const key of snapshot.keys) {
if (!isBatchDeleteSnapshotInScope(snapshot)) {
interrupted = true;
break;
}
try {
await api.nacosDeleteConfig(snapshot.connectionId, key);
deletedKeys.add(configIdentityKey(key));
} catch (error) {
if (!firstError) firstError = error instanceof Error ? error.message : String(error);
}
}
if (!isBatchDeleteSnapshotInScope(snapshot)) interrupted = true;
if (!interrupted) {
if (deletedKeys.size) {
reconcileDeletedConfigSelection(deletedKeys);
await loadConfigs();
}
const selectedKey = selectedConfigOriginalKey.value;
const stillViewingDeletedConfig = editorSessionId === configEditorSessionId && selectedKey != null && deletedKeys.has(configIdentityKey(selectedKey));
if (stillViewingDeletedConfig) {
configDetailRequestGuard.invalidate();
configEditorSessionId += 1;
selectedConfig.value = null;
selectedConfigOriginalKey.value = null;
configContent.value = "";
originalConfigContent.value = "";
destroyConfigEditor();
}
if (firstError) {
configError.value = `${t("nacos.batchDeletePartial", { deleted: deletedKeys.size, failed: snapshot.keys.length - deletedKeys.size })} ${firstError}`;
toast(t("nacos.batchDeletePartial", { deleted: deletedKeys.size, failed: snapshot.keys.length - deletedKeys.size }), 3000);
} else {
toast(t("nacos.batchDeleteSuccess", { count: deletedKeys.size }), 2000);
}
} else {
toast(t("nacos.batchDeleteInterrupted", { deleted: deletedKeys.size }), 3000);
}
} finally {
deletingConfig.value = false;
}
}
async function loadServices(page = servicePageNo.value) {
const requestId = servicesRequestGuard.begin();
const connectionId = props.connectionId;
@@ -2150,6 +2312,7 @@ watch(
if (!view) return;
const [{ EditorView }, theme] = await Promise.all([import("@codemirror/view"), loadEditorTheme(settings.theme, editorThemeAppearance(), currentCustomThemeColors(), themePalette.value)]);
if (configEditorView.value !== view) return;
configEditorFontSize.value = clampEditorFontSize(settings.fontSize);
view.dispatch({
effects: [configEditorTheme.reconfigure(theme), configEditorFontTheme.reconfigure(editorFontTheme(EditorView, settings.fontSize, settings.fontFamily, { fixedHeight: true, scrollable: true }))],
});
@@ -2226,6 +2389,7 @@ onBeforeUnmount(() => {
stopNacosNamespacesChangedListener = null;
if (activeSearchOperationId.value) void api.nacosCancelConfigContentSearch(activeSearchOperationId.value);
configListResizeObserver?.disconnect();
configEditorZoomCommitScheduler.dispose();
destroyConfigEditor();
});
</script>
@@ -2270,6 +2434,11 @@ onBeforeUnmount(() => {
<ArrowLeftRight class="h-3.5 w-3.5" />
{{ t("nacos.copyToNamespace") }}
</Button>
<Button v-if="activeTab === 'configs' && selectedConfigCount > 0" size="sm" variant="destructive" class="h-8 gap-1.5" :disabled="!canRequestBatchDeleteConfigs" @click="requestBatchDeleteConfigs">
<Trash2 class="h-3.5 w-3.5" />
{{ t("nacos.batchDelete") }}
<span class="border-l border-current/30 pl-1.5 text-xs font-semibold tabular-nums">{{ selectedConfigCount }}</span>
</Button>
</div>
</div>
@@ -2296,7 +2465,7 @@ onBeforeUnmount(() => {
</button>
</div>
<div class="relative min-w-0">
<Input v-model="configGroup" class="h-8 min-w-0 pr-8" :placeholder="t('nacos.allGroups')" @keyup.enter="loadConfigsWithRetry(1)" />
<Input v-model="configGroup" class="h-8 min-w-0 pr-8" :placeholder="t('nacos.configListGroup')" @keyup.enter="loadConfigsWithRetry(1)" />
<button
v-if="configGroup"
type="button"
@@ -2309,7 +2478,7 @@ onBeforeUnmount(() => {
</button>
</div>
<div class="relative min-w-0">
<Input v-model="configAppName" class="h-8 min-w-0 pr-8" :placeholder="t('nacos.application')" @keyup.enter="loadConfigsWithRetry(1)" />
<Input v-model="configAppName" class="h-8 min-w-0 pr-8" :placeholder="t('nacos.configListApplication')" @keyup.enter="loadConfigsWithRetry(1)" />
<button
v-if="configAppName"
type="button"
@@ -2333,86 +2502,85 @@ onBeforeUnmount(() => {
<div ref="configListViewport" class="min-h-0 flex-1 overflow-auto">
<div class="w-max min-w-full" :style="{ minWidth: configListMinWidth }">
<div class="sticky top-0 z-20 grid border-b bg-muted px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground shadow-sm" :style="{ gridTemplateColumns: configListGridTemplate }">
<div class="relative min-w-0 pr-3">
<span class="flex items-center gap-2">
<div v-for="(column, columnIndex) in configListColumns" :key="column" class="relative min-w-0" :class="column === 'dataId' ? 'pr-3' : columnIndex === configListColumns.length - 1 ? 'pl-3 pr-10' : 'px-3'">
<span v-if="column === 'dataId'" class="flex items-center gap-2">
<input type="checkbox" :checked="allCurrentPageSelected" :aria-label="t('nacos.selectCurrentPage')" @change="toggleCurrentPageSelection(($event.target as HTMLInputElement).checked)" />
<span class="block truncate">dataID</span>
</span>
<span v-else-if="column === 'group'" class="block truncate">{{ t("nacos.group") }}</span>
<span v-else-if="column === 'application'" class="block truncate">{{ configListColumnLabel("application") }}</span>
<span v-else class="block truncate">{{ configListColumnLabel("format") }}</span>
<div
v-if="columnIndex < configListColumns.length - 1"
data-column-resize-handle
role="separator"
aria-orientation="vertical"
:aria-label="t('nacos.resizeColumn')"
class="group absolute -right-1 top-0 z-10 h-full w-2 cursor-col-resize hover:bg-primary/10"
:class="configListResizingColumnIndex === 0 ? 'bg-primary/15' : ''"
@mousedown="onConfigListColumnResizeStart(0, $event)"
:class="configListResizingColumnIndex === columnIndex ? 'bg-primary/15' : ''"
@mousedown="onConfigListColumnResizeStart(columnIndex, $event)"
>
<span class="pointer-events-none absolute left-1/2 top-1/2 h-5 w-px -translate-x-1/2 -translate-y-1/2 bg-border/90 transition-colors group-hover:bg-primary" />
</div>
</div>
<div class="relative min-w-0 px-3">
<span class="block truncate">{{ t("nacos.group") }}</span>
<div
data-column-resize-handle
role="separator"
aria-orientation="vertical"
:aria-label="t('nacos.resizeColumn')"
class="group absolute -right-1 top-0 z-10 h-full w-2 cursor-col-resize hover:bg-primary/10"
:class="configListResizingColumnIndex === 1 ? 'bg-primary/15' : ''"
@mousedown="onConfigListColumnResizeStart(1, $event)"
>
<span class="pointer-events-none absolute left-1/2 top-1/2 h-5 w-px -translate-x-1/2 -translate-y-1/2 bg-border/90 transition-colors group-hover:bg-primary" />
</div>
</div>
<div class="relative min-w-0 px-3">
<span class="block truncate">{{ t("nacos.application") }}</span>
<div
data-column-resize-handle
role="separator"
aria-orientation="vertical"
:aria-label="t('nacos.resizeColumn')"
class="group absolute -right-1 top-0 z-10 h-full w-2 cursor-col-resize hover:bg-primary/10"
:class="configListResizingColumnIndex === 2 ? 'bg-primary/15' : ''"
@mousedown="onConfigListColumnResizeStart(2, $event)"
>
<span class="pointer-events-none absolute left-1/2 top-1/2 h-5 w-px -translate-x-1/2 -translate-y-1/2 bg-border/90 transition-colors group-hover:bg-primary" />
</div>
</div>
<div class="relative min-w-0 pl-3">
<span class="block truncate">{{ t("nacos.format") }}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="sm" variant="ghost" class="absolute right-1 top-1/2 z-20 h-7 w-7 -translate-y-1/2 p-0" :title="t('nacos.visibleColumns')" :aria-label="t('nacos.visibleColumns')">
<Columns3 class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-40" @close-auto-focus.prevent>
<DropdownMenuCheckboxItem v-for="column in configListToggleableColumns" :key="column" :model-value="isConfigListColumnVisible(column)" @select.prevent @update:model-value="setConfigListColumnVisible(column, $event)">
{{ configListColumnLabel(column) }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
v-for="item in configs"
:key="`${item.namespace}:${item.group}:${item.dataId}`"
class="grid w-full cursor-pointer items-center border-b px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent/50"
:class="{ 'bg-accent': selectedConfig?.dataId === item.dataId && selectedConfig?.group === item.group && (selectedConfig?.namespace || namespace) === (item.namespace || namespace) }"
:class="{ 'border-l-2 border-l-primary': isSelectedConfigListItem(item) }"
:style="{ gridTemplateColumns: configListGridTemplate }"
@click="selectConfig(item)"
>
<span class="flex min-w-0 items-center gap-2 pr-3" :title="item.dataId">
<input type="checkbox" :checked="selectedConfigKeys.includes(configIdentityKey(item))" :aria-label="t('nacos.selectConfigForBatch', { dataId: item.dataId })" @click.stop @change.stop="toggleConfigSelection(item, ($event.target as HTMLInputElement).checked)" />
<button type="button" class="flex min-w-0 items-center gap-2 text-left" @click.stop="selectConfig(item)">
<FileText class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="truncate font-medium text-foreground">{{ item.dataId }}</span>
</button>
</span>
<span class="truncate px-3 text-xs text-muted-foreground" :title="item.group || 'DEFAULT_GROUP'">{{ item.group || "DEFAULT_GROUP" }}</span>
<span class="truncate px-3 text-xs text-muted-foreground" :title="item.appName || '-'">{{ item.appName || "-" }}</span>
<span class="truncate pl-3 text-xs text-muted-foreground" :title="configFormatLabel(item)">{{ configFormatLabel(item) }}</span>
<template v-for="column in configListColumns" :key="column">
<span v-if="column === 'dataId'" class="flex min-w-0 items-center gap-2 pr-3" :title="item.dataId">
<input type="checkbox" :checked="selectedConfigKeys.includes(configIdentityKey(item))" :aria-label="t('nacos.selectConfigForBatch', { dataId: item.dataId })" @click.stop @change.stop="toggleConfigSelection(item, ($event.target as HTMLInputElement).checked)" />
<button type="button" class="flex min-w-0 items-center gap-2 text-left" @click.stop="selectConfig(item)">
<FileText class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="truncate font-medium text-foreground">{{ item.dataId }}</span>
</button>
</span>
<span v-else-if="column === 'group'" class="truncate px-3 text-xs text-muted-foreground" :title="item.group || 'DEFAULT_GROUP'">{{ item.group || "DEFAULT_GROUP" }}</span>
<span v-else-if="column === 'application'" class="truncate px-3 text-xs text-muted-foreground" :title="item.appName || '-'">{{ item.appName || "-" }}</span>
<span v-else class="truncate pl-3 text-xs text-muted-foreground" :title="configFormatLabel(item)">{{ configFormatLabel(item) }}</span>
</template>
</div>
</div>
<div v-if="!configLoading && configs.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("nacos.noConfigs") }}</div>
</div>
<div class="flex shrink-0 items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
<div class="flex items-center gap-3">
<span>{{ t("nacos.total", { count: configTotal }) }}</span>
<span>{{ t("nacos.selectedCount", { count: selectedConfigCount }) }}</span>
<div class="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 border-t px-3 py-2 text-xs text-muted-foreground">
<div class="flex min-w-0 items-center gap-3 overflow-hidden">
<span class="min-w-0 truncate">{{ t("nacos.total", { count: configTotal }) }}</span>
</div>
<div class="flex items-center gap-2">
<Button size="sm" variant="outline" class="h-7" :disabled="configPageNo <= 1 || configLoading" @click="loadConfigs(configPageNo - 1)">{{ t("nacos.prev") }}</Button>
<div class="flex shrink-0 items-center gap-2">
<div class="flex items-center gap-1.5 whitespace-nowrap">
<span>{{ t("nacos.configPageSize") }}</span>
<Select :model-value="String(configPageSize)" :disabled="configLoading" @update:model-value="setConfigPageSize(String($event))">
<SelectTrigger size="sm" class="w-16 text-xs" :aria-label="t('nacos.configPageSize')"><SelectValue /></SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="size in NACOS_CONFIG_PAGE_SIZE_OPTIONS" :key="size" :value="String(size)">{{ size }}</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="icon" variant="outline" class="h-7 w-7" :title="t('nacos.prev')" :aria-label="t('nacos.prev')" :disabled="configPageNo <= 1 || configLoading" @click="loadConfigs(configPageNo - 1)">
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
<span>{{ configPageNo }} / {{ configTotalPages }}</span>
<Button size="sm" variant="outline" class="h-7" :disabled="configPageNo >= configTotalPages || configLoading" @click="loadConfigs(configPageNo + 1)">{{ t("nacos.next") }}</Button>
<Button size="icon" variant="outline" class="h-7 w-7" :title="t('nacos.next')" :aria-label="t('nacos.next')" :disabled="configPageNo >= configTotalPages || configLoading" @click="loadConfigs(configPageNo + 1)">
<ChevronRight class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
@@ -2806,7 +2974,14 @@ onBeforeUnmount(() => {
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2 xl:justify-end">
<Button size="sm" variant="outline" class="h-7" :disabled="readOnly || !supportsInstanceUpdate || isInstanceUpdating(instance)" @click="openInstanceEditor(instance)">{{ t("nacos.edit") }}</Button>
<Button size="sm" variant="outline" class="h-7 gap-1" :disabled="readOnly || !supportsInstanceUpdate || isInstanceUpdating(instance)" @click="requestUpdateInstance(instance, { enabled: !instance.enabled })">
<Button
size="sm"
variant="outline"
class="h-7 gap-1"
:class="instance.enabled === false ? 'border-emerald-500/50 text-emerald-700 hover:bg-emerald-500/10 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200' : 'border-destructive/50 text-destructive hover:bg-destructive/10'"
:disabled="readOnly || !supportsInstanceUpdate || isInstanceUpdating(instance)"
@click="requestUpdateInstance(instance, { enabled: !instance.enabled })"
>
<Loader2 v-if="isInstanceUpdating(instance)" class="h-3 w-3 animate-spin" />
{{ instance.enabled === false ? t("nacos.enable") : t("nacos.disable") }}
</Button>
@@ -3039,6 +3214,22 @@ onBeforeUnmount(() => {
@confirm="deleteConfig"
/>
<DangerConfirmDialog
:open="!!pendingBatchDelete"
:title="t('nacos.batchDeleteTitle')"
:message="t('nacos.batchDeleteConfirm', { count: pendingBatchDelete?.keys.length || 0 })"
:details="pendingBatchDeleteDetails"
:confirm-label="t('nacos.batchDelete')"
:loading="deletingConfig"
:close-on-confirm="false"
@update:open="
(value: boolean) => {
if (!value && !deletingConfig) pendingBatchDelete = null;
}
"
@confirm="deleteSelectedConfigs"
/>
<DangerConfirmDialog
:open="!!pendingHistoryRollback"
:title="t('nacos.confirmRollbackTitle')"
@@ -94,17 +94,23 @@ function loadPage(pageNo: number) {
<Dialog v-model:open="open">
<DialogContent :show-close-button="false" class="nacos-config-history-dialog flex h-[min(82vh,760px)] flex-col gap-0 overflow-hidden rounded-lg p-0 shadow-2xl">
<DialogHeader class="shrink-0 border-b bg-muted/20 px-5 py-4">
<div class="flex items-center justify-between gap-4">
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 items-start gap-3">
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-background text-primary">
<Clock3 class="h-4 w-4" />
</div>
<div class="min-w-0">
<DialogTitle class="truncate text-lg font-semibold">{{ t("nacos.configHistory") }}</DialogTitle>
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
<Badge variant="outline" class="max-w-64 truncate font-mono">namespace={{ namespaceLabel }}</Badge>
<Badge variant="secondary" class="max-w-72 truncate font-mono">dataId={{ dataIdLabel }}</Badge>
<Badge variant="outline" class="max-w-48 truncate font-mono">group={{ groupLabel }}</Badge>
<div class="mt-1.5 flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="min-w-0 font-mono" :title="`namespace=${namespaceLabel}`"
>namespace=<span class="break-all text-foreground">{{ namespaceLabel }}</span></span
>
<span class="min-w-0 font-mono" :title="`dataId=${dataIdLabel}`"
>dataId=<span class="break-all text-foreground">{{ dataIdLabel }}</span></span
>
<span class="min-w-0 font-mono" :title="`group=${groupLabel}`"
>group=<span class="break-all text-foreground">{{ groupLabel }}</span></span
>
</div>
<div class="sr-only">{{ historyTitle }}</div>
</div>
@@ -0,0 +1,213 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App, type ComponentPublicInstance } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NacosConfigItem, NacosConfigKey, NacosConfigList } from "@/types/nacos";
const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn(),
nacosDeleteConfig: vi.fn(),
nacosListConfigs: vi.fn(),
nacosListServices: vi.fn(),
nacosTestConnection: vi.fn(),
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/lib/backend/api", () => ({
nacosDeleteConfig: mocks.nacosDeleteConfig,
nacosListConfigs: mocks.nacosListConfigs,
nacosListServices: mocks.nacosListServices,
nacosTestConnection: mocks.nacosTestConnection,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
connections: [],
ensureConnected: mocks.ensureConnected,
getConfig: () => ({ id: "connection-1", db_type: "nacos", name: "Nacos" }),
}),
}));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({
clearNacosNavigationTarget: vi.fn(),
openNacosAdmin: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
activeCustomThemeId: "",
customThemeColors: {},
customThemes: [],
fontFamily: "monospace",
fontSize: 13,
theme: "default",
},
updateEditorSettings: vi.fn(),
}),
}));
vi.mock("@/composables/useTheme", async () => {
const { ref } = await import("vue");
return { useTheme: () => ({ isDark: ref(false), themePalette: ref({}) }) };
});
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/database/productionExecutionGuard", () => ({
executeWithProductionContextGuard: ({ execute }: { execute: () => Promise<unknown> }) => execute(),
}));
vi.mock("@/lib/database/productionSafety", () => ({
productionContextForDatabase: () => ({ active: false, databases: [] }),
}));
vi.mock("@/lib/nacos/nacosNamespaceCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/nacos/nacosNamespaceCache")>();
return { ...actual, subscribeNacosNamespacesChanged: () => () => undefined };
});
vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") }));
vi.mock("@/components/ui/badge", async () => ({ Badge: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Badge", "span") }));
vi.mock("@/components/ui/input", async () => ({ Input: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Input", "input") }));
vi.mock("@/components/ui/label", async () => ({ Label: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Label", "label") }));
vi.mock("@/components/ui/dialog", async () => {
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
const stub = createPassthroughStub("Dialog");
return { Dialog: stub, DialogContent: stub, DialogDescription: stub, DialogFooter: stub, DialogHeader: stub, DialogTitle: stub };
});
vi.mock("@/components/ui/dropdown-menu", async () => {
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
const stub = createPassthroughStub("DropdownMenu");
return { DropdownMenu: stub, DropdownMenuCheckboxItem: stub, DropdownMenuContent: stub, DropdownMenuTrigger: stub };
});
vi.mock("@/components/ui/select", async () => {
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
const stub = createPassthroughStub("Select");
return { Select: stub, SelectContent: stub, SelectItem: stub, SelectTrigger: stub, SelectValue: stub };
});
vi.mock("@/components/common/ProductionContextBadge.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("ProductionContextBadge") }));
vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("DangerConfirmDialog") }));
vi.mock("@/components/editor/EditorSearchPanel.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("EditorSearchPanel") }));
vi.mock("@/components/nacos/NacosConfigDiffDialog.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("NacosConfigDiffDialog") }));
vi.mock("@/components/nacos/NacosConfigHistoryDialog.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("NacosConfigHistoryDialog") }));
vi.mock("@/components/nacos/NacosConfigBatchDialog.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("NacosConfigBatchDialog") }));
vi.mock("@/components/nacos/NacosContentSearchDialog.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("NacosContentSearchDialog") }));
vi.mock("splitpanes", async () => {
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
const stub = createPassthroughStub("Splitpanes");
return { Splitpanes: stub, Pane: stub };
});
import NacosAdminConsole from "@/components/nacos/NacosAdminConsole.vue";
type NacosAdminSetupState = {
configGroup: string;
configPageNo: number;
configPageSize: number;
configs: NacosConfigItem[];
deleteConfig: () => Promise<void>;
deleteSelectedConfigs: () => Promise<void>;
requestBatchDeleteConfigs: () => void;
requestDeleteConfig: () => void;
selectedConfig: NacosConfigItem | null;
selectedConfigKeys: string[];
selectedConfigOriginalKey: NacosConfigKey | null;
toggleConfigSelection: (item: NacosConfigItem, checked: boolean) => void;
};
const configA: NacosConfigItem = { namespace: "public", group: "DEFAULT_GROUP", dataId: "config-a", configType: "yaml" };
const configB: NacosConfigItem = { namespace: "public", group: "DEFAULT_GROUP", dataId: "config-b", configType: "text" };
let app: App | null = null;
let host: HTMLElement | null = null;
let state: NacosAdminSetupState;
function configList(pageNo: number, pageSize: number, totalCount: number, items: NacosConfigItem[]): NacosConfigList {
return { pageNo, pageSize, totalCount, items };
}
async function flushUi() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await nextTick();
}
}
beforeEach(async () => {
mocks.ensureConnected.mockReset().mockResolvedValue(undefined);
mocks.nacosDeleteConfig.mockReset().mockResolvedValue(undefined);
mocks.nacosListConfigs.mockReset().mockResolvedValue(configList(1, 20, 2, [configA, configB]));
mocks.nacosListServices.mockReset().mockResolvedValue({ pageNo: 1, pageSize: 20, totalCount: 0, items: [] });
mocks.nacosTestConnection.mockReset().mockResolvedValue({
serverAddr: "http://127.0.0.1:8848",
displayServerAddr: "127.0.0.1:8848",
namespace: "public",
auth: "none",
capabilities: { supportsConfigManagement: true, supportsConfigHistory: true, supportsServiceManagement: true, supportsInstanceUpdate: true, supportsRawApi: true },
});
mocks.toast.mockReset();
host = document.createElement("div");
document.body.append(host);
app = createApp(NacosAdminConsole, { connectionId: "connection-1", namespace: "public" });
const instance = app.mount(host) as ComponentPublicInstance;
state = instance.$.setupState as unknown as NacosAdminSetupState;
await flushUi();
mocks.nacosListConfigs.mockClear();
mocks.nacosDeleteConfig.mockClear();
});
afterEach(() => {
app?.unmount();
app = null;
host?.remove();
host = null;
document.body.innerHTML = "";
});
describe("NacosAdminConsole config deletion", () => {
it("clears a singly deleted selection and reloads the last valid filtered page", async () => {
state.configGroup = "group-filter";
state.configPageNo = 2;
state.selectedConfig = configA;
state.selectedConfigOriginalKey = { namespace: configA.namespace, group: configA.group, dataId: configA.dataId };
state.toggleConfigSelection(configA, true);
state.requestDeleteConfig();
mocks.nacosListConfigs.mockResolvedValueOnce(configList(2, 20, 20, [])).mockResolvedValueOnce(configList(1, 20, 20, [configB]));
await state.deleteConfig();
await flushUi();
expect(state.selectedConfigKeys).toEqual([]);
expect(state.configPageNo).toBe(1);
expect(state.configs).toEqual([configB]);
expect(mocks.nacosDeleteConfig).toHaveBeenCalledWith("connection-1", { namespace: "public", group: "DEFAULT_GROUP", dataId: "config-a" });
expect(mocks.nacosListConfigs.mock.calls.map(([, query]) => query)).toEqual([expect.objectContaining({ group: "group-filter", groupContains: true, pageNo: 2, pageSize: 20 }), expect.objectContaining({ group: "group-filter", groupContains: true, pageNo: 1, pageSize: 20 })]);
});
it("clears batch selections and reloads the last valid page for a different page size", async () => {
state.configPageNo = 2;
state.configPageSize = 50;
state.toggleConfigSelection(configA, true);
state.toggleConfigSelection(configB, true);
state.requestBatchDeleteConfigs();
mocks.nacosListConfigs.mockResolvedValueOnce(configList(2, 50, 50, [])).mockResolvedValueOnce(configList(1, 50, 50, [configB]));
await state.deleteSelectedConfigs();
await flushUi();
expect(state.selectedConfigKeys).toEqual([]);
expect(state.configPageNo).toBe(1);
expect(mocks.nacosDeleteConfig.mock.calls.map(([, key]) => key)).toEqual([
{ namespace: "public", group: "DEFAULT_GROUP", dataId: "config-a" },
{ namespace: "public", group: "DEFAULT_GROUP", dataId: "config-b" },
]);
expect(mocks.nacosListConfigs.mock.calls.map(([, query]) => query)).toEqual([expect.objectContaining({ pageNo: 2, pageSize: 50 }), expect.objectContaining({ pageNo: 1, pageSize: 50 })]);
});
});
@@ -115,6 +115,76 @@ describe("NacosAdminConsole config workbench layout", () => {
expect(source).toContain("instance.healthy === false ? 'border-destructive/50 text-destructive' : 'border-emerald-500/50 text-emerald-700 dark:text-emerald-300'");
});
it("keeps the configuration editor zoom behavior aligned with the SQL editor", () => {
expect(source).toContain("createEditorZoomCommitScheduler");
expect(source).toContain("EditorView.domEventHandlers");
expect(source).toContain("fontSizeFromWheelDelta(configEditorFontSize.value, event.deltaY)");
expect(source).toContain("if (!event.metaKey && !event.ctrlKey) return false;");
expect(source).toContain("configEditorZoomCommitScheduler.dispose()");
});
it("allows optional configuration columns to be hidden without hiding the data ID", () => {
const configListHeader = source.indexOf('class="sticky top-0 z-20 grid border-b bg-muted');
const columnVisibility = source.indexOf("nacos.visibleColumns");
expect(configListHeader).toBeGreaterThan(0);
expect(columnVisibility).toBeGreaterThan(configListHeader);
expect(source).toContain("configListToggleableColumns");
expect(source).toContain("DropdownMenuCheckboxItem");
expect(source).toContain("nacos.visibleColumns");
expect(source).toContain('v-for="(column, columnIndex) in configListColumns"');
expect(source).toContain("column === 'dataId'");
});
it("marks the selected configuration without giving its row the header background", () => {
expect(source).toContain("function isSelectedConfigListItem");
expect(source).toContain(":class=\"{ 'border-l-2 border-l-primary': isSelectedConfigListItem(item) }\"");
});
it("adds a guarded batch delete action for selected configurations", () => {
const toolbar = source.indexOf('class="flex min-w-0 flex-wrap items-center justify-end gap-2"');
const batchDeleteButton = source.indexOf("v-if=\"activeTab === 'configs' && selectedConfigCount > 0\"");
expect(toolbar).toBeGreaterThan(0);
expect(batchDeleteButton).toBeGreaterThan(toolbar);
expect(source).toContain('class="border-l border-current/30 pl-1.5 text-xs font-semibold tabular-nums"');
expect(source).not.toContain('<Badge variant="outline" class="h-5 min-w-5 border-current px-1.5 text-current">{{ selectedConfigCount }}</Badge>');
expect(source).toContain("const pendingBatchDelete = ref<NacosBatchDeleteSnapshot | null>(null);");
expect(source).toContain("const canRequestBatchDeleteConfigs = computed(");
expect(source).toContain("function requestBatchDeleteConfigs()");
expect(source).toContain("async function deleteSelectedConfigs()");
expect(source).toContain('await confirmNacosMutation(t("nacos.batchDelete"), snapshot.connectionId, snapshot.namespace)');
expect(source).toContain("for (const key of snapshot.keys)");
expect(source).toContain("await api.nacosDeleteConfig(snapshot.connectionId, key);");
expect(source).toContain("function reconcileDeletedConfigSelection(deletedKeys: ReadonlySet<string>)");
expect(source).toContain("reconcileDeletedConfigSelection(deletedKeys);");
expect(source).toContain("nacos.batchDeletePartial");
expect(source).toContain("nacos.batchDeleteInterrupted");
});
it("allows the Nacos configuration page size to be changed within the supported range", () => {
expect(source).toContain("const NACOS_CONFIG_PAGE_SIZE_OPTIONS = [20, 50, 100, 200, 500] as const;");
expect(source).toContain('const NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY = "dbx-nacos-config-page-size";');
expect(source).toContain("function setConfigPageSize(value: string)");
expect(source).toContain("safeLocalStorageSet(NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY, String(nextPageSize));");
expect(source).toContain('t("nacos.configPageSize")');
expect(source).toContain('v-for="size in NACOS_CONFIG_PAGE_SIZE_OPTIONS"');
expect(source).toContain('@update:model-value="setConfigPageSize(String($event))"');
expect(source).toContain(":aria-label=\"t('nacos.configPageSize')\"");
expect(source).toContain('class="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 border-t px-3 py-2 text-xs text-muted-foreground"');
expect(source).not.toContain('class="flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t px-3 py-2 text-xs text-muted-foreground"');
const footerStart = source.indexOf('class="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 border-t px-3 py-2 text-xs text-muted-foreground"');
const footerEnd = source.indexOf("</div>\n </div>\n </Pane>", footerStart);
expect(source.slice(footerStart, footerEnd)).not.toContain('t("nacos.selectedCount", { count: selectedConfigCount })');
expect(source).toContain('<ChevronLeft class="h-3.5 w-3.5" />');
expect(source).toContain('<ChevronRight class="h-3.5 w-3.5" />');
});
it("uses semantic coloring for instance enable and disable actions", () => {
expect(source).toContain("border-emerald-500/50 text-emerald-700");
expect(source).toContain("border-destructive/50 text-destructive hover:bg-destructive/10");
});
it("separates the service header, filtering controls, and management actions", () => {
expect(source).toContain('<header class="shrink-0 border-b bg-background">');
expect(source).toContain('class="flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-muted/30 px-4 py-2"');
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../NacosConfigHistoryDialog.vue", import.meta.url), "utf8");
describe("NacosConfigHistoryDialog", () => {
it("keeps configuration identity values compact while allowing long values to wrap", () => {
expect(source).toContain('class="mt-1.5 flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-muted-foreground"');
expect(source).toContain('<span class="break-all text-foreground">{{ namespaceLabel }}</span>');
expect(source).toContain('<span class="break-all text-foreground">{{ dataIdLabel }}</span>');
expect(source).toContain('<span class="break-all text-foreground">{{ groupLabel }}</span>');
expect(source).toContain(':title="`namespace=${namespaceLabel}`"');
expect(source).toContain(':title="`dataId=${dataIdLabel}`"');
expect(source).toContain(':title="`group=${groupLabel}`"');
expect(source).not.toContain("grid-cols-1 gap-1.5 text-xs sm:grid-cols-2 xl:grid-cols-3");
expect(source).not.toContain("h-auto w-full");
expect(source).not.toContain('class="max-w-64 truncate font-mono"');
expect(source).not.toContain('class="max-w-72 truncate font-mono"');
expect(source).not.toContain('class="max-w-48 truncate font-mono"');
});
});