10 Commits

Author SHA1 Message Date
Your Name 5c42d802c0 fix: 活跃作业回退统计补充 elap_time + 过滤 T-Pod 占位符
Z1 从 DomainDetail 回退活跃作业时, 之前只统计了 owner/pid/design/t_pod,
漏了 elap_time (运行时间)。现在一并提取, 并过滤 '--' 占位符。
2026-07-17 14:07:49 +08:00
Your Name 4a9c527d6e fix: 进度条改用 table 实现 (Gmail 兼容)
嵌套 div 被 Gmail 过滤导致进度条消失, 且后续 HTML 丢失。
改用 table 实现: 灰色背景轨道 200px, 蓝色填充按利用率百分比。
50% → 100px 蓝色长条, 0% → 无蓝色填充
2026-07-17 13:55:08 +08:00
Your Name 29ddfccc7b fix: 活跃作业从 DomainDetail 回退统计 + 修复 Gmail 嵌套 div 进度条
1. Z1 详细输出把作业存在 DomainDetail 而非 JobInfo, 增加回退逻辑
   按 (owner, pid, design) 去重统计活跃作业数
2. 移除利用率进度条的嵌套 div (Gmail 会过滤导致后续内容丢失)
   改为单元素蓝色进度条
3. 活跃作业卡片显示实际统计数而非 Snapshot 缓存值
2026-07-17 13:51:31 +08:00
Your Name c21f5028dd fix: 邮件 HTML 简化 — 移除 overflow:hidden/linear-gradient/box-shadow
部分邮件客户端过滤这些 CSS 属性导致内容截断。改用纯蓝色背景、
移除阴影和内溢出隐藏,提高邮件客户端兼容性。
2026-07-17 13:37:45 +08:00
Your Name e27655784f fix: Z1 详细输出解析 — Board X has N domains 头匹配
Z1 详细 test_server 输出使用 'Board X has N domains' 格式而非
'Logic drawer X has N domains',导致逐域归属数据(domain_details)
未被解析,邮件中缺少用户-LD 板统计。修复后 Z1/Z2 两种格式均可解析。
2026-07-17 13:08:01 +08:00
Your Name 793538e19d fix: LD数改为纯数字显示, 移除徽章背景样式 2026-07-17 12:47:56 +08:00
Your Name b4210d74b9 fix: LD板编号格式简化为 LD0 LD1 (去除 Rack/Cluster 前缀) 2026-07-17 12:46:19 +08:00
Your Name 7e0261341e fix: 邮件用户统计 HTML 表增加 LD 板编号列 2026-07-17 12:44:51 +08:00
Your Name 07867eaefc feat: 邮件报告增加用户-LD 板使用统计
- 在 _collect_report_data 中聚合 DomainDetail 按用户统计 LD 板使用
- 纯文本版新增【用户-LD 板统计】版块 (用户/LD板/Rack/T-Pod/设计)
- HTML 版新增紫色标题表格, 紫色徽章显示 LD 数
- 自动过滤 NONE/UNOWNED/FREE/AVAILABLE 等无主域
- 过滤 T-Pod 占位符 '--' (包括 '24 --' 含 -- 的值)
2026-07-17 12:39:38 +08:00
Your Name b5205d0543 feat: 用户视角 — 按用户聚合 LD 板使用信息
- 新增 DomainDetail 数据模型存储逐域归属 (Owner/PID/T-Pod/Design)
- 解析详细版 test_server 输出 (Logic drawer X has N domains 格式)
- 新增 /users 页面 + /api/users API 按用户聚合 LD 板
- 修复邮件配置保存时中文逗号收件人分割问题
- 修复邮件发送时 ASCII 编码错误 (收件人含中文逗号)
2026-07-15 12:32:48 +08:00
5 changed files with 428 additions and 19 deletions
+186 -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,105 @@ def parse_test_server_output(raw_text: str) -> dict:
})
continue
# LD 板行
# "Logic drawer X has N domains" (Z2) 或 "Board X has N domains" (Z1 详细输出)
ld_header = re.match(
r"(?:Logic\s+drawer|Board)\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 +621,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 +1294,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(
+153 -13
View File
@@ -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(),
@@ -243,6 +254,41 @@ def collect_report_data() -> dict:
# ── 活跃作业 ──
jobs_active = JobInfo.query.filter_by(snapshot_id=latest.id).order_by(JobInfo.job_index).all()
# Z1 详细输出把作业信息存在 DomainDetail 里, 若 JobInfo 为空则从 DomainDetail 回退
if not jobs_active:
try:
from app import DomainDetail
domain_rows = DomainDetail.query.filter_by(snapshot_id=latest.id).all()
if domain_rows:
# 按 (owner, pid, design) 去重统计活跃作业数
active_set = {} # key: (owner, pid) -> {design, t_pod, elap_time}
for dr in domain_rows:
owner = dr.owner or ""
pid = dr.pid or ""
if owner and owner not in ("NONE", "") and pid and pid not in ("0", ""):
key = (owner, pid)
if key not in active_set:
active_set[key] = {
"design": dr.design or "",
"t_pod": dr.t_pod or "",
"elap_time": dr.elap_time or "",
}
if active_set:
# 构造虚拟 job 对象供渲染 (兼容 jobs_active 格式)
class _VJob:
pass
for idx, ((o, p), info) in enumerate(sorted(active_set.items()), 1):
j = _VJob()
j.job_index = idx
j.owner = o
j.pid = p
j.t_pod = info["t_pod"] if "--" not in info["t_pod"] and info["t_pod"] not in ("",) else ""
j.design = info["design"]
j.elap_time = info["elap_time"] if info["elap_time"] not in ("", "") else ""
jobs_active.append(j)
except (ImportError, AttributeError):
pass
# ── 24h 统计 ──
snaps_24h = ScanSnapshot.query.filter(
ScanSnapshot.timestamp >= cutoff_24h
@@ -275,12 +321,63 @@ def collect_report_data() -> dict:
stats_24h = None
samples_24h = []
# ── 按用户聚合 LD 板使用 ──
user_board_agg = []
try:
from app import DomainDetail
domain_rows = DomainDetail.query.filter_by(
snapshot_id=latest.id
).all()
if domain_rows:
# 按用户聚合: 统计每用户用了多少块不同 LD
user_boards: dict[str, dict] = {}
for dr in domain_rows:
owner = dr.owner or ""
if not owner or owner.upper() in ("NONE", "UNOWNED", "FREE", "AVAILABLE"):
continue
board_key = (dr.rack, dr.cluster, dr.ld_index)
if owner not in user_boards:
user_boards[owner] = {
"user": owner,
"boards": set(),
"racks": set(),
"pods": set(),
"designs": set(),
}
entry = user_boards[owner]
entry["boards"].add(board_key)
if dr.rack is not None:
entry["racks"].add(dr.rack)
t_pod = dr.t_pod or ""
# 过滤占位符 "--" (包括 "24 --" 这种含 -- 的)
if t_pod and "--" not in t_pod and t_pod.strip() != "":
entry["pods"].add(t_pod)
design = dr.design or ""
if design:
entry["designs"].add(design)
# 转为列表并排序 (按 LD 数降序)
for owner, info in user_boards.items():
boards_sorted = sorted(info["boards"], key=lambda x: x[2])
user_board_agg.append({
"user": owner,
"ld_count": len(boards_sorted),
"boards": ", ".join(f"LD{b[2]}" for b in boards_sorted),
"racks": ", ".join(str(r) for r in sorted(info["racks"])) if info["racks"] else "",
"pods": ", ".join(sorted(info["pods"])) if info["pods"] else "",
"designs": "; ".join(sorted(info["designs"])) if info["designs"] else "",
})
user_board_agg.sort(key=lambda x: (-x["ld_count"], x["user"]))
except (ImportError, AttributeError):
pass
return {
"latest": latest,
"boards_offline": boards_offline,
"jobs_active": jobs_active,
"stats_24h": stats_24h,
"samples_24h": samples_24h,
"user_board_agg": user_board_agg,
"now_utc": now,
"now_local": datetime.now(),
}
@@ -338,6 +435,20 @@ def _render_text(data: dict, base_url: str) -> str:
lines.append(f" 活跃作业: {latest.active_jobs}")
lines.append("")
# 用户-LD 板统计
if data.get("user_board_agg"):
lines.append("【用户-LD 板统计】")
for ub in data["user_board_agg"]:
lines.append(f" {ub['user']}: {ub['ld_count']} 块 LD")
lines.append(f" LD 板: {ub['boards']}")
if ub["racks"] != "":
lines.append(f" Rack: {ub['racks']}")
if ub["pods"] != "":
lines.append(f" T-Pod: {ub['pods']}")
if ub["designs"] != "":
lines.append(f" 设计: {ub['designs']}")
lines.append("")
# 24h 趋势
if data["stats_24h"]:
s = data["stats_24h"]
@@ -432,17 +543,46 @@ def _render_html(data: dict, base_url: str) -> str:
else:
jobs_html = '<p style="color:#888;margin:6px 0">无活跃作业</p>'
# 用户-LD 板统计 HTML
user_board_html = ""
if data.get("user_board_agg"):
ub_rows = "".join(
f'<tr style="border-bottom:1px solid #e9ecef">'
f'<td style="padding:6px 12px;font-family:monospace;font-weight:bold;color:#6f42c1">{ub["user"]}</td>'
f'<td style="padding:6px 12px;text-align:center;font-weight:bold">{ub["ld_count"]}</td>'
f'<td style="padding:6px 12px;font-family:monospace;font-size:11px;color:#444;line-height:1.6">{ub["boards"]}</td>'
f'<td style="padding:6px 12px;font-family:monospace;font-size:12px">{ub["racks"]}</td>'
f'<td style="padding:6px 12px;font-family:monospace;font-size:12px">{ub["pods"]}</td>'
f'<td style="padding:6px 12px;font-size:12px;color:#555">{ub["designs"]}</td>'
f'</tr>'
for ub in data["user_board_agg"]
)
user_board_html = f"""
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">👤 用户-LD 板统计</h2>
<table style="border-collapse:collapse;width:100%;margin-top:6px">
<thead><tr style="background:#6f42c1;color:#fff">
<th style="padding:6px 12px;text-align:left">用户</th>
<th style="padding:6px 12px;text-align:center">LD 数</th>
<th style="padding:6px 12px;text-align:left">LD 板</th>
<th style="padding:6px 12px;text-align:left">Rack</th>
<th style="padding:6px 12px;text-align:left">T-Pod</th>
<th style="padding:6px 12px;text-align:left">设计</th>
</tr></thead>
<tbody>{ub_rows}</tbody>
</table>"""
# 24h 趋势 HTML
if data["stats_24h"]:
s = data["stats_24h"]
# 进度条: 用 table 实现 (Gmail 兼容), 宽 200px, 蓝色按利用率百分比填充
sample_rows = "".join(
f'<tr><td style="padding:4px 12px;font-family:monospace">{_fmt_ts(smp.timestamp, "%m-%d %H:%M")}</td>'
f'<td style="padding:4px 12px;text-align:right;font-weight:bold">{smp.utilization:.1f}%</td>'
f'<td style="padding:4px 12px">'
f'<div style="background:#e0e0e0;height:14px;width:200px;border-radius:2px">'
f'<div style="background:{DOMAIN_COLORS.get("downloaded", "#007bff")};'
f'height:14px;width:{min(smp.utilization, 100):.1f}%;border-radius:2px"></div>'
f'</div></td></tr>'
f'<table style="width:200px;background:#e0e0e0;border-radius:2px;height:8px">'
f'<tr><td style="width:{max(0, min(200, int(round(smp.utilization / 100 * 200))))}px;background:{DOMAIN_COLORS.get("downloaded", "#007bff")};border-radius:2px;height:8px"></td></tr>'
f'</table>'
f'</td></tr>'
for smp in data["samples_24h"]
)
trend_html = f"""
@@ -470,11 +610,10 @@ def _render_html(data: dict, base_url: str) -> str:
<head><meta charset="UTF-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
background:#f5f5f7;padding:0;margin:0;color:#222">
<div style="max-width:720px;margin:20px auto;background:#fff;border-radius:8px;
box-shadow:0 2px 8px rgba(0,0,0,0.08);overflow:hidden">
<div style="max-width:720px;margin:20px auto;background:#fff;border-radius:8px">
<!-- Header -->
<div style="background:linear-gradient(135deg,#007bff,#0056b3);color:#fff;padding:24px 28px">
<div style="background:#007bff;color:#fff;padding:24px 28px">
<h1 style="margin:0;font-size:22px">⚡ Palladium 每日报告</h1>
<div style="margin-top:6px;opacity:0.9;font-size:14px">{title_date}</div>
</div>
@@ -504,17 +643,18 @@ def _render_html(data: dict, base_url: str) -> str:
<div style="flex:1;min-width:200px;background:#f8f9fa;padding:14px;border-radius:6px;border-left:4px solid {DOMAIN_COLORS.get('downloaded')}">
<div style="color:#888;font-size:12px">逻辑板利用率</div>
<div style="font-size:24px;font-weight:bold;margin-top:4px;color:#007bff">{latest.utilization:.1f}%</div>
<div style="background:#e0e0e0;height:8px;border-radius:4px;margin-top:6px">
<div style="background:{DOMAIN_COLORS.get('downloaded')};height:8px;width:{util_bar_width};border-radius:4px"></div>
</div>
<div style="background:#007bff;height:8px;border-radius:4px;margin-top:6px;width:{util_bar_width}"></div>
<div style="font-size:12px;margin-top:4px">使用 {latest.used_boards} / 空闲 {latest.online_boards - latest.used_boards}</div>
</div>
<div style="flex:1;min-width:140px;background:#f8f9fa;padding:14px;border-radius:6px;border-left:4px solid #28a745">
<div style="color:#888;font-size:12px">活跃作业</div>
<div style="font-size:24px;font-weight:bold;margin-top:4px">{latest.active_jobs}</div>
<div style="font-size:24px;font-weight:bold;margin-top:4px">{len(data.get('jobs_active', []))}</div>
</div>
</div>
<!-- 用户-LD 板统计 -->
{user_board_html}
<!-- 24h 趋势 -->
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">📈 近 24 小时趋势</h2>
{trend_html}
+27
View File
@@ -814,6 +814,33 @@ 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);
}
.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;
+1
View File
@@ -16,6 +16,7 @@
<div class="nav-links">
<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('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>
<button class="nav-btn" onclick="openPasteModal()">📥 粘贴数据</button>
</div>
+61
View File
@@ -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" style="font-weight:bold">{{ item.ld_count }}</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 %}