mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
✨ feat(nacos): 完善分组服务发现交互
- 在服务发现树中按 Group 展示入口并向工作台传递初始筛选 - 统一解析分组服务名并在列表中展示服务与所属 Group - 防止快速切换服务时旧实例响应覆盖当前选择 - 服务增删后刷新对应命名空间的侧栏分组并保护强制刷新顺序 - 补充服务路由、分组菜单、侧栏刷新和实例竞态测试
This commit is contained in:
239
frontend/src/components/NacosServiceViewer.interaction.test.tsx
Normal file
239
frontend/src/components/NacosServiceViewer.interaction.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import NacosServiceViewer from './NacosServiceViewer';
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [{
|
||||
id: 'nacos-1',
|
||||
name: 'nacos',
|
||||
config: { type: 'nacos', host: '127.0.0.1', port: 8848 },
|
||||
}],
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
uiVersion: 'v2',
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
useNativeMacWindowControls: false,
|
||||
},
|
||||
}));
|
||||
|
||||
const nacosBackend = vi.hoisted(() => ({
|
||||
NacosListServices: vi.fn(),
|
||||
NacosListInstances: vi.fn(),
|
||||
NacosDeleteService: vi.fn(),
|
||||
}));
|
||||
|
||||
const antdState = vi.hoisted(() => ({
|
||||
tableProps: [] as any[],
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('../i18n/provider', () => ({
|
||||
useOptionalI18n: () => ({ language: 'en-US' }),
|
||||
}));
|
||||
|
||||
vi.mock('./RedisResizableDivider', async () => {
|
||||
const ReactModule = await import('react');
|
||||
return { default: () => ReactModule.createElement('nacos-divider') };
|
||||
});
|
||||
|
||||
vi.mock('@ant-design/icons', async () => {
|
||||
const ReactModule = await import('react');
|
||||
const Icon = () => ReactModule.createElement('span', { 'data-icon': true });
|
||||
return {
|
||||
DeleteOutlined: Icon,
|
||||
PlusOutlined: Icon,
|
||||
ReloadOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd', async () => {
|
||||
const ReactModule = await import('react');
|
||||
const passthrough = (tag: string) => ({ children, ...props }: any) =>
|
||||
ReactModule.createElement(tag, props, children);
|
||||
const Form = Object.assign(
|
||||
passthrough('form'),
|
||||
{
|
||||
Item: passthrough('form-item'),
|
||||
useForm: () => [{
|
||||
validateFields: vi.fn(),
|
||||
resetFields: vi.fn(),
|
||||
setFieldsValue: vi.fn(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
Button: ({ children, ...props }: any) => ReactModule.createElement('button', props, children),
|
||||
Form,
|
||||
Input: (props: any) => ReactModule.createElement('input', props),
|
||||
InputNumber: (props: any) => ReactModule.createElement('input-number', props),
|
||||
Modal: ({ open, children, ...props }: any) => open
|
||||
? ReactModule.createElement('modal', props, children)
|
||||
: null,
|
||||
Popconfirm: ({ children, ...props }: any) =>
|
||||
ReactModule.createElement('popconfirm', props, children),
|
||||
Space: passthrough('space'),
|
||||
Switch: (props: any) => ReactModule.createElement('switch-control', props),
|
||||
Table: (props: any) => {
|
||||
antdState.tableProps.push(props);
|
||||
return ReactModule.createElement('nacos-table');
|
||||
},
|
||||
Tag: passthrough('tag'),
|
||||
message: antdState.message,
|
||||
};
|
||||
});
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
const deferred = <T,>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const flushEffects = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const latestServiceTableProps = () =>
|
||||
[...antdState.tableProps].reverse().find((props) => props.pagination !== false);
|
||||
|
||||
const latestInstanceTableProps = () =>
|
||||
[...antdState.tableProps].reverse().find((props) => props.pagination === false);
|
||||
|
||||
describe('NacosServiceViewer instance request ordering', () => {
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
antdState.tableProps = [];
|
||||
nacosBackend.NacosListServices.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
count: 3,
|
||||
pageNo: 1,
|
||||
pageSize: 50,
|
||||
serviceNames: ['GROUP_A@@alpha', 'GROUP_B@@beta', 'GROUP_C@@charlie'],
|
||||
},
|
||||
});
|
||||
nacosBackend.NacosDeleteService.mockResolvedValue({ success: true });
|
||||
vi.stubGlobal('window', {
|
||||
go: { app: { App: nacosBackend } },
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
renderer?.unmount();
|
||||
renderer = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('clears stale instances and ignores responses from an older service selection', async () => {
|
||||
const betaResponse = deferred<any>();
|
||||
const charlieResponse = deferred<any>();
|
||||
nacosBackend.NacosListInstances.mockImplementation(
|
||||
async (_config: unknown, payload: { serviceName: string }) => {
|
||||
if (payload.serviceName === 'alpha') {
|
||||
return {
|
||||
success: true,
|
||||
data: { hosts: [{ ip: '10.0.0.1', port: 8080, healthy: true, enabled: true, ephemeral: true }] },
|
||||
};
|
||||
}
|
||||
if (payload.serviceName === 'beta') return betaResponse.promise;
|
||||
return charlieResponse.promise;
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<NacosServiceViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
|
||||
);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const serviceTable = latestServiceTableProps();
|
||||
await act(async () => {
|
||||
serviceTable.onRow(serviceTable.dataSource[0]).onClick();
|
||||
});
|
||||
await flushEffects();
|
||||
expect(latestInstanceTableProps().dataSource).toEqual([
|
||||
expect.objectContaining({ ip: '10.0.0.1' }),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
serviceTable.onRow(serviceTable.dataSource[1]).onClick();
|
||||
});
|
||||
expect(latestInstanceTableProps().dataSource).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
serviceTable.onRow(serviceTable.dataSource[2]).onClick();
|
||||
});
|
||||
charlieResponse.resolve({
|
||||
success: true,
|
||||
data: { hosts: [{ ip: '10.0.0.3', port: 8080, healthy: true, enabled: true, ephemeral: true }] },
|
||||
});
|
||||
await flushEffects();
|
||||
expect(latestInstanceTableProps().dataSource).toEqual([
|
||||
expect.objectContaining({ ip: '10.0.0.3' }),
|
||||
]);
|
||||
expect(latestInstanceTableProps().loading).toBe(false);
|
||||
|
||||
betaResponse.resolve({
|
||||
success: true,
|
||||
data: { hosts: [{ ip: '10.0.0.2', port: 8080, healthy: true, enabled: true, ephemeral: true }] },
|
||||
});
|
||||
await flushEffects();
|
||||
expect(latestInstanceTableProps().dataSource).toEqual([
|
||||
expect.objectContaining({ ip: '10.0.0.3' }),
|
||||
]);
|
||||
expect(latestInstanceTableProps().loading).toBe(false);
|
||||
|
||||
});
|
||||
|
||||
it('notifies the sidebar after a service is deleted', async () => {
|
||||
nacosBackend.NacosListInstances.mockResolvedValue({ success: true, data: { hosts: [] } });
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<NacosServiceViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
|
||||
);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const serviceTable = latestServiceTableProps();
|
||||
const deleteAction = serviceTable.columns[1].render(undefined, serviceTable.dataSource[0]);
|
||||
await act(async () => {
|
||||
deleteAction.props.onConfirm();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
const event = vi.mocked(window.dispatchEvent).mock.calls[0][0] as CustomEvent;
|
||||
expect(event.type).toBe('gonavi:nacos-services-changed');
|
||||
expect(event.detail).toEqual({ connectionId: 'nacos-1', namespaceId: 'dev' });
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { t, type I18nParams } from '../i18n';
|
||||
import { useOptionalI18n } from '../i18n/provider';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { parseNacosServiceName } from './nacosServiceName';
|
||||
|
||||
type ServicePage = {
|
||||
count: number;
|
||||
@@ -65,24 +66,22 @@ type NacosServiceViewerProps = {
|
||||
connectionId: string;
|
||||
namespaceId: string;
|
||||
namespaceName?: string;
|
||||
initialGroup?: string;
|
||||
};
|
||||
|
||||
const parseServiceName = (raw: string): { groupName: string; serviceName: string } => {
|
||||
const text = String(raw || '').trim();
|
||||
if (text.includes('@@')) {
|
||||
const [groupName, serviceName] = text.split('@@');
|
||||
return {
|
||||
groupName: groupName || 'DEFAULT_GROUP',
|
||||
serviceName: serviceName || text,
|
||||
};
|
||||
}
|
||||
return { groupName: 'DEFAULT_GROUP', serviceName: text };
|
||||
type NacosServiceRow = {
|
||||
rawName: string;
|
||||
serviceName: string;
|
||||
groupName: string;
|
||||
};
|
||||
|
||||
const NACOS_SERVICES_CHANGED_EVENT = 'gonavi:nacos-services-changed';
|
||||
|
||||
const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
connectionId,
|
||||
namespaceId,
|
||||
namespaceName,
|
||||
initialGroup,
|
||||
}) => {
|
||||
const connections = useStore((state) => state.connections);
|
||||
const appTheme = useStore((state) => state.theme);
|
||||
@@ -144,7 +143,7 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
const [serviceTotal, setServiceTotal] = useState(0);
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [pageSize] = useState(50);
|
||||
const [groupFilter, setGroupFilter] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState(() => String(initialGroup || '').trim());
|
||||
const [selectedServiceRaw, setSelectedServiceRaw] = useState<string | null>(null);
|
||||
const [instances, setInstances] = useState<NacosInstance[]>([]);
|
||||
|
||||
@@ -156,11 +155,24 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
// Left service list pane width; drag divider to adjust (same pattern as Redis).
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('38%');
|
||||
const leftPanelRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRequestIdRef = useRef(0);
|
||||
|
||||
const selectedParsed = useMemo(
|
||||
() => (selectedServiceRaw ? parseServiceName(selectedServiceRaw) : null),
|
||||
() => (selectedServiceRaw ? parseNacosServiceName(selectedServiceRaw) : null),
|
||||
[selectedServiceRaw],
|
||||
);
|
||||
const serviceRows = useMemo<NacosServiceRow[]>(
|
||||
() => serviceNames.map((rawName) => ({ rawName, ...parseNacosServiceName(rawName) })),
|
||||
[serviceNames],
|
||||
);
|
||||
const notifyServiceGroupsChanged = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent(NACOS_SERVICES_CHANGED_EVENT, {
|
||||
detail: {
|
||||
connectionId,
|
||||
namespaceId: namespaceId || '',
|
||||
},
|
||||
}));
|
||||
}, [connectionId, namespaceId]);
|
||||
|
||||
const loadServices = useCallback(
|
||||
async (page = 1) => {
|
||||
@@ -183,8 +195,10 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
setServiceTotal(Number(pageData.count) || names.length);
|
||||
setPageNo(Number(pageData.pageNo) || page);
|
||||
if (selectedServiceRaw && !names.includes(selectedServiceRaw)) {
|
||||
instanceRequestIdRef.current += 1;
|
||||
setSelectedServiceRaw(null);
|
||||
setInstances([]);
|
||||
setLoadingInstances(false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || String(error));
|
||||
@@ -198,7 +212,8 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
const loadInstances = useCallback(
|
||||
async (rawServiceName: string) => {
|
||||
if (!rpcConfig) return;
|
||||
const parsed = parseServiceName(rawServiceName);
|
||||
const parsed = parseNacosServiceName(rawServiceName);
|
||||
const requestId = ++instanceRequestIdRef.current;
|
||||
setLoadingInstances(true);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.NacosListInstances(rpcConfig, {
|
||||
@@ -206,6 +221,7 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
serviceName: parsed.serviceName,
|
||||
groupName: parsed.groupName,
|
||||
});
|
||||
if (requestId !== instanceRequestIdRef.current) return;
|
||||
if (!res?.success) {
|
||||
message.error(res?.message || 'list instances failed');
|
||||
return;
|
||||
@@ -213,16 +229,26 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
const list = (res.data || {}) as InstanceList;
|
||||
setInstances(Array.isArray(list.hosts) ? list.hosts : []);
|
||||
} catch (error: any) {
|
||||
if (requestId !== instanceRequestIdRef.current) return;
|
||||
message.error(error?.message || String(error));
|
||||
} finally {
|
||||
setLoadingInstances(false);
|
||||
if (requestId === instanceRequestIdRef.current) {
|
||||
setLoadingInstances(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[rpcConfig, namespaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
instanceRequestIdRef.current += 1;
|
||||
setSelectedServiceRaw(null);
|
||||
setInstances([]);
|
||||
setLoadingInstances(false);
|
||||
void loadServices(1);
|
||||
return () => {
|
||||
instanceRequestIdRef.current += 1;
|
||||
};
|
||||
}, [connectionId, namespaceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleCreateService = async () => {
|
||||
@@ -240,6 +266,7 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
return;
|
||||
}
|
||||
message.success(tr('nacos_service.message.service_create_success'));
|
||||
notifyServiceGroupsChanged();
|
||||
setServiceModalOpen(false);
|
||||
serviceForm.resetFields();
|
||||
await loadServices(1);
|
||||
@@ -251,7 +278,7 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
|
||||
const handleDeleteService = async (raw: string) => {
|
||||
if (!rpcConfig || structureRestricted) return;
|
||||
const parsed = parseServiceName(raw);
|
||||
const parsed = parseNacosServiceName(raw);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.NacosDeleteService(
|
||||
rpcConfig,
|
||||
@@ -264,9 +291,12 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
return;
|
||||
}
|
||||
message.success(tr('nacos_service.message.service_delete_success'));
|
||||
notifyServiceGroupsChanged();
|
||||
if (selectedServiceRaw === raw) {
|
||||
instanceRequestIdRef.current += 1;
|
||||
setSelectedServiceRaw(null);
|
||||
setInstances([]);
|
||||
setLoadingInstances(false);
|
||||
}
|
||||
await loadServices(pageNo);
|
||||
} catch (error: any) {
|
||||
@@ -478,8 +508,8 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
<Table
|
||||
size="small"
|
||||
loading={loadingServices}
|
||||
rowKey={(row) => row.name}
|
||||
dataSource={serviceNames.map((name) => ({ name }))}
|
||||
rowKey={(row) => row.rawName}
|
||||
dataSource={serviceRows}
|
||||
pagination={{
|
||||
current: pageNo,
|
||||
pageSize,
|
||||
@@ -489,30 +519,54 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
setSelectedServiceRaw(record.name);
|
||||
void loadInstances(record.name);
|
||||
setSelectedServiceRaw(record.rawName);
|
||||
setInstances([]);
|
||||
void loadInstances(record.rawName);
|
||||
},
|
||||
})}
|
||||
rowClassName={(record) =>
|
||||
selectedServiceRaw === record.name ? 'ant-table-row-selected' : ''
|
||||
selectedServiceRaw === record.rawName ? 'ant-table-row-selected' : ''
|
||||
}
|
||||
scroll={{ y: 'calc(100vh - 280px)' }}
|
||||
columns={[
|
||||
{
|
||||
title: tr('nacos_service.field.service'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
dataIndex: 'serviceName',
|
||||
key: 'serviceName',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: NacosServiceRow) => (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
title={row.serviceName}
|
||||
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{row.serviceName}
|
||||
</div>
|
||||
<div
|
||||
title={row.groupName}
|
||||
style={{
|
||||
marginTop: 2,
|
||||
color: workbenchTheme.textMuted,
|
||||
fontSize: 12,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{row.groupName}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: tr('nacos_viewer.action.delete'),
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (_: unknown, row: { name: string }) => (
|
||||
render: (_: unknown, row: NacosServiceRow) => (
|
||||
<Popconfirm
|
||||
title={tr('nacos_service.message.confirm_delete_service', { name: row.name })}
|
||||
title={tr('nacos_service.message.confirm_delete_service', { name: row.rawName })}
|
||||
disabled={structureRestricted}
|
||||
onConfirm={() => void handleDeleteService(row.name)}
|
||||
onConfirm={() => void handleDeleteService(row.rawName)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -199,6 +199,7 @@ import {
|
||||
resolveV2ConnectionGroup,
|
||||
resolveV2ActiveConnectionId,
|
||||
resolveV2CommandSearchPersistentFilter,
|
||||
resolveNacosServicesDoubleClickAction,
|
||||
shouldClearSidebarNodeChildrenOnCollapse,
|
||||
shouldSkipSidebarLoadOnExpandWhileDragging,
|
||||
shouldSkipSidebarSelectWhileDragging,
|
||||
@@ -293,6 +294,42 @@ const { Search } = Input;
|
||||
const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50;
|
||||
const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;
|
||||
const SIDEBAR_CACHED_DATABASE_TREE_LIMIT = 12;
|
||||
const NACOS_SERVICES_CHANGED_EVENT = 'gonavi:nacos-services-changed';
|
||||
|
||||
type NacosServiceRefreshTreeNode = {
|
||||
key: React.Key;
|
||||
children?: NacosServiceRefreshTreeNode[];
|
||||
};
|
||||
|
||||
export const resolveNacosServiceGroupsRefreshTarget = (
|
||||
detail: unknown,
|
||||
treeData: readonly NacosServiceRefreshTreeNode[],
|
||||
expandedKeys: readonly React.Key[],
|
||||
): { key: string; node: NacosServiceRefreshTreeNode; shouldReload: boolean } | null => {
|
||||
if (!detail || typeof detail !== 'object') return null;
|
||||
const eventDetail = detail as Record<string, unknown>;
|
||||
const connectionId = String(eventDetail.connectionId || '').trim();
|
||||
if (!connectionId) return null;
|
||||
const namespaceId = String(eventDetail.namespaceId ?? '').trim();
|
||||
const key = `${connectionId}-nacos-ns-${namespaceId || 'public'}-services`;
|
||||
|
||||
const findNode = (nodes: readonly NacosServiceRefreshTreeNode[]): NacosServiceRefreshTreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (String(node.key) === key) return node;
|
||||
const child = node.children?.length ? findNode(node.children) : null;
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const node = findNode(treeData);
|
||||
if (!node) return null;
|
||||
return {
|
||||
key,
|
||||
node,
|
||||
shouldReload: expandedKeys.some((expandedKey) => String(expandedKey) === key),
|
||||
};
|
||||
};
|
||||
|
||||
// resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers
|
||||
|
||||
@@ -688,6 +725,7 @@ const Sidebar: React.FC<{
|
||||
const deferredV2CommandSearchValue = useDeferredValue(v2CommandSearchValue);
|
||||
const [v2CommandActiveIndex, setV2CommandActiveIndex] = useState(0);
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
const expandedKeysRef = useRef<React.Key[]>([]);
|
||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||||
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
@@ -695,6 +733,15 @@ const Sidebar: React.FC<{
|
||||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||||
const databaseTreeTouchedAtRef = useRef<Record<string, number>>({});
|
||||
const pruneLoadedDatabaseTreesRef = useRef<() => void>(() => {});
|
||||
const loadNacosServiceGroupsRef = useRef<(
|
||||
node: any,
|
||||
options?: { force?: boolean },
|
||||
) => Promise<boolean>>(async () => false);
|
||||
const replaceTreeNodeChildrenRef = useRef<(
|
||||
key: React.Key,
|
||||
children: TreeNode[] | undefined,
|
||||
dataRef?: unknown,
|
||||
) => TreeNode[]>(() => []);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const treeDragSelectSuppressUntilRef = useRef(0);
|
||||
const treeDragSelectionSnapshotRef = useRef<{
|
||||
@@ -707,6 +754,7 @@ const Sidebar: React.FC<{
|
||||
activeContext: null,
|
||||
});
|
||||
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
|
||||
expandedKeysRef.current = expandedKeys;
|
||||
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
|
||||
const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]);
|
||||
const unmatchedSavedQueries = useMemo(
|
||||
@@ -1532,6 +1580,8 @@ const Sidebar: React.FC<{
|
||||
await loadTables({ key, dataRef });
|
||||
} else if (type === 'nacos-config-entry') {
|
||||
await loadNacosConfigGroups({ key, dataRef });
|
||||
} else if (type === 'nacos-services-entry') {
|
||||
await loadNacosServiceGroups({ key, dataRef });
|
||||
} else if (type === 'external-sql-root') {
|
||||
await refreshGlobalExternalSQLRootNode(false);
|
||||
} else if (type === 'table') {
|
||||
@@ -1727,6 +1777,7 @@ const Sidebar: React.FC<{
|
||||
|| type === 'nacos-config-entry'
|
||||
|| type === 'nacos-config-group'
|
||||
|| type === 'nacos-services-entry'
|
||||
|| type === 'nacos-service-group'
|
||||
) {
|
||||
setActiveContext({
|
||||
connectionId: dataRef.id,
|
||||
@@ -1811,6 +1862,7 @@ const Sidebar: React.FC<{
|
||||
|| type === 'nacos-config-entry'
|
||||
|| type === 'nacos-config-group'
|
||||
|| type === 'nacos-services-entry'
|
||||
|| type === 'nacos-service-group'
|
||||
) {
|
||||
setActiveContext({
|
||||
connectionId: dataRef.id,
|
||||
@@ -1899,23 +1951,13 @@ const Sidebar: React.FC<{
|
||||
...(isAll ? {} : { nacosGroup: groupName }),
|
||||
});
|
||||
return;
|
||||
} else if (node.type === 'nacos-services-entry') {
|
||||
const {
|
||||
id,
|
||||
nacosNamespaceId = '',
|
||||
nacosNamespaceName = '',
|
||||
} = node.dataRef || {};
|
||||
const nsName = nacosNamespaceName || nacosNamespaceId || 'public';
|
||||
const nsKey = nacosNamespaceId || 'public';
|
||||
addTab({
|
||||
id: `nacos-services-${id}-ns-${nsKey}`,
|
||||
title: `${nsName} · services`,
|
||||
type: 'nacos-services',
|
||||
connectionId: id,
|
||||
nacosNamespaceId: nacosNamespaceId || '',
|
||||
nacosNamespaceName: nsName,
|
||||
});
|
||||
return;
|
||||
} else if (node.type === 'nacos-services-entry' || node.type === 'nacos-service-group') {
|
||||
const action = resolveNacosServicesDoubleClickAction(node);
|
||||
if (action?.kind === 'open') {
|
||||
addTab(action.tab);
|
||||
return;
|
||||
}
|
||||
// Service explorer entry is a folder: fall through to expand/collapse + lazy load groups.
|
||||
} else if (node.type === 'db-trigger') {
|
||||
const { triggerName, triggerTableName, schemaName, dbName, id } = node.dataRef;
|
||||
addTab({
|
||||
@@ -2159,6 +2201,7 @@ const Sidebar: React.FC<{
|
||||
loadJVMResources,
|
||||
loadTables,
|
||||
loadNacosConfigGroups,
|
||||
loadNacosServiceGroups,
|
||||
} = useSidebarTreeLoaders({
|
||||
savedQueries,
|
||||
tableSortPreference,
|
||||
@@ -2178,6 +2221,35 @@ const Sidebar: React.FC<{
|
||||
pruneLoadedDatabaseTrees();
|
||||
},
|
||||
});
|
||||
loadNacosServiceGroupsRef.current = loadNacosServiceGroups;
|
||||
replaceTreeNodeChildrenRef.current = replaceTreeNodeChildren;
|
||||
|
||||
useEffect(() => {
|
||||
const handleNacosServicesChanged = (event: Event) => {
|
||||
const target = resolveNacosServiceGroupsRefreshTarget(
|
||||
(event as CustomEvent).detail,
|
||||
treeDataRef.current,
|
||||
expandedKeysRef.current,
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
replaceTreeNodeChildrenRef.current(target.key, undefined);
|
||||
setLoadedKeys((prev) => prev.filter((key) => String(key) !== target.key));
|
||||
if (!target.shouldReload) return;
|
||||
|
||||
void loadNacosServiceGroupsRef.current(
|
||||
{ ...target.node, children: undefined },
|
||||
{ force: true },
|
||||
).then((loaded) => {
|
||||
if (!loaded) return;
|
||||
setLoadedKeys((prev) => prev.includes(target.key) ? prev : [...prev, target.key]);
|
||||
});
|
||||
};
|
||||
window.addEventListener(NACOS_SERVICES_CHANGED_EVENT, handleNacosServicesChanged as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(NACOS_SERVICES_CHANGED_EVENT, handleNacosServicesChanged as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openSchemaVisibilitySettings = useCallback((node: any) => {
|
||||
const dbName = String(node?.dataRef?.dbName || node?.title || '').trim();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { setCurrentLanguage } from '../i18n';
|
||||
import {
|
||||
formatSidebarDriverAgentUpdateWarning,
|
||||
formatSidebarRowCount,
|
||||
resolveNacosServiceGroupsRefreshTarget,
|
||||
} from './Sidebar';
|
||||
|
||||
describe('Sidebar v2 metadata', () => {
|
||||
@@ -37,4 +38,50 @@ describe('Sidebar v2 metadata', () => {
|
||||
}),
|
||||
).toBe('ClickHouse 驱动代理需要重装');
|
||||
});
|
||||
|
||||
it('targets the matching expanded Nacos service group cache for immediate reload', () => {
|
||||
const serviceNode = {
|
||||
key: 'nacos-1-nacos-ns-dev-services',
|
||||
children: [{ key: 'nacos-1-nacos-ns-dev-service-group-GROUP_A' }],
|
||||
};
|
||||
const treeData = [{
|
||||
key: 'nacos-1',
|
||||
children: [{
|
||||
key: 'nacos-1-nacos-ns-dev',
|
||||
children: [serviceNode],
|
||||
}],
|
||||
}];
|
||||
|
||||
expect(resolveNacosServiceGroupsRefreshTarget(
|
||||
{ connectionId: 'nacos-1', namespaceId: 'dev' },
|
||||
treeData,
|
||||
['nacos-1-nacos-ns-dev-services'],
|
||||
)).toEqual({
|
||||
key: 'nacos-1-nacos-ns-dev-services',
|
||||
node: serviceNode,
|
||||
shouldReload: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidates a collapsed public service group cache without reloading it', () => {
|
||||
const serviceNode = {
|
||||
key: 'nacos-1-nacos-ns-public-services',
|
||||
children: [{ key: 'nacos-1-nacos-ns-public-service-group-DEFAULT_GROUP' }],
|
||||
};
|
||||
|
||||
expect(resolveNacosServiceGroupsRefreshTarget(
|
||||
{ connectionId: 'nacos-1', namespaceId: '' },
|
||||
[{ key: 'nacos-1', children: [serviceNode] }],
|
||||
[],
|
||||
)).toEqual({
|
||||
key: 'nacos-1-nacos-ns-public-services',
|
||||
node: serviceNode,
|
||||
shouldReload: false,
|
||||
});
|
||||
expect(resolveNacosServiceGroupsRefreshTarget(
|
||||
{ connectionId: 'nacos-2', namespaceId: '' },
|
||||
[{ key: 'nacos-1', children: [serviceNode] }],
|
||||
[],
|
||||
)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TabData } from '../types';
|
||||
import WorkbenchTabContent from './WorkbenchTabContent';
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Spin: () => <span data-spin="true" />,
|
||||
}));
|
||||
|
||||
vi.mock('./NacosServiceViewer', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div
|
||||
data-nacos-services-viewer="true"
|
||||
data-connection-id={props.connectionId}
|
||||
data-namespace-id={props.namespaceId}
|
||||
data-initial-group={props.initialGroup}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('WorkbenchTabContent Nacos service routing', () => {
|
||||
it('passes the tab group to the service viewer as its initial filter', async () => {
|
||||
const tab: TabData = {
|
||||
id: 'nacos-services-nacos-1-ns-mkefu-dev-g-MKEFU_SERVICE',
|
||||
title: 'mkefu development · MKEFU_SERVICE',
|
||||
type: 'nacos-services',
|
||||
connectionId: 'nacos-1',
|
||||
nacosNamespaceId: 'mkefu-dev',
|
||||
nacosNamespaceName: 'mkefu development',
|
||||
nacosGroup: 'MKEFU_SERVICE',
|
||||
};
|
||||
let renderer: TestRenderer.ReactTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(<WorkbenchTabContent tab={tab} isActive />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const viewer = renderer!.root.findByProps({ 'data-nacos-services-viewer': 'true' });
|
||||
expect(viewer.props['data-connection-id']).toBe('nacos-1');
|
||||
expect(viewer.props['data-namespace-id']).toBe('mkefu-dev');
|
||||
expect(viewer.props['data-initial-group']).toBe('MKEFU_SERVICE');
|
||||
act(() => renderer!.unmount());
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ export const WorkbenchTabContent: React.FC<WorkbenchTabContentProps> = React.mem
|
||||
connectionId={tab.connectionId}
|
||||
namespaceId={tab.nacosNamespaceId ?? ''}
|
||||
namespaceName={tab.nacosNamespaceName}
|
||||
initialGroup={tab.nacosGroup}
|
||||
/>
|
||||
);
|
||||
} else if (tab.type === 'trigger') {
|
||||
|
||||
82
frontend/src/components/nacosServiceName.test.ts
Normal file
82
frontend/src/components/nacosServiceName.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
collectNacosServiceGroupsByPage,
|
||||
NACOS_SERVICE_GROUP_PAGE_SIZE,
|
||||
parseNacosServiceName,
|
||||
} from './nacosServiceName';
|
||||
|
||||
describe('parseNacosServiceName', () => {
|
||||
it('keeps a non-default group separate from the service name', () => {
|
||||
expect(parseNacosServiceName('MKEFU_SERVICE@@mkefu-manage-service-http')).toEqual({
|
||||
groupName: 'MKEFU_SERVICE',
|
||||
serviceName: 'mkefu-manage-service-http',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses DEFAULT_GROUP for legacy bare service names', () => {
|
||||
expect(parseNacosServiceName('orders')).toEqual({
|
||||
groupName: 'DEFAULT_GROUP',
|
||||
serviceName: 'orders',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectNacosServiceGroupsByPage', () => {
|
||||
it('starts at page one with 500 rows and stops when the reported count is reached', async () => {
|
||||
const calls: Array<[number, number]> = [];
|
||||
const groups = await collectNacosServiceGroupsByPage(async (pageNo, pageSize) => {
|
||||
calls.push([pageNo, pageSize]);
|
||||
if (pageNo === 1) {
|
||||
return {
|
||||
count: 3,
|
||||
serviceNames: [
|
||||
'MKEFU_SERVICE@@mkefu-manage-service-http',
|
||||
'DEFAULT_GROUP@@orders',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
count: 3,
|
||||
serviceNames: ['MKEFU_SERVICE@@mkefu-comm-service-http'],
|
||||
};
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
[1, NACOS_SERVICE_GROUP_PAGE_SIZE],
|
||||
[2, NACOS_SERVICE_GROUP_PAGE_SIZE],
|
||||
]);
|
||||
expect(groups).toEqual(['DEFAULT_GROUP', 'MKEFU_SERVICE']);
|
||||
});
|
||||
|
||||
it('deduplicates and stably sorts groups until an empty page when count is absent', async () => {
|
||||
const calls: Array<[number, number]> = [];
|
||||
const groups = await collectNacosServiceGroupsByPage(async (pageNo, pageSize) => {
|
||||
calls.push([pageNo, pageSize]);
|
||||
if (pageNo === 1) {
|
||||
return {
|
||||
serviceNames: [
|
||||
'ZETA@@z-service',
|
||||
'MKEFU_SERVICE@@manage',
|
||||
'orders',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (pageNo === 2) {
|
||||
return {
|
||||
serviceNames: [
|
||||
'MKEFU_SERVICE@@comm',
|
||||
'',
|
||||
],
|
||||
};
|
||||
}
|
||||
return { serviceNames: [] };
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
[1, NACOS_SERVICE_GROUP_PAGE_SIZE],
|
||||
[2, NACOS_SERVICE_GROUP_PAGE_SIZE],
|
||||
[3, NACOS_SERVICE_GROUP_PAGE_SIZE],
|
||||
]);
|
||||
expect(groups).toEqual(['DEFAULT_GROUP', 'MKEFU_SERVICE', 'ZETA']);
|
||||
});
|
||||
});
|
||||
69
frontend/src/components/nacosServiceName.ts
Normal file
69
frontend/src/components/nacosServiceName.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type NacosServiceIdentity = {
|
||||
groupName: string;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
export type NacosServiceNamePage = {
|
||||
count?: number;
|
||||
serviceNames?: unknown[];
|
||||
};
|
||||
|
||||
export type NacosServiceNamePageFetcher = (
|
||||
pageNo: number,
|
||||
pageSize: number,
|
||||
) => Promise<NacosServiceNamePage | null | undefined>;
|
||||
|
||||
export const NACOS_SERVICE_GROUP_PAGE_SIZE = 500;
|
||||
|
||||
export const parseNacosServiceName = (raw: string): NacosServiceIdentity => {
|
||||
const text = String(raw || '').trim();
|
||||
const separator = text.indexOf('@@');
|
||||
if (separator >= 0) {
|
||||
const groupName = text.slice(0, separator).trim() || 'DEFAULT_GROUP';
|
||||
const serviceName = text.slice(separator + 2).trim();
|
||||
return { groupName, serviceName: serviceName || text };
|
||||
}
|
||||
return { groupName: 'DEFAULT_GROUP', serviceName: text };
|
||||
};
|
||||
|
||||
const sortNacosServiceGroups = (groups: Iterable<string>): string[] => (
|
||||
Array.from(groups).sort((left, right) => {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
})
|
||||
);
|
||||
|
||||
export const collectNacosServiceGroupsByPage = async (
|
||||
fetchPage: NacosServiceNamePageFetcher,
|
||||
pageSize = NACOS_SERVICE_GROUP_PAGE_SIZE,
|
||||
): Promise<string[]> => {
|
||||
const normalizedPageSize = Number.isFinite(pageSize) && pageSize > 0
|
||||
? Math.floor(pageSize)
|
||||
: NACOS_SERVICE_GROUP_PAGE_SIZE;
|
||||
const groups = new Set<string>();
|
||||
let pageNo = 1;
|
||||
let loadedServiceCount = 0;
|
||||
|
||||
while (true) {
|
||||
const page = await fetchPage(pageNo, normalizedPageSize);
|
||||
const serviceNames = Array.isArray(page?.serviceNames) ? page.serviceNames : [];
|
||||
|
||||
for (const rawName of serviceNames) {
|
||||
const identity = parseNacosServiceName(String(rawName ?? ''));
|
||||
if (identity.serviceName) {
|
||||
groups.add(identity.groupName);
|
||||
}
|
||||
}
|
||||
|
||||
loadedServiceCount += serviceNames.length;
|
||||
const total = Number(page?.count);
|
||||
const reachedTotal = Number.isFinite(total) && total >= 0 && loadedServiceCount >= total;
|
||||
if (serviceNames.length === 0 || reachedTotal) {
|
||||
break;
|
||||
}
|
||||
pageNo += 1;
|
||||
}
|
||||
|
||||
return sortNacosServiceGroups(groups);
|
||||
};
|
||||
@@ -335,7 +335,7 @@ export const parseV2CommandSearchQuery = (value: unknown): V2CommandSearchQuery
|
||||
|
||||
/**
|
||||
* shouldLoadSidebarNodeOnExpand 判断节点展开时是否需要懒加载子节点。
|
||||
* 仅 connection/database/external-sql-root/table/jvm-mode/jvm-resource 类型且无已加载 children 时返回 true。
|
||||
* 仅可懒加载的目录类型且无已加载 children 时返回 true。
|
||||
*/
|
||||
export const shouldLoadSidebarNodeOnExpand = (
|
||||
node: Pick<SidebarNodeLike, 'type' | 'children' | 'isLeaf'> | null | undefined,
|
||||
@@ -347,5 +347,6 @@ export const shouldLoadSidebarNodeOnExpand = (
|
||||
|| node.type === 'table'
|
||||
|| node.type === 'jvm-mode'
|
||||
|| node.type === 'jvm-resource'
|
||||
|| node.type === 'nacos-config-entry';
|
||||
|| node.type === 'nacos-config-entry'
|
||||
|| node.type === 'nacos-services-entry';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildSidebarLegacyNodeMenuItems } from './sidebarLegacyNodeMenu';
|
||||
|
||||
describe('Nacos service group context menu', () => {
|
||||
it('opens the selected service group with its group filter', () => {
|
||||
const addTab = vi.fn();
|
||||
const items = buildSidebarLegacyNodeMenuItems({
|
||||
type: 'nacos-service-group',
|
||||
dataRef: {
|
||||
id: 'nacos-1',
|
||||
nacosNamespaceId: 'mkefu-dev',
|
||||
nacosNamespaceName: 'mkefu development',
|
||||
nacosGroup: 'MKEFU_SERVICE',
|
||||
},
|
||||
}, { addTab }) as any[];
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.key).toBe('open-nacos-service-group');
|
||||
items[0]?.onClick?.();
|
||||
expect(addTab).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'nacos-services-nacos-1-ns-mkefu-dev-g-MKEFU_SERVICE',
|
||||
type: 'nacos-services',
|
||||
nacosGroup: 'MKEFU_SERVICE',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not attach a group filter to the all-services node', () => {
|
||||
const addTab = vi.fn();
|
||||
const items = buildSidebarLegacyNodeMenuItems({
|
||||
type: 'nacos-service-group',
|
||||
dataRef: {
|
||||
id: 'nacos-1',
|
||||
nacosNamespaceId: 'mkefu-dev',
|
||||
nacosNamespaceName: 'mkefu development',
|
||||
nacosGroup: '',
|
||||
},
|
||||
}, { addTab }) as any[];
|
||||
|
||||
items[0]?.onClick?.();
|
||||
const tab = addTab.mock.calls[0]?.[0];
|
||||
expect(tab?.id).toBe('nacos-services-nacos-1-ns-mkefu-dev');
|
||||
expect(tab).not.toHaveProperty('nacosGroup');
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||
import { supportsTableTruncateAction } from '../tableDataDangerActions';
|
||||
import { normalizeConnectionEnvironmentType } from '../../utils/connectionEnvironment';
|
||||
import { noAutoCapInputProps } from '../../utils/inputAutoCap';
|
||||
import { buildNacosServicesTabData } from '../sidebarV2Utils';
|
||||
|
||||
type NacosNamespaceFormMode = 'create' | 'edit';
|
||||
|
||||
@@ -918,6 +919,15 @@ export const buildSidebarLegacyNodeMenuItems = (
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (node.type === 'nacos-service-group') {
|
||||
return [
|
||||
{
|
||||
key: 'open-nacos-service-group',
|
||||
label: t('nacos_service.title.service_explorer'),
|
||||
icon: <CloudOutlined />,
|
||||
onClick: () => addTab(buildNacosServicesTabData(node.dataRef || {})),
|
||||
},
|
||||
];
|
||||
} else if (node.type === 'redis-db') {
|
||||
// Redis database menu
|
||||
const { id, redisDB } = node.dataRef;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useSidebarTreeLoaders } from './useSidebarTreeLoaders';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
replaceTreeNodeChildren: vi.fn(),
|
||||
setLoadedKeys: vi.fn(),
|
||||
storeState: {
|
||||
connections: [] as any[],
|
||||
tableSortPreference: {} as Record<string, string>,
|
||||
tableAccessCount: {} as Record<string, number>,
|
||||
pinnedSidebarTables: [] as string[],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../store', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../store')>('../../store');
|
||||
const useStore = Object.assign(vi.fn(), {
|
||||
getState: () => mocks.storeState,
|
||||
});
|
||||
return { ...actual, useStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||
DBGetDatabases: vi.fn(),
|
||||
DBGetTables: vi.fn(),
|
||||
DBQuery: vi.fn(),
|
||||
GetDriverStatusList: vi.fn(),
|
||||
JVMProbeCapabilities: vi.fn(),
|
||||
}));
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
const deferred = <T,>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('useSidebarTreeLoaders Nacos service groups', () => {
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.replaceTreeNodeChildren.mockImplementation((_key, children) => children || []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount());
|
||||
renderer = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps a forced refresh result when an older group request resolves later', async () => {
|
||||
const oldResponse = deferred<any>();
|
||||
const refreshedResponse = deferred<any>();
|
||||
const listServices = vi.fn()
|
||||
.mockReturnValueOnce(oldResponse.promise)
|
||||
.mockReturnValueOnce(refreshedResponse.promise);
|
||||
vi.stubGlobal('window', {
|
||||
go: { app: { App: { NacosListServices: listServices } } },
|
||||
});
|
||||
|
||||
let loaders: ReturnType<typeof useSidebarTreeLoaders> | undefined;
|
||||
const loadingNodesRef = { current: new Set<string>() };
|
||||
const Harness = () => {
|
||||
loaders = useSidebarTreeLoaders({
|
||||
savedQueries: [],
|
||||
tableSortPreference: {},
|
||||
tableAccessCount: {},
|
||||
pinnedSidebarTables: [],
|
||||
isV2Ui: true,
|
||||
loadingNodesRef,
|
||||
setConnectionStates: vi.fn(),
|
||||
setLoadedKeys: mocks.setLoadedKeys,
|
||||
replaceTreeNodeChildren: mocks.replaceTreeNodeChildren,
|
||||
buildRuntimeConfig: (conn) => conn.config,
|
||||
buildJVMRuntimeConfig: (conn) => conn.config,
|
||||
buildJVMDiagnosticTreeNodes: () => [],
|
||||
resolveSavedQueryDisplayName: (name) => String(name || ''),
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
renderer = create(<Harness />);
|
||||
});
|
||||
|
||||
const node = {
|
||||
key: 'nacos-1-nacos-ns-dev-services',
|
||||
dataRef: {
|
||||
id: 'nacos-1',
|
||||
nacosNamespaceId: 'dev',
|
||||
nacosNamespaceName: 'Development',
|
||||
config: { type: 'nacos', host: '127.0.0.1', port: 8848 },
|
||||
},
|
||||
};
|
||||
const oldLoad = loaders!.loadNacosServiceGroups(node);
|
||||
const refreshedLoad = loaders!.loadNacosServiceGroups(node, { force: true });
|
||||
|
||||
refreshedResponse.resolve({
|
||||
success: true,
|
||||
data: { count: 1, serviceNames: ['NEW_GROUP@@orders'] },
|
||||
});
|
||||
await act(async () => {
|
||||
expect(await refreshedLoad).toBe(true);
|
||||
});
|
||||
|
||||
expect(mocks.replaceTreeNodeChildren).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.replaceTreeNodeChildren.mock.calls[0][1].map((item: any) => item.dataRef.nacosGroup))
|
||||
.toEqual(['', 'NEW_GROUP']);
|
||||
|
||||
oldResponse.resolve({
|
||||
success: true,
|
||||
data: { count: 1, serviceNames: ['OLD_GROUP@@legacy'] },
|
||||
});
|
||||
await act(async () => {
|
||||
expect(await oldLoad).toBe(false);
|
||||
});
|
||||
|
||||
expect(mocks.replaceTreeNodeChildren).toHaveBeenCalledTimes(1);
|
||||
expect(loadingNodesRef.current.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from './sidebarPartitions';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities } from '../../../wailsjs/go/app/App';
|
||||
import type { SidebarTableMetadataSnapshot } from '../../utils/sidebarTableMetadata';
|
||||
import { collectNacosServiceGroupsByPage } from '../nacosServiceName';
|
||||
|
||||
type DriverStatusSnapshot = {
|
||||
type: string;
|
||||
@@ -188,6 +189,7 @@ export const useSidebarTreeLoaders = ({
|
||||
items: Record<string, DriverStatusSnapshot>;
|
||||
} | null>(null);
|
||||
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
|
||||
const nacosServiceGroupRequestIdsRef = useRef<Record<string, number>>({});
|
||||
|
||||
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
|
||||
const cached = driverStatusCacheRef.current;
|
||||
@@ -426,7 +428,7 @@ export const useSidebarTreeLoaders = ({
|
||||
icon: <CloudOutlined style={{ color: '#13C2C2' }} />,
|
||||
type: 'nacos-services-entry' as const,
|
||||
dataRef: nsDataRef,
|
||||
isLeaf: true,
|
||||
isLeaf: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1308,10 +1310,89 @@ export const useSidebarTreeLoaders = ({
|
||||
}
|
||||
};
|
||||
|
||||
const loadNacosServiceGroups = async (
|
||||
node: any,
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<boolean> => {
|
||||
const dataRef = node?.dataRef || {};
|
||||
const connectionId = String(dataRef.id || '');
|
||||
const namespaceId = String(dataRef.nacosNamespaceId ?? '');
|
||||
const namespaceName = String(dataRef.nacosNamespaceName || namespaceId || 'public');
|
||||
const nodeKeyId = namespaceId || 'public';
|
||||
const loadKey = `nacos-service-groups-${connectionId}-${nodeKeyId}`;
|
||||
if (!connectionId) return false;
|
||||
if (loadingNodesRef.current.has(loadKey) && !options.force) return false;
|
||||
const requestId = (nacosServiceGroupRequestIdsRef.current[loadKey] || 0) + 1;
|
||||
nacosServiceGroupRequestIdsRef.current[loadKey] = requestId;
|
||||
loadingNodesRef.current.add(loadKey);
|
||||
try {
|
||||
const rpcConfig = buildRpcConnectionConfig(dataRef.config || {});
|
||||
const groups = await collectNacosServiceGroupsByPage(async (pageNo, pageSize) => {
|
||||
const res = await (window as any).go.app.App.NacosListServices(rpcConfig, {
|
||||
namespaceId,
|
||||
groupName: '',
|
||||
pageNo,
|
||||
pageSize,
|
||||
});
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || 'list service groups failed');
|
||||
}
|
||||
return res.data || {};
|
||||
});
|
||||
if (nacosServiceGroupRequestIdsRef.current[loadKey] !== requestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allNode: TreeNode = {
|
||||
title: t('nacos_viewer.label.all'),
|
||||
key: `${connectionId}-nacos-ns-${nodeKeyId}-service-group-__all__`,
|
||||
icon: <AppstoreOutlined style={{ color: '#13C2C2' }} />,
|
||||
type: 'nacos-service-group',
|
||||
dataRef: {
|
||||
...dataRef,
|
||||
nacosNamespaceId: namespaceId,
|
||||
nacosNamespaceName: namespaceName,
|
||||
nacosGroup: '',
|
||||
},
|
||||
isLeaf: true,
|
||||
};
|
||||
const groupNodes: TreeNode[] = groups.map((group) => ({
|
||||
title: group,
|
||||
key: `${connectionId}-nacos-ns-${nodeKeyId}-service-group-${encodeURIComponent(group)}`,
|
||||
icon: <FolderOpenOutlined style={{ color: '#13C2C2' }} />,
|
||||
type: 'nacos-service-group',
|
||||
dataRef: {
|
||||
...dataRef,
|
||||
nacosNamespaceId: namespaceId,
|
||||
nacosNamespaceName: namespaceName,
|
||||
nacosGroup: group,
|
||||
},
|
||||
isLeaf: true,
|
||||
}));
|
||||
replaceTreeNodeChildren(node.key, [allNode, ...groupNodes], dataRef);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (nacosServiceGroupRequestIdsRef.current[loadKey] !== requestId) {
|
||||
return false;
|
||||
}
|
||||
message.error({
|
||||
content: t('sidebar.message.connection_failed', { error: error?.message || String(error) }),
|
||||
key: loadKey,
|
||||
});
|
||||
setLoadedKeys((prev) => prev.filter((k) => k !== node.key));
|
||||
return false;
|
||||
} finally {
|
||||
if (nacosServiceGroupRequestIdsRef.current[loadKey] === requestId) {
|
||||
loadingNodesRef.current.delete(loadKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loadDatabases,
|
||||
loadJVMResources,
|
||||
loadTables,
|
||||
loadNacosConfigGroups,
|
||||
loadNacosServiceGroups,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildNacosServicesTabData,
|
||||
resolveNacosServicesDoubleClickAction,
|
||||
shouldLoadSidebarNodeOnExpand as shouldLoadV2SidebarNodeOnExpand,
|
||||
} from './sidebarV2Utils';
|
||||
import { shouldLoadSidebarNodeOnExpand } from './sidebar/sidebarHelpers';
|
||||
|
||||
const namespaceData = {
|
||||
id: 'nacos-1',
|
||||
nacosNamespaceId: 'mkefu-dev',
|
||||
nacosNamespaceName: 'mkefu development',
|
||||
};
|
||||
|
||||
describe('Nacos service group navigation', () => {
|
||||
it('keeps the service explorer entry as a lazy folder on double click', () => {
|
||||
const entryNode = {
|
||||
type: 'nacos-services-entry' as const,
|
||||
children: [],
|
||||
isLeaf: false,
|
||||
};
|
||||
expect(shouldLoadV2SidebarNodeOnExpand(entryNode)).toBe(true);
|
||||
expect(shouldLoadSidebarNodeOnExpand(entryNode)).toBe(true);
|
||||
expect(resolveNacosServicesDoubleClickAction({
|
||||
type: 'nacos-services-entry',
|
||||
dataRef: namespaceData,
|
||||
})).toEqual({ kind: 'expand' });
|
||||
});
|
||||
|
||||
it('opens the all-services tab without a group filter', () => {
|
||||
const tab = buildNacosServicesTabData({
|
||||
...namespaceData,
|
||||
nacosGroup: '',
|
||||
});
|
||||
|
||||
expect(tab).toMatchObject({
|
||||
id: 'nacos-services-nacos-1-ns-mkefu-dev',
|
||||
type: 'nacos-services',
|
||||
connectionId: 'nacos-1',
|
||||
nacosNamespaceId: 'mkefu-dev',
|
||||
nacosNamespaceName: 'mkefu development',
|
||||
});
|
||||
expect(tab).not.toHaveProperty('nacosGroup');
|
||||
});
|
||||
|
||||
it('opens a group-specific tab with an isolated id and filter', () => {
|
||||
const action = resolveNacosServicesDoubleClickAction({
|
||||
type: 'nacos-service-group',
|
||||
dataRef: {
|
||||
...namespaceData,
|
||||
nacosGroup: 'MKEFU SERVICE',
|
||||
},
|
||||
});
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'open',
|
||||
tab: expect.objectContaining({
|
||||
id: 'nacos-services-nacos-1-ns-mkefu-dev-g-MKEFU%20SERVICE',
|
||||
title: 'mkefu development · MKEFU SERVICE',
|
||||
type: 'nacos-services',
|
||||
nacosGroup: 'MKEFU SERVICE',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
buildSidebarTablePinKey,
|
||||
resolveSidebarRootOrderTokens,
|
||||
} from '../store';
|
||||
import type { ConnectionTag, SavedConnection } from '../types';
|
||||
import type { ConnectionTag, SavedConnection, TabData } from '../types';
|
||||
import type { SidebarTableMetadataField } from '../utils/sidebarTableMetadata';
|
||||
import { readTableAccessCount } from '../utils/tableAccessCount';
|
||||
import { t } from '../i18n';
|
||||
@@ -56,6 +56,7 @@ export type SidebarTreeNodeType =
|
||||
| 'nacos-config-entry'
|
||||
| 'nacos-config-group'
|
||||
| 'nacos-services-entry'
|
||||
| 'nacos-service-group'
|
||||
| 'tag'
|
||||
| 'jvm-mode'
|
||||
| 'jvm-resource'
|
||||
@@ -87,7 +88,50 @@ export const shouldLoadSidebarNodeOnExpand = (
|
||||
|| node.type === 'table'
|
||||
|| node.type === 'jvm-mode'
|
||||
|| node.type === 'jvm-resource'
|
||||
|| node.type === 'nacos-config-entry';
|
||||
|| node.type === 'nacos-config-entry'
|
||||
|| node.type === 'nacos-services-entry';
|
||||
};
|
||||
|
||||
type NacosServicesTabDataRef = {
|
||||
id?: unknown;
|
||||
nacosNamespaceId?: unknown;
|
||||
nacosNamespaceName?: unknown;
|
||||
nacosGroup?: unknown;
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
return {
|
||||
id: `nacos-services-${connectionId}-ns-${namespaceKey}${groupName ? `-g-${encodeURIComponent(groupName)}` : ''}`,
|
||||
title: groupName
|
||||
? `${namespaceName} · ${groupName}`
|
||||
: `${namespaceName} · ${t('nacos_service.title.service_explorer')}`,
|
||||
type: 'nacos-services',
|
||||
connectionId,
|
||||
nacosNamespaceId: namespaceId,
|
||||
nacosNamespaceName: namespaceName,
|
||||
...(groupName ? { nacosGroup: groupName } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export type NacosServicesDoubleClickAction =
|
||||
| { kind: 'expand' }
|
||||
| { kind: 'open'; tab: TabData }
|
||||
| null;
|
||||
|
||||
export const resolveNacosServicesDoubleClickAction = (
|
||||
node: Pick<SidebarTreeNode, 'type' | 'dataRef'> | null | undefined,
|
||||
): NacosServicesDoubleClickAction => {
|
||||
if (node?.type === 'nacos-services-entry') return { kind: 'expand' };
|
||||
if (node?.type === 'nacos-service-group') {
|
||||
return { kind: 'open', tab: buildNacosServicesTabData(node.dataRef || {}) };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveSidebarTableNameForCopy = (
|
||||
|
||||
@@ -515,7 +515,7 @@ export interface TabData {
|
||||
redisDB?: number; // Redis database index for redis tabs
|
||||
nacosNamespaceId?: string; // Nacos namespace id (empty string means public)
|
||||
nacosNamespaceName?: string; // Nacos namespace display name
|
||||
nacosGroup?: string; // Nacos config group filter for config workbench
|
||||
nacosGroup?: string; // Nacos group filter for config or service workbenches
|
||||
triggerName?: string; // Trigger name for trigger tabs
|
||||
triggerTableName?: string; // Trigger target table for trigger tabs
|
||||
viewName?: string; // View name for view definition tabs
|
||||
|
||||
Reference in New Issue
Block a user