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 48794d53 fix: guard cluster page against null component lists (#1036)
48794d53 is described below
commit 48794d53471a91cc43b811537806f30ff262796a
Author: 0 <[email protected]>
AuthorDate: Thu Aug 6 13:44:05 2026 +0800
fix: guard cluster page against null component lists (#1036)
---
.../studio/cluster/broker/RealClusterProvider.java | 5 +
.../cluster/broker/RealClusterProviderTest.java | 13 +++
web/src/pages/cluster/clusterStats.test.ts | 110 +++++++++++++++++++++
web/src/pages/cluster/clusterStats.ts | 43 ++++++++
web/src/pages/cluster/index.tsx | 9 +-
5 files changed, 177 insertions(+), 3 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
index 542b9b1a..6e398401 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.cluster.broker;
import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.studio.cluster.config.ClusterConfigVO;
import org.apache.rocketmq.studio.cluster.nameserver.NameServerVO;
import org.apache.rocketmq.studio.common.domain.enums.BrokerStatus;
import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
@@ -105,6 +106,8 @@ public class RealClusterProvider implements ClusterProvider
{
.map(addr ->
NameServerVO.builder().addr(addr).status(ClusterStatus.healthy).build())
.toList();
+ // A live cluster has no proxy or config history until one is
provisioned;
+ // default to empty collections so the web UI never dereferences nulls.
ClusterVO cluster = ClusterVO.builder()
.name(clusterName)
.nsClusterName(clusterName)
@@ -114,6 +117,8 @@ public class RealClusterProvider implements ClusterProvider
{
.brokers(brokers)
.proxies(List.of())
.nameServers(nameServers)
+ .config(new ClusterConfigVO())
+ .tpsHistory(List.of())
.build();
cluster.setId(clusterName);
return cluster;
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
index 2ed9ddab..b8a4922e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
@@ -96,6 +96,19 @@ class RealClusterProviderTest {
assertThat(cluster.getBrokers().get(0).getStatus()).isEqualTo(BrokerStatus.running);
}
+ @Test
+ void describeClusterShouldDefaultAbsentRuntimeCollectionsToEmpty() throws
Exception {
+ stubClusterInfo("10.0.0.1:9876", sampleClusterInfo());
+
+ ClusterVO cluster = provider.describeCluster("10.0.0.1:9876");
+
+ // A live cluster without a proxy must still serialize non-null
collections
+ // so the web UI never dereferences null component lists.
+ assertThat(cluster.getProxies()).isEmpty();
+ assertThat(cluster.getTpsHistory()).isEmpty();
+ assertThat(cluster.getConfig()).isNotNull();
+ }
+
@Test
void discoverClustersShouldReturnEmptyWhenNoNamesrvConfigured() {
properties.setNamesrvAddr(" ");
diff --git a/web/src/pages/cluster/clusterStats.test.ts
b/web/src/pages/cluster/clusterStats.test.ts
new file mode 100644
index 00000000..33f605f1
--- /dev/null
+++ b/web/src/pages/cluster/clusterStats.test.ts
@@ -0,0 +1,110 @@
+/*
+ * 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 { ClusterInfo } from '../../api/cluster';
+import { countClusterComponents } from './clusterStats';
+
+const baseCluster: ClusterInfo = {
+ id: 'c1',
+ name: 'rmq-prod',
+ nsClusterName: 'ns-prod',
+ type: 'V5_PROXY_CLUSTER',
+ endpoint: '10.0.0.1:9876',
+ status: 'healthy',
+ version: '5.2.0',
+ brokers: [],
+ proxies: [],
+ nameServers: [],
+ config: {
+ flushDiskType: 'SYNC_FLUSH',
+ autoCreateTopicEnable: false,
+ autoCreateSubscriptionGroup: false,
+ maxMessageSize: 4194304,
+ msgTraceTopicName: 'RMQ_SYS_TRACE_TOPIC',
+ fileReservedTime: 72,
+ writeQueueNums: 16,
+ readQueueNums: 16,
+ brokerPermission: 6,
+ deleteWhen: '04',
+ },
+ topicCount: 10,
+ groupCount: 5,
+ tpsHistory: [1, 2, 3],
+};
+
+describe('countClusterComponents', () => {
+ it('returns zero counts for an empty cluster list', () => {
+ expect(countClusterComponents([])).toEqual({ brokers: 0, nameServers: 0,
proxies: 0 });
+ });
+
+ it('counts components across clusters', () => {
+ const clusters: ClusterInfo[] = [
+ {
+ ...baseCluster,
+ brokers: [
+ {
+ addr: 'a:10911',
+ name: 'b1',
+ status: 'running',
+ tpsIn: 1,
+ tpsOut: 2,
+ diskUsage: 3,
+ version: '5.2.0',
+ },
+ ],
+ proxies: [{ addr: 'p:8081' } as ClusterInfo['proxies'][number]],
+ nameServers: [{ addr: 'ns:9876' } as
ClusterInfo['nameServers'][number]],
+ },
+ {
+ ...baseCluster,
+ id: 'c2',
+ brokers: [
+ {
+ addr: 'a2:10911',
+ name: 'b2',
+ status: 'running',
+ tpsIn: 1,
+ tpsOut: 2,
+ diskUsage: 3,
+ version: '5.2.0',
+ },
+ {
+ addr: 'a3:10911',
+ name: 'b3',
+ status: 'running',
+ tpsIn: 1,
+ tpsOut: 2,
+ diskUsage: 3,
+ version: '5.2.0',
+ },
+ ],
+ },
+ ];
+ expect(countClusterComponents(clusters)).toEqual({ brokers: 3,
nameServers: 1, proxies: 1 });
+ });
+
+ it('treats missing component lists as empty instead of crashing', () => {
+ // A cluster without a proxy serializes its component lists as null.
+ const clusters = [
+ { ...baseCluster, proxies: null as unknown as ClusterInfo['proxies'] },
+ { ...baseCluster, id: 'c2', brokers: null as unknown as
ClusterInfo['brokers'] },
+ { ...baseCluster, id: 'c3', nameServers: undefined as unknown as
ClusterInfo['nameServers'] },
+ ];
+ expect(countClusterComponents(clusters)).toEqual({ brokers: 0,
nameServers: 0, proxies: 0 });
+ });
+});
diff --git a/web/src/pages/cluster/clusterStats.ts
b/web/src/pages/cluster/clusterStats.ts
new file mode 100644
index 00000000..4a8eab25
--- /dev/null
+++ b/web/src/pages/cluster/clusterStats.ts
@@ -0,0 +1,43 @@
+/*
+ * 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 { ClusterInfo } from '../../api/cluster';
+
+export interface ClusterComponentCounts {
+ brokers: number;
+ nameServers: number;
+ proxies: number;
+}
+
+/**
+ * Aggregate per-component counts across a list of clusters.
+ *
+ * The backend may serialize a cluster without its runtime component lists
+ * (e.g. a real cluster without a proxy), leaving `brokers`/`proxies`/
+ * `nameServers` as null. Guard every field so the summary never crashes on
+ * such payloads.
+ */
+export function countClusterComponents(clusters: ClusterInfo[]):
ClusterComponentCounts {
+ return clusters.reduce(
+ (acc, c) => ({
+ brokers: acc.brokers + (c.brokers ?? []).length,
+ nameServers: acc.nameServers + (c.nameServers ?? []).length,
+ proxies: acc.proxies + (c.proxies ?? []).length,
+ }),
+ { brokers: 0, nameServers: 0, proxies: 0 },
+ );
+}
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index cab68134..f8558027 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -47,6 +47,7 @@ import {
import { Cpu, HardDrives, Globe } from '@phosphor-icons/react';
import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
+import { countClusterComponents } from './clusterStats';
import type {
BrokerInfo,
ProxyInfo,
@@ -934,9 +935,11 @@ const ClusterPage = () => {
// ─── Render
─────────────────────────────────────────────────────────────────
- const totalBrokers = clusters.reduce((s, c) => s + (c.brokers?.length ?? 0),
0);
- const totalNameServers = clusters.reduce((s, c) => s +
(c.nameServers?.length ?? 0), 0);
- const totalProxies = clusters.reduce((s, c) => s + (c.proxies?.length ?? 0),
0);
+ const {
+ brokers: totalBrokers,
+ nameServers: totalNameServers,
+ proxies: totalProxies,
+ } = countClusterComponents(clusters);
return (
<div style={{ padding: 24 }}>