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 5318e8d57 feat(consumer): refresh a single consumer group (#2521)
5318e8d57 is described below
commit 5318e8d57dee6bc0f52ea195a1421fde581170ff
Author: zhaohai <[email protected]>
AuthorDate: Sat Aug 22 16:00:00 2026 +0800
feat(consumer): refresh a single consumer group (#2521)
Adds a per-group refresh action so operators can re-read one consumer
group directly from the broker without reloading the whole list.
Backend:
- MetadataService.refreshConsumerGroup: resolves the group through the
provider's list path filtered to the requested name and returns the
latest ConsumerGroupVO (404 when the group no longer exists).
- ConsumerGroupController: new GET /api/groups/{name}/refresh endpoint.
Frontend:
- api/service: refreshConsumerGroup wrapper (mock returns the mock
group copy).
- Consumer page action column gains a 刷新 button that replaces the
matching row with the refreshed group and invalidates the cached
queue progress so the detail modal re-fetches it.
---
.../instance/group/ConsumerGroupController.java | 7 +++
.../studio/instance/topic/MetadataService.java | 20 +++++++++
.../studio/instance/topic/MetadataServiceTest.java | 22 ++++++++++
web/src/api/metadata.ts | 8 ++++
.../pages/instance/__tests__/ConsumerPage.test.tsx | 20 +++++++++
web/src/pages/instance/consumer.tsx | 50 +++++++++++++++++++++-
web/src/services/consumerService.ts | 12 ++++++
7 files changed, 137 insertions(+), 2 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
index d6d0733ca..751a734d3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
@@ -79,6 +79,13 @@ public class ConsumerGroupController {
request.getRetryQueueNums(), request.getRetryMaxTimes()));
}
+ @GetMapping("/{name}/refresh")
+ public Result<ConsumerGroupVO> refreshConsumerGroup(
+ @PathVariable String name,
+ @RequestParam(required = false) String instanceId) {
+ return Result.ok(metadataService.refreshConsumerGroup(instanceId,
name));
+ }
+
@GetMapping("/{name}/progress")
public Result<List<QueueProgressVO>> getGroupProgress(
@PathVariable String name,
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
index 17d492a34..ae77ed0e9 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
@@ -208,6 +208,26 @@ public class MetadataService {
return adminClient.getConsumerGroup(instanceId, groupName);
}
+ /**
+ * Re-reads a single consumer group so an operator can refresh one row
without reloading the
+ * whole group list. Implementation queries the provider's list path with
the group name as
+ * the search filter and then exact-matches the name among the returned
entries (no direct
+ * per-group lookup exists that works for every vendor).
+ *
+ * <p>Returns {@code null} when the group no longer exists: a missing
group is an empty
+ * business state, not an RPC error, so the endpoint responds 200 with
empty data and the
+ * frontend keeps the existing row unchanged.
+ */
+ public ConsumerGroupVO refreshConsumerGroup(String instanceId, String
name) {
+ instanceId = normalizeInstanceId(instanceId);
+ String groupName = requireName(name, "consumer group name");
+ return listConsumerGroups(instanceId, null, normalizeFilter(groupName))
+ .stream()
+ .filter(group -> groupName.equals(group.getName()))
+ .findFirst()
+ .orElse(null);
+ }
+
public List<QueueProgressVO> getGroupProgress(String name) {
return getGroupProgress(null, name);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
index 0812728e3..aa6e713ae 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
@@ -335,4 +335,26 @@ class MetadataServiceTest {
.hasMessage("pageSize must be between 1 and 100");
}
+ @Test
+ void refreshConsumerGroupShouldExactMatchWithinProviderSearchResults() {
+ ConsumerGroupVO similar = new ConsumerGroupVO();
+ similar.setName("cg-order-archive");
+ ConsumerGroupVO group = new ConsumerGroupVO();
+ group.setName("cg-order");
+ when(apacheProvider.listConsumerGroups("instance-a", "cg-order"))
+ .thenReturn(List.of(similar, group));
+
+ ConsumerGroupVO result =
metadataService.refreshConsumerGroup("instance-a", " cg-order ");
+
+ assertThat(result).isSameAs(group);
+ verify(apacheProvider).listConsumerGroups("instance-a", "cg-order");
+ }
+
+ @Test
+ void refreshConsumerGroupShouldReturnNullWhenGroupMissingInsteadOfError() {
+ when(apacheProvider.listConsumerGroups("instance-a",
"cg-gone")).thenReturn(List.of());
+
+ assertThat(metadataService.refreshConsumerGroup("instance-a",
"cg-gone")).isNull();
+ }
+
}
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index 662ea21ba..4096cd38d 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -251,6 +251,14 @@ export async function getConsumerGroup(name: string,
instanceId?: string) {
return res.data.data;
}
+export async function refreshConsumerGroup(name: string, instanceId?: string) {
+ const res = await client.get<{ data: ConsumerGroup | null }>(
+ `/groups/${encodeURIComponent(name)}/refresh`,
+ { params: instanceId ? { instanceId } : {} },
+ );
+ return res.data.data;
+}
+
export async function getConsumerProgress(name: string, instanceId?: string) {
const res = await client.get<{ data: QueueProgress[] }>(
`/groups/${encodeURIComponent(name)}/progress`,
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 82da18f4f..ccc83e89a 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(),
listConsumerGroupPage: vi.fn(),
+ refreshConsumerGroup: vi.fn(),
resetConsumerOffset: vi.fn(),
}));
const instanceServiceMocks = vi.hoisted(() => ({ listInstances: vi.fn() }));
@@ -121,6 +122,11 @@ describe('Consumer page', () => {
},
]);
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(groupPage([group]));
+ vi.mocked(consumerService.refreshConsumerGroup).mockResolvedValue({
+ ...group,
+ totalLag: 42,
+ delaySeconds: 7,
+ });
vi.mocked(consumerService.createConsumerGroup).mockImplementation(
async (data: Partial<ConsumerGroup>) =>
({
@@ -767,4 +773,18 @@ describe('Consumer page', () => {
expect(screen.getByRole('button', { name: /导入/ })).toBeDisabled();
expect(screen.getByRole('button', { name: '创建 Group' })).toBeDisabled();
});
+
+ it('refreshes a single consumer group row without reloading the list', async
() => {
+ const user = userEvent.setup();
+ renderWithProviders(<ConsumerPage />);
+
+ const row = await screen.findByRole('row', { name: /remote-cg/ });
+ expect(within(row).getByText('10')).toBeInTheDocument();
+ await user.click(within(row).getByRole('button', { name: /刷\s*新/ }));
+
+ await waitFor(() => {
+
expect(consumerService.refreshConsumerGroup).toHaveBeenCalledWith('remote-cg',
'instance-1');
+ });
+ expect(await within(row).findByText('42')).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 6001176a1..cff253685 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -56,7 +56,13 @@ import {
ArrowsClockwise,
SlidersHorizontal,
} from '@phosphor-icons/react';
-import { ImportOutlined, ExportOutlined, DeleteOutlined, SyncOutlined } from
'@ant-design/icons';
+import {
+ ImportOutlined,
+ ExportOutlined,
+ DeleteOutlined,
+ SyncOutlined,
+ ReloadOutlined,
+} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
@@ -81,6 +87,7 @@ import {
getConsumerStack,
getConsumerSubscriptions,
listConsumerGroupPage,
+ refreshConsumerGroup,
resetConsumerOffset,
getConsumerGroupSettings,
updateConsumerGroupSettings,
@@ -327,6 +334,33 @@ const ConsumerPageContent = ({
[progressByGroup, t, selectedInstanceId],
);
+ const [refreshingGroup, setRefreshingGroup] = useState<string | null>(null);
+
+ const handleRefreshGroup = async (record: ConsumerGroup) => {
+ if (refreshingGroup) return;
+ setRefreshingGroup(record.name);
+ try {
+ const refreshed = await refreshConsumerGroup(record.name,
selectedInstanceId || undefined);
+ if (!refreshed) {
+ // 组不存在(业务状态为空):保留原行数据,不弹错。
+ return;
+ }
+ setGroups((prev) => prev.map((group) => (group.name === record.name ?
refreshed : group)));
+ // Invalidate the cached progress so the detail modal re-fetches it on
next open.
+ setProgressByGroup((prev) => {
+ const next = { ...prev };
+ delete next[diagnosticCacheKey(selectedInstanceId, record.name)];
+ return next;
+ });
+ message.success(`消费组 ${record.name} 已刷新`);
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : '';
+ message.error(detail || `刷新消费组 ${record.name} 失败`);
+ } finally {
+ setRefreshingGroup(null);
+ }
+ };
+
/* ─── Filtered & sorted data ─── */
const filtered = useMemo(() => {
let data = groups;
@@ -683,7 +717,7 @@ const ConsumerPageContent = ({
{
title: '操作',
key: 'actions',
- width: 210,
+ width: 360,
render: (_: unknown, record: ConsumerGroup) => (
<Flex gap={6}>
<Button
@@ -697,6 +731,18 @@ const ConsumerPageContent = ({
>
配置
</Button>
+ <Button
+ size="small"
+ icon={<ReloadOutlined />}
+ loading={refreshingGroup === record.name}
+ disabled={refreshingGroup !== null && refreshingGroup !==
record.name}
+ onClick={(e) => {
+ e.stopPropagation();
+ void handleRefreshGroup(record);
+ }}
+ >
+ 刷新
+ </Button>
<Button
size="small"
icon={<Eye size={14} />}
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index db00f75a5..eef0bc779 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -125,6 +125,18 @@ export async function updateConsumerGroupSettings(
return metadataApi.updateConsumerGroupSettings(data);
}
+export async function refreshConsumerGroup(
+ name: string,
+ instanceId?: string,
+): Promise<ConsumerGroup | null> {
+ if (isMockMode()) {
+ const group = mockConsumerGroups.find((item) => item.name === name);
+ return group ? copyConsumerGroup(group) : null;
+ }
+ const data = await metadataApi.refreshConsumerGroup(name, instanceId);
+ return data ? normalizeConsumerGroup(data) : null;
+}
+
export async function getConsumerSubscriptions(
name: string,
instanceId?: string,