This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new b19cef42 feat: add runtime detail modals (#641)
b19cef42 is described below

commit b19cef42807dab1f60bb85e841fabc5e6fcaea29
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 07:25:47 2026 -0700

    feat: add runtime detail modals (#641)
    
    * [Studio] Add runtime detail modals
    
    * test: cover client controller contract
---
 .../cluster/client/ClientControllerTest.java       | 104 +++++++++++++++++++++
 web/src/i18n/translations.ts                       |   2 +
 .../pages/cluster/__tests__/ClientsPage.test.tsx   |  96 +++++++++++++++++++
 .../pages/cluster/__tests__/ClusterPage.test.tsx   |  66 +++++++++++++
 web/src/pages/cluster/clients.tsx                  |  81 +++++++++++++++-
 web/src/pages/cluster/index.tsx                    |  56 ++++++++++-
 6 files changed, 401 insertions(+), 4 deletions(-)

diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ClientControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ClientControllerTest.java
new file mode 100644
index 00000000..5961efcf
--- /dev/null
+++ 
b/server/src/test/java/org/apache/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 org.apache.rocketmq.studio.cluster.client;
+
+import org.apache.rocketmq.studio.common.domain.enums.ClientLanguage;
+import org.apache.rocketmq.studio.common.domain.enums.ClientType;
+import org.apache.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");
+    }
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 90d94f32..80164b5f 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -187,6 +187,7 @@ const translations: Record<string, Record<Lang, string>> = {
   '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<string, Record<Lang, string>> = {
   '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: '[email protected]: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(
+    <App>
+      <LangProvider>{ui}</LangProvider>
+    </App>,
+  );
+
+describe('Clients page', () => {
+  it('opens a client detail dialog from the connection table', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<ClientsPage />);
+
+    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('[email protected]: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(
+    <App>
+      <LangProvider>{ui}</LangProvider>
+    </App>,
+  );
+
+describe('Cluster page', () => {
+  it('opens proxy detail dialog from the proxy table', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<ClusterPage />);
+
+    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<string>('ALL');
+  const [selectedConnection, setSelectedConnection] = 
useState<ClientConnection | null>(null);
 
   useEffect(() => {
     let cancelled = false;
@@ -221,6 +235,22 @@ const ClientsPage = () => {
         </Text>
       ),
     },
+    {
+      title: t('common.actions'),
+      key: 'actions',
+      width: 90,
+      fixed: 'right',
+      render: (_: unknown, record: ClientConnection) => (
+        <Button
+          size="small"
+          icon={<Eye size={14} />}
+          style={{ borderColor: '#1677ff', color: '#1677ff' }}
+          onClick={() => setSelectedConnection(record)}
+        >
+          {t('common.detail')}
+        </Button>
+      ),
+    },
   ];
 
   /* ═══════════════════════════════════════════
@@ -271,6 +301,53 @@ const ClientsPage = () => {
           size="small"
         />
       </Card>
+
+      <Modal
+        title={t('clients.detailTitle', { id: selectedConnection?.clientId ?? 
'' })}
+        open={Boolean(selectedConnection)}
+        onCancel={() => setSelectedConnection(null)}
+        footer={<Button onClick={() => 
setSelectedConnection(null)}>{t('common.close')}</Button>}
+        width={640}
+        destroyOnClose
+      >
+        {selectedConnection && (
+          <Descriptions column={1} bordered size="small">
+            <Descriptions.Item label={t('clients.clientId')}>
+              <Text copyable style={{ fontFamily: 'monospace' }}>
+                {selectedConnection.clientId}
+              </Text>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('clients.cluster')}>
+              <Tag color="blue">{selectedConnection.clusterName}</Tag>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('common.type')}>
+              {typeConfig[selectedConnection.type]?.label ?? 
selectedConnection.type}
+            </Descriptions.Item>
+            <Descriptions.Item label={t('clients.groupOrTopic')}>
+              {selectedConnection.groupOrTopic}
+            </Descriptions.Item>
+            <Descriptions.Item label={t('clients.protocol')}>
+              <Tag color={protocolConfig[selectedConnection.protocol]?.color 
?? 'default'}>
+                {protocolConfig[selectedConnection.protocol]?.label ?? 
selectedConnection.protocol}
+              </Tag>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('common.address')}>
+              <Text style={{ fontFamily: 'monospace' 
}}>{selectedConnection.address}</Text>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('clients.language')}>
+              <Tag color={languageConfig[selectedConnection.language]?.color 
?? 'default'}>
+                {languageConfig[selectedConnection.language]?.label ?? 
selectedConnection.language}
+              </Tag>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('common.version')}>
+              {selectedConnection.version}
+            </Descriptions.Item>
+            <Descriptions.Item label={t('cluster.heartbeat')}>
+              {selectedConnection.connectedAt}
+            </Descriptions.Item>
+          </Descriptions>
+        )}
+      </Modal>
     </div>
   );
 };
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index daa241f1..e86006ae 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,
@@ -85,6 +86,8 @@ const buildProxyConnMap = (clusters: ClusterInfo[]): 
Record<string, number> => {
   return result;
 };
 
+type ProxyDetail = ProxyInfo & { clusterId: string; clusterName: string; 
nsClusterName: string };
+
 // ─── Page 
─────────────────────────────────────────────────────────────────────
 
 const ClusterPage = () => {
@@ -100,6 +103,7 @@ const ClusterPage = () => {
   const [selectedCluster, setSelectedCluster] = useState<ClusterInfo | 
null>(null);
   const [nsModalOpen, setNsModalOpen] = useState(false);
   const [nsModalMode, setNsModalMode] = useState<'create' | 'edit'>('create');
+  const [selectedProxy, setSelectedProxy] = useState<ProxyDetail | null>(null);
   const [nsForm] = Form.useForm();
   const [configForm] = Form.useForm();
 
@@ -683,7 +687,7 @@ const ClusterPage = () => {
   // ─── Tab 3: Proxy 管理 (flat table) ────────────────────────────────────────
 
   function renderProxyTab() {
-    type ProxyRow = ProxyInfo & { clusterId: string; clusterName: string; 
nsClusterName: string };
+    type ProxyRow = ProxyDetail;
 
     const allProxies: ProxyRow[] = clusters
       .filter((c) => c.proxies.length > 0)
@@ -780,7 +784,7 @@ const ClusterPage = () => {
               size="small"
               icon={<EyeOutlined />}
               style={{ borderColor: '#1677ff', color: '#1677ff' }}
-              onClick={() => message.info(t('cluster.viewDetail', { addr: 
record.addr }))}
+              onClick={() => setSelectedProxy(record)}
             >
               {t('common.detail')}
             </Button>
@@ -941,6 +945,54 @@ const ClusterPage = () => {
         </Form>
       </Modal>
       <Tabs items={tabItems} defaultActiveKey="broker" />
+      <Modal
+        title={t('cluster.proxyDetailTitle', { addr: selectedProxy?.addr ?? '' 
})}
+        open={Boolean(selectedProxy)}
+        onCancel={() => setSelectedProxy(null)}
+        footer={<Button onClick={() => 
setSelectedProxy(null)}>{t('common.close')}</Button>}
+        width={560}
+        destroyOnClose
+      >
+        {selectedProxy && (
+          <Descriptions column={1} bordered size="small">
+            <Descriptions.Item label={t('cluster.k8sName')}>
+              {selectedProxy.clusterName}
+            </Descriptions.Item>
+            <Descriptions.Item label={t('cluster.nsClusterName')}>
+              {selectedProxy.nsClusterName}
+            </Descriptions.Item>
+            <Descriptions.Item label={t('cluster.proxyAddr')}>
+              <Text copyable code>
+                {selectedProxy.addr}
+              </Text>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('common.status')}>
+              <Tag
+                color={
+                  selectedProxy.status === 'healthy'
+                    ? 'green'
+                    : selectedProxy.status === 'warning'
+                      ? 'gold'
+                      : selectedProxy.status === 'error'
+                        ? 'red'
+                        : 'default'
+                }
+              >
+                {selectedProxy.status}
+              </Tag>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('cluster.connections')}>
+              {selectedProxy.connections.toLocaleString()}
+            </Descriptions.Item>
+            <Descriptions.Item label={t('cluster.grpcPort')}>
+              {selectedProxy.grpcPort}
+            </Descriptions.Item>
+            <Descriptions.Item label={t('cluster.remotingPort')}>
+              {selectedProxy.remotingPort}
+            </Descriptions.Item>
+          </Descriptions>
+        )}
+      </Modal>
     </div>
   );
 };

Reply via email to