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 951ec318 fix: preserve LiteTopic namespace isolation (#677)
951ec318 is described below
commit 951ec318d712fee35479879c8cc0ba994492da06
Author: Rui <[email protected]>
AuthorDate: Fri Jul 31 16:19:38 2026 +0800
fix: preserve LiteTopic namespace isolation (#677)
---
.../studio/instance/topic/LiteTopicService.java | 13 +-
.../instance/topic/LiteTopicServiceTest.java | 8 +-
web/src/api/liteTopic.test.ts | 2 +
web/src/api/liteTopic.ts | 1 +
web/src/pages/studio/LiteTopic.tsx | 206 +++++++++------
web/src/pages/studio/__tests__/LiteTopic.test.tsx | 280 ++++++++++++++++++++-
6 files changed, 433 insertions(+), 77 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
index 76a6ca89..af8129fd 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
@@ -27,8 +27,8 @@ public class LiteTopicService {
public List<LiteTopicItemVO> listLiteTopics(String pattern, String
namespace) {
return sampleItems().stream()
- .filter(item -> matches(pattern, item.getTopicPattern()))
- .filter(item -> matches(namespace, item.getNamespace()))
+ .filter(item -> matchesPattern(pattern,
item.getTopicPattern()))
+ .filter(item -> matchesNamespace(namespace,
item.getNamespace()))
.toList();
}
@@ -113,10 +113,17 @@ public class LiteTopicService {
.build());
}
- private boolean matches(String filter, String value) {
+ private boolean matchesPattern(String filter, String value) {
if (filter == null || filter.isBlank()) {
return true;
}
return value != null &&
value.toLowerCase(Locale.ROOT).contains(filter.toLowerCase(Locale.ROOT));
}
+
+ private boolean matchesNamespace(String namespace, String value) {
+ if (namespace == null || namespace.isBlank()) {
+ return true;
+ }
+ return value != null && value.equalsIgnoreCase(namespace.trim());
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
index 26a4acee..d2677d11 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
@@ -30,13 +30,19 @@ class LiteTopicServiceTest {
@Test
void listLiteTopicsShouldFilterByPatternAndNamespace() {
- List<LiteTopicItemVO> result = liteTopicService.listLiteTopics("chat",
"default");
+ List<LiteTopicItemVO> result = liteTopicService.listLiteTopics("hat",
" DEFAULT ");
assertThat(result).hasSize(1);
assertThat(result.get(0).getTopicPattern()).isEqualTo("chat/{sessionId}");
assertThat(result.get(0).getNamespace()).isEqualTo("default");
}
+ @Test
+ void listLiteTopicsShouldMatchNamespaceExactly() {
+ assertThat(liteTopicService.listLiteTopics(null, "def")).isEmpty();
+ assertThat(liteTopicService.listLiteTopics(null, " ")).hasSize(2);
+ }
+
@Test
void getQuotaShouldReturnDefaultLimits() {
LiteTopicQuotaVO quota = liteTopicService.getQuota("default");
diff --git a/web/src/api/liteTopic.test.ts b/web/src/api/liteTopic.test.ts
index 352555ca..a571470f 100644
--- a/web/src/api/liteTopic.test.ts
+++ b/web/src/api/liteTopic.test.ts
@@ -32,6 +32,7 @@ import {
const mock = new MockAdapter(client);
const sampleItem: LiteTopicItem = {
+ namespace: 'default',
topicPattern: 'order-*',
topicCount: 15,
consumerCount: 3,
@@ -88,6 +89,7 @@ describe('LiteTopic API', () => {
const result = await queryLiteTopicList();
expect(result).toHaveLength(1);
+ expect(result[0].namespace).toBe('default');
expect(result[0].topicPattern).toBe('order-*');
});
diff --git a/web/src/api/liteTopic.ts b/web/src/api/liteTopic.ts
index f080ef21..1dfeff3b 100644
--- a/web/src/api/liteTopic.ts
+++ b/web/src/api/liteTopic.ts
@@ -35,6 +35,7 @@ export interface LiteTopicQuota {
}
export interface LiteTopicItem {
+ namespace: string;
topicPattern: string;
topicCount?: number;
consumerCount?: number;
diff --git a/web/src/pages/studio/LiteTopic.tsx
b/web/src/pages/studio/LiteTopic.tsx
index 001760de..3aefb2d8 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useState, useRef } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import {
Table,
Button,
@@ -78,9 +78,30 @@ const getProgressStatus = (percent: number): 'exception' |
'active' | 'normal' =
return 'normal';
};
+const collectNamespaces = (items: LiteTopicItem[]): string[] => {
+ const namespaces = new Map<string, string>();
+
+ items.forEach((item) => {
+ const namespace = item.namespace?.trim() || '';
+ if (!namespace) return;
+
+ const normalized = namespace.toLowerCase();
+ if (!namespaces.has(normalized)) {
+ namespaces.set(normalized, namespace);
+ }
+ });
+
+ return Array.from(namespaces.values()).sort((left, right) => {
+ const normalizedOrder =
left.toLowerCase().localeCompare(right.toLowerCase());
+ return normalizedOrder || left.localeCompare(right);
+ });
+};
+
const LiteTopicPage: React.FC = () => {
const { t } = useLang();
const { message } = App.useApp();
+ const messageRef = useRef(message);
+ const translationRef = useRef(t);
const [loading, setLoading] = useState(false);
const [topicList, setTopicList] = useState<LiteTopicItem[]>([]);
@@ -88,6 +109,7 @@ const LiteTopicPage: React.FC = () => {
const [capabilitySupported, setCapabilitySupported] = useState(true);
const [patternFilter, setPatternFilter] = useState('');
const [namespaceFilter, setNamespaceFilter] = useState('');
+ const [namespaceOptions, setNamespaceOptions] = useState<string[]>([]);
// Session drawer
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
@@ -102,92 +124,121 @@ const LiteTopicPage: React.FC = () => {
}>({ topicPattern: '', newTTL: null });
const [extendTTLLoading, setExtendTTLLoading] = useState(false);
- const initialized = useRef<boolean | null>(null);
- if (initialized.current == null) {
- initialized.current = true;
- const init = async () => {
+ const mountedRef = useRef(false);
+ const bootstrapRequestId = useRef(0);
+ const displayRequestId = useRef(0);
+
+ useEffect(() => {
+ messageRef.current = message;
+ translationRef.current = t;
+ }, [message, t]);
+
+ const fetchData = useCallback(
+ async (
+ pattern: string | undefined,
+ namespace: string | undefined,
+ options: { clear?: boolean; collectNamespaceOptions?: boolean } = {},
+ ) => {
+ const requestId = ++displayRequestId.current;
+
+ if (options.clear) {
+ setTopicList([]);
+ setQuota(null);
+ }
+ setLoading(true);
+
+ const [quotaResult, listResult] = await Promise.allSettled([
+ queryLiteTopicQuota(namespace),
+ queryLiteTopicList(pattern, namespace),
+ ]);
+
+ if (!mountedRef.current) return;
+
+ if (listResult.status === 'fulfilled' &&
options.collectNamespaceOptions) {
+ setNamespaceOptions(collectNamespaces(listResult.value));
+ }
+
+ if (requestId !== displayRequestId.current) return;
+
+ if (quotaResult.status === 'fulfilled') {
+ setQuota(quotaResult.value);
+ }
+
+ if (listResult.status === 'fulfilled') {
+ setTopicList(listResult.value);
+ } else {
+ setTopicList([]);
+
messageRef.current.error(translationRef.current('liteTopic.fetchListFailed'));
+ }
+
+ setLoading(false);
+ },
+ [],
+ );
+
+ useEffect(() => {
+ const mountedState = mountedRef;
+ const bootstrapCounter = bootstrapRequestId;
+ const displayCounter = displayRequestId;
+ mountedState.current = true;
+
+ const bootstrapId = ++bootstrapCounter.current;
+ const initialDisplayRequestId = displayCounter.current;
+ const bootstrapIsActive = () =>
+ mountedState.current && bootstrapId === bootstrapCounter.current;
+
+ const initialize = async () => {
try {
- const cap = await queryLiteTopicCapability();
- if (cap && cap.supported === false) {
+ const capability = await queryLiteTopicCapability();
+ if (!bootstrapIsActive()) return;
+ if (capability && capability.supported === false) {
+ ++displayCounter.current;
setCapabilitySupported(false);
+ setTopicList([]);
+ setQuota(null);
+ setLoading(false);
return;
}
} catch {
- // Assume supported on error
- }
- try {
- const q = await queryLiteTopicQuota();
- setQuota(q);
- } catch {
- // ignore
+ if (!bootstrapIsActive()) return;
+ // Assume supported when capability detection is unavailable.
}
- setLoading(true);
- try {
- const list = await queryLiteTopicList();
- setTopicList(list);
- } catch {
- message.error(t('liteTopic.fetchListFailed'));
- } finally {
- setLoading(false);
+
+ if (displayCounter.current === initialDisplayRequestId) {
+ await fetchData(undefined, undefined, { collectNamespaceOptions: true
});
+ } else {
+ try {
+ const list = await queryLiteTopicList();
+ if (bootstrapIsActive()) {
+ setNamespaceOptions(collectNamespaces(list));
+ }
+ } catch {
+ // The active display request owns user-visible error handling.
+ }
}
};
- init();
- }
- const fetchQuota = async () => {
- try {
- const q = await queryLiteTopicQuota(namespaceFilter || undefined);
- setQuota(q);
- } catch {
- // ignore
- }
- };
+ void initialize();
- const fetchTopicList = async () => {
- setLoading(true);
- try {
- const list = await queryLiteTopicList(
- patternFilter || undefined,
- namespaceFilter || undefined,
- );
- setTopicList(list);
- } catch {
- message.error(t('liteTopic.fetchListFailed'));
- setTopicList([]);
- } finally {
- setLoading(false);
- }
- };
+ return () => {
+ mountedState.current = false;
+ ++bootstrapCounter.current;
+ ++displayCounter.current;
+ };
+ }, [fetchData]);
const handleSearch = () => {
- fetchTopicList();
+ void fetchData(patternFilter || undefined, namespaceFilter || undefined);
};
const handleRefresh = () => {
- fetchQuota();
- fetchTopicList();
+ void fetchData(patternFilter || undefined, namespaceFilter || undefined);
};
const handleNamespaceChange = (val: string | undefined) => {
- setNamespaceFilter(val || '');
- // Re-fetch with new namespace
- const doFetch = async () => {
- setLoading(true);
- try {
- const ns = val || undefined;
- const [q, list] = await Promise.all([
- queryLiteTopicQuota(ns),
- queryLiteTopicList(patternFilter || undefined, ns),
- ]);
- setQuota(q);
- setTopicList(list);
- } catch {
- message.error(t('liteTopic.fetchListFailed'));
- } finally {
- setLoading(false);
- }
- };
- doFetch();
+ const namespace = val || undefined;
+ setNamespaceFilter(namespace || '');
+ void fetchData(patternFilter || undefined, namespace, { clear: true });
};
const handleViewSessions = async (sessionId: string) => {
@@ -219,8 +270,7 @@ const LiteTopicPage: React.FC = () => {
await extendLiteTopicTTL(extendTTLForm.topicPattern,
extendTTLForm.newTTL);
message.success(t('liteTopic.extendTtlSuccess'));
setExtendTTLModalOpen(false);
- fetchTopicList();
- fetchQuota();
+ void fetchData(patternFilter || undefined, namespaceFilter || undefined);
} catch {
message.error(t('liteTopic.extendTtlFailed'));
} finally {
@@ -241,6 +291,13 @@ const LiteTopicPage: React.FC = () => {
// ─── Columns ─────────────────────────────────────────────────
const columns: ColumnsType<LiteTopicItem> = [
+ {
+ title: t('liteTopic.namespace'),
+ dataIndex: 'namespace',
+ key: 'namespace',
+ render: (text: string) => text || '-',
+ ellipsis: true,
+ },
{
title: t('liteTopic.pattern'),
dataIndex: 'topicPattern',
@@ -663,6 +720,11 @@ const LiteTopicPage: React.FC = () => {
allowClear
>
<Select.Option
value="">{t('liteTopic.allNamespaces')}</Select.Option>
+ {namespaceOptions.map((namespace) => (
+ <Select.Option key={namespace.toLowerCase()} value={namespace}>
+ {namespace}
+ </Select.Option>
+ ))}
</Select>
<Button type="primary" icon={<MagnifyingGlass size={14} />}
onClick={handleSearch}>
{t('common.search')}
@@ -675,7 +737,7 @@ const LiteTopicPage: React.FC = () => {
<Table
columns={columns}
dataSource={topicList}
- rowKey={(record) => record.topicPattern || Math.random().toString()}
+ rowKey={(record) => JSON.stringify([record.namespace,
record.topicPattern])}
loading={loading}
pagination={{
pageSize: 10,
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index 059a29db..8680eddb 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -16,10 +16,11 @@
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, within } from '@testing-library/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
+import type { LiteTopicItem, LiteTopicQuota } from '../../../api/liteTopic';
import LiteTopic from '../LiteTopic';
const apiMocks = vi.hoisted(() => ({
@@ -57,6 +58,33 @@ const renderPage = () =>
</App>,
);
+const createDeferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+const createQuota = (currentTopicCount: number): LiteTopicQuota => ({
+ currentTopicCount,
+ maxTopicCount: 100,
+ currentSessionCount: 0,
+ maxSessionCount: 100,
+ currentCreationRate: 0,
+ maxCreationRate: 100,
+});
+
+const selectNamespace = async (name: string) => {
+ const user = userEvent.setup();
+ await user.click(screen.getAllByRole('combobox')[0]);
+ await user.click(
+ await screen.findByText(name, { selector:
'.ant-select-item-option-content' }),
+ );
+};
+
describe('LiteTopic Page', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -64,6 +92,7 @@ describe('LiteTopic Page', () => {
apiMocks.queryLiteTopicQuota.mockResolvedValue(null);
apiMocks.queryLiteTopicList.mockResolvedValue([
{
+ namespace: 'default',
topicPattern: 'order-*',
sessionIds: ['session-1'],
},
@@ -86,4 +115,253 @@ describe('LiteTopic Page', () => {
const popProgressLabel = await screen.findByText('Pop 进度');
expect(within(popProgressLabel.parentElement!).getByText('96%')).toBeInTheDocument();
});
+
+ it('keeps namespace identity in the table and builds stable options from the
initial list', async () => {
+ const initialItems: LiteTopicItem[] = [
+ { namespace: 'zeta', topicPattern: 'shared-*' },
+ { namespace: 'Alpha', topicPattern: 'shared-*' },
+ { namespace: ' alpha ', topicPattern: 'another-*' },
+ { namespace: ' ', topicPattern: 'blank-*' },
+ ];
+ apiMocks.queryLiteTopicList
+ .mockResolvedValueOnce(initialItems)
+ .mockResolvedValueOnce([{ namespace: 'Alpha', topicPattern: 'filtered-*'
}]);
+
+ const user = userEvent.setup();
+ const { container } = renderPage();
+
+ expect(await screen.findAllByText('shared-*')).toHaveLength(2);
+ expect(screen.getByRole('columnheader', { name: '命名空间'
})).toBeInTheDocument();
+
+ const rowKeys =
Array.from(container.querySelectorAll('tr[data-row-key]')).map((row) =>
+ row.getAttribute('data-row-key'),
+ );
+ expect(rowKeys).toContain(JSON.stringify(['zeta', 'shared-*']));
+ expect(rowKeys).toContain(JSON.stringify(['Alpha', 'shared-*']));
+
+ await user.type(screen.getByPlaceholderText('按 Topic 模式筛选...'),
'filtered');
+ await user.click(screen.getByRole('button', { name: /搜索/ }));
+ expect(await screen.findByText('filtered-*')).toBeInTheDocument();
+
+ await user.click(screen.getAllByRole('combobox')[0]);
+ const dropdown =
document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
+ expect(dropdown).not.toBeNull();
+ expect(
+
Array.from(dropdown!.querySelectorAll('.ant-select-item-option-content')).map(
+ (option) => option.textContent,
+ ),
+ ).toEqual(['全部命名空间', 'Alpha', 'zeta']);
+ });
+
+ it('keeps an early filtered display while a delayed bootstrap supplies
namespace options', async () => {
+ const capability = createDeferred<{ supported: boolean }>();
+ const bootstrapList = createDeferred<LiteTopicItem[]>();
+
+ apiMocks.queryLiteTopicCapability.mockReturnValue(capability.promise);
+ apiMocks.queryLiteTopicList.mockImplementation((pattern: string |
undefined) =>
+ pattern === 'needle'
+ ? Promise.resolve([{ namespace: 'Filtered', topicPattern:
'filtered-result-*' }])
+ : bootstrapList.promise,
+ );
+ apiMocks.queryLiteTopicQuota.mockResolvedValue(createQuota(20));
+
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.type(screen.getByPlaceholderText('按 Topic 模式筛选...'), 'needle');
+ await user.click(screen.getByRole('button', { name: /搜索/ }));
+ expect(await screen.findByText('filtered-result-*')).toBeInTheDocument();
+ expect(screen.getByText('20 / 100')).toBeInTheDocument();
+
+ await act(async () => {
+ capability.resolve({ supported: true });
+ });
+ await waitFor(() => {
+ expect(apiMocks.queryLiteTopicList).toHaveBeenCalledTimes(2);
+ expect(apiMocks.queryLiteTopicList).toHaveBeenLastCalledWith();
+ });
+
+ await act(async () => {
+ bootstrapList.resolve([
+ { namespace: 'Beta', topicPattern: 'bootstrap-beta-*' },
+ { namespace: 'Alpha', topicPattern: 'bootstrap-alpha-*' },
+ ]);
+ });
+
+ expect(screen.getByText('filtered-result-*')).toBeInTheDocument();
+ expect(screen.getByText('20 / 100')).toBeInTheDocument();
+ expect(screen.queryByText('bootstrap-alpha-*')).not.toBeInTheDocument();
+ expect(apiMocks.queryLiteTopicQuota).toHaveBeenCalledTimes(1);
+
+ await user.click(screen.getAllByRole('combobox')[0]);
+ const dropdown =
document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
+ expect(dropdown).not.toBeNull();
+ expect(
+
Array.from(dropdown!.querySelectorAll('.ant-select-item-option-content')).map(
+ (option) => option.textContent,
+ ),
+ ).toEqual(['全部命名空间', 'Alpha', 'Beta']);
+ });
+
+ it('collects namespace options from a stale in-flight bootstrap display
request', async () => {
+ const bootstrapList = createDeferred<LiteTopicItem[]>();
+ const bootstrapQuota = createDeferred<LiteTopicQuota>();
+
+ apiMocks.queryLiteTopicList.mockImplementation((pattern: string |
undefined) =>
+ pattern === 'needle'
+ ? Promise.resolve([{ namespace: 'Filtered', topicPattern:
'filtered-result-*' }])
+ : bootstrapList.promise,
+ );
+ apiMocks.queryLiteTopicQuota
+ .mockReturnValueOnce(bootstrapQuota.promise)
+ .mockResolvedValueOnce(createQuota(20));
+
+ const user = userEvent.setup();
+ renderPage();
+
+ await waitFor(() => {
+ expect(apiMocks.queryLiteTopicList).toHaveBeenCalledWith(undefined,
undefined);
+ });
+ await user.type(screen.getByPlaceholderText('按 Topic 模式筛选...'), 'needle');
+ await user.click(screen.getByRole('button', { name: /搜索/ }));
+ expect(await screen.findByText('filtered-result-*')).toBeInTheDocument();
+ expect(screen.getByText('20 / 100')).toBeInTheDocument();
+
+ await act(async () => {
+ bootstrapQuota.resolve(createQuota(90));
+ bootstrapList.resolve([
+ { namespace: 'Beta', topicPattern: 'bootstrap-beta-*' },
+ { namespace: 'Alpha', topicPattern: 'bootstrap-alpha-*' },
+ ]);
+ });
+
+ expect(screen.getByText('filtered-result-*')).toBeInTheDocument();
+ expect(screen.getByText('20 / 100')).toBeInTheDocument();
+ expect(screen.queryByText('bootstrap-alpha-*')).not.toBeInTheDocument();
+ expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+
+ await user.click(screen.getAllByRole('combobox')[0]);
+ const dropdown =
document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
+ expect(dropdown).not.toBeNull();
+ expect(
+
Array.from(dropdown!.querySelectorAll('.ant-select-item-option-content')).map(
+ (option) => option.textContent,
+ ),
+ ).toEqual(['全部命名空间', 'Alpha', 'Beta']);
+ });
+
+ it('honors a delayed unsupported capability after an early search', async ()
=> {
+ const capability = createDeferred<{ supported: boolean }>();
+ const earlyList = createDeferred<LiteTopicItem[]>();
+ const earlyQuota = createDeferred<LiteTopicQuota>();
+
+ apiMocks.queryLiteTopicCapability.mockReturnValue(capability.promise);
+ apiMocks.queryLiteTopicList.mockReturnValue(earlyList.promise);
+ apiMocks.queryLiteTopicQuota.mockReturnValue(earlyQuota.promise);
+
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.type(screen.getByPlaceholderText('按 Topic 模式筛选...'), 'needle');
+ await user.click(screen.getByRole('button', { name: /搜索/ }));
+ expect(apiMocks.queryLiteTopicList).toHaveBeenCalledWith('needle',
undefined);
+
+ await act(async () => {
+ capability.resolve({ supported: false });
+ });
+ expect(await screen.findByText('当前集群不支持 LiteTopic,请升级到 RocketMQ
5.x')).toBeInTheDocument();
+
+ await act(async () => {
+ earlyQuota.resolve(createQuota(20));
+ earlyList.resolve([{ namespace: 'default', topicPattern: 'late-result-*'
}]);
+ });
+ expect(screen.getByText('当前集群不支持 LiteTopic,请升级到 RocketMQ
5.x')).toBeInTheDocument();
+ expect(screen.queryByText('late-result-*')).not.toBeInTheDocument();
+ });
+
+ it('ignores stale list and quota responses when namespaces change quickly',
async () => {
+ const alphaList = createDeferred<LiteTopicItem[]>();
+ const betaList = createDeferred<LiteTopicItem[]>();
+ const alphaQuota = createDeferred<LiteTopicQuota>();
+ const betaQuota = createDeferred<LiteTopicQuota>();
+
+ apiMocks.queryLiteTopicList.mockImplementation(
+ (_pattern: string | undefined, namespace: string | undefined) => {
+ if (namespace === 'Alpha') return alphaList.promise;
+ if (namespace === 'Beta') return betaList.promise;
+ return Promise.resolve([
+ { namespace: 'Alpha', topicPattern: 'initial-alpha-*' },
+ { namespace: 'Beta', topicPattern: 'initial-beta-*' },
+ ]);
+ },
+ );
+ apiMocks.queryLiteTopicQuota.mockImplementation((namespace: string |
undefined) => {
+ if (namespace === 'Alpha') return alphaQuota.promise;
+ if (namespace === 'Beta') return betaQuota.promise;
+ return Promise.resolve(createQuota(90));
+ });
+
+ renderPage();
+
+ expect(await screen.findByText('initial-alpha-*')).toBeInTheDocument();
+ expect(screen.getByText('90 / 100')).toBeInTheDocument();
+
+ await selectNamespace('Alpha');
+ expect(screen.queryByText('initial-alpha-*')).not.toBeInTheDocument();
+ expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+
+ await selectNamespace('Beta');
+ expect(apiMocks.queryLiteTopicList).toHaveBeenCalledWith(undefined,
'Beta');
+ expect(apiMocks.queryLiteTopicQuota).toHaveBeenCalledWith('Beta');
+
+ await act(async () => {
+ betaQuota.resolve(createQuota(20));
+ betaList.resolve([{ namespace: 'Beta', topicPattern: 'beta-result-*' }]);
+ });
+ expect(await screen.findByText('beta-result-*')).toBeInTheDocument();
+ expect(screen.getByText('20 / 100')).toBeInTheDocument();
+
+ await act(async () => {
+ alphaQuota.resolve(createQuota(10));
+ alphaList.resolve([{ namespace: 'Alpha', topicPattern: 'stale-alpha-*'
}]);
+ });
+ expect(screen.getByText('beta-result-*')).toBeInTheDocument();
+ expect(screen.getByText('20 / 100')).toBeInTheDocument();
+ expect(screen.queryByText('stale-alpha-*')).not.toBeInTheDocument();
+ expect(screen.queryByText('10 / 100')).not.toBeInTheDocument();
+ });
+
+ it('keeps a failed namespace request cleared', async () => {
+ const failedList = createDeferred<LiteTopicItem[]>();
+ const failedQuota = createDeferred<LiteTopicQuota>();
+
+ apiMocks.queryLiteTopicList.mockImplementation(
+ (_pattern: string | undefined, namespace: string | undefined) =>
+ namespace === 'Alpha'
+ ? failedList.promise
+ : Promise.resolve([{ namespace: 'Alpha', topicPattern: 'old-*' }]),
+ );
+ apiMocks.queryLiteTopicQuota.mockImplementation((namespace: string |
undefined) =>
+ namespace === 'Alpha' ? failedQuota.promise :
Promise.resolve(createQuota(90)),
+ );
+
+ const { container } = renderPage();
+ expect(await screen.findByText('old-*')).toBeInTheDocument();
+
+ await selectNamespace('Alpha');
+ expect(screen.queryByText('old-*')).not.toBeInTheDocument();
+ expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+
+ await act(async () => {
+ failedQuota.reject(new Error('quota failed'));
+ failedList.reject(new Error('list failed'));
+ });
+
+ expect(await screen.findByText('获取 LiteTopic 列表失败')).toBeInTheDocument();
+ await waitFor(() => {
+
expect(container.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
+ });
+ expect(screen.queryByText('old-*')).not.toBeInTheDocument();
+ expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+ });
});