Compare commits

..

38 Commits

Author SHA1 Message Date
Crazylychee
a5138eb0d8 [ISSUE #317] Removed useless topic cache 2025-06-24 15:21:57 +08:00
Crazylychee
b43c7abe52 [ISSUE #321] Fix interface permission verification 2025-06-24 15:21:25 +08:00
Crazylychee
bfd0e26737 [ISSUE #319] Store the username in localStorage 2025-06-24 15:21:10 +08:00
Crazylychee
31d8086db3 [ISSUES#323]: fix Maven packaging error 2025-06-24 14:56:12 +08:00
Crazylychee
8564296440 [GSOC][RIP-78][ISSUES#308]: delete the old ui (#314) 2025-06-16 14:34:31 +08:00
Crazylychee
e81dceb6ae [ISSUES #315]: Add acl2.0 cluster support 2025-06-16 14:31:18 +08:00
Crazylychee
bc1a05d16c [GSOC][RIP-78][ISSUES#308] Add part of refactored backend files (#313) 2025-06-16 14:04:53 +08:00
Crazylychee
3cbff604e6 [GSOC][RIP-78][ISSUES#308] Add part of refactored front-end files (#312) 2025-06-16 13:51:18 +08:00
Crazylychee
bd94e8c4f5 [GSOC][RIP-78][ISSUES#308] Add part of refactored front-end files (#311) 2025-06-16 13:47:34 +08:00
Crazylychee
52545ccd23 [GSOC][RIP-78][ISSUES#308] Add part of refactored front-end files (#310) 2025-06-16 13:47:14 +08:00
Crazylychee
b75ace4804 [GSOC][RIP-78][ISSUES#308]: Add part of refactored front-end files (#309) 2025-06-16 13:46:41 +08:00
Crazylychee
eb51da6ca4 Merge branch 'refactor' of github.com:apache/rocketmq-dashboard into refactor (#307)
* pref: optimize the response speed of the query api

* pref: optimize the response speed of the query api (#273)

* Fixing and Adding Unit Tests (#266) (#278)

* fix: align top navigation bar styles #279

* fix code style

---------

Co-authored-by: icenfly <87740812+icenfly@users.noreply.github.com>
2025-06-12 19:57:07 +08:00
Crazylychee
c85aa2e2a9 fix: Solve the null pointer after a single refresh of the consumer #295 (#296) 2025-06-12 10:45:38 +08:00
Xu Yichi
a450594ace fix: Add consumer global refresh and fix the problem #290 (#291) 2025-04-15 09:49:34 +08:00
Xu Yichi
bbabd1cd0d [ISSUES #281 #274 #285 #287] Speeds up topic and consumer queries, adds caching, and fixes delay/dead-letter topic mix-up (#286)
* fix: Resolved issue of query failure under a large number of topics and consumers apache#281

* fix: Expand the message ID query time range to avoid query failure

* fix: Remove duplicates from topic queries, increase system topic recognition #287
2025-04-13 19:41:24 +08:00
Xu Yichi
e76185437f fix: align top navigation bar styles #279 (#280) 2025-04-01 09:55:33 +08:00
Xu Yichi
3d13e4e2b8 fix:Failed to find messages older than 3 days using message ID #274 (#275) 2025-03-31 10:45:24 +08:00
iamgd67
1aad0cda25 front js check is v5 'false' will be true fix (#269) 2025-03-14 10:50:31 +08:00
yuz10
e57d423268 remove rocketmq-namesrv dependency (#254) 2024-11-04 15:49:02 +08:00
dependabot[bot]
0d87486d7a Bump snakeyaml from 1.30 to 1.32 (#130)
---
updated-dependencies:
- dependency-name: org.yaml:snakeyaml
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-04 11:36:30 +08:00
yuz10
94d7a4e418 remove rocketmq-broker dependency (#249) 2024-11-04 11:35:39 +08:00
xx
3fbaa3ab92 fix: Duplicate message in the topic tab message list in the message menu. (#202)
Co-authored-by: xx <xx@123.com>
2024-11-04 11:35:17 +08:00
RongtongJin
e6d454301f [maven-release-plugin] prepare for next development iteration 2024-09-18 09:57:37 +08:00
RongtongJin
f5c09ac287 [maven-release-plugin] prepare release rocketmq-dashboard-2.0.0 2024-09-18 09:57:04 +08:00
Guyu
e97072a3b1 style: Remove unused imports for the checkstyle. (#232)
* fix: 5.x query message detail throw: Failed to query message by Id: xxx

* style: Remove unused imports for the checkstyle.

---------

Co-authored-by: yangzengc <yangzengc@ewan.cn>
2024-09-12 16:51:34 +08:00
Evan
6d360509c0 fix: 5.x query message detail throw: Failed to query message by Id: xxx (#231)
Co-authored-by: yangzengc <yangzengc@ewan.cn>
2024-09-07 19:59:38 +08:00
Akai
464f57adf8 Support retryMaxTimes filed set for consumer group (#229)
* fix:Fixed the issue that normal messages in version v4 are not showed

* feat:support retryMaxTimes args set

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
Co-authored-by: yuanziwei.akai <yuanziwei.akai@bytedance.com>
2024-08-27 20:30:00 +08:00
Akai
5d08d3b122 Support Unspecified Topic Add & Update & Query (#223)
* fix:Fixed the issue that normal messages in version v4 are not showed

* feat:support unspecified topic

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-07-24 10:57:04 +08:00
Akai
d9fc76d3a3 fix:Fixed the issue that normal messages in version v4 are not showed (#222)
Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-07-24 10:56:47 +08:00
Akai
2bc59db340 Supplement UserGuide for RocketMQ 5.0 (#208)
* Support UserGuide for new feature

* update userGuide md

* update userGuide md

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-06-13 15:22:38 +08:00
Akai
d58e13da95 Proxy Support And ConsumerGroup Enhancement (#207)
* Support dashboard v4-v5 switch And query for v5 topic

* Modify tag name

* Support proxy-module And Fix the problem of showing wrong consumerGroup-info

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-06-12 09:12:19 +08:00
Akai
e7cb315050 Support FIFO-Type SubGroup Add、Update and Query For V5 (#204)
* Support dashboard v4-v5 switch And query for v5 topic

* Modify tag name

* Support subGroup FIFO Type Query and Update

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-06-11 10:53:36 +08:00
Akai
21dc2acfdc Support dashboard v4-v5 switch And query for v5 topic (#198)
* Support dashboard v4-v5 switch And query for v5 topic

* Modify tag name

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-06-05 15:17:23 +08:00
guangdashao
823bce2b8b feat: add topic message type
add message type
2024-06-04 11:40:46 +08:00
Javen
2fb0fce0b1 perf: The new metrics of getTransferredTps for rocketmq5.x and the old metrics of getTransferedTps for rocketmq4.x (#197)
Co-authored-by: jinwei2 <jinwei2@enmonster.com>
2024-03-26 17:02:16 +08:00
Abhijeet Mishra
6456630324 [#148] Throwables.propagate in deprecated for making runtime exception more verbose (#160) 2023-04-19 20:33:17 +08:00
Abhijeet Mishra
a25ccd6337 5.1.0 rocketmq version update (#155)
* update rocketmq version to 5.1.0
2023-04-07 08:30:18 +08:00
Abhijeet Mishra
538d1c1c45 [ISSUE apache#149] updated lombok version in pom.xml because of this compilation was failing (#151) 2023-03-20 15:33:45 +08:00
1109 changed files with 31711 additions and 277063 deletions

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@
.project
.factorypath
.settings/
.vscode
htmlReport/

View File

@@ -63,6 +63,18 @@
* 根据消息主题和消息Id进行消息的查询
* 消息详情可以展示这条消息的详细信息,查看消息对应到具体消费组的消费情况(如果异常,可以查看具体的异常信息)。可以向指定的消费组重发消息。
## RocketMQ-V5.0 仪表盘
* 版本切换
* RocketMQ右上角可切换不同版本用户可以自主选择 RocketMQ-5.x 或 RocketMQ-4.x 版本
* 主题页面
* 支持延迟/顺序/事务消息的筛选
* 支持延迟/顺序/事物/普通等多种消息类型主题的新增与更新
* 消费页面
* 支持顺序消费类型订阅组的过滤
* 提供顺序消费类型订阅组的新增与更新如果需要开启顺序消费FIFO类型的订阅组一定需要打开consumeOrderlyEnable选项
* 代理页面RocketMQ 5.0新增)
* 支持代理节点的新增与查询
* 支持代理节点地址配置在application.yml中可对proxyAddr和proxyAddrs属性进行预配置
## HTTPS 方式访问Dashboard
* HTTPS功能实际上是使用SpringBoot提供的配置功能即可完成首先需要有一个SSL KeyStore来存放服务端证书可以使用本工程所提供的测试密钥库:

View File

@@ -64,6 +64,18 @@
* look over this message's detail info.you can see the message's consume state(each group has one line),show the exception message if has exception.
you can send this message to the group you selected
## RocketMQ-V5.0 dashboard
* Version switching
* RocketMQ can switch between different versions in the upper right corner, and users can freely choose between RocketMQ-5.X or RocketMQ-4.X versions
* Theme page
* Support filtering of delayed/sequential/transaction messages
* Support the addition and update of multiple message types such as delay, sequence, object, and ordinary themes
* Consumption page
* Support filtering of subscription groups for fifo consumption types
* Provide the addition and update of subscription groups for sequential consumption types. If fifo consumption needs to be enabled, FIFO type subscription groups must have the consumeOrderlyEnable option enabled
* Proxy page (Added in RocketMQ 5.0)
* Support for adding and querying proxy nodes
* Support proxy node address configuration: ProxyAddr and proxyAddrs properties can be pre configured in application.yml
## Access Dashboard with HTTPS
* SpringBoot itself has provided the SSL configuration. You can use the project test Keystore:resources/rmqcngkeystore.jks. The store is generated with the following unix keytool commands:

70
frontend-new/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

17534
frontend-new/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
frontend-new/package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "frontend-new",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"antd": "^5.25.1",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"framer-motion": "^12.16.0",
"http-proxy-middleware": "^3.0.5",
"i18next": "^25.1.3",
"moment": "^2.30.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.0",
"react-scripts": "5.0.1",
"react-toastify": "^11.0.5",
"redux": "^5.0.1",
"typescript": "^4.8.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "cross-env PORT=3003 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"cross-env": "^7.0.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -13,4 +13,23 @@
~ 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.
-->
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="" />
<meta name="keywords" content="" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>RocketMQ Dashboard</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<!-- React App will mount here -->
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

17
frontend-new/src/App.css Normal file
View File

@@ -0,0 +1,17 @@
/*
* 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.
*/

38
frontend-new/src/App.jsx Normal file
View File

@@ -0,0 +1,38 @@
/*
* 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 React from 'react';
import AppRouter from './router'; // 你 router/index.jsx 导出的组件
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import {ConfigProvider} from "antd";
import {useTheme} from "./store/context/ThemeContext";
function App() {
const {currentTheme} = useTheme();
return (
<>
<ConfigProvider theme={currentTheme}>
<ToastContainer />
<AppRouter />
</ConfigProvider>
</>
);
}
export default App;

View File

@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render, screen } from '@testing-library/react';
import App from './App';

View File

@@ -0,0 +1,942 @@
/*
* 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.
*/
const appConfig = {
apiBaseUrl: 'http://localhost:8082' // 请替换为你的实际 API Base URL
};
let _redirectHandler = null;
const remoteApi = {
setRedirectHandler: (handler) => {
_redirectHandler = handler;
},
buildUrl: (endpoint) => {
if (endpoint.charAt(0) === '/') {
endpoint = endpoint.substring(1);
}
return `${appConfig.apiBaseUrl}/${endpoint}`;
},
_fetch: async (url, options) => {
try {
// 在 options 中添加 credentials: 'include'
const response = await fetch(url, {
...options, // 保留原有的 options
credentials: 'include' // 关键改动:允许发送 Cookie
});
// 检查响应是否被重定向,并且最终的 URL 包含了登录页的路径。
// 这是会话过期或需要认证时后端重定向到登录页的常见模式。
// 注意fetch 会自动跟随 GET 请求的 3xx 重定向,所以我们检查的是 response.redirected。
if (response.redirected) {
if (_redirectHandler) {
_redirectHandler(); // 如果设置了重定向处理函数,则调用它
}
return { __isRedirectHandled: true };
}
return response;
} catch (error) {
console.error("Fetch 请求出错:", error);
throw error;
}
},
queryTopic: async (skipSysProcess) => {
try {
const params = new URLSearchParams();
if (skipSysProcess) {
params.append('skipSysProcess', 'true');
}
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/list.query?${params.toString()}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching topic list:", error);
}
},
listUsers: async (brokerAddress) => {
const params = new URLSearchParams();
if (brokerAddress) params.append('brokerAddress', brokerAddress);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/acl/listUsers?${params.toString()}`));
return await response.json();
},
createUser: async (brokerAddress, userInfo) => {
const response = await remoteApi._fetch(remoteApi.buildUrl('/acl/createUser'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brokerAddress, userInfo })
});
return await response.json(); // 返回字符串消息
},
updateUser: async (brokerAddress, userInfo) => {
const response = await remoteApi._fetch(remoteApi.buildUrl('/acl/updateUser'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brokerAddress, userInfo })
});
return await response.json();
},
deleteUser: async (brokerAddress, username) => {
const params = new URLSearchParams();
if (brokerAddress) params.append('brokerAddress', brokerAddress);
params.append('username', username);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/acl/deleteUser?${params.toString()}`), {
method: 'DELETE'
});
return await response.json();
},
// --- ACL 权限相关 API ---
listAcls: async (brokerAddress, searchParam) => {
const params = new URLSearchParams();
if (brokerAddress) params.append('brokerAddress', brokerAddress);
if (searchParam) params.append('searchParam', searchParam);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/acl/listAcls?${params.toString()}`));
return await response.json();
},
createAcl: async (brokerAddress, subject, policies) => {
const response = await remoteApi._fetch(remoteApi.buildUrl('/acl/createAcl'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brokerAddress, subject, policies })
});
return await response.json();
},
updateAcl: async (brokerAddress, subject, policies) => {
const response = await remoteApi._fetch(remoteApi.buildUrl('/acl/updateAcl'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brokerAddress, subject, policies })
});
return await response.json();
},
deleteAcl: async (brokerAddress, subject, resource) => {
const params = new URLSearchParams();
if (brokerAddress) params.append('brokerAddress', brokerAddress);
params.append('subject', subject);
if (resource) params.append('resource', resource);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/acl/deleteAcl?${params.toString()}`), {
method: 'DELETE'
});
return await response.json();
},
queryMessageByMessageId: async (msgId, topic, callback) => {
try {
const params = new URLSearchParams();
params.append('msgId', msgId);
params.append('topic', topic);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/messageTrace/viewMessage.query?${params.toString()}`));
const data = await response.json();
return data
} catch (error) {
console.error("Error querying message by ID:", error);
callback({ status: 1, errMsg: "Failed to query message by ID" });
}
},
queryMessageTraceByMessageId: async (msgId, traceTopic, callback) => {
try {
const params = new URLSearchParams();
params.append('msgId', msgId);
params.append('traceTopic', traceTopic);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/messageTrace/viewMessageTraceGraph.query?${params.toString()}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error querying message trace:", error);
}
},
queryDlqMessageByConsumerGroup: async (consumerGroup, beginTime, endTime, pageNum, pageSize, taskId) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/dlqMessage/queryDlqMessageByConsumerGroup.query"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
topic: `%DLQ%${consumerGroup}`,
begin: beginTime,
end: endTime,
pageNum: pageNum,
pageSize: pageSize,
taskId: taskId,
}),
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error querying DLQ messages by consumer group:", error);
return { status: 1, errMsg: "Failed to query DLQ messages by consumer group" };
}
},
resendDlqMessage: async (msgId, consumerGroup, topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/message/consumeMessageDirectly.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
params: {
msgId: msgId,
consumerGroup: consumerGroup,
topic: topic
},
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error resending DLQ message:", error);
return { status: 1, errMsg: "Failed to resend DLQ message" };
}
},
exportDlqMessage: async (msgId, consumerGroup) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/dlqMessage/exportDlqMessage.do?msgId=${msgId}&consumerGroup=${consumerGroup}`));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 假设服务器总是返回 JSON
const data = await response.json();
// 1. 打开一个新的空白窗口
const newWindow = window.open('', '_blank');
if (!newWindow) {
// 浏览器可能会阻止弹窗,需要用户允许
return { status: 1, errMsg: "Failed to open new window. Please allow pop-ups for this site." };
}
// 2. 将 JSON 数据格式化后写入新窗口
newWindow.document.write('<html><head><title>DLQ 导出内容</title></head><body>');
newWindow.document.write('<h1>DLQ 导出 JSON 内容</h1>');
// 使用 <pre> 标签保持格式,并使用 JSON.stringify 格式化 JSON 以便于阅读
newWindow.document.write('<pre>' + JSON.stringify(data, null, 2) + '</pre>');
newWindow.document.write('</body></html>');
newWindow.document.close(); // 关闭文档流,确保内容显示
return { status: 0, msg: "导出请求成功,内容已在新页面显示" };
} catch (error) {
console.error("Error exporting DLQ message:", error);
return { status: 1, errMsg: "Failed to export DLQ message: " + error.message };
}
},
batchResendDlqMessage: async (messages) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/dlqMessage/batchResendDlqMessage.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(messages),
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error batch resending DLQ messages:", error);
return { status: 1, errMsg: "Failed to batch resend DLQ messages" };
}
},
/**
* Queries messages by topic with pagination.
* @param {string} topic The topic to query.
* @param {number} begin Timestamp in milliseconds for the start time.
* @param {number} end Timestamp in milliseconds for the end time.
* @param {number} pageNum The current page number (1-based).
* @param {number} pageSize The number of items per page.
* @param {string} taskId Optional task ID for continuous queries.
* @returns {Promise<Object>} The API response.
*/
queryMessagePageByTopic: async (topic, begin, end, pageNum, pageSize, taskId) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/message/queryMessagePageByTopic.query"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
topic: topic,
begin: begin,
end: end,
pageNum: pageNum,
pageSize: pageSize,
taskId: taskId
})
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching message page by topic:", error);
}
},
/**
* Queries messages by topic and key.
* @param {string} topic The topic to query.
* @param {string} key The message key to query.
* @returns {Promise<Object>} The API response.
*/
queryMessageByTopicAndKey: async (topic, key) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/message/queryMessageByTopicAndKey.query?topic=${topic}&key=${key}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching message by topic and key:", error);
}
},
/**
* Views a message by its message ID and topic.
* @param {string} msgId The message ID.
* @param {string} topic The topic of the message.
* @returns {Promise<Object>} The API response.
*/
viewMessage: async (msgId, topic) => {
try {
const encodedTopic = encodeURIComponent(topic);
const url = remoteApi.buildUrl(
`/message/viewMessage.query?msgId=${msgId}&topic=${encodedTopic}`
);
const response = await remoteApi._fetch(url);
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching message by message ID:", error);
}
},
/**
* Resends a message directly to a consumer group.
* @param {string} msgId The message ID.
* @param {string} consumerGroup The consumer group to resend to.
* @param {string} topic The topic of the message.
* @returns {Promise<Object>} The API response.
*/
resendMessageDirectly: async (msgId, consumerGroup, topic) => {
topic = encodeURIComponent(topic)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/message/consumeMessageDirectly.do?msgId=${msgId}&consumerGroup=${consumerGroup}&topic=${topic}`), {
method: 'POST',
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error resending message directly:", error);
}
},
queryProducerConnection: async (topic, producerGroup, callback) => {
topic = encodeURIComponent(topic)
producerGroup = encodeURIComponent(producerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/producer/producerConnection.query?topic=${topic}&producerGroup=${producerGroup}`));
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error fetching producer connection list:", error);
callback({ status: 1, errMsg: "Failed to fetch producer connection list" }); // Simulate error response
}
},
queryConsumerGroupList: async (skipSysGroup = false) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/groupList.query?skipSysGroup=${skipSysGroup}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching consumer group list:", error);
return { status: 1, errMsg: "Failed to fetch consumer group list" };
}
},
refreshConsumerGroup: async (consumerGroup) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/group.refresh?consumerGroup=${consumerGroup}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error refreshing consumer group ${consumerGroup}:`, error);
return { status: 1, errMsg: `Failed to refresh consumer group ${consumerGroup}` };
}
},
refreshAllConsumerGroup: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/group.refresh.all"));
const data = await response.json();
return data;
} catch (error) {
console.error("Error refreshing all consumer groups:", error);
return { status: 1, errMsg: "Failed to refresh all consumer groups" };
}
},
queryConsumerMonitorConfig: async (consumeGroupName) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/monitor/consumerMonitorConfigByGroupName.query?consumeGroupName=${consumeGroupName}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching monitor config for ${consumeGroupName}:`, error);
return { status: 1, errMsg: `Failed to fetch monitor config for ${consumeGroupName}` };
}
},
createOrUpdateConsumerMonitor: async (consumeGroupName, minCount, maxDiffTotal) => {
consumeGroupName = encodeURIComponent(consumeGroupName)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/monitor/createOrUpdateConsumerMonitor.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ consumeGroupName, minCount, maxDiffTotal })
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error creating or updating consumer monitor:", error);
return { status: 1, errMsg: "Failed to create or update consumer monitor" };
}
},
fetchBrokerNameList: async (consumerGroup) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/fetchBrokerNameList.query?consumerGroup=${consumerGroup}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching broker name list for ${consumerGroup}:`, error);
return { status: 1, errMsg: `Failed to fetch broker name list for ${consumerGroup}` };
}
},
deleteConsumerGroup: async (groupName, brokerNameList) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/deleteSubGroup.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ groupName, brokerNameList })
});
const data = await response.json();
return data;
} catch (error) {
console.error(`Error deleting consumer group ${groupName}:`, error);
return { status: 1, errMsg: `Failed to delete consumer group ${groupName}` };
}
},
queryConsumerConfig: async (consumerGroup) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/examineSubscriptionGroupConfig.query?consumerGroup=${consumerGroup}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching consumer config for ${consumerGroup}:`, error);
return { status: 1, errMsg: `Failed to fetch consumer config for ${consumerGroup}` };
}
},
createOrUpdateConsumer: async (consumerRequest) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/createOrUpdate.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(consumerRequest)
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error creating or updating consumer:", error);
return { status: 1, errMsg: "Failed to create or update consumer" };
}
},
queryTopicByConsumer: async (consumerGroup, address) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/queryTopicByConsumer.query?consumerGroup=${consumerGroup}&address=${address}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching topics for consumer group ${consumerGroup}:`, error);
return { status: 1, errMsg: `Failed to fetch topics for consumer group ${consumerGroup}` };
}
},
queryConsumerConnection: async (consumerGroup, address) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/consumerConnection.query?consumerGroup=${consumerGroup}&address=${address}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching consumer connections for ${consumerGroup}:`, error);
return { status: 1, errMsg: `Failed to fetch consumer connections for ${consumerGroup}` };
}
},
queryConsumerRunningInfo: async (consumerGroup, clientId, jstack = false) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/consumerRunningInfo.query?consumerGroup=${consumerGroup}&clientId=${clientId}&jstack=${jstack}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching running info for client ${clientId} in group ${consumerGroup}:`, error);
return { status: 1, errMsg: `Failed to fetch running info for client ${clientId} in group ${consumerGroup}` };
}
},
queryTopicList: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/topic/list.queryTopicType"));
return await response.json();
} catch (error) {
console.error("Error fetching topic list:", error);
return { status: 1, errMsg: "Failed to fetch topic list" };
}
},
deleteTopic: async (topic) => {
try {
const url = remoteApi.buildUrl(`/topic/deleteTopic.do?topic=${encodeURIComponent(topic)}`);
const response = await remoteApi._fetch(url, {
method: 'POST', // 仍然使用 POST 方法,但参数在 URL 中
headers: {
'Content-Type': 'application/json', // 可以根据你的后端需求决定是否需要这个 header
},
// body: JSON.stringify({ topic }) // 移除 body
});
return await response.json();
} catch (error) {
console.error("Error deleting topic:", error);
return { status: 1, errMsg: "Failed to delete topic" };
}
},
getTopicStats: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/stats.query?topic=${topic}`));
return await response.json();
} catch (error) {
console.error("Error fetching topic stats:", error);
return { status: 1, errMsg: "Failed to fetch topic stats" };
}
},
getTopicRoute: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/route.query?topic=${topic}`));
return await response.json();
} catch (error) {
console.error("Error fetching topic route:", error);
return { status: 1, errMsg: "Failed to fetch topic route" };
}
},
getTopicConsumers: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/queryConsumerByTopic.query?topic=${topic}`));
return await response.json();
} catch (error) {
console.error("Error fetching topic consumers:", error);
return { status: 1, errMsg: "Failed to fetch topic consumers" };
}
},
getTopicConsumerGroups: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/queryTopicConsumerInfo.query?topic=${topic}`));
return await response.json();
} catch (error) {
console.error("Error fetching consumer groups:", error);
return { status: 1, errMsg: "Failed to fetch consumer groups" };
}
},
getTopicConfig: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/examineTopicConfig.query?topic=${topic}`));
return await response.json();
} catch (error) {
console.error("Error fetching topic config:", error);
return { status: 1, errMsg: "Failed to fetch topic config" };
}
},
getClusterList: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/cluster/list.query"));
return await response.json();
} catch (error) {
console.error("Error fetching cluster list:", error);
return { status: 1, errMsg: "Failed to fetch cluster list" };
}
},
createOrUpdateTopic: async (topicData) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/topic/createOrUpdate.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(topicData)
});
return await response.json();
} catch (error) {
console.error("Error creating/updating topic:", error);
return { status: 1, errMsg: "Failed to create/update topic" };
}
},
resetConsumerOffset: async (data) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/resetOffset.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
return await response.json();
} catch (error) {
console.error("Error resetting consumer offset:", error);
return { status: 1, errMsg: "Failed to reset consumer offset" };
}
},
skipMessageAccumulate: async (data) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/skipAccumulate.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
return await response.json();
} catch (error) {
console.error("Error skipping message accumulate:", error);
return { status: 1, errMsg: "Failed to skip message accumulate" };
}
},
sendTopicMessage: async (messageData) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/topic/sendTopicMessage.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(messageData)
});
return await response.json();
} catch (error) {
console.error("Error sending topic message:", error);
return { status: 1, errMsg: "Failed to send topic message" };
}
},
deleteTopicByBroker: async (brokerName, topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/topic/deleteTopicByBroker.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ brokerName, topic })
});
return await response.json();
} catch (error) {
console.error("Error deleting topic by broker:", error);
return { status: 1, errMsg: "Failed to delete topic by broker" };
}
},
// New API methods for Ops page
queryOpsHomePage: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/ops/homePage.query"));
return await response.json();
} catch (error) {
console.error("Error fetching ops home page data:", error);
return { status: 1, errMsg: "Failed to fetch ops home page data" };
}
},
updateNameSvrAddr: async (nameSvrAddr) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/ops/updateNameSvrAddr.do?nameSvrAddrList=${encodeURIComponent(nameSvrAddr)}`), {
method: 'POST',
});
return await response.json();
} catch (error) {
console.error("Error updating NameServer address:", error);
return { status: 1, errMsg: "Failed to update NameServer address" };
}
},
addNameSvrAddr: async (newNamesrvAddr) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/ops/addNameSvrAddr.do?newNamesrvAddr=${encodeURIComponent(newNamesrvAddr)}`), {
method: 'POST',
});
return await response.json();
} catch (error) {
console.error("Error adding NameServer address:", error);
return { status: 1, errMsg: "Failed to add NameServer address" };
}
},
updateIsVIPChannel: async (useVIPChannel) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/ops/updateIsVIPChannel.do?useVIPChannel=${useVIPChannel}`), {
method: 'POST',
});
return await response.json();
} catch (error) {
console.error("Error updating VIP Channel status:", error);
return { status: 1, errMsg: "Failed to update VIP Channel status" };
}
},
updateUseTLS: async (useTLS) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/ops/updateUseTLS.do?useTLS=${useTLS}`), {
method: 'POST',
});
return await response.json();
} catch (error) {
console.error("Error updating TLS status:", error);
return { status: 1, errMsg: "Failed to update TLS status" };
}
},
queryClusterList: async (callback) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/cluster/list.query"));
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error fetching cluster list:", error);
callback({ status: 1, errMsg: "Failed to fetch cluster list" });
}
},
queryBrokerHisData: async (date, callback) => {
try {
const url = new URL(remoteApi.buildUrl('/dashboard/broker.query'));
url.searchParams.append('date', date);
const response = await remoteApi._fetch(url.toString(), { signal: AbortSignal.timeout(15000) }); // 15s timeout
const data = await response.json();
callback(data);
} catch (error) {
if (error.name === 'TimeoutError') {
console.error("Broker history data request timed out:", error);
callback({ status: 1, errMsg: "Request timed out for broker history data" });
} else {
console.error("Error fetching broker history data:", error);
callback({ status: 1, errMsg: "Failed to fetch broker history data" });
}
}
},
queryTopicHisData: async (date, topicName, callback) => {
try {
const url = new URL(remoteApi.buildUrl('/dashboard/topic.query'));
url.searchParams.append('date', date);
url.searchParams.append('topicName', topicName);
const response = await remoteApi._fetch(url.toString(), { signal: AbortSignal.timeout(15000) }); // 15s timeout
const data = await response.json();
callback(data);
} catch (error) {
if (error.name === 'TimeoutError') {
console.error("Topic history data request timed out:", error);
callback({ status: 1, errMsg: "Request timed out for topic history data" });
} else {
console.error("Error fetching topic history data:", error);
callback({ status: 1, errMsg: "Failed to fetch topic history data" });
}
}
},
queryTopicCurrentData: async (callback) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl('/dashboard/topicCurrent.query'), { signal: AbortSignal.timeout(15000) }); // 15s timeout
const data = await response.json();
callback(data);
} catch (error) {
if (error.name === 'TimeoutError') {
console.error("Topic current data request timed out:", error);
callback({ status: 1, errMsg: "Request timed out for topic current data" });
} else {
console.error("Error fetching topic current data:", error);
callback({ status: 1, errMsg: "Failed to fetch topic current data" });
}
}
},
queryBrokerConfig: async (brokerAddr, callback) => {
try {
const url = new URL(remoteApi.buildUrl('/cluster/brokerConfig.query'));
url.searchParams.append('brokerAddr', brokerAddr);
const response = await remoteApi._fetch(url.toString());
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error fetching broker config:", error);
callback({ status: 1, errMsg: "Failed to fetch broker config" });
}
},
/**
* 查询 Proxy 首页信息,包括地址列表和当前 Proxy 地址
*/
queryProxyHomePage: async (callback) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/proxy/homePage.query"));
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error fetching proxy home page:", error);
callback({ status: 1, errMsg: "Failed to fetch proxy home page" });
}
},
/**
* 添加新的 Proxy 地址
*/
addProxyAddr: async (newProxyAddr, callback) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/proxy/addProxyAddr.do"), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ newProxyAddr }).toString()
});
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error adding proxy address:", error);
callback({ status: 1, errMsg: "Failed to add proxy address" });
}
},
login: async (username, password) => {
try {
// 2. 发送请求,注意 body 可以是空字符串或 null或者直接省略 body
// 这里使用 GET 方法,因为参数在 URL 上
const response = await remoteApi._fetch(remoteApi.buildUrl("/login/login.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded' // 这个 header 可能不再需要,或者需要调整
},
body: new URLSearchParams({
username: username, // 假设 username 是变量名
password: password // 假设 password 是变量名
}).toString()
});
// 3. 处理响应
const data = await response.json();
return data;
} catch (error) {
console.error("Error logging in:", error);
return { status: 1, errMsg: "Failed to log in" };
}
},
logout: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/login/logout.do"),{
method: 'POST'
});
return await response.json()
}catch (error) {
console.error("Error logging out:", error);
return { status: 1, errMsg: "Failed to log out" };
}
}
};
const tools = {
// 适配新的数据结构
dashboardRefreshTime: 5000,
generateBrokerMap: (brokerServer, clusterAddrTable, brokerAddrTable) => {
const clusterMap = {}; // 最终存储 { clusterName: [brokerInstance1, brokerInstance2, ...] }
Object.entries(clusterAddrTable).forEach(([clusterName, brokerNamesInCluster]) => {
clusterMap[clusterName] = []; // 初始化当前集群的 broker 列表
brokerNamesInCluster.forEach(brokerName => {
// 从 brokerAddrTable 获取当前 brokerName 下的所有 brokerId 及其地址
const brokerAddrs = brokerAddrTable[brokerName]?.brokerAddrs; // 确保 brokerAddrs 存在
if (brokerAddrs) {
Object.entries(brokerAddrs).forEach(([brokerIdStr, address]) => {
const brokerId = parseInt(brokerIdStr); // brokerId 是字符串,转为数字
// 从 brokerServer 获取当前 brokerName 和 brokerId 对应的详细信息
const detail = brokerServer[brokerName]?.[brokerIdStr];
if (detail) {
clusterMap[clusterName].push({
brokerName: brokerName,
brokerId: brokerId,
address: address,
...detail,
detail: detail,
brokerConfig: {},
});
} else {
console.warn(`No detail found for broker: ${brokerName} with ID: ${brokerIdStr}`);
}
});
} else {
console.warn(`No addresses found for brokerName: ${brokerName} in brokerAddrTable`);
}
});
});
return clusterMap;
}
};
export { remoteApi, tools };

View File

@@ -0,0 +1,108 @@
/*
* 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.
*/
export const defaultTheme = {
token: {
colorPrimary: '#0cb5fb', // 主题色
borderRadius: 1.5, // 组件圆角
},
components: {
Button: {
colorPrimary: '#1c324a', // 普通按钮主题色
},
Layout: {
headerBg: '#1c324a', // 设置 Header 的背景色为 #1c324a
headerColor: '#ffffff', // 设置 Header 内文本颜色为白色
backgroundColor: '#ffffff', // 设置 Layout 的背景色为白色
colorBgLayout: '#f9fcfe',
},
Menu: {
darkItemBg: '#1c324a',
horizontalItemSelectedBg: '#0cb5fb',
itemSelectedColor: '#ffffff',
itemColor: '#ffffff',
colorText: 'rgba(255, 255, 255, 0.88)', // Adjust for dark theme menu
activeBarBorderWidth: 0,
},
Drawer: {
colorBgElevated: '#1c324a', // Drawer 背景色
},
},
};
export const pinkTheme = {
token: {
colorPrimary: '#FF69B4', // 热粉色
borderRadius: 1.5,
},
components: {
Button: {
colorPrimary: '#FFC0CB', // 深粉色
},
Layout: {
headerBg: '#FFC0CB', // 粉色
headerColor: '#000000', // 黑色文本
backgroundColor: '#F8F8FF', // 幽灵白
colorBgLayout: '#faf4f4',
},
Menu: {
darkItemBg: '#FFC0CB', // 粉色
horizontalItemSelectedBg: '#FF69B4',
itemSelectedColor: '#ffffff',
itemColor: '#000000', // 黑色文本
colorText: 'rgba(0, 0, 0, 0.88)',
activeBarBorderWidth: 0,
},
Drawer: {
colorBgElevated: '#FFC0CB', // Drawer 背景色
},
},
};
export const greenTheme = {
token: {
colorPrimary: '#52c41a', // 绿色
borderRadius: 1.5,
},
components: {
Button: {
colorPrimary: '#7cb305', // 橄榄绿
},
Layout: {
headerBg: '#3f673f', // 深绿色
headerColor: '#ffffff', // 白色文本
backgroundColor: '#f6ffed',
colorBgLayout: '#ebf8eb',
},
Menu: {
darkItemBg: '#3f673f', // 深绿色
horizontalItemSelectedBg: '#52c41a',
itemSelectedColor: '#ffffff',
itemColor: '#ffffff',
colorText: 'rgba(255, 255, 255, 0.88)',
activeBarBorderWidth: 0,
},
Drawer: {
colorBgElevated: '#3f673f', // Drawer 背景色
},
},
};
export const themes = {
default: defaultTheme,
pink: pinkTheme,
green: greenTheme,
};

View File

