feat: 用户视角 — 按用户聚合 LD 板使用信息

- 新增 DomainDetail 数据模型存储逐域归属 (Owner/PID/T-Pod/Design)
- 解析详细版 test_server 输出 (Logic drawer X has N domains 格式)
- 新增 /users 页面 + /api/users API 按用户聚合 LD 板
- 修复邮件配置保存时中文逗号收件人分割问题
- 修复邮件发送时 ASCII 编码错误 (收件人含中文逗号)
This commit is contained in:
Your Name
2026-07-15 12:32:48 +08:00
parent d8eb6824b8
commit b5205d0543
5 changed files with 297 additions and 8 deletions
+184 -6
View File
@@ -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) # 07
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/<int:sid>")
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(