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<group> 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;
+}