diff --git a/frontend/src/components/NacosServiceViewer.interaction.test.tsx b/frontend/src/components/NacosServiceViewer.interaction.test.tsx new file mode 100644 index 00000000..cd5ea191 --- /dev/null +++ b/frontend/src/components/NacosServiceViewer.interaction.test.tsx @@ -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 = { + promise: Promise; + resolve: (value: T) => void; +}; + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + const charlieResponse = deferred(); + 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( + , + ); + }); + 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( + , + ); + }); + 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' }); + }); +}); diff --git a/frontend/src/components/NacosServiceViewer.tsx b/frontend/src/components/NacosServiceViewer.tsx index bedc7614..294fed18 100644 --- a/frontend/src/components/NacosServiceViewer.tsx +++ b/frontend/src/components/NacosServiceViewer.tsx @@ -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 = ({ connectionId, namespaceId, namespaceName, + initialGroup, }) => { const connections = useStore((state) => state.connections); const appTheme = useStore((state) => state.theme); @@ -144,7 +143,7 @@ const NacosServiceViewer: React.FC = ({ 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(null); const [instances, setInstances] = useState([]); @@ -156,11 +155,24 @@ const NacosServiceViewer: React.FC = ({ // Left service list pane width; drag divider to adjust (same pattern as Redis). const [leftPanelWidth, setLeftPanelWidth] = useState('38%'); const leftPanelRef = useRef(null); + const instanceRequestIdRef = useRef(0); const selectedParsed = useMemo( - () => (selectedServiceRaw ? parseServiceName(selectedServiceRaw) : null), + () => (selectedServiceRaw ? parseNacosServiceName(selectedServiceRaw) : null), [selectedServiceRaw], ); + const serviceRows = useMemo( + () => 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ }} 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) => ( +
+
+ {row.serviceName} +
+
+ {row.groupName} +
+
+ ), }, { title: tr('nacos_viewer.action.delete'), key: 'actions', width: 90, - render: (_: unknown, row: { name: string }) => ( + render: (_: unknown, row: NacosServiceRow) => ( void handleDeleteService(row.name)} + onConfirm={() => void handleDeleteService(row.rawName)} >