@@ -0,0 +1,73 @@
/*
* 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 React from 'react';
import { Form, Input, Typography, Modal } from 'antd';
import moment from 'moment';
import { useLanguage } from '../i18n/LanguageContext'; // 根据实际路径调整
const { Text } = Typography;
const DlqMessageDetailViewDialog = ({ ngDialogData }) => {
const { t } = useLanguage();
const messageView = ngDialogData?.messageView || {};
return (
<div style={{ padding: '20px' }}>
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
<Form.Item label="Message ID:">
<Text strong>{messageView.msgId}</Text>
</Form.Item>
<Form.Item label="Topic:">
<Text strong>{messageView.topic}</Text>
</Form.Item>
<Form.Item label="Properties:">
<Input.TextArea
value={typeof messageView.properties === 'object' ? JSON.stringify(messageView.properties, null, 2) : messageView.properties}
style={{ minHeight: 100, resize: 'none' }}
readOnly
/>
</Form.Item>
<Form.Item label="ReconsumeTimes:">
<Text strong>{messageView.reconsumeTimes}</Text>
</Form.Item>
<Form.Item label="Tag:">
<Text strong>{messageView.properties?.TAGS}</Text>
</Form.Item>
<Form.Item label="Key:">
<Text strong>{messageView.properties?.KEYS}</Text>
</Form.Item>
<Form.Item label="Storetime:">
<Text strong>{moment(messageView.storeTimestamp).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Form.Item>
<Form.Item label="StoreHost:">
<Text strong>{messageView.storeHost}</Text>
</Form.Item>
<Form.Item label="Message body:">
<Input.TextArea
value={messageView.messageBody}
style={{ minHeight: 100, resize: 'none' }}
readOnly
/>
</Form.Item>
</Form>
</div>
);
};
export default DlqMessageDetailViewDialog;

View File

@@ -0,0 +1,208 @@
/*
* 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 React from 'react';
import { Modal, Button, Typography, Descriptions, Tag, Spin, notification } from 'antd';
import moment from 'moment';
import { ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { useLanguage } from '../i18n/LanguageContext';
import { remoteApi } from '../api/remoteApi/remoteApi'; // 确保这个路径正确
const { Text, Paragraph } = Typography;
const MessageDetailViewDialog = ({ visible, onCancel, messageId, topic, onResendMessage }) => {
const { t } = useLanguage();
const [loading, setLoading] = React.useState(true);
const [messageDetail, setMessageDetail] = React.useState(null);
const [error, setError] = React.useState(null);
React.useEffect(() => {
const fetchMessageDetail = async () => {
// 只有当 visible 为 true 且 messageId 和 topic 存在时才进行数据请求
if (!visible || !messageId || !topic) {
// 如果 Modal 不可见或者必要参数缺失,则不加载数据
setMessageDetail(null); // 清空旧数据
setError(null); // 清空错误信息
setLoading(false); // 停止加载状态
return;
}
setLoading(true);
setError(null); // 在每次新的请求前清除之前的错误
try {
const resp = await remoteApi.viewMessage(messageId, topic);
if (resp.status === 0) {
setMessageDetail(resp.data);
} else {
const errorMessage = resp.errMsg || t.FETCH_MESSAGE_DETAIL_FAILED;
setError(errorMessage);
notification.error({
message: t.ERROR,
description: errorMessage,
});
}
} catch (err) {
const errorMessage = t.FETCH_MESSAGE_DETAIL_FAILED;
setError(errorMessage);
notification.error({
message: t.ERROR,
description: errorMessage,
});
console.error("Error fetching message detail:", err);
} finally {
setLoading(false);
}
};
fetchMessageDetail();
}, [visible, messageId, topic, t]); // 依赖项中添加 visible确保在 Modal 显示时触发
// handleShowExceptionDesc 方法不再需要,因为我们直接使用 Paragraph 的 ellipsis
return (
<Modal
title={t.MESSAGE_DETAIL}
open={visible} // Ant Design 5.x 版本中visible 属性已更名为 open
onCancel={onCancel}
footer={[
<Button key="close" onClick={onCancel}>
{t.CLOSE}
</Button>,
]}
width={900}
destroyOnHidden={true} // 使用新的 destroyOnHidden 替代 destroyOnClose
>
<Spin spinning={loading} tip={t.LOADING}>
{error && (
<Paragraph type="danger" style={{ textAlign: 'center' }}>
{error}
</Paragraph>
)}
{messageDetail ? ( // 确保 messageDetail 存在时才渲染内容
<>
{/* 消息信息部分 */}
<Descriptions title={<Text strong>{t.MESSAGE_INFO}</Text>} bordered column={2} size="small" style={{ marginBottom: 20 }}>
<Descriptions.Item label="Topic" span={2}><Text copyable>{messageDetail.messageView.topic}</Text></Descriptions.Item>
<Descriptions.Item label="Message ID" span={2}><Text copyable>{messageDetail.messageView.msgId}</Text></Descriptions.Item>
<Descriptions.Item label="StoreHost">{messageDetail.messageView.storeHost}</Descriptions.Item>
<Descriptions.Item label="BornHost">{messageDetail.messageView.bornHost}</Descriptions.Item>
<Descriptions.Item label="StoreTime">
{moment(messageDetail.messageView.storeTimestamp).format("YYYY-MM-DD HH:mm:ss")}
</Descriptions.Item>
<Descriptions.Item label="BornTime">
{moment(messageDetail.messageView.bornTimestamp).format("YYYY-MM-DD HH:mm:ss")}
</Descriptions.Item>
<Descriptions.Item label="Queue ID">{messageDetail.messageView.queueId}</Descriptions.Item>
<Descriptions.Item label="Queue Offset">{messageDetail.messageView.queueOffset}</Descriptions.Item>
<Descriptions.Item label="StoreSize">{messageDetail.messageView.storeSize} bytes</Descriptions.Item>
<Descriptions.Item label="ReconsumeTimes">{messageDetail.messageView.reconsumeTimes}</Descriptions.Item>
<Descriptions.Item label="BodyCRC">{messageDetail.messageView.bodyCRC}</Descriptions.Item>
<Descriptions.Item label="SysFlag">{messageDetail.messageView.sysFlag}</Descriptions.Item>
<Descriptions.Item label="Flag">{messageDetail.messageView.flag}</Descriptions.Item>
<Descriptions.Item label="PreparedTransactionOffset">{messageDetail.messageView.preparedTransactionOffset}</Descriptions.Item>
</Descriptions>
{/* 消息属性部分 */}
{Object.keys(messageDetail.messageView.properties).length > 0 && (
<Descriptions title={<Text strong>{t.MESSAGE_PROPERTIES}</Text>} bordered column={1} size="small" style={{ marginBottom: 20 }}>
{Object.entries(messageDetail.messageView.properties).map(([key, value]) => (
<Descriptions.Item label={key} key={key}><Text copyable>{value}</Text></Descriptions.Item>
))}
</Descriptions>
)}
{/* 消息体部分 */}
<Descriptions title={<Text strong>{t.MESSAGE_BODY}</Text>} bordered column={1} size="small" style={{ marginBottom: 20 }}>
<Descriptions.Item>
<Paragraph
copyable
ellipsis={{
rows: 5,
expandable: true,
symbol: t.SHOW_ALL_CONTENT,
}}
>
{messageDetail.messageView.messageBody}
</Paragraph>
</Descriptions.Item>
</Descriptions>
{/* 消息轨迹列表部分 */}
{messageDetail.messageTrackList && messageDetail.messageTrackList.length > 0 ? (
<>
<Text strong>{t.MESSAGE_TRACKING}</Text>
<div style={{ marginTop: 10 }}>
{messageDetail.messageTrackList.map((track, index) => (
<Descriptions bordered column={1} size="small" key={index} style={{ marginBottom: 15 }}>
<Descriptions.Item label={t.CONSUMER_GROUP}>
{track.consumerGroup}
</Descriptions.Item>
<Descriptions.Item label={t.TRACK_TYPE}>
<Tag color={
track.trackType === 'CONSUMED_SOME_TIME_OK' ? 'success' :
track.trackType === 'NOT_ONLINE' ? 'default' :
track.trackType === 'PULL_SUCCESS' ? 'processing' :
track.trackType === 'NO_MATCHED_CONSUMER' ? 'warning' :
'error'
}>
{track.trackType}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t.OPERATION}>
<Button
icon={<SyncOutlined />}
onClick={() => onResendMessage(messageDetail.messageView, track.consumerGroup)}
size="small"
style={{ marginRight: 8 }}
>
{t.RESEND_MESSAGE}
</Button>
{/* 移除“查看异常”按钮,因为现在直接在下方展示可展开内容 */}
</Descriptions.Item>
{track.exceptionDesc && (
<Descriptions.Item label={t.EXCEPTION_SUMMARY}>
{/* 异常信息截断显示,点击“查看更多”可展开 */}
<Paragraph
ellipsis={{
rows: 2, // 默认显示2行
expandable: true,
symbol: <Text style={{ color: '#1890ff', cursor: 'pointer' }}>{t.READ_MORE}</Text>, // 蓝色展开文本
}}
>
{track.exceptionDesc}
</Paragraph>
</Descriptions.Item>
)}
</Descriptions>
))}
</div>
</>
) : (
<Paragraph>{t.NO_TRACKING_INFO}</Paragraph>
)}
</>
) : (
// 当 messageDetail 为 null 时,可以显示一个占位符或者不显示内容
!loading && !error && <Paragraph style={{ textAlign: 'center' }}>{t.NO_MESSAGE_DETAIL_AVAILABLE}</Paragraph>
)}
</Spin>
</Modal>
);
};
export default MessageDetailViewDialog;

View File

@@ -0,0 +1,470 @@
/*
* 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 React, { useEffect, useRef } from 'react';
import { Form, Input, Typography, Collapse, Table, Tag } from 'antd';
import moment from 'moment';
import { useLanguage } from '../i18n/LanguageContext';
import Paragraph from "antd/es/skeleton/Paragraph";
import * as echarts from 'echarts'; // Import ECharts
const { Text } = Typography;
const { Panel } = Collapse;
// Constants for styling and formatting, derived from the example
const SUCCESS_COLOR = '#75d874';
const ERROR_COLOR = 'red';
const UNKNOWN_COLOR = 'yellow';
const TRANSACTION_COMMIT_COLOR = SUCCESS_COLOR;
const TRANSACTION_ROLLBACK_COLOR = ERROR_COLOR;
const TRANSACTION_UNKNOWN_COLOR = 'grey';
const TIME_FORMAT_PATTERN = "YYYY-MM-DD HH:mm:ss.SSS";
const DEFAULT_DISPLAY_DURATION = 10 * 1000;
const TRANSACTION_CHECK_COST_TIME = 50; // transactionTraceNode do not have costTime, assume it cost 50ms
const MessageTraceDetailViewDialog = ({ ngDialogData }) => {
const { t } = useLanguage();
const messageTraceGraphRef = useRef(null);
const producerNode = ngDialogData?.producerNode;
const subscriptionNodeList = ngDialogData?.subscriptionNodeList || [];
const messageTraceViews = ngDialogData?.messageTraceViews || []; // This data structure seems redundant for the Gantt chart but can be used for extra tooltip details.
useEffect(() => {
if (messageTraceGraphRef.current && ngDialogData) {
const chart = echarts.init(messageTraceGraphRef.current);
let data = [];
let dataZoomEnd = 100;
let startTime = Number.MAX_VALUE;
let endTime = 0;
let messageGroups = []; // This will be our Y-axis categories
if (producerNode) {
startTime = +producerNode.traceNode.beginTimestamp;
endTime = +producerNode.traceNode.endTimestamp;
}
// Helper functions from the provided example
function buildNodeColor(traceNode) {
if (traceNode.transactionState != null) {
switch (traceNode.transactionState) {
case 'COMMIT_MESSAGE':
return TRANSACTION_COMMIT_COLOR;
case 'ROLLBACK_MESSAGE':
return TRANSACTION_ROLLBACK_COLOR;
case 'UNKNOW':
return TRANSACTION_UNKNOWN_COLOR;
default:
return ERROR_COLOR;
}
}
switch (traceNode.status) {
case 'FAILED': // Changed 'failed' to 'FAILED' to match backend typically
return ERROR_COLOR;
case 'UNKNOWN': // Changed 'unknown' to 'UNKNOWN'
return UNKNOWN_COLOR;
default:
return SUCCESS_COLOR;
}
}
function formatXAxisTime(value) {
let duration = Math.max(0, value - startTime);
if (duration < 1000)
return timeFormat(duration, 'ms');
duration /= 1000;
if (duration < 60)
return timeFormat(duration, 's');
duration /= 60;
if (duration < 60)
return timeFormat(duration, 'min');
duration /= 60;
return timeFormat(duration, 'h');
}
function timeFormat(duration, unit) {
return duration.toFixed(2) + unit;
}
function buildTraceInfo(itemName, itemValue) {
if (itemValue) {
return `${itemName}: ${itemValue}<br />`
}
return "";
}
function formatCostTimeStr(costTime) {
if (costTime < 0) {
return "";
}
let costTimeStr = costTime;
if (costTime === 0) {
costTimeStr = '<1'
}
return `${costTimeStr}ms`;
}
function buildCostTimeInfo(costTime) {
if (costTime < 0) {
return "";
}
return `Cost Time: ${formatCostTimeStr(costTime)}<br/>`
}
function buildTimeStamp(timestamp) {
if (timestamp < 0) {
return 'N/A';
}
return moment(timestamp).format(TIME_FORMAT_PATTERN);
}
function formatNodeToolTip(params) {
let traceNode = params.data.traceData.traceNode;
return `
${buildCostTimeInfo(traceNode.costTime)}
Status: ${traceNode.status}<br />
${buildTraceInfo('Begin Timestamp', buildTimeStamp(traceNode.beginTimestamp))}
${buildTraceInfo('End Timestamp', buildTimeStamp(traceNode.endTimestamp))}
Client Host: ${traceNode.clientHost}<br />
Store Host: ${traceNode.storeHost}<br />
Retry Times: ${traceNode.retryTimes < 0 ? 'N/A' : traceNode.retryTimes}<br />
${buildTraceInfo('Message Type', traceNode.msgType)}
${buildTraceInfo('Transaction ID', traceNode.transactionId)}
${buildTraceInfo('Transaction State', traceNode.transactionState)}
${buildTraceInfo('From Transaction Check', traceNode.fromTransactionCheck)}
`;
}
function calcGraphTimestamp(timestamp, relativeTimeStamp, duration, addDuration) {
if (timestamp > 0) {
return timestamp;
}
if (duration < 0) {
return relativeTimeStamp;
}
return addDuration ? relativeTimeStamp + duration : relativeTimeStamp - duration;
}
function addTraceData(traceNode, index, groupName) {
if (traceNode.beginTimestamp < 0 && traceNode.endTimestamp < 0) {
return;
}
let beginTimestamp = calcGraphTimestamp(traceNode.beginTimestamp, traceNode.endTimestamp, traceNode.costTime, false);
let endTimestamp = calcGraphTimestamp(traceNode.endTimestamp, traceNode.beginTimestamp, traceNode.costTime, true);
if (endTimestamp === beginTimestamp) {
endTimestamp = beginTimestamp + 1; // Ensure a minimum duration for visualization
}
data.push({
name: groupName, // To display group name in tooltip or for internal reference
value: [
index,
beginTimestamp,
endTimestamp,
traceNode.costTime
],
itemStyle: {
normal: {
color: buildNodeColor(traceNode),
opacity: 1
}
},
traceData: {
traceNode: traceNode
}
});
startTime = Math.min(startTime, beginTimestamp);
endTime = Math.max(endTime, endTimestamp);
}
// Populate data for the Gantt chart
subscriptionNodeList.forEach(item => {
messageGroups.push(item.subscriptionGroup);
});
subscriptionNodeList.forEach((subscriptionNode, index) => {
subscriptionNode.consumeNodeList.forEach(traceNode => addTraceData(traceNode, index, subscriptionNode.subscriptionGroup));
});
if (producerNode) {
messageGroups.push(producerNode.groupName);
let producerNodeIndex = messageGroups.length - 1;
addTraceData(producerNode.traceNode, producerNodeIndex, producerNode.groupName);
producerNode.transactionNodeList.forEach(transactionNode => {
transactionNode.beginTimestamp = Math.max(producerNode.traceNode.endTimestamp,
transactionNode.endTimestamp - TRANSACTION_CHECK_COST_TIME);
addTraceData(transactionNode, producerNodeIndex, producerNode.groupName);
endTime = Math.max(endTime, transactionNode.endTimestamp);
});
}
let totalDuration = endTime - startTime;
if (totalDuration > DEFAULT_DISPLAY_DURATION) {
dataZoomEnd = (DEFAULT_DISPLAY_DURATION / totalDuration) * 100;
}
function renderItem(params, api) {
let messageGroupIndex = api.value(0); // Y-axis index
let start = api.coord([api.value(1), messageGroupIndex]); // X-axis start time, Y-axis group index
let end = api.coord([api.value(2), messageGroupIndex]); // X-axis end time, Y-axis group index
let height = api.size([0, 1])[1] * 0.6; // Height of the bar
let rectShape = echarts.graphic.clipRectByRect({
x: start[0],
y: start[1] - height / 2,
width: Math.max(end[0] - start[0], 1), // Ensure minimum width
height: height
}, {
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height
});
return rectShape && {
type: 'rect',
transition: ['shape'],
shape: rectShape,
style: api.style({
text: formatCostTimeStr(api.value(3)), // Display cost time on the bar
textFill: '#000',
textAlign: 'right'
})
};
}
const option = {
tooltip: {
formatter: function (params) {
return formatNodeToolTip(params);
}
},
title: {
text: producerNode ? `Message Trace: ${producerNode.topic}` : "Message Trace",
left: 'center'
},
dataZoom: [
{
type: 'slider',
filterMode: 'weakFilter',
showDataShadow: false,
top: 'bottom', // Position at the bottom
start: 0,
end: dataZoomEnd,
labelFormatter: function (value) {
return formatXAxisTime(value + startTime); // Adjust label to show relative time from start
}
},
{
type: 'inside',
filterMode: 'weakFilter'
}
],
grid: {
height: 300,
left: '10%', // Adjust left margin for Y-axis labels
right: '10%'
},
xAxis: {
min: startTime,
scale: true,
axisLabel: {
formatter: function (value) {
return formatXAxisTime(value);
}
}
},
yAxis: {
data: messageGroups, // Use group names as Y-axis categories
axisLabel: {
formatter: function (value, index) {
// Display the group name on the Y-axis
return value;
}
}
},
series: [
{
type: 'custom',
renderItem: renderItem,
encode: {
x: [1, 2], // Use beginTimestamp and endTimestamp for X-axis
y: 0 // Use the index for Y-axis category
},
data: data
}
]
};
chart.setOption(option);
const resizeChart = () => chart.resize();
window.addEventListener('resize', resizeChart);
return () => {
window.removeEventListener('resize', resizeChart);
chart.dispose();
};
}
}, [ngDialogData, t]); // Add t as a dependency for the useEffect hook
// ... (rest of your existing component code)
const transactionColumns = [
{ title: t.TIMESTAMP, dataIndex: 'beginTimestamp', key: 'beginTimestamp', align: 'center', render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss.SSS') },
{ title: t.TRANSACTION_STATE, dataIndex: 'transactionState', key: 'transactionState', align: 'center', render: (text) => <Tag color={text === 'COMMIT_MESSAGE' ? 'green' : (text === 'ROLLBACK_MESSAGE' ? 'red' : 'default')}>{text}</Tag> },
{ title: t.FROM_TRANSACTION_CHECK, dataIndex: 'fromTransactionCheck', key: 'fromTransactionCheck', align: 'center', render: (text) => (text ? <Tag color="blue">{t.YES}</Tag> : <Tag color="purple">{t.NO}</Tag>) },
{ title: t.CLIENT_HOST, dataIndex: 'clientHost', key: 'clientHost', align: 'center' },
{ title: t.STORE_HOST, dataIndex: 'storeHost', key: 'storeHost', align: 'center' },
];
const consumeColumns = [
{ title: t.BEGIN_TIMESTAMP, dataIndex: 'beginTimestamp', key: 'beginTimestamp', align: 'center', render: (text) => text < 0 ? 'N/A' : moment(text).format('YYYY-MM-DD HH:mm:ss.SSS') },
{ title: t.END_TIMESTAMP, dataIndex: 'endTimestamp', key: 'endTimestamp', align: 'center', render: (text) => text < 0 ? 'N/A' : moment(text).format('YYYY-MM-DD HH:mm:ss.SSS') },
{ title: t.COST_TIME, dataIndex: 'costTime', key: 'costTime', align: 'center', render: (text) => text < 0 ? 'N/A' : `${text === 0 ? '<1' : text}ms` },
{ title: t.STATUS, dataIndex: 'status', key: 'status', align: 'center', render: (text) => <Tag color={text === 'SUCCESS' ? 'green' : (text === 'FAILED' ? 'red' : 'default')}>{text}</Tag> },
{ title: t.RETRY_TIMES, dataIndex: 'retryTimes', key: 'retryTimes', align: 'center', render: (text) => text < 0 ? 'N/A' : text },
{ title: t.CLIENT_HOST, dataIndex: 'clientHost', key: 'clientHost', align: 'center' },
{ title: t.STORE_HOST, dataIndex: 'storeHost', key: 'storeHost', align: 'center' },
];
return (
<div style={{ padding: '20px', backgroundColor: '#f0f2f5' }}>
<div style={{ marginBottom: '20px', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)' }}>
<Collapse defaultActiveKey={['messageTraceGraph']} expandIconPosition="right">
<Panel header={<Typography.Title level={3} style={{ margin: 0, color: '#333' }}>{t.MESSAGE_TRACE_GRAPH}</Typography.Title>} key="messageTraceGraph">
<div ref={messageTraceGraphRef} style={{ height: 500, width: '100%', backgroundColor: '#fff', padding: '10px' }}>
{/* ECharts message trace graph will be rendered here */}
{(!producerNode && subscriptionNodeList.length === 0) && (
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: '150px' }}>{t.TRACE_GRAPH_PLACEHOLDER}</Text>
)}
</div>
</Panel>
</Collapse>
</div>
<div style={{ marginBottom: '20px', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)' }}>
<Collapse defaultActiveKey={['sendMessageTrace']} expandIconPosition="right">
<Panel header={<Typography.Title level={3} style={{ margin: 0, color: '#333' }}>{t.SEND_MESSAGE_TRACE}</Typography.Title>} key="sendMessageTrace">
{!producerNode ? (
<Paragraph style={{ padding: '16px', textAlign: 'center', color: '#666' }}>{t.NO_PRODUCER_TRACE_DATA}</Paragraph>
) : (
<div style={{ padding: '16px', backgroundColor: '#fff' }}>
<Typography.Title level={4} style={{ marginBottom: '20px' }}>
{t.SEND_MESSAGE_INFO} : ( {t.MESSAGE_ID} <Text strong copyable>{producerNode.msgId}</Text> )
</Typography.Title>
<Form layout="vertical" colon={false}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
<Form.Item label={<Text strong>{t.TOPIC}</Text>}>
<Input value={producerNode.topic} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.PRODUCER_GROUP}</Text>}>
<Input value={producerNode.groupName} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.MESSAGE_KEY}</Text>}>
<Input value={producerNode.keys} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.TAG}</Text>}>
<Input value={producerNode.tags} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.BEGIN_TIMESTAMP}</Text>}>
<Input value={moment(producerNode.traceNode.beginTimestamp).format('YYYY-MM-DD HH:mm:ss.SSS')} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.END_TIMESTAMP}</Text>}>
<Input value={moment(producerNode.traceNode.endTimestamp).format('YYYY-MM-DD HH:mm:ss.SSS')} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.COST_TIME}</Text>}>
<Input value={`${producerNode.traceNode.costTime === 0 ? '<1' : producerNode.traceNode.costTime}ms`} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.MSG_TYPE}</Text>}>
<Input value={producerNode.traceNode.msgType} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.CLIENT_HOST}</Text>}>
<Input value={producerNode.traceNode.clientHost} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.STORE_HOST}</Text>}>
<Input value={producerNode.traceNode.storeHost} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.RETRY_TIMES}</Text>}>
<Input value={producerNode.traceNode.retryTimes} readOnly />
</Form.Item>
<Form.Item label={<Text strong>{t.OFFSET_MSG_ID}</Text>}>
<Input value={producerNode.offSetMsgId} readOnly />
</Form.Item>
</div>
</Form>
{producerNode.transactionNodeList && producerNode.transactionNodeList.length > 0 && (
<div style={{ marginTop: '30px' }}>
<Typography.Title level={4} style={{ marginBottom: '15px' }}>{t.CHECK_TRANSACTION_INFO}:</Typography.Title>
<Table
columns={transactionColumns}
dataSource={producerNode.transactionNodeList}
rowKey={(record, index) => `transaction_${index}`}
bordered
pagination={false}
size="middle"
scroll={{ x: 'max-content' }}
/>
</div>
)}
</div>
)}
</Panel>
</Collapse>
</div>
<div style={{ borderRadius: '8px', overflow: 'hidden', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)' }}>
<Collapse defaultActiveKey={['consumeMessageTrace']} expandIconPosition="right">
<Panel header={<Typography.Title level={3} style={{ margin: 0, color: '#333' }}>{t.CONSUME_MESSAGE_TRACE}</Typography.Title>} key="consumeMessageTrace">
{subscriptionNodeList.length === 0 ? (
<Paragraph style={{ padding: '16px', textAlign: 'center', color: '#666' }}>{t.NO_CONSUMER_TRACE_DATA}</Paragraph>
) : (
<div style={{ padding: '16px', backgroundColor: '#fff' }}>
{subscriptionNodeList.map(subscriptionNode => (
<Collapse
key={subscriptionNode.subscriptionGroup}
style={{ marginBottom: '10px', border: '1px solid #e0e0e0', borderRadius: '4px' }}
defaultActiveKey={[subscriptionNode.subscriptionGroup]}
ghost
>
<Panel
header={<Typography.Title level={4} style={{ margin: 0 }}>{t.SUBSCRIPTION_GROUP}: <Text strong>{subscriptionNode.subscriptionGroup}</Text></Typography.Title>}
key={subscriptionNode.subscriptionGroup}
>
<Table
columns={consumeColumns}
dataSource={subscriptionNode.consumeNodeList}
rowKey={(record, index) => `${subscriptionNode.subscriptionGroup}_${index}`}
bordered
pagination={false}
size="middle"
scroll={{ x: 'max-content' }}
/>
</Panel>
</Collapse>
))}
</div>
)}
</Panel>
</Collapse>
</div>
</div>
);
};
export default MessageTraceDetailViewDialog;

View File

@@ -0,0 +1,211 @@
/*
* 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 React, {useEffect, useState} from 'react';
import { Layout, Menu, Dropdown, Button, Drawer, Grid, Space } from 'antd';
import {GlobalOutlined, DownOutlined, UserOutlined, MenuOutlined, BgColorsOutlined} from '@ant-design/icons';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLanguage } from '../i18n/LanguageContext';
import {useTheme} from "../store/context/ThemeContext";
import {remoteApi} from "../api/remoteApi/remoteApi";
const { Header } = Layout;
const { useBreakpoint } = Grid; // Used to determine screen breakpoints
const Navbar = ({ rmqVersion = true, showAcl = true}) => {
const location = useLocation();
const navigate = useNavigate();
const { lang, setLang, t } = useLanguage();
const screens = useBreakpoint(); // Get current screen size breakpoints
const { currentThemeName, setCurrentThemeName } = useTheme();
const [userName, setUserName] = useState(null);
const [drawerVisible, setDrawerVisible] = useState(false); // Controls drawer visibility
// Get selected menu item key based on current route path
const getPath = () => location.pathname.replace('/', '');
const handleMenuClick = ({ key }) => {
navigate(`/${key}`);
setDrawerVisible(false); // Close drawer after clicking a menu item
};
const onLogout = () => {
remoteApi.logout().then(res => {
if (res.status === 0) {
window.localStorage.removeItem("username");
window.localStorage.removeItem("userRole");
window.localStorage.removeItem("token");
window.localStorage.removeItem("rmqVersion");
navigate('/login');
} else {
console.error('Logout failed:', res.message)
navigate('/login');
}
})
};
useEffect(() => {
const storedUsername = window.localStorage.getItem("username");
if (storedUsername) {
setUserName(storedUsername);
}else {
setUserName(null);
}
}, []);
const langMenu = (
<Menu onClick={({ key }) => setLang(key)}>
<Menu.Item key="en">{t.ENGLISH}</Menu.Item>
<Menu.Item key="zh">{t.CHINESE}</Menu.Item>
</Menu>
);
const userMenu = (
<Menu>
<Menu.Item key="logout" onClick={onLogout}>{t.LOGOUT}</Menu.Item>
</Menu>
);
const themeMenu = (
<Menu onClick={({ key }) => setCurrentThemeName(key)}>
<Menu.Item key="default">{t.BLUE} ({t.DEFAULT})</Menu.Item>
<Menu.Item key="pink">{t.PINK}</Menu.Item>
<Menu.Item key="green">{t.GREEN}</Menu.Item>
</Menu>
);
// Menu item configuration
const menuItems = [
{ key: 'ops', label: t.OPS },
...(rmqVersion ? [{ key: 'proxy', label: t.PROXY }] : []),
{ key: '', label: t.DASHBOARD }, // Dashboard corresponds to root path
{ key: 'cluster', label: t.CLUSTER },
{ key: 'topic', label: t.TOPIC },
{ key: 'consumer', label: t.CONSUMER },
{ key: 'producer', label: t.PRODUCER },
{ key: 'message', label: t.MESSAGE },
{ key: 'dlqMessage', label: t.DLQ_MESSAGE },
{ key: 'messageTrace', label: t.MESSAGETRACE },
...(showAcl ? [{ key: 'acl', label: t.WHITE_LIST }] : []),
];
// Determine if it's a small screen (e.g., less than md)
const isSmallScreen = !screens.md;
// Determine if it's an extra small screen (e.g., less than sm)
const isExtraSmallScreen = !screens.sm;
return (
<Header
className="navbar"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: isExtraSmallScreen ? '0 16px' : '0 24px', // Smaller padding on extra small screens
}}
>
<div className="navbar-left" style={{ display: 'flex', alignItems: 'center' }}>
<div
style={{
fontWeight: 'bold',
marginRight: isSmallScreen ? '16px' : '24px', // Adjust margin on small screens
whiteSpace: 'nowrap', // Prevent text wrapping
flexShrink: 0, // Prevent shrinking in flex container
color: 'white', // Title text color also set to white
fontSize: isSmallScreen ? '14px' : '18px',
}}
>
{t.TITLE}
</div>
{!isSmallScreen && ( // Display full menu on large screens
<Menu
onClick={handleMenuClick}
selectedKeys={[getPath()]}
mode="horizontal"
items={menuItems}
theme="dark" // Use dark theme to match Header background
style={{ flex: 1, minWidth: 0 }} // Allow menu items to adapt width
/>
)}
</div>
<Space size={isExtraSmallScreen ? 8 : 16} > {/* Adjust spacing for buttons */}
{/* Theme switch button */}
<Dropdown overlay={themeMenu}>
<Button icon={<BgColorsOutlined />} size="small">
{!isExtraSmallScreen && `${t.TOPIC}: ${currentThemeName}`}
<DownOutlined />
</Button>
</Dropdown>
<Dropdown overlay={langMenu}>
<Button icon={<GlobalOutlined />} size="small">
{!isExtraSmallScreen && t.CHANGE_LANG} {/* Hide text on extra small screens */}
<DownOutlined />
</Button>
</Dropdown>
{userName && (
<Dropdown overlay={userMenu}>
{/* 使用一个可点击的元素作为 Dropdown 的唯一子元素 */}
<a onClick={e => e.preventDefault()} style={{ display: 'flex', alignItems: 'center' }}>
<UserOutlined style={{ marginRight: 8 }} /> {/* 添加一些间距 */}
{userName}
<DownOutlined style={{ marginLeft: 8 }} />
</a>
</Dropdown>
)}
{isSmallScreen && ( // Display hamburger icon on small screens
<Button
type="primary"
icon={<MenuOutlined />}
onClick={() => setDrawerVisible(true)}
style={{ marginLeft: isExtraSmallScreen ? 8 : 16 }} // Adjust margin for hamburger icon
/>
)}
</Space>
{/* Modify Drawer and Menu components here */}
<Drawer
// Default Drawer background color is white. If you need to change the Drawer's own background color, set it additionally
// or set a dark background in bodyStyle, then let Menu override it
title={t.MENU} // Drawer title
placement="left" // Drawer pops out from the left
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
// If you want the Drawer's background to match the Menu's background color, you can set bodyStyle like this
// or set components.Drawer.colorBgElevated in theme.js, etc.
bodyStyle={{ padding: 0, backgroundColor: '#1c324a' }} // Set Drawer body background to dark
width={200} // Set drawer width
>
<Menu
onClick={handleMenuClick}
selectedKeys={[getPath()]}
mode="inline" // Use vertical menu in drawer
items={menuItems}
theme="dark"
style={{ height: '100%', borderRight: 0 }} // Ensure menu fills the drawer
/>
</Drawer>
</Header>
);
};
export default Navbar;

View File

@@ -0,0 +1,127 @@
/*
* 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 { Input, Select, Tag, Space } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
const { Option } = Select;
// 资源类型枚举
const resourceTypes = [
{ value: 0, label: 'Unknown', prefix: 'UNKNOWN' },
{ value: 1, label: 'Any', prefix: 'ANY' },
{ value: 2, label: 'Cluster', prefix: 'CLUSTER' },
{ value: 3, label: 'Namespace', prefix: 'NAMESPACE' },
{ value: 4, label: 'Topic', prefix: 'TOPIC' },
{ value: 5, label: 'Group', prefix: 'GROUP' },
];
const ResourceInput = ({ value = [], onChange }) => {
// 确保 value 始终是数组
const safeValue = Array.isArray(value) ? value : [];
const [selectedType, setSelectedType] = useState(resourceTypes[0].prefix); // 默认选中第一个
const [resourceName, setResourceName] = useState('');
const [inputVisible, setInputVisible] = useState(false);
const inputRef = React.useRef(null);
// 处理删除已添加的资源
const handleClose = removedResource => {
const newResources = safeValue.filter(resource => resource !== removedResource);
onChange(newResources);
};
// 显示输入框
const showInput = () => {
setInputVisible(true);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
};
// 处理资源类型选择
const handleTypeChange = type => {
setSelectedType(type);
};
// 处理资源名称输入
const handleNameChange = e => {
setResourceName(e.target.value);
};
// 添加资源到列表
const handleAddResource = () => {
if (resourceName) {
const fullResource = `${selectedType}:${resourceName}`;
// 避免重复添加
if (!safeValue.includes(fullResource)) {
onChange([...safeValue, fullResource]);
}
setResourceName(''); // 清空输入
setInputVisible(false); // 隐藏输入框
}
};
return (
<Space size={[0, 8]} wrap>
{/* 显示已添加的资源标签 */}
{safeValue.map(resource => ( // 使用 safeValue
<Tag
key={resource}
closable
onClose={() => handleClose(resource)}
color="blue"
>
{resource}
</Tag>
))}
{/* 新增资源输入区域 */}
{inputVisible ? (
<Space>
<Select
value={selectedType}
style={{ width: 120 }}
onChange={handleTypeChange}
>
{resourceTypes.map(type => (
<Option key={type.value} value={type.prefix}>
{type.label}
</Option>
))}
</Select>
<Input
ref={inputRef}
style={{ width: 180 }}
value={resourceName}
onChange={handleNameChange}
onPressEnter={handleAddResource}
onBlur={handleAddResource} // 失去焦点也自动添加
placeholder="请输入资源名称"
/>
</Space>
) : (
<Tag onClick={showInput} style={{ background: '#fff', borderStyle: 'dashed' }}>
<PlusOutlined /> 添加资源
</Tag>
)}
</Space>
);
};
export default ResourceInput;

View File

