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 4707d54c5 fix(web): clamp out-of-range pages to the last valid page
after deletes (#2841)
4707d54c5 is described below
commit 4707d54c5a3943a226841dd9b4387909498a4899
Author: cyberslack_lee <[email protected]>
AuthorDate: Wed Sep 2 16:26:08 2026 +0800
fix(web): clamp out-of-range pages to the last valid page after deletes
(#2841)
clamp out-of-range pages to the last valid page after deletes
---
web/src/pages/instance/__tests__/AclPage.test.tsx | 43 ++++++++++++++++
.../pages/instance/__tests__/ConsumerPage.test.tsx | 36 ++++++++++++++
.../pages/instance/__tests__/TopicPage.test.tsx | 57 ++++++++++++++++++++++
web/src/pages/instance/acl.tsx | 6 +++
web/src/pages/instance/consumer.tsx | 3 ++
web/src/pages/instance/topic.tsx | 3 ++
6 files changed, 148 insertions(+)
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index f279794e4..a753c34c5 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -158,6 +158,49 @@ describe('ACL page', () => {
expect(within(accessKeyCell).queryByRole('button')).not.toBeInTheDocument();
});
+ it('clamps rules back to a valid page when the current page becomes empty',
async () => {
+ const user = userEvent.setup();
+ const ruleItems = (count: number) =>
+ Array.from({ length: count }, (_, index) => ({
+ id: index + 1,
+ principal: 'remote-user',
+ resource: `acl-topic-${String(index + 1).padStart(2, '0')}`,
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: 2,
+ gmtCreate: '2026-07-23T00:00:00Z',
+ }));
+ let call = 0;
+ vi.mocked(aclService.listAclRules).mockImplementation(async (params) => {
+ call += 1;
+ if (params?.page === 2) {
+ // Page 2 went out of range (its rules were deleted server-side).
+ return { items: [], total: 15, page: 2, size: 20 };
+ }
+ // First load reports 45 rules (3 pages); the clamp re-fetch reports the
shrunk 15.
+ return call === 1
+ ? { items: ruleItems(20), total: 45, page: 1, size: 20 }
+ : { items: ruleItems(15), total: 15, page: 1, size: 20 };
+ });
+ renderWithProviders(<AclPage />);
+
+ expect(await screen.findByText('acl-topic-01')).toBeInTheDocument();
+
+ const secondPage = document.querySelector('.ant-pagination-item-2');
+ expect(secondPage).not.toBeNull();
+ await user.click(secondPage as HTMLElement);
+
+ // The empty out-of-range page is corrected: the rules reload page 1.
+ await waitFor(() =>
+ expect(aclService.listAclRules).toHaveBeenLastCalledWith(
+ expect.objectContaining({ page: 1, pageSize: 20 }),
+ ),
+ );
+ });
+
it('closes an ACL rule dialog when switching to another instance', async ()
=> {
const user = userEvent.setup();
vi.mocked(instanceService.listInstances).mockResolvedValue([
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 55ba19b2c..12186cc05 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -91,6 +91,12 @@ const group: ConsumerGroup = {
instances: [],
};
+const buildGroups = (count: number): ConsumerGroup[] =>
+ Array.from({ length: count }, (_, index) => ({
+ ...group,
+ name: `remote-cg-${String(index + 1).padStart(2, '0')}`,
+ }));
+
const groupPage = (
items: ConsumerGroup[],
overrides: Partial<{ total: number; page: number; size: number }> = {},
@@ -294,6 +300,36 @@ describe('Consumer page', () => {
message.destroy();
});
+ it('clamps back to a valid page when the current page becomes empty after a
delete', async () => {
+ const user = userEvent.setup();
+ let call = 0;
+ vi.mocked(consumerService.listConsumerGroupPage).mockImplementation(async
(params) => {
+ call += 1;
+ if (params?.page === 2) {
+ // Page 2 went out of range (its rows were deleted server-side).
+ return groupPage([], { total: 15, page: 2 });
+ }
+ // First load reports 45 rows (3 pages); the clamp re-fetch reports the
shrunk 15.
+ return call === 1
+ ? groupPage(buildGroups(20), { total: 45, size: 20 })
+ : groupPage(buildGroups(15), { total: 15, size: 20 });
+ });
+ renderWithProviders(<ConsumerPage />);
+
+ expect(await screen.findByText('remote-cg-01')).toBeInTheDocument();
+
+ const secondPage = document.querySelector('.ant-pagination-item-2');
+ expect(secondPage).not.toBeNull();
+ await user.click(secondPage as HTMLElement);
+
+ // The empty out-of-range page is corrected: the list reloads page 1.
+ await waitFor(() =>
+ expect(consumerService.listConsumerGroupPage).toHaveBeenLastCalledWith(
+ expect.objectContaining({ page: 1, pageSize: 20 }),
+ ),
+ );
+ });
+
it('submits the canonical global delivery order type', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config)
=> {
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index d2003577e..8dc37744b 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -324,6 +324,63 @@ describe('TopicPage', () => {
expect(within(getTableBody()).queryByText('topic-01')).not.toBeInTheDocument();
});
+ it('clamps back to a valid page when the current page becomes empty after a
delete', async () => {
+ const user = userEvent.setup();
+ instanceServiceMocks.listInstances.mockResolvedValue([
+ {
+ id: 6,
+ name: 'instance-a',
+ type: 'DIRECT',
+ endpoint: '127.0.0.1:9876',
+ remark: '',
+ topicCount: 45,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-01-01T00:00:00Z',
+ gmtModified: '2026-01-01T00:00:00Z',
+ },
+ ]);
+ const requestedPages: number[] = [];
+ topicServiceMocks.listTopicsPage.mockImplementation(async (params) => {
+ requestedPages.push(params?.page ?? 0);
+ if (params?.page === 2) {
+ // Page 2 went out of range (its rows were deleted server-side):
+ // report no items and the shrunk total.
+ return { items: [], total: 15, page: 2, size: 20 };
+ }
+ // The first load reports 45 rows (3 pages); the clamp re-fetch
+ // reports the shrunk 15 rows that fit on a single page.
+ return requestedPages.length === 1
+ ? {
+ items: buildTopics(20).map((t) => ({ ...t, instanceId:
'instance-a' })),
+ total: 45,
+ page: 1,
+ size: 20,
+ }
+ : {
+ items: buildTopics(15).map((t) => ({ ...t, instanceId:
'instance-a' })),
+ total: 15,
+ page: 1,
+ size: 20,
+ };
+ });
+ renderWithProviders('/instance/instance-a/topic');
+
+ expect(await screen.findByText('topic-01')).toBeInTheDocument();
+
+ const secondPage = document.querySelector('.ant-pagination-item-2');
+ expect(secondPage).not.toBeNull();
+ await user.click(secondPage as HTMLElement);
+
+ // The empty out-of-range page is corrected: the table reloads the last
valid page.
+ await waitFor(() =>
+ expect(topicServiceMocks.listTopicsPage).toHaveBeenLastCalledWith(
+ expect.objectContaining({ page: 1, pageSize: 20 }),
+ ),
+ );
+ expect(requestedPages).toEqual([1, 2, 1]);
+ expect(within(getTableBody()).getByText('topic-15')).toBeInTheDocument();
+ });
+
it('keeps the selected instance when rebuilding a topic without a broker
route', async () => {
const user = userEvent.setup();
const topic = { ...buildTopics(1)[0], instanceId: 'instance-a' };
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index bb08518bf..d08ab9884 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -197,6 +197,9 @@ const AclPageContent = ({
if (!mounted) return;
setRules(nextRules.items.map(normalizeRule));
setRuleTotal(nextRules.total);
+ if (nextRules.items.length === 0 && nextRules.total > 0 && rulePage >
1) {
+ setRulePage(Math.max(1, Math.ceil(nextRules.total / rulePageSize)));
+ }
})
.catch(() => {
if (mounted) message.error(t('common.fetchDataFailed'));
@@ -215,6 +218,9 @@ const AclPageContent = ({
if (mounted) {
setUsers(result.items.map(normalizeUser));
setUserTotal(result.total);
+ if (result.items.length === 0 && result.total > 0 && userPage > 1) {
+ setUserPage(Math.max(1, Math.ceil(result.total / userPageSize)));
+ }
}
})
.catch(() => {
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index bed410356..e286603ce 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -331,6 +331,9 @@ const ConsumerPageContent = ({
if (requestId === groupRequestIdRef.current) {
setGroups(result.items);
setTotalGroups(result.total);
+ if (result.items.length === 0 && result.total > 0 && page > 1) {
+ setPage(Math.max(1, Math.ceil(result.total / pageSize)));
+ }
}
})
.catch(() => {
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index c2860ddd0..c45d78188 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -382,6 +382,9 @@ const TopicPage = () => {
if (requestId === topicRequestIdRef.current) {
setTopics(result.items);
setTotalTopics(result.total);
+ if (result.items.length === 0 && result.total > 0 && tablePage >
1) {
+ setTablePage(Math.max(1, Math.ceil(result.total /
tablePageSize)));
+ }
}
})
.catch(() => {