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 b38151f6 fix(web): guard stale responses and race conditions in Studio
pages (#1563)
b38151f6 is described below
commit b38151f68bb0ee6528167d6717afcbd02dc85ad5
Author: 0 <[email protected]>
AuthorDate: Thu Aug 13 16:11:58 2026 +0800
fix(web): guard stale responses and race conditions in Studio pages (#1563)
* fix(web): migrate deprecated Ant Design props across Studio pages
Consolidates 17 per-page migration PRs by 123123213weqw: message (#1835),
certificate (#1836), client (#1837), cluster (#1838), ACL (#1839),
consumer (#1840), DLQ (#1841), instance (#1842), topic (#1843),
alert rule (#1844), settings modal (#1845), alert asset modal (#1846),
group management (#1847), LiteTopic (#1848), producer (#1849),
proxy (#1850), SSL settings (#1851). Behavior unchanged.
* fix(server): harden validation, normalization and null-safety
Consolidates 40 backend robustness PRs by 123123213weqw (#1114,
#1733-#1735, #1737-#1739, #1742, #1744-#1745, #1854-#1882, #1884):
block new admin connections during shutdown, Locale.ROOT normalization
for LLM engines and agent providers, reject null or malformed persisted
JSON, clamp cloud catalog counts and retry values, skip null catalog,
trace and connection entries, report lost concurrent updates for alert
rules, ACL rules, credentials, instances and certificates, normalize
stored enum values, validate alert identifiers and stored auth modes.
* fix(web): guard stale responses and race conditions in Studio pages
Consolidates 16 frontend UX PRs by 123123213weqw (#1563, #1624, #1732,
#1743, #1747-#1750, #1852-#1853, #1883, #2020-#2024): DLQ resend request-id
guard, best-effort proxy address persistence, locale-independent navigation
search, preserve selected home prompt mode, normalize alert levels before
filtering, restore audit refresh loading state, bound AI SSE event buffers,
format blank dates as unavailable, hook mocks for instance changes and
topic-scoped traces, keep 401 errors for malformed request URLs, prevent
Mock-mode home reload loop, surface Message Explorer topic load failures,
refresh alerts after clearing acknowledged records, gate data source
mutations until the initial list is ready, ignore stale NameServer drift
bootstrap results.
---
web/src/api/ai.test.ts | 33 +++++++++++++++-
web/src/api/ai.ts | 43 ++++++++++++++-------
web/src/api/client.test.ts | 9 +++++
web/src/api/client.ts | 16 +++++---
web/src/api/message.test.ts | 12 ++++--
web/src/layouts/navigationSearch.test.ts | 15 +++++++-
web/src/layouts/navigationSearch.ts | 4 +-
web/src/pages/ai/__tests__/AiPage.test.tsx | 20 ++++++++--
web/src/pages/ai/chatDraft.test.ts | 10 +++++
web/src/pages/ai/chatDraft.ts | 10 +++++
web/src/pages/ai/index.tsx | 23 +++++++----
web/src/pages/home/__tests__/HomePage.test.tsx | 13 +++++++
web/src/pages/home/index.tsx | 13 +++++++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 45 +++++++++++++++++++++-
web/src/pages/instance/dlq.tsx | 43 ++++++++++++++++-----
web/src/pages/instance/message.tsx | 16 ++++++--
web/src/pages/ops/__tests__/AuditPage.test.tsx | 35 +++++++++++++++++
.../pages/ops/__tests__/SystemAlertsPage.test.tsx | 29 ++++++++++++++
web/src/pages/ops/audit.tsx | 3 ++
web/src/pages/ops/nameServerConfigDrift.tsx | 9 +++--
web/src/pages/ops/systemAlerts.tsx | 14 +++++--
web/src/pages/settings/index.tsx | 4 +-
web/src/pages/studio/Proxy.tsx | 18 +++++----
web/src/pages/studio/__tests__/Proxy.test.tsx | 13 +++++++
.../ai/chatDraft.ts => utils/format.test.ts} | 30 +++++++--------
web/src/utils/format.ts | 4 +-
26 files changed, 395 insertions(+), 89 deletions(-)
diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts
index 4475931d..6a3d9181 100644
--- a/web/src/api/ai.test.ts
+++ b/web/src/api/ai.test.ts
@@ -31,12 +31,13 @@ import {
const mock = new MockAdapter(client);
const encoder = new TextEncoder();
-function streamResponse(chunks: string[]): Response {
+function streamResponse(chunks: string[], onCancel?: () => void): Response {
const body = new ReadableStream<Uint8Array>({
start(controller) {
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)));
- controller.close();
+ if (!onCancel) controller.close();
},
+ cancel: onCancel,
});
return new Response(body, { status: 200 });
}
@@ -135,6 +136,34 @@ describe('AI API', () => {
expect(chunks).toEqual(['hello', 'raw text']);
});
+ it('cancels the reader after the done event', async () => {
+ const onCancel = vi.fn();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(streamResponse(['data: [DONE]\n\n'],
onCancel)),
+ );
+
+ await chatStream({ message: 'hello', mode: 'chat', model: 'stub' },
vi.fn());
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects an unbounded SSE event and cancels the reader', async () => {
+ const onCancel = vi.fn();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(streamResponse(['data: ' + 'x'.repeat(1024 *
1024)], onCancel)),
+ );
+
+ await expect(
+ chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, vi.fn()),
+ ).rejects.toMatchObject({
+ message: 'AI stream event exceeds 1 MiB',
+ code: 'llm.stream.event_too_large',
+ });
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
it('throws structured errors from SSE error events', async () => {
vi.stubGlobal(
'fetch',
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
index 399538d6..c834125e 100644
--- a/web/src/api/ai.ts
+++ b/web/src/api/ai.ts
@@ -18,6 +18,8 @@
import client from './client';
import { readAuthSession } from '../stores/authStorage';
+const MAX_SSE_EVENT_CHARS = 1024 * 1024;
+
// ─── Types ──────────────────────────────────────────────────────
export interface McpTool {
name: string;
@@ -178,22 +180,35 @@ export async function chatStream(
const decoder = new TextDecoder();
let buffer = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- let boundary = getEventBoundary(buffer);
- while (boundary) {
- const event = buffer.slice(0, boundary.index);
- buffer = buffer.slice(boundary.index + boundary.length);
- if (emitEvent(event, onChunk, onEnhance)) return;
- boundary = getEventBoundary(buffer);
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ if (buffer.length > MAX_SSE_EVENT_CHARS && !getEventBoundary(buffer)) {
+ throw new AiStreamError('AI stream event exceeds 1 MiB',
'llm.stream.event_too_large');
+ }
+ let boundary = getEventBoundary(buffer);
+ while (boundary) {
+ const event = buffer.slice(0, boundary.index);
+ buffer = buffer.slice(boundary.index + boundary.length);
+ if (event.length > MAX_SSE_EVENT_CHARS) {
+ throw new AiStreamError('AI stream event exceeds 1 MiB',
'llm.stream.event_too_large');
+ }
+ if (emitEvent(event, onChunk, onEnhance)) return;
+ boundary = getEventBoundary(buffer);
+ }
}
- }
- buffer += decoder.decode();
- if (buffer && emitEvent(buffer, onChunk, onEnhance)) return;
+ buffer += decoder.decode();
+ if (buffer.length > MAX_SSE_EVENT_CHARS) {
+ throw new AiStreamError('AI stream event exceeds 1 MiB',
'llm.stream.event_too_large');
+ }
+ if (buffer && emitEvent(buffer, onChunk, onEnhance)) return;
+ } finally {
+ await reader.cancel().catch(() => undefined);
+ }
}
export async function executeAiCommand(data: AiExecuteRequest) {
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts
index 27c31326..c6637610 100644
--- a/web/src/api/client.test.ts
+++ b/web/src/api/client.test.ts
@@ -148,4 +148,13 @@ describe('API client response contract', () => {
expect(localStorage.getItem('rocketmq-studio-user')).toBeNull();
expect(localStorage.getItem('rocketmq-studio-user-admin')).toBeNull();
});
+
+ it('preserves the original 401 error when the request URL is malformed',
async () => {
+ localStorage.setItem('token', 'expired-token');
+ mock.onGet('http://[').reply(401, { code: 401, message: 'Unauthorized',
data: null });
+
+ await expect(client.get('http://[')).rejects.toMatchObject({ response: {
status: 401 } });
+
+ expect(localStorage.getItem('token')).toBeNull();
+ });
});
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 5b002a0f..668ca891 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -47,12 +47,16 @@ function getBusinessError(data: unknown): string | null {
function isPublicAuthRequest(url?: string): boolean {
if (!url) return false;
- const requestPath = new URL(url, window.location.origin).pathname;
- const apiBasePath = new URL(API_BASE_URL, window.location.origin).pathname;
- const relativePath = requestPath.startsWith(`${apiBasePath}/`)
- ? requestPath.slice(apiBasePath.length)
- : requestPath;
- return PUBLIC_AUTH_PATHS.has(relativePath);
+ try {
+ const requestPath = new URL(url, window.location.origin).pathname;
+ const apiBasePath = new URL(API_BASE_URL, window.location.origin).pathname;
+ const relativePath = requestPath.startsWith(`${apiBasePath}/`)
+ ? requestPath.slice(apiBasePath.length)
+ : requestPath;
+ return PUBLIC_AUTH_PATHS.has(relativePath);
+ } catch {
+ return false;
+ }
}
const client = axios.create({
diff --git a/web/src/api/message.test.ts b/web/src/api/message.test.ts
index 38802c4d..8541d9f8 100644
--- a/web/src/api/message.test.ts
+++ b/web/src/api/message.test.ts
@@ -108,10 +108,10 @@ describe('message API', () => {
],
};
mock
- .onGet('/messages/msg-1/trace', { params: { instanceId: 'instance-a' } })
+ .onGet('/messages/msg-1/trace', { params: { instanceId: 'instance-a',
topic: 'orders' } })
.reply(200, { code: 200, data: trace });
- await expect(getMessageTrace('msg-1',
'instance-a')).resolves.toEqual(trace);
+ await expect(getMessageTrace('msg-1', 'instance-a',
'orders')).resolves.toEqual(trace);
});
it('encodes message IDs before requesting trace records', async () => {
@@ -120,9 +120,13 @@ describe('message API', () => {
consumerStatus: [],
};
mock
- .onGet('/messages/AC1E0A64%2F0000%202A9F%3A1/trace', { params: {
instanceId: 'instance-a' } })
+ .onGet('/messages/AC1E0A64%2F0000%202A9F%3A1/trace', {
+ params: { instanceId: 'instance-a', topic: 'orders' },
+ })
.reply(200, { code: 200, data: trace });
- await expect(getMessageTrace('AC1E0A64/0000 2A9F:1',
'instance-a')).resolves.toEqual(trace);
+ await expect(getMessageTrace('AC1E0A64/0000 2A9F:1', 'instance-a',
'orders')).resolves.toEqual(
+ trace,
+ );
});
});
diff --git a/web/src/layouts/navigationSearch.test.ts
b/web/src/layouts/navigationSearch.test.ts
index 18f14801..db5c2680 100644
--- a/web/src/layouts/navigationSearch.test.ts
+++ b/web/src/layouts/navigationSearch.test.ts
@@ -15,10 +15,14 @@
* limitations under the License.
*/
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { filterNavigationEntries, isNavigationSearchShortcut } from
'./navigationSearch';
describe('navigation search helpers', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
const entries = [
{ key: '/cluster', label: 'RocketMQ 集群' },
{ key: '/settings', label: 'Settings' },
@@ -30,6 +34,15 @@ describe('navigation search helpers', () => {
expect(filterNavigationEntries(entries, '集群')).toEqual([entries[0]]);
});
+ it('normalizes searches independently of the browser locale', () => {
+ const localeLowerCase = String.prototype.toLocaleLowerCase;
+ vi.spyOn(String.prototype,
'toLocaleLowerCase').mockImplementation(function (this: string) {
+ return localeLowerCase.call(this, 'tr');
+ });
+
+ expect(filterNavigationEntries(entries, 'SETTINGS')).toEqual([entries[1]]);
+ });
+
it('filters by route keys case-insensitively', () => {
expect(filterNavigationEntries(entries, 'CLUSTER')).toEqual([entries[0]]);
expect(filterNavigationEntries(entries,
'/settings')).toEqual([entries[1]]);
diff --git a/web/src/layouts/navigationSearch.ts
b/web/src/layouts/navigationSearch.ts
index 0be63cbc..89c0b5dc 100644
--- a/web/src/layouts/navigationSearch.ts
+++ b/web/src/layouts/navigationSearch.ts
@@ -26,7 +26,7 @@ export interface NavigationSearchEntry {
function normalizeSearchText(value: string): string {
return value
.normalize('NFKC')
- .toLocaleLowerCase()
+ .toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
}
@@ -78,7 +78,7 @@ export function isNavigationSearchShortcut(event: {
) !== null;
return (
- event.key.toLocaleLowerCase() === 'k' &&
+ event.key.toLowerCase() === 'k' &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!isEditableTarget
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 1f92bb3b..4392bbf7 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -21,7 +21,7 @@ import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { MemoryRouter } from 'react-router-dom';
import { LangProvider } from '../../../i18n/LangContext';
-import { executeTool, listTools } from '../../../api/ai';
+import { chatStream, executeTool, listTools } from '../../../api/ai';
import { listClusters, type ClusterInfo } from '../../../api/cluster';
import { getLlmConfig, getLlmModels } from '../../../api/llm';
import AiPage from '../index';
@@ -59,11 +59,11 @@ beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
-const renderPage = () =>
+const renderPage = (state?: unknown) =>
render(
<App>
<LangProvider>
- <MemoryRouter initialEntries={['/ai']}>
+ <MemoryRouter initialEntries={[state === undefined ? '/ai' : {
pathname: '/ai', state }]}>
<AiPage />
</MemoryRouter>
</LangProvider>
@@ -105,6 +105,20 @@ describe('AiPage tool runner', () => {
]);
});
+ it('uses the mode carried from the home-page draft', async () => {
+ vi.mocked(chatStream).mockResolvedValue(undefined);
+ renderPage({ prompt: '检查集群状态', mode: 'diagnose' });
+
+ await waitFor(() => {
+ expect(chatStream).toHaveBeenCalledWith(
+ expect.objectContaining({ message: '检查集群状态', mode: 'diagnose' }),
+ expect.any(Function),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
+ });
+ });
+
it('loads the catalog, creates a schema template, and renders structured
output', async () => {
const user = userEvent.setup();
vi.mocked(executeTool).mockResolvedValue({
diff --git a/web/src/pages/ai/chatDraft.test.ts
b/web/src/pages/ai/chatDraft.test.ts
index d7d89f42..734e9046 100644
--- a/web/src/pages/ai/chatDraft.test.ts
+++ b/web/src/pages/ai/chatDraft.test.ts
@@ -26,6 +26,16 @@ describe('AI chat draft navigation state', () => {
});
});
+ it('preserves supported home modes and drops unknown modes', () => {
+ expect(getChatDraft({ prompt: '检查集群状态', mode: 'diagnose' })).toEqual({
+ prompt: '检查集群状态',
+ mode: 'diagnose',
+ });
+ expect(getChatDraft({ prompt: '检查集群状态', mode: 'unknown' })).toEqual({
+ prompt: '检查集群状态',
+ });
+ });
+
it('rejects invalid or empty navigation state', () => {
expect(getChatDraft(null)).toBeNull();
expect(getChatDraft({ prompt: ' ' })).toBeNull();
diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/pages/ai/chatDraft.ts
index f26f14a9..b206cf32 100644
--- a/web/src/pages/ai/chatDraft.ts
+++ b/web/src/pages/ai/chatDraft.ts
@@ -15,9 +15,14 @@
* limitations under the License.
*/
+export type ChatMode = 'chat' | 'diagnose' | 'manage' | 'query';
+
+const CHAT_MODES = new Set<ChatMode>(['chat', 'diagnose', 'manage', 'query']);
+
export interface ChatDraft {
prompt: string;
model?: string;
+ mode?: ChatMode;
enhance?: boolean;
}
@@ -26,10 +31,15 @@ export function getChatDraft(state: unknown): ChatDraft |
null {
const candidate = state as Record<string, unknown>;
if (typeof candidate.prompt !== 'string' || !candidate.prompt.trim()) return
null;
const model = typeof candidate.model === 'string' ? candidate.model.trim() :
'';
+ const mode =
+ typeof candidate.mode === 'string' && CHAT_MODES.has(candidate.mode as
ChatMode)
+ ? (candidate.mode as ChatMode)
+ : undefined;
return {
prompt: candidate.prompt.trim(),
...(model ? { model } : {}),
+ ...(mode ? { mode } : {}),
...(candidate.enhance === true ? { enhance: true } : {}),
};
}
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 2926670b..4a7e4153 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -46,7 +46,7 @@ import { AiStreamError, chatStream, executeTool, listTools,
type McpTool } from
import { listClusters } from '../../api/cluster';
import { getLlmConfig, getLlmModels, type LlmConfig } from '../../api/llm';
import { useEngineStore } from '../../stores/engineStore';
-import { getChatDraft } from './chatDraft';
+import { getChatDraft, type ChatMode } from './chatDraft';
const { Text } = Typography;
@@ -404,9 +404,12 @@ const AiPage = () => {
const conversationIdRef = useRef<string | null>(null);
const toolLoadRequestRef = useRef(0);
const consumedDraftRef = useRef(false);
- const pendingAutoSendRef = useRef<{ prompt: string; model?: string;
enhance?: boolean } | null>(
- null,
- );
+ const pendingAutoSendRef = useRef<{
+ prompt: string;
+ model?: string;
+ mode?: ChatMode;
+ enhance?: boolean;
+ } | null>(null);
const scrollToBottom = useCallback(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -466,6 +469,7 @@ const AiPage = () => {
pendingAutoSendRef.current = {
prompt: draft.prompt,
model: draft.model,
+ mode: draft.mode,
enhance: draft.enhance,
};
navigate('/ai', { replace: true, state: null });
@@ -491,7 +495,12 @@ const AiPage = () => {
const llmReady = Boolean((llmConfig?.ready ?? llmConfig?.enabled) &&
selectedModel);
const handleSend = useCallback(
- async (textOverride?: string, modelOverride?: string, enhance?: boolean)
=> {
+ async (
+ textOverride?: string,
+ modelOverride?: string,
+ enhance?: boolean,
+ modeOverride: ChatMode = 'chat',
+ ) => {
const text = (textOverride ?? inputValue).trim();
const model = modelOverride ?? selectedModel;
if (!text || loading) return;
@@ -528,7 +537,7 @@ const AiPage = () => {
await chatStream(
{
message: text,
- mode: 'chat',
+ mode: modeOverride,
model,
engine: useEngineStore.getState().engine,
enhance,
@@ -586,7 +595,7 @@ const AiPage = () => {
const pending = pendingAutoSendRef.current;
if (!pending || loading || !llmReady) return;
pendingAutoSendRef.current = null;
- void handleSend(pending.prompt, pending.model, pending.enhance);
+ void handleSend(pending.prompt, pending.model, pending.enhance,
pending.mode);
}, [llmReady, loading, handleSend]);
const handleStop = useCallback(() => {
diff --git a/web/src/pages/home/__tests__/HomePage.test.tsx
b/web/src/pages/home/__tests__/HomePage.test.tsx
index 513fe6a5..035e8ae7 100644
--- a/web/src/pages/home/__tests__/HomePage.test.tsx
+++ b/web/src/pages/home/__tests__/HomePage.test.tsx
@@ -78,6 +78,18 @@ describe('HomePage LLM models', () => {
expect(await screen.findByText('qwen3.8-max')).toBeInTheDocument();
});
+ it('does not fetch LLM config in Mock mode', async () => {
+ const { useDataModeStore } = await import('../../../stores/dataModeStore');
+ useDataModeStore.getState().toggle();
+
+ renderHome();
+
+ expect(llmApiMocks.getLlmConfig).not.toHaveBeenCalled();
+ expect(await screen.findByText('qwen3.8-max')).toBeInTheDocument();
+
+ useDataModeStore.getState().toggle();
+ });
+
it('submits the selected model and engine to the AI page', async () => {
const user = userEvent.setup();
renderHome();
@@ -94,6 +106,7 @@ describe('HomePage LLM models', () => {
prompt: '查看集群状态',
model: 'qwen3.8-max',
engine: 'claude-code',
+ mode: 'chat',
},
});
});
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index 4da5ae43..45e7ebb2 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -34,6 +34,7 @@ import {
} from '@phosphor-icons/react';
import { getLlmConfig } from '../../api/llm';
import { useEngineStore } from '../../stores/engineStore';
+import { useDataModeStore } from '../../stores/dataModeStore';
import { useLang } from '../../i18n/LangContext';
/* ─── Time-aware greeting key ─── */
@@ -98,6 +99,17 @@ const HomePage = () => {
let cancelled = false;
const loadModels = async () => {
+ const useMock = useDataModeStore.getState().useMock;
+ if (useMock) {
+ if (cancelled) return;
+ setModelOptions(
+ HOME_MODELS.map((value) => ({ value, recommended: value ===
RECOMMENDED_MODEL })),
+ );
+ setSelectedModel((current) =>
+ current && HOME_MODELS.includes(current) ? current : HOME_MODELS[0]
|| '',
+ );
+ return;
+ }
const config = await getLlmConfig().catch(() => null);
if (cancelled) return;
@@ -192,6 +204,7 @@ const HomePage = () => {
prompt,
...(selectedModel ? { model: selectedModel } : {}),
engine,
+ mode: activeMode,
...(promoteOn ? { enhance: true } : {}),
}
: null,
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index b43bb333..db61c3bb 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -16,12 +16,12 @@
*/
import { App } from 'antd';
-import { render, screen, waitFor, within } from '@testing-library/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
-import type { DLQGroup } from '../../../api/message';
+import type { DLQGroup, DLQResendResult } from '../../../api/message';
import { LangProvider } from '../../../i18n/LangContext';
import * as messageService from '../../../services/messageService';
import DLQPage from '../dlq';
@@ -349,4 +349,45 @@ describe('DLQ page', () => {
resolveSecondInstance([secondDlqGroup]);
expect(await screen.findByText('-cg-"payment"')).toBeInTheDocument();
});
+
+ it('ignores a retry completion from the previously selected instance', async
() => {
+ let resolveRetry!: (result: DLQResendResult) => void;
+ vi.mocked(messageService.resendDLQ).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveRetry = resolve;
+ }),
+ );
+ vi.mocked(messageService.listDLQGroups)
+ .mockResolvedValueOnce([dlqGroup])
+ .mockResolvedValueOnce([secondDlqGroup]);
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ const firstRow = (await screen.findByText('cg-order')).closest('tr');
+ if (!firstRow) throw new Error('DLQ group row not found');
+ await user.click(within(firstRow).getByRole('button', { name: '重投消息' }));
+ await user.type(screen.getByPlaceholderText('输入目标 Topic 名称'),
'orders-retry');
+ await user.click(screen.getByRole('button', { name: '确认重投' }));
+
+ await user.click(screen.getAllByRole('combobox')[0]);
+ await user.click(
+ await screen.findByText('instance-2', { selector:
'.ant-select-item-option-content' }),
+ );
+ const secondRow = (await screen.findByText('-cg-"payment"')).closest('tr');
+ if (!secondRow) throw new Error('second DLQ group row not found');
+ await user.click(within(secondRow).getByRole('button', { name: '重投消息' }));
+ let retryDialog = screen.getByText('重投死信消息').closest('.ant-modal');
+ if (!retryDialog) throw new Error('retry dialog not found');
+ expect(within(retryDialog as
HTMLElement).getByText('-cg-"payment"')).toBeInTheDocument();
+
+ await act(async () =>
+ resolveRetry({ matched: 7, resent: 7, failed: 0, outcome: 'SUCCESS' }),
+ );
+
+ retryDialog = screen.getByText('重投死信消息').closest('.ant-modal');
+ if (!retryDialog) throw new Error('retry dialog was closed by the stale
request');
+ expect(within(retryDialog as
HTMLElement).getByText('-cg-"payment"')).toBeInTheDocument();
+ expect(messageService.listDLQGroups).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 51f17b78..73538309 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Card,
@@ -121,6 +121,14 @@ const DLQPage = () => {
const [selectedGroupNames, setSelectedGroupNames] = useState<string[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [retryError, setRetryError] = useState<string | null>(null);
+ const retryRequestIdRef = useRef(0);
+
+ useEffect(
+ () => () => {
+ retryRequestIdRef.current += 1;
+ },
+ [],
+ );
// The retry dialog owns a group name that is meaningful only for the
// currently selected instance. Clear all instance-scoped state before
@@ -197,6 +205,12 @@ const DLQPage = () => {
}, [groups, selectedGroupNames]);
/* ─── Handlers ─── */
+ const handleInstanceChange = (instanceId: string) => {
+ retryRequestIdRef.current += 1;
+ setRetrySubmitting(false);
+ selectInstance(instanceId);
+ };
+
const openRetryModal = (group: DLQGroup) => {
setRetryGroup(group);
setRetryRange([dayjs().subtract(1, 'day'), dayjs()]);
@@ -212,16 +226,21 @@ const DLQPage = () => {
}
if (!retryGroup || !selectedInstanceId) return;
+ const requestId = retryRequestIdRef.current + 1;
+ retryRequestIdRef.current = requestId;
+ const groupName = retryGroup.groupName;
+ const targetTopic = retryTargetTopic;
setRetrySubmitting(true);
setRetryError(null);
try {
const result = await resendDLQ({
instanceId: selectedInstanceId,
- groupName: retryGroup.groupName,
+ groupName,
startTime: retryRange[0].valueOf(),
endTime: retryRange[1].valueOf(),
- targetTopic: retryTargetTopic,
+ targetTopic,
});
+ if (retryRequestIdRef.current !== requestId) return;
setRefreshKey((key) => key + 1);
if (result.scanIncomplete) {
message.warning(
@@ -231,16 +250,20 @@ const DLQPage = () => {
message.warning(`重投部分完成:成功 ${result.resent},失败 ${result.failed}`);
} else {
message.success(
- `重投完成:${retryGroup.groupName} → ${retryTargetTopic}(${result.resent}
条)`,
+ `重投完成:${groupName} → ${targetTopic}(${result.resent} 条)`,
);
}
setRetryModalOpen(false);
setRetryGroup(null);
setRetryError(null);
} catch (error) {
- setRetryError(getErrorMessage(error, DEFAULT_RETRY_ERROR));
+ if (retryRequestIdRef.current === requestId) {
+ setRetryError(getErrorMessage(error, DEFAULT_RETRY_ERROR));
+ }
} finally {
- setRetrySubmitting(false);
+ if (retryRequestIdRef.current === requestId) {
+ setRetrySubmitting(false);
+ }
}
};
@@ -364,7 +387,7 @@ const DLQPage = () => {
<Space size={12} wrap>
<InstanceSelect
value={selectedInstanceId || undefined}
- onChange={selectInstance}
+ onChange={handleInstanceChange}
options={instanceOptions}
style={{ width: 220 }}
/>
@@ -393,7 +416,7 @@ const DLQPage = () => {
)}
{/* ── Table ── */}
- <Card bodyStyle={{ padding: 0 }}>
+ <Card styles={{ body: { padding: 0 } }}>
<Table
columns={columns}
dataSource={filtered}
@@ -437,7 +460,7 @@ const DLQPage = () => {
okText="确认重投"
cancelText="取消"
width={520}
- destroyOnClose
+ destroyOnHidden
>
{retryGroup && (
<div style={{ marginTop: 16 }}>
@@ -514,7 +537,7 @@ const DLQPage = () => {
onCancel={() => setDetailGroup(null)}
footer={<Button onClick={() => setDetailGroup(null)}>关闭</Button>}
width={560}
- destroyOnClose
+ destroyOnHidden
>
{detailGroup && (
<div style={{ marginTop: 8 }}>
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 9f03fcf5..857f7ce2 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -270,6 +270,7 @@ const MessagePageContent = ({
}: InstanceFilterProps) => {
const { t } = useLang();
const [topicOptions, setTopicOptions] = useState<string[]>([]);
+ const [topicError, setTopicError] = useState<string | null>(null);
useEffect(() => {
if (!selectedInstanceId) {
@@ -279,10 +280,13 @@ const MessagePageContent = ({
void listTopics({ instanceId: selectedInstanceId })
.then((nextTopics) => {
if (cancelled) return;
+ setTopicError(null);
setTopicOptions(nextTopics.map((topic) => topic.name));
})
- .catch(() => {
- if (!cancelled) setTopicOptions([]);
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ setTopicOptions([]);
+ setTopicError(error instanceof Error ? error.message : '加载 Topic
列表失败');
});
return () => {
cancelled = true;
@@ -869,12 +873,16 @@ const MessagePageContent = ({
</Space>
</Card>
+ {topicError && (
+ <Alert showIcon type="error" message={topicError} style={{
marginBottom: 16 }} />
+ )}
+
{queryError && (
<Alert showIcon type="warning" message={queryError} style={{
marginBottom: 16 }} />
)}
{/* ── Results Table ── */}
- <Card bodyStyle={{ padding: 0 }}>
+ <Card styles={{ body: { padding: 0 } }}>
<Table
columns={columns}
dataSource={messages}
@@ -895,7 +903,7 @@ const MessagePageContent = ({
width={800}
open={modalOpen}
onCancel={closeDetail}
- destroyOnClose
+ destroyOnHidden
footer={
<Flex justify="flex-end" gap={8}>
<Button onClick={closeDetail}>关闭</Button>
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index 1edbc099..2fbc0e58 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -168,6 +168,41 @@ describe('Audit page', () => {
);
});
+ it('shows loading state while refreshed records are pending', async () => {
+ const user = userEvent.setup();
+ vi.mocked(opsService.listAuditRecords)
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 'audit-1',
+ timestamp: '2026-08-01 10:00:00',
+ operator: 'admin',
+ operationType: 'DELETE_TOPIC',
+ resourceType: 'TOPIC',
+ target: 'topic-a',
+ clusterId: 'prod-cn',
+ detail: 'removed topic-a',
+ result: 'SUCCESS',
+ errorMessage: '',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ })
+ .mockImplementationOnce(() => new Promise(() => {}));
+ const { container } = renderWithProviders(<AuditPage />);
+ expect(await screen.findByText('topic-a')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('combobox', { name: '操作类型' }));
+ await user.click(
+ await screen.findByText('CREATE TOPIC', { selector:
'.ant-select-item-option-content' }),
+ );
+
+ await waitFor(() =>
expect(opsService.listAuditRecords).toHaveBeenCalledTimes(2));
+ expect(container.querySelector('.ant-spin-spinning')).not.toBeNull();
+ });
+
it('still loads audit records when filter options cannot be loaded', async
() => {
vi.mocked(opsService.getAuditFilterOptions).mockRejectedValueOnce(new
Error('unavailable'));
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 52f7d17e..0ba325a4 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -86,6 +86,35 @@ describe('SystemAlertsPage', () => {
expect(screen.getByText('A newer backend emitted this
level')).toBeInTheDocument();
});
+ it('filters backend alert levels case-insensitively', async () => {
+ vi.mocked(listSystemAlerts).mockResolvedValue([
+ {
+ id: 'alert-error',
+ level: 'Error',
+ title: 'Mixed-case error',
+ description: 'error',
+ time: '2026-08-10 01:00',
+ acknowledged: false,
+ },
+ {
+ id: 'alert-warning',
+ level: 'WARNING',
+ title: 'Mixed-case warning',
+ description: 'warning',
+ time: '2026-08-10 01:01',
+ acknowledged: false,
+ },
+ ]);
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByText('Mixed-case error');
+
+ await user.click(screen.getByRole('button', { name: /严重/ }));
+
+ expect(screen.getByText('Mixed-case error')).toBeInTheDocument();
+ expect(screen.queryByText('Mixed-case warning')).not.toBeInTheDocument();
+ });
+
it('tracks simultaneous acknowledgements independently', async () => {
vi.mocked(acknowledgeAlert).mockImplementation(() => new Promise(() =>
{}));
const user = userEvent.setup();
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index d3f3b710..84ba81d1 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -125,6 +125,9 @@ const AuditPage: React.FC = () => {
useEffect(() => {
let cancelled = false;
+ void Promise.resolve().then(() => {
+ if (!cancelled) setLoading(true);
+ });
void listAuditRecords({
page,
diff --git a/web/src/pages/ops/nameServerConfigDrift.tsx
b/web/src/pages/ops/nameServerConfigDrift.tsx
index 6c750c8c..b7137316 100644
--- a/web/src/pages/ops/nameServerConfigDrift.tsx
+++ b/web/src/pages/ops/nameServerConfigDrift.tsx
@@ -65,6 +65,7 @@ const NameServerConfigDriftPage = () => {
useEffect(() => {
let cancelled = false;
+ const sequence = ++requestSequence.current;
void listInstances()
.then(async (items) => {
if (cancelled) return;
@@ -77,17 +78,19 @@ const NameServerConfigDriftPage = () => {
return;
}
const clustersForInstance = await listClusters(firstInstanceId);
- if (cancelled) return;
+ if (cancelled || sequence !== requestSequence.current) return;
setClusters(clustersForInstance);
const firstClusterId = clustersForInstance[0]?.id;
setSelectedClusterId(firstClusterId);
if (firstClusterId) void runCheck(firstClusterId, firstInstanceId);
})
.catch(() => {
- if (!cancelled) message.error(t('nameServerDrift.loadClustersFailed'));
+ if (!cancelled && sequence === requestSequence.current) {
+ message.error(t('nameServerDrift.loadClustersFailed'));
+ }
})
.finally(() => {
- if (!cancelled) setClustersLoading(false);
+ if (!cancelled && sequence === requestSequence.current)
setClustersLoading(false);
});
return () => {
cancelled = true;
diff --git a/web/src/pages/ops/systemAlerts.tsx
b/web/src/pages/ops/systemAlerts.tsx
index 8f73da59..c5504f8c 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -29,6 +29,8 @@ import type { SystemAlert } from '../../api/ops';
const { Text } = Typography;
+const normalizeAlertLevel = (level: string) => level.toLowerCase();
+
const SystemAlertsPage = () => {
const { t } = useLang();
@@ -63,7 +65,10 @@ const SystemAlertsPage = () => {
};
}, []);
- const filtered = levelFilter === 'all' ? alerts : alerts.filter((a) =>
a.level === levelFilter);
+ const filtered =
+ levelFilter === 'all'
+ ? alerts
+ : alerts.filter((a) => normalizeAlertLevel(a.level) === levelFilter);
const unackCount = alerts.filter((a) => !a.acknowledged).length;
@@ -88,7 +93,8 @@ const SystemAlertsPage = () => {
setClearing(true);
try {
await clearAcknowledgedAlerts();
- setAlerts((prev) => prev.filter((a) => !a.acknowledged));
+ const fresh = await listSystemAlerts();
+ setAlerts(fresh);
message.success(t('sysAlerts.cleared'));
} catch {
message.error('清理已确认告警失败,请稍后重试');
@@ -125,7 +131,7 @@ const SystemAlertsPage = () => {
{level === 'all' ? t('common.all') :
alertLevelConfig[level]?.label}
{level !== 'all' && (
<Badge
- count={alerts.filter((a) => a.level === level).length}
+ count={alerts.filter((a) => normalizeAlertLevel(a.level) ===
level).length}
style={{
marginLeft: 4,
backgroundColor:
@@ -142,7 +148,7 @@ const SystemAlertsPage = () => {
{loading && <Card loading />}
{!loading &&
filtered.map((alert) => {
- const normalizedLevel = alert.level.toLowerCase();
+ const normalizedLevel = normalizeAlertLevel(alert.level);
const cfg = alertLevelConfig[normalizedLevel] ?? {
color: '#8c8c8c',
bg: '#fafafa',
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index 11219d1b..ff9e7b64 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -476,7 +476,7 @@ export const DataSourceTab = () => {
return (
<>
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
- <Button type="primary" icon={<PlusOutlined />}
onClick={openCreateModal}>
+ <Button type="primary" icon={<PlusOutlined />}
onClick={openCreateModal} disabled={loading}>
添加数据源
</Button>
</Flex>
@@ -500,7 +500,7 @@ export const DataSourceTab = () => {
}}
onOk={() => void handleSubmit()}
confirmLoading={submitting}
- destroyOnClose
+ destroyOnHidden
>
<Form form={dsForm} layout="vertical" preserve={false}>
<Form.Item
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index a2989e64..17b79724 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -49,6 +49,15 @@ import { queryProxyHomePage, reloadProxyConfig, type
ProxyNode } from '../../api
const { Text } = Typography;
+const persistProxyAddress = (address?: string) => {
+ if (!address) return;
+ try {
+ localStorage.setItem('proxyAddr', address);
+ } catch {
+ // Proxy discovery remains usable when browser storage is unavailable.
+ }
+};
+
const ProxyPage: React.FC = () => {
const { t } = useLang();
const { message } = App.useApp();
@@ -96,12 +105,7 @@ const ProxyPage: React.FC = () => {
totalTPS: null,
});
- if (currentProxyAddr) {
- localStorage.setItem('proxyAddr', currentProxyAddr);
- } else if (proxyAddrList && proxyAddrList.length > 0) {
- localStorage.setItem('proxyAddr', proxyAddrList[0]);
- }
-
+ persistProxyAddress(currentProxyAddr || proxyAddrList?.[0]);
return true;
} catch {
if (requestId !== loadRequestId.current) return false;
@@ -383,7 +387,7 @@ const ProxyPage: React.FC = () => {
{/* Node Table */}
<Card
title={t('proxy.nodes')}
- bordered={false}
+ variant="borderless"
style={{ borderRadius: 8, marginBottom: 24 }}
>
<Table columns={columns} dataSource={proxyNodes} pagination={false}
size="middle" />
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index b8c1ae8d..bcaa8d8a 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -76,6 +76,19 @@ describe('ProxyPage', () => {
});
});
+ it('keeps discovered nodes when browser storage is unavailable', async () =>
{
+ const storageSpy = vi.spyOn(Storage.prototype,
'setItem').mockImplementation(() => {
+ throw new DOMException('storage disabled', 'SecurityError');
+ });
+
+ renderPage();
+
+ expect(await screen.findByText('127.0.0.1:8081')).toBeInTheDocument();
+ expect(screen.queryByText('获取代理列表失败')).not.toBeInTheDocument();
+ expect(queryProxyHomePage).toHaveBeenCalledTimes(1);
+ storageSpy.mockRestore();
+ });
+
it('loads Proxy nodes once after the page mounts', async () => {
renderPage();
diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/utils/format.test.ts
similarity index 57%
copy from web/src/pages/ai/chatDraft.ts
copy to web/src/utils/format.test.ts
index f26f14a9..ee181fd0 100644
--- a/web/src/pages/ai/chatDraft.ts
+++ b/web/src/utils/format.test.ts
@@ -15,21 +15,19 @@
* limitations under the License.
*/
-export interface ChatDraft {
- prompt: string;
- model?: string;
- enhance?: boolean;
-}
+import { describe, expect, it } from 'vitest';
+import { formatDate, formatDateTime } from './format';
-export function getChatDraft(state: unknown): ChatDraft | null {
- if (typeof state !== 'object' || state === null) return null;
- const candidate = state as Record<string, unknown>;
- if (typeof candidate.prompt !== 'string' || !candidate.prompt.trim()) return
null;
- const model = typeof candidate.model === 'string' ? candidate.model.trim() :
'';
+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('-');
+ }
+ });
- return {
- prompt: candidate.prompt.trim(),
- ...(model ? { model } : {}),
- ...(candidate.enhance === true ? { enhance: true } : {}),
- };
-}
+ 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');
+ });
+});
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index b9a150e4..b246b2c6 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -21,7 +21,7 @@ const pad = (n: number, width = 2): string =>
String(n).padStart(width, '0');
* Format a date string or Date object to 'YYYY-MM-DD HH:mm:ss'.
*/
export function formatDateTime(date: string | Date | null | undefined): string
{
- if (date === null || date === undefined) return '-';
+ 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);
return (
@@ -34,7 +34,7 @@ export function formatDateTime(date: string | Date | null |
undefined): string {
* Format a date string or Date object to 'YYYY-MM-DD'.
*/
export function formatDate(date: string | Date | null | undefined): string {
- if (date === null || date === undefined) return '-';
+ 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);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;