@@ -0,0 +1,101 @@
/*
* 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 { Input, Select } from 'antd';
import React, { useState, useEffect } from 'react';
const { Option } = Select;
// Subject 类型枚举
const subjectTypes = [
{ value: 'User', label: 'User' },
];
const SubjectInput = ({ value, onChange, disabled }) => {
// 解析传入的 value将其拆分为 type 和 name
const parseValue = (val) => {
if (!val || typeof val !== 'string') {
return { type: subjectTypes[0].value, name: '' }; // 默认值
}
const parts = val.split(':');
if (parts.length === 2 && subjectTypes.some(t => t.value === parts[0])) {
return { type: parts[0], name: parts[1] };
}
return { type: subjectTypes[0].value, name: val }; // 如果格式不匹配,将整个值作为 name类型设为默认
};
const [currentType, setCurrentType] = useState(() => parseValue(value).type);
const [currentName, setCurrentName] = useState(() => parseValue(value).name);
// 当外部 value 变化时,更新内部状态
useEffect(() => {
const parsed = parseValue(value);
setCurrentType(parsed.type);
setCurrentName(parsed.name);
}, [value]);
// 当类型或名称变化时,通知 Form.Item
const triggerChange = (changedType, changedName) => {
if (onChange) {
// 只有当名称不为空时才组合,否则只返回类型或空字符串
if (changedName) {
onChange(`${changedType}:${changedName}`);
} else if (changedType) { // 如果只选择了类型,但名称为空,则不组合
onChange(''); // 或者根据需求返回 'User:' 等,但通常这种情况下不应该有值
} else {
onChange('');
}
}
};
const onTypeChange = (newType) => {
setCurrentType(newType);
triggerChange(newType, currentName);
};
const onNameChange = (e) => {
const newName = e.target.value;
setCurrentName(newName);
triggerChange(currentType, newName);
};
return (
<Input.Group compact>
<Select
style={{ width: '30%' }}
value={currentType}
onChange={onTypeChange}
disabled={disabled}
>
{subjectTypes.map(type => (
<Option key={type.value} value={type.value}>
{type.label}
</Option>
))}
</Select>
<Input
style={{ width: '70%' }}
value={currentName}
onChange={onNameChange}
placeholder="请输入名称 (例如: yourUsername)"
disabled={disabled}
/>
</Input.Group>
);
};
export default SubjectInput;

View File

@@ -0,0 +1,103 @@
/*
* 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 React, { useState, useEffect } from 'react';
import { Modal, Table, Spin } from 'antd';
import { remoteApi } from '../../api/remoteApi/remoteApi';
import { useLanguage } from '../../i18n/LanguageContext';
const ClientInfoModal = ({ visible, group, address, onCancel }) => {
const { t } = useLanguage();
const [loading, setLoading] = useState(false);
const [connectionData, setConnectionData] = useState(null);
const [subscriptionData, setSubscriptionData] = useState(null);
useEffect(() => {
const fetchData = async () => {
if (!visible) return;
setLoading(true);
try {
const connResponse = await remoteApi.queryConsumerConnection(group, address);
const topicResponse = await remoteApi.queryTopicByConsumer(group, address);
if (connResponse.status === 0) setConnectionData(connResponse.data);
if (topicResponse.status === 0) setSubscriptionData(topicResponse.data);
} finally {
setLoading(false);
}
};
fetchData();
}, [visible, group, address]);
const connectionColumns = [
{ title: 'ClientId', dataIndex: 'clientId' },
{ title: 'ClientAddr', dataIndex: 'clientAddr' },
{ title: 'Language', dataIndex: 'language' },
{ title: 'Version', dataIndex: 'versionDesc' },
];
const subscriptionColumns = [
{ title: 'Topic', dataIndex: 'topic' },
{ title: 'SubExpression', dataIndex: 'subString' },
];
return (
<Modal
title={`[${group}]${t.CLIENT}`}
visible={visible}
onCancel={onCancel}
footer={null}
width={800}
>
<Spin spinning={loading}>
{connectionData && (
<>
<Table
columns={connectionColumns}
dataSource={connectionData.connectionSet}
rowKey="clientId"
pagination={false}
/>
<h4>{t.SUBSCRIPTION}</h4>
<Table
columns={subscriptionColumns}
dataSource={
subscriptionData?.subscriptionTable
? Object.entries(subscriptionData.subscriptionTable).map(([topic, detail]) => ({
topic,
...detail,
}))
: []
}
rowKey="topic"
pagination={false}
locale={{
emptyText: loading ? <Spin size="small" /> : t.NO_DATA
}}
/>
<p>ConsumeType: {connectionData.consumeType}</p>
<p>MessageModel: {connectionData.messageModel}</p>
</>
)}
</Spin>
</Modal>
);
};
export default ClientInfoModal;

View File

@@ -0,0 +1,287 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { Button, Descriptions, Form, Input, Select, Switch, message } from 'antd';
import { remoteApi } from '../../api/remoteApi/remoteApi'; // 确保路径正确
const { Option } = Select;
const ConsumerConfigItem = ({ initialConfig, isAddConfig, group, brokerName, allBrokerList, allClusterNames,onCancel, onSuccess, t }) => {
const [form] = Form.useForm();
const [currentBrokerName, setCurrentBrokerName] = useState(brokerName);
useEffect(() => {
if (initialConfig) {
if (!isAddConfig && initialConfig.brokerNameList && initialConfig.brokerNameList.length > 0) {
// 更新模式设置当前BrokerName为第一个如果只有一个的话或者您有其他选择逻辑
setCurrentBrokerName(initialConfig.brokerNameList[0]);
}
form.setFieldsValue({
...initialConfig.subscriptionGroupConfig,
groupName: isAddConfig ? undefined : initialConfig.subscriptionGroupConfig.groupName, // 添加模式下groupName可编辑
brokerName: isAddConfig ? [] : initialConfig.brokerNameList, // 更新模式下显示已有的brokerName
clusterName: isAddConfig ? [] : initialConfig.clusterNameList, // 更新模式下显示已有的clusterName
});
} else {
// Reset form for add mode or when initialConfig is null (e.g., when the modal is closed)
form.resetFields();
form.setFieldsValue({
groupName: undefined,
autoCommit: true,
enableAutoCommit: true,
enableAutoOffsetReset: true,
groupSysFlag: 0,
consumeTimeoutMinute: 10,
consumeEnable: true,
consumeMessageOrderly: false,
consumeBroadcastEnable: false,
retryQueueNums: 1,
retryMaxTimes: 16,
brokerId: 0,
whichBrokerWhenConsumeSlowly: 0,
brokerName: [],
clusterName: [],
});
setCurrentBrokerName(undefined); // 清空当前brokerName
}
}, [initialConfig, isAddConfig, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const numericValues = {
retryQueueNums: Number(values.retryQueueNums),
retryMaxTimes: Number(values.retryMaxTimes),
brokerId: Number(values.brokerId),
whichBrokerWhenConsumeSlowly: Number(values.whichBrokerWhenConsumeSlowly),
};
// 确保brokerNameList是数组
let finalBrokerNameList = Array.isArray(values.brokerName) ? values.brokerName : [values.brokerName];
// 确保clusterNameList是数组
let finalClusterNameList = Array.isArray(values.clusterName) ? values.clusterName : [values.clusterName];
const payload = {
subscriptionGroupConfig: {
...(initialConfig && initialConfig.subscriptionGroupConfig ? initialConfig.subscriptionGroupConfig : {}), // 保留旧的配置,除非被新值覆盖
...values,
...numericValues,
groupName: isAddConfig ? values.groupName : group, // 添加模式使用表单中的groupName更新模式使用传入的group
},
brokerNameList: finalBrokerNameList,
clusterNameList: isAddConfig ? finalClusterNameList : null, // 更新模式保留原有clusterNameList
};
const response = await remoteApi.createOrUpdateConsumer(payload);
if (response.status === 0) {
message.success(t.SUCCESS);
onSuccess();
} else {
message.error(`${t.OPERATION_FAILED}: ${response.errMsg}`);
console.error('Failed to create or update consumer:', response.errMsg);
}
} catch (error) {
console.error('Validation failed or API call error:', error);
message.error(t.FORM_VALIDATION_FAILED);
} finally {
onCancel()
}
};
// Helper function to parse input value to number
const parseNumber = (event) => {
const value = event.target.value;
return value === '' ? undefined : Number(value);
};
// 如果是添加模式并且用户还没有选择brokerName或者没有clusterName可供选择则不渲染表单
if (isAddConfig && (!allBrokerList || allBrokerList.length === 0 || !allClusterNames || allClusterNames.length === 0)) {
return <p>{t.NO_DATA}</p>;
}
return (
<div style={{border: '1px solid #e8e8e8', padding: 20, marginBottom: 20, borderRadius: 8}}>
{/* 标题根据当前BrokerName或“添加新配置”显示 */}
<h3>{isAddConfig ? t.ADD_CONSUMER : `${t.CONFIG_FOR_BROKER}: ${currentBrokerName || 'N/A'}`}</h3>
{!isAddConfig && initialConfig && (
<Descriptions bordered column={2} style={{marginBottom: 24}} size="small">
<Descriptions.Item label={t.CLUSTER_NAME} span={2}>
{initialConfig.clusterNameList?.join(', ') || 'N/A'}
</Descriptions.Item>
<Descriptions.Item label={t.RETRY_POLICY} span={2}>
<pre style={{margin: 0, maxHeight: '100px', overflow: 'auto', fontSize: '12px'}}>
{JSON.stringify(
initialConfig.subscriptionGroupConfig.groupRetryPolicy,
null,
2
) || 'N/A'}
</pre>
</Descriptions.Item>
<Descriptions.Item label={t.CONSUME_TIMEOUT}>
{`${initialConfig.subscriptionGroupConfig.consumeTimeoutMinute} ${t.MINUTES}` || 'N/A'}
</Descriptions.Item>
<Descriptions.Item label={t.SYSTEM_FLAG}>
{initialConfig.subscriptionGroupConfig.groupSysFlag || 'N/A'}
</Descriptions.Item>
</Descriptions>
)}
<Form form={form} layout="vertical">
<Form.Item
name="groupName"
label={t.GROUP_NAME}
rules={[{required: true, message: t.CANNOT_BE_EMPTY}]}
>
<Input disabled={!isAddConfig}/>
</Form.Item>
{isAddConfig && (
<Form.Item
name="clusterName"
label={t.CLUSTER_NAME}
rules={[{required: true, message: t.PLEASE_SELECT_CLUSTER_NAME}]}
>
<Select
mode="multiple"
placeholder={t.SELECT_CLUSTERS}
disabled={!isAddConfig}
>
{allClusterNames.map((cluster) => (
<Option key={cluster} value={cluster}>
{cluster}
</Option>
))}
</Select>
</Form.Item>
)}
<Form.Item
name="brokerName"
label={t.BROKER_NAME}
rules={[{required: true, message: t.PLEASE_SELECT_BROKER}]}
>
<Select
mode="multiple"
placeholder={t.SELECT_BROKERS}
disabled={!isAddConfig} // 只有在添加模式下才能选择brokerName
onChange={(selectedBrokers) => {
if (isAddConfig && selectedBrokers.length > 0) {
// 在添加模式下如果选择了broker则将第一个选中的broker设置为当前brokerName用于显示
setCurrentBrokerName(selectedBrokers[0]);
}
}}
>
{allBrokerList.map((broker) => (
<Option key={broker} value={broker}>
{broker}
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="consumeEnable" label={t.CONSUME_ENABLE} valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item name="consumeMessageOrderly" label={t.ORDERLY_CONSUMPTION} valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item name="consumeBroadcastEnable" label={t.BROADCAST_CONSUMPTION} valuePropName="checked">
<Switch/>
</Form.Item>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16}}>
<Form.Item
name="retryQueueNums"
label={t.RETRY_QUEUES}
rules={[{
type: 'number',
message: t.PLEASE_INPUT_NUMBER,
transform: value => Number(value)
}, {
required: true,
message: t.CANNOT_BE_EMPTY
}]}
getValueFromEvent={parseNumber}
>
<Input type="number"/>
</Form.Item>
<Form.Item
name="retryMaxTimes"
label={t.MAX_RETRIES}
rules={[{
type: 'number',
message: t.PLEASE_INPUT_NUMBER,
transform: value => Number(value)
}, {
required: true,
message: t.CANNOT_BE_EMPTY
}]}
getValueFromEvent={parseNumber}
>
<Input type="number"/>
</Form.Item>
<Form.Item
name="brokerId"
label={t.BROKER_ID}
rules={[{
type: 'number',
message: t.PLEASE_INPUT_NUMBER,
transform: value => Number(value)
}, {
required: true,
message: t.CANNOT_BE_EMPTY
}]}
getValueFromEvent={parseNumber}
>
<Input type="number"/>
</Form.Item>
<Form.Item
name="whichBrokerWhenConsumeSlowly"
label={t.SLOW_CONSUMPTION_BROKER}
rules={[{
type: 'number',
message: t.PLEASE_INPUT_NUMBER,
transform: value => Number(value)
}, {
required: true,
message: t.CANNOT_BE_EMPTY
}]}
getValueFromEvent={parseNumber}
>
<Input type="number"/>
</Form.Item>
</div>
<div style={{textAlign: 'right', marginTop: 20}}>
<Button type="primary" onClick={handleSubmit}>
{t.COMMIT}
</Button>
</div>
</Form>
</div>
);
};
export default ConsumerConfigItem;

View File

@@ -0,0 +1,169 @@
/*
* 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 React, {useEffect, useState} from 'react';
import {Button, Modal, Spin} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
import {useLanguage} from '../../i18n/LanguageContext';
import ConsumerConfigItem from './ConsumerConfigItem'; // 导入子组件
const ConsumerConfigModal = ({visible, isAddConfig, group, onCancel, setIsAddConfig, onSuccess}) => {
const {t} = useLanguage();
const [loading, setLoading] = useState(false);
const [allBrokerList, setAllBrokerList] = useState([]); // 存储所有可用的broker
const [allClusterNames, setAllClusterNames] = useState([]); // 存储所有可用的cluster names
const [initialConfigData, setInitialConfigData] = useState({}); // 存储按brokerName分的初始配置数据
useEffect(() => {
if (visible) {
const fetchInitialData = async () => {
setLoading(true);
try {
// Fetch cluster list for broker names and cluster names
if(isAddConfig) {
const clusterResponse = await remoteApi.getClusterList();
if (clusterResponse.status === 0 && clusterResponse.data) {
const clusterInfo = clusterResponse.data.clusterInfo;
const brokers = [];
const clusterNames = Object.keys(clusterInfo?.clusterAddrTable || {});
clusterNames.forEach(clusterName => {
const brokersInCluster = clusterInfo?.clusterAddrTable?.[clusterName] || [];
brokers.push(...brokersInCluster);
});
setAllBrokerList([...new Set(brokers)]); // 确保brokerName唯一
setAllClusterNames(clusterNames);
} else {
console.error('Failed to fetch cluster list:', clusterResponse.errMsg);
}
}
if (!isAddConfig) {
// Fetch existing consumer config for update mode
const consumerConfigResponse = await remoteApi.queryConsumerConfig(group);
if (consumerConfigResponse.status === 0 && consumerConfigResponse.data && consumerConfigResponse.data.length > 0) {
const configMap = {};
consumerConfigResponse.data.forEach(config => {
// 假设每个brokerName有一个独立的配置项
config.brokerNameList.forEach(brokerName => {
configMap[brokerName] = {
...config,
// 确保brokerNameList和clusterNameList是数组形式即使API返回单值
brokerNameList: Array.isArray(config.brokerNameList) ? config.brokerNameList : [config.brokerNameList],
clusterNameList: Array.isArray(config.clusterNameList) ? config.clusterNameList : [config.clusterNameList]
};
});
});
setInitialConfigData(configMap);
} else {
console.error(`Failed to fetch consumer config for group: ${group}`);
onCancel(); // Close modal if config not found
}
} else {
// For add mode, initialize with empty values and allow selecting any broker
setInitialConfigData({
// 当isAddConfig为true时我们只提供一个空的配置模板用户选择broker后会创建新的配置
// 在这里我们将设置一个空的初始配置供用户选择broker来创建新配置
newConfig: {
groupName: undefined,
subscriptionGroupConfig: {
autoCommit: true,
enableAutoCommit: true,
enableAutoOffsetReset: true,
groupSysFlag: 0,
consumeTimeoutMinute: 10,
consumeEnable: true,
consumeMessageOrderly: false,
consumeBroadcastEnable: false,
retryQueueNums: 1,
retryMaxTimes: 16,
brokerId: 0,
whichBrokerWhenConsumeSlowly: 0,
},
brokerNameList: [],
clusterNameList: []
}
});
}
} catch (error) {
console.error('Error in fetching initial data:', error);
} finally {
setLoading(false);
}
};
fetchInitialData();
} else {
// Reset state when modal is closed
setInitialConfigData({});
setAllBrokerList([]);
setAllClusterNames([]);
}
}, [visible, isAddConfig, group, onCancel]);
const getBrokersToRender = () => {
if (isAddConfig) {
return ['newConfig'];
} else {
return Object.keys(initialConfigData);
}
}
return (
<Modal
title={isAddConfig ? t.ADD_CONSUMER : `${t.CONFIG} - ${group}`}
visible={visible}
onCancel={() => {
onCancel();
setIsAddConfig(false); // 确保关闭时重置添加模式
}}
width={800}
footer={[
<Button key="cancel" onClick={() => {
onCancel();
setIsAddConfig(false);
}}>
{t.CLOSE}
</Button>,
]}
style={{top: 20}} // 让弹窗靠上一点方便内容滚动
bodyStyle={{maxHeight: 'calc(100vh - 200px)', overflowY: 'auto'}} // 允许内容滚动
>
<Spin spinning={loading}>
{getBrokersToRender().map(brokerOrKey => (
<ConsumerConfigItem
key={brokerOrKey} // 使用brokerName作为key
initialConfig={initialConfigData[brokerOrKey]}
isAddConfig={isAddConfig}
group={group} // 传递当前group
brokerName={isAddConfig ? undefined : brokerOrKey} // 添加模式下brokerName由用户选择更新模式下是当前遍历的brokerName
allBrokerList={allBrokerList}
allClusterNames={allClusterNames}
onSuccess={onSuccess}
onCancel={onCancel}
t={t} // 传递i18n函数
/>
))}
</Spin>
</Modal>
);
};
export default ConsumerConfigModal;

View File

@@ -0,0 +1,79 @@
/*
* 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 React, { useState, useEffect } from 'react';
import { Modal, Table, Spin } from 'antd';
import { remoteApi } from '../../api/remoteApi/remoteApi';
import { useLanguage } from '../../i18n/LanguageContext';
const ConsumerDetailModal = ({ visible, group, address, onCancel }) => {
const { t } = useLanguage();
const [loading, setLoading] = useState(false);
const [details, setDetails] = useState([]);
useEffect(() => {
const fetchData = async () => {
if (!visible) return;
setLoading(true);
try {
const response = await remoteApi.queryTopicByConsumer(group, address);
if (response.status === 0) {
setDetails(response.data);
}
} finally {
setLoading(false);
}
};
fetchData();
}, [visible, group, address]);
const queueColumns = [
{ title: 'Broker', dataIndex: 'brokerName' },
{ title: 'Queue', dataIndex: 'queueId' },
{ title: 'BrokerOffset', dataIndex: 'brokerOffset' },
{ title: 'ConsumerOffset', dataIndex: 'consumerOffset' },
{ title: 'DiffTotal', dataIndex: 'diffTotal' },
{ title: 'LastTimestamp', dataIndex: 'lastTimestamp' },
];
return (
<Modal
title={`[${group}]${t.CONSUME_DETAIL}`}
visible={visible}
onCancel={onCancel}
footer={null}
width={1200}
>
<Spin spinning={loading}>
{details.map((consumeDetail, index) => (
<div key={index}>
<Table
columns={queueColumns}
dataSource={consumeDetail.queueStatInfoList}
rowKey="queueId"
pagination={false}
/>
</div>
))}
</Spin>
</Modal>
);
};
export default ConsumerDetailModal;

View File

@@ -0,0 +1,110 @@
/*
* 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 React, { useState, useEffect } from 'react';
import { Modal, Spin, Checkbox, Button, notification } from 'antd';
import { remoteApi } from '../../api/remoteApi/remoteApi';
import { useLanguage } from '../../i18n/LanguageContext';
const DeleteConsumerModal = ({ visible, group, onCancel, onSuccess }) => {
const { t } = useLanguage();
const [brokerList, setBrokerList] = useState([]);
const [selectedBrokers, setSelectedBrokers] = useState([]);
const [loading, setLoading] = useState(false);
// 获取Broker列表
useEffect(() => {
const fetchBrokers = async () => {
if (!visible) return;
setLoading(true);
try {
const response = await remoteApi.fetchBrokerNameList(group);
if (response.status === 0) {
setBrokerList(response.data);
}
} finally {
setLoading(false);
}
};
fetchBrokers();
}, [visible, group]);
// 处理删除提交
const handleDelete = async () => {
if (selectedBrokers.length === 0) {
notification.warning({ message: t.PLEASE_SELECT_BROKER });
return;
}
setLoading(true);
try {
const response = await remoteApi.deleteConsumerGroup(
group,
selectedBrokers
);
if (response.status === 0) {
notification.success({ message: t.DELETE_SUCCESS });
onSuccess();
onCancel();
}
} finally {
setLoading(false);
}
};
return (
<Modal
title={`${t.DELETE_CONSUMER_GROUP} - ${group}`}
visible={visible}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
{t.CANCEL}
</Button>,
<Button
key="delete"
type="primary"
danger
loading={loading}
onClick={handleDelete}
>
{t.CONFIRM_DELETE}
</Button>
]}
>
<Spin spinning={loading}>
<div style={{ marginBottom: 16 }}>{t.SELECT_DELETE_BROKERS}:</div>
<Checkbox.Group
style={{ width: '100%' }}
value={selectedBrokers}
onChange={values => setSelectedBrokers(values)}
>
{brokerList.map(broker => (
<div key={broker}>
<Checkbox value={broker}>{broker}</Checkbox>
</div>
))}
</Checkbox.Group>
</Spin>
</Modal>
);
};
export default DeleteConsumerModal;

View File

@@ -0,0 +1,76 @@
/*
* 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 { Button, DatePicker, Form, Modal, Select } from "antd";
import React, { useEffect, useState } from "react";
const ConsumerResetOffsetDialog = ({ visible, onClose, topic, allConsumerGroupList, handleResetOffset, t }) => {
const [form] = Form.useForm();
const [selectedConsumerGroup, setSelectedConsumerGroup] = useState([]);
const [selectedTime, setSelectedTime] = useState(null);
useEffect(() => {
if (!visible) {
setSelectedConsumerGroup([]);
setSelectedTime(null);
form.resetFields();
}
}, [visible, form]);
const handleResetButtonClick = () => {
handleResetOffset(selectedConsumerGroup, selectedTime ? selectedTime.valueOf() : null);
};
return (
<Modal
title={`${topic} ${t.RESET_OFFSET}`}
open={visible}
onCancel={onClose}
footer={[
<Button key="reset" type="primary" onClick={handleResetButtonClick}>
{t.RESET}
</Button>,
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item label={t.SUBSCRIPTION_GROUP} required>
<Select
mode="multiple"
placeholder={t.SELECT_CONSUMER_GROUP}
value={selectedConsumerGroup}
onChange={setSelectedConsumerGroup}
options={allConsumerGroupList.map(group => ({ value: group, label: group }))}
/>
</Form.Item>
<Form.Item label={t.TIME} required>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={selectedTime}
onChange={setSelectedTime}
style={{ width: '100%' }}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default ConsumerResetOffsetDialog;

View File

@@ -0,0 +1,96 @@
/*
* 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 moment from "moment/moment";
import {Button, Modal, Table} from "antd";
import React from "react";
const ConsumerViewDialog = ({ visible, onClose, topic, consumerData, consumerGroupCount, t }) => {
const columns = [
{ title: t.BROKER, dataIndex: 'brokerName', key: 'brokerName', align: 'center' },
{ title: t.QUEUE, dataIndex: 'queueId', key: 'queueId', align: 'center' },
{ title: t.CONSUMER_CLIENT, dataIndex: 'clientInfo', key: 'clientInfo', align: 'center' },
{ title: t.BROKER_OFFSET, dataIndex: 'brokerOffset', key: 'brokerOffset', align: 'center' },
{ title: t.CONSUMER_OFFSET, dataIndex: 'consumerOffset', key: 'consumerOffset', align: 'center' },
{
title: t.DIFF_TOTAL,
dataIndex: 'diffTotal',
key: 'diffTotal',
align: 'center',
render: (_, record) => record.brokerOffset - record.consumerOffset,
},
{
title: t.LAST_TIME_STAMP,
dataIndex: 'lastTimestamp',
key: 'lastTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
];
return (
<Modal
title={`${topic} ${t.SUBSCRIPTION_GROUP}`}
open={visible}
onCancel={onClose}
width={1000}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
{consumerGroupCount === 0 ? (
<div>{t.NO_DATA} {t.SUBSCRIPTION_GROUP}</div>
) : (
consumerData && Object.entries(consumerData).map(([consumerGroup, consumeDetail]) => (
<div key={consumerGroup} style={{ marginBottom: '24px' }}>
<Table
bordered
pagination={false}
showHeader={false}
dataSource={[{ consumerGroup, diffTotal: consumeDetail.diffTotal, lastTimestamp: consumeDetail.lastTimestamp }]}
columns={[
{ title: t.SUBSCRIPTION_GROUP, dataIndex: 'consumerGroup', key: 'consumerGroup' },
{ title: t.DELAY, dataIndex: 'diffTotal', key: 'diffTotal' },
{
title: t.LAST_CONSUME_TIME,
dataIndex: 'lastTimestamp',
key: 'lastTimestamp',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
]}
rowKey="consumerGroup"
size="small"
style={{ marginBottom: '12px' }}
/>
<Table
bordered
pagination={false}
dataSource={consumeDetail.queueStatInfoList}
columns={columns}
rowKey={(record, index) => `${record.brokerName}-${record.queueId}-${index}`}
size="small"
/>
</div>
))
)}
</Modal>
);
};
export default ConsumerViewDialog;

View File

@@ -0,0 +1,65 @@
/*
* 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 {Button, Modal, Table} from "antd";
import React from "react";
const ResetOffsetResultDialog = ({ visible, onClose, result, t }) => {
return (
<Modal
title="ResetResult"
open={visible}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
{result && Object.entries(result).map(([groupName, groupData]) => (
<div key={groupName} style={{ marginBottom: '16px', border: '1px solid #f0f0f0', padding: '10px' }}>
<Table
dataSource={[{ groupName, status: groupData.status }]}
columns={[
{ title: 'GroupName', dataIndex: 'groupName', key: 'groupName' },
{ title: 'State', dataIndex: 'status', key: 'status' },
]}
pagination={false}
rowKey="groupName"
size="small"
bordered
/>
{groupData.rollbackStatsList === null ? (
<div>You Should Check It Yourself</div>
) : (
<Table
dataSource={groupData.rollbackStatsList.map((item, index) => ({ key: index, item }))}
columns={[{ dataIndex: 'item', key: 'item' }]}
pagination={false}
rowKey="key"
size="small"
bordered
showHeader={false}
/>
)}
</div>
))}
</Modal>
);
};
export default ResetOffsetResultDialog;

View File

@@ -0,0 +1,111 @@
/*
* 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 { Button, Modal, Table } from "antd";
import React from "react";
const RouterViewDialog = ({ visible, onClose, topic, routeData, t }) => {
const brokerColumns = [
{
title: 'Broker',
dataIndex: 'brokerName',
key: 'brokerName',
},
{
title: 'Broker Addrs',
key: 'brokerAddrs',
render: (_, record) => (
<Table
dataSource={Object.entries(record.brokerAddrs || []).map(([key, value]) => ({ key, idx: key, address: value }))}
columns={[
{ title: 'Index', dataIndex: 'idx', key: 'idx' },
{ title: 'Address', dataIndex: 'address', key: 'address' },
]}
pagination={false}
bordered
size="small"
/>
),
},
];
const queueColumns = [
{
title: t.BROKER_NAME,
dataIndex: 'brokerName',
key: 'brokerName',
},
{
title: t.READ_QUEUE_NUMS,
dataIndex: 'readQueueNums',
key: 'readQueueNums',
},
{
title: t.WRITE_QUEUE_NUMS,
dataIndex: 'writeQueueNums',
key: 'writeQueueNums',
},
{
title: t.PERM,
dataIndex: 'perm',
key: 'perm',
},
];
return (
<Modal
title={`${topic}${t.ROUTER}`}
open={visible}
onCancel={onClose}
width={800}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<div className="limit_height">
<div>
<h3>Broker Datas:</h3>
{routeData?.brokerDatas?.map((item, index) => (
<div key={index} style={{ marginBottom: '15px', border: '1px solid #d9d9d9', padding: '10px' }}>
<Table
dataSource={[item]}
columns={brokerColumns}
pagination={false}
bordered
size="small"
/>
</div>
))}
</div>
<div style={{ marginTop: '20px' }}>
<h3>{t.QUEUE_DATAS}:</h3>
<Table
dataSource={routeData?.queueDatas || []}
columns={queueColumns}
pagination={false}
bordered
size="small"
/>
</div>
</div>
</Modal>
);
};
export default RouterViewDialog;

View File

@@ -0,0 +1,65 @@
/*
* 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 {Button, Form, Modal, Table} from "antd";
import React from "react";
const SendResultDialog = ({ visible, onClose, result, t }) => {
return (
<Modal
title="SendResult"
open={visible}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Form layout="horizontal">
<Table
bordered
dataSource={
result
? Object.entries(result).map(([key, value], index) => ({
key: index,
label: key,
value: typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value),
}))
: []
}
columns={[
{ dataIndex: 'label', key: 'label' },
{
dataIndex: 'value',
key: 'value',
render: (text) => <pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{text}</pre>,
},
]}
pagination={false}
showHeader={false}
rowKey="key"
size="small"
/>
</Form>
</Modal>
);
};
export default SendResultDialog;

View File

@@ -0,0 +1,103 @@
/*
* 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 {Button, Checkbox, Form, Input, Modal} from "antd";
import React, {useEffect} from "react";
import {remoteApi} from "../../api/remoteApi/remoteApi";
const SendTopicMessageDialog = ({
visible,
onClose,
topic,
setSendResultData,
setIsSendResultModalVisible,
setIsSendTopicMessageModalVisible,
message,
t,
}) => {
const [form] = Form.useForm();
useEffect(() => {
if (visible) {
form.setFieldsValue({
topic: topic,
tag: '',
key: '',
messageBody: '',
traceEnabled: false,
});
} else {
form.resetFields();
}
}, [visible, topic, form]);
const handleSendTopicMessage = async () => {
try {
const values = await form.validateFields();
const result = await remoteApi.sendTopicMessage(values);
if (result.status === 0) {
setSendResultData(result.data);
setIsSendResultModalVisible(true);
setIsSendTopicMessageModalVisible(false);
} else {
message.error(result.errMsg);
}
} catch (error) {
console.error("Error sending message:", error);
message.error("Failed to send message");
}
};
return (
<Modal
title={`${t.SEND}[${topic}]${t.MESSAGE}`}
open={visible}
onCancel={onClose}
footer={[
<Button key="commit" type="primary" onClick={handleSendTopicMessage}>
{t.COMMIT}
</Button>,
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item label={t.TOPIC} name="topic">
<Input disabled />
</Form.Item>
<Form.Item label={t.TAG} name="tag">
<Input />
</Form.Item>
<Form.Item label={t.KEY} name="key">
<Input />
</Form.Item>
<Form.Item label={t.MESSAGE_BODY} name="messageBody" rules={[{ required: true, message: t.REQUIRED }]}>
<Input.TextArea
style={{ maxHeight: '200px', minHeight: '200px', resize: 'none' }}
rows={8}
/>
</Form.Item>
<Form.Item label={t.ENABLE_MESSAGE_TRACE} name="traceEnabled" valuePropName="checked">
<Checkbox />
</Form.Item>
</Form>
</Modal>
);
};
export default SendTopicMessageDialog;

View File

@@ -0,0 +1,70 @@
/*
* 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 { Button, Form, message, Modal, Select } from "antd";
import React, { useEffect, useState } from "react";
const SkipMessageAccumulateDialog = ({ visible, onClose, topic, allConsumerGroupList, handleSkipMessageAccumulate, t }) => {
const [form] = Form.useForm();
const [selectedConsumerGroup, setSelectedConsumerGroup] = useState([]);
useEffect(() => {
if (!visible) {
setSelectedConsumerGroup([]);
form.resetFields();
}
}, [visible, form]);
const handleCommit = () => {
if (!selectedConsumerGroup.length) {
message.error(t.PLEASE_SELECT_GROUP);
return;
}
handleSkipMessageAccumulate(selectedConsumerGroup);
onClose();
};
return (
<Modal
title={`${topic} ${t.SKIP_MESSAGE_ACCUMULATE}`}
open={visible}
onCancel={onClose}
footer={[
<Button key="commit" type="primary" onClick={handleCommit}>
{t.COMMIT}
</Button>,
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item label={t.SUBSCRIPTION_GROUP} required>
<Select
mode="multiple"
placeholder={t.SELECT_CONSUMER_GROUP}
value={selectedConsumerGroup}
onChange={setSelectedConsumerGroup}
options={allConsumerGroupList.map(group => ({ value: group, label: group }))}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default SkipMessageAccumulateDialog;

View File

@@ -0,0 +1,66 @@
/*
* 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 moment from "moment/moment";
import {Button, Modal, Table} from "antd";
import React from "react";
const StatsViewDialog = ({ visible, onClose, topic, statsData, t }) => {
const columns = [
{ title: t.QUEUE, dataIndex: 'queue', key: 'queue', align: 'center' },
{ title: t.MIN_OFFSET, dataIndex: 'minOffset', key: 'minOffset', align: 'center' },
{ title: t.MAX_OFFSET, dataIndex: 'maxOffset', key: 'maxOffset', align: 'center' },
{
title: t.LAST_UPDATE_TIME_STAMP,
dataIndex: 'lastUpdateTimestamp',
key: 'lastUpdateTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
];
const dataSource = statsData?.offsetTable ? Object.entries(statsData.offsetTable).map(([queue, info]) => ({
key: queue,
queue: queue,
...info,
})) : [];
return (
<Modal
title={`[${topic}]${t.STATUS}`}
open={visible}
onCancel={onClose}
width={800}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Table
bordered
dataSource={dataSource}
columns={columns}
pagination={false}
rowKey="key"
size="small"
/>
</Modal>
);
};
export default StatsViewDialog;

View File

@@ -0,0 +1,65 @@
/*
* 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.
*/
// TopicModifyDialog.js
import { Button, Modal } from "antd";
import React from "react";
import TopicSingleModifyForm from './TopicSingleModifyForm';
const TopicModifyDialog = ({
visible,
onClose,
initialData,
bIsUpdate,
writeOperationEnabled,
allClusterNameList,
allBrokerNameList,
onSubmit,
t,
}) => {
return (
<Modal
title={bIsUpdate ? t.TOPIC_CHANGE : t.TOPIC_ADD}
open={visible}
onCancel={onClose}
width={700}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
Style={{ maxHeight: '70vh', overflowY: 'auto' }}
>
{initialData.map((data, index) => (
<TopicSingleModifyForm
key={index}
initialData={data}
bIsUpdate={bIsUpdate}
writeOperationEnabled={writeOperationEnabled}
allClusterNameList={allClusterNameList}
allBrokerNameList={allBrokerNameList}
onSubmit={onSubmit}
formIndex={index}
t={t}
/>
))}
</Modal>
);
};
export default TopicModifyDialog;

View File

@@ -0,0 +1,144 @@
/*
* 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.
*/
// TopicSingleModifyForm.js
import React, { useEffect } from "react";
import {Button, Form, Input, Select, Divider, Row, Col} from "antd";
const TopicSingleModifyForm = ({
initialData,
bIsUpdate,
writeOperationEnabled,
allClusterNameList,
allBrokerNameList,
onSubmit,
formIndex,
t,
}) => {
const [form] = Form.useForm();
useEffect(() => {
if (initialData) {
form.setFieldsValue(initialData);
} else {
form.resetFields();
}
}, [initialData, form, formIndex]);
const handleFormSubmit = () => {
form.validateFields()
.then(values => {
const updatedValues = { ...values };
// 提交时,如果 clusterNameList 或 brokerNameList 为空,则填充所有可用的名称
if(!bIsUpdate){
if (!updatedValues.clusterNameList || updatedValues.clusterNameList.length === 0) {
updatedValues.clusterNameList = allClusterNameList;
}
if (!updatedValues.brokerNameList || updatedValues.brokerNameList.length === 0) {
updatedValues.brokerNameList = allBrokerNameList;
}
}
onSubmit(updatedValues, formIndex); // 传递 formIndex
})
.catch(info => {
console.log('Validate Failed:', info);
});
};
const messageTypeOptions = [
{ value: 'TRANSACTION', label: 'TRANSACTION' },
{ value: 'FIFO', label: 'FIFO' },
{ value: 'DELAY', label: 'DELAY' },
{ value: 'NORMAL', label: 'NORMAL' },
];
return (
<div style={{ paddingBottom: 24 }}>
{bIsUpdate && <Divider orientation="left">{`${t.TOPIC_CONFIG} - ${initialData.brokerNameList ? initialData.brokerNameList.join(', ') : t.UNKNOWN_BROKER}`}</Divider>}
<Row justify="center"> {/* 使用 Row 居中内容 */}
<Col span={16}> {/* 表单内容占据 12 栅格宽度,并自动居中 */}
<Form
form={form}
layout="horizontal"
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
>
<Form.Item label={t.CLUSTER_NAME} name="clusterNameList">
<Select
mode="multiple"
disabled={bIsUpdate}
placeholder={t.SELECT_CLUSTER_NAME}
options={allClusterNameList.map(name => ({ value: name, label: name }))}
/>
</Form.Item>
<Form.Item label="BROKER_NAME" name="brokerNameList">
<Select
mode="multiple"
disabled={bIsUpdate}
placeholder={t.SELECT_BROKER_NAME}
options={allBrokerNameList.map(name => ({ value: name, label: name }))}
/>
</Form.Item>
<Form.Item
label={t.TOPIC_NAME}
name="topicName"
rules={[{ required: true, message: `${t.TOPIC_NAME}${t.CANNOT_BE_EMPTY}` }]}
>
<Input disabled={bIsUpdate} />
</Form.Item>
<Form.Item label={t.MESSAGE_TYPE} name="messageType">
<Select
disabled={bIsUpdate}
options={messageTypeOptions}
/>
</Form.Item>
<Form.Item
label={t.WRITE_QUEUE_NUMS}
name="writeQueueNums"
rules={[{ required: true, message: `${t.WRITE_QUEUE_NUMS}${t.CANNOT_BE_EMPTY}` }]}
>
<Input disabled={!writeOperationEnabled} />
</Form.Item>
<Form.Item
label={t.READ_QUEUE_NUMS}
name="readQueueNums"
rules={[{ required: true, message: `${t.READ_QUEUE_NUMS}${t.CANNOT_BE_EMPTY}` }]}
>
<Input disabled={!writeOperationEnabled} />
</Form.Item>
<Form.Item
label={t.PERM}
name="perm"
rules={[{ required: true, message: `${t.PERM}${t.CANNOT_BE_EMPTY}` }]}
>
<Input disabled={!writeOperationEnabled} />
</Form.Item>
{!initialData.sysFlag && writeOperationEnabled && (
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Button type="primary" onClick={handleFormSubmit}>
{t.COMMIT}
</Button>
</Form.Item>
)}
</Form>
</Col>
</Row>
</div>
);
};
export default TopicSingleModifyForm;

View File

