feat(nacos): improve configuration batch management
This commit is contained in:
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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user