feat(nacos-workbench): 优化配置管理与服务发现交互

- 固定服务列表分页到底部并重构端点优先的实例布局
- 支持 IPv6 端点和响应式实例操作区
- 修复详情乱序、监听启动竞态与重复远端变更提示
- 使用结构化索引处理配置导入选择并避免标签页冲突
This commit is contained in:
Syngnat
2026-07-28 23:51:47 +08:00
parent 12e410aead
commit 8e0b5c5668
9 changed files with 2892 additions and 272 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,10 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import NacosViewer from './NacosViewer';
import { nacosConfigSelectionKey } from './nacosConfigSelection';
import {
nacosConfigSelectionKey,
nacosImportSelectionKey,
} from './nacosConfigSelection';
const rows = [
{ dataId: 'app.yaml', group: 'DEFAULT_GROUP', type: 'yaml' },
@@ -19,6 +22,11 @@ const storeState = vi.hoisted(() => ({
type: 'nacos',
host: '127.0.0.1',
port: 8848,
readOnly: false,
protection: {
restrictDataEdit: false,
restrictDataImport: false,
},
},
},
],
@@ -36,7 +44,13 @@ const nacosBackend = vi.hoisted(() => ({
NacosSearchConfigs: vi.fn(),
NacosListConfigGroups: vi.fn(),
NacosExportConfigs: vi.fn(),
NacosPreviewImportConfigs: vi.fn(),
NacosImportConfigs: vi.fn(),
NacosDeleteConfig: vi.fn(),
NacosGetConfig: vi.fn(),
NacosGetBetaConfig: vi.fn(),
NacosStartConfigListen: vi.fn(),
NacosStopConfigListen: vi.fn(),
}));
const antdState = vi.hoisted(() => ({
@@ -49,6 +63,10 @@ const antdState = vi.hoisted(() => ({
},
}));
const runtimeState = vi.hoisted(() => ({
configChangedHandler: null as ((event: any) => void) | null,
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
@@ -58,12 +76,15 @@ vi.mock('../i18n/provider', () => ({
}));
vi.mock('../../wailsjs/runtime', () => ({
EventsOn: vi.fn(() => vi.fn()),
EventsOn: vi.fn((_eventName: string, handler: (event: any) => void) => {
runtimeState.configChangedHandler = handler;
return vi.fn();
}),
}));
vi.mock('./MonacoEditor', async () => {
const React = await import('react');
return { default: () => React.createElement('nacos-editor') };
return { default: (props: any) => React.createElement('nacos-editor', props) };
});
vi.mock('./RedisResizableDivider', async () => {
@@ -114,7 +135,15 @@ vi.mock('antd', async () => {
);
return {
Alert: passthrough('alert'),
Alert: ({ children, message, description, action, ...props }: any) =>
React.createElement(
'alert',
props,
message,
description,
action,
children,
),
AutoComplete: (props: any) => React.createElement('autocomplete', props),
Button,
Checkbox: ({ children, ...props }: any) =>
@@ -161,10 +190,27 @@ const latestConfigTableProps = () =>
const findButtonByText = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAllByType('button').find((node) => renderedText(node.props.children).includes(text));
const findButtonByExactText = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAllByType('button').find((node) => renderedText(node.props.children) === text);
const deferred = <T,>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
};
describe('NacosViewer config selection actions', () => {
beforeEach(() => {
vi.clearAllMocks();
antdState.tableProps = [];
runtimeState.configChangedHandler = null;
storeState.connections[0].config.readOnly = false;
storeState.connections[0].config.protection = {
restrictDataEdit: false,
restrictDataImport: false,
};
nacosBackend.NacosSearchConfigs.mockResolvedValue({
success: true,
data: {
@@ -182,7 +228,32 @@ describe('NacosViewer config selection actions', () => {
success: true,
data: { exported: rows.length },
});
nacosBackend.NacosPreviewImportConfigs.mockResolvedValue({
success: true,
data: { file: 'configs.zip', items: [] },
});
nacosBackend.NacosImportConfigs.mockResolvedValue({
success: true,
data: { imported: 0, skipped: 0 },
});
nacosBackend.NacosDeleteConfig.mockResolvedValue({ success: true });
nacosBackend.NacosGetConfig.mockResolvedValue({
success: true,
data: {
...rows[0],
content: 'server: value',
md5: 'test-md5',
},
});
nacosBackend.NacosGetBetaConfig.mockResolvedValue({
success: true,
data: { exists: false },
});
nacosBackend.NacosStartConfigListen.mockResolvedValue({
success: true,
data: { watchId: '' },
});
nacosBackend.NacosStopConfigListen.mockResolvedValue({ success: true });
vi.stubGlobal('window', {
requestAnimationFrame: (callback: FrameRequestCallback) => callback(0),
getSelection: () => ({ removeAllRanges: vi.fn() }),
@@ -287,4 +358,520 @@ describe('NacosViewer config selection actions', () => {
renderer!.unmount();
});
it('imports structurally selected identities with separators, duplicates, and empty fields intact', async () => {
const importRows = [
{ group: 'GROUP@@blue', dataId: 'config@@prod.yaml', type: 'yaml' },
{ group: 'DUPLICATE', dataId: 'same.yaml', type: 'yaml' },
{ group: 'DUPLICATE', dataId: 'same.yaml', type: 'yaml' },
{ group: '', dataId: '', type: 'text' },
];
nacosBackend.NacosPreviewImportConfigs.mockResolvedValue({
success: true,
data: {
file: 'configs.zip',
total: importRows.length,
items: importRows,
},
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
await act(async () => {
findButtonByExactText(renderer!, 'Import')!.props.onClick();
});
await flushEffects();
const allKeys = importRows.map(nacosImportSelectionKey);
const importTable = [...antdState.tableProps]
.reverse()
.find(
(props) =>
props.dataSource?.map((row: any) => row.selectionKey).join('|') ===
allKeys.join('|'),
);
expect(importTable.rowKey).toBe('selectionKey');
expect(importTable.rowSelection.selectedRowKeys).toEqual(allKeys);
const selectedKeys = [allKeys[0], allKeys[2], allKeys[3]];
await act(async () => {
importTable.rowSelection.onChange(selectedKeys);
});
const importModal = renderer!.root.findAll(
(node) => String(node.type) === 'modal',
)[0];
await act(async () => {
importModal.props.onOk();
});
await flushEffects();
expect(nacosBackend.NacosImportConfigs).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
scope: 'selected',
items: [
{ group: 'GROUP@@blue', dataId: 'config@@prod.yaml', index: 0 },
{ group: 'DUPLICATE', dataId: 'same.yaml', index: 2 },
{ group: '', dataId: '', index: 3 },
],
}),
);
renderer!.unmount();
});
it('keeps a newer config selection when an older detail request resolves last', async () => {
const firstDetail = deferred<any>();
const secondDetail = deferred<any>();
nacosBackend.NacosGetConfig.mockImplementation(
(_config: unknown, _namespace: string, _group: string, dataId: string) =>
dataId === rows[0].dataId ? firstDetail.promise : secondDetail.promise,
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[0]).onClick();
latestConfigTableProps().onRow(rows[1]).onClick();
});
secondDetail.resolve({
success: true,
data: { ...rows[1], content: 'newer detail', md5: 'second-md5' },
});
await flushEffects();
expect(
renderer!.root.find((node) => (node.type as any) === 'nacos-editor').props.value,
).toBe('newer detail');
firstDetail.resolve({
success: true,
data: { ...rows[0], content: 'older detail', md5: 'first-md5' },
});
await flushEffects();
expect(
renderer!.root.find((node) => (node.type as any) === 'nacos-editor').props.value,
).toBe('newer detail');
expect(nacosBackend.NacosStartConfigListen).toHaveBeenCalledTimes(1);
expect(nacosBackend.NacosStartConfigListen).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ dataId: rows[1].dataId }),
);
renderer!.unmount();
});
it('does not apply stale beta metadata after selecting another config', async () => {
const firstBeta = deferred<any>();
nacosBackend.NacosGetConfig.mockImplementation(
async (_config: unknown, _namespace: string, group: string, dataId: string) => ({
success: true,
data: {
dataId,
group,
content: `${dataId} detail`,
md5: `${dataId}-md5`,
},
}),
);
nacosBackend.NacosGetBetaConfig.mockImplementation(
(_config: unknown, _namespace: string, _group: string, dataId: string) =>
dataId === rows[0].dataId
? firstBeta.promise
: Promise.resolve({ success: true, data: { exists: false } }),
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[0]).onClick();
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[1]).onClick();
});
await flushEffects();
act(() => {
renderer!.root.find(
(node) => String(node.type) === 'radio-group',
).props.onChange({
target: { value: 'beta' },
});
});
expect(findButtonByExactText(renderer!, 'Load beta content')?.props.disabled).toBe(
true,
);
firstBeta.resolve({
success: true,
data: { exists: true, betaIps: '10.0.0.1' },
});
await flushEffects();
expect(findButtonByExactText(renderer!, 'Load beta content')?.props.disabled).toBe(
true,
);
renderer!.unmount();
});
it('stops a stale listener immediately when its start request resolves after the current selection', async () => {
const firstListen = deferred<any>();
nacosBackend.NacosGetConfig.mockImplementation(
async (_config: unknown, _namespace: string, group: string, dataId: string) => ({
success: true,
data: {
dataId,
group,
content: `${dataId} detail`,
md5: `${dataId}-md5`,
},
}),
);
nacosBackend.NacosStartConfigListen.mockImplementation(
(_config: unknown, request: any) =>
request.dataId === rows[0].dataId
? firstListen.promise
: Promise.resolve({
success: true,
data: { watchId: 'watch-current' },
}),
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[0]).onClick();
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[1]).onClick();
});
await flushEffects();
firstListen.resolve({
success: true,
data: { watchId: 'watch-stale' },
});
await flushEffects();
expect(nacosBackend.NacosStopConfigListen).toHaveBeenCalledWith(
'watch-stale',
);
expect(renderedText(renderer!.toJSON())).toContain('Listening');
await act(async () => {
renderer!.unmount();
});
await flushEffects();
});
it('consumes an immediate one-shot event before the start RPC resolves', async () => {
const pendingListen = deferred<any>();
let pendingRequest: any;
nacosBackend.NacosStartConfigListen.mockImplementation(
(_config: unknown, request: any) => {
pendingRequest = request;
return pendingListen.promise;
},
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[0]).onClick();
});
await flushEffects();
expect(pendingRequest?.watchId).toMatch(/^nacos-/);
await act(async () => {
runtimeState.configChangedHandler!({
watchId: pendingRequest.watchId,
connectionId: 'nacos-1',
namespaceId: 'dev',
group: rows[0].group,
dataId: rows[0].dataId,
});
});
await flushEffects();
expect(nacosBackend.NacosStopConfigListen).toHaveBeenCalledWith(
pendingRequest.watchId,
);
expect(antdState.message.info).toHaveBeenCalledTimes(1);
pendingListen.resolve({
success: true,
data: { watchId: pendingRequest.watchId },
});
await flushEffects();
expect(renderedText(renderer!.toJSON())).not.toContain('Listening');
await act(async () => {
renderer!.unmount();
});
await flushEffects();
});
it.each([
{
context: 'namespace',
nextProps: {
connectionId: 'nacos-1',
namespaceId: 'prod',
namespaceName: 'prod',
},
},
{
context: 'connection',
nextProps: {
connectionId: 'missing-nacos-connection',
namespaceId: 'dev',
namespaceName: 'dev',
},
},
])('invalidates the selected detail and active listener when the $context context changes', async ({
nextProps,
}) => {
nacosBackend.NacosStartConfigListen.mockResolvedValue({
success: true,
data: { watchId: 'watch-dev' },
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[0]).onClick();
});
await flushEffects();
expect(renderedText(renderer!.toJSON())).toContain('Listening');
await act(async () => {
renderer!.update(
<NacosViewer {...nextProps} />,
);
});
await flushEffects();
expect(nacosBackend.NacosStopConfigListen).toHaveBeenCalledWith('watch-dev');
expect(renderedText(renderer!.toJSON())).not.toContain('Listening');
expect(
renderer!.root.findAll((node) => (node.type as any) === 'nacos-editor'),
).toHaveLength(0);
await act(async () => {
renderer!.unmount();
});
});
it('ignores a detail response from the previous namespace context', async () => {
const oldContextDetail = deferred<any>();
nacosBackend.NacosGetConfig.mockReturnValue(oldContextDetail.promise);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[0]).onClick();
});
await act(async () => {
renderer!.update(
<NacosViewer connectionId="nacos-1" namespaceId="prod" namespaceName="prod" />,
);
});
oldContextDetail.resolve({
success: true,
data: { ...rows[0], content: 'stale context detail', md5: 'stale-md5' },
});
await flushEffects();
expect(
renderer!.root.findAll((node) => (node.type as any) === 'nacos-editor'),
).toHaveLength(0);
expect(nacosBackend.NacosStartConfigListen).not.toHaveBeenCalled();
await act(async () => {
renderer!.unmount();
});
});
it('consumes a matching remote-change watch once and suppresses duplicate event prompts', async () => {
nacosBackend.NacosStartConfigListen.mockResolvedValue({
success: true,
data: { watchId: 'watch-event' },
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[0]).onClick();
});
await flushEffects();
expect(renderedText(renderer!.toJSON())).toContain('Listening');
const event = {
watchId: 'watch-event',
connectionId: 'nacos-1',
namespaceId: 'dev',
group: rows[0].group,
dataId: rows[0].dataId,
};
await act(async () => {
runtimeState.configChangedHandler!(event);
runtimeState.configChangedHandler!(event);
});
await flushEffects();
expect(nacosBackend.NacosStopConfigListen).toHaveBeenCalledTimes(1);
expect(nacosBackend.NacosStopConfigListen).toHaveBeenCalledWith(
'watch-event',
);
expect(antdState.message.info).toHaveBeenCalledTimes(1);
expect(renderedText(renderer!.toJSON())).not.toContain('Listening');
await act(async () => {
findButtonByExactText(renderer!, 'Reload remote')!.props.onClick();
});
await flushEffects();
expect(nacosBackend.NacosStartConfigListen).toHaveBeenCalledTimes(2);
expect(renderedText(renderer!.toJSON())).toContain('Listening');
await act(async () => {
renderer!.unmount();
});
await flushEffects();
});
it('stops a listener whose start request resolves after unmount', async () => {
const pendingListen = deferred<any>();
nacosBackend.NacosStartConfigListen.mockReturnValue(pendingListen.promise);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
act(() => {
latestConfigTableProps().onRow(rows[0]).onClick();
});
await flushEffects();
await act(async () => {
renderer!.unmount();
});
pendingListen.resolve({
success: true,
data: { watchId: 'watch-after-unmount' },
});
await flushEffects();
expect(nacosBackend.NacosStopConfigListen).toHaveBeenCalledWith(
'watch-after-unmount',
);
});
it.each([
{
name: 'explicit readOnly',
configure: () => {
storeState.connections[0].config.readOnly = true;
},
},
{
name: 'restrictDataEdit protection',
configure: () => {
storeState.connections[0].config.protection.restrictDataEdit = true;
},
},
])('disables config publishing and deletion for $name', async ({ configure }) => {
configure();
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
await act(async () => {
latestConfigTableProps().onRow(rows[0]).onClick();
});
await flushEffects();
const editor = renderer!.root.find((node) => (node.type as any) === 'nacos-editor');
await act(async () => {
editor.props.onChange('changed: value');
});
expect(findButtonByExactText(renderer!, 'Publish')?.props.disabled).toBe(true);
expect(findButtonByExactText(renderer!, 'Delete')?.props.disabled).toBe(true);
renderer!.unmount();
});
it('disables config import for restrictDataImport protection only', async () => {
storeState.connections[0].config.protection.restrictDataImport = true;
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<NacosViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
expect(findButtonByExactText(renderer!, 'Import')?.props.disabled).toBe(true);
expect(findButtonByExactText(renderer!, 'New')?.props.disabled).toBe(false);
renderer!.unmount();
});
});

