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;
