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 4c24628 feat: add Producer page (#475)
4c24628 is described below
commit 4c24628b67bab56e4a44609772a468d51a07f2c9
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 faba278..3959a7c 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 0000000..c87fa07
--- /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 0000000..80e76f5
--- /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 47ba454..b3d0930 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 0000000..241adff
--- /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;