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

commit 210c3d81d36fead0a16bd461c49da98820c029a9
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:20:03 2026 +0800

    feat: add Producer page (#475)
    
    Add a page to query producer client connections by Topic and Producer Group.
---
 web/src/App.tsx                   |   2 +
 web/src/api/producer.test.ts      |  84 ++++++++++++++++++++++
 web/src/api/producer.ts           |  45 ++++++++++++
 web/src/i18n/translations.ts      |  16 +++--
 web/src/pages/studio/Producer.tsx | 148 ++++++++++++++++++++++++++++++++++++++
 5 files changed, 291 insertions(+), 4 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index faba2783..3959a7c9 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts';
 import AuditPage from './pages/ops/audit';
 import AiPage from './pages/ai';
 import SettingsPage from './pages/settings';
+import ProducerPage from './pages/studio/Producer';
 import OpsPage from './pages/studio/Ops';
 import LoginPage from './pages/login';
 
@@ -57,6 +58,7 @@ function App() {
         <Route path="ops/audit" element={<AuditPage />} />
         <Route path="ai" element={<AiPage />} />
         <Route path="settings" element={<SettingsPage />} />
+        <Route path="studio/producer" element={<ProducerPage />} />
         <Route path="studio/ops" element={<OpsPage />} />
         <Route path="*" element={<Navigate to="/" replace />} />
       </Route>
diff --git a/web/src/api/producer.test.ts b/web/src/api/producer.test.ts
new file mode 100644
index 00000000..c87fa070
--- /dev/null
+++ b/web/src/api/producer.test.ts
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { fetchTopicList, queryProducerConnection } from './producer';
+
+const mock = new MockAdapter(client);
+
+describe('Producer API', () => {
+  beforeEach(() => {
+    mock.reset();
+    vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+  });
+
+  afterEach(() => {
+    mock.reset();
+    vi.unstubAllGlobals();
+  });
+
+  it('fetches topic list sorted alphabetically', async () => {
+    mock.onGet('/topics').reply(200, {
+      topicList: ['order-events', 'user-signup', 'batch-process'],
+    });
+
+    const result = await fetchTopicList();
+    expect(result).toEqual(['batch-process', 'order-events', 'user-signup']);
+  });
+
+  it('handles empty topic list', async () => {
+    mock.onGet('/topics').reply(200, { topicList: [] });
+
+    const result = await fetchTopicList();
+    expect(result).toEqual([]);
+  });
+
+  it('queries producer connections by topic and group', async () => {
+    const connections = [
+      {
+        clientId: 'producer-1',
+        clientAddr: '192.168.1.10',
+        language: 'JAVA',
+        versionDesc: '5.1.0',
+      },
+      {
+        clientId: 'producer-2',
+        clientAddr: '192.168.1.11',
+        language: 'JAVA',
+        versionDesc: '5.1.0',
+      },
+    ];
+    mock.onGet('/producer/connection').reply((config) => {
+      expect(config.params.topic).toBe('order-events');
+      expect(config.params.producerGroup).toBe('order-producer');
+      return [200, { connectionSet: connections }];
+    });
+
+    const result = await queryProducerConnection('order-events', 
'order-producer');
+    expect(result).toHaveLength(2);
+    expect(result[0].clientId).toBe('producer-1');
+  });
+
+  it('handles empty producer connections', async () => {
+    mock.onGet('/producer/connection').reply(200, { connectionSet: [] });
+
+    const result = await queryProducerConnection('topic', 'group');
+    expect(result).toEqual([]);
+  });
+});
diff --git a/web/src/api/producer.ts b/web/src/api/producer.ts
new file mode 100644
index 00000000..80e76f58
--- /dev/null
+++ b/web/src/api/producer.ts
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import client from './client';
+
+// ─── Types ──────────────────────────────────────────────────────
+export interface ProducerConnection {
+  clientId: string;
+  clientAddr: string;
+  language: string;
+  versionDesc: string;
+}
+
+// ─── API ────────────────────────────────────────────────────────
+
+/** Fetch all topic names */
+export async function fetchTopicList(): Promise<string[]> {
+  const res = await client.get<{ topicList: string[] }>('/topics');
+  return (res.data?.topicList ?? []).sort();
+}
+
+/** Query producer connections by topic and group */
+export async function queryProducerConnection(
+  topic: string,
+  producerGroup: string,
+): Promise<ProducerConnection[]> {
+  const res = await client.get<{ connectionSet: ProducerConnection[] 
}>('/producer/connection', {
+    params: { topic, producerGroup },
+  });
+  return res.data?.connectionSet ?? [];
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 47ba4549..b3d0930a 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -628,6 +628,18 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'ssl.invalidCertFormat': { zh: '仅允许证书文件!', en: 'Only certificate files are 
allowed!' },
   'ssl.certRemoved': { zh: '证书文件已移除', en: 'Certificate file removed' },
 
+  // ─── Producer ───
+  'producer.title': { zh: '生产者连接', en: 'Producer Connection' },
+  'producer.language': { zh: '语言', en: 'Language' },
+  'producer.selectTopic': { zh: '请选择 Topic', en: 'Select a topic' },
+  'producer.inputGroup': { zh: '请输入生产者组', en: 'Input producer group' },
+  'producer.fetchTopicFailed': { zh: '获取 Topic 列表失败', en: 'Failed to fetch 
topic list' },
+  'producer.fetchConnectionFailed': {
+    zh: '获取生产者连接失败',
+    en: 'Failed to fetch producer connections',
+  },
+  'producer.noConnections': { zh: '暂无生产者连接', en: 'No producer connections 
found' },
+
   // ─── Namespace ───
   'ns.title': { zh: '命名空间管理', en: 'Namespace Management' },
   'ns.name': { zh: '命名空间', en: 'Namespace' },
@@ -768,10 +780,6 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'liteTopic.extendTtlFailed': { zh: 'TTL 延长失败', en: 'Failed to extend TTL' },
   'liteTopic.total': { zh: '共 {total} 条记录', en: 'Total {total} records' },
 
-  // ─── Producer ───
-  'producer.title': { zh: '生产者', en: 'Producer' },
-  'producer.group': { zh: '生产组', en: 'Producer Group' },
-
   // ─── Common (additional) ───
   'common.loading': { zh: '加载中', en: 'Loading' },
   'common.refresh': { zh: '刷新', en: 'Refresh' },
diff --git a/web/src/pages/studio/Producer.tsx 
b/web/src/pages/studio/Producer.tsx
new file mode 100644
index 00000000..241adffc
--- /dev/null
+++ b/web/src/pages/studio/Producer.tsx
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useState, useRef } from 'react';
+import { Button, Form, Input, Select, Table, Card, App } from 'antd';
+import { MagnifyingGlass } from '@phosphor-icons/react';
+import { useLang } from '../../i18n/LangContext';
+import {
+  fetchTopicList,
+  queryProducerConnection,
+  type ProducerConnection,
+} from '../../api/producer';
+
+const ProducerPage = () => {
+  const [form] = Form.useForm();
+  const [topicList, setTopicList] = useState<string[]>([]);
+  const [connectionList, setConnectionList] = 
useState<ProducerConnection[]>([]);
+  const [loading, setLoading] = useState(false);
+  const { t } = useLang();
+  const { message } = App.useApp();
+
+  // Load topic list on mount (once)
+  const initialized = useRef<boolean | null>(null);
+  if (initialized.current == null) {
+    initialized.current = true;
+    const loadTopics = async () => {
+      try {
+        const topics = await fetchTopicList();
+        setTopicList(topics);
+      } catch {
+        message.error(t('producer.fetchTopicFailed'));
+      }
+    };
+    loadTopics();
+  }
+
+  const onFinish = async (values: { selectedTopic: string; producerGroup: 
string }) => {
+    setLoading(true);
+    try {
+      const connections = await queryProducerConnection(values.selectedTopic, 
values.producerGroup);
+      setConnectionList(connections);
+      if (connections.length === 0) {
+        message.info(t('producer.noConnections'));
+      }
+    } catch {
+      message.error(t('producer.fetchConnectionFailed'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const columns = [
+    { title: 'Client ID', dataIndex: 'clientId', key: 'clientId', align: 
'center' as const },
+    {
+      title: t('common.address'),
+      dataIndex: 'clientAddr',
+      key: 'clientAddr',
+      align: 'center' as const,
+    },
+    {
+      title: t('producer.language'),
+      dataIndex: 'language',
+      key: 'language',
+      align: 'center' as const,
+    },
+    {
+      title: t('brokerCluster.version'),
+      dataIndex: 'versionDesc',
+      key: 'versionDesc',
+      align: 'center' as const,
+    },
+  ];
+
+  return (
+    <div style={{ padding: 0 }}>
+      <div
+        style={{
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          marginBottom: 20,
+        }}
+      >
+        <h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 
}}>{t('producer.title')}</h2>
+      </div>
+
+      <Card bordered={false} style={{ borderRadius: 8, boxShadow: '0 1px 6px 
rgba(0,0,0,0.04)' }}>
+        <Form form={form} layout="inline" onFinish={onFinish} style={{ 
marginBottom: 20 }}>
+          <Form.Item
+            label="TOPIC"
+            name="selectedTopic"
+            rules={[{ required: true, message: t('producer.selectTopic') }]}
+          >
+            <Select
+              showSearch
+              placeholder={t('producer.selectTopic')}
+              style={{ width: 300 }}
+              optionFilterProp="label"
+              options={topicList.map((topic) => ({ value: topic, label: topic 
}))}
+            />
+          </Form.Item>
+          <Form.Item
+            label="PRODUCER GROUP"
+            name="producerGroup"
+            rules={[{ required: true, message: t('producer.inputGroup') }]}
+          >
+            <Input style={{ width: 300 }} />
+          </Form.Item>
+          <Form.Item>
+            <Button
+              type="primary"
+              htmlType="submit"
+              loading={loading}
+              icon={<MagnifyingGlass size={14} />}
+            >
+              {t('common.search')}
+            </Button>
+          </Form.Item>
+        </Form>
+
+        <Table
+          dataSource={connectionList}
+          columns={columns}
+          rowKey="clientId"
+          pagination={false}
+          bordered
+          size="middle"
+        />
+      </Card>
+    </div>
+  );
+};
+
+export default ProducerPage;

Reply via email to