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 e1072d27 fix(web): guard nullable fields and localize home page (#2098)
e1072d27 is described below

commit e1072d2759804db5439da390f8c1602e4657a8a4
Author: yyqdbngt <[email protected]>
AuthorDate: Fri Aug 14 11:43:27 2026 +0800

    fix(web): guard nullable fields and localize home page (#2098)
    
    Consolidates #2056, #2085, #2087, #2088, #2093, #2094, #2095, #2096,
    #2098, #2108, #2109: null guards for delivery status, cluster component
    lists, subscribedTopics, alert levels and topic detail requests; reuse
    formatDateTime for cert dates; render a dash for zero consume time;
    clamp formatBytes units; surface AI tool and send errors; preserve alert
    rule cluster/broker scope when editing; localize the home banner,
    placeholder and AI error copy.
    
    Co-authored-by: yyqdbngt <[email protected]>
---
 web/src/i18n/translations.ts             |  8 +++---
 web/src/pages/ai/index.tsx               |  4 +--
 web/src/pages/cluster/certs.tsx          |  7 +----
 web/src/pages/home/index.tsx             |  2 +-
 web/src/pages/instance/message.tsx       |  3 +-
 web/src/pages/instance/topic.tsx         | 45 ++++++++++++++++++------------
 web/src/pages/ops/systemAlerts.tsx       |  2 +-
 web/src/pages/studio/AlertManagement.tsx | 36 ++++++++++++++++++++++++
 web/src/pages/studio/BrokerCluster.tsx   |  6 ++--
 web/src/pages/studio/GroupManagement.tsx |  4 +--
 web/src/utils/format.test.ts             | 47 ++++++++++++++------------------
 web/src/utils/format.ts                  |  9 ++++--
 12 files changed, 106 insertions(+), 67 deletions(-)

diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 4427ab61..b7ff012f 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -296,6 +296,10 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'ai.commonCommands': { zh: '常用指令', en: 'Common Commands' },
 
   // ─── Home Page ───
+  'home.banner': {
+    zh: 'RocketMQ Studio — 跨集群 · 跨架构 · 跨云的统一管控平台',
+    en: 'RocketMQ Studio — unified control plane across clusters, 
architectures and clouds',
+  },
   'home.greeting.night': { zh: '夜深了', en: 'Late night' },
   'home.greeting.morning': { zh: '上午好', en: 'Good morning' },
   'home.greeting.noon': { zh: '中午好', en: 'Good afternoon' },
@@ -516,10 +520,6 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'consumer.resetFailed': { zh: '消费位点重置失败', en: 'Failed to reset consume 
offset' },
 
   // ─── Home Page (additional) ───
-  'home.banner': {
-    zh: 'RocketMQ Studio — 跨集群 · 跨架构 · 跨云的统一管控平台',
-    en: 'RocketMQ Studio — Cross-cluster · Cross-arch · Cross-cloud unified 
management',
-  },
   'home.placeholder': {
     zh: '向 RocketMQ Bot 提问,全程加密、安全、可信',
     en: 'Ask RocketMQ Bot, fully encrypted, secure, trusted',
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 4a7e4153..a8f5814a 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -702,8 +702,8 @@ const AiPage = () => {
     try {
       setToolResult(await executeTool(selectedToolName, parsedInput));
       message.success('工具执行成功');
-    } catch {
-      message.error('工具执行失败');
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '工具执行失败');
     } finally {
       setToolExecuting(false);
     }
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index c916ce6f..fc23d928 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -21,15 +21,10 @@ import type { ColumnsType } from 'antd/es/table';
 import PageHeader from '../../components/PageHeader';
 import type { K8sCertInfo } from '../../api/cluster';
 import { listK8sCerts } from '../../services/clusterService';
+import { formatDateTime } from '../../utils/format';
 
 const { Text } = Typography;
 
-const formatDateTime = (iso: string): string => {
-  const d = new Date(iso);
-  const pad = (n: number) => String(n).padStart(2, '0');
-  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
-};
-
 const getErrorMessage = (error: unknown): string =>
   error instanceof Error && error.message ? error.message : '请求失败,请稍后重试';
 
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index 45e7ebb2..3d00d52a 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -266,7 +266,7 @@ const HomePage = () => {
               <div className="flex justify-center items-center px-4 py-2 
min-h-[36px]">
                 <span className="inline-flex items-center gap-2 text-sm 
text-amber-600 cursor-pointer hover:text-amber-700 transition-colors">
                   <MegaphoneSimple size={16} weight="fill" />
-                  <span>RocketMQ Studio — 跨集群 · 跨架构 · 跨云的统一管控平台</span>
+                  <span>{t('home.banner')}</span>
                 </span>
               </div>
             </div>
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index 02212af8..b46ee466 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -120,6 +120,7 @@ const formatSize = (bytes: number): string => {
 };
 
 const formatTimeMs = (value: number | string): string => {
+  if (!value) return '-';
   const d = new Date(value);
   const pad = (n: number, len = 2) => String(n).padStart(len, '0');
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(),
 3)}`;
@@ -617,7 +618,7 @@ const MessagePageContent = ({
       dataIndex: 'deliveryStatus',
       key: 'deliveryStatus',
       render: (status: string) => {
-        const s = DELIVERY_STATUS_MAP[status.toLowerCase()] || {
+        const s = DELIVERY_STATUS_MAP[(status ?? '').toLowerCase()] || {
           label: status,
           color: 'default',
         };
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index d7206682..ebdf624a 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useEffect, useState, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useState, useMemo, useRef } from 'react';
 import {
   Alert,
   Table,
@@ -326,6 +326,7 @@ const TopicPage = () => {
   const [importing, setImporting] = useState(false);
 
   const topicRequestIdRef = useRef(0);
+  const detailRequestIdRef = useRef(0);
   const createInFlightRef = useRef(false);
 
   useEffect(() => {
@@ -393,22 +394,30 @@ const TopicPage = () => {
   };
 
   // ─── Open detail modal ────────────────────────────────────────
-  const openDetail = async (topic: Topic) => {
-    setSelectedTopic(topic);
-    setDetailModalOpen(true);
-    setDetailLoading(true);
-    try {
-      await loadTopicConsumers(topic);
-      if (!isCloudInstance) {
-        const routes = await getTopicRoutes(topic.name, selectedInstanceId || 
undefined);
-        setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes 
}));
+  const openDetail = useCallback(
+    async (topic: Topic) => {
+      const requestId = detailRequestIdRef.current + 1;
+      detailRequestIdRef.current = requestId;
+      setSelectedTopic(topic);
+      setDetailModalOpen(true);
+      setDetailLoading(true);
+      try {
+        await loadTopicConsumers(topic);
+        if (requestId !== detailRequestIdRef.current) return;
+        if (!isCloudInstance) {
+          const routes = await getTopicRoutes(topic.name, selectedInstanceId 
|| undefined);
+          if (requestId !== detailRequestIdRef.current) return;
+          setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes 
}));
+        }
+      } catch {
+        if (requestId === detailRequestIdRef.current)
+          message.error('Topic 详情加载失败,请稍后重试');
+      } finally {
+        if (requestId === detailRequestIdRef.current) setDetailLoading(false);
       }
-    } catch {
-      message.error('Topic 详情加载失败,请稍后重试');
-    } finally {
-      setDetailLoading(false);
-    }
-  };
+    },
+    [loadTopicConsumers, isCloudInstance, selectedInstanceId],
+  );
 
   // Metadata lives in the database, so a record can exist without a broker 
route.
   const rebuildTopic = async (topic: Topic) => {
@@ -872,8 +881,8 @@ const TopicPage = () => {
       });
       // Keep the modal open for consecutive sends
       message.success(`消息发送成功!MsgId: ${result.msgId}`);
-    } catch {
-      message.error('消息发送失败,请稍后重试');
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '消息发送失败,请稍后重试');
     } finally {
       setSending(false);
     }
diff --git a/web/src/pages/ops/systemAlerts.tsx 
b/web/src/pages/ops/systemAlerts.tsx
index c5504f8c..ab84d06c 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -29,7 +29,7 @@ import type { SystemAlert } from '../../api/ops';
 
 const { Text } = Typography;
 
-const normalizeAlertLevel = (level: string) => level.toLowerCase();
+const normalizeAlertLevel = (level?: string | null) => (level ?? 
'').toLowerCase();
 
 const SystemAlertsPage = () => {
   const { t } = useLang();
diff --git a/web/src/pages/studio/AlertManagement.tsx 
b/web/src/pages/studio/AlertManagement.tsx
index 5eeef011..b866beca 100644
--- a/web/src/pages/studio/AlertManagement.tsx
+++ b/web/src/pages/studio/AlertManagement.tsx
@@ -247,15 +247,51 @@ function parseExpression(
   };
 }
 
+function parseScopeLabels(metric: string): {
+  metric: string;
+  clusterName?: string;
+  brokerName?: string;
+} {
+  const open = metric.indexOf('{');
+  if (open < 0) return { metric };
+  const metricName = metric.slice(0, open).trim();
+  const inner = metric.slice(open + 1, metric.lastIndexOf('}'));
+  let clusterName: string | undefined;
+  let brokerName: string | undefined;
+  const remaining: string[] = [];
+  for (const part of inner.split(',')) {
+    const eq = part.indexOf('=');
+    if (eq < 0) continue;
+    const key = part.slice(0, eq).trim();
+    const raw = part.slice(eq + 1).trim();
+    const value = raw.replace(/^"|"$/g, '').replace(/\\\\/g, 
'\\').replace(/\\"/g, '"');
+    if (key === 'cluster') clusterName = value;
+    else if (key === 'broker') brokerName = value;
+    else remaining.push(part.trim());
+  }
+  // Rebuild the metric expression preserving every non-scope label.
+  const metricExpr =
+    remaining.length > 0
+      ? `${metricName}{${remaining.join(',')}}`
+      : clusterName || brokerName
+        ? metricName
+        : metric;
+  return { metric: metricExpr, clusterName, brokerName };
+}
+
 function toAlertRuleRequest(
   values: AlertRuleFormValues,
   editingRule: AlertRule | null,
 ): AlertRuleRequest {
   const expression = parseExpression(values.expr);
+  const scope = parseScopeLabels(expression.metric ?? '');
   return {
     id: editingRule?.id,
     name: values.alert.trim(),
     ...expression,
+    metric: scope.metric,
+    clusterName: scope.clusterName,
+    brokerName: scope.brokerName,
     duration: values.for,
     enabled: values.enabled ?? true,
     description: combineDescription(values.summary, values.description),
diff --git a/web/src/pages/studio/BrokerCluster.tsx 
b/web/src/pages/studio/BrokerCluster.tsx
index 3918bed1..4c5e8af2 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -95,7 +95,7 @@ function mapClusters(clusters: ClusterInfo[]): {
   clusters.forEach((cluster) => {
     const clusterLabel = cluster.nsClusterName || cluster.name || cluster.id;
 
-    cluster.brokers.forEach((broker, index) => {
+    (cluster.brokers ?? []).forEach((broker, index) => {
       brokers.push({
         key: `${cluster.id}-broker-${broker.addr || index}`,
         clusterId: cluster.id,
@@ -110,7 +110,7 @@ function mapClusters(clusters: ClusterInfo[]): {
       });
     });
 
-    cluster.nameServers.forEach((nameServer, index) => {
+    (cluster.nameServers ?? []).forEach((nameServer, index) => {
       nameServers.push({
         key: `${cluster.id}-ns-${nameServer.addr || index}`,
         k8sCluster: clusterLabel,
@@ -122,7 +122,7 @@ function mapClusters(clusters: ClusterInfo[]): {
       });
     });
 
-    cluster.proxies.forEach((proxy, index) => {
+    (cluster.proxies ?? []).forEach((proxy, index) => {
       const host = hostOf(proxy.addr);
       proxies.push({
         key: `${cluster.id}-proxy-${proxy.addr || index}`,
diff --git a/web/src/pages/studio/GroupManagement.tsx 
b/web/src/pages/studio/GroupManagement.tsx
index 31b82e45..69f5e859 100644
--- a/web/src/pages/studio/GroupManagement.tsx
+++ b/web/src/pages/studio/GroupManagement.tsx
@@ -399,7 +399,7 @@ const GroupManagementPage = () => {
                             {t('groupMgmt.subscribedTopics')}
                           </div>
                           <div style={{ fontSize: 24, fontWeight: 600 }}>
-                            {selectedGroup.subscribedTopics.length}
+                            {(selectedGroup.subscribedTopics ?? []).length}
                           </div>
                         </Card>
                       </Col>
@@ -434,7 +434,7 @@ const GroupManagementPage = () => {
                         {selectedGroup.createdAt}
                       </Descriptions.Item>
                       <Descriptions.Item 
label={t('groupMgmt.subscribedTopics')} span={2}>
-                        {selectedGroup.subscribedTopics.join(', ')}
+                        {(selectedGroup.subscribedTopics ?? []).join(', ')}
                       </Descriptions.Item>
                     </Descriptions>
                     <h4 style={{ marginTop: 20, marginBottom: 12 }}>
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index ee181fd0..d0696686 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -1,33 +1,26 @@
-/*
- * 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.
- */
-
+// SPDX-License-Identifier: Apache-2.0
 import { describe, expect, it } from 'vitest';
-import { formatDate, formatDateTime } from './format';
+import { formatBytes } from './format';
+
+describe('formatBytes', () => {
+  it('formats zero', () => {
+    expect(formatBytes(0)).toBe('0 B');
+  });
+
+  it('formats negative values', () => {
+    expect(formatBytes(-1536)).toBe('-1.5 KB');
+  });
 
-describe('date formatters', () => {
-  it('renders missing and blank date values consistently', () => {
-    for (const value of [null, undefined, '', '   ']) {
-      expect(formatDateTime(value)).toBe('-');
-      expect(formatDate(value)).toBe('-');
-    }
+  it('clamps to the largest unit', () => {
+    expect(formatBytes(1024 ** 5)).toBe('1.0 PB');
+    const huge = formatBytes(1024 ** 9);
+    expect(huge).toContain('PB');
+    expect(huge).not.toContain('undefined');
   });
 
-  it('preserves a nonblank invalid value for diagnostics', () => {
-    expect(formatDateTime('not-a-date')).toBe('not-a-date');
-    expect(formatDate('not-a-date')).toBe('not-a-date');
+  it('handles non-finite input', () => {
+    expect(formatBytes(Number.NaN)).toBe('-');
+    expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('-');
+    expect(formatBytes(Number.NEGATIVE_INFINITY)).toBe('-');
   });
 });
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index b246b2c6..213c5b5d 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -45,13 +45,18 @@ export function formatDate(date: string | Date | null | 
undefined): string {
  * e.g. 1536 → '1.5 KB', 1048576 → '1 MB'
  */
 export function formatBytes(bytes: number, decimals = 1): string {
+  if (!Number.isFinite(bytes)) return '-';
   if (bytes === 0) return '0 B';
   if (bytes < 0) return `-${formatBytes(-bytes, decimals)}`;
 
   const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
   const k = 1024;
-  const i = Math.floor(Math.log(bytes) / Math.log(k));
-  const value = bytes / Math.pow(k, i);
+  let i = 0;
+  let value = Math.abs(bytes);
+  while (value >= k && i < units.length - 1) {
+    value /= k;
+    i += 1;
+  }
   return `${value.toFixed(decimals)} ${units[i]}`;
 }
 

Reply via email to