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 2fc9ebd6 fix: harden cluster runtime data and presentation (#950)
2fc9ebd6 is described below

commit 2fc9ebd69a390dae2adab4b5c7dc88f5502cd913
Author: aias00 <[email protected]>
AuthorDate: Tue Aug 4 03:07:16 2026 -0700

    fix: harden cluster runtime data and presentation (#950)
    
    * fix: gate demo cluster seed data
    
    * [ISSUE #802] Disable mock mode for production builds
    
    * fix(web): remove BrokerCluster mock fallback rows
    
    * [ISSUE #804] Load BrokerCluster data from service
    
    * fix: stop fabricating proxy runtime metrics
    
    * fix: handle missing cluster addresses
    
    * [ISSUE #790] Gate demo instance seed data
---
 .../cluster/broker/ClusterRepositoryImpl.java      |   8 +-
 server/src/main/resources/application.yml          |   2 +
 .../cluster/broker/ClusterRepositoryImplTest.java  |   9 +-
 .../MybatisPlusInstanceRepositoryTest.java         |   8 ++
 web/.env.production                                |   6 +-
 web/src/api/proxy.ts                               |  14 +--
 .../pages/cluster/__tests__/ClusterPage.test.tsx   |  32 +++++
 web/src/pages/cluster/index.tsx                    |  34 +++---
 web/src/pages/studio/BrokerCluster.tsx             | 129 +--------------------
 web/src/pages/studio/Proxy.tsx                     | 114 ++++++++++--------
 .../pages/studio/__tests__/BrokerCluster.test.tsx  |  31 ++---
 web/src/pages/studio/__tests__/Proxy.test.tsx      |   8 ++
 12 files changed, 183 insertions(+), 212 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java
index 7bdfa8e0..e4252219 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java
@@ -25,6 +25,7 @@ import 
org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
 import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
 import org.apache.rocketmq.studio.common.domain.enums.FlushDiskType;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Repository;
 
 import java.time.LocalDateTime;
@@ -40,8 +41,11 @@ public class ClusterRepositoryImpl implements 
ClusterRepository {
 
     private final Map<String, ClusterVO> store = new ConcurrentHashMap<>();
 
-    public ClusterRepositoryImpl() {
-        initStubData();
+    public 
ClusterRepositoryImpl(@Value("${studio.cluster.seed-demo-data:false}") boolean 
seedDemoData) {
+        if (seedDemoData) {
+            initStubData();
+            log.info("Initialized demo cluster data for Studio cluster 
repository");
+        }
     }
 
     @Override
diff --git a/server/src/main/resources/application.yml 
b/server/src/main/resources/application.yml
index ce721da0..7295d3db 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -34,6 +34,8 @@ studio:
       - username: ${STUDIO_AUTH_ADMIN_USERNAME:}
         password: ${STUDIO_AUTH_ADMIN_PASSWORD:}
         admin: true
+  cluster:
+    seed-demo-data: ${STUDIO_CLUSTER_SEED_DEMO_DATA:false}
   metrics:
     prometheus:
       base-url: ${STUDIO_METRICS_PROMETHEUS_BASE_URL:}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImplTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImplTest.java
index e805803e..ac074a44 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImplTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterRepositoryImplTest.java
@@ -26,11 +26,18 @@ class ClusterRepositoryImplTest {
 
     @Test
     void findAllShouldReturnClustersInStableNameOrder() {
-        ClusterRepositoryImpl repository = new ClusterRepositoryImpl();
+        ClusterRepositoryImpl repository = new ClusterRepositoryImpl(true);
 
         List<ClusterVO> clusters = repository.findAll();
 
         assertThat(clusters).extracting(ClusterVO::getName)
                 .containsExactly("rmq-cluster-prod", "rmq-cluster-staging");
     }
+
+    @Test
+    void findAllShouldBeEmptyWhenDemoDataIsDisabled() {
+        ClusterRepositoryImpl repository = new ClusterRepositoryImpl(false);
+
+        assertThat(repository.findAll()).isEmpty();
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
index 6f71d802..eba043f8 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
@@ -79,6 +79,14 @@ class MybatisPlusInstanceRepositoryTest {
         assertThat(proxy.getConsumerGroupCount()).isEqualTo(2);
     }
 
+    @Test
+    void constructorShouldNotSeedDemoInstances() {
+        
when(instanceMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
+
+        assertThat(repository.findAll()).isEmpty();
+        verify(instanceMapper, never()).insert(any(RmqInstance.class));
+    }
+
     @Test
     void findAllShouldReturnEmptyWithoutCountQueriesWhenNoInstances() {
         
when(instanceMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
diff --git a/web/.env.production b/web/.env.production
index 3e827984..b406b7c0 100644
--- a/web/.env.production
+++ b/web/.env.production
@@ -1,3 +1,3 @@
-# Backend not ready yet - use mock data
-# Set to "false" when real API is available
-VITE_USE_MOCK=true
+# Production builds should call real backend APIs by default.
+# Set to "true" only for explicit demo/mock deployments.
+VITE_USE_MOCK=false
diff --git a/web/src/api/proxy.ts b/web/src/api/proxy.ts
index 26e311cc..2ed50e67 100644
--- a/web/src/api/proxy.ts
+++ b/web/src/api/proxy.ts
@@ -27,13 +27,13 @@ export interface ProxyHomePageData {
 export interface ProxyNode {
   key: string;
   address: string;
-  status: 'healthy' | 'unhealthy' | 'warning';
-  version: string;
-  connections: number;
-  tps: number;
-  memory: number;
-  cpu: number;
-  uptime: string;
+  status: 'healthy' | 'unhealthy' | 'warning' | 'unknown';
+  version: string | null;
+  connections: number | null;
+  tps: number | null;
+  memory: number | null;
+  cpu: number | null;
+  uptime: string | null;
   isSelected: boolean;
 }
 
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 7d3aa093..b436e577 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -163,6 +163,38 @@ describe('Cluster page', () => {
     expect(within(dialog).getByText('8080')).toBeInTheDocument();
   });
 
+  it('keeps cluster tabs usable when address fields are missing', async () => {
+    const user = userEvent.setup();
+    const submitSearch = async (placeholder: string, value: string) => {
+      const input = screen.getByPlaceholderText(placeholder);
+      await user.type(input, value);
+      const searchBox = input.closest('.ant-input-search');
+      expect(searchBox).not.toBeNull();
+      const searchButton = (searchBox as 
HTMLElement).querySelector('.ant-input-search-button');
+      expect(searchButton).not.toBeNull();
+      await user.click(searchButton as HTMLElement);
+    };
+    const cluster = buildCluster();
+    cluster.brokers = [{ ...cluster.brokers[0], addr: null as unknown as 
string }];
+    cluster.nameServers = [{ ...cluster.nameServers[0], addr: null as unknown 
as string }];
+    cluster.proxies = [{ ...cluster.proxies[0], addr: null as unknown as 
string }];
+    clusterServiceMocks.listClusters.mockResolvedValue([cluster]);
+
+    renderWithProviders(<ClusterPage />);
+    expect(await screen.findByText('rocketmq-prod-0')).toBeInTheDocument();
+
+    await submitSearch('搜索 Broker 名称或地址', 'not-found');
+    expect(screen.queryByText('rocketmq-prod-0')).not.toBeInTheDocument();
+
+    await user.click(screen.getByRole('tab', { name: /NameServer 管理/ }));
+    await submitSearch('搜索地址', 'not-found');
+    expect(screen.getByPlaceholderText('搜索地址')).toHaveValue('not-found');
+
+    await user.click(screen.getByRole('tab', { name: /Proxy 管理/ }));
+    await submitSearch('搜索 Proxy 地址', 'not-found');
+    expect(screen.getByPlaceholderText('搜索 Proxy 
地址')).toHaveValue('not-found');
+  });
+
   it('polls the API after two seconds and renders only returned metrics', 
async () => {
     vi.useFakeTimers();
     const randomSpy = vi.spyOn(Math, 'random');
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 4d4e0a8e..5d873148 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -72,6 +72,11 @@ type RefreshSource = 'initial' | 'manual' | 'operation' | 
'background';
 
 type ProxyDetail = ProxyInfo & { clusterId: string; clusterName: string; 
nsClusterName: string };
 
+const safeText = (value: string | null | undefined) => value ?? '';
+const searchText = (value: string | null | undefined) => 
safeText(value).toLowerCase();
+const compareText = (left: string | null | undefined, right: string | null | 
undefined) =>
+  safeText(left).localeCompare(safeText(right));
+
 // ─── Page 
─────────────────────────────────────────────────────────────────────
 
 const ClusterPage = () => {
@@ -329,14 +334,15 @@ const ClusterPage = () => {
       nsClusterName: string;
       cluster: ClusterInfo;
     };
+    const brokerSearchText = searchText(brokerSearch);
 
     const allBrokers: BrokerWithCluster[] = clusters.flatMap((c) =>
       c.brokers
         .filter((b) => {
           const matchSearch =
-            !brokerSearch ||
-            b.name.toLowerCase().includes(brokerSearch.toLowerCase()) ||
-            b.addr.toLowerCase().includes(brokerSearch.toLowerCase());
+            !brokerSearchText ||
+            searchText(b.name).includes(brokerSearchText) ||
+            searchText(b.addr).includes(brokerSearchText);
           const matchNsCluster =
             !brokerNsClusterFilter || c.nsClusterName === 
brokerNsClusterFilter;
           return matchSearch && matchNsCluster;
@@ -419,8 +425,8 @@ const ClusterPage = () => {
         key: 'addr',
         width: 170,
         align: 'right',
-        sorter: (a, b) => a.addr.localeCompare(b.addr),
-        render: (addr: string) => <span style={{ fontSize: 13 }}>{addr}</span>,
+        sorter: (a, b) => compareText(a.addr, b.addr),
+        render: (addr: string | null) => <span style={{ fontSize: 13 
}}>{safeText(addr)}</span>,
       },
       {
         title: 'TPS In',
@@ -577,10 +583,11 @@ const ClusterPage = () => {
   // ─── Tab 2: NameServer 管理 (nested by cluster) ────────────────────────────
 
   function renderNameServerTab() {
+    const nsSearchText = searchText(nsSearch);
     const filteredClusters = clusters
       .map((c) => {
         const nameServers = c.nameServers.filter((ns) => {
-          const matchSearch = !nsSearch || 
ns.addr.toLowerCase().includes(nsSearch.toLowerCase());
+          const matchSearch = !nsSearchText || 
searchText(ns.addr).includes(nsSearchText);
           return matchSearch;
         });
         return { ...c, filteredNameServers: nameServers };
@@ -592,10 +599,10 @@ const ClusterPage = () => {
         title: t('common.address'),
         dataIndex: 'addr',
         key: 'addr',
-        sorter: (a, b) => a.addr.localeCompare(b.addr),
-        render: (addr: string) => (
+        sorter: (a, b) => compareText(a.addr, b.addr),
+        render: (addr: string | null) => (
           <Text code style={{ fontSize: 12 }}>
-            {addr}
+            {safeText(addr)}
           </Text>
         ),
       },
@@ -750,6 +757,7 @@ const ClusterPage = () => {
 
   function renderProxyTab() {
     type ProxyRow = ProxyDetail;
+    const proxySearchText = searchText(proxySearch);
 
     const allProxies: ProxyRow[] = clusters
       .filter((c) => c.proxies.length > 0)
@@ -757,7 +765,7 @@ const ClusterPage = () => {
         c.proxies
           .filter((p) => {
             const matchSearch =
-              !proxySearch || 
p.addr.toLowerCase().includes(proxySearch.toLowerCase());
+              !proxySearchText || searchText(p.addr).includes(proxySearchText);
             return matchSearch;
           })
           .map((p) => ({
@@ -786,10 +794,10 @@ const ClusterPage = () => {
         dataIndex: 'addr',
         key: 'addr',
         width: 200,
-        sorter: (a, b) => a.addr.localeCompare(b.addr),
-        render: (addr: string) => (
+        sorter: (a, b) => compareText(a.addr, b.addr),
+        render: (addr: string | null) => (
           <Text code style={{ fontSize: 12 }}>
-            {addr}
+            {safeText(addr)}
           </Text>
         ),
       },
diff --git a/web/src/pages/studio/BrokerCluster.tsx 
b/web/src/pages/studio/BrokerCluster.tsx
index 9e589c0d..3063def5 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -66,129 +66,6 @@ interface ProxyRecord {
   connections: number;
 }
 
-// ─── Mock Data (fallback when the API is unavailable) ───────────
-const mockBrokerData: BrokerRecord[] = [
-  {
-    key: '1',
-    k8sCluster: 'prod-cn-east-1',
-    brokerName: 'broker-a',
-    status: 'running',
-    version: '5.3.0',
-    diskUsage: 62,
-    address: '10.0.1.10:10911',
-    tpsIn: 12580,
-    tpsOut: 8340,
-  },
-  {
-    key: '2',
-    k8sCluster: 'prod-cn-east-1',
-    brokerName: 'broker-b',
-    status: 'readonly',
-    version: '5.3.0',
-    diskUsage: 89,
-    address: '10.0.1.11:10911',
-    tpsIn: 0,
-    tpsOut: 3120,
-  },
-  {
-    key: '3',
-    k8sCluster: 'prod-cn-east-1',
-    brokerName: 'broker-c',
-    status: 'running',
-    version: '5.2.0',
-    diskUsage: 45,
-    address: '10.0.1.12:10911',
-    tpsIn: 9750,
-    tpsOut: 6280,
-  },
-  {
-    key: '4',
-    k8sCluster: 'prod-cn-south-1',
-    brokerName: 'broker-d',
-    status: 'maintenance',
-    version: '5.3.0',
-    diskUsage: 33,
-    address: '10.0.2.10:10911',
-    tpsIn: 0,
-    tpsOut: 0,
-  },
-  {
-    key: '5',
-    k8sCluster: 'prod-cn-south-1',
-    brokerName: 'broker-e',
-    status: 'running',
-    version: '5.3.0',
-    diskUsage: 51,
-    address: '10.0.2.11:10911',
-    tpsIn: 7890,
-    tpsOut: 5430,
-  },
-  {
-    key: '6',
-    k8sCluster: 'staging-cn-east-1',
-    brokerName: 'broker-staging-a',
-    status: 'running',
-    version: '5.3.1',
-    diskUsage: 28,
-    address: '10.0.10.10:10911',
-    tpsIn: 1230,
-    tpsOut: 980,
-  },
-];
-
-const mockNameServerData: NameServerRecord[] = [
-  {
-    key: '1',
-    k8sCluster: 'prod-cn-east-1',
-    name: 'nameserver-a',
-    status: 'running',
-    version: '5.3.0',
-    address: '10.0.1.20:9876',
-    connections: 156,
-  },
-  {
-    key: '2',
-    k8sCluster: 'prod-cn-east-1',
-    name: 'nameserver-b',
-    status: 'running',
-    version: '5.3.0',
-    address: '10.0.1.21:9876',
-    connections: 148,
-  },
-  {
-    key: '3',
-    k8sCluster: 'prod-cn-south-1',
-    name: 'nameserver-c',
-    status: 'running',
-    version: '5.3.0',
-    address: '10.0.2.20:9876',
-    connections: 92,
-  },
-];
-
-const mockProxyData: ProxyRecord[] = [
-  {
-    key: '1',
-    k8sCluster: 'prod-cn-east-1',
-    name: 'proxy-a',
-    status: 'running',
-    version: '5.3.0',
-    address: '10.0.1.30:8080',
-    grpcPort: '10.0.1.30:8081',
-    connections: 2340,
-  },
-  {
-    key: '2',
-    k8sCluster: 'prod-cn-south-1',
-    name: 'proxy-b',
-    status: 'running',
-    version: '5.3.0',
-    address: '10.0.2.30:8080',
-    grpcPort: '10.0.2.30:8081',
-    connections: 1560,
-  },
-];
-
 // ─── Helpers ────────────────────────────────────────────────────
 const normalizeStatus = (status: string): NodeStatus => {
   const value = (status || '').toLowerCase();
@@ -260,9 +137,9 @@ const BrokerClusterPage = () => {
   const [autoRefresh, setAutoRefresh] = useState(false);
   const [activeTab, setActiveTab] = useState('broker');
   const [loading, setLoading] = useState(false);
-  const [brokerData, setBrokerData] = useState<BrokerRecord[]>(mockBrokerData);
-  const [nameServerData, setNameServerData] = 
useState<NameServerRecord[]>(mockNameServerData);
-  const [proxyData, setProxyData] = useState<ProxyRecord[]>(mockProxyData);
+  const [brokerData, setBrokerData] = useState<BrokerRecord[]>([]);
+  const [nameServerData, setNameServerData] = useState<NameServerRecord[]>([]);
+  const [proxyData, setProxyData] = useState<ProxyRecord[]>([]);
   const { t } = useLang();
   const { message } = App.useApp();
 
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index 40812baa..590498cb 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -34,6 +34,7 @@ import {
   Tooltip,
   Popconfirm,
   App,
+  Typography,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import {
@@ -50,6 +51,8 @@ import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
 import { queryProxyHomePage, addProxyAddr, removeProxyAddr, type ProxyNode } 
from '../../api/proxy';
 
+const { Text } = Typography;
+
 const ProxyPage: React.FC = () => {
   const { t } = useLang();
   const { message } = App.useApp();
@@ -63,9 +66,9 @@ const ProxyPage: React.FC = () => {
 
   const [clusterStats, setClusterStats] = useState({
     totalNodes: 0,
-    healthyNodes: 0,
-    totalConnections: 0,
-    totalTPS: 0,
+    healthyNodes: null as number | null,
+    totalConnections: null as number | null,
+    totalTPS: null as number | null,
   });
 
   const initialized = useRef<boolean | null>(null);
@@ -81,25 +84,22 @@ const ProxyPage: React.FC = () => {
       const nodes: ProxyNode[] = (proxyAddrList || []).map((addr) => ({
         key: addr,
         address: addr,
-        status: 'healthy' as const,
-        version: '5.3.0',
-        connections: Math.floor(Math.random() * 1000) + 100,
-        tps: Math.floor(Math.random() * 5000) + 1000,
-        memory: Math.floor(Math.random() * 60) + 20,
-        cpu: Math.floor(Math.random() * 50) + 10,
-        uptime: `${Math.floor(Math.random() * 30) + 1}d`,
+        status: 'unknown' as const,
+        version: null,
+        connections: null,
+        tps: null,
+        memory: null,
+        cpu: null,
+        uptime: null,
         isSelected: addr === currentProxyAddr,
       }));
       setProxyNodes(nodes);
 
-      const healthyCount = nodes.filter((n) => n.status === 'healthy').length;
-      const totalConn = nodes.reduce((sum, n) => sum + n.connections, 0);
-      const totalTPS = nodes.reduce((sum, n) => sum + n.tps, 0);
       setClusterStats({
         totalNodes: nodes.length,
-        healthyNodes: healthyCount,
-        totalConnections: totalConn,
-        totalTPS,
+        healthyNodes: null,
+        totalConnections: null,
+        totalTPS: null,
       });
 
       if (currentProxyAddr) {
@@ -183,8 +183,13 @@ const ProxyPage: React.FC = () => {
         icon: <Warning size={12} weight="fill" />,
         label: t('proxy.warning'),
       },
+      unknown: {
+        color: 'default',
+        icon: null,
+        label: t('common.na'),
+      },
     };
-    const cfg = map[status] || map.healthy;
+    const cfg = map[status] || map.unknown;
     return (
       <Tag color={cfg.color} icon={cfg.icon}>
         {cfg.label}
@@ -192,6 +197,14 @@ const ProxyPage: React.FC = () => {
     );
   };
 
+  const renderUnavailable = () => <Text 
type="secondary">{t('common.na')}</Text>;
+
+  const renderNumberMetric = (value: number | null) =>
+    value == null ? renderUnavailable() : value.toLocaleString();
+
+  const compareNullable = (left: number | null, right: number | null) =>
+    (left ?? Number.NEGATIVE_INFINITY) - (right ?? Number.NEGATIVE_INFINITY);
+
   // ─── Columns ─────────────────────────────────────────────────
 
   const columns: ColumnsType<ProxyNode> = [
@@ -216,53 +229,61 @@ const ProxyPage: React.FC = () => {
       title: t('proxy.version'),
       dataIndex: 'version',
       key: 'version',
+      render: (value: string | null) => value || renderUnavailable(),
     },
     {
       title: t('proxy.connections'),
       dataIndex: 'connections',
       key: 'connections',
-      render: (val: number) => val.toLocaleString(),
-      sorter: (a, b) => a.connections - b.connections,
+      render: renderNumberMetric,
+      sorter: (a, b) => compareNullable(a.connections, b.connections),
     },
     {
       title: 'TPS',
       dataIndex: 'tps',
       key: 'tps',
-      render: (val: number) => val.toLocaleString(),
-      sorter: (a, b) => a.tps - b.tps,
+      render: renderNumberMetric,
+      sorter: (a, b) => compareNullable(a.tps, b.tps),
     },
     {
       title: t('proxy.memory'),
       dataIndex: 'memory',
       key: 'memory',
-      render: (val: number) => (
-        <Progress
-          percent={val}
-          size="small"
-          status={val > 80 ? 'exception' : 'normal'}
-          style={{ width: 100 }}
-        />
-      ),
-      sorter: (a, b) => a.memory - b.memory,
+      render: (val: number | null) =>
+        val == null ? (
+          renderUnavailable()
+        ) : (
+          <Progress
+            percent={val}
+            size="small"
+            status={val > 80 ? 'exception' : 'normal'}
+            style={{ width: 100 }}
+          />
+        ),
+      sorter: (a, b) => compareNullable(a.memory, b.memory),
     },
     {
       title: 'CPU',
       dataIndex: 'cpu',
       key: 'cpu',
-      render: (val: number) => (
-        <Progress
-          percent={val}
-          size="small"
-          status={val > 80 ? 'exception' : 'normal'}
-          style={{ width: 100 }}
-        />
-      ),
-      sorter: (a, b) => a.cpu - b.cpu,
+      render: (val: number | null) =>
+        val == null ? (
+          renderUnavailable()
+        ) : (
+          <Progress
+            percent={val}
+            size="small"
+            status={val > 80 ? 'exception' : 'normal'}
+            style={{ width: 100 }}
+          />
+        ),
+      sorter: (a, b) => compareNullable(a.cpu, b.cpu),
     },
     {
       title: t('proxy.uptime'),
       dataIndex: 'uptime',
       key: 'uptime',
+      render: (value: string | null) => value || renderUnavailable(),
     },
     {
       title: t('proxy.action'),
@@ -331,12 +352,11 @@ const ProxyPage: React.FC = () => {
             <Card>
               <Statistic
                 title={t('proxy.healthyNodes')}
-                value={clusterStats.healthyNodes}
-                suffix={`/ ${clusterStats.totalNodes}`}
-                valueStyle={{
-                  color:
-                    clusterStats.healthyNodes === clusterStats.totalNodes ? 
'#3f8600' : '#cf1322',
-                }}
+                value={clusterStats.healthyNodes ?? t('common.na')}
+                suffix={
+                  clusterStats.healthyNodes == null ? undefined : `/ 
${clusterStats.totalNodes}`
+                }
+                valueStyle={{ color: clusterStats.healthyNodes == null ? 
undefined : '#3f8600' }}
               />
             </Card>
           </Col>
@@ -344,7 +364,7 @@ const ProxyPage: React.FC = () => {
             <Card>
               <Statistic
                 title={t('proxy.totalConnections')}
-                value={clusterStats.totalConnections}
+                value={clusterStats.totalConnections ?? t('common.na')}
                 valueStyle={{ color: '#1890ff' }}
               />
             </Card>
@@ -353,7 +373,7 @@ const ProxyPage: React.FC = () => {
             <Card>
               <Statistic
                 title={t('proxy.totalTps')}
-                value={clusterStats.totalTPS}
+                value={clusterStats.totalTPS ?? t('common.na')}
                 valueStyle={{ color: '#1890ff' }}
               />
             </Card>
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx 
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index 03a5ccd4..fde4dc56 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -62,7 +62,7 @@ const clusterFixture: ClusterInfo[] = [
     version: '5.3.0',
     brokers: [
       {
-        name: 'broker-a',
+        name: 'broker-api-a',
         addr: '10.0.1.10:10911',
         version: '5.3.0',
         status: 'running',
@@ -71,7 +71,7 @@ const clusterFixture: ClusterInfo[] = [
         tpsOut: 8340,
       },
       {
-        name: 'broker-b',
+        name: 'broker-api-b',
         addr: '10.0.1.11:10911',
         version: '5.3.0',
         status: 'readonly',
@@ -89,7 +89,7 @@ const clusterFixture: ClusterInfo[] = [
         remotingPort: 8080,
       },
     ],
-    nameServers: [{ addr: 'nameserver-a', status: 'healthy' }],
+    nameServers: [{ addr: 'nameserver-api-a', status: 'healthy' }],
     config: {
       flushDiskType: 'SYNC_FLUSH',
       autoCreateTopicEnable: false,
@@ -140,14 +140,15 @@ describe('BrokerCluster Page', () => {
   it('should display broker tab with data from the API', async () => {
     renderWithProviders(<BrokerCluster />);
     // Default tab is broker - data is loaded asynchronously from the service
-    const brokerA = await screen.findAllByText('broker-a');
+    const brokerA = await screen.findAllByText('broker-api-a');
     expect(brokerA.length).toBeGreaterThan(0);
-    expect(screen.getAllByText('broker-b').length).toBeGreaterThan(0);
+    expect(screen.getAllByText('broker-api-b').length).toBeGreaterThan(0);
+    expect(screen.queryByText('broker-a')).not.toBeInTheDocument();
   });
 
   it('should display broker status tags', async () => {
     renderWithProviders(<BrokerCluster />);
-    await screen.findAllByText('broker-a');
+    await screen.findAllByText('broker-api-a');
     const runningTags = screen.getAllByText('运行中');
     expect(runningTags.length).toBeGreaterThan(0);
     const readonlyTags = screen.getAllByText('只读');
@@ -157,17 +158,18 @@ describe('BrokerCluster Page', () => {
   it('should switch to NameServer tab on click', async () => {
     const user = userEvent.setup();
     renderWithProviders(<BrokerCluster />);
-    await screen.findByText('broker-a');
+    await screen.findByText('broker-api-a');
     const nsTab = screen.getByText('NameServer 管理');
     await user.click(nsTab);
     // After clicking, NameServer data should be visible (name equals address, 
so it appears twice)
-    expect(screen.getAllByText('nameserver-a').length).toBeGreaterThan(0);
+    expect(screen.getAllByText('nameserver-api-a').length).toBeGreaterThan(0);
+    expect(screen.queryByText('nameserver-a')).not.toBeInTheDocument();
   });
 
   it('should switch to Proxy tab on click', async () => {
     const user = userEvent.setup();
     renderWithProviders(<BrokerCluster />);
-    await screen.findByText('broker-a');
+    await screen.findByText('broker-api-a');
     const proxyTab = screen.getByText('Proxy 管理');
     await user.click(proxyTab);
     // After clicking, Proxy data should be visible (proxy name equals its 
address, so it appears twice)
@@ -176,19 +178,22 @@ describe('BrokerCluster Page', () => {
 
   it('should render config and restart action buttons', async () => {
     renderWithProviders(<BrokerCluster />);
-    await screen.findByText('broker-a');
+    await screen.findByText('broker-api-a');
     const configButtons = screen.getAllByText('配置');
     expect(configButtons.length).toBeGreaterThan(0);
     const restartButtons = screen.getAllByText('重启');
     expect(restartButtons.length).toBeGreaterThan(0);
   });
 
-  it('should fall back to mock data when the API fails', async () => {
+  it('does not show mock infrastructure data when the API fails', async () => {
     vi.mocked(listClusters).mockRejectedValueOnce(new Error('network error'));
     renderWithProviders(<BrokerCluster />);
-    // Initial state holds the mock fallback rows
     await waitFor(() => {
-      expect(screen.getByText('broker-a')).toBeInTheDocument();
+      expect(listClusters).toHaveBeenCalledTimes(1);
     });
+    expect(screen.queryByText('broker-a')).not.toBeInTheDocument();
+    expect(screen.queryByText('broker-b')).not.toBeInTheDocument();
+    expect(screen.queryByText('nameserver-a')).not.toBeInTheDocument();
+    expect(screen.queryByText('proxy-a')).not.toBeInTheDocument();
   });
 });
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx 
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index a79e2720..ae69fe66 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -104,4 +104,12 @@ describe('ProxyPage', () => {
     expect(screen.queryByText('proxy.maxConnections')).not.toBeInTheDocument();
     
expect(screen.queryByText('rocketmq.namesrv.addr')).not.toBeInTheDocument();
   });
+
+  it('marks runtime metrics unavailable when proxy API only returns 
addresses', async () => {
+    renderPage();
+    await screen.findByText('127.0.0.1:8081');
+
+    expect(screen.queryByText('5.3.0')).not.toBeInTheDocument();
+    expect(screen.getAllByText('N/A').length).toBeGreaterThanOrEqual(5);
+  });
 });

Reply via email to