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');
+    });
+  });
 });

Reply via email to