@@ -0,0 +1,37 @@
/*
* 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 React, { createContext, useState, useContext } from 'react';
import { translations } from '../i18n';
const LanguageContext = createContext({
lang: 'en',
setLang: () => {},
t: translations['en'], // 当前语言的文本资源
});
export const LanguageProvider = ({ children }) => {
const [lang, setLang] = useState('en');
const t = translations[lang] || translations['en'];
return (
<LanguageContext.Provider value={{ lang, setLang, t }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => useContext(LanguageContext);

View File

@@ -0,0 +1,542 @@
/*
* 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.
*/
export const translations = {
zh: {
"SELECT_TRACE_TOPIC_PLACEHOLDER": "请选择消息轨迹主题",
"TRACE_TOPIC_HINT": "消息轨迹主题",
"ONLY_RETURN_64_MESSAGES": "仅返回64条消息",
"SELECT_TOPIC_PLACEHOLDER": "请选择主题",
"MESSAGE_ID_TOPIC_HINT": "消息ID主题",
"OPERATION_FAILED": "操作失败",
"FORM_VALIDATION_FAILED": "表单验证失败",
"CONFIG_FOR_BROKER": "配置代理",
"RETRY_POLICY": "重试策略",
"CONSUME_TIMEOUT": "消费超时",
"MINUTES": "分钟",
"SYSTEM_FLAG": "系统标志",
"GROUP_NAME": "组名称",
"PLEASE_SELECT_CLUSTER_NAME": "请选择集群名称",
"SELECT_CLUSTERS": "选择集群",
"SELECT_BROKERS": "选择代理",
"CONSUME_ENABLE": "启用消费",
"ORDERLY_CONSUMPTION": "有序消费",
"BROADCAST_CONSUMPTION": "广播消费",
"RETRY_QUEUES": "重试队列",
"MAX_RETRIES": "最大重试次数",
"BROKER_ID": "代理ID",
"SLOW_CONSUMPTION_BROKER": "慢消费代理",
"PLEASE_INPUT_NUMBER": "请输入数字",
"TOPIC_CONFIG": "主题配置",
"TOPIC_ADD": "添加主题",
"SELECT_CLUSTER_NAME": "请选择集群",
"FETCH_TOPIC_FAILED": "获取主题列表失败",
"CONFIRM_DELETE": "确认删除",
"CANCEL": "取消",
"SELECT_DELETE_BROKERS":"请选择在哪个Broker删除消费者组",
"DELETE_CONSUMER_GROUP":"删除消费者组",
"ENGLISH": "英文",
"ADD_CONSUMER":"添加消费者",
"CHINESE": "简体中文",
"CANNOT_BE_EMPTY": "不能为空",
"TITLE": "RocketMQ仪表板",
"CLOSE": "关闭",
"NO": "编号",
"ADDRESS": "地址",
"VERSION": "版本",
"PRO_MSG_TPS": "生产消息TPS",
"CUS_MSG_TPS": "消费消息TPS",
"YESTERDAY_PRO_COUNT": "昨日生产总数",
"YESTERDAY_CUS_COUNT": "昨日消费总数",
"TODAY_PRO_COUNT": "今天生产总数",
"TODAY_CUS_COUNT": "今天消费总数",
"INSTANCE": "实例",
"SPLIT": "分片",
"CLUSTER": "集群",
"CLUSTER_DETAIL": "集群详情",
"COMMIT": "提交",
"TOPIC": "主题",
"SUBSCRIPTION_GROUP":"订阅组",
"PRODUCER_GROUP":"生产组",
"CONSUMER":"消费者",
"PRODUCER":"生产者",
"MESSAGE":"消息",
"MESSAGE_DETAIL":"消息详情",
"RESEND_MESSAGE":"重新发送",
"VIEW_EXCEPTION":"查看异常",
"DLQ_MESSAGE":"死信消息",
"MESSAGETRACE":"消息轨迹",
"OPERATION": "操作",
"ADD": "新增",
"UPDATE": "更新",
"STATUS": "状态",
"ROUTER": "路由",
"MANAGE": "管理",
"CONFIG": "配置",
"SEND_MSG": "发送消息",
"RESET_CUS_OFFSET": "重置消费位点",
"SKIP_MESSAGE_ACCUMULATE":"跳过堆积",
"DELETE": "删除",
"CHANGE_LANG": "更换语言",
"CHANGE_VERSION": "更换版本",
"BROKER": "Broker",
"NORMAL": "普通",
"RETRY": "重试",
"FIFO": "顺序",
"TRANSACTION": "事务",
"UNSPECIFIED": "未指定",
"DLQ": "死信",
"QUANTITY":"数量",
"TYPE":"类型",
"MODE":"模式",
"DELAY":"延迟",
"DASHBOARD":"驾驶舱",
"CONSUME_DETAIL":"消费详情",
"CLIENT":"终端",
"LAST_CONSUME_TIME":"最后消费时间",
"TIME":"时间点",
"RESET":"重置",
"DATE":"日期",
"NO_DATA":"暂无数据",
"SEARCH":"搜索",
"BEGIN":"开始",
"END":"结束",
"TOPIC_CHANGE":"修改主题",
"SEND":"发送",
"SUBSCRIPTION_CHANGE":"修改订阅",
"QUEUE":"队列",
"MIN_OFFSET":"最小位点",
"MAX_OFFSET":"最大位点",
"LAST_UPDATE_TIME_STAMP":"上次更新时间",
"QUEUE_DATAS":"队列信息",
"READ_QUEUE_NUMS":"读队列数量",
"WRITE_QUEUE_NUMS":"写队列数量",
"PERM":"perm",
"TAG":"标签",
"KEY":"值",
"MESSAGE_BODY":"消息主体",
"TOPIC_NAME":"主题名",
"ORDER":"顺序",
"CONSUMER_CLIENT":"消费者终端",
"BROKER_OFFSET":"代理者位点",
"CONSUMER_OFFSET":"消费者位点",
"DIFF_TOTAL":"差值",
"LAST_TIME_STAMP":"上次时间",
"RESET_OFFSET":"重置位点",
"CLUSTER_NAME":"集群名",
"OPS":"运维",
"PROXY":"代理",
"AUTO_REFRESH":"自动刷新",
"REFRESH":"刷新",
"LOGOUT":"退出",
"LOGIN":"登录",
"USER_NAME":"用户名",
"PASSWORD":"密码",
"SYSTEM":"系统",
"WELCOME":"您好欢迎使用RocketMQ仪表盘",
"ENABLE_MESSAGE_TRACE":"开启消息轨迹",
"MESSAGE_TRACE_DETAIL":"消息轨迹详情",
"TRACE_TOPIC":"消息轨迹主题",
"SELECT_TRACE_TOPIC":"选择消息轨迹主题",
"EXPORT": "导出",
"NO_MATCH_RESULT": "没有查到符合条件的结果",
"BATCH_RESEND": "批量重发",
"BATCH_EXPORT": "批量导出",
"WHITE_LIST":"白名单",
"ACCOUNT_INFO":"账户信息",
"IS_ADMIN":"是否管理员",
"DEFAULT_TOPIC_PERM":"topic默认权限",
"DEFAULT_GROUP_PERM":"消费组默认权限",
"TOPIC_PERM":"topic权限",
"GROUP_PERM":"消费组权限",
"SYNCHRONIZE":"同步",
"SHOW":"显示",
"HIDE":"隐藏",
"MESSAGE_TYPE":"消息类型",
"MESSAGE_TYPE_UNSPECIFIED": "未指定,为普通消息",
"MESSAGE_TYPE_NORMAL": "普通消息",
"MESSAGE_TYPE_FIFO": "顺序消息",
"MESSAGE_TYPE_DELAY": "定时/延时消息",
"MESSAGE_TYPE_TRANSACTION": "事务消息",
"UPDATE_TIME": "更新时间",
"TREND": "趋势",
"PROXY_CONFIG": "代理配置",
"READ_MORE": "阅读更多",
"FETCH_PROXY_LIST_FAILED": "获取代理列表失败",
"INPUT_PROXY_ADDR_REQUIRED": "请输入代理地址",
"SUCCESS": "成功",
"ADD_PROXY_FAILED": "添加代理失败",
"INPUT_PROXY_ADDR": "输入代理地址",
"NO_CONFIG_DATA": "无配置数据",
"FETCH_MESSAGE_DETAIL_FAILED": "获取消息详情失败",
"MESSAGE_INFO": "消息信息",
"MESSAGE_PROPERTIES": "消息属性",
"SHOW_ALL_CONTENT": "显示全部内容",
"MESSAGE_TRACKING": "消息追踪",
"CONSUMER_GROUP": "消费者组",
"PLEASE_SELECT_BROKER": "请选择Broker",
"DELETE_SUCCESS": "删除成功",
"FAILED_TO_FETCH_DATA": "获取数据失败",
"REFRESH_SUCCESS": "刷新成功",
"REFRESH_FAILED": "刷新失败",
"REFRESHED": "已刷新",
"QUERY_BROKER_HISTORY_FAILED": "查询Broker历史失败",
"QUERY_TOPIC_HISTORY_FAILED": "查询Topic历史失败",
"QUERY_CLUSTER_LIST_FAILED": "查询集群列表失败",
"QUERY_TOPIC_CURRENT_FAILED": "查询当前Topic失败",
"BROKER_NAME": "Broker名称",
"BROKER_ADDR": "Broker地址",
"WARNING": "警告",
"PLEASE_SELECT_CONSUMER_GROUP": "请选择消费者组",
"END_TIME_LATER_THAN_BEGIN_TIME": "结束时间应晚于开始时间",
"NO_RESULT": "无结果",
"QUERY_FAILED": "查询失败",
"MESSAGE_ID_AND_CONSUMER_GROUP_REQUIRED": "消息ID和消费者组为必填项",
"RESEND_SUCCESS": "重新发送成功",
"RESULT": "结果",
"TOPIC_AND_KEY_REQUIRED": "Topic和Key为必填项",
"MESSAGE_ID_REQUIRED": "消息ID为必填项",
"REFRESHING_TOPIC_LIST": "正在刷新Topic列表",
"TOPIC_OPERATION_SUCCESS": "Topic操作成功",
"ARE_YOU_SURE_TO_DELETE": "您确定要删除吗?",
"YES": "是",
"NOT": "否",
"BLUE": "蓝色",
"GREEN": "绿色",
"PINK": "粉色",
"DEFAULT": "默认",
"INVALID_IP_ADDRESSES": "以下IP地址不合法: ",
"ENABLED": "启用",
"DISABLED": "禁用",
"GET_USERS_FAILED": "获取用户列表失败: ",
"UNKNOWN_ERROR": "未知错误",
"GET_USERS_EXCEPTION": "获取用户列表异常",
"N_A": "N/A",
"INVALID_OR_EMPTY_ACL_DATA": "收到无效或空的ACL数据。",
"GET_ACLS_FAILED": "获取ACL列表失败: ",
"GET_ACLS_EXCEPTION": "获取ACL列表异常",
"USER_DELETE_SUCCESS": "用户删除成功",
"USER_DELETE_FAILED": "用户删除失败: ",
"USER_DELETE_EXCEPTION": "用户删除异常",
"USER_UPDATE_SUCCESS": "用户更新成功",
"USER_CREATE_SUCCESS": "用户创建成功",
"SAVE_USER_FAILED": "保存用户失败",
"ACL_DELETE_SUCCESS": "ACL 删除成功",
"ACL_DELETE_FAILED": "ACL 删除失败: ",
"ACL_DELETE_EXCEPTION": "ACL 删除异常",
"ACL_UPDATE_SUCCESS": "ACL 更新成功",
"ACL_UPDATE_FAILED": "ACL 更新失败: ",
"ACL_CREATE_SUCCESS": "ACL 创建成功",
"ACL_CREATE_FAILED": "ACL 创建失败: ",
"SAVE_ACL_FAILED": "保存 ACL 失败",
"USERNAME": "用户名",
"VIEW": "查看",
"USER_TYPE": "用户类型",
"USER_STATUS": "用户状态",
"MODIFY": "修改",
"CONFIRM_DELETE_USER": "确定删除此用户吗?",
"USERNAME_SUBJECT": "用户名/Subject",
"POLICY_TYPE": "策略类型",
"RESOURCE_NAME": "资源名",
"OPERATION_TYPE": "操作类型",
"SOURCE_IP": "来源IP",
"DECISION": "决策",
"CONFIRM_DELETE_ACL": "确定删除此ACL吗",
"ACL_MANAGEMENT": "ACL 管理",
"ACL_USERS": "ACL 用户",
"ACL_PERMISSIONS": "ACL 权限",
"ADD_USER": "添加用户",
"ADD_ACL_PERMISSION": "添加 ACL 权限",
"SEARCH_PLACEHOLDER": "搜索...",
"USER": "用户",
"ACL_PERMISSION": "ACL 权限",
"EDIT_USER": "编辑用户",
"CONFIRM": "确认",
"PLEASE_ENTER_USERNAME": "请输入用户名!",
"PLEASE_ENTER_PASSWORD": "请输入密码!",
"PLEASE_SELECT_USER_TYPE": "请选择用户类型!",
"PLEASE_SELECT_USER_STATUS": "请选择用户状态!",
"EDIT_ACL_PERMISSION": "编辑 ACL 权限",
"SUBJECT_LABEL": "Subject (例如: User:yourUsername)",
"PLEASE_ENTER_SUBJECT": "请输入 Subject!",
"PLEASE_ENTER_POLICY_TYPE": "请输入策略类型!",
"RESOURCE": "资源",
"PLEASE_ADD_RESOURCE": "请添加资源!",
"ENTER_IP_HINT": "请输入 IP 地址,按回车键添加,支持 IPv4、IPv6 和 CIDR",
"PLEASE_ENTER_DECISION": "请输入决策!",
"MENU": "菜单",
},
en: {
"DEFAULT": "Default",
"BLUE": "Blue",
"GREEN": "Green",
"PINK": "Pink",
"NOT": "No",
"ARE_YOU_SURE_TO_DELETE": "Are you sure to delete?",
"YES": "Yes",
"SELECT_TRACE_TOPIC_PLACEHOLDER": "Please select trace topic",
"TRACE_TOPIC_HINT": "Trace Topic",
"ONLY_RETURN_64_MESSAGES": "Only return 64 messages",
"SELECT_TOPIC_PLACEHOLDER": "Please select topic",
"MESSAGE_ID_TOPIC_HINT": "Message ID Topic",
"TOPIC_ADD": "Add Topic",
"SKIP_MESSAGE_ACCUMULATE":"Skip Message Accumulate",
"OPERATION_FAILED": "Operation Failed",
"FORM_VALIDATION_FAILED": "Form Validation Failed",
"ADD_CONSUMER": "Add Consumer",
"CONFIG_FOR_BROKER": "Config for Broker",
"RETRY_POLICY": "Retry Policy",
"CONSUME_TIMEOUT": "Consume Timeout",
"MINUTES": "Minutes",
"SYSTEM_FLAG": "System Flag",
"GROUP_NAME": "Group Name",
"CANNOT_BE_EMPTY": "Cannot be empty",
"PLEASE_SELECT_CLUSTER_NAME": "Please select cluster name",
"SELECT_CLUSTERS": "Select Clusters",
"SELECT_BROKERS": "Select Brokers",
"CONSUME_ENABLE": "Consume Enable",
"ORDERLY_CONSUMPTION": "Orderly Consumption",
"BROADCAST_CONSUMPTION": "Broadcast Consumption",
"RETRY_QUEUES": "Retry Queues",
"MAX_RETRIES": "Max Retries",
"BROKER_ID": "Broker ID",
"SLOW_CONSUMPTION_BROKER": "Slow Consumption Broker",
"PLEASE_INPUT_NUMBER": "Please input number",
"FETCH_TOPIC_FAILED": "Failed to fetch topic list",
"ENGLISH": "English",
"CHINESE": "Chinese",
"TITLE": "RocketMQ-Dashboard",
"CLOSE": "Close",
"NO": "NO.",
"ADDRESS": "Address",
"VERSION": "Version",
"PRO_MSG_TPS": "Produce Message TPS",
"CUS_MSG_TPS": "Consumer Message TPS",
"YESTERDAY_PRO_COUNT": "Yesterday Produce Count",
"YESTERDAY_CUS_COUNT": "Yesterday Consume Count",
"TODAY_PRO_COUNT": "Today Produce Count",
"TODAY_CUS_COUNT": "Today Consume Count",
"INSTANCE": "Instance",
"SPLIT": "Broker",
"CLUSTER": "Cluster",
"CLUSTER_DETAIL": "Cluster Detail",
"TOPIC": "Topic",
"SUBSCRIPTION_GROUP":"SubscriptionGroup",
"PRODUCER_GROUP":"ProducerGroup",
"CONSUMER":"Consumer",
"PRODUCER":"Producer",
"MESSAGE":"Message",
"MESSAGE_DETAIL":"Message Detail",
"RESEND_MESSAGE":"Resend Message",
"VIEW_EXCEPTION":"View Exception",
"MESSAGETRACE":"MessageTrace",
"DLQ_MESSAGE":"DLQMessage",
"COMMIT": "Commit",
"OPERATION": "Operation",
"ADD": "Add",
"UPDATE": "Update",
"STATUS": "Status",
"ROUTER": "Router",
"MANAGE": "Manage",
"CONFIG": "Config",
"SEND_MSG": "Send Massage",
"RESET_CUS_OFFSET": "Reset Consumer Offset",
"DELETE": "Delete",
"CHANGE_LANG": "ChangeLanguage",
"CHANGE_VERSION": "ChangeVersion",
"BROKER": "Broker",
"NORMAL": "NORMAL",
"RETRY": "RETRY",
"FIFO": "FIFO",
"TRANSACTION": "TRANSACTION",
"UNSPECIFIED": "UNSPECIFIED",
"DLQ": "DLQ",
"QUANTITY":"Quantity",
"TYPE":"Type",
"MODE":"Mode",
"DELAY":"Delay",
"DASHBOARD":"Dashboard",
"CONSUME_DETAIL":"CONSUME DETAIL",
"CLIENT":"CLIENT",
"LAST_CONSUME_TIME":"LastConsumeTime",
"TIME":"Time",
"RESET":"RESET",
"DATE":"Date",
"NO_DATA":"NO DATA",
"SEARCH":"Search",
"BEGIN":"Begin",
"END":"End",
"TOPIC_CHANGE":"Topic Change",
"SEND":"Send",
"SUBSCRIPTION_CHANGE":"Subscription Change",
"QUEUE":"Queue",
"MIN_OFFSET":"minOffset",
"MAX_OFFSET":"maxOffset",
"LAST_UPDATE_TIME_STAMP":"lastUpdateTimeStamp",
"QUEUE_DATAS":"queueDatas",
"READ_QUEUE_NUMS":"readQueueNums",
"WRITE_QUEUE_NUMS":"writeQueueNums",
"PERM":"perm",
"TAG":"Tag",
"KEY":"Key",
"MESSAGE_BODY":"Message Body",
"TOPIC_NAME":"topicName",
"ORDER":"order",
"CONSUMER_CLIENT":"consumerClient",
"BROKER_OFFSET":"brokerOffset",
"CONSUMER_OFFSET":"consumerOffset",
"DIFF_TOTAL":"diffTotal",
"LAST_TIME_STAMP":"lastTimeStamp",
"RESET_OFFSET":"resetOffset",
"CLUSTER_NAME":"clusterName",
"OPS":"OPS",
"PROXY":"Proxy",
"AUTO_REFRESH":"AUTO_REFRESH",
"REFRESH":"REFRESH",
"LOGOUT":"Logout",
"LOGIN":"Login",
"USER_NAME":"Username",
"PASSWORD":"Password",
"SYSTEM":"SYSTEM",
"WELCOME":"Hi, welcome using RocketMQ Dashboard",
"ENABLE_MESSAGE_TRACE":"Enable Message Trace",
"MESSAGE_TRACE_DETAIL":"Message Trace Detail",
"TRACE_TOPIC":"TraceTopic",
"SELECT_TRACE_TOPIC":"selectTraceTopic",
"EXPORT": "export",
"NO_MATCH_RESULT": "no match result",
"BATCH_RESEND": "batchReSend",
"BATCH_EXPORT": "batchExport",
"WHITE_LIST":"White List",
"ACCOUNT_INFO":"Account Info",
"IS_ADMIN":"Is Admin",
"DEFAULT_TOPIC_PERM":"Default Topic Permission",
"DEFAULT_GROUP_PERM":"Default Group Permission",
"TOPIC_PERM":"Topic Permission",
"GROUP_PERM":"Group Permission",
"SYNCHRONIZE":"Synchronize Data",
"SHOW":"Show",
"HIDE":"Hide",
"MESSAGE_TYPE":"messageType",
"MESSAGE_TYPE_UNSPECIFIED": "UNSPECIFIED, is NORMAL",
"MESSAGE_TYPE_NORMAL": "NORMAL",
"MESSAGE_TYPE_FIFO": "FIFO",
"MESSAGE_TYPE_DELAY": "DELAY",
"MESSAGE_TYPE_TRANSACTION": "TRANSACTION",
"UPDATE_TIME": "Update Time",
"TREND": "trend",
"FETCH_PROXY_LIST_FAILED": "Failed to fetch proxy list",
"INPUT_PROXY_ADDR_REQUIRED": "Input proxy address required",
"SUCCESS": "Success",
"ADD_PROXY_FAILED": "Failed to add proxy",
"INPUT_PROXY_ADDR": "Input proxy address",
"NO_CONFIG_DATA": "No configuration data",
"FETCH_MESSAGE_DETAIL_FAILED": "Failed to fetch message details",
"MESSAGE_INFO": "Message info",
"MESSAGE_PROPERTIES": "Message properties",
"SHOW_ALL_CONTENT": "Show all content",
"MESSAGE_TRACKING": "Message tracking",
"CONSUMER_GROUP": "Consumer group",
"PLEASE_SELECT_BROKER": "Please select a broker",
"DELETE_SUCCESS": "Delete successful",
"FAILED_TO_FETCH_DATA": "Failed to fetch data",
"REFRESH_SUCCESS": "Refresh successful",
"REFRESH_FAILED": "Refresh failed",
"REFRESHED": "Refreshed",
"QUERY_BROKER_HISTORY_FAILED": "Failed to query broker history",
"QUERY_TOPIC_HISTORY_FAILED": "Failed to query topic history",
"QUERY_CLUSTER_LIST_FAILED": "Failed to query cluster list",
"QUERY_TOPIC_CURRENT_FAILED": "Failed to query current topic",
"BROKER_NAME": "Broker name",
"BROKER_ADDR": "Broker address",
"WARNING": "Warning",
"PLEASE_SELECT_CONSUMER_GROUP": "Please select a consumer group",
"END_TIME_LATER_THAN_BEGIN_TIME": "End time should be later than begin time",
"NO_RESULT": "No result",
"QUERY_FAILED": "Query failed",
"MESSAGE_ID_AND_CONSUMER_GROUP_REQUIRED": "Message ID and consumer group required",
"RESEND_SUCCESS": "Resend successful",
"RESULT": "Result",
"TOPIC_AND_KEY_REQUIRED": "Topic and key required",
"MESSAGE_ID_REQUIRED": "Message ID required",
"REFRESHING_TOPIC_LIST": "Refreshing topic list",
"TOPIC_OPERATION_SUCCESS": "Topic operation successful",
"INVALID_IP_ADDRESSES": "The following IP addresses are invalid: ",
"ENABLED": "Enabled",
"DISABLED": "Disabled",
"GET_USERS_FAILED": "Failed to get user list: ",
"UNKNOWN_ERROR": "Unknown error",
"GET_USERS_EXCEPTION": "Exception getting user list",
"N_A": "N/A",
"INVALID_OR_EMPTY_ACL_DATA": "Received invalid or empty ACL data.",
"GET_ACLS_FAILED": "Failed to get ACL list: ",
"GET_ACLS_EXCEPTION": "Exception getting ACL list",
"USER_DELETE_SUCCESS": "User deleted successfully",
"USER_DELETE_FAILED": "User deletion failed: ",
"USER_DELETE_EXCEPTION": "User deletion exception",
"USER_UPDATE_SUCCESS": "User updated successfully",
"USER_CREATE_SUCCESS": "User created successfully",
"SAVE_USER_FAILED": "Failed to save user",
"ACL_DELETE_SUCCESS": "ACL deleted successfully",
"ACL_DELETE_FAILED": "ACL deletion failed: ",
"ACL_DELETE_EXCEPTION": "ACL deletion exception",
"ACL_UPDATE_SUCCESS": "ACL updated successfully",
"ACL_UPDATE_FAILED": "ACL update failed: ",
"ACL_CREATE_SUCCESS": "ACL created successfully",
"ACL_CREATE_FAILED": "ACL creation failed: ",
"SAVE_ACL_FAILED": "Failed to save ACL",
"USERNAME": "Username",
"VIEW": "View",
"USER_TYPE": "User Type",
"USER_STATUS": "User Status",
"MODIFY": "Modify",
"CONFIRM_DELETE_USER": "Are you sure you want to delete this user?",
"USERNAME_SUBJECT": "Username/Subject",
"POLICY_TYPE": "Policy Type",
"RESOURCE_NAME": "Resource Name",
"OPERATION_TYPE": "Operation Type",
"SOURCE_IP": "Source IP",
"DECISION": "Decision",
"CONFIRM_DELETE_ACL": "Are you sure you want to delete this ACL?",
"ACL_MANAGEMENT": "ACL Management",
"ACL_USERS": "ACL Users",
"ACL_PERMISSIONS": "ACL Permissions",
"ADD_USER": "Add User",
"ADD_ACL_PERMISSION": "Add ACL Permission",
"SEARCH_PLACEHOLDER": "Search ...",
"USER": "User",
"ACL_PERMISSION": "ACL Permission",
"EDIT_USER": "Edit User",
"CANCEL": "Cancel",
"CONFIRM": "Confirm",
"PLEASE_ENTER_USERNAME": "Please enter username!",
"PLEASE_ENTER_PASSWORD": "Please enter password!",
"PLEASE_SELECT_USER_TYPE": "Please select user type!",
"PLEASE_SELECT_USER_STATUS": "Please select user status!",
"EDIT_ACL_PERMISSION": "Edit ACL Permission",
"SUBJECT_LABEL": "Subject (e.g.: User:yourUsername)",
"PLEASE_ENTER_SUBJECT": "Please enter Subject!",
"PLEASE_ENTER_POLICY_TYPE": "Please enter policy type!",
"RESOURCE": "Resource",
"PLEASE_ADD_RESOURCE": "Please add resource!",
"ENTER_IP_HINT": "Please enter IP address, press Enter to add. Supports IPv4, IPv6, and CIDR.",
"PLEASE_ENTER_DECISION": "Please enter decision!",
"MENU": "Menu",
}
};

View File

@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

View File

@@ -14,17 +14,30 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { App as AntdApp } from 'antd';
import reportWebVitals from './reportWebVitals';
import {LanguageProvider} from "./i18n/LanguageContext";
import {Provider} from "react-redux";
import store from './store';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<LanguageProvider>
<React.StrictMode>
<AntdApp>
<Provider store={store}>
<App/>
</Provider>
</AntdApp>
</React.StrictMode>
</LanguageProvider>
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function

View File

@@ -0,0 +1,799 @@
/*
* 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 React, { useState, useEffect } from 'react';
import {
Table,
Button,
Input,
Tabs,
Modal,
Form,
message,
Space,
Tag,
Popconfirm,
Select
} from 'antd';
import {
EditOutlined,
DeleteOutlined,
EyeOutlined,
EyeInvisibleOutlined
} from '@ant-design/icons';
import {remoteApi} from "../../api/remoteApi/remoteApi";
import ResourceInput from '../../components/acl/ResourceInput';
import SubjectInput from "../../components/acl/SubjectInput";
import {useLanguage} from "../../i18n/LanguageContext";
const { TabPane } = Tabs;
const { Search } = Input;
const Acl = () => {
const [activeTab, setActiveTab] = useState('users');
const [userListData, setUserListData] = useState([]);
const [aclListData, setAclListData] = useState([]);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const [isUserModalVisible, setIsUserModalVisible] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
const [userForm] = Form.useForm();
const [showPassword, setShowPassword] = useState(false);
const [isAclModalVisible, setIsAclModalVisible] = useState(false);
const [currentAcl, setCurrentAcl] = useState(null);
const [aclForm] = Form.useForm();
const [messageApi, msgContextHolder] = message.useMessage();
const [isUpdate, setIsUpdate] = useState(false);
const [ips, setIps] = useState([]);
const {t} = useLanguage();
// 校验IP地址的正则表达式
const ipRegex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^((?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){6}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,1}|(?:[0-9A-Fa-f]{1,4}:){5}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,2}|(?:[0-9A-Fa-f]{1,4}:){4}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,3}|(?:[0-9A-Fa-f]{1,4}:){3}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,4}|(?:[0-9A-Fa-f]{1,4}:){2}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5}|(?:[0-9A-Fa-f]{1,4}:){1}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6}|(?::(?::[0-9A-Fa-f]{1,4}){1,7}|::))(\/(?:12[0-7]|1[0-1][0-9]|[1-9]?[0-9]))?$/;
// 支持 IPv4 和 IPv6包括 CIDR 表示法
// State to store the entire clusterInfo object for easy access
const [clusterData, setClusterData] = useState(null);
// State for the list of available cluster names for the dropdown
const [clusterNamesOptions, setClusterNamesOptions] = useState([]);
// State for the currently selected cluster name
const [selectedCluster, setSelectedCluster] = useState(undefined);
// State for the list of available broker names for the dropdown (depends on selectedCluster)
const [brokerNamesOptions, setBrokerNamesOptions] = useState([]);
// State for the currently selected broker name
const [selectedBroker, setSelectedBroker] = useState(undefined);
// State for the address of the selected broker
const [brokerAddress, setBrokerAddress] = useState(undefined);
// --- Data Fetching and Initial Setup ---
useEffect(() => {
const fetchData = async () => {
const clusterResponse = await remoteApi.getClusterList();
if (clusterResponse.status === 0 && clusterResponse.data) {
const { clusterInfo } = clusterResponse.data;
setClusterData(clusterInfo); // Store the entire clusterInfo
// Populate cluster names for the first dropdown
const clusterNames = Object.keys(clusterInfo?.clusterAddrTable || {});
setClusterNamesOptions(clusterNames.map(name => ({ label: name, value: name })));
// Set initial selections if clusters are available
if (clusterNames.length > 0) {
const defaultCluster = clusterNames[0];
setSelectedCluster(defaultCluster);
// Manually trigger broker list update for the default cluster
updateBrokerOptions(defaultCluster, clusterInfo);
// Set default broker and its address if available
const brokersInDefaultCluster = clusterInfo.clusterAddrTable[defaultCluster] || [];
if (brokersInDefaultCluster.length > 0) {
const defaultBroker = brokersInDefaultCluster[0];
setSelectedBroker(defaultBroker);
// Get the address from brokerAddrTable using the defaultBroker name
const addr = clusterInfo.brokerAddrTable?.[defaultBroker]?.brokerAddrs?.["0"];
setBrokerAddress(addr);
}
}
} else {
console.error('Failed to fetch cluster list:', clusterResponse.errMsg);
}
};
if(!clusterData){
fetchData();
}
if(brokerAddress){
// Call fetchUsers or fetchAcls based on activeTab initially
if (activeTab === 'users') {
fetchUsers();
} else {
fetchAcls();
}
}
}, [activeTab]); // Dependencies for useEffect
// --- Helper function to update broker options based on selected cluster ---
const updateBrokerOptions = (clusterName, info = clusterData) => {
if (!info || !info.clusterAddrTable) {
setBrokerNamesOptions([]);
return;
}
const brokersInCluster = info.clusterAddrTable[clusterName] || [];
setBrokerNamesOptions(brokersInCluster.map(broker => ({ label: broker, value: broker })));
};
// --- Event Handlers ---
const handleClusterChange = (value) => {
setSelectedCluster(value);
setSelectedBroker(undefined); // Reset broker selection
setBrokerAddress(undefined); // Reset broker address
// Update the broker options based on the newly selected cluster
updateBrokerOptions(value);
};
const handleBrokerChange = (value) => {
setSelectedBroker(value);
// Find the corresponding broker address from clusterData
if (clusterData && clusterData.brokerAddrTable && clusterData.brokerAddrTable[value]) {
const addr = clusterData.brokerAddrTable[value].brokerAddrs?.["0"];
setBrokerAddress(addr);
} else {
setBrokerAddress(undefined);
}
};
// --- Log selected values for debugging (optional) ---
useEffect(() => {
console.log('Selected Cluster:', selectedCluster);
console.log('Selected Broker:', selectedBroker);
console.log('Broker Address:', brokerAddress);
}, [selectedCluster, selectedBroker, brokerAddress]);
const handleIpChange = value => {
// 过滤掉重复的IP地址
const uniqueIps = Array.from(new Set(value));
setIps(uniqueIps);
};
const handleIpDeselect = value => {
// 移除被取消选择的IP
setIps(ips.filter(ip => ip !== value));
};
const validateIp = (rule, value) => {
if (!value || value.length === 0) {
return Promise.resolve(); // Allow empty
}
const invalidIps = value.filter(ip => !ipRegex.test(ip));
if (invalidIps.length > 0) {
return Promise.reject(t.INVALID_IP_ADDRESSES +"ips:" + invalidIps.join(', '));
}
return Promise.resolve();
};
// --- Data Loading Functions ---
const fetchUsers = async () => {
setLoading(true);
try {
const result = await remoteApi.listUsers(brokerAddress);
if (result && result.status === 0 && result.data) {
const formattedUsers = result.data.map(user => ({
...user,
key: user.username, // Table needs key
userStatus: user.userStatus === 'enable' ? t.ENABLED : t.DISABLED // Format status
}));
setUserListData(formattedUsers);
} else {
messageApi.error(t.GET_USERS_FAILED+result?.errMsg);
}
} catch (error) {
console.error("Failed to fetch users:", error);
messageApi.error(t.GET_USERS_EXCEPTION);
} finally {
setLoading(false);
}
};
const fetchAcls = async (value) => {
setLoading(true);
try {
const result = await remoteApi.listAcls(brokerAddress, value);
if (result && result.status === 0) {
const formattedAcls = [];
if (result && result.data && Array.isArray(result.data)) {
result.data.forEach((acl, aclIndex) => {
const subject = acl.subject;
if (acl.policies && Array.isArray(acl.policies)) {
acl.policies.forEach((policy, policyIndex) => {
const policyType = policy.policyType;
if (policy.entries && Array.isArray(policy.entries)) {
policy.entries.forEach((entry, entryIndex) => {
const resources = Array.isArray(entry.resource) ? entry.resource : (entry.resource ? [entry.resource] : []);
resources.forEach((singleResource, resourceIndex) => {
console.log(singleResource)
formattedAcls.push({
key: `acl-${aclIndex}-policy-${policyIndex}-entry-${entryIndex}-resource-${singleResource}`,
subject: subject,
policyType: policyType,
resource: singleResource || t.N_A,
actions: (entry.actions && Array.isArray(entry.actions)) ? entry.actions.join(', ') : '',
sourceIps: (entry.sourceIps && Array.isArray(entry.sourceIps)) ? entry.sourceIps.join(', ') : t.N_A,
decision: entry.decision || t.N_A
});
});
});
}
});
}
});
} else {
console.warn(t.INVALID_OR_EMPTY_ACL_DATA);
}
setAclListData(formattedAcls);
} else {
messageApi.error(t.GET_ACLS_FAILED + result?.errMsg);
}
} catch (error) {
console.error("Failed to fetch ACLs:", error);
messageApi.error(t.GET_ACLS_EXCEPTION);
} finally {
setLoading(false);
}
};
// --- User Management Logic ---
const handleAddUser = () => {
setCurrentUser(null);
userForm.resetFields();
setShowPassword(false);
setIsUserModalVisible(true);
};
const handleEditUser = (record) => {
setCurrentUser(record);
userForm.setFieldsValue({
username: record.username,
password: record.password,
userType: record.userType,
userStatus: record.userStatus === t.ENABLED ? 'enable' : 'disable'
});
setShowPassword(false);
setIsUserModalVisible(true);
};
const handleDeleteUser = async (username) => {
setLoading(true);
try {
const result = await remoteApi.deleteUser(brokerAddress, username);
if (result.status === 0) {
messageApi.success(t.USER_DELETE_SUCCESS);
fetchUsers(brokerAddress);
} else {
messageApi.error(t.USER_DELETE_FAILED + result.errMsg);
}
} catch (error) {
console.error("Failed to delete user:", error);
messageApi.error(t.USER_DELETE_EXCEPTION);
} finally {
setLoading(false);
}
};
const handleUserModalOk = async () => {
try {
const values = await userForm.validateFields();
setLoading(true);
let result;
const userInfoParam = {
username: values.username,
password: values.password,
userType: values.userType,
userStatus: values.userStatus,
};
if (currentUser) {
result = await remoteApi.updateUser(brokerAddress, userInfoParam);
if (result.status === 0) {
messageApi.success(t.USER_UPDATE_SUCCESS);
} else {
messageApi.error(result.errMsg);
}
} else {
result = await remoteApi.createUser(brokerAddress, userInfoParam);
if (result.status === 0) {
messageApi.success(t.USER_CREATE_SUCCESS);
} else {
messageApi.error(result.errMsg);
}
}
setIsUserModalVisible(false);
fetchUsers();
} catch (error) {
console.error("Failed to save user:", error);
messageApi.error(t.SAVE_USER_FAILED);
} finally {
setLoading(false);
}
};
// --- ACL Permission Management Logic ---
const handleAddAcl = () => {
setCurrentAcl(null);
setIsUpdate(false)
aclForm.resetFields();
setIsAclModalVisible(true);
};
const handleEditAcl = (record) => {
setCurrentAcl(record);
setIsUpdate(true);
aclForm.setFieldsValue({
subject: record.subject,
policyType: record.policyType,
resource: record.resource,
actions: record.actions ? record.actions.split(', ') : [],
sourceIps: record.sourceIps ? record.sourceIps.split(', ') : [],
decision: record.decision
});
setIsAclModalVisible(true);
};
const handleDeleteAcl = async (subject, resource) => {
setLoading(true);
try {
const result = await remoteApi.deleteAcl(brokerAddress, subject, resource);
if (result.status === 0) {
messageApi.success(t.ACL_DELETE_SUCCESS);
fetchAcls();
} else {
messageApi.error(t.ACL_DELETE_FAILED+result.errMsg);
}
} catch (error) {
console.error("Failed to delete ACL:", error);
messageApi.error(t.ACL_DELETE_EXCEPTION);
} finally {
setLoading(false);
}
};
const handleAclModalOk = async () => {
try {
const values = await aclForm.validateFields();
setLoading(true);
let result;
const policiesParam = [
{
policyType: values.policyType,
entries: [
{
resource: isUpdate ? [values.resource] : values.resource,
actions: values.actions,
sourceIps: values.sourceIps,
decision: values.decision
}
]
}
];
if (isUpdate) { // This condition seems reversed for update/create based on the current logic.
result = await remoteApi.updateAcl(brokerAddress, values.subject, policiesParam);
if (result.status === 0) {
messageApi.success(t.ACL_UPDATE_SUCCESS);
setIsAclModalVisible(false);
fetchAcls(brokerAddress);
} else {
messageApi.error(t.ACL_UPDATE_FAILED+result.errMsg);
}
setIsUpdate(false)
} else {
result = await remoteApi.createAcl(brokerAddress, values.subject, policiesParam);
console.log(result)
if (result.status === 0) {
messageApi.success(t.ACL_CREATE_SUCCESS);
setIsAclModalVisible(false);
fetchAcls(brokerAddress);
} else {
messageApi.error(t.ACL_CREATE_FAILED+result.errMsg);
}
}
} catch (error) {
console.error("Failed to save ACL:", error);
messageApi.error(t.SAVE_ACL_FAILED);
} finally {
setLoading(false);
}
};
// --- Search Functionality ---
const handleSearch = (value) => {
if (activeTab === 'users') {
const filteredData = userListData.filter(item =>
Object.values(item).some(val =>
String(val).toLowerCase().includes(value.toLowerCase())
)
);
if (value === '') {
fetchUsers();
} else {
setUserListData(filteredData);
}
} else {
fetchAcls(value);
}
};
// --- User Table Column Definitions ---
const userColumns = [
{
title: t.USERNAME,
dataIndex: 'username',
key: 'username',
},
{
title: t.PASSWORD,
dataIndex: 'password',
key: 'password',
render: (text) => (
<span>
{showPassword ? text : '********'}
<Button
type="link"
icon={showPassword ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => setShowPassword(!showPassword)}
style={{ marginLeft: 8 }}
>
{showPassword ? t.HIDE : t.VIEW}
</Button>
</span>
),
},
{
title: t.USER_TYPE,
dataIndex: 'userType',
key: 'userType',
},
{
title: t.USER_STATUS,
dataIndex: 'userStatus',
key: 'userStatus',
render: (status) => (
<Tag color={status=== 'enable' ? 'green' : 'red'}>{status}</Tag>
),
},
{
title: t.OPERATION,
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button icon={<EditOutlined />} onClick={() => handleEditUser(record)}>{t.MODIFY}</Button>
<Popconfirm
title={t.CONFIRM_DELETE_USER}
onConfirm={() => handleDeleteUser(record.username)}
okText={t.YES}
cancelText={t.NO}
>
<Button icon={<DeleteOutlined />} danger>{t.DELETE}</Button>
</Popconfirm>
</Space>
),
},
];
// --- ACL Permission Table Column Definitions ---
const aclColumns = [
{
title: t.USERNAME_SUBJECT,
dataIndex: 'subject',
key: 'subject',
},
{
title: t.POLICY_TYPE,
dataIndex: 'policyType',
key: 'policyType',
},
{
title: t.RESOURCE_NAME,
dataIndex: 'resource',
key: 'resource',
},
{
title: t.OPERATION_TYPE,
dataIndex: 'actions',
key: 'actions',
render: (text) => text ? text.split(', ').map((action, index) => (
<Tag key={index} color="blue">{action}</Tag>
)) : null,
},
{
title: t.SOURCE_IP,
dataIndex: 'sourceIps',
key: 'sourceIps',
},
{
title: t.DECISION,
dataIndex: 'decision',
key: 'decision',
render: (text) => (
<Tag color={text === 'Allow' ? 'green' : 'red'}>{text}</Tag>
),
},
{
title: t.OPERATION,
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button icon={<EditOutlined />} onClick={() => handleEditAcl(record)}>{t.MODIFY}</Button>
<Popconfirm
title={t.CONFIRM_DELETE_ACL}
onConfirm={() => handleDeleteAcl(record.subject, record.resource)}
okText={t.YES}
cancelText={t.NO}
>
<Button icon={<DeleteOutlined />} danger>{t.DELETE}</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
{msgContextHolder}
<div style={{padding: 24}}>
<h2>{t.ACL_MANAGEMENT}</h2>
<div style={{ marginBottom: 16, display: 'flex', gap: 16 }}>
<Form.Item label={t.PLEASE_SELECT_CLUSTER} style={{ marginBottom: 0 }}>
<Select
placeholder={t.PLEASE_SELECT_CLUSTER}
style={{ width: 200 }}
onChange={handleClusterChange}
value={selectedCluster}
options={clusterNamesOptions}
/>
</Form.Item>
<Form.Item label={t.PLEASE_SELECT_BROKER} style={{ marginBottom: 0 }}>
<Select
placeholder={t.PLEASE_SELECT_BROKER}
style={{ width: 200 }}
onChange={handleBrokerChange}
value={selectedBroker}
options={brokerNamesOptions} // Now dynamically updated
disabled={!selectedCluster} // Disable broker selection if no cluster is chosen
/>
</Form.Item>
<Button type="primary" onClick={activeTab === 'users' ? fetchUsers : fetchAcls}>
{t.CONFIRM}
</Button>
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab={t.ACL_USERS} key="users"/>
<TabPane tab={t.ACL_PERMISSIONS} key="acls"/>
</Tabs>
<div style={{marginBottom: 16, display: 'flex', justifyContent: 'space-between'}}>
<Button type="primary" onClick={activeTab === 'users' ? handleAddUser : handleAddAcl}>
{activeTab === 'users' ? t.ADD_USER : t.ADD_ACL_PERMISSION}
</Button>
<Search
placeholder={t.SEARCH_PLACEHOLDER}
allowClear
onSearch={handleSearch}
style={{width: 300}}
/>
</div>
{activeTab === 'users' && (
<Table
columns={userColumns}
dataSource={userListData}
loading={loading}
pagination={{pageSize: 10}}
rowKey="username"
/>
)}
{activeTab === 'acls' && (
<Table
columns={aclColumns}
dataSource={aclListData}
loading={loading}
pagination={{pageSize: 10}}
rowKey="key"
/>
)}
{/* User Management Modal */}
<Modal
title={currentUser ? t.EDIT_USER : t.ADD_USER}
visible={isUserModalVisible}
onOk={handleUserModalOk}
onCancel={() => setIsUserModalVisible(false)}
confirmLoading={loading}
footer={[
<Button key="cancel" onClick={() => setIsUserModalVisible(false)}>
{t.CANCEL}
</Button>,
<Button key="submit" type="primary" onClick={handleUserModalOk} loading={loading}>
{t.CONFIRM}
</Button>,
]}
>
<Form
form={userForm}
layout="vertical"
name="user_form"
initialValues={{userStatus: 'enable'}}
>
<Form.Item
name="username"
label={t.USERNAME}
rules={[{required: true, message: t.PLEASE_ENTER_USERNAME}]}
>
<Input disabled={!!currentUser}/>
</Form.Item>
<Form.Item
name="password"
label={t.PASSWORD}
rules={[{required: !currentUser, message: t.PLEASE_ENTER_PASSWORD}]}
>
<Input.Password
placeholder={t.PASSWORD}
iconRender={visible => (visible ? <EyeOutlined/> : <EyeInvisibleOutlined/>)}
/>
</Form.Item>
<Form.Item
name="userType"
label={t.USER_TYPE}
rules={[{required: true, message: t.PLEASE_SELECT_USER_TYPE}]}
>
<Select mode="single" placeholder="Super, Normal" style={{width: '100%'}}>
<Select.Option value="Super">Super</Select.Option>
<Select.Option value="Normal">Normal</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="userStatus"
label={t.USER_STATUS}
rules={[{required: true, message: t.PLEASE_SELECT_USER_STATUS}]}
>
<Select mode="single" placeholder="enable, disable" style={{width: '100%'}}>
<Select.Option value="enable">enable</Select.Option>
<Select.Option value="disable">disable</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* ACL Permission Management Modal */}
<Modal
title={currentAcl ? t.EDIT_ACL_PERMISSION : t.ADD_ACL_PERMISSION}
visible={isAclModalVisible}
onOk={handleAclModalOk}
onCancel={() => setIsAclModalVisible(false)}
confirmLoading={loading}
>
<Form
form={aclForm}
layout="vertical"
name="acl_form"
>
<Form.Item
name="subject"
label={t.SUBJECT_LABEL}
rules={[{required: true, message: t.PLEASE_ENTER_SUBJECT}]}
>
<SubjectInput disabled={!!currentAcl}/>
</Form.Item>
<Form.Item
name="policyType"
label={t.POLICY_TYPE}
rules={[{required: true, message: t.PLEASE_ENTER_POLICY_TYPE}]}
>
<Select mode="single" disabled={isUpdate} placeholder="policyType" style={{width: '100%'}}>
<Select.Option value="Custom">Custom</Select.Option>
<Select.Option value="Default">Default</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="resource"
label={t.RESOURCE}
rules={[{required: true, message: t.PLEASE_ADD_RESOURCE}]}
>
{isUpdate ? (
<Input disabled={isUpdate}/>
) : (
<ResourceInput/>
)}
</Form.Item>
<Form.Item
name="actions"
label={t.OPERATION_TYPE}
>
<Select mode="multiple" placeholder="action" style={{width: '100%'}}>
<Select.Option value="All">All</Select.Option>
<Select.Option value="Pub">Pub</Select.Option>
<Select.Option value="Sub">Sub</Select.Option>
<Select.Option value="Create">Create</Select.Option>
<Select.Option value="Update">Update</Select.Option>
<Select.Option value="Delete">Delete</Select.Option>
<Select.Option value="Get">Get</Select.Option>
<Select.Option value="List">List</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="sourceIps"
label={t.SOURCE_IP}
rules={[
{
validator: validateIp,
},
]}
>
<Select
mode="tags"
style={{width: '100%'}}
placeholder={t.ENTER_IP_HINT}
onChange={handleIpChange}
onDeselect={handleIpDeselect}
value={ips}
tokenSeparators={[',', ' ']}
>
<Select.Option value="192.168.1.1">192.168.1.1</Select.Option>
<Select.Option value="0.0.0.0">0.0.0.0</Select.Option>
<Select.Option value="127.0.0.1">127.0.0.1</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="decision"
label={t.DECISION}
rules={[{required: true, message: t.PLEASE_ENTER_DECISION}]}
>
<Select mode="single" placeholder="Allow, Deny" style={{width: '100%'}}>
<Select.Option value="Allow">Allow</Select.Option>
<Select.Option value="Deny">Deny</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
</>
);
}
export default Acl;

