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