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>
);
};