You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/10/14 02:29:32 UTC

(rocketmq-dashboard) branch rocketmq-studio updated: fix(web): tidy timestamp, disk usage, risk and filter-bar rendering; add regression skill (#6633)

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 e2f815fba fix(web): tidy timestamp, disk usage, risk and filter-bar rendering; add regression skill (#6633)
e2f815fba is described below

commit e2f815fba774a1e399bf9bb3709246edca1a436d
Author: lizhimins <70...@qq.com>
AuthorDate: Wed Oct 14 10:29:26 2026 +0800

    fix(web): tidy timestamp, disk usage, risk and filter-bar rendering; add regression skill (#6633)
    
    - formatUtcDateTime no longer appends the viewer's zone name, so timestamps read
      `YYYY-MM-DD HH:mm:ss` everywhere; time columns are sized for the shorter string
      (156 in small tables, 182 in the default-size user table, which was ellipsizing
      the value it exists to show)
    - the provider scales commitLogDiskRatio to whole tenths and the render sites
      round again, so `0.29 * 100` can no longer reach a label as 28.999999999999996
      and squeeze the Progress bar to zero width; the Broker cluster page also drops
      Progress's own in-bar label, which duplicated the adjacent span
    - client connection risks open a detail modal from the risk title instead of
      printing the whole description under every row of the diagnostics table
    - the system alerts filter bar stays on one line at 1600px (a single RangePicker
      instead of two datetime-local inputs, narrower controls) and the collector
      status chip moves to the page header, rendered at bar height with a neutral
      border instead of as a 22px success Tag
    - adds the regression-rocketmq skill: a real-environment regression runbook
      (cluster bring-up through deploy-rocketmq, e2e suite, dead-letter end-to-end
      check, report), with its scripts exercised against the test deployment
---
 .claude/skills/regression-rocketmq/SKILL.md        |  63 ++++++++++++
 .../assets/FailingConsumer.java                    |  35 +++++++
 .../regression-rocketmq/scripts/_studio_client.py  |  28 ++++++
 .../regression-rocketmq/scripts/check_env.sh       |  39 ++++++++
 .../regression-rocketmq/scripts/clean_leftovers.py |  56 +++++++++++
 .../regression-rocketmq/scripts/dlq_check.sh       | 106 +++++++++++++++++++++
 .../skills/regression-rocketmq/scripts/run_e2e.sh  |  20 ++++
 .../provider/apache/RocketMQClusterProvider.java   |   4 +-
 .../apache/RocketMQClusterProviderTest.java        |  18 ++++
 web/src/i18n/translations.ts                       |   5 +-
 .../pages/ai/components/ConversationListModal.tsx  |   4 +-
 .../pages/cluster/__tests__/ClientsPage.test.tsx   |  20 ++++
 .../pages/cluster/__tests__/ClusterPage.test.tsx   |  16 ++++
 web/src/pages/cluster/certs.tsx                    |   4 +-
 web/src/pages/cluster/clients.tsx                  |  72 ++++++++++++--
 web/src/pages/cluster/index.tsx                    |   3 +-
 .../pages/instance/__tests__/ConsumerPage.test.tsx |   8 +-
 web/src/pages/instance/__tests__/DLQPage.test.tsx  |   2 +-
 web/src/pages/instance/dlq.tsx                     |   2 +-
 .../pages/ops/__tests__/SystemAlertsPage.test.tsx  |  14 +--
 web/src/pages/ops/alerts.tsx                       |   2 +-
 web/src/pages/ops/auditRiskInsightModel.ts         |   3 +-
 web/src/pages/ops/systemAlerts.tsx                 |  74 ++++++++------
 web/src/pages/studio/BrokerCluster.tsx             |   9 +-
 web/src/pages/studio/UserManagement.tsx            |   9 +-
 .../pages/studio/__tests__/BrokerCluster.test.tsx  |  19 +++-
 .../pages/studio/__tests__/UserManagement.test.tsx |   6 +-
 web/src/utils/format.test.ts                       |  11 ++-
 web/src/utils/format.ts                            |  29 +++---
 29 files changed, 597 insertions(+), 84 deletions(-)

diff --git a/.claude/skills/regression-rocketmq/SKILL.md b/.claude/skills/regression-rocketmq/SKILL.md
new file mode 100644
index 000000000..ae3a232fe
--- /dev/null
+++ b/.claude/skills/regression-rocketmq/SKILL.md
@@ -0,0 +1,63 @@
+---
+name: regression-rocketmq
+description: RocketMQ Studio 真实环境回归。当用户说 回归 studio、跑 studio 全功能测试、验证死信/收发/轨迹、studio 发版前验证、regression-rocketmq 时触发。流程:环境体检 → 用 deploy-rocketmq 起或校验开源集群(本地有 k8s 证书则再用 deploy-rocketmq-k8s.sh 拉起/校验一套 k8s 开源集群)→ 清遗留资源 → 跑 e2e 套件 → 死信全链路 → 收发与轨迹抽查 → 出报告。
+---
+
+# RocketMQ Studio 真实环境回归
+
+对**真实部署**的 Studio + 真实 RocketMQ 集群做功能回归:API 层跑 studio-docs 的 e2e 套件,
+死信/收发/轨迹走 broker 侧真实链路,最后出一份报告。不做 mock。
+
+## 流程
+
+1. **体检**:`set -a; source <terrances>/project/rocketmq-studio-docs/e2e-tests/.env; set +a`
+   后跑 `scripts/check_env.sh`。按输出补缺:
+   - studio 不通 / 登录失败 → 检查 `.env` 凭据,或按 deploy-rocketmq 模式 A 重新部署;
+   - 无 `rmq-*` 容器 → 按 **deploy-rocketmq 模式 C** 起集群(与 studio 同机时共用 `rocketmq_net`);
+   - `kubectl` 有 context 但缺 `rocketmq1` namespace → 跑
+     `project/rocketmq-studio-docs/deploy/rocketmq-k8s/deploy-rocketmq-k8s.sh`(幂等,含内网 SLB 与
+     4 类 topic),并在 studio 确认 `open-source-k8s` 实例存在;无 k8s 证书则跳过并在报告里注明。
+2. **清遗留**:`scripts/clean_leftovers.py`(上一轮崩掉会留下告警规则 / topic / group,
+   让本轮创建类用例 409)。`run_e2e.sh` 已内含此步。
+3. **e2e 套件**:`scripts/run_e2e.sh`(默认 `-m "not cloud"`;要连云实例传 `'-m cloud'`,
+   全量传 `''`)。红了先单跑那条看 message 再分类:409 / 「id is required」多是遗留或套件落后于
+   API(对照 server controller 的 DTO),404 多是路由改名(grep `@RequestMapping`);
+   排除这两类仍红才是产品回归。
+4. **死信全链路**:`scripts/dlq_check.sh`,约 2 分钟(并发消费重试阶梯 10s/30s,
+   `MAX_RECONSUME_TIMES=2`)。它自建 topic/group、发 3 条、起必失败消费者、等 DLQ 攒到 3 条、
+   重投并确认消费者再次收到、trap 收尾删除 topic/group。
+5. **收发 / 轨迹抽查**:API 侧由 e2e `test_messages.py` 覆盖(发送、msgId/key/时间查询、轨迹);
+   broker 侧抽查 `mqadmin clusterList`(两 broker `#ACTIVATED=true`、In/Out TPS ≈ 挂具速率)与
+   `queryMsgTraceById`(Pub + Sub 齐全),命令见 deploy-rocketmq skill「常用查询命令」。
+6. **浏览器抽检(可选)**:playwright-core harness 扫关键页——无 `GMT` 后缀、无 0 宽进度条、
+   时间单元格不截断、筛选条不折行。**量宽度前先 `fc-list :lang=zh` 确认 headless 环境有中文字体**,
+   豆腐块量出的中文宽度偏小,据此定的列宽在真实浏览器里会裁字。
+7. **报告**:落 `project/rocketmq-studio-docs/test/report-<YYYYmmdd-HHMMSS>.md`(沿用该目录既有
+   命名),写明:环境(studio commit / 集群形态)、e2e 通过数、死信结论、broker 抽查、遗留问题。
+
+## 通过标准
+
+- e2e `not cloud` 全绿;
+- `dlq_check.sh` 打印 `PASS 死信链路…`;
+- `clusterList` 两 broker ACTIVATED,模式 C 挂具 producer 持续 `SEND_OK`;
+- 浏览器抽检无 ❌ 项(做了才要求)。
+
+## 已知坑
+
+- 部署开了登录(`STUDIO_AUTH_LOGIN_REQUIRED=true`)时 e2e 靠 `.env` 的
+  `STUDIO_USERNAME` / `STUDIO_PASSWORD` 登录拿 cookie;免登录部署两键留空即可。
+- 告警规则删除接口按 **id**(`DeleteAlertRuleDTO`);按 name 删返回 400 且旧测试不断言,
+  清理静默失败 → 下一轮 create 409「same evaluation conditions already exists」。
+- `/api/dashboard/overview`、`/api/dashboard/metrics` 不存在;真实路由是
+  `GET /api/dashboard?instanceId=` 与 `GET /api/metrics/grafana/dashboards`。
+- 死信链路必须用**直连 nameserver 的 push 消费** + `RECONSUME_LATER` + `setMaxReconsumeTimes`;
+  走 proxy 的 pop 消费不产生 `%RETRY%`/同一路径的 DLQ 表现不同。
+- 收尾必须删 group 再删 topic:`%DLQ%<group>` 遗留会污染下一轮 DLQ 列表与计数断言。
+
+## Resources
+
+- `scripts/check_env.sh` —— 环境体检(只读)
+- `scripts/clean_leftovers.py` —— 清 e2e / 回归遗留资源
+- `scripts/run_e2e.sh` —— 清遗留 + 跑 pytest 套件
+- `scripts/dlq_check.sh` —— 死信全链路(含清理)
+- `assets/FailingConsumer.java` —— 必失败 push 消费者,在目标机用集群镜像现场编译运行
diff --git a/.claude/skills/regression-rocketmq/assets/FailingConsumer.java b/.claude/skills/regression-rocketmq/assets/FailingConsumer.java
new file mode 100644
index 000000000..f270bd98d
--- /dev/null
+++ b/.claude/skills/regression-rocketmq/assets/FailingConsumer.java
@@ -0,0 +1,35 @@
+import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
+import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
+import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
+import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
+
+/**
+ * Always-failing push consumer used to drive messages into the dead-letter queue.
+ * Retries follow the concurrent-consume delay ladder (10s, 30s, ...), so with
+ * MAX_RECONSUME_TIMES=2 a message lands in %DLQ&lt;group&gt; roughly 40s after first delivery.
+ */
+public class FailingConsumer {
+    public static void main(String[] args) throws Exception {
+        String namesrv = System.getenv().getOrDefault("NAMESRV_ADDR", "rmq-nameserver:9876");
+        String topic = System.getenv().getOrDefault("TOPIC", "E2EDlq");
+        String group = System.getenv().getOrDefault("GROUP", "E2EDlqGid");
+        int maxReconsume = Integer.parseInt(System.getenv().getOrDefault("MAX_RECONSUME_TIMES", "2"));
+
+        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(group);
+        consumer.setNamesrvAddr(namesrv);
+        consumer.setMaxReconsumeTimes(maxReconsume);
+        // The messages are produced before this consumer exists; a fresh group defaults to
+        // CONSUME_FROM_LAST_OFFSET and would silently skip them.
+        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
+        consumer.subscribe(topic, "*");
+        consumer.registerMessageListener((MessageListenerConcurrently) (msgs, ctx) -> {
+            msgs.forEach(m -> System.out.println(
+                "fail-consume " + m.getMsgId() + " reconsumeTimes=" + m.getReconsumeTimes()));
+            return ConsumeConcurrentlyStatus.RECONSUME_LATER;
+        });
+        consumer.start();
+        System.out.println("failing consumer started, topic=" + topic + " group=" + group
+            + " maxReconsumeTimes=" + maxReconsume);
+        Thread.currentThread().join();
+    }
+}
diff --git a/.claude/skills/regression-rocketmq/scripts/_studio_client.py b/.claude/skills/regression-rocketmq/scripts/_studio_client.py
new file mode 100644
index 000000000..ee9caa9fe
--- /dev/null
+++ b/.claude/skills/regression-rocketmq/scripts/_studio_client.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+"""Login to Studio and exec the payload received on stdin with `session` / BASE / INSTANCE in scope."""
+
+import os
+import sys
+
+import requests
+from dotenv import load_dotenv
+
+E2E_DIR = os.getenv(
+    "E2E_DIR",
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..",
+                 "..", "..", "project", "rocketmq-studio-docs", "e2e-tests"),
+)
+load_dotenv(os.path.join(E2E_DIR, ".env"))
+
+BASE = os.getenv("STUDIO_URL", "http://47.98.43.243:6789").rstrip("/")
+INSTANCE = os.getenv("OPEN_SOURCE_INSTANCE_ID", "open-source-local")
+
+session = requests.Session()
+login = session.post(f"{BASE}/api/auth/login", json={
+    "username": os.getenv("STUDIO_USERNAME", ""),
+    "password": os.getenv("STUDIO_PASSWORD", ""),
+}, timeout=30).json()
+if login.get("code") != 200:
+    sys.exit(f"login failed: {login.get('message')}")
+
+exec(sys.stdin.read())
diff --git a/.claude/skills/regression-rocketmq/scripts/check_env.sh b/.claude/skills/regression-rocketmq/scripts/check_env.sh
new file mode 100755
index 000000000..9f3dd3588
--- /dev/null
+++ b/.claude/skills/regression-rocketmq/scripts/check_env.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# 回归环境体检:studio 可达且能登录、docker 开源集群、k8s 开源集群(有证书才查)、e2e 依赖。
+# 只读不改;缺失项只报告,修复动作见 SKILL.md。
+set -uo pipefail
+
+SSH_TARGET="${SSH_TARGET:-root@47.98.43.243}"
+SSH="ssh -o BatchMode=yes -o ConnectTimeout=8 $SSH_TARGET"
+STUDIO_URL="${STUDIO_URL:-http://47.98.43.243:6789}"
+K8S_NS="${K8S_NS:-rocketmq1}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+E2E_DIR="${E2E_DIR:-$SCRIPT_DIR/../../../../../../project/rocketmq-studio-docs/e2e-tests}"
+
+echo "== studio =="
+echo "web HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$STUDIO_URL/" 2>/dev/null || echo 000)"
+if [[ -n "${STUDIO_USERNAME:-}" && -n "${STUDIO_PASSWORD:-}" ]]; then
+  curl -s --max-time 10 -X POST "$STUDIO_URL/api/auth/login" -H 'Content-Type: application/json' \
+    -d "{\"username\":\"$STUDIO_USERNAME\",\"password\":\"$STUDIO_PASSWORD\"}" \
+    | head -c 120 | sed 's/^/login: /' ; echo
+else
+  echo "login: 未配 STUDIO_USERNAME/STUDIO_PASSWORD(从 e2e-tests/.env export 后再跑)"
+fi
+
+echo "== docker 开源集群(deploy-rocketmq 模式 B/C)=="
+$SSH 'docker ps --format "{{.Names}}\t{{.Status}}" | grep "^rmq-" || echo "(无 rmq-* 容器:按 deploy-rocketmq 模式 C 起集群)"' 2>/dev/null
+
+echo "== k8s 开源集群(本地有证书才查)=="
+if command -v kubectl >/dev/null 2>&1 && kubectl config current-context >/dev/null 2>&1; then
+  echo "context: $(kubectl config current-context)"
+  kubectl -n "$K8S_NS" get pods 2>/dev/null \
+    || echo "(namespace $K8S_NS 不存在:跑 studio-docs/deploy/rocketmq-k8s/deploy-rocketmq-k8s.sh 拉起)"
+else
+  echo "无 kubectl 或无可用 context:跳过 k8s 集群,本轮回归只覆盖 docker 集群"
+fi
+
+echo "== e2e 依赖 =="
+echo "e2e dir: $E2E_DIR"
+[[ -f "$E2E_DIR/.env" ]] && echo ".env: 存在" || echo ".env: 缺失(cp env.example .env 并填值)"
+python3 -c 'import pytest, requests, dotenv; print("python deps: ok")' 2>/dev/null \
+  || echo "python deps: 缺(pip install -r requirements.txt)"
diff --git a/.claude/skills/regression-rocketmq/scripts/clean_leftovers.py b/.claude/skills/regression-rocketmq/scripts/clean_leftovers.py
new file mode 100644
index 000000000..069b8f636
--- /dev/null
+++ b/.claude/skills/regression-rocketmq/scripts/clean_leftovers.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Delete resources left behind by earlier e2e / regression runs.
+
+A crashed run leaves alert rules (same evaluation conditions then make the next create
+return 409), topics and groups behind. Run this before every regression round.
+"""
+
+import os
+import sys
+from pathlib import Path
+
+import requests
+from dotenv import load_dotenv
+
+E2E_DIR = Path(os.getenv("E2E_DIR", Path(__file__).resolve().parents[5] / "rocketmq-studio-docs" / "e2e-tests"))
+load_dotenv(E2E_DIR / ".env")
+
+BASE = os.getenv("STUDIO_URL", "http://47.98.43.243:6789").rstrip("/")
+INSTANCE = os.getenv("OPEN_SOURCE_INSTANCE_ID", "open-source-local")
+PREFIXES = ("e2e_rule_", "e2e_del_", "e2e_channel_", "E2ETest", "E2EDlq")
+
+session = requests.Session()
+login = session.post(f"{BASE}/api/auth/login", json={
+    "username": os.getenv("STUDIO_USERNAME", ""),
+    "password": os.getenv("STUDIO_PASSWORD", ""),
+}, timeout=30).json()
+if login.get("code") != 200:
+    sys.exit(f"login failed: {login.get('message')}")
+
+
+def listing(path, params=None):
+    body = session.get(f"{BASE}{path}", params=params or {}, timeout=30).json()
+    return body.get("data") or []
+
+
+removed = []
+
+rules = listing("/api/alert-rules")
+for rule in rules if isinstance(rules, list) else []:
+    name = str(rule.get("name", ""))
+    if name.startswith(PREFIXES):
+        body = session.post(f"{BASE}/api/alert-rules/delete", json={"id": rule.get("id")}, timeout=30).json()
+        removed.append(f"alert-rule {name} -> {body.get('code')}")
+
+for path, key, payload_key in (("/api/topics", "name", "name"), ("/api/groups", "name", "name")):
+    items = listing(path, {"instanceId": INSTANCE})
+    records = items.get("records") if isinstance(items, dict) else items
+    for item in records or []:
+        name = str(item.get(key, ""))
+        if name.startswith(PREFIXES):
+            body = session.post(f"{BASE}{path}/delete", json={
+                "instanceId": INSTANCE, payload_key: name,
+            }, timeout=30).json()
+            removed.append(f"{path.strip('/')} {name} -> {body.get('code')}")
+
+print("\n".join(removed) if removed else "no leftovers")
diff --git a/.claude/skills/regression-rocketmq/scripts/dlq_check.sh b/.claude/skills/regression-rocketmq/scripts/dlq_check.sh
new file mode 100755
index 000000000..5854bcc98
--- /dev/null
+++ b/.claude/skills/regression-rocketmq/scripts/dlq_check.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# 死信全链路回归:建 topic/group → Studio 发 3 条 → 必失败消费者重试进 DLQ →
+# Studio DLQ 接口可见 → 重投 → 清理。命名带时间戳,trap 保证收尾清理。
+# 依赖:目标机已有 deploy-rocketmq 模式 B/C 集群(容器网络 rocketmq_net + 镜像 apache-rocketmq:develop)。
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL_DIR="$(dirname "$SCRIPT_DIR")"
+E2E_DIR="${E2E_DIR:-$SCRIPT_DIR/../../../../../../project/rocketmq-studio-docs/e2e-tests}"
+SSH_TARGET="${SSH_TARGET:-root@47.98.43.243}"
+SSH="ssh -o BatchMode=yes $SSH_TARGET"
+NAMESRV="${NAMESRV:-rmq-nameserver:9876}"
+RMQ_IMAGE="${RMQ_IMAGE:-apache-rocketmq:develop}"
+REMOTE_DIR="${REMOTE_DIR:-/opt/e2e-dlq}"
+
+cd "$E2E_DIR"
+[[ -f .env ]] || { echo "FAIL 缺 $E2E_DIR/.env"; exit 1; }
+set -a; source .env; set +a
+export E2E_DIR
+
+TS="$(date +%s)"
+export TOPIC="E2EDlq_$TS" GROUP="E2EDlqGid_$TS" CONTAINER="rmq-e2e-failing-$TS"
+
+api() {  # api:stdin 里的 python 片段可直接用 session / BASE / INSTANCE(已登录)
+  python3 "$SCRIPT_DIR/_studio_client.py"
+}
+
+cleanup() {
+  echo "-- 清理 --"
+  $SSH "docker rm -f $CONTAINER >/dev/null 2>&1" || true
+  api <<'PY' || true
+import os
+for path, key in (("/api/groups/delete", "name"), ("/api/topics/delete", "name")):
+    name = os.environ["GROUP"] if "groups" in path else os.environ["TOPIC"]
+    body = session.post(f"{BASE}{path}", json={"instanceId": INSTANCE, key: name}, timeout=30).json()
+    print(f"  delete {name} -> {body.get('code')}")
+PY
+}
+trap cleanup EXIT
+
+echo "== 1. 建 topic/group 并发 3 条 =="
+api <<'PY'
+import os
+topic, group = os.environ["TOPIC"], os.environ["GROUP"]
+body = session.post(f"{BASE}/api/topics/create", json={
+    "instanceId": INSTANCE, "name": topic, "type": "NORMAL", "remark": "dlq regression"}, timeout=30).json()
+assert body.get("code") == 200, f"create topic: {body}"
+body = session.post(f"{BASE}/api/groups/create", json={
+    "instanceId": INSTANCE, "name": group, "consumeType": "CLUSTERING",
+    "subscriptionMode": "Push"}, timeout=30).json()
+assert body.get("code") == 200, f"create group: {body}"
+ids = []
+for i in range(3):
+    sent = session.post(f"{BASE}/api/topics/send", json={
+        "instanceId": INSTANCE, "topic": topic, "tag": "dlq", "key": f"dlq-key-{i}",
+        "body": f"dlq regression {i}"}, timeout=30).json()
+    assert sent.get("code") == 200, f"send {i}: {sent}"
+    ids.append(sent["data"]["msgId"])
+print(f"  topic={topic} group={group} msgIds={ids}")
+PY
+
+echo "== 2. 起必失败消费者(maxReconsumeTimes=2,约 40s 进 DLQ)=="
+$SSH "mkdir -p $REMOTE_DIR"
+scp -q "$SKILL_DIR/assets/FailingConsumer.java" "$SSH_TARGET:$REMOTE_DIR/"
+$SSH "docker run -d --name $CONTAINER --network rocketmq_net -v $REMOTE_DIR:/clients:ro \
+  -e NAMESRV_ADDR=$NAMESRV -e TOPIC=$TOPIC -e GROUP=$GROUP -e MAX_RECONSUME_TIMES=2 \
+  $RMQ_IMAGE sh -c 'javac -encoding UTF-8 -cp \"lib/*\" -d /tmp/fc /clients/FailingConsumer.java && java -cp \"lib/*:/tmp/fc\" FailingConsumer'" >/dev/null
+sleep 5
+$SSH "docker logs $CONTAINER 2>&1 | tail -2"
+
+echo "== 3. 等 DLQ 出现 3 条(最多 240s)=="
+api <<'PY'
+import os, time
+group = os.environ["GROUP"]
+deadline = time.time() + 240
+count = 0
+while time.time() < deadline:
+    page = session.get(f"{BASE}/api/dlq", params={
+        "instanceId": INSTANCE, "search": group, "page": 1, "pageSize": 20}, timeout=30).json()
+    items = (page.get("data") or {}).get("items") or []
+    hit = next((x for x in items if x.get("groupName") == group), None)
+    if hit and hit.get("messageCount", 0) >= 3:
+        count = hit["messageCount"]
+        print(f"  DLQ group={group} messageCount={count} retryCount={hit.get('retryCount')} status={hit.get('status')}")
+        break
+    time.sleep(10)
+assert count >= 3, f"DLQ 未在 240s 内攒到 3 条(最后 count={count})"
+PY
+
+echo "== 4. 重投并确认消费者再次收到 =="
+before=$($SSH "docker logs $CONTAINER 2>&1 | grep -c fail-consume" || true)
+api <<'PY'
+import os, time
+now = int(time.time() * 1000)
+body = session.post(f"{BASE}/api/dlq/resend", json={
+    "instanceId": INSTANCE, "groupName": os.environ["GROUP"],
+    "startTime": now - 3600_000, "endTime": now + 3600_000}, timeout=60).json()
+assert body.get("code") == 200, f"resend: {body}"
+print(f"  resend -> {body['data']}")
+PY
+sleep 15
+after=$($SSH "docker logs $CONTAINER 2>&1 | grep -c fail-consume" || true)
+echo "  fail-consume 次数 $before -> $after"
+[[ "$after" -gt "$before" ]] || { echo "FAIL 重投后消费者没有再次收到"; exit 1; }
+
+echo "PASS 死信链路:发送 → 重试 → DLQ → 重投 全通"
diff --git a/.claude/skills/regression-rocketmq/scripts/run_e2e.sh b/.claude/skills/regression-rocketmq/scripts/run_e2e.sh
new file mode 100755
index 000000000..ccc448428
--- /dev/null
+++ b/.claude/skills/regression-rocketmq/scripts/run_e2e.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# 跑 studio-docs 的 e2e 套件(默认排除 cloud 用例)。先清遗留资源,避免上一轮的
+# 告警规则/ topic / group 让本轮创建类用例 409。用法:run_e2e.sh [pytest 参数...]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+E2E_DIR="${E2E_DIR:-$SCRIPT_DIR/../../../../../../project/rocketmq-studio-docs/e2e-tests}"
+
+cd "$E2E_DIR"
+[[ -f .env ]] || { echo "FAIL 缺 $E2E_DIR/.env:cp env.example .env 并填值后重跑"; exit 1; }
+set -a; source .env; set +a
+export E2E_DIR
+
+python3 "$SCRIPT_DIR/clean_leftovers.py"
+
+if [[ $# -gt 0 ]]; then
+  python3 -m pytest "$@" -q 2>&1 | tail -25
+else
+  python3 -m pytest -m "not cloud" -q 2>&1 | tail -25
+fi
diff --git a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClusterProvider.java b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClusterProvider.java
index 77f57d395..c21495703 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClusterProvider.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClusterProvider.java
@@ -320,7 +320,9 @@ public class RocketMQClusterProvider implements ClusterProvider {
                     if (Double.isFinite(parsedRatio) && parsedRatio >= 0) {
                         // RocketMQ exposes commitLogDiskRatio as a fraction in [0, 1],
                         // while BrokerVO and the web progress bars use percentage points.
-                        builder.diskUsage(parsedRatio * 100D);
+                        // Scaled in whole tenths: 0.29 * 100 is 28.999999999999996 in binary
+                        // floating point, and the web renders that number verbatim.
+                        builder.diskUsage(Math.round(parsedRatio * 1000D) / 10D);
                     }
                 } catch (NumberFormatException ignored) {
                     // keep default
diff --git a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClusterProviderTest.java b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClusterProviderTest.java
index 2d8b62859..54bed71ab 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClusterProviderTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClusterProviderTest.java
@@ -111,6 +111,24 @@ class RocketMQClusterProviderTest {
                 .isEqualTo(62.5D);
     }
 
+    @Test
+    void discoverClustersRoundsDiskRatioToTenthsTest() throws Exception {
+        DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+        RocketMQClusterProvider provider = newProvider(adminExt);
+        when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo());
+        KVTable runtime = runtimeStats();
+        runtime.getTable().put("commitLogDiskRatio", "0.29");
+        when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911")).thenReturn(runtime);
+
+        ClusterVO cluster = provider.discoverClusters().get(0);
+
+        // 0.29 * 100 is 28.999999999999996 in binary floating point. The web renders that number
+        // verbatim, and a label that wide squeezes the Progress bar inside it down to zero width.
+        assertThat(cluster.getBrokers()).singleElement()
+                .extracting(broker -> broker.getDiskUsage())
+                .isEqualTo(29.0D);
+    }
+
     @Test
     void discoverClustersShouldParseDailyMessageCounters() throws Exception {
         DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 5708e5089..42fcbc1e8 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -903,6 +903,8 @@ const translations: Record<string, Record<Lang, string>> = {
   'clients.diagnosticSeverity': { zh: '级别', en: 'Severity' },
   'clients.diagnosticIssue': { zh: '风险项', en: 'Risk' },
   'clients.diagnosticEvidence': { zh: '依据', en: 'Evidence' },
+  'clients.issueDetailTitle': { zh: '风险项详情', en: 'Risk Detail' },
+  'clients.issueDescription': { zh: '说明', en: 'Description' },
   'clients.healthStatusHealthy': { zh: '健康', en: 'Healthy' },
   'clients.healthStatusWarning': { zh: '关注', en: 'Warning' },
   'clients.healthStatusCritical': { zh: '高风险', en: 'Critical' },
@@ -1424,8 +1426,7 @@ const translations: Record<string, Record<Lang, string>> = {
   'sysAlerts.instanceFilter': { zh: '实例 ID 筛选', en: 'Filter by instance ID' },
   'sysAlerts.labelsFilter': { zh: '资源标签筛选', en: 'Filter by resource labels' },
   'sysAlerts.labelsPlaceholder': { zh: '标签 key=value', en: 'Labels key=value' },
-  'sysAlerts.startTimeFilter': { zh: '开始时间筛选', en: 'Filter by start time' },
-  'sysAlerts.endTimeFilter': { zh: '结束时间筛选', en: 'Filter by end time' },
+  'sysAlerts.timeRangeFilter': { zh: '时间范围筛选', en: 'Filter by time range' },
   'sysAlerts.allStatuses': { zh: '全部状态', en: 'All statuses' },
   'sysAlerts.suppressionFilter': { zh: '通知抑制筛选', en: 'Filter by notification suppression' },
   'sysAlerts.allDeliveryStatuses': { zh: '全部投递状态', en: 'All delivery statuses' },
diff --git a/web/src/pages/ai/components/ConversationListModal.tsx b/web/src/pages/ai/components/ConversationListModal.tsx
index 70d25eef9..07db73e0a 100644
--- a/web/src/pages/ai/components/ConversationListModal.tsx
+++ b/web/src/pages/ai/components/ConversationListModal.tsx
@@ -363,9 +363,7 @@ const ConversationListPanel = ({
         dataIndex: 'updatedAt',
         key: 'updatedAt',
         width: COLUMN_WIDTHS.updatedAt,
-        // No zone name: it is the viewer's own and identical on every row, so it would only make
-        // this the widest column in the table to say something the reader already knows.
-        render: (updatedAt: string) => formatUtcDateTime(updatedAt, undefined, { zone: false }),
+        render: (updatedAt: string) => formatUtcDateTime(updatedAt),
       },
       {
         title: t('ai.list.columnAction'),
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index 86fd3adf7..751d2813c 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -284,6 +284,26 @@ describe('Clients page', () => {
     ).toBeInTheDocument();
   });
 
+  it('shows a risk description only in the modal opened from the risk title', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    vi.mocked(connectionsService.listConnections).mockResolvedValue([
+      { ...connection, clientId: 'shared-client', address: '10.0.1.12:49152' },
+      { ...connection, clientId: 'shared-client', address: '10.0.1.13:49152' },
+    ]);
+    renderWithProviders(<ClientsPage />);
+
+    const diagnostics = await screen.findByTestId('client-connection-diagnostics');
+    const description =
+      '同一个 Client ID 同时出现在多个远端地址,可能是实例 ID 配置冲突或旧连接未及时清理。';
+    expect(within(diagnostics).queryByText(description)).not.toBeInTheDocument();
+
+    await user.click(within(diagnostics).getByText('Client ID 连接到多个地址'));
+
+    const dialog = await screen.findByRole('dialog', { name: '风险项详情' });
+    expect(within(dialog).getByText(description)).toBeInTheDocument();
+    expect(within(dialog).getByText('10.0.1.12:49152')).toBeInTheDocument();
+  });
+
   it('updates statistics when the selected cluster filter changes', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(connectionsService.listConnections).mockImplementation((query) =>
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 3d57348bb..56fc3c2fe 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -671,6 +671,22 @@ describe('Cluster page', () => {
     expect(screen.getByPlaceholderText('搜索 Proxy 地址')).toHaveValue('not-found');
   });
 
+  it('renders a fractional broker disk usage as a short progress label', async () => {
+    // A broker reporting commitLogDiskRatio=0.29 becomes 28.999999999999996 once scaled to
+    // percentage points. Progress reserves room for its label inside the bar, so the unrounded
+    // float printed all 17 digits and left the bar itself zero pixels wide.
+    const cluster = buildCluster();
+    cluster.brokers = [{ ...cluster.brokers[0], diskUsage: 28.999999999999996 }];
+    clusterServiceMocks.listRegistryClusters.mockResolvedValue([cluster]);
+
+    renderWithProviders(<ClusterPage />);
+
+    const row = (await screen.findByText('rocketmq-prod-0')).closest('tr');
+    if (!row) throw new Error('broker row not found');
+    expect(within(row).getByText('29%')).toBeInTheDocument();
+    expect(row.querySelector('.ant-progress-bg')).not.toBeNull();
+  });
+
   it('creates and deletes nameserver registry entries', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClusterPage />);
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index 1b633d985..412245b2f 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -181,11 +181,11 @@ const K8sCertsPage = () => {
       title: '到期时间',
       dataIndex: 'notAfter',
       key: 'notAfter',
-      width: 170,
+      width: 156,
       sorter: (a, b) => (Date.parse(a.notAfter ?? '') || 0) - (Date.parse(b.notAfter ?? '') || 0),
       render: (iso: string | null) => (
         <Text type="secondary" style={{ fontSize: 14 }}>
-          {formatUtcDateTime(iso, undefined, { zone: false })}
+          {formatUtcDateTime(iso)}
         </Text>
       ),
     },
diff --git a/web/src/pages/cluster/clients.tsx b/web/src/pages/cluster/clients.tsx
index 780aac7b7..6de57802a 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -68,7 +68,7 @@ const typeConfig: Record<string, { color: string; label: string }> = {
 /**
  * One clipped line inside a diagnostics-table cell. The project-wide
  * `.ant-table-cell { white-space: nowrap }` keeps long values (trace-producer resource names,
- * client ids, risk descriptions) from wrapping, so without an explicit ellipsis they spill over
+ * client ids) from wrapping, so without an explicit ellipsis they spill over
  * the neighbouring column; each line clips itself and carries its full text in a `title`.
  */
 const diagnosticCellLine: CSSProperties = {
@@ -171,6 +171,7 @@ const ClientsPage = () => {
   const [clusterFilter, setClusterFilter] = useState<string>('ALL');
   const [typeFilter, setTypeFilter] = useState<string>('ALL');
   const [selectedConnection, setSelectedConnection] = useState<ClientConnection | null>(null);
+  const [selectedIssue, setSelectedIssue] = useState<ClientConnectionIssue | null>(null);
   const [loadError, setLoadError] = useState<string | null>(null);
   const [registryLoadKey, setRegistryLoadKey] = useState(0);
   const [connectionLoadKey, setConnectionLoadKey] = useState(0);
@@ -648,17 +649,19 @@ const ClientsPage = () => {
       title: t('clients.diagnosticIssue'),
       key: 'issue',
       width: 260,
-      // The description is a full sentence; clipped to one line with the full text on hover,
-      // never wrapped (project table rule) and never spilling into the Client ID column.
+      // The title is the row's identity and the only click target: it opens the detail modal that
+      // carries the description. The cell itself stays one clipped line so the row height matches
+      // the other diagnostics tables.
       ellipsis: { showTitle: false },
       render: (_: unknown, record) => (
         <div style={{ minWidth: 0 }}>
-          <Text strong style={diagnosticCellLine} title={record.title}>
+          <Typography.Link
+            style={diagnosticCellLine}
+            title={record.title}
+            onClick={() => setSelectedIssue(record)}
+          >
             {record.title}
-          </Text>
-          <Text type="secondary" style={diagnosticCellLine} title={record.description}>
-            {record.description}
-          </Text>
+          </Typography.Link>
         </div>
       ),
     },
@@ -1038,6 +1041,59 @@ const ClientsPage = () => {
           </Descriptions>
         )}
       </Modal>
+
+      <Modal
+        title={t('clients.issueDetailTitle')}
+        open={Boolean(selectedIssue)}
+        onCancel={() => setSelectedIssue(null)}
+        footer={<Button onClick={() => setSelectedIssue(null)}>{t('common.close')}</Button>}
+        width={640}
+        destroyOnHidden
+      >
+        {selectedIssue && (
+          <Descriptions column={1} bordered size="small">
+            <Descriptions.Item label={t('clients.diagnosticSeverity')}>
+              <Tag color={issueSeverityColor[selectedIssue.severity]}>
+                {t(issueSeverityTextKey[selectedIssue.severity])}
+              </Tag>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('clients.diagnosticIssue')}>
+              <Text strong>{selectedIssue.title}</Text>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('clients.issueDescription')}>
+              <Text>{selectedIssue.description}</Text>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('clients.diagnosticRecommendations')}>
+              <Text>{selectedIssue.recommendation}</Text>
+            </Descriptions.Item>
+            {selectedIssue.clientId ? (
+              <Descriptions.Item label={t('clients.clientId')}>
+                <Text copyable style={{ fontFamily: 'monospace' }}>
+                  {selectedIssue.clientId}
+                </Text>
+              </Descriptions.Item>
+            ) : null}
+            {selectedIssue.resource ? (
+              <Descriptions.Item label={t('clients.groupOrTopic')}>
+                <Text>{selectedIssue.resource}</Text>
+              </Descriptions.Item>
+            ) : null}
+            <Descriptions.Item label={t('clients.diagnosticEvidence')}>
+              <Space size={4} wrap>
+                {selectedIssue.evidence.length === 0 ? (
+                  <Text type="secondary">-</Text>
+                ) : (
+                  selectedIssue.evidence.map((item) => (
+                    <Text key={item} code>
+                      {item}
+                    </Text>
+                  ))
+                )}
+              </Space>
+            </Descriptions.Item>
+          </Descriptions>
+        )}
+      </Modal>
     </div>
   );
 };
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 2cbdae3c2..828d22f20 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -88,6 +88,7 @@ import {
 import { listInstances } from '../../services/instanceService';
 import { supportsApacheRuntime } from '../../api/instance';
 import { isMockMode } from '../../services/dataMode';
+import { roundPercent } from '../../utils/format';
 import { tableScrollX } from '../../utils/table';
 
 const { Text } = Typography;
@@ -1293,7 +1294,7 @@ const ClusterPage = () => {
         sorter: (a, b) => a.diskUsage - b.diskUsage,
         render: (v: number) => (
           <Progress
-            percent={v}
+            percent={roundPercent(v)}
             size="small"
             strokeColor={v > 85 ? '#ff4d4f' : v > 70 ? '#faad14' : '#1677ff'}
           />
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 946436ed5..9ee81317b 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -1334,7 +1334,13 @@ describe('Consumer page', () => {
       await user.click(await screen.findByRole('button', { name: /详情/ }));
       await user.click(await screen.findByRole('button', { name: /线程栈/ }));
 
-      expect(await screen.findByText('2026-07-23 08:00:00 GMT+8')).toBeInTheDocument();
+      // Scoped to the stack modal: the 最后心跳 column renders the same wall time, and only the
+      // capture time goes through the UTC-aware formatter.
+      const stackModal = (await screen.findByText('消费者线程栈')).closest<HTMLElement>(
+        '[role="dialog"]',
+      );
+      if (!stackModal) throw new Error('consumer stack modal not found');
+      expect(within(stackModal).getByText('2026-07-23 08:00:00')).toBeInTheDocument();
     } finally {
       vi.unstubAllEnvs();
     }
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 8565149ea..202690b84 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -272,7 +272,7 @@ describe('DLQ page', () => {
 
       const row = (await screen.findByText('cg-order')).closest('tr');
       if (!row) throw new Error('DLQ group row not found');
-      expect(within(row).getByText('2026-07-24 18:00:00 GMT+8')).toBeInTheDocument();
+      expect(within(row).getByText('2026-07-24 18:00:00')).toBeInTheDocument();
     } finally {
       vi.unstubAllEnvs();
     }
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 884c51695..531531b57 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -541,7 +541,7 @@ const DLQPage = () => {
       title: '最近入队时间',
       dataIndex: 'lastEnqueueTime',
       key: 'lastEnqueueTime',
-      width: 180,
+      width: 156,
       sorter: (a, b) => (a.lastEnqueueTime || '').localeCompare(b.lastEnqueueTime || ''),
       render: (time?: string | null) => (
         <Text type="secondary" style={{ fontSize: 14 }}>
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 44929e8b4..3d5397d00 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -349,12 +349,14 @@ describe('SystemAlertsPage', () => {
 
     await user.type(screen.getByLabelText('实例 ID 筛选'), 'local');
     await user.type(screen.getByLabelText('资源标签筛选'), 'brokerName=broker-a');
-    fireEvent.change(screen.getByLabelText('开始时间筛选'), {
-      target: { value: '2026-08-01T00:00' },
-    });
-    fireEvent.change(screen.getByLabelText('结束时间筛选'), {
-      target: { value: '2026-08-02T00:00' },
-    });
+    await user.click(screen.getByPlaceholderText('开始时间'));
+    await user.keyboard('2026-08-01 00:00');
+    await user.tab();
+    await user.keyboard('2026-08-02 00:00');
+    // showTime 的时间面板要点确定才提交;「清除已确认」按钮的名字里也带「确」,只能按类名取
+    const okButton = document.querySelector('.ant-picker-ok button');
+    if (!(okButton instanceof HTMLElement)) throw new Error('range picker ok button not found');
+    await user.click(okButton);
 
     await waitFor(() => {
       expect(listSystemAlertsPage).toHaveBeenLastCalledWith(
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 373e88a46..4a6853c61 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -704,7 +704,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }: AlertsPageProps) => {
     },
     {
       title: t('alerts.lastTriggered'),
-      width: 150,
+      width: 156,
       sorter: (a, b) => (a.lastTriggered ?? '').localeCompare(b.lastTriggered ?? ''),
       render: (_, record) =>
         record.lastTriggered ? (
diff --git a/web/src/pages/ops/auditRiskInsightModel.ts b/web/src/pages/ops/auditRiskInsightModel.ts
index d59c53c39..faf800acb 100644
--- a/web/src/pages/ops/auditRiskInsightModel.ts
+++ b/web/src/pages/ops/auditRiskInsightModel.ts
@@ -17,6 +17,7 @@
 
 import type { AuditSummary } from '../../api/audit';
 import type { AuditRecord } from '../../api/ops';
+import { roundPercent } from '../../utils/format';
 import {
   getAuditOperationPresentation,
   isControlPlaneAuditRecord,
@@ -131,8 +132,6 @@ const highRiskOperationCodes = new Set([
   'DELETE_K8S_CERTIFICATE',
 ]);
 
-const roundPercent = (value: number): number => Math.round(value * 10) / 10;
-
 const percent = (count: number, total: number): number => {
   if (total <= 0) return 0;
   return roundPercent((count / total) * 100);
diff --git a/web/src/pages/ops/systemAlerts.tsx b/web/src/pages/ops/systemAlerts.tsx
index 2347178d3..5d826741c 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -18,6 +18,7 @@
 import { useEffect, useRef, useState } from 'react';
 import {
   Card,
+  DatePicker,
   Tag,
   Flex,
   Typography,
@@ -32,7 +33,9 @@ import {
   Input,
   Popconfirm,
   Segmented,
+  theme,
 } from 'antd';
+import dayjs from 'dayjs';
 import { CheckCircle, DownloadSimple, Trash } from '@phosphor-icons/react';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
@@ -62,6 +65,7 @@ import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 import { zonedLocalDateTimeToUtc } from '../../utils/timeZone';
 
 const { Text } = Typography;
+const { RangePicker } = DatePicker;
 
 const ALERT_EXPORT_PAGE_SIZE = 100;
 const ALERT_EXPORT_MAX_PAGES = 10_000;
@@ -138,6 +142,7 @@ const SystemAlertsPage = () => {
   const userId = useAuthStore((state) => state.userId);
   const admin = useAuthStore((state) => state.admin);
   const canManageSilences = !userId || admin === true;
+  const { token } = theme.useToken();
 
   const alertLevelConfig: Record<string, { color: string; bg: string; label: string }> = {
     error: { color: '#ff4d4f', bg: '#fff2f0', label: t('sysAlerts.severe') },
@@ -481,7 +486,26 @@ const SystemAlertsPage = () => {
         title={t('sysAlerts.title')}
         subtitle={t('sysAlerts.subtitle', { n: unackCount })}
         extra={
-          <Flex gap={8}>
+          <Flex gap={8} align="center">
+            {collectorStatus && (
+              // 与筛选控件同高同边框的状态片:Tag 的 success 配色只有 22px 高、绿底绿字,
+              // 夹在一排 32px 白底控件里既矮又跳。放在页头而不是筛选条,是因为 1600 视口下
+              // 筛选条放下 8 个控件后只剩 40px,容不下它。
+              <Flex
+                align="center"
+                gap={6}
+                style={{
+                  height: 32,
+                  padding: '0 12px',
+                  border: `1px solid ${token.colorBorder}`,
+                  borderRadius: token.borderRadius,
+                  background: token.colorBgContainer,
+                }}
+              >
+                <CheckCircle size={14} color="#52c41a" weight="fill" />
+                <Text style={{ fontSize: 14 }}>{t('sysAlerts.nativeCollectionEnabled')}</Text>
+              </Flex>
+            )}
             <Button
               icon={<DownloadSimple size={14} />}
               onClick={() => void exportAlerts()}
@@ -510,9 +534,9 @@ const SystemAlertsPage = () => {
         }
       />
 
-      {/* 筛选条:全部控件统一默认尺寸(32px 高),宽度按内容分三档,不再混用 size="small"
-          —— 原来 4 个级别按钮 + 3 个 Select + 4 个 Input 高矮不一(datetime-local 在 small
-          下压不到 24px、实测 26px),宽度也是 132/150/190/124 各写各的。 */}
+      {/* 筛选条:全部控件统一默认尺寸(32px 高)。时间范围用单个 RangePicker 而不是两个
+          datetime-local:368px 是填了值之后两段 `YYYY-MM-DD HH:mm` 加箭头和日历图标的实测宽,
+          再窄会裁字。整条约 1292px,在 1600 视口(容器 1332px)下保持一行。 */}
       <Flex gap={8} wrap align="center" style={{ marginBottom: 16 }}>
         <Segmented
           value={levelFilter}
@@ -541,7 +565,7 @@ const SystemAlertsPage = () => {
         />
         <Select
           value={domainFilter}
-          style={{ minWidth: 140 }}
+          style={{ minWidth: 120 }}
           onChange={(value) => {
             setDomainFilter(value);
             setPage(1);
@@ -555,7 +579,7 @@ const SystemAlertsPage = () => {
         <Input
           aria-label={t('sysAlerts.instanceFilter')}
           placeholder={t('sysAlerts.instanceId')}
-          style={{ width: 180, flex: 'none' }}
+          style={{ width: 140, flex: 'none' }}
           value={instanceFilter}
           onChange={(event) => {
             setInstanceFilter(event.target.value);
@@ -565,36 +589,29 @@ const SystemAlertsPage = () => {
         <Input
           aria-label={t('sysAlerts.labelsFilter')}
           placeholder={t('sysAlerts.labelsPlaceholder')}
-          style={{ width: 180, flex: 'none' }}
+          style={{ width: 140, flex: 'none' }}
           value={labelFilter}
           onChange={(event) => {
             setLabelFilter(event.target.value);
             setPage(1);
           }}
         />
-        <Input
-          aria-label={t('sysAlerts.startTimeFilter')}
-          type="datetime-local"
-          style={{ width: 210, height: 32, flex: 'none' }}
-          value={fromFilter}
-          onChange={(event) => {
-            setFromFilter(event.target.value);
-            setPage(1);
-          }}
-        />
-        <Input
-          aria-label={t('sysAlerts.endTimeFilter')}
-          type="datetime-local"
-          style={{ width: 210, height: 32, flex: 'none' }}
-          value={toFilter}
-          onChange={(event) => {
-            setToFilter(event.target.value);
+        <RangePicker
+          aria-label={t('sysAlerts.timeRangeFilter')}
+          placeholder={[t('sysAlerts.startTime'), t('sysAlerts.endTime')]}
+          showTime={{ format: 'HH:mm' }}
+          format="YYYY-MM-DD HH:mm"
+          style={{ width: 368, flex: 'none' }}
+          value={[fromFilter ? dayjs(fromFilter) : null, toFilter ? dayjs(toFilter) : null]}
+          onChange={(values) => {
+            setFromFilter(values?.[0] ? values[0].format('YYYY-MM-DDTHH:mm') : '');
+            setToFilter(values?.[1] ? values[1].format('YYYY-MM-DDTHH:mm') : '');
             setPage(1);
           }}
         />
         <Select
           value={transitionFilter}
-          style={{ minWidth: 140 }}
+          style={{ minWidth: 120 }}
           onChange={(value) => {
             setTransitionFilter(value);
             setPage(1);
@@ -608,7 +625,7 @@ const SystemAlertsPage = () => {
         <Select
           aria-label={t('sysAlerts.suppressionFilter')}
           value={suppressionFilter}
-          style={{ minWidth: 160 }}
+          style={{ minWidth: 140 }}
           onChange={(value) => {
             setSuppressionFilter(value);
             setPage(1);
@@ -619,11 +636,6 @@ const SystemAlertsPage = () => {
             { value: 'delivered', label: t('sysAlerts.notificationNotSuppressed') },
           ]}
         />
-        {collectorStatus && (
-          <Tag color="success" style={{ marginInlineEnd: 0, fontSize: 14 }}>
-            {t('sysAlerts.nativeCollectionEnabled')}
-          </Tag>
-        )}
       </Flex>
 
       <Flex vertical gap={12}>
diff --git a/web/src/pages/studio/BrokerCluster.tsx b/web/src/pages/studio/BrokerCluster.tsx
index dedeeae46..333517963 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -32,6 +32,7 @@ import { supportsApacheRuntime, type Instance } from '../../api/instance';
 import { listInstances } from '../../services/instanceService';
 import { useVisiblePolling } from '../../hooks/useVisiblePolling';
 import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { roundPercent } from '../../utils/format';
 
 // ─── Types ──────────────────────────────────────────────────────
 type NodeStatus = 'running' | 'readonly' | 'maintenance' | 'unknown';
@@ -285,16 +286,20 @@ const BrokerClusterPage = () => {
       status = 'active';
       color = '#fa8c16';
     }
+    const rounded = roundPercent(percent);
     return (
       <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+        {/* The label belongs to the span below. Progress renders its own text inside the 80px bar,
+            which duplicated the value and left the bar itself no width to draw. */}
         <Progress
-          percent={percent}
+          percent={rounded}
           size="small"
           status={status}
+          showInfo={false}
           style={{ width: 80, margin: 0 }}
           strokeColor={color}
         />
-        <span style={{ fontSize: 14, color, fontWeight: 500 }}>{percent}%</span>
+        <span style={{ fontSize: 14, color, fontWeight: 500 }}>{rounded}%</span>
       </div>
     );
   };
diff --git a/web/src/pages/studio/UserManagement.tsx b/web/src/pages/studio/UserManagement.tsx
index 7eb520579..8466c1b4d 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -463,21 +463,24 @@ const UserManagementPage = () => {
     {
       title: t('userMgmt.lastActive'),
       dataIndex: 'lastSessionSeenAt',
-      width: 132,
+      // 182 = the `YYYY-MM-DD HH:mm:ss` label at 14px (149px as measured in Chromium with this
+      // app's font stack) plus the 16px of cell padding a default-size table puts on each side.
+      // The small-table equivalent used elsewhere is 156.
+      width: 182,
       ellipsis: true,
       render: dateTime,
     },
     {
       title: t('userMgmt.nearestExpiry'),
       dataIndex: 'nearestSessionExpiresAt',
-      width: 132,
+      width: 182,
       ellipsis: true,
       render: dateTime,
     },
     {
       title: t('userMgmt.createdAt'),
       dataIndex: 'gmtCreate',
-      width: 132,
+      width: 182,
       ellipsis: true,
       render: dateTime,
     },
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index c9aa13fdc..70676dbce 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -16,7 +16,7 @@
  */
 
 import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
@@ -224,6 +224,23 @@ describe('BrokerCluster Page', () => {
     expect(readonlyTags.length).toBeGreaterThan(0);
   });
 
+  it('renders a fractional disk usage as one short label and keeps the bar', async () => {
+    // The broker publishes commitLogDiskRatio as a fraction, and 0.29 * 100 is
+    // 28.999999999999996. Progress draws its own label inside the 80px bar, so the unrounded value
+    // printed the float twice and squeezed the bar down to zero width.
+    const [cluster] = clusterFixture;
+    vi.mocked(listClusters).mockResolvedValue([
+      { ...cluster, brokers: [{ ...cluster.brokers[0], diskUsage: 28.999999999999996 }] },
+    ]);
+    renderWithProviders(<BrokerCluster />);
+
+    const row = (await screen.findAllByText('broker-api-a'))[0].closest('tr');
+    if (!row) throw new Error('broker row not found');
+    expect(within(row).getAllByText('29%')).toHaveLength(1);
+    expect(row.querySelector('.ant-progress-text')).toBeNull();
+    expect(row.querySelector('.ant-progress-bg')).not.toBeNull();
+  });
+
   it('should switch to NameServer tab on click', async () => {
     const user = userEvent.setup();
     renderWithProviders(<BrokerCluster />);
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index cb75cd0c4..d44aee1ef 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -305,12 +305,12 @@ describe('UserManagementPage', () => {
 
       await screen.findByText('operator');
       // 09:30 UTC is 17:30 in Asia/Shanghai; a browser-local misparse shows 09:30.
-      expect(screen.getByText('2026-08-22 17:30:00 GMT+8')).toBeInTheDocument();
+      expect(screen.getByText('2026-08-22 17:30:00')).toBeInTheDocument();
 
       await user.click(screen.getByRole('button', { name: '会话' }));
       const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
-      expect(within(drawer).getByText('2026-08-22 17:45:00 GMT+8')).toBeInTheDocument();
-      expect(within(drawer).getByText('2026-08-22 18:00:00 GMT+8')).toBeInTheDocument();
+      expect(within(drawer).getByText('2026-08-22 17:45:00')).toBeInTheDocument();
+      expect(within(drawer).getByText('2026-08-22 18:00:00')).toBeInTheDocument();
     } finally {
       vi.unstubAllEnvs();
     }
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index 2333a0217..fb6e89fb1 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -11,6 +11,7 @@ import {
   formatRelativeTime,
   formatTimeOfDay,
   formatTimeMs,
+  roundPercent,
 } from './format';
 
 describe('formatBytes', () => {
@@ -65,6 +66,12 @@ describe('formatBytes', () => {
     expect(formatPercent(12.345, -1)).toBe('12%');
   });
 
+  it('rounds percentages to the tenth the progress bars display', () => {
+    expect(roundPercent(28.999999999999996)).toBe(29);
+    expect(roundPercent(62.5)).toBe(62.5);
+    expect(roundPercent(0)).toBe(0);
+  });
+
   it('uses a placeholder for invalid dates and numeric values', () => {
     expect(formatDate('not-a-date')).toBe('-');
     expect(formatDateTime(new Date(Number.NaN))).toBe('-');
@@ -79,9 +86,9 @@ describe('formatBytes', () => {
 
   it('treats timezone-less alert timestamps as UTC', () => {
     expect(formatUtcDateTime('2026-08-23T10:35:38.590731', 'America/Los_Angeles')).toBe(
-      '2026-08-23 03:35:38 PDT',
+      '2026-08-23 03:35:38',
     );
-    expect(formatUtcDateTime('2026-08-23T10:35:38Z', 'UTC')).toBe('2026-08-23 10:35:38 UTC');
+    expect(formatUtcDateTime('2026-08-23T10:35:38Z', 'UTC')).toBe('2026-08-23 10:35:38');
   });
 
   it('formats recent timestamps for compact conversation history', () => {
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index e7d510ced..750dbd11f 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -49,24 +49,18 @@ export function formatTimeMs(value: number | string | null | undefined): string
   return `${formatDateTime(date)}.${pad(date.getMilliseconds(), 3)}`;
 }
 
-export interface FormatUtcDateTimeOptions {
-  /**
-   * Append the viewer's short zone name (`GMT+8`). Defaults to true; pass false where the zone is
-   * column noise rather than information — a compact list whose every row would repeat the viewer's
-   * own zone, and whose width that suffix would otherwise dictate.
-   */
-  zone?: boolean;
-}
-
 /**
  * Format a UTC timestamp for alert events in the viewer's timezone. Alert APIs
  * serialize UTC LocalDateTime values without an offset, so normal Date parsing
  * would incorrectly treat them as browser-local timestamps.
+ *
+ * The zone name is deliberately not appended: every timestamp on a page renders in the viewer's own
+ * zone, so a `GMT+8` suffix repeats the reader's own setting on every row and forces every time
+ * column wider.
  */
 export function formatUtcDateTime(
   date: string | Date | null | undefined,
   timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
-  options: FormatUtcDateTimeOptions = {},
 ): string {
   if (date === null || date === undefined || (typeof date === 'string' && !date.trim())) return '-';
   const utcDate =
@@ -84,7 +78,6 @@ export function formatUtcDateTime(
     minute: '2-digit',
     second: '2-digit',
     hourCycle: 'h23',
-    timeZoneName: 'short',
   }).formatToParts(utcDate);
   const value = (type: Intl.DateTimeFormatPartTypes) =>
     parts.find((part) => part.type === type)?.value;
@@ -94,9 +87,8 @@ export function formatUtcDateTime(
   const hour = value('hour');
   const minute = value('minute');
   const second = value('second');
-  const zone = options.zone === false ? '' : value('timeZoneName');
   return year && month && day && hour && minute && second
-    ? `${year}-${month}-${day} ${hour}:${minute}:${second}${zone ? ` ${zone}` : ''}`
+    ? `${year}-${month}-${day} ${hour}:${minute}:${second}`
     : '-';
 }
 
@@ -230,3 +222,14 @@ export function formatPercent(value: number, decimals = 1): string {
   if (!Number.isFinite(value)) return '-';
   return `${value.toFixed(safeDecimals(decimals))}%`;
 }
+
+/**
+ * Round a percentage to the one decimal the UI shows.
+ *
+ * Ratios scaled to percentage points carry binary floating point noise — a broker reporting
+ * `commitLogDiskRatio=0.29` becomes `28.999999999999996` — and an antd `Progress` reserves room
+ * for its label inside the bar, so a label that long squeezes the bar down to zero width.
+ */
+export function roundPercent(value: number): number {
+  return Math.round(value * 10) / 10;
+}