From b5205d0543180e03ddec3f6fffdd0ecfa0f9a205 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Jul 2026 12:32:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E8=A7=86=E8=A7=92=20?= =?UTF-8?q?=E2=80=94=20=E6=8C=89=E7=94=A8=E6=88=B7=E8=81=9A=E5=90=88=20LD?= =?UTF-8?q?=20=E6=9D=BF=E4=BD=BF=E7=94=A8=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DomainDetail 数据模型存储逐域归属 (Owner/PID/T-Pod/Design) - 解析详细版 test_server 输出 (Logic drawer X has N domains 格式) - 新增 /users 页面 + /api/users API 按用户聚合 LD 板 - 修复邮件配置保存时中文逗号收件人分割问题 - 修复邮件发送时 ASCII 编码错误 (收件人含中文逗号) --- app.py | 190 +++++++++++++++++++++++++++++++++++++++++-- mailer.py | 15 +++- static/style.css | 38 +++++++++ templates/base.html | 1 + templates/users.html | 61 ++++++++++++++ 5 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 templates/users.html diff --git a/app.py b/app.py index 60fb508..4e69ec6 100644 --- a/app.py +++ b/app.py @@ -123,6 +123,24 @@ class JobInfo(db.Model): reserved_key = db.Column(db.String(100), default="") +class DomainDetail(db.Model): + """逐域归属信息 (详细版 test_server 输出, 每块 LD 的 8 个域各自有 Owner/PID/T-Pod/Design)。""" + __tablename__ = "domain_details" + id = db.Column(db.Integer, primary_key=True) + snapshot_id = db.Column(db.Integer, db.ForeignKey("snapshots.id"), nullable=False, index=True) + rack = db.Column(db.Integer) + cluster = db.Column(db.Integer) + ld_index = db.Column(db.Integer) + domain_index = db.Column(db.Integer) # 0–7 + domain_label = db.Column(db.String(10)) # "0.0", "0.1", ... "29.7" + owner = db.Column(db.String(100), default="") + pid = db.Column(db.String(200), default="") + t_pod = db.Column(db.String(200), default="") + design = db.Column(db.String(200), default="") + elap_time = db.Column(db.String(20), default="") + reserved_key = db.Column(db.String(100), default="") + + class PodInfo(db.Model): __tablename__ = "pod_infos" id = db.Column(db.Integer, primary_key=True) @@ -220,19 +238,103 @@ def parse_test_server_output(raw_text: str) -> dict: }) continue - # LD 板行 + # "Logic drawer X has N domains Logic drawer: ONLINE" 行 + ld_header = re.match(r"Logic\s+drawer\s+(\d+)\s+has\s+(\d+)\s+domains", line) + if ld_header and result["racks"] and result["racks"][-1]["clusters"]: + ld_num = int(ld_header.group(1)) + # 找到对应的 board + target_board = None + for board in result["racks"][-1]["clusters"][-1]["boards"]: + if board["ld"] == ld_num: + target_board = board + break + # 如果 board 还不存在, 先创建一个 (状态 ONLINE, 域为占位符) + if not target_board: + target_board = {"ld": ld_num, "status": "ONLINE", "domains": ["-"] * 8, "domain_details": []} + result["racks"][-1]["clusters"][-1]["boards"].append(target_board) + # 重置 domain_details, 准备填入 + target_board["domain_details"] = [] + continue + + # LD 板行 (short 格式, 只有 8 个域值) — 始终覆盖 domains (这是域的实际状态) board_match = board_re.match(line) if board_match and result["racks"] and result["racks"][-1]["clusters"]: ld_idx = int(board_match.group(1)) status = board_match.group(2) domains = [board_match.group(i) for i in range(3, 11)] - result["racks"][-1]["clusters"][-1]["boards"].append({ - "ld": ld_idx, - "status": status, - "domains": domains, - }) + # 更新或创建 board + target_board = None + for board in result["racks"][-1]["clusters"][-1]["boards"]: + if board["ld"] == ld_idx: + target_board = board + break + if target_board: + target_board["status"] = status + target_board["domains"] = domains # 始终覆盖 (short 格式是域状态的真值) + else: + target_board = {"ld": ld_idx, "status": status, "domains": domains, "domain_details": []} + result["racks"][-1]["clusters"][-1]["boards"].append(target_board) continue + # 逐域归属数据行: "0.0 root UNKNOWN:37608 -- -- SA:hw_top:S 02:06:35 --" + if (result["racks"] and result["racks"][-1]["clusters"] and + result["racks"][-1]["clusters"][-1]["boards"]): + detail_match = re.match( + r"^\s*(\d+)\.(\d+)\s+(\S+)\s+(\S+)\s+(.*)", + line + ) + if detail_match: + ld_num = int(detail_match.group(1)) + dom_idx = int(detail_match.group(2)) + owner = detail_match.group(3) + pid = detail_match.group(4) + rest = detail_match.group(5) # "T-Pod Design ElapTime ReservedKey" + # 分割 rest 为 4 段 (从右边取 3 个字段) + rest_parts = rest.strip().split() + if len(rest_parts) >= 3: + reserved_key = rest_parts[-1] + elap_time = rest_parts[-2] + design = rest_parts[-3] + t_pod = " ".join(rest_parts[:-3]) + else: + reserved_key = elap_time = design = t_pod = "" + + # 写入当前 board 的 domain_details + target_board = None + for board in result["racks"][-1]["clusters"][-1]["boards"]: + if board["ld"] == ld_num: + target_board = board + break + if target_board: + target_board.setdefault("domain_details", []).append({ + "domain_index": dom_idx, + "domain_label": f"{ld_num}.{dom_idx}", + "owner": owner, + "pid": pid, + "t_pod": t_pod, + "design": design, + "elap_time": elap_time, + "reserved_key": reserved_key, + }) + continue + + # ─── 后置处理: 从 domain_details 推导 domains (详细输出无 short 格式板行) ─ + for rack in result["racks"]: + for cluster in rack["clusters"]: + for board in cluster["boards"]: + details = board.get("domain_details", []) + if details and board["domains"] == ["-"] * 8: + # 按 domain_index 排序 + details.sort(key=lambda d: d["domain_index"]) + domains = [] + for d in details: + owner = d.get("owner", "") + if owner in ("NONE", "", "—"): + domains.append("-") # 可用 + else: + domains.append(str(d.get("domain_index", "?"))) # 已加载 (用域索引作值) + board["domains"] = domains + # ─── 作业信息 ────────────────────────────────────────────── job_section = re.search( r"Job Information:.*?(?=Target Pod Information:|Exit code:|$)", @@ -517,6 +619,66 @@ def history(): return render_template("history.html", pagination=pagination) +@app.route("/users") +def users_page(): + """用户视角: 按用户聚合 LD 板使用信息。""" + page = request.args.get("page", 1, type=int) + per_page = 30 + # 获取用户列表及其使用的 LD 板 + pagination = _build_user_list(page, per_page) + return render_template("users.html", pagination=pagination) + + +def _build_user_list(page, per_page): + """构建用户列表 (按用户聚合 LD 板)。""" + # 找到所有非 NONE 的用户 + users_raw = ( + DomainDetail.query + .filter(DomainDetail.owner != "NONE") + .filter(DomainDetail.owner != "") + .group_by(DomainDetail.owner, DomainDetail.rack, DomainDetail.cluster, + DomainDetail.ld_index) + .order_by(DomainDetail.owner, DomainDetail.rack, DomainDetail.cluster, DomainDetail.ld_index) + .all() + ) + # 聚合每个用户的 LD 板 + user_data = {} + for row in users_raw: + uid = row.owner + if uid not in user_data: + user_data[uid] = {"boards": set(), "racks": set(), "pods": set(), "designs": set()} + user_data[uid]["boards"].add(f"LD{row.ld_index}") + user_data[uid]["racks"].add(row.rack) + if row.t_pod: + user_data[uid]["pods"].add(row.t_pod) + if row.design: + user_data[uid]["designs"].add(row.design) + + # 计算每用户的统计 + users_list = [] + for uid, data in sorted(user_data.items()): + users_list.append({ + "user": uid, + "ld_count": len(data["boards"]), + "boards": ", ".join(sorted(data["boards"])), + "racks": ", ".join(sorted(str(r) for r in data["racks"])), + "pods": ", ".join(sorted(data["pods"])) if data["pods"] else "-", + "designs": ", ".join(sorted(data["designs"])) if data["designs"] else "-", + }) + + # 分页 + total = len(users_list) + start = (page - 1) * per_page + end = start + per_page + return { + "items": users_list[start:end], + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page, + } + + @app.route("/snapshot/") def snapshot_detail(sid): """单条快照详情。""" @@ -1130,6 +1292,22 @@ def store_snapshot(raw_output: str, source: str = "manual") -> int: d0=domains[0], d1=domains[1], d2=domains[2], d3=domains[3], d4=domains[4], d5=domains[5], d6=domains[6], d7=domains[7], )) + # Store DomainDetail records if present + for detail in board.get("domain_details", []): + db.session.add(DomainDetail( + snapshot_id=snap.id, + rack=rack["rack"], + cluster=cluster["cluster"], + ld_index=board["ld"], + domain_index=detail["domain_index"], + domain_label=detail["domain_label"], + owner=detail["owner"], + pid=detail["pid"], + t_pod=detail["t_pod"], + design=detail["design"], + elap_time=detail["elap_time"], + reserved_key=detail["reserved_key"], + )) for job in parsed["jobs"]: db.session.add(JobInfo( diff --git a/mailer.py b/mailer.py index 31c8038..c9834c6 100644 --- a/mailer.py +++ b/mailer.py @@ -124,10 +124,21 @@ def save_config(data: dict) -> int: raise RuntimeError("EmailConfig 模型不可用") to_addrs = data.get("to_addrs") + # 统一处理:支持中英文逗号、分号分隔,自动规范化为英文逗号分隔 if isinstance(to_addrs, list): - to_addrs_str = ",".join(str(x) for x in to_addrs if x) + # 每个元素内部也可能含中英文逗号,全部拆开 + parts = [] + for item in to_addrs: + s = str(item).replace("\uff0c", ",").replace("\u3001", ",").replace(";", ",") + for p in s.split(","): + p = p.strip() + if p: + parts.append(p) + to_addrs_str = ",".join(parts) else: - to_addrs_str = str(to_addrs or "") + s = str(to_addrs or "").replace("\uff0c", ",").replace("\u3001", ",").replace(";", ",") + parts = [p.strip() for p in s.split(",") if p.strip()] + to_addrs_str = ",".join(parts) save_fields = { "enabled": str(data.get("enabled", False)).lower(), diff --git a/static/style.css b/static/style.css index d171118..3aa1179 100644 --- a/static/style.css +++ b/static/style.css @@ -814,6 +814,44 @@ body { /* 全宽字段 */ .form-group.full-width { grid-column: 1 / -1; } +/* ─── 用户视角页面 ─── */ +.users-table { table-layout: fixed; } +.users-table th:nth-child(1) { width: 14%; } +.users-table th:nth-child(2) { width: 10%; text-align: center; } +.users-table th:nth-child(3) { width: 30%; } +.users-table th:nth-child(4) { width: 12%; } +.users-table th:nth-child(5) { width: 15%; } +.users-table th:nth-child(6) { width: 19%; } + +.user-cell { + font-weight: 600; + color: var(--accent); +} +.user-count-badge { + display: inline-block; + min-width: 28px; + padding: 2px 8px; + background: rgba(88,166,255,0.15); + color: var(--accent); + border-radius: 10px; + font-size: 13px; + font-weight: 700; + text-align: center; +} +.boards-cell { + max-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--text-secondary); +} +.design-cell { + font-size: 12px; + color: var(--text-secondary); +} +.text-center { text-align: center; } + /* 启用开关 */ .toggle-row { display: flex; diff --git a/templates/base.html b/templates/base.html index dd1c94e..0e568fd 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,6 +16,7 @@ diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..7093ee2 --- /dev/null +++ b/templates/users.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}用户视角 — Palladium Monitor{% endblock %} + +{% block content %} + + +{% if pagination['items'] %} + +
+ + + + + + + + + + + + + {% for item in pagination['items'] %} + + + + + + + + + {% endfor %} + +
用户使用 LD 数LD 板RackT-Pod设计名
{{ item.user }}{{ item.ld_count }}{{ item.boards }}{{ item.racks }}{{ item.pods }}{{ item.designs }}
+
+ + +{% if pagination['pages'] > 1 %} + +{% endif %} + +{% else %} +
+
👤
+

暂无用户数据

+

请先使用 test_server (非 -short 模式) 采集数据, + 详细输出会包含每个域的 Owner/PID/T-Pod/Design 信息。

+

或粘贴详细版 test_server 输出到仪表板页面进行解析。

+
+{% endif %} +{% endblock %} \ No newline at end of file