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 b1c86f31f fix(web): export complete Topic and Consumer Group
inventories (#2555)
b1c86f31f is described below
commit b1c86f31fdd4803bedda3a0bc544732b5be79040
Author: coder999o <[email protected]>
AuthorDate: Tue Aug 25 17:33:40 2026 +0800
fix(web): export complete Topic and Consumer Group inventories (#2555)
* fix: export complete Topic and Consumer Group inventories
* Update
---
.../pages/instance/__tests__/ConsumerPage.test.tsx | 31 +++++++-
.../pages/instance/__tests__/TopicPage.test.tsx | 29 ++++++-
web/src/pages/instance/consumer.tsx | 75 ++++++++++++------
web/src/pages/instance/topic.tsx | 89 ++++++++++++++--------
web/src/services/consumerService.test.ts | 73 +++++++++++++++++-
web/src/services/consumerService.ts | 25 ++++++
web/src/services/topicService.test.ts | 7 ++
web/src/services/topicService.ts | 18 +++++
8 files changed, 283 insertions(+), 64 deletions(-)
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 58c4edc41..8d62db0f4 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -36,6 +36,7 @@ vi.mock('../../../services/consumerService', () => ({
getConsumerStack: vi.fn(),
getConsumerSubscriptions: vi.fn(),
getConsumerGroupSettings: vi.fn(),
+ listAllConsumerGroups: vi.fn(),
updateConsumerGroupSettings: vi.fn(),
listConsumerGroupPage: vi.fn(),
refreshConsumerGroup: vi.fn(),
@@ -124,6 +125,7 @@ describe('Consumer page', () => {
},
]);
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(groupPage([group]));
+
vi.mocked(consumerService.listAllConsumerGroups).mockResolvedValue([group]);
vi.mocked(consumerService.refreshConsumerGroup).mockResolvedValue({
...group,
totalLag: 42,
@@ -226,7 +228,7 @@ describe('Consumer page', () => {
);
});
- it('downloads the currently filtered consumer groups when exporting', async
() => {
+ it('downloads all consumer groups matching the current filters when
exporting', async () => {
const user = userEvent.setup();
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype,
'click').mockImplementation(vi.fn());
let exportedBlob: Blob | undefined;
@@ -234,13 +236,23 @@ describe('Consumer page', () => {
exportedBlob = blob as Blob;
return 'blob:consumer-group-export';
});
- const exportGroups = [
+ const currentPageGroups = [
{
...group,
name: 'orders-cg',
namespace: '\r=formula-risk',
subscribedTopics: ['orders-topic', 'payments,topic'],
},
+ ];
+ const archivedGroup = {
+ ...group,
+ name: 'orders-cg-archive',
+ namespace: '=archive',
+ subscribedTopics: ['orders-topic'],
+ };
+ const exportGroups = [
+ ...currentPageGroups,
+ archivedGroup,
{
...group,
name: 'users-cg',
@@ -252,12 +264,17 @@ describe('Consumer page', () => {
const filtered = params?.search
? exportGroups.filter((item) => item.name.includes(params.search ??
''))
: exportGroups;
- return groupPage(filtered, {
+ return groupPage(filtered.slice(0, 1), {
total: filtered.length,
page: params?.page ?? 1,
size: params?.pageSize ?? 20,
});
});
+ vi.mocked(consumerService.listAllConsumerGroups).mockImplementation(async
(params) =>
+ params?.search
+ ? exportGroups.filter((item) => item.name.includes(params.search ??
''))
+ : exportGroups,
+ );
renderWithProviders(<ConsumerPage />);
expect(await screen.findByText('orders-cg')).toBeInTheDocument();
@@ -265,6 +282,12 @@ describe('Consumer page', () => {
await waitFor(() =>
expect(screen.queryByText('users-cg')).not.toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /导出/ }));
+ await waitFor(() =>
+ expect(consumerService.listAllConsumerGroups).toHaveBeenCalledWith({
+ instanceId: 'instance-1',
+ search: 'orders',
+ }),
+ );
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:consumer-group-export');
@@ -275,7 +298,9 @@ describe('Consumer page', () => {
expect(exportedBlob).toBeDefined();
const csv = await exportedBlob!.text();
expect(csv).toContain('"orders-cg"');
+ expect(csv).toContain('"orders-cg-archive"');
expect(csv).toContain('"\'\r=formula-risk"');
+ expect(csv).toContain('"\'=archive"');
expect(csv).toContain('"orders-topic;payments,topic"');
expect(csv).not.toContain('users-cg');
clickSpy.mockRestore();
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 5514d4e0c..41ff78f62 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -32,6 +32,7 @@ const topicServiceMocks = vi.hoisted(() => ({
getTopicConsumers: vi.fn(),
getTopicConsumerPage: vi.fn(),
getTopicRoutes: vi.fn(),
+ listAllTopics: vi.fn(),
listTopics: vi.fn(),
listTopicsPage: vi.fn(),
sendTopicMessage: vi.fn(),
@@ -132,6 +133,7 @@ const getTableBody = () => {
describe('TopicPage', () => {
beforeEach(() => {
mockTopicsList(buildTopics(25));
+ topicServiceMocks.listAllTopics.mockResolvedValue(buildTopics(25));
topicServiceMocks.batchDeleteTopics.mockResolvedValue({ deleted: [],
failed: [] });
topicServiceMocks.createTopic.mockImplementation(async (data:
Partial<Topic>) => ({
...buildTopics(1)[0],
@@ -200,7 +202,7 @@ describe('TopicPage', () => {
expect(create).toHaveClass('ant-btn-loading');
});
- it('downloads the currently filtered topics when exporting', async () => {
+ it('downloads all topics matching the current filters when exporting', async
() => {
const user = userEvent.setup();
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype,
'click').mockImplementation(vi.fn());
let exportedBlob: Blob | undefined;
@@ -208,20 +210,32 @@ describe('TopicPage', () => {
exportedBlob = blob as Blob;
return 'blob:topic-export';
});
- mockTopicsList([
+ const currentPageTopics = [
{
...buildTopics(1)[0],
name: 'orders-topic',
namespace: 'trade',
remark: '\t=orders, "critical"',
},
+ ];
+ const archivedTopic = {
+ ...buildTopics(1)[0],
+ name: 'orders-topic-archive',
+ namespace: 'trade',
+ remark: '=archive',
+ };
+ const allMatchingTopics = [
+ ...currentPageTopics,
+ archivedTopic,
{
...buildTopics(1)[0],
name: 'users-topic',
namespace: 'user',
remark: '=formula-risk',
},
- ]);
+ ];
+ mockTopicsList(currentPageTopics);
+ topicServiceMocks.listAllTopics.mockResolvedValue(allMatchingTopics);
renderWithProviders();
expect(await screen.findByText('orders-topic')).toBeInTheDocument();
@@ -230,6 +244,13 @@ describe('TopicPage', () => {
await waitFor(() =>
expect(screen.queryByText('users-topic')).not.toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /导出/ }));
+ await waitFor(() =>
+ expect(topicServiceMocks.listAllTopics).toHaveBeenCalledWith({
+ instanceId: 'instance-proxy-1',
+ type: undefined,
+ search: 'orders',
+ }),
+ );
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:topic-export');
@@ -238,7 +259,9 @@ describe('TopicPage', () => {
expect(exportedBlob).toBeDefined();
const csv = await exportedBlob!.text();
expect(csv).toContain('"orders-topic"');
+ expect(csv).toContain('"orders-topic-archive"');
expect(csv).toContain('"\'\t=orders, ""critical"""');
+ expect(csv).toContain('"\'=archive"');
expect(csv).not.toContain('users-topic');
clickSpy.mockRestore();
});
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 5e119a44b..f5fa93ff1 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -81,6 +81,7 @@ import {
getConsumerProgress,
getConsumerStack,
getConsumerSubscriptions,
+ listAllConsumerGroups,
listConsumerGroupPage,
refreshConsumerGroup,
resetConsumerOffset,
@@ -151,6 +152,26 @@ const GROUP_EXPORT_COLUMNS: CsvColumn<ConsumerGroup>[] = [
const buildConsumerGroupCsv = (groups: ConsumerGroup[]) =>
buildCsv(GROUP_EXPORT_COLUMNS, groups);
+const visibleConsumerGroups = (
+ groups: ConsumerGroup[],
+ modeFilter: string,
+ sortKey: string,
+): ConsumerGroup[] => {
+ let data = groups;
+
+ if (modeFilter !== 'ALL') {
+ data = data.filter((group) => group.subscriptionMode === modeFilter);
+ }
+
+ if (sortKey === 'lag_desc') {
+ data = [...data].sort((left, right) => right.totalLag - left.totalLag);
+ } else if (sortKey === 'name_asc') {
+ data = [...data].sort((left, right) =>
left.name.localeCompare(right.name));
+ }
+
+ return data;
+};
+
const normalizedConsistency = (value?: string | null): string =>
value?.trim().toLowerCase() ?? '';
const isConsistentValue = (value?: string | null): boolean =>
@@ -233,6 +254,7 @@ const ConsumerPageContent = ({
const [importRows, setImportRows] =
useState<ResourceImportRow<Partial<ConsumerGroup>>[]>([]);
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
+ const [exporting, setExporting] = useState(false);
const groupRequestIdRef = useRef(0);
const stackRequestIdRef = useRef(0);
@@ -244,6 +266,7 @@ const ConsumerPageContent = ({
silentRefreshRef.current = silent;
setRefreshKey((key) => key + 1);
}, []);
+ const selectedGroupName = selectedGroup?.name;
useEffect(() => {
if (!selectedInstanceId) {
@@ -357,20 +380,28 @@ const ConsumerPageContent = ({
/* ─── Filtered & sorted data ─── */
const filtered = useMemo(() => {
- let data = groups;
-
- if (modeFilter !== 'ALL') {
- data = data.filter((g) => g.subscriptionMode === modeFilter);
- }
+ return visibleConsumerGroups(groups, modeFilter, sortKey);
+ }, [groups, modeFilter, sortKey]);
- if (sortKey === 'lag_desc') {
- data = [...data].sort((a, b) => b.totalLag - a.totalLag);
- } else if (sortKey === 'name_asc') {
- data = [...data].sort((a, b) => a.name.localeCompare(b.name));
+ const handleExport = async () => {
+ setExporting(true);
+ try {
+ const allGroups = await listAllConsumerGroups({
+ instanceId: selectedInstanceId || undefined,
+ search: search.trim() || undefined,
+ });
+ const exportGroups = visibleConsumerGroups(allGroups, modeFilter,
sortKey);
+ downloadCsv(
+ `rocketmq-consumer-groups-${new Date().toISOString().slice(0,
10)}.csv`,
+ buildConsumerGroupCsv(exportGroups),
+ );
+ message.success(`已导出 ${exportGroups.length} 个 Group`);
+ } catch {
+ message.error('导出 Group 失败,请稍后重试');
+ } finally {
+ setExporting(false);
}
-
- return data;
- }, [groups, modeFilter, sortKey]);
+ };
/* ─── Open detail modal ─── */
const [detailTab, setDetailTab] = useState('overview');
@@ -436,8 +467,8 @@ const ConsumerPageContent = ({
}
};
- const selectedDiagnosticKey = selectedGroup
- ? diagnosticCacheKey(selectedInstanceId, selectedGroup.name)
+ const selectedDiagnosticKey = selectedGroupName
+ ? diagnosticCacheKey(selectedInstanceId, selectedGroupName)
: '';
const resetDiagnosticKey = resetGroup
? diagnosticCacheKey(selectedInstanceId, resetGroup.name)
@@ -460,7 +491,10 @@ const ConsumerPageContent = ({
const visibleSubscriptions = showOnlyInconsistent
? inconsistentSubscriptions
: selectedSubscriptions;
- const selectedProgress = selectedGroup ?
(progressByGroup[selectedDiagnosticKey] ?? []) : [];
+ const selectedProgress = useMemo(
+ () => (selectedGroupName ? (progressByGroup[selectedDiagnosticKey] ?? [])
: []),
+ [progressByGroup, selectedDiagnosticKey, selectedGroupName],
+ );
const progressTopicOptions = useMemo(
() => Array.from(new Set(selectedProgress.map((q) =>
q.topic).filter(Boolean))).sort(),
[selectedProgress],
@@ -1099,16 +1133,7 @@ const ConsumerPageContent = ({
>
导入
</Button>
- <Button
- icon={<ExportOutlined />}
- onClick={() => {
- downloadCsv(
- `rocketmq-consumer-groups-${new Date().toISOString().slice(0,
10)}.csv`,
- buildConsumerGroupCsv(filtered),
- );
- message.success(`已导出 ${filtered.length} 个 Group`);
- }}
- >
+ <Button icon={<ExportOutlined />} loading={exporting} onClick={() =>
void handleExport()}>
导出
</Button>
<Button
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 47b74126f..d380015dc 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -67,6 +67,7 @@ import {
deleteTopic,
getTopicConsumerPage,
getTopicRoutes,
+ listAllTopics,
listTopicsPage,
sendTopicMessage,
} from '../../services/topicService';
@@ -156,6 +157,21 @@ const TOPIC_EXPORT_COLUMNS: CsvColumn<Topic>[] = [
const buildTopicCsv = (topics: Topic[]) => buildCsv(TOPIC_EXPORT_COLUMNS,
topics);
+const visibleTopics = (
+ topics: Topic[],
+ selectedInstanceId: string | undefined,
+ searchText: string,
+ typeFilter: string,
+) =>
+ topics
+ .filter((topic) => {
+ if (selectedInstanceId && topic.instanceId !== selectedInstanceId)
return false;
+ if (searchText &&
!topic.name.toLowerCase().includes(searchText.toLowerCase())) return false;
+ if (typeFilter && topic.type !== typeFilter) return false;
+ return true;
+ })
+ .sort((left, right) => left.name.localeCompare(right.name));
+
// ─── Random message body generators ──────────────────────────────
const randomOrderBody = () =>
JSON.stringify(
@@ -326,6 +342,7 @@ const TopicPage = () => {
const [importRows, setImportRows] =
useState<ResourceImportRow<Partial<Topic>>[]>([]);
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
+ const [exporting, setExporting] = useState(false);
const topicRequestIdRef = useRef(0);
const detailRequestIdRef = useRef(0);
@@ -377,15 +394,7 @@ const TopicPage = () => {
// ─── Filtered data ─────────────────────────────────────────────
const filteredTopics = useMemo(
- () =>
- topics
- .filter((t) => {
- if (selectedInstanceId && t.instanceId !== selectedInstanceId)
return false;
- if (searchText &&
!t.name.toLowerCase().includes(searchText.toLowerCase())) return false;
- if (typeFilter && t.type !== typeFilter) return false;
- return true;
- })
- .sort((a, b) => a.name.localeCompare(b.name)),
+ () => visibleTopics(topics, selectedInstanceId, searchText, typeFilter),
[topics, selectedInstanceId, searchText, typeFilter],
);
@@ -396,19 +405,22 @@ const TopicPage = () => {
setTablePage(1);
};
- const loadTopicConsumers = async (topic: Topic, page = 1, pageSize = 20) => {
- const requestId = ++consumersRequestIdRef.current;
- const consumers = await getTopicConsumerPage(
- topic.name,
- selectedInstanceId || undefined,
- page,
- pageSize,
- );
- // Guard against a slower earlier page overwriting a newer one when the
user pages quickly.
- if (requestId === consumersRequestIdRef.current) {
- setConsumersByTopic((previous) => ({ ...previous, [topic.name]:
consumers }));
- }
- };
+ const loadTopicConsumers = useCallback(
+ async (topic: Topic, page = 1, pageSize = 20) => {
+ const requestId = ++consumersRequestIdRef.current;
+ const consumers = await getTopicConsumerPage(
+ topic.name,
+ selectedInstanceId || undefined,
+ page,
+ pageSize,
+ );
+ // Guard against a slower earlier page overwriting a newer one when the
user pages quickly.
+ if (requestId === consumersRequestIdRef.current) {
+ setConsumersByTopic((previous) => ({ ...previous, [topic.name]:
consumers }));
+ }
+ },
+ [selectedInstanceId],
+ );
// ─── Open detail modal ────────────────────────────────────────
const openDetail = useCallback(
@@ -555,6 +567,28 @@ const TopicPage = () => {
}
};
+ const handleExport = () => {
+ setExporting(true);
+
+ void listAllTopics({
+ instanceId: selectedInstanceId || undefined,
+ type: typeFilter || undefined,
+ search: searchText.trim() || undefined,
+ })
+ .then((allTopics) => {
+ const exportTopics = visibleTopics(allTopics, selectedInstanceId,
searchText, typeFilter);
+ downloadCsv(
+ `rocketmq-topics-${new Date().toISOString().slice(0, 10)}.csv`,
+ buildTopicCsv(exportTopics),
+ );
+ message.success(`已导出 ${exportTopics.length} 个 Topic`);
+ })
+ .catch(() => {
+ message.error('导出 Topic 失败,请稍后重试');
+ })
+ .finally(() => setExporting(false));
+ };
+
// ─── Table columns ────────────────────────────────────────────
const columns: TableColumnsType<Topic> = [
{
@@ -1151,16 +1185,7 @@ const TopicPage = () => {
>
导入
</Button>
- <Button
- icon={<ExportOutlined />}
- onClick={() => {
- downloadCsv(
- `rocketmq-topics-${new Date().toISOString().slice(0, 10)}.csv`,
- buildTopicCsv(filteredTopics),
- );
- message.success(`已导出 ${filteredTopics.length} 个 Topic`);
- }}
- >
+ <Button icon={<ExportOutlined />} loading={exporting} onClick={() =>
void handleExport()}>
导出
</Button>
{!isCloudInstance && (
diff --git a/web/src/services/consumerService.test.ts
b/web/src/services/consumerService.test.ts
index b40872f1d..897e19e3f 100644
--- a/web/src/services/consumerService.test.ts
+++ b/web/src/services/consumerService.test.ts
@@ -22,13 +22,18 @@ import {
getConsumerProgress,
getConsumerStack,
getConsumerSubscriptions,
+ listAllConsumerGroups,
listConsumerGroupPage,
listConsumerGroups,
} from './consumerService';
const { mode, metadataApi } = vi.hoisted(() => ({
mode: { mock: true },
- metadataApi: { getConsumerGroup: vi.fn(), listConsumerGroups: vi.fn() },
+ metadataApi: {
+ getConsumerGroup: vi.fn(),
+ listConsumerGroupPage: vi.fn(),
+ listConsumerGroups: vi.fn(),
+ },
}));
vi.mock('./dataMode', () => ({ isMockMode: () => mode.mock }));
@@ -102,6 +107,72 @@ describe('consumer service mock data', () => {
expect(page.size).toBe(1);
});
+ it('loads every API consumer group page matching the export filters', async
() => {
+ mode.mock = false;
+ const firstGroup = { name: 'cg-a', subscribedTopics: null, instances: null
};
+ const secondGroup = { name: 'cg-b', subscribedTopics: ['topic-b'],
instances: [] };
+ metadataApi.listConsumerGroupPage
+ .mockResolvedValueOnce({
+ items: [firstGroup],
+ total: 2,
+ page: 1,
+ size: 100,
+ })
+ .mockResolvedValueOnce({
+ items: [secondGroup],
+ total: 2,
+ page: 2,
+ size: 100,
+ });
+ try {
+ const groups = await listAllConsumerGroups({
+ instanceId: 'instance-1',
+ search: 'cg',
+ });
+
+ expect(metadataApi.listConsumerGroupPage).toHaveBeenNthCalledWith(1, {
+ instanceId: 'instance-1',
+ search: 'cg',
+ page: 1,
+ pageSize: 100,
+ });
+ expect(metadataApi.listConsumerGroupPage).toHaveBeenNthCalledWith(2, {
+ instanceId: 'instance-1',
+ search: 'cg',
+ page: 2,
+ pageSize: 100,
+ });
+ expect(groups).toEqual([
+ { name: 'cg-a', subscribedTopics: [], instances: [] },
+ { name: 'cg-b', subscribedTopics: ['topic-b'], instances: [] },
+ ]);
+ } finally {
+ mode.mock = true;
+ }
+ });
+
+ it('stops API consumer group export when pagination exceeds the safety
limit', async () => {
+ mode.mock = false;
+ metadataApi.listConsumerGroupPage.mockReset();
+ metadataApi.listConsumerGroupPage.mockResolvedValue({
+ items: [{ name: 'cg-a', subscribedTopics: null, instances: null }],
+ total: Number.MAX_SAFE_INTEGER,
+ page: 1,
+ size: 100,
+ });
+ try {
+ await expect(listAllConsumerGroups()).rejects.toThrow(
+ 'Consumer group export exceeded 100 pages',
+ );
+ expect(metadataApi.listConsumerGroupPage).toHaveBeenCalledTimes(100);
+ expect(metadataApi.listConsumerGroupPage).toHaveBeenLastCalledWith({
+ page: 100,
+ pageSize: 100,
+ });
+ } finally {
+ mode.mock = true;
+ }
+ });
it('returns copied progress and subscription rows', async () => {
const firstProgress = await getConsumerProgress('cg-order-notify');
const firstSubscriptions = await
getConsumerSubscriptions('cg-order-notify');
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index eef0bc779..c2b854638 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -15,6 +15,8 @@ import type {
import { mockConsumerGroups, mockQueueProgress, mockSubscriptions } from
'../mock/consumers';
const consumerGroupsState = mockConsumerGroups as unknown as ConsumerGroup[];
+const EXPORT_PAGE_SIZE = 100;
+const MAX_EXPORT_PAGES = 100;
function copyConsumerInstance(
instance: ConsumerGroup['instances'][number],
@@ -83,6 +85,29 @@ export async function listConsumerGroupPage(
return metadataApi.listConsumerGroupPage(params);
}
+export async function listAllConsumerGroups(
+ params: ConsumerGroupQuery = {},
+): Promise<ConsumerGroup[]> {
+ const groups: ConsumerGroup[] = [];
+ let page = 1;
+
+ while (page <= MAX_EXPORT_PAGES) {
+ const result = await listConsumerGroupPage({
+ ...params,
+ page,
+ pageSize: EXPORT_PAGE_SIZE,
+ });
+ groups.push(...result.items);
+ const total = result.total ?? groups.length;
+ if (result.items.length === 0 || groups.length >= total) {
+ return groups.map(normalizeConsumerGroup);
+ }
+ page += 1;
+ }
+
+ throw new Error(`Consumer group export exceeded ${MAX_EXPORT_PAGES} pages`);
+}
+
export async function getConsumerProgress(
name: string,
instanceId?: string,
diff --git a/web/src/services/topicService.test.ts
b/web/src/services/topicService.test.ts
index 7aa9cbb4c..b6a381e8e 100644
--- a/web/src/services/topicService.test.ts
+++ b/web/src/services/topicService.test.ts
@@ -20,6 +20,7 @@ import {
getTopicConsumerPage,
getTopicConsumers,
getTopicRoutes,
+ listAllTopics,
listTopics,
} from './topicService';
@@ -84,9 +85,15 @@ describe('topic service mock data', () => {
it('filters mock topics by instance ID', async () => {
const topics = await listTopics({ instanceId: 'instance-proxy-1' });
+ const directTopics = await listTopics({ instanceId: 'instance-proxy-1',
search: 'order' });
+ const exportedTopics = await listAllTopics({
+ instanceId: 'instance-proxy-1',
+ search: 'order',
+ });
expect(topics).not.toHaveLength(0);
expect(topics.every((topic) => topic.instanceId ===
'instance-proxy-1')).toBe(true);
+ expect(exportedTopics).toEqual(directTopics);
});
it('rejects duplicate topic creates in the same cluster', async () => {
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 9218e030d..5843c2e63 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -12,6 +12,9 @@ import type {
} from '../api/metadata';
import { topics as mockTopics, topicRoutes, topicConsumers } from
'../mock/topics';
+const EXPORT_PAGE_SIZE = 100;
+const MAX_EXPORT_PAGES = 100;
+
const cloneTopic = (topic: Topic): Topic => ({ ...topic });
const cloneRoutes = (routes: BrokerRoute[]): BrokerRoute[] =>
routes.map((route) => ({ ...route }));
const cloneConsumers = (consumers: ConsumerGroupInfo[]): ConsumerGroupInfo[] =>
@@ -54,6 +57,21 @@ export async function listTopicsPage(
return metadataApi.listTopicsPage(params);
}
+export const listAllTopics = async (params: TopicQuery = {}): Promise<Topic[]>
=> {
+ const topics: Topic[] = [];
+ let page = 1;
+
+ while (page <= MAX_EXPORT_PAGES) {
+ const result = await listTopicsPage({ ...params, page, pageSize:
EXPORT_PAGE_SIZE });
+ topics.push(...result.items);
+ const total = result.total ?? topics.length;
+ if (result.items.length === 0 || topics.length >= total) return topics;
+ page += 1;
+ }
+
+ throw new Error(`Topic export exceeded ${MAX_EXPORT_PAGES} pages`);
+};
+
export async function createTopic(data: Partial<Topic>): Promise<Topic> {
if (isMockMode()) {
const duplicate = mockTopics.some(