21 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
Your Name d8eb6824b8 fix: email config save 返回缺少 enabled 字段导致前端显示配置不完整 2026-07-15 12:09:04 +08:00
Your Name f3145369c4 fix: 解析器支持 Z2 'logic drawers' 输出格式
Cluster 正则从硬编码 'boards' 改为兼容 'boards|logic drawers',
修复 Palladium Z2 test_server 输出无法解析逻辑板信息的问题。
2026-07-14 16:05:12 +08:00
Hermes 83621cb213 sync: import complete app.py/mailer.py/collect.py + dev customizations
完整功能 (相对于 ac9a060):
- app.py: 1276 行 (vs 633 行) — 含 /api/email/* + /api/export + Chart 小数
- mailer.py: 645 行 — SMTP 邮件模块 + scheduler
- collect.py: 233 行 — Python 3.6 兼容 (typing.Dict 代替 dict[X,Y])
- README.md: 整合版
- templates/email_config.html: 邮件配置 Web UI
- static/style.css: 邮件状态徽章等新样式

清理:
- 删除 'palladium-monitor/' 子目录 (远端残留, 已废弃)
2026-07-08 15:23:24 +08:00
Your Name ac9a060b46 feat: 新增 systemd service 文件 (palladium-monitor.service) 2026-07-08 14:48:35 +08:00
Your Name 6726524c50 fix: db.get_or_404 -> query.get_or_404 + rename to Palladium Monitor
- 修复删除快照时 AttributeError: SQLAlchemy.get_or_404 (低版本兼容)
- UI/标题/邮件主题从 'Palladium Z1 Monitor' 改为 'Palladium Monitor'
2026-07-08 14:46:18 +08:00
Your Name 89dc6b4449 feat: 邮件配置页面 + 数据库持久化
- 新增 /email 配置页面 + /api/email/config API (GET/POST)
- 新增 /api/email/test-send 测试邮件 + /api/email/reset 重置
- EmailConfig SQLite 表持久化配置,回退到环境变量
- mailer: get_config 优先读 DB,send_report 读取 DB 密码
- 修复 _send_smtp 登录使用环境变量密码的 bug
- 前端新增配置表单 (启用开关/SMTP/TLS/认证/发件人/收件人/时间)
- CSS 暗色主题样式 + 响应式适配
- 导航栏新增「设置」链接
2026-07-08 14:36:41 +08:00
Your Name 3ab73805a3 mail 2026-07-08 13:28:47 +08:00
Your Name da2fd92f07 support python36 2026-07-08 13:00:06 +08:00
AI Agent 5250c2960c Palladium Z1 Monitor - LD逻辑板监控系统
- 解析 test_server -short 输出 (Rack/Cluster/Board/D0-D7)
- 暗色主题仪表板: 逻辑板状态可视化、利用率、作业信息、T-Pod可用性
- 板级统计: 使用/空闲/离线, 利用率趋势图 (Chart.js)
- collect.sh: Shell采集脚本(cron定时推送, 无需Python)
- 历史快照管理: 查看详情/删除/清理
- systemd service 开机自启
2026-07-08 12:51:16 +08:00
AI Agent 91f52941d6 Merge branch 'master' of ssh://git.cnbugs.com:10022/AI-Agent/palladium-monitor 2026-07-08 12:13:49 +08:00
Hermes 187f37f8e7 fix: add 'from __future__ import annotations' for Python <3.10
PEP 585 内建泛型 (tuple[str, str], dict[str, int]) 在 Python 3.9 之前不支持。
PEP 563 让所有 type hint 变成字符串, 兼容 Python 3.7+。

错误: 'type' object is not subscriptable
影响: app.py:1184 start_email_scheduler() -> import mailer -> mailer.py:417
修复: mailer.py / app.py 顶部加 from __future__ import annotations
2026-07-08 12:13:31 +08:00
16 changed files with 1309 additions and 99 deletions
+4
View File
@@ -0,0 +1,4 @@
instance/
__pycache__/
*.pyc
.env
+5 -5
View File
@@ -1,6 +1,6 @@
# Palladium Z1 Monitor
# Palladium Monitor
LD 逻辑板使用监控系统 — 解析 `test_server -short` 输出,可视化展示 Palladium Z1 仿真器的逻辑板状态、逻辑板利用率、作业信息和 T-Pod 可用性。
LD 逻辑板使用监控系统 — 解析 `test_server -short` 输出,可视化展示 Palladium 仿真器的逻辑板状态、逻辑板利用率、作业信息和 T-Pod 可用性。
## 功能
@@ -37,7 +37,7 @@ python3 app.py
### 方式二:API 推送 (collect.py + 一键安装)
在 Palladium Z1 主机上部署采集 agent,**推荐**用一键脚本(处理 env 加载、crontab、依赖):
在 Palladium 主机上部署采集 agent,**推荐**用一键脚本(处理 env 加载、crontab、依赖):
```bash
# 把 palladium-monitor/ 整个目录 scp 到 Z1 主机, 然后:
@@ -194,7 +194,7 @@ gunicorn --workers 2 --bind 0.0.0.0:5100 app:app
| `SMTP_USE_TLS` | ❌ | `true` | 是否用 STARTTLS (端口 587 时设为 `true`) |
| `SMTP_USE_SSL` | ❌ | `false` | 是否用隐式 SSL (端口 465 时设为 `true`) |
| `MAIL_FROM` | 启用时必填 | — | 发件人邮箱 |
| `MAIL_FROM_NAME` | ❌ | `Palladium Z1 Monitor` | 发件人显示名 |
| `MAIL_FROM_NAME` | ❌ | `Palladium Monitor` | 发件人显示名 |
| `MAIL_TO` | 启用时必填 | — | 收件人列表, 英文逗号分隔 (如 `alice@x.com,bob@y.com`) |
| `MAIL_SEND_HOUR` | ❌ | `9` | 每天发送时间-小时 (0-23) |
| `MAIL_SEND_MINUTE` | ❌ | `0` | 每天发送时间-分钟 (0-59) |
@@ -251,7 +251,7 @@ ExecStart=/usr/bin/gunicorn --workers 2 --bind 0.0.0.0:5100 app:app
#### 邮件内容示例
每日报告包含:
- **主题**: `[Palladium Z1] 每日报告 YYYY-MM-DD — 利用率 X.X%`
- **主题**: `[Palladium] 每日报告 YYYY-MM-DD — 利用率 X.X%`
- **正文 (HTML + 纯文本)**: 系统信息 / 当前指标卡片 / 近 24h 趋势统计 + 样本柱状图 / 离线板列表 / 活跃作业列表
- **多部分**: 同时发 HTML 和纯文本, 邮件客户端自动选最合适的渲染
Binary file not shown.
Binary file not shown.
+282 -14
View File
@@ -1,8 +1,9 @@
from typing import List, Dict, Tuple, Set, Any, Optional, Union
#!/usr/bin/env python3
"""
Palladium Z1 Monitor — LD逻辑板使用监控系统
Palladium Monitor — LD逻辑板使用监控系统
解析 `test_server -short` 输出,展示 Palladium Z1 仿真器的
解析 `test_server -short` 输出,展示 Palladium 仿真器的
逻辑板 (LD) 状态、逻辑板利用率、作业信息和 T-Pod 可用性。
支持两种数据来源:
@@ -10,6 +11,7 @@ Palladium Z1 Monitor — LD逻辑板使用监控系统
2. API 推送 — 在 Palladium 主机运行 collect.py 远程推送
"""
import os
import re
import io
@@ -121,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)
@@ -204,9 +224,9 @@ def parse_test_server_output(raw_text: str) -> dict:
})
continue
# Cluster 头: "Cluster 0 has 6 boards CCD: ONLINE"
# Cluster 头: "Cluster 0 has 6 boards CCD: ONLINE" (Z1) 或 "Cluster 0 has 6 logic drawers CCD: ONLINE" (Z2)
cluster_match = re.match(
r"Cluster\s+(\d+)\s+has\s+(\d+)\s+boards\s+CCD:\s*(\w+)", line
r"Cluster\s+(\d+)\s+has\s+(\d+)\s+(?:boards?|logic\s+drawers?)\s+CCD:\s*(\w+)", line
)
if cluster_match and result["racks"]:
current_cluster = int(cluster_match.group(1))
@@ -218,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:|$)",
@@ -515,10 +621,70 @@ 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):
"""单条快照详情。"""
snapshot = db.get_or_404(ScanSnapshot, sid)
snapshot = ScanSnapshot.query.get_or_404(sid)
stats = {
"total_boards": snapshot.total_boards,
"online_boards": snapshot.online_boards,
@@ -679,7 +845,7 @@ def api_history():
@app.route("/api/snapshot/<int:sid>/delete", methods=["POST"])
def delete_snapshot(sid):
"""删除指定快照。"""
snapshot = db.get_or_404(ScanSnapshot, sid)
snapshot = ScanSnapshot.query.get_or_404(sid)
db.session.delete(snapshot)
db.session.commit()
return redirect(url_for("history"))
@@ -698,6 +864,17 @@ def cleanup_old():
return jsonify({"ok": True, "deleted": count})
# ═══════════════════════════════════════════════════════════════════════════
# EmailConfig — 邮件配置持久化 (Web UI 写入, mailer 读取)
# ═══════════════════════════════════════════════════════════════════════════
class EmailConfig(db.Model):
__tablename__ = 'email_config'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(50), unique=True, nullable=False)
value = db.Column(db.Text, nullable=False, default='')
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ═══════════════════════════════════════════════════════════════════════════
# Email Routes (延迟导入 mailer, 避免在 mailer 缺失时整个 app 起不来)
# ═══════════════════════════════════════════════════════════════════════════
@@ -754,6 +931,81 @@ def email_preview():
headers={"Content-Disposition": 'inline; filename="mail-preview.txt"'})
@app.route("/email")
def email_config():
"""邮件配置页面。"""
return render_template("email_config.html")
@app.route("/api/email/config")
def email_config_api():
"""获取完整邮件配置 (含密码, 仅本地管理用)。"""
ml = _get_mailer()
if not ml:
return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500
cfg = ml.get_config_full()
errs = ml.config_errors(cfg)
return jsonify({
"ok": True,
"configured": len(errs) == 0,
"config_errors": errs,
**cfg,
})
@app.route("/api/email/config", methods=["POST"])
def email_config_save():
"""保存邮件配置到数据库。"""
data = request.get_json(silent=True) or {}
ml = _get_mailer()
if not ml:
return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500
try:
saved = ml.save_config(data)
except Exception as e:
return jsonify({"ok": False, "error": f"保存失败: {e}"}), 500
cfg = ml.get_config_full()
errs = ml.config_errors(cfg)
return jsonify({
"ok": True,
"saved": saved,
"enabled": cfg["enabled"],
"configured": len(errs) == 0,
"config_errors": errs,
})
@app.route("/api/email/reset", methods=["POST"])
def email_config_reset():
"""清除数据库配置, 回退到环境变量。"""
ml = _get_mailer()
if not ml:
return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500
try:
ml.reset_config()
except Exception as e:
return jsonify({"ok": False, "error": f"重置失败: {e}"}), 500
cfg = ml.get_config_full()
errs = ml.config_errors(cfg)
return jsonify({
"ok": True,
"message": "已清除数据库配置, 回退到环境变量",
"configured": len(errs) == 0,
"config_errors": errs,
})
@app.route("/api/email/test-send", methods=["POST"])
def email_test_send():
"""发送测试邮件 (从 UI 手动触发)。"""
ml = _get_mailer()
if not ml:
return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500
result = ml.send_report()
status = 200 if result["ok"] else 500
return jsonify(result), status
@app.route("/api/email/send", methods=["POST"])
def email_send():
"""
@@ -796,7 +1048,7 @@ def _scheduler_loop():
print("[scheduler] 邮件调度线程已启动", flush=True)
while True:
try:
cfg = mailer.get_config()
cfg = mailer.get_config_full()
if not cfg["enabled"]:
# 关闭, 每小时看一眼
time.sleep(3600)
@@ -1042,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(
@@ -1183,7 +1451,7 @@ if __name__ == "__main__":
seed_sample_data()
start_email_scheduler()
print("════════════════════════════════════════════════════════")
print(" Palladium Z1 Monitor")
print(" Palladium Monitor")
print(" http://0.0.0.0:5100")
print("════════════════════════════════════════════════════════")
app.run(host="0.0.0.0", port=5100, debug=True)
+9 -8
View File
@@ -1,8 +1,9 @@
from typing import List, Dict, Tuple, Set, Any, Optional, Union
#!/usr/bin/env python3
"""
collect.py — Palladium Z1 远程数据采集推送脚本
collect.py — Palladium 远程数据采集推送脚本
在 Palladium Z1 主机上运行,执行 test_server -short 并将结果推送到监控服务器。
在 Palladium 主机上运行,执行 test_server -short 并将结果推送到监控服务器。
用法:
python3 collect.py # 默认配置
@@ -46,7 +47,7 @@ DEFAULT_RETRY_DELAY = 5
class Logger:
"""同时输出到 stderr 和可选的日志文件。"""
def __init__(self, log_file: str | None = None):
def __init__(self, log_file: Optional[str] = None):
self.log_file = log_file
def _emit(self, level: str, msg: str, ts: str) -> None:
@@ -73,7 +74,7 @@ class Logger:
# ═══════════════════════════════════════════════════════════════════════════
# Command Execution
# ═══════════════════════════════════════════════════════════════════════════
def build_shell_cmd(cmd: str, env_script: str | None) -> str:
def build_shell_cmd(cmd: str, env_script: Optional[str]) -> str:
"""
如果指定了 env_script,把 source env 之后再执行 cmd 拼成一个 shell 串。
不指定则直接返回 cmd。
@@ -90,7 +91,7 @@ def shlex_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def run_test_server(cmd: str, timeout: int, env_script: str | None) -> tuple[str, int, str]:
def run_test_server(cmd: str, timeout: int, env_script: Optional[str]) -> Tuple[str, int, str]:
"""
执行 test_server -short, 返回 (stdout, returncode, stderr)。
timeout 触发时 subprocess.run 不会抛 (>=3.12 subprocessing 时可能抛), 这里显式处理。
@@ -101,7 +102,7 @@ def run_test_server(cmd: str, timeout: int, env_script: str | None) -> tuple[str
full_cmd,
shell=True,
executable="/bin/bash",
capture_output=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout,
)
except subprocess.TimeoutExpired as e:
@@ -120,7 +121,7 @@ def run_test_server(cmd: str, timeout: int, env_script: str | None) -> tuple[str
# ═══════════════════════════════════════════════════════════════════════════
def push_to_monitor(url: str, raw_output: str, source: str,
retries: int, retry_delay: int,
log: Logger) -> tuple[bool, dict | None, str]:
log: Logger) -> Tuple[bool, Optional[Dict], str]:
"""带重试的 HTTP 推送, 返回 (ok, json_body_or_None, error_msg)。"""
payload = {"raw_output": raw_output, "source": source}
last_err = ""
@@ -152,7 +153,7 @@ def push_to_monitor(url: str, raw_output: str, source: str,
# ═══════════════════════════════════════════════════════════════════════════
def main() -> int:
parser = argparse.ArgumentParser(
description="Palladium Z1 数据采集推送 (cron 友好, 带重试和日志)",
description="Palladium 数据采集推送 (cron 友好, 带重试和日志)",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--url", default=DEFAULT_URL,
+306 -32
View File
@@ -1,10 +1,12 @@
from typing import List, Dict, Tuple, Set, Any, Optional, Union
"""
mailer.py — Palladium Z1 每日报告邮件发送
mailer.py — Palladium 每日报告邮件发送
通过 SMTP 把"今日快照 + 24h 趋势"汇总发送到指定邮箱。
所有配置都从环境变量读 (生产建议用 systemd EnvironmentFile 或 .env)。
"""
import os
import smtplib
import socket
@@ -32,33 +34,172 @@ def _bool(name: str, default: bool = False) -> bool:
return default
def _int(name: str, default: int) -> int:
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "").strip())
except (TypeError, ValueError):
return default
def get_config() -> dict:
"""读取并返回当前生效的邮件配置 (用于 /api/email/status 显示)。"""
# ═══════════════════════════════════════════════════════════════════════════
# 数据库配置持久化
# ═══════════════════════════════════════════════════════════════════════════
def _get_email_config_model():
"""惰性获取 EmailConfig 模型 (避免循环导入)。"""
try:
from app import EmailConfig
return EmailConfig
except (ImportError, AttributeError):
return None
def _read_db_config() -> Optional[Dict[str, str]]:
"""从 EmailConfig 表读取配置, 返回 {key: value} dict, 无记录返回 None。"""
Model = _get_email_config_model()
if not Model:
return None
try:
with app.app_context():
rows = Model.query.all()
if not rows:
return None
return {r.key: r.value for r in rows}
except Exception:
return None
def _build_config_from_env() -> dict:
"""从环境变量读取配置 (原始逻辑)。"""
return {
"enabled": _bool("MAIL_ENABLED", False),
"smtp_host": os.environ.get("SMTP_HOST", "").strip(),
"smtp_port": _int("SMTP_PORT", 587),
"smtp_port": _int_env("SMTP_PORT", 587),
"smtp_user": os.environ.get("SMTP_USER", "").strip(),
"smtp_password_set": bool(os.environ.get("SMTP_PASSWORD", "").strip()),
"smtp_use_tls": _bool("SMTP_USE_TLS", True),
"smtp_use_ssl": _bool("SMTP_USE_SSL", False),
"from_addr": os.environ.get("MAIL_FROM", "").strip(),
"from_name": os.environ.get("MAIL_FROM_NAME", "Palladium Z1 Monitor").strip(),
"from_name": os.environ.get("MAIL_FROM_NAME", "Palladium Monitor").strip(),
"to_addrs": [a.strip() for a in os.environ.get("MAIL_TO", "").split(",") if a.strip()],
"send_hour": _int("MAIL_SEND_HOUR", 9), # 每天 09:00
"send_minute": _int("MAIL_SEND_MINUTE", 0),
"send_hour": _int_env("MAIL_SEND_HOUR", 9),
"send_minute": _int_env("MAIL_SEND_MINUTE", 0),
"monitor_base_url": os.environ.get("MONITOR_BASE_URL", "http://127.0.0.1:5100").strip(),
}
def config_errors(cfg: dict) -> list[str]:
def _build_config_from_dict(raw: Dict[str, str]) -> dict:
"""从数据库 {key: value} dict 构建配置。"""
def _s(key: str, default: str = "") -> str:
return raw.get(key, default).strip()
def _b(key: str, default: bool) -> bool:
v = raw.get(key, "").strip().lower()
return v in ("1", "true", "yes", "y", "on")
def _i(key: str, default: int) -> int:
try:
return int(raw.get(key, str(default)))
except (TypeError, ValueError):
return default
return {
"enabled": _b("enabled", False),
"smtp_host": _s("smtp_host"),
"smtp_port": _i("smtp_port", 587),
"smtp_user": _s("smtp_user"),
"smtp_password_set": bool(_s("smtp_password")),
"smtp_use_tls": _b("smtp_use_tls", True),
"smtp_use_ssl": _b("smtp_use_ssl", False),
"from_addr": _s("from_addr"),
"from_name": _s("from_name") or "Palladium Monitor",
"to_addrs": [a.strip() for a in _s("to_addrs").split(",") if a.strip()],
"send_hour": _i("send_hour", 9),
"send_minute": _i("send_minute", 0),
"monitor_base_url": _s("monitor_base_url") or "http://127.0.0.1:5100",
}
def save_config(data: dict) -> int:
"""保存配置到数据库。返回保存的键数。"""
Model = _get_email_config_model()
if not Model:
raise RuntimeError("EmailConfig 模型不可用")
to_addrs = data.get("to_addrs")
# 统一处理:支持中英文逗号、分号分隔,自动规范化为英文逗号分隔
if isinstance(to_addrs, list):
# 每个元素内部也可能含中英文逗号,全部拆开
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:
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(),
"smtp_host": str(data.get("smtp_host", "")).strip(),
"smtp_port": str(data.get("smtp_port", 587)),
"smtp_user": str(data.get("smtp_user", "")).strip(),
"smtp_password": str(data.get("smtp_password", "")),
"smtp_use_tls": str(data.get("smtp_use_tls", True)).lower(),
"smtp_use_ssl": str(data.get("smtp_use_ssl", False)).lower(),
"from_addr": str(data.get("from_addr", "")).strip(),
"from_name": str(data.get("from_name", "Palladium Monitor")).strip(),
"to_addrs": to_addrs_str.strip(),
"send_hour": str(data.get("send_hour", 9)),
"send_minute": str(data.get("send_minute", 0)),
"monitor_base_url": str(data.get("monitor_base_url", "http://127.0.0.1:5100")).strip(),
}
count = 0
with app.app_context():
for key, value in save_fields.items():
row = Model.query.filter_by(key=key).first()
if row:
row.value = value
else:
db.session.add(Model(key=key, value=value))
count += 1
db.session.commit()
return count
def reset_config() -> bool:
"""清除数据库配置, 回退到环境变量。"""
Model = _get_email_config_model()
if not Model:
return False
with app.app_context():
Model.query.delete()
db.session.commit()
return True
def get_config() -> dict:
"""读取邮件配置。优先使用数据库存储的配置, 回退到环境变量。"""
db_raw = _read_db_config()
if db_raw is not None:
return _build_config_from_dict(db_raw)
return _build_config_from_env()
def get_config_full() -> dict:
"""读取完整配置 (含密码原始值)。优先 DB, 回退到环境变量。"""
cfg = get_config()
db_raw = _read_db_config()
if db_raw is not None:
cfg["smtp_password"] = db_raw.get("smtp_password", "").strip()
else:
cfg["smtp_password"] = os.environ.get("SMTP_PASSWORD", "").strip()
return cfg
def config_errors(cfg: dict) -> List[str]:
"""检查配置是否完整可发送。返回缺失/错误项的列表 (空=OK)。"""
errs = []
if not cfg["enabled"]:
@@ -113,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
@@ -145,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(),
}
@@ -180,11 +407,11 @@ def _render_text(data: dict, base_url: str) -> str:
"""纯文本版本 (HTML 不支持时回退)。"""
latest = data["latest"]
if not latest:
return "Palladium Z1 Monitor — 当前无快照数据, 无法生成报告。\n"
return "Palladium Monitor — 当前无快照数据, 无法生成报告。\n"
lines = []
title_date = _fmt_ts(latest.timestamp, "%Y-%m-%d")
lines.append(f"Palladium Z1 每日报告 — {title_date}")
lines.append(f"Palladium 每日报告 — {title_date}")
lines.append("=" * 60)
lines.append(f"生成时间: {_fmt_ts(data['now_utc'])} UTC")
lines.append(f"快照时间: {_fmt_ts(latest.timestamp)}")
@@ -208,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"]
@@ -251,7 +492,7 @@ def _render_html(data: dict, base_url: str) -> str:
if not latest:
return (
'<html><body style="font-family:sans-serif;padding:20px">'
'<h2>Palladium Z1 Monitor</h2>'
'<h2>Palladium Monitor</h2>'
'<p>当前无快照数据, 无法生成报告。</p>'
'</body></html>'
)
@@ -302,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"""
@@ -340,12 +610,11 @@ 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">
<h1 style="margin:0;font-size:22px">⚡ Palladium Z1 每日报告</h1>
<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>
@@ -374,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}
@@ -402,7 +672,7 @@ def _render_html(data: dict, base_url: str) -> str:
<!-- Footer -->
<div style="background:#f8f9fa;padding:16px 28px;border-top:1px solid #e0e0e0;
font-size:12px;color:#888;text-align:center">
由 <a href="{base_url}/" style="color:#007bff;text-decoration:none">Palladium Z1 Monitor</a> 自动生成 ·
由 <a href="{base_url}/" style="color:#007bff;text-decoration:none">Palladium Monitor</a> 自动生成 ·
{_fmt_ts(data['now_utc'], '%Y-%m-%d %H:%M:%S')} UTC
</div>
@@ -414,7 +684,7 @@ def _render_html(data: dict, base_url: str) -> str:
# ═══════════════════════════════════════════════════════════════════════════
# 发送
# ═══════════════════════════════════════════════════════════════════════════
def render_report(data: dict, cfg: dict) -> tuple[str, str, str]:
def render_report(data: dict, cfg: dict) -> Tuple[str, str, str]:
"""返回 (subject, html, text)。"""
latest = data["latest"]
if latest:
@@ -422,13 +692,13 @@ def render_report(data: dict, cfg: dict) -> tuple[str, str, str]:
else:
title_date = _fmt_ts(data["now_utc"], "%Y-%m-%d")
util = f"{latest.utilization:.1f}%" if latest else ""
subject = f"[Palladium Z1] 每日报告 {title_date} — 利用率 {util}"
subject = f"[Palladium] 每日报告 {title_date} — 利用率 {util}"
html = _render_html(data, cfg["monitor_base_url"])
text = _render_text(data, cfg["monitor_base_url"])
return subject, html, text
def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> tuple[bool, str]:
def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> Tuple[bool, str]:
"""实际 SMTP 发送, 返回 (ok, error_msg)。"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
@@ -438,12 +708,16 @@ def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> tuple[bool, str
msg.attach(MIMEText(text, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
password = cfg.get("smtp_password", "")
if not password:
password = os.environ.get("SMTP_PASSWORD", "")
try:
if cfg["smtp_use_ssl"]:
# SMTP_SSL (port 465)
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], context=ctx, timeout=30) as s:
s.login(cfg["smtp_user"], os.environ.get("SMTP_PASSWORD", ""))
s.login(cfg["smtp_user"], password)
s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string())
else:
# STARTTLS (port 587) 或 明文 (port 25)
@@ -452,7 +726,7 @@ def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> tuple[bool, str
if cfg["smtp_use_tls"]:
s.starttls()
s.ehlo()
s.login(cfg["smtp_user"], os.environ.get("SMTP_PASSWORD", ""))
s.login(cfg["smtp_user"], password)
s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string())
return (True, "")
except smtplib.SMTPAuthenticationError as e:
@@ -467,12 +741,12 @@ def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> tuple[bool, str
return (False, f"{type(e).__name__}: {e}")
def send_report(cfg: dict | None = None) -> dict:
def send_report(cfg: Optional[Dict] = None) -> dict:
"""
收集数据 → 渲染 → 发送。
返回: {"ok": bool, "subject": str, "recipients": [...], "error": str, "preview_text": str}
"""
cfg = cfg or get_config()
cfg = cfg or get_config_full()
if not cfg["enabled"]:
return {"ok": False, "error": "邮件功能未启用 (MAIL_ENABLED 未设 true)", "subject": "", "recipients": []}
errs = config_errors(cfg)
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=Palladium Monitor
After=network.target
[Service]
Type=simple
WorkingDirectory=/root/palladium-monitor
ExecStart=/usr/bin/python3 /root/palladium-monitor/app.py
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
# 安全加固
NoNewPrivileges=yes
PrivateTmp=yes
# 邮件相关环境变量(按需取消注释并修改)
# Environment="SMTP_HOST=smtp.example.com"
# Environment="SMTP_PORT=587"
# Environment="SMTP_USE_TLS=true"
# Environment="SMTP_USER=your@email.com"
# Environment="SMTP_PASSWORD=yourpassword"
# Environment="MAIL_FROM=your@email.com"
# Environment="MAIL_TO=admin@example.com"
# Environment="MAIL_ENABLED=true"
# Environment="MAIL_SEND_HOUR=9"
# Environment="ALLOW_EMAIL_TRIGGER=true"
[Install]
WantedBy=multi-user.target
-33
View File
@@ -1,33 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
.pytest_cache/
.mypy_cache/
# Virtual env
venv/
env/
.venv/
# IDE
.vscode/
.idea/
*.swp
.DS_Store
# 项目数据 (含真实快照, 不上传)
instance/*.db
instance/*.sqlite
instance/*.sqlite3
# 日志
*.log
logs/
# 测试 fixture
test_fixtures/
/tmp/
+270 -1
View File
@@ -1,5 +1,5 @@
/* ═══════════════════════════════════════════════════════════════
Palladium Z1 Monitor — Dark Theme Dashboard
Palladium Monitor — Dark Theme Dashboard
═══════════════════════════════════════════════════════════════ */
:root {
--bg-primary: #0d1117;
@@ -690,6 +690,271 @@ body {
padding: 16px;
}
/* ─── 邮件配置页面 ─── */
.page-header {
margin-bottom: 20px;
}
.page-header h1 {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
}
.page-subtitle {
color: var(--text-secondary);
font-size: 13px;
margin-top: 4px;
}
/* 状态卡片 */
.config-status-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
transition: border-color 0.2s;
}
.config-status-card.status-ok { border-color: var(--green); background: rgba(63,185,80,0.06); }
.config-status-card.status-error { border-color: var(--red); background: rgba(248,81,73,0.06); }
.config-status-card.status-warning { border-color: var(--orange); background: rgba(210,153,34,0.06); }
.status-icon { font-size: 24px; flex-shrink: 0; }
.status-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.status-detail { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
/* 配置卡片 */
.config-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
margin-bottom: 16px;
}
.config-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.config-card-header h2 { font-size: 16px; font-weight: 700; }
.config-source-badge {
font-size: 11px;
color: var(--accent);
background: rgba(88,166,255,0.1);
padding: 2px 8px;
border-radius: 4px;
}
/* 表单通用 */
.form-group { margin-bottom: 14px; }
.form-group label {
display: block;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 5px;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group input[type="url"],
.form-group input[type="password"],
.form-group input[type="number"],
.form-group textarea {
width: 100%;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-group textarea { resize: vertical; line-height: 1.5; }
.label-hint { color: var(--text-muted); font-weight: 400; font-size: 11px; }
/* 小字段 (行内并排) */
.form-group-small { margin-bottom: 14px; }
/* 表单分区 */
.form-section {
padding: 16px 0;
border-top: 1px solid var(--border);
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-section:first-of-type { border-top: none; padding-top: 0; }
.form-section-title {
grid-column: 1 / -1;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
padding-top: 4px;
}
/* 输入框提示文字 */
.input-hint {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* 全宽字段 */
.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;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
}
.toggle-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.toggle-desc { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
cursor: pointer;
}
.toggle-switch input { display: none; }
.toggle-slider {
position: absolute;
inset: 0;
background: var(--border-light);
border-radius: 12px;
transition: 0.2s;
}
.toggle-slider:before {
content: "";
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--accent); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(20px); }
/* 密码输入 */
.password-input-wrap { position: relative; }
.password-input-wrap input { padding-right: 36px; }
.password-toggle {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 4px;
color: var(--text-secondary);
}
/* 复选框组 */
.checkbox-group { display: flex; gap: 16px; padding-top: 4px; }
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
accent-color: var(--accent);
width: 15px;
height: 15px;
}
/* 操作按钮 */
.form-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding-top: 18px;
border-top: 1px solid var(--border);
margin-top: 6px;
}
.form-actions .btn { white-space: nowrap; }
.btn-outline {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-outline:hover { border-color: var(--text-secondary); color: var(--text-primary); }
.form-actions .btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* 信息卡片 */
.info-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 14px 18px;
}
.info-card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.info-card ul { margin: 0; padding-left: 18px; }
.info-card li {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 4px;
line-height: 1.5;
}
/* ─── Responsive ─── */
@media (max-width: 768px) {
.topnav { padding: 0 12px; }
@@ -698,4 +963,8 @@ body {
.refresh-controls { margin-left: 0; }
.boards-grid { grid-template-columns: 1fr; }
.metrics-grid { grid-template-columns: 1fr; }
.form-section { grid-template-columns: 1fr; }
.toggle-row { flex-direction: column; align-items: flex-start; gap: 8px; }
.form-actions { flex-direction: column; }
.form-actions .btn { width: 100%; text-align: center; }
}
+5 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Palladium Z1 Monitor{% endblock %}</title>
<title>{% block title %}Palladium Monitor{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
@@ -11,11 +11,13 @@
<nav class="topnav">
<div class="nav-brand">
<span class="nav-logo"></span>
<span class="nav-title">Palladium Z1 Monitor</span>
<span class="nav-title">Palladium Monitor</span>
</div>
<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>
</nav>
@@ -35,7 +37,7 @@
<div class="modal-body">
<p class="modal-hint">在 Palladium 主机执行 <code>test_server -short</code>,将输出粘贴到下方:</p>
<textarea name="raw_output" rows="20" class="paste-textarea"
placeholder="Emulator: sc01_emu Hardware: Palladium Z1 ..."></textarea>
placeholder="Emulator: sc01_emu Hardware: Palladium ..."></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closePasteModal()">取消</button>
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}仪表板 — Palladium Z1 Monitor{% endblock %}
{% block title %}仪表板 — Palladium Monitor{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
+334
View File
@@ -0,0 +1,334 @@
{% extends "base.html" %}
{% block title %}邮件配置 — Palladium Monitor{% endblock %}
{% block content %}
<!-- ═══ 页面标题 ═══ -->
<div class="page-header">
<h1>📧 邮件配置</h1>
<p class="page-subtitle">配置每日监控报告邮件发送。设置保存后立即生效,无需重启。</p>
</div>
<!-- ═══ 状态卡片 ═══ -->
<div class="config-status-card" id="statusCard">
<div class="status-icon" id="statusIcon"></div>
<div class="status-text">
<div class="status-title" id="statusTitle">加载中...</div>
<div class="status-detail" id="statusDetail">正在获取当前配置...</div>
</div>
</div>
<!-- ═══ 配置表单 ═══ -->
<div class="config-card">
<div class="config-card-header">
<h2>⚙️ SMTP 配置</h2>
<span class="config-source-badge" id="configSource"></span>
</div>
<form id="emailForm" onsubmit="return false;">
<!-- 启用开关 -->
<div class="form-row toggle-row">
<label class="toggle-label">
<span class="toggle-title">启用每日邮件报告</span>
<span class="toggle-desc">开启后每天定时发送监控报告</span>
</label>
<label class="toggle-switch">
<input type="checkbox" id="cfg_enabled">
<span class="toggle-slider"></span>
</label>
</div>
<!-- SMTP 基础 -->
<div class="form-section">
<div class="form-group">
<label for="cfg_smtp_host">SMTP 服务器</label>
<input type="text" id="cfg_smtp_host" placeholder="smtp.example.com" autocomplete="off">
</div>
<div class="form-group form-group-small">
<label for="cfg_smtp_port">端口</label>
<input type="number" id="cfg_smtp_port" placeholder="587" min="1" max="65535">
</div>
<div class="form-group form-group-small">
<label>加密方式</label>
<div class="checkbox-group">
<label class="checkbox-label"><input type="checkbox" id="cfg_smtp_use_tls"> STARTTLS</label>
<label class="checkbox-label"><input type="checkbox" id="cfg_smtp_use_ssl"> SMTP SSL</label>
</div>
</div>
</div>
<!-- 认证 -->
<div class="form-section">
<div class="form-group">
<label for="cfg_smtp_user">用户名</label>
<input type="text" id="cfg_smtp_user" placeholder="your@email.com" autocomplete="off">
</div>
<div class="form-group">
<label for="cfg_smtp_password">密码 / 授权码</label>
<div class="password-input-wrap">
<input type="password" id="cfg_smtp_password" placeholder="输入密码">
<button type="button" class="password-toggle" onclick="togglePassword()">👁</button>
</div>
</div>
</div>
<!-- 发件人 -->
<div class="form-section">
<div class="form-group">
<label for="cfg_from_addr">发件邮箱</label>
<input type="email" id="cfg_from_addr" placeholder="monitor@example.com" autocomplete="off">
</div>
<div class="form-group">
<label for="cfg_from_name">发件人名称</label>
<input type="text" id="cfg_from_name" placeholder="Palladium Monitor">
</div>
</div>
<!-- 收件人 -->
<div class="form-section">
<div class="form-group full-width">
<label for="cfg_to_addrs">收件邮箱 <span class="label-hint">(多个邮箱用逗号分隔)</span></label>
<textarea id="cfg_to_addrs" rows="2" placeholder="admin@example.com, dev@example.com"></textarea>
</div>
</div>
<!-- 发送邮件时间 -->
<div class="form-section">
<div class="form-section-title">🕐 发送邮件时间</div>
<div class="form-group form-group-small">
<label for="cfg_send_hour">发送时间 (小时)</label>
<input type="number" id="cfg_send_hour" placeholder="9" min="0" max="23" step="1">
<span class="input-hint">023 (24小时制)</span>
</div>
<div class="form-group form-group-small">
<label for="cfg_send_minute">发送时间 (分钟)</label>
<input type="number" id="cfg_send_minute" placeholder="0" min="0" max="59" step="1">
<span class="input-hint">059</span>
</div>
<div class="form-group">
<label for="cfg_monitor_base_url">监控服务器地址</label>
<input type="url" id="cfg_monitor_base_url" placeholder="http://127.0.0.1:5100">
<span class="input-hint">邮件中「查看仪表板」链接的跳转地址</span>
</div>
</div>
</form>
<!-- 操作按钮 -->
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="saveConfig()">💾 保存配置</button>
<button type="button" class="btn btn-secondary" onclick="testSend()" id="testSendBtn">📤 发送测试邮件</button>
<a href="/api/email/preview" target="_blank" class="btn btn-secondary">👁 预览报告</a>
<button type="button" class="btn btn-outline" onclick="resetConfig()">🔄 恢复环境变量默认</button>
</div>
</div>
<!-- 提示信息 -->
<div class="info-card">
<h3>️ 说明</h3>
<ul>
<li>配置保存到数据库后立即可用,邮件调度器会自动读取新配置。</li>
<li>如果数据库和环境中都未配置,回退到环境变量 (SMTP_HOST, SMTP_USER 等)。</li>
<li>「恢复环境变量默认」会清除数据库中的配置,回退到环境变量。</li>
<li>手动发送测试邮件需先确保配置完整(无错误提示)。</li>
</ul>
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
const fields = [
'enabled', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
'smtp_use_tls', 'smtp_use_ssl', 'from_addr', 'from_name',
'to_addrs', 'send_hour', 'send_minute', 'monitor_base_url'
];
function setBusy(busy) {
const btns = document.querySelectorAll('.form-actions .btn');
btns.forEach(b => b.disabled = busy);
}
function showError(msg) {
document.getElementById('statusIcon').textContent = '❌';
document.getElementById('statusTitle').textContent = '配置错误';
document.getElementById('statusDetail').textContent = msg;
document.getElementById('statusCard').className = 'config-status-card status-error';
}
function showConfigured(enabled) {
document.getElementById('statusIcon').textContent = enabled ? '✅' : '⚠️';
document.getElementById('statusTitle').textContent = enabled
? '配置完整'
: '配置不完整';
document.getElementById('statusDetail').textContent = enabled
? '所有必填项已填写,邮件功能已就绪。'
: '缺少必要的配置项,邮件无法发送。';
document.getElementById('statusCard').className = 'config-status-card '
+ (enabled ? 'status-ok' : 'status-warning');
}
function showErrors(errs) {
document.getElementById('statusIcon').textContent = '⚠️';
document.getElementById('statusTitle').textContent = '配置不完整';
document.getElementById('statusDetail').textContent = errs.join('');
document.getElementById('statusCard').className = 'config-status-card status-warning';
}
function loadConfig() {
fetch('/api/email/config')
.then(r => r.json())
.then(data => {
if (!data.ok) {
showError(data.error || '获取配置失败');
return;
}
// 填充表单
document.getElementById('cfg_enabled').checked = !!data.enabled;
document.getElementById('cfg_smtp_host').value = data.smtp_host || '';
document.getElementById('cfg_smtp_port').value = data.smtp_port || 587;
document.getElementById('cfg_smtp_user').value = data.smtp_user || '';
document.getElementById('cfg_smtp_password').value = data.smtp_password || '';
document.getElementById('cfg_smtp_use_tls').checked = !!data.smtp_use_tls;
document.getElementById('cfg_smtp_use_ssl').checked = !!data.smtp_use_ssl;
document.getElementById('cfg_from_addr').value = data.from_addr || '';
document.getElementById('cfg_from_name').value = data.from_name || '';
document.getElementById('cfg_to_addrs').value = (data.to_addrs || []).join(', ');
document.getElementById('cfg_send_hour').value = data.send_hour != null ? data.send_hour : 9;
document.getElementById('cfg_send_minute').value = data.send_minute != null ? data.send_minute : 0;
document.getElementById('cfg_monitor_base_url').value = data.monitor_base_url || '';
// 显示状态
if (data.configured) {
showConfigured(data.enabled);
} else if (data.config_errors && data.config_errors.length > 0) {
showErrors(data.config_errors);
} else {
showConfigured(!!data.enabled);
}
// 来源标识
document.getElementById('configSource').textContent = '数据库配置';
})
.catch(err => {
showError('网络请求失败: ' + err.message);
});
}
window.saveConfig = function() {
setBusy(true);
const data = {};
data.enabled = document.getElementById('cfg_enabled').checked;
data.smtp_host = document.getElementById('cfg_smtp_host').value.trim();
data.smtp_port = parseInt(document.getElementById('cfg_smtp_port').value) || 587;
data.smtp_user = document.getElementById('cfg_smtp_user').value.trim();
data.smtp_password = document.getElementById('cfg_smtp_password').value;
data.smtp_use_tls = document.getElementById('cfg_smtp_use_tls').checked;
data.smtp_use_ssl = document.getElementById('cfg_smtp_use_ssl').checked;
data.from_addr = document.getElementById('cfg_from_addr').value.trim();
data.from_name = document.getElementById('cfg_from_name').value.trim();
const toRaw = document.getElementById('cfg_to_addrs').value.trim();
data.to_addrs = toRaw ? toRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
// 校验时间范围
let h = parseInt(document.getElementById('cfg_send_hour').value);
let m = parseInt(document.getElementById('cfg_send_minute').value);
if (isNaN(h) || h < 0 || h > 23) {
setBusy(false);
showError('发送时间(小时)必须在 0–23 之间');
document.getElementById('cfg_send_hour').focus();
return;
}
if (isNaN(m) || m < 0 || m > 59) {
setBusy(false);
showError('发送时间(分钟)必须在 0–59 之间');
document.getElementById('cfg_send_minute').focus();
return;
}
data.send_hour = h;
data.send_minute = m;
data.monitor_base_url = document.getElementById('cfg_monitor_base_url').value.trim();
fetch('/api/email/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
setBusy(false);
if (result.ok) {
if (result.configured) {
showConfigured(result.enabled);
} else if (result.config_errors && result.config_errors.length > 0) {
showErrors(result.config_errors);
}
} else {
showError(result.error || '保存失败');
}
})
.catch(err => {
setBusy(false);
showError('保存失败: ' + err.message);
});
};
window.testSend = function() {
if (!confirm('确定要发送一封测试邮件到当前配置的收件人吗?')) return;
setBusy(true);
const btn = document.getElementById('testSendBtn');
btn.textContent = '⏳ 发送中...';
fetch('/api/email/test-send', { method: 'POST' })
.then(r => r.json())
.then(result => {
setBusy(false);
btn.textContent = '📤 发送测试邮件';
if (result.ok) {
document.getElementById('statusIcon').textContent = '✅';
document.getElementById('statusTitle').textContent = '发送成功';
document.getElementById('statusDetail').textContent =
'已发送到: ' + (result.recipients || []).join(', ');
document.getElementById('statusCard').className = 'config-status-card status-ok';
} else {
showError(result.error || '发送失败');
}
})
.catch(err => {
setBusy(false);
btn.textContent = '📤 发送测试邮件';
showError('发送失败: ' + err.message);
});
};
window.resetConfig = function() {
if (!confirm('确定要清除数据库配置并回退到环境变量吗?')) return;
setBusy(true);
fetch('/api/email/reset', { method: 'POST' })
.then(r => r.json())
.then(result => {
setBusy(false);
if (result.ok) {
document.getElementById('configSource').textContent = '环境变量';
loadConfig(); // 重新加载
} else {
showError(result.error || '重置失败');
}
})
.catch(err => {
setBusy(false);
showError('重置失败: ' + err.message);
});
};
window.togglePassword = function() {
const inp = document.getElementById('cfg_smtp_password');
inp.type = inp.type === 'password' ? 'text' : 'password';
};
// 初始化
loadConfig();
})();
</script>
{% endblock %}
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}历史记录 — Palladium Z1 Monitor{% endblock %}
{% block title %}历史记录 — Palladium Monitor{% endblock %}
{% block content %}
<div class="section-header">
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}快照 #{{ snapshot.id }} — Palladium Z1 Monitor{% endblock %}
{% block title %}快照 #{{ snapshot.id }} — Palladium Monitor{% endblock %}
{% block content %}
<div class="snapshot-header">
+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 %}