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 c56006a9 feat: add Producer Group autocomplete (#1046)
c56006a9 is described below
commit c56006a9367cf90ab043697395d605895d5f3c56
Author: yx9o <[email protected]>
AuthorDate: Thu Aug 6 14:05:23 2026 +0800
feat: add Producer Group autocomplete (#1046)
---
.../cluster/client/ProducerConnectionService.java | 11 +++++
.../studio/cluster/client/ProducerController.java | 8 ++++
.../client/ProducerConnectionServiceTest.java | 15 +++++++
.../cluster/client/ProducerControllerTest.java | 13 ++++++
web/src/api/producer.test.ts | 11 ++++-
web/src/api/producer.ts | 6 +++
web/src/pages/studio/Producer.tsx | 28 +++++++++++-
web/src/pages/studio/__tests__/Producer.test.tsx | 50 +++++++++++++++++++---
8 files changed, 133 insertions(+), 9 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionService.java
index 646bc4fb..d13227ad 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionService.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.cluster.client;
+import org.apache.rocketmq.studio.common.domain.enums.ClientType;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -39,6 +40,16 @@ public class ProducerConnectionService {
.toList();
}
+ public List<String> listProducerGroups() {
+ return clientProvider.findConnections(null,
ClientType.Producer.name()).stream()
+ .map(ClientConnectionVO::getProducerGroup)
+ .filter(this::hasText)
+ .map(String::trim)
+ .distinct()
+ .sorted()
+ .toList();
+ }
+
private ProducerConnectionVO toProducerConnection(ClientConnectionVO
connection) {
return ProducerConnectionVO.builder()
.clientId(connection.getClientId())
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerController.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerController.java
index 9967f57d..26fd77fa 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ProducerController.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.cluster.client;
+import org.apache.rocketmq.studio.common.domain.Result;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
@@ -23,6 +24,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import java.util.List;
+
@RestController
@RequestMapping("/api/producer")
@RequiredArgsConstructor
@@ -30,6 +33,11 @@ public class ProducerController {
private final ProducerConnectionService producerConnectionService;
+ @GetMapping("/groups")
+ public Result<List<String>> listProducerGroups() {
+ return Result.ok(producerConnectionService.listProducerGroups());
+ }
+
@GetMapping("/connection")
public ProducerConnectionResultVO listConnections(
@RequestParam(required = false) String topic,
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionServiceTest.java
index 7bdbb473..f83a06f9 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerConnectionServiceTest.java
@@ -94,4 +94,19 @@ class ProducerConnectionServiceTest {
assertThat(result).isEmpty();
verify(clientProvider).findProducerConnections("order-topic",
"pg-order");
}
+
+ @Test
+ void listProducerGroupsShouldReturnSortedUniqueActiveGroups() {
+ when(clientProvider.findConnections(null, ClientType.Producer.name()))
+ .thenReturn(List.of(
+ ClientConnectionVO.builder().producerGroup("
pg-payment ").build(),
+
ClientConnectionVO.builder().producerGroup("pg-order").build(),
+
ClientConnectionVO.builder().producerGroup("pg-payment").build(),
+ ClientConnectionVO.builder().producerGroup("
").build(),
+ ClientConnectionVO.builder().build()));
+
+ assertThat(producerConnectionService.listProducerGroups())
+ .containsExactly("pg-order", "pg-payment");
+ verify(clientProvider).findConnections(null,
ClientType.Producer.name());
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerControllerTest.java
index 0341a9ec..d5a9ac92 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ProducerControllerTest.java
@@ -42,6 +42,19 @@ class ProducerControllerTest {
@MockBean
private ProducerConnectionService producerConnectionService;
+ @Test
+ void listProducerGroupsShouldReturnSuggestions() throws Exception {
+ when(producerConnectionService.listProducerGroups())
+ .thenReturn(List.of("pg-order", "pg-payment"));
+
+ mockMvc.perform(get("/api/producer/groups"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0]").value("pg-order"))
+ .andExpect(jsonPath("$.data[1]").value("pg-payment"));
+
+ verify(producerConnectionService).listProducerGroups();
+ }
+
@Test
void listConnectionsShouldReturnLegacyConnectionSetPayload() throws
Exception {
ProducerConnectionVO connection = ProducerConnectionVO.builder()
diff --git a/web/src/api/producer.test.ts b/web/src/api/producer.test.ts
index b5b3f31c..6f5b329b 100644
--- a/web/src/api/producer.test.ts
+++ b/web/src/api/producer.test.ts
@@ -18,7 +18,7 @@
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import client from './client';
-import { fetchTopicList, queryProducerConnection } from './producer';
+import { fetchProducerGroups, fetchTopicList, queryProducerConnection } from
'./producer';
const mock = new MockAdapter(client);
@@ -59,6 +59,15 @@ describe('Producer API', () => {
expect(result).toEqual([]);
});
+ it('fetches active producer group suggestions', async () => {
+ mock.onGet('/producer/groups').reply(200, {
+ code: 200,
+ data: ['pg-order', 'pg-payment'],
+ });
+
+ await expect(fetchProducerGroups()).resolves.toEqual(['pg-order',
'pg-payment']);
+ });
+
it('queries producer connections by topic and group', async () => {
const connections = [
{
diff --git a/web/src/api/producer.ts b/web/src/api/producer.ts
index 89d55fa1..10e05380 100644
--- a/web/src/api/producer.ts
+++ b/web/src/api/producer.ts
@@ -43,6 +43,12 @@ export async function fetchTopicList(): Promise<string[]> {
return topics.sort();
}
+/** Fetch active producer groups for query suggestions */
+export async function fetchProducerGroups(): Promise<string[]> {
+ const res = await client.get<{ data?: string[] }>('/producer/groups');
+ return res.data.data ?? [];
+}
+
/** Query producer connections by topic and producer group */
export async function queryProducerConnection(
topic: string,
diff --git a/web/src/pages/studio/Producer.tsx
b/web/src/pages/studio/Producer.tsx
index 5e1f1c9c..7a619005 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -16,10 +16,11 @@
*/
import { useEffect, useState } from 'react';
-import { Button, Form, Input, Select, Table, Card, App } from 'antd';
+import { App, AutoComplete, Button, Card, Form, Select, Table } from 'antd';
import { MagnifyingGlass } from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
import {
+ fetchProducerGroups,
fetchTopicList,
queryProducerConnection,
type ProducerConnection,
@@ -28,6 +29,7 @@ import {
const ProducerPage = () => {
const [form] = Form.useForm();
const [topicList, setTopicList] = useState<string[]>([]);
+ const [producerGroups, setProducerGroups] = useState<string[]>([]);
const [connectionList, setConnectionList] =
useState<ProducerConnection[]>([]);
const [loading, setLoading] = useState(false);
const { t } = useLang();
@@ -50,7 +52,21 @@ const ProducerPage = () => {
}
};
+ const loadProducerGroups = async () => {
+ try {
+ const groups = await fetchProducerGroups();
+ if (!cancelled) {
+ setProducerGroups(groups);
+ }
+ } catch {
+ if (!cancelled) {
+ setProducerGroups([]);
+ }
+ }
+ };
+
void loadTopics();
+ void loadProducerGroups();
return () => {
cancelled = true;
@@ -127,7 +143,15 @@ const ProducerPage = () => {
name="producerGroup"
rules={[{ required: true, whitespace: true, message:
t('producer.inputGroup') }]}
>
- <Input placeholder={t('producer.inputGroup')} style={{ width: 300
}} />
+ <AutoComplete
+ allowClear
+ placeholder={t('producer.inputGroup')}
+ style={{ width: 300 }}
+ options={producerGroups.map((group) => ({ value: group }))}
+ filterOption={(inputValue, option) =>
+ option?.value.toLowerCase().includes(inputValue.toLowerCase())
?? false
+ }
+ />
</Form.Item>
<Form.Item>
<Button
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx
b/web/src/pages/studio/__tests__/Producer.test.tsx
index 17e7f241..3efa1c04 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -21,9 +21,14 @@ import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
import ProducerPage from '../Producer';
-import { fetchTopicList, queryProducerConnection } from
'../../../api/producer';
+import {
+ fetchProducerGroups,
+ fetchTopicList,
+ queryProducerConnection,
+} from '../../../api/producer';
vi.mock('../../../api/producer', () => ({
+ fetchProducerGroups: vi.fn(),
fetchTopicList: vi.fn(),
queryProducerConnection: vi.fn(),
}));
@@ -56,6 +61,7 @@ describe('ProducerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchTopicList).mockResolvedValue(['order-events',
'payment-events']);
+ vi.mocked(fetchProducerGroups).mockResolvedValue(['pg-order',
'pg-payment']);
vi.mocked(queryProducerConnection).mockResolvedValue([]);
});
@@ -75,11 +81,22 @@ describe('ProducerPage', () => {
expect(fetchTopicList).toHaveBeenCalledTimes(1);
});
- await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getAllByRole('combobox')[0]);
await screen.findByRole('option', { name: 'order-events' });
expect(await screen.findByRole('option', { name: 'payment-events'
})).toBeInTheDocument();
});
+ it('suggests active producer groups while keeping free-form input', async ()
=> {
+ const user = userEvent.setup();
+ renderWithProviders(<ProducerPage />);
+
+ await waitFor(() => expect(fetchProducerGroups).toHaveBeenCalledTimes(1));
+ const groupInput = screen.getAllByRole('combobox')[1];
+ await user.type(groupInput, 'payment');
+
+ expect(await screen.findByRole('option', { name: 'pg-payment'
})).toBeInTheDocument();
+ });
+
it('queries producer connections with the required topic and group', async
() => {
const user = userEvent.setup();
vi.mocked(queryProducerConnection).mockResolvedValue([
@@ -93,12 +110,12 @@ describe('ProducerPage', () => {
renderWithProviders(<ProducerPage />);
await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
- const topicSelect = screen.getByRole('combobox');
+ const [topicSelect, groupInput] = screen.getAllByRole('combobox');
fireEvent.mouseDown(topicSelect.parentElement!);
await user.click(
await screen.findByText('order-events', { selector:
'.ant-select-item-option-content' }),
);
- await user.type(screen.getByRole('textbox'), 'order-producer');
+ await user.type(groupInput, 'order-producer');
await user.click(screen.getByRole('button', { name: /搜索/ }));
await waitFor(() => {
@@ -112,14 +129,35 @@ describe('ProducerPage', () => {
renderWithProviders(<ProducerPage />);
await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
- const topicSelect = screen.getByRole('combobox');
+ const [topicSelect] = screen.getAllByRole('combobox');
fireEvent.mouseDown(topicSelect.parentElement!);
await user.click(
await screen.findByText('order-events', { selector:
'.ant-select-item-option-content' }),
);
await user.click(screen.getByRole('button', { name: /搜索/ }));
- expect(await screen.findByText('请输入生产者组')).toBeInTheDocument();
+ expect(
+ await screen.findByText('请输入生产者组', { selector:
'.ant-form-item-explain-error' }),
+ ).toBeInTheDocument();
expect(queryProducerConnection).not.toHaveBeenCalled();
});
+
+ it('keeps manual producer group queries available when suggestions fail',
async () => {
+ vi.mocked(fetchProducerGroups).mockRejectedValue(new Error('broker
unavailable'));
+ const user = userEvent.setup();
+ renderWithProviders(<ProducerPage />);
+
+ await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
+ const [topicSelect, groupInput] = screen.getAllByRole('combobox');
+ fireEvent.mouseDown(topicSelect.parentElement!);
+ await user.click(
+ await screen.findByText('order-events', { selector:
'.ant-select-item-option-content' }),
+ );
+ await user.type(groupInput, 'manual-producer');
+ await user.click(screen.getByRole('button', { name: /搜索/ }));
+
+ await waitFor(() => {
+ expect(queryProducerConnection).toHaveBeenCalledWith('order-events',
'manual-producer');
+ });
+ });
});