View File

@@ -0,0 +1,303 @@
/*
* 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 React, {useCallback, useEffect, useState} from 'react';
import {Button, Modal, notification, Select, Spin, Table} from 'antd';
import {useLanguage} from "../../i18n/LanguageContext";
import {remoteApi, tools} from "../../api/remoteApi/remoteApi"; // 确保路径正确
const {Option} = Select;
const Cluster = () => {
const {t} = useLanguage();
const [loading, setLoading] = useState(false);
const [clusterNames, setClusterNames] = useState([]);
const [selectedCluster, setSelectedCluster] = useState('');
const [instances, setInstances] = useState([]);
const [allBrokersData, setAllBrokersData] = useState({});
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [configModalVisible, setConfigModalVisible] = useState(false);
const [currentDetail, setCurrentDetail] = useState({});
const [currentConfig, setCurrentConfig] = useState({});
const [currentBrokerName, setCurrentBrokerName] = useState('');
const [currentIndex, setCurrentIndex] = useState(null); // 对应 brokerId
const [currentBrokerAddress, setCurrentBrokerAddress] = useState('');
const [api, contextHolder] = notification.useNotification();
const switchCluster = useCallback((clusterName) => {
if (allBrokersData[clusterName]) {
setInstances(allBrokersData[clusterName]);
} else {
setInstances([]);
}
}, [allBrokersData]);
const handleChangeCluster = (value) => {
setSelectedCluster(value);
switchCluster(value);
};
useEffect(() => {
setLoading(true);
remoteApi.queryClusterList((resp) => {
setLoading(false);
if (resp.status === 0) {
const {clusterInfo, brokerServer} = resp.data;
const {clusterAddrTable, brokerAddrTable} = clusterInfo;
const generatedBrokers = tools.generateBrokerMap(brokerServer, clusterAddrTable, brokerAddrTable);
setAllBrokersData(generatedBrokers);
const names = Object.keys(clusterAddrTable);
setClusterNames(names);
if (names.length > 0) {
const defaultCluster = names[0];
setSelectedCluster(defaultCluster);
if (generatedBrokers[defaultCluster]) {
setInstances(generatedBrokers[defaultCluster]);
} else {
setInstances([]);
}
}
} else {
api.error({message: resp.errMsg || t.QUERY_CLUSTER_LIST_FAILED, duration: 2});
}
});
}, []);
const showDetail = (brokerName, brokerId, record) => { // 传入 record 整个对象,方便直接显示
setCurrentBrokerName(brokerName);
setCurrentIndex(brokerId);
setCurrentDetail(record); // 直接使用 record 作为详情
setDetailModalVisible(true);
};
const showConfig = (brokerAddress, brokerName, brokerId) => { // 保持一致,传入 brokerId
setCurrentBrokerName(brokerName);
setCurrentIndex(brokerId);
setCurrentBrokerAddress(brokerAddress);
setLoading(true);
remoteApi.queryBrokerConfig(brokerAddress, (resp) => {
setLoading(false);
if (resp.status === 0) {
// ✨ 确保 resp.data 是一个对象,如果后端返回的不是对象,这里需要处理
if (typeof resp.data === 'object' && resp.data !== null) {
setCurrentConfig(resp.data);
setConfigModalVisible(true);
} else {
api.error({message: t.INVALID_CONFIG_DATA || 'Invalid config data received', duration: 2});
setCurrentConfig({}); // 清空配置,避免显示错误
}
} else {
api.error({message: resp.errMsg || t.QUERY_BROKER_CONFIG_FAILED, duration: 2});
}
});
};
const columns = [
{
title: t.SPLIT,
dataIndex: 'brokerName', // 直接使用 brokerId
key: 'split',
align: 'center'
},
{
title: t.NO,
dataIndex: 'brokerId', // 直接使用 brokerId
key: 'no',
align: 'center',
render: (brokerId) => `${brokerId}${brokerId === 0 ? `(${t.MASTER})` : `(${t.SLAVE})`}`,
},
{
title: t.ADDRESS,
dataIndex: 'address', // 确保 generateBrokerMap 返回的数据有 address 字段
key: 'address',
align: 'center',
},
{
title: t.VERSION,
dataIndex: 'brokerVersionDesc',
key: 'version',
align: 'center',
},
{
title: t.PRO_MSG_TPS,
dataIndex: 'putTps',
key: 'putTps',
align: 'center',
render: (text) => {
const tpsValue = text ? Number(String(text).split(' ')[0]) : 0; // 确保text是字符串
return tpsValue.toFixed(2);
},
},
{
title: t.CUS_MSG_TPS,
key: 'cusMsgTps',
align: 'center',
render: (_, record) => {
// 根据你提供的数据结构,这里可能是 getTransferredTps
const val = record.getTransferedTps?.trim() ? record.getTransferedTps : record.getTransferredTps;
const tpsValue = val ? Number(String(val).split(' ')[0]) : 0; // 确保val是字符串
return tpsValue.toFixed(2);
},
},
{
title: t.YESTERDAY_PRO_COUNT,
key: 'yesterdayProCount',
align: 'center',
render: (_, record) => {
const putTotalTodayMorning = parseFloat(record.msgPutTotalTodayMorning || 0);
const putTotalYesterdayMorning = parseFloat(record.msgPutTotalYesterdayMorning || 0);
return (putTotalTodayMorning - putTotalYesterdayMorning).toLocaleString();
}
},
{
title: t.YESTERDAY_CUS_COUNT,
key: 'yesterdayCusCount',
align: 'center',
render: (_, record) => {
const getTotalTodayMorning = parseFloat(record.msgGetTotalTodayMorning || 0);
const getTotalYesterdayMorning = parseFloat(record.msgGetTotalYesterdayMorning || 0);
return (getTotalTodayMorning - getTotalYesterdayMorning).toLocaleString();
}
},
{
title: t.TODAY_PRO_COUNT,
key: 'todayProCount',
align: 'center',
render: (_, record) => {
const putTotalTodayNow = parseFloat(record.msgPutTotalTodayNow || 0);
const putTotalTodayMorning = parseFloat(record.msgPutTotalTodayMorning || 0);
return (putTotalTodayNow - putTotalTodayMorning).toLocaleString();
}
},
{
title: t.TODAY_CUS_COUNT,
key: 'todayCusCount',
align: 'center',
render: (_, record) => {
const getTotalTodayNow = parseFloat(record.msgGetTotalTodayNow || 0);
const getTotalTodayMorning = parseFloat(record.msgGetTotalTodayMorning || 0);
return (getTotalTodayNow - getTotalTodayMorning).toLocaleString();
}
},
{
title: t.OPERATION,
key: 'operation',
align: 'center',
render: (_, record) => (
<>
<Button size="small" type="primary"
onClick={() => showDetail(record.brokerName, record.brokerId, record)}
style={{marginRight: 8}}>
{t.STATUS}
</Button>
{/* 传入 record.address */}
<Button size="small" type="primary"
onClick={() => showConfig(record.address, record.brokerName, record.brokerId)}>
{t.CONFIG}
</Button>
</>
),
},
];
return (
<>
{contextHolder}
<Spin spinning={loading} tip={t.LOADING}>
<div style={{padding: 24}}>
<div style={{marginBottom: 16, display: 'flex', alignItems: 'center'}}>
<label style={{marginRight: 8}}>{t.CLUSTER}:</label>
<Select
style={{width: 300}}
placeholder={t.SELECT_CLUSTER || "Please select a cluster"}
value={selectedCluster}
onChange={handleChangeCluster}
allowClear
>
{clusterNames.map((name) => (
<Option key={name} value={name}>
{name}
</Option>
))}
</Select>
</div>
<Table
dataSource={instances}
columns={columns}
rowKey={(record) => `${record.brokerName}-${record.brokerId}`}
pagination={false}
bordered
size="middle"
/>
<Modal
title={`${t.BROKER} [${currentBrokerName}][${currentIndex}]`}
open={detailModalVisible}
footer={null}
onCancel={() => setDetailModalVisible(false)}
width={800}
bodyStyle={{maxHeight: '60vh', overflowY: 'auto'}}
>
<Table
dataSource={Object.entries(currentDetail).map(([key, value]) => ({key, value}))}
columns={[
{title: t.KEY || 'Key', dataIndex: 'key', key: 'key'},
{title: t.VALUE || 'Value', dataIndex: 'value', key: 'value'},
]}
pagination={false}
size="small"
bordered
rowKey="key"
/>
</Modal>
<Modal
title={`${t.BROKER} [${currentBrokerName}][${currentIndex}]`}
open={configModalVisible}
footer={null}
onCancel={() => setConfigModalVisible(false)}
width={800}
bodyStyle={{maxHeight: '60vh', overflowY: 'auto'}}
>
<Table
dataSource={Object.entries(currentConfig).map(([key, value]) => ({key, value}))}
columns={[
{title: t.KEY || 'Key', dataIndex: 'key', key: 'key'},
{title: t.VALUE || 'Value', dataIndex: 'value', key: 'value'},
]}
pagination={false}
size="small"
bordered
rowKey="key"
/>
</Modal>
</div>
</Spin>
</>
);
};
export default Cluster;

View File

@@ -0,0 +1,480 @@
/*
* 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 React, {useCallback, useEffect, useState} from 'react';
import {Button, Checkbox, Input, message, notification, Spin, Table} from 'antd';
import {useLanguage} from '../../i18n/LanguageContext';
import {remoteApi} from '../../api/remoteApi/remoteApi';
import ClientInfoModal from "../../components/consumer/ClientInfoModal";
import ConsumerDetailModal from "../../components/consumer/ConsumerDetailModal";
import ConsumerConfigModal from "../../components/consumer/ConsumerConfigModal";
import DeleteConsumerModal from "../../components/consumer/DeleteConsumerModal";
const ConsumerGroupList = () => {
const {t} = useLanguage();
const [filterStr, setFilterStr] = useState('');
const [filterNormal, setFilterNormal] = useState(true);
const [filterFIFO, setFilterFIFO] = useState(false);
const [filterSystem, setFilterSystem] = useState(false);
const [rmqVersion, setRmqVersion] = useState(true);
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true);
const [intervalProcessSwitch, setIntervalProcessSwitch] = useState(false);
const [loading, setLoading] = useState(false);
const [consumerGroupShowList, setConsumerGroupShowList] = useState([]);
const [allConsumerGroupList, setAllConsumerGroupList] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null);
const [selectedAddress, setSelectedAddress] = useState(null);
const [showClientInfo, setShowClientInfo] = useState(false);
const [showConsumeDetail, setShowConsumeDetail] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [isAddConfig, setIsAddConfig] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [messageApi, msgContextHolder] = message.useMessage();
const [notificationApi,notificationContextHolder] = notification.useNotification();
const [paginationConf, setPaginationConf] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [sortConfig, setSortConfig] = useState({
sortKey: null,
sortOrder: 1,
});
const loadConsumerGroups = useCallback(async (currentPage) => {
setLoading(true);
try {
const response = await remoteApi.queryConsumerGroupList(false);
if (response.status === 0) {
setAllConsumerGroupList(response.data);
if(currentPage!=null){
filterList(currentPage, response.data);
}else{
filterList(1, response.data);
}
} else {
messageApi.error({title: t.ERROR, content: response.errMsg});
}
} catch (error) {
messageApi.error({title: t.ERROR, content: t.FAILED_TO_FETCH_DATA});
console.error("Error loading consumer groups:", error);
} finally {
setLoading(false);
}
}, [t]);
const filterByType = (str, type, version) => {
if (filterSystem && type === "SYSTEM") return true;
if (filterNormal && (type === "NORMAL" || (!version && type === "FIFO"))) return true;
if (filterFIFO && type === "FIFO") return true;
return false;
};
const filterList = useCallback((currentPage, data) => {
// 排序处理
let sortedData = [...data];
if (sortConfig.sortKey) {
sortedData.sort((a, b) => {
const aValue = a[sortConfig.sortKey];
const bValue = b[sortConfig.sortKey];
if (typeof aValue === 'string') {
return sortConfig.sortOrder * aValue.localeCompare(bValue);
}
return sortConfig.sortOrder * (aValue > bValue ? 1 : -1);
});
}
// 过滤处理
const lowExceptStr = filterStr.toLowerCase();
const canShowList = sortedData.filter(element =>
filterByType(element.group, element.subGroupType, rmqVersion) &&
element.group.toLowerCase().includes(lowExceptStr)
);
// 更新分页和显示列表
const perPage = paginationConf.pageSize;
const from = (currentPage - 1) * perPage;
const to = from + perPage;
setPaginationConf(prev => ({
...prev,
current: currentPage,
total: canShowList.length,
}));
setConsumerGroupShowList(canShowList.slice(from, to));
}, [filterStr, filterNormal, filterSystem, filterFIFO, rmqVersion, sortConfig, paginationConf.pageSize]);
const doSort = useCallback(() => {
const sortedList = [...allConsumerGroupList];
if (sortConfig.sortKey === 'diffTotal') {
sortedList.sort((a, b) => {
return (a.diffTotal > b.diffTotal) ? sortConfig.sortOrder :
((b.diffTotal > a.diffTotal) ? -sortConfig.sortOrder : 0);
});
}
if (sortConfig.sortKey === 'group') {
sortedList.sort((a, b) => {
return (a.group > b.group) ? sortConfig.sortOrder :
((b.group > a.group) ? -sortConfig.sortOrder : 0);
});
}
if (sortConfig.sortKey === 'count') {
sortedList.sort((a, b) => {
return (a.count > b.count) ? sortConfig.sortOrder :
((b.count > a.count) ? -sortConfig.sortOrder : 0);
});
}
if (sortConfig.sortKey === 'consumeTps') {
sortedList.sort((a, b) => {
return (a.consumeTps > b.consumeTps) ? sortConfig.sortOrder :
((b.consumeTps > a.consumeTps) ? -sortConfig.sortOrder : 0);
});
}
setAllConsumerGroupList(sortedList);
filterList(paginationConf.current, sortedList);
}, [sortConfig, allConsumerGroupList, paginationConf.current]);
useEffect(() => {
loadConsumerGroups();
}, [loadConsumerGroups]);
useEffect(() => {
let intervalId;
if (intervalProcessSwitch) {
intervalId = setInterval(loadConsumerGroups, 10000);
}
return () => clearInterval(intervalId);
}, [intervalProcessSwitch, loadConsumerGroups]);
useEffect(() => {
filterList(paginationConf.current, allConsumerGroupList);
}, [allConsumerGroupList, filterStr, filterNormal, filterSystem, filterFIFO, sortConfig, filterList, paginationConf.current]);
const handleFilterInputChange = (value) => {
setFilterStr(value);
setPaginationConf(prev => ({...prev, current: 1}));
};
const handleTypeFilterChange = (filterType, checked) => {
switch (filterType) {
case 'normal':
setFilterNormal(checked);
break;
case 'fifo':
setFilterFIFO(checked);
break;
case 'system':
setFilterSystem(checked);
break;
default:
break;
}
setPaginationConf(prev => ({...prev, current: 1}));
};
const handleRefreshConsumerData = async () => {
setLoading(true);
const refreshResult = await remoteApi.refreshAllConsumerGroup();
setLoading(false);
if (refreshResult && refreshResult.status === 0) {
notificationApi.success({message: t.REFRESH_SUCCESS, duration: 2});
loadConsumerGroups();
} else if (refreshResult && refreshResult.errMsg) {
notificationApi.error({message: t.REFRESH_FAILED + ": " + refreshResult.errMsg, duration: 2});
} else {
notificationApi.error({message: t.REFRESH_FAILED, duration: 2});
}
};
const handleOpenAddDialog = () => {
setIsAddConfig(true)
setShowConfig(true);
};
// 修改操作按钮的点击处理函数
const handleClient = (group, address) => {
setSelectedGroup(group);
setSelectedAddress(address);
setShowClientInfo(true);
};
const handleDetail = (group, address) => {
setSelectedGroup(group);
setSelectedAddress(address);
setShowConsumeDetail(true);
};
const handleUpdateConfigDialog = (group) => {
setSelectedGroup(group);
setShowConfig(true);
};
const handleDelete = (group) => {
setSelectedGroup(group);
setShowDeleteModal(true);
};
const handleRefreshConsumerGroup = async (group) => {
setLoading(true);
const response = await remoteApi.refreshConsumerGroup(group);
setLoading(false);
if (response.status === 0) {
messageApi.success({content: `${group} ${t.REFRESHED}`});
loadConsumerGroups(paginationConf.current);
} else {
messageApi.error({title: t.ERROR, content: response.errMsg});
}
};
const handleSort = (sortKey) => {
setSortConfig(prev => ({
sortKey,
sortOrder: prev.sortKey === sortKey ? -prev.sortOrder : 1,
}));
setPaginationConf(prev => ({...prev, current: 1}));
};
const columns = [
{
title: <a onClick={() => handleSort('group')}>{t.SUBSCRIPTION_GROUP}</a>,
dataIndex: 'group',
key: 'group',
align: 'center',
render: (text) => {
const sysFlag = text.startsWith('%SYS%');
return (
<span style={{color: sysFlag ? 'red' : ''}}>
{sysFlag ? text.substring(5) : text}
</span>
);
},
},
{
title: <a onClick={() => handleSort('count')}>{t.QUANTITY}</a>,
dataIndex: 'count',
key: 'count',
align: 'center',
},
{
title: t.VERSION,
dataIndex: 'version',
key: 'version',
align: 'center',
},
{
title: t.TYPE,
dataIndex: 'consumeType',
key: 'consumeType',
align: 'center',
},
{
title: t.MODE,
dataIndex: 'messageModel',
key: 'messageModel',
align: 'center',
},
{
title: <a onClick={() => handleSort('consumeTps')}>TPS</a>,
dataIndex: 'consumeTps',
key: 'consumeTps',
align: 'center',
},
{
title: <a onClick={() => handleSort('diffTotal')}>{t.DELAY}</a>,
dataIndex: 'diffTotal',
key: 'diffTotal',
align: 'center',
},
{
title: t.UPDATE_TIME,
dataIndex: 'updateTime',
key: 'updateTime',
align: 'center',
},
{
title: t.OPERATION,
key: 'operation',
align: 'left',
render: (_, record) => {
const sysFlag = record.group.startsWith('%SYS%');
return (
<>
<Button
type="primary"
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleClient(record.group, record.address)}
>
{t.CLIENT}
</Button>
<Button
type="primary"
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleDetail(record.group, record.address)}
>
{t.CONSUME_DETAIL}
</Button>
<Button
type="primary"
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleUpdateConfigDialog(record.group)}
>
{t.CONFIG}
</Button>
<Button
type="primary"
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleRefreshConsumerGroup(record.group)}
>
{t.REFRESH}
</Button>
{!sysFlag && writeOperationEnabled && (
<Button
type="primary"
danger
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleDelete(record.group)}
>
{t.DELETE}
</Button>
)}
</>
);
},
},
];
const handleTableChange = (pagination) => {
setPaginationConf(prev => ({
...prev,
current: pagination.current,
pageSize: pagination.pageSize
}));
filterList(pagination.current, allConsumerGroupList);
};
const closeConfigModal = () =>{
setShowConfig(false);
setIsAddConfig(false);
}
return (
<>
{msgContextHolder}
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip={t.LOADING}>
<div style={{marginBottom: '20px'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '15px'}}>
<div style={{display: 'flex', alignItems: 'center'}}>
<label style={{marginRight: '8px'}}>{t.SUBSCRIPTION_GROUP}:</label>
<Input
style={{width: '200px'}}
value={filterStr}
onChange={(e) => handleFilterInputChange(e.target.value)}
/>
</div>
<Checkbox checked={filterNormal}
onChange={(e) => handleTypeFilterChange('normal', e.target.checked)}>
{t.NORMAL}
</Checkbox>
{rmqVersion && (
<Checkbox checked={filterFIFO}
onChange={(e) => handleTypeFilterChange('fifo', e.target.checked)}>
{t.FIFO}
</Checkbox>
)}
<Checkbox checked={filterSystem}
onChange={(e) => handleTypeFilterChange('system', e.target.checked)}>
{t.SYSTEM}
</Checkbox>
{writeOperationEnabled && (
<Button type="primary" onClick={handleOpenAddDialog}>
{t.ADD} / {t.UPDATE}
</Button>
)}
<Button type="primary" onClick={handleRefreshConsumerData}>
{t.REFRESH}
</Button>
{/*<Switch*/}
{/* checked={intervalProcessSwitch}*/}
{/* onChange={(checked) => setIntervalProcessSwitch(checked)}*/}
{/* checkedChildren={t.AUTO_REFRESH}*/}
{/* unCheckedChildren={t.AUTO_REFRESH}*/}
{/*/>*/}
</div>
</div>
<Table
dataSource={consumerGroupShowList}
columns={columns}
rowKey="group"
bordered
pagination={paginationConf}
onChange={handleTableChange}
sortDirections={['ascend', 'descend']}
/>
</Spin>
<ClientInfoModal
visible={showClientInfo}
group={selectedGroup}
address={selectedAddress}
onCancel={() => setShowClientInfo(false)}
/>
<ConsumerDetailModal
visible={showConsumeDetail}
group={selectedGroup}
address={selectedAddress}
onCancel={() => setShowConsumeDetail(false)}
/>
<ConsumerConfigModal
visible={showConfig}
isAddConfig={isAddConfig}
group={selectedGroup}
onCancel={closeConfigModal}
setIsAddConfig={setIsAddConfig}
onSuccess={loadConsumerGroups}
/>
<DeleteConsumerModal
visible={showDeleteModal}
group={selectedGroup}
onCancel={() => setShowDeleteModal(false)}
onSuccess={loadConsumerGroups}
/>
</div>
</>
);
};
export default ConsumerGroupList;

View File

