diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index a9527aa611..0e872051c3 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -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(NACOS_CONFIG_PAGE_SIZE_OPTIONS.find((size) => size === savedNacosConfigPageSize) ?? 20); const configs = ref([]); const configTotal = ref(0); const selectedConfig = ref(null); @@ -126,6 +132,12 @@ const configAdvancedOpen = ref(false); const configSaveNotice = ref(""); const pendingConfigSave = ref(false); const pendingDeleteConfig = ref(null); +interface NacosBatchDeleteSnapshot { + connectionId: string; + namespace: string; + keys: NacosConfigKey[]; +} +const pendingBatchDelete = ref(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(null); const configEditorView = shallowRef(null); const configSearchPanelRef = ref>(); +const configEditorFontSize = ref(clampEditorFontSize(settingsStore.editorSettings.fontSize)); +const configEditorZoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => { + if (settingsStore.editorSettings.fontSize === fontSize) return; + settingsStore.updateEditorSettings({ fontSize }); +}); const knownConfigFormats = ref>({}); const selectedConfigKeys = ref([]); const searchOpen = ref(false); @@ -237,7 +254,26 @@ const CONNECTION_NOT_FOUND_RETRY_DELAYS_MS = [150, 350, 700]; const configListViewport = ref(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): 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 { 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) { + 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(); + 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(); }); @@ -2270,6 +2434,11 @@ onBeforeUnmount(() => { {{ t("nacos.copyToNamespace") }} + @@ -2296,7 +2465,7 @@ onBeforeUnmount(() => {
- +
- + + + + + {{ configListColumnLabel(column) }} + + +
- - - - - {{ item.group || "DEFAULT_GROUP" }} - {{ item.appName || "-" }} - {{ configFormatLabel(item) }} +
{{ t("nacos.noConfigs") }}
-
-
- {{ t("nacos.total", { count: configTotal }) }} - {{ t("nacos.selectedCount", { count: selectedConfigCount }) }} +
+
+ {{ t("nacos.total", { count: configTotal }) }}
-
- +
+
+ {{ t("nacos.configPageSize") }} + +
+ {{ configPageNo }} / {{ configTotalPages }} - +
@@ -2806,7 +2974,14 @@ onBeforeUnmount(() => {
- @@ -3039,6 +3214,22 @@ onBeforeUnmount(() => { @confirm="deleteConfig" /> + + -
+
{{ t("nacos.configHistory") }} -
- namespace={{ namespaceLabel }} - dataId={{ dataIdLabel }} - group={{ groupLabel }} +
+ namespace={{ namespaceLabel }} + dataId={{ dataIdLabel }} + group={{ groupLabel }}
{{ historyTitle }}
diff --git a/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts new file mode 100644 index 0000000000..13154d3170 --- /dev/null +++ b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts @@ -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 }) => execute(), +})); + +vi.mock("@/lib/database/productionSafety", () => ({ + productionContextForDatabase: () => ({ active: false, databases: [] }), +})); + +vi.mock("@/lib/nacos/nacosNamespaceCache", async (importOriginal) => { + const actual = await importOriginal(); + 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; + deleteSelectedConfigs: () => Promise; + 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 })]); + }); +}); diff --git a/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleLayout.spec.ts b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleLayout.spec.ts index b091691311..c11728c9e9 100644 --- a/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleLayout.spec.ts +++ b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleLayout.spec.ts @@ -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('{{ selectedConfigCount }}'); + expect(source).toContain("const pendingBatchDelete = ref(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)"); + 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("
\n
\n ", footerStart); + expect(source.slice(footerStart, footerEnd)).not.toContain('t("nacos.selectedCount", { count: selectedConfigCount })'); + expect(source).toContain(''); + expect(source).toContain(''); + }); + + 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('
'); expect(source).toContain('class="flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-muted/30 px-4 py-2"'); diff --git a/apps/desktop/src/components/nacos/__tests__/NacosConfigHistoryDialog.spec.ts b/apps/desktop/src/components/nacos/__tests__/NacosConfigHistoryDialog.spec.ts new file mode 100644 index 0000000000..7bcd1d4ecd --- /dev/null +++ b/apps/desktop/src/components/nacos/__tests__/NacosConfigHistoryDialog.spec.ts @@ -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('{{ namespaceLabel }}'); + expect(source).toContain('{{ dataIdLabel }}'); + expect(source).toContain('{{ groupLabel }}'); + 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"'); + }); +}); diff --git a/apps/desktop/src/composables/useNacosConfigListColumnResize.ts b/apps/desktop/src/composables/useNacosConfigListColumnResize.ts index 6c4bfac4bb..b7dd409f78 100644 --- a/apps/desktop/src/composables/useNacosConfigListColumnResize.ts +++ b/apps/desktop/src/composables/useNacosConfigListColumnResize.ts @@ -1,10 +1,20 @@ import { computed, ref, type Ref } from "vue"; -import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; +import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage"; export const NACOS_CONFIG_LIST_COLUMN_WIDTHS_STORAGE_KEY = "dbx-nacos-config-list-column-widths"; +export const NACOS_CONFIG_LIST_HIDDEN_COLUMNS_STORAGE_KEY = "dbx-nacos-config-list-hidden-columns"; export const DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS = [280, 180, 180, 96] as const; export const NACOS_CONFIG_LIST_HORIZONTAL_PADDING = 24; const MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS = [140, 96, 96, 72] as const; +const NACOS_CONFIG_LIST_COLUMNS = ["dataId", "group", "application", "format"] as const; +const TOGGLEABLE_NACOS_CONFIG_LIST_COLUMNS = ["group", "application", "format"] as const; + +export type NacosConfigListColumnKey = (typeof NACOS_CONFIG_LIST_COLUMNS)[number]; +export type ToggleableNacosConfigListColumnKey = (typeof TOGGLEABLE_NACOS_CONFIG_LIST_COLUMNS)[number]; + +function isToggleableColumn(key: unknown): key is ToggleableNacosConfigListColumnKey { + return TOGGLEABLE_NACOS_CONFIG_LIST_COLUMNS.includes(key as ToggleableNacosConfigListColumnKey); +} function minWidthForColumn(index: number) { return MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS[index] ?? MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS[MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS.length - 1]; @@ -34,20 +44,42 @@ function saveNacosConfigListColumnWidths(widths: readonly number[]) { safeLocalStorageSet(NACOS_CONFIG_LIST_COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(normalized)); } +function loadNacosConfigListHiddenColumns(): ToggleableNacosConfigListColumnKey[] { + const raw = safeLocalStorageGet(NACOS_CONFIG_LIST_HIDDEN_COLUMNS_STORAGE_KEY); + if (!raw) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return [...new Set(parsed.filter(isToggleableColumn))]; + } catch { + return []; + } +} + +function saveNacosConfigListHiddenColumns(columns: readonly ToggleableNacosConfigListColumnKey[]) { + const normalized = [...new Set(columns.filter(isToggleableColumn))]; + if (normalized.length === 0) { + safeLocalStorageRemove(NACOS_CONFIG_LIST_HIDDEN_COLUMNS_STORAGE_KEY); + return; + } + safeLocalStorageSet(NACOS_CONFIG_LIST_HIDDEN_COLUMNS_STORAGE_KEY, JSON.stringify(normalized)); +} + function gridTemplateColumnsForWidths(widths: readonly number[]) { return widths.map((width) => `${width}px`).join(" "); } -export function fitNacosConfigListColumnWidths(widths: readonly number[], availableWidth: number): number[] { +function fitNacosConfigListColumnWidthsForIndexes(widths: readonly number[], availableWidth: number, columnIndexes: readonly number[]): number[] { const normalized = normalizeNacosConfigListColumnWidths([...widths]) ?? [...DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS]; + const selectedWidths = columnIndexes.map((index) => normalized[index] ?? minWidthForColumn(index)); const targetWidth = Math.floor(availableWidth) - NACOS_CONFIG_LIST_HORIZONTAL_PADDING; - if (targetWidth <= 0) return normalized; + if (targetWidth <= 0) return selectedWidths; - const minimums = normalized.map((_, index) => minWidthForColumn(index)); + const minimums = columnIndexes.map((index) => minWidthForColumn(index)); const minimumTotal = minimums.reduce((sum, width) => sum + width, 0); if (targetWidth <= minimumTotal) return minimums; - const preferredExtras = normalized.map((width, index) => Math.max(0, width - minimums[index]!)); + const preferredExtras = selectedWidths.map((width, index) => Math.max(0, width - minimums[index]!)); const preferredExtraTotal = preferredExtras.reduce((sum, width) => sum + width, 0); const availableExtra = targetWidth - minimumTotal; const fitted = minimums.map((minimum, index) => { @@ -58,42 +90,63 @@ export function fitNacosConfigListColumnWidths(widths: readonly number[], availa return fitted; } +export function fitNacosConfigListColumnWidths(widths: readonly number[], availableWidth: number): number[] { + return fitNacosConfigListColumnWidthsForIndexes( + widths, + availableWidth, + DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS.map((_, index) => index), + ); +} + export function useNacosConfigListColumnResize(availableWidth?: Readonly>) { const preferredColumnWidths = ref(loadNacosConfigListColumnWidths()); + const hiddenColumnKeys = ref(loadNacosConfigListHiddenColumns()); const resizingColumnIndex = ref(null); + const visibleColumns = computed(() => NACOS_CONFIG_LIST_COLUMNS.filter((key) => !hiddenColumnKeys.value.includes(key as ToggleableNacosConfigListColumnKey))); + const visibleColumnIndexes = computed(() => visibleColumns.value.map((key) => NACOS_CONFIG_LIST_COLUMNS.indexOf(key))); const columnWidths = computed(() => { const width = availableWidth?.value ?? 0; return width > 0 ? fitNacosConfigListColumnWidths(preferredColumnWidths.value, width) : preferredColumnWidths.value; }); - const gridTemplateColumns = computed(() => gridTemplateColumnsForWidths(columnWidths.value)); - const totalWidth = computed(() => columnWidths.value.reduce((sum, width) => sum + width, 0)); + const visibleColumnWidths = computed(() => { + const width = availableWidth?.value ?? 0; + return width > 0 ? fitNacosConfigListColumnWidthsForIndexes(preferredColumnWidths.value, width, visibleColumnIndexes.value) : visibleColumnIndexes.value.map((index) => preferredColumnWidths.value[index]!); + }); + const gridTemplateColumns = computed(() => gridTemplateColumnsForWidths(visibleColumnWidths.value)); + const totalWidth = computed(() => visibleColumnWidths.value.reduce((sum, width) => sum + width, 0)); const minWidth = computed(() => `${totalWidth.value + NACOS_CONFIG_LIST_HORIZONTAL_PADDING}px`); function onResizeStart(columnIndex: number, event: MouseEvent) { - if (columnIndex < 0 || columnIndex >= columnWidths.value.length - 1) return; + if (columnIndex < 0 || columnIndex >= visibleColumnWidths.value.length - 1) return; event.preventDefault(); const startX = event.clientX; - const startWidths = [...columnWidths.value]; - const startWidth = startWidths[columnIndex] ?? minWidthForColumn(columnIndex); - const nextStartWidth = startWidths[columnIndex + 1] ?? minWidthForColumn(columnIndex + 1); + const startWidths = [...visibleColumnWidths.value]; + const sourceIndex = visibleColumnIndexes.value[columnIndex]!; + const nextIndex = visibleColumnIndexes.value[columnIndex + 1]!; + const startWidth = startWidths[columnIndex] ?? minWidthForColumn(sourceIndex); + const nextStartWidth = startWidths[columnIndex + 1] ?? minWidthForColumn(nextIndex); const pairWidth = startWidth + nextStartWidth; resizingColumnIndex.value = columnIndex; const onMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientX - startX; - const minimum = minWidthForColumn(columnIndex); - const nextMinimum = minWidthForColumn(columnIndex + 1); + const minimum = minWidthForColumn(sourceIndex); + const nextMinimum = minWidthForColumn(nextIndex); const width = Math.min(pairWidth - nextMinimum, Math.max(minimum, startWidth + delta)); - const nextWidths = [...startWidths]; - nextWidths[columnIndex] = width; - nextWidths[columnIndex + 1] = pairWidth - width; + const nextWidths = [...preferredColumnWidths.value]; + nextWidths[sourceIndex] = width; + nextWidths[nextIndex] = pairWidth - width; preferredColumnWidths.value = nextWidths; }; const onUp = (moveEvent: MouseEvent) => { onMove(moveEvent); resizingColumnIndex.value = null; - preferredColumnWidths.value = [...columnWidths.value]; + const nextWidths = [...preferredColumnWidths.value]; + visibleColumnIndexes.value.forEach((index, visibleIndex) => { + nextWidths[index] = visibleColumnWidths.value[visibleIndex]!; + }); + preferredColumnWidths.value = nextWidths; saveNacosConfigListColumnWidths(preferredColumnWidths.value); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); @@ -103,12 +156,26 @@ export function useNacosConfigListColumnResize(availableWidth?: Readonly key !== column) : [...new Set([...hiddenColumnKeys.value, column])]; + hiddenColumnKeys.value = next; + saveNacosConfigListHiddenColumns(next); + } + return { columnWidths, + visibleColumns, + toggleableColumns: TOGGLEABLE_NACOS_CONFIG_LIST_COLUMNS, gridTemplateColumns, totalWidth, minWidth, resizingColumnIndex, onResizeStart, + isColumnVisible, + setColumnVisible, }; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8513be7daa..7ff1264630 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7300,9 +7300,20 @@ export default { batchExport: "Batch export", batchImport: "Batch import", copyToNamespace: "Config sync", + batchDelete: "Batch delete", + batchDeleteTitle: "Delete selected Nacos configs", + batchDeleteConfirm: "Delete {count} selected configs? This action cannot be undone.", + batchDeleteSuccess: "Deleted {count} configs", + batchDeletePartial: "Deleted {deleted} configs; {failed} failed.", + batchDeleteInterrupted: "Batch delete stopped after deleting {deleted} configs.", + configPageSize: "Per page", selectedCount: "{count} selected", retryConnectionInfo: "Retry connection info probe", resizeColumn: "Drag to resize column", + visibleColumns: "Visible columns", + configListGroup: "Group", + configListApplication: "Application", + configListFormat: "Format", selectCurrentPage: "Select current page", selectConfigForBatch: "Select config {dataId}", batchExportTitle: "Batch export Nacos configs", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d690e30a0f..2dd3dba910 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6005,9 +6005,20 @@ export default withEnglishFallback({ batchExport: "Exportación por lotes", batchImport: "Importación por lotes", copyToNamespace: "Sincronizar configuraciones", + batchDelete: "Eliminar en lote", + batchDeleteTitle: "Eliminar configuraciones Nacos seleccionadas", + batchDeleteConfirm: "¿Eliminar {count} configuraciones seleccionadas? Esta acción no se puede deshacer.", + batchDeleteSuccess: "Se eliminaron {count} configuraciones", + batchDeletePartial: "Se eliminaron {deleted} configuraciones; {failed} fallaron.", + batchDeleteInterrupted: "La eliminación en lote se detuvo después de eliminar {deleted} configuraciones.", + configPageSize: "Por página", selectedCount: "{count} seleccionadas", retryConnectionInfo: "Reintentar detección de la conexión", resizeColumn: "Arrastre para cambiar el ancho de la columna", + visibleColumns: "Columnas visibles", + configListGroup: "Grupo", + configListApplication: "Aplicación", + configListFormat: "Formato", selectCurrentPage: "Seleccionar página actual", selectConfigForBatch: "Seleccionar configuración {dataId}", batchExportTitle: "Exportar configuraciones Nacos por lotes", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index e6fc13c69d..bf727db071 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6005,9 +6005,20 @@ export default withEnglishFallback({ batchExport: "Esportazione in blocco", batchImport: "Importazione in blocco", copyToNamespace: "Sincronizza configurazioni", + batchDelete: "Elimina in blocco", + batchDeleteTitle: "Elimina configurazioni Nacos selezionate", + batchDeleteConfirm: "Eliminare {count} configurazioni selezionate? Questa azione non può essere annullata.", + batchDeleteSuccess: "Eliminate {count} configurazioni", + batchDeletePartial: "Eliminate {deleted} configurazioni; {failed} non riuscite.", + batchDeleteInterrupted: "L'eliminazione in blocco si è interrotta dopo aver eliminato {deleted} configurazioni.", + configPageSize: "Per pagina", selectedCount: "{count} selezionate", retryConnectionInfo: "Riprova rilevamento connessione", resizeColumn: "Trascina per ridimensionare la colonna", + visibleColumns: "Colonne visibili", + configListGroup: "Gruppo", + configListApplication: "Applicazione", + configListFormat: "Formato", selectCurrentPage: "Seleziona pagina corrente", selectConfigForBatch: "Seleziona configurazione {dataId}", batchExportTitle: "Esporta configurazioni Nacos in blocco", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1a86c89936..1ab9d5a031 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6048,9 +6048,20 @@ export default withEnglishFallback({ batchExport: "一括エクスポート", batchImport: "一括インポート", copyToNamespace: "設定同期", + batchDelete: "一括削除", + batchDeleteTitle: "選択した Nacos 設定を削除", + batchDeleteConfirm: "選択した {count} 件の設定を削除しますか?この操作は取り消せません。", + batchDeleteSuccess: "{count} 件の設定を削除しました", + batchDeletePartial: "{deleted} 件の設定を削除し、{failed} 件は失敗しました。", + batchDeleteInterrupted: "一括削除を停止しました。{deleted} 件の設定を削除しました。", + configPageSize: "ページあたり", selectedCount: "{count} 件選択", retryConnectionInfo: "接続情報の検出を再試行", resizeColumn: "ドラッグして列幅を変更", + visibleColumns: "表示列", + configListGroup: "グループ", + configListApplication: "アプリ", + configListFormat: "形式", selectCurrentPage: "現在のページを選択", selectConfigForBatch: "設定 {dataId} を選択", batchExportTitle: "Nacos 設定を一括エクスポート", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index dd0f258f40..15348a91a2 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6780,9 +6780,20 @@ export default withEnglishFallback({ batchExport: "일괄 내보내기", batchImport: "일괄 가져오기", copyToNamespace: "구성 동기화", + batchDelete: "일괄 삭제", + batchDeleteTitle: "선택한 Nacos 구성 삭제", + batchDeleteConfirm: "선택한 구성 {count}개를 삭제할까요? 이 작업은 되돌릴 수 없습니다.", + batchDeleteSuccess: "구성 {count}개를 삭제했습니다", + batchDeletePartial: "구성 {deleted}개를 삭제했고 {failed}개는 실패했습니다.", + batchDeleteInterrupted: "일괄 삭제가 중지되었습니다. 구성 {deleted}개를 삭제했습니다.", + configPageSize: "페이지당 항목", selectedCount: "{count}개 선택됨", retryConnectionInfo: "연결 정보 프로브 다시 시도", resizeColumn: "드래그하여 열 크기 조정", + visibleColumns: "표시 열", + configListGroup: "그룹", + configListApplication: "애플리케이션", + configListFormat: "형식", selectCurrentPage: "현재 페이지 선택", selectConfigForBatch: "구성 {dataId} 선택", batchExportTitle: "Nacos 구성 일괄 내보내기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f2bb6278a3..bb5816a350 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6007,9 +6007,20 @@ export default withEnglishFallback({ batchExport: "Exportação em lote", batchImport: "Importação em lote", copyToNamespace: "Sincronizar configurações", + batchDelete: "Excluir em lote", + batchDeleteTitle: "Excluir configurações Nacos selecionadas", + batchDeleteConfirm: "Excluir {count} configurações selecionadas? Esta ação não pode ser desfeita.", + batchDeleteSuccess: "Excluídas {count} configurações", + batchDeletePartial: "Excluídas {deleted} configurações; {failed} falharam.", + batchDeleteInterrupted: "A exclusão em lote foi interrompida após excluir {deleted} configurações.", + configPageSize: "Por página", selectedCount: "{count} selecionadas", retryConnectionInfo: "Tentar detectar a conexão novamente", resizeColumn: "Arraste para redimensionar a coluna", + visibleColumns: "Colunas visíveis", + configListGroup: "Grupo", + configListApplication: "Aplicação", + configListFormat: "Formato", selectCurrentPage: "Selecionar página atual", selectConfigForBatch: "Selecionar configuração {dataId}", batchExportTitle: "Exportar configurações do Nacos em lote", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 198715854d..47f96e8e63 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7219,9 +7219,20 @@ export default withEnglishFallback({ batchExport: "批量导出", batchImport: "批量导入", copyToNamespace: "配置同步", + batchDelete: "批量删除", + batchDeleteTitle: "批量删除 Nacos 配置", + batchDeleteConfirm: "确定删除已选的 {count} 个配置?此操作无法撤销。", + batchDeleteSuccess: "已删除 {count} 个配置", + batchDeletePartial: "已删除 {deleted} 个配置,{failed} 个删除失败。", + batchDeleteInterrupted: "批量删除已停止,已删除 {deleted} 个配置。", + configPageSize: "每页条数", selectedCount: "已选 {count} 项", retryConnectionInfo: "重试连接信息探测", resizeColumn: "拖拽调整列宽", + visibleColumns: "显示字段", + configListGroup: "分组", + configListApplication: "应用", + configListFormat: "格式", selectCurrentPage: "选择当前页", selectConfigForBatch: "选择配置 {dataId}", batchExportTitle: "批量导出 Nacos 配置", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index bceac4bf59..77b5444c36 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5441,9 +5441,20 @@ export default withEnglishFallback({ batchExport: "批次匯出", batchImport: "批次匯入", copyToNamespace: "設定同步", + batchDelete: "批次刪除", + batchDeleteTitle: "批次刪除 Nacos 配置", + batchDeleteConfirm: "確定要刪除已選的 {count} 個配置嗎?此操作無法復原。", + batchDeleteSuccess: "已刪除 {count} 個配置", + batchDeletePartial: "已刪除 {deleted} 個配置,{failed} 個刪除失敗。", + batchDeleteInterrupted: "批次刪除已停止,已刪除 {deleted} 個配置。", + configPageSize: "每頁筆數", selectedCount: "已選 {count} 項", retryConnectionInfo: "重試連線資訊偵測", resizeColumn: "拖曳調整欄寬", + visibleColumns: "顯示欄位", + configListGroup: "分組", + configListApplication: "應用", + configListFormat: "格式", selectCurrentPage: "選取目前頁面", selectConfigForBatch: "選取設定 {dataId}", batchExportTitle: "批次匯出 Nacos 設定", diff --git a/crates/dbx-core/src/nacos/http.rs b/crates/dbx-core/src/nacos/http.rs index fa9894eb9b..d8fcabb8a2 100644 --- a/crates/dbx-core/src/nacos/http.rs +++ b/crates/dbx-core/src/nacos/http.rs @@ -2706,12 +2706,48 @@ fn parse_config_history_item( operator: optional_string_field(&item, &["operator", "opUser", "srcUser", "createUser", "modifyUser", "user"]), last_modified_time: optional_string_field( &item, - &["lastModifiedTime", "lastModifiedTs", "gmtModified", "modifiedTime", "opTime", "createdTime"], + &[ + "lastModifiedTime", + "lastModifiedTs", + "lastModified", + "last_modified_time", + "last_modified", + "gmtModified", + "gmt_modified", + "modifiedTime", + "modifyTime", + "updateTime", + "update_time", + "publishTime", + "publish_time", + "opTime", + "createdTime", + "createTime", + "create_time", + ], ) .or_else(|| { optional_u64_field( &item, - &["lastModifiedTime", "lastModifiedTs", "gmtModified", "modifiedTime", "opTime", "createdTime"], + &[ + "lastModifiedTime", + "lastModifiedTs", + "lastModified", + "last_modified_time", + "last_modified", + "gmtModified", + "gmt_modified", + "modifiedTime", + "modifyTime", + "updateTime", + "update_time", + "publishTime", + "publish_time", + "opTime", + "createdTime", + "createTime", + "create_time", + ], ) .map(|value| value.to_string()) }), @@ -5282,6 +5318,35 @@ mod tests { assert_eq!(parsed.items[0].operation.as_deref(), Some("publish")); } + #[test] + fn parses_history_last_modified_aliases() { + let parsed = parse_config_history_list( + serde_json::json!({ + "data": { + "pageItems": [{ + "id": 7, + "dataId": "app.yaml", + "group": "DEFAULT_GROUP", + "lastModified": "2026-08-11 15:07:48" + }, { + "id": 8, + "dataId": "app.yaml", + "group": "DEFAULT_GROUP", + "publish_time": 1786430868000i64 + }] + } + }), + "public".to_string(), + 1, + 20, + "fallback.yaml", + "DEFAULT_GROUP", + ); + + assert_eq!(parsed.items[0].last_modified_time.as_deref(), Some("2026-08-11 15:07:48")); + assert_eq!(parsed.items[1].last_modified_time.as_deref(), Some("1786430868000")); + } + #[test] fn parses_config_history_detail_shape() { let parsed = parse_config_history_detail( diff --git a/packages/app-tests/nacosConfigListLayout.test.ts b/packages/app-tests/nacosConfigListLayout.test.ts index 9697084566..fb51aa0f30 100644 --- a/packages/app-tests/nacosConfigListLayout.test.ts +++ b/packages/app-tests/nacosConfigListLayout.test.ts @@ -1,12 +1,7 @@ import assert from "node:assert/strict"; import { ref } from "vue"; import { afterEach, beforeEach, test } from "vitest"; -import { - NACOS_CONFIG_LIST_COLUMN_WIDTHS_STORAGE_KEY, - DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS, - NACOS_CONFIG_LIST_HORIZONTAL_PADDING, - useNacosConfigListColumnResize, -} from "../../apps/desktop/src/composables/useNacosConfigListColumnResize.ts"; +import { NACOS_CONFIG_LIST_COLUMN_WIDTHS_STORAGE_KEY, NACOS_CONFIG_LIST_HIDDEN_COLUMNS_STORAGE_KEY, DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS, NACOS_CONFIG_LIST_HORIZONTAL_PADDING, useNacosConfigListColumnResize } from "../../apps/desktop/src/composables/useNacosConfigListColumnResize.ts"; function installLocalStorage() { const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); @@ -124,5 +119,41 @@ test("Nacos config list fits every default column into the actual list viewport" assert.equal(layout.totalWidth.value, 800 - NACOS_CONFIG_LIST_HORIZONTAL_PADDING); assert.equal(layout.minWidth.value, "800px"); - assert.equal(layout.columnWidths.value.every((width) => width > 0), true); + assert.equal( + layout.columnWidths.value.every((width) => width > 0), + true, + ); +}); + +test("Nacos config list persists hidden optional columns and gives the remaining columns the available space", () => { + const viewportWidth = ref(600); + const layout = useNacosConfigListColumnResize(viewportWidth); + + layout.setColumnVisible("group", false); + layout.setColumnVisible("application", false); + + assert.deepEqual(layout.visibleColumns.value, ["dataId", "format"]); + assert.equal(layout.totalWidth.value, 600 - NACOS_CONFIG_LIST_HORIZONTAL_PADDING); + assert.equal(layout.gridTemplateColumns.value, "450px 126px"); + assert.equal(localStorage.getItem(NACOS_CONFIG_LIST_HIDDEN_COLUMNS_STORAGE_KEY), JSON.stringify(["group", "application"])); + + let prevented = false; + layout.onResizeStart(0, { + clientX: 450, + preventDefault() { + prevented = true; + }, + } as MouseEvent); + documentHarness!.dispatchMouseEvent("mousemove", 570); + documentHarness!.dispatchMouseEvent("mouseup", 570); + + assert.equal(prevented, true); + assert.equal(layout.gridTemplateColumns.value, "504px 72px"); + + const restoredLayout = useNacosConfigListColumnResize(viewportWidth); + assert.deepEqual(restoredLayout.visibleColumns.value, ["dataId", "format"]); + + layout.setColumnVisible("group", true); + layout.setColumnVisible("application", true); + assert.equal(localStorage.getItem(NACOS_CONFIG_LIST_HIDDEN_COLUMNS_STORAGE_KEY), null); });