feat: initial commit - Palladium Z1 LD monitor

- app.py: Flask + SQLite, parse test_server -short, 3-domain board state
- collect.py: remote collector agent with retry/log/env-loading
- mailer.py: daily report email (SMTP, HTML+text, scheduler)
- install_z1_agent.sh: one-click install for Z1 host
- /api/export: CSV/JSON export with time/source filtering
- /api/email/{status,preview,send}: SMTP config and manual trigger
- Chart.js Y-axis stepSize 0.5 + toFixed(1) for utilization
- README: env-var-based deploy, troubleshooting, gunicorn hooks
This commit is contained in:
Hermes
2026-07-08 11:13:30 +08:00
parent 26940c4574
commit 535e5832ab
12 changed files with 4006 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# 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/
+287
View File
@@ -0,0 +1,287 @@
# Palladium Z1 Monitor
LD 逻辑板使用监控系统 — 解析 `test_server -short` 输出,可视化展示 Palladium Z1 仿真器的逻辑板状态、逻辑板利用率、作业信息和 T-Pod 可用性。
## 功能
- **逻辑板状态可视化** — Rack / Cluster / Board 三级结构,每块 LD 板的 8 个逻辑板 (D0-D7) 以色块展示
- 🟢 可用 (`-`)
- 🔵 已加载/预留 (`<num>`)
- 🔴 已禁用 (`D`)
- ⬜ 不存在 (`X`)
- **实时指标** — 逻辑板总数、在线/离线、逻辑板利用率、活跃作业数
- **作业信息** — 用户、PID、T-Pod、设计名、已运行时间
- **T-Pod 可用性** — 按 Rack 展示 HDSB / T-Pod 的可用/锁定/预留/不可用状态
- **历史趋势** — 利用率折线图 (Chart.js),支持历史快照查看和清理
- **自动刷新** — 可选 15s/30s/1m/5m 间隔自动刷新仪表板
- **多数据来源** — 手动粘贴、API 推送、远程采集脚本
## 快速开始
```bash
cd palladium-monitor
pip install -r requirements.txt --break-system-packages
python3 app.py
```
浏览器访问 `http://localhost:5100`
## 数据采集方式
### 方式一:手动粘贴 (Web 界面)
1. 在 Palladium 主机执行 `test_server -short`
2. 复制完整输出
3. 在 Web 界面点击「📥 粘贴数据」按钮
4. 粘贴输出到文本框,点击「解析并存储」
### 方式二:API 推送 (collect.py + 一键安装)
在 Palladium Z1 主机上部署采集 agent,**推荐**用一键脚本(处理 env 加载、crontab、依赖):
```bash
# 把 palladium-monitor/ 整个目录 scp 到 Z1 主机, 然后:
sudo ./install_z1_agent.sh \
--url http://<监控服务器IP>:5100/api/collect \
--host scmp03 \
--env /edatools/vxe/VXE23.03.001/bin/env.csh \
--interval 30
```
脚本会:建 `/opt/palladium-monitor/`、装 `requests`、备份原 crontab、写入 `*/30 * * * *` 任务、跑一次 dry-run 验证。
**手动部署**(不跑安装器):
```bash
# 安装 requests
pip install requests
# 手动推送一次
python3 collect.py --url http://<监控服务器IP>:5100/api/collect --host scmp03
# 配合 crontab 定时推送 (每30分钟)
*/30 * * * * /opt/palladium-monitor/collect.py --url http://<监控服务器IP>:5100/api/collect --host scmp03 --env /edatools/vxe/VXE23.03.001/bin/env.csh --log /var/log/palladium-collect.log >> /var/log/palladium-collect.cron.log 2>&1
```
#### collect.py 参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--url` | `http://127.0.0.1:5000/api/collect` | 监控服务器 API 地址 |
| `--cmd` | `test_server -short` | 采集命令 |
| `--host` | `hostname` | 本机标签,会写入 `source` 字段 (如 `collect.py@scmp03`) |
| `--env` | — | VXE env.csh/env.sh 路径,脚本自动 `source` 后再执行采集命令 |
| `--timeout` | `300` | 采集命令超时秒数 |
| `--retries` | `3` | 推送失败重试次数 |
| `--retry-delay` | `5` | 重试间隔秒数 |
| `--log` | — | 追加写日志文件路径 |
| `--dry-run` | — | 只采集不推送,打印输出 |
#### 退出码
| Code | 含义 |
|------|------|
| 0 | 成功 |
| 1 | 采集命令无输出 / 失败 |
| 2 | 推送失败 (已重试) |
| 130 | 用户中断 (Ctrl-C) |
cron 收到非 0 时会通过 `MAILTO` 触发告警。
### 方式三:直接 API 调用
```bash
curl -X POST http://<服务器>:5100/api/collect \
-H "Content-Type: application/json" \
-d '{"raw_output": "<test_server -short 完整输出>"}'
```
## API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 仪表板页面 |
| GET | `/history` | 历史快照列表 |
| GET | `/snapshot/<id>` | 快照详情 |
| POST | `/paste` | 手动粘贴数据 (表单提交) |
| POST | `/api/collect` | 接收原始输出 (JSON) |
| GET | `/api/latest` | 最新快照 JSON |
| GET | `/api/history` | 历史趋势 JSON |
| GET | `/api/export` | 导出历史 (CSV/JSON, 支持时间范围 + 来源过滤) |
| POST | `/api/cleanup?days=N` | 清理 N 天前数据 |
| POST | `/api/snapshot/<id>/delete` | 删除指定快照 |
### 导出历史数据
`/api/export` 端点支持 CSV (Excel 友好, 带 UTF-8 BOM) 和 JSON 两种格式,可按时间范围和来源过滤。
```
GET /api/export?format=csv # 默认: CSV 全部
GET /api/export?format=json # JSON 全部
GET /api/export?days=7 # 最近 7 天
GET /api/export?since=2026-07-01&until=2026-07-31 # 自定义范围
GET /api/export?source=collect.py # 按来源模糊匹配
GET /api/export?format=json&days=30&source=scmp03 # 组合
```
参数说明:
- `format`: `csv` (默认) 或 `json`
- `days`: 限制最近 N 天 (0/缺省 = 全部)
- `since` / `until`: `YYYY-MM-DD` 或 ISO 时间戳
- `source`: 模糊匹配 `source` 字段 (如 `collect.py``scmp03`)
- `limit`: 最多返回条数 (默认 10000, 上限 100000)
CSV 文件名: `palladium_snapshots_YYYYMMDD_HHMMSS.csv`,包含 19 列(时间本地+UTC、仿真器/硬件/ConfigMgr、系统状态、来源、板/域计数、利用率、作业数等)。
Web 界面入口:历史记录页右上角「📤 导出」按钮。
## 解析器支持的输出格式
```
Emulator: sc01_emu Hardware: Palladium Z1 Configmgr: V21.02.102.s002System Status: PARTIAL
Rack 0 has 2 clusters
Cluster 0 has 6 boards CCD: ONLINE
LD Status D0 D1 D2 D3 D4 D5 D6 D7
0 ONLINE - - - - - - - -
...
```
逻辑板状态图例:
- `-` → 可用 (Domain is available)
- `X` → 不存在 (Domain does not exist)
- `D` → 已禁用 (Domain is disabled)
- `<num>` → 已加载/预留 (Domain is downloaded or reserved)
## 项目结构
```
palladium-monitor/
├── app.py # Flask 应用: 解析器 + 数据模型 + 路由 + API
├── collect.py # 远程数据采集推送脚本 (Z1 主机跑)
├── install_z1_agent.sh # Z1 主机一键部署脚本 (env 加载 + crontab + 依赖)
├── requirements.txt # Flask, Flask-SQLAlchemy
├── instance/ # SQLite 数据库
├── templates/
│ ├── base.html # 基础布局 (导航 + 粘贴弹窗)
│ ├── dashboard.html # 主仪表板
│ ├── history.html # 历史记录列表
│ └── snapshot.html # 快照详情页
└── static/
└── style.css # 暗色主题样式
```
## 生产部署
### 监控服务器端
```bash
cd palladium-monitor
pip install -r requirements.txt --break-system-packages
gunicorn --workers 2 --bind 0.0.0.0:5100 app:app
```
### 邮件功能配置 (环境变量)
监控服务启动时会自动读以下环境变量, 无需 Web UI。所有变量都是可选的 (不配就相当于关闭邮件功能)。
| 变量 | 必填 | 默认 | 说明 |
|------|------|------|------|
| `MAIL_ENABLED` | ❌ | `false` | 是否启用邮件功能, 设为 `true`/`1`/`yes` 开启 |
| `SMTP_HOST` | 启用时必填 | — | SMTP 服务器地址 (如 `smtp.gmail.com``smtp.exmail.qq.com`) |
| `SMTP_PORT` | ❌ | `587` | SMTP 端口 (587=STARTTLS, 465=SSL, 25=明文) |
| `SMTP_USER` | 启用时必填 | — | SMTP 登录用户名 (通常等于邮箱地址) |
| `SMTP_PASSWORD` | 启用时必填 | — | SMTP 密码 (Gmail 用 App Password, 16 位) |
| `SMTP_USE_TLS` | ❌ | `true` | 是否用 STARTTLS (端口 587 时设为 `true`) |
| `SMTP_USE_SSL` | ❌ | `false` | 是否用隐式 SSL (端口 465 时设为 `true`) |
| `MAIL_FROM` | 启用时必填 | — | 发件人邮箱 |
| `MAIL_FROM_NAME` | ❌ | `Palladium Z1 Monitor` | 发件人显示名 |
| `MAIL_TO` | 启用时必填 | — | 收件人列表, 英文逗号分隔 (如 `alice@x.com,bob@y.com`) |
| `MAIL_SEND_HOUR` | ❌ | `9` | 每天发送时间-小时 (0-23) |
| `MAIL_SEND_MINUTE` | ❌ | `0` | 每天发送时间-分钟 (0-59) |
| `MONITOR_BASE_URL` | ❌ | `http://127.0.0.1:5100` | 监控页 URL (用于邮件里"查看完整仪表板"链接) |
| `ALLOW_EMAIL_TRIGGER` | ❌ | — | 设为 `true` 允许通过 `POST /api/email/send` 手动触发 (默认禁止) |
#### 启动方式
**方式 1: 直接 export**
```bash
export MAIL_ENABLED=true
export SMTP_HOST=smtp.gmail.com
export SMTP_PORT=587
export SMTP_USER=monitor@gmail.com
export SMTP_PASSWORD=**** # Gmail 用 App Password
export MAIL_FROM=monitor@gmail.com
export MAIL_TO=team-lead@company.com,ops@company.com
gunicorn --workers 2 --bind 0.0.0.0:5100 app:app
```
**方式 2: systemd EnvironmentFile (推荐)**
```ini
# /etc/palladium-monitor/mail.env
MAIL_ENABLED=true
SMTP_HOST=smtp.exmail.qq.com
SMTP_PORT=587
SMTP_USER=monitor@company.com
SMTP_PASSWORD=**** # 或从 secret manager 注入
MAIL_FROM=monitor@company.com
MAIL_TO=team@company.com
MAIL_SEND_HOUR=8
MAIL_SEND_MINUTE=30
```
```ini
# /etc/systemd/system/palladium-monitor.service
[Service]
EnvironmentFile=/etc/palladium-monitor/mail.env
ExecStart=/usr/bin/gunicorn --workers 2 --bind 0.0.0.0:5100 app:app
```
`systemctl daemon-reload && systemctl restart palladium-monitor`
#### 邮件 API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/email/status` | 查看当前邮件配置 (不泄露密码) |
| GET | `/api/email/preview` | 渲染报告内容 (不发送, 浏览器内联展示) |
| POST | `/api/email/send` | 手动触发一次 (需 `ALLOW_EMAIL_TRIGGER=true`) |
**注意**: scheduler 在进程内启动, **每个 gunicorn worker 都会尝试启动**; 但用 `_scheduler_started` 锁保证实际只有一个线程在跑, 其余的只是 `time.sleep(86400)` 等下一天, 不影响。
#### 邮件内容示例
每日报告包含:
- **主题**: `[Palladium Z1] 每日报告 YYYY-MM-DD — 利用率 X.X%`
- **正文 (HTML + 纯文本)**: 系统信息 / 当前指标卡片 / 近 24h 趋势统计 + 样本柱状图 / 离线板列表 / 活跃作业列表
- **多部分**: 同时发 HTML 和纯文本, 邮件客户端自动选最合适的渲染
### Z1 主机端(采集 agent
把整个 `palladium-monitor/` 目录 `scp` 到 Z1 主机,然后一键安装:
```bash
sudo ./install_z1_agent.sh \
--url http://<监控服务器IP>:5100/api/collect \
--host <本机标签, e.g. scmp03> \
--env /edatools/vxe/VXE23.03.001/bin/env.csh \
--interval 30
```
卸载:`sudo ./install_z1_agent.sh --uninstall`(保留日志)。
### 故障排查
| 症状 | 检查 |
|------|------|
| 仪表板一直显示示例数据 | `curl http://<monitor>:5100/api/latest` 看最新 snapshot 的 `source` 字段,应为 `collect.py@<host>` 而不是 `sample` |
| Z1 端推送失败 (exit=2) | `tail -50 /var/log/palladium-collect.log`;若 `test_server` 命令找不到,加 `--env /edatools/vxe/VXE23.03.001/bin/env.csh` |
| cron 没跑 | `crontab -l \| grep palladium``grep CRON /var/log/syslog`Linux |
| 推送成功但数据没更新 | 监控服务器 SQLite 写入是同步的,先看 `/api/latest``timestamp`,再 `tail /var/log/palladium-monitor-stderr.log`gunicorn 日志) |
| 想清理老数据 | `curl -X POST 'http://<monitor>:5100/api/cleanup?days=30'` |
| 多台 Z1 一起推 | 给每台用不同的 `--host` 标签,仪表板即可区分来源 |
## 技术栈
- **后端**: Flask 3.0 + Flask-SQLAlchemy + SQLite
- **前端**: Jinja2 模板 + 原生 CSS (暗色主题) + Chart.js (趋势图)
- **解析**: Python re 正则表达式解析 `test_server -short` 输出
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
collect.py — Palladium Z1 远程数据采集推送脚本
在 Palladium Z1 主机上运行,执行 test_server -short 并将结果推送到监控服务器。
用法:
python3 collect.py # 默认配置
python3 collect.py --url http://monitor:5100/api/collect
python3 collect.py --host scmp03 --env /edatools/vxe/VXE23.03.001/bin/env.csh
python3 collect.py --dry-run # 只采集不推送
python3 collect.py --log /var/log/palladium-collect.log # 追加写日志
可配合 crontab 定时执行 (每 30 分钟):
*/30 * * * * /opt/palladium-monitor/collect.py --host scmp03 --url http://MONITOR:5100/api/collect --log /var/log/palladium-collect.log >> /var/log/palladium-collect.cron.log 2>&1
"""
import argparse
import os
import socket
import subprocess
import sys
import time
from datetime import datetime
try:
import requests
except ImportError:
print("ERROR: requests 未安装,请执行 pip install requests", file=sys.stderr)
sys.exit(1)
# ═══════════════════════════════════════════════════════════════════════════
# Defaults
# ═══════════════════════════════════════════════════════════════════════════
DEFAULT_URL = "http://127.0.0.1:5000/api/collect"
DEFAULT_CMD = "test_server -short"
DEFAULT_TIMEOUT = 300
DEFAULT_RETRIES = 3
DEFAULT_RETRY_DELAY = 5
# ═══════════════════════════════════════════════════════════════════════════
# Logging
# ═══════════════════════════════════════════════════════════════════════════
class Logger:
"""同时输出到 stderr 和可选的日志文件。"""
def __init__(self, log_file: str | None = None):
self.log_file = log_file
def _emit(self, level: str, msg: str, ts: str) -> None:
line = f"{ts} [{level}] {msg}"
print(line, file=sys.stderr)
if self.log_file:
try:
os.makedirs(os.path.dirname(self.log_file), exist_ok=True)
with open(self.log_file, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError as e:
print(f"{ts} [WARN] 无法写入日志文件 {self.log_file}: {e}", file=sys.stderr)
def info(self, msg: str) -> None:
self._emit("INFO", msg, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
def warn(self, msg: str) -> None:
self._emit("WARN", msg, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
def error(self, msg: str) -> None:
self._emit("ERROR", msg, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# ═══════════════════════════════════════════════════════════════════════════
# Command Execution
# ═══════════════════════════════════════════════════════════════════════════
def build_shell_cmd(cmd: str, env_script: str | None) -> str:
"""
如果指定了 env_script,把 source env 之后再执行 cmd 拼成一个 shell 串。
不指定则直接返回 cmd。
"""
if not env_script:
return cmd
# env 文件可能是 csh 或 sh 后缀, 用 bash -c 强制走 sh 语义, 兼容大多数 vendor
# 假设 vendor 提供的 env.* 是 POSIX 兼容 (setenv → export)
return f"source {shlex_quote(env_script)} >/dev/null 2>&1; {cmd}"
def shlex_quote(s: str) -> str:
"""最小 shell 单引号转义 (用于 env_script 路径里有空格的情况)。"""
return "'" + s.replace("'", "'\\''") + "'"
def run_test_server(cmd: str, timeout: int, env_script: str | None) -> tuple[str, int, str]:
"""
执行 test_server -short, 返回 (stdout, returncode, stderr)。
timeout 触发时 subprocess.run 不会抛 (>=3.12 subprocessing 时可能抛), 这里显式处理。
"""
full_cmd = build_shell_cmd(cmd, env_script)
try:
result = subprocess.run(
full_cmd,
shell=True,
executable="/bin/bash",
capture_output=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as e:
return (e.stdout.decode("utf-8", errors="replace") if e.stdout else "", -1,
f"命令超时 (>={timeout}s)")
except FileNotFoundError as e:
return ("", 127, f"shell 不可用: {e}")
stdout = result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
return (stdout, result.returncode, stderr)
# ═══════════════════════════════════════════════════════════════════════════
# Push to monitor
# ═══════════════════════════════════════════════════════════════════════════
def push_to_monitor(url: str, raw_output: str, source: str,
retries: int, retry_delay: int,
log: Logger) -> tuple[bool, dict | None, str]:
"""带重试的 HTTP 推送, 返回 (ok, json_body_or_None, error_msg)。"""
payload = {"raw_output": raw_output, "source": source}
last_err = ""
for attempt in range(1, retries + 1):
try:
resp = requests.post(url, json=payload, timeout=30)
except requests.exceptions.ConnectionError as e:
last_err = f"无法连接 {url}: {e}"
except requests.exceptions.Timeout as e:
last_err = f"推送超时: {e}"
except requests.exceptions.RequestException as e:
last_err = f"请求异常: {e}"
else:
if resp.status_code == 200:
try:
return (True, resp.json(), "")
except ValueError:
return (True, None, "")
last_err = f"HTTP {resp.status_code}: {resp.text[:200]}"
if attempt < retries:
log.warn(f"推送失败 (第 {attempt}/{retries} 次): {last_err}{retry_delay}s 后重试")
time.sleep(retry_delay)
return (False, None, last_err)
# ═══════════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════════
def main() -> int:
parser = argparse.ArgumentParser(
description="Palladium Z1 数据采集推送 (cron 友好, 带重试和日志)",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--url", default=DEFAULT_URL,
help=f"监控服务器 API 地址 (默认: {DEFAULT_URL})")
parser.add_argument("--cmd", default=DEFAULT_CMD,
help=f"采集命令 (默认: {DEFAULT_CMD})")
parser.add_argument("--host", default=socket.gethostname(),
help="本机标签, 会写入 source 字段 (默认: hostname)")
parser.add_argument("--env", default=None,
help="VXE env.csh/env.sh 路径, 自动 source 后再执行命令")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT,
help=f"命令超时秒数 (默认: {DEFAULT_TIMEOUT})")
parser.add_argument("--retries", type=int, default=DEFAULT_RETRIES,
help=f"推送失败重试次数 (默认: {DEFAULT_RETRIES})")
parser.add_argument("--retry-delay", type=int, default=DEFAULT_RETRY_DELAY,
help=f"重试间隔秒数 (默认: {DEFAULT_RETRY_DELAY})")
parser.add_argument("--log", default=None,
help="追加写日志的文件路径 (例如 /var/log/palladium-collect.log)")
parser.add_argument("--dry-run", action="store_true",
help="只采集不推送, 打印解析结果")
args = parser.parse_args()
log = Logger(args.log)
source = f"collect.py@{args.host}"
log.info(f"开始采集 — host={args.host} cmd={args.cmd!r} env={args.env!r}")
# ─── 执行采集命令 ──────────────────────────────────────────
stdout, rc, stderr = run_test_server(args.cmd, args.timeout, args.env)
if rc != 0:
log.warn(f"命令退出码 {rc} (stderr: {stderr.strip()[:300]})")
if not stdout.strip():
log.error("命令无输出, 终止")
return 1
log.info(f"采集成功 — {len(stdout)} 字节, rc={rc}")
# ─── dry-run 模式 ──────────────────────────────────────────
if args.dry_run:
print("" * 60)
print(stdout)
print("" * 60)
log.info("dry-run 模式, 不推送")
return 0
# ─── 推送到监控服务器 ──────────────────────────────────────
log.info(f"推送到 {args.url} (source={source})")
ok, body, err = push_to_monitor(
args.url, stdout, source,
args.retries, args.retry_delay, log,
)
if not ok:
log.error(f"推送失败 (已重试 {args.retries} 次): {err}")
return 2
# 成功 — 打印 stats 摘要
snapshot_id = body.get("snapshot_id") if body else "?"
stats = body.get("stats") if body else None
if stats:
log.info(
f"推送成功 — snapshot #{snapshot_id} | "
f"{stats['total_boards']} (在线 {stats['online_boards']}/离线 {stats['offline_boards']}) | "
f"利用率 {stats['utilization']}% (使用 {stats['used_boards']}/{stats['total_boards']}) | "
f"作业 {stats['active_jobs']}"
)
else:
log.info(f"推送成功 — snapshot #{snapshot_id} (无 stats 字段)")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n[!] 用户中断", file=sys.stderr)
sys.exit(130)
+217
View File
@@ -0,0 +1,217 @@
#!/bin/bash
#═══════════════════════════════════════════════════════════════════════════════
# install_z1_agent.sh — Palladium Z1 主机端 collect.py 一键部署脚本
#═══════════════════════════════════════════════════════════════════════════════
# 用法:
# sudo ./install_z1_agent.sh --url http://MONITOR_HOST:5100/api/collect --host scmp03
# sudo ./install_z1_agent.sh --uninstall
#
# 关键点:
# - 默认 30 分钟一次 (cron */30)
# - 自动检测 env.csh / env.sh (如不存在则提示用户提供 --env)
# - 幂等: 重复运行会先卸载旧的
# - 保险模式: 不会覆盖用户已有 crontab, 也不会 rm 任何文件
#═══════════════════════════════════════════════════════════════════════════════
set -euo pipefail
# ─── Defaults ────────────────────────────────────────────────────────────────
INSTALL_DIR="/opt/palladium-monitor"
LOG_DIR="/var/log"
LOG_FILE="$LOG_DIR/palladium-collect.log"
CRON_TAG="palladium-monitor-collect" # crontab 注释标记, 便于后续识别
DEFAULT_TIMEOUT=300
DEFAULT_RETRIES=3
DEFAULT_RETRY_DELAY=5
# ─── CLI ─────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Palladium Z1 监控客户端安装脚本
用法:
$0 --url URL [--host NAME] [--env ENV_SCRIPT] [--interval MINUTES] [--uninstall]
必选 (首次安装):
--url URL 监控服务器 API 地址 (例如 http://10.0.0.10:5100/api/collect)
可选:
--host NAME 本机标签 (默认: hostname)
--env ENV_SCRIPT VXE env.csh / env.sh 绝对路径
--interval MINUTES 采集间隔分钟数 (默认: 30)
--timeout SECONDS test_server 超时 (默认: 300)
--retries N 推送失败重试次数 (默认: 3)
--uninstall 卸载 (保留日志和安装目录, 仅移除 crontab 和脚本)
示例:
$0 --url http://10.0.0.10:5100/api/collect --host scmp03 \\
--env /edatools/vxe/VXE23.03.001/bin/env.csh --interval 30
EOF
}
UNINSTALL=0
URL=""
HOST="$(hostname -s 2>/dev/null || hostname)"
ENV_SCRIPT=""
INTERVAL=30
TIMEOUT=$DEFAULT_TIMEOUT
RETRIES=$DEFAULT_RETRIES
while [[ $# -gt 0 ]]; do
case "$1" in
--url) URL="$2"; shift 2 ;;
--host) HOST="$2"; shift 2 ;;
--env) ENV_SCRIPT="$2"; shift 2 ;;
--interval) INTERVAL="$2"; shift 2 ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--retries) RETRIES="$2"; shift 2 ;;
--uninstall) UNINSTALL=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage; exit 1 ;;
esac
done
# ─── Helpers ──────────────────────────────────────────────────────────────────
log_info() { echo "[INFO] $*"; }
log_warn() { echo "[WARN] $*" >&2; }
log_error() { echo "[ERROR] $*" >&2; }
require_root() {
if [[ $EUID -ne 0 ]]; then
log_error "请用 root 运行: sudo $0 $*"
exit 1
fi
}
# 备份当前 crontab
backup_crontab() {
local bak="/tmp/crontab.backup.$(date +%Y%m%d_%H%M%S)"
if crontab -l >/dev/null 2>&1; then
crontab -l > "$bak"
log_info "已备份现有 crontab → $bak"
else
: > "$bak"
log_info "当前用户无 crontab, 备份为空文件 $bak"
fi
}
# 移除已存在的同标记行 (幂等)
remove_existing_cron() {
if crontab -l 2>/dev/null | grep -q "$CRON_TAG"; then
log_info "检测到旧任务, 先移除 (幂等)"
crontab -l | grep -v "$CRON_TAG" | grep -v "^#" | crontab -
fi
}
# ─── Uninstall ────────────────────────────────────────────────────────────────
do_uninstall() {
require_root
log_info "卸载: 移除 crontab 任务 (标签: $CRON_TAG)"
remove_existing_cron
if [[ -f "$INSTALL_DIR/collect.py" ]]; then
log_info "移除脚本: $INSTALL_DIR/collect.py"
rm -f "$INSTALL_DIR/collect.py"
fi
log_info "卸载完成. 日志保留在 $LOG_FILE (如不再需要请手动清理)"
exit 0
}
[[ "$UNINSTALL" -eq 1 ]] && do_uninstall
# ─── Install ──────────────────────────────────────────────────────────────────
require_root
if [[ -z "$URL" ]]; then
log_error "--url 是必选参数"
usage
exit 1
fi
# 校验 URL 格式 (基本)
if ! [[ "$URL" =~ ^https?://[^:]+:[0-9]+/.* ]]; then
log_warn "URL 格式异常: $URL (期望 http://host:port/path)"
read -r -p "仍要继续? [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]] || { log_info "已取消"; exit 1; }
fi
# 校验 env 脚本 (如果指定)
if [[ -n "$ENV_SCRIPT" ]]; then
if [[ ! -f "$ENV_SCRIPT" ]]; then
log_error "env 脚本不存在: $ENV_SCRIPT"
exit 1
fi
log_info "env 脚本校验通过: $ENV_SCRIPT"
fi
# 创建安装目录
log_info "创建安装目录: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# 复制 collect.py
SCRIPT_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/collect.py"
if [[ ! -f "$SCRIPT_SRC" ]]; then
log_error "未找到 collect.py, 请把脚本和本安装器放在同一目录"
exit 1
fi
install -m 0755 "$SCRIPT_SRC" "$INSTALL_DIR/collect.py"
log_info "已安装: $INSTALL_DIR/collect.py"
# 确保 requests 可用
if ! python3 -c "import requests" 2>/dev/null; then
log_warn "python3 'requests' 未安装, 尝试安装..."
if command -v pip3 >/dev/null; then
pip3 install requests
elif command -v pip >/dev/null; then
pip install requests
else
log_error "找不到 pip/pip3, 请手动安装 requests 后重试"
exit 1
fi
fi
log_info "Python 'requests' 模块可用"
# 准备日志
touch "$LOG_FILE" 2>/dev/null || { log_error "无法写入 $LOG_FILE"; exit 1; }
chmod 0644 "$LOG_FILE"
# 安装 crontab
log_info "安装 crontab — 每 $INTERVAL 分钟, 标签: $CRON_TAG"
backup_crontab
remove_existing_cron
# 构造 cron 行
CRON_LINE="*/${INTERVAL} * * * * /usr/bin/python3 ${INSTALL_DIR}/collect.py --url ${URL} --host ${HOST}"
if [[ -n "$ENV_SCRIPT" ]]; then
CRON_LINE="${CRON_LINE} --env ${ENV_SCRIPT}"
fi
CRON_LINE="${CRON_LINE} --timeout ${TIMEOUT} --retries ${RETRIES} --log ${LOG_FILE} >> ${LOG_FILE}.cron 2>&1 # ${CRON_TAG}"
# 写入新 crontab (保留旧任务)
{ crontab -l 2>/dev/null; echo "$CRON_LINE"; } | crontab -
log_info "crontab 已更新"
# ─── 验证安装 ────────────────────────────────────────────────────────────────
log_info "── 当前 crontab 中本任务相关行 ──"
crontab -l | grep "$CRON_TAG" || log_warn "未找到新行, 请检查"
log_info "── 立即跑一次 dry-run 验证脚本可执行 ──"
"$INSTALL_DIR/collect.py" \
--url "$URL" --host "$HOST" \
${ENV_SCRIPT:+--env "$ENV_SCRIPT"} \
--timeout 30 --retries 1 --retry-delay 1 \
--dry-run 2>&1 | tail -20 || log_warn "dry-run 失败 (test_server 可能在 VXE 工具链环境外, 这是正常的)"
cat <<EOF
════════════════════════════════════════════════════════════════════════════
✅ 安装完成
脚本: $INSTALL_DIR/collect.py
日志: $LOG_FILE (主日志)
${LOG_FILE}.cron (cron 自己的输出)
Crontab: 每 $INTERVAL 分钟一次
监控服务器: $URL
主机标签: $HOST
卸载: sudo $0 --uninstall
════════════════════════════════════════════════════════════════════════════
EOF
+511
View File
@@ -0,0 +1,511 @@
"""
mailer.py — Palladium Z1 每日报告邮件发送
通过 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(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 显示)。"""
return {
"enabled": _bool("MAIL_ENABLED", False),
"smtp_host": os.environ.get("SMTP_HOST", "").strip(),
"smtp_port": _int("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(),
"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),
"monitor_base_url": os.environ.get("MONITOR_BASE_URL", "http://127.0.0.1:5100").strip(),
}
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()
# ── 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 = []
return {
"latest": latest,
"boards_offline": boards_offline,
"jobs_active": jobs_active,
"stats_24h": stats_24h,
"samples_24h": samples_24h,
"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 Z1 Monitor — 当前无快照数据, 无法生成报告。\n"
lines = []
title_date = _fmt_ts(latest.timestamp, "%Y-%m-%d")
lines.append(f"Palladium Z1 每日报告 — {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("")
# 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 Z1 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>'
# 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'<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>'
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;
box-shadow:0 2px 8px rgba(0,0,0,0.08);overflow:hidden">
<!-- 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="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:#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="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>
</div>
<!-- 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 Z1 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 Z1] 每日报告 {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"))
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.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"], os.environ.get("SMTP_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: dict | None = None) -> dict:
"""
收集数据 → 渲染 → 发送。
返回: {"ok": bool, "subject": str, "recipients": [...], "error": str, "preview_text": str}
"""
cfg = cfg or get_config()
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
+2
View File
@@ -0,0 +1,2 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
+701
View File
@@ -0,0 +1,701 @@
/* ═══════════════════════════════════════════════════════════════
Palladium Z1 Monitor — Dark Theme Dashboard
═══════════════════════════════════════════════════════════════ */
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-card: #1c2330;
--bg-card-hover: #232b3a;
--border: #30363d;
--border-light: #3a4250;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--orange: #d29922;
--blue: #58a6ff;
--purple: #bc8cff;
--gray: #6e7681;
--radius: 8px;
--shadow: 0 2px 8px rgba(0,0,0,0.3);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans SC", "Microsoft YaHei", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
}
/* ─── Top Nav ─── */
.topnav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.nav-brand {
display: flex;
align-items: center;
gap: 8px;
}
.nav-logo { font-size: 22px; }
.nav-title {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
}
.nav-links {
display: flex;
align-items: center;
gap: 4px;
}
.nav-links a {
padding: 8px 16px;
color: var(--text-secondary);
text-decoration: none;
border-radius: var(--radius);
transition: all 0.15s;
font-weight: 500;
}
.nav-links a:hover { color: var(--text-primary); background: var(--bg-card); }
.nav-links a.active { color: var(--accent); background: rgba(88,166,255,0.1); }
.nav-btn {
padding: 8px 16px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: opacity 0.15s;
}
.nav-btn:hover { opacity: 0.85; }
/* ─── Container ─── */
.container {
max-width: 1600px;
margin: 0 auto;
padding: 20px 24px 60px;
}
/* ─── System Info Bar ─── */
.system-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 24px;
padding: 12px 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
}
.sys-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.sys-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sys-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
font-family: "SF Mono", "Fira Code", Consolas, monospace;
}
.refresh-controls {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-direction: row;
}
.refresh-controls label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
.refresh-controls select {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 3px 8px;
font-size: 12px;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
background: var(--bg-card-hover);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
}
.btn-sm:hover { background: var(--border-light); }
.btn-sm.btn-danger { color: var(--red); border-color: var(--red); }
.btn-sm.btn-danger:hover { background: rgba(248,81,73,0.15); }
/* ─── Status Badges ─── */
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.3px;
}
.status-ok { background: rgba(63,185,80,0.15); color: var(--green); }
.status-warning { background: rgba(210,153,34,0.15); color: var(--orange); }
.status-error { background: rgba(248,81,73,0.15); color: var(--red); }
.status-unknown { background: rgba(110,118,129,0.15); color: var(--gray); }
.status-online { background: rgba(63,185,80,0.15); color: var(--green); }
.status-offline { background: rgba(248,81,73,0.15); color: var(--red); }
/* ─── Metrics Grid ─── */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.metric-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color 0.15s;
}
.metric-card:hover { border-color: var(--border-light); }
.metric-icon { font-size: 28px; }
.metric-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.metric-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
}
.metric-value.metric-warn { color: var(--orange); }
.metric-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
/* ─── Section Headers ─── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 24px 0 12px;
}
.section-header h2 {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
/* ─── Legend ─── */
.legend {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--text-secondary);
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
/* ─── Rack Card ─── */
.rack-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
}
.rack-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.rack-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.rack-count {
font-size: 12px;
color: var(--text-muted);
}
/* ─── Cluster Section ─── */
.cluster-section {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.cluster-section:last-child { border-bottom: none; }
.cluster-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.ccd-badge {
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.ccd-online { background: rgba(63,185,80,0.15); color: var(--green); }
.ccd-offline { background: rgba(248,81,73,0.15); color: var(--red); }
/* ─── Boards Grid ─── */
.boards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 8px;
}
/* ─── Board Card ─── */
.board-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
transition: all 0.15s;
}
.board-card:hover { border-color: var(--border-light); transform: translateY(-1px); }
.board-card.board-offline {
border-color: rgba(248,81,73,0.4);
background: rgba(248,81,73,0.05);
}
.board-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.board-ld {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.board-status-badge {
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
}
/* ─── Domain Cells ─── */
.domains-row {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
}
.domain-cell {
text-align: center;
font-size: 10px;
font-weight: 700;
padding: 4px 0;
border-radius: 3px;
min-width: 18px;
transition: transform 0.1s;
cursor: default;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
}
.domain-cell:hover { transform: scale(1.15); z-index: 5; position: relative; }
.domain-available {
background: rgba(63,185,80,0.15);
color: var(--green);
border: 1px solid rgba(63,185,80,0.3);
}
.domain-downloaded {
background: rgba(88,166,255,0.2);
color: var(--blue);
border: 1px solid rgba(88,166,255,0.4);
}
.domain-disabled {
background: rgba(248,81,73,0.15);
color: var(--red);
border: 1px solid rgba(248,81,73,0.3);
}
.domain-not_exist {
background: rgba(110,118,129,0.1);
color: var(--gray);
border: 1px solid rgba(110,118,129,0.2);
}
/* ─── Card (generic) ─── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 12px;
}
.card.empty-state {
padding: 24px;
text-align: center;
color: var(--text-muted);
}
/* ─── Data Table ─── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table thead {
background: var(--bg-secondary);
}
.data-table th {
padding: 10px 14px;
text-align: left;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table td {
padding: 8px 14px;
border-bottom: 1px solid rgba(48,54,61,0.5);
color: var(--text-primary);
}
.data-table tbody tr:hover { background: var(--bg-card-hover); }
.data-table tbody tr:last-child td { border-bottom: none; }
.text-mono {
font-family: "SF Mono", "Fira Code", Consolas, monospace;
font-size: 12px;
}
.text-green { color: var(--green); }
.text-red { color: var(--red); }
.text-blue { color: var(--blue); }
.text-gray { color: var(--gray); }
/* ─── Pods Grid ─── */
.pods-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.pod-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.pod-header {
padding: 8px 14px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 14px;
}
.pod-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.pod-table td {
padding: 5px 14px;
border-bottom: 1px solid rgba(48,54,61,0.4);
}
.pod-table tr:last-child td { border-bottom: none; }
.pod-label {
color: var(--text-secondary);
white-space: nowrap;
width: 40%;
}
.pod-sublabel {
color: var(--text-muted);
font-size: 11px;
padding-left: 24px !important;
}
.pod-value {
font-family: "SF Mono", "Fira Code", Consolas, monospace;
color: var(--text-primary);
}
.pod-row-available td { color: var(--green); }
.pod-row-locked td { color: var(--orange); }
.pod-row-reserved td { color: var(--blue); }
.pod-row-unavailable td { color: var(--red); }
/* ─── Utilization Bar (history table) ─── */
.util-bar {
position: relative;
width: 120px;
height: 20px;
background: var(--bg-primary);
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
}
.util-fill {
height: 100%;
background: linear-gradient(90deg, var(--green), var(--blue));
border-radius: 10px;
transition: width 0.3s;
}
.util-text {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--text-primary);
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
}
/* ─── Pagination ─── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-btn {
padding: 6px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
text-decoration: none;
font-size: 13px;
transition: all 0.15s;
}
.page-btn:hover { border-color: var(--accent); color: var(--accent); }
.page-info {
font-size: 13px;
color: var(--text-secondary);
}
/* ─── Tags ─── */
.tag {
display: inline-block;
padding: 1px 8px;
background: rgba(88,166,255,0.1);
color: var(--accent);
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
/* ─── Modal ─── */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { font-size: 16px; font-weight: 700; }
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.modal-close:hover { background: var(--bg-card-hover); color: var(--text-primary); }
.modal-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
}
.modal-hint {
margin-bottom: 10px;
color: var(--text-secondary);
font-size: 13px;
}
.modal-hint code {
background: var(--bg-primary);
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
color: var(--accent);
}
.paste-textarea {
width: 100%;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
font-size: 12px;
resize: vertical;
line-height: 1.6;
}
.paste-textarea:focus {
outline: none;
border-color: var(--accent);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border);
}
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 600;
font-size: 13px;
text-decoration: none;
transition: all 0.15s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #4493f8; }
.btn-secondary { background: var(--bg-card-hover); color: var(--text-primary); border: 1px solid var(--border); }
.btn-secondary:hover { border-color: var(--border-light); }
.btn-lg { padding: 12px 24px; font-size: 15px; }
/* ─── Empty State (no data) ─── */
.empty-state-full {
text-align: center;
padding: 80px 20px;
}
.empty-icon { font-size: 64px; margin-bottom: 16px; }
.empty-state-full h2 { font-size: 22px; margin-bottom: 8px; }
.empty-state-full p { color: var(--text-secondary); margin-bottom: 20px; }
.empty-actions { margin-bottom: 16px; }
.empty-hint {
color: var(--text-muted) !important;
font-size: 13px;
}
.empty-hint code {
background: var(--bg-card);
padding: 2px 8px;
border-radius: 4px;
color: var(--accent);
}
/* ─── Snapshot Header ─── */
.snapshot-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.snapshot-meta {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
/* ─── Raw Output ─── */
.raw-output-section {
margin-top: 24px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
}
.raw-output-section summary {
cursor: pointer;
font-weight: 600;
color: var(--text-secondary);
padding: 4px 0;
}
.raw-output {
margin-top: 12px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
font-size: 11px;
color: var(--text-secondary);
overflow-x: auto;
white-space: pre-wrap;
max-height: 500px;
overflow-y: auto;
}
/* ─── Chart container ─── */
.card canvas {
padding: 16px;
}
/* ─── Responsive ─── */
@media (max-width: 768px) {
.topnav { padding: 0 12px; }
.container { padding: 12px; }
.system-bar { gap: 12px; }
.refresh-controls { margin-left: 0; }
.boards-grid { grid-template-columns: 1fr; }
.metrics-grid { grid-template-columns: 1fr; }
}
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Palladium Z1 Monitor{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<nav class="topnav">
<div class="nav-brand">
<span class="nav-logo"></span>
<span class="nav-title">Palladium Z1 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>
<button class="nav-btn" onclick="openPasteModal()">📥 粘贴数据</button>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<!-- 粘贴数据弹窗 -->
<div id="pasteModal" class="modal-overlay" style="display:none;">
<div class="modal">
<div class="modal-header">
<h2>📥 粘贴 test_server -short 输出</h2>
<button class="modal-close" onclick="closePasteModal()"></button>
</div>
<form action="{{ url_for('paste_data') }}" method="POST">
<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>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closePasteModal()">取消</button>
<button type="submit" class="btn btn-primary">解析并存储</button>
</div>
</form>
</div>
</div>
<script>
function openPasteModal() {
document.getElementById('pasteModal').style.display = 'flex';
}
function closePasteModal() {
document.getElementById('pasteModal').style.display = 'none';
}
document.getElementById('pasteModal').addEventListener('click', function(e) {
if (e.target === this) closePasteModal();
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+394
View File
@@ -0,0 +1,394 @@
{% extends "base.html" %}
{% block title %}仪表板 — Palladium Z1 Monitor{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
{% if snapshot %}
<!-- ═══ 系统信息栏 ═══ -->
<div class="system-bar">
<div class="sys-item">
<span class="sys-label">仿真器</span>
<span class="sys-value">{{ snapshot.emulator }}</span>
</div>
<div class="sys-item">
<span class="sys-label">硬件</span>
<span class="sys-value">{{ snapshot.hardware }}</span>
</div>
<div class="sys-item">
<span class="sys-label">ConfigMgr</span>
<span class="sys-value">{{ snapshot.configmgr }}</span>
</div>
<div class="sys-item">
<span class="sys-label">系统状态</span>
{% set ss = snapshot.system_status.upper() %}
{% if ss == 'OK' %}
<span class="status-badge status-ok">● {{ snapshot.system_status }}</span>
{% elif 'PARTIAL' in ss %}
<span class="status-badge status-warning">● {{ snapshot.system_status }}</span>
{% elif 'DOWN' in ss or 'ERROR' in ss %}
<span class="status-badge status-error">● {{ snapshot.system_status }}</span>
{% else %}
<span class="status-badge status-unknown">● {{ snapshot.system_status }}</span>
{% endif %}
</div>
<div class="sys-item">
<span class="sys-label">最后更新</span>
<span class="sys-value" id="lastUpdate">{{ snapshot.timestamp | fmt_time }}</span>
</div>
<div class="sys-item">
<span class="sys-label">数据来源</span>
<span class="sys-value">{{ snapshot.source }}</span>
</div>
<div class="sys-item refresh-controls">
<label><input type="checkbox" id="autoRefresh" checked> 自动刷新</label>
<select id="refreshInterval">
<option value="15">15s</option>
<option value="30" selected>30s</option>
<option value="60">1m</option>
<option value="300">5m</option>
</select>
<button class="btn-sm" onclick="location.reload()">🔄</button>
</div>
</div>
<!-- ═══ 指标卡片 ═══ -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-icon">🖥️</div>
<div class="metric-body">
<div class="metric-label">逻辑板总数</div>
<div class="metric-value">{{ stats.total_boards }}</div>
<div class="metric-sub">
<span class="text-green">在线 {{ stats.online_boards }}</span> ·
<span class="text-red">离线 {{ stats.offline_boards }}</span>
</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">📊</div>
<div class="metric-body">
<div class="metric-label">逻辑板利用率</div>
<div class="metric-value {{ 'metric-warn' if stats.utilization > 80 else '' }}">{{ stats.utilization }}%</div>
<div class="metric-sub">
使用 {{ stats.used_boards }} / 空闲 {{ stats.online_boards - stats.used_boards }}
</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">🔲</div>
<div class="metric-body">
<div class="metric-label">逻辑板状态</div>
<div class="metric-value">{{ stats.total_boards }}</div>
<div class="metric-sub">
<span class="text-green">● {{ stats.online_boards - stats.used_boards }} 空闲</span>
<span class="text-blue">● {{ stats.used_boards }} 使用</span>
<span class="text-red">● {{ stats.offline_boards }} 离线</span>
</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">⚙️</div>
<div class="metric-body">
<div class="metric-label">活跃作业</div>
<div class="metric-value">{{ stats.active_jobs }}</div>
<div class="metric-sub">
{% if jobs %}
{{ jobs[0].owner }} · {{ jobs[0].elap_time }}
{% else %}
无活跃作业
{% endif %}
</div>
</div>
</div>
</div>
<!-- ═══ Rack / Cluster / Board 可视化 ═══ -->
<div class="section-header">
<h2>🏗️ 逻辑板状态</h2>
<div class="legend">
<span class="legend-item"><span class="domain-cell domain-available">-</span> 可用</span>
<span class="legend-item"><span class="domain-cell domain-downloaded">1</span> 已加载</span>
<span class="legend-item"><span class="domain-cell domain-disabled">D</span> 已禁用</span>
<span class="legend-item"><span class="domain-cell domain-not_exist">X</span> 不存在</span>
</div>
</div>
{% for rack in racks %}
<div class="rack-card">
<div class="rack-header">
<span class="rack-title">Rack {{ rack.rack }}</span>
<span class="rack-count">{{ rack.clusters | length }} clusters</span>
</div>
{% for cluster in rack.clusters %}
<div class="cluster-section">
<div class="cluster-header">
<span>Cluster {{ cluster.cluster }}</span>
{% set ccd = cluster.ccd.upper() %}
{% if ccd == 'ONLINE' %}
<span class="ccd-badge ccd-online">CCD: {{ cluster.ccd }}</span>
{% else %}
<span class="ccd-badge ccd-offline">CCD: {{ cluster.ccd }}</span>
{% endif %}
</div>
<div class="boards-grid">
{% for board in cluster.boards %}
<div class="board-card board-{{ board.status | lower }}">
<div class="board-top">
<span class="board-ld">LD {{ board.ld }}</span>
<span class="board-status-badge status-{{ board.status | lower }}">{{ board.status }}</span>
</div>
<div class="domains-row">
{% for d in board.domains %}
<div class="domain-cell domain-{{ d | domain_state }}"
title="D{{ loop.index0 }}: {{ d | domain_label }}">{{ d }}</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
<!-- ═══ 作业信息 ═══ -->
<div class="section-header">
<h2>📋 作业信息</h2>
</div>
{% if jobs %}
<div class="card">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>用户</th>
<th>PID</th>
<th>T-Pod</th>
<th>设计名</th>
<th>已运行</th>
<th>预留Key</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>{{ job.job_index }}</td>
<td class="text-mono">{{ job.owner }}</td>
<td class="text-mono">{{ job.pid }}</td>
<td class="text-mono">{{ job.t_pod }}</td>
<td>{{ job.design }}</td>
<td class="text-mono">{{ job.elap_time }}</td>
<td>{{ job.reserved_key }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card empty-state">当前无活跃作业</div>
{% endif %}
<!-- ═══ T-Pod 可用性 ═══ -->
<div class="section-header">
<h2>🎯 T-Pod 可用性</h2>
</div>
<div class="pods-grid">
{% for pod in pods %}
<div class="pod-card">
<div class="pod-header">Rack {{ pod.rack }}</div>
<table class="pod-table">
{% set all_h = pod.all_hdsb | from_json %}
{% set all_t = pod.all_t_pods | from_json %}
{% if all_h %}
<tr>
<td class="pod-label" colspan="2"><b>全部 HDSB</b></td>
</tr>
{% for length, val in all_h.items() %}
<tr>
<td class="pod-sublabel">Length {{ length }}</td>
<td class="pod-value">{{ val }}</td>
</tr>
{% endfor %}
{% endif %}
{% if all_t %}
<tr>
<td class="pod-label" colspan="2"><b>全部 T-Pods</b></td>
</tr>
{% for length, val in all_t.items() %}
<tr>
<td class="pod-sublabel">Length {{ length }}</td>
<td class="pod-value">{{ val }}</td>
</tr>
{% endfor %}
{% endif %}
<tr class="pod-row-available">
<td class="pod-label">可用 HDSB</td>
<td class="pod-value">{{ pod.available_hdsb }}</td>
</tr>
<tr class="pod-row-available">
<td class="pod-label">可用 T-Pods</td>
<td class="pod-value">{{ pod.available_t_pods }}</td>
</tr>
<tr class="pod-row-locked">
<td class="pod-label">锁定 HDSB</td>
<td class="pod-value">{{ pod.locked_hdsb }}</td>
</tr>
<tr class="pod-row-locked">
<td class="pod-label">锁定 T-Pods</td>
<td class="pod-value">{{ pod.locked_t_pods }}</td>
</tr>
<tr class="pod-row-reserved">
<td class="pod-label">预留 HDSB</td>
<td class="pod-value">{{ pod.reserved_hdsb }}</td>
</tr>
<tr class="pod-row-reserved">
<td class="pod-label">预留 T-Pods</td>
<td class="pod-value">{{ pod.reserved_t_pods }}</td>
</tr>
<tr class="pod-row-unavailable">
<td class="pod-label">不可用 HDSB</td>
<td class="pod-value">{{ pod.unavailable_hdsb }}</td>
</tr>
<tr class="pod-row-unavailable">
<td class="pod-label">不可用 T-Pods</td>
<td class="pod-value">{{ pod.unavailable_t_pods }}</td>
</tr>
</table>
</div>
{% endfor %}
</div>
<!-- ═══ 利用率趋势图 ═══ -->
{% if history | length > 1 %}
<div class="section-header">
<h2>📈 利用率趋势</h2>
</div>
<div class="card">
<canvas id="utilChart" height="80"></canvas>
</div>
{% endif %}
{% else %}
<!-- 无数据状态 -->
<div class="empty-state-full">
<div class="empty-icon">📊</div>
<h2>暂无监控数据</h2>
<p>请通过以下方式导入数据:</p>
<div class="empty-actions">
<button class="btn btn-primary btn-lg" onclick="openPasteModal()">📥 粘贴 test_server -short 输出</button>
</div>
<p class="empty-hint">或在 Palladium 主机运行:<code>python3 collect.py --url http://&lt;监控服务器&gt;:5100/api/collect</code></p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{% if snapshot and history | length > 1 %}
<script>
const ctx = document.getElementById('utilChart').getContext('2d');
const labels = [
{% for h in history %}
'{{ h.timestamp | fmt_time_short }}'{% if not loop.last %}, {% endif %}
{% endfor %}
];
const utilData = [
{% for h in history %}
{{ h.utilization }}{% if not loop.last %}, {% endif %}
{% endfor %}
];
const usedData = [
{% for h in history %}
{{ h.used_boards }}{% if not loop.last %}, {% endif %}
{% endfor %}
];
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '利用率 (%)',
data: utilData,
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.1)',
yAxisID: 'y',
tension: 0.3,
fill: true,
},
{
label: '使用逻辑板数',
data: usedData,
borderColor: '#28a745',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
yAxisID: 'y1',
tension: 0.3,
fill: false,
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
x: { ticks: { color: '#9ba1b3', maxRotation: 45 } },
y: {
type: 'linear', position: 'left',
title: { display: true, text: '利用率 %', color: '#007bff' },
min: 0, max: 100,
ticks: {
color: '#007bff',
// 关键: 显示 1 位小数, stepSize 0.5 让 0/0.5/1/... 都画出来
stepSize: 0.5,
callback: (v) => v.toFixed(1) + '%'
},
},
y1: {
type: 'linear', position: 'right',
title: { display: true, text: '使用逻辑板', color: '#28a745' },
ticks: { color: '#28a745', precision: 0 },
grid: { drawOnChartArea: false },
}
},
plugins: {
legend: { labels: { color: '#e4e6eb' } },
tooltip: {
callbacks: {
// tooltip 也显示 1 位小数 + 单位
label: (ctx) => {
const v = ctx.parsed.y;
if (ctx.dataset.yAxisID === 'y') {
return ` ${ctx.dataset.label}: ${v.toFixed(1)}%`;
}
return ` ${ctx.dataset.label}: ${v}`;
}
}
}
}
}
});
</script>
{% endif %}
<script>
// 自动刷新
let refreshTimer = null;
function setupRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
const cb = document.getElementById('autoRefresh');
const sel = document.getElementById('refreshInterval');
if (!cb || !sel) return;
if (cb.checked) {
const sec = parseInt(sel.value);
refreshTimer = setTimeout(() => location.reload(), sec * 1000);
}
}
document.addEventListener('DOMContentLoaded', setupRefresh);
document.getElementById('autoRefresh')?.addEventListener('change', setupRefresh);
document.getElementById('refreshInterval')?.addEventListener('change', setupRefresh);
</script>
{% endblock %}
+196
View File
@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}历史记录 — Palladium Z1 Monitor{% endblock %}
{% block content %}
<div class="section-header">
<h2>📅 历史快照</h2>
<div class="header-actions">
<button class="btn btn-sm btn-secondary" onclick="openExportModal()">📤 导出</button>
<button class="btn btn-sm btn-secondary" onclick="cleanupOld()">🧹 清理30天前数据</button>
</div>
</div>
{% if pagination.items %}
<div class="card">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>时间</th>
<th>仿真器</th>
<th>系统状态</th>
<th>逻辑板</th>
<th>逻辑板利用率</th>
<th>使用/总数</th>
<th>作业数</th>
<th>来源</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for snap in pagination.items %}
<tr>
<td>{{ snap.id }}</td>
<td class="text-mono">{{ snap.timestamp | fmt_time }}</td>
<td>{{ snap.emulator }}</td>
<td>
{% set ss = snap.system_status.upper() %}
{% if ss == 'OK' %}
<span class="status-badge status-ok">{{ snap.system_status }}</span>
{% elif 'PARTIAL' in ss %}
<span class="status-badge status-warning">{{ snap.system_status }}</span>
{% elif 'DOWN' in ss or 'ERROR' in ss %}
<span class="status-badge status-error">{{ snap.system_status }}</span>
{% else %}
<span class="status-badge status-unknown">{{ snap.system_status }}</span>
{% endif %}
</td>
<td>
{{ snap.total_boards }}
<span class="text-green">({{ snap.online_boards }}</span>/<span class="text-red">{{ snap.offline_boards }})</span>
</td>
<td>
<div class="util-bar">
<div class="util-fill" style="width: {{ snap.utilization }}%"></div>
<span class="util-text">{{ snap.utilization }}%</span>
</div>
</td>
<td class="text-mono">{{ snap.used_boards }} / {{ snap.total_boards }}</td>
<td>{{ snap.active_jobs }}</td>
<td><span class="tag">{{ snap.source }}</span></td>
<td>
<a href="{{ url_for('snapshot_detail', sid=snap.id) }}" class="btn-sm">查看</a>
<form method="POST" action="{{ url_for('delete_snapshot', sid=snap.id) }}"
style="display:inline;" onsubmit="return confirm('确认删除快照 #{{ snap.id }}?')">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('history', page=pagination.prev_num) }}" class="page-btn">← 上一页</a>
{% endif %}
<span class="page-info">第 {{ pagination.page }} / {{ pagination.pages }} 页 (共 {{ pagination.total }} 条)</span>
{% if pagination.has_next %}
<a href="{{ url_for('history', page=pagination.next_num) }}" class="page-btn">下一页 →</a>
{% endif %}
</div>
{% else %}
<div class="card empty-state">暂无历史数据</div>
{% endif %}
<script>
function cleanupOld() {
if (!confirm('确认清理30天前的历史数据?此操作不可撤销。')) return;
fetch('/api/cleanup?days=30', { method: 'POST' })
.then(r => r.json())
.then(data => {
alert('已清理 ' + data.deleted + ' 条快照');
location.reload();
})
.catch(e => alert('清理失败: ' + e));
}
// ─── 导出 ──────────────────────────────────────────────────────
function openExportModal() {
const m = document.getElementById('exportModal');
if (!m) return;
m.style.display = 'flex';
}
function closeExportModal() {
const m = document.getElementById('exportModal');
if (m) m.style.display = 'none';
}
function doExport() {
const fmt = document.getElementById('expFormat').value;
const range = document.getElementById('expRange').value;
const since = document.getElementById('expSince').value;
const until = document.getElementById('expUntil').value;
const source = document.getElementById('expSource').value.trim();
const params = new URLSearchParams();
params.set('format', fmt);
if (range !== 'all') params.set('days', range);
if (range === 'custom') {
if (since) params.set('since', since);
if (until) params.set('until', until);
}
if (source) params.set('source', source);
const url = '/api/export?' + params.toString();
// 触发浏览器下载
window.location.href = url;
setTimeout(closeExportModal, 300);
}
function toggleRangeFields() {
const v = document.getElementById('expRange').value;
const custom = document.getElementById('expCustomRange');
if (custom) custom.style.display = (v === 'custom') ? 'flex' : 'none';
}
</script>
<!-- 导出弹窗 -->
<div id="exportModal" class="modal-overlay" style="display:none;">
<div class="modal" style="max-width: 480px;">
<div class="modal-header">
<h2>📤 导出历史快照</h2>
<button class="modal-close" onclick="closeExportModal()"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>格式</label>
<select id="expFormat" class="form-control">
<option value="csv">CSV (Excel 友好, 带 UTF-8 BOM)</option>
<option value="json">JSON (程序处理)</option>
</select>
</div>
<div class="form-group">
<label>时间范围</label>
<select id="expRange" class="form-control" onchange="toggleRangeFields()">
<option value="all">全部</option>
<option value="1">最近 1 天</option>
<option value="7" selected>最近 7 天</option>
<option value="30">最近 30 天</option>
<option value="90">最近 90 天</option>
<option value="custom">自定义…</option>
</select>
</div>
<div id="expCustomRange" class="form-row" style="display:none;">
<div class="form-group">
<label>起始 (含)</label>
<input type="date" id="expSince" class="form-control">
</div>
<div class="form-group">
<label>截止 (含)</label>
<input type="date" id="expUntil" class="form-control">
</div>
</div>
<div class="form-group">
<label>来源过滤 (模糊匹配, 可空)</label>
<input type="text" id="expSource" class="form-control"
placeholder="例如: collect.py、scmp03、sample">
</div>
<p class="modal-hint">
导出上限 10 万条; 超过会自动截断。<br>
CSV 默认包含 19 列: 时间(本地+UTC)、仿真器/硬件/ConfigMgr、系统状态、来源、板/域计数、利用率(1 位小数)、作业数等。
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeExportModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="doExport()">📥 下载</button>
</div>
</div>
</div>
<script>
document.getElementById('exportModal')?.addEventListener('click', function(e) {
if (e.target === this) closeExportModal();
});
</script>
{% endblock %}
+183
View File
@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}快照 #{{ snapshot.id }} — Palladium Z1 Monitor{% endblock %}
{% block content %}
<div class="snapshot-header">
<div>
<h2>📸 快照 #{{ snapshot.id }}</h2>
<div class="snapshot-meta">
{{ snapshot.timestamp | fmt_time }} · 来源: {{ snapshot.source }} ·
{{ snapshot.emulator }} / {{ snapshot.hardware }}
</div>
</div>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">← 返回仪表板</a>
</div>
<!-- 系统信息 -->
<div class="system-bar">
<div class="sys-item">
<span class="sys-label">仿真器</span>
<span class="sys-value">{{ snapshot.emulator }}</span>
</div>
<div class="sys-item">
<span class="sys-label">硬件</span>
<span class="sys-value">{{ snapshot.hardware }}</span>
</div>
<div class="sys-item">
<span class="sys-label">ConfigMgr</span>
<span class="sys-value">{{ snapshot.configmgr }}</span>
</div>
<div class="sys-item">
<span class="sys-label">系统状态</span>
{% set ss = snapshot.system_status.upper() %}
{% if ss == 'OK' %}
<span class="status-badge status-ok">{{ snapshot.system_status }}</span>
{% elif 'PARTIAL' in ss %}
<span class="status-badge status-warning">{{ snapshot.system_status }}</span>
{% elif 'DOWN' in ss or 'ERROR' in ss %}
<span class="status-badge status-error">{{ snapshot.system_status }}</span>
{% else %}
<span class="status-badge status-unknown">{{ snapshot.system_status }}</span>
{% endif %}
</div>
</div>
<!-- 指标卡片 -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-icon">🖥️</div>
<div class="metric-body">
<div class="metric-label">逻辑板</div>
<div class="metric-value">{{ stats.total_boards }}</div>
<div class="metric-sub">
<span class="text-green">在线 {{ stats.online_boards }}</span> ·
<span class="text-red">离线 {{ stats.offline_boards }}</span>
</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">📊</div>
<div class="metric-body">
<div class="metric-label">逻辑板利用率</div>
<div class="metric-value">{{ stats.utilization }}%</div>
<div class="metric-sub">使用 {{ stats.used_boards }} / 空闲 {{ stats.online_boards - stats.used_boards }}</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">⚙️</div>
<div class="metric-body">
<div class="metric-label">活跃作业</div>
<div class="metric-value">{{ stats.active_jobs }}</div>
</div>
</div>
</div>
<!-- Rack / Cluster / Board -->
<div class="section-header">
<h2>🏗️ 逻辑板状态</h2>
<div class="legend">
<span class="legend-item"><span class="domain-cell domain-available">-</span> 可用</span>
<span class="legend-item"><span class="domain-cell domain-downloaded">1</span> 已加载</span>
<span class="legend-item"><span class="domain-cell domain-disabled">D</span> 已禁用</span>
<span class="legend-item"><span class="domain-cell domain-not_exist">X</span> 不存在</span>
</div>
</div>
{% for rack in racks %}
<div class="rack-card">
<div class="rack-header">
<span class="rack-title">Rack {{ rack.rack }}</span>
</div>
{% for cluster in rack.clusters %}
<div class="cluster-section">
<div class="cluster-header">
<span>Cluster {{ cluster.cluster }}</span>
{% set ccd = cluster.ccd.upper() %}
{% if ccd == 'ONLINE' %}
<span class="ccd-badge ccd-online">CCD: {{ cluster.ccd }}</span>
{% else %}
<span class="ccd-badge ccd-offline">CCD: {{ cluster.ccd }}</span>
{% endif %}
</div>
<div class="boards-grid">
{% for board in cluster.boards %}
<div class="board-card board-{{ board.status | lower }}">
<div class="board-top">
<span class="board-ld">LD {{ board.ld }}</span>
<span class="board-status-badge status-{{ board.status | lower }}">{{ board.status }}</span>
</div>
<div class="domains-row">
{% for d in board.domains %}
<div class="domain-cell domain-{{ d | domain_state }}"
title="D{{ loop.index0 }}: {{ d | domain_label }}">{{ d }}</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
<!-- 作业信息 -->
<div class="section-header"><h2>📋 作业信息</h2></div>
{% if jobs %}
<div class="card">
<table class="data-table">
<thead>
<tr><th>#</th><th>用户</th><th>PID</th><th>T-Pod</th><th>设计名</th><th>已运行</th><th>预留Key</th></tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>{{ job.job_index }}</td>
<td class="text-mono">{{ job.owner }}</td>
<td class="text-mono">{{ job.pid }}</td>
<td class="text-mono">{{ job.t_pod }}</td>
<td>{{ job.design }}</td>
<td class="text-mono">{{ job.elap_time }}</td>
<td>{{ job.reserved_key }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card empty-state">该快照无活跃作业</div>
{% endif %}
<!-- T-Pod -->
<div class="section-header"><h2>🎯 T-Pod 可用性</h2></div>
<div class="pods-grid">
{% for pod in pods %}
<div class="pod-card">
<div class="pod-header">Rack {{ pod.rack }}</div>
<table class="pod-table">
{% set all_h = pod.all_hdsb | from_json %}
{% set all_t = pod.all_t_pods | from_json %}
{% for length, val in all_h.items() %}
<tr><td class="pod-sublabel">HDSB L{{ length }}</td><td class="pod-value">{{ val }}</td></tr>
{% endfor %}
{% for length, val in all_t.items() %}
<tr><td class="pod-sublabel">T-Pods L{{ length }}</td><td class="pod-value">{{ val }}</td></tr>
{% endfor %}
<tr class="pod-row-available"><td class="pod-label">可用 HDSB</td><td class="pod-value">{{ pod.available_hdsb }}</td></tr>
<tr class="pod-row-available"><td class="pod-label">可用 T-Pods</td><td class="pod-value">{{ pod.available_t_pods }}</td></tr>
<tr class="pod-row-locked"><td class="pod-label">锁定 HDSB</td><td class="pod-value">{{ pod.locked_hdsb }}</td></tr>
<tr class="pod-row-locked"><td class="pod-label">锁定 T-Pods</td><td class="pod-value">{{ pod.locked_t_pods }}</td></tr>
<tr class="pod-row-reserved"><td class="pod-label">预留 HDSB</td><td class="pod-value">{{ pod.reserved_hdsb }}</td></tr>
<tr class="pod-row-reserved"><td class="pod-label">预留 T-Pods</td><td class="pod-value">{{ pod.reserved_t_pods }}</td></tr>
<tr class="pod-row-unavailable"><td class="pod-label">不可用 HDSB</td><td class="pod-value">{{ pod.unavailable_hdsb }}</td></tr>
<tr class="pod-row-unavailable"><td class="pod-label">不可用 T-Pods</td><td class="pod-value">{{ pod.unavailable_t_pods }}</td></tr>
</table>
</div>
{% endfor %}
</div>
<!-- 原始输出 -->
<details class="raw-output-section">
<summary>📄 原始 test_server 输出</summary>
<pre class="raw-output">{{ snapshot.raw_output }}</pre>
</details>
{% endblock %}