@@ -0,0 +1,455 @@
/*
* 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 React, {useCallback, useEffect, useRef, useState} from 'react';
import {Card, Col, DatePicker, message, notification, Row, Select, Spin, Table} from 'antd';
import * as echarts from 'echarts';
import moment from 'moment';
import {useLanguage} from '../../i18n/LanguageContext';
import {remoteApi, tools} from '../../api/remoteApi/remoteApi';
const {Option} = Select;
const DashboardPage = () => {
const {t} = useLanguage();
const barChartRef = useRef(null);
const lineChartRef = useRef(null);
const topicBarChartRef = useRef(null);
const topicLineChartRef = useRef(null);
const [loading, setLoading] = useState(false);
const [date, setDate] = useState(moment());
const [topicNames, setTopicNames] = useState([]);
const [selectedTopic, setSelectedTopic] = useState(null);
const [brokerTableData, setBrokerTableData] = useState([]);
const barChartInstance = useRef(null);
const lineChartInstance = useRef(null);
const topicBarChartInstance = useRef(null);
const topicLineChartInstance = useRef(null);
const [messageApi, msgContextHolder] = message.useMessage();
const [notificationApi, notificationContextHolder] = notification.useNotification();
const initChart = useCallback((chartRef, titleText, isLine = false) => {
if (chartRef.current) {
const chart = echarts.init(chartRef.current);
let option = {
title: {text: titleText},
tooltip: {},
legend: {data: ['TotalMsg']},
axisPointer: {type: 'shadow'},
xAxis: {
type: 'category',
data: [],
axisLabel: {
inside: false,
color: '#000000',
rotate: 0,
interval: 0
},
axisTick: {show: true},
axisLine: {show: true},
z: 10
},
yAxis: {
type: 'value',
boundaryGap: [0, '100%'],
axisLabel: {formatter: (value) => value.toFixed(2)},
splitLine: {show: true}
},
series: [{name: 'TotalMsg', type: 'bar', data: []}]
};
if (isLine) {
option = {
title: {text: titleText},
toolbox: {
feature: {
dataZoom: {yAxisIndex: 'none'},
restore: {},
saveAsImage: {}
}
},
tooltip: {trigger: 'axis', axisPointer: {animation: false}},
yAxis: {
type: 'value',
boundaryGap: [0, '80%'],
axisLabel: {formatter: (value) => value.toFixed(2)},
splitLine: {show: true}
},
dataZoom: [{
type: 'inside', start: 90, end: 100
}, {
start: 0,
end: 10,
handleIcon: 'M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4v1.3h1.3v-1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7V23h6.6V24.4z M13.3,19.6H6.7v-1.4h6.6V19.6z',
handleSize: '80%',
handleStyle: {
color: '#fff',
shadowBlur: 3,
shadowColor: 'rgba(0, 0, 0, 0.6)',
shadowOffsetX: 2,
shadowOffsetY: 2
}
}],
legend: {data: [], top: 30},
xAxis: {type: 'time', boundaryGap: false, data: []},
series: []
};
}
chart.setOption(option);
return chart;
}
return null;
}, []);
useEffect(() => {
barChartInstance.current = initChart(barChartRef, t.BROKER + ' TOP 10');
lineChartInstance.current = initChart(lineChartRef, t.BROKER + ' 5min trend', true);
topicBarChartInstance.current = initChart(topicBarChartRef, t.TOPIC + ' TOP 10');
topicLineChartInstance.current = initChart(topicLineChartRef, t.TOPIC + ' 5min trend', true);
return () => {
barChartInstance.current?.dispose();
lineChartInstance.current?.dispose();
topicBarChartInstance.current?.dispose();
topicLineChartInstance.current?.dispose();
};
}, [t, initChart]);
const getBrokerBarChartOp = useCallback((xAxisData, data) => {
return {
xAxis: {data: xAxisData},
series: [{name: 'TotalMsg', data: data}]
};
}, []);
const getBrokerLineChartOp = useCallback((legend, data) => {
const series = [];
let xAxisData = [];
let isFirstSeries = true;
Object.entries(data).forEach(([key, values]) => {
const tpsValues = [];
values.forEach(tpsValue => {
const tpsArray = tpsValue.split(",");
if (isFirstSeries) {
xAxisData.push(moment(parseInt(tpsArray[0])).format("HH:mm:ss"));
}
tpsValues.push(parseFloat(tpsArray[1]));
});
isFirstSeries = false;
series.push({
name: key,
type: 'line',
smooth: true,
symbol: 'none',
sampling: 'average',
data: tpsValues
});
});
return {
legend: {data: legend},
color: ["#FF0000", "#00BFFF", "#FF00FF", "#1ce322", "#000000", '#EE7942'],
xAxis: {type: 'category', boundaryGap: false, data: xAxisData},
series: series
};
}, []);
const getTopicLineChartOp = useCallback((legend, data) => {
const series = [];
let xAxisData = [];
let isFirstSeries = true;
Object.entries(data).forEach(([key, values]) => {
const tpsValues = [];
values.forEach(tpsValue => {
const tpsArray = tpsValue.split(",");
if (isFirstSeries) {
xAxisData.push(moment(parseInt(tpsArray[0])).format("HH:mm:ss"));
}
tpsValues.push(parseFloat(tpsArray[2]));
});
isFirstSeries = false;
series.push({
name: key,
type: 'line',
smooth: true,
symbol: 'none',
sampling: 'average',
data: tpsValues
});
});
return {
legend: {data: legend},
xAxis: {type: 'category', boundaryGap: false, data: xAxisData},
series: series
};
}, []);
const queryLineData = useCallback(async () => {
const _date = date ? date.format("YYYY-MM-DD") : moment().format("YYYY-MM-DD");
lineChartInstance.current?.showLoading();
await remoteApi.queryBrokerHisData(_date, (resp) => {
lineChartInstance.current?.hideLoading();
if (resp.status === 0) {
const _data = {};
const _xAxisData = [];
Object.entries(resp.data).forEach(([address, values]) => {
_data[address] = values;
_xAxisData.push(address);
});
lineChartInstance.current?.setOption(getBrokerLineChartOp(_xAxisData, _data));
} else {
notificationApi.error({message: resp.errMsg || t.QUERY_BROKER_HISTORY_FAILED, duration: 2});
}
});
if (selectedTopic) {
topicLineChartInstance.current?.showLoading();
await remoteApi.queryTopicHisData(_date, selectedTopic, (resp) => {
topicLineChartInstance.current?.hideLoading();
if (resp.status === 0) {
const _data = {};
_data[selectedTopic] = resp.data;
topicLineChartInstance.current?.setOption(getTopicLineChartOp([selectedTopic], _data));
} else {
notificationApi.error({message: resp.errMsg || t.QUERY_TOPIC_HISTORY_FAILED, duration: 2});
}
});
}
}, [date, selectedTopic, getBrokerLineChartOp, getTopicLineChartOp, t]);
useEffect(() => {
setLoading(true);
barChartInstance.current?.showLoading();
remoteApi.queryClusterList((resp) => {
setLoading(false);
barChartInstance.current?.hideLoading();
if (resp.status === 0) {
const clusterAddrTable = resp.data.clusterInfo.clusterAddrTable;
const brokerAddrTable = resp.data.clusterInfo.brokerAddrTable; // Corrected to brokerAddrTable
const brokerDetail = resp.data.brokerServer;
const clusterMap = tools.generateBrokerMap(brokerDetail, clusterAddrTable, brokerAddrTable);
let brokerArray = [];
Object.values(clusterMap).forEach(brokersInCluster => {
brokerArray = brokerArray.concat(brokersInCluster);
});
// Update broker table data
setBrokerTableData(brokerArray.map(broker => ({
...broker,
key: broker.brokerName // Ant Design Table needs a unique key
})));
brokerArray.sort((firstBroker, lastBroker) => {
const firstTotalMsg = parseFloat(firstBroker.msgGetTotalTodayNow || 0);
const lastTotalMsg = parseFloat(lastBroker.msgGetTotalTodayNow || 0);
return lastTotalMsg - firstTotalMsg;
});
const xAxisData = [];
const data = [];
brokerArray.slice(0, 10).forEach(broker => {
xAxisData.push(`${broker.brokerName}:${broker.index}`);
data.push(parseFloat(broker.msgGetTotalTodayNow || 0));
});
barChartInstance.current?.setOption(getBrokerBarChartOp(xAxisData, data));
} else {
notificationApi.error({message: resp.errMsg || t.QUERY_CLUSTER_LIST_FAILED, duration: 2});
}
});
}, [getBrokerBarChartOp, t]);
useEffect(() => {
topicBarChartInstance.current?.showLoading();
remoteApi.queryTopicCurrentData((resp) => {
topicBarChartInstance.current?.hideLoading();
if (resp.status === 0) {
const topicList = resp.data;
topicList.sort((first, last) => {
const firstTotalMsg = parseFloat(first.split(",")[1] || 0);
const lastTotalMsg = parseFloat(last.split(",")[1] || 0);
return lastTotalMsg - firstTotalMsg;
});
const xAxisData = [];
const data = [];
const names = [];
topicList.forEach((currentData) => {
const currentArray = currentData.split(",");
names.push(currentArray[0]);
});
setTopicNames(names);
if (names.length > 0 && selectedTopic === null) {
setSelectedTopic(names[0]);
}
topicList.slice(0, 10).forEach((currentData) => {
const currentArray = currentData.split(",");
xAxisData.push(currentArray[0]);
data.push(parseFloat(currentArray[1] || 0));
});
const option = {
xAxis: {
data: xAxisData,
axisLabel: {
inside: false,
color: '#000000',
rotate: 60,
interval: 0
},
},
series: [{name: 'TotalMsg', data: data}]
};
topicBarChartInstance.current?.setOption(option);
} else {
notificationApi.error({message: resp.errMsg || t.QUERY_TOPIC_CURRENT_FAILED, duration: 2});
}
});
}, [selectedTopic, t]);
useEffect(() => {
if (barChartInstance.current && lineChartInstance.current && topicBarChartInstance.current && topicLineChartInstance.current) {
queryLineData();
}
}, [date, selectedTopic, queryLineData]);
useEffect(() => {
const intervalId = setInterval(queryLineData, tools.dashboardRefreshTime);
return () => {
clearInterval(intervalId);
};
}, [queryLineData]);
const brokerColumns = [
{title: t.BROKER_NAME, dataIndex: 'brokerName', key: 'brokerName'},
{title: t.BROKER_ADDR, dataIndex: 'brokerAddress', key: 'brokerAddress'},
{
title: t.TOTAL_MSG_RECEIVED_TODAY,
dataIndex: 'msgGetTotalTodayNow',
key: 'msgGetTotalTodayNow',
render: (text) => parseFloat(text || 0).toLocaleString(),
sorter: (a, b) => parseFloat(a.msgGetTotalTodayNow || 0) - parseFloat(b.msgGetTotalTodayNow || 0),
},
{
title: t.TODAY_PRO_COUNT,
key: 'todayProCount',
render: (_, record) => parseFloat(record.msgPutTotalTodayMorning || 0).toLocaleString(), // Assuming msgPutTotalTodayMorning is 'today pro count'
},
{
title: t.YESTERDAY_PRO_COUNT,
key: 'yesterdayProCount',
// This calculation (today morning - yesterday morning) might not be correct for 'yesterday pro count'.
// It depends on what msgPutTotalTodayMorning and msgPutTotalYesterdayMorning truly represent.
// If they are cumulative totals up to morning, then the difference is not accurate for yesterday's count.
// You might need a specific 'msgPutTotalYesterdayNow' from the backend.
render: (_, record) => (parseFloat(record.msgPutTotalTodayMorning || 0) - parseFloat(record.msgPutTotalYesterdayMorning || 0)).toLocaleString(),
},
];
return (
<>
{msgContextHolder}
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip={t.LOADING}>
<Row gutter={[16, 16]} style={{marginBottom: '20px'}}>
<Col span={12}>
<Card title={t.BROKER_OVERVIEW} bordered>
<Table
columns={brokerColumns}
dataSource={brokerTableData}
rowKey="key"
pagination={false}
size="small"
bordered
scroll={{y: 240}}
/>
</Card>
</Col>
<Col span={12}>
<Card title={t.DASHBOARD_DATE_SELECTION} bordered>
<DatePicker
format="YYYY-MM-DD"
value={date}
onChange={setDate}
allowClear
style={{width: '100%'}}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{marginBottom: '20px'}}>
<Col span={12}>
<Card title={`${t.BROKER} TOP 10`} bordered>
<div ref={barChartRef} style={{height: 300}}/>
</Card>
</Col>
<Col span={12}>
<Card title={`${t.BROKER} 5min ${t.TREND}`} bordered>
<div ref={lineChartRef} style={{height: 300}}/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<Card title={`${t.TOPIC} TOP 10`} bordered>
<div ref={topicBarChartRef} style={{height: 300}}/>
</Card>
</Col>
<Col span={12}>
<Card title={`${t.TOPIC} 5min ${t.TREND}`} bordered>
<div style={{marginBottom: '10px'}}>
<Select
showSearch
style={{width: '100%'}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{topicNames.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</div>
<div ref={topicLineChartRef} style={{height: 300}}/>
</Card>
</Col>
</Row>
</Spin>
</div>
</>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,703 @@
/*
* 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 React, {useCallback, useEffect, useState} from 'react';
import {
Button,
Checkbox,
DatePicker,
Form,
Input,
Modal,
notification,
Select,
Spin,
Table,
Tabs,
Typography
} from 'antd';
import moment from 'moment';
import {ExportOutlined, SearchOutlined, SendOutlined} from '@ant-design/icons';
import DlqMessageDetailViewDialog from "../../components/DlqMessageDetailViewDialog"; // Ensure this path is correct
import {useLanguage} from '../../i18n/LanguageContext'; // Ensure this path is correct
import {remoteApi} from '../../api/remoteApi/remoteApi'; // Adjust the path to your remoteApi.js file
const {TabPane} = Tabs;
const {Option} = Select;
const {Text, Paragraph} = Typography;
const SYS_GROUP_TOPIC_PREFIX = "CID_RMQ_SYS_"; // Define this constant as in Angular
const DLQ_GROUP_TOPIC_PREFIX = "%DLQ%"; // Define this constant
const DlqMessageQueryPage = () => {
const {t} = useLanguage();
const [activeTab, setActiveTab] = useState('consumer');
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// Consumer 查询状态
const [allConsumerGroupList, setAllConsumerGroupList] = useState([]);
const [selectedConsumerGroup, setSelectedConsumerGroup] = useState(null);
const [timepickerBegin, setTimepickerBegin] = useState(moment().subtract(3, 'hour')); // 默认三小时前
const [timepickerEnd, setTimepickerEnd] = useState(moment());
const [messageShowList, setMessageShowList] = useState([]);
const [paginationConf, setPaginationConf] = useState({
current: 1,
pageSize: 20, // Adjusted to 20 as per Angular code
total: 0,
});
const [checkedAll, setCheckedAll] = useState(false);
const [selectedMessageIds, setSelectedMessageIds] = useState(new Set()); // Stores msgId for selected messages
const [messageCheckedList, setMessageCheckedList] = useState([]); // Stores full message objects for checked items
const [taskId, setTaskId] = useState("");
// Message ID 查询状态
const [messageId, setMessageId] = useState('');
const [queryDlqMessageByMessageIdResult, setQueryDlqMessageByMessageIdResult] = useState([]);
const [modalApi, modalContextHolder] = Modal.useModal();
const [notificationApi, notificationContextHolder] = notification.useNotification();
// Fetch consumer group list on component mount
useEffect(() => {
const fetchConsumerGroups = async () => {
setLoading(true);
const resp = await remoteApi.queryConsumerGroupList();
if (resp.status === 0) {
const filteredGroups = resp.data
.filter(consumerGroup => !consumerGroup.group.startsWith(SYS_GROUP_TOPIC_PREFIX))
.map(consumerGroup => consumerGroup.group)
.sort();
setAllConsumerGroupList(filteredGroups);
} else {
notificationApi.error({message: t.ERROR, description: resp.errMsg});
}
setLoading(false);
};
fetchConsumerGroups();
}, [t]);
// Effect to manage batch buttons' disabled state
useEffect(() => {
const batchResendBtn = document.getElementById('batchResendBtn');
const batchExportBtn = document.getElementById('batchExportBtn');
if (selectedMessageIds.size > 0) {
batchResendBtn?.classList.remove('disabled');
batchExportBtn?.classList.remove('disabled');
} else {
batchResendBtn?.classList.add('disabled');
batchExportBtn?.classList.add('disabled');
}
}, [selectedMessageIds]);
const onChangeQueryCondition = useCallback(() => {
// console.log("查询条件改变");
setTaskId(""); // Reset taskId when query conditions change
setPaginationConf(prev => ({...prev, currentPage: 1, totalItems: 0}));
}, []);
const queryDlqMessageByConsumerGroup = useCallback(async (page = paginationConf.current, pageSize = paginationConf.pageSize) => {
if (!selectedConsumerGroup) {
notificationApi.warning({
message: t.WARNING,
description: t.PLEASE_SELECT_CONSUMER_GROUP,
});
return;
}
if (moment(timepickerEnd).valueOf() < moment(timepickerBegin).valueOf()) {
notificationApi.error({message: t.END_TIME_LATER_THAN_BEGIN_TIME, delay: 2000});
return;
}
setLoading(true);
// console.log("根据消费者组查询DLQ消息:", { selectedConsumerGroup, timepickerBegin, timepickerEnd, page, pageSize, taskId });
try {
const resp = await remoteApi.queryDlqMessageByConsumerGroup(
selectedConsumerGroup,
moment(timepickerBegin).valueOf(),
moment(timepickerEnd).valueOf(),
page,
pageSize,
taskId
);
if (resp.status === 0) {
const fetchedMessages = resp.data.page.content.map(msg => ({...msg, checked: false}));
setMessageShowList(fetchedMessages);
if (fetchedMessages.length === 0) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
setPaginationConf(prev => ({
...prev,
current: resp.data.page.number + 1,
pageSize: pageSize,
total: resp.data.page.totalElements,
}));
setTaskId(resp.data.taskId);
setSelectedMessageIds(new Set()); // Reset选中项
setCheckedAll(false); // Reset全选状态
setMessageCheckedList([]); // Clear checked list
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
console.error("查询失败:", error);
} finally {
setLoading(false);
}
}, [selectedConsumerGroup, timepickerBegin, timepickerEnd, paginationConf.current, paginationConf.pageSize, taskId, t]);
const queryDlqMessageByMessageId = useCallback(async () => {
if (!messageId || !selectedConsumerGroup) {
notificationApi.warning({
message: t.WARNING,
description: t.MESSAGE_ID_AND_CONSUMER_GROUP_REQUIRED,
});
return;
}
setLoading(true);
// console.log("根据Message ID查询DLQ消息:", { msgId: messageId, consumerGroup: selectedConsumerGroup });
try {
const resp = await remoteApi.viewMessage(messageId, DLQ_GROUP_TOPIC_PREFIX + selectedConsumerGroup);
if (resp.status === 0) {
setQueryDlqMessageByMessageIdResult(resp.data ? [resp.data] : []);
if (!resp.data) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
console.error("查询失败:", error);
} finally {
setLoading(false);
}
}, [messageId, selectedConsumerGroup, t]);
const queryDlqMessageDetail = useCallback(async (msgId, consumerGroup) => {
setLoading(true);
// console.log(`查询DLQ消息详情: ${msgId}, 消费者组: ${consumerGroup}`);
try {
const resp = await remoteApi.viewMessage(msgId, DLQ_GROUP_TOPIC_PREFIX + consumerGroup);
if (resp.status === 0) {
modalApi.info({
title: t.MESSAGE_DETAIL,
width: 800,
content: (
<DlqMessageDetailViewDialog
ngDialogData={{messageView: resp.data}}
/>
),
onOk: () => {
},
okText: t.CLOSE,
});
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
console.error("查询失败:", error);
} finally {
setLoading(false);
}
}, [t]);
const resendDlqMessage = useCallback(async (messageView, consumerGroup) => {
setLoading(true);
const topic = messageView.properties.RETRY_TOPIC;
const msgId = messageView.properties.ORIGIN_MESSAGE_ID;
// console.log(`重发DLQ消息: MsgId=${msgId}, Topic=${topic}, 消费者组=${consumerGroup}`);
try {
const resp = await remoteApi.resendDlqMessage(msgId, consumerGroup, topic);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.RESEND_SUCCESS,
});
modalApi.info({
title: t.RESULT,
content: resp.data,
});
// Refresh list
queryDlqMessageByConsumerGroup(paginationConf.current, paginationConf.pageSize);
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
modalApi.error({
title: t.RESULT,
content: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.RESEND_FAILED,
});
console.error("重发失败:", error);
} finally {
setLoading(false);
}
}, [paginationConf.current, paginationConf.pageSize, queryDlqMessageByConsumerGroup, t]);
const exportDlqMessage = useCallback(async (msgId, consumerGroup) => {
setLoading(true);
// console.log(`导出DLQ消息: MsgId=${msgId}, 消费者组=${consumerGroup}`);
try {
const resp = await remoteApi.exportDlqMessage(msgId, consumerGroup);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.EXPORT_SUCCESS,
});
// The actual file download is handled within remoteApi.js
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.EXPORT_FAILED,
});
console.error("导出失败:", error);
} finally {
setLoading(false);
}
}, [t]);
const batchResendDlqMessage = useCallback(async () => {
if (selectedMessageIds.size === 0) {
notificationApi.warning({
message: t.WARNING,
description: t.PLEASE_SELECT_MESSAGE_TO_RESEND,
});
return;
}
setLoading(true);
const messagesToResend = messageCheckedList.map(message => ({
topic: message.properties.RETRY_TOPIC,
msgId: message.properties.ORIGIN_MESSAGE_ID,
consumerGroup: selectedConsumerGroup,
}));
// console.log(`批量重发DLQ消息到 ${selectedConsumerGroup}:`, messagesToResend);
try {
const resp = await remoteApi.batchResendDlqMessage(messagesToResend);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.BATCH_RESEND_SUCCESS,
});
modalApi.info({
title: t.RESULT,
content: resp.data,
});
// Refresh list and reset selected state
queryDlqMessageByConsumerGroup(paginationConf.current, paginationConf.pageSize);
setSelectedMessageIds(new Set());
setCheckedAll(false);
setMessageCheckedList([]);
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
modalApi.error({
title: t.RESULT,
content: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.BATCH_RESEND_FAILED,
});
console.error("批量重发失败:", error);
} finally {
setLoading(false);
}
}, [selectedMessageIds, messageCheckedList, selectedConsumerGroup, paginationConf.current, paginationConf.pageSize, queryDlqMessageByConsumerGroup, t]);
const batchExportDlqMessage = useCallback(async () => {
if (selectedMessageIds.size === 0) {
notificationApi.warning({
message: t.WARNING,
description: t.PLEASE_SELECT_MESSAGE_TO_EXPORT,
});
return;
}
setLoading(true);
const messagesToExport = messageCheckedList.map(message => ({
msgId: message.msgId,
consumerGroup: selectedConsumerGroup,
}));
// console.log(`批量导出DLQ消息从 ${selectedConsumerGroup}:`, messagesToExport);
try {
const resp = await remoteApi.batchExportDlqMessage(messagesToExport);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.BATCH_EXPORT_SUCCESS,
});
// The actual file download is handled within remoteApi.js
// Refresh list and reset selected state
queryDlqMessageByConsumerGroup(paginationConf.current, paginationConf.pageSize);
setSelectedMessageIds(new Set());
setCheckedAll(false);
setMessageCheckedList([]);
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.BATCH_EXPORT_FAILED,
});
console.error("批量导出失败:", error);
} finally {
setLoading(false);
}
}, [selectedMessageIds, messageCheckedList, selectedConsumerGroup, paginationConf.current, paginationConf.pageSize, queryDlqMessageByConsumerGroup, t]);
const handleSelectAll = (e) => {
const checked = e.target.checked;
setCheckedAll(checked);
const newSelectedIds = new Set();
const newCheckedList = [];
const updatedList = messageShowList.map(item => {
if (checked) {
newSelectedIds.add(item.msgId);
newCheckedList.push(item);
}
return {...item, checked};
});
setMessageShowList(updatedList);
setSelectedMessageIds(newSelectedIds);
setMessageCheckedList(newCheckedList);
};
const handleSelectItem = (item, checked) => {
const newSelectedIds = new Set(selectedMessageIds);
const newCheckedList = [...messageCheckedList];
if (checked) {
newSelectedIds.add(item.msgId);
newCheckedList.push(item);
} else {
newSelectedIds.delete(item.msgId);
const index = newCheckedList.findIndex(msg => msg.msgId === item.msgId);
if (index > -1) {
newCheckedList.splice(index, 1);
}
}
setSelectedMessageIds(newSelectedIds);
setMessageCheckedList(newCheckedList);
// Update single item checked state in the displayed list
const updatedList = messageShowList.map(msg =>
msg.msgId === item.msgId ? {...msg, checked} : msg
);
setMessageShowList(updatedList);
// Check if all are selected
setCheckedAll(newSelectedIds.size === updatedList.length && updatedList.length > 0);
};
const consumerColumns = [
{
title: (
<Checkbox
checked={checkedAll}
onChange={handleSelectAll}
disabled={messageShowList.length === 0}
/>
),
dataIndex: 'checked',
key: 'checkbox',
align: 'center',
render: (checked, record) => (
<Checkbox
checked={checked}
onChange={(e) => handleSelectItem(record, e.target.checked)}
/>
),
},
{title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center'},
{
title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center',
render: (tags) => tags || '-' // Display '-' if tags are null or undefined
},
{
title: 'Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center',
render: (keys) => keys || '-' // Display '-' if keys are null or undefined
},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format("YYYY-MM-DD HH:mm:ss"),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<>
<Button type="primary" size="small" style={{marginRight: 8, marginBottom: 8}}
onClick={() => queryDlqMessageDetail(record.msgId, selectedConsumerGroup)}>
{t.MESSAGE_DETAIL}
</Button>
<Button type="primary" size="small" style={{marginRight: 8, marginBottom: 8}}
onClick={() => resendDlqMessage(record, selectedConsumerGroup)}>
{t.RESEND_MESSAGE}
</Button>
<Button type="primary" size="small" style={{marginBottom: 8}}
onClick={() => exportDlqMessage(record.msgId, selectedConsumerGroup)}>
{t.EXPORT}
</Button>
</>
),
},
];
const messageIdColumns = [
{title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center'},
{
title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center',
render: (tags) => tags || '-'
},
{
title: 'Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center',
render: (keys) => keys || '-'
},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format("YYYY-MM-DD HH:mm:ss"),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<>
<Button type="primary" size="small" style={{marginRight: 8, marginBottom: 8}}
onClick={() => queryDlqMessageDetail(record.msgId, selectedConsumerGroup)}>
{t.MESSAGE_DETAIL}
</Button>
<Button type="primary" size="small" style={{marginRight: 8, marginBottom: 8}}
onClick={() => resendDlqMessage(record, selectedConsumerGroup)}>
{t.RESEND_MESSAGE}
</Button>
<Button type="primary" size="small" style={{marginBottom: 8}}
onClick={() => exportDlqMessage(record.msgId, selectedConsumerGroup)}>
{t.EXPORT}
</Button>
</>
),
},
];
return (
<>
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip="加载中...">
<Tabs activeKey={activeTab} onChange={setActiveTab} centered>
<TabPane tab={t.CONSUMER} key="consumer">
<h5 style={{margin: '15px 0'}}>{t.TOTAL_MESSAGES}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" form={form} style={{marginBottom: '20px'}}>
<Form.Item label={t.CONSUMER}>
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_CONSUMER_GROUP_PLACEHOLDER}
value={selectedConsumerGroup}
onChange={(value) => {
setSelectedConsumerGroup(value);
onChangeQueryCondition();
}}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allConsumerGroupList.map(group => (
<Option key={group} value={group}>{group}</Option>
))}
</Select>
</Form.Item>
<Form.Item label={t.BEGIN}>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={timepickerBegin}
onChange={(date) => {
setTimepickerBegin(date);
onChangeQueryCondition();
}}
/>
</Form.Item>
<Form.Item label={t.END}>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={timepickerEnd}
onChange={(date) => {
setTimepickerEnd(date);
onChangeQueryCondition();
}}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={() => queryDlqMessageByConsumerGroup()}>
{t.SEARCH}
</Button>
</Form.Item>
<Form.Item>
<Button
id="batchResendBtn"
type="primary"
icon={<SendOutlined/>}
onClick={batchResendDlqMessage}
disabled={selectedMessageIds.size === 0}
>
{t.BATCH_RESEND}
</Button>
</Form.Item>
<Form.Item>
<Button
id="batchExportBtn"
type="primary"
icon={<ExportOutlined/>}
onClick={batchExportDlqMessage}
disabled={selectedMessageIds.size === 0}
>
{t.BATCH_EXPORT}
</Button>
</Form.Item>
</Form>
<Table
columns={consumerColumns}
dataSource={messageShowList}
rowKey="msgId"
bordered
pagination={{
current: paginationConf.current,
pageSize: paginationConf.pageSize,
total: paginationConf.total,
onChange: (page, pageSize) => queryDlqMessageByConsumerGroup(page, pageSize),
showSizeChanger: true, // Allow changing page size
pageSizeOptions: ['10', '20', '50', '100'], // Customizable page size options
}}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
<TabPane tab="Message ID" key="messageId">
<h5 style={{margin: '15px 0'}}>
{t.MESSAGE_ID_CONSUMER_GROUP_HINT}
</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" style={{marginBottom: '20px'}}>
<Form.Item label={t.CONSUMER}>
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_CONSUMER_GROUP_PLACEHOLDER}
value={selectedConsumerGroup}
onChange={setSelectedConsumerGroup}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allConsumerGroupList.map(group => (
<Option key={group} value={group}>{group}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="MessageId:">
<Input
style={{width: 450}}
value={messageId}
onChange={(e) => setMessageId(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={queryDlqMessageByMessageId}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={messageIdColumns}
dataSource={queryDlqMessageByMessageIdResult}
rowKey="msgId"
bordered
pagination={false}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
{modalContextHolder}
</Tabs>
</Spin>
</div>
</>
);
};
export default DlqMessageQueryPage;

View File

@@ -0,0 +1,90 @@
/*
* 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 React from 'react';
import { Form, Input, Button, message, Typography } from 'antd';
import {remoteApi} from "../../api/remoteApi/remoteApi";
const { Title } = Typography;
const Login = () => {
const [form] = Form.useForm();
const [messageApi, msgContextHolder] = message.useMessage();
const onFinish = async (values) => {
const { username, password } = values;
remoteApi.login(username, password).then((res) => {
if (res.status === 0) {
messageApi.success('登录成功');
window.localStorage.setItem("username", res.data.loginUserName);
window.localStorage.setItem("userrole", res.data.loginUserRole);
window.location.href = '/';
} else {
messageApi.error(res.message || '登录失败,请检查用户名和密码');
}
})
};
return (
<>
{msgContextHolder}
<div style={{
maxWidth: 400,
margin: '100px auto',
padding: 24,
boxShadow: '0 2px 8px #f0f1f2',
borderRadius: 8
}}>
<Title level={3} style={{textAlign: 'center', marginBottom: 24}}>
WELCOME
</Title>
<Form
form={form}
name="login_form"
layout="vertical"
onFinish={onFinish}
initialValues={{username: '', password: ''}}
>
<Form.Item
label="用户名"
name="username"
rules={[{required: true, message: '请输入用户名'}]}
>
<Input placeholder="请输入用户名"/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{required: true, message: '请输入密码'}]}
>
<Input.Password placeholder="请输入密码"/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
登录
</Button>
</Form.Item>
</Form>
</div>
</>
);
};
export default Login;

View File

@@ -0,0 +1,478 @@
/*
* 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 React, {useCallback, useEffect, useState} from 'react';
import {Button, DatePicker, Form, Input, notification, Select, Spin, Table, Tabs, Typography} from 'antd';
import moment from 'moment';
import {SearchOutlined} from '@ant-design/icons';
import {useLanguage} from '../../i18n/LanguageContext';
import MessageDetailViewDialog from "../../components/MessageDetailViewDialog"; // Keep this path
import {remoteApi} from '../../api/remoteApi/remoteApi'; // Keep this path
const {TabPane} = Tabs;
const {Option} = Select;
const {Text, Paragraph} = Typography;
const MessageQueryPage = () => {
const {t} = useLanguage();
const [activeTab, setActiveTab] = useState('topic');
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// Topic 查询状态
const [allTopicList, setAllTopicList] = useState([]);
const [selectedTopic, setSelectedTopic] = useState(null);
const [timepickerBegin, setTimepickerBegin] = useState(moment().subtract(1, 'hour')); // 默认一小时前
const [timepickerEnd, setTimepickerEnd] = useState(moment());
const [messageShowList, setMessageShowList] = useState([]);
const [paginationConf, setPaginationConf] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [taskId, setTaskId] = useState("");
// Message Key 查询状态
const [key, setKey] = useState('');
const [queryMessageByTopicAndKeyResult, setQueryMessageByTopicAndKeyResult] = useState([]);
// Message ID 查询状态
const [messageId, setMessageId] = useState('');
// State for Message Detail Dialog
const [isMessageDetailModalVisible, setIsMessageDetailModalVisible] = useState(false);
const [currentMessageIdForDetail, setCurrentMessageIdForDetail] = useState(null);
const [currentTopicForDetail, setCurrentTopicForDetail] = useState(null);
const [notificationApi, notificationContextHolder] = notification.useNotification();
const fetchAllTopics = useCallback(async () => {
setLoading(true);
try {
const resp = await remoteApi.queryTopic(false);
if (resp.status === 0) {
setAllTopicList(resp.data.topicList.sort());
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.FETCH_TOPIC_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.FETCH_TOPIC_FAILED,
});
console.error("Error fetching topic list:", error);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
fetchAllTopics();
}, [fetchAllTopics]);
const onChangeQueryCondition = () => {
setTaskId("");
setPaginationConf(prev => ({
...prev,
current: 1,
total: 0,
}));
};
const queryMessagePageByTopic = async (page = paginationConf.current, pageSize = paginationConf.pageSize) => {
if (!selectedTopic) {
notificationApi.warning({
message: t.WARNING,
description: t.PLEASE_SELECT_TOPIC,
});
return;
}
if (timepickerEnd.valueOf() < timepickerBegin.valueOf()) {
notificationApi.error({message: t.ERROR, description: t.END_TIME_EARLIER_THAN_BEGIN_TIME});
return;
}
setLoading(true);
try {
const resp = await remoteApi.queryMessagePageByTopic(
selectedTopic,
timepickerBegin.valueOf(),
timepickerEnd.valueOf(),
page,
pageSize,
taskId
);
if (resp.status === 0) {
setMessageShowList(resp.data.page.content);
setPaginationConf(prev => ({
...prev,
current: resp.data.page.number + 1,
total: resp.data.page.totalElements,
pageSize: pageSize,
}));
setTaskId(resp.data.taskId);
if (resp.data.page.content.length === 0) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.QUERY_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
console.error("查询失败:", error);
} finally {
setLoading(false);
}
};
const queryMessageByTopicAndKey = async () => {
if (!selectedTopic || !key) {
notificationApi.warning({
message: t.WARNING,
description: t.TOPIC_AND_KEY_REQUIRED,
});
return;
}
setLoading(true);
try {
const resp = await remoteApi.queryMessageByTopicAndKey(selectedTopic, key);
if (resp.status === 0) {
setQueryMessageByTopicAndKeyResult(resp.data);
if (resp.data.length === 0) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.QUERY_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
console.error("查询失败:", error);
} finally {
setLoading(false);
}
};
// Updated to open the dialog
const showMessageDetail = (msgIdToQuery, topicToQuery) => {
if (!msgIdToQuery) {
notificationApi.warning({
message: t.WARNING,
description: t.MESSAGE_ID_REQUIRED,
});
return;
}
setCurrentMessageIdForDetail(msgIdToQuery);
setCurrentTopicForDetail(topicToQuery);
setIsMessageDetailModalVisible(true);
};
const handleCloseMessageDetailModal = () => {
setIsMessageDetailModalVisible(false);
setCurrentMessageIdForDetail(null);
setCurrentTopicForDetail(null);
};
const handleResendMessage = async (messageView, consumerGroup) => {
setLoading(true); // Set loading for the main page as well, as the dialog itself can't control it
let topicToResend = messageView.topic;
let msgIdToResend = messageView.msgId;
if (topicToResend.startsWith('%DLQ%')) {
if (messageView.properties && messageView.properties.hasOwnProperty("RETRY_TOPIC")) {
topicToResend = messageView.properties.RETRY_TOPIC;
}
if (messageView.properties && messageView.properties.hasOwnProperty("ORIGIN_MESSAGE_ID")) {
msgIdToResend = messageView.properties.ORIGIN_MESSAGE_ID;
}
}
try {
const resp = await remoteApi.resendMessageDirectly(msgIdToResend, consumerGroup, topicToResend);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.RESEND_SUCCESS,
});
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.RESEND_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.RESEND_FAILED,
});
console.error("重发失败:", error);
} finally {
setLoading(false);
// Optionally, you might want to refresh the message detail after resend
// or close the modal if resend was successful and you don't need to see details immediately.
// For now, we'll keep the modal open and let the user close it.
}
};
const topicColumns = [
{
title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center',
render: (text) => <Text copyable>{text}</Text>
},
{title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center'},
{title: 'Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center'},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format("YYYY-MM-DD HH:mm:ss"),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<Button type="primary" size="small" onClick={() => showMessageDetail(record.msgId, record.topic)}>
{t.MESSAGE_DETAIL}
</Button>
),
},
];
const keyColumns = [
{
title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center',
render: (text) => <Text copyable>{text}</Text>
},
{title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center'},
{title: 'Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center'},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format("YYYY-MM-DD HH:mm:ss"),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<Button type="primary" size="small" onClick={() => showMessageDetail(record.msgId, record.topic)}>
{t.MESSAGE_DETAIL}
</Button>
),
},
];
return (
<>
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip={t.LOADING_DATA}>
<Tabs activeKey={activeTab} onChange={setActiveTab} centered>
<TabPane tab="Topic" key="topic">
<h5 style={{margin: '15px 0'}}>{t.TOTAL_MESSAGES}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" form={form} style={{marginBottom: '20px'}}>
<Form.Item label={t.TOPIC}>
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={(value) => {
setSelectedTopic(value);
onChangeQueryCondition();
}}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label={t.BEGIN}>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={timepickerBegin}
onChange={(date) => {
setTimepickerBegin(date);
onChangeQueryCondition();
}}
/>
</Form.Item>
<Form.Item label={t.END}>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={timepickerEnd}
onChange={(date) => {
setTimepickerEnd(date);
onChangeQueryCondition();
}}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={() => queryMessagePageByTopic()}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={topicColumns}
dataSource={messageShowList}
rowKey="msgId"
bordered
pagination={{
current: paginationConf.current,
pageSize: paginationConf.pageSize,
total: paginationConf.total,
onChange: (page, pageSize) => queryMessagePageByTopic(page, pageSize),
}}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
<TabPane tab="Message Key" key="messageKey">
<h5 style={{margin: '15px 0'}}>{t.ONLY_RETURN_64_MESSAGES}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" style={{marginBottom: '20px'}}>
<Form.Item label="Topic:">
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="Key:">
<Input
style={{width: 450}}
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={queryMessageByTopicAndKey}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={keyColumns}
dataSource={queryMessageByTopicAndKeyResult}
rowKey="msgId"
bordered
pagination={false}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
<TabPane tab="Message ID" key="messageId">
<h5 style={{margin: '15px 0'}}>
{t.MESSAGE_ID_TOPIC_HINT}
</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" style={{marginBottom: '20px'}}>
<Form.Item label="Topic:">
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="MessageId:">
<Input
style={{width: 450}}
value={messageId}
onChange={(e) => setMessageId(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={() => showMessageDetail(messageId, selectedTopic)}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
{/* Message ID 查询结果通常直接弹窗显示,这里不需要表格 */}
</div>
</TabPane>
</Tabs>
</Spin>
{/* Message Detail Dialog Component */}
<MessageDetailViewDialog
visible={isMessageDetailModalVisible}
onCancel={handleCloseMessageDetailModal}
messageId={currentMessageIdForDetail}
topic={currentTopicForDetail}
onResendMessage={handleResendMessage} // Pass the resend function
/>
</div>
</>
);
};
export default MessageQueryPage;

View File

@@ -0,0 +1,429 @@
/*
* 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 React, {useEffect, useState} from 'react';
import {Button, Form, Input, notification, Select, Spin, Table, Tabs, Typography} from 'antd';
import moment from 'moment';
import {SearchOutlined} from '@ant-design/icons';
import {useLanguage} from '../../i18n/LanguageContext';
import MessageTraceDetailViewDialog from "../../components/MessageTraceDetailViewDialog";
import {remoteApi} from '../../api/remoteApi/remoteApi'; // Import the remoteApi
const {TabPane} = Tabs;
const {Option} = Select;
const {Text, Paragraph} = Typography;
const MessageTraceQueryPage = () => {
const {t} = useLanguage();
const [activeTab, setActiveTab] = useState('messageKey');
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// 轨迹主题选择
const [allTraceTopicList, setAllTraceTopicList] = useState([]);
const [selectedTraceTopic, setSelectedTraceTopic] = useState(null); // Initialize as null or a default trace topic if applicable
// Topic 查询状态
const [allTopicList, setAllTopicList] = useState([]);
const [selectedTopic, setSelectedTopic] = useState(null);
const [key, setKey] = useState('');
const [queryMessageByTopicAndKeyResult, setQueryMessageByTopicAndKeyResult] = useState([]);
// Message ID 查询状态
const [messageId, setMessageId] = useState('');
const [queryMessageByMessageIdResult, setQueryMessageByMessageIdResult] = useState([]);
// State for MessageTraceDetailViewDialog
const [isTraceDetailViewOpen, setIsTraceDetailViewOpen] = useState(false);
const [traceDetailData, setTraceDetailData] = useState(null);
const [notificationApi, notificationContextHolder] = notification.useNotification();
useEffect(() => {
const fetchTopics = async () => {
setLoading(true);
try {
const resp = await remoteApi.queryTopic(true);
if (resp.status === 0) {
const topics = resp.data.topicList.sort();
setAllTopicList(topics);
const traceTopics = topics.filter(topic =>
!topic.startsWith('%RETRY%') && !topic.startsWith('%DLQ%')
);
setAllTraceTopicList(traceTopics);
// Optionally set a default trace topic if available, e.g., 'RMQ_SYS_TRACE_TOPIC'
if (traceTopics.includes('RMQ_SYS_TRACE_TOPIC')) {
setSelectedTraceTopic('RMQ_SYS_TRACE_TOPIC');
} else if (traceTopics.length > 0) {
setSelectedTraceTopic(traceTopics[0]); // Select the first one if no default
}
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.QUERY_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: error.message || t.QUERY_FAILED,
});
} finally {
setLoading(false);
}
};
fetchTopics();
}, [t]);
const queryMessageByTopicAndKey = async () => {
if (!selectedTopic || !key) {
notificationApi.warning({
message: t.WARNING,
description: t.TOPIC_AND_KEY_REQUIRED,
});
return;
}
setLoading(true);
try {
const data = await remoteApi.queryMessageByTopicAndKey(selectedTopic, key);
if (data.status === 0) {
setQueryMessageByTopicAndKeyResult(data.data);
if (data.data.length === 0) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
} else {
notificationApi.error({
message: t.ERROR,
description: data.errMsg || t.QUERY_FAILED,
});
setQueryMessageByTopicAndKeyResult([]); // Clear previous results on error
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: error.message || t.QUERY_FAILED,
});
setQueryMessageByTopicAndKeyResult([]); // Clear previous results on error
} finally {
setLoading(false);
}
};
const queryMessageByMessageId = async (msgIdToQuery, topicToQuery) => {
if (!msgIdToQuery) {
notificationApi.warning({
message: t.WARNING,
description: t.MESSAGE_ID_REQUIRED,
});
return;
}
setLoading(true);
try {
const res = await remoteApi.queryMessageByMessageId(msgIdToQuery, topicToQuery);
if (res.status === 0) {
// 确保 data.data.messageView 存在,并将其包装成数组
setQueryMessageByMessageIdResult(res.data && res.data.messageView ? [res.data.messageView] : []);
} else {
notificationApi.error({
message: t.ERROR,
description: res.errMsg || t.QUERY_FAILED,
});
setQueryMessageByMessageIdResult([]); // 清除错误时的旧数据
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: error.message || t.QUERY_FAILED,
});
setQueryMessageByMessageIdResult([]); // 清除错误时的旧数据
} finally {
setLoading(false);
}
};
const queryMessageTraceByMessageId = async (msgId, traceTopic) => {
if (!msgId) {
notificationApi.warning({
message: t.WARNING,
description: t.MESSAGE_ID_REQUIRED,
});
return;
}
setLoading(true);
try {
const data = await remoteApi.queryMessageTraceByMessageId(msgId, traceTopic || 'RMQ_SYS_TRACE_TOPIC');
if (data.status === 0) {
setTraceDetailData(data.data);
setIsTraceDetailViewOpen(true);
} else {
notificationApi.error({
message: t.ERROR,
description: data.errMsg || t.QUERY_FAILED,
});
setTraceDetailData(null); // Clear previous trace data on error
setIsTraceDetailViewOpen(false); // Do not open dialog if data is not available
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: error.message || t.QUERY_FAILED,
});
setTraceDetailData(null); // Clear previous trace data on error
setIsTraceDetailViewOpen(false); // Do not open dialog if data is not available
} finally {
setLoading(false);
}
};
const handleCloseTraceDetailView = () => {
setIsTraceDetailViewOpen(false);
setTraceDetailData(null);
};
const keyColumns = [
{title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center'},
{title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center'},
{title: 'Message Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center'},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<Button type="primary" size="small"
onClick={() => queryMessageTraceByMessageId(record.msgId, selectedTraceTopic)}>
{t.MESSAGE_TRACE_DETAIL}
</Button>
),
},
];
const messageIdColumns = [
{title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center'},
// 注意:这里的 dataIndex 直接指向了 messageView 内部的属性
{title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center'},
{title: 'Message Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center'},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<Button type="primary" size="small"
onClick={() => queryMessageTraceByMessageId(record.msgId, selectedTraceTopic)}>
{t.MESSAGE_TRACE_DETAIL}
</Button>
),
},
];
return (
<>
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip="加载中...">
<div style={{marginBottom: '20px', borderBottom: '1px solid #f0f0f0', paddingBottom: '15px'}}>
<Form layout="inline">
<Form.Item label={<Text strong>{t.TRACE_TOPIC}:</Text>}>
<Select
showSearch
style={{minWidth: 300}}
placeholder={t.SELECT_TRACE_TOPIC_PLACEHOLDER}
value={selectedTraceTopic}
onChange={setSelectedTraceTopic}
filterOption={(input, option) =>
option.children && option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTraceTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Text type="secondary" style={{marginLeft: 10}}>({t.TRACE_TOPIC_HINT})</Text>
</Form>
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab} centered>
<TabPane tab="Message Key" key="messageKey">
<h5 style={{margin: '15px 0'}}>{t.ONLY_RETURN_64_MESSAGES}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" form={form} style={{marginBottom: '20px'}}>
<Form.Item label="Topic:">
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
required
filterOption={(input, option) =>
option.children && option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
<Option value="">{t.SELECT_TOPIC_PLACEHOLDER}</Option>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="Key:">
<Input
style={{width: 450}}
value={key}
onChange={(e) => setKey(e.target.value)}
required
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={queryMessageByTopicAndKey}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={keyColumns}
dataSource={queryMessageByTopicAndKeyResult}
rowKey="msgId"
bordered
pagination={false}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
<TabPane tab="Message ID" key="messageId">
<h5 style={{margin: '15px 0'}}>{t.MESSAGE_ID_TOPIC_HINT}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" style={{marginBottom: '20px'}}>
<Form.Item label="Topic:">
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
required
filterOption={(input, option) => {
if (option.children && typeof option.children === 'string') {
return option.children.toLowerCase().includes(input.toLowerCase());
}
return false;
}}
>
<Option value="">{t.SELECT_TOPIC_PLACEHOLDER}</Option>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="MessageId:">
<Input
style={{width: 450}}
value={messageId}
onChange={(e) => setMessageId(e.target.value)}
required
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={() => queryMessageByMessageId(messageId, selectedTopic)}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={messageIdColumns}
dataSource={queryMessageByMessageIdResult}
rowKey="msgId"
bordered
pagination={false}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
</Tabs>
</Spin>
{/* MessageTraceDetailViewDialog as a child component */}
{isTraceDetailViewOpen && traceDetailData && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
}}>
<div style={{
backgroundColor: '#fff',
padding: '20px',
borderRadius: '8px',
width: '80%',
maxHeight: '90%',
overflowY: 'auto',
position: 'relative'
}}>
<Typography.Title level={4}
style={{marginBottom: '20px'}}>{t.MESSAGE_TRACE_DETAIL}</Typography.Title>
<Button
onClick={handleCloseTraceDetailView}
style={{
position: 'absolute',
top: '20px',
right: '20px',
}}
>
{t.CLOSE}
</Button>
<MessageTraceDetailViewDialog
ngDialogData={traceDetailData}
/>
</div>
</div>
)}
</div>
</>
);
};
export default MessageTraceQueryPage;

View File

