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 45a510af feat: add client connection distribution statistics (#756)
45a510af is described below

commit 45a510af61b2f3281fd31fd21221bb869b20fc36
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:36:36 2026 +0800

    feat: add client connection distribution statistics (#756)
---
 .../pages/cluster/__tests__/ClientsPage.test.tsx   | 116 ++++++++++++++++++-
 web/src/pages/cluster/clients.tsx                  | 125 ++++++++++++++++++---
 2 files changed, 225 insertions(+), 16 deletions(-)

diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index 686dc804..beb6b5e6 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -16,7 +16,7 @@
  */
 
 import { App } from 'antd';
-import { render, screen, within } from '@testing-library/react';
+import { render, screen, waitFor, 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';
@@ -41,6 +41,32 @@ const connection: ClientConnection = {
   clusterName: 'ns-prod',
 };
 
+const connections: ClientConnection[] = [
+  connection,
+  {
+    clientId: '[email protected]:49153',
+    type: 'Consumer',
+    groupOrTopic: 'payment-consumer',
+    protocol: 'gRPC',
+    address: '10.0.1.13:49153',
+    language: 'Go',
+    version: '2.1.0',
+    connectedAt: '2026-07-01 08:31:00',
+    clusterName: 'ns-prod',
+  },
+  {
+    clientId: '[email protected]:49154',
+    type: 'Consumer',
+    groupOrTopic: 'audit-consumer',
+    protocol: 'Remoting',
+    address: '10.0.2.10:49154',
+    language: 'Cpp',
+    version: '4.9.8',
+    connectedAt: '2026-07-01 08:32:00',
+    clusterName: 'ns-audit',
+  },
+];
+
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -73,6 +99,94 @@ const renderWithProviders = (ui: React.ReactElement) =>
   );
 
 describe('Clients page', () => {
+  it('summarizes connection types, protocols, and language versions', async () 
=> {
+    
vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
+    renderWithProviders(<ClientsPage />);
+
+    expect(
+      within(await screen.findByTestId('connection-total')).getByText('3'),
+    ).toBeInTheDocument();
+    
expect(within(screen.getByTestId('producer-total')).getByText('1')).toBeInTheDocument();
+    
expect(within(screen.getByTestId('consumer-total')).getByText('2')).toBeInTheDocument();
+
+    const protocols = screen.getByTestId('protocol-distribution');
+    expect(within(protocols).getByText('gRPC: 2')).toBeInTheDocument();
+    expect(within(protocols).getByText('Remoting: 1')).toBeInTheDocument();
+
+    const languageVersions = 
screen.getByTestId('language-version-distribution');
+    expect(within(languageVersions).getByText('Java 5.0.7: 
1')).toBeInTheDocument();
+    expect(within(languageVersions).getByText('Go 2.1.0: 
1')).toBeInTheDocument();
+    expect(within(languageVersions).getByText('C++ 4.9.8: 
1')).toBeInTheDocument();
+  });
+
+  it('updates statistics when the selected cluster changes', async () => {
+    const user = userEvent.setup();
+    
vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
+    renderWithProviders(<ClientsPage />);
+
+    await screen.findByText('[email protected]:49154');
+    await user.click(screen.getByRole('combobox', { name: '所属集群' }));
+    await user.click(
+      await screen.findByText('ns-prod', { selector: 
'.ant-select-item-option-content' }),
+    );
+
+    await waitFor(() => {
+      
expect(within(screen.getByTestId('connection-total')).getByText('2')).toBeInTheDocument();
+    });
+    
expect(within(screen.getByTestId('producer-total')).getByText('1')).toBeInTheDocument();
+    
expect(within(screen.getByTestId('consumer-total')).getByText('1')).toBeInTheDocument();
+    expect(
+      within(screen.getByTestId('protocol-distribution')).getByText('gRPC: 2'),
+    ).toBeInTheDocument();
+    expect(
+      
within(screen.getByTestId('protocol-distribution')).queryByText('Remoting: 1'),
+    ).toBeNull();
+    expect(
+      
within(screen.getByTestId('language-version-distribution')).getByText('Java 
5.0.7: 1'),
+    ).toBeInTheDocument();
+    expect(
+      
within(screen.getByTestId('language-version-distribution')).queryByText('C++ 
4.9.8: 1'),
+    ).toBeNull();
+  });
+
+  it('keeps cluster statistics stable when text search narrows the table', 
async () => {
+    const user = userEvent.setup();
+    
vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
+    renderWithProviders(<ClientsPage />);
+
+    await screen.findByText('[email protected]:49154');
+    await user.type(screen.getByPlaceholderText('搜索 Client ID 或地址'), 
'order-svc');
+
+    
expect(within(screen.getByTestId('connection-total')).getByText('3')).toBeInTheDocument();
+    
expect(within(screen.getByTestId('producer-total')).getByText('1')).toBeInTheDocument();
+    
expect(within(screen.getByTestId('consumer-total')).getByText('2')).toBeInTheDocument();
+    expect(
+      within(screen.getByTestId('protocol-distribution')).getByText('Remoting: 
1'),
+    ).toBeInTheDocument();
+    expect(
+      
within(screen.getByTestId('language-version-distribution')).getByText('C++ 
4.9.8: 1'),
+    ).toBeInTheDocument();
+    
expect(screen.getByText('[email protected]:49152')).toBeInTheDocument();
+    expect(screen.queryByText('[email protected]:49154')).toBeNull();
+  });
+
+  it('renders empty distributions when no connections are available', async () 
=> {
+    vi.mocked(connectionsService.listConnections).mockResolvedValue([]);
+    renderWithProviders(<ClientsPage />);
+
+    expect(
+      within(await screen.findByTestId('connection-total')).getByText('0'),
+    ).toBeInTheDocument();
+    
expect(within(screen.getByTestId('producer-total')).getByText('0')).toBeInTheDocument();
+    
expect(within(screen.getByTestId('consumer-total')).getByText('0')).toBeInTheDocument();
+    expect(
+      within(screen.getByTestId('protocol-distribution')).getByText('暂无数据'),
+    ).toBeInTheDocument();
+    expect(
+      
within(screen.getByTestId('language-version-distribution')).getByText('暂无数据'),
+    ).toBeInTheDocument();
+  });
+
   it('opens a client detail dialog from the connection table', async () => {
     const user = userEvent.setup();
     renderWithProviders(<ClientsPage />);
diff --git a/web/src/pages/cluster/clients.tsx 
b/web/src/pages/cluster/clients.tsx
index 8071e5fa..351b6634 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -25,10 +25,12 @@ import {
   Modal,
   Select,
   Space,
+  Statistic,
   Table,
   Tag,
   Typography,
   message,
+  theme,
 } from 'antd';
 import { Eye, MagnifyingGlass } from '@phosphor-icons/react';
 import type { ColumnsType } from 'antd/es/table';
@@ -57,13 +59,28 @@ const languageConfig: Record<string, { color: string; 
label: string }> = {
   Go: { color: 'cyan', label: 'Go' },
   Python: { color: 'purple', label: 'Python' },
   Rust: { color: 'orange', label: 'Rust' },
+  Cpp: { color: 'geekblue', label: 'C++' },
+  CSharp: { color: 'magenta', label: 'C#' },
+  NodeJS: { color: 'lime', label: 'Node.js' },
+  PHP: { color: 'gold', label: 'PHP' },
 };
 
+const countBy = (values: string[]) =>
+  [
+    ...values.reduce(
+      (counts, value) => counts.set(value, (counts.get(value) ?? 0) + 1),
+      new Map<string, number>(),
+    ),
+  ]
+    .map(([label, count]) => ({ label, count }))
+    .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
+
 /* ═══════════════════════════════════════════
    ClientsPage
    ═══════════════════════════════════════════ */
 const ClientsPage = () => {
   const { t } = useLang();
+  const { token } = theme.useToken();
   const [connections, setConnections] = useState<ClientConnection[]>([]);
   const [loading, setLoading] = useState(true);
   const [search, setSearch] = useState('');
@@ -100,18 +117,36 @@ const ClientsPage = () => {
     ];
   }, [connections, t]);
 
+  const clusterConnections = useMemo(
+    () =>
+      clusterFilter === 'ALL'
+        ? connections
+        : connections.filter((connection) => connection.clusterName === 
clusterFilter),
+    [connections, clusterFilter],
+  );
+
+  const connectionStats = useMemo(
+    () => ({
+      total: clusterConnections.length,
+      producers: clusterConnections.filter((connection) => connection.type === 
'Producer').length,
+      consumers: clusterConnections.filter((connection) => connection.type === 
'Consumer').length,
+      protocols: countBy(clusterConnections.map((connection) => 
connection.protocol)),
+      languageVersions: countBy(
+        clusterConnections.map((connection) => `${connection.language} 
${connection.version}`),
+      ),
+    }),
+    [clusterConnections],
+  );
+
   /* ─── Filtered data (search + cluster only, table handles column filters) 
─── */
   const filtered = useMemo(() => {
-    let data = connections.filter(
-      (c) => c.clientId.toLowerCase().includes(search.toLowerCase()) || 
c.address.includes(search),
+    const normalizedSearch = search.toLowerCase();
+    return clusterConnections.filter(
+      (connection) =>
+        connection.clientId.toLowerCase().includes(normalizedSearch) ||
+        connection.address.includes(search),
     );
-
-    if (clusterFilter !== 'ALL') {
-      data = data.filter((c) => c.clusterName === clusterFilter);
-    }
-
-    return data;
-  }, [connections, search, clusterFilter]);
+  }, [clusterConnections, search]);
 
   /* ═══════════════════════════════════════════
      Table Columns (with built-in filters)
@@ -205,12 +240,10 @@ const ClientsPage = () => {
       dataIndex: 'language',
       key: 'language',
       width: 100,
-      filters: [
-        { text: 'Java', value: 'Java' },
-        { text: 'Go', value: 'Go' },
-        { text: 'Python', value: 'Python' },
-        { text: 'Rust', value: 'Rust' },
-      ],
+      filters: Object.entries(languageConfig).map(([value, config]) => ({
+        text: config.label,
+        value,
+      })),
       onFilter: (value, record) => record.language === value,
       render: (lang: string) => {
         const cfg = languageConfig[lang] ?? { color: 'default', label: lang };
@@ -268,6 +301,7 @@ const ClientsPage = () => {
       <Flex justify="space-between" align="center" style={{ marginBottom: 16 
}}>
         <Space size={12} wrap>
           <Select
+            aria-label={t('clients.cluster')}
             value={clusterFilter}
             onChange={setClusterFilter}
             style={{ width: 180 }}
@@ -285,6 +319,67 @@ const ClientsPage = () => {
         </Space>
       </Flex>
 
+      <Flex
+        data-testid="connection-statistics"
+        gap={32}
+        align="flex-start"
+        wrap
+        style={{
+          marginBottom: 16,
+          padding: '12px 16px',
+          background: token.colorBgContainer,
+          border: `1px solid ${token.colorBorderSecondary}`,
+          borderRadius: token.borderRadiusLG,
+        }}
+      >
+        <div data-testid="connection-total">
+          <Statistic title={t('clients.title')} value={connectionStats.total} 
/>
+        </div>
+        <div data-testid="producer-total">
+          <Statistic title="Producer" value={connectionStats.producers} />
+        </div>
+        <div data-testid="consumer-total">
+          <Statistic title="Consumer" value={connectionStats.consumers} />
+        </div>
+        <div data-testid="protocol-distribution" style={{ minWidth: 180 }}>
+          <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
+            {t('clients.protocol')}
+          </Text>
+          <Flex gap={4} wrap>
+            {connectionStats.protocols.length > 0 ? (
+              connectionStats.protocols.map(({ label, count }) => (
+                <Tag key={label} color={protocolConfig[label]?.color ?? 
'default'}>
+                  {label}: {count}
+                </Tag>
+              ))
+            ) : (
+              <Text type="secondary">{t('common.noData')}</Text>
+            )}
+          </Flex>
+        </div>
+        <div data-testid="language-version-distribution" style={{ minWidth: 
220 }}>
+          <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
+            {t('clients.language')} / {t('common.version')}
+          </Text>
+          <Flex gap={4} wrap style={{ maxHeight: 76, overflowY: 'auto' }}>
+            {connectionStats.languageVersions.length > 0 ? (
+              connectionStats.languageVersions.map(({ label, count }) => {
+                const [language, ...versionParts] = label.split(' ');
+                const version = versionParts.join(' ');
+                const config = languageConfig[language] ?? { color: 'default', 
label: language };
+                return (
+                  <Tag key={label} color={config.color}>
+                    {config.label} {version}: {count}
+                  </Tag>
+                );
+              })
+            ) : (
+              <Text type="secondary">{t('common.noData')}</Text>
+            )}
+          </Flex>
+        </div>
+      </Flex>
+
       {/* ─── Table ─── */}
       <Card bodyStyle={{ padding: 0 }}>
         <Table

Reply via email to