From d80f364e017bd61cf7a87bb41ae6fd2f44051d96 Mon Sep 17 00:00:00 2001 From: liuhy Date: Sun, 26 Jul 2026 19:53:14 -0700 Subject: [PATCH 1/2] [Studio] Add runtime detail modals --- web/src/i18n/translations.ts | 2 + .../cluster/__tests__/ClientsPage.test.tsx | 96 +++++++++++++++++++ .../cluster/__tests__/ClusterPage.test.tsx | 66 +++++++++++++ web/src/pages/cluster/clients.tsx | 81 +++++++++++++++- web/src/pages/cluster/index.tsx | 56 ++++++++++- 5 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 web/src/pages/cluster/__tests__/ClientsPage.test.tsx create mode 100644 web/src/pages/cluster/__tests__/ClusterPage.test.tsx diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 3b37ff10..b13e5a76 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -187,6 +187,7 @@ const translations: Record> = { 'clients.cluster': { zh: '所属集群', en: 'Cluster' }, 'clients.allClusters': { zh: '全部集群', en: 'All Clusters' }, 'clients.searchPlaceholder': { zh: '搜索 Client ID 或地址', en: 'Search Client ID or address' }, + 'clients.detailTitle': { zh: '客户端详情 - {id}', en: 'Client Detail - {id}' }, // ─── Alert Rules ─── 'alerts.title': { zh: '告警规则管理', en: 'Alert Rules' }, @@ -502,6 +503,7 @@ const translations: Record> = { 'cluster.readQueues': { zh: '读队列数', en: 'Read Queues' }, 'cluster.brokerPermission': { zh: 'Broker 权限', en: 'Broker Permission' }, 'cluster.viewDetail': { zh: '查看详情: {addr}', en: 'View detail: {addr}' }, + 'cluster.proxyDetailTitle': { zh: 'Proxy 详情 - {addr}', en: 'Proxy Detail - {addr}' }, 'cluster.restartProxyConfirm': { zh: '确定要重启 Proxy "{addr}" 吗?', en: 'Are you sure to restart Proxy "{addr}"?', diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx new file mode 100644 index 00000000..686dc804 --- /dev/null +++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App } from 'antd'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type React from 'react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ClientConnection } from '../../../api/connections'; +import { LangProvider } from '../../../i18n/LangContext'; +import * as connectionsService from '../../../services/connectionsService'; +import ClientsPage from '../clients'; + +vi.mock('../../../services/connectionsService', () => ({ + listConnections: vi.fn(), +})); + +const connection: ClientConnection = { + clientId: 'order-svc-0@10.0.1.12:49152', + type: 'Producer', + groupOrTopic: 'order-create', + protocol: 'gRPC', + address: '10.0.1.12:49152', + language: 'Java', + version: '5.0.7', + connectedAt: '2026-07-01 08:30:00', + clusterName: 'ns-prod', +}; + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +beforeEach(() => { + vi.mocked(connectionsService.listConnections).mockResolvedValue([connection]); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +const renderWithProviders = (ui: React.ReactElement) => + render( + + {ui} + , + ); + +describe('Clients page', () => { + it('opens a client detail dialog from the connection table', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const row = await screen.findByRole('row', { name: /order-svc-0@10\.0\.1\.12:49152/ }); + await user.click(within(row).getByRole('button', { name: /详情/ })); + + const dialog = await screen.findByRole('dialog', { + name: /客户端详情 - order-svc-0@10\.0\.1\.12:49152/, + }); + expect(within(dialog).getAllByText('order-svc-0@10.0.1.12:49152')).toHaveLength(1); + expect(within(dialog).getByText('ns-prod')).toBeInTheDocument(); + expect(within(dialog).getByText('Producer')).toBeInTheDocument(); + expect(within(dialog).getByText('order-create')).toBeInTheDocument(); + expect(within(dialog).getByText('gRPC')).toBeInTheDocument(); + expect(within(dialog).getByText('10.0.1.12:49152')).toBeInTheDocument(); + expect(within(dialog).getByText('Java')).toBeInTheDocument(); + expect(within(dialog).getByText('5.0.7')).toBeInTheDocument(); + expect(within(dialog).getByText('2026-07-01 08:30:00')).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx new file mode 100644 index 00000000..ad3577bd --- /dev/null +++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App } from 'antd'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type React from 'react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { LangProvider } from '../../../i18n/LangContext'; +import ClusterPage from '../index'; + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +const renderWithProviders = (ui: React.ReactElement) => + render( + + {ui} + , + ); + +describe('Cluster page', () => { + it('opens proxy detail dialog from the proxy table', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole('tab', { name: /Proxy 管理/ })); + const proxyRow = screen.getByRole('row', { name: /10\.101\.2\.21:8081/ }); + await user.click(within(proxyRow).getByRole('button', { name: /详情/ })); + + const dialog = await screen.findByRole('dialog', { name: /Proxy 详情 - 10\.101\.2\.21:8081/ }); + expect(within(dialog).getByText('rocketmq-prod')).toBeInTheDocument(); + expect(within(dialog).getByText('ns-prod')).toBeInTheDocument(); + expect(within(dialog).getAllByText('10.101.2.21:8081')).toHaveLength(1); + expect(within(dialog).getByText('1,842')).toBeInTheDocument(); + expect(within(dialog).getByText('8081')).toBeInTheDocument(); + expect(within(dialog).getByText('8080')).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/cluster/clients.tsx b/web/src/pages/cluster/clients.tsx index 8cca2c23..8071e5fa 100644 --- a/web/src/pages/cluster/clients.tsx +++ b/web/src/pages/cluster/clients.tsx @@ -16,8 +16,21 @@ */ import { useEffect, useMemo, useState } from 'react'; -import { Table, Card, Tag, Space, Input, Select, Flex, Typography, message } from 'antd'; -import { MagnifyingGlass } from '@phosphor-icons/react'; +import { + Button, + Card, + Descriptions, + Flex, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import { Eye, MagnifyingGlass } from '@phosphor-icons/react'; import type { ColumnsType } from 'antd/es/table'; import PageHeader from '../../components/PageHeader'; @@ -55,6 +68,7 @@ const ClientsPage = () => { const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [clusterFilter, setClusterFilter] = useState('ALL'); + const [selectedConnection, setSelectedConnection] = useState(null); useEffect(() => { let cancelled = false; @@ -221,6 +235,22 @@ const ClientsPage = () => { ), }, + { + title: t('common.actions'), + key: 'actions', + width: 90, + fixed: 'right', + render: (_: unknown, record: ClientConnection) => ( + + ), + }, ]; /* ═══════════════════════════════════════════ @@ -271,6 +301,53 @@ const ClientsPage = () => { size="small" /> + + setSelectedConnection(null)} + footer={} + width={640} + destroyOnClose + > + {selectedConnection && ( + + + + {selectedConnection.clientId} + + + + {selectedConnection.clusterName} + + + {typeConfig[selectedConnection.type]?.label ?? selectedConnection.type} + + + {selectedConnection.groupOrTopic} + + + + {protocolConfig[selectedConnection.protocol]?.label ?? selectedConnection.protocol} + + + + {selectedConnection.address} + + + + {languageConfig[selectedConnection.language]?.label ?? selectedConnection.language} + + + + {selectedConnection.version} + + + {selectedConnection.connectedAt} + + + )} + ); }; diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx index 5fdcf63e..375e96be 100644 --- a/web/src/pages/cluster/index.tsx +++ b/web/src/pages/cluster/index.tsx @@ -29,6 +29,7 @@ import { Switch, InputNumber, Progress, + Descriptions, Flex, Space, Typography, @@ -56,6 +57,8 @@ import clusters, { const { Text } = Typography; +type ProxyDetail = ProxyInfo & { clusterName: string; nsClusterName: string }; + // ─── Page ───────────────────────────────────────────────────────────────────── const ClusterPage = () => { @@ -69,6 +72,7 @@ const ClusterPage = () => { const [selectedCluster, setSelectedCluster] = useState(null); const [nsModalOpen, setNsModalOpen] = useState(false); const [nsModalMode, setNsModalMode] = useState<'create' | 'edit'>('create'); + const [selectedProxy, setSelectedProxy] = useState(null); const [nsForm] = Form.useForm(); const [configForm] = Form.useForm(); @@ -608,7 +612,7 @@ const ClusterPage = () => { // ─── Tab 3: Proxy 管理 (flat table) ──────────────────────────────────────── function renderProxyTab() { - type ProxyRow = ProxyInfo & { clusterName: string; nsClusterName: string }; + type ProxyRow = ProxyDetail; const allProxies: ProxyRow[] = clusters .filter((c) => c.proxies.length > 0) @@ -704,7 +708,7 @@ const ClusterPage = () => { size="small" icon={} style={{ borderColor: '#1677ff', color: '#1677ff' }} - onClick={() => message.info(t('cluster.viewDetail', { addr: record.addr }))} + onClick={() => setSelectedProxy(record)} > {t('common.detail')} @@ -854,6 +858,54 @@ const ClusterPage = () => { + setSelectedProxy(null)} + footer={} + width={560} + destroyOnClose + > + {selectedProxy && ( + + + {selectedProxy.clusterName} + + + {selectedProxy.nsClusterName} + + + + {selectedProxy.addr} + + + + + {selectedProxy.status} + + + + {selectedProxy.connections.toLocaleString()} + + + {selectedProxy.grpcPort} + + + {selectedProxy.remotingPort} + + + )} + ); }; From 5eb8b824617a17e2641225aa92cd27503e37d55a Mon Sep 17 00:00:00 2001 From: liuhy Date: Fri, 24 Jul 2026 09:04:20 -0700 Subject: [PATCH 2/2] test: cover client controller contract --- .../cluster/client/ClientControllerTest.java | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 server/src/test/java/com/rocketmq/studio/cluster/client/ClientControllerTest.java diff --git a/server/src/test/java/com/rocketmq/studio/cluster/client/ClientControllerTest.java b/server/src/test/java/com/rocketmq/studio/cluster/client/ClientControllerTest.java new file mode 100644 index 00000000..43e0a110 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/cluster/client/ClientControllerTest.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.cluster.client; + +import com.rocketmq.studio.common.domain.enums.ClientLanguage; +import com.rocketmq.studio.common.domain.enums.ClientType; +import com.rocketmq.studio.common.domain.enums.Protocol; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ClientController.class) +@AutoConfigureMockMvc(addFilters = false) +class ClientControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ClientService clientService; + + @Test + void listConnectionsShouldReturnProtocolAwareClientRows() throws Exception { + ClientConnectionVO grpcClient = ClientConnectionVO.builder() + .clientId("grpc-client-001") + .type(ClientType.Consumer) + .groupOrTopic("cg-order") + .protocol(Protocol.gRPC) + .address("10.0.0.1:8081") + .language(ClientLanguage.Java) + .version("5.1.0") + .connectedAt(LocalDateTime.of(2026, 1, 1, 12, 0)) + .clusterName("production-cluster") + .build(); + ClientConnectionVO remotingClient = ClientConnectionVO.builder() + .clientId("remoting-client-001") + .type(ClientType.Producer) + .groupOrTopic("order-topic") + .protocol(Protocol.Remoting) + .address("10.0.0.2:10911") + .language(ClientLanguage.Go) + .version("4.9.8") + .connectedAt(LocalDateTime.of(2026, 1, 1, 12, 5)) + .clusterName("production-cluster") + .build(); + when(clientService.listConnections(null, null)).thenReturn(List.of(grpcClient, remotingClient)); + + mockMvc.perform(get("/api/clients")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].clientId").value("grpc-client-001")) + .andExpect(jsonPath("$.data[0].type").value("Consumer")) + .andExpect(jsonPath("$.data[0].protocol").value("gRPC")) + .andExpect(jsonPath("$.data[0].language").value("Java")) + .andExpect(jsonPath("$.data[0].version").value("5.1.0")) + .andExpect(jsonPath("$.data[1].clientId").value("remoting-client-001")) + .andExpect(jsonPath("$.data[1].type").value("Producer")) + .andExpect(jsonPath("$.data[1].protocol").value("Remoting")); + + verify(clientService).listConnections(null, null); + } + + @Test + void listConnectionsShouldPassClusterAndTypeFilters() throws Exception { + when(clientService.listConnections("production-cluster", "Consumer")).thenReturn(List.of()); + + mockMvc.perform(get("/api/clients") + .param("clusterId", "production-cluster") + .param("type", "Consumer")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data").isEmpty()); + + verify(clientService).listConnections("production-cluster", "Consumer"); + } +}