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 bb0594ffa fix(web): consolidate frontend display guards and mock
alignment fixes (#2627)
bb0594ffa is described below
commit bb0594ffa4cd59dc46c5e96b705a1a0c3c93b9c2
Author: yyqdbngt <[email protected]>
AuthorDate: Mon Aug 31 19:47:49 2026 +0800
fix(web): consolidate frontend display guards and mock alignment fixes
(#2627)
* fix(web): handle unavailable ACL credentials
* fix(alerts): terminate exports on empty pages
* fix(message): preserve epoch queue timestamps
* fix(dlq): guard invalid message timestamps
* fix(ai): enforce history limit in UTF-8 bytes
* fix(lite-topic): guard invalid activity timestamps
* fix(mock): normalize connection filters consistently
* fix(mock): validate message pagination consistently
* fix(mock): validate ACL user pagination
* fix(mock): isolate Grafana dashboard state
---------
Co-authored-by: Yue Wang <[email protected]>
---
web/src/api/acl.ts | 4 +-
web/src/components/QueueBrowser.tsx | 6 +--
web/src/components/__tests__/QueueBrowser.test.tsx | 15 +++++-
web/src/pages/instance/__tests__/AclPage.test.tsx | 27 +++++++++++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 8 +++-
web/src/pages/instance/acl.tsx | 29 ++++++------
web/src/pages/instance/dlq.tsx | 32 ++++++++-----
.../pages/ops/__tests__/SystemAlertsPage.test.tsx | 53 ++++++++++++++++++++++
web/src/pages/ops/systemAlerts.tsx | 25 ++++++++--
web/src/pages/studio/LiteTopic.tsx | 7 +--
web/src/pages/studio/__tests__/LiteTopic.test.tsx | 10 +++-
web/src/services/aclService.test.ts | 8 ++++
web/src/services/aclService.ts | 3 ++
web/src/services/connectionsService.test.ts | 18 ++++++++
web/src/services/connectionsService.ts | 7 +--
web/src/services/grafanaService.test.ts | 12 +++++
web/src/services/grafanaService.ts | 4 +-
web/src/services/messageService.test.ts | 10 ++++
web/src/services/messageService.ts | 3 ++
web/src/stores/aiChatHistoryStore.test.ts | 19 ++++++++
web/src/stores/aiChatHistoryStore.ts | 4 +-
21 files changed, 260 insertions(+), 44 deletions(-)
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index a4ac9331d..003a86922 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -50,8 +50,8 @@ export interface AclUserPage {
export interface AclUser {
id: AclEntityId;
username: string;
- accessKey: string;
- secretKey: string;
+ accessKey?: string | null;
+ secretKey?: string | null;
admin: boolean;
clusters: string[];
permRead?: boolean;
diff --git a/web/src/components/QueueBrowser.tsx
b/web/src/components/QueueBrowser.tsx
index cd0e1024c..9b9c5b005 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -42,9 +42,9 @@ export interface TopicOption {
value: string;
}
-const formatTimeMs = (value: number | string) => {
- const ts = typeof value === 'string' ? new Date(value).getTime() : value;
- if (!ts || Number.isNaN(ts)) return '-';
+export const formatTimeMs = (value: number | string) => {
+ const ts = typeof value === 'string' ? Date.parse(value) : value;
+ if (!Number.isFinite(ts)) return '-';
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
};
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx
b/web/src/components/__tests__/QueueBrowser.test.tsx
index f0aa7a908..261a47411 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -20,7 +20,7 @@ import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MessageRecord, QueueOffset } from '../../api/message';
import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
-import { useQueueBrowser } from '../QueueBrowser';
+import { formatTimeMs, useQueueBrowser } from '../QueueBrowser';
vi.mock('../../api/message', () => ({
getQueueOffsets: vi.fn(),
@@ -89,6 +89,19 @@ function QueueBrowserProbe({ instanceId = 'instance-a' }: {
instanceId?: string
);
}
+describe('formatTimeMs', () => {
+ it('preserves the Unix epoch timestamp', () => {
+ expect(formatTimeMs(0)).not.toBe('-');
+ });
+
+ it.each(['not-a-date', Number.NaN, Number.POSITIVE_INFINITY])(
+ 'returns a placeholder for invalid timestamp %s',
+ (value) => {
+ expect(formatTimeMs(value)).toBe('-');
+ },
+ );
+});
+
describe('QueueBrowser request ownership', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index cf5befec8..f279794e4 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -131,6 +131,33 @@ describe('ACL page', () => {
expect(aclService.pageAclUsers).toHaveBeenCalledTimes(1);
});
+ it('shows a non-copyable placeholder when an ACL user has no access key',
async () => {
+ const user = userEvent.setup();
+ vi.mocked(aclService.pageAclUsers).mockResolvedValue({
+ items: [
+ {
+ id: 12,
+ username: 'cloud-role',
+ accessKey: null,
+ secretKey: null,
+ admin: false,
+ clusters: ['cluster-a'],
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ renderWithProviders(<AclPage />);
+
+ await user.click(await screen.findByText('用户管理'));
+ const row = await screen.findByRole('row', { name: /cloud-role/ });
+ const accessKeyCell = row.querySelectorAll('td')[1];
+
+ expect(within(accessKeyCell).getByText('-')).toBeInTheDocument();
+
expect(within(accessKeyCell).queryByRole('button')).not.toBeInTheDocument();
+ });
+
it('closes an ACL rule dialog when switching to another instance', async ()
=> {
const user = userEvent.setup();
vi.mocked(instanceService.listInstances).mockResolvedValue([
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index df86adf25..57aa6aba0 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -24,7 +24,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect,
it, vi } from 'vite
import type { DLQGroup, DLQGroupPage, DLQMessagePage, DLQResendResult } from
'../../../api/message';
import { LangProvider } from '../../../i18n/LangContext';
import * as messageService from '../../../services/messageService';
-import DLQPage from '../dlq';
+import DLQPage, { formatDateTime } from '../dlq';
vi.mock('../../../services/messageService', () => ({
listDLQGroups: vi.fn(),
@@ -155,6 +155,12 @@ describe('DLQ page', () => {
vi.clearAllMocks();
});
+ it('renders invalid message timestamps as unavailable without throwing', ()
=> {
+ expect(formatDateTime(Number.NaN)).toBe('-');
+ expect(formatDateTime(Number.POSITIVE_INFINITY)).toBe('-');
+ expect(formatDateTime(0)).not.toBe('-');
+ });
+
it('loads DLQ groups through the service layer', async () => {
renderWithProviders(<DLQPage />);
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index d06bf5416..bb08518bf 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -90,7 +90,9 @@ const normalizeRule = (rule: AclRule): AclRule => ({
gmtCreate: rule.gmtCreate ?? null,
});
-const normalizeUser = (user: AclUser): AclUser => ({
+type NormalizedAclUser = AclUser & { accessKey: string; secretKey: string };
+
+const normalizeUser = (user: AclUser): NormalizedAclUser => ({
...user,
id: user.id ?? user.username,
username: user.username ?? '',
@@ -125,7 +127,7 @@ const AclPageContent = ({
/* ─── State ─── */
const [rules, setRules] = useState<AclRule[]>([]);
- const [users, setUsers] = useState<AclUser[]>([]);
+ const [users, setUsers] = useState<NormalizedAclUser[]>([]);
const [rulesLoading, setRulesLoading] = useState(hasSelectedInstance);
const [usersLoading, setUsersLoading] = useState(hasSelectedInstance);
const [userPage, setUserPage] = useState(1);
@@ -154,7 +156,7 @@ const AclPageContent = ({
// User modal
const [userModalOpen, setUserModalOpen] = useState(false);
- const [editingUser, setEditingUser] = useState<AclUser | null>(null);
+ const [editingUser, setEditingUser] = useState<NormalizedAclUser |
null>(null);
const [userForm] = Form.useForm();
// Secret key reveal
@@ -361,8 +363,8 @@ const AclPageContent = ({
setCredentialsByUser((prev) => ({
...prev,
[userKey]: {
- accessKey: credentials.accessKey,
- secretKey: credentials.secretKey,
+ accessKey: credentials.accessKey ?? '',
+ secretKey: credentials.secretKey ?? '',
},
}));
} catch {
@@ -383,7 +385,7 @@ const AclPageContent = ({
setUserModalOpen(true);
};
- const openEditUserModal = (user: AclUser) => {
+ const openEditUserModal = (user: NormalizedAclUser) => {
setEditingUser(user);
userForm.setFieldsValue({
username: user.username,
@@ -696,14 +698,14 @@ const AclPageContent = ({
/* ═══════════════════════════════════════════
Users Table
═══════════════════════════════════════════ */
- const userColumns: ColumnsType<AclUser> = [
+ const userColumns: ColumnsType<NormalizedAclUser> = [
{
title: t('acl.username'),
dataIndex: 'username',
key: 'username',
width: 200,
sorter: (a, b) => a.username.localeCompare(b.username),
- render: (text: string, record: AclUser) => (
+ render: (text: string, record: NormalizedAclUser) => (
<Space size={6}>
<User size={14} color="#8c8c8c" weight="fill" />
<span style={{ fontWeight: 500 }}>{text}</span>
@@ -722,16 +724,17 @@ const AclPageContent = ({
key: 'accessKey',
width: 220,
sorter: (a, b) => a.accessKey.localeCompare(b.accessKey),
- render: (text: string, record: AclUser) => {
+ render: (text: string, record: NormalizedAclUser) => {
const revealed = revealedKeys.has(record.id);
const fullAccessKey = credentialsByUser[String(record.id)]?.accessKey
?? text;
+ const displayedAccessKey = revealed && fullAccessKey ? fullAccessKey :
text || '-';
return (
<Space size={8}>
<Typography.Text
- copyable={{ text: fullAccessKey }}
+ copyable={fullAccessKey ? { text: fullAccessKey } : false}
style={{ fontFamily: 'monospace', fontSize: 14 }}
>
- {revealed ? fullAccessKey : text}
+ {displayedAccessKey}
</Typography.Text>
</Space>
);
@@ -742,7 +745,7 @@ const AclPageContent = ({
dataIndex: 'secretKey',
key: 'secretKey',
width: 240,
- render: (_: string, record: AclUser) => {
+ render: (_: string, record: NormalizedAclUser) => {
const revealed = revealedKeys.has(record.id);
const secret = credentialsByUser[String(record.id)]?.secretKey;
return (
@@ -809,7 +812,7 @@ const AclPageContent = ({
title: t('common.actions'),
key: 'userActions',
width: 160,
- render: (_: unknown, record: AclUser) => (
+ render: (_: unknown, record: NormalizedAclUser) => (
<Flex gap={6}>
<Button
size="small"
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 5cb14544e..f97ebda50 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -78,9 +78,9 @@ const getErrorMessage = (error: unknown, fallback: string):
string => {
return fallback;
};
-const formatDateTime = (iso?: string | null): string => {
- if (!iso) return '-';
- const d = new Date(iso);
+export const formatDateTime = (value?: string | number | null): string => {
+ if (value === undefined || value === null || value === '') return '-';
+ const d = new Date(value);
if (Number.isNaN(d.getTime())) return '-';
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())}`;
@@ -534,9 +534,7 @@ const DLQPage = () => {
key: 'storeTime',
width: 160,
render: (storeTime: number) => (
- <Text style={{ fontFamily: 'monospace', fontSize: 14 }}>
- {formatDateTime(new Date(storeTime).toISOString())}
- </Text>
+ <Text style={{ fontFamily: 'monospace', fontSize: 14
}}>{formatDateTime(storeTime)}</Text>
),
},
{
@@ -555,7 +553,7 @@ const DLQPage = () => {
ellipsis: true,
render: (body: string | null) => (
<Text type="secondary" style={{ fontSize: 14 }}>
- {body && body.length > 80 ? `${body.slice(0, 80)}…` : body ?? '-'}
+ {body && body.length > 80 ? `${body.slice(0, 80)}…` : (body ?? '-')}
</Text>
),
},
@@ -791,7 +789,10 @@ const DLQPage = () => {
>
<Space size={24} wrap>
<div>
- <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
+ <Text
+ type="secondary"
+ style={{ fontSize: 14, display: 'block', marginBottom: 4 }}
+ >
DLQ Topic
</Text>
<Text copyable style={{ fontFamily: 'monospace' }}>
@@ -799,17 +800,26 @@ const DLQPage = () => {
</Text>
</div>
<div>
- <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
+ <Text
+ type="secondary"
+ style={{ fontSize: 14, display: 'block', marginBottom: 4 }}
+ >
死信数量
</Text>
- <Text strong style={{ color: detailGroup.messageCount > 0 ?
'#fa8c16' : undefined }}>
+ <Text
+ strong
+ style={{ color: detailGroup.messageCount > 0 ? '#fa8c16' :
undefined }}
+ >
{detailGroup.statsAvailable === false
? '不可用'
: detailGroup.messageCount.toLocaleString()}
</Text>
</div>
<div>
- <Text type="secondary" style={{ fontSize: 14, display:
'block', marginBottom: 4 }}>
+ <Text
+ type="secondary"
+ style={{ fontSize: 14, display: 'block', marginBottom: 4 }}
+ >
最近入队时间
</Text>
<Text style={{ fontFamily: 'monospace' }}>
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 41cd1fdb2..9ae4fe41a 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -10,7 +10,9 @@ import { fireEvent, render, screen, waitFor } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
import { formatUtcDateTime } from '../../../utils/format';
+import { downloadCsv } from '../../../utils/download';
import {
acknowledgeAlert,
createAlertSilence,
@@ -35,6 +37,12 @@ vi.mock('../../../services/opsService', () => ({
deleteAlertSilence: vi.fn(),
}));
+vi.mock('../../../utils/download', async () => {
+ const actual =
+ await vi.importActual<typeof
import('../../../utils/download')>('../../../utils/download');
+ return { ...actual, downloadCsv: vi.fn() };
+});
+
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -89,6 +97,51 @@ describe('SystemAlertsPage', () => {
vi.mocked(listAlertSilences).mockResolvedValue([]);
});
+ it('finishes an export when a later page is empty after the result set
shrinks', async () => {
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ vi.mocked(listSystemAlertsPage)
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 1,
+ level: 'error',
+ title: 'Broker unavailable',
+ description: 'broker a',
+ time: '2026-08-10 01:00',
+ acknowledged: false,
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ })
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 1,
+ level: 'error',
+ title: 'Broker unavailable',
+ description: 'broker a',
+ time: '2026-08-10 01:00',
+ acknowledged: false,
+ },
+ ],
+ total: 200,
+ page: 1,
+ size: 100,
+ })
+ .mockResolvedValueOnce({ items: [], total: 200, page: 2, size: 100 });
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByText('Broker unavailable');
+
+ await user.click(screen.getByRole('button', { name: 'Export CSV' }));
+
+ await waitFor(() => expect(downloadCsv).toHaveBeenCalledTimes(1));
+ expect(listSystemAlertsPage).toHaveBeenCalledTimes(3);
+ expect(listSystemAlertsPage).toHaveBeenLastCalledWith({ page: 2, pageSize:
100 });
+ });
+
it('renders an alert with an unknown backend level', async () => {
vi.mocked(listSystemAlertsPage).mockReset();
vi.mocked(listSystemAlertsPage).mockResolvedValue({
diff --git a/web/src/pages/ops/systemAlerts.tsx
b/web/src/pages/ops/systemAlerts.tsx
index 7438485b0..513b50d81 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -60,6 +60,9 @@ import { buildCsv, downloadCsv, type CsvColumn } from
'../../utils/download';
const { Text } = Typography;
+const ALERT_EXPORT_PAGE_SIZE = 100;
+const ALERT_EXPORT_MAX_PAGES = 10_000;
+
const normalizeAlertLevel = (level?: string | null) => (level ??
'').toLowerCase();
const formatAlertTransition = (
transition: SystemAlert['transition'] | undefined,
@@ -256,11 +259,27 @@ const SystemAlertsPage = () => {
setExporting(true);
try {
const query = currentQuery();
- const first = await listSystemAlertsPage({ ...query, page: 1, pageSize:
100 });
+ const first = await listSystemAlertsPage({
+ ...query,
+ page: 1,
+ pageSize: ALERT_EXPORT_PAGE_SIZE,
+ });
const rows = [...first.items];
- for (let currentPage = 2; rows.length < first.total; currentPage += 1) {
- const result = await listSystemAlertsPage({ ...query, page:
currentPage, pageSize: 100 });
+ let expectedTotal = first.total;
+ let currentPage = 2;
+ while (rows.length < expectedTotal) {
+ if (currentPage > ALERT_EXPORT_MAX_PAGES) {
+ throw new Error('System alert export exceeded the pagination limit');
+ }
+ const result = await listSystemAlertsPage({
+ ...query,
+ page: currentPage,
+ pageSize: ALERT_EXPORT_PAGE_SIZE,
+ });
+ if (result.items.length === 0) break;
rows.push(...result.items);
+ expectedTotal = Math.min(expectedTotal, result.total);
+ currentPage += 1;
}
downloadCsv(
`rocketmq-system-alerts-${new Date().toISOString().slice(0, 10)}.csv`,
diff --git a/web/src/pages/studio/LiteTopic.tsx
b/web/src/pages/studio/LiteTopic.tsx
index 652d3b48f..b00a4b947 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -69,9 +69,10 @@ const formatDuration = (ms: number | undefined | null):
string => {
return `${(ms / 3600000).toFixed(1)}h`;
};
-const formatTime = (timestamp: number | undefined | null): string => {
- if (!timestamp) return '-';
- return new Date(timestamp).toLocaleString();
+export const formatTime = (timestamp: number | undefined | null): string => {
+ if (timestamp == null || !Number.isFinite(timestamp)) return '-';
+ const date = new Date(timestamp);
+ return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
const getProgressStatus = (percent: number): 'exception' | 'active' | 'normal'
=> {
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index eac0b4abe..0dc12a554 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -22,7 +22,7 @@ import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
import type { LiteTopicItem, LiteTopicQuota } from '../../../api/liteTopic';
import { downloadCsv } from '../../../utils/download';
-import LiteTopic from '../LiteTopic';
+import LiteTopic, { formatTime } from '../LiteTopic';
const apiMocks = vi.hoisted(() => ({
queryLiteTopicCapability: vi.fn(),
@@ -78,6 +78,14 @@ const createDeferred = <T,>() => {
return { promise, resolve, reject };
};
+describe('LiteTopic time formatting', () => {
+ it('preserves epoch timestamps and rejects invalid provider values', () => {
+ expect(formatTime(0)).toBe(new Date(0).toLocaleString());
+ expect(formatTime(Number.POSITIVE_INFINITY)).toBe('-');
+ expect(formatTime(Number.MAX_VALUE)).toBe('-');
+ });
+});
+
const createQuota = (currentTopicCount: number): LiteTopicQuota => ({
currentTopicCount,
maxTopicCount: 100,
diff --git a/web/src/services/aclService.test.ts
b/web/src/services/aclService.test.ts
index 98d180c5f..6aae08c4a 100644
--- a/web/src/services/aclService.test.ts
+++ b/web/src/services/aclService.test.ts
@@ -23,6 +23,7 @@ import {
examineBrokerClusterAclConfig,
listAclRules,
listAclUsers,
+ pageAclUsers,
updateAclRule,
updateAclUser,
} from './aclService';
@@ -81,6 +82,13 @@ describe('ACL service mock data', () => {
expect(second[0]).not.toBe(first[0]);
});
+ it('validates ACL user pagination like the backend', async () => {
+ await expect(pageAclUsers({ page: 0, pageSize: 20
})).rejects.toThrow('page must be >= 1');
+ await expect(pageAclUsers({ page: 1, pageSize: 101 })).rejects.toThrow(
+ 'pageSize must be between 1 and 100',
+ );
+ });
+
it('copies ACL user arrays on create and update', async () => {
const clusters = ['rmq-created'];
const created = await createAclUser({
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index ad1042519..877f6c182 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -96,6 +96,9 @@ export async function pageAclUsers(params: {
pageSize: number;
}): Promise<AclUserPage> {
if (isMockMode()) {
+ if (params.page < 1 || params.pageSize < 1 || params.pageSize > 100) {
+ throw new Error('page must be >= 1 and pageSize must be between 1 and
100');
+ }
const users = await listAclUsers(params);
const from = (params.page - 1) * params.pageSize;
return {
diff --git a/web/src/services/connectionsService.test.ts
b/web/src/services/connectionsService.test.ts
index 625eb02d7..7e703d645 100644
--- a/web/src/services/connectionsService.test.ts
+++ b/web/src/services/connectionsService.test.ts
@@ -77,4 +77,22 @@ describe('connectionsService mock connections', () => {
listConnections({ namesrvAddr: '10.101.2.1:9876', clusterId: 'ns-pre' }),
).resolves.toEqual([]);
});
+
+ it('normalizes optional filters like the real endpoint', async () => {
+ const padded = await listConnections({
+ namesrvAddr: '10.101.2.1:9876',
+ clusterId: ' ns-prod ',
+ type: ' Consumer ',
+ });
+ const blank = await listConnections({
+ namesrvAddr: '10.101.2.1:9876',
+ clusterId: ' ',
+ type: ' ',
+ });
+
+ expect(padded).not.toHaveLength(0);
+ expect(padded.every((connection) => connection.clusterName ===
'ns-prod')).toBe(true);
+ expect(padded.every((connection) => connection.type ===
'Consumer')).toBe(true);
+ expect(blank).not.toHaveLength(0);
+ });
});
diff --git a/web/src/services/connectionsService.ts
b/web/src/services/connectionsService.ts
index 44eba5a96..9630fe79d 100644
--- a/web/src/services/connectionsService.ts
+++ b/web/src/services/connectionsService.ts
@@ -16,10 +16,11 @@ export async function listConnections(params?:
ClientConnectionQuery): Promise<C
const instanceCluster = mockClientClusterByNamesrvAddr[namesrvAddr];
if (!instanceCluster) return [];
+ const clusterId = params?.clusterId?.trim();
+ const type = params?.type?.trim();
let result = mockClients.filter((connection) => connection.clusterName ===
instanceCluster);
- if (params?.clusterId)
- result = result.filter((connection) => connection.clusterName ===
params.clusterId);
- if (params?.type) result = result.filter((c) => c.type === params.type);
+ if (clusterId) result = result.filter((connection) =>
connection.clusterName === clusterId);
+ if (type) result = result.filter((connection) => connection.type === type);
return (result as unknown as ClientConnection[]).map(copyConnection);
}
return connApi.listConnections(params);
diff --git a/web/src/services/grafanaService.test.ts
b/web/src/services/grafanaService.test.ts
index 8bf35b7d8..ab39689c3 100644
--- a/web/src/services/grafanaService.test.ts
+++ b/web/src/services/grafanaService.test.ts
@@ -35,6 +35,18 @@ describe('grafanaService', () => {
expect(model.uid).toBe('rocketmq-overview');
});
+ it('does not expose mutable mock dashboard state', async () => {
+ const dashboards = await listGrafanaDashboards();
+ dashboards[0].tags.push('mutated');
+ const model = await getGrafanaDashboard('rocketmq-overview');
+ (model.panels as Array<Record<string, unknown>>)[0].title = 'mutated';
+
+ const freshDashboards = await listGrafanaDashboards();
+ const freshModel = await getGrafanaDashboard('rocketmq-overview');
+ expect(freshDashboards[0].tags).not.toContain('mutated');
+ expect((freshModel.panels as Array<Record<string,
unknown>>)[0].title).toBe('Messages In TPS');
+ });
+
it('throws for an unknown dashboard uid in mock mode', async () => {
await expect(getGrafanaDashboard('nope')).rejects.toThrow();
});
diff --git a/web/src/services/grafanaService.ts
b/web/src/services/grafanaService.ts
index 43120defb..7d2080a41 100644
--- a/web/src/services/grafanaService.ts
+++ b/web/src/services/grafanaService.ts
@@ -12,7 +12,7 @@ export async function listGrafanaDashboards():
Promise<GrafanaDashboardInfo[]> {
uid,
title,
description,
- tags,
+ tags: [...tags],
}));
}
return metricsApi.listGrafanaDashboards();
@@ -24,7 +24,7 @@ export async function getGrafanaDashboard(uid: string):
Promise<Record<string, u
if (!found) {
throw new Error(`Grafana dashboard not found: ${uid}`);
}
- return found.model;
+ return structuredClone(found.model);
}
return metricsApi.getGrafanaDashboard(uid);
}
diff --git a/web/src/services/messageService.test.ts
b/web/src/services/messageService.test.ts
index ff1113d6a..716392f7f 100644
--- a/web/src/services/messageService.test.ts
+++ b/web/src/services/messageService.test.ts
@@ -20,6 +20,7 @@ import {
consumeMessageDirectly,
getMessageTrace,
listDLQGroups,
+ queryMessagePage,
queryMessages,
} from './messageService';
@@ -54,6 +55,15 @@ describe('message service mock data', () => {
expect(messages.map((message) =>
message.msgId)).toEqual(['AC1E0A6400002A9F0000000001A3F7C2']);
});
+ it('validates mock message pagination like the real endpoint', async () => {
+ await expect(queryMessagePage({ topic: 'order-create', page: 0
})).rejects.toThrow(
+ 'page must be positive',
+ );
+ await expect(
+ queryMessagePage({ topic: 'order-create', page: 1, pageSize: 201 }),
+ ).rejects.toThrow('pageSize must be between 1 and 200');
+ });
+
it('returns copied message trace rows', async () => {
const first = await getMessageTrace('AC1E0A6400002A9F0000000001A3F2B1');
expect(first?.nodes[0].title).toBe('Producer 发送');
diff --git a/web/src/services/messageService.ts
b/web/src/services/messageService.ts
index 837aeebda..ebfba8b54 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -61,6 +61,9 @@ export async function queryMessagePage(
const items = await queryMessages(params);
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 50;
+ if (page < 1 || pageSize < 1 || pageSize > 200) {
+ throw new Error('page must be positive and pageSize must be between 1
and 200');
+ }
const from = Math.min((page - 1) * pageSize, items.length);
return {
items: items.slice(from, from + pageSize),
diff --git a/web/src/stores/aiChatHistoryStore.test.ts
b/web/src/stores/aiChatHistoryStore.test.ts
index 656f7bf58..1deffbcea 100644
--- a/web/src/stores/aiChatHistoryStore.test.ts
+++ b/web/src/stores/aiChatHistoryStore.test.ts
@@ -160,6 +160,25 @@ describe('aiChatHistoryStore', () => {
);
});
+ it('measures the persisted history limit in UTF-8 bytes', async () => {
+ const { MAX_AI_CHAT_HISTORY_BYTES } = await import('./aiChatHistoryStore');
+ const store = await loadStore();
+ for (let index = 0; index < 20; index += 1) {
+ store
+ .getState()
+ .setMessages('real', `conversation-${index}`, [
+ { id: `message-${index}`, role: 'user', text: '界'.repeat(16 * 1024)
},
+ ]);
+ }
+
+ const serialized = JSON.stringify({
+ conversations: store.getState().histories.real.conversations,
+ });
+ expect(new
TextEncoder().encode(serialized).byteLength).toBeLessThanOrEqual(
+ MAX_AI_CHAT_HISTORY_BYTES,
+ );
+ });
+
it('clears pending throttled persistence when histories are cleared', async
() => {
vi.useFakeTimers();
const store = await loadStore();
diff --git a/web/src/stores/aiChatHistoryStore.ts
b/web/src/stores/aiChatHistoryStore.ts
index d9a6e60be..4e0056252 100644
--- a/web/src/stores/aiChatHistoryStore.ts
+++ b/web/src/stores/aiChatHistoryStore.ts
@@ -83,6 +83,8 @@ const boundMessageFields = (messages: AiChatMessage[]):
AiChatMessage[] =>
thinking: truncateMessageField(message.thinking),
}));
+const utf8ByteLength = (value: string): number => new
TextEncoder().encode(value).byteLength;
+
const restoreMessages = (messages: unknown): AiChatMessage[] =>
(Array.isArray(messages) ? messages : [])
.filter(isRecord)
@@ -110,7 +112,7 @@ const limitHistorySize = (history: AiChatHistory):
AiChatHistory => {
}));
while (
conversations.length > 0 &&
- JSON.stringify({ conversations }).length > MAX_AI_CHAT_HISTORY_BYTES
+ utf8ByteLength(JSON.stringify({ conversations })) >
MAX_AI_CHAT_HISTORY_BYTES
) {
const oldestIndex = conversations.length - 1;
const oldest = conversations[oldestIndex];