mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
✨ feat(nacos-workbench): 优化配置管理与服务发现交互
- 固定服务列表分页到底部并重构端点优先的实例布局 - 支持 IPv6 端点和响应式实例操作区 - 修复详情乱序、监听启动竞态与重复远端变更提示 - 使用结构化索引处理配置导入选择并避免标签页冲突
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user