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(

Reply via email to