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 /> },
         ]}

Reply via email to