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 0a8caef1c fix(web): harden formatters, preserve special message
property keys, discard stale batch selections (#2433)
0a8caef1c is described below
commit 0a8caef1c8d445da631b2fe52c11e904ce0c6402
Author: yyqdbngt <[email protected]>
AuthorDate: Sat Aug 22 14:47:17 2026 +0800
fix(web): harden formatters, preserve special message property keys,
discard stale batch selections (#2433)
Co-authored-by: Yue Wang <[email protected]>
---
.../pages/instance/__tests__/InstancePage.test.tsx | 83 +++++++++++++++++++++-
web/src/pages/instance/index.tsx | 19 +++--
web/src/utils/format.test.ts | 29 +++++++-
web/src/utils/format.ts | 23 ++++--
web/src/utils/messageProperties.test.ts | 44 ++++++++++++
web/src/utils/messageProperties.ts | 8 +--
6 files changed, 188 insertions(+), 18 deletions(-)
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 3b31c4795..35bf481fb 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -801,7 +801,88 @@ describe('InstancePage', () => {
await waitFor(() =>
expect(instanceService.deleteInstancesBatch).toHaveBeenCalledWith(['batch-a']),
);
- expect(confirmSpy).toHaveBeenCalled();
+ expect(confirmSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: '确认删除选中的 1 个实例?',
+ content: expect.stringContaining('将删除:batch-a。'),
+ }),
+ );
+ confirmSpy.mockRestore();
+ });
+
+ it('clears selections hidden by a search result', async () => {
+ const user = userEvent.setup();
+ vi.mocked(instanceService.listInstances)
+ .mockResolvedValueOnce([instance(22, 'search-selected'), instance(23,
'search-visible')])
+ .mockResolvedValueOnce([instance(23, 'search-visible')]);
+ renderPage();
+
+ const selectedName = await screen.findByText('search-selected');
+ const selectedCheckbox =
within(selectedName.closest('tr')!).getByRole('checkbox');
+ await user.click(selectedCheckbox);
+ const deleteButton = screen.getAllByRole('button', { name: /删除/ })[0];
+ expect(deleteButton).toBeEnabled();
+
+ await user.type(screen.getByPlaceholderText('搜索实例 ID 或地址'), 'visible');
+
+ await waitFor(() =>
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({ search:
'visible' }),
+ );
+ await waitFor(() =>
expect(screen.queryByText('search-selected')).not.toBeInTheDocument());
+ expect(deleteButton).toBeDisabled();
+ });
+
+ it('clears selections hidden by a type-filter result', async () => {
+ const user = userEvent.setup();
+ vi.mocked(instanceService.listInstances)
+ .mockResolvedValueOnce([
+ instance(24, 'proxy-selected'),
+ instance(25, 'direct-visible', 'DIRECT'),
+ ])
+ .mockResolvedValueOnce([instance(25, 'direct-visible', 'DIRECT')]);
+ renderPage();
+
+ const selectedName = await screen.findByText('proxy-selected');
+ await
user.click(within(selectedName.closest('tr')!).getByRole('checkbox'));
+ const deleteButton = screen.getAllByRole('button', { name: /删除/ })[0];
+ expect(deleteButton).toBeEnabled();
+
+ const typeSelect = screen.getByRole('combobox');
+ fireEvent.mouseDown(typeSelect.parentElement!);
+ await user.click(
+ await screen.findByText('Direct 模式', { selector:
'.ant-select-item-option-content' }),
+ );
+
+ await waitFor(() =>
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' }),
+ );
+ await waitFor(() =>
expect(screen.queryByText('proxy-selected')).not.toBeInTheDocument());
+ expect(deleteButton).toBeDisabled();
+ });
+
+ it('reconciles selections when a mutation refresh removes an instance',
async () => {
+ const user = userEvent.setup();
+ vi.mocked(instanceService.listInstances)
+ .mockResolvedValueOnce([instance(26, 'refresh-selected'), instance(27,
'refresh-trigger')])
+ .mockResolvedValueOnce([instance(27, 'refresh-trigger')]);
+ vi.mocked(instanceService.deleteInstance).mockResolvedValue();
+ const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config)
=> {
+ void config.onOk?.();
+ return { destroy: vi.fn(), update: vi.fn() } as unknown as
ReturnType<typeof Modal.confirm>;
+ });
+ renderPage();
+
+ const selectedName = await screen.findByText('refresh-selected');
+ await
user.click(within(selectedName.closest('tr')!).getByRole('checkbox'));
+ const deleteButton = screen.getAllByRole('button', { name: /删除/ })[0];
+ expect(deleteButton).toBeEnabled();
+
+ const triggerName = screen.getByText('refresh-trigger');
+ await user.click(within(triggerName.closest('tr')!).getByRole('button', {
name: /删除/ }));
+
+ await waitFor(() =>
expect(instanceService.listInstances).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
expect(screen.queryByText('refresh-selected')).not.toBeInTheDocument());
+ expect(deleteButton).toBeDisabled();
confirmSpy.mockRestore();
});
});
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 020b5831d..af4bd454f 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -148,6 +148,8 @@ const InstancePage = () => {
const nextInstances = await listInstances(query);
if (requestId === requestIdRef.current) {
setInstances(nextInstances);
+ const availableNames = new Set(nextInstances.map((instance) =>
instance.name));
+ setSelectedRowKeys((keys) => keys.filter((key) =>
availableNames.has(String(key))));
}
} catch {
if (requestId === requestIdRef.current) {
@@ -415,17 +417,22 @@ const InstancePage = () => {
};
const handleBatchDelete = () => {
- const names = selectedRowKeys.map(String);
- if (names.length === 0) return;
- const selected = instances.filter((instance) =>
names.includes(instance.name));
+ const selectedNames = new Set(selectedRowKeys.map(String));
+ const selected = instances.filter((instance) =>
selectedNames.has(instance.name));
+ const names = selected.map((instance) => instance.name);
+ if (names.length === 0) {
+ setSelectedRowKeys([]);
+ return;
+ }
const hasCloud = selected.some(
(instance) => instance.vendor === 'ALIYUN' || instance.vendor ===
'TENCENT',
);
+ const warning = hasCloud
+ ? '云厂商实例仅从 Studio 移除记录,不会释放云上的 RocketMQ 实例;仍有 Topic/Group 的开源实例无法删除。'
+ : '仍有 Topic/Group 的开源实例无法删除。';
Modal.confirm({
title: `确认删除选中的 ${names.length} 个实例?`,
- content: hasCloud
- ? '云厂商实例仅从 Studio 移除记录,不会释放云上的 RocketMQ 实例;仍有 Topic/Group 的开源实例无法删除。'
- : '仍有 Topic/Group 的开源实例无法删除。',
+ content: `将删除:${names.join('、')}。${warning}`,
okText: '删除',
okButtonProps: { danger: true },
onOk: async () => {
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index b6371b5b0..b42df5e46 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -1,6 +1,15 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
-import { formatBytes, formatRelativeTime, formatTimeOfDay } from './format';
+import {
+ formatBytes,
+ formatDate,
+ formatDateTime,
+ formatDelay,
+ formatNumber,
+ formatPercent,
+ formatRelativeTime,
+ formatTimeOfDay,
+} from './format';
describe('formatBytes', () => {
it('formats zero', () => {
@@ -24,6 +33,24 @@ describe('formatBytes', () => {
expect(formatBytes(Number.NEGATIVE_INFINITY)).toBe('-');
});
+ it('bounds invalid precision arguments', () => {
+ expect(formatBytes(1536, Number.POSITIVE_INFINITY)).toBe('1.5 KB');
+ expect(formatBytes(1536, -2)).toBe('2 KB');
+ expect(formatPercent(12.345, Number.NaN)).toBe('12.3%');
+ expect(formatPercent(12.345, -1)).toBe('12%');
+ });
+
+ it('uses a placeholder for invalid dates and numeric values', () => {
+ expect(formatDate('not-a-date')).toBe('-');
+ expect(formatDateTime(new Date(Number.NaN))).toBe('-');
+ expect(formatRelativeTime(Number.NaN, 'en', (key) => key)).toBe('-');
+ expect(formatRelativeTime(Date.now(), 'en', (key) => key,
Number.POSITIVE_INFINITY)).toBe('-');
+ expect(formatTimeOfDay(Number.POSITIVE_INFINITY)).toBe('-');
+ expect(formatNumber(Number.NaN)).toBe('-');
+ expect(formatDelay(Number.POSITIVE_INFINITY, 'en')).toBe('-');
+ expect(formatPercent(Number.NEGATIVE_INFINITY)).toBe('-');
+ });
+
it('formats recent timestamps for compact conversation history', () => {
const now = new Date(2026, 7, 13, 15, 30).getTime();
const zh = (key: string, params?: Record<string, string | number>) =>
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 81fd6d044..5f8556c5a 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -16,6 +16,8 @@
*/
const pad = (n: number, width = 2): string => String(n).padStart(width, '0');
+const safeDecimals = (decimals: number): number =>
+ Number.isFinite(decimals) ? Math.min(100, Math.max(0, Math.trunc(decimals)))
: 1;
/**
* Format a date string or Date object to 'YYYY-MM-DD HH:mm:ss'.
@@ -23,7 +25,7 @@ const pad = (n: number, width = 2): string =>
String(n).padStart(width, '0');
export function formatDateTime(date: string | Date | null | undefined): string
{
if (date === null || date === undefined || (typeof date === 'string' &&
!date.trim())) return '-';
const d = typeof date === 'string' ? new Date(date) : date;
- if (isNaN(d.getTime())) return String(date);
+ if (isNaN(d.getTime())) return '-';
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
@@ -36,7 +38,7 @@ export function formatDateTime(date: string | Date | null |
undefined): string {
export function formatDate(date: string | Date | null | undefined): string {
if (date === null || date === undefined || (typeof date === 'string' &&
!date.trim())) return '-';
const d = typeof date === 'string' ? new Date(date) : date;
- if (isNaN(d.getTime())) return String(date);
+ if (isNaN(d.getTime())) return '-';
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
@@ -51,6 +53,7 @@ export function formatRelativeTime(
t: RelativeTimeTranslator,
now = Date.now(),
): string {
+ if (!Number.isFinite(timestamp) || !Number.isFinite(now)) return '-';
if (!timestamp) return t('ai.history.justNow');
const elapsed = Math.max(0, now - timestamp);
@@ -62,7 +65,11 @@ export function formatRelativeTime(
const current = new Date(now);
const locale = lang === 'zh' ? 'zh-CN' : 'en-US';
if (updatedAt.toDateString() === current.toDateString()) {
- return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute:
'2-digit', hour12: false }).format(updatedAt);
+ return new Intl.DateTimeFormat(locale, {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(updatedAt);
}
return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric'
}).format(updatedAt);
}
@@ -71,7 +78,9 @@ export function formatRelativeTime(
* Format a message timestamp for a compact chat bubble footer.
*/
export function formatTimeOfDay(timestamp: number): string {
+ if (!Number.isFinite(timestamp)) return '-';
const date = new Date(timestamp);
+ if (Number.isNaN(date.getTime())) return '-';
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
@@ -92,7 +101,7 @@ export function formatBytes(bytes: number, decimals = 1):
string {
value /= k;
i += 1;
}
- return `${value.toFixed(decimals)} ${units[i]}`;
+ return `${value.toFixed(safeDecimals(decimals))} ${units[i]}`;
}
/**
@@ -100,7 +109,7 @@ export function formatBytes(bytes: number, decimals = 1):
string {
* e.g. 1234567 → '1,234,567'
*/
export function formatNumber(num: number): string {
- return num.toLocaleString('en-US');
+ return Number.isFinite(num) ? num.toLocaleString('en-US') : '-';
}
/**
@@ -109,6 +118,7 @@ export function formatNumber(num: number): string {
* e.g. 82500 → zh: "22小时55分钟", en: "22h 55m"
*/
export function formatDelay(totalSeconds: number, lang: 'zh' | 'en' = 'zh'):
string {
+ if (!Number.isFinite(totalSeconds)) return '-';
if (totalSeconds <= 0) return lang === 'zh' ? '0秒' : '0s';
const days = Math.floor(totalSeconds / 86400);
@@ -139,5 +149,6 @@ export function formatDelay(totalSeconds: number, lang:
'zh' | 'en' = 'zh'): str
* Format a percentage value (0-100) with fixed decimals.
*/
export function formatPercent(value: number, decimals = 1): string {
- return `${value.toFixed(decimals)}%`;
+ if (!Number.isFinite(value)) return '-';
+ return `${value.toFixed(safeDecimals(decimals))}%`;
}
diff --git a/web/src/utils/messageProperties.test.ts
b/web/src/utils/messageProperties.test.ts
new file mode 100644
index 000000000..95a2c4439
--- /dev/null
+++ b/web/src/utils/messageProperties.test.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 { parseMessageProperties } from './messageProperties';
+
+describe('parseMessageProperties', () => {
+ it('preserves JavaScript object prototype property names', () => {
+ const result = parseMessageProperties(
+
'__proto__=trace-prototype\nconstructor=trace-constructor\ntoString=trace-string',
+ );
+
+ expect(result.errors).toEqual([]);
+ expect(Object.keys(result.properties)).toEqual(['__proto__',
'constructor', 'toString']);
+ expect(result.properties['__proto__']).toBe('trace-prototype');
+ expect(result.properties['constructor']).toBe('trace-constructor');
+ expect(result.properties['toString']).toBe('trace-string');
+ const serialized = JSON.parse(JSON.stringify(result.properties)) as
Record<string, string>;
+ expect(serialized['__proto__']).toBe('trace-prototype');
+ expect(serialized['constructor']).toBe('trace-constructor');
+ expect(serialized['toString']).toBe('trace-string');
+ });
+
+ it('still reports duplicate special property names', () => {
+ const result = parseMessageProperties('__proto__=first\n__proto__=second');
+
+ expect(result.properties['__proto__']).toBe('first');
+ expect(result.errors).toEqual(['属性名“__proto__”重复']);
+ });
+});
diff --git a/web/src/utils/messageProperties.ts
b/web/src/utils/messageProperties.ts
index 552b0d07c..41641a4e1 100644
--- a/web/src/utils/messageProperties.ts
+++ b/web/src/utils/messageProperties.ts
@@ -22,7 +22,7 @@ interface ParsedProperties {
// 解析批量粘贴的用户属性串:key=value 按换行或逗号分隔,等号只取第一个
export const parseMessageProperties = (text: string): ParsedProperties => {
- const properties: Record<string, string> = {};
+ const entries = new Map<string, string>();
const errors: string[] = [];
for (const line of text.split(/[\n,]+/)) {
const trimmed = line.trim();
@@ -35,11 +35,11 @@ export const parseMessageProperties = (text: string):
ParsedProperties => {
const key = trimmed.slice(0, eqIndex).trim();
if (!key) {
errors.push(`“${trimmed}”的属性名不能为空`);
- } else if (Object.prototype.hasOwnProperty.call(properties, key)) {
+ } else if (entries.has(key)) {
errors.push(`属性名“${key}”重复`);
} else {
- properties[key] = trimmed.slice(eqIndex + 1).trim();
+ entries.set(key, trimmed.slice(eqIndex + 1).trim());
}
}
- return { properties, errors };
+ return { properties: Object.fromEntries(entries), errors };
};