This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 5cdeeba7c fix(web): InfoBanner dark mode, breadcrumb and nav polish;
credential tab (#2344)
5cdeeba7c is described below
commit 5cdeeba7c0f5e9c0d2506f5b198ef1fdb593134c
Author: lizhimins <[email protected]>
AuthorDate: Tue Aug 18 15:15:39 2026 +0800
fix(web): InfoBanner dark mode, breadcrumb and nav polish; credential tab
(#2344)
- InfoBanner switches from hardcoded #fafafa/#f0f0f0 to antd theme tokens
so page-level banners adapt to dark mode (topic/ACL/K8s cert pages).
- Breadcrumb no longer shows the instance ID path segment; sidebar menu
closes the gap below the logo bar.
- Deploy hardening: MySQL utf8mb4 server charset and nginx charset utf-8.
- Settings: extract cloud credential management into its own tab.
- deploy-rocketmq skill: document the loadgen mode D flow.
---
.claude/skills/deploy-rocketmq/SKILL.md | 190 +++++++++++++-
deploy/docker-compose.yml | 4 +
deploy/nginx.conf | 1 +
web/nginx.conf | 1 +
web/src/api/cloudCredential.test.ts | 57 +++-
web/src/api/cloudCredential.ts | 29 +++
web/src/components/InfoBanner.tsx | 63 ++---
web/src/index.css | 30 +++
web/src/layouts/MainLayout.tsx | 33 ++-
web/src/pages/settings/CloudCredentialTab.tsx | 288 +++++++++++++++++++++
.../settings/__tests__/CloudCredentialTab.test.tsx | 168 ++++++++++++
web/src/pages/settings/index.tsx | 4 +-
12 files changed, 822 insertions(+), 46 deletions(-)
diff --git a/.claude/skills/deploy-rocketmq/SKILL.md
b/.claude/skills/deploy-rocketmq/SKILL.md
index 4c524e829..5a491585c 100644
--- a/.claude/skills/deploy-rocketmq/SKILL.md
+++ b/.claude/skills/deploy-rocketmq/SKILL.md
@@ -1,11 +1,11 @@
---
name: deploy-rocketmq
-description: 部署 RocketMQ Studio 与开源 RocketMQ。当用户说 部署 studio、把 studio
部署到远程机器/测试机、更新远程 studio、单机部署开源 rocketmq、远程部署 rocketmq 集群、单机部署 rocketmq 测试客户端、部署
studio 测试集群 时触发。
+description: 部署 RocketMQ Studio 与开源 RocketMQ。当用户说 部署 studio、把 studio
部署到远程机器/测试机、更新远程 studio、单机部署开源 rocketmq、远程部署 rocketmq 集群、单机部署 rocketmq 测试客户端、部署
studio 测试集群、部署负载挂具、给 K8s 集群上消费负载、制造消费延迟 inflight 时触发。
---
# 部署 RocketMQ Studio / 开源 RocketMQ
-三种部署模式,全部遵循同一条主线:
+四种部署模式,全部遵循同一条主线:
> **本地把源码打成 tar.gz → 复制到目标机器 → 在目标机器上构建镜像并启动。**
> 构建统一在目标机执行,复用目标机本地缓存(Maven `~/.m2`、docker 层缓存),
@@ -16,8 +16,9 @@ description: 部署 RocketMQ Studio 与开源 RocketMQ。当用户说 部署 stu
| A. Studio 部署到目标机器 | 把本项目(server + web + mysql)部署到一台远程 Linux 机器,浏览器访问 |
| B. 单机部署开源 RocketMQ | 在目标机器部署纯净开源集群(nameserver + 双 broker + proxy),不带测试挂具 |
| C. 单机部署 RocketMQ 测试客户端 | 在目标机器部署模式 B 集群 + producer/consumer 1 TPS 轨迹挂具,供
Studio 调试 |
+| D. K8s 集群负载挂具 | 对 K8s 上的 RocketMQ 集群,用 docker compose 在同 VPC 机器跑单容器负载(4 种
topic 类型 × remoting/grpc 双协议消费 + 可控消费延迟),供 Studio 观测消费延迟/inflight |
-> ⚠️ 模式 A 可用 `deploy/deploy.sh` 一键执行;模式 B/C 无脚本,按手动步骤执行。
+> ⚠️ 模式 A 可用 `deploy/deploy.sh` 一键执行;模式 B/C/D 无脚本,按手动步骤执行。
## 通用前置条件(目标机器)
@@ -271,6 +272,189 @@ $SSH 'cd /opt/rocketmq/rocketmq && docker compose down -v'
---
+## 模式 D:K8s 集群负载挂具(4 种 topic 类型 × 双协议消费)
+
+对部署在 K8s 上的 RocketMQ 集群(如社区 Helm chart 部署的 rocketmq1/rocketmq2),
+在与集群**同 VPC** 的机器上用 docker compose 跑一个负载容器:
+
+- producer 每秒向 4 种类型 topic(NORMAL / FIFO / DELAY / TRANSACTION)各发 1 条
+- 每个 topic 挂 1 个 remoting push 消费者 + 1 个 gRPC simple 消费者,共 **8 个消费者**
+- 每条消息消费时固定 sleep `CONSUME_DELAY_MS`(默认 15s)才返回 success,
+ 使管控(Studio)能看到稳定的消费延迟与 inflight(稳态积压 ≈ TPS × 延迟秒数/组)
+
+源码在 terrances 仓库 `project/rocketmq-loadgen/`(不放进 apache 仓库),
+本地 `mvn package` 出 fat jar 后传到目标机器构建镜像。
+
+### 1. 为 proxy 创建内网 SLB(关键知识点)
+
+社区 Helm chart 的 Service 全部硬编码 headless(`clusterIP: None`),集群外机器
+只能直连 Pod IP,而 **Pod IP 在 pod 重建后会变**。稳定做法是给 proxy 建一个
+**内网 SLB**(LoadBalancer Service):
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: <release>-proxy-slb # 如 rocketmq1-proxy-slb
+ namespace: <namespace>
+ annotations:
+ # 强制内网 SLB,禁止公网暴露(测试集群纪律:No public SLB)
+ service.beta.kubernetes.io/alibaba-cloud-loadbalancer-address-type:
"intranet"
+spec:
+ type: LoadBalancer
+ selector: # 与 chart proxy Service 的 selector 一致
+ app.kubernetes.io/instance: <release>
+ app.kubernetes.io/name: proxy
+ ports:
+ - { name: remoting, port: 8080, targetPort: 8080, protocol: TCP }
+ - { name: grpc, port: 8081, targetPort: 8081, protocol: TCP }
+```
+
+```bash
+kubectl apply -f proxy-slb.yaml
+# 等待 EXTERNAL-IP 分配(约 10~30s)
+kubectl -n <ns> get svc <release>-proxy-slb -w
+```
+
+要点:
+
+- SLB IP 属 VPC 内网地址,同 VPC 机器(含 docker bridge 内容器)直接可达;
+ 用 `(exec 3<>/dev/tcp/<slb-ip>/8081)` 验证连通。
+- endpoints 由 CCM 自动跟随 proxy pod 重建,客户端端点**永不失效**——这是
+ 选 SLB 而不是 Pod IP 的原因。
+- SLB TCP 监听默认空闲超时 900s,覆盖 gRPC/pop 长轮询,无需额外配置。
+- 客户端经 SLB 只连 proxy 即可,**不需要 nameserver/broker 直达**(见步骤 3 原理)。
+
+**nameserver 也要挂 SLB(按需)**:负载挂具本身只需 proxy SLB,但以下场景需要
+nameserver 的集群外接入点:Studio/其他 remoting 客户端直连 nameserver、
+mqadmin 从集群外管理。同样套上面的模板,selector 换成
+`app.kubernetes.io/name: nameserver`、端口 9876:
+
+```yaml
+metadata:
+ name: <release>-nameserver-slb
+ # annotation 同上(intranet)
+spec:
+ type: LoadBalancer
+ selector:
+ app.kubernetes.io/instance: <release>
+ app.kubernetes.io/name: nameserver
+ ports:
+ - { name: nameserver, port: 9876, targetPort: 9876, protocol: TCP }
+```
+
+验证:集群内任意容器 `./mqadmin clusterList -n <ns-slb-ip>:9876` 能列出 broker
+即转发正常。注意 nameserver SLB 只解决「拿路由」,remoting 客户端拿到路由后仍
+直连 broker 地址(broker 注册的是 pod IP)——集群外 remoting 全链路收发建议走
+proxy SLB(proxy 会把自己应答为 broker 地址);nameserver SLB 适合只读路由/
+探测类场景。
+
+已实测落地的 SLB(2026-08-17,ACK 集群):
+
+| Service | namespace | 内网 IP | 端口 |
+|---------|-----------|---------|------|
+| rocketmq1-proxy-slb | rocketmq1 | 10.0.2.11 | 8080 remoting / 8081 gRPC |
+| rocketmq1-nameserver-slb | rocketmq1 | 10.0.1.31 | 9876 |
+| rocketmq2-nameserver-slb | rocketmq2 | 10.0.1.32 | 9876 |
+
+### 2. 创建四种类型的 topic
+
+在集群内任一容器执行(官方镜像 workdir 已在 `bin/`,直接 `./mqadmin`;
+集群名按实际,chart 默认 `DefaultCluster`):
+
+```bash
+NS=$(kubectl -n <ns> get pod -l app.kubernetes.io/name=nameserver -o
jsonpath='{.items[0].metadata.name}')
+for t in "studio-normal NORMAL" "studio-fifo FIFO" "studio-delay DELAY"
"studio-transaction TRANSACTION"; do
+ set -- $t
+ kubectl -n <ns> exec $NS -- ./mqadmin updateTopic -n localhost:9876 \
+ -c DefaultCluster -t $1 -r 4 -w 4 -a "+message.type=$2"
+done
+```
+
+`-a "+message.type=..."` 必带:proxy 对 gRPC 与 remoting 链路都做 topic 消息
+类型校验,类型不匹配发送直接报
+`TopicMessageType validate failed, the expected type is X, but actual type is
Y`。
+
+### 3. 打包部署负载容器
+
+```bash
+cd project/rocketmq-loadgen
+mvn -B -ntp package # 产出
target/rocketmq-loadgen.jar(~120M fat jar)
+tar czf /tmp/rocketmq-loadgen.tar.gz pom.xml Dockerfile docker-compose.yml
target/rocketmq-loadgen.jar
+scp /tmp/rocketmq-loadgen.tar.gz <user>@<host>:/tmp/
+$SSH 'rm -rf /opt/rocketmq-loadgen && mkdir -p /opt/rocketmq-loadgen && \
+ tar xzf /tmp/rocketmq-loadgen.tar.gz -C /opt/rocketmq-loadgen && \
+ printf
"REMOTING_ADDR=<slb-ip>:8080\nGRPC_ADDR=<slb-ip>:8081\nTOPIC_PREFIX=studio\nSEND_INTERVAL_MS=1000\nCONSUME_DELAY_MS=15000\n"
\
+ > /opt/rocketmq-loadgen/.env'
+$SSH 'cd /opt/rocketmq-loadgen && docker compose up -d --build'
+```
+
+环境变量(compose 自动读同目录 `.env`):
+
+| 变量 | 默认 | 说明 |
+|------|------|------|
+| `REMOTING_ADDR` | 必填 | proxy remoting 端点 `<slb-ip>:8080`,remoting
producer/consumer 的 namesrvAddr |
+| `GRPC_ADDR` | 必填 | proxy gRPC 端点 `<slb-ip>:8081`,gRPC SimpleConsumer 的
endpoints |
+| `TOPIC_PREFIX` | `studio` | topic 前缀,得到
`<前缀>-{normal,fifo,delay,transaction}` |
+| `SEND_INTERVAL_MS` | `1000` | 每 topic 发送间隔(每轮 4 条) |
+| `CONSUME_DELAY_MS` | `15000` | 每条消息消费耗时,制造延迟与 inflight |
+
+运行镜像基于 `alibabadragonwell/dragonwell:21`;依赖 rocketmq-client 5.5.0
+(remoting)+ rocketmq-client-java 5.2.x(gRPC,内部已 shade grpc/netty,与
+remoting 客户端共存无冲突),maven-shade 打 fat jar。
+
+**原理**:remoting 客户端把 `namesrvAddr` 直接指向 proxy 的 8080——proxy 兼容
+nameserver 路由协议,会把 broker 地址应答为自身,收发全部经 proxy 转发,因此
+无需 nameserver/broker 对客户端网络可达。gRPC 客户端天然以 proxy 8081 为接入点。
+两种协议都只依赖 proxy → 一个 SLB 两个端口搞定。
+
+### 4. 验证
+
+```bash
+# 发送:每秒 4 条(每类型 1 条)SEND_OK
+$SSH 'docker logs rmq-loadgen | grep "send #" | tail -8'
+# 消费:8 路([remoting|grpc][normal|fifo|delay|transaction])均有 consumed 日志
+$SSH 'docker logs rmq-loadgen | grep consumed | awk "{print \$1,\$2}" | sort |
uniq -c'
+
+# broker 侧稳态积压(inflight):Accumulation ≈ TPS × CONSUME_DELAY_MS/1000
+kubectl -n <ns> exec $NS -- ./mqadmin statsAll -n localhost:9876 -t
studio-normal
+```
+
+通过标准:4 种类型全部 `SEND_OK`;8 个消费者组持续 `consumed delay=15000ms`;
+`statsAll` 每组 Accumulation 稳定在 ~15(= 1 TPS × 15s),In/Out TPS 均 ≈ 1.00。
+
+### 5. 已知坑(实测 2026-08-17,5.5.0 chart 集群)
+
+- **FIFO 消息必须带分片键**:proxy 按消息属性推断类型,FIFO 依赖
+ `MessageConst.PROPERTY_SHARDING_KEY`(值为 `__SHARDINGKEY`)。这是系统属性,
+ `Message.putUserProperty` 会拒绝,须 `msg.getProperties().put(...)` 直接写。
+ 不带时发 FIFO topic 报 `expected type is FIFO, but actual type is NORMAL`。
+- **gRPC SimpleConsumer 必须并行消费 + 背压**:串行 sleep 后 ack 吞吐只有
+ `1/CONSUME_DELAY_MS`,会无限积压;且单条处理时间超过 `receive` 第二参
+ invisibleDuration 时报 `INVALID_RECEIPT_HANDLE`。挂具用 20 线程池并行
+ sleep+ack,invisibleDuration 取 120s;**还要限制在途量 ≤ 线程数**
+ (pending 计数器背压),否则积压追赶时排队等待也会让 handle 过期。
+- **client-java 5.x builder 命名是 `set*`**:`setEndpoints` / `setRequestTimeout`
+ (文档示例的 `with*` 是早期/其他版本写法)。
+- **走 proxy 的消费是 pop 消费**:不产生 `%RETRY%<group>` topic,
+ `mqadmin consumerProgress` / `consumerConnection` 会报
+ `No topic route info ... %RETRY%...`,**改用 `statsAll -t <topic>` 看积压**。
+- **扩容 broker 后 topic 不会自动补建**:`-c DefaultCluster` 集群级建 topic
+ 只覆盖当时已注册的 broker,后注册的新 broker 无 topic、无流量。扩容后需对
+ 新 broker 用 `-b <podIP>:10911` 逐个补建(或直接重新集群级建一次),
+ 再用 `topicRoute -t <topic>` 确认路由覆盖所有 broker(2026-08-18 实测)。
+- transaction topic 发送必须走 `TransactionMQProducer.sendMessageInTransaction`
+ (半消息 + COMMIT),普通 send 会被类型校验拒绝。
+
+### 6. 清理
+
+```bash
+$SSH 'cd /opt/rocketmq-loadgen && docker compose down'
+kubectl -n <ns> delete svc <release>-proxy-slb <release>-nameserver-slb #
不再需要外部接入点时
+```
+
+---
+
## 常用查询命令(mqadmin)
以下命令均已实测可用,在任意集群容器内执行(示例用 nameserver)。远程执行时
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index d131c86eb..8ab6852c3 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -5,6 +5,10 @@ services:
image: mysql:8.0
container_name: rocketmq-studio-mysql
restart: unless-stopped
+ command:
+ - --character-set-server=utf8mb4
+ - --collation-server=utf8mb4_unicode_ci
+ - --init-connect=SET NAMES utf8mb4
environment:
TZ: ${TZ:-Asia/Shanghai}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-studio123}
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index 9b50e505e..f48035479 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -1,6 +1,7 @@
server {
listen 80;
server_name _;
+ charset utf-8;
location / {
root /usr/share/nginx/html;
diff --git a/web/nginx.conf b/web/nginx.conf
index 57426599b..20bc561fb 100644
--- a/web/nginx.conf
+++ b/web/nginx.conf
@@ -1,6 +1,7 @@
server {
listen 80;
server_name _;
+ charset utf-8;
# Compress text assets; the entry JS bundle is ~1MB uncompressed.
gzip on;
diff --git a/web/src/api/cloudCredential.test.ts
b/web/src/api/cloudCredential.test.ts
index 77502914d..162bfc48a 100644
--- a/web/src/api/cloudCredential.test.ts
+++ b/web/src/api/cloudCredential.test.ts
@@ -18,7 +18,12 @@
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import client from './client';
-import { listCloudCredentials } from './cloudCredential';
+import {
+ createCloudCredential,
+ deleteCloudCredential,
+ listCloudCredentials,
+ updateCloudCredential,
+} from './cloudCredential';
const mock = new MockAdapter(client);
@@ -53,4 +58,54 @@ describe('cloudCredential API', () => {
expect(credentials[0].vendor).toBe('ALIYUN');
expect(credentials[0].secretKey).toBeUndefined();
});
+
+ it('creates a credential with the full payload', async () => {
+ mock.onPost('/cloud-credentials/create').reply((config) => {
+ const body = JSON.parse(config.data);
+ return [
+ 200,
+ {
+ code: 200,
+ data: {
+ id: 1,
+ name: body.name,
+ vendor: body.vendor,
+ accessKey: 'LTAI****0001',
+ gmtCreate: '2026-08-18T00:00:00',
+ },
+ },
+ ];
+ });
+
+ const saved = await createCloudCredential({
+ name: 'aliyun-test',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI00000001',
+ secretKey: 'secret-0001',
+ remark: 'test account',
+ });
+
+ expect(saved.id).toBe(1);
+ expect(JSON.parse(mock.history.post[0].data)).toMatchObject({
+ name: 'aliyun-test',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI00000001',
+ secretKey: 'secret-0001',
+ });
+ });
+
+ it('updates only provided fields and deletes by string id', async () => {
+ mock.onPost('/cloud-credentials/update').reply(200, {
+ code: 200,
+ data: { id: 1, name: 'renamed', vendor: 'ALIYUN', accessKey:
'LTAI****0001' },
+ });
+ mock.onPost('/cloud-credentials/delete').reply(200, { code: 200 });
+
+ const saved = await updateCloudCredential({ id: 1, name: 'renamed' });
+ expect(saved.name).toBe('renamed');
+ expect(JSON.parse(mock.history.post[0].data)).toEqual({ id: 1, name:
'renamed' });
+
+ await deleteCloudCredential(1);
+ expect(JSON.parse(mock.history.post[1].data)).toEqual({ id: '1' });
+ });
});
diff --git a/web/src/api/cloudCredential.ts b/web/src/api/cloudCredential.ts
index 41cdca0ba..39890c43d 100644
--- a/web/src/api/cloudCredential.ts
+++ b/web/src/api/cloudCredential.ts
@@ -20,3 +20,32 @@ export async function listCloudCredentials() {
const res = await client.get<{ data: CloudCredential[]
}>('/cloud-credentials');
return res.data.data;
}
+
+export interface CreateCloudCredentialRequest {
+ name: string;
+ vendor: InstanceVendor;
+ accessKey: string;
+ secretKey: string;
+ remark?: string;
+}
+
+export async function createCloudCredential(request:
CreateCloudCredentialRequest) {
+ const res = await client.post<{ data: CloudCredential
}>('/cloud-credentials/create', request);
+ return res.data.data;
+}
+
+export interface UpdateCloudCredentialRequest {
+ id: number;
+ name?: string;
+ secretKey?: string;
+ remark?: string;
+}
+
+export async function updateCloudCredential(request:
UpdateCloudCredentialRequest) {
+ const res = await client.post<{ data: CloudCredential
}>('/cloud-credentials/update', request);
+ return res.data.data;
+}
+
+export async function deleteCloudCredential(id: number) {
+ await client.post('/cloud-credentials/delete', { id: String(id) });
+}
diff --git a/web/src/components/InfoBanner.tsx
b/web/src/components/InfoBanner.tsx
index d9d35b6b9..06307799d 100644
--- a/web/src/components/InfoBanner.tsx
+++ b/web/src/components/InfoBanner.tsx
@@ -16,6 +16,7 @@
*/
import type { CSSProperties, ReactNode } from 'react';
+import { theme } from 'antd';
interface InfoBannerProps {
title?: ReactNode;
@@ -25,34 +26,38 @@ interface InfoBannerProps {
'data-testid'?: string;
}
-// 页面级常驻说明统一使用中性灰色 banner,区别于带语义色的告警/错误 Alert
-const InfoBanner = ({ title, description, children, style, ...rest }:
InfoBannerProps) => (
- <div
- {...rest}
- style={{
- marginBottom: 16,
- padding: '12px 16px',
- borderRadius: 8,
- border: '1px solid var(--bolt-elements-border-color, #f0f0f0)',
- background: 'var(--bolt-elements-bg-depth-2, #fafafa)',
- ...style,
- }}
- >
- {title && <div style={{ fontSize: 14, fontWeight: 500 }}>{title}</div>}
- {description && (
- <div
- style={{
- fontSize: 14,
- lineHeight: 1.6,
- color: '#8c8c8c',
- marginTop: title ? 6 : 0,
- }}
- >
- {description}
- </div>
- )}
- {children}
- </div>
-);
+// 页面级常驻说明统一使用中性灰色 banner,区别于带语义色的告警/错误 Alert;
+// 颜色取 antd 主题 token,深色模式下自动适配(浅色模式观感等同 #fafafa/#f0f0f0)
+const InfoBanner = ({ title, description, children, style, ...rest }:
InfoBannerProps) => {
+ const { token } = theme.useToken();
+ return (
+ <div
+ {...rest}
+ style={{
+ marginBottom: 16,
+ padding: '12px 16px',
+ borderRadius: 8,
+ border: `1px solid ${token.colorBorderSecondary}`,
+ background: token.colorFillQuaternary,
+ ...style,
+ }}
+ >
+ {title && <div style={{ fontSize: 14, fontWeight: 500 }}>{title}</div>}
+ {description && (
+ <div
+ style={{
+ fontSize: 14,
+ lineHeight: 1.6,
+ color: token.colorTextSecondary,
+ marginTop: title ? 6 : 0,
+ }}
+ >
+ {description}
+ </div>
+ )}
+ {children}
+ </div>
+ );
+};
export default InfoBanner;
diff --git a/web/src/index.css b/web/src/index.css
index 3dafc49cc..e1384f638 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -66,6 +66,36 @@ body {
}
}
+@keyframes oneday-orb-drift-b {
+ 0% {
+ transform: translate3d(0, 0, 0) scale(1) rotate(0deg);
+ }
+ 30% {
+ transform: translate3d(-25%, -15%, 0) scale(1.15) rotate(-6deg);
+ }
+ 60% {
+ transform: translate3d(-40%, -30%, 0) scale(1.3) rotate(4deg);
+ }
+ 100% {
+ transform: translate3d(0, 0, 0) scale(1) rotate(0deg);
+ }
+}
+
+@keyframes oneday-orb-drift-c {
+ 0% {
+ transform: translate3d(0, 0, 0) scale(1);
+ }
+ 40% {
+ transform: translate3d(-18%, 22%, 0) scale(1.2);
+ }
+ 70% {
+ transform: translate3d(-30%, 10%, 0) scale(0.95);
+ }
+ 100% {
+ transform: translate3d(0, 0, 0) scale(1);
+ }
+}
+
@keyframes oneday-bg-drift {
0%,
100% {
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index 48c2266bf..6c53e9fcd 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -209,17 +209,26 @@ const MainLayout = () => {
),
key: 'home',
},
- ...pathSnippets.map((_, index) => {
- const path = '/' + pathSnippets.slice(0, index + 1).join('/');
- const isSectionLeaf = instanceScopedMatch && index ===
pathSnippets.length - 1;
- const leafTitle = isSectionLeaf
- ? breadcrumbMap[`/instance/${instanceScopedMatch[1]}`]
- : undefined;
- return {
- title: breadcrumbMap[path] || leafTitle || path,
- key: path,
- };
- }),
+ ...pathSnippets
+ .map((_, index) => {
+ const path = '/' + pathSnippets.slice(0, index + 1).join('/');
+ // The instance ID path segment (/instance/<id>) is an identifier,
not a
+ // navigation level — keep it out of the breadcrumb trail.
+ const isInstanceIdSegment =
+ index === 1 && pathSnippets[0] === 'instance' &&
!breadcrumbMap[path];
+ if (isInstanceIdSegment) {
+ return null;
+ }
+ const isSectionLeaf = instanceScopedMatch && index ===
pathSnippets.length - 1;
+ const leafTitle = isSectionLeaf
+ ? breadcrumbMap[`/instance/${instanceScopedMatch[1]}`]
+ : undefined;
+ return {
+ title: breadcrumbMap[path] || leafTitle || path,
+ key: path,
+ };
+ })
+ .filter((item): item is NonNullable<typeof item> => item !== null),
];
}, [location.pathname, navigate, breadcrumbMap, instanceScopedMatch, t]);
@@ -315,7 +324,7 @@ const MainLayout = () => {
defaultOpenKeys={['instance-group', 'cluster-ops-group']}
items={menuItems}
onClick={({ key }) => navigate(key)}
- style={{ borderRight: 'none', paddingTop: 8, background:
'transparent' }}
+ style={{ borderRight: 'none', background: 'transparent' }}
/>
</Sider>
diff --git a/web/src/pages/settings/CloudCredentialTab.tsx
b/web/src/pages/settings/CloudCredentialTab.tsx
new file mode 100644
index 000000000..476bee009
--- /dev/null
+++ b/web/src/pages/settings/CloudCredentialTab.tsx
@@ -0,0 +1,288 @@
+/*
+ * 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, useState } from 'react';
+import {
+ Button,
+ Descriptions,
+ Flex,
+ Form,
+ Input,
+ Modal,
+ Popconfirm,
+ Select,
+ Space,
+ Table,
+ Tag,
+ message,
+} from 'antd';
+import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
+import type { ColumnsType } from 'antd/es/table';
+
+import {
+ createCloudCredential,
+ deleteCloudCredential,
+ listCloudCredentials,
+ updateCloudCredential,
+} from '../../api/cloudCredential';
+import type { CloudCredential } from '../../api/cloudCredential';
+import type { InstanceVendor } from '../../api/instance';
+
+const vendorLabel: Record<string, string> = {
+ ALIYUN: '阿里云',
+ TENCENT: '腾讯云',
+};
+
+const vendorTagColor: Record<string, string> = {
+ ALIYUN: 'orange',
+ TENCENT: 'blue',
+};
+
+const VENDOR_OPTIONS = [
+ { value: 'ALIYUN', label: '阿里云' },
+ { value: 'TENCENT', label: '腾讯云' },
+];
+
+interface CredentialFormValues {
+ name: string;
+ vendor: InstanceVendor;
+ accessKey?: string;
+ secretKey?: string;
+ remark?: string;
+}
+
+export const CloudCredentialTab = () => {
+ const [credentials, setCredentials] = useState<CloudCredential[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editingCredential, setEditingCredential] = useState<CloudCredential |
null>(null);
+ const [form] = Form.useForm<CredentialFormValues>();
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ void listCloudCredentials()
+ .then((list) => {
+ if (!cancelled) setCredentials(list);
+ })
+ .catch(() => {
+ if (!cancelled) message.error('云凭据加载失败,请稍后重试');
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const closeModal = () => {
+ setModalOpen(false);
+ setEditingCredential(null);
+ form.resetFields();
+ };
+
+ const openCreateModal = () => {
+ setEditingCredential(null);
+ form.resetFields();
+ setModalOpen(true);
+ };
+
+ const openEditModal = (credential: CloudCredential) => {
+ setEditingCredential(credential);
+ form.setFieldsValue({
+ name: credential.name,
+ vendor: credential.vendor,
+ remark: credential.remark,
+ });
+ setModalOpen(true);
+ };
+
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+ setSubmitting(true);
+ if (editingCredential) {
+ const saved = await updateCloudCredential({
+ id: editingCredential.id,
+ name: values.name,
+ secretKey: values.secretKey,
+ remark: values.remark,
+ });
+ setCredentials((previous) => previous.map((item) => (item.id ===
saved.id ? saved : item)));
+ message.success('云凭据已更新');
+ } else {
+ const saved = await createCloudCredential({
+ name: values.name,
+ vendor: values.vendor,
+ accessKey: values.accessKey ?? '',
+ secretKey: values.secretKey ?? '',
+ remark: values.remark,
+ });
+ setCredentials((previous) => [...previous, saved]);
+ message.success('云凭据已添加');
+ }
+ closeModal();
+ } catch (error) {
+ if (error && typeof error === 'object' && 'errorFields' in error) {
+ return; // validation failure; antd already shows field-level errors
+ }
+ message.error('保存云凭据失败,请稍后重试');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDelete = async (credential: CloudCredential) => {
+ try {
+ await deleteCloudCredential(credential.id);
+ setCredentials((previous) => previous.filter((item) => item.id !==
credential.id));
+ message.success('云凭据已删除');
+ } catch {
+ message.error('删除云凭据失败(可能仍被实例引用),请稍后重试');
+ }
+ };
+
+ const columns: ColumnsType<CloudCredential> = [
+ { title: '名称', dataIndex: 'name', key: 'name' },
+ {
+ title: '云厂商',
+ dataIndex: 'vendor',
+ key: 'vendor',
+ render: (vendor: string) => (
+ <Tag color={vendorTagColor[vendor]}>{vendorLabel[vendor] ??
vendor}</Tag>
+ ),
+ },
+ { title: 'AccessKey', dataIndex: 'accessKey', key: 'accessKey' },
+ { title: '备注', dataIndex: 'remark', key: 'remark' },
+ { title: '创建时间', dataIndex: 'gmtCreate', key: 'gmtCreate' },
+ {
+ title: '操作',
+ key: 'action',
+ render: (_: unknown, record: CloudCredential) => (
+ <Space size="small">
+ <Button
+ type="link"
+ size="small"
+ icon={<EditOutlined />}
+ onClick={() => openEditModal(record)}
+ >
+ 编辑
+ </Button>
+ <Popconfirm
+ title="确定要删除该云凭据吗?"
+ onConfirm={() => void handleDelete(record)}
+ okText="确定"
+ cancelText="取消"
+ >
+ <Button type="link" size="small" danger icon={<DeleteOutlined />}>
+ 删除
+ </Button>
+ </Popconfirm>
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <>
+ <Flex justify="flex-end" style={{ marginBottom: 16 }}>
+ <Button type="primary" icon={<PlusOutlined />}
onClick={openCreateModal} disabled={loading}>
+ 添加云凭据
+ </Button>
+ </Flex>
+
+ <Table<CloudCredential>
+ columns={columns}
+ dataSource={credentials}
+ rowKey="id"
+ loading={loading}
+ pagination={false}
+ size="middle"
+ />
+
+ <Modal
+ title={editingCredential ? '编辑云凭据' : '添加云凭据'}
+ open={modalOpen}
+ onCancel={closeModal}
+ onOk={() => void handleSubmit()}
+ confirmLoading={submitting}
+ destroyOnHidden
+ >
+ {editingCredential && (
+ <Descriptions
+ size="small"
+ column={1}
+ style={{ marginBottom: 16 }}
+ items={[
+ {
+ key: 'vendor',
+ label: '云厂商',
+ children: vendorLabel[editingCredential.vendor] ??
editingCredential.vendor,
+ },
+ { key: 'accessKey', label: 'AccessKey', children:
editingCredential.accessKey },
+ ]}
+ />
+ )}
+ <Form form={form} layout="vertical" preserve={false}>
+ <Form.Item
+ label="名称"
+ name="name"
+ rules={[{ required: true, message: '请输入凭据名称' }]}
+ >
+ <Input placeholder="例如:阿里云测试账号" />
+ </Form.Item>
+
+ {!editingCredential && (
+ <>
+ <Form.Item
+ label="云厂商"
+ name="vendor"
+ rules={[{ required: true, message: '请选择云厂商' }]}
+ >
+ <Select placeholder="请选择" virtual={false}
options={VENDOR_OPTIONS} />
+ </Form.Item>
+
+ <Form.Item
+ label="AccessKey"
+ name="accessKey"
+ rules={[{ required: true, message: '请输入 AccessKey' }]}
+ >
+ <Input autoComplete="off" placeholder="LTAI..." />
+ </Form.Item>
+ </>
+ )}
+
+ <Form.Item
+ label="SecretKey"
+ name="secretKey"
+ rules={editingCredential ? [] : [{ required: true, message: '请输入
SecretKey' }]}
+ extra={editingCredential ? '留空表示保持原 SecretKey 不变' : undefined}
+ >
+ <Input.Password autoComplete="off" placeholder="请输入 SecretKey" />
+ </Form.Item>
+
+ <Form.Item label="备注" name="remark">
+ <Input.TextArea rows={2} placeholder="可选" />
+ </Form.Item>
+ </Form>
+ </Modal>
+ </>
+ );
+};
+
+export default CloudCredentialTab;
diff --git a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
new file mode 100644
index 000000000..46ab48c99
--- /dev/null
+++ b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
@@ -0,0 +1,168 @@
+/*
+ * 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 { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import type { CloudCredential } from '../../../api/cloudCredential';
+import {
+ createCloudCredential,
+ deleteCloudCredential,
+ listCloudCredentials,
+ updateCloudCredential,
+} from '../../../api/cloudCredential';
+import { LangProvider } from '../../../i18n/LangContext';
+import { CloudCredentialTab } from '../CloudCredentialTab';
+
+vi.mock('../../../api/cloudCredential', () => ({
+ createCloudCredential: vi.fn(),
+ deleteCloudCredential: vi.fn(),
+ listCloudCredentials: vi.fn(),
+ updateCloudCredential: vi.fn(),
+}));
+
+const credentials: CloudCredential[] = [
+ {
+ id: 1,
+ name: 'aliyun-test',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI****0001',
+ remark: '测试账号',
+ gmtCreate: '2026-08-18T10:00:00',
+ },
+];
+
+const renderTab = () =>
+ render(
+ <App>
+ <LangProvider>
+ <CloudCredentialTab />
+ </LangProvider>
+ </App>,
+ );
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+describe('CloudCredentialTab', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(listCloudCredentials).mockResolvedValue(credentials);
+ });
+
+ it('renders masked credentials from the backend', async () => {
+ renderTab();
+
+ await waitFor(() =>
expect(screen.getByText('aliyun-test')).toBeInTheDocument());
+ expect(screen.getByText('LTAI****0001')).toBeInTheDocument();
+ expect(screen.getByText('阿里云')).toBeInTheDocument();
+ });
+
+ it('creates a credential from the modal form', async () => {
+ vi.mocked(createCloudCredential).mockResolvedValue({
+ id: 2,
+ name: 'tencent-prod',
+ vendor: 'TENCENT',
+ accessKey: 'AKID****9999',
+ gmtCreate: '2026-08-18T11:00:00',
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderTab();
+
+ await waitFor(() =>
expect(screen.getByText('aliyun-test')).toBeInTheDocument());
+ await user.click(screen.getByRole('button', { name: /添加云凭据/ }));
+
+ const dialog = await screen.findByRole('dialog');
+ await user.type(within(dialog).getByPlaceholderText(/请输入凭据名称|例如/),
'tencent-prod');
+ // fill name field explicitly (placeholder shared with example text)
+ await user.click(within(dialog).getByLabelText('云厂商'));
+ await user.click(await screen.findByText('腾讯云'));
+ await user.type(screen.getByPlaceholderText('LTAI...'),
'AKID000000009999');
+ await user.type(screen.getByPlaceholderText('请输入 SecretKey'),
'secret-9999');
+ await user.click(within(dialog).getByRole('button', { name: 'OK' }));
+
+ await waitFor(() =>
+ expect(createCloudCredential).toHaveBeenCalledWith({
+ name: 'tencent-prod',
+ vendor: 'TENCENT',
+ accessKey: 'AKID000000009999',
+ secretKey: 'secret-9999',
+ remark: undefined,
+ }),
+ );
+ await waitFor(() =>
expect(screen.getByText('tencent-prod')).toBeInTheDocument());
+ });
+
+ it('updates name and remark while keeping the secret unchanged when blank',
async () => {
+ vi.mocked(updateCloudCredential).mockResolvedValue({
+ ...credentials[0],
+ name: 'aliyun-renamed',
+ remark: '新备注',
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderTab();
+
+ await waitFor(() =>
expect(screen.getByText('aliyun-test')).toBeInTheDocument());
+ await user.click(screen.getByRole('button', { name: /编辑/ }));
+
+ const dialog = await screen.findByRole('dialog');
+ const nameInput = within(dialog).getByDisplayValue('aliyun-test');
+ await user.clear(nameInput);
+ await user.type(nameInput, 'aliyun-renamed');
+ const remarkInput = within(dialog).getByDisplayValue('测试账号');
+ await user.clear(remarkInput);
+ await user.type(remarkInput, '新备注');
+ await user.click(within(dialog).getByRole('button', { name: 'OK' }));
+
+ await waitFor(() =>
+ expect(updateCloudCredential).toHaveBeenCalledWith({
+ id: 1,
+ name: 'aliyun-renamed',
+ secretKey: undefined,
+ remark: '新备注',
+ }),
+ );
+ await waitFor(() =>
expect(screen.getByText('aliyun-renamed')).toBeInTheDocument());
+ });
+
+ it('deletes a credential after confirmation', async () => {
+ vi.mocked(deleteCloudCredential).mockResolvedValue(undefined);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderTab();
+
+ await waitFor(() =>
expect(screen.getByText('aliyun-test')).toBeInTheDocument());
+ await user.click(screen.getByRole('button', { name: /删除/ }));
+ await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
+
+ await waitFor(() => expect(deleteCloudCredential).toHaveBeenCalledWith(1));
+ await waitFor(() =>
expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument());
+ });
+});
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index 1fb200271..5400e098c 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -22,10 +22,11 @@ import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
import { GeneralSettingsTab } from './GeneralSettingsTab';
import { AiAssistantTab } from './AiAssistantTab';
+import { CloudCredentialTab } from './CloudCredentialTab';
import { DataSourceTab } from './DataSourceTab';
import { AboutTab } from './AboutTab';
-const TAB_KEYS = ['general', 'ai', 'datasource', 'about'] as const;
+const TAB_KEYS = ['general', 'ai', 'credential', 'datasource', 'about'] as
const;
type TabKey = (typeof TAB_KEYS)[number];
const SettingsPage = () => {
@@ -46,6 +47,7 @@ const SettingsPage = () => {
items={[
{ key: 'general', label: '通用设置', children: <GeneralSettingsTab /> },
{ key: 'ai', label: 'AI 助手', children: <AiAssistantTab /> },
+ { key: 'credential', label: '云凭据管理', children: <CloudCredentialTab
/> },
{ key: 'datasource', label: '数据源管理', children: <DataSourceTab /> },
{ key: 'about', label: '关于', children: <AboutTab /> },
]}