29ddfccc7b
1. Z1 详细输出把作业存在 DomainDetail 而非 JobInfo, 增加回退逻辑 按 (owner, pid, design) 去重统计活跃作业数 2. 移除利用率进度条的嵌套 div (Gmail 会过滤导致后续内容丢失) 改为单元素蓝色进度条 3. 活跃作业卡片显示实际统计数而非 Snapshot 缓存值
777 lines
33 KiB
Python
777 lines
33 KiB
Python
from typing import List, Dict, Tuple, Set, Any, Optional, Union
|
|
"""
|
|
mailer.py — Palladium 每日报告邮件发送
|
|
|
|
通过 SMTP 把"今日快照 + 24h 趋势"汇总发送到指定邮箱。
|
|
所有配置都从环境变量读 (生产建议用 systemd EnvironmentFile 或 .env)。
|
|
"""
|
|
|
|
|
|
import os
|
|
import smtplib
|
|
import socket
|
|
import ssl
|
|
from datetime import datetime, timezone, timedelta
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from email.utils import formataddr, format_datetime
|
|
|
|
from app import (
|
|
ScanSnapshot, BoardStatus, JobInfo, db, app,
|
|
DOMAIN_LABELS, DOMAIN_COLORS,
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 配置 (从环境变量读)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
def _bool(name: str, default: bool = False) -> bool:
|
|
v = os.environ.get(name, "").strip().lower()
|
|
if v in ("1", "true", "yes", "y", "on"):
|
|
return True
|
|
if v in ("0", "false", "no", "n", "off", ""):
|
|
return False
|
|
return default
|
|
|
|
|
|
def _int_env(name: str, default: int) -> int:
|
|
try:
|
|
return int(os.environ.get(name, "").strip())
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 数据库配置持久化
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
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_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 Monitor").strip(),
|
|
"to_addrs": [a.strip() for a in os.environ.get("MAIL_TO", "").split(",") if a.strip()],
|
|
"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 _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"]:
|
|
return errs # 禁用时不做任何检查
|
|
if not cfg["smtp_host"]:
|
|
errs.append("SMTP_HOST 未设置")
|
|
if not cfg["smtp_user"]:
|
|
errs.append("SMTP_USER 未设置")
|
|
if not cfg["smtp_password_set"]:
|
|
errs.append("SMTP_PASSWORD 未设置")
|
|
if not cfg["from_addr"]:
|
|
errs.append("MAIL_FROM 未设置")
|
|
if not cfg["to_addrs"]:
|
|
errs.append("MAIL_TO 未设置 (逗号分隔多个地址)")
|
|
if not (0 <= cfg["send_hour"] <= 23):
|
|
errs.append(f"MAIL_SEND_HOUR 越界: {cfg['send_hour']}")
|
|
if not (0 <= cfg["send_minute"] <= 59):
|
|
errs.append(f"MAIL_SEND_MINUTE 越界: {cfg['send_minute']}")
|
|
return errs
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 数据收集
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
def collect_report_data() -> dict:
|
|
"""
|
|
拉取邮件所需的所有数据。
|
|
返回 dict 包含: latest, boards_offline, jobs_active, stats_24h, samples_24h。
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
cutoff_24h = now - timedelta(hours=24)
|
|
cutoff_yesterday = now - timedelta(hours=48)
|
|
|
|
# ── 最新快照 ──
|
|
latest = ScanSnapshot.query.order_by(ScanSnapshot.timestamp.desc()).first()
|
|
if not latest:
|
|
return {
|
|
"latest": None,
|
|
"boards_offline": [],
|
|
"jobs_active": [],
|
|
"stats_24h": None,
|
|
"samples_24h": [],
|
|
"now_utc": now,
|
|
"now_local": datetime.now(),
|
|
}
|
|
|
|
# ── 离线板列表 ──
|
|
boards_offline = BoardStatus.query.filter_by(
|
|
snapshot_id=latest.id, status="OFFLINE"
|
|
).order_by(BoardStatus.rack, BoardStatus.cluster, BoardStatus.ld_index).all()
|
|
|
|
# ── 活跃作业 ──
|
|
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 = set()
|
|
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", ""):
|
|
active_set.add((owner, pid, dr.design or "—", dr.t_pod or "—"))
|
|
if active_set:
|
|
# 构造虚拟 job 对象供渲染 (兼容 jobs_active 格式)
|
|
class _VJob:
|
|
pass
|
|
for idx, (o, p, design, tpod) in enumerate(sorted(active_set), 1):
|
|
j = _VJob()
|
|
j.job_index = idx
|
|
j.owner = o
|
|
j.pid = p
|
|
j.t_pod = tpod
|
|
j.design = design
|
|
j.elap_time = ""
|
|
jobs_active.append(j)
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
|
|
# ── 24h 统计 ──
|
|
snaps_24h = ScanSnapshot.query.filter(
|
|
ScanSnapshot.timestamp >= cutoff_24h
|
|
).order_by(ScanSnapshot.timestamp.asc()).all()
|
|
|
|
if snaps_24h:
|
|
utils = [s.utilization for s in snaps_24h]
|
|
used_counts = [s.used_boards for s in snaps_24h]
|
|
stats_24h = {
|
|
"count": len(snaps_24h),
|
|
"util_min": min(utils),
|
|
"util_max": max(utils),
|
|
"util_avg": round(sum(utils) / len(utils), 1),
|
|
"used_min": min(used_counts),
|
|
"used_max": max(used_counts),
|
|
"used_avg": round(sum(used_counts) / len(used_counts), 1),
|
|
"first_ts": snaps_24h[0].timestamp,
|
|
"last_ts": snaps_24h[-1].timestamp,
|
|
}
|
|
# 趋势样本: 最多 12 个等距点 (避免邮件太长)
|
|
if len(snaps_24h) > 12:
|
|
step = len(snaps_24h) // 12
|
|
samples = snaps_24h[::step]
|
|
if samples[-1] != snaps_24h[-1]:
|
|
samples.append(snaps_24h[-1])
|
|
else:
|
|
samples = snaps_24h
|
|
samples_24h = samples
|
|
else:
|
|
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(),
|
|
}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 模板渲染
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
def _fmt_ts(ts, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
|
if ts is None:
|
|
return "—"
|
|
if ts.tzinfo is None:
|
|
ts = ts.replace(tzinfo=timezone.utc)
|
|
return ts.astimezone().strftime(fmt)
|
|
|
|
|
|
def _util_color_class(util: float) -> str:
|
|
"""根据利用率返回 CSS class 名 (用于 HTML 颜色条)。"""
|
|
if util >= 80:
|
|
return "util-high"
|
|
if util >= 50:
|
|
return "util-mid"
|
|
return "util-low"
|
|
|
|
|
|
def _render_text(data: dict, base_url: str) -> str:
|
|
"""纯文本版本 (HTML 不支持时回退)。"""
|
|
latest = data["latest"]
|
|
if not latest:
|
|
return "Palladium Monitor — 当前无快照数据, 无法生成报告。\n"
|
|
|
|
lines = []
|
|
title_date = _fmt_ts(latest.timestamp, "%Y-%m-%d")
|
|
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)}")
|
|
lines.append("")
|
|
|
|
# 系统信息
|
|
lines.append("【系统信息】")
|
|
lines.append(f" 仿真器: {latest.emulator or '—'}")
|
|
lines.append(f" 硬件: {latest.hardware or '—'}")
|
|
lines.append(f" ConfigMgr:{latest.configmgr or '—'}")
|
|
lines.append(f" 系统状态: {latest.system_status or '—'}")
|
|
lines.append(f" 数据来源: {latest.source or '—'}")
|
|
lines.append("")
|
|
|
|
# 当前指标
|
|
lines.append("【当前指标】")
|
|
lines.append(f" 逻辑板总数: {latest.total_boards}")
|
|
lines.append(f" 在线: {latest.online_boards}")
|
|
lines.append(f" 离线: {latest.offline_boards}")
|
|
lines.append(f" 利用率: {latest.utilization:.1f}% (使用 {latest.used_boards}/{latest.total_boards})")
|
|
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"]
|
|
lines.append("【近 24 小时趋势】")
|
|
lines.append(f" 快照数: {s['count']}")
|
|
lines.append(f" 利用率: min={s['util_min']:.1f}% max={s['util_max']:.1f}% avg={s['util_avg']:.1f}%")
|
|
lines.append(f" 使用板: min={s['used_min']} max={s['used_max']} avg={s['used_avg']:.1f}")
|
|
lines.append("")
|
|
lines.append(" 趋势样本 (最多 12 个):")
|
|
for s in data["samples_24h"]:
|
|
bar = "█" * int(round(s.utilization / 5)) # 1 个 █ = 5%
|
|
lines.append(f" {_fmt_ts(s.timestamp, '%m-%d %H:%M')} {s.utilization:5.1f}% {bar}")
|
|
lines.append("")
|
|
|
|
# 离线板
|
|
if data["boards_offline"]:
|
|
lines.append(f"【离线板列表】 ({len(data['boards_offline'])} 块)")
|
|
for b in data["boards_offline"]:
|
|
lines.append(f" Rack {b.rack} / Cluster {b.cluster} / LD {b.ld_index} CCD={b.ccd or '?'}")
|
|
lines.append("")
|
|
else:
|
|
lines.append("【离线板列表】 无 (全部在线)")
|
|
lines.append("")
|
|
|
|
# 活跃作业
|
|
if data["jobs_active"]:
|
|
lines.append(f"【活跃作业】 ({len(data['jobs_active'])} 个)")
|
|
for j in data["jobs_active"]:
|
|
t_pod = j.t_pod or "—"
|
|
lines.append(f" #{j.job_index} {j.owner} {j.pid} T-Pod: {t_pod} {j.design or '—'} ({j.elap_time or '—'})")
|
|
lines.append("")
|
|
|
|
lines.append("─" * 60)
|
|
lines.append(f"查看完整仪表板: {base_url}/")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _render_html(data: dict, base_url: str) -> str:
|
|
"""HTML 版本 (multipart/alternative 中优先使用)。"""
|
|
latest = data["latest"]
|
|
if not latest:
|
|
return (
|
|
'<html><body style="font-family:sans-serif;padding:20px">'
|
|
'<h2>Palladium Monitor</h2>'
|
|
'<p>当前无快照数据, 无法生成报告。</p>'
|
|
'</body></html>'
|
|
)
|
|
|
|
title_date = _fmt_ts(latest.timestamp, "%Y-%m-%d")
|
|
util_class = _util_color_class(latest.utilization)
|
|
util_bar_width = f"{min(latest.utilization, 100):.1f}%"
|
|
|
|
# 离线板 HTML
|
|
if data["boards_offline"]:
|
|
offline_rows = "".join(
|
|
f"<tr><td>Rack {b.rack}</td><td>Cluster {b.cluster}</td>"
|
|
f"<td>LD {b.ld_index}</td><td>{b.ccd or '—'}</td></tr>"
|
|
for b in data["boards_offline"]
|
|
)
|
|
offline_html = f"""
|
|
<table style="border-collapse:collapse;margin-top:6px">
|
|
<thead><tr style="background:#dc3545;color:#fff">
|
|
<th style="padding:6px 12px">Rack</th>
|
|
<th style="padding:6px 12px">Cluster</th>
|
|
<th style="padding:6px 12px">LD</th>
|
|
<th style="padding:6px 12px">CCD</th>
|
|
</tr></thead>
|
|
<tbody>{offline_rows}</tbody>
|
|
</table>"""
|
|
else:
|
|
offline_html = '<p style="color:#28a745;margin:6px 0">✓ 无离线板, 全部在线</p>'
|
|
|
|
# 作业 HTML
|
|
if data["jobs_active"]:
|
|
job_rows = "".join(
|
|
f"<tr><td>#{j.job_index}</td><td>{j.owner}</td><td>{j.pid}</td>"
|
|
f"<td>{j.t_pod or '—'}</td><td>{j.design or '—'}</td><td>{j.elap_time or '—'}</td></tr>"
|
|
for j in data["jobs_active"]
|
|
)
|
|
jobs_html = f"""
|
|
<table style="border-collapse:collapse;margin-top:6px">
|
|
<thead><tr style="background:#007bff;color:#fff">
|
|
<th style="padding:6px 12px">#</th>
|
|
<th style="padding:6px 12px">用户</th>
|
|
<th style="padding:6px 12px">PID</th>
|
|
<th style="padding:6px 12px">T-Pod</th>
|
|
<th style="padding:6px 12px">设计</th>
|
|
<th style="padding:6px 12px">已运行</th>
|
|
</tr></thead>
|
|
<tbody>{job_rows}</tbody>
|
|
</table>"""
|
|
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"]
|
|
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'<span style="background:#007bff;color:#fff;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:bold">{smp.utilization:.1f}%</span>'
|
|
f'</td></tr>'
|
|
for smp in data["samples_24h"]
|
|
)
|
|
trend_html = f"""
|
|
<table style="border-collapse:collapse;width:100%;margin-top:6px">
|
|
<tr><td style="padding:4px 12px">快照数</td><td style="padding:4px 12px"><b>{s['count']}</b></td></tr>
|
|
<tr style="background:#f5f5f5"><td style="padding:4px 12px">利用率最小/最大/平均</td>
|
|
<td style="padding:4px 12px"><b>{s['util_min']:.1f}%</b> / <b>{s['util_max']:.1f}%</b> / <b>{s['util_avg']:.1f}%</b></td></tr>
|
|
<tr><td style="padding:4px 12px">使用板数最小/最大/平均</td>
|
|
<td style="padding:4px 12px"><b>{s['used_min']}</b> / <b>{s['used_max']}</b> / <b>{s['used_avg']:.1f}</b></td></tr>
|
|
</table>
|
|
<h4 style="margin:14px 0 4px">趋势样本</h4>
|
|
<table style="border-collapse:collapse;width:100%">
|
|
<thead><tr style="background:#f5f5f5">
|
|
<th style="padding:6px 12px;text-align:left">时间</th>
|
|
<th style="padding:6px 12px;text-align:right">利用率</th>
|
|
<th style="padding:6px 12px;text-align:left">图示</th>
|
|
</tr></thead>
|
|
<tbody>{sample_rows}</tbody>
|
|
</table>"""
|
|
else:
|
|
trend_html = '<p style="color:#888;margin:6px 0">近 24 小时无快照数据</p>'
|
|
|
|
return f"""
|
|
<html>
|
|
<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">
|
|
|
|
<!-- Header -->
|
|
<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>
|
|
|
|
<div style="padding:24px 28px">
|
|
|
|
<!-- 系统信息 -->
|
|
<h2 style="font-size:16px;margin:0 0 10px;color:#555">📋 系统信息</h2>
|
|
<table style="border-collapse:collapse;width:100%;font-size:14px">
|
|
<tr><td style="padding:5px 0;color:#888;width:90px">仿真器</td><td><b>{latest.emulator or '—'}</b></td></tr>
|
|
<tr><td style="padding:5px 0;color:#888">硬件</td><td>{latest.hardware or '—'}</td></tr>
|
|
<tr><td style="padding:5px 0;color:#888">ConfigMgr</td><td>{latest.configmgr or '—'}</td></tr>
|
|
<tr><td style="padding:5px 0;color:#888">系统状态</td><td>{latest.system_status or '—'}</td></tr>
|
|
<tr><td style="padding:5px 0;color:#888">数据来源</td><td><code>{latest.source or '—'}</code></td></tr>
|
|
<tr><td style="padding:5px 0;color:#888">快照时间</td><td>{_fmt_ts(latest.timestamp)}</td></tr>
|
|
</table>
|
|
|
|
<!-- 当前指标 -->
|
|
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">📊 当前指标</h2>
|
|
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
|
<div style="flex:1;min-width:140px;background:#f8f9fa;padding:14px;border-radius:6px;border-left:4px solid #6c757d">
|
|
<div style="color:#888;font-size:12px">逻辑板总数</div>
|
|
<div style="font-size:24px;font-weight:bold;margin-top:4px">{latest.total_boards}</div>
|
|
<div style="color:#28a745;font-size:12px">在线 {latest.online_boards}</div>
|
|
<div style="color:#dc3545;font-size:12px">离线 {latest.offline_boards}</div>
|
|
</div>
|
|
<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:#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">{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}
|
|
|
|
<!-- 离线板 -->
|
|
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">🔴 离线板</h2>
|
|
{offline_html}
|
|
|
|
<!-- 活跃作业 -->
|
|
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">⚙️ 活跃作业</h2>
|
|
{jobs_html}
|
|
|
|
</div>
|
|
|
|
<!-- 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 Monitor</a> 自动生成 ·
|
|
{_fmt_ts(data['now_utc'], '%Y-%m-%d %H:%M:%S')} UTC
|
|
</div>
|
|
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 发送
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
def render_report(data: dict, cfg: dict) -> Tuple[str, str, str]:
|
|
"""返回 (subject, html, text)。"""
|
|
latest = data["latest"]
|
|
if latest:
|
|
title_date = _fmt_ts(latest.timestamp, "%Y-%m-%d")
|
|
else:
|
|
title_date = _fmt_ts(data["now_utc"], "%Y-%m-%d")
|
|
util = f"{latest.utilization:.1f}%" if latest else "—"
|
|
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]:
|
|
"""实际 SMTP 发送, 返回 (ok, error_msg)。"""
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = formataddr((cfg["from_name"], cfg["from_addr"]))
|
|
msg["To"] = ", ".join(cfg["to_addrs"])
|
|
msg["Date"] = format_datetime(datetime.now(timezone.utc))
|
|
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"], password)
|
|
s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string())
|
|
else:
|
|
# STARTTLS (port 587) 或 明文 (port 25)
|
|
with smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=30) as s:
|
|
s.ehlo()
|
|
if cfg["smtp_use_tls"]:
|
|
s.starttls()
|
|
s.ehlo()
|
|
s.login(cfg["smtp_user"], password)
|
|
s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string())
|
|
return (True, "")
|
|
except smtplib.SMTPAuthenticationError as e:
|
|
# Python 3.12+: smtp_error is str; older: bytes
|
|
smtp_err = e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else (e.smtp_error or "")
|
|
return (False, f"SMTP 认证失败: {e.smtp_code} {smtp_err}")
|
|
except smtplib.SMTPException as e:
|
|
return (False, f"SMTP 错误: {e}")
|
|
except (socket.timeout, ConnectionError, OSError) as e:
|
|
return (False, f"网络错误: {e}")
|
|
except Exception as e:
|
|
return (False, f"{type(e).__name__}: {e}")
|
|
|
|
|
|
def send_report(cfg: Optional[Dict] = None) -> dict:
|
|
"""
|
|
收集数据 → 渲染 → 发送。
|
|
返回: {"ok": bool, "subject": str, "recipients": [...], "error": str, "preview_text": str}
|
|
"""
|
|
cfg = cfg or get_config_full()
|
|
if not cfg["enabled"]:
|
|
return {"ok": False, "error": "邮件功能未启用 (MAIL_ENABLED 未设 true)", "subject": "", "recipients": []}
|
|
errs = config_errors(cfg)
|
|
if errs:
|
|
return {"ok": False, "error": "配置不完整: " + "; ".join(errs), "subject": "", "recipients": []}
|
|
|
|
with app.app_context():
|
|
data = collect_report_data()
|
|
subject, html, text = render_report(data, cfg)
|
|
|
|
ok, err = _send_smtp(cfg, subject, html, text)
|
|
return {
|
|
"ok": ok,
|
|
"subject": subject,
|
|
"recipients": cfg["to_addrs"],
|
|
"error": err,
|
|
"preview_text": text[:500] + ("..." if len(text) > 500 else ""),
|
|
"bytes": len(text) + len(html),
|
|
}
|
|
|
|
|
|
def write_preview(output_path: str) -> str:
|
|
"""把渲染好的报告写到文件 (不发送, 仅供调试)。返回写入路径。"""
|
|
cfg = get_config()
|
|
with app.app_context():
|
|
data = collect_report_data()
|
|
subject, html, text = render_report(data, cfg)
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(f"Subject: {subject}\n")
|
|
f.write(f"To: {', '.join(cfg['to_addrs'])}\n")
|
|
f.write(f"From: {cfg['from_name']} <{cfg['from_addr']}>\n")
|
|
f.write("\n--- TEXT VERSION ---\n")
|
|
f.write(text)
|
|
f.write("\n\n--- HTML VERSION ---\n")
|
|
f.write(html)
|
|
return output_path
|