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:
@@ -123,6 +123,24 @@ class JobInfo(db.Model):
|
|||||||
reserved_key = db.Column(db.String(100), default="")
|
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):
|
class PodInfo(db.Model):
|
||||||
__tablename__ = "pod_infos"
|
__tablename__ = "pod_infos"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -220,19 +238,103 @@ def parse_test_server_output(raw_text: str) -> dict:
|
|||||||
})
|
})
|
||||||
continue
|
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)
|
board_match = board_re.match(line)
|
||||||
if board_match and result["racks"] and result["racks"][-1]["clusters"]:
|
if board_match and result["racks"] and result["racks"][-1]["clusters"]:
|
||||||
ld_idx = int(board_match.group(1))
|
ld_idx = int(board_match.group(1))
|
||||||
status = board_match.group(2)
|
status = board_match.group(2)
|
||||||
domains = [board_match.group(i) for i in range(3, 11)]
|
domains = [board_match.group(i) for i in range(3, 11)]
|
||||||
result["racks"][-1]["clusters"][-1]["boards"].append({
|
# 更新或创建 board
|
||||||
"ld": ld_idx,
|
target_board = None
|
||||||
"status": status,
|
for board in result["racks"][-1]["clusters"][-1]["boards"]:
|
||||||
"domains": domains,
|
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
|
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(
|
job_section = re.search(
|
||||||
r"Job Information:.*?(?=Target Pod Information:|Exit code:|$)",
|
r"Job Information:.*?(?=Target Pod Information:|Exit code:|$)",
|
||||||
@@ -517,6 +619,66 @@ def history():
|
|||||||
return render_template("history.html", pagination=pagination)
|
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>")
|
@app.route("/snapshot/<int:sid>")
|
||||||
def snapshot_detail(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],
|
d0=domains[0], d1=domains[1], d2=domains[2], d3=domains[3],
|
||||||
d4=domains[4], d5=domains[5], d6=domains[6], d7=domains[7],
|
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"]:
|
for job in parsed["jobs"]:
|
||||||
db.session.add(JobInfo(
|
db.session.add(JobInfo(
|
||||||
|
|||||||
@@ -124,10 +124,21 @@ def save_config(data: dict) -> int:
|
|||||||
raise RuntimeError("EmailConfig 模型不可用")
|
raise RuntimeError("EmailConfig 模型不可用")
|
||||||
|
|
||||||
to_addrs = data.get("to_addrs")
|
to_addrs = data.get("to_addrs")
|
||||||
|
# 统一处理:支持中英文逗号、分号分隔,自动规范化为英文逗号分隔
|
||||||
if isinstance(to_addrs, list):
|
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:
|
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 = {
|
save_fields = {
|
||||||
"enabled": str(data.get("enabled", False)).lower(),
|
"enabled": str(data.get("enabled", False)).lower(),
|
||||||
|
|||||||
@@ -814,6 +814,44 @@ body {
|
|||||||
/* 全宽字段 */
|
/* 全宽字段 */
|
||||||
.form-group.full-width { grid-column: 1 / -1; }
|
.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 {
|
.toggle-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">仪表板</a>
|
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">仪表板</a>
|
||||||
<a href="{{ url_for('history') }}" class="{% if request.endpoint == 'history' %}active{% endif %}">历史记录</a>
|
<a href="{{ url_for('history') }}" class="{% if request.endpoint == 'history' %}active{% endif %}">历史记录</a>
|
||||||
|
<a href="{{ url_for('users_page') }}" class="{% if request.endpoint == 'users_page' %}active{% endif %}">👤 用户</a>
|
||||||
<a href="{{ url_for('email_config') }}" class="{% if request.endpoint == 'email_config' %}active{% endif %}">⚙️ 设置</a>
|
<a href="{{ url_for('email_config') }}" class="{% if request.endpoint == 'email_config' %}active{% endif %}">⚙️ 设置</a>
|
||||||
<button class="nav-btn" onclick="openPasteModal()">📥 粘贴数据</button>
|
<button class="nav-btn" onclick="openPasteModal()">📥 粘贴数据</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}用户视角 — Palladium Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>👤 用户视角</h1>
|
||||||
|
<p class="page-subtitle">按用户聚合 LD 板使用情况 (数据来自详细版 test_server 输出)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination['items'] %}
|
||||||
|
<!-- 用户列表表 -->
|
||||||
|
<div class="card">
|
||||||
|
<table class="data-table users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>使用 LD 数</th>
|
||||||
|
<th>LD 板</th>
|
||||||
|
<th>Rack</th>
|
||||||
|
<th>T-Pod</th>
|
||||||
|
<th>设计名</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in pagination['items'] %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-mono user-cell">{{ item.user }}</td>
|
||||||
|
<td class="text-center"><span class="user-count-badge">{{ item.ld_count }}</span></td>
|
||||||
|
<td class="boards-cell">{{ item.boards }}</td>
|
||||||
|
<td class="text-mono">{{ item.racks }}</td>
|
||||||
|
<td class="text-mono">{{ item.pods }}</td>
|
||||||
|
<td class="design-cell">{{ item.designs }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
{% if pagination['pages'] > 1 %}
|
||||||
|
<div class="pagination">
|
||||||
|
{% if pagination['page'] > 1 %}
|
||||||
|
<a href="{{ url_for('users_page', page=pagination['page'] - 1) }}" class="page-btn">← 上一页</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="page-info">第 {{ pagination['page'] }} / {{ pagination['pages'] }} 页 (共 {{ pagination['total'] }} 个用户)</span>
|
||||||
|
{% if pagination['page'] < pagination['pages'] %}
|
||||||
|
<a href="{{ url_for('users_page', page=pagination['page'] + 1) }}" class="page-btn">下一页 →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state-full">
|
||||||
|
<div class="empty-icon">👤</div>
|
||||||
|
<h2>暂无用户数据</h2>
|
||||||
|
<p>请先使用 <code>test_server</code> (非 -short 模式) 采集数据,
|
||||||
|
详细输出会包含每个域的 Owner/PID/T-Pod/Design 信息。</p>
|
||||||
|
<p>或粘贴详细版 test_server 输出到仪表板页面进行解析。</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user