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:
@@ -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/
|
||||||
@@ -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
@@ -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)
|
||||||
Executable
+217
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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://<监控服务器>: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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user