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