View File

@@ -27,6 +27,7 @@ import {
SaveOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { v4 as uuidv4 } from 'uuid';
import Editor from './MonacoEditor';
import RedisResizableDivider from './RedisResizableDivider';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
@@ -40,17 +41,18 @@ import {
} from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
isConnectionDataEditRestricted,
isConnectionDataImportRestricted,
} from '../utils/connectionReadOnly';
import { t, type I18nParams } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import {
buildNacosImportSelectionRows,
deleteSelectedNacosConfigs,
nacosConfigSelectionKey,
reconcileNacosConfigSelection,
selectedNacosConfigItems,
selectedNacosImportItems,
} from './nacosConfigSelection';
type NacosConfigItem = {
@@ -202,9 +204,12 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
), [isV2Ui, workbenchTheme]);
const connection = connections.find((item) => item.id === connectionId);
const readOnly = isConnectionDataEditRestricted(connection?.config)
|| !!connection?.config?.readOnly;
const importRestricted = isConnectionDataImportRestricted(connection?.config) || readOnly;
const connectionProtection = connection?.config?.protection;
const readOnly = !!connection?.config?.readOnly
|| connectionProtection?.restrictDataEdit === true;
const importRestricted = readOnly
|| connectionProtection?.restrictDataImport === true
|| isConnectionDataImportRestricted(connection?.config);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
@@ -256,13 +261,33 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
const watchIdRef = useRef<string | null>(null);
const detailRef = useRef<NacosConfigDetail | null>(null);
const draftDirtyRef = useRef(false);
const selectionGenerationRef = useRef(0);
const listenGenerationRef = useRef(0);
const mountedRef = useRef(true);
const rpcConfig = useMemo(() => {
if (!connection?.config) return null;
return buildRpcConnectionConfig(connection.config as any);
}, [connection?.config]);
const selectionContextRef = useRef({
connectionId,
namespaceId,
rpcConfig,
});
selectionContextRef.current = {
connectionId,
namespaceId,
rpcConfig,
};
const selectedRowKey = selectedKey;
const importSelectionRows = useMemo(
() =>
buildNacosImportSelectionRows(
Array.isArray(importPreview?.items) ? importPreview.items : [],
),
[importPreview],
);
const selectedItems = useMemo(
() => selectedNacosConfigItems(items, selectedRowKeys),
[items, selectedRowKeys],
@@ -278,26 +303,54 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
draftDirtyRef.current = draftDirty;
}, [draftDirty]);
const stopListen = useCallback(async () => {
const watchId = watchIdRef.current;
watchIdRef.current = null;
setListenActive(false);
const stopWatch = useCallback(async (watchId: string | null) => {
if (!watchId) return;
try {
await (window as any).go.app.App.NacosStopConfigListen(watchId);
} catch {
// ignore stop errors on unmount
// Stopping a listener is best-effort during selection changes/unmount.
}
}, []);
const stopListen = useCallback(async () => {
listenGenerationRef.current += 1;
const watchId = watchIdRef.current;
watchIdRef.current = null;
if (mountedRef.current) {
setListenActive(false);
}
await stopWatch(watchId);
}, [stopWatch]);
const startListen = useCallback(
async (target: NacosConfigDetail) => {
async (target: NacosConfigDetail, selectionGeneration: number) => {
if (!rpcConfig) return;
await stopListen();
setRemoteChanged(false);
const listenGeneration = ++listenGenerationRef.current;
const previousWatchId = watchIdRef.current;
watchIdRef.current = null;
if (mountedRef.current) {
setListenActive(false);
setRemoteChanged(false);
}
await stopWatch(previousWatchId);
const isCurrentListen = () => {
const currentContext = selectionContextRef.current;
return (
mountedRef.current &&
listenGenerationRef.current === listenGeneration &&
selectionGenerationRef.current === selectionGeneration &&
currentContext.connectionId === connectionId &&
currentContext.namespaceId === namespaceId &&
currentContext.rpcConfig === rpcConfig
);
};
if (!isCurrentListen()) return;
const contentMd5 = String(target.md5 || '').trim();
const pendingWatchId = `nacos-${uuidv4()}`;
watchIdRef.current = pendingWatchId;
try {
const res = await (window as any).go.app.App.NacosStartConfigListen(rpcConfig, {
watchId: pendingWatchId,
connectionId,
namespaceId: namespaceId || '',
dataId: target.dataId,
@@ -305,17 +358,31 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
contentMd5,
});
if (!res?.success) {
setListenActive(false);
if (isCurrentListen()) {
if (watchIdRef.current === pendingWatchId) {
watchIdRef.current = null;
}
setListenActive(false);
}
return;
}
const nextWatchId = String(res?.data?.watchId || '').trim();
if (!isCurrentListen()) {
await stopWatch(nextWatchId || null);
return;
}
watchIdRef.current = nextWatchId || null;
setListenActive(!!nextWatchId);
} catch {
setListenActive(false);
if (isCurrentListen()) {
if (watchIdRef.current === pendingWatchId) {
watchIdRef.current = null;
}
setListenActive(false);
}
}
},
[rpcConfig, connectionId, namespaceId, stopListen],
[rpcConfig, connectionId, namespaceId, stopWatch],
);
const mergeUniqueStrings = useCallback((prev: string[], next: string[]) => {
@@ -423,8 +490,26 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
);
const loadBetaMeta = useCallback(
async (item: { dataId: string; group: string }) => {
async (
item: { dataId: string; group: string },
selectionGeneration = selectionGenerationRef.current,
) => {
if (!rpcConfig) return;
const requestContext = {
connectionId,
namespaceId,
rpcConfig,
};
const isCurrentSelection = () => {
const currentContext = selectionContextRef.current;
return (
mountedRef.current &&
selectionGenerationRef.current === selectionGeneration &&
currentContext.connectionId === requestContext.connectionId &&
currentContext.namespaceId === requestContext.namespaceId &&
currentContext.rpcConfig === requestContext.rpcConfig
);
};
try {
const res = await (window as any).go.app.App.NacosGetBetaConfig(
rpcConfig,
@@ -432,6 +517,7 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
item.group,
item.dataId,
);
if (!isCurrentSelection()) return;
if (!res?.success) {
setBetaExists(false);
return;
@@ -444,15 +530,33 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
setBetaIps('');
}
} catch {
setBetaExists(false);
if (isCurrentSelection()) {
setBetaExists(false);
}
}
},
[rpcConfig, namespaceId],
[rpcConfig, connectionId, namespaceId],
);
const loadDetail = useCallback(
async (item: NacosConfigItem) => {
if (!rpcConfig) return;
const generation = ++selectionGenerationRef.current;
void stopListen();
const requestContext = {
connectionId,
namespaceId,
rpcConfig,
};
const isCurrentSelection = () => {
const currentContext = selectionContextRef.current;
return (
selectionGenerationRef.current === generation &&
currentContext.connectionId === requestContext.connectionId &&
currentContext.namespaceId === requestContext.namespaceId &&
currentContext.rpcConfig === requestContext.rpcConfig
);
};
setLoadingDetail(true);
try {
const res = await (window as any).go.app.App.NacosGetConfig(
@@ -461,6 +565,7 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
item.group,
item.dataId,
);
if (!isCurrentSelection()) return;
if (!res?.success) {
message.error(
tr('nacos_viewer.message.load_failed', {
@@ -470,29 +575,59 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
return;
}
const next = (res.data || {}) as NacosConfigDetail;
detailRef.current = next;
draftDirtyRef.current = false;
setDetail(next);
setDraftContent(String(next.content ?? ''));
setDraftType(String(next.type || item.type || 'text'));
setDraftDirty(false);
setRemoteChanged(false);
setPublishMode('formal');
setSelectedKey(`${item.group}@@${item.dataId}`);
void startListen(next);
void loadBetaMeta({ dataId: next.dataId, group: next.group });
setSelectedKey(nacosConfigSelectionKey(item));
void startListen(next, generation);
void loadBetaMeta(
{ dataId: next.dataId, group: next.group },
generation,
);
} catch (error: any) {
if (!isCurrentSelection()) return;
message.error(
tr('nacos_viewer.message.load_failed', {
detail: error?.message || String(error),
}),
);
} finally {
setLoadingDetail(false);
if (isCurrentSelection()) {
setLoadingDetail(false);
}
}
},
[rpcConfig, namespaceId, tr, startListen, loadBetaMeta],
[
rpcConfig,
connectionId,
namespaceId,
tr,
startListen,
stopListen,
loadBetaMeta,
],
);
useEffect(() => {
selectionGenerationRef.current += 1;
void stopListen();
detailRef.current = null;
draftDirtyRef.current = false;
setLoadingDetail(false);
setDetail(null);
setSelectedKey(null);
setDraftContent('');
setDraftType('text');
setDraftDirty(false);
setRemoteChanged(false);
setListenActive(false);
setBetaExists(false);
setBetaIps('');
const nextGroup = String(initialGroup || '').trim();
setFilterGroup(nextGroup);
setDataIdSuggestions([]);
@@ -500,7 +635,7 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
// Reload when switching tab identity / initial group.
void loadList(1);
void loadFilterSuggestions();
}, [connectionId, namespaceId, initialGroup]); // eslint-disable-line react-hooks/exhaustive-deps
}, [connectionId, namespaceId, initialGroup, rpcConfig]); // eslint-disable-line react-hooks/exhaustive-deps
// Keep table body height = available pane height (minus real pagination height).
useEffect(() => {
@@ -589,12 +724,15 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
const current = detailRef.current;
if (!current) return;
const watchId = watchIdRef.current;
if (watchId && event?.watchId && event.watchId !== watchId) return;
if (!watchId) return;
if (event?.watchId && event.watchId !== watchId) return;
if (event?.connectionId && event.connectionId !== connectionId) return;
if (event?.namespaceId && event.namespaceId !== namespaceId) return;
const eventDataId = String(event?.dataId || '').trim();
const eventGroup = String(event?.group || 'DEFAULT_GROUP').trim() || 'DEFAULT_GROUP';
if (eventDataId && eventDataId !== current.dataId) return;
if (eventGroup && eventGroup !== current.group) return;
void stopListen();
setRemoteChanged(true);
if (!draftDirtyRef.current) {
message.info(tr('nacos_viewer.message.remote_changed'));
@@ -603,10 +741,13 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
return () => {
if (typeof off === 'function') off();
};
}, [connectionId, tr]);
}, [connectionId, namespaceId, stopListen, tr]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
selectionGenerationRef.current += 1;
void stopListen();
};
}, [stopListen]);
@@ -764,9 +905,9 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
}
const preview = res.data || {};
setImportPreview(preview);
const keys = (Array.isArray(preview.items) ? preview.items : []).map(
(item: any) => `${item.group}@@${item.dataId}`,
);
const keys = buildNacosImportSelectionRows(
Array.isArray(preview.items) ? preview.items : [],
).map((row) => row.selectionKey);
setImportSelectedKeys(keys);
setImportConflictMode('skip');
setImportModalOpen(true);
@@ -779,10 +920,10 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
if (!rpcConfig || !importPreview || importRestricted) return;
setImporting(true);
try {
const selectedItems = importSelectedKeys.map((key) => {
const [group, dataId] = String(key).split('@@');
return { group, dataId };
});
const selectedItems = selectedNacosImportItems(
Array.isArray(importPreview.items) ? importPreview.items : [],
importSelectedKeys,
);
const res = await (window as any).go.app.App.NacosImportConfigs(rpcConfig, {
namespaceId: namespaceId || '',
conflictMode: importConflictMode,
@@ -815,7 +956,6 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
group: detail.group,
type: draftType || detail.type,
});
setRemoteChanged(false);
};
const resetDetailState = () => {
@@ -1422,7 +1562,7 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
},
})}
rowClassName={(record) =>
selectedRowKey === `${record.group}@@${record.dataId}`
selectedRowKey === nacosConfigSelectionKey(record)
? 'ant-table-row-selected gn-nacos-config-table__row--active'
: 'gn-nacos-config-table__row'
}
@@ -1614,7 +1754,7 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
open={newModalOpen}
onCancel={() => setNewModalOpen(false)}
onOk={() => void handleCreate()}
destroyOnClose
destroyOnHidden
>
<Form form={newForm} layout="vertical" initialValues={{ group: 'DEFAULT_GROUP', type: 'text' }}>
<Form.Item
@@ -1642,7 +1782,7 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
onCancel={() => setHistoryOpen(false)}
footer={null}
width={860}
destroyOnClose
destroyOnHidden
>
<Table
size="small"
@@ -1766,9 +1906,9 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
onCancel={() => setImportModalOpen(false)}
onOk={() => void handleImport()}
confirmLoading={importing}
okButtonProps={{ disabled: importSelectedKeys.length === 0 }}
okButtonProps={{ disabled: importRestricted || importSelectedKeys.length === 0 }}
width={820}
destroyOnClose
destroyOnHidden
>
{importPreview ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -1792,8 +1932,8 @@ const NacosViewer: React.FC<NacosViewerProps> = ({
/>
<Table
size="small"
rowKey={(row: any) => `${row.group}@@${row.dataId}`}
dataSource={Array.isArray(importPreview.items) ? importPreview.items : []}
rowKey="selectionKey"
dataSource={importSelectionRows}
pagination={{ pageSize: 8 }}
rowSelection={{
selectedRowKeys: importSelectedKeys,

View File

@@ -1,9 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildNacosImportSelectionRows,
deleteSelectedNacosConfigs,
nacosConfigSelectionKey,
nacosImportSelectionKey,
reconcileNacosConfigSelection,
selectedNacosImportItems,
selectedNacosConfigItems,
} from './nacosConfigSelection';
@@ -14,6 +17,78 @@ const rows = [
];
describe('nacos config selection', () => {
it('round-trips import identities containing @@ without splitting fields', () => {
const importRows = [
{ group: 'GROUP@@blue', dataId: 'config@@prod.yaml' },
{ group: 'GROUP', dataId: 'other.yaml' },
];
const selectedKey = nacosImportSelectionKey(importRows[0], 0);
expect(selectedNacosImportItems(importRows, [selectedKey])).toEqual([
{ group: 'GROUP@@blue', dataId: 'config@@prod.yaml', index: 0 },
]);
});
it('keeps duplicate and empty import rows independently selectable', () => {
const importRows = [
{ group: 'DUPLICATE', dataId: 'same.yaml' },
{ group: 'DUPLICATE', dataId: 'same.yaml' },
{ group: '', dataId: '' },
null,
];
const keys = importRows.map(nacosImportSelectionKey);
expect(new Set(keys).size).toBe(importRows.length);
expect(
selectedNacosImportItems(importRows, [keys[1], keys[2], keys[3]]),
).toEqual([
{ group: 'DUPLICATE', dataId: 'same.yaml', index: 1 },
{ group: '', dataId: '', index: 2 },
{ group: '', dataId: '', index: 3 },
]);
});
it('materializes stable preview row keys without relying on paginated table indexes', () => {
const importRows = [
{
group: 'DUPLICATE',
dataId: 'same.yaml',
exists: false,
index: 7,
},
{
group: 'DUPLICATE',
dataId: 'same.yaml',
exists: true,
index: 9,
},
null,
];
expect(buildNacosImportSelectionRows(importRows)).toEqual([
{
group: 'DUPLICATE',
dataId: 'same.yaml',
exists: false,
index: 7,
selectionKey: '[7,"DUPLICATE","same.yaml"]',
},
{
group: 'DUPLICATE',
dataId: 'same.yaml',
exists: true,
index: 9,
selectionKey: '[9,"DUPLICATE","same.yaml"]',
},
{
group: '',
dataId: '',
index: 2,
selectionKey: '[2,"",""]',
},
]);
});
it('builds collision-safe keys for arbitrary data ids and groups', () => {
expect(nacosConfigSelectionKey(rows[2])).toBe('["APP_GROUP","contains@@separator"]');
expect(nacosConfigSelectionKey({ dataId: 'separator', group: 'APP_GROUP@@contains' }))

View File

@@ -3,6 +3,20 @@ export type NacosConfigIdentity = {
group: string;
};
export type NacosImportIdentityInput =
| (Partial<NacosConfigIdentity> & { index?: number })
| null
| undefined;
export type NacosImportSelectionItem = NacosConfigIdentity & {
index: number;
};
export type NacosImportSelectionRow = NacosImportSelectionItem &
Record<string, unknown> & {
selectionKey: string;
};
export type NacosDeleteResponse = {
success: boolean;
message?: string;
@@ -16,6 +30,55 @@ export type NacosDeleteFailure<T extends NacosConfigIdentity> = {
export const nacosConfigSelectionKey = (item: NacosConfigIdentity): string =>
JSON.stringify([String(item.group || 'DEFAULT_GROUP'), String(item.dataId || '')]);
const normalizeNacosImportIdentity = (
item: NacosImportIdentityInput,
): NacosConfigIdentity => ({
group: String(item?.group ?? ''),
dataId: String(item?.dataId ?? ''),
});
export const nacosImportSelectionKey = (
item: NacosImportIdentityInput,
index: number,
): string => {
const identity = normalizeNacosImportIdentity(item);
return JSON.stringify([index, identity.group, identity.dataId]);
};
export const buildNacosImportSelectionRows = (
rows: NacosImportIdentityInput[],
): NacosImportSelectionRow[] =>
rows.map((row, index) => {
const identity = normalizeNacosImportIdentity(row);
const sourceIndex = Number(row?.index);
const previewIndex = Number.isInteger(sourceIndex) && sourceIndex >= 0
? sourceIndex
: index;
return {
...(row || {}),
...identity,
index: previewIndex,
selectionKey: nacosImportSelectionKey(identity, previewIndex),
};
});
export const selectedNacosImportItems = (
rows: NacosImportIdentityInput[],
keys: Array<string | number | bigint>,
): NacosImportSelectionItem[] => {
const selectedKeys = new Set(keys.map((key) => String(key)));
return buildNacosImportSelectionRows(rows).flatMap((row) => {
if (!selectedKeys.has(row.selectionKey)) {
return [];
}
return [{
group: row.group,
dataId: row.dataId,
index: row.index,
}];
});
};
export const selectedNacosConfigItems = <T extends NacosConfigIdentity>(
rows: T[],
keys: Array<string | number | bigint>,

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
buildNacosServicesTabData,
resolveNacosNamespaceDiscoveryModeFromTreeNode,
resolveNacosServicesDoubleClickAction,
shouldLoadSidebarNodeOnExpand as shouldLoadV2SidebarNodeOnExpand,
} from './sidebarV2Utils';
@@ -14,6 +15,27 @@ const namespaceData = {
};
describe('Nacos service group navigation', () => {
it('recovers configured namespace discovery mode from preserved children after a root rebuild', () => {
expect(resolveNacosNamespaceDiscoveryModeFromTreeNode({
type: 'connection',
dataRef: {
id: 'nacos-1',
config: { type: 'nacos' },
},
children: [
{
title: 'Development',
key: 'nacos-1-nacos-ns-dev',
type: 'nacos-namespace',
dataRef: {
id: 'nacos-1',
nacosNamespaceDiscoveryMode: 'configured',
},
},
],
})).toBe('configured');
});
it('keeps the service explorer entry as a lazy folder on double click', () => {
const entryNode = {
type: 'nacos-services-entry' as const,
@@ -63,4 +85,20 @@ describe('Nacos service group navigation', () => {
}),
});
});
it('does not collide when a namespace contains the group delimiter', () => {
const allServices = buildNacosServicesTabData({
id: 'nacos-1',
nacosNamespaceId: 'dev-g-orders',
nacosNamespaceName: 'Combined namespace',
});
const groupServices = buildNacosServicesTabData({
id: 'nacos-1',
nacosNamespaceId: 'dev',
nacosNamespaceName: 'Development',
nacosGroup: 'orders',
});
expect(allServices.id).not.toBe(groupServices.id);
});
});

View File

@@ -99,15 +99,43 @@ type NacosServicesTabDataRef = {
nacosGroup?: unknown;
};
export type NacosNamespaceDiscoveryMode = 'listed' | 'configured';
export const resolveNacosNamespaceDiscoveryModeFromTreeNode = (
node: Partial<SidebarTreeNode> | null | undefined,
): NacosNamespaceDiscoveryMode | undefined => {
if (!node) return undefined;
const pending = [node];
let foundListedMode = false;
while (pending.length > 0) {
const current = pending.pop();
const mode = current?.dataRef?.nacosNamespaceDiscoveryMode;
if (mode === 'configured') return 'configured';
if (mode === 'listed') foundListedMode = true;
if (Array.isArray(current?.children)) {
pending.push(...current.children);
}
}
return foundListedMode ? 'listed' : undefined;
};
const encodeNacosServicesTabIdPart = (value: string): string =>
encodeURIComponent(value)
.split('-ns-').join('%2Dns%2D')
.split('-g-').join('%2Dg%2D');
export const buildNacosServicesTabData = (dataRef: NacosServicesTabDataRef): TabData => {
const connectionId = String(dataRef?.id || '').trim();
const namespaceId = String(dataRef?.nacosNamespaceId || '').trim();
const namespaceName = String(dataRef?.nacosNamespaceName || namespaceId || 'public').trim();
const groupName = String(dataRef?.nacosGroup || '').trim();
const namespaceKey = namespaceId || 'public';
const connectionTabKey = encodeNacosServicesTabIdPart(connectionId);
const namespaceTabKey = encodeNacosServicesTabIdPart(namespaceKey);
const groupTabKey = encodeNacosServicesTabIdPart(groupName);
return {
id: `nacos-services-${connectionId}-ns-${namespaceKey}${groupName ? `-g-${encodeURIComponent(groupName)}` : ''}`,
id: `nacos-services-${connectionTabKey}-ns-${namespaceTabKey}${groupName ? `-g-${groupTabKey}` : ''}`,
title: groupName
? `${namespaceName} · ${groupName}`
: `${namespaceName} · ${t('nacos_service.title.service_explorer')}`,

View File

@@ -1397,6 +1397,225 @@ body[data-ui-version="v2"] .gn-v2-nacos-detail-pane .ant-table-tbody > tr.ant-ta
background: var(--gn-bg-hover) !important;
}
/* ─── Nacos service discovery: full-height list + endpoint inspector ─── */
.gn-nacos-service-list-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.gn-nacos-service-list-scroll {
flex: 1 1 0;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.gn-nacos-service-list-footer {
flex: 0 0 auto;
min-height: 48px;
box-sizing: border-box;
padding: 9px 2px 2px;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
column-gap: 8px;
row-gap: 8px;
}
.gn-nacos-service-list-footer__summary {
flex: 1 1 auto;
min-width: 112px;
margin-inline-end: auto;
font-size: 12px;
line-height: 28px;
white-space: nowrap;
}
.gn-nacos-service-list-footer .ant-pagination {
flex: 0 0 auto;
margin: 0 !important;
}
.gn-nacos-instance-inspector {
container-name: gn-nacos-instance-inspector;
container-type: inline-size;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.gn-nacos-instance-inspector__empty {
min-height: 180px;
display: grid;
place-items: center;
color: var(--gn-fg-4, inherit);
font-size: 13px;
}
.gn-nacos-instance-row {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 20px;
min-width: 0;
padding: 16px 4px 16px 8px;
border-bottom: 1px solid var(--gn-br-1, rgba(15, 23, 42, 0.08));
}
.gn-nacos-instance-row:first-child {
border-top: 1px solid var(--gn-br-1, rgba(15, 23, 42, 0.08));
}
.gn-nacos-instance-row__main {
min-width: 0;
display: grid;
grid-template-columns: minmax(180px, 1fr) auto;
align-items: center;
gap: 13px 20px;
}
.gn-nacos-instance-row__identity {
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
}
.gn-nacos-instance-row__identity .ant-tag {
flex: 0 0 auto;
margin: 0;
}
.gn-nacos-instance-row__health-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 12%, transparent);
}
.gn-nacos-instance-row__health-dot--healthy {
color: var(--gn-status-connected, #15803d);
background: currentColor;
}
.gn-nacos-instance-row__health-dot--unhealthy {
color: var(--gn-danger, #dc2626);
background: currentColor;
}
.gn-nacos-instance-row__endpoint {
min-width: 0;
overflow: hidden;
color: var(--gn-fg-1, inherit);
font-family: var(--gn-font-mono, ui-monospace, monospace);
font-size: 14px;
font-weight: 650;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.gn-nacos-instance-row__health-control {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
color: var(--gn-fg-3, inherit);
font-size: 12px;
white-space: nowrap;
}
.gn-nacos-instance-row__metadata {
grid-column: 1 / -1;
min-width: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(3, minmax(88px, 1fr));
gap: 8px 18px;
}
.gn-nacos-instance-row__metadata > div {
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: baseline;
gap: 7px;
}
.gn-nacos-instance-row__metadata dt {
color: var(--gn-fg-4, inherit);
font-size: 11px;
line-height: 1.4;
}
.gn-nacos-instance-row__metadata dd {
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--gn-fg-2, inherit);
font-family: var(--gn-font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.gn-nacos-instance-row__actions {
align-self: stretch;
display: flex;
align-items: center;
gap: 8px;
padding-inline-start: 16px;
border-inline-start: 1px solid var(--gn-br-1, rgba(15, 23, 42, 0.08));
}
body[data-ui-version="v2"] .gn-nacos-instance-row:hover {
background: color-mix(in srgb, var(--gn-bg-hover) 72%, transparent);
}
body[data-ui-version="v2"] .gn-nacos-service-table .ant-table-tbody > tr {
cursor: pointer;
}
body[data-ui-version="v2"] .gn-nacos-service-list-footer {
color: var(--gn-fg-4);
}
@container gn-nacos-instance-inspector (max-width: 620px) {
.gn-nacos-instance-row {
grid-template-columns: minmax(0, 1fr);
row-gap: 14px;
}
.gn-nacos-instance-row__actions {
min-height: 34px;
justify-content: flex-end;
padding: 12px 0 0;
border-inline-start: 0;
border-top: 1px solid var(--gn-br-1, rgba(15, 23, 42, 0.08));
}
}
@container gn-nacos-instance-inspector (max-width: 440px) {
.gn-nacos-instance-row__main {
grid-template-columns: minmax(0, 1fr);
}
.gn-nacos-instance-row__health-control {
justify-content: flex-start;
}
.gn-nacos-instance-row__metadata {
grid-template-columns: minmax(0, 1fr);
}
}
/* ─── V2 Table Overview ─ */
.gn-table-overview {
container-name: gn-table-overview;