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 0849e3f1b feat(topic): add route topology diagnostics (#2836)
0849e3f1b is described below
commit 0849e3f1bb29752a0bacfd6b5aebc2b281e7ca38
Author: coder999o <[email protected]>
AuthorDate: Mon Aug 31 20:26:40 2026 +0800
feat(topic): add route topology diagnostics (#2836)
---
.../studio/instance/topic/BrokerRouteVO.java | 11 +
.../provider/apache/RocketMQMetadataProvider.java | 49 +-
.../studio/instance/topic/TopicControllerTest.java | 26 +-
.../apache/RocketMQMetadataProviderTest.java | 41 ++
web/src/api/metadata.ts | 8 +
web/src/mock/topics.ts | 30 ++
.../pages/instance/__tests__/TopicPage.test.tsx | 57 ++-
web/src/pages/instance/topic.tsx | 320 +++++++++++--
web/src/services/topicService.test.ts | 4 +
web/src/services/topicService.ts | 7 +-
web/src/utils/topicRouteDiagnostics.test.ts | 181 ++++++++
web/src/utils/topicRouteDiagnostics.ts | 509 +++++++++++++++++++++
12 files changed, 1193 insertions(+), 50 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/BrokerRouteVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/BrokerRouteVO.java
index 20234c808..46b0bbb96 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/BrokerRouteVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/BrokerRouteVO.java
@@ -22,6 +22,9 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.util.List;
+import java.util.Map;
+
@Data
@Builder
@NoArgsConstructor
@@ -29,7 +32,15 @@ import lombok.NoArgsConstructor;
public class BrokerRouteVO {
private String brokerName;
private String brokerAddr;
+ private String masterAddr;
+ private Map<Long, String> brokerAddrs;
+ private List<Long> brokerIds;
+ private int replicaCount;
private int writeQueues;
private int readQueues;
private TopicPerm perm;
+ private int permCode;
+ private boolean readable;
+ private boolean writable;
+ private int topicSysFlag;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
index d3ec8ce94..61a199623 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
@@ -66,6 +66,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -417,20 +418,24 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
if (routeData.getQueueDatas() != null) {
for (QueueData qd : routeData.getQueueDatas()) {
BrokerData bd = brokerDataMap.get(qd.getBrokerName());
- String brokerAddr = "";
- if (bd != null && bd.getBrokerAddrs() != null &&
!bd.getBrokerAddrs().isEmpty()) {
- brokerAddr = bd.getBrokerAddrs().get(MixAll.MASTER_ID);
- if (brokerAddr == null) {
- brokerAddr =
bd.getBrokerAddrs().values().iterator().next();
- }
- }
+ Map<Long, String> brokerAddrs = orderedBrokerAddrs(bd);
+ String masterAddr = brokerAddrs.get(MixAll.MASTER_ID);
+ int perm = qd.getPerm();
routes.add(BrokerRouteVO.builder()
.brokerName(qd.getBrokerName())
- .brokerAddr(brokerAddr)
+ .brokerAddr(selectBrokerAddr(brokerAddrs))
+ .masterAddr(masterAddr)
+ .brokerAddrs(brokerAddrs)
+ .brokerIds(new ArrayList<>(brokerAddrs.keySet()))
+ .replicaCount(countReplicaAddrs(brokerAddrs))
.writeQueues(qd.getWriteQueueNums())
.readQueues(qd.getReadQueueNums())
- .perm(mapPerm(qd.getPerm()))
+ .perm(mapPerm(perm))
+ .permCode(perm)
+ .readable(PermName.isReadable(perm))
+ .writable(PermName.isWriteable(perm))
+ .topicSysFlag(qd.getTopicSysFlag())
.build());
}
}
@@ -441,6 +446,32 @@ public class RocketMQMetadataProvider implements
MetadataProvider {
}
}
+ private Map<Long, String> orderedBrokerAddrs(BrokerData brokerData) {
+ if (brokerData == null || brokerData.getBrokerAddrs() == null
+ || brokerData.getBrokerAddrs().isEmpty()) {
+ return Map.of();
+ }
+ Map<Long, String> sorted = new LinkedHashMap<>();
+ brokerData.getBrokerAddrs().entrySet().stream()
+ .sorted(Map.Entry.comparingByKey())
+ .forEach(entry -> sorted.put(entry.getKey(),
entry.getValue()));
+ return sorted;
+ }
+
+ private String selectBrokerAddr(Map<Long, String> brokerAddrs) {
+ String masterAddr = brokerAddrs.get(MixAll.MASTER_ID);
+ if (masterAddr != null) {
+ return masterAddr;
+ }
+ return brokerAddrs.values().stream().findFirst().orElse("");
+ }
+
+ private int countReplicaAddrs(Map<Long, String> brokerAddrs) {
+ return (int) brokerAddrs.keySet().stream()
+ .filter(id -> !Long.valueOf(MixAll.MASTER_ID).equals(id))
+ .count();
+ }
+
@Override
public List<TopicConsumerVO> getTopicConsumers(String instanceId, String
name) {
return getTopicConsumersPage(instanceId, name, 1,
TOPIC_CONSUMER_SCAN_LIMIT).getItems();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
index 4fa2b1c99..31792bef2 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
@@ -18,6 +18,7 @@
package org.apache.rocketmq.studio.instance.topic;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
@@ -92,12 +93,33 @@ class TopicControllerTest {
@Test
void topicRuntimeDiagnosticsShouldPassSelectedInstance() throws Exception {
- when(metadataService.getTopicRoutes("instance-a",
"orders")).thenReturn(List.of());
+ BrokerRouteVO route = BrokerRouteVO.builder()
+ .brokerName("broker-a")
+ .brokerAddr("10.0.0.1:10911")
+ .masterAddr("10.0.0.1:10911")
+ .brokerAddrs(Map.of(0L, "10.0.0.1:10911", 1L,
"10.0.0.2:10911"))
+ .brokerIds(List.of(0L, 1L))
+ .replicaCount(1)
+ .writeQueues(8)
+ .readQueues(8)
+ .perm(TopicPerm.RW)
+ .permCode(6)
+ .readable(true)
+ .writable(true)
+ .topicSysFlag(0)
+ .build();
+ when(metadataService.getTopicRoutes("instance-a",
"orders")).thenReturn(List.of(route));
when(metadataService.getTopicConsumers("instance-a",
"orders")).thenReturn(List.of());
mockMvc.perform(get("/api/topics/orders/routes").param("instanceId",
"instance-a"))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.code").value(200));
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[0].brokerName").value("broker-a"))
+
.andExpect(jsonPath("$.data[0].masterAddr").value("10.0.0.1:10911"))
+ .andExpect(jsonPath("$.data[0].brokerIds[1]").value(1))
+ .andExpect(jsonPath("$.data[0].replicaCount").value(1))
+ .andExpect(jsonPath("$.data[0].readable").value(true))
+ .andExpect(jsonPath("$.data[0].writable").value(true));
mockMvc.perform(get("/api/topics/orders/consumers").param("instanceId",
"instance-a"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
index eec2cbe81..3fdd47f6b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
@@ -280,6 +280,47 @@ class RocketMQMetadataProviderTest {
});
}
+ @Test
+ void getTopicRoutesShouldExposeBrokerTopologyDetails() throws Exception {
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+ QueueData queueData = new QueueData();
+ queueData.setBrokerName("broker-a");
+ queueData.setReadQueueNums(4);
+ queueData.setWriteQueueNums(6);
+ queueData.setPerm(6);
+ queueData.setTopicSysFlag(1);
+
+ HashMap<Long, String> brokerAddrs = new LinkedHashMap<>();
+ brokerAddrs.put(1L, "10.0.0.2:10911");
+ brokerAddrs.put(0L, "10.0.0.1:10911");
+ brokerAddrs.put(2L, "10.0.0.3:10911");
+ BrokerData brokerData = new BrokerData();
+ brokerData.setBrokerName("broker-a");
+ brokerData.setBrokerAddrs(brokerAddrs);
+
+ TopicRouteData routeData = new TopicRouteData();
+ routeData.setQueueDatas(List.of(queueData));
+ routeData.setBrokerDatas(List.of(brokerData));
+ when(admin.examineTopicRouteInfo("TopicA")).thenReturn(routeData);
+
+ List<BrokerRouteVO> routes =
newLiveProvider(admin).getTopicRoutes(null, "TopicA");
+
+ assertThat(routes).singleElement().satisfies(route -> {
+ assertThat(route.getBrokerAddr()).isEqualTo("10.0.0.1:10911");
+ assertThat(route.getMasterAddr()).isEqualTo("10.0.0.1:10911");
+ assertThat(route.getBrokerAddrs()).containsExactly(
+ Map.entry(0L, "10.0.0.1:10911"),
+ Map.entry(1L, "10.0.0.2:10911"),
+ Map.entry(2L, "10.0.0.3:10911"));
+ assertThat(route.getBrokerIds()).containsExactly(0L, 1L, 2L);
+ assertThat(route.getReplicaCount()).isEqualTo(2);
+ assertThat(route.getPermCode()).isEqualTo(6);
+ assertThat(route.isReadable()).isTrue();
+ assertThat(route.isWritable()).isTrue();
+ assertThat(route.getTopicSysFlag()).isEqualTo(1);
+ });
+ }
+
@Test
void getTopicConsumersShouldUseSelectedInstanceRuntimeClient() {
TopicConsumerPageVO consumers = TopicConsumerPageVO.builder()
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index 4096cd38d..118fdf81d 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -33,9 +33,17 @@ export interface TopicQuery {
export interface BrokerRoute {
brokerName: string;
brokerAddr: string;
+ masterAddr?: string;
+ brokerAddrs?: Record<string, string>;
+ brokerIds?: number[];
+ replicaCount?: number;
writeQueues: number;
readQueues: number;
perm: string;
+ permCode?: number;
+ readable?: boolean;
+ writable?: boolean;
+ topicSysFlag?: number;
}
export interface ConsumerGroupInfo {
diff --git a/web/src/mock/topics.ts b/web/src/mock/topics.ts
index 7fe18df0d..22c0edd40 100644
--- a/web/src/mock/topics.ts
+++ b/web/src/mock/topics.ts
@@ -240,9 +240,17 @@ export const topics: Topic[] = [
export interface BrokerRoute {
brokerName: string;
brokerAddr: string;
+ masterAddr?: string;
+ brokerAddrs?: Record<string, string>;
+ brokerIds?: number[];
+ replicaCount?: number;
writeQueues: number;
readQueues: number;
perm: string;
+ permCode?: number;
+ readable?: boolean;
+ writable?: boolean;
+ topicSysFlag?: number;
}
export const topicRoutes: Record<string, BrokerRoute[]> = {
@@ -250,16 +258,38 @@ export const topicRoutes: Record<string, BrokerRoute[]> =
{
{
brokerName: 'broker-a-0',
brokerAddr: '10.0.1.10:10911',
+ masterAddr: '10.0.1.10:10911',
+ brokerAddrs: {
+ '0': '10.0.1.10:10911',
+ '1': '10.0.1.12:10911',
+ },
+ brokerIds: [0, 1],
+ replicaCount: 1,
writeQueues: 8,
readQueues: 8,
perm: 'RW',
+ permCode: 6,
+ readable: true,
+ writable: true,
+ topicSysFlag: 0,
},
{
brokerName: 'broker-b-0',
brokerAddr: '10.0.1.11:10911',
+ masterAddr: '10.0.1.11:10911',
+ brokerAddrs: {
+ '0': '10.0.1.11:10911',
+ '1': '10.0.1.13:10911',
+ },
+ brokerIds: [0, 1],
+ replicaCount: 1,
writeQueues: 8,
readQueues: 8,
perm: 'RW',
+ permCode: 6,
+ readable: true,
+ writable: true,
+ topicSysFlag: 0,
},
],
'user-activity-log': [
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 41ff78f62..df2e54cd0 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -21,7 +21,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
-import type { Topic } from '../../../api/metadata';
+import type { BrokerRoute, Topic } from '../../../api/metadata';
import { parseMessageProperties } from '../../../utils/messageProperties';
import TopicPage from '../topic';
@@ -342,6 +342,61 @@ describe('TopicPage', () => {
expect(topicServiceMocks.getTopicRoutes).toHaveBeenLastCalledWith('topic-01',
'instance-a');
});
+ it('renders topic route health diagnostics in the detail modal', async () =>
{
+ const user = userEvent.setup();
+ const routes: BrokerRoute[] = [
+ {
+ brokerName: 'broker-a',
+ brokerAddr: '',
+ masterAddr: '',
+ brokerAddrs: {
+ '1': '10.0.0.2:10911',
+ },
+ brokerIds: [1],
+ replicaCount: 1,
+ writeQueues: 12,
+ readQueues: 0,
+ perm: 'WO',
+ permCode: 2,
+ readable: false,
+ writable: true,
+ topicSysFlag: 0,
+ },
+ {
+ brokerName: 'broker-b',
+ brokerAddr: '10.0.0.3:10911',
+ masterAddr: '10.0.0.3:10911',
+ brokerAddrs: {
+ '0': '10.0.0.3:10911',
+ },
+ brokerIds: [0],
+ replicaCount: 0,
+ writeQueues: 2,
+ readQueues: 8,
+ perm: 'RW',
+ permCode: 6,
+ readable: true,
+ writable: true,
+ topicSysFlag: 0,
+ },
+ ];
+ mockTopicsList([buildTopics(1)[0]]);
+ topicServiceMocks.getTopicRoutes.mockResolvedValue(routes);
+ renderWithProviders();
+
+ await user.click(await screen.findByRole('button', { name: /详情/ }));
+
+ expect(await screen.findByText('路由诊断:不可用')).toBeInTheDocument();
+ expect(screen.getByText('可写 Broker')).toBeInTheDocument();
+ expect(screen.getByText('14 个写队列')).toBeInTheDocument();
+ expect(screen.getByText('Replica 1')).toBeInTheDocument();
+ expect(screen.getAllByText('写队列分布不均').length).toBeGreaterThan(0);
+ expect(screen.getByText('broker-a:读队列不可用')).toBeInTheDocument();
+ expect(
+ screen.getByText('检查 Broker 是否仍向 NameServer 注册,并确认 master 节点可达。'),
+ ).toBeInTheDocument();
+ });
+
it('keeps failed topics selected after a partially successful batch
deletion', async () => {
const user = userEvent.setup();
mockTopicsList(buildTopics(3));
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 58d4961fc..a93506ae1 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -40,6 +40,7 @@ import {
Spin,
message,
App,
+ Progress,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
@@ -52,6 +53,9 @@ import {
SyncOutlined,
PlusCircleOutlined,
MinusCircleOutlined,
+ CheckCircleOutlined,
+ ExclamationCircleOutlined,
+ WarningOutlined,
} from '@ant-design/icons';
import PageHeader from '../../components/PageHeader';
import InfoBanner from '../../components/InfoBanner';
@@ -81,6 +85,12 @@ import {
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
import { parseMessageProperties } from '../../utils/messageProperties';
import { tableScrollX } from '../../utils/table';
+import {
+ analyzeTopicRoutes,
+ type RouteDiagnosticIssue,
+ type RouteDiagnosticStatus,
+ type RouteDistribution,
+} from '../../utils/topicRouteDiagnostics';
const { Text } = Typography;
@@ -288,6 +298,22 @@ const formatDateTime = (iso?: string): string => {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
+const ROUTE_STATUS_META: Record<
+ RouteDiagnosticStatus,
+ { color: string; label: string; icon: React.ReactNode }
+> = {
+ healthy: { color: 'success', label: '健康', icon: <CheckCircleOutlined /> },
+ warning: { color: 'warning', label: '关注', icon: <WarningOutlined /> },
+ critical: { color: 'error', label: '异常', icon: <ExclamationCircleOutlined />
},
+};
+
+const ISSUE_SEVERITY_COLOR: Record<RouteDiagnosticIssue['severity'], string> =
{
+ warning: 'warning',
+ critical: 'error',
+};
+
+const formatPercent = (value: number) => `${value.toFixed(value % 1 === 0 ? 0
: 1)}%`;
+
// ═══════════════════════════════════════════════════════════════════
const TopicPage = () => {
const { t } = useLang();
@@ -686,17 +712,114 @@ const TopicPage = () => {
},
];
+ const renderRouteStatusTag = (status: RouteDiagnosticStatus) => {
+ const meta = ROUTE_STATUS_META[status];
+ return (
+ <Tag color={meta.color} icon={meta.icon}>
+ {meta.label}
+ </Tag>
+ );
+ };
+
+ const renderRouteIssueTags = (issues: RouteDiagnosticIssue[]) => {
+ if (issues.length === 0) return <Text type="secondary">无</Text>;
+ return (
+ <Space size={[4, 4]} wrap>
+ {issues.slice(0, 3).map((item) => (
+ <Tag key={item.id} color={ISSUE_SEVERITY_COLOR[item.severity]}>
+ {item.title}
+ </Tag>
+ ))}
+ {issues.length > 3 && <Tag>+{issues.length - 3}</Tag>}
+ </Space>
+ );
+ };
+
// ─── Route table columns ──────────────────────────────────────
- const routeColumns: TableColumnsType<BrokerRoute> = [
- { title: 'Broker 名称', dataIndex: 'brokerName', key: 'brokerName' },
- { title: 'Broker 地址', dataIndex: 'brokerAddr', key: 'brokerAddr' },
- { title: '写队列', dataIndex: 'writeQueues', key: 'writeQueues' },
- { title: '读队列', dataIndex: 'readQueues', key: 'readQueues' },
+ const routeColumns: TableColumnsType<RouteDistribution> = [
+ {
+ title: 'Broker',
+ dataIndex: 'brokerName',
+ key: 'brokerName',
+ width: 170,
+ render: (_: string, record) => (
+ <Space direction="vertical" size={2}>
+ <Text strong>{record.brokerName}</Text>
+ {renderRouteStatusTag(record.status)}
+ </Space>
+ ),
+ },
+ {
+ title: '地址拓扑',
+ key: 'brokerAddr',
+ width: 260,
+ render: (_: unknown, record) => (
+ <Space direction="vertical" size={2} style={{ width: '100%' }}>
+ <Text code copyable style={{ fontSize: 14 }}>
+ {record.brokerAddr}
+ </Text>
+ {record.masterAddr && record.masterAddr !== record.brokerAddr && (
+ <Text type="secondary" style={{ fontSize: 14 }}>
+ Master {record.masterAddr}
+ </Text>
+ )}
+ <Space size={4} wrap>
+ {record.brokerIds.length > 0 ? (
+ record.brokerIds.map((id) => (
+ <Tag key={id} color={id === '0' ? 'blue' : undefined}>
+ {id === '0' ? 'Master' : `Replica ${id}`}
+ </Tag>
+ ))
+ ) : (
+ <Tag color="warning">地址未知</Tag>
+ )}
+ </Space>
+ </Space>
+ ),
+ },
+ {
+ title: '队列分布',
+ key: 'queues',
+ width: 220,
+ render: (_: unknown, record) => (
+ <Space direction="vertical" size={4} style={{ width: '100%' }}>
+ <div>
+ <Flex justify="space-between">
+ <Text>写队列 {record.writeQueues}</Text>
+ <Text type="secondary">{formatPercent(record.writeShare)}</Text>
+ </Flex>
+ <Progress percent={record.writeShare} showInfo={false}
size="small" />
+ </div>
+ <div>
+ <Flex justify="space-between">
+ <Text>读队列 {record.readQueues}</Text>
+ <Text type="secondary">{formatPercent(record.readShare)}</Text>
+ </Flex>
+ <Progress percent={record.readShare} showInfo={false} size="small"
/>
+ </div>
+ </Space>
+ ),
+ },
{
title: '权限',
dataIndex: 'perm',
key: 'perm',
- render: (p: string) => <Tag>{PERM_LABEL[p] || p}</Tag>,
+ width: 130,
+ render: (_: string, record) => (
+ <Space direction="vertical" size={4}>
+ <Tag>{PERM_LABEL[record.perm] || record.perm}</Tag>
+ <Space size={4}>
+ <Tag color={record.readable ? 'success' : 'error'}>读</Tag>
+ <Tag color={record.writable ? 'success' : 'error'}>写</Tag>
+ </Space>
+ </Space>
+ ),
+ },
+ {
+ title: '诊断',
+ key: 'diagnostics',
+ width: 220,
+ render: (_: unknown, record) => renderRouteIssueTags(record.issues),
},
];
@@ -747,6 +870,158 @@ const TopicPage = () => {
},
];
+ const renderRouteMetric = (label: string, value: React.ReactNode, extra?:
React.ReactNode) => (
+ <Col xs={12} md={6}>
+ <div
+ style={{
+ border: '1px solid #f0f0f0',
+ borderRadius: 6,
+ padding: '10px 12px',
+ minHeight: 78,
+ background: '#fafafa',
+ }}
+ >
+ <Text type="secondary" style={{ display: 'block', fontSize: 14 }}>
+ {label}
+ </Text>
+ <Text strong style={{ fontSize: 20, fontVariantNumeric: 'tabular-nums'
}}>
+ {value}
+ </Text>
+ {extra && (
+ <div style={{ marginTop: 2 }}>
+ <Text type="secondary" style={{ fontSize: 14 }}>
+ {extra}
+ </Text>
+ </div>
+ )}
+ </div>
+ </Col>
+ );
+
+ const renderRouteIssues = (issues: RouteDiagnosticIssue[]) => {
+ if (issues.length === 0) return null;
+ return (
+ <div
+ data-testid="topic-route-issues"
+ style={{ border: '1px solid #f0f0f0', borderRadius: 6, padding: 12 }}
+ >
+ <Text strong style={{ display: 'block', marginBottom: 8 }}>
+ 诊断项
+ </Text>
+ <Space direction="vertical" size={8} style={{ width: '100%' }}>
+ {issues.map((item) => (
+ <Flex key={item.id} align="flex-start" gap={8}>
+ <Tag color={ISSUE_SEVERITY_COLOR[item.severity]} style={{
marginTop: 1 }}>
+ {item.severity === 'critical' ? '异常' : '关注'}
+ </Tag>
+ <div>
+ <Text strong>
+ {item.brokerName ? `${item.brokerName}:${item.title}` :
item.title}
+ </Text>
+ <Text type="secondary" style={{ display: 'block' }}>
+ {item.description}
+ </Text>
+ </div>
+ </Flex>
+ ))}
+ </Space>
+ </div>
+ );
+ };
+
+ const renderRouteRecommendations = (recommendations: string[]) => {
+ if (recommendations.length === 0) return null;
+ return (
+ <InfoBanner
+ title="建议处理"
+ description={
+ <Space direction="vertical" size={2}>
+ {recommendations.map((item) => (
+ <Text key={item} style={{ fontSize: 14 }}>
+ {item}
+ </Text>
+ ))}
+ </Space>
+ }
+ />
+ );
+ };
+
+ const renderRouteSection = (topic: Topic) => {
+ const routes = getRoutes(topic.name);
+ const diagnostics = analyzeTopicRoutes(routes);
+ const summary = diagnostics.summary;
+
+ return (
+ <>
+ <Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12
}}>
+ 路由信息
+ </Text>
+ {!detailLoading && (
+ <Space direction="vertical" size={12} style={{ width: '100%',
marginBottom: 12 }}>
+ <Alert
+ type={diagnostics.statusColor}
+ showIcon
+ message={`路由诊断:${diagnostics.statusText}`}
+ description={
+ diagnostics.status === 'healthy'
+ ? `共 ${summary.brokerCount} 个 Broker,写队列
${summary.totalWriteQueues} 个,读队列 ${summary.totalReadQueues} 个。`
+ : `发现 ${diagnostics.issues.length} 个诊断项,优先处理异常标记的 Broker。`
+ }
+ action={
+ routes.length === 0 ? (
+ <Button
+ size="small"
+ type="primary"
+ loading={rebuilding}
+ onClick={() => void rebuildTopic(topic)}
+ >
+ 在 Broker 上重建
+ </Button>
+ ) : undefined
+ }
+ />
+ <Row gutter={[12, 12]}>
+ {renderRouteMetric(
+ 'Broker 数',
+ summary.brokerCount,
+ `${summary.addressCount} 个地址`,
+ )}
+ {renderRouteMetric(
+ '可写 Broker',
+ summary.writableBrokerCount,
+ `${summary.totalWriteQueues} 个写队列`,
+ )}
+ {renderRouteMetric(
+ '可读 Broker',
+ summary.readableBrokerCount,
+ `${summary.totalReadQueues} 个读队列`,
+ )}
+ {renderRouteMetric(
+ 'Replica 数',
+ summary.replicaCount,
+ summary.writeSkew.gap > 0 || summary.readSkew.gap > 0
+ ? `队列差距 写 ${summary.writeSkew.gap} / 读
${summary.readSkew.gap}`
+ : '队列均衡',
+ )}
+ </Row>
+ {renderRouteIssues(diagnostics.issues)}
+ {renderRouteRecommendations(diagnostics.recommendations)}
+ </Space>
+ )}
+ <Table<RouteDistribution>
+ columns={routeColumns}
+ dataSource={detailLoading ? [] : diagnostics.distributions}
+ rowKey="key"
+ pagination={false}
+ size="small"
+ loading={detailLoading}
+ scroll={{ x: tableScrollX(routeColumns) }}
+ />
+ </>
+ );
+ };
+
// ─── Modal: detail tab ────────────────────────────────────────
const renderDetailTab = (topic: Topic) => {
const cluster = CLUSTER_NAME_MAP[topic.clusterId];
@@ -1163,7 +1438,7 @@ const TopicPage = () => {
title={selectedTopic?.name}
open={detailModalOpen}
onCancel={() => setDetailModalOpen(false)}
- width={800}
+ width={1080}
destroyOnHidden
footer={null}
>
@@ -1180,36 +1455,7 @@ const TopicPage = () => {
<Divider style={{ margin: '20px 0 16px' }} />
{/* Section 2: 路由信息 */}
- <Text strong style={{ fontSize: 14, display: 'block',
marginBottom: 12 }}>
- 路由信息
- </Text>
- {!detailLoading && getRoutes(selectedTopic.name).length === 0
&& (
- <Alert
- type="warning"
- showIcon
- style={{ marginBottom: 12 }}
- message="Broker 上没有该 Topic 的路由"
- description="元数据库中存在这条记录,但 Broker 未返回路由信息,可能尚未在 Broker
上创建或已被删除。可按库中记录的队列数重建。"
- action={
- <Button
- size="small"
- type="primary"
- loading={rebuilding}
- onClick={() => void rebuildTopic(selectedTopic)}
- >
- 在 Broker 上重建
- </Button>
- }
- />
- )}
- <Table<BrokerRoute>
- columns={routeColumns}
- dataSource={getRoutes(selectedTopic.name)}
- rowKey="brokerName"
- pagination={false}
- size="small"
- loading={detailLoading}
- />
+ {renderRouteSection(selectedTopic)}
</>
)}
diff --git a/web/src/services/topicService.test.ts
b/web/src/services/topicService.test.ts
index b6a381e8e..43af8c550 100644
--- a/web/src/services/topicService.test.ts
+++ b/web/src/services/topicService.test.ts
@@ -46,9 +46,13 @@ describe('topic service mock data', () => {
expect(first[0].brokerName).toBe('broker-a-0');
first[0].brokerName = 'mutated-broker';
+ if (first[0].brokerAddrs) first[0].brokerAddrs['0'] = '127.0.0.1:10911';
+ if (first[0].brokerIds) first[0].brokerIds.push(99);
const second = await getTopicRoutes('order-create');
expect(second[0].brokerName).toBe('broker-a-0');
+ expect(second[0].brokerAddrs?.['0']).toBe('10.0.1.10:10911');
+ expect(second[0].brokerIds).toEqual([0, 1]);
expect(second[0]).not.toBe(first[0]);
});
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 5843c2e63..2423dc36c 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -16,7 +16,12 @@ const EXPORT_PAGE_SIZE = 100;
const MAX_EXPORT_PAGES = 100;
const cloneTopic = (topic: Topic): Topic => ({ ...topic });
-const cloneRoutes = (routes: BrokerRoute[]): BrokerRoute[] =>
routes.map((route) => ({ ...route }));
+const cloneRoutes = (routes: BrokerRoute[]): BrokerRoute[] =>
+ routes.map((route) => ({
+ ...route,
+ brokerAddrs: route.brokerAddrs ? { ...route.brokerAddrs } : undefined,
+ brokerIds: route.brokerIds ? [...route.brokerIds] : undefined,
+ }));
const cloneConsumers = (consumers: ConsumerGroupInfo[]): ConsumerGroupInfo[] =>
consumers.map((consumer) => ({ ...consumer }));
diff --git a/web/src/utils/topicRouteDiagnostics.test.ts
b/web/src/utils/topicRouteDiagnostics.test.ts
new file mode 100644
index 000000000..fd05fdec4
--- /dev/null
+++ b/web/src/utils/topicRouteDiagnostics.test.ts
@@ -0,0 +1,181 @@
+/*
+ * 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 { BrokerRoute } from '../api/metadata';
+import { analyzeTopicRoutes } from './topicRouteDiagnostics';
+
+const route = (overrides: Partial<BrokerRoute>): BrokerRoute => ({
+ brokerName: 'broker-a',
+ brokerAddr: '10.0.0.1:10911',
+ masterAddr: '10.0.0.1:10911',
+ brokerAddrs: {
+ '0': '10.0.0.1:10911',
+ '1': '10.0.0.2:10911',
+ },
+ brokerIds: [0, 1],
+ replicaCount: 1,
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ permCode: 6,
+ readable: true,
+ writable: true,
+ topicSysFlag: 0,
+ ...overrides,
+});
+
+describe('topic route diagnostics', () => {
+ it('summarizes balanced readable and writable routes as healthy', () => {
+ const diagnostics = analyzeTopicRoutes([
+ route({ brokerName: 'broker-a' }),
+ route({
+ brokerName: 'broker-b',
+ brokerAddr: '10.0.1.1:10911',
+ masterAddr: '10.0.1.1:10911',
+ brokerAddrs: {
+ '0': '10.0.1.1:10911',
+ '1': '10.0.1.2:10911',
+ },
+ }),
+ ]);
+
+ expect(diagnostics.status).toBe('healthy');
+ expect(diagnostics.summary).toMatchObject({
+ brokerCount: 2,
+ addressCount: 4,
+ replicaCount: 2,
+ writableBrokerCount: 2,
+ readableBrokerCount: 2,
+ totalWriteQueues: 16,
+ totalReadQueues: 16,
+ });
+ expect(diagnostics.distributions.map((item) =>
item.writeShare)).toEqual([50, 50]);
+ expect(diagnostics.issues).toEqual([]);
+ expect(diagnostics.recommendations).toEqual([]);
+ });
+
+ it('returns a critical diagnostic when the broker route is missing', () => {
+ const diagnostics = analyzeTopicRoutes([]);
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary.brokerCount).toBe(0);
+ expect(diagnostics.distributions).toEqual([]);
+ expect(diagnostics.issues).toEqual([
+ expect.objectContaining({
+ code: 'NO_ROUTE',
+ severity: 'critical',
+ }),
+ ]);
+ expect(diagnostics.recommendations).toContain(
+ '确认 Topic 已在目标 Broker 上创建;必要时使用“在 Broker 上重建”。',
+ );
+ });
+
+ it('flags route risks from permissions, queues, skew, and stale addresses',
() => {
+ const diagnostics = analyzeTopicRoutes([
+ route({
+ brokerName: 'broker-a',
+ brokerAddr: '',
+ masterAddr: '',
+ brokerAddrs: {
+ '1': '10.0.0.2:10911',
+ },
+ brokerIds: [1],
+ writeQueues: 12,
+ readQueues: 0,
+ perm: 'WO',
+ permCode: 2,
+ readable: false,
+ writable: true,
+ }),
+ route({
+ brokerName: 'broker-b',
+ brokerAddr: '10.0.0.2:10911',
+ masterAddr: '10.0.0.2:10911',
+ brokerAddrs: {
+ '0': '10.0.0.2:10911',
+ },
+ brokerIds: [0],
+ writeQueues: 2,
+ readQueues: 8,
+ }),
+ ]);
+
+ const issueCodes = diagnostics.issues.map((item) => item.code);
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary.writeSkew).toEqual({ gap: 10, ratio: 1.43 });
+ expect(diagnostics.summary.readSkew).toEqual({ gap: 0, ratio: 0 });
+ expect(issueCodes).toEqual(
+ expect.arrayContaining([
+ 'MISSING_MASTER_ADDRESS',
+ 'READ_QUEUE_UNAVAILABLE',
+ 'PERMISSION_NOT_READABLE',
+ 'READ_WRITE_QUEUE_MISMATCH',
+ 'WRITE_QUEUE_SKEW',
+ 'DUPLICATE_BROKER_ADDRESS',
+ ]),
+ );
+ expect(diagnostics.distributions[0]).toMatchObject({
+ brokerName: 'broker-a',
+ brokerAddr: '10.0.0.2:10911',
+ readable: false,
+ writable: true,
+ status: 'critical',
+ });
+ expect(diagnostics.recommendations).toEqual(
+ expect.arrayContaining([
+ '检查 Broker 是否仍向 NameServer 注册,并确认 master 节点可达。',
+ '对比各 Broker 上的 TopicConfig,统一读写队列数后再观察客户端路由。',
+ '评估是否需要扩容、迁移或重新分配队列,降低单 Broker 负载集中风险。',
+ ]),
+ );
+ });
+
+ it('infers read and write permissions from legacy route payloads', () => {
+ const diagnostics = analyzeTopicRoutes([
+ route({
+ brokerName: 'broker-readonly',
+ readable: undefined,
+ writable: undefined,
+ perm: 'RO',
+ }),
+ route({
+ brokerName: 'broker-writeonly',
+ readable: undefined,
+ writable: undefined,
+ perm: 'WO',
+ }),
+ ]);
+
+ expect(diagnostics.status).toBe('warning');
+ expect(diagnostics.distributions[0]).toMatchObject({
+ brokerName: 'broker-readonly',
+ readable: true,
+ writable: false,
+ });
+ expect(diagnostics.distributions[1]).toMatchObject({
+ brokerName: 'broker-writeonly',
+ readable: false,
+ writable: true,
+ });
+ expect(diagnostics.issues.map((item) => item.code)).toEqual(
+ expect.arrayContaining(['PERMISSION_NOT_WRITABLE',
'PERMISSION_NOT_READABLE']),
+ );
+ });
+});
diff --git a/web/src/utils/topicRouteDiagnostics.ts
b/web/src/utils/topicRouteDiagnostics.ts
new file mode 100644
index 000000000..d273dd15e
--- /dev/null
+++ b/web/src/utils/topicRouteDiagnostics.ts
@@ -0,0 +1,509 @@
+/*
+ * 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 { BrokerRoute } from '../api/metadata';
+
+export type RouteDiagnosticStatus = 'healthy' | 'warning' | 'critical';
+
+export type RouteIssueCode =
+ | 'NO_ROUTE'
+ | 'MISSING_BROKER_ADDRESS'
+ | 'MISSING_MASTER_ADDRESS'
+ | 'NO_WRITABLE_ROUTE'
+ | 'NO_READABLE_ROUTE'
+ | 'WRITE_QUEUE_UNAVAILABLE'
+ | 'READ_QUEUE_UNAVAILABLE'
+ | 'PERMISSION_NOT_WRITABLE'
+ | 'PERMISSION_NOT_READABLE'
+ | 'READ_WRITE_QUEUE_MISMATCH'
+ | 'WRITE_QUEUE_SKEW'
+ | 'READ_QUEUE_SKEW'
+ | 'SINGLE_BROKER_ROUTE'
+ | 'DUPLICATE_BROKER_ADDRESS';
+
+export interface RouteDiagnosticIssue {
+ id: string;
+ code: RouteIssueCode;
+ severity: Exclude<RouteDiagnosticStatus, 'healthy'>;
+ title: string;
+ description: string;
+ brokerName?: string;
+}
+
+export interface RouteQueueSkew {
+ gap: number;
+ ratio: number;
+}
+
+export interface RouteDistribution {
+ key: string;
+ brokerName: string;
+ brokerAddr: string;
+ masterAddr: string;
+ brokerIds: string[];
+ replicaCount: number;
+ writeQueues: number;
+ readQueues: number;
+ writeShare: number;
+ readShare: number;
+ perm: string;
+ readable: boolean;
+ writable: boolean;
+ topicSysFlag?: number;
+ status: RouteDiagnosticStatus;
+ issues: RouteDiagnosticIssue[];
+}
+
+export interface RouteDiagnosticsSummary {
+ brokerCount: number;
+ addressCount: number;
+ replicaCount: number;
+ writableBrokerCount: number;
+ readableBrokerCount: number;
+ totalWriteQueues: number;
+ totalReadQueues: number;
+ writeSkew: RouteQueueSkew;
+ readSkew: RouteQueueSkew;
+}
+
+export interface TopicRouteDiagnostics {
+ status: RouteDiagnosticStatus;
+ statusText: string;
+ statusColor: 'success' | 'warning' | 'error';
+ summary: RouteDiagnosticsSummary;
+ distributions: RouteDistribution[];
+ issues: RouteDiagnosticIssue[];
+ recommendations: string[];
+}
+
+const STATUS_ORDER: Record<RouteDiagnosticStatus, number> = {
+ healthy: 0,
+ warning: 1,
+ critical: 2,
+};
+
+const STATUS_TEXT: Record<RouteDiagnosticStatus, string> = {
+ healthy: '路由健康',
+ warning: '需要关注',
+ critical: '不可用',
+};
+
+const STATUS_COLOR: Record<RouteDiagnosticStatus, 'success' | 'warning' |
'error'> = {
+ healthy: 'success',
+ warning: 'warning',
+ critical: 'error',
+};
+
+const EMPTY_SKEW: RouteQueueSkew = { gap: 0, ratio: 0 };
+
+const issue = (
+ code: RouteIssueCode,
+ severity: Exclude<RouteDiagnosticStatus, 'healthy'>,
+ title: string,
+ description: string,
+ brokerName?: string,
+): RouteDiagnosticIssue => ({
+ id: brokerName ? `${brokerName}:${code}` : code,
+ code,
+ severity,
+ title,
+ description,
+ brokerName,
+});
+
+const queueCount = (value: number | undefined): number =>
+ Number.isFinite(value) && value && value > 0 ? value : 0;
+
+const queueShare = (value: number, total: number): number =>
+ total <= 0 ? 0 : Math.round((value / total) * 1000) / 10;
+
+const inferReadable = (route: BrokerRoute): boolean => {
+ if (typeof route.readable === 'boolean') return route.readable;
+ return route.perm === 'RW' || route.perm === 'RO';
+};
+
+const inferWritable = (route: BrokerRoute): boolean => {
+ if (typeof route.writable === 'boolean') return route.writable;
+ return route.perm === 'RW' || route.perm === 'WO';
+};
+
+const routeBrokerIds = (route: BrokerRoute): string[] => {
+ if (route.brokerIds && route.brokerIds.length > 0) {
+ return route.brokerIds.map(String).sort((left, right) => Number(left) -
Number(right));
+ }
+ if (route.brokerAddrs) {
+ return Object.keys(route.brokerAddrs).sort((left, right) => Number(left) -
Number(right));
+ }
+ return [];
+};
+
+const routeAddresses = (route: BrokerRoute): string[] =>
+ Object.values(route.brokerAddrs ?? {}).filter((addr) => addr.trim().length >
0);
+
+const preferredBrokerAddr = (route: BrokerRoute): string => {
+ if (route.brokerAddr) return route.brokerAddr;
+ if (route.masterAddr) return route.masterAddr;
+ return routeAddresses(route)[0] ?? '';
+};
+
+const routeReplicaCount = (route: BrokerRoute, brokerIds: string[]): number =>
{
+ if (typeof route.replicaCount === 'number') return Math.max(0,
route.replicaCount);
+ return brokerIds.filter((id) => id !== '0').length;
+};
+
+const calculateSkew = (values: number[]): RouteQueueSkew => {
+ const activeValues = values.filter((value) => value > 0);
+ if (activeValues.length <= 1) return EMPTY_SKEW;
+
+ const max = Math.max(...activeValues);
+ const min = Math.min(...activeValues);
+ const gap = max - min;
+ if (gap === 0) return EMPTY_SKEW;
+
+ const average = activeValues.reduce((sum, value) => sum + value, 0) /
activeValues.length;
+ return { gap, ratio: average === 0 ? 0 : Math.round((gap / average) * 100) /
100 };
+};
+
+const maxStatus = (issues: RouteDiagnosticIssue[]): RouteDiagnosticStatus =>
+ issues.reduce<RouteDiagnosticStatus>(
+ (status, current) =>
+ STATUS_ORDER[current.severity] > STATUS_ORDER[status] ? current.severity
: status,
+ 'healthy',
+ );
+
+const hasSkew = (skew: RouteQueueSkew): boolean => skew.gap > 0 && skew.ratio
>= 0.5;
+
+const collectAddressDuplicates = (routes: BrokerRoute[]): Set<string> => {
+ const firstBrokerByAddr = new Map<string, string>();
+ const duplicates = new Set<string>();
+
+ routes.forEach((route) => {
+ const brokerName = route.brokerName || 'unknown';
+ routeAddresses(route).forEach((addr) => {
+ const firstBroker = firstBrokerByAddr.get(addr);
+ if (firstBroker && firstBroker !== brokerName) {
+ duplicates.add(addr);
+ } else {
+ firstBrokerByAddr.set(addr, brokerName);
+ }
+ });
+ });
+
+ return duplicates;
+};
+
+const distributionIssues = (
+ route: BrokerRoute,
+ writeSkew: RouteQueueSkew,
+ readSkew: RouteQueueSkew,
+ duplicateAddresses: Set<string>,
+): RouteDiagnosticIssue[] => {
+ const brokerName = route.brokerName || 'unknown';
+ const writeQueues = queueCount(route.writeQueues);
+ const readQueues = queueCount(route.readQueues);
+ const readable = inferReadable(route);
+ const writable = inferWritable(route);
+ const brokerAddr = preferredBrokerAddr(route);
+ const masterAddr = route.masterAddr ?? route.brokerAddrs?.['0'] ?? '';
+ const issues: RouteDiagnosticIssue[] = [];
+
+ if (!brokerAddr) {
+ issues.push(
+ issue(
+ 'MISSING_BROKER_ADDRESS',
+ 'critical',
+ 'Broker 地址缺失',
+ 'NameServer 返回了队列元数据,但没有返回可用于定位 Broker 的地址。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (route.brokerAddrs && Object.keys(route.brokerAddrs).length > 0 &&
!masterAddr) {
+ issues.push(
+ issue(
+ 'MISSING_MASTER_ADDRESS',
+ 'warning',
+ 'Master 地址缺失',
+ '该 Broker 只返回了非 master 地址,Topic 写入链路需要确认 master 是否在线。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (writeQueues === 0) {
+ issues.push(
+ issue(
+ 'WRITE_QUEUE_UNAVAILABLE',
+ 'critical',
+ '写队列不可用',
+ '该 Broker 没有可写队列,生产者不会把消息写到这个 Broker。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (readQueues === 0) {
+ issues.push(
+ issue(
+ 'READ_QUEUE_UNAVAILABLE',
+ 'critical',
+ '读队列不可用',
+ '该 Broker 没有可读队列,消费者不会从这个 Broker 拉取消息。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (!writable) {
+ issues.push(
+ issue(
+ 'PERMISSION_NOT_WRITABLE',
+ 'warning',
+ '权限不允许写入',
+ 'Topic 权限缺少写权限,生产者发送可能失败或被路由到其他 Broker。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (!readable) {
+ issues.push(
+ issue(
+ 'PERMISSION_NOT_READABLE',
+ 'warning',
+ '权限不允许读取',
+ 'Topic 权限缺少读权限,消费者订阅后可能无法正常消费。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (writeQueues !== readQueues) {
+ issues.push(
+ issue(
+ 'READ_WRITE_QUEUE_MISMATCH',
+ 'warning',
+ '读写队列不一致',
+ '该 Broker 的读队列数和写队列数不同,扩缩容或迁移后需要确认配置是否符合预期。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (hasSkew(writeSkew)) {
+ issues.push(
+ issue(
+ 'WRITE_QUEUE_SKEW',
+ 'warning',
+ '写队列分布不均',
+ '不同 Broker 的写队列数差距较大,生产流量可能无法均匀分摊。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (hasSkew(readSkew)) {
+ issues.push(
+ issue(
+ 'READ_QUEUE_SKEW',
+ 'warning',
+ '读队列分布不均',
+ '不同 Broker 的读队列数差距较大,消费者负载可能无法均匀分摊。',
+ brokerName,
+ ),
+ );
+ }
+
+ if (routeAddresses(route).some((addr) => duplicateAddresses.has(addr))) {
+ issues.push(
+ issue(
+ 'DUPLICATE_BROKER_ADDRESS',
+ 'warning',
+ 'Broker 地址重复',
+ '多个 BrokerName 返回了相同地址,请确认 NameServer 注册信息是否过期。',
+ brokerName,
+ ),
+ );
+ }
+
+ return issues;
+};
+
+const buildRecommendations = (issues: RouteDiagnosticIssue[]): string[] => {
+ const actions: string[] = [];
+ const codes = new Set(issues.map((item) => item.code));
+
+ if (codes.has('NO_ROUTE')) {
+ actions.push('确认 Topic 已在目标 Broker 上创建;必要时使用“在 Broker 上重建”。');
+ }
+ if (codes.has('MISSING_BROKER_ADDRESS') ||
codes.has('MISSING_MASTER_ADDRESS')) {
+ actions.push('检查 Broker 是否仍向 NameServer 注册,并确认 master 节点可达。');
+ }
+ if (codes.has('NO_WRITABLE_ROUTE') || codes.has('PERMISSION_NOT_WRITABLE')) {
+ actions.push('确认 Topic 权限包含写权限,避免生产者发送失败。');
+ }
+ if (codes.has('NO_READABLE_ROUTE') || codes.has('PERMISSION_NOT_READABLE')) {
+ actions.push('确认 Topic 权限包含读权限,避免消费者订阅后无可读队列。');
+ }
+ if (
+ codes.has('WRITE_QUEUE_UNAVAILABLE') ||
+ codes.has('READ_QUEUE_UNAVAILABLE') ||
+ codes.has('READ_WRITE_QUEUE_MISMATCH')
+ ) {
+ actions.push('对比各 Broker 上的 TopicConfig,统一读写队列数后再观察客户端路由。');
+ }
+ if (codes.has('WRITE_QUEUE_SKEW') || codes.has('READ_QUEUE_SKEW')) {
+ actions.push('评估是否需要扩容、迁移或重新分配队列,降低单 Broker 负载集中风险。');
+ }
+ if (codes.has('SINGLE_BROKER_ROUTE')) {
+ actions.push('确认该 Topic 是否预期只部署在单 Broker;生产业务建议准备冗余路由。');
+ }
+ if (codes.has('DUPLICATE_BROKER_ADDRESS')) {
+ actions.push('清理过期 Broker 注册信息,避免客户端拿到重复或错误地址。');
+ }
+
+ return actions;
+};
+
+export const analyzeTopicRoutes = (routes: BrokerRoute[]):
TopicRouteDiagnostics => {
+ if (routes.length === 0) {
+ const issues = [
+ issue(
+ 'NO_ROUTE',
+ 'critical',
+ 'Broker 上没有 Topic 路由',
+ '元数据中存在 Topic 记录,但当前实例没有返回任何 Broker 路由。',
+ ),
+ ];
+ return {
+ status: 'critical',
+ statusText: STATUS_TEXT.critical,
+ statusColor: STATUS_COLOR.critical,
+ summary: {
+ brokerCount: 0,
+ addressCount: 0,
+ replicaCount: 0,
+ writableBrokerCount: 0,
+ readableBrokerCount: 0,
+ totalWriteQueues: 0,
+ totalReadQueues: 0,
+ writeSkew: EMPTY_SKEW,
+ readSkew: EMPTY_SKEW,
+ },
+ distributions: [],
+ issues,
+ recommendations: buildRecommendations(issues),
+ };
+ }
+
+ const writeCounts = routes.map((route) => queueCount(route.writeQueues));
+ const readCounts = routes.map((route) => queueCount(route.readQueues));
+ const totalWriteQueues = writeCounts.reduce((sum, value) => sum + value, 0);
+ const totalReadQueues = readCounts.reduce((sum, value) => sum + value, 0);
+ const writeSkew = calculateSkew(writeCounts);
+ const readSkew = calculateSkew(readCounts);
+ const duplicateAddresses = collectAddressDuplicates(routes);
+
+ const distributions = routes.map<RouteDistribution>((route, index) => {
+ const brokerIds = routeBrokerIds(route);
+ const routeIssues = distributionIssues(route, writeSkew, readSkew,
duplicateAddresses);
+
+ return {
+ key: `${route.brokerName || 'broker'}-${index}`,
+ brokerName: route.brokerName || '-',
+ brokerAddr: preferredBrokerAddr(route) || '-',
+ masterAddr: route.masterAddr ?? route.brokerAddrs?.['0'] ?? '',
+ brokerIds,
+ replicaCount: routeReplicaCount(route, brokerIds),
+ writeQueues: queueCount(route.writeQueues),
+ readQueues: queueCount(route.readQueues),
+ writeShare: queueShare(queueCount(route.writeQueues), totalWriteQueues),
+ readShare: queueShare(queueCount(route.readQueues), totalReadQueues),
+ perm: route.perm,
+ readable: inferReadable(route),
+ writable: inferWritable(route),
+ topicSysFlag: route.topicSysFlag,
+ status: maxStatus(routeIssues),
+ issues: routeIssues,
+ };
+ });
+
+ const issues = distributions.flatMap((distribution) => distribution.issues);
+ const writableBrokerCount = distributions.filter(
+ (distribution) => distribution.writable && distribution.writeQueues > 0,
+ ).length;
+ const readableBrokerCount = distributions.filter(
+ (distribution) => distribution.readable && distribution.readQueues > 0,
+ ).length;
+
+ if (writableBrokerCount === 0) {
+ issues.push(
+ issue(
+ 'NO_WRITABLE_ROUTE',
+ 'critical',
+ '没有可写路由',
+ '所有 Broker 都缺少写权限或写队列,生产者无法向该 Topic 发送消息。',
+ ),
+ );
+ }
+
+ if (readableBrokerCount === 0) {
+ issues.push(
+ issue(
+ 'NO_READABLE_ROUTE',
+ 'critical',
+ '没有可读路由',
+ '所有 Broker 都缺少读权限或读队列,消费者无法从该 Topic 拉取消息。',
+ ),
+ );
+ }
+
+ if (routes.length === 1) {
+ issues.push(
+ issue(
+ 'SINGLE_BROKER_ROUTE',
+ 'warning',
+ '单 Broker 路由',
+ '该 Topic 只返回一个 Broker 路由,生产业务需要确认是否符合容灾预期。',
+ ),
+ );
+ }
+
+ const status = maxStatus(issues);
+ const addressCount = new Set(routes.flatMap((route) =>
routeAddresses(route))).size;
+
+ return {
+ status,
+ statusText: STATUS_TEXT[status],
+ statusColor: STATUS_COLOR[status],
+ summary: {
+ brokerCount: routes.length,
+ addressCount,
+ replicaCount: distributions.reduce((sum, item) => sum +
item.replicaCount, 0),
+ writableBrokerCount,
+ readableBrokerCount,
+ totalWriteQueues,
+ totalReadQueues,
+ writeSkew,
+ readSkew,
+ },
+ distributions,
+ issues,
+ recommendations: buildRecommendations(issues),
+ };
+};