@@ -0,0 +1,183 @@
/*
* 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 React, { useState, useEffect } from 'react';
import { Select, Button, Switch, Input, Typography, Space, message } from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
const { Title } = Typography;
const { Option } = Select;
const Ops = () => {
const [namesrvAddrList, setNamesrvAddrList] = useState([]);
const [selectedNamesrv, setSelectedNamesrv] = useState('');
const [newNamesrvAddr, setNewNamesrvAddr] = useState('');
const [useVIPChannel, setUseVIPChannel] = useState(false);
const [useTLS, setUseTLS] = useState(false);
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true); // Default to true
const [messageApi, msgContextHolder] = message.useMessage();
useEffect(() => {
const fetchOpsData = async () => {
const userRole = sessionStorage.getItem("userrole");
setWriteOperationEnabled(userRole === null || userRole === "1"); // Assuming "1" means write access
const resp = await remoteApi.queryOpsHomePage();
if (resp.status === 0) {
setNamesrvAddrList(resp.data.namesvrAddrList);
setUseVIPChannel(resp.data.useVIPChannel);
setUseTLS(resp.data.useTLS);
setSelectedNamesrv(resp.data.currentNamesrv);
} else {
messageApi.error(resp.errMsg);
}
};
fetchOpsData();
}, []);
const handleUpdateNameSvrAddr = async () => {
if (!selectedNamesrv) {
messageApi.warning('请选择一个 NameServer 地址');
return;
}
const resp = await remoteApi.updateNameSvrAddr(selectedNamesrv);
if (resp.status === 0) {
messageApi.info('UPDATE SUCCESS');
} else {
messageApi.error(resp.errMsg);
}
};
const handleAddNameSvrAddr = async () => {
if (!newNamesrvAddr.trim()) {
messageApi.warning('请输入新的 NameServer 地址');
return;
}
const resp = await remoteApi.addNameSvrAddr(newNamesrvAddr.trim());
if (resp.status === 0) {
if (!namesrvAddrList.includes(newNamesrvAddr.trim())) {
setNamesrvAddrList([...namesrvAddrList, newNamesrvAddr.trim()]);
}
setNewNamesrvAddr('');
messageApi.info('ADD SUCCESS');
} else {
messageApi.error(resp.errMsg);
}
};
const handleUpdateIsVIPChannel = async (checked) => {
setUseVIPChannel(checked); // Optimistic update
const resp = await remoteApi.updateIsVIPChannel(checked);
if (resp.status === 0) {
messageApi.info('UPDATE SUCCESS');
} else {
messageApi.error(resp.errMsg);
setUseVIPChannel(!checked); // Revert on error
}
};
const handleUpdateUseTLS = async (checked) => {
setUseTLS(checked); // Optimistic update
const resp = await remoteApi.updateUseTLS(checked);
if (resp.status === 0) {
messageApi.info('UPDATE SUCCESS');
} else {
messageApi.error(resp.errMsg);
setUseTLS(!checked); // Revert on error
}
};
return (
<>
{msgContextHolder}
<div style={{padding: 24}}>
<div style={{marginBottom: 24}}>
<Title level={4}>NameServerAddressList</Title>
<Space wrap align="start">
<Select
style={{minWidth: 400, maxWidth: 500}}
value={selectedNamesrv}
onChange={setSelectedNamesrv}
disabled={!writeOperationEnabled}
placeholder="请选择 NameServer 地址"
>
{namesrvAddrList.map((addr) => (
<Option key={addr} value={addr}>
{addr}
</Option>
))}
</Select>
{writeOperationEnabled && (
<Button type="primary" onClick={handleUpdateNameSvrAddr}>
UPDATE
</Button>
)}
{writeOperationEnabled && (
<Input.Group compact style={{minWidth: 400}}>
<Input
style={{width: 300}}
placeholder="NamesrvAddr"
value={newNamesrvAddr}
onChange={(e) => setNewNamesrvAddr(e.target.value)}
/>
<Button type="primary" onClick={handleAddNameSvrAddr}>
ADD
</Button>
</Input.Group>
)}
</Space>
</div>
<div style={{marginBottom: 24}}>
<Title level={4}>IsUseVIPChannel</Title>
<Space align="center">
<Switch
checked={useVIPChannel}
onChange={handleUpdateIsVIPChannel}
disabled={!writeOperationEnabled}
/>
{writeOperationEnabled && (
<Button type="primary" onClick={() => handleUpdateIsVIPChannel(useVIPChannel)}>
UPDATE
</Button>
)}
</Space>
</div>
<div style={{marginBottom: 24}}>
<Title level={4}>useTLS</Title>
<Space align="center">
<Switch
checked={useTLS}
onChange={handleUpdateUseTLS}
disabled={!writeOperationEnabled}
/>
{writeOperationEnabled && (
<Button type="primary" onClick={() => handleUpdateUseTLS(useTLS)}>
UPDATE
</Button>
)}
</Space>
</div>
</div>
</>
);
};
export default Ops;

View File

@@ -0,0 +1,143 @@
/*
* 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 React, {useEffect, useState} from 'react';
import {Button, Form, Input, message, Select, Table} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
const {Option} = Select;
const ProducerConnectionList = () => {
const [form] = Form.useForm();
const [allTopicList, setAllTopicList] = useState([]);
const [connectionList, setConnectionList] = useState([]);
const [loading, setLoading] = useState(false);
const [messageApi, msgContextHolder] = message.useMessage();
useEffect(() => {
const fetchTopicList = async () => {
setLoading(true);
try {
const resp = await remoteApi.queryTopic(true);
if (!resp) {
messageApi.error("Failed to fetch topic list - no response");
return;
}
if (resp.status === 0) {
setAllTopicList(resp.data.topicList.sort());
} else {
messageApi.error(resp.errMsg || "Failed to fetch topic list");
}
} catch (error) {
messageApi.error("An error occurred while fetching topic list");
console.error("Fetch error:", error);
} finally {
setLoading(false);
}
};
fetchTopicList();
}, []);
const onFinish = (values) => {
setLoading(true);
const {selectedTopic, producerGroup} = values;
remoteApi.queryProducerConnection(selectedTopic, producerGroup, (resp) => {
if (resp.status === 0) {
setConnectionList(resp.data.connectionSet);
} else {
messageApi.error(resp.errMsg || "Failed to fetch producer connection list");
}
setLoading(false);
});
};
const columns = [
{
title: 'clientId',
dataIndex: 'clientId',
key: 'clientId',
align: 'center',
},
{
title: 'clientAddr',
dataIndex: 'clientAddr',
key: 'clientAddr',
align: 'center',
},
{
title: 'language',
dataIndex: 'language',
key: 'language',
align: 'center',
},
{
title: 'version',
dataIndex: 'versionDesc',
key: 'versionDesc',
align: 'center',
},
];
return (
<>
{msgContextHolder}
<div className="container-fluid" id="deployHistoryList">
<Form
form={form}
layout="inline"
onFinish={onFinish}
style={{marginBottom: 20}}
>
<Form.Item label="TOPIC" name="selectedTopic"
rules={[{required: true, message: 'Please select a topic!'}]}>
<Select
showSearch
placeholder="Select a topic"
style={{width: 300}}
optionFilterProp="children"
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTopicList.map((topic) => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="PRODUCER_GROUP" name="producerGroup"
rules={[{required: true, message: 'Please input producer group!'}]}>
<Input style={{width: 300}}/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading}>
<span className="glyphicon glyphicon-search"></span> SEARCH
</Button>
</Form.Item>
</Form>
<Table
dataSource={connectionList}
columns={columns}
rowKey="clientId"
pagination={false}
bordered
/>
</div>
</>
);
};
export default ProducerConnectionList;

View File

@@ -0,0 +1,181 @@
/*
* 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 React, { useState, useEffect } from 'react';
import { Modal, Button, Select, Input, Card, Row, Col, notification, Spin } from 'antd';
import { useLanguage } from '../../i18n/LanguageContext';
import { remoteApi } from "../../api/remoteApi/remoteApi";
const { Option } = Select;
const ProxyManager = () => {
const { t } = useLanguage();
const [loading, setLoading] = useState(false);
const [proxyAddrList, setProxyAddrList] = useState([]);
const [selectedProxy, setSelectedProxy] = useState('');
const [newProxyAddr, setNewProxyAddr] = useState('');
const [allProxyConfig, setAllProxyConfig] = useState({});
const [showModal, setShowModal] = useState(false); // 控制 Modal 弹窗显示
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true); // 写操作权限,默认 true
const [notificationApi, notificationContextHolder] = notification.useNotification();
useEffect(() => {
const userRole = sessionStorage.getItem("userrole");
const isWriteEnabled = userRole === null || userRole === '1';
setWriteOperationEnabled(isWriteEnabled);
}, []);
useEffect(() => {
setLoading(true);
remoteApi.queryProxyHomePage((resp) => {
setLoading(false);
if (resp.status === 0) {
const { proxyAddrList, currentProxyAddr } = resp.data;
setProxyAddrList(proxyAddrList || []);
setSelectedProxy(currentProxyAddr || (proxyAddrList && proxyAddrList.length > 0 ? proxyAddrList[0] : ''));
if (currentProxyAddr) {
localStorage.setItem('proxyAddr', currentProxyAddr);
} else if (proxyAddrList && proxyAddrList.length > 0) {
localStorage.setItem('proxyAddr', proxyAddrList[0]);
}
} else {
notificationApi.error({ message: resp.errMsg || t.FETCH_PROXY_LIST_FAILED, duration: 2 });
}
});
}, [t]);
const handleSelectChange = (value) => {
setSelectedProxy(value);
localStorage.setItem('proxyAddr', value);
};
const handleAddProxyAddr = () => {
if (!newProxyAddr.trim()) {
notificationApi.warning({ message: t.INPUT_PROXY_ADDR_REQUIRED || "Please input a new proxy address.", duration: 2 });
return;
}
setLoading(true);
remoteApi.addProxyAddr(newProxyAddr.trim(), (resp) => {
setLoading(false);
if (resp.status === 0) {
if (!proxyAddrList.includes(newProxyAddr.trim())) {
setProxyAddrList(prevList => [...prevList, newProxyAddr.trim()]);
}
setNewProxyAddr('');
notificationApi.info({ message: t.SUCCESS || "SUCCESS", duration: 2 });
} else {
notificationApi.error({ message: resp.errMsg || t.ADD_PROXY_FAILED, duration: 2 });
}
});
};
return (
<Spin spinning={loading} tip={t.LOADING}>
<div className="container-fluid" style={{ padding: '24px' }} id="deployHistoryList">
<Card
title={
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
ProxyServerAddressList
</div>
}
bordered={false}
>
<Row gutter={[16, 16]} align="middle">
<Col flex="auto" style={{ minWidth: 300, maxWidth: 500 }}>
<Select
style={{ width: '100%' }}
value={selectedProxy}
onChange={handleSelectChange}
placeholder={t.SELECT}
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{proxyAddrList.map(addr => (
<Option key={addr} value={addr}>
{addr}
</Option>
))}
</Select>
</Col>
</Row>
{writeOperationEnabled && (
<Row gutter={[16, 16]} align="middle" style={{ marginTop: 16 }}>
<Col>
<label htmlFor="newProxyAddrInput">ProxyAddr:</label>
</Col>
<Col>
<Input
id="newProxyAddrInput"
style={{ width: 300 }}
value={newProxyAddr}
onChange={(e) => setNewProxyAddr(e.target.value)}
placeholder={t.INPUT_PROXY_ADDR}
/>
</Col>
<Col>
<Button type="primary" onClick={handleAddProxyAddr}>
{t.ADD}
</Button>
</Col>
</Row>
)}
</Card>
<Modal
open={showModal}
onCancel={() => setShowModal(false)}
title={`${t.PROXY_CONFIG} [${selectedProxy}]`}
footer={
<div style={{ textAlign: 'center' }}>
<Button onClick={() => setShowModal(false)}>{t.CLOSE}</Button>
</div>
}
width={800}
bodyStyle={{ maxHeight: '60vh', overflowY: 'auto' }}
>
<table className="table table-bordered" style={{ width: '100%' }}>
<tbody>
{Object.entries(allProxyConfig).length > 0 ? (
Object.entries(allProxyConfig).map(([key, value]) => (
<tr key={key}>
<td style={{ fontWeight: 500, width: '30%' }}>{key}</td>
<td>{value}</td>
</tr>
))
) : (
<tr>
<td colSpan="2" style={{ textAlign: 'center' }}>{t.NO_CONFIG_DATA || "No configuration data available."}</td>
</tr>
)}
</tbody>
</table>
</Modal>
</div>
</Spin>
);
};
export default ProxyManager;

View File

@@ -0,0 +1,705 @@
/*
* 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 React, {useEffect, useState} from 'react';
import {Button, Checkbox, Form, Input, message, Popconfirm, Space, Table} from 'antd';
import {useLanguage} from '../../i18n/LanguageContext';
import {remoteApi} from '../../api/remoteApi/remoteApi';
import ResetOffsetResultDialog from "../../components/topic/ResetOffsetResultDialog";
import SendResultDialog from "../../components/topic/SendResultDialog";
import TopicModifyDialog from "../../components/topic/TopicModifyDialog";
import ConsumerViewDialog from "../../components/topic/ConsumerViewDialog";
import ConsumerResetOffsetDialog from "../../components/topic/ConsumerResetOffsetDialog";
import SkipMessageAccumulateDialog from "../../components/topic/SkipMessageAccumulateDialog";
import StatsViewDialog from "../../components/topic/StatsViewDialog";
import RouterViewDialog from "../../components/topic/RouterViewDialog";
import SendTopicMessageDialog from "../../components/topic/SendTopicMessageDialog";
const DeployHistoryList = () => {
const {t} = useLanguage();
const [filterStr, setFilterStr] = useState('');
const [filterNormal, setFilterNormal] = useState(true);
const [filterDelay, setFilterDelay] = useState(false);
const [filterFifo, setFilterFifo] = useState(false);
const [filterTransaction, setFilterTransaction] = useState(false);
const [filterUnspecified, setFilterUnspecified] = useState(false);
const [filterRetry, setFilterRetry] = useState(false);
const [filterDLQ, setFilterDLQ] = useState(false);
const [filterSystem, setFilterSystem] = useState(false);
const [rmqVersion, setRmqVersion] = useState(true);
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true);
const [allTopicList, setAllTopicList] = useState([]);
const [allMessageTypeList, setAllMessageTypeList] = useState([]);
const [topicShowList, setTopicShowList] = useState([]);
const [loading, setLoading] = useState(false);
// Dialog visibility states
const [isAddUpdateTopicModalVisible, setIsAddUpdateTopicModalVisible] = useState(false);
const [isResetOffsetResultModalVisible, setIsResetOffsetResultModalVisible] = useState(false);
const [isSendResultModalVisible, setIsSendResultModalVisible] = useState(false);
const [isConsumerViewModalVisible, setIsConsumerViewModalVisible] = useState(false);
const [isConsumerResetOffsetModalVisible, setIsConsumerResetOffsetModalVisible] = useState(false);
const [isSkipMessageAccumulateModalVisible, setIsSkipMessageAccumulateModalVisible] = useState(false);
const [isStatsViewModalVisible, setIsStatsViewModalVisible] = useState(false);
const [isRouterViewModalVisible, setIsRouterViewModalVisible] = useState(false);
const [isSendTopicMessageModalVisible, setIsSendTopicMessageModalVisible] = useState(false);
// Data for dialogs
const [currentTopicForDialogs, setCurrentTopicForDialogs] = useState('');
const [isUpdateMode, setIsUpdateMode] = useState(false);
const [resetOffsetResultData, setResetOffsetResultData] = useState(null);
const [sendResultData, setSendResultData] = useState(null);
const [consumerData, setConsumerData] = useState(null);
const [allConsumerGroupList, setAllConsumerGroupList] = useState([]);
const [statsData, setStatsData] = useState(null);
const [routeData, setRouteData] = useState(null);
const [topicModifyData, setTopicModifyData] = useState([]);
const [sendTopicMessageData, setSendTopicMessageData] = useState({
topic: '',
tag: '',
key: '',
messageBody: '',
traceEnabled: false,
});
const [selectedConsumerGroups, setSelectedConsumerGroups] = useState([]);
const [resetOffsetTime, setResetOffsetTime] = useState(new Date());
const [allClusterNameList, setAllClusterNameList] = useState([]);
const [allBrokerNameList, setAllBrokerNameList] = useState([]);
const [messageApi, msgContextHolder] = message.useMessage();
// Pagination config
const [paginationConf, setPaginationConf] = useState({
current: 1,
pageSize: 10,
total: 0,
});
useEffect(() => {
getTopicList();
}, []);
useEffect(() => {
filterList(paginationConf.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterStr, filterNormal, filterDelay, filterFifo, filterTransaction,
filterUnspecified, filterRetry, filterDLQ, filterSystem, allTopicList]);
// Close functions for Modals
const closeAddUpdateDialog = () => {
setIsAddUpdateTopicModalVisible(false);
setTopicModifyData([]);
};
const closeResetOffsetResultDialog = () => {
setIsResetOffsetResultModalVisible(false);
setResetOffsetResultData(null);
};
const closeSendResultDialog = () => {
setIsSendResultModalVisible(false);
setSendResultData(null);
};
const closeConsumerViewDialog = () => {
setIsConsumerViewModalVisible(false);
setConsumerData(null);
setAllConsumerGroupList([]);
};
const closeConsumerResetOffsetDialog = () => {
setIsConsumerResetOffsetModalVisible(false);
setSelectedConsumerGroups([]);
setResetOffsetTime(new Date());
setAllConsumerGroupList([]);
};
const closeSkipMessageAccumulateDialog = () => {
setIsSkipMessageAccumulateModalVisible(false);
setSelectedConsumerGroups([]);
setAllConsumerGroupList([]);
};
const closeStatsViewDialog = () => {
setIsStatsViewModalVisible(false);
setStatsData(null);
};
const closeRouterViewDialog = () => {
setIsRouterViewModalVisible(false);
setRouteData(null);
};
const closeSendTopicMessageDialog = () => {
setIsSendTopicMessageModalVisible(false);
setSendTopicMessageData({topic: '', tag: '', key: '', messageBody: '', traceEnabled: false});
};
const getTopicList = async () => {
setLoading(true);
try {
const result = await remoteApi.queryTopicList();
if (result.status === 0) {
setAllTopicList(result.data.topicNameList);
setAllMessageTypeList(result.data.messageTypeList);
setPaginationConf(prev => ({
...prev,
total: result.data.topicNameList.length
}));
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching topic list:", error);
messageApi.error("Failed to fetch topic list");
} finally {
setLoading(false);
}
};
const filterList = (currentPage) => {
const lowExceptStr = filterStr.toLowerCase();
const canShowList = allTopicList.filter((topic, index) => {
if (filterStr && !topic.toLowerCase().includes(lowExceptStr)) {
return false;
}
return filterByType(topic, allMessageTypeList[index]);
});
const perPage = paginationConf.pageSize;
const from = (currentPage - 1) * perPage;
const to = (from + perPage) > canShowList.length ? canShowList.length : from + perPage;
setTopicShowList(canShowList.slice(from, to));
setPaginationConf(prev => ({
...prev,
current: currentPage,
total: canShowList.length
}));
};
const filterByType = (topic, type) => {
if (filterRetry && type.includes("RETRY")) return true;
if (filterDLQ && type.includes("DLQ")) return true;
if (filterSystem && type.includes("SYSTEM")) return true;
if (rmqVersion && filterUnspecified && type.includes("UNSPECIFIED")) return true;
if (filterNormal && type.includes("NORMAL")) return true;
if (!rmqVersion && filterNormal && type.includes("UNSPECIFIED")) return true;
if (rmqVersion && filterDelay && type.includes("DELAY")) return true;
if (rmqVersion && filterFifo && type.includes("FIFO")) return true;
if (rmqVersion && filterTransaction && type.includes("TRANSACTION")) return true;
return false;
};
const handleTableChange = (pagination) => {
setPaginationConf(pagination);
filterList(pagination.current);
};
const openAddUpdateDialog = async (topic, isSys) => {
setCurrentTopicForDialogs(typeof topic === 'string' ? topic : (topic && topic.name) || '');
const isUpdate = typeof topic === 'string' && !!topic; // 如果 topic 是非空字符串,则认为是更新
setIsUpdateMode(isUpdate);
try {
if (isUpdate) {
// topic 已经是字符串
const configResult = await remoteApi.getTopicConfig(topic);
if (configResult.status === 0) {
const dataToSet = Array.isArray(configResult.data) ? configResult.data : [configResult.data];
setTopicModifyData(dataToSet.map(item => ({
clusterNameList: [],
brokerNameList: item.brokerNameList || [],
topicName: item.topicName,
messageType: item.messageType || 'NORMAL',
writeQueueNums: item.writeQueueNums || 8,
readQueueNums: item.readQueueNums || 8,
perm: item.perm || 7,
})));
} else {
messageApi.error(configResult.errMsg);
return;
}
} else {
setTopicModifyData([{
clusterNameList: [],
brokerNameList: [],
topicName: '',
messageType: 'NORMAL',
writeQueueNums: 8,
readQueueNums: 8,
perm: 7,
}]);
}
} catch (error) {
console.error("Error opening add/update dialog:", error);
messageApi.error("Failed to open dialog");
return;
}
if(!isUpdate){
const clusterResult = await remoteApi.getClusterList();
if (clusterResult.status === 0) {
setAllClusterNameList(Object.keys(clusterResult.data.clusterInfo.clusterAddrTable));
setAllBrokerNameList(Object.keys(clusterResult.data.brokerServer));
} else {
messageApi.error(clusterResult.errMsg);
}
}
setIsAddUpdateTopicModalVisible(true);
};
// Post Topic Request (Add/Update)
const postTopicRequest = async (values) => {
try {
const result = await remoteApi.createOrUpdateTopic(values);
if (result.status === 0) {
messageApi.success(t.TOPIC_OPERATION_SUCCESS);
closeAddUpdateDialog();
if(!isUpdateMode) {
await getTopicList()
}
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error creating/updating topic:", error);
messageApi.error("Failed to create/update topic");
}
};
// Delete Topic
const deleteTopic = async (topicToDelete) => {
try {
const result = await remoteApi.deleteTopic(topicToDelete);
if (result.status === 0) {
messageApi.success(`${t.TOPIC} [${topicToDelete}] ${t.DELETED_SUCCESSFULLY}`);
setAllTopicList(allTopicList.filter(topic => topic !== topicToDelete));
await getTopicList()
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error deleting topic:", error);
messageApi.error("Failed to delete topic");
}
};
// Open Stats View Dialog
const statsView = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicStats(topic);
if (result.status === 0) {
setStatsData(result.data);
setIsStatsViewModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching stats:", error);
messageApi.error("Failed to fetch stats");
}
};
// Open Router View Dialog
const routerView = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicRoute(topic);
if (result.status === 0) {
setRouteData(result.data);
setIsRouterViewModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching route:", error);
messageApi.error("Failed to fetch route");
}
};
// Open Consumer View Dialog
const consumerView = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicConsumers(topic);
if (result.status === 0) {
setConsumerData(result.data);
setAllConsumerGroupList(Object.keys(result.data));
setIsConsumerViewModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching consumers:", error);
messageApi.error("Failed to fetch consumers");
}
};
// Open Consumer Reset Offset Dialog
const openConsumerResetOffsetDialog = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicConsumerGroups(topic);
if (result.status === 0) {
if (!result.data.groupList) {
messageApi.error("No consumer groups found");
return;
}
setAllConsumerGroupList(result.data.groupList);
setIsConsumerResetOffsetModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching consumer groups:", error);
messageApi.error("Failed to fetch consumer groups");
}
};
// Open Skip Message Accumulate Dialog
const openSkipMessageAccumulateDialog = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicConsumerGroups(topic);
if (result.status === 0) {
if (!result.data.groupList) {
messageApi.error("No consumer groups found");
return;
}
setAllConsumerGroupList(result.data.groupList);
setIsSkipMessageAccumulateModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching consumer groups:", error);
messageApi.error("Failed to fetch consumer groups");
}
};
// Open Send Topic Message Dialog
const openSendTopicMessageDialog = (topic) => {
setCurrentTopicForDialogs(topic);
setSendTopicMessageData(prev => ({...prev, topic}));
setIsSendTopicMessageModalVisible(true);
};
const handleInputChange = (e) => {
const {name, value} = e.target;
setSendTopicMessageData(prevData => ({
...prevData,
[name]: value,
}));
};
const handleResetOffset = async (consumerGroupList, resetTime) => {
try {
const result = await remoteApi.resetConsumerOffset({
resetTime: resetTime, // 使用传递过来的 resetTime
consumerGroupList: consumerGroupList, // 使用传递过来的 consumerGroupList
topic: currentTopicForDialogs,
force: true
});
if (result.status === 0) {
setResetOffsetResultData(result.data);
setIsResetOffsetResultModalVisible(true);
setIsConsumerResetOffsetModalVisible(false);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error resetting offset:", error);
messageApi.error("Failed to reset offset");
}
};
const handleSkipMessageAccumulate = async (consumerGroupListFromDialog) => {
try {
const result = await remoteApi.skipMessageAccumulate({
resetTime: -1,
consumerGroupList: consumerGroupListFromDialog, // 使用子组件传递的 consumerGroupList
topic: currentTopicForDialogs, // 使用父组件中管理的 topic
force: true
});
if (result.status === 0) {
setResetOffsetResultData(result.data); // 注意这里使用了 setResetOffsetResultData确认这是你期望的
setIsResetOffsetResultModalVisible(true); // 注意这里使用了 setIsResetOffsetResultModalVisible确认这是你期望的
setIsSkipMessageAccumulateModalVisible(false);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error skipping message accumulate:", error);
messageApi.error("Failed to skip message accumulate");
}
};
const columns = [
{
title: t.TOPIC,
dataIndex: 'topic',
key: 'topic',
align: 'center',
render: (text) => {
const sysFlag = text.startsWith('%SYS%');
const topic = sysFlag ? text.substring(5) : text;
return <span style={{color: sysFlag ? 'red' : ''}}>{topic}</span>;
},
},
{
title: t.OPERATION,
key: 'operation',
align: 'left',
render: (_, record) => {
const sysFlag = record.topic.startsWith('%SYS%');
const topicName = sysFlag ? record.topic.substring(5) : record.topic;
return (
<Space size="small">
<Button type="primary" size="small" onClick={() => statsView(topicName)}>
{t.STATUS}
</Button>
<Button type="primary" size="small" onClick={() => routerView(topicName)}>
{t.ROUTER}
</Button>
<Button type="primary" size="small" onClick={() => consumerView(topicName)}>
Consumer {t.MANAGE}
</Button>
<Button type="primary" size="small" onClick={() => openAddUpdateDialog(topicName, sysFlag)}>
Topic {t.CONFIG}
</Button>
{!sysFlag && (
<Button type="primary" size="small" onClick={() => openSendTopicMessageDialog(topicName)}>
{t.SEND_MSG}
</Button>
)}
{!sysFlag && writeOperationEnabled && (
<Button type="primary" danger size="small"
onClick={() => openConsumerResetOffsetDialog(topicName)}>
{t.RESET_CUS_OFFSET}
</Button>
)}
{!sysFlag && writeOperationEnabled && (
<Button type="primary" danger size="small"
onClick={() => openSkipMessageAccumulateDialog(topicName)}>
{t.SKIP_MESSAGE_ACCUMULATE}
</Button>
)}
{!sysFlag && writeOperationEnabled && (
<Popconfirm
title={`${t.ARE_YOU_SURE_TO_DELETE}`}
onConfirm={() => deleteTopic(topicName)}
okText={t.YES}
cancelText={t.NOT}
>
<Button type="primary" danger size="small">
{t.DELETE}
</Button>
</Popconfirm>
)}
</Space>
);
},
},
];
return (
<>
{msgContextHolder}
<div className="container-fluid" id="deployHistoryList">
<div className="modal-body">
<div className="row">
<Form layout="inline" className="pull-left col-sm-12">
<Form.Item label={t.TOPIC}>
<Input
value={filterStr}
onChange={(e) => setFilterStr(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={filterNormal} onChange={(e) => setFilterNormal(e.target.checked)}>
{t.NORMAL}
</Checkbox>
</Form.Item>
{rmqVersion && (
<>
<Form.Item>
<Checkbox checked={filterDelay}
onChange={(e) => setFilterDelay(e.target.checked)}>
{t.DELAY}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterFifo}
onChange={(e) => setFilterFifo(e.target.checked)}>
{t.FIFO}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterTransaction}
onChange={(e) => setFilterTransaction(e.target.checked)}>
{t.TRANSACTION}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterUnspecified}
onChange={(e) => setFilterUnspecified(e.target.checked)}>
{t.UNSPECIFIED}
</Checkbox>
</Form.Item>
</>
)}
<Form.Item>
<Checkbox checked={filterRetry} onChange={(e) => setFilterRetry(e.target.checked)}>
{t.RETRY}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterDLQ} onChange={(e) => setFilterDLQ(e.target.checked)}>
{t.DLQ}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterSystem} onChange={(e) => setFilterSystem(e.target.checked)}>
{t.SYSTEM}
</Checkbox>
</Form.Item>
{writeOperationEnabled && (
<Form.Item>
<Button type="primary" onClick={openAddUpdateDialog}>
{t.ADD} / {t.UPDATE}
</Button>
</Form.Item>
)}
<Form.Item>
<Button type="primary" onClick={getTopicList}>
{t.REFRESH}
</Button>
</Form.Item>
</Form>
</div>
<br/>
<div>
<div className="row">
<Table
bordered
loading={loading}
dataSource={topicShowList.map((topic, index) => ({key: index, topic}))}
columns={columns}
pagination={paginationConf}
onChange={handleTableChange}
/>
</div>
</div>
</div>
{/* Modals/Dialogs - 传递 visible 和 onClose prop */}
<ResetOffsetResultDialog
visible={isResetOffsetResultModalVisible}
onClose={closeResetOffsetResultDialog} // 传递关闭函数
result={resetOffsetResultData}
t={t}
/>
<SendResultDialog
visible={isSendResultModalVisible}
onClose={closeSendResultDialog} // 传递关闭函数
result={sendResultData}
t={t}
/>
<TopicModifyDialog
visible={isAddUpdateTopicModalVisible}
onClose={closeAddUpdateDialog}
initialData={topicModifyData}
bIsUpdate={isUpdateMode}
writeOperationEnabled={writeOperationEnabled}
allClusterNameList={allClusterNameList || []}
allBrokerNameList={allBrokerNameList || []}
onSubmit={postTopicRequest}
onInputChange={handleInputChange}
t={t}
/>
<ConsumerViewDialog
visible={isConsumerViewModalVisible}
onClose={closeConsumerViewDialog} // 传递关闭函数
topic={currentTopicForDialogs}
consumerData={consumerData}
consumerGroupCount={allConsumerGroupList.length}
t={t}
/>
<ConsumerResetOffsetDialog
visible={isConsumerResetOffsetModalVisible}
onClose={closeConsumerResetOffsetDialog} // 传递关闭函数
topic={currentTopicForDialogs}
allConsumerGroupList={allConsumerGroupList}
handleResetOffset={handleResetOffset}
t={t}
/>
<SkipMessageAccumulateDialog
visible={isSkipMessageAccumulateModalVisible}
onClose={closeSkipMessageAccumulateDialog} // 传递关闭函数
topic={currentTopicForDialogs}
allConsumerGroupList={allConsumerGroupList}
handleSkipMessageAccumulate={handleSkipMessageAccumulate}
t={t}
/>
<StatsViewDialog
visible={isStatsViewModalVisible}
onClose={closeStatsViewDialog} // 传递关闭函数
topic={currentTopicForDialogs}
statsData={statsData}
t={t}
/>
<RouterViewDialog
visible={isRouterViewModalVisible}
onClose={closeRouterViewDialog} // 传递关闭函数
topic={currentTopicForDialogs}
routeData={routeData}
t={t}
/>
<SendTopicMessageDialog
visible={isSendTopicMessageModalVisible}
onClose={closeSendTopicMessageDialog} // 传递关闭函数
topic={currentTopicForDialogs}
setSendResultData={setSendResultData}
setIsSendResultModalVisible={setIsSendResultModalVisible}
setIsSendTopicMessageModalVisible={setIsSendTopicMessageModalVisible}
sendTopicMessageData={sendTopicMessageData}
message={messageApi}
t={t}
/>
</div>
</>
);
};
export default DeployHistoryList;

View File

