Compare commits
19 Commits
91f52941d6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c42d802c0 | |||
| 4a9c527d6e | |||
| 29ddfccc7b | |||
| c21f5028dd | |||
| e27655784f | |||
| 793538e19d | |||
| b4210d74b9 | |||
| 7e0261341e | |||
| 07867eaefc | |||
| b5205d0543 | |||
| d8eb6824b8 | |||
| f3145369c4 | |||
| 83621cb213 | |||
| ac9a060b46 | |||
| 6726524c50 | |||
| 89dc6b4449 | |||
| 3ab73805a3 | |||
| da2fd92f07 | |||
| 5250c2960c |
@@ -0,0 +1,4 @@
|
||||
instance/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -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.
@@ -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,7 +11,6 @@ Palladium Z1 Monitor — LD逻辑板使用监控系统
|
||||
2. API 推送 — 在 Palladium 主机运行 collect.py 远程推送
|
||||
"""
|
||||
|
||||
from __future__ import annotations # PEP 563: 兼容 Python 3.7+ (避免 PEP 585 泛型语法)
|
||||
|
||||
import os
|
||||
import re
|
||||
@@ -123,6 +123,24 @@ class JobInfo(db.Model):
|
||||
reserved_key = db.Column(db.String(100), default="")
|
||||
|
||||
|
||||
class DomainDetail(db.Model):
|
||||
"""逐域归属信息 (详细版 test_server 输出, 每块 LD 的 8 个域各自有 Owner/PID/T-Pod/Design)。"""
|
||||
__tablename__ = "domain_details"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
snapshot_id = db.Column(db.Integer, db.ForeignKey("snapshots.id"), nullable=False, index=True)
|
||||
rack = db.Column(db.Integer)
|
||||
cluster = db.Column(db.Integer)
|
||||
ld_index = db.Column(db.Integer)
|
||||
domain_index = db.Column(db.Integer) # 0–7
|
||||
domain_label = db.Column(db.String(10)) # "0.0", "0.1", ... "29.7"
|
||||
owner = db.Column(db.String(100), default="")
|
||||
pid = db.Column(db.String(200), default="")
|
||||
t_pod = db.Column(db.String(200), default="")
|
||||
design = db.Column(db.String(200), default="")
|
||||
elap_time = db.Column(db.String(20), default="")
|
||||
reserved_key = db.Column(db.String(100), default="")
|
||||
|
||||
|
||||
class PodInfo(db.Model):
|
||||
__tablename__ = "pod_infos"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -206,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))
|
||||
@@ -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,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,
|
||||
@@ -681,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"))
|
||||
@@ -700,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 起不来)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -756,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():
|
||||
"""
|
||||
@@ -798,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)
|
||||
@@ -1044,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(
|
||||
@@ -1185,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
@@ -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,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from typing import List, Dict, Tuple, Set, Any, Optional, Union
|
||||
"""
|
||||
mailer.py — Palladium Z1 每日报告邮件发送
|
||||
mailer.py — Palladium 每日报告邮件发送
|
||||
|
||||
通过 SMTP 把"今日快照 + 24h 趋势"汇总发送到指定邮箱。
|
||||
所有配置都从环境变量读 (生产建议用 systemd EnvironmentFile 或 .env)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations # PEP 563: 让所有 type hint 变字符串, 兼容 Python 3.7+
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
@@ -34,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"]:
|
||||
@@ -115,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
|
||||
@@ -147,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(),
|
||||
}
|
||||
@@ -182,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)}")
|
||||
@@ -210,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"]
|
||||
@@ -253,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>'
|
||||
)
|
||||
@@ -304,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"""
|
||||
@@ -342,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>
|
||||
|
||||
@@ -376,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}
|
||||
@@ -404,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>
|
||||
|
||||
@@ -416,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:
|
||||
@@ -424,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
|
||||
@@ -440,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)
|
||||
@@ -454,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:
|
||||
@@ -469,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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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">0–23 (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">0–59</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,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}历史记录 — Palladium Z1 Monitor{% endblock %}
|
||||
{% block title %}历史记录 — Palladium Monitor{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section-header">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user