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]}`;
}