@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {

View File

@@ -0,0 +1,262 @@
/*
* 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 React, {useEffect} from 'react';
import {HashRouter as Router, Navigate, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
import {Layout} from 'antd';
import {AnimatePresence, motion} from 'framer-motion';
import Login from '../pages/Login/login';
import Ops from '../pages/Ops/ops';
import Proxy from '../pages/Proxy/proxy';
import Cluster from '../pages/Cluster/cluster';
import Topic from '../pages/Topic/topic';
import Consumer from '../pages/Consumer/consumer';
import Producer from '../pages/Producer/producer';
import Message from '../pages/Message/message';
import DlqMessage from '../pages/DlqMessage/dlqmessage';
import MessageTrace from '../pages/MessageTrace/messagetrace';
import Acl from '../pages/Acl/acl';
import Navbar from '../components/Navbar';
import DashboardPage from "../pages/Dashboard/DashboardPage";
import {remoteApi} from "../api/remoteApi/remoteApi";
const {Header, Content} = Layout;
const pageVariants = {
initial: {
opacity: 0,
x: "-100vw"
},
in: {
opacity: 1,
x: 0
},
out: {
opacity: 0,
x: "100vw"
}
};
const pageTransition = {
type: "tween",
ease: "anticipate",
duration: 0.2
};
const AppRouter = () => {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
remoteApi.setRedirectHandler(() => {
navigate('/login', { replace: true });
});
}, [navigate]);
return (
<Layout style={{minHeight: '100vh'}}>
<Header style={{padding: 0, height: 'auto', lineHeight: 'normal'}}>
<Navbar/>
</Header>
<Content style={{padding: '24px'}}>
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route
path="/login"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Login/>
</motion.div>
}
/>
<Route
path="/"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<DashboardPage/>
</motion.div>
}
/>
<Route
path="/ops"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Ops/>
</motion.div>
}
/>
<Route
path="/proxy"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Proxy/>
</motion.div>
}
/>
<Route
path="/cluster"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Cluster/>
</motion.div>
}
/>
<Route
path="/topic"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Topic/>
</motion.div>
}
/>
<Route
path="/consumer"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Consumer/>
</motion.div>
}
/>
<Route
path="/producer"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Producer/>
</motion.div>
}
/>
<Route
path="/message"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Message/>
</motion.div>
}
/>
<Route
path="/dlqMessage"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<DlqMessage/>
</motion.div>
}
/>
<Route
path="/messageTrace"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<MessageTrace/>
</motion.div>
}
/>
<Route
path="/acl"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Acl/>
</motion.div>
}
/>
<Route path="*" element={<Navigate to="/"/>}/>
</Routes>
</AnimatePresence>
</Content>
</Layout>
);
};
const AppWrapper = () => (
<Router>
<AppRouter/>
</Router>
);
export default AppWrapper;

View File

@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)

View File

@@ -15,15 +15,9 @@
* limitations under the License.
*/
.login-panel{
export const SET_THEME = 'SET_THEME';
}
.login-panel .qrcode {
width: 100%;
hight: 100%;
}
.login-panel .validateCode {
/*height:20px;*/
}
export const setTheme = (themeName) => ({
type: SET_THEME,
payload: themeName,
});

View File

@@ -0,0 +1,39 @@
/*
* 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 { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { themes, defaultTheme } from '../../assets/styles/theme';
import { setTheme } from '../actions/themeActions';
export const useTheme = () => {
// 从 Redux store 中取出 currentThemeName
const currentThemeName = useSelector(state => state.theme.currentThemeName);
const dispatch = useDispatch();
const currentTheme = themes[currentThemeName] || defaultTheme;
useEffect(() => {
localStorage.setItem('appTheme', currentThemeName);
}, [currentThemeName]);
return {
currentTheme,
currentThemeName,
setCurrentThemeName: (themeName) => dispatch(setTheme(themeName)),
};
};

View File

@@ -0,0 +1,28 @@
/*
* 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 { createStore,combineReducers } from 'redux';
import themeReducer from './reducers/themeReducer';
// 组合所有的 reducers
const rootReducer = combineReducers({
theme: themeReducer, // theme 状态将通过 state.theme 访问
});
// 创建 Redux store
const store = createStore(rootReducer);
export default store;

View File

@@ -0,0 +1,41 @@
/*
* 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 { SET_THEME } from '../actions/themeActions';
const getInitialTheme = () => {
return localStorage.getItem('appTheme') || 'default';
};
const initialState = {
currentThemeName: getInitialTheme(),
};
const themeReducer = (state = initialState, action) => {
switch (action.type) {
case SET_THEME:
// 注意reducer 应该返回新的状态对象,而不是直接修改旧状态
return {
...state,
currentThemeName: action.payload,
};
default:
return state;
}
};
export default themeReducer;

16635
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-json-view": "^1.21.3",
"react-scripts": "4.0.3",
"web-vitals": "^1.0.1"
},
"proxy": "http://localhost:8080",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,59 +0,0 @@
<!--
~ 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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,49 +0,0 @@
/*
* 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 React, {useState, useEffect} from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
const [message, setMessage] = useState("");
useEffect(() => {
fetch('cluster/list.query')
.then(response => response.text())
.then(message => {
setMessage(message);
});
}, [])
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" height="60"/>
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
</header>
<h1>ClusterInfo</h1>
<p>
{message}
</p>
</div>
);
}
export default App;

View File

@@ -1,17 +0,0 @@
<!--
~ 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.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because it is too large Load Diff

76
pom.xml
View File

@@ -28,7 +28,7 @@
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-dashboard</artifactId>
<packaging>jar</packaging>
<version>1.0.1-SNAPSHOT</version>
<version>2.0.1-SNAPSHOT</version>
<name>rocketmq-dashboard</name>
<scm>
@@ -82,29 +82,29 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.test.skip>false</maven.test.skip>
<guava.version>29.0-jre</guava.version>
<commons-digester.version>2.1</commons-digester.version>
<commons-lang.version>2.6</commons-lang.version>
<commons-io.version>2.4</commons-io.version>
<commons-cli.version>1.2</commons-cli.version>
<commons-collections.version>3.2.2</commons-collections.version>
<rocketmq.version>4.9.3</rocketmq.version>
<rocketmq.version>5.3.3</rocketmq.version>
<surefire.version>2.19.1</surefire.version>
<aspectj.version>1.9.6</aspectj.version>
<lombok.version>1.18.12</lombok.version>
<lombok.version>1.18.22</lombok.version>
<main.basedir>${basedir}/../..</main.basedir>
<docker.image.prefix>apacherocketmq</docker.image.prefix>
<spring.boot.version>2.6.0</spring.boot.version>
<spring.boot.version>3.4.5</spring.boot.version>
<mockito-inline.version>3.3.3</mockito-inline.version>
<jaxb-api.version>2.3.1</jaxb-api.version>
<jakarta.xml.bind-api.version>4.0.0</jakarta.xml.bind-api.version>
<commons-pool2.version>2.4.3</commons-pool2.version>
<easyexcel.version>2.2.10</easyexcel.version>
<asm.version>4.2</asm.version>
<junit.version>4.12</junit.version>
<snakeyaml.version>1.30</snakeyaml.version>
<snakeyaml.version>2.0</snakeyaml.version>
<cglib.version>2.2.2</cglib.version>
<joor.version>0.9.6</joor.version>
<bcpkix-jdk15on.version>1.68</bcpkix-jdk15on.version>
@@ -115,6 +115,12 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.version}</version>
<exclusions>
<exclusion>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -167,6 +173,7 @@
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-namesrv</artifactId>
<version>${rocketmq.version}</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
@@ -186,6 +193,7 @@
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-broker</artifactId>
<version>${rocketmq.version}</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
@@ -233,9 +241,9 @@
<version>${bcpkix-jdk15on.version}</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>${jaxb-api.version}</version>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>${jakarta.xml.bind-api.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
@@ -280,7 +288,7 @@
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
@@ -294,6 +302,9 @@
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
@@ -382,9 +393,9 @@
<version>4.3.0</version>
<dependencies>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>${jaxb-api.version}</version>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>${jakarta.xml.bind-api.version}</version>
</dependency>
</dependencies>
</plugin>
@@ -402,11 +413,11 @@
<exclude>docs/**</exclude>
<exclude>src/main/resources/static/vendor/**</exclude>
<exclude>src/main/resources/static/src/data/**</exclude>
<exclude>frontend/node_modules/**</exclude>
<exclude>frontend/build/**</exclude>
<exclude>frontend/**.json</exclude>
<exclude>frontend/**.lock</exclude>
<exclude>frontend/public/manifest.json</exclude>
<exclude>frontend-new/node_modules/**</exclude>
<exclude>frontend-new/build/**</exclude>
<exclude>frontend-new/**.json</exclude>
<exclude>frontend-new/**.lock</exclude>
<exclude>frontend-new/public/manifest.json</exclude>
<exclude>package-lock.json</exclude>
</excludes>
</configuration>
@@ -416,37 +427,36 @@
<artifactId>frontend-maven-plugin</artifactId>
<version>1.11.3</version>
<configuration>
<workingDirectory>frontend</workingDirectory>
<workingDirectory>frontend-new</workingDirectory>
<installDirectory>target</installDirectory>
</configuration>
<executions>
<execution>
<id>install node and yarn</id>
<id>install node </id>
<goals>
<goal>install-node-and-yarn</goal>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>v16.2.0</nodeVersion>
<yarnVersion>v1.22.10</yarnVersion>
<nodeVersion>v18.2.0</nodeVersion>
</configuration>
</execution>
<execution>
<id>yarn install</id>
<id>npm install</id>
<goals>
<goal>yarn</goal>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install</arguments>
<arguments>install --legacy-peer-deps</arguments>
</configuration>
</execution>
<execution>
<id>yarn build</id>
<id>npm build</id>
<goals>
<goal>yarn</goal>
<goal>npm</goal>
</goals>
<configuration>
<arguments>build</arguments>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
@@ -459,7 +469,7 @@
<configuration>
<target>
<copy todir="${project.build.directory}/classes/public">
<fileset dir="${project.basedir}/frontend/build"/>
<fileset dir="${project.basedir}/frontend-new/build" />
</copy>
</target>
</configuration>

View File

@@ -0,0 +1,25 @@
/*
* 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.
*/
package org.apache.rocketmq.dashboard.admin;
import org.apache.rocketmq.tools.admin.MQAdminExt;
@FunctionalInterface
public interface MQAdminExtCallback<T> {
T doInMQAdminExt(MQAdminExt mqAdminExt) throws Exception;
}

View File

@@ -16,7 +16,6 @@
*/
package org.apache.rocketmq.dashboard.admin;
import java.util.concurrent.atomic.AtomicLong;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.acl.common.AclClientRPCHook;
@@ -26,6 +25,8 @@ import org.apache.rocketmq.remoting.RPCHook;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.apache.rocketmq.tools.admin.MQAdminExt;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j
public class MQAdminFactory {
private RMQConfigure rmqConfigure;

View File

@@ -21,7 +21,7 @@ import org.apache.commons.collections.MapUtils;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.PooledObjectFactory;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.rocketmq.common.protocol.body.ClusterInfo;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.tools.admin.MQAdminExt;
@Slf4j

View File

@@ -0,0 +1,132 @@
/*
* 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.
*/
package org.apache.rocketmq.dashboard.admin;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.apache.rocketmq.client.ClientConfig;
import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.tools.admin.MQAdminExt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Component
@Slf4j
public class UserMQAdminPoolManager {
private final ConcurrentMap<String/* userAk */, GenericObjectPool<MQAdminExt>> userPools = new ConcurrentHashMap<>();
private final ClientConfig baseClientConfig;
@Autowired
public UserMQAdminPoolManager(RMQConfigure rmqConfigure) {
this.baseClientConfig = new ClientConfig();
this.baseClientConfig.setNamesrvAddr(rmqConfigure.getNamesrvAddr());
this.baseClientConfig.setClientCallbackExecutorThreads(rmqConfigure.getClientCallbackExecutorThreads());
this.baseClientConfig.setVipChannelEnabled(Boolean.parseBoolean(rmqConfigure.getIsVIPChannel()));
this.baseClientConfig.setUseTLS(rmqConfigure.isUseTLS());
log.info("UserMQAdminPoolManager initialized with baseClientConfig for NameServer: {}", rmqConfigure.getNamesrvAddr());
}
public MQAdminExt borrowMQAdminExt(String userAk, String userSk) throws Exception {
GenericObjectPool<MQAdminExt> userPool = userPools.get(userAk);
if (userPool == null) {
log.info("Creating new MQAdminExt pool for user: {}", userAk);
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
poolConfig.setMaxTotal(1);
poolConfig.setMaxIdle(1);
poolConfig.setMinIdle(0);
poolConfig.setTestWhileIdle(true);
poolConfig.setTimeBetweenEvictionRunsMillis(20000);
poolConfig.setMaxWaitMillis(10000);
UserSpecificMQAdminPooledObjectFactory factory =
new UserSpecificMQAdminPooledObjectFactory(baseClientConfig, userAk, userSk);
GenericObjectPool<MQAdminExt> newUserPool = new GenericObjectPool<>(factory, poolConfig);
GenericObjectPool<MQAdminExt> existingPool = userPools.putIfAbsent(userAk, newUserPool);
if (existingPool != null) {
log.warn("Another thread concurrently created MQAdminExt pool for user {}. Shutting down redundant pool.", userAk);
newUserPool.close();
userPool = existingPool;
} else {
userPool = newUserPool;
log.info("Successfully created and registered MQAdminExt pool for user: {}", userAk);
}
}
return userPool.borrowObject();
}
public void returnMQAdminExt(String userAk, MQAdminExt mqAdminExt) {
GenericObjectPool<MQAdminExt> userPool = userPools.get(userAk);
if (userPool != null) {
try {
userPool.returnObject(mqAdminExt);
log.debug("Returned MQAdminExt object ({}) to pool for user: {}", mqAdminExt, userAk);
} catch (Exception e) {
log.error("Failed to return MQAdminExt object ({}) for user {}: {}", mqAdminExt, userAk, e.getMessage(), e);
if (mqAdminExt != null) {
try {
mqAdminExt.shutdown();
} catch (Exception se) {
log.warn("Error shutting down MQAdminExt after failed return: {}", se.getMessage());
}
}
}
} else {
log.warn("Attempted to return MQAdminExt for non-existent user pool: {}. Shutting down the object directly.", userAk);
if (mqAdminExt != null) {
try {
mqAdminExt.shutdown();
} catch (Exception se) {
log.warn("Error shutting down MQAdminExt for non-existent pool: {}", se.getMessage());
}
}
}
}
public void shutdownUserPool(String userAk) {
GenericObjectPool<MQAdminExt> userPool = userPools.remove(userAk);
if (userPool != null) {
userPool.close();
log.info("Shutdown and removed MQAdminExt pool for user: {}", userAk);
} else {
log.warn("Attempted to shut down non-existent user pool: {}", userAk);
}
}
@PreDestroy
public void shutdownAllPools() {
log.info("Shutting down all MQAdminExt user pools...");
userPools.forEach((userAk, pool) -> {
pool.close();
log.info("Shutdown MQAdminExt pool for user: {}", userAk);
});
userPools.clear();
log.info("All MQAdminExt user pools have been shut down.");
}
}

View File

@@ -0,0 +1,112 @@
/*
* 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.
*/
package org.apache.rocketmq.dashboard.admin;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.PooledObjectFactory;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.rocketmq.acl.common.AclClientRPCHook;
import org.apache.rocketmq.acl.common.SessionCredentials;
import org.apache.rocketmq.client.ClientConfig;
import org.apache.rocketmq.remoting.RPCHook;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.apache.rocketmq.tools.admin.MQAdminExt;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j
public class UserSpecificMQAdminPooledObjectFactory implements PooledObjectFactory<MQAdminExt> {
private final ClientConfig userSpecificClientConfig;
private final RPCHook rpcHook;
private final String userAk;
private final AtomicLong instanceCreationCounter = new AtomicLong(0);
public UserSpecificMQAdminPooledObjectFactory(ClientConfig baseClientConfig, String userAk, String userSk) {
this.userSpecificClientConfig = baseClientConfig.cloneClientConfig();
this.userSpecificClientConfig.setInstanceName("MQ_ADMIN_INSTANCE_" + userAk + "_" + UUID.randomUUID());
if (StringUtils.isNotEmpty(userAk) && StringUtils.isNotEmpty(userSk)) {
this.rpcHook = new AclClientRPCHook(new SessionCredentials(userAk, userSk));
} else {
this.rpcHook = null;
}
this.userAk = userAk;
log.info("UserSpecificMQAdminPooledObjectFactory initialized for user: {}", userAk);
log.debug("Factory ClientConfig for user {}: {}", userAk, userSpecificClientConfig);
}
@Override
public PooledObject<MQAdminExt> makeObject() throws Exception {
DefaultMQAdminExt mqAdminExt = new DefaultMQAdminExt(rpcHook);
mqAdminExt.setAdminExtGroup("MQ_ADMIN_GROUP_FOR_" + userAk + "_" + instanceCreationCounter.getAndIncrement());
mqAdminExt.start();
log.info("Created new MQAdminExt instance ({}) for user {}", mqAdminExt, userAk);
return new DefaultPooledObject<>(mqAdminExt);
}
@Override
public void destroyObject(PooledObject<MQAdminExt> p) {
MQAdminExt mqAdmin = p.getObject();
if (mqAdmin != null) {
try {
mqAdmin.shutdown();
} catch (Exception e) {
log.warn("Failed to shut down MQAdminExt object ({}) for user {}: {}", p.getObject(), userAk, e.getMessage(), e);
}
}
log.info("Destroyed MQAdminExt object ({}) for user {}", p.getObject(), userAk);
}
@Override
public boolean validateObject(PooledObject<MQAdminExt> p) {
MQAdminExt mqAdmin = p.getObject();
if (mqAdmin == null) {
log.warn("MQAdminExt object is null or not started for user {}: {}", userAk, mqAdmin);
return false;
}
try {
ClusterInfo clusterInfo = mqAdmin.examineBrokerClusterInfo();
boolean isValid = clusterInfo != null && !clusterInfo.getBrokerAddrTable().isEmpty();
if (!isValid) {
log.warn("Validation failed for MQAdminExt object for user {}: ClusterInfo is invalid or empty. ClusterInfo = {}", userAk, clusterInfo);
}
return isValid;
} catch (Exception e) {
log.warn("Validation error for MQAdminExt object for user {}: {}", userAk, e.getMessage(), e);
return false;
}
}
@Override
public void activateObject(PooledObject<MQAdminExt> p) {
log.debug("Activating MQAdminExt object ({}) for user {}", p.getObject(), userAk);
}
@Override
public void passivateObject(PooledObject<MQAdminExt> p) {
log.debug("Passivating MQAdminExt object ({}) for user {}", p.getObject(), userAk);
}
}

View File

@@ -18,42 +18,114 @@ package org.apache.rocketmq.dashboard.aspect.admin;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.rocketmq.dashboard.admin.UserMQAdminPoolManager;
import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.dashboard.service.client.MQAdminInstance;
import org.apache.rocketmq.dashboard.util.UserInfoContext;
import org.apache.rocketmq.dashboard.util.WebUtil;
import org.apache.rocketmq.remoting.protocol.body.UserInfo;
import org.apache.rocketmq.tools.admin.MQAdminExt;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
@Aspect
@Service
@Component
@Slf4j
public class MQAdminAspect {
@Autowired
private UserMQAdminPoolManager userMQAdminPoolManager;
@Autowired
private GenericObjectPool<MQAdminExt> mqAdminExtPool;
public MQAdminAspect() {
@Autowired
private RMQConfigure rmqConfigure;
private static final Set<String> METHODS_TO_CHECK = new HashSet<>();
static {
METHODS_TO_CHECK.add("getUser");
METHODS_TO_CHECK.add("examineBrokerClusterInfo");
METHODS_TO_CHECK.add("examineConsumerConnectionInfo");
METHODS_TO_CHECK.add("examineConsumeStats");
METHODS_TO_CHECK.add("examineProducerConnectionInfo");
}
// Pointcut remains the same, targeting methods in MQAdminExtImpl
@Pointcut("execution(* org.apache.rocketmq.dashboard.service.client.MQAdminExtImpl..*(..))")
public void mQAdminMethodPointCut() {
}
@Around(value = "mQAdminMethodPointCut()")
@Pointcut("execution(* org.apache.rocketmq.dashboard.service.client.ProxyAdminImpl..*(..))")
public void proxyAdminMethodPointCut() {
}
@Around(value = "mQAdminMethodPointCut()||proxyAdminMethodPointCut()")
public Object aroundMQAdminMethod(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object obj = null;
MQAdminExt mqAdminExt = null; // The MQAdminExt instance borrowed from the pool
UserInfo currentUserInfo = null; // The user initiating the operation
String methodName = joinPoint.getSignature().getName();
try {
MQAdminInstance.createMQAdmin(mqAdminExtPool);
obj = joinPoint.proceed();
if (isPoolConfigIsolatedByUser(rmqConfigure.isLoginRequired(), methodName)) {
currentUserInfo = (UserInfo) UserInfoContext.get(WebUtil.USER_NAME);
// 2. Borrow the user-specific MQAdminExt instance.
// currentUser.getName() is assumed to be the AccessKey, and currentUser.getPassword() is SecretKey.
mqAdminExt = userMQAdminPoolManager.borrowMQAdminExt(currentUserInfo.getUsername(), currentUserInfo.getPassword());
// 3. Set the borrowed MQAdminExt instance into the ThreadLocal for MQAdminInstance.
// This makes it available to MQAdminExtImpl methods.
MQAdminInstance.setCurrentMQAdminExt(mqAdminExt);
log.debug("MQAdminExt borrowed for user {} and set in ThreadLocal.", currentUserInfo.getUsername());
} else {
mqAdminExt = mqAdminExtPool.borrowObject(); // Fallback to a default MQAdminExt if no user is provided
MQAdminInstance.setCurrentMQAdminExt(mqAdminExt);
}
// 4. Proceed with the original method execution.
return joinPoint.proceed();
} finally {
MQAdminInstance.returnMQAdmin(mqAdminExtPool);
log.debug("op=look method={} cost={}", joinPoint.getSignature().getName(), System.currentTimeMillis() - start);
if (currentUserInfo != null) {
if (mqAdminExt != null) {
userMQAdminPoolManager.returnMQAdminExt(currentUserInfo.getUsername(), mqAdminExt);
MQAdminInstance.clearCurrentMQAdminExt();
log.debug("MQAdminExt returned for user {} and cleared from ThreadLocal.", currentUserInfo.getUsername());
}
log.debug("Operation {} for user {} cost {}ms",
methodName,
currentUserInfo.getUsername(),
System.currentTimeMillis() - start);
} else {
if (mqAdminExt != null) {
mqAdminExtPool.returnObject(mqAdminExt);
MQAdminInstance.clearCurrentMQAdminExt();
log.debug("MQAdminExt returned to default pool and cleared from ThreadLocal.");
}
log.debug("Operation {} cost {}ms",
methodName,
System.currentTimeMillis() - start);
}
}
return obj;
}
private boolean isPoolConfigIsolatedByUser(boolean loginRequired, String methodName) {
if (!loginRequired) {
return false;
} else {
return !METHODS_TO_CHECK.contains(methodName);
}
}
}

View File

@@ -17,6 +17,8 @@
package org.apache.rocketmq.dashboard.config;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.rocketmq.dashboard.interceptor.AuthInterceptor;
import org.apache.rocketmq.dashboard.model.UserInfo;
import org.apache.rocketmq.dashboard.util.WebUtil;
@@ -29,16 +31,16 @@ import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
@Configuration
public class AuthWebMVCConfigurerAdapter extends WebMvcConfigurerAdapter {
public class AuthWebMVCConfigurerAdapter implements WebMvcConfigurer {
@Autowired
@Qualifier("authInterceptor")
private AuthInterceptor authInterceptor;
@@ -50,19 +52,19 @@ public class AuthWebMVCConfigurerAdapter extends WebMvcConfigurerAdapter {
public void addInterceptors(InterceptorRegistry registry) {
if (configure.isLoginRequired()) {
registry.addInterceptor(authInterceptor).addPathPatterns(
"/cluster/**",
"/consumer/**",
"/dashboard/**",
"/dlqMessage/**",
"/message/**",
"/messageTrace/**",
"/monitor/**",
"/rocketmq/**",
"/ops/**",
"/producer/**",
"/test/**",
"/topic/**",
"/acl/**");
"/cluster/**",
"/consumer/**",
"/dashboard/**",
"/dlqMessage/**",
"/message/**",
"/messageTrace/**",
"/monitor/**",
"/rocketmq/**",
"/ops/**",
"/producer/**",
"/test/**",
"/topic/**",
"/acl/**");
}
}
@@ -77,19 +79,30 @@ public class AuthWebMVCConfigurerAdapter extends WebMvcConfigurerAdapter {
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer,
NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
UserInfo userInfo = (UserInfo) WebUtil.getValueFromSession((HttpServletRequest) nativeWebRequest.getNativeRequest(),
UserInfo.USER_INFO);
UserInfo.USER_INFO);
if (userInfo != null) {
return userInfo;
}
throw new MissingServletRequestPartException(UserInfo.USER_INFO);
}
});
super.addArgumentResolvers(argumentResolvers); //REVIEW ME
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:3003")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.maxAge(3600)
.allowCredentials(true)
.allowedHeaders("content-type", "Authorization", "X-Requested-With", "Origin", "Accept")
.exposedHeaders("authorization");
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("*.htm").setViewName("forward:/app.html");

View File

@@ -17,16 +17,17 @@
package org.apache.rocketmq.dashboard.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "threadpool.config")

View File

@@ -16,7 +16,8 @@
*/
package org.apache.rocketmq.dashboard.config;
import java.util.ArrayList;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.common.MixAll;
@@ -31,59 +32,70 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import static org.apache.rocketmq.client.ClientConfig.SEND_MESSAGE_WITH_VIP_CHANNEL_PROPERTY;
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "rocketmq.config")
public class RMQConfigure {
private Logger logger = LoggerFactory.getLogger(RMQConfigure.class);
//use rocketmq.namesrv.addr first,if it is empty,than use system proerty or system env
@Getter
private volatile String namesrvAddr = System.getProperty(MixAll.NAMESRV_ADDR_PROPERTY, System.getenv(MixAll.NAMESRV_ADDR_ENV));
@Setter
@Getter
private volatile String proxyAddr;
@Getter
private volatile String isVIPChannel = System.getProperty(SEND_MESSAGE_WITH_VIP_CHANNEL_PROPERTY, "true");
@Setter
private String dataPath = "/tmp/rocketmq-console/data";
@Getter
private boolean enableDashBoardCollect;
@Setter
@Getter
private boolean loginRequired = false;
private String accessKey;
@Setter
@Getter
private String secretKey;
@Setter
@Getter
private boolean useTLS = false;
@Setter
@Getter
private Long timeoutMillis;
@Getter
private List<String> namesrvAddrs = new ArrayList<>();
public String getAccessKey() {
return accessKey;
}
@Getter
private List<String> proxyAddrs = new ArrayList<>();
public void setAccessKey(String accessKey) {
this.accessKey = accessKey;
}
@Setter
@Getter
private Integer clientCallbackExecutorThreads = 4;
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String secretKey) {
this.secretKey = secretKey;
}
public String getNamesrvAddr() {
return namesrvAddr;
}
public List<String> getNamesrvAddrs() {
return namesrvAddrs;
public void setProxyAddrs(List<String> proxyAddrs) {
this.proxyAddrs = proxyAddrs;
if (CollectionUtils.isNotEmpty(proxyAddrs)) {
this.setProxyAddr(proxyAddrs.get(0));
}
}
public void setNamesrvAddrs(List<String> namesrvAddrs) {
@@ -112,14 +124,6 @@ public class RMQConfigure {
return dataPath + File.separator + "dashboard";
}
public void setDataPath(String dataPath) {
this.dataPath = dataPath;
}
public String getIsVIPChannel() {
return isVIPChannel;
}
public void setIsVIPChannel(String isVIPChannel) {
if (StringUtils.isNotBlank(isVIPChannel)) {
this.isVIPChannel = isVIPChannel;
@@ -128,38 +132,10 @@ public class RMQConfigure {
}
}
public boolean isEnableDashBoardCollect() {
return enableDashBoardCollect;
}
public void setEnableDashBoardCollect(String enableDashBoardCollect) {
this.enableDashBoardCollect = Boolean.valueOf(enableDashBoardCollect);
}
public boolean isLoginRequired() {
return loginRequired;
}
public void setLoginRequired(boolean loginRequired) {
this.loginRequired = loginRequired;
}
public boolean isUseTLS() {
return useTLS;
}
public void setUseTLS(boolean useTLS) {
this.useTLS = useTLS;
}
public Long getTimeoutMillis() {
return timeoutMillis;
}
public void setTimeoutMillis(Long timeoutMillis) {
this.timeoutMillis = timeoutMillis;
}
// Error Page process logic, move to a central configure later
@Bean
public ErrorPageRegistrar errorPageRegistrar() {

View File

@@ -14,133 +14,90 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.rocketmq.dashboard.controller;
import com.google.common.base.Preconditions;
import java.util.List;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.common.AclConfig;
import org.apache.rocketmq.common.PlainAccessConfig;
import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.dashboard.model.User;
import org.apache.rocketmq.dashboard.model.UserInfo;
import org.apache.rocketmq.dashboard.model.request.AclRequest;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.AclService;
import org.apache.rocketmq.dashboard.support.JsonResult;
import org.apache.rocketmq.dashboard.util.WebUtil;
import org.apache.rocketmq.dashboard.model.PolicyRequest;
import org.apache.rocketmq.dashboard.model.request.UserCreateRequest;
import org.apache.rocketmq.dashboard.model.request.UserUpdateRequest;
import org.apache.rocketmq.dashboard.service.impl.AclServiceImpl;
import org.apache.rocketmq.remoting.protocol.body.UserInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/acl")
@Permission
public class AclController {
@Resource
private AclService aclService;
@Autowired
private AclServiceImpl aclService;
@Resource
private RMQConfigure configure;
@GetMapping("/enable.query")
public Object isEnableAcl() {
return new JsonResult<>(configure.isACLEnabled());
@GetMapping("/listUsers")
@ResponseBody
public List<UserInfo> listUsers(@RequestParam(required = false) String brokerAddress) {
return aclService.listUsers(brokerAddress);
}
@GetMapping("/config.query")
public AclConfig getAclConfig(HttpServletRequest request) {
if (!configure.isLoginRequired()) {
return aclService.getAclConfig(false);
}
UserInfo userInfo = (UserInfo) WebUtil.getValueFromSession(request, WebUtil.USER_INFO);
// if user info is null but reach here, must exclude secret key for safety.
return aclService.getAclConfig(userInfo == null || userInfo.getUser().getType() != User.ADMIN);
@GetMapping("/listAcls")
@ResponseBody
public Object listAcls(
@RequestParam(required = false) String brokerAddress,
@RequestParam(required = false) String searchParam) {
return aclService.listAcls(brokerAddress, searchParam);
}
@PostMapping("/add.do")
public Object addAclConfig(@RequestBody PlainAccessConfig config) {
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getAccessKey()), "accessKey is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getSecretKey()), "secretKey is null");
aclService.addAclConfig(config);
@PostMapping("/createAcl")
@ResponseBody
public Object createAcl(@RequestBody PolicyRequest request) {
aclService.createAcl(request);
return true;
}
@PostMapping("/delete.do")
public Object deleteAclConfig(@RequestBody PlainAccessConfig config) {
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getAccessKey()), "accessKey is null");
aclService.deleteAclConfig(config);
@DeleteMapping("/deleteUser")
@ResponseBody
public Object deleteUser(@RequestParam(required = false) String brokerAddress, @RequestParam String username) {
aclService.deleteUser(brokerAddress, username);
return true;
}
@PostMapping("/update.do")
public Object updateAclConfig(@RequestBody PlainAccessConfig config) {
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getSecretKey()), "secretKey is null");
aclService.updateAclConfig(config);
@RequestMapping(value = "/updateUser", method = RequestMethod.POST, produces = "application/json;charset=UTF-8")
@ResponseBody
public Object updateUser(@RequestBody UserUpdateRequest request) {
aclService.updateUser(request.getBrokerAddress(), request.getUserInfo());
return true;
}
@PostMapping("/topic/add.do")
public Object addAclTopicConfig(@RequestBody AclRequest request) {
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getAccessKey()), "accessKey is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getSecretKey()), "secretKey is null");
Preconditions.checkArgument(CollectionUtils.isNotEmpty(request.getConfig().getTopicPerms()), "topic perms is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getTopicPerm()), "topic perm is null");
aclService.addOrUpdateAclTopicConfig(request);
@PostMapping("/createUser")
public Object createUser(@RequestBody UserCreateRequest request) {
aclService.createUser(request.getBrokerAddress(), request.getUserInfo());
return true;
}
@PostMapping("/group/add.do")
public Object addAclGroupConfig(@RequestBody AclRequest request) {
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getAccessKey()), "accessKey is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getSecretKey()), "secretKey is null");
Preconditions.checkArgument(CollectionUtils.isNotEmpty(request.getConfig().getGroupPerms()), "group perms is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getGroupPerm()), "group perm is null");
aclService.addOrUpdateAclGroupConfig(request);
@DeleteMapping("/deleteAcl")
public Object deleteAcl(
@RequestParam(required = false) String brokerAddress,
@RequestParam String subject,
@RequestParam(required = false) String resource) {
aclService.deleteAcl(brokerAddress, subject, resource);
return true;
}
@PostMapping("/perm/delete.do")
public Object deletePermConfig(@RequestBody AclRequest request) {
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getAccessKey()), "accessKey is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getSecretKey()), "secretKey is null");
aclService.deletePermConfig(request);
@RequestMapping(value = "/updateAcl", method = RequestMethod.POST, produces = "application/json;charset=UTF-8")
@ResponseBody
public Object updateAcl(@RequestBody PolicyRequest request) {
aclService.updateAcl(request);
return true;
}
@PostMapping("/sync.do")
public Object syncConfig(@RequestBody PlainAccessConfig config) {
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getAccessKey()), "accessKey is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getSecretKey()), "secretKey is null");
aclService.syncData(config);
return true;
}
@PostMapping("/white/list/add.do")
public Object addWhiteList(@RequestBody List<String> whiteList) {
Preconditions.checkArgument(CollectionUtils.isNotEmpty(whiteList), "white list is null");
aclService.addWhiteList(whiteList);
return true;
}
@DeleteMapping("/white/list/delete.do")
public Object deleteWhiteAddr(@RequestParam String request) {
aclService.deleteWhiteAddr(request);
return true;
}
@PostMapping("/white/list/sync.do")
public Object synchronizeWhiteList(@RequestBody List<String> whiteList) {
Preconditions.checkArgument(CollectionUtils.isNotEmpty(whiteList), "white list is null");
aclService.synchronizeWhiteList(whiteList);
return true;
}
}

View File

@@ -16,14 +16,13 @@
*/
package org.apache.rocketmq.dashboard.controller;
import jakarta.annotation.Resource;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.ClusterService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller

View File

@@ -17,9 +17,8 @@
package org.apache.rocketmq.dashboard.controller;
import com.google.common.base.Preconditions;
import javax.annotation.Resource;
import jakarta.annotation.Resource;
import org.apache.commons.collections.CollectionUtils;
import org.apache.rocketmq.common.protocol.body.ConsumerConnection;
import org.apache.rocketmq.dashboard.model.ConnectionInfo;
import org.apache.rocketmq.dashboard.model.request.ConsumerConfigInfo;
import org.apache.rocketmq.dashboard.model.request.DeleteSubGroupRequest;
@@ -27,6 +26,7 @@ import org.apache.rocketmq.dashboard.model.request.ResetOffsetRequest;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.ConsumerService;
import org.apache.rocketmq.dashboard.util.JsonUtil;
import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
@@ -47,14 +47,27 @@ public class ConsumerController {
@RequestMapping(value = "/groupList.query")
@ResponseBody
public Object list(@RequestParam(value = "skipSysGroup", required = false) boolean skipSysGroup) {
return consumerService.queryGroupList(skipSysGroup);
public Object list(@RequestParam(value = "skipSysGroup", required = false) boolean skipSysGroup, String address) {
return consumerService.queryGroupList(skipSysGroup, address);
}
@RequestMapping(value = "/group.refresh")
@ResponseBody
public Object refresh(String address,
String consumerGroup) {
return consumerService.refreshGroup(address, consumerGroup);
}
@RequestMapping(value = "group.refresh.all")
@ResponseBody
public Object refreshAll(String address) {
return consumerService.refreshAllGroup(address);
}
@RequestMapping(value = "/group.query")
@ResponseBody
public Object groupQuery(@RequestParam String consumerGroup) {
return consumerService.queryGroup(consumerGroup);
public Object groupQuery(@RequestParam String consumerGroup, String address) {
return consumerService.queryGroup(consumerGroup, address);
}
@RequestMapping(value = "/resetOffset.do", method = {RequestMethod.POST})
@@ -99,14 +112,14 @@ public class ConsumerController {
@RequestMapping(value = "/queryTopicByConsumer.query")
@ResponseBody
public Object queryConsumerByTopic(@RequestParam String consumerGroup) {
return consumerService.queryConsumeStatsListByGroupName(consumerGroup);
public Object queryConsumerByTopic(@RequestParam String consumerGroup, String address) {
return consumerService.queryConsumeStatsListByGroupName(consumerGroup, address);
}
@RequestMapping(value = "/consumerConnection.query")
@ResponseBody
public Object consumerConnection(@RequestParam(required = false) String consumerGroup) {
ConsumerConnection consumerConnection = consumerService.getConsumerConnection(consumerGroup);
public Object consumerConnection(@RequestParam(required = false) String consumerGroup, String address) {
ConsumerConnection consumerConnection = consumerService.getConsumerConnection(consumerGroup, address);
consumerConnection.setConnectionSet(ConnectionInfo.buildConnectionInfoHashSet(consumerConnection.getConnectionSet()));
return consumerConnection;
}

View File

@@ -17,9 +17,8 @@
package org.apache.rocketmq.dashboard.controller;
import javax.annotation.Resource;
import com.google.common.base.Strings;
import jakarta.annotation.Resource;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.DashboardService;
import org.springframework.stereotype.Controller;

View File

@@ -17,10 +17,8 @@
package org.apache.rocketmq.dashboard.controller;
import com.google.common.collect.Lists;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.common.message.MessageExt;
@@ -41,6 +39,9 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.ArrayList;
import java.util.List;
@Controller
@RequestMapping("/dlqMessage")
@Permission
@@ -61,7 +62,7 @@ public class DlqMessageController {
@GetMapping(value = "/exportDlqMessage.do")
public void exportDlqMessage(HttpServletResponse response, @RequestParam String consumerGroup,
@RequestParam String msgId) {
@RequestParam String msgId) {
MessageExt messageExt = null;
try {
String topic = MixAll.DLQ_GROUP_TOPIC_PREFIX + consumerGroup;

View File

@@ -17,6 +17,9 @@
package org.apache.rocketmq.dashboard.controller;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.dashboard.model.LoginInfo;
import org.apache.rocketmq.dashboard.model.LoginResult;
@@ -32,12 +35,8 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller
@RequestMapping("/login")
@@ -66,12 +65,11 @@ public class LoginController {
@RequestMapping(value = "/login.do", method = RequestMethod.POST)
@ResponseBody
public Object login(@RequestParam("username") String username,
@RequestParam(value = "password") String password,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
logger.info("user:{} login", username);
User user = userService.queryByUsernameAndPassword(username, password);
public Object login(org.apache.rocketmq.remoting.protocol.body.UserInfo userInfoRequest,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
logger.info("user:{} login", userInfoRequest.getUsername());
User user = userService.queryByUsernameAndPassword(userInfoRequest.getUsername(), userInfoRequest.getPassword());
if (user == null) {
throw new IllegalArgumentException("Bad username or password!");
@@ -79,9 +77,9 @@ public class LoginController {
user.setPassword(null);
UserInfo userInfo = WebUtil.setLoginInfo(request, response, user);
WebUtil.setSessionValue(request, WebUtil.USER_INFO, userInfo);
WebUtil.setSessionValue(request, WebUtil.USER_NAME, username);
WebUtil.setSessionValue(request, WebUtil.USER_NAME, userInfoRequest.getUsername());
userInfo.setSessionId(WebUtil.getSessionId(request));
LoginResult result = new LoginResult(username, user.getType(), contextPath);
LoginResult result = new LoginResult(userInfoRequest.getUsername(), user.getType(), contextPath);
return result;
}
}

View File

@@ -17,26 +17,26 @@
package org.apache.rocketmq.dashboard.controller;
import com.google.common.collect.Maps;
import jakarta.annotation.Resource;
import org.apache.rocketmq.common.Pair;
import org.apache.rocketmq.common.protocol.body.ConsumeMessageDirectlyResult;
import org.apache.rocketmq.dashboard.model.MessagePage;
import org.apache.rocketmq.dashboard.model.MessageView;
import org.apache.rocketmq.dashboard.model.request.MessageQuery;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.MessageService;
import org.apache.rocketmq.dashboard.util.JsonUtil;
import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
import org.apache.rocketmq.tools.admin.api.MessageTrack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;

View File

@@ -18,11 +18,7 @@
package org.apache.rocketmq.dashboard.controller;
import com.google.common.collect.Maps;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import jakarta.annotation.Resource;
import org.apache.rocketmq.common.Pair;
import org.apache.rocketmq.dashboard.model.MessageView;
import org.apache.rocketmq.dashboard.model.trace.MessageTraceGraph;
@@ -36,6 +32,9 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.List;
import java.util.Map;
@Controller
@RequestMapping("/messageTrace")
@Permission

View File

@@ -16,7 +16,7 @@
*/
package org.apache.rocketmq.dashboard.controller;
import javax.annotation.Resource;
import jakarta.annotation.Resource;
import org.apache.rocketmq.dashboard.model.ConsumerMonitorConfig;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.MonitorService;

View File

@@ -16,7 +16,7 @@
*/
package org.apache.rocketmq.dashboard.controller;
import javax.annotation.Resource;
import jakarta.annotation.Resource;
import org.apache.rocketmq.dashboard.aspect.admin.annotation.OriginalControllerReturnValue;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.OpsService;

View File

@@ -17,7 +17,7 @@
package org.apache.rocketmq.dashboard.controller;
import com.google.common.base.Preconditions;
import javax.annotation.Resource;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.OpsService;

View File

@@ -16,11 +16,12 @@
*/
package org.apache.rocketmq.dashboard.controller;
import javax.annotation.Resource;
import org.apache.rocketmq.common.protocol.body.ProducerConnection;
import jakarta.annotation.Resource;
import org.apache.rocketmq.dashboard.model.ConnectionInfo;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.ProducerService;
import org.apache.rocketmq.remoting.protocol.body.ProducerConnection;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

View File

@@ -0,0 +1,54 @@
/*
* 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.
*/
package org.apache.rocketmq.dashboard.controller;
import jakarta.annotation.Resource;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.ProxyService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@RequestMapping("/proxy")
@Permission
public class ProxyController {
@Resource
private ProxyService proxyService;
@RequestMapping(value = "/homePage.query", method = RequestMethod.GET)
@ResponseBody
public Object homePage() {
return proxyService.getProxyHomePage();
}
@RequestMapping(value = "/addProxyAddr.do", method = RequestMethod.POST)
@ResponseBody
public Object addProxyAddr(@RequestParam String newProxyAddr) {
proxyService.addProxyAddrList(newProxyAddr);
return true;
}
@RequestMapping(value = "/updateProxyAddr.do", method = RequestMethod.POST)
@ResponseBody
public Object updateProxyAddr(@RequestParam String proxyAddr) {
proxyService.updateProxyAddrList(proxyAddr);
return true;
}
}

View File

@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.dashboard.controller;
import jakarta.annotation.Resource;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
@@ -26,11 +27,9 @@ import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.remoting.exception.RemotingException;
import java.util.List;
import javax.annotation.Resource;
import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.dashboard.util.JsonUtil;
import org.apache.rocketmq.remoting.exception.RemotingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
@@ -38,6 +37,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.List;
@Controller
@RequestMapping("/test")
public class TestController {

View File

@@ -16,16 +16,17 @@
*/
package org.apache.rocketmq.dashboard.controller;
import com.google.common.base.Preconditions;
import jakarta.annotation.Resource;
import org.apache.commons.collections.CollectionUtils;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.remoting.exception.RemotingException;
import org.apache.rocketmq.dashboard.model.request.SendTopicMessageRequest;
import org.apache.rocketmq.dashboard.model.request.TopicConfigInfo;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.ConsumerService;
import org.apache.rocketmq.dashboard.service.TopicService;
import org.apache.rocketmq.dashboard.util.JsonUtil;
import com.google.common.base.Preconditions;
import org.apache.commons.collections.CollectionUtils;
import org.apache.rocketmq.remoting.exception.RemotingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
@@ -33,8 +34,6 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@@ -56,6 +55,13 @@ public class TopicController {
return topicService.fetchAllTopicList(skipSysProcess, skipRetryAndDlq);
}
@RequestMapping(value = "/list.queryTopicType", method = RequestMethod.GET)
@ResponseBody
public Object listTopicType() {
return topicService.examineAllTopicType();
}
@RequestMapping(value = "/stats.query", method = RequestMethod.GET)
@ResponseBody
public Object stats(@RequestParam String topic) {

View File

@@ -17,27 +17,29 @@
package org.apache.rocketmq.dashboard.filter;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletResponse;
@WebFilter(urlPatterns = "/*", filterName = "httpBasicAuthorizedFilter")
public class HttpBasicAuthorizedFilter implements Filter {
@Override
public void init(FilterConfig config) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FilterChain chain) throws IOException, ServletException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setCharacterEncoding("UTF-8");
httpResponse.setContentType("application/json; charset=utf-8");

View File

@@ -17,22 +17,24 @@
package org.apache.rocketmq.dashboard.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.rocketmq.dashboard.service.LoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class AuthInterceptor extends HandlerInterceptorAdapter {
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private LoginService loginService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return loginService.login(request, response);
}
}

View File

@@ -17,10 +17,11 @@
package org.apache.rocketmq.dashboard.model;
import com.google.common.collect.Sets;
import org.apache.rocketmq.common.MQVersion;
import org.apache.rocketmq.remoting.protocol.body.Connection;
import java.util.Collection;
import java.util.HashSet;
import org.apache.rocketmq.common.MQVersion;
import org.apache.rocketmq.common.protocol.body.Connection;
public class ConnectionInfo extends Connection {
private String versionDesc;

View File

@@ -16,8 +16,8 @@
*/
package org.apache.rocketmq.dashboard.model;
import org.apache.rocketmq.common.admin.RollbackStats;
import com.google.common.collect.Lists;
import org.apache.rocketmq.remoting.protocol.admin.RollbackStats;
import java.util.List;

View File

@@ -22,12 +22,13 @@ import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.metadata.BaseRowModel;
import com.alibaba.excel.util.DateUtils;
import com.google.common.base.Charsets;
import java.io.Serializable;
import java.util.Date;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.rocketmq.common.message.MessageExt;
import java.io.Serializable;
import java.util.Date;
@Data
@NoArgsConstructor
public class DlqMessageExcelModel extends BaseRowModel implements Serializable {

View File

@@ -17,8 +17,8 @@
package org.apache.rocketmq.dashboard.model;
import lombok.Data;
import org.apache.rocketmq.common.protocol.body.CMResult;
import org.apache.rocketmq.common.protocol.body.ConsumeMessageDirectlyResult;
import org.apache.rocketmq.remoting.protocol.body.CMResult;
import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
@Data
public class DlqMessageResendResult {

Some files were not shown because too many files have changed in this diff Show More