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 45535312 feat: add producer connection health summary (#1270)
45535312 is described below
commit 455353128ea055c41bad95f846c2c013ee8c1b70
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 10 20:20:08 2026 +0800
feat: add producer connection health summary (#1270)
---
.../cluster/client/ProducerConnectionResultVO.java | 8 +-
...O.java => ProducerConnectionSummaryItemVO.java} | 7 +-
.../client/ProducerConnectionSummaryVO.java | 144 +++++++++++++++++++++
.../client/ProducerConnectionSummaryVOTest.java | 80 ++++++++++++
.../cluster/client/ProducerControllerTest.java | 6 +-
web/src/api/producer.test.ts | 70 +++++++++-
web/src/api/producer.ts | 115 +++++++++++++++-
web/src/i18n/translations.ts | 27 ++++
web/src/pages/studio/Producer.tsx | 111 +++++++++++++++-
web/src/pages/studio/__tests__/Producer.test.tsx | 107 ++++++++++++---
10 files changed, 641 insertions(+), 34 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java
index d99ad97b..5a77d3fc 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java
@@ -16,7 +16,6 @@
*/
package org.apache.rocketmq.studio.cluster.client;
-import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -24,7 +23,12 @@ import java.util.List;
@Data
@NoArgsConstructor
-@AllArgsConstructor
public class ProducerConnectionResultVO {
private List<ProducerConnectionVO> connectionSet;
+ private ProducerConnectionSummaryVO summary;
+
+ public ProducerConnectionResultVO(List<ProducerConnectionVO>
connectionSet) {
+ this.connectionSet = connectionSet == null ? List.of() : connectionSet;
+ this.summary = ProducerConnectionSummaryVO.from(this.connectionSet);
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryItemVO.java
similarity index 89%
copy from
server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java
copy to
server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryItemVO.java
index d99ad97b..d00a3ed3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryItemVO.java
@@ -20,11 +20,10 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
-import java.util.List;
-
@Data
@NoArgsConstructor
@AllArgsConstructor
-public class ProducerConnectionResultVO {
- private List<ProducerConnectionVO> connectionSet;
+public class ProducerConnectionSummaryItemVO {
+ private String value;
+ private long count;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryVO.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryVO.java
new file mode 100644
index 00000000..7294a2e1
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryVO.java
@@ -0,0 +1,144 @@
+/*
+ * 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 lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Data
+@NoArgsConstructor
+public class ProducerConnectionSummaryVO {
+ public static final String READY = "READY";
+ public static final String WARNING = "WARNING";
+ public static final String UNAVAILABLE = "UNAVAILABLE";
+ public static final String NO_CONNECTIONS = "NO_CONNECTIONS";
+ public static final String DUPLICATE_CLIENT_ID = "DUPLICATE_CLIENT_ID";
+ public static final String MIXED_CLIENT_VERSION = "MIXED_CLIENT_VERSION";
+ public static final String INCOMPLETE_CLIENT_METADATA =
"INCOMPLETE_CLIENT_METADATA";
+
+ private int totalConnections;
+ private int uniqueClientCount;
+ private int uniqueAddressCount;
+ private int uniqueLanguageCount;
+ private int uniqueVersionCount;
+ private List<ProducerConnectionSummaryItemVO> languages = List.of();
+ private List<ProducerConnectionSummaryItemVO> versions = List.of();
+ private List<String> duplicateClientIds = List.of();
+ private List<String> warnings = List.of();
+ private String readiness = READY;
+
+ public static ProducerConnectionSummaryVO from(List<ProducerConnectionVO>
connections) {
+ List<ProducerConnectionVO> safeConnections = connections == null ?
List.of() : connections;
+ ProducerConnectionSummaryVO summary = new
ProducerConnectionSummaryVO();
+ summary.totalConnections = safeConnections.size();
+ summary.uniqueClientCount = countDistinct(safeConnections,
ProducerConnectionVO::getClientId);
+ summary.uniqueAddressCount = countDistinct(safeConnections,
ProducerConnectionVO::getClientAddr);
+ summary.languages = distribution(safeConnections,
ProducerConnectionVO::getLanguage);
+ summary.versions = distribution(safeConnections,
ProducerConnectionVO::getVersionDesc);
+ summary.uniqueLanguageCount = summary.languages.size();
+ summary.uniqueVersionCount = summary.versions.size();
+ summary.duplicateClientIds = duplicateClientIds(safeConnections);
+ summary.warnings = warnings(summary, safeConnections);
+ summary.readiness = readiness(summary);
+ return summary;
+ }
+
+ private static int countDistinct(
+ List<ProducerConnectionVO> connections,
Function<ProducerConnectionVO, String> extractor) {
+ return (int) connections.stream()
+ .map(extractor)
+ .filter(ProducerConnectionSummaryVO::hasText)
+ .distinct()
+ .count();
+ }
+
+ private static List<ProducerConnectionSummaryItemVO> distribution(
+ List<ProducerConnectionVO> connections,
Function<ProducerConnectionVO, String> extractor) {
+ return connections.stream()
+ .map(extractor)
+ .map(ProducerConnectionSummaryVO::normalizeDimension)
+ .collect(Collectors.groupingBy(Function.identity(),
Collectors.counting()))
+ .entrySet()
+ .stream()
+ .sorted(Comparator
+ .<Map.Entry<String,
Long>>comparingLong(Map.Entry::getValue)
+ .reversed()
+ .thenComparing(Map.Entry::getKey))
+ .map(entry -> new
ProducerConnectionSummaryItemVO(entry.getKey(), entry.getValue()))
+ .toList();
+ }
+
+ private static List<String> duplicateClientIds(List<ProducerConnectionVO>
connections) {
+ return connections.stream()
+ .map(ProducerConnectionVO::getClientId)
+ .filter(ProducerConnectionSummaryVO::hasText)
+ .collect(Collectors.groupingBy(Function.identity(),
Collectors.counting()))
+ .entrySet()
+ .stream()
+ .filter(entry -> entry.getValue() > 1)
+ .map(Map.Entry::getKey)
+ .sorted()
+ .toList();
+ }
+
+ private static List<String> warnings(
+ ProducerConnectionSummaryVO summary, List<ProducerConnectionVO>
connections) {
+ if (summary.totalConnections == 0) {
+ return List.of(NO_CONNECTIONS);
+ }
+ List<String> warnings = new java.util.ArrayList<>();
+ if (!summary.duplicateClientIds.isEmpty()) {
+ warnings.add(DUPLICATE_CLIENT_ID);
+ }
+ if (summary.uniqueVersionCount > 1) {
+ warnings.add(MIXED_CLIENT_VERSION);
+ }
+ if
(connections.stream().anyMatch(ProducerConnectionSummaryVO::hasIncompleteMetadata))
{
+ warnings.add(INCOMPLETE_CLIENT_METADATA);
+ }
+ return List.copyOf(warnings);
+ }
+
+ private static String readiness(ProducerConnectionSummaryVO summary) {
+ if (summary.totalConnections == 0) {
+ return UNAVAILABLE;
+ }
+ return summary.warnings.isEmpty() ? READY : WARNING;
+ }
+
+ private static boolean hasIncompleteMetadata(ProducerConnectionVO
connection) {
+ return !hasText(connection.getClientId())
+ || !hasText(connection.getClientAddr())
+ || !hasText(connection.getLanguage())
+ || !hasText(connection.getVersionDesc());
+ }
+
+ private static String normalizeDimension(String value) {
+ return hasText(value) ? value.trim() : "UNKNOWN";
+ }
+
+ private static boolean hasText(String value) {
+ return value != null && !value.trim().isEmpty() &&
!Objects.equals(value.trim(), "null");
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryVOTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryVOTest.java
new file mode 100644
index 00000000..24c5eb68
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionSummaryVOTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ProducerConnectionSummaryVOTest {
+
+ @Test
+ void fromShouldMarkEmptyConnectionsUnavailable() {
+ ProducerConnectionSummaryVO summary =
ProducerConnectionSummaryVO.from(List.of());
+
+ assertThat(summary.getTotalConnections()).isZero();
+
assertThat(summary.getReadiness()).isEqualTo(ProducerConnectionSummaryVO.UNAVAILABLE);
+
assertThat(summary.getWarnings()).containsExactly(ProducerConnectionSummaryVO.NO_CONNECTIONS);
+ }
+
+ @Test
+ void fromShouldReportMixedVersionsAndDuplicateClients() {
+ ProducerConnectionSummaryVO summary =
ProducerConnectionSummaryVO.from(List.of(
+ connection("producer-a", "10.0.0.1:38888", "Java", "5.1.0"),
+ connection("producer-a", "10.0.0.2:38888", "Java", "5.1.0"),
+ connection("producer-b", "10.0.0.3:38888", "Go", "5.2.0")));
+
+ assertThat(summary.getTotalConnections()).isEqualTo(3);
+ assertThat(summary.getUniqueClientCount()).isEqualTo(2);
+ assertThat(summary.getUniqueAddressCount()).isEqualTo(3);
+ assertThat(summary.getLanguages())
+ .extracting(ProducerConnectionSummaryItemVO::getValue)
+ .containsExactly("Java", "Go");
+ assertThat(summary.getVersions())
+ .extracting(ProducerConnectionSummaryItemVO::getValue)
+ .containsExactly("5.1.0", "5.2.0");
+
assertThat(summary.getDuplicateClientIds()).containsExactly("producer-a");
+ assertThat(summary.getWarnings()).containsExactly(
+ ProducerConnectionSummaryVO.DUPLICATE_CLIENT_ID,
+ ProducerConnectionSummaryVO.MIXED_CLIENT_VERSION);
+
assertThat(summary.getReadiness()).isEqualTo(ProducerConnectionSummaryVO.WARNING);
+ }
+
+ @Test
+ void fromShouldWarnWhenConnectionMetadataIsIncomplete() {
+ ProducerConnectionSummaryVO summary =
ProducerConnectionSummaryVO.from(List.of(
+ connection("producer-a", "", null, "5.1.0")));
+
+
assertThat(summary.getReadiness()).isEqualTo(ProducerConnectionSummaryVO.WARNING);
+ assertThat(summary.getWarnings()).containsExactly(
+ ProducerConnectionSummaryVO.INCOMPLETE_CLIENT_METADATA);
+ assertThat(summary.getLanguages())
+ .extracting(ProducerConnectionSummaryItemVO::getValue)
+ .containsExactly("UNKNOWN");
+ }
+
+ private ProducerConnectionVO connection(String clientId, String address,
String language, String version) {
+ return ProducerConnectionVO.builder()
+ .clientId(clientId)
+ .clientAddr(address)
+ .language(language)
+ .versionDesc(version)
+ .build();
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerControllerTest.java
index c59b4134..5e824875 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerControllerTest.java
@@ -75,7 +75,11 @@ class ProducerControllerTest {
.andExpect(jsonPath("$.connectionSet[0].clientId").value("producer-1"))
.andExpect(jsonPath("$.connectionSet[0].clientAddr").value("10.0.0.1:38888"))
.andExpect(jsonPath("$.connectionSet[0].language").value("Java"))
-
.andExpect(jsonPath("$.connectionSet[0].versionDesc").value("5.1.0"));
+
.andExpect(jsonPath("$.connectionSet[0].versionDesc").value("5.1.0"))
+ .andExpect(jsonPath("$.summary.totalConnections").value(1))
+ .andExpect(jsonPath("$.summary.uniqueClientCount").value(1))
+ .andExpect(jsonPath("$.summary.uniqueAddressCount").value(1))
+ .andExpect(jsonPath("$.summary.readiness").value("READY"));
verify(producerConnectionService).listConnections("instance-1",
"order-topic", "pg-order");
}
diff --git a/web/src/api/producer.test.ts b/web/src/api/producer.test.ts
index 6303b075..8245cfd1 100644
--- a/web/src/api/producer.test.ts
+++ b/web/src/api/producer.test.ts
@@ -18,7 +18,12 @@
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import client from './client';
-import { fetchProducerGroups, fetchTopicList, queryProducerConnection } from
'./producer';
+import {
+ buildProducerConnectionSummary,
+ fetchProducerGroups,
+ fetchTopicList,
+ queryProducerConnection,
+} from './producer';
const mock = new MockAdapter(client);
@@ -94,14 +99,71 @@ describe('Producer API', () => {
});
const result = await queryProducerConnection('instance-1', 'order-events',
'order-producer');
- expect(result).toHaveLength(2);
- expect(result[0].clientId).toBe('producer-1');
+ expect(result.connectionSet).toHaveLength(2);
+ expect(result.connectionSet[0].clientId).toBe('producer-1');
+ expect(result.summary.totalConnections).toBe(2);
+ expect(result.summary.readiness).toBe('READY');
});
it('handles empty producer connections', async () => {
mock.onGet('/producer/connection').reply(200, { connectionSet: [] });
const result = await queryProducerConnection('instance-1', 'topic',
'group');
- expect(result).toEqual([]);
+ expect(result.connectionSet).toEqual([]);
+ expect(result.summary.readiness).toBe('UNAVAILABLE');
+ expect(result.summary.warnings).toEqual(['NO_CONNECTIONS']);
+ });
+
+ it('uses backend producer connection summaries when provided', async () => {
+ mock.onGet('/producer/connection').reply(200, {
+ connectionSet: [],
+ summary: {
+ totalConnections: 0,
+ uniqueClientCount: 0,
+ uniqueAddressCount: 0,
+ uniqueLanguageCount: 0,
+ uniqueVersionCount: 0,
+ languages: [],
+ versions: [],
+ duplicateClientIds: [],
+ warnings: ['NO_CONNECTIONS'],
+ readiness: 'UNAVAILABLE',
+ },
+ });
+
+ const result = await queryProducerConnection('instance-1', 'topic',
'group');
+ expect(result.summary).toEqual({
+ totalConnections: 0,
+ uniqueClientCount: 0,
+ uniqueAddressCount: 0,
+ uniqueLanguageCount: 0,
+ uniqueVersionCount: 0,
+ languages: [],
+ versions: [],
+ duplicateClientIds: [],
+ warnings: ['NO_CONNECTIONS'],
+ readiness: 'UNAVAILABLE',
+ });
+ });
+
+ it('builds producer connection warning summaries for legacy responses', ()
=> {
+ const result = buildProducerConnectionSummary([
+ {
+ clientId: 'producer-a',
+ clientAddr: '10.0.0.1',
+ language: 'Java',
+ versionDesc: '5.1.0',
+ },
+ {
+ clientId: 'producer-a',
+ clientAddr: '10.0.0.2',
+ language: 'Go',
+ versionDesc: '5.2.0',
+ },
+ ]);
+
+ expect(result.readiness).toBe('WARNING');
+ expect(result.duplicateClientIds).toEqual(['producer-a']);
+ expect(result.warnings).toEqual(['DUPLICATE_CLIENT_ID',
'MIXED_CLIENT_VERSION']);
});
});
diff --git a/web/src/api/producer.ts b/web/src/api/producer.ts
index 61138103..5249a870 100644
--- a/web/src/api/producer.ts
+++ b/web/src/api/producer.ts
@@ -25,6 +25,34 @@ export interface ProducerConnection {
versionDesc: string;
}
+export type ProducerReadiness = 'READY' | 'WARNING' | 'UNAVAILABLE';
+
+export type ProducerConnectionWarning =
+ 'NO_CONNECTIONS' | 'DUPLICATE_CLIENT_ID' | 'MIXED_CLIENT_VERSION' |
'INCOMPLETE_CLIENT_METADATA';
+
+export interface ProducerConnectionSummaryItem {
+ value: string;
+ count: number;
+}
+
+export interface ProducerConnectionSummary {
+ totalConnections: number;
+ uniqueClientCount: number;
+ uniqueAddressCount: number;
+ uniqueLanguageCount: number;
+ uniqueVersionCount: number;
+ languages: ProducerConnectionSummaryItem[];
+ versions: ProducerConnectionSummaryItem[];
+ duplicateClientIds: string[];
+ warnings: ProducerConnectionWarning[];
+ readiness: ProducerReadiness;
+}
+
+export interface ProducerConnectionResult {
+ connectionSet: ProducerConnection[];
+ summary: ProducerConnectionSummary;
+}
+
interface TopicRecord {
name: string;
}
@@ -34,8 +62,85 @@ interface TopicListResponse {
topicList?: string[];
}
+interface ProducerConnectionResponse {
+ connectionSet?: ProducerConnection[];
+ summary?: ProducerConnectionSummary;
+}
+
// ─── API ────────────────────────────────────────────────────────
+const hasText = (value?: string | null) => Boolean(value?.trim() &&
value.trim() !== 'null');
+
+const normalizeDimension = (value?: string | null) => (hasText(value) ?
value!.trim() : 'UNKNOWN');
+
+const countDistinct = (
+ connections: ProducerConnection[],
+ extractor: (connection: ProducerConnection) => string,
+) => new Set(connections.map(extractor).filter(hasText)).size;
+
+const distribution = (
+ connections: ProducerConnection[],
+ extractor: (connection: ProducerConnection) => string,
+): ProducerConnectionSummaryItem[] => {
+ const counts = new Map<string, number>();
+ connections.forEach((connection) => {
+ const value = normalizeDimension(extractor(connection));
+ counts.set(value, (counts.get(value) ?? 0) + 1);
+ });
+ return [...counts]
+ .map(([value, count]) => ({ value, count }))
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
+};
+
+export function buildProducerConnectionSummary(
+ connections: ProducerConnection[],
+): ProducerConnectionSummary {
+ const duplicateClientIds = [
+ ...connections.reduce((counts, connection) => {
+ const clientId = connection.clientId?.trim();
+ if (clientId) counts.set(clientId, (counts.get(clientId) ?? 0) + 1);
+ return counts;
+ }, new Map<string, number>()),
+ ]
+ .filter(([, count]) => count > 1)
+ .map(([clientId]) => clientId)
+ .sort();
+ const languages = distribution(connections, (connection) =>
connection.language);
+ const versions = distribution(connections, (connection) =>
connection.versionDesc);
+ const warnings: ProducerConnectionWarning[] = [];
+
+ if (connections.length === 0) {
+ warnings.push('NO_CONNECTIONS');
+ } else {
+ if (duplicateClientIds.length > 0) warnings.push('DUPLICATE_CLIENT_ID');
+ if (versions.length > 1) warnings.push('MIXED_CLIENT_VERSION');
+ if (
+ connections.some(
+ (connection) =>
+ !hasText(connection.clientId) ||
+ !hasText(connection.clientAddr) ||
+ !hasText(connection.language) ||
+ !hasText(connection.versionDesc),
+ )
+ ) {
+ warnings.push('INCOMPLETE_CLIENT_METADATA');
+ }
+ }
+
+ return {
+ totalConnections: connections.length,
+ uniqueClientCount: countDistinct(connections, (connection) =>
connection.clientId),
+ uniqueAddressCount: countDistinct(connections, (connection) =>
connection.clientAddr),
+ uniqueLanguageCount: languages.length,
+ uniqueVersionCount: versions.length,
+ languages,
+ versions,
+ duplicateClientIds,
+ warnings,
+ readiness: connections.length === 0 ? 'UNAVAILABLE' : warnings.length > 0
? 'WARNING' : 'READY',
+ };
+}
+
/** Fetch topic names for a managed instance. */
export async function fetchTopicList(instanceId: string): Promise<string[]> {
const res = await client.get<TopicListResponse>('/topics', { params: {
instanceId } });
@@ -54,9 +159,13 @@ export async function queryProducerConnection(
instanceId: string,
topic: string,
producerGroup: string,
-): Promise<ProducerConnection[]> {
- const res = await client.get<{ connectionSet: ProducerConnection[]
}>('/producer/connection', {
+): Promise<ProducerConnectionResult> {
+ const res = await
client.get<ProducerConnectionResponse>('/producer/connection', {
params: { instanceId, topic, producerGroup },
});
- return res.data?.connectionSet ?? [];
+ const connectionSet = res.data?.connectionSet ?? [];
+ return {
+ connectionSet,
+ summary: res.data?.summary ??
buildProducerConnectionSummary(connectionSet),
+ };
}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index ec89bf46..e484c0b4 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1161,6 +1161,33 @@ const translations: Record<string, Record<Lang, string>>
= {
en: 'Failed to fetch producer connections',
},
'producer.noConnections': { zh: '暂无生产者连接', en: 'No producer connections
found' },
+ 'producer.readiness': { zh: '生产者连接健康', en: 'Producer connection health' },
+ 'producer.readinessREADY': { zh: '就绪', en: 'Ready' },
+ 'producer.readinessWARNING': { zh: '存在风险', en: 'Warning' },
+ 'producer.readinessUNAVAILABLE': { zh: '无可用连接', en: 'Unavailable' },
+ 'producer.connectionTotal': { zh: '连接数', en: 'Connections' },
+ 'producer.uniqueClients': { zh: '唯一客户端', en: 'Unique clients' },
+ 'producer.uniqueAddresses': { zh: '唯一地址', en: 'Unique addresses' },
+ 'producer.languageKinds': { zh: '语言种类', en: 'Languages' },
+ 'producer.versionKinds': { zh: '版本种类', en: 'Versions' },
+ 'producer.languageDistribution': { zh: '语言分布', en: 'Language distribution' },
+ 'producer.versionDistribution': { zh: '版本分布', en: 'Version distribution' },
+ 'producer.warningNoConnections': {
+ zh: '未发现活跃连接',
+ en: 'No active producer connection',
+ },
+ 'producer.warningDuplicateClientId': {
+ zh: '存在重复 Client ID',
+ en: 'Duplicate client IDs found',
+ },
+ 'producer.warningMixedVersion': {
+ zh: '存在多个客户端版本',
+ en: 'Multiple client versions found',
+ },
+ 'producer.warningIncompleteMetadata': {
+ zh: '连接元数据不完整',
+ en: 'Incomplete connection metadata',
+ },
// ─── Namespace ───
'ns.title': { zh: '命名空间管理', en: 'Namespace Management' },
diff --git a/web/src/pages/studio/Producer.tsx
b/web/src/pages/studio/Producer.tsx
index 132ccd37..7f2a782c 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -16,7 +16,19 @@
*/
import { useEffect, useState } from 'react';
-import { App, AutoComplete, Button, Card, Form, Select, Table } from 'antd';
+import {
+ Alert,
+ App,
+ AutoComplete,
+ Button,
+ Card,
+ Flex,
+ Form,
+ Select,
+ Statistic,
+ Table,
+ Tag,
+} from 'antd';
import { MagnifyingGlass } from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
import {
@@ -24,15 +36,27 @@ import {
fetchTopicList,
queryProducerConnection,
type ProducerConnection,
+ type ProducerConnectionSummary,
+ type ProducerConnectionWarning,
+ type ProducerReadiness,
} from '../../api/producer';
import type { Instance } from '../../api/instance';
import { listInstances } from '../../services/instanceService';
+const readinessConfig: Record<ProducerReadiness, { color: string; type:
'success' | 'warning' }> = {
+ READY: { color: 'success', type: 'success' },
+ WARNING: { color: 'warning', type: 'warning' },
+ UNAVAILABLE: { color: 'error', type: 'warning' },
+};
+
const ProducerPage = () => {
const [form] = Form.useForm();
const [topicList, setTopicList] = useState<string[]>([]);
const [producerGroups, setProducerGroups] = useState<string[]>([]);
const [connectionList, setConnectionList] =
useState<ProducerConnection[]>([]);
+ const [connectionSummary, setConnectionSummary] =
useState<ProducerConnectionSummary | null>(
+ null,
+ );
const [instances, setInstances] = useState<Instance[]>([]);
const [selectedInstanceId, setSelectedInstanceId] = useState('');
const [loading, setLoading] = useState(false);
@@ -129,12 +153,14 @@ const ProducerPage = () => {
}
setLoading(true);
try {
- const connections = await queryProducerConnection(
+ const result = await queryProducerConnection(
selectedInstanceId,
values.selectedTopic,
values.producerGroup,
);
+ const connections = result.connectionSet;
setConnectionList(connections);
+ setConnectionSummary(result.summary);
if (connections.length === 0) {
message.info(t('producer.noConnections'));
}
@@ -167,6 +193,26 @@ const ProducerPage = () => {
},
];
+ const warningLabel: Record<ProducerConnectionWarning, string> = {
+ NO_CONNECTIONS: t('producer.warningNoConnections'),
+ DUPLICATE_CLIENT_ID: t('producer.warningDuplicateClientId'),
+ MIXED_CLIENT_VERSION: t('producer.warningMixedVersion'),
+ INCOMPLETE_CLIENT_METADATA: t('producer.warningIncompleteMetadata'),
+ };
+
+ const renderDistribution = (items: ProducerConnectionSummary['languages']) =>
+ items.length === 0 ? (
+ <Tag>{t('common.noData')}</Tag>
+ ) : (
+ <Flex gap={4} wrap>
+ {items.map((item) => (
+ <Tag key={item.value}>
+ {item.value}: {item.count}
+ </Tag>
+ ))}
+ </Flex>
+ );
+
return (
<div style={{ padding: 0 }}>
<div
@@ -233,10 +279,69 @@ const ProducerPage = () => {
</Form.Item>
</Form>
+ {connectionSummary && (
+ <div style={{ marginBottom: 20 }}>
+ <Alert
+ showIcon
+ type={readinessConfig[connectionSummary.readiness].type}
+ message={
+ <Flex align="center" gap={8} wrap>
+ <span>{t('producer.readiness')}</span>
+ <Tag
color={readinessConfig[connectionSummary.readiness].color}>
+ {t(`producer.readiness${connectionSummary.readiness}`)}
+ </Tag>
+ {connectionSummary.warnings.map((warning) => (
+ <Tag key={warning} color="warning">
+ {warningLabel[warning] ?? warning}
+ </Tag>
+ ))}
+ </Flex>
+ }
+ style={{ marginBottom: 12 }}
+ />
+ <Flex gap={24} wrap style={{ marginBottom: 12 }}>
+ <Statistic
+ title={t('producer.connectionTotal')}
+ value={connectionSummary.totalConnections}
+ />
+ <Statistic
+ title={t('producer.uniqueClients')}
+ value={connectionSummary.uniqueClientCount}
+ />
+ <Statistic
+ title={t('producer.uniqueAddresses')}
+ value={connectionSummary.uniqueAddressCount}
+ />
+ <Statistic
+ title={t('producer.languageKinds')}
+ value={connectionSummary.uniqueLanguageCount}
+ />
+ <Statistic
+ title={t('producer.versionKinds')}
+ value={connectionSummary.uniqueVersionCount}
+ />
+ </Flex>
+ <Flex gap={16} wrap>
+ <div>
+ <div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 6
}}>
+ {t('producer.languageDistribution')}
+ </div>
+ {renderDistribution(connectionSummary.languages)}
+ </div>
+ <div>
+ <div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 6
}}>
+ {t('producer.versionDistribution')}
+ </div>
+ {renderDistribution(connectionSummary.versions)}
+ </div>
+ </Flex>
+ </div>
+ )}
+
<Table
dataSource={connectionList}
columns={columns}
- rowKey="clientId"
+ rowKey={(record) => `${record.clientId}:${record.clientAddr}`}
pagination={false}
bordered
size="middle"
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx
b/web/src/pages/studio/__tests__/Producer.test.tsx
index 5bc65599..22236aa8 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -22,6 +22,8 @@ import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
import ProducerPage from '../Producer';
import {
+ type ProducerConnection,
+ type ProducerConnectionResult,
fetchProducerGroups,
fetchTopicList,
queryProducerConnection,
@@ -62,6 +64,22 @@ const renderWithProviders = (ui: React.ReactElement) => {
);
};
+const producerResult = (connectionSet: ProducerConnection[]):
ProducerConnectionResult => ({
+ connectionSet,
+ summary: {
+ totalConnections: connectionSet.length,
+ uniqueClientCount: new Set(connectionSet.map((connection) =>
connection.clientId)).size,
+ uniqueAddressCount: new Set(connectionSet.map((connection) =>
connection.clientAddr)).size,
+ uniqueLanguageCount: new Set(connectionSet.map((connection) =>
connection.language)).size,
+ uniqueVersionCount: new Set(connectionSet.map((connection) =>
connection.versionDesc)).size,
+ languages: connectionSet.map((connection) => ({ value:
connection.language, count: 1 })),
+ versions: connectionSet.map((connection) => ({ value:
connection.versionDesc, count: 1 })),
+ duplicateClientIds: [],
+ warnings: connectionSet.length === 0 ? ['NO_CONNECTIONS'] : [],
+ readiness: connectionSet.length === 0 ? 'UNAVAILABLE' : 'READY',
+ },
+});
+
describe('ProducerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -80,7 +98,7 @@ describe('ProducerPage', () => {
]);
vi.mocked(fetchTopicList).mockResolvedValue(['order-events',
'payment-events']);
vi.mocked(fetchProducerGroups).mockResolvedValue(['pg-order',
'pg-payment']);
- vi.mocked(queryProducerConnection).mockResolvedValue([]);
+ vi.mocked(queryProducerConnection).mockResolvedValue(producerResult([]));
});
it('loads topic options after mount', async () => {
@@ -117,14 +135,16 @@ describe('ProducerPage', () => {
it('queries producer connections with the required topic and group', async
() => {
const user = userEvent.setup();
- vi.mocked(queryProducerConnection).mockResolvedValue([
- {
- clientId: 'producer-1',
- clientAddr: '192.168.1.10',
- language: 'JAVA',
- versionDesc: '5.1.0',
- },
- ]);
+ vi.mocked(queryProducerConnection).mockResolvedValue(
+ producerResult([
+ {
+ clientId: 'producer-1',
+ clientAddr: '192.168.1.10',
+ language: 'JAVA',
+ versionDesc: '5.1.0',
+ },
+ ]),
+ );
renderWithProviders(<ProducerPage />);
await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
@@ -144,6 +164,8 @@ describe('ProducerPage', () => {
);
});
expect(await screen.findByText('producer-1')).toBeInTheDocument();
+ expect(await screen.findByText('生产者连接健康')).toBeInTheDocument();
+ expect(screen.getByText('就绪')).toBeInTheDocument();
});
it('does not query without a producer group', async () => {
@@ -164,6 +186,55 @@ describe('ProducerPage', () => {
expect(queryProducerConnection).not.toHaveBeenCalled();
});
+ it('renders producer connection warnings from the summary', async () => {
+ const user = userEvent.setup();
+ vi.mocked(queryProducerConnection).mockResolvedValue({
+ connectionSet: [
+ {
+ clientId: 'producer-a',
+ clientAddr: '192.168.1.10',
+ language: 'JAVA',
+ versionDesc: '5.1.0',
+ },
+ {
+ clientId: 'producer-a',
+ clientAddr: '192.168.1.11',
+ language: 'JAVA',
+ versionDesc: '5.2.0',
+ },
+ ],
+ summary: {
+ totalConnections: 2,
+ uniqueClientCount: 1,
+ uniqueAddressCount: 2,
+ uniqueLanguageCount: 1,
+ uniqueVersionCount: 2,
+ languages: [{ value: 'JAVA', count: 2 }],
+ versions: [
+ { value: '5.1.0', count: 1 },
+ { value: '5.2.0', count: 1 },
+ ],
+ duplicateClientIds: ['producer-a'],
+ warnings: ['DUPLICATE_CLIENT_ID', 'MIXED_CLIENT_VERSION'],
+ readiness: 'WARNING',
+ },
+ });
+ renderWithProviders(<ProducerPage />);
+
+ await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
+ const [, topicSelect, groupInput] = screen.getAllByRole('combobox');
+ fireEvent.mouseDown(topicSelect.parentElement!);
+ await user.click(
+ await screen.findByText('order-events', { selector:
'.ant-select-item-option-content' }),
+ );
+ await user.type(groupInput, 'order-producer');
+ await user.click(screen.getByRole('button', { name: /搜索/ }));
+
+ expect(await screen.findByText('存在重复 Client ID')).toBeInTheDocument();
+ expect(screen.getByText('存在多个客户端版本')).toBeInTheDocument();
+ expect(screen.getByText('JAVA: 2')).toBeInTheDocument();
+ });
+
it('keeps manual producer group queries available when suggestions fail',
async () => {
vi.mocked(fetchProducerGroups).mockRejectedValue(new Error('broker
unavailable'));
const user = userEvent.setup();
@@ -213,14 +284,16 @@ describe('ProducerPage', () => {
},
]);
vi.mocked(fetchTopicList).mockResolvedValueOnce(['order-events']).mockResolvedValueOnce([]);
- vi.mocked(queryProducerConnection).mockResolvedValue([
- {
- clientId: 'producer-1',
- clientAddr: '192.168.1.10',
- language: 'JAVA',
- versionDesc: '5.1.0',
- },
- ]);
+ vi.mocked(queryProducerConnection).mockResolvedValue(
+ producerResult([
+ {
+ clientId: 'producer-1',
+ clientAddr: '192.168.1.10',
+ language: 'JAVA',
+ versionDesc: '5.1.0',
+ },
+ ]),
+ );
const user = userEvent.setup();
const { container } = renderWithProviders(<ProducerPage />);