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 8ba7a872e feat(clients): add client connection diagnostics (#3028)
8ba7a872e is described below
commit 8ba7a872e4025c8712138f943f00556341424ded
Author: coder999o <[email protected]>
AuthorDate: Fri Sep 4 15:00:50 2026 +0800
feat(clients): add client connection diagnostics (#3028)
---
web/src/i18n/translations.ts | 25 +
.../pages/cluster/__tests__/ClientsPage.test.tsx | 51 +-
web/src/pages/cluster/clients.tsx | 296 ++++++++++
web/src/utils/clientConnectionDiagnostics.test.ts | 208 +++++++
web/src/utils/clientConnectionDiagnostics.ts | 607 +++++++++++++++++++++
5 files changed, 1184 insertions(+), 3 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index b010c0dd2..0352c2f46 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -248,6 +248,31 @@ const translations: Record<string, Record<Lang, string>> =
{
'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}' },
+ 'clients.diagnostics': { zh: '客户端连接诊断', en: 'Client Connection Diagnostics'
},
+ 'clients.diagnosticHealthyMessage': {
+ zh: '未发现客户端连接风险',
+ en: 'No client connection risk found',
+ },
+ 'clients.diagnosticIssues': { zh: '风险项', en: 'Risks' },
+ 'clients.diagnosticRecommendations': { zh: '处理建议', en: 'Recommendations' },
+ 'clients.diagnosticResources': { zh: '资源数', en: 'Resources' },
+ 'clients.diagnosticMixedProtocols': { zh: '协议混用', en: 'Mixed Protocols' },
+ 'clients.diagnosticVersionSkews': { zh: '版本漂移', en: 'Version Skews' },
+ 'clients.diagnosticSingleConsumers': { zh: '单实例 Group', en: 'Single Groups'
},
+ 'clients.diagnosticUniqueClients': { zh: '唯一 Client', en: 'Unique Clients' },
+ 'clients.diagnosticUniqueAddresses': { zh: '唯一地址', en: 'Unique Addresses' },
+ 'clients.diagnosticClients': { zh: '客户端实例', en: 'Client Instances' },
+ 'clients.diagnosticClientUnit': { zh: '客户端', en: 'clients' },
+ 'clients.diagnosticAddressUnit': { zh: '地址', en: 'addresses' },
+ 'clients.diagnosticSeverity': { zh: '级别', en: 'Severity' },
+ 'clients.diagnosticIssue': { zh: '风险项', en: 'Risk' },
+ 'clients.diagnosticEvidence': { zh: '依据', en: 'Evidence' },
+ 'clients.healthStatusHealthy': { zh: '健康', en: 'Healthy' },
+ 'clients.healthStatusWarning': { zh: '关注', en: 'Warning' },
+ 'clients.healthStatusCritical': { zh: '高风险', en: 'Critical' },
+ 'clients.issueSeverityCritical': { zh: '高风险', en: 'Critical' },
+ 'clients.issueSeverityWarning': { zh: '关注', en: 'Warning' },
+ 'clients.issueSeverityInfo': { zh: '提示', en: 'Info' },
// ─── Alert Rules ───
'alerts.title': { zh: '集群告警', en: 'Cluster Alerts' },
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index e8b5bae57..277762e4e 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -198,6 +198,53 @@ describe('Clients page', () => {
expect(within(languageVersions).getByText('C++ 4.9.8:
1')).toBeInTheDocument();
});
+ it('renders client connection diagnostics for risky inventories', async ()
=> {
+ vi.mocked(connectionsService.listConnections).mockResolvedValue([
+ {
+ ...connection,
+ clientId: 'shared-client',
+ groupOrTopic: 'order-create',
+ address: '10.0.1.12:49152',
+ },
+ {
+ ...connection,
+ clientId: 'shared-client',
+ groupOrTopic: 'order-create',
+ address: '10.0.1.13:49152',
+ },
+ {
+ ...connection,
+ clientId: 'consumer-a',
+ type: 'Consumer',
+ groupOrTopic: 'cg-order',
+ address: '10.0.2.10:49152',
+ protocol: 'gRPC',
+ version: '5.0.7',
+ },
+ {
+ ...connection,
+ clientId: 'consumer-b',
+ type: 'Consumer',
+ groupOrTopic: 'cg-order',
+ address: '10.0.2.11:49152',
+ protocol: 'Remoting',
+ version: '4.9.8',
+ },
+ ]);
+ renderWithProviders(<ClientsPage />);
+
+ const diagnostics = await
screen.findByTestId('client-connection-diagnostics');
+ expect(within(diagnostics).getByText('客户端连接诊断')).toBeInTheDocument();
+ expect(within(diagnostics).getByText('客户端连接存在高风险')).toBeInTheDocument();
+ expect(within(diagnostics).getByText('Client ID
连接到多个地址')).toBeInTheDocument();
+ expect(within(diagnostics).getByText('同一资源存在多协议连接')).toBeInTheDocument();
+ expect(
+ within(diagnostics).getByText(
+ '确认该资源是否处于协议迁移期,并分别检查 Proxy 与 Broker 侧连接状态。',
+ ),
+ ).toBeInTheDocument();
+ });
+
it('updates statistics when the selected cluster filter changes', async ()
=> {
const user = userEvent.setup();
vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
@@ -447,9 +494,7 @@ describe('Clients page', () => {
expect(await screen.findByText('Unable to load registry
clusters')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /重\s*试/ }));
- await waitFor(() =>
- expect(clusterService.listRegistryClusters).toHaveBeenNthCalledWith(2),
- );
+ await waitFor(() =>
expect(clusterService.listRegistryClusters).toHaveBeenNthCalledWith(2));
await act(async () => {
stale.resolve([]);
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index d696550de..98eefcea0 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -24,6 +24,7 @@ import {
Flex,
Input,
Modal,
+ Progress,
Select,
Space,
Statistic,
@@ -45,6 +46,11 @@ import type { ClusterInfo } from '../../api/cluster';
import { formatDateTime } from '../../utils/format';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
import { tableScrollX } from '../../utils/table';
+import {
+ analyzeClientConnections,
+ type ClientConnectionIssue,
+ type ClientResourceSummary,
+} from '../../utils/clientConnectionDiagnostics';
const { Text } = Typography;
const DEFAULT_LOAD_ERROR = '客户端连接加载失败,请稍后重试';
@@ -61,6 +67,30 @@ const protocolConfig: Record<string, { color: string; label:
string }> = {
Remoting: { color: 'blue', label: 'Remoting' },
};
+const healthStatusColor: Record<ClientResourceSummary['status'], string> = {
+ healthy: 'green',
+ warning: 'gold',
+ critical: 'red',
+};
+
+const healthStatusTextKey: Record<ClientResourceSummary['status'], string> = {
+ healthy: 'clients.healthStatusHealthy',
+ warning: 'clients.healthStatusWarning',
+ critical: 'clients.healthStatusCritical',
+};
+
+const issueSeverityColor: Record<ClientConnectionIssue['severity'], string> = {
+ critical: 'red',
+ warning: 'gold',
+ info: 'blue',
+};
+
+const issueSeverityTextKey: Record<ClientConnectionIssue['severity'], string>
= {
+ critical: 'clients.issueSeverityCritical',
+ warning: 'clients.issueSeverityWarning',
+ info: 'clients.issueSeverityInfo',
+};
+
const languageConfig: Record<string, { color: string; label: string }> = {
Java: { color: 'default', label: 'Java' },
Go: { color: 'cyan', label: 'Go' },
@@ -265,6 +295,48 @@ const ClientsPage = () => {
};
}, [clusterConnections]);
+ const clientDiagnostics = useMemo(
+ () => analyzeClientConnections(clusterConnections),
+ [clusterConnections],
+ );
+
+ const diagnosticProgressStatus =
+ clientDiagnostics.status === 'critical'
+ ? 'exception'
+ : clientDiagnostics.status === 'healthy'
+ ? 'success'
+ : 'normal';
+
+ const diagnosticStrokeColor =
+ clientDiagnostics.status === 'critical'
+ ? '#ff4d4f'
+ : clientDiagnostics.status === 'warning'
+ ? '#faad14'
+ : '#52c41a';
+
+ const diagnosticSummaryItems = [
+ {
+ key: 'resources',
+ title: t('clients.diagnosticResources'),
+ value: clientDiagnostics.summary.resourceCount,
+ },
+ {
+ key: 'mixedProtocol',
+ title: t('clients.diagnosticMixedProtocols'),
+ value: clientDiagnostics.summary.mixedProtocolResourceCount,
+ },
+ {
+ key: 'mixedVersion',
+ title: t('clients.diagnosticVersionSkews'),
+ value: clientDiagnostics.summary.mixedVersionResourceCount,
+ },
+ {
+ key: 'singleConsumer',
+ title: t('clients.diagnosticSingleConsumers'),
+ value: clientDiagnostics.summary.singleConsumerGroupCount,
+ },
+ ];
+
/* ─── Filtered data (search + cluster only, table handles column filters)
─── */
const filtered = useMemo(() => {
const normalizedSearch = search.toLowerCase();
@@ -434,6 +506,146 @@ const ClientsPage = () => {
},
];
+ const resourceColumns: ColumnsType<ClientResourceSummary> = [
+ {
+ title: t('common.status'),
+ dataIndex: 'status',
+ key: 'status',
+ width: 90,
+ render: (status: ClientResourceSummary['status']) => (
+ <Tag
color={healthStatusColor[status]}>{t(healthStatusTextKey[status])}</Tag>
+ ),
+ },
+ {
+ title: t('clients.groupOrTopic'),
+ key: 'resource',
+ width: 220,
+ render: (_: unknown, record) => (
+ <Space direction="vertical" size={2}>
+ <Text strong>{record.resource}</Text>
+ <Text type="secondary">{record.type}</Text>
+ </Space>
+ ),
+ },
+ {
+ title: t('clients.diagnosticClients'),
+ key: 'clients',
+ width: 170,
+ render: (_: unknown, record) => (
+ <Space size={4} wrap>
+ <Tag>
+ {record.uniqueClientCount} {t('clients.diagnosticClientUnit')}
+ </Tag>
+ <Tag>
+ {record.uniqueAddressCount} {t('clients.diagnosticAddressUnit')}
+ </Tag>
+ </Space>
+ ),
+ },
+ {
+ title: t('clients.protocol'),
+ dataIndex: 'protocols',
+ key: 'protocols',
+ width: 160,
+ render: (protocols: string[]) => (
+ <Space size={4} wrap>
+ {protocols.map((protocol) => (
+ <Tag key={protocol} color={protocolConfig[protocol]?.color ??
'default'}>
+ {protocol}
+ </Tag>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: `${t('clients.language')} / ${t('common.version')}`,
+ key: 'versions',
+ width: 220,
+ render: (_: unknown, record) => (
+ <Space size={4} wrap>
+ {record.languages.map((language) => (
+ <Tag key={language} color={languageConfig[language]?.color ??
'default'}>
+ {languageConfig[language]?.label ?? language}
+ </Tag>
+ ))}
+ {record.versions.map((version) => (
+ <Text key={version} code>
+ {version}
+ </Text>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: t('clients.diagnosticIssues'),
+ dataIndex: 'issueCount',
+ key: 'issueCount',
+ width: 90,
+ render: (count: number) => <Tag color={count > 0 ? 'gold' :
'green'}>{count}</Tag>,
+ },
+ ];
+
+ const issueColumns: ColumnsType<ClientConnectionIssue> = [
+ {
+ title: t('clients.diagnosticSeverity'),
+ dataIndex: 'severity',
+ key: 'severity',
+ width: 90,
+ render: (severity: ClientConnectionIssue['severity']) => (
+ <Tag
color={issueSeverityColor[severity]}>{t(issueSeverityTextKey[severity])}</Tag>
+ ),
+ },
+ {
+ title: t('clients.diagnosticIssue'),
+ key: 'issue',
+ width: 260,
+ render: (_: unknown, record) => (
+ <Space direction="vertical" size={2}>
+ <Text strong>{record.title}</Text>
+ <Text type="secondary">{record.description}</Text>
+ </Space>
+ ),
+ },
+ {
+ title: t('clients.clientId'),
+ dataIndex: 'clientId',
+ key: 'clientId',
+ width: 180,
+ render: (clientId?: string) =>
+ clientId ? (
+ <Text style={{ fontFamily: 'monospace' }}>{clientId}</Text>
+ ) : (
+ <Text type="secondary">-</Text>
+ ),
+ },
+ {
+ title: t('clients.groupOrTopic'),
+ dataIndex: 'resource',
+ key: 'resource',
+ width: 160,
+ render: (resource?: string) => resource || '-',
+ },
+ {
+ title: t('clients.diagnosticEvidence'),
+ dataIndex: 'evidence',
+ key: 'evidence',
+ width: 220,
+ render: (evidence: string[]) => (
+ <Space size={4} wrap>
+ {evidence.length === 0 ? (
+ <Text type="secondary">-</Text>
+ ) : (
+ evidence.map((item) => (
+ <Text key={item} code>
+ {item}
+ </Text>
+ ))
+ )}
+ </Space>
+ ),
+ },
+ ];
+
/* ═══════════════════════════════════════════
Render
═══════════════════════════════════════════ */
@@ -582,6 +794,90 @@ const ClientsPage = () => {
</div>
</Flex>
+ <div
+ data-testid="client-connection-diagnostics"
+ style={{
+ marginBottom: 16,
+ padding: '16px',
+ background: token.colorBgContainer,
+ border: `1px solid ${token.colorBorderSecondary}`,
+ borderRadius: token.borderRadiusLG,
+ }}
+ >
+ <Flex gap={20} align="center" wrap="wrap" style={{ marginBottom: 16 }}>
+ <Progress
+ type="circle"
+ percent={clientDiagnostics.score}
+ size={92}
+ status={diagnosticProgressStatus}
+ strokeColor={diagnosticStrokeColor}
+ format={(percent) => `${percent}`}
+ />
+ <div style={{ minWidth: 220, flex: '1 1 260px' }}>
+ <Typography.Title level={5} style={{ margin: 0 }}>
+ {t('clients.diagnostics')}
+ </Typography.Title>
+ <Text type="secondary">{clientDiagnostics.statusText}</Text>
+ <div style={{ marginTop: 8 }}>
+ <Tag color={clientDiagnostics.statusColor}>
+ {t('clients.diagnosticIssues')}:
{clientDiagnostics.issues.length}
+ </Tag>
+ <Tag>
+ {t('clients.diagnosticUniqueClients')}:{' '}
+ {clientDiagnostics.summary.uniqueClientCount}
+ </Tag>
+ <Tag>
+ {t('clients.diagnosticUniqueAddresses')}:{' '}
+ {clientDiagnostics.summary.uniqueAddressCount}
+ </Tag>
+ </div>
+ </div>
+ <Flex gap={16} wrap="wrap" style={{ flex: '2 1 440px' }}>
+ {diagnosticSummaryItems.map((item) => (
+ <div key={item.key} style={{ minWidth: 126 }}>
+ <Statistic title={item.title} value={item.value} valueStyle={{
fontSize: 22 }} />
+ </div>
+ ))}
+ </Flex>
+ </Flex>
+
+ <Table<ClientResourceSummary>
+ columns={resourceColumns}
+ dataSource={clientDiagnostics.resources}
+ rowKey="id"
+ pagination={false}
+ size="small"
+ scroll={{ x: tableScrollX(resourceColumns) }}
+ locale={{ emptyText: t('common.noData') }}
+ style={{ marginBottom: 12 }}
+ />
+
+ {clientDiagnostics.issues.length > 0 ? (
+ <Table<ClientConnectionIssue>
+ columns={issueColumns}
+ dataSource={clientDiagnostics.issues}
+ rowKey="id"
+ pagination={false}
+ size="small"
+ scroll={{ x: tableScrollX(issueColumns) }}
+ style={{ marginBottom: 12 }}
+ />
+ ) : (
+ <Alert type="success" showIcon
message={t('clients.diagnosticHealthyMessage')} />
+ )}
+
+ <div style={{ marginTop: 12 }}>
+ <Text strong>{t('clients.diagnosticRecommendations')}</Text>
+ <ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
+ {clientDiagnostics.recommendations.map((item) => (
+ <li key={item}>
+ <Text>{item}</Text>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </div>
+
{/* ─── Table ─── */}
<Card styles={{ body: { padding: 0 } }}>
<Table
diff --git a/web/src/utils/clientConnectionDiagnostics.test.ts
b/web/src/utils/clientConnectionDiagnostics.test.ts
new file mode 100644
index 000000000..30221b91d
--- /dev/null
+++ b/web/src/utils/clientConnectionDiagnostics.test.ts
@@ -0,0 +1,208 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+import type { ClientConnection } from '../api/connections';
+import { analyzeClientConnections } from './clientConnectionDiagnostics';
+
+const connection = (overrides: Partial<ClientConnection>): ClientConnection =>
({
+ clientId: '[email protected]:49152',
+ type: 'Producer',
+ groupOrTopic: 'order-events',
+ protocol: 'gRPC',
+ address: '10.0.1.10:49152',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 08:30:00',
+ clusterName: 'ns-prod',
+ ...overrides,
+});
+
+describe('client connection diagnostics', () => {
+ it('marks a consistent multi-client inventory as healthy', () => {
+ const diagnostics = analyzeClientConnections([
+ connection({ clientId: 'producer-a', address: '10.0.1.10:49152' }),
+ connection({ clientId: 'producer-b', address: '10.0.1.11:49152' }),
+ connection({
+ clientId: 'consumer-a',
+ type: 'Consumer',
+ groupOrTopic: 'cg-order',
+ address: '10.0.2.10:49152',
+ }),
+ connection({
+ clientId: 'consumer-b',
+ type: 'Consumer',
+ groupOrTopic: 'cg-order',
+ address: '10.0.2.11:49152',
+ }),
+ ]);
+
+ expect(diagnostics.status).toBe('healthy');
+ expect(diagnostics.score).toBe(100);
+ expect(diagnostics.summary).toMatchObject({
+ totalConnections: 4,
+ uniqueClientCount: 4,
+ uniqueAddressCount: 4,
+ resourceCount: 2,
+ partialConnectionCount: 0,
+ mixedProtocolResourceCount: 0,
+ mixedVersionResourceCount: 0,
+ singleConsumerGroupCount: 0,
+ });
+ expect(diagnostics.issues).toEqual([]);
+ expect(diagnostics.recommendations).toEqual([
+ '保持客户端连接清单按集群定期巡检,重点关注协议和 SDK 版本收敛。',
+ ]);
+ });
+
+ it('reports an empty client inventory as critical', () => {
+ const diagnostics = analyzeClientConnections([]);
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary.totalConnections).toBe(0);
+ expect(diagnostics.resources).toEqual([]);
+ expect(diagnostics.issues).toEqual([
+ expect.objectContaining({
+ code: 'NO_CONNECTIONS',
+ severity: 'critical',
+ }),
+ ]);
+ });
+
+ it('detects partial scans, client ID collisions, and duplicated records', ()
=> {
+ const diagnostics = analyzeClientConnections([
+ connection({
+ clientId: 'shared-client',
+ groupOrTopic: 'order-events',
+ address: '10.0.1.10:49152',
+ partial: true,
+ }),
+ connection({
+ clientId: 'shared-client',
+ groupOrTopic: 'order-events',
+ address: '10.0.1.11:49152',
+ }),
+ connection({
+ clientId: 'dup-client',
+ groupOrTopic: 'payment-events',
+ address: '10.0.1.12:49152',
+ }),
+ connection({
+ clientId: 'dup-client',
+ groupOrTopic: 'payment-events',
+ address: '10.0.1.12:49152',
+ }),
+ ]);
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary.partialConnectionCount).toBe(1);
+ expect(diagnostics.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: 'PARTIAL_CONNECTION_SCAN',
+ severity: 'warning',
+ evidence: ['partial=1'],
+ }),
+ expect.objectContaining({
+ code: 'CLIENT_ID_COLLISION',
+ severity: 'critical',
+ clientId: 'shared-client',
+ }),
+ expect.objectContaining({
+ code: 'EXACT_DUPLICATE_CONNECTION',
+ severity: 'info',
+ clientId: 'dup-client',
+ }),
+ ]),
+ );
+ });
+
+ it('flags mixed protocols, mixed versions, and single consumer instances per
resource', () => {
+ const diagnostics = analyzeClientConnections([
+ connection({
+ type: 'Consumer',
+ clientId: 'consumer-a',
+ groupOrTopic: 'cg-order',
+ protocol: 'gRPC',
+ language: 'Java',
+ version: '5.0.7',
+ }),
+ connection({
+ type: 'Consumer',
+ clientId: 'consumer-b',
+ groupOrTopic: 'cg-order',
+ protocol: 'Remoting',
+ language: 'Java',
+ version: '4.9.8',
+ address: '10.0.1.11:49152',
+ }),
+ connection({
+ type: 'Consumer',
+ clientId: 'consumer-single',
+ groupOrTopic: 'cg-payment',
+ address: '10.0.2.10:49152',
+ }),
+ ]);
+
+ expect(diagnostics.status).toBe('warning');
+ expect(diagnostics.summary).toMatchObject({
+ mixedProtocolResourceCount: 1,
+ mixedVersionResourceCount: 1,
+ singleConsumerGroupCount: 1,
+ });
+ expect(diagnostics.resources[0]).toMatchObject({
+ type: 'Consumer',
+ resource: 'cg-order',
+ status: 'warning',
+ issueCount: 2,
+ });
+ expect(diagnostics.issues.map((item) => item.code)).toEqual(
+ expect.arrayContaining([
+ 'MIXED_PROTOCOL_RESOURCE',
+ 'MIXED_VERSION_RESOURCE',
+ 'SINGLE_CONSUMER_INSTANCE',
+ ]),
+ );
+ });
+
+ it('detects unknown fields, invalid timestamps, and address concentration',
() => {
+ const diagnostics = analyzeClientConnections([
+ connection({
+ clientId: 'client-a',
+ protocol: 'Custom',
+ language: 'Ruby',
+ version: '-',
+ connectedAt: 'not-a-date',
+ }),
+ connection({ clientId: 'client-b' }),
+ connection({ clientId: 'client-c' }),
+ connection({ clientId: 'client-d' }),
+ ]);
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary.concentratedAddressCount).toBe(1);
+ expect(diagnostics.issues.map((item) => item.code)).toEqual(
+ expect.arrayContaining([
+ 'UNKNOWN_PROTOCOL',
+ 'UNKNOWN_LANGUAGE',
+ 'UNKNOWN_VERSION',
+ 'INVALID_CONNECTION_TIME',
+ 'ADDRESS_CONCENTRATION',
+ ]),
+ );
+ });
+});
diff --git a/web/src/utils/clientConnectionDiagnostics.ts
b/web/src/utils/clientConnectionDiagnostics.ts
new file mode 100644
index 000000000..640fc16eb
--- /dev/null
+++ b/web/src/utils/clientConnectionDiagnostics.ts
@@ -0,0 +1,607 @@
+/*
+ * 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 type { ClientConnection } from '../api/connections';
+
+export type ClientConnectionHealthStatus = 'healthy' | 'warning' | 'critical';
+export type ClientConnectionIssueSeverity =
+ Exclude<ClientConnectionHealthStatus, 'healthy'> | 'info';
+
+export type ClientConnectionIssueCode =
+ | 'NO_CONNECTIONS'
+ | 'PARTIAL_CONNECTION_SCAN'
+ | 'CLIENT_ID_COLLISION'
+ | 'EXACT_DUPLICATE_CONNECTION'
+ | 'MIXED_PROTOCOL_RESOURCE'
+ | 'MIXED_VERSION_RESOURCE'
+ | 'SINGLE_CONSUMER_INSTANCE'
+ | 'ADDRESS_CONCENTRATION'
+ | 'UNKNOWN_PROTOCOL'
+ | 'UNKNOWN_LANGUAGE'
+ | 'UNKNOWN_VERSION'
+ | 'INVALID_CONNECTION_TIME';
+
+export interface ClientConnectionIssue {
+ id: string;
+ code: ClientConnectionIssueCode;
+ severity: ClientConnectionIssueSeverity;
+ title: string;
+ description: string;
+ resource?: string;
+ clientId?: string;
+ evidence: string[];
+ recommendation: string;
+}
+
+export interface ClientResourceSummary {
+ id: string;
+ type: string;
+ resource: string;
+ connectionCount: number;
+ uniqueClientCount: number;
+ uniqueAddressCount: number;
+ protocols: string[];
+ languages: string[];
+ versions: string[];
+ partial: boolean;
+ status: ClientConnectionHealthStatus;
+ issueCount: number;
+}
+
+export interface ClientConnectionHealthSummary {
+ totalConnections: number;
+ uniqueClientCount: number;
+ uniqueAddressCount: number;
+ resourceCount: number;
+ partialConnectionCount: number;
+ mixedProtocolResourceCount: number;
+ mixedVersionResourceCount: number;
+ singleConsumerGroupCount: number;
+ concentratedAddressCount: number;
+}
+
+export interface ClientConnectionDiagnostics {
+ status: ClientConnectionHealthStatus;
+ statusText: string;
+ statusColor: 'success' | 'warning' | 'error';
+ score: number;
+ summary: ClientConnectionHealthSummary;
+ resources: ClientResourceSummary[];
+ issues: ClientConnectionIssue[];
+ recommendations: string[];
+}
+
+type ConnectionGroup = {
+ type: string;
+ resource: string;
+ connections: ClientConnection[];
+};
+
+const STATUS_TEXT: Record<ClientConnectionHealthStatus, string> = {
+ healthy: '客户端连接健康',
+ warning: '客户端连接需要关注',
+ critical: '客户端连接存在高风险',
+};
+
+const STATUS_COLOR: Record<ClientConnectionHealthStatus, 'success' | 'warning'
| 'error'> = {
+ healthy: 'success',
+ warning: 'warning',
+ critical: 'error',
+};
+
+const KNOWN_PROTOCOLS = new Set(['gRPC', 'Remoting']);
+const KNOWN_LANGUAGES = new Set(['Java', 'Go', 'Python', 'Rust', 'Cpp',
'CSharp', 'NodeJS', 'PHP']);
+
+const normalizeText = (value?: string | null, fallback = 'unknown'): string =>
{
+ const trimmed = (value ?? '').trim();
+ return trimmed || fallback;
+};
+
+const uniqueSorted = (values: string[]): string[] =>
+ [...new Set(values.map((value) =>
normalizeText(value)).filter(Boolean))].sort((a, b) =>
+ a.localeCompare(b),
+ );
+
+const countBy = (values: string[]): Map<string, number> => {
+ const counts = new Map<string, number>();
+ values.forEach((value) => {
+ const normalized = normalizeText(value);
+ counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
+ });
+ return counts;
+};
+
+const issue = (
+ code: ClientConnectionIssueCode,
+ severity: ClientConnectionIssueSeverity,
+ title: string,
+ description: string,
+ recommendation: string,
+ options: {
+ resource?: string;
+ clientId?: string;
+ evidence?: string[];
+ id?: string;
+ } = {},
+): ClientConnectionIssue => ({
+ id:
+ options.id ??
+ [options.resource, options.clientId, code, ...(options.evidence ?? [])]
+ .filter(Boolean)
+ .join(':'),
+ code,
+ severity,
+ title,
+ description,
+ resource: options.resource,
+ clientId: options.clientId,
+ evidence: options.evidence ?? [],
+ recommendation,
+});
+
+const connectionIdentity = (connection: ClientConnection): string =>
+ [
+ normalizeText(connection.type),
+ normalizeText(connection.clientId),
+ normalizeText(connection.groupOrTopic),
+ normalizeText(connection.address),
+ ].join('|');
+
+const resourceKey = (connection: ClientConnection): string =>
+
`${normalizeText(connection.type)}:${normalizeText(connection.groupOrTopic)}`;
+
+const groupConnections = (connections: ClientConnection[]): ConnectionGroup[]
=> {
+ const groups = new Map<string, ConnectionGroup>();
+
+ connections.forEach((connection) => {
+ const key = resourceKey(connection);
+ const group = groups.get(key);
+ if (group) {
+ group.connections.push(connection);
+ return;
+ }
+ groups.set(key, {
+ type: normalizeText(connection.type),
+ resource: normalizeText(connection.groupOrTopic),
+ connections: [connection],
+ });
+ });
+
+ return [...groups.values()].sort(
+ (left, right) =>
+ left.type.localeCompare(right.type) ||
left.resource.localeCompare(right.resource),
+ );
+};
+
+const parseTime = (value?: string | null): number | null => {
+ if (!value) return null;
+ const normalized = value.includes('T') ? value : value.replace(' ', 'T');
+ const timestamp = Date.parse(normalized);
+ return Number.isFinite(timestamp) ? timestamp : null;
+};
+
+const resourceSeverity = (issues: ClientConnectionIssue[]):
ClientConnectionHealthStatus => {
+ if (issues.some((item) => item.severity === 'critical')) return 'critical';
+ if (issues.some((item) => item.severity === 'warning')) return 'warning';
+ return 'healthy';
+};
+
+const addInventoryIssues = (connections: ClientConnection[], issues:
ClientConnectionIssue[]) => {
+ if (connections.length === 0) {
+ issues.push(
+ issue(
+ 'NO_CONNECTIONS',
+ 'critical',
+ '未发现客户端连接',
+ '当前 NameServer 查询没有返回任何 Producer 或 Consumer 连接。',
+ '确认目标 NameServer、Proxy 和 Broker 侧客户端注册链路是否正常。',
+ ),
+ );
+ return;
+ }
+
+ const partialCount = connections.filter((connection) =>
connection.partial).length;
+ if (partialCount > 0) {
+ issues.push(
+ issue(
+ 'PARTIAL_CONNECTION_SCAN',
+ 'warning',
+ '客户端扫描结果不完整',
+ '部分 Producer 连接来自受限 Topic 扫描,当前列表可能不是完整客户端清单。',
+ '缩小 Topic 或集群范围后重新查询,并在排障时避免把当前列表视为全集。',
+ { evidence: [`partial=${partialCount}`] },
+ ),
+ );
+ }
+};
+
+const addClientIdIssues = (connections: ClientConnection[], issues:
ClientConnectionIssue[]) => {
+ const connectionsByClient = new Map<string, ClientConnection[]>();
+ const identityCounts = new Map<string, number>();
+
+ connections.forEach((connection) => {
+ const clientId = normalizeText(connection.clientId);
+ const existing = connectionsByClient.get(clientId) ?? [];
+ existing.push(connection);
+ connectionsByClient.set(clientId, existing);
+
+ const identity = connectionIdentity(connection);
+ identityCounts.set(identity, (identityCounts.get(identity) ?? 0) + 1);
+ });
+
+ connectionsByClient.forEach((clientConnections, clientId) => {
+ const addresses = uniqueSorted(clientConnections.map((connection) =>
connection.address));
+ if (addresses.length > 1) {
+ issues.push(
+ issue(
+ 'CLIENT_ID_COLLISION',
+ 'critical',
+ 'Client ID 连接到多个地址',
+ '同一个 Client ID 同时出现在多个远端地址,可能是实例 ID 配置冲突或旧连接未及时清理。',
+ '检查客户端 instanceName/clientId 配置,确保同一进程实例使用唯一标识。',
+ {
+ clientId,
+ evidence: addresses,
+ },
+ ),
+ );
+ }
+ });
+
+ identityCounts.forEach((count, identity) => {
+ if (count <= 1) return;
+ const [, clientId, resource, address] = identity.split('|');
+ issues.push(
+ issue(
+ 'EXACT_DUPLICATE_CONNECTION',
+ 'info',
+ '连接记录重复',
+ '相同客户端、资源和地址出现了重复记录,可能来自采集侧合并或上游返回重复项。',
+ '刷新连接清单;若重复持续存在,检查客户端连接采集路径是否重复汇总。',
+ {
+ clientId,
+ resource,
+ evidence: [`address=${address}`, `count=${count}`],
+ id: `${identity}:EXACT_DUPLICATE_CONNECTION`,
+ },
+ ),
+ );
+ });
+};
+
+const addUnknownFieldIssues = (
+ connections: ClientConnection[],
+ issues: ClientConnectionIssue[],
+) => {
+ connections.forEach((connection, index) => {
+ const clientId = normalizeText(connection.clientId);
+ const resource = normalizeText(connection.groupOrTopic);
+ const protocol = normalizeText(connection.protocol);
+ const language = normalizeText(connection.language);
+ const version = normalizeText(connection.version);
+
+ if (!KNOWN_PROTOCOLS.has(protocol)) {
+ issues.push(
+ issue(
+ 'UNKNOWN_PROTOCOL',
+ 'warning',
+ '协议类型未知',
+ '该客户端连接的协议不在 Studio 已知协议列表中,统计和排障可能不准确。',
+ '确认客户端协议版本和服务端采集字段,必要时补充协议映射。',
+ {
+ clientId,
+ resource,
+ evidence: [`protocol=${protocol}`],
+ id: `${index}:UNKNOWN_PROTOCOL`,
+ },
+ ),
+ );
+ }
+
+ if (!KNOWN_LANGUAGES.has(language)) {
+ issues.push(
+ issue(
+ 'UNKNOWN_LANGUAGE',
+ 'info',
+ '客户端语言未知',
+ '该客户端连接的语言不在 Studio 已知语言列表中。',
+ '确认客户端 SDK 语言和采集字段,必要时补充语言展示映射。',
+ {
+ clientId,
+ resource,
+ evidence: [`language=${language}`],
+ id: `${index}:UNKNOWN_LANGUAGE`,
+ },
+ ),
+ );
+ }
+
+ if (version === 'unknown' || version === '-') {
+ issues.push(
+ issue(
+ 'UNKNOWN_VERSION',
+ 'warning',
+ '客户端版本未知',
+ '该客户端没有上报明确版本,升级治理和兼容性判断缺少依据。',
+ '升级客户端 SDK 或检查版本字段采集,确保连接清单能展示真实客户端版本。',
+ {
+ clientId,
+ resource,
+ evidence: [`version=${version}`],
+ id: `${index}:UNKNOWN_VERSION`,
+ },
+ ),
+ );
+ }
+
+ if (connection.connectedAt && parseTime(connection.connectedAt) === null) {
+ issues.push(
+ issue(
+ 'INVALID_CONNECTION_TIME',
+ 'info',
+ '连接时间无法解析',
+ '该连接的时间字段无法被浏览器解析,排序和人工判断可能受影响。',
+ '统一连接时间格式,优先返回 ISO-8601 或 yyyy-MM-dd HH:mm:ss。',
+ {
+ clientId,
+ resource,
+ evidence: [connection.connectedAt],
+ id: `${index}:INVALID_CONNECTION_TIME`,
+ },
+ ),
+ );
+ }
+ });
+};
+
+const addResourceIssues = (groups: ConnectionGroup[], issues:
ClientConnectionIssue[]) => {
+ groups.forEach((group) => {
+ const protocols = uniqueSorted(group.connections.map((connection) =>
connection.protocol));
+ const versions = uniqueSorted(group.connections.map((connection) =>
connection.version));
+ const languages = uniqueSorted(group.connections.map((connection) =>
connection.language));
+ const clients = uniqueSorted(group.connections.map((connection) =>
connection.clientId));
+ const addresses = uniqueSorted(group.connections.map((connection) =>
connection.address));
+ const resource = `${group.type}:${group.resource}`;
+
+ if (protocols.length > 1) {
+ issues.push(
+ issue(
+ 'MIXED_PROTOCOL_RESOURCE',
+ 'warning',
+ '同一资源存在多协议连接',
+ '同一个 Group 或 Topic 同时存在 gRPC 与 Remoting 客户端,迁移期排障需要区分控制面来源。',
+ '确认该资源是否处于协议迁移期,并分别检查 Proxy 与 Broker 侧连接状态。',
+ {
+ resource,
+ evidence: protocols,
+ },
+ ),
+ );
+ }
+
+ if (versions.length > 1) {
+ issues.push(
+ issue(
+ 'MIXED_VERSION_RESOURCE',
+ 'warning',
+ '同一资源存在多版本客户端',
+ '同一个 Group 或 Topic 内客户端版本不一致,可能导致重试、负载均衡或协议能力差异。',
+ '按资源维度收敛客户端 SDK 版本,升级后再次确认连接清单。',
+ {
+ resource,
+ evidence: versions,
+ },
+ ),
+ );
+ }
+
+ if (group.type === 'Consumer' && clients.length === 1 && addresses.length
=== 1) {
+ issues.push(
+ issue(
+ 'SINGLE_CONSUMER_INSTANCE',
+ 'warning',
+ 'Consumer Group 只有单实例在线',
+ '该 Consumer Group 当前只有一个客户端实例,进程故障会直接影响消费连续性。',
+ '为关键 Consumer Group 保持至少两个实例在线,并确认负载均衡后队列分配正常。',
+ {
+ resource,
+ evidence: [`address=${addresses[0]}`],
+ },
+ ),
+ );
+ }
+
+ if (languages.length > 1 && versions.length > 1) {
+ issues.push(
+ issue(
+ 'MIXED_VERSION_RESOURCE',
+ 'info',
+ '同一资源存在多语言多版本客户端',
+ '该资源由多语言 SDK 共同访问,升级治理和兼容性排查需要同时关注语言与版本。',
+ '记录各语言 SDK 的目标版本矩阵,避免只按单一语言判断升级完成度。',
+ {
+ resource,
+ evidence: [...languages, ...versions],
+ id: `${resource}:MIXED_LANGUAGE_VERSION_RESOURCE`,
+ },
+ ),
+ );
+ }
+ });
+};
+
+const addAddressConcentrationIssues = (
+ connections: ClientConnection[],
+ issues: ClientConnectionIssue[],
+) => {
+ if (connections.length < 4) return;
+
+ const addressCounts = countBy(connections.map((connection) =>
connection.address));
+ const threshold = Math.max(4, Math.ceil(connections.length * 0.5));
+
+ addressCounts.forEach((count, address) => {
+ if (count < threshold) return;
+ issues.push(
+ issue(
+ 'ADDRESS_CONCENTRATION',
+ 'warning',
+ '连接集中在单一地址',
+ '较多客户端连接集中在同一个地址,主机或网关故障会影响多个生产或消费链路。',
+ '检查该地址上的客户端部署密度,必要时拆分实例或调整负载分布。',
+ {
+ evidence: [`${address}: ${count}/${connections.length}`],
+ id: `${address}:ADDRESS_CONCENTRATION`,
+ },
+ ),
+ );
+ });
+};
+
+const issuesForResource = (issues: ClientConnectionIssue[], resource: string)
=>
+ issues.filter((issue) => issue.resource === resource);
+
+const buildResourceSummaries = (
+ groups: ConnectionGroup[],
+ issues: ClientConnectionIssue[],
+): ClientResourceSummary[] =>
+ groups
+ .map((group) => {
+ const resource = `${group.type}:${group.resource}`;
+ const resourceIssues = issuesForResource(issues, resource);
+ return {
+ id: resource,
+ type: group.type,
+ resource: group.resource,
+ connectionCount: group.connections.length,
+ uniqueClientCount: uniqueSorted(group.connections.map((connection) =>
connection.clientId))
+ .length,
+ uniqueAddressCount: uniqueSorted(group.connections.map((connection) =>
connection.address))
+ .length,
+ protocols: uniqueSorted(group.connections.map((connection) =>
connection.protocol)),
+ languages: uniqueSorted(group.connections.map((connection) =>
connection.language)),
+ versions: uniqueSorted(group.connections.map((connection) =>
connection.version)),
+ partial: group.connections.some((connection) => connection.partial),
+ status: resourceSeverity(resourceIssues),
+ issueCount: resourceIssues.length,
+ };
+ })
+ .sort((left, right) => {
+ const statusOrder: Record<ClientConnectionHealthStatus, number> = {
+ critical: 0,
+ warning: 1,
+ healthy: 2,
+ };
+ return (
+ statusOrder[left.status] - statusOrder[right.status] ||
+ right.issueCount - left.issueCount ||
+ left.resource.localeCompare(right.resource)
+ );
+ });
+
+const buildSummary = (
+ connections: ClientConnection[],
+ issues: ClientConnectionIssue[],
+ resources: ClientResourceSummary[],
+): ClientConnectionHealthSummary => {
+ const addressConcentrationIssues = issues.filter((item) => item.code ===
'ADDRESS_CONCENTRATION');
+ return {
+ totalConnections: connections.length,
+ uniqueClientCount: uniqueSorted(connections.map((connection) =>
connection.clientId)).length,
+ uniqueAddressCount: uniqueSorted(connections.map((connection) =>
connection.address)).length,
+ resourceCount: resources.length,
+ partialConnectionCount: connections.filter((connection) =>
connection.partial).length,
+ mixedProtocolResourceCount: resources.filter((resource) =>
resource.protocols.length > 1)
+ .length,
+ mixedVersionResourceCount: resources.filter((resource) =>
resource.versions.length > 1).length,
+ singleConsumerGroupCount: issues.filter((item) => item.code ===
'SINGLE_CONSUMER_INSTANCE')
+ .length,
+ concentratedAddressCount: addressConcentrationIssues.length,
+ };
+};
+
+const scoreDiagnostics = (issues: ClientConnectionIssue[]): number => {
+ const penalty = issues.reduce((sum, item) => {
+ if (item.severity === 'critical') return sum + 24;
+ if (item.severity === 'warning') return sum + 9;
+ return sum + 3;
+ }, 0);
+ return Math.max(0, 100 - penalty);
+};
+
+const statusFromIssues = (
+ issues: ClientConnectionIssue[],
+ score: number,
+): ClientConnectionHealthStatus => {
+ if (issues.some((item) => item.severity === 'critical') || score < 60)
return 'critical';
+ if (issues.some((item) => item.severity === 'warning') || score < 90) return
'warning';
+ return 'healthy';
+};
+
+const buildRecommendations = (issues: ClientConnectionIssue[]): string[] => {
+ const recommendations: string[] = [];
+ const seen = new Set<string>();
+
+ issues.forEach((item) => {
+ if (seen.has(item.recommendation)) return;
+ seen.add(item.recommendation);
+ recommendations.push(item.recommendation);
+ });
+
+ if (recommendations.length === 0) {
+ recommendations.push('保持客户端连接清单按集群定期巡检,重点关注协议和 SDK 版本收敛。');
+ }
+
+ return recommendations.slice(0, 6);
+};
+
+export const analyzeClientConnections = (
+ connections: ClientConnection[],
+): ClientConnectionDiagnostics => {
+ const normalizedConnections = connections.map((connection) => ({
+ ...connection,
+ clientId: normalizeText(connection.clientId),
+ type: normalizeText(connection.type),
+ groupOrTopic: normalizeText(connection.groupOrTopic),
+ protocol: normalizeText(connection.protocol),
+ address: normalizeText(connection.address),
+ language: normalizeText(connection.language),
+ version: normalizeText(connection.version),
+ }));
+ const groups = groupConnections(normalizedConnections);
+ const issues: ClientConnectionIssue[] = [];
+
+ addInventoryIssues(normalizedConnections, issues);
+ addClientIdIssues(normalizedConnections, issues);
+ addUnknownFieldIssues(normalizedConnections, issues);
+ addResourceIssues(groups, issues);
+ addAddressConcentrationIssues(normalizedConnections, issues);
+
+ const resources = buildResourceSummaries(groups, issues);
+ const score = scoreDiagnostics(issues);
+ const status = statusFromIssues(issues, score);
+
+ return {
+ status,
+ statusText: STATUS_TEXT[status],
+ statusColor: STATUS_COLOR[status],
+ score,
+ summary: buildSummary(normalizedConnections, issues, resources),
+ resources,
+ issues,
+ recommendations: buildRecommendations(issues),
+ };
+};