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

Reply via email to