Initial hardware dashboard and scheduled task service

This commit is contained in:
张帅 2026-11-05 18:39:20 +08:00
commit cedae7ab92
25 changed files with 3530 additions and 0 deletions

12
.gitattributes vendored Normal file
View File

@ -0,0 +1,12 @@
* text=auto
*.py text eol=lf
*.js text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.json text eol=lf
*.service text eol=lf
*.timer text eol=lf
*.conf text eol=lf
*.svg text eol=lf
scripts/hwlog text eol=lf

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
venv/
# Runtime state and local deployment secrets
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
*.log
*.pid
config/hardware-watch.json
config/hardware-task-admin.json
hardware-task-admin-login*.txt
# Local archives and editor state
*.tar.gz
.idea/
.vscode/
.DS_Store
Thumbs.db

58
README.md Normal file
View File

@ -0,0 +1,58 @@
# Hardware Dashboard
这是运行在 192.168.1.37 上的局域网设备监控与维护面板。页面提供 CPU、内存、双 P100 显卡、磁盘 SMART、性能历史曲线、硬件问题记录和定时任务管理。后端只使用 Python 标准库;前端使用原生 HTML、CSS 和 JavaScript,不依赖外部 CDN。
当前访问地址是 http://192.168.1.37:8090。定时任务可以执行服务器命令,必须使用独立的管理员账号登录。该账号与 SSH root 密码相互独立。
## 目录结构
```
.
├── server.py # 监控网页、实时指标和历史数据 API
├── task_core.py # 定时任务定义、校验和 systemd 管理
├── taskd.py # root 侧任务管理服务
├── task_exec.py # 单个任务的执行器和输出记录
├── static/ # 前端页面、样式和脚本
├── scripts/hwlog # 硬件事件采集与命令行查看工具
├── config/
| └── hardware-watch.example.json
├── deploy/systemd/ # dashboard、taskd 和硬件采集服务文件
├── deploy/journald/ # 原始内核日志持久化配置
├── docs/DEPLOYMENT.zh-CN.md # 服务器部署与技术说明
└── tests/ # 自动化测试
```
## 本地检查
项目只需要 Python 3.12 或更新版本,没有第三方 Python 依赖。
```
python -m unittest discover -s tests -p "test_*.py" -v
python -m py_compile server.py task_core.py taskd.py task_exec.py scripts/hwlog
```
如需本地启动网页:
```
python server.py --host 127.0.0.1 --port 8090
```
服务器上的完整部署方式、文件路径、权限、任务管理和维护命令见 docs/DEPLOYMENT.zh-CN.md。
## 服务器配置
仓库中不保存真实硬件基准配置和任务管理凭据。部署前复制示例配置:
```
sudo cp config/hardware-watch.example.json /etc/hardware-watch.json
sudo nano /etc/hardware-watch.json
```
把 expected_gpu_count、GPU UUID 和磁盘名改成当前服务器的实际值。修改后重启监控采集服务。定时任务管理服务首次初始化会生成独立管理员密码文件;不要把生成的密码、任务数据库、日志或系统配置哈希提交到仓库。
## 安全边界
- 监控页面本身是只读的,可查看状态、曲线和日志。
- 定时任务能以 root 身份执行命令,新增、编辑、启用和立即执行均需要任务管理员登录。
- 不要把这个服务直接暴露到公网。当前设计只适合可信局域网。
- 远程访问应通过可信 VPN 或带 HTTPS 与额外认证的反向代理,而不是直接端口映射。

View File

@ -0,0 +1,11 @@
{
"expected_gpu_count": 2,
"expected_gpu_uuids": [
"GPU-00000000-0000-0000-0000-000000000001",
"GPU-00000000-0000-0000-0000-000000000002"
],
"expected_disks": ["nvme0n1"],
"gpu_warn_c": 85,
"history_since": "7 days ago",
"retention_days": 90
}

44
deploy/install.sh Executable file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_ROOT="${INSTALL_ROOT:-/opt/hardware-dashboard}"
CONFIG_FILE="${CONFIG_FILE:-/etc/hardware-watch.json}"
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
if [[ "$(id -u)" -ne 0 ]]; then
echo "必须使用 root 运行部署脚本。" >&2
exit 1
fi
if [[ ! -f "${CONFIG_FILE}" ]]; then
echo "缺少 ${CONFIG_FILE}。请先复制 config/hardware-watch.example.json 并填入当前服务器实际值。" >&2
exit 1
fi
getent group hardware-watch >/dev/null || groupadd --system hardware-watch
getent passwd hardware-dashboard >/dev/null || useradd --system --gid hardware-watch --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin hardware-dashboard
install -d -m 0755 "${INSTALL_ROOT}/static"
install -d -m 0750 /var/lib/hardware-dashboard /var/lib/hardware-watch /var/log/hardware-watch
install -d -m 0755 /etc/systemd/journald.conf.d
install -m 0644 server.py task_core.py taskd.py task_exec.py "${INSTALL_ROOT}/"
install -m 0644 static/index.html static/app.js static/tasks.js static/style.css static/favicon.svg "${INSTALL_ROOT}/static/"
install -m 0755 scripts/hwlog /usr/local/sbin/hwlog
install -m 0640 config/hardware-watch.json "${CONFIG_FILE}"
install -m 0644 deploy/systemd/*.service deploy/systemd/*.timer "${SYSTEMD_DIR}/"
install -m 0644 deploy/journald/60-hardware-watch.conf /etc/systemd/journald.conf.d/60-hardware-watch.conf
setfacl -m u:hardware-dashboard:rx /var/lib/hardware-watch /var/log/hardware-watch
setfacl -d -m u:hardware-dashboard:rx /var/lib/hardware-watch /var/log/hardware-watch
find /var/lib/hardware-watch /var/log/hardware-watch -maxdepth 1 -type f -exec setfacl -m u:hardware-dashboard:r {} +
setfacl -m u:hardware-dashboard:r "${CONFIG_FILE}"
systemctl daemon-reload
systemctl enable --now hardware-watch.timer
systemctl restart hardware-watch.service
systemctl enable --now hardware-taskd.service
systemctl restart hardware-dashboard.service
echo "部署完成。首次初始化定时任务管理员:"
echo "python3 ${INSTALL_ROOT}/taskd.py init-admin"

View File

@ -0,0 +1,5 @@
[Journal]
Storage=persistent
SystemMaxUse=1G
SystemKeepFree=2G
MaxRetentionSec=90day

View File

@ -0,0 +1,29 @@
[Unit]
Description=Hardware monitoring dashboard on LAN port 8090
After=network.target hardware-watch.timer
[Service]
Type=simple
User=hardware-dashboard
Group=hardware-watch
WorkingDirectory=/opt/hardware-dashboard
ExecStart=/usr/bin/python3 /opt/hardware-dashboard/server.py --port 8090
Restart=always
RestartSec=5
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=TZ=Asia/Shanghai
StateDirectory=hardware-dashboard
StateDirectoryMode=0750
UMask=0027
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
ReadWritePaths=/var/lib/hardware-dashboard
MemoryMax=384M
Nice=10
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,23 @@
[Unit]
Description=Authenticated local scheduled-task broker
After=local-fs.target
[Service]
Type=simple
User=root
Group=hardware-watch
ExecStart=/usr/bin/python3 /opt/hardware-dashboard/taskd.py serve
Restart=on-failure
RestartSec=5
RuntimeDirectory=hardware-taskd
RuntimeDirectoryMode=0750
StateDirectory=hardware-taskd
StateDirectoryMode=0700
LogsDirectory=hardware-taskd
LogsDirectoryMode=0700
UMask=0077
Environment=TZ=Asia/Shanghai
Environment=PYTHONDONTWRITEBYTECODE=1
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,23 @@
[Unit]
Description=Hardware event journal and GPU / disk health sampling
After=systemd-journald.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/hwlog collect
TimeoutStartSec=55
Nice=10
IOSchedulingClass=idle
UMask=0027
StateDirectory=hardware-watch
StateDirectoryMode=0750
LogsDirectory=hardware-watch
LogsDirectoryMode=0750
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectControlGroups=true
MemoryMax=384M
ReadWritePaths=/var/lib/hardware-watch /var/log/hardware-watch

View File

@ -0,0 +1,11 @@
[Unit]
Description=Check hardware health every minute
[Timer]
OnBootSec=45s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=hardware-watch.service
[Install]
WantedBy=timers.target

263
docs/DEPLOYMENT.zh-CN.md Normal file
View File

@ -0,0 +1,263 @@
# 192.168.1.37 监控与定时任务技术说明
版本:v1.2,2026 年 11 月 4 日。
## 1. 访问与登录
设备监控页面:**[http://192.168.1.37:8090](http://192.168.1.37:8090)**
定时任务入口在“问题与维护”下方,也可通过左侧“定时任务”菜单定位。手机底部有同名入口。
硬件监控仍可直接查看。定时任务能够实际执行服务器操作,因此使用独立的管理员账号登录:
- 账号:`admin`。
- 初始密码:见交付的 `定时任务管理员登录信息.txt`,本说明不包含密码。
- 登录最长有效 8 小时;修改密码后所有任务管理登录均失效。
- 此密码与 SSH root 密码相互独立,本次没有修改 SSH 密码。
页面只开放在原有局域网访问范围内。当前使用局域网 HTTP,适用于可信局域网;不应将这个具备 root 任务执行能力的入口直接映射到公网。将来如需远程公开访问,应先配置 HTTPS 和适当的访问控制。
## 2. 本次新增功能
“定时任务”支持:
- 新增、编辑、启用、停用和删除任务。
- 立即执行一次;这不会自动启用任务的定时计划。
- 停止本次执行;与停用后续调度分开处理。
- 查看下次计划时间、最近执行结果和是否正在执行。
- 查看单任务执行记录,以及包含已删除任务的全局执行记录。
- 查看开始/结束时间、耗时、退出码、标准输出和错误输出。
- 管理员登录、退出及修改密码。
执行方式有三种:
| 执行方式 | 如何填写 | 说明 |
|---|---|---|
| 直接输入命令 | 例如 `/usr/bin/nvidia-smi` | 使用 Bash 执行,支持管道和多行命令。 |
| 粘贴脚本内容 | 选择 Bash 或 Python 3,粘贴代码 | 可以导入本地 `.sh`、`.py` 或文本脚本到编辑框;保存后才会传给服务器。 |
| 服务器上的脚本文件 | 填写绝对路径和可选参数 | 可选择 Bash、Python 3 或按文件自身方式执行;最后一种要求文件具有执行权限。 |
执行身份固定为 **root**,因为重启服务器和维护证书通常需要管理员权限。它不是隔离的脚本沙箱:应只运行自己理解并信任的命令。
工作目录默认 `/root`,使用非交互执行环境,不继承某个 SSH 窗口的虚拟环境。PATH 包含常用系统目录和 `/snap/bin`;如需 Conda、venv 或自定义环境变量,应在脚本内明确设置。
## 3. 第一次添加任务
1. 刷新页面,打开“定时任务”,点击“登录管理”。
2. 使用交付文件中的 `admin` 与初始密码登录。
3. 点击“新增任务”,填写任务名称。
4. 选择执行方式,输入命令、脚本或服务器脚本路径。
5. 设置执行周期,点击“检查执行周期”核对下次时间。
6. 保持“保存后启用”不勾选,先保存为停用状态。
7. 如适合测试,点击“执行一次”,确认执行内容,查看执行日志。
8. 核对结果后,再启用计划。
保存任务本身不会立即执行脚本。新任务默认停用。退出登录、关闭网页或重启网页服务不会停止已经启用的系统计划。
### 常用模板
新增窗口提供:
- **显卡状态检查**:读取 `nvidia-smi`,结果保存在执行日志中。
- **服务器定时重启**:示例命令为 `systemctl reboot`,默认周期是每周日 03:00,默认停用。启用或立即执行前都会提示可能中断计算任务。
- **SSL 证书续签示例**:检查已有的 Certbot/acme.sh 工具并调用其续签入口。它不会安装工具、申请新域名或替用户配置证书。先确认现有续签配置,以及是否已经有其他自动续签任务;Web 服务的重新加载应按实际服务配置。
本次部署没有创建或启用实际的服务器重启任务和证书续签任务,也没有执行这两类操作。
## 4. 执行周期
所有日历时间按 **Asia/Shanghai,北京时间 UTC+8** 处理,页面显示服务器当前时间。
| 周期 | 规则 |
|---|---|
| 每天 | 指定时、分。 |
| 每周 | 可选择一个或多个星期及时间。 |
| 每月 | 选择 1 至 31 日和时间;当月不存在该日期时跳过。 |
| 每隔若干分钟/小时 | 启用后先等待指定间隔;每次执行结束后重新计时,避免长任务造成重叠。 |
| 指定日期,仅执行一次 | 选择未来的日期和时间,精确到分钟;至少晚于保存时刻 30 秒。 |
| 自定义 Cron | 标准五段:分、时、日、月、周。支持 `*`、逗号、范围、步长及常见英文月/星期名。 |
Cron 示例:
```text
0 3 * * * 每天 03:00
0 3 * * 0 每周日 03:00
0 3 1 * * 每月 1 日 03:00
*/30 * * * * 每小时的 00、30 分
```
不支持 `L`、`W`、`#`、`?` 等扩展语法,也不接受带秒的六段 Cron。日和星期同时指定时,按传统 Cron 的“任一满足”语义处理。
Cron 是日历触发,和“上次结束后再间隔 N 分钟”不同。例如长任务执行 10 分钟,再间隔 5 分钟,下一次大约在本次开始 15 分钟后。
### 开机、错过与长任务
- 日历任务设置 `Persistent=false`,服务器关机期间错过的执行时间不补跑,避免开机后补执行重启类任务。
- 同一个任务由固定 systemd 服务承载,并有文件锁,不会同时重叠执行两份。
- 一个任务执行超时后会被停止。默认最长 1800 秒,可在高级设置中改为 10 至 86400 秒。
- 关闭网页只会关闭查看界面,不会停止系统定时器。
- 本机历史上出现过时钟回拨。启用重启等任务前,应核对页面显示的服务器时间;定时调度依赖服务器时钟和系统校时状态。
部署验证时,服务器时区为 `Asia/Shanghai`,系统报告 `NTPSynchronized=yes`。
## 5. 看懂执行结果
| 显示 | 意义 |
|---|---|
| 尚未执行 | 没有记录到执行。 |
| 执行中 | 本次进程仍在运行。 |
| 执行成功 | 命令正常退出,退出码为 0。 |
| 执行失败 | 命令退出码非 0,或启动过程中发生错误。 |
| 超时已停止 | 达到设置的最长执行时间,已停止本次进程。 |
| 已停止 | 本次进程被停止,可能是手动操作或系统停止服务。 |
| 执行被中断 | 执行期间系统重启或进程中断,未取得完整结果。 |
成功只表示脚本返回了 0,不保证某个证书一定已更新、备份一定可恢复或某个业务结果一定正确;这些应结合脚本输出核对。
“停用”只取消将来的调度,不会强制中止已经开始的执行。“停止本次”会终止执行进程,脚本可能尚未完成修改,操作前会提示确认。
目前单任务窗口展示最近 40 次执行,全局窗口展示最近 80 次。服务器执行记录按 90 天清理;每次输出上限约 512 KiB,网页每次读取最后 128 KiB。输出与脚本快照还有合计 256 MiB 的空间限制,较旧文件可能提前清理,执行状态元数据仍保留至期限。
删除任务会停用计划并移除模块生成的定时器,保留已有执行记录。历史记录可在“全部执行记录”查看。
## 6. 参考与技术栈
界面参考 [宝塔 Linux 面板](https://www.bt.cn/) 计划任务的组织方式:任务列表、执行周期、Shell 脚本、启停、手动执行和执行日志。本次功能自行编写,沿用已有监控页面的深色风格,没有安装宝塔,也没有复制其面板源码。
原监控布局仍参考:
- [Zabbix](https://github.com/zabbix/zabbix):设备总览和告警分级。
- [Netdata](https://github.com/netdata/netdata):实时指标展示。
- [Grafana](https://github.com/grafana/grafana):历史曲线、图例和时间范围。
| 部分 | 使用技术 |
|---|---|
| 监控与任务前端 | HTML、CSS、原生 JavaScript、原生 `<dialog>` 对话框。 |
| 曲线 | 原生 SVG,保留原有 CPU、内存、GPU 温度、功耗和利用率历史。 |
| 网页后端 | Python 3.12 标准库,`ThreadingHTTPServer`。 |
| 数据库 | SQLite,WAL 模式;监控数据与任务数据分库存储。 |
| 系统调度 | systemd `.timer` 和 `.service`,不是浏览器内的倒计时。 |
| 网页与特权服务通信 | 本机 Unix socket,限制连接进程身份。 |
| 管理密码 | 随机盐、PBKDF2-HMAC-SHA256,240000 次迭代。 |
| 登录态 | 内存会话、HttpOnly/SameSite=Strict Cookie;写操作同时检查 Origin 与 CSRF。 |
| 执行控制 | 独立进程组、文件锁、超时、停止信号、限量输出、执行审计。 |
没有新增 npm、pip 或外部 CDN 依赖。服务器不需要 Node.js;开发阶段使用 Node.js 检查 JavaScript 语法。
## 7. 为什么要拆成两个服务
```mermaid
flowchart LR
A[浏览器:监控与任务界面] --> B[hardware-dashboard\n普通账号\nHTTP 8090]
B --> C[(原监控数据库)]
B -->|本机 Unix socket\n验证会话与 CSRF| D[hardware-taskd\nroot 任务管理服务]
D --> E[(独立任务数据库\nroot 专用目录)]
D --> F[systemd 定时器]
F --> G[task_exec\n执行指定任务]
G --> H[执行日志与结果]
H --> D
```
硬件网页继续使用原来的 `hardware-dashboard` 普通账号。新增 root 服务只通过本机 Unix socket 接收经验证的任务操作,不额外开放网络端口。
任务内容不插入 systemd 的 `ExecStart`。生成的服务只启动固定的执行器和经过验证的任务 ID;执行器从 root 专用数据库读取任务内容。这避免任务名称、换行或参数变成 systemd 单元配置。
上传或粘贴的脚本文本会统一换行为 Linux 格式。服务器脚本路径和参数以独立参数列表传递;命令内容模式则按管理员输入交给 Bash 执行。
root 服务不是恶意脚本隔离环境。管理员既然有权输入 root 命令,就应当视为具有服务器管理员权限。
## 8. 文件与数据库位置
以下均在 `192.168.1.37`:
| 路径 | 内容 |
|---|---|
| `/opt/hardware-dashboard/server.py` | 网页 HTTP 和监控 API;新增任务 API 代理。 |
| `/opt/hardware-dashboard/static/tasks.js` | 任务表单、登录、启停、执行和日志交互。 |
| `/opt/hardware-dashboard/task_core.py` | 校验、周期转换、任务数据库和 systemd 管理。 |
| `/opt/hardware-dashboard/taskd.py` | 本机任务服务、会话验证、审计和清理。 |
| `/opt/hardware-dashboard/task_exec.py` | 单次执行、输出、超时与退出结果。 |
| `/etc/systemd/system/hardware-taskd.service` | 新增任务管理常驻服务。 |
| `/etc/systemd/system/hwjob-任务ID.timer` | 本模块为已创建任务生成的定时器。 |
| `/etc/systemd/system/hwjob-任务ID.service` | 对应的执行服务。 |
| `/run/hardware-taskd/control.sock` | 普通网页与 root 服务之间的本机通信入口。 |
| `/etc/hardware-task-admin.json` | 管理密码的盐与散列,仅 root 可读。 |
| `/var/lib/hardware-taskd/tasks.sqlite3` | 任务定义、执行元数据和审计。 |
| `/var/lib/hardware-taskd/scripts/` | 脚本内容模式的执行快照。 |
| `/var/log/hardware-taskd/` | 每次执行的输出日志,仅经管理登录读取。 |
| `/var/lib/hardware-dashboard/monitor.sqlite3` | 原来的性能和硬件日志数据库,继续保留。 |
| `/root/hardware-task-admin-login.txt` | 初始登录信息的服务器副本,仅 root 可读。 |
原有 `hwlog` 命令、硬件采集器及性能历史继续运行。本模块只管理自己创建的 `hwjob-*` 单元,不接管现有 crontab、系统 timer 或其他面板任务。
## 9. 常用维护命令
```bash
# 查看网页服务与任务服务
systemctl status hardware-dashboard.service hardware-taskd.service
# 查看任务管理服务自身报错
journalctl -u hardware-taskd.service -n 50 --no-pager
# 查看本模块的全部系统定时器
systemctl list-timers --all 'hwjob-*.timer'
# 查看某个任务的系统层日志,任务 ID 可从页面或数据库查出
# 将下方 32 位 ID 替换成实际值,不要把说明文字直接输入命令
systemctl status hwjob-0123456789abcdef0123456789abcdef.service
# 重启网页服务:不会取消已启用的任务
systemctl restart hardware-dashboard.service
# 重启任务管理服务:已有网页登录会失效,系统定时器仍独立运行
systemctl restart hardware-taskd.service
```
忘记任务管理密码时,可通过 SSH 以 root 执行以下命令。新密码在交互输入,不放进命令行参数:
```bash
python3 /opt/hardware-dashboard/taskd.py password
systemctl restart hardware-taskd.service
```
只停止网页或任务管理进程,不等于停用计划。要停止后续执行,应先在页面将对应任务停用;需要立即中断时再使用“停止本次”。
## 10. 备份与回退
本次更新前的网页代码备份:`/opt/hardware-dashboard-backup.vNWXhLKD/`。
任务数据库与监控数据库分开。备份正在运行的 SQLite 应使用 SQLite 的备份接口,不能只复制主文件而忽略 WAL。备份任务数据库、管理密码散列和脚本/日志时,应继续保持 root 专用权限。
如需回退界面,先在页面停用所有不再需要的任务,再停止任务管理服务并恢复代码。只恢复旧网页代码不会自动删除或停用已经生成的系统定时器。
## 11. 已完成的验证
### 自动化测试
16 项本地和服务器测试通过,覆盖:
- 原监控数据、问题合并、时钟回拨及过期状态。
- 每日/每周/每月/间隔周期与 Cron 字段解析。
- Cron 日期和星期的 OR 语义、非法范围和步长。
- 任务名称与脚本内容不会成为 systemd 单元配置指令。
- 未登录和错误 CSRF 不会调用特权任务操作。
- 修改密码使所有旧会话失效。
- 日志读取量上限与路径编号验证。
### 服务器真实执行验证
使用明确命名的“部署验证”任务,确认了:
| 验证内容 | 实际结果 |
|---|---|
| 手动打印命令 | 成功,退出码 0。 |
| 指定日期自动触发 | 在计划时间由 systemd 自动执行,记录为 `scheduled`,退出码 0。 |
| Python 脚本内容 | 成功,输出 `PYTHON_TASK_OK`。 |
| 服务器已有文件执行 | 成功,输出 `EXISTING_FILE_OK`。 |
| 主动返回退出码 7 | 正确显示失败,保留错误输出。 |
| 10 秒超时 | 约 10 秒后停止,显示超时。 |
| 手动停止等待任务 | 正确停止并记录状态。 |
| 编辑、启用和停用 | 页面与系统定时器状态一致。 |
| 未登录、错误来源、错误 CSRF | 分别被 HTTP 401/403 拒绝。 |
这些测试仅打印信息或短暂等待,没有执行重启、关机、证书续签或修改现有业务配置。测试计划在交付前移除,历史中保留有“部署验证”字样的执行记录。

26
docs/REPOSITORY.md Normal file
View File

@ -0,0 +1,26 @@
# Git 提交说明
仓库已按可发布项目结构整理。源代码和部署模板可提交;运行状态、日志、数据库和管理凭据不应提交。
## 提交前检查
```
python -m unittest discover -s tests -p "test_*.py" -v
python -m py_compile server.py task_core.py taskd.py task_exec.py scripts/hwlog
```
确认没有新增以下内容:
- /var/lib/hardware-dashboard/monitor.sqlite3
- /var/log/hardware-watch/*.jsonl
- /etc/hardware-task-admin.json
- 管理员密码、SSH 密码、私钥或真实硬件 UUID
真实配置文件 config/hardware-watch.json 已被 .gitignore 忽略。仓库只保留 config/hardware-watch.example.json。
## 初始化远端
```
git remote add origin <你的仓库地址>
git push -u origin main
```

10
pyproject.toml Normal file
View File

@ -0,0 +1,10 @@
[project]
name = "hardware-dashboard"
version = "1.2.0"
description = "LAN hardware monitoring dashboard with authenticated scheduled tasks"
requires-python = ">=3.12"
dependencies = []
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

438
scripts/hwlog Executable file
View File

@ -0,0 +1,438 @@
#!/usr/bin/python3
"""Read-only hardware sampling and bounded, persistent event summaries."""
import argparse
import csv
import datetime as dt
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import time
BASE = Path('/var/lib/hardware-watch')
LOG = Path('/var/log/hardware-watch')
CONFIG = Path('/etc/hardware-watch.json')
VERSION = '1.0'
PATTERN = (r'NVRM|NVIDIA|AER:|PCIe Bus Error|BadTLP|BadDLLP|'
r'EDAC|Machine check|Hardware Error|mce:|'
r'I/O error|Buffer I/O|EXT4-fs error|BTRFS.*error|'
r'nvme.*(timeout|reset|fail|critical)|ata[0-9].*(error|fail|reset)|'
r'critical temperature|temperature above threshold|thermal.*(thrott|critical)')
NAMES = {
'power': '显卡辅助供电检测失败', 'gpu_init': '显卡驱动初始化失败',
'pcie_fatal': 'PCIe 不可纠正错误', 'pcie_correctable': 'PCIe 可纠正通信错误',
'gpu_xid': 'NVIDIA Xid 错误', 'memory_cpu': 'CPU/内存硬件报错',
'storage': '磁盘或存储通信错误', 'thermal': '硬件温度告警',
'gpu_missing': 'PCIe 显卡数量不足', 'gpu_driver': '显卡管理接口异常',
'gpu_identity': '预期显卡 UUID 缺失', 'gpu_temp': '显卡温度超过设定阈值',
'disk_missing': '预期磁盘缺失', 'smart': '磁盘 SMART 健康告警',
'sensor_temp': '传感器温度告警', 'journal': '系统日志采集异常',
'monitor': '硬件日志采集器', 'boot': '系统启动',
'clock': '系统时钟发生明显变化',
}
def stamp():
return dt.datetime.now().astimezone().isoformat(timespec='seconds')
def read(path, default=''):
try:
return Path(path).read_text().strip()
except OSError:
return default
def load(path, default):
try:
return json.loads(Path(path).read_text())
except (OSError, ValueError):
return default
def save(path, obj):
tmp = Path(str(path) + '.tmp')
tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + '\n')
os.chmod(tmp, 0o640)
os.replace(tmp, path)
def command(args, timeout=12):
try:
p = subprocess.run(args, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout, env={**os.environ, 'LC_ALL': 'C'})
return p.returncode, p.stdout, p.stderr
except (OSError, subprocess.TimeoutExpired) as exc:
return 124, '', str(exc)
def emit(kind, severity='INFO', **values):
row = {'observed_at': stamp(), 'severity': severity, 'category': kind,
'summary': NAMES.get(kind, kind), **values}
path = LOG / ('events-' + dt.datetime.now().astimezone().strftime('%Y-%m-%d') + '.jsonl')
with path.open('a', encoding='utf-8') as f:
f.write(json.dumps(row, ensure_ascii=False) + '\n')
os.chmod(path, 0o640)
def classify(message):
low = message.lower()
if 'necessary power cables' in low:
return 'power', 'ERROR'
if 'rminitadapter failed' in low or 'rm_init_adapter failed' in low:
return 'gpu_init', 'ERROR'
if 'no nvidia gpu found' in low:
return 'gpu_missing', 'WARN'
if 'fallen off' in low or re.search(r'\bXid\b', message):
return 'gpu_xid', 'ERROR'
if any(x in low for x in ('severity=uncorrectable', 'aer: device recovery failed', 'non-functional downstream link')):
return 'pcie_fatal', 'ERROR'
if 'severity=correctable' in low or 'badtlp' in low or 'baddllp' in low or re.search(r'\[\s*12\]\s+Timeout', message):
return 'pcie_correctable', 'WARN'
if re.search(r'machine check|hardware error|edac.*(\b[cu]e\b|error)|mce:.*(error|bank)', low):
return 'memory_cpu', 'ERROR'
if re.search(r'i/o error|buffer i/o|ext4-fs error|btrfs.*error|nvme.*(timeout|reset|fail|critical)|ata[0-9].*(error|fail)', low):
return 'storage', 'ERROR'
if re.search(r'critical temperature|temperature above threshold|thermal.*(thrott|critical)', low):
return 'thermal', 'WARN'
return None
def kernel_events(state, cfg):
args = ['journalctl', '--no-pager', '-o', 'json', '_TRANSPORT=kernel',
'--grep=' + PATTERN, '--case-sensitive=no', '--lines=+20000']
cursor = state.get('cursor')
args.append('--after-cursor=' + cursor if cursor else '--since=' + cfg.get('history_since', '7 days ago'))
code, out, err = command(args, 22)
if code not in (0, 1):
state['journal_error'] = err[:1000] or f'journalctl exit {code}'
emit('journal', 'ERROR', detail=err[:1000], action='下次继续重试;原始 journal 未删除。')
if 'cursor' in err.lower() and 'cursor' in state:
state.pop('cursor')
return
state.pop('journal_error', None)
state['journal_last_success'] = stamp()
entries = []
for line in out.splitlines():
try:
entries.append(json.loads(line))
except ValueError:
continue
# A journal with a backwards clock step can replay records before its cursor.
recent = state.get('recent_source_ids', [])
seen = set(recent)
def source_id(entry):
return hashlib.sha256(entry.get('__CURSOR', '').encode()).hexdigest()[:24]
if not recent and cursor and entries and entries[-1].get('__CURSOR') == cursor:
recent = [source_id(entry) for entry in entries]
seen = set(recent)
emit('clock', 'WARN', detail='检测到历史校时造成 journal 游标重放;已跳过此前处理过的记录,避免误计为新故障。')
groups = {}
rows = 0
for entry in entries:
rows += 1
state['cursor'] = entry.get('__CURSOR', state.get('cursor'))
token = source_id(entry)
if token in seen:
continue
seen.add(token)
recent.append(token)
message = entry.get('MESSAGE', '')
if not isinstance(message, str):
continue
result = classify(message)
if not result:
continue
category, severity = result
match = re.search(r'(?:[0-9a-f]{4}:)?[0-9a-f]{2}:[0-9a-f]{2}\.[0-7]', message, re.I)
device = match.group(0) if match else ''
normalized = re.sub(r'0x[0-9a-f]+|\b[0-9]+\b', '#', message, flags=re.I)
key = (entry.get('_BOOT_ID', ''), category, device, normalized)
realtime = int(entry.get('__REALTIME_TIMESTAMP', 0)) / 1000000
when = dt.datetime.fromtimestamp(realtime).astimezone().isoformat(timespec='seconds')
observed = stamp()
monotonic = entry.get('__MONOTONIC_TIMESTAMP')
group = groups.setdefault(key, {'category': category, 'severity': severity,
'source': 'kernel', 'device': device, 'boot_id': entry.get('_BOOT_ID', ''),
'first_at': when, 'last_at': when, 'first_monotonic_us': entry.get('__MONOTONIC_TIMESTAMP'),
'last_monotonic_us': monotonic, 'first_observed_at': observed,
'last_observed_at': observed, 'count': 0, 'detail': message[:1600]})
group['count'] += 1
group['last_at'] = when
group['last_monotonic_us'] = monotonic
group['last_observed_at'] = observed
for row in groups.values():
emit(row.pop('category'), row.pop('severity'), **row)
state['recent_source_ids'] = recent[-4096:]
pending = rows >= 20000
if pending and not state.get('journal_backlog'):
emit('journal', 'WARN', detail='本轮读取 20000 条匹配记录;下轮从游标继续处理积压,未跳过中间记录。')
elif not pending and state.get('journal_backlog'):
emit('journal', 'INFO', detail='历史日志积压已处理完成,后续按游标增量采集。')
state['journal_backlog'] = pending
def pci_gpus():
items = []
for path in sorted(Path('/sys/bus/pci/devices').glob('*')):
if read(path / 'vendor') != '0x10de' or not read(path / 'class').startswith('0x03'):
continue
driver = path / 'driver'
items.append({'pci': path.name, 'device_id': read(path / 'device'),
'driver': driver.resolve().name if driver.exists() else None,
'link_speed': read(path / 'current_link_speed'), 'link_width': read(path / 'current_link_width')})
return items
def gpu_query():
args = ['nvidia-smi', '--query-gpu=pci.bus_id,name,uuid,temperature.gpu,power.draw,memory.total',
'--format=csv,noheader,nounits']
code, out, err = command(args)
result = []
if code == 0:
for row in csv.reader(out.splitlines(), skipinitialspace=True):
if len(row) != 6:
continue
try:
result.append({'pci': row[0], 'name': row[1], 'uuid': row[2],
'temperature_c': float(row[3]), 'power_w': float(row[4]), 'memory_mib': float(row[5])})
except ValueError:
result.append({'pci': row[0], 'name': row[1], 'uuid': row[2], 'raw': row})
return result, None if code == 0 and result else (err or out or 'nvidia-smi 未返回设备')[:1200]
def disk_names():
return sorted(p.name for p in Path('/sys/block').glob('*')
if not p.name.startswith(('loop', 'ram', 'zram', 'dm-', 'md')))
def sensors():
result = []
for path in sorted(Path('/sys/class/hwmon').glob('hwmon*')):
for inp in path.glob('temp*_input'):
try:
prefix = inp.name[:-6]
result.append({'chip': read(path / 'name'), 'label': read(path / (prefix + '_label'), prefix),
'celsius': int(read(inp)) / 1000,
'critical_c': int(read(path / (prefix + '_crit'), '0')) / 1000})
except ValueError:
pass
return result
def active_problem(problems, key, kind, detail, severity='ERROR'):
problems[key] = {'category': kind, 'severity': severity, 'detail': detail}
def smart_checks(state, problems, disks):
# Hourly, read-only SMART query. Never starts self-tests or changes disk settings.
cached = state.get('smart', {})
if time.time() - state.get('smart_at', 0) >= 3600:
cached = {}
for name in disks:
code, out, err = command(['smartctl', '-a', '-j', '/dev/' + name], 7)
try:
data = json.loads(out)
except ValueError:
cached[name] = {'available': False, 'detail': (err or out)[:500]}
continue
nv = data.get('nvme_smart_health_information_log', {})
cached[name] = {'available': 'smart_status' in data,
'passed': data.get('smart_status', {}).get('passed'),
'critical_warning': nv.get('critical_warning'), 'media_errors': nv.get('media_errors'),
'temperature_c': data.get('temperature', {}).get('current'),
'percentage_used': nv.get('percentage_used'), 'exit_status': code}
state['smart'], state['smart_at'] = cached, time.time()
for name, item in cached.items():
if not item.get('available'):
active_problem(problems, 'smart-unavailable:' + name, 'smart',
f'{name}: SMART 读取不可用,当前仅监控内核存储报错。{item.get("detail", "")}', 'WARN')
elif item.get('passed') is False or item.get('critical_warning', 0):
active_problem(problems, 'smart:' + name, 'smart', f'{name}: {json.dumps(item, ensure_ascii=False)}')
previous = state.get('smart_media', {}).get(name)
media = item.get('media_errors')
if media is not None and previous is not None and media > previous:
emit('smart', 'ERROR', detail=f'{name}: NVMe 介质错误计数从 {previous} 增加到 {media}')
if media is not None:
state.setdefault('smart_media', {})[name] = media
return cached
def collect(state, cfg):
now = stamp()
boot = read('/proc/sys/kernel/random/boot_id')
clock = {'wall': time.time(), 'monotonic': time.monotonic(), 'boot_id': boot}
old_clock = state.get('clock', {})
if old_clock.get('boot_id') == boot:
delta = (clock['wall'] - old_clock['wall']) - (clock['monotonic'] - old_clock['monotonic'])
if abs(delta) > 60:
emit('clock', 'WARN', detail=f'同一次启动中墙上时钟相对单调时钟变化 {delta:.1f} 秒;核对事件时间时应结合启动 ID 和单调时钟。')
state['clock'] = clock
if boot != state.get('boot_id'):
emit('boot', boot_id=boot, previous_boot_id=state.get('boot_id'),
detail='监控首次观察到本次启动;准确启动信息可通过 journalctl 查看。')
state['boot_id'] = boot
state['smart_at'] = 0
kernel_events(state, cfg)
pci = pci_gpus()
gpu, nv_error = gpu_query() if pci else ([], 'PCIe 总线没有 NVIDIA 显卡')
disks = disk_names()
temps = sensors()
problems = {}
if state.get('journal_error'):
active_problem(problems, 'journal-read', 'journal', state['journal_error'], 'WARN')
if len(pci) < cfg['expected_gpu_count']:
active_problem(problems, 'gpu-count', 'gpu_missing',
f'预期 {cfg["expected_gpu_count"]} 块 NVIDIA 显卡,PCIe 实际 {len(pci)} 块。')
if nv_error or len(gpu) < len(pci):
active_problem(problems, 'gpu-driver', 'gpu_driver', nv_error or f'PCIe {len(pci)} 块,nvidia-smi 仅 {len(gpu)} 块。')
if not nv_error:
missing = sorted(set(cfg.get('expected_gpu_uuids', [])) - {x['uuid'] for x in gpu})
if missing:
active_problem(problems, 'gpu-uuid', 'gpu_identity', '缺少预期 GPU:' + ', '.join(missing))
for item in gpu:
if item.get('temperature_c', 0) >= cfg.get('gpu_warn_c', 85):
active_problem(problems, 'gpu-temp:' + item['uuid'], 'gpu_temp',
f'{item["name"]} {item["pci"]}: {item["temperature_c"]}°C', 'WARN')
for name in set(cfg.get('expected_disks', [])) - set(disks):
active_problem(problems, 'disk:' + name, 'disk_missing', name)
for item in temps:
if item['critical_c'] > 0 and item['celsius'] >= item['critical_c']:
active_problem(problems, 'sensor:' + item['chip'] + item['label'], 'sensor_temp',
json.dumps(item, ensure_ascii=False), 'ERROR')
smart = smart_checks(state, problems, disks)
previous = state.get('active', {})
for key, value in problems.items():
old = previous.get(key)
value['since'] = old.get('since', now) if old else now
if not old or old.get('detail') != value['detail']:
emit(value['category'], value['severity'], event='FAULT', key=key, detail=value['detail'], since=value['since'])
for key, value in previous.items():
if key not in problems:
emit(value['category'], 'INFO', event='RECOVERED', key=key, detail='此前异常在本轮检查中已消失。', since=value.get('since'))
state['active'] = problems
status = {'checked_at': now, 'boot_id': boot, 'pci_gpus': pci, 'gpus': gpu,
'nvidia_error': nv_error, 'disks': disks, 'smart': smart, 'sensors': temps, 'active': problems,
'journal_last_success': state.get('journal_last_success'), 'journal_backlog': state.get('journal_backlog', False)}
save(BASE / 'status.json', status)
if time.time() - state.get('snapshot_at', 0) >= 900:
path = LOG / ('samples-' + dt.datetime.now().astimezone().strftime('%Y-%m-%d') + '.jsonl')
with path.open('a', encoding='utf-8') as f:
f.write(json.dumps(status, ensure_ascii=False) + '\n')
os.chmod(path, 0o640)
state['snapshot_at'] = time.time()
save(BASE / 'state.json', state)
# Age and total-size limits apply only to this monitor's own log files.
files = sorted(LOG.glob('*.jsonl'), key=lambda p: p.stat().st_mtime)
cutoff = time.time() - cfg.get('retention_days', 90) * 86400
total = sum(p.stat().st_size for p in files)
for path in files:
if path.stat().st_mtime < cutoff or total > 256 * 1024 * 1024:
if path.name.endswith(dt.datetime.now().strftime('%Y-%m-%d') + '.jsonl'):
continue
total -= path.stat().st_size
path.unlink()
def show_status():
status = load(BASE / 'status.json', {})
print('硬件管理日志 | 最近检查:' + status.get('checked_at', '尚未运行'))
age = (dt.datetime.now().astimezone() - dt.datetime.fromisoformat(status['checked_at'])).total_seconds() if status else 9999
if age > 180:
print('警告:最近检查超过 3 分钟,请检查 systemctl status hardware-watch.timer hardware-watch.service')
for item in status.get('pci_gpus', []):
print(f'PCIe {item["pci"]} | {item["link_speed"]} x{item["link_width"]} | {item["driver"]}')
for item in status.get('gpus', []):
print(f'{item["name"]} | {item["pci"]} | {item.get("temperature_c", "?")}°C | {item.get("power_w", "?")}W | {item["uuid"]}')
for name, item in status.get('smart', {}).items():
print(f'磁盘 {name} | SMART={item.get("passed")} | 温度={item.get("temperature_c")}°C | 介质错误={item.get("media_errors")}')
print('当前采样告警:' + str(len(status.get('active', {}))))
for value in status.get('active', {}).values():
print(f' [{value["severity"]}] {NAMES[value["category"]]}:{value["detail"]}')
if status.get('journal_backlog'):
print('历史内核日志正在补录,实时 GPU/磁盘采样已启用。')
print('PCIe 等内核历史事件另见 hwlog events;当前在线不代表此前没有通信错误。')
def show_events(args):
rows = []
seen = set()
status = load(BASE / 'status.json', {})
active = status.get('active', {})
since = dt.datetime.fromisoformat(args.since).astimezone() if args.since else None
for path in sorted(LOG.glob('events-*.jsonl')):
for line in path.read_text().splitlines():
try:
row = json.loads(line)
if row.get('source') == 'kernel':
key = (row.get('boot_id'), row.get('category'), row.get('device'),
row.get('first_monotonic_us'), row.get('last_monotonic_us'), row.get('detail'))
if key in seen:
continue
seen.add(key)
event_time = row.get('first_at', row['observed_at'])
source_last = row.get('last_at', event_time)
observed = row.get('observed_at', event_time)
if since and max(source_last, observed) < since.isoformat():
continue
if args.category and row['category'] != args.category:
continue
rows.append(row)
except (ValueError, KeyError):
continue
# Recently collected records remain visible even if old source clocks were ahead.
rows.sort(key=lambda x: x['observed_at'])
for row in rows[-max(1, args.limit):]:
if args.json:
print(json.dumps(row, ensure_ascii=False))
else:
print(f'{row.get("first_at", row["observed_at"])} [{row["severity"]}] {row["summary"]} {row.get("device", "")} {row.get("event", "")}')
if 'count' in row:
print(f' 原始故障时间:{row.get("first_at", "未知")} 至 {row.get("last_at", "未知")}')
print(f' 监控发现时间:{row.get("first_observed_at", row["observed_at"])} 至 {row.get("last_observed_at", row["observed_at"])}')
first_mono = row.get('first_monotonic_us')
last_mono = row.get('last_monotonic_us')
if first_mono and last_mono:
print(f' 本次启动经过:{int(first_mono)/1000000:.3f}s 至 {int(last_mono)/1000000:.3f}s;启动 {row.get("boot_id", "")}')
print(f' 本批重复次数:{row["count"]}')
current = False
for value in active.values():
detail = value.get('detail', '')
if value.get('category') == row.get('category') and (not row.get('device') or row.get('device') in detail):
current = True
break
print(' 当前判断:当前采样仍有同类告警' if current else ' 当前判断:当前采样未复现,属于历史记录或已恢复')
if row['last_at'] < row['first_at']:
print(' 时间提示:源日志时钟发生回拨,以上首末时间不能按日历顺序判断;请结合启动 ID、单调时钟及原始 journal。')
if row['first_at'] > row['observed_at']:
print(' 时间提示:源日志日期晚于采集日期,源系统时钟曾异常;采集时间为 ' + row['observed_at'])
print(' ' + row.get('detail', ''))
def main():
parser = argparse.ArgumentParser(description='硬件管理日志:每分钟采样,查看状态和历史故障。')
sub = parser.add_subparsers(dest='action')
sub.add_parser('collect')
sub.add_parser('status')
events = sub.add_parser('events')
events.add_argument('--since', help='例如 2026-11-02 或 2026-11-02T12:00:00+08:00')
events.add_argument('--category', choices=sorted(NAMES))
events.add_argument('--limit', type=int, default=50)
events.add_argument('--json', action='store_true')
sub.add_parser('self-test')
args = parser.parse_args()
if args.action == 'self-test':
cases = [('NVRM: GPU does not have the necessary power cables connected.', 'power'),
('NVRM: RmInitAdapter failed! (0x24:0x1c:1603)', 'gpu_init'),
('PCIe Bus Error: severity=Correctable, type=Data Link Layer', 'pcie_correctable'),
('PCIe Bus Error: severity=Uncorrectable (Fatal)', 'pcie_fatal'),
('NVRM: Xid (PCI:0000:03:00): 79, GPU has fallen off the bus.', 'gpu_xid'),
('nvme nvme0: I/O 10 timeout, reset controller', 'storage'),
('EDAC MC0: 1 CE memory read error', 'memory_cpu')]
for message, expected in cases:
assert classify(message)[0] == expected, message
assert classify('NVRM: loading NVIDIA UNIX x86_64 Kernel Module') is None
assert classify('nvidia: module verification failed: tainting kernel') is None
print('PASS: 7 hardware error cases and 2 benign driver messages')
return
if args.action == 'collect':
BASE.mkdir(parents=True, exist_ok=True)
LOG.mkdir(parents=True, exist_ok=True)
with (BASE / 'lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
collect(load(BASE / 'state.json', {}), load(CONFIG, {}))
elif args.action == 'events':
show_events(args)
else:
show_status()
if __name__ == '__main__':
main()

730
server.py Normal file
View File

@ -0,0 +1,730 @@
#!/usr/bin/python3
"""LAN-only, read-only hardware dashboard. Python 3.12 standard library."""
import argparse
import csv
from contextlib import contextmanager
import datetime as dt
import hashlib
import io
import json
import logging
import math
import os
from pathlib import Path
import re
import signal
import socket
from http.cookies import SimpleCookie, CookieError
import sqlite3
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parent
DATA = Path(os.environ.get('DASHBOARD_DATA', '/var/lib/hardware-dashboard'))
LOGS = Path(os.environ.get('HARDWARE_LOGS', '/var/log/hardware-watch'))
HWSTATE = Path(os.environ.get('HARDWARE_STATE', '/var/lib/hardware-watch/status.json'))
CONFIG = Path(os.environ.get('HARDWARE_CONFIG', '/etc/hardware-watch.json'))
INTERVAL = 10
KEEP_DAYS = 30
TZ = dt.timezone(dt.timedelta(hours=8))
STOP = threading.Event()
LOCK = threading.Lock()
LATEST = {}
TASK_SOCKET='/run/hardware-taskd/control.sock'
RANGES = {'1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800, '30d': 2592000}
CATEGORY = {
'power': ('显卡辅助供电异常', '显卡检测不到必要的外接供电。', '正常关机并断电后,检查该卡供电线两端、转接头和退针情况;仍异常时换用匹配的已知正常供电线测试。'),
'gpu_init': ('显卡初始化失败', '系统发现了显卡,但驱动未能让它进入可用状态。', '先查看同一时段是否有供电或 PCIe 错误,再检查供电与插槽;不要直接反复重装驱动。'),
'gpu_driver': ('显卡状态读取失败', '管理接口没有返回全部预期显卡。', '对照供电、PCIe 和驱动日志排查;查看下方原始错误。'),
'gpu_missing': ('显卡未被发现', '实际发现的显卡少于预期,或内核报告未找到 NVIDIA GPU。', '先确认是否主动拆卡;否则正常关机断电后检查供电与插槽。'),
'gpu_identity': ('预期显卡缺失', '当前显卡身份与监控基准不一致。', '若主动更换了显卡,更新监控基准;否则检查对应显卡是否在线。'),
'gpu_temp': ('显卡温度偏高', '显卡温度超过配置的告警线。', '检查机箱风道和风扇,必要时降低计算负载;P100 依赖机箱送风。'),
'pcie_correctable': ('显卡与主板通信需要重试', '显卡与主板传数据时出现了系统可以自动纠正的通信错误。这条告警本身不表示显卡损坏。', '目前显卡在线时可以继续使用并观察。如果持续报错、任务变慢或显卡消失,安排空闲时正常关机并断电,检查显卡供电线和插槽,再做换线或换槽测试。'),
'pcie_fatal': ('PCIe 严重通信错误', '硬件连接出现不可纠正错误,可能导致设备不可用。', '先保存日志;正常关机断电后检查显卡供电、插槽及转接设备。'),
'gpu_xid': ('显卡运行错误', 'NVIDIA 驱动记录了 GPU 运行异常。', '结合原始 Xid 编号与同一时间的温度、供电、PCIe 日志排查。'),
'memory_cpu': ('CPU/内存硬件告警', '内核报告了机器检查或内存纠错事件。', '保留原始槽位与错误信息,由维护人员检查对应内存、CPU 和主板。'),
'storage': ('磁盘读写/通信异常', '内核记录了存储读写错误、超时或重置。', '先确认重要数据已有备份,再检查 SMART 状态、硬盘连接和供电。'),
'smart': ('磁盘健康告警', '磁盘 SMART 健康检查异常或无法读取。', '查看具体健康项;若出现介质错误或健康失败,优先备份并安排检修。'),
'disk_missing': ('预期磁盘缺失', '监控没有发现预期磁盘。', '确认是否主动移除;否则断电检查连接并安排维护。'),
'thermal': ('硬件温度告警', '内核报告过热或温度相关降速。', '检查散热器、风扇和机箱风道,必要时降低负载。'),
'sensor_temp': ('传感器温度告警', '硬件传感器温度达到告警阈值。', '检查对应 CPU、磁盘或机箱散热。'),
'clock': ('系统时间有变化', '历史系统时间曾跳变,原始时间可能不可靠。', '对照采集时间、启动 ID 和启动后经过时间;带“估算”的时刻不能当作精确时间。'),
'journal': ('日志采集状态', '硬件日志采集器记录了自身的运行状态。', '若持续采集失败或状态超过三分钟未更新,请维护人员检查 hardware-watch 服务。'),
'boot': ('系统启动记录', '采集器观察到了一个新的系统启动。', '如果不是计划内重启,查看此前是否有断电、温度或硬件异常。'),
'monitor': ('监控运行记录', '监控服务自身的状态变化。', '查看详细信息。'),
'web_monitor': ('网页采集异常', '网页无法取得最新硬件数据。', '检查监控服务是否正常,不要把旧数据当成实时状态。'),
'memory_pressure': ('可用内存偏少', '系统可用内存不足总量的 5%。', '检查正在运行的任务,减少不必要的并发或安排内存扩容。'),
}
def read(path, default=''):
try:
return Path(path).read_text().strip()
except OSError:
return default
def read_json(path, default=None):
try:
return json.loads(read(path))
except (ValueError, TypeError):
return {} if default is None else default
def iso(ts=None):
return dt.datetime.fromtimestamp(time.time() if ts is None else ts, TZ).isoformat(timespec='seconds')
def epoch(value):
try:
return dt.datetime.fromisoformat(value).timestamp()
except (TypeError, ValueError):
return None
def run(args, timeout=6):
try:
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout,
env={**os.environ, 'LC_ALL': 'C'})
return p.returncode, p.stdout, p.stderr
except (OSError, subprocess.TimeoutExpired) as exc:
return 124, '', str(exc)
@contextmanager
def connect():
db = sqlite3.connect(DATA / 'monitor.sqlite3', timeout=8)
db.row_factory = sqlite3.Row
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
def initialize():
DATA.mkdir(parents=True, exist_ok=True)
with connect() as db:
db.execute('PRAGMA journal_mode=WAL')
db.executescript('''
CREATE TABLE IF NOT EXISTS metrics (
ts INTEGER PRIMARY KEY, cpu REAL, memory REAL, cpu_temp REAL,
disk_read REAL, disk_write REAL, net_rx REAL, net_tx REAL,
gpus TEXT NOT NULL, source TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY, occurred REAL NOT NULL, observed REAL NOT NULL,
category TEXT, severity TEXT, raw TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS event_time ON events(occurred DESC);
CREATE INDEX IF NOT EXISTS event_observed ON events(observed DESC);
CREATE INDEX IF NOT EXISTS event_category ON events(category, observed DESC);
CREATE INDEX IF NOT EXISTS metric_source ON metrics(source, ts);
CREATE TABLE IF NOT EXISTS imports (file TEXT PRIMARY KEY, inode TEXT, offset INTEGER);
CREATE TABLE IF NOT EXISTS boot_clock (boot TEXT PRIMARY KEY, anchor REAL, mono REAL);
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
''')
def event_id(row):
if row.get('source') == 'kernel':
value = [row.get(k) for k in ('boot_id', 'category', 'device', 'first_monotonic_us', 'last_monotonic_us', 'detail')]
else:
value = [row.get(k) for k in ('observed_at', 'category', 'key', 'event', 'detail')]
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
def import_logs(db):
for path in sorted(LOGS.glob('*.jsonl')):
if not path.name.startswith(('events-', 'samples-')):
continue
stat = path.stat()
checkpoint = db.execute('SELECT inode,offset FROM imports WHERE file=?', (path.name,)).fetchone()
offset = checkpoint['offset'] if checkpoint and checkpoint['inode'] == str(stat.st_ino) and checkpoint['offset'] <= stat.st_size else 0
if offset == stat.st_size:
continue
with path.open('rb') as f:
f.seek(offset)
for _ in range(5000):
pos = f.tell()
line = f.readline()
if not line or not line.endswith(b'\n'):
f.seek(pos)
break
try:
row = json.loads(line)
except (ValueError, UnicodeDecodeError):
continue
if path.name.startswith('samples-'):
ts = epoch(row.get('checked_at'))
if ts is None or ts < time.time() - KEEP_DAYS * 86400:
continue
gpus = [{'uuid': x.get('uuid'), 'name': x.get('name'), 'pci': x.get('pci'),
'temperature_c': x.get('temperature_c'), 'power_w': x.get('power_w'),
'utilization': None, 'memory_used_mib': None,
'memory_total_mib': x.get('memory_mib')} for x in row.get('gpus', [])]
db.execute('INSERT OR IGNORE INTO metrics VALUES(?,NULL,NULL,NULL,NULL,NULL,NULL,NULL,?,?)',
(int(ts), json.dumps(gpus), 'legacy'))
else:
observed = epoch(row.get('observed_at'))
first = epoch(row.get('first_at')) or observed
if observed is None:
continue
last = epoch(row.get('last_at'))
mono = row.get('last_monotonic_us')
if last and mono and last <= observed + 60 and row.get('boot_id'):
m = int(mono) / 1e6
db.execute('''INSERT INTO boot_clock VALUES(?,?,?) ON CONFLICT(boot) DO UPDATE
SET anchor=excluded.anchor,mono=excluded.mono WHERE excluded.mono > boot_clock.mono''',
(row['boot_id'], last - m, m))
db.execute('INSERT OR IGNORE INTO events VALUES(?,?,?,?,?,?)',
(event_id(row), first, observed, row.get('category', 'monitor'),
row.get('severity', 'INFO'), json.dumps(row, ensure_ascii=False)))
offset = f.tell()
db.execute('INSERT OR REPLACE INTO imports VALUES(?,?,?)', (path.name, str(stat.st_ino), offset))
def temperatures():
result = []
for path in Path('/sys/class/hwmon').glob('hwmon*'):
chip = read(path / 'name')
for inp in path.glob('temp*_input'):
prefix = inp.name[:-6]
try:
result.append({'chip': chip, 'label': read(path / (prefix + '_label'), prefix),
'value': int(read(inp)) / 1000})
except ValueError:
pass
return result
def cpu_counters():
fields = list(map(int, read('/proc/stat').splitlines()[0].split()[1:9]))
return sum(fields), fields[3] + fields[4]
def counters():
net_rx = net_tx = disk_read = disk_write = 0
for line in read('/proc/net/dev').splitlines()[2:]:
name, values = line.split(':', 1)
if name.strip() == 'lo':
continue
fields = values.split()
net_rx += int(fields[0]); net_tx += int(fields[8])
disks = []
for path in Path('/sys/block').glob('*'):
if path.name.startswith(('loop', 'ram', 'zram', 'dm-', 'md')):
continue
fields = read(path / 'stat').split()
if len(fields) >= 7:
disk_read += int(fields[2]) * 512; disk_write += int(fields[6]) * 512
disks.append(path.name)
return {'net_rx': net_rx, 'net_tx': net_tx, 'disk_read': disk_read, 'disk_write': disk_write}, disks
def gpu_metrics():
fields = 'index,uuid,pci.bus_id,name,utilization.gpu,temperature.gpu,power.draw,power.limit,memory.used,memory.total'
code, out, err = run(['nvidia-smi', '--query-gpu=' + fields, '--format=csv,noheader,nounits'])
if code:
return [], (err or out or '无法读取显卡')[:1000]
result = []
for row in csv.reader(out.splitlines(), skipinitialspace=True):
if len(row) != 10:
continue
item = {'index': row[0], 'uuid': row[1], 'pci': row[2], 'name': row[3]}
for key, value in zip(('utilization', 'temperature_c', 'power_w', 'power_limit_w', 'memory_used_mib', 'memory_total_mib'), row[4:]):
try:
number = float(value)
item[key] = number if math.isfinite(number) else None
except ValueError:
item[key] = None
result.append(item)
return result, None if result else 'NVIDIA 管理接口没有返回可用显卡'
def issue(category, detail, level='WARN', device=''):
title, meaning, action = CATEGORY.get(category, CATEGORY['monitor'])
return {'category': category, 'title': title, 'meaning': meaning, 'action': action,
'detail': detail, 'severity': level, 'device': device}
def pci_address(value):
match = re.search(r'([0-9a-f]{4,8}):([0-9a-f]{2}):([0-9a-f]{2}\.[0-7])$', value or '', re.I)
if match:
return f'{int(match[1],16):04x}:{match[2].lower()}:{match[3].lower()}'
return value or ''
def device_map(gpus):
"""Map current GPU endpoints and their actual sysfs upstream bridges to one identity."""
result = {}
for index, gpu in enumerate(sorted(gpus,key=lambda x:x['uuid'])):
pci = pci_address(gpu['pci'])
label = f'第 {index+1} 张 P100' if 'P100' in gpu.get('name','') else f'第 {index+1} 张显卡'
entry = {'id':gpu['uuid'], 'label':label, 'pci':pci}
result[pci] = entry
path = Path('/sys/bus/pci/devices') / pci
if path.exists():
for parent in path.resolve().parents:
if re.fullmatch(r'[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-7]',parent.name):
result.setdefault(parent.name,entry)
return result
def event_component(row,current):
address = pci_address(row.get('device',''))
same_boot = row.get('boot_id','').replace('-','') == current.get('boot_id','').replace('-','')
matched = current.get('device_map',{}).get(address) if same_boot else None
if matched:
return matched['id'], matched['label'], address
if row.get('category','').startswith('pcie'):
return address or 'unknown-link', '设备与主板的连接' + (f'(端口 {address[5:]})' if address else ''), address
return address or row.get('key') or 'system', ('设备 '+address[5:]) if address else '这台电脑', address
def sample(previous):
now = time.time()
mono = time.monotonic()
total, idle = cpu_counters()
cpu = None
if previous and total > previous['cpu_total']:
cpu = max(0, min(100, 100 * (1 - (idle - previous['cpu_idle']) / (total - previous['cpu_total']))))
mem = {}
for line in read('/proc/meminfo').splitlines():
key, value = line.split(':', 1)
mem[key] = int(value.strip().split()[0]) * 1024
ram_total, ram_available = mem['MemTotal'], mem.get('MemAvailable', mem['MemFree'])
count, disks = counters()
rates = {key: None for key in count}
if previous and mono > previous['mono']:
rates = {key: max(0, (value - previous['counters'][key]) / (mono - previous['mono'])) / 1048576
for key, value in count.items()}
temps = temperatures()
cpu_temp = max([x['value'] for x in temps if x['chip'] == 'coretemp'], default=None)
disk_temp = max([x['value'] for x in temps if x['chip'] == 'nvme'], default=None)
gpu, gpu_error = gpu_metrics()
hw = read_json(HWSTATE)
cfg = read_json(CONFIG)
expected = cfg.get('expected_gpu_count', 2)
faults = []
if len(gpu) < expected:
faults.append(issue('gpu_missing', f'预期 {expected} 块,当前管理接口可用 {len(gpu)} 块。', 'ERROR'))
if gpu_error:
faults.append(issue('gpu_driver', gpu_error, 'ERROR'))
missing_uuid = set(cfg.get('expected_gpu_uuids', [])) - {x['uuid'] for x in gpu}
if missing_uuid and not gpu_error and len(gpu) >= expected:
faults.append(issue('gpu_identity', '当前显卡 UUID 与预期配置不同。', 'WARN'))
for item in gpu:
if item.get('temperature_c') is not None and item['temperature_c'] >= cfg.get('gpu_warn_c', 85):
faults.append(issue('gpu_temp', f'{item["temperature_c"]}°C,告警线 {cfg.get("gpu_warn_c",85)}°C', 'WARN', item['pci']))
if cpu_temp is not None and cpu_temp >= 85:
faults.append(issue('sensor_temp', f'CPU 最高温度 {cpu_temp}°C,网页关注线 85°C。'))
if ram_available / ram_total < .05:
faults.append(issue('memory_pressure', f'剩余 {ram_available/1073741824:.1f} GiB。'))
hw_age = now - (epoch(hw.get('checked_at')) or 0)
if hw_age > 180 or hw_age < -60:
faults.append(issue('journal', '硬件日志采集状态超过 3 分钟未更新,或系统时间异常。'))
else:
for value in hw.get('active', {}).values():
matched = next((x for x in faults if x['category'] == value.get('category')), None)
if matched:
if value.get('severity') == 'ERROR':
matched['severity'] = 'ERROR'
matched['detail'] += ' ' + value.get('detail','')
continue
faults.append(issue(value.get('category', 'monitor'), value.get('detail', ''), value.get('severity', 'WARN')))
disk_usage = os.statvfs('/var/lib')
used = (disk_usage.f_blocks - disk_usage.f_bfree) * disk_usage.f_frsize
capacity = disk_usage.f_blocks * disk_usage.f_frsize
cpu_name = next((line.split(':', 1)[1].strip() for line in read('/proc/cpuinfo').splitlines()
if line.startswith('model name')), 'CPU')
cpu_sockets = len(set(line.split(':',1)[1].strip() for line in read('/proc/cpuinfo').splitlines()
if line.startswith('physical id')))
latest = {'ts': now, 'checked_at': iso(now), 'host': socket.gethostname(), 'address': '192.168.1.37',
'boot_id': read('/proc/sys/kernel/random/boot_id'), 'monotonic_s': mono,
'uptime_s': float(read('/proc/uptime').split()[0]), 'cpu': cpu, 'cpu_name': cpu_name,
'cpu_threads': os.cpu_count(), 'cpu_sockets': cpu_sockets, 'cpu_temp': cpu_temp, 'load': list(os.getloadavg()),
'memory': (1 - ram_available / ram_total) * 100, 'memory_total_gib': ram_total / 1073741824,
'memory_available_gib': ram_available / 1073741824, 'gpus': gpu,
'expected_gpu_count': expected, 'gpu_warn_c': cfg.get('gpu_warn_c',85),
'gpu_error': gpu_error, 'disk_temp': disk_temp, 'disks': disks, 'rates': rates,
'device_map': device_map(gpu),
'disk_used_gib': used / 1073741824, 'disk_total_gib': capacity / 1073741824,
'disk_usage_pct': used / capacity * 100, 'smart': hw.get('smart', {}),
'hardware_checked_at': hw.get('checked_at'), 'hardware_stale': hw_age > 180 or hw_age < -60,
'hardware_active': hw.get('active', {}) if 0 <= hw_age <= 180 else {},
'issues': faults, 'sensors': temps, 'interval': INTERVAL}
return latest, {'cpu_total': total, 'cpu_idle': idle, 'mono': mono, 'counters': count}
def sampler():
previous = None
last_clean = 0
while not STOP.is_set():
started = time.monotonic()
try:
current, previous = sample(previous)
with connect() as db:
import_logs(db)
db.execute('INSERT OR REPLACE INTO metrics VALUES(?,?,?,?,?,?,?,?,?,?)',
(int(current['ts']), current['cpu'], current['memory'], current['cpu_temp'],
current['rates']['disk_read'], current['rates']['disk_write'], current['rates']['net_rx'],
current['rates']['net_tx'], json.dumps(current['gpus']), 'live'))
db.execute('INSERT OR REPLACE INTO boot_clock VALUES(?,?,?)',
(current['boot_id'].replace('-', ''), current['ts'] - current['monotonic_s'], current['monotonic_s']))
if time.time() - last_clean >= 3600:
db.execute('DELETE FROM metrics WHERE ts < ?', (time.time() - KEEP_DAYS * 86400,))
db.execute('DELETE FROM events WHERE observed < ?', (time.time() - 90 * 86400,))
last_clean = time.time()
current['history_started_at'] = db.execute("SELECT MIN(ts) FROM metrics WHERE source='live'").fetchone()[0]
recent = db.execute('SELECT raw FROM events WHERE observed>? AND category IN (?,?,?)',
(time.time()-180, 'pcie_fatal', 'pcie_correctable', 'power')).fetchall()
for record in recent:
row = json.loads(record['raw'])
source_boot = row.get('boot_id', '').replace('-', '')
source_mono = row.get('last_monotonic_us')
if source_boot != current['boot_id'].replace('-', '') or source_mono is None:
continue
age = current['monotonic_s'] - int(source_mono) / 1e6
row_component = event_component(row,current)[0]
already_present = any(x['category'] == row['category'] and
event_component({'boot_id':current['boot_id'],'device':x.get('device',''),'category':x['category']},current)[0] == row_component
for x in current['issues'])
if 0 <= age <= 120 and not already_present:
severity = 'WARN' if row['category'] == 'pcie_correctable' else 'ERROR'
report = issue(row['category'], '最近两分钟仍有新的通信报告。' if row['category']=='pcie_correctable' else '最近两分钟仍有新报错。', severity, row.get('device',''))
report['component_label'] = event_component(row,current)[1]
current['issues'].append(report)
with LOCK:
LATEST.clear(); LATEST.update(current)
except Exception:
logging.exception('Sample failed')
with LOCK:
LATEST['collector_error'] = '网页采集失败,请查看服务日志。'
STOP.wait(max(.2, INTERVAL - (time.monotonic() - started)))
def snapshot():
with LOCK:
data = json.loads(json.dumps(LATEST))
data['server_ts'] = time.time()
data['stale'] = not data.get('ts') or time.time() - data.get('ts', 0) > 35
return data
def history(range_name):
seconds = RANGES.get(range_name, 86400)
end = int(time.time()); start = end - seconds
step = max(INTERVAL, math.ceil(seconds / 360))
with connect() as db:
rows = db.execute('''SELECT CAST((ts-?)/? AS INTEGER) AS b,AVG(cpu) cpu,AVG(memory) memory,
AVG(cpu_temp) cpu_temp,AVG(disk_read) disk_read,AVG(disk_write) disk_write,
AVG(net_rx) net_rx,AVG(net_tx) net_tx,SUM(source='live') live
FROM metrics WHERE ts>=? AND ts<=? GROUP BY b ORDER BY b''', (start,step,start,end)).fetchall()
gpu_rows = db.execute('''SELECT CAST((m.ts-?)/? AS INTEGER) AS b,
json_extract(j.value,'$.uuid') uuid, MAX(json_extract(j.value,'$.name')) name,
AVG(json_extract(j.value,'$.utilization')) utilization,
AVG(json_extract(j.value,'$.temperature_c')) temperature_c,
AVG(json_extract(j.value,'$.power_w')) power_w,
AVG(json_extract(j.value,'$.memory_used_mib')) memory_used_mib
FROM metrics m,json_each(m.gpus) j WHERE m.ts>=? AND m.ts<=?
GROUP BY b,uuid ORDER BY b''', (start,step,start,end)).fetchall()
points = {row['b']: {'ts': start + row['b'] * step, **{k: row[k] for k in row.keys() if k != 'b'}, 'gpus': {}} for row in rows}
devices = {}
for row in gpu_rows:
if row['b'] in points and row['uuid']:
points[row['b']]['gpus'][row['uuid']] = {key: row[key] for key in ('utilization','temperature_c','power_w','memory_used_mib')}
devices[row['uuid']] = row['name']
return {'start': start, 'end': end, 'step': step, 'range': range_name,
'points': list(points.values()), 'devices': devices}
def source_times(row, anchors):
observed = epoch(row.get('observed_at')) or time.time()
first = epoch(row.get('first_at')) or observed
last = epoch(row.get('last_at')) or first
adjusted = first > observed + 60 or last < first
note = ''
if adjusted:
boot = row.get('boot_id', '').replace('-', '')
if boot in anchors and row.get('first_monotonic_us') and row.get('last_monotonic_us'):
first = anchors[boot] + int(row['first_monotonic_us']) / 1e6
last = anchors[boot] + int(row['last_monotonic_us']) / 1e6
note = '原始系统时间曾异常;发生时间按同次启动的校时记录估算,原始时间保留如下。'
else:
first = last = observed
note = '原始系统时间不可靠,当前按监控读到记录的时间展示,无法确认精确发生时刻。'
return first, last, note
def merge_incidents(items):
"""Group reports, never claim a log line equals an independent hardware failure."""
buckets = {}
output = []
for item in sorted(items,key=lambda x:x['first_ts']):
if item['category'] in ('boot','clock','journal','monitor') or item['raw'].get('event') == 'RECOVERED':
item.update(record_count=1, signal_reports=0, raw_examples=[item['raw']])
output.append(item)
continue
key = (item['category'],item['raw'].get('boot_id','').replace('-',''),item['component_id'])
group = buckets.get(key)
if group is None or item['first_ts'] > group['last_ts'] + 300:
group = dict(item)
group.update(record_count=0, count=0, signal_reports=0, raw_examples=[],
observed_first_at=item['observed_at'], devices=[])
group['id'] = 'incident-' + hashlib.sha256(json.dumps([*key,item['first_ts']]).encode()).hexdigest()[:24]
buckets[key] = group
output.append(group)
group['first_ts'] = min(group['first_ts'],item['first_ts'])
group['first_at'] = iso(group['first_ts'])
if item['last_ts'] >= group['last_ts']:
for field in ('last_ts','last_at','raw'):
group[field] = item[field]
group['record_count'] += 1
group['count'] += item['count']
is_report = item['category'] != 'pcie_correctable' or 'PCIe Bus Error:' in item['raw'].get('detail','')
group['signal_reports'] += item['count'] if is_report else 0
group['observed_first_at'] = min(group['observed_first_at'],item['observed_at'])
group['observed_at'] = max(group['observed_at'],item['observed_at'])
if item['device'] and item['device'] not in group['devices']:
group['devices'].append(item['device'])
if len(group['raw_examples']) < 8 and not any(x.get('detail') == item['raw'].get('detail') for x in group['raw_examples']):
group['raw_examples'].append(item['raw'])
priority = {'ERROR':3,'WARN':2,'INFO':1}
if priority.get(item['severity'],0)>priority.get(group['severity'],0):
group['severity']=item['severity']
state_priority = {'unknown':6,'active':5,'related':4,'history':2,'recovered':1,'info':0}
if state_priority.get(item['state'],0)>state_priority.get(group['state'],0):
group['state']=item['state'];group['state_label']=item['state_label']
if item['time_note']:
group['time_note']=item['time_note']
return output
def explain_item(item,current):
online = len(current.get('gpus',[]))
expected = current.get('expected_gpu_count',2)
usable = online >= expected and not current.get('stale')
if item['category']=='pcie_correctable':
known_gpu=item.get('component_id','').lower().startswith('gpu')
device_word='显卡' if known_gpu else '设备'
item['title']=device_word+'连接反复重试' if item.get('signal_reports',0)>1 else device_word+'连接出现一次重试'
if not item.get('signal_reports'):
item['title']='设备连接出现重试记录'
item['meaning']='主板和'+device_word+'传数据时发现通信错误,系统会自动纠正或重发。这条记录不能证明'+device_word+'已经损坏。'
item['impact']=f'当前 {online}/{expected} 块显卡可被系统正常读取,暂未看到掉卡。' if usable else '当前未能确认全部显卡正常,请先看顶部的实时状态。'
item['urgency']='可以继续使用,建议安排空闲时检查连接' if usable else '先确认显卡是否在线,再安排维护'
item['next_steps']=['现在观察计算任务有没有报错、变慢或中断。',
'若通信重试持续出现,在业务空闲时正常关机并彻底断电。',
'检查显卡供电线两端和插槽;仍有问题时由维护人员换线、换槽对照测试。']
if item['state']=='active':
item['state_label']='仍在重试 · 显卡可用' if usable and known_gpu else '仍有连接重试 · 需要关注'
elif item['category']=='power':
item['meaning']='系统当时检测不到这张显卡需要的外接供电,所以显卡可能无法启动。线接触不良、线材、电源或卡上检测电路都可能引起这一现象。'
item['impact']=f'当前 {online}/{expected} 块显卡可用;这条是以前的供电异常记录。' if usable else '当前显卡状态不完整,请检查顶部告警。'
item['urgency']='历史记录,当前先观察是否再次出现' if usable else '需要检查辅助供电'
item['next_steps']=[item['action']]
else:
item['impact']='最近采样未再发现该问题。' if item['state']=='history' else item['state_label']
item['urgency']='查看说明,按建议处理'
item['next_steps']=[item['action']]
if item['state']=='history':
item['state_label']='以前的记录 · 当前未再发现'
if item['state']=='unknown':
item['impact']='实时数据已过期,暂时无法判断现在是否正常。'
item['urgency']='先恢复监控连接,再判断是否需要维修'
item['current_checked_at']=current.get('checked_at')
return item
def events(params):
now = time.time()
days = min(90, max(1, int(params.get('days', ['7'])[0])))
start = epoch(params.get('from', [''])[0]) or now - days * 86400
end = epoch(params.get('to', [''])[0]) or now + 60
if len(params.get('to', [''])[0]) == 10:
end += 86399
category = params.get('category', [''])[0]
severity = params.get('severity', [''])[0]
query = params.get('q', [''])[0][:120].lower()
page = max(0, min(10000, int(params.get('page', ['0'])[0])))
where = ['((occurred>=? AND occurred<=?) OR (observed>=? AND observed<=?))']
values = [start,end,start,end]
if category:
where.append('category=?'); values.append(category)
if severity=='problem':
where.append("severity IN ('ERROR','WARN')")
elif severity:
where.append('severity=?'); values.append(severity)
with connect() as db:
anchors = {x['boot'].replace('-', ''): x['anchor'] for x in db.execute('SELECT * FROM boot_clock')}
records = db.execute('SELECT * FROM events WHERE ' + ' AND '.join(where) + ' ORDER BY observed DESC LIMIT 15000', values).fetchall()
current = snapshot()
items = []
for record in records:
row = json.loads(record['raw'])
first,last,note = source_times(row,anchors)
if max(first,last) < start or min(first,last) > end:
continue
title,meaning,action = CATEGORY.get(row.get('category'), CATEGORY['monitor'])
ongoing = False
related = False
component_id,component_label,address = event_component(row,current)
for problem in current.get('issues', []):
if problem['category'] == row.get('category'):
other = event_component({'boot_id':current.get('boot_id',''),'device':problem.get('device',''),'category':problem['category']},current)[0]
related = component_id==other or not problem.get('device')
if related:
break
if row.get('key') and row['key'] in current.get('hardware_active', {}):
ongoing = row.get('event') != 'RECOVERED'
if not current.get('stale') and related and row.get('source') == 'kernel':
mono = row.get('last_monotonic_us')
same_boot = row.get('boot_id', '').replace('-','') == current.get('boot_id','').replace('-','')
if same_boot and mono:
ongoing = 0 <= current.get('monotonic_s',0) - int(mono)/1e6 <= 120
if current.get('stale'):
state,label = 'unknown','当前状态未知'
elif row.get('event') == 'RECOVERED':
state,label = 'recovered','已记录恢复'
elif ongoing:
state,label = 'active','当前仍有同类异常'
elif related:
state,label = 'related','历史记录 · 当前另有同类告警'
else:
state,label = 'history','历史记录 · 当前未复现'
if row.get('severity') == 'INFO' and row.get('event') != 'RECOVERED':
state,label = 'info','运行记录'
items.append({'id': record['id'], 'title': title, 'meaning': meaning, 'action': action,
'severity': row.get('severity','INFO'), 'category': row.get('category'),
'device': row.get('device',''), 'first_ts': first,'last_ts': last,
'first_at': iso(first),'last_at': iso(last),'observed_at': row.get('observed_at'),
'count': row.get('count',1),'time_note': note,'state': state,'state_label': label,'raw': row,
'component_id':component_id,'component_label':component_label})
raw_total=len(items)
view=params.get('view',['issues'])[0]
if view!='raw':
items=merge_incidents(items)
else:
for item in items:
item.update(record_count=1,signal_reports=item['count'] if item['category']!='pcie_correctable' or 'PCIe Bus Error:' in item['raw'].get('detail','') else 0,
raw_examples=[item['raw']])
for item in items:
explain_item(item,current)
if query:
items=[item for item in items if query in json.dumps(item,ensure_ascii=False).lower()]
raw_total=sum(item.get('record_count',1) for item in items)
items.sort(key=lambda x: x['last_ts'],reverse=True)
offset = page * 30
return {'items': items[offset:offset+30], 'total': len(items),'raw_total':raw_total,'view':view,'page': page,
'more': offset+30 < len(items), 'categories': [{'id':key,'name':value[0]} for key,value in CATEGORY.items()]}
def task_rpc(action,payload=None,token='',csrf='',client=''):
request={'action':action,'payload':payload or {},'token':token,'csrf':csrf,'client':client}
try:
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as conn:
conn.settimeout(25);conn.connect(TASK_SOCKET)
conn.sendall(json.dumps(request,ensure_ascii=False).encode()+b'\n')
with conn.makefile('rb') as stream:
raw=stream.readline(1024*1024)
return json.loads(raw)
except (OSError,ValueError) as exc:
logging.warning('Task broker unavailable: %s',type(exc).__name__)
return {'ok':False,'status':503,'error':'定时任务管理服务暂不可用;设备监控仍可正常查看。'}
class Handler(BaseHTTPRequestHandler):
server_version = 'HardwareDashboard/1.0'
def log_message(self, fmt, *args):
if len(args)>1 and str(args[1]) not in ('200','304'):
logging.info('%s %s',self.client_address[0], fmt % args)
def send(self, body, content_type, status=200, attachment=None,extra_headers=None):
if isinstance(body,str):
body = body.encode('utf-8')
self.send_response(status)
self.send_header('Content-Type',content_type)
self.send_header('Content-Length',str(len(body)))
self.send_header('Cache-Control','no-store')
self.send_header('X-Content-Type-Options','nosniff')
self.send_header('X-Frame-Options','DENY')
self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
if attachment:
self.send_header('Content-Disposition',f'attachment; filename="{attachment}"')
for key,value in (extra_headers or {}).items():self.send_header(key,value)
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
try:
if parsed.path in ('/api/tasks/auth','/api/tasks','/api/tasks/item','/api/tasks/runs','/api/tasks/runlog','/api/tasks/history'):
action={'/api/tasks/auth':'auth.status','/api/tasks':'tasks.list','/api/tasks/item':'tasks.get',
'/api/tasks/runs':'tasks.runs','/api/tasks/runlog':'tasks.runlog','/api/tasks/history':'tasks.history'}[parsed.path]
payload={'id':params.get('id',[''])[0],'run_id':params.get('run_id',[''])[0]}
return self.task_response(action,payload)
elif parsed.path == '/api/status':
data = snapshot()
elif parsed.path == '/api/history':
data = history(params.get('range',['24h'])[0])
elif parsed.path == '/api/events':
data = events(params)
elif parsed.path == '/api/events.csv':
output = io.StringIO(); writer = csv.writer(output)
writer.writerow(['问题开始时间','最近发生时间','监控最近发现时间','原级别','问题','当前判断','设备','合并技术记录数','日志总行数','报错报告数(非独立故障次数)','维护建议','时间说明','原始日志样例'])
for row in events(params)['items']:
values = [row['first_at'],row['last_at'],row['observed_at'],row['severity'],row['title'],row['state_label'],row.get('component_label',row['device']),row.get('record_count',1),row['count'],row.get('signal_reports',0),row['action'],row['time_note'],row['raw'].get('detail','')]
writer.writerow(["'"+v if isinstance(v,str) and v.startswith(('=','+','-','@')) else v for v in values])
return self.send('\ufeff'+output.getvalue(),'text/csv; charset=utf-8',attachment='hardware-events.csv')
elif parsed.path in ('/','/app.js','/tasks.js','/style.css','/favicon.svg'):
filename = 'index.html' if parsed.path=='/' else parsed.path[1:]
mime = {'index.html':'text/html; charset=utf-8','app.js':'text/javascript; charset=utf-8','tasks.js':'text/javascript; charset=utf-8','style.css':'text/css; charset=utf-8','favicon.svg':'image/svg+xml'}
return self.send((ROOT/'static'/filename).read_bytes(),mime[filename])
else:
return self.send('Not found','text/plain',404)
self.send(json.dumps(data,ensure_ascii=False,allow_nan=False),'application/json; charset=utf-8')
except (ValueError,TypeError):
self.send('{"error":"参数格式不正确"}','application/json; charset=utf-8',400)
except (BrokenPipeError,ConnectionResetError):
pass
except Exception:
logging.exception('Request failed')
self.send('{"error":"读取失败,请稍后重试"}','application/json; charset=utf-8',500)
def task_response(self,action,payload):
token=''
try:
cookie=SimpleCookie();cookie.load(self.headers.get('Cookie',''))
if 'hw_task_session' in cookie:token=cookie['hw_task_session'].value
except CookieError:pass
result=task_rpc(action,payload,token,self.headers.get('X-Task-CSRF',''),self.client_address[0])
headers={}
if result.get('ok'):
data=result['data']
if action=='auth.login':
secret=data.pop('token')
headers['Set-Cookie']=f'hw_task_session={secret}; Path=/api/tasks; Max-Age={data["max_age"]}; HttpOnly; SameSite=Strict'
elif action in ('auth.logout','auth.password'):
headers['Set-Cookie']='hw_task_session=; Path=/api/tasks; Max-Age=0; HttpOnly; SameSite=Strict'
self.send(json.dumps(data,ensure_ascii=False),'application/json; charset=utf-8',extra_headers=headers)
else:
self.send(json.dumps({'error':result.get('error','任务操作失败。')},ensure_ascii=False),
'application/json; charset=utf-8',result.get('status',500))
def do_POST(self):
parsed=urlparse(self.path)
actions={'/api/tasks/login':'auth.login','/api/tasks/logout':'auth.logout','/api/tasks/password':'auth.password',
'/api/tasks/preview':'tasks.preview','/api/tasks/save':'tasks.save','/api/tasks/enable':'tasks.enable',
'/api/tasks/delete':'tasks.delete','/api/tasks/run':'tasks.run','/api/tasks/stop':'tasks.stop'}
if parsed.path not in actions:return self.send('Not found','text/plain',404)
origin=self.headers.get('Origin','')
host=self.headers.get('Host','')
if urlparse(origin).netloc!=host or urlparse(origin).scheme not in ('http','https') or urlparse(origin).hostname not in ('192.168.1.37','localhost','127.0.0.1'):
return self.send('{"error":"请从本机监控页面发起操作。"}','application/json; charset=utf-8',403)
try:
length=int(self.headers.get('Content-Length','0'))
if not 0<length<=131072:raise ValueError()
if not self.headers.get('Content-Type','').startswith('application/json'):raise ValueError()
payload=json.loads(self.rfile.read(length))
if not isinstance(payload,dict):raise ValueError()
except (ValueError,UnicodeDecodeError):
return self.send('{"error":"请求格式不正确或内容超过128KiB。"}','application/json; charset=utf-8',400)
self.task_response(actions[parsed.path],payload)
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--host',default='0.0.0.0'); parser.add_argument('--port',type=int,default=8090)
args=parser.parse_args()
logging.basicConfig(level=logging.INFO,format='%(asctime)s %(levelname)s %(message)s')
initialize()
worker=threading.Thread(target=sampler,daemon=True); worker.start()
server=ThreadingHTTPServer((args.host,args.port),Handler)
server.daemon_threads=True
logging.info('Hardware dashboard listening on %s:%s',args.host,args.port)
try:
server.serve_forever(poll_interval=.5)
finally:
STOP.set(); server.server_close(); worker.join(timeout=10)
if __name__=='__main__':
main()

171
static/app.js Normal file
View File

@ -0,0 +1,171 @@
'use strict';
const $ = id => document.getElementById(id);
const colors = {green:'#b3ee80',blue:'#6bbcff',purple:'#b4a1f4',amber:'#f5be6a',red:'#ff8c86'};
const initialSection=location.hash.slice(1);
let latest = null, selectedRange = '24h', historyData = null, eventPage = 0, eventParams = new URLSearchParams({days:'7',severity:'problem',view:'issues'});
let eventData = null, statusBusy = false, historySequence = 0, eventsBusy = false, eventSequence = 0, eventController = null;
function el(tag, cls, text) { const e=document.createElement(tag);if(cls)e.className=cls;if(text!==undefined)e.textContent=text;return e; }
function icon(name) {const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');const use=document.createElementNS(svg.namespaceURI,'use');use.setAttribute('href','#'+name);svg.append(use);return svg;}
function valid(v) {return v!==null&&v!==undefined&&Number.isFinite(Number(v));}
function num(v, digits=0) {return valid(v)?Number(v).toFixed(digits):'—';}
function when(v, full=true) {if(!v)return '—';const d=typeof v==='number'?new Date(v*1000):new Date(v);if(Number.isNaN(d.getTime()))return '时间未知';return new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',...(full?{year:'numeric',month:'2-digit',day:'2-digit'}:{}),hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(d);}
function duration(s){if(!valid(s))return '—';s=Math.max(0,Math.round(s));const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return `${d?d+' 天 ':''}${h} 小时 ${m} 分钟`;}
function toast(text){$('toast').textContent=text;$('toast').hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').hidden=true,4500);}
async function api(path){const response=await fetch(path,{signal:AbortSignal.timeout(9000),cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);return response.json();}
function meter(id,value){$(id).style.width=Math.min(100,Math.max(0,Number(value)||0))+'%';}
function metric(id,value,unit,digits=0){$(id).replaceChildren(document.createTextNode(num(value,digits)),el('small','',unit));}
function connectionLost(message){$('connection').replaceChildren(el('i','dot'),document.createTextNode('连接中断'));$('connection').firstChild.style.background=colors.red;$('health-banner').className='health-banner error';$('health-title').textContent='暂时无法确认设备状态';$('health-description').textContent=message||'网页与设备的连接已中断。下面保留的是上次数据,请勿当作实时状态。';$('health-icon').replaceChildren(icon('alert').firstChild);}
function renderStatus(data){
if(!data.ts){connectionLost('采集器正在启动,暂未取得实时数据,请稍候。');return;}
const readingAnchor=captureSectionAnchor();
latest=data;
$('host-name').textContent=data.host||'设备';$('last-update').textContent=when(data.ts,false);
if(data.stale){connectionLost('设备采样超过 35 秒未更新,当前状态未知。');return;}
$('connection').replaceChildren(el('i','dot'),document.createTextNode('实时连接 · 10 秒'));
const issues=data.issues||[],error=issues.some(x=>x.severity==='ERROR'),warn=issues.length>0;
$('health-banner').className='health-banner'+(error?' error':warn?' warning':'');
const onlyRetries=warn&&issues.every(x=>x.category==='pcie_correctable')&&data.gpus.length>=data.expected_gpu_count;
$('health-title').textContent=error?'设备存在故障,需要处理':onlyRetries?'显卡还能用,但连接需要检查':warn?'设备需要关注':'设备运行正常';
$('health-description').textContent=onlyRetries?'显卡当前在线,主板与显卡之间仍有通信重试。可以继续观察,安排空闲时检查连接。':warn?`${issues.length} 项需要关注,具体原因和处理建议见下方。`:`${data.gpus.length}/${data.expected_gpu_count} 块计算显卡在线 · 当前采样未发现异常`;
$('health-icon').replaceChildren(icon(warn?'alert':'check').firstChild);
const list=$('current-issues');list.hidden=!issues.length;list.replaceChildren();
issues.forEach(item=>{const card=el('article','issue'+(item.severity==='ERROR'?' error':''));const device=item.component_label?item.component_label+':':'';card.append(el('strong','',device+item.title),el('p','',item.meaning+' '+item.detail),el('small','', '怎么处理:'+item.action));list.append(card);});
$('nav-alert').hidden=!issues.length;$('nav-alert').textContent=String(issues.length);
metric('cpu-value',data.cpu,'%',1);meter('cpu-bar',data.cpu);$('cpu-threads').textContent=(data.cpu_sockets?data.cpu_sockets+' 路 CPU · ':'')+(data.cpu_threads||'—')+' 线程';$('cpu-temp').textContent=num(data.cpu_temp)+' °C';$('cpu-temp').title=data.cpu_name;
metric('memory-value',data.memory,'%');meter('memory-bar',data.memory);$('memory-used').textContent=`${num(data.memory_total_gib-data.memory_available_gib,1)} / ${num(data.memory_total_gib,1)} GiB`;$('memory-free').textContent=num(data.memory_available_gib,1)+' GiB 可用';
metric('gpu-count',data.gpus.length,`/ ${data.expected_gpu_count} 在线`);$('gpu-online-text').replaceChildren(el('i','dot'),document.createTextNode(data.gpus.length>=data.expected_gpu_count?'全部预期显卡可用':'显卡数量少于预期'));$('gpu-online-text').firstChild.style.background=data.gpus.length>=data.expected_gpu_count?colors.green:colors.red;$('gpu-type').textContent=data.gpus[0]?.name?.includes('P100')?'Tesla P100':'NVIDIA';
const disks=Object.values(data.smart||{}),diskBad=!data.hardware_stale&&disks.some(x=>x.passed===false||x.critical_warning),diskKnown=!data.hardware_stale&&disks.length&&disks.every(x=>x.available&&x.passed===true);
$('disk-health').textContent=diskBad?'需要检查':diskKnown?'健康正常':'状态未知';$('disk-health').style.color=diskBad?colors.red:diskKnown?colors.green:colors.amber;
$('disk-health-detail').textContent=diskKnown?'SMART 检查通过':diskBad?'请展开磁盘相关日志':'尚未取得完整 SMART 数据';$('disk-space').textContent=`已用 ${num(data.disk_used_gib,0)} / ${num(data.disk_total_gib,0)} GiB`;$('disk-temp').textContent=num(data.disk_temp)+' °C';
renderGPUs(data);
$('uptime').textContent=duration(data.uptime_s);['disk-read','disk-write','net-rx','net-tx'].forEach(id=>$(id).textContent=num(data.rates?.[id.replace('-','_')],2)+' MiB/s');
const start=data.history_started_at?when(data.history_started_at):'本次部署';
$('history-note').textContent=`CPU、内存和显卡使用率从 ${start} 开始记录。已有的显卡温度、功耗历史已补录(旧记录约 15 分钟一条);空白表示该时段没有采样。曲线为时段均值。`;
restoreSectionAnchor(readingAnchor);queueNavigation();
}
function renderGPUs(data){
const holder=$('gpu-grid');holder.replaceChildren();
const gpus=[...data.gpus].sort((a,b)=>a.uuid.localeCompare(b.uuid));
gpus.forEach((gpu,index)=>{
const card=el('article','gpu-card');card.style.setProperty('--accent',index%2?colors.amber:colors.blue);
const head=el('div','gpu-head');const state=el('span','gpu-state');state.append(el('i','dot'),document.createTextNode('在线'));head.append(el('span','gpu-label','GPU '+String(index+1).padStart(2,'0')),el('h3','',gpu.name?.replace('Tesla ','')||'NVIDIA GPU'),state);
const content=el('div','gpu-content'),util=el('div','gpu-util');util.append(document.createTextNode(num(gpu.utilization)),el('small','','%'),el('span','',valid(gpu.utilization)&&gpu.utilization>5?'正在计算':'空闲 / 低负载'));
const numbers=el('div','gpu-numbers');[['温度',gpu.temperature_c,'°C'],['功耗',gpu.power_w,'W']].forEach(([label,value,unit])=>{const cell=el('div','gpu-number'),strong=el('strong','',num(value));strong.append(el('small','',unit));if(unit==='°C'&&value>=data.gpu_warn_c)strong.style.color=colors.red;cell.append(el('span','',label),strong);numbers.append(cell);});content.append(util,numbers);
const vram=el('div','gpu-vram');vram.append(el('span','','显存占用'),el('strong','',`${num(valid(gpu.memory_used_mib)?gpu.memory_used_mib/1024:null,1)} / ${num(valid(gpu.memory_total_mib)?gpu.memory_total_mib/1024:null,0)} GiB`));
const bar=el('div','meter'),fill=el('i');fill.style.width=(gpu.memory_total_mib?Math.min(100,gpu.memory_used_mib/gpu.memory_total_mib*100):0)+'%';bar.append(fill);
const meta=el('div','gpu-meta');meta.append(el('span','',gpu.pci.replace(/^00000000:/,'0000:')),el('span','',gpu.uuid.slice(0,16)+'…'),el('span','',`功耗上限 ${num(gpu.power_limit_w)}W`));meta.title=gpu.uuid;
card.append(head,content,vram,bar,meta);holder.append(card);
});
for(let i=gpus.length;i<data.expected_gpu_count;i++){const card=el('article','gpu-card');card.style.setProperty('--accent',colors.red);card.append(el('h3','','显卡未就绪'),el('p','','当前未读取到全部预期显卡。请查看顶部异常说明和下方故障日志。'));holder.append(card);}
}
async function loadStatus(){if(statusBusy)return;statusBusy=true;try{renderStatus(await api('/api/status'));}catch(error){connectionLost();}finally{statusBusy=false;}}
function svgNode(tag,attributes={},text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);Object.entries(attributes).forEach(([k,v])=>e.setAttribute(k,v));if(text!==undefined)e.textContent=text;return e;}
function chart(id,legendId,series,data,unit,maxValue){
const container=$(id),legend=$(legendId);container.replaceChildren();legend.replaceChildren();
series.forEach(s=>{const item=el('span','legend-item'),swatch=el('i','legend-swatch');swatch.style.background=s.color;item.append(swatch,document.createTextNode(s.name));legend.append(item);});
const width=560,height=190,left=36,right=12,top=10,bottom=27,w=width-left-right,h=height-top-bottom;
const svg=svgNode('svg',{viewBox:`0 0 ${width} ${height}`,preserveAspectRatio:'none','aria-hidden':'true'});
const values=series.flatMap(s=>data.points.map(p=>s.value(p)).filter(valid));
const ceiling=Math.max(maxValue||50,Math.ceil(Math.max(0,...values)/25)*25);
const x=t=>left+(t-data.start)/(data.end-data.start)*w,y=v=>top+h-(v/ceiling)*h;
for(let i=0;i<=4;i++){const v=ceiling*i/4,yy=y(v);svg.append(svgNode('line',{x1:left,y1:yy,x2:width-right,y2:yy,class:'grid-line'}),svgNode('text',{x:left-8,y:yy+3,'text-anchor':'end'},num(v)));}
for(let i=0;i<=4;i++){const t=data.start+(data.end-data.start)*i/4;const label=new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',...(data.end-data.start>86400?{month:'2-digit',day:'2-digit'}:{hour:'2-digit',minute:'2-digit',hour12:false})}).format(new Date(t*1000));svg.append(svgNode('text',{x:x(t),y:height-6,'text-anchor':i===0?'start':i===4?'end':'middle'},label));}
series.forEach(s=>{
const points=data.points.filter(p=>valid(s.value(p)));let path='',last=null;
points.forEach(p=>{const gap=last? p.ts-last.ts:0;const maxGap=p.live||last?.live?Math.max(data.step*3,45):Math.max(data.step*3,1250);path+=(last&&gap<=maxGap?'L':'M')+x(p.ts).toFixed(2)+','+y(s.value(p)).toFixed(2);last=p;});
if(path)svg.append(svgNode('path',{d:path,stroke:s.color,class:'series-line'}));
if(points.length<4)points.forEach(p=>svg.append(svgNode('circle',{cx:x(p.ts),cy:y(s.value(p)),r:2.4,fill:s.color})));
});
if(unit==='°C'){svg.append(svgNode('line',{x1:left,x2:width-right,y1:y(85),y2:y(85),stroke:colors.amber,'stroke-width':'.7','stroke-dasharray':'5 6',opacity:'.4'}));}
const cursor=svgNode('line',{x1:0,x2:0,y1:top,y2:height-bottom,class:'cursor',visibility:'hidden'}),overlay=svgNode('rect',{x:left,y:top,width:w,height:h,class:'chart-overlay'});svg.append(cursor,overlay);container.append(svg);
const tooltip=el('div','chart-tooltip');tooltip.hidden=true;container.append(tooltip);
if(!values.length){const empty=el('div','chart-empty');empty.append(el('span','','这个时段还没有性能数据'),el('small','','采集运行后,曲线会自动出现'));container.append(empty);}
overlay.addEventListener('pointermove',event=>{
const bounds=svg.getBoundingClientRect(),px=Math.max(left,Math.min(width-right,(event.clientX-bounds.left)/bounds.width*width));
const t=data.start+(px-left)/w*(data.end-data.start);let closest=null;
data.points.forEach(p=>{if(!closest||Math.abs(p.ts-t)<Math.abs(closest.ts-t))closest=p;});
cursor.setAttribute('x1',px);cursor.setAttribute('x2',px);cursor.setAttribute('visibility','visible');tooltip.hidden=false;tooltip.replaceChildren();
const available=closest&&Math.abs(closest.ts-t)<=Math.max(data.step*1.5,closest.live?20:900);
tooltip.append(el('strong','',when(available?closest.ts:t)));
if(available){series.forEach(s=>{const row=el('p'),value=s.value(closest);row.append(el('span','',s.name),el('span','',valid(value)?num(value,1)+' '+unit:'未采集'));tooltip.append(row);});}else tooltip.append(el('p','','该时段没有采样'));
tooltip.style.left=Math.max(3,Math.min(container.clientWidth-190,(event.clientX-bounds.left)+12))+'px';
});overlay.addEventListener('pointerleave',()=>{cursor.setAttribute('visibility','hidden');tooltip.hidden=true;});
}
function renderHistory(data){
historyData=data;
const devices=Object.keys(data.devices).sort();const palette=[colors.blue,colors.amber,colors.purple,colors.green];
const gpuSeries=key=>devices.map((uuid,i)=>({name:'GPU '+String(i+1).padStart(2,'0'),color:palette[i%palette.length],value:p=>p.gpus?.[uuid]?.[key]}));
chart('chart-system','legend-system',[{name:'CPU',color:colors.green,value:p=>p.cpu},{name:'内存',color:colors.purple,value:p=>p.memory}],data,'%',100);
chart('chart-gpu','legend-gpu',gpuSeries('utilization'),data,'%',100);
chart('chart-temp','legend-temp',[{name:'CPU 最高温度',color:colors.green,value:p=>p.cpu_temp},...gpuSeries('temperature_c')],data,'°C',100);
chart('chart-power','legend-power',gpuSeries('power_w'),data,'W',Math.max(250,...data.points.flatMap(p=>Object.values(p.gpus||{}).map(g=>g.power_w||0)))*1.08);
$('history-caption').textContent=`${when(data.start)} — ${when(data.end)} · 曲线为时段均值,移入可查看数值`;
}
async function loadHistory(){const seq=++historySequence;try{const data=await api('/api/history?range='+selectedRange);if(seq===historySequence)renderHistory(data);}catch(error){if(seq===historySequence)$('history-caption').textContent='曲线暂时无法读取,请稍后重试。';}}
function addTime(container,label,value){const cell=el('div');cell.append(el('span','',label),el('strong','',value));container.append(cell);}
function renderEvents(data,preservePosition=true){
eventData=data;const holder=$('event-list');
const opened=new Set([...holder.querySelectorAll('details[open]')].map(x=>x.dataset.id));
const visible=preservePosition?[...holder.querySelectorAll('.event-row')].find(x=>x.getBoundingClientRect().bottom>70&&x.getBoundingClientRect().top<innerHeight):null;
const anchor=visible?{id:visible.dataset.id,top:visible.getBoundingClientRect().top}:null;
holder.replaceChildren();
if($('category').options.length===1){data.categories.forEach(item=>{const option=el('option','',item.name);option.value=item.id;$('category').append(option);});}
$('event-count').textContent=data.view==='raw'?`共 ${data.total} 条技术记录`:`合并后 ${data.total} 段问题记录 · 来自 ${data.raw_total} 条技术记录,不是故障次数`;
if(!data.items.length){const empty=el('div','empty-events');empty.append(el('strong','','这个范围内没有匹配记录'),el('span','','可以扩大日期范围,或选择“全部问题”。'));holder.append(empty);}
data.items.forEach(item=>{
const details=el('details','event-row');details.dataset.id=item.id;details.open=opened.has(item.id);
const summary=el('summary'),severity=item.severity==='ERROR'?'error':item.severity==='INFO'?'info':'';
const main=el('div','event-main');main.append(el('strong','',item.title),el('p','',item.component_label||'这台电脑'));
const time=el('div','event-time',when(item.last_ts));time.append(el('small','',item.time_note?'最近一次 · 时间有估算':'最近一次发生'));
const historical=item.state==='history'||item.state==='related';const badge=item.severity==='ERROR'?(historical?'曾有故障':'需处理'):item.severity==='WARN'?(historical?'曾需关注':'需关注'):'记录';
summary.append(el('span','event-level '+severity,badge),main,el('span','event-state '+item.state+' '+(item.severity==='WARN'?'warning':''),item.state_label),time,el('span','event-chevron','›'));
const detail=el('div','event-detail');
[['这是什么问题',item.meaning],['现在还能不能用',item.impact]].forEach(([label,text])=>{const answer=el('div','plain-answer');answer.append(el('strong','',label),el('p','',text));detail.append(answer);});
const action=el('div','maintenance');action.append(el('strong','','你现在需要做什么'),el('p','maintenance-priority',item.urgency));const steps=el('ol');(item.next_steps||[item.action]).forEach(text=>steps.append(el('li','',text)));action.append(steps);detail.append(action);
const times=el('div','time-grid friendly-times');addTime(times,item.time_note?'这段问题开始(估算)':'这段问题从何时开始',when(item.first_ts));addTime(times,'最近一次发生',when(item.last_ts));addTime(times,'监控最近读到记录',when(item.observed_at));addTime(times,'当前状态检查于',when(item.current_checked_at));detail.append(times);
detail.append(el('p','record-summary',`已合并 ${item.record_count||1} 条技术记录,共 ${item.count} 行日志。${item.signal_reports?'其中有 '+item.signal_reports+' 条报错报告。':''}这些数字不是设备损坏次数。`));
if(item.time_note)detail.append(el('div','time-warning',item.time_note));
const technical=el('details','technical-details');technical.dataset.id=item.id+'-technical';technical.open=opened.has(technical.dataset.id);technical.append(el('summary','','查看原始技术日志(维护人员用)'));
const evidence=el('div','technical-body'),rawTimes=el('div','time-grid');
if(item.raw.first_monotonic_us)addTime(rawTimes,'原始批次:该次启动后经过',num(Number(item.raw.first_monotonic_us)/1e6,3)+' 秒 — '+num(Number(item.raw.last_monotonic_us)/1e6,3)+' 秒');
if(item.raw.boot_id)addTime(rawTimes,'启动 ID',item.raw.boot_id);
if(item.raw.first_at)addTime(rawTimes,'原始批次首次时间(未改写)',when(item.raw.first_at));
if(item.raw.last_at)addTime(rawTimes,'原始批次末次时间(未改写)',when(item.raw.last_at));
if(item.devices?.length)addTime(rawTimes,'涉及的硬件地址',item.devices.join(' / '));
evidence.append(rawTimes);(item.raw_examples||[item.raw]).forEach(row=>evidence.append(el('pre','raw-log',row.detail||JSON.stringify(row,null,2))));technical.append(evidence);detail.append(technical);
details.append(summary,detail);holder.append(details);
});
$('page-label').textContent=`第 ${data.page+1} 页 · 每页最多 30 条`;$('previous-page').disabled=data.page===0;$('next-page').disabled=!data.more;
if(anchor&&!navigationLock){const replacement=[...holder.querySelectorAll('.event-row')].find(x=>x.dataset.id===anchor.id);if(replacement){const delta=replacement.getBoundingClientRect().top-anchor.top;if(Math.abs(delta)>1)scrollPage(scrollYValue()+delta,false);}}
queueNavigation();
}
async function loadEvents(force=false){if(eventsBusy&&!force)return;if(force)eventController?.abort();const seq=++eventSequence;eventsBusy=true;const controller=new AbortController();eventController=controller;const timer=setTimeout(()=>controller.abort(),10000);try{const params=new URLSearchParams(eventParams);params.set('page',eventPage);const response=await fetch('/api/events?'+params.toString(),{signal:controller.signal,cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);const data=await response.json();if(seq===eventSequence)renderEvents(data,!force);}catch(error){if(seq===eventSequence&&error.name!=='AbortError')$('event-count').textContent='暂时无法读取记录,请点击“查询日志”重试。';}finally{clearTimeout(timer);if(seq===eventSequence)eventsBusy=false;}}
function applyEventFilters(){if($('date-from').value&&$('date-to').value&&$('date-from').value>$('date-to').value){toast('开始日期不能晚于结束日期。');return;}eventParams=new URLSearchParams({days:'7'});[['view','event-view'],['category','category'],['severity','severity'],['from','date-from'],['to','date-to'],['q','event-search']].forEach(([key,id])=>{if($(id).value)eventParams.set(key,$(id).value);});eventPage=0;$('event-count').textContent='正在按所选条件查询…';loadEvents(true);}
$('event-filter').addEventListener('submit',event=>{event.preventDefault();applyEventFilters();});
['event-view','category','severity','date-from','date-to'].forEach(id=>$(id).addEventListener('change',applyEventFilters));let searchTimer;$('event-search').addEventListener('input',()=>{clearTimeout(searchTimer);searchTimer=setTimeout(applyEventFilters,400);});
$('previous-page').addEventListener('click',()=>{if(!eventsBusy&&eventPage>0){eventPage--;loadEvents(true);}});$('next-page').addEventListener('click',()=>{if(!eventsBusy&&eventData?.more){eventPage++;loadEvents(true);}});
$('export-events').addEventListener('click',()=>{const params=new URLSearchParams(eventParams);params.set('page',eventPage);window.location.assign('/api/events.csv?'+params.toString());});
$('ranges').addEventListener('click',event=>{const button=event.target.closest('button[data-range]');if(!button)return;selectedRange=button.dataset.range;document.querySelectorAll('#ranges button').forEach(x=>x.classList.toggle('selected',x===button));$('history-caption').textContent='正在加载所选范围…';loadHistory();});
$('fullscreen').addEventListener('click',async()=>{try{if(document.fullscreenElement)await document.exitFullscreen();else await document.body.requestFullscreen();}catch(error){toast('此浏览器不支持网页全屏,可按 F11。');}});
const sections=[{id:'overview',name:'设备总览'},{id:'trends',name:'性能趋势'},{id:'events',name:'问题与维护'},{id:'tasks',name:'定时任务'},{id:'guide',name:'使用说明'}];
let navigationLock=null,navigationTimer=null,navigationFrame=null,currentSection='overview',userHasScrolled=false;
function scrollRoot(){return document.fullscreenElement===document.body?document.body:document.scrollingElement;}
function scrollYValue(){return scrollRoot()?.scrollTop??window.scrollY;}
function scrollPage(top,smooth=true){const options={top,behavior:smooth&&!matchMedia('(prefers-reduced-motion: reduce)').matches?'smooth':'instant'};if(document.fullscreenElement===document.body)document.body.scrollTo(options);else window.scrollTo(options);}
function captureSectionAnchor(){if(navigationLock||scrollYValue()<200||currentSection==='overview')return null;const target=$(currentSection);return target?{id:currentSection,top:target.getBoundingClientRect().top}:null;}
function restoreSectionAnchor(anchor){if(!anchor||navigationLock)return;const target=$(anchor.id);if(target){const delta=target.getBoundingClientRect().top-anchor.top;if(Math.abs(delta)>1)scrollPage(scrollYValue()+delta,false);}}
function markSection(id,updateHash=true){const index=Math.max(0,sections.findIndex(x=>x.id===id));currentSection=id;document.querySelectorAll('[data-section-link]').forEach(link=>{const active=link.dataset.sectionLink===id;link.classList.toggle('active',active);if(active)link.setAttribute('aria-current','location');else link.removeAttribute('aria-current');});$('current-section').textContent=sections[index].name;$('next-section').textContent=index<sections.length-1?'下一项:'+sections[index+1].name+' ↓':'↑ 回到设备总览';if(updateHash&&location.hash!=='#'+id)history.replaceState(null,'','#'+id);}
function synchronizeNavigation(){navigationFrame=null;const root=scrollRoot();if(!root)return;const y=scrollYValue(),total=Math.max(1,root.scrollHeight-root.clientHeight);$('reading-progress').style.width=Math.min(100,y/total*100)+'%';$('back-to-top').hidden=y<500;if(document.querySelector('.task-dialog[open]')){if(currentSection!=='tasks')markSection('tasks');return;}if(navigationLock)return;let active='overview';sections.forEach(section=>{if($(section.id).getBoundingClientRect().top<=105)active=section.id;});if(y>0&&y>=total-4)active='guide';if(active!==currentSection)markSection(active);}
function queueNavigation(){if(!navigationFrame)navigationFrame=requestAnimationFrame(synchronizeNavigation);}
function navigateSection(id,smooth=true){if(!sections.some(s=>s.id===id))return;userHasScrolled=true;clearTimeout(navigationTimer);navigationLock=id;markSection(id);scrollPage(id==='overview'?0:Math.max(0,$(id).getBoundingClientRect().top+scrollYValue()-76),smooth);navigationTimer=setTimeout(()=>{navigationLock=null;queueNavigation();},smooth?1300:50);}
document.querySelectorAll('[data-section-link]').forEach(link=>link.addEventListener('click',event=>{event.preventDefault();navigateSection(link.dataset.sectionLink);}));
$('back-to-top').addEventListener('click',()=>navigateSection('overview'));$('next-section').addEventListener('click',()=>{const index=sections.findIndex(s=>s.id===currentSection);navigateSection(sections[index+1]?.id||'overview');});
window.addEventListener('scroll',()=>{queueNavigation();if(navigationLock){clearTimeout(navigationTimer);navigationTimer=setTimeout(()=>{navigationLock=null;queueNavigation();},180);}},{passive:true,capture:true});
function manualScrollIntent(){userHasScrolled=true;if(navigationLock){scrollPage(scrollYValue(),false);clearTimeout(navigationTimer);navigationLock=null;}queueNavigation();}
['wheel','touchstart'].forEach(name=>window.addEventListener(name,manualScrollIntent,{passive:true}));
window.addEventListener('keydown',event=>{if(['PageDown','PageUp','ArrowDown','ArrowUp','Home','End',' '].includes(event.key))manualScrollIntent();});
window.addEventListener('hashchange',()=>navigateSection(location.hash.slice(1),false));window.addEventListener('resize',queueNavigation);document.addEventListener('fullscreenchange',queueNavigation);
window.addEventListener('online',()=>{loadStatus();loadHistory();loadEvents();});
Promise.allSettled([loadStatus(),loadHistory(),loadEvents()]).then(()=>{if(!userHasScrolled&&sections.some(s=>s.id===initialSection))navigateSection(initialSection,false);else queueNavigation();});
setInterval(loadStatus,10000);setInterval(()=>{if(!document.hidden){loadHistory();loadEvents();}},30000);
document.addEventListener('visibilitychange',()=>{if(!document.hidden){loadStatus();loadHistory();loadEvents();}});

1
static/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="15" fill="#142229"/><path d="M12 35h9l7-17 9 29 7-17h8" fill="none" stroke="#b4f27a" stroke-width="5" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 227 B

81
static/index.html Normal file

File diff suppressed because one or more lines are too long

465
static/style.css Normal file
View File

@ -0,0 +1,465 @@
:root{--bg:#0c1218;--panel:#141d25;--panel2:#19242d;--line:#28333e;--text:#e9eff2;--muted:#8b9ba8;--green:#b3ee80;--blue:#6bbcff;--purple:#b4a1f4;--amber:#f5be6a;--red:#ff8c86;--sans:Inter,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;--mono:"Cascadia Code",Consolas,monospace}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:28px}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.6 var(--sans);letter-spacing:.1px}
button,input,select{font:inherit}
button,a,input,select,summary{-webkit-tap-highlight-color:transparent}
button{cursor:pointer}
button:disabled{cursor:default;opacity:.35}
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--green);outline-offset:4px}
a{color:inherit;text-decoration:none}
svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
.sprite{position:absolute;width:0;height:0;overflow:hidden}
[hidden]{display:none!important}
.sidebar{position:fixed;inset:0 auto 0 0;width:220px;background:#10181f;border-right:1px solid var(--line);display:flex;flex-direction:column;padding:32px 20px;z-index:10}
.brand{display:flex;gap:12px;align-items:center;font-size:19px;font-weight:650;letter-spacing:.5px}
.brand small{display:block;font-size:8px;color:var(--muted);letter-spacing:1.6px;margin-top:1px}
.brand-mark{width:39px;height:39px;border-radius:11px;background:var(--green);color:#18271b;display:grid;place-items:center}
.brand-mark svg{width:26px;height:26px;stroke-width:2}
.nav-caption{font-size:10px;color:#647888;letter-spacing:1.5px;margin:46px 12px 12px}
.sidebar nav{display:grid;gap:6px}
.sidebar nav a{padding:12px;border-radius:7px;display:flex;gap:11px;align-items:center;color:#95a5b1;font-size:13px}
.sidebar nav a:hover{background:#1b2832;color:white}
.sidebar nav a.active{background:#24332b;color:var(--green)}
.nav-dot{width:5px;height:5px;background:var(--green);border-radius:50%;margin-left:auto}
.nav-count{font:10px var(--mono);background:#5a302e;color:var(--red);padding:2px 5px;border-radius:4px;margin-left:auto}
.sidebar-node{margin-top:auto;padding:24px 13px 28px;display:grid;gap:7px}
.tiny-label{font-size:10px;color:#697d8a;letter-spacing:1px}
.sidebar-node strong{font:12px var(--mono);display:flex;align-items:center;gap:8px;margin-top:4px}
.sidebar-node>span:not(.tiny-label){font:12px var(--mono);color:var(--muted);padding-left:14px}
.node-rule{height:1px;background:var(--line);margin:18px 0 4px}
.sidebar-node p{font-size:11px;color:#788c9a;line-height:1.9;margin:0}
.sidebar-footer{border-top:1px solid var(--line);padding:20px 12px 0;font-size:10px;color:#7e929f;display:flex;gap:8px;align-items:center}
.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);flex-shrink:0}
.sidebar-footer .dot{background:#58776b}
main{margin-left:220px;padding:34px 36px 20px;max-width:1920px}
.page-header{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:27px}
.eyebrow{font-size:10px;font-weight:600;letter-spacing:1.2px;color:#8094a3}
h1{font-size:28px;letter-spacing:.3px;font-weight:600;margin:7px 0 5px;line-height:1.4}
.title-dot{color:var(--green);padding-left:1px}
.page-header p{margin:0;color:var(--muted);font-size:11px}
.separator{color:#465865;padding:0 9px}
#host-name{font-family:var(--mono)}
.header-actions{display:flex;align-items:center;gap:17px}
.live-badge{font-size:11px;display:flex;gap:7px;align-items:center;color:#b8c7cf;white-space:nowrap}
.icon-button{background:var(--panel);border:1px solid var(--line);width:36px;height:36px;border-radius:7px;color:#b8c7cf;display:grid;place-items:center}
.icon-button:hover,.text-button:hover{border-color:#61717d;color:white}
.health-banner{display:flex;gap:15px;align-items:center;background:linear-gradient(105deg,#1c3027,#17221f 70%);border:1px solid #36503c;border-radius:9px;padding:18px 21px;margin-bottom:20px}
.health-icon{width:37px;height:37px;border-radius:50%;background:#294c30;color:var(--green);display:grid;place-items:center}
.health-icon svg{width:23px;height:23px;stroke-width:2}
.health-text strong{font-size:16px;font-weight:600;color:#d7edcc}
.health-text p{font-size:11px;color:#92b49d;margin:3px 0 0}
.health-time{margin-left:auto;padding-left:24px;border-left:1px solid #33463d;text-align:right;display:grid;gap:3px;white-space:nowrap}
.health-time span{font-size:10px;color:#88a393}
.health-time strong{font:11px var(--mono);font-weight:400;color:#c8d7cd}
.health-banner.warning{background:linear-gradient(100deg,#332b20,#211f1c);border-color:#5e4a2c}
.warning .health-icon{background:#554025;color:var(--amber)}
.warning .health-text strong{color:#f7d99f}
.warning .health-text p{color:#c0a77f}
.health-banner.error{background:linear-gradient(100deg,#352322,#221c1f);border-color:#60403c}
.error .health-icon{background:#5b302d;color:var(--red)}
.error .health-text strong{color:#ffc2bd}
.error .health-text p{color:#ca9c98}
.health-banner.loading{background:var(--panel);border-color:var(--line)}
.loading .health-icon{background:var(--panel2);color:var(--muted)}
.loading .health-text strong{color:var(--text)}
.loading .health-text p{color:var(--muted)}
.current-issues{display:grid;gap:10px;margin:0 0 20px}
.issue{border:1px solid #67422f;background:#271f1c;border-radius:8px;padding:16px 20px}
.issue strong{color:var(--amber);font-size:14px}
.issue p{margin:5px 0;font-size:12px;color:#c6bbb1}
.issue small{color:#a89583}
.issue.error{border-color:#673b3b;background:#281d20}
.issue.error strong{color:var(--red)}
.overview-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
.metric-card{border:1px solid var(--line);background:var(--panel);border-radius:9px;padding:18px 20px 15px;min-width:0}
.card-top{display:flex;align-items:center;justify-content:space-between;color:#a6b6c1;font-size:12px}
.card-top svg{color:#647e8e;width:19px;height:19px}
.metric-value{font:38px/1.25 var(--mono);letter-spacing:-1.8px;margin:17px 0 12px;color:#eef4f4;white-space:nowrap}
.metric-value small{font:12px var(--sans);color:var(--muted);letter-spacing:0;margin-left:7px}
.metric-value.word{font:28px/1.7 var(--sans);letter-spacing:1px;margin-top:12px;margin-bottom:6px;color:var(--green)}
.meter{height:4px;border-radius:4px;background:#26343e;overflow:hidden}
.meter i{display:block;height:100%;width:0;background:var(--green);border-radius:4px;transition:width .6s}
.meter.violet i{background:var(--purple)}
.metric-bottom{display:flex;justify-content:space-between;align-items:center;margin-top:15px;gap:5px;white-space:nowrap;font-size:10px;color:#8195a3}
.metric-bottom strong{color:#b9c6cd;font-size:10px;font-weight:400}
.gpu-online-label,.disk-health-detail{font-size:11px;color:#a4b5bf;display:flex;gap:6px;align-items:center;height:4px;line-height:16px;margin-top:17px;margin-bottom:5px}
.disk-health-detail{height:10px;margin-top:5px}
.section-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:34px 0 16px;scroll-margin-top:24px}
.section-heading h2{font-size:19px;font-weight:600;letter-spacing:.4px;margin:5px 0}
.section-heading p{font-size:11px;color:var(--muted);margin:5px 0 0}
.section-heading.compact{margin:25px 0 13px}
.section-heading.compact>div{display:flex;align-items:center;gap:15px}
.compact h2{font-size:15px;margin:0}
.compact span{font-size:10px;color:var(--muted)}
.subtle-pill{border:1px solid var(--line);padding:3px 9px;border-radius:4px;white-space:nowrap;font:10px var(--mono)!important;color:#91a6b3!important}
.gpu-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.gpu-card{border:1px solid var(--line);border-radius:9px;background:var(--panel);padding:19px 21px;position:relative;overflow:hidden;--accent:var(--blue)}
.gpu-card::before{content:"";position:absolute;top:19px;bottom:19px;left:0;width:3px;background:var(--accent);border-radius:0 4px 4px 0}
.gpu-card .gpu-head{display:flex;gap:10px;align-items:center}
.gpu-label{font:11px var(--mono);color:var(--accent);background:#213443;padding:3px 7px;border-radius:4px}
.gpu-card h3{font-size:13px;margin:0;font-weight:500}
.gpu-state{font-size:10px;color:var(--green);margin-left:auto;display:flex;gap:6px;align-items:center}
.gpu-card .gpu-content{display:flex;gap:28px;align-items:center;margin:19px 0 11px}
.gpu-util{font:43px/1 var(--mono);letter-spacing:-1.8px;min-width:115px}
.gpu-util small{font:12px var(--sans);color:var(--muted);margin-left:3px;letter-spacing:0}
.gpu-util span{display:block;font:10px var(--sans);color:var(--muted);letter-spacing:0;margin-top:8px}
.gpu-numbers{flex:1;display:grid;grid-template-columns:1fr 1fr;gap:14px}
.gpu-number>span{display:block;font-size:10px;color:var(--muted);margin-bottom:4px}
.gpu-number strong{font:20px var(--mono)}
.gpu-number small{font:10px var(--sans);color:#899daa;margin-left:5px}
.gpu-vram{display:flex;justify-content:space-between;gap:10px;font-size:10px;color:var(--muted);margin:16px 0 7px}
.gpu-vram strong{font:10px var(--mono);color:#b3c2cb}
.gpu-card .meter{height:3px}
.gpu-card .meter i{background:var(--accent)}
.gpu-meta{display:flex;justify-content:space-between;gap:9px;border-top:1px solid #24313b;padding-top:11px;margin-top:13px;font:9px var(--mono);color:#738c9d;white-space:nowrap}
.skeleton{display:grid;place-items:center;color:var(--muted);min-height:230px;animation:fade 2s infinite}
@keyframes fade{50%{opacity:.45}
}
.system-strip{display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px 20px;margin-top:14px;border:1px solid #202c35;border-radius:7px;background:#111a21;padding:13px 17px;color:#7d939f;font-size:10px}
.system-strip span{display:flex;gap:8px;align-items:center}
.system-strip svg{width:13px;height:13px}
.system-strip strong{font:10px var(--mono);color:#afc1cd}
.segmented{display:flex;gap:2px;padding:4px;border:1px solid var(--line);background:#121c23;border-radius:7px;white-space:nowrap}
.segmented button{border:none;color:var(--muted);background:none;padding:6px 11px;border-radius:4px;font-size:11px}
.segmented button.selected{background:#304131;color:#cff5b3}
.segmented button:hover{color:white}
.history-note{font-size:10px;color:#8398a6;border-left:2px solid #3d505e;padding-left:10px;margin:-4px 0 15px;line-height:1.8}
.chart-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.chart-card{border:1px solid var(--line);border-radius:9px;background:var(--panel);padding:18px 20px 12px;min-width:0}
.chart-heading{display:flex;justify-content:space-between;gap:10px;align-items:center}
.chart-heading h3{margin:0;font-size:13px;font-weight:500}
.chart-heading>span{font:10px var(--mono);color:#728c9e}
.chart-legend{display:flex;gap:17px;flex-wrap:wrap;margin:13px 0 4px;font-size:9px;color:#a6b8c5;min-height:17px}
.legend-item{display:flex;align-items:center;gap:6px}
.legend-swatch{width:13px;height:3px;border-radius:3px}
.chart{height:190px;position:relative}
.chart svg{width:100%;height:100%;stroke:none;overflow:visible;display:block}
.chart text{font:9px var(--mono);fill:#6e8798}
.chart .grid-line{stroke:#27343e;stroke-dasharray:3 5;stroke-width:.6}
.chart .series-line{fill:none;stroke-width:1.7;stroke-linejoin:round;stroke-linecap:round}
.chart .cursor{stroke:#93a6b1;stroke-width:.7;stroke-dasharray:4 3}
.chart .chart-overlay{fill:transparent;cursor:crosshair}
.chart-tooltip{position:absolute;top:18px;background:#202d37;border:1px solid #455866;border-radius:7px;padding:9px 12px;z-index:2;box-shadow:0 8px 24px #0007;font-size:10px;pointer-events:none;min-width:140px}
.chart-tooltip strong{display:block;color:#dce8ef;font:10px var(--mono);margin-bottom:7px}
.chart-tooltip p{margin:3px 0;display:flex;justify-content:space-between;gap:15px;color:#b5c5d0}
.chart-empty{position:absolute;inset:0;display:grid;place-content:center;text-align:center;color:#75909e;font-size:11px;pointer-events:none;gap:3px}
.chart-empty small{font-size:9px;color:#5c7482}
.text-button{display:inline-flex;align-items:center;justify-content:center;gap:7px;background:#18242d;border:1px solid #34444f;border-radius:5px;padding:7px 12px;color:#afc2cc;font-size:11px;white-space:nowrap}
.text-button svg{width:14px;height:14px}
.event-panel{border:1px solid var(--line);background:var(--panel);border-radius:9px;overflow:hidden}
.event-toolbar{padding:17px 20px;display:flex;align-items:flex-end;gap:10px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.event-toolbar label{display:grid;gap:5px;font-size:10px;color:#8ea3b1}
.event-toolbar select,.event-toolbar input{border:1px solid #34434e;border-radius:5px;background:#101a22;color:#cedae2;height:33px;padding:5px 9px;font-size:11px;max-width:200px;min-width:110px}
.event-toolbar input[type=date]{max-width:135px;font-family:var(--mono);font-size:10px}
.event-toolbar .search-label{flex:1;min-width:150px}
.event-toolbar input[type=search]{width:100%;max-width:none}
.primary-button{border:1px solid #b3e98a;background:var(--green);color:#233423;padding:7px 14px;border-radius:5px;font-size:11px;font-weight:600;height:33px;white-space:nowrap}
.primary-button:hover{background:#c7fa9d}
.event-caption{display:flex;justify-content:space-between;gap:15px;font-size:10px;color:#8298a8;padding:13px 20px;background:#121c24}
.event-caption>span:first-child{color:#b1c3ce}
.event-row{border-top:1px solid #26343f}
.event-row>summary{display:flex;gap:13px;align-items:center;padding:15px 20px;list-style:none;cursor:pointer}
.event-row>summary::-webkit-details-marker{display:none}
.event-row>summary:hover{background:#1a2630}
.event-level{font-size:10px;border:1px solid #4c3d2c;background:#332b20;color:var(--amber);border-radius:4px;padding:2px 6px;white-space:nowrap}
.event-level.error{border-color:#663c3b;background:#392526;color:var(--red)}
.event-level.info{border-color:#354b5b;background:#23323f;color:#9ab7cf}
.event-main{flex:1;min-width:0}
.event-main strong{display:block;font-size:12px;font-weight:500}
.event-main p{margin:3px 0 0;color:#7891a2;font-size:9px;font-family:var(--mono);overflow-wrap:anywhere}
.event-state{border:1px solid #344651;background:#1a2932;color:#93acbb;padding:3px 7px;border-radius:4px;font-size:9px;white-space:nowrap}
.event-state.active{color:var(--red);background:#382628;border-color:#654044}
.event-state.recovered{color:var(--green);background:#24372c;border-color:#3c5b40}
.event-state.related{color:var(--amber);background:#372c20;border-color:#665036}
.event-time{text-align:right;font:10px var(--mono);color:#afc1cd;white-space:nowrap;min-width:135px}
.event-time small{display:block;font:9px var(--sans);color:#748d9e;margin-top:2px}
.event-chevron{font-size:14px;color:#738b99;transform:rotate(0deg);transition:transform .2s}
.event-row[open] .event-chevron{transform:rotate(90deg)}
.event-detail{padding:3px 20px 19px 64px;font-size:11px;color:#9cb2c1}
.event-meaning{margin:0 0 10px;color:#c2d0d9}
.maintenance{background:#1d2b32;border-left:2px solid #6a879a;padding:10px 13px;margin:10px 0;color:#c5d4dc;line-height:1.9}
.maintenance strong{color:var(--green);font-size:10px;display:block}
.time-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 18px;font-size:10px;margin:12px 0}
.time-grid span{color:#738d9f;display:block;font-size:9px}
.time-grid strong{font-family:var(--mono);font-weight:400;overflow-wrap:anywhere}
.time-warning{padding:9px 12px;border:1px solid #5d4a32;border-radius:4px;background:#2b251d;font-size:10px;color:#d9b67d;margin:10px 0}
.raw-log{margin:10px 0 0;background:#0c151d;border:1px solid #293a47;border-radius:5px;padding:10px 12px;color:#9bb4c6;font:10px/1.8 var(--mono);white-space:pre-wrap;word-break:break-word}
.pagination{display:flex;align-items:center;justify-content:space-between;padding:15px 20px;border-top:1px solid var(--line);font-size:10px;color:#869eac}
.pagination>div{display:flex;gap:7px}
.empty-events{padding:35px;text-align:center;color:var(--muted);font-size:12px}
.empty-events strong{display:block;color:#c9d9e2;font-weight:500;margin-bottom:5px}
.guide{margin-top:34px;border:1px solid #293944;border-radius:9px;padding:21px;background:#121d25;scroll-margin-top:25px}
.guide>div:first-child{display:flex;align-items:center;gap:9px}
.guide>div:first-child svg{color:#91b0c4;width:18px;height:18px}
.guide h2{font-size:14px;font-weight:500;margin:0}
.guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:28px;margin-top:20px}
.guide-number{font:11px var(--mono);color:#8dad9b}
.guide h3{font-size:12px;font-weight:500;margin:6px 0}
.guide p{font-size:10px;color:#839baa;line-height:1.9;margin:0}
footer{display:flex;justify-content:space-between;gap:20px;padding-top:25px;color:#607b8d;font-size:9px}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#304438;border:1px solid #6b886c;color:#e0f0de;padding:12px 20px;border-radius:7px;z-index:50;box-shadow:0 8px 20px #0006;font-size:12px}
body:fullscreen main{margin-left:0}
body:fullscreen .sidebar{display:none}
@media(min-width:1700px){main{padding-left:50px;padding-right:50px}
.chart{height:230px}
.metric-card{padding:23px}
.gpu-card{padding:23px}
}
@media(max-width:1200px){.sidebar{width:185px;padding-left:13px;padding-right:13px}
.brand{font-size:17px;gap:9px}
.brand-mark{width:33px;height:33px}
.brand small{font-size:7px}
main{margin-left:185px;padding:27px 23px}
.metric-card{padding:15px}
.metric-value{font-size:33px}
.metric-bottom{font-size:9px;flex-wrap:wrap}
.gpu-card{padding:17px}
.gpu-content{gap:14px!important}
.gpu-util{font-size:37px;min-width:95px}
.gpu-meta{font-size:8px}
.event-toolbar{gap:8px}
.event-state{max-width:150px;white-space:normal}
.compact>div>span{display:none}
}
@media(max-width:950px){.sidebar{width:66px;align-items:center;padding:23px 8px}
.brand>span:not(.brand-mark),.nav-caption,.sidebar nav a:not(.active) .nav-dot,.sidebar-node,.sidebar-footer{display:none}
.sidebar nav{margin-top:37px;width:100%}
.sidebar nav a{font-size:0;padding:12px;gap:0;justify-content:center}
.sidebar nav a svg{width:20px}
.nav-dot{display:none}
.nav-count{font-size:8px;position:absolute;margin:0 0 0 31px}
.sidebar nav a{position:relative}
main{margin-left:66px;padding:25px 22px}
.overview-grid{gap:11px}
.gpu-meta{flex-wrap:wrap;gap:4px}
.gpu-numbers{gap:12px}
.gpu-card h3{font-size:11px}
.gpu-state{font-size:9px}
.event-time{min-width:100px;font-size:9px}
.event-state{font-size:8px}
.event-row>summary{gap:9px;padding:14px}
.guide-grid{gap:17px}
.segmented button{padding:6px 8px}
}
@media(max-width:720px){main{padding:21px 15px}
.page-header{margin-bottom:20px}
h1{font-size:23px}
.eyebrow{font-size:8px}
.header-actions{gap:10px}
.live-badge{font-size:9px}
.page-header p{font-size:9px}
.overview-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.metric-value{font-size:34px;margin-top:12px}
.metric-card{padding:15px 17px}
.health-banner{padding:14px;gap:10px}
.health-icon{width:30px;height:30px}
.health-icon svg{width:20px}
.health-text strong{font-size:13px}
.health-text p{font-size:10px}
.health-time{padding-left:10px;font-size:9px}
.health-time strong{font-size:9px}
.gpu-grid{grid-template-columns:1fr}
.gpu-card h3{font-size:13px}
.gpu-content{gap:25px!important}
.gpu-meta{font-size:9px}
.chart-grid{grid-template-columns:1fr}
.section-heading{flex-wrap:wrap;margin-top:28px}
.section-heading h2{font-size:18px}
.section-heading p{font-size:10px}
.segmented{width:100%;justify-content:space-around}
.segmented button{flex:1}
.section-heading.compact{flex-wrap:nowrap}
.event-caption{font-size:9px;padding:12px}
.event-caption span:last-child{display:none}
.event-row>summary{flex-wrap:wrap;gap:8px}
.event-main{min-width:150px}
.event-state{order:5;margin-left:42px;font-size:9px}
.event-time{margin-left:auto;min-width:130px;font-size:9px}
.event-chevron{order:6;margin-left:auto}
.event-detail{padding:5px 14px 17px}
.time-grid{grid-template-columns:1fr}
.event-toolbar{padding:14px}
.event-toolbar label{flex:1;min-width:110px}
.event-toolbar input,.event-toolbar select{max-width:none;width:100%;min-width:0}
.event-toolbar input[type=date]{max-width:none}
.event-toolbar .search-label{min-width:100%}
.primary-button{width:100%}
.guide-grid{grid-template-columns:1fr;gap:20px}
.guide-number{float:left;margin-right:13px;margin-top:5px}
.guide p{font-size:11px}
.guide h3{font-size:13px}
.guide h2{font-size:12px}
footer{flex-direction:column;gap:4px;padding-bottom:10px}
}
@media(max-width:420px){.sidebar{display:none}
main{margin-left:0;padding:20px 13px}
h1{font-size:22px}
.health-time{display:none}
.gpu-numbers{gap:9px}
.gpu-util{min-width:100px}
.metric-card{padding:14px}
.page-header{display:block;position:relative}
.header-actions{position:absolute;right:0;top:0;gap:8px}
.page-header .eyebrow{padding-right:130px;min-height:24px}
.page-header h1{font-size:23px;margin-top:10px;white-space:nowrap}
}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}
*{animation:none!important;transition:none!important}
}
/* Readable incidents and scroll-linked navigation, revision 1.1. */
main{max-width:none}
.section-context{position:sticky;top:0;z-index:12;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:11px 14px;margin:-8px 0 20px;background:#101b24f2;border:1px solid #2c404d;border-radius:7px;backdrop-filter:blur(12px)}
.section-context>div:first-child{display:flex;gap:11px;align-items:center}
.context-label{font-size:11px;color:#87a0b0}
.section-context strong{font-size:13px;font-weight:500;color:#e3ecef}
.context-actions{display:flex;gap:8px}
.context-button{background:transparent;border:1px solid #3c505d;border-radius:5px;padding:4px 10px;color:#b9cdd8;font-size:11px}
.context-button:hover{border-color:var(--green);color:var(--green)}
.reading-progress{height:2px;position:absolute;bottom:-1px;left:0;right:0;overflow:hidden;border-radius:0 0 7px 7px}
.reading-progress i{display:block;height:100%;width:0;background:var(--green);transition:width .1s linear}
.sidebar nav a.active:after{content:'';width:5px;height:5px;border-radius:50%;background:var(--green);margin-left:auto}
.sidebar nav a:not(.active):after{display:none}
.event-explainer{margin:0 0 14px;padding:14px 17px;border:1px solid #425344;border-radius:7px;background:#1a2823;display:grid;gap:4px}
.event-explainer strong{font-size:13px;color:#d0e3c8;font-weight:500}
.event-explainer span{font-size:12px;color:#99b1a5}
.event-row>summary{padding:18px 20px;gap:15px}
.event-main strong{font-size:15px;line-height:1.7}
.event-main p{font-size:12px;font-family:var(--sans);color:#91a9b9;margin-top:4px}
.event-level{font-size:11px;padding:3px 7px}
.event-state{font-size:11px;padding:4px 8px}
.event-state.active.warning{color:var(--amber);background:#3a2c1a;border-color:#785a30}
.event-time{font-size:12px;min-width:160px}
.event-time small{font-size:10px}
.event-detail{padding:4px 22px 23px 22px;font-size:13px;line-height:1.85}
.plain-answer{padding:10px 13px;margin-bottom:5px;border-left:2px solid #3a5262}
.plain-answer>strong{display:block;color:#cfdee7;font-weight:500;font-size:12px}
.plain-answer p{margin:3px 0 0;color:#a5becd;font-size:13px}
.maintenance{margin:12px 0 15px;padding:14px 17px}
.maintenance>strong{font-size:12px}
.maintenance-priority{margin:3px 0 7px;font-size:14px;color:#e6f1e3}
.maintenance ol{margin:5px 0 0;padding-left:21px;font-size:13px}
.maintenance li{margin:4px 0}
.friendly-times{background:#111d25;padding:12px 15px;border:1px solid #2a3b47;border-radius:6px;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
.friendly-times span{font-size:11px;margin-bottom:3px}
.friendly-times strong{font-size:12px;color:#bdd0dd}
.record-summary{font-size:12px;color:#91a9b8;line-height:1.8}
.time-warning{font-size:12px}
.technical-details{border:1px solid #2d414f;border-radius:6px;margin-top:15px}
.technical-details>summary{cursor:pointer;padding:10px 13px;font-size:12px;color:#91adbf;background:#121e27}
.technical-details>summary:hover{color:var(--green)}
.technical-body{padding:2px 13px 14px}
.technical-body .time-grid{font-size:11px;grid-template-columns:repeat(2,minmax(0,1fr))}
.technical-body .time-grid span{font-size:10px}
.technical-body .raw-log{font-size:11px}
.event-toolbar label{font-size:11px}
.event-toolbar select,.event-toolbar input{font-size:12px;height:36px}
.primary-button{height:36px;font-size:12px}
.event-caption{font-size:11px;line-height:1.8}
.mobile-section-nav{display:none}
@media(min-width:1700px){.card-top{font-size:14px}.metric-bottom,.metric-bottom strong{font-size:12px}.gpu-card h3{font-size:15px}.gpu-number>span,.gpu-vram,.gpu-vram strong{font-size:12px}.system-strip,.system-strip strong{font-size:12px}.gpu-meta{font-size:11px}.chart-heading h3{font-size:15px}.chart-legend{font-size:11px}.chart{height:235px}}
@media(max-width:1100px){.friendly-times{grid-template-columns:repeat(2,minmax(0,1fr))}.event-row>summary{gap:10px}.event-state{font-size:10px}.event-time{min-width:140px;font-size:10px}}
@media(max-width:950px){.mobile-section-nav{position:fixed;display:grid;grid-template-columns:repeat(4,1fr);bottom:0;left:66px;right:0;background:#111d27f5;border-top:1px solid #3a4c57;z-index:25;padding:8px 6px calc(8px + env(safe-area-inset-bottom));backdrop-filter:blur(12px)}.mobile-section-nav a{display:flex;gap:7px;align-items:center;justify-content:center;font-size:12px;color:#8da8b9;padding:7px 4px;border-radius:5px}.mobile-section-nav a.active{color:var(--green);background:#293a2a}.mobile-section-nav svg{width:17px;height:17px}main{padding-bottom:90px}.sidebar nav a.active:after{position:absolute;right:3px;width:4px;height:4px}.context-button{font-size:10px}.section-context{margin-bottom:16px}}
@media(max-width:720px){.event-main{min-width:130px}.event-main strong{font-size:14px}.event-row>summary{padding:15px}.event-state{margin-left:0}.event-time{margin-left:auto;min-width:140px}.event-detail{padding:3px 14px 17px}.event-caption{padding:12px 14px;font-size:11px}.plain-answer p,.maintenance ol{font-size:12px}.friendly-times strong{font-size:11px}.technical-body .time-grid{grid-template-columns:1fr}.context-label{display:none}.section-context{padding:9px 10px}.section-context strong{font-size:12px}.context-button{padding:4px 7px}.event-explainer strong{font-size:12px}.event-explainer span{font-size:11px}}
@media(max-width:420px){.mobile-section-nav{left:0}.context-button{font-size:9px}.section-context strong{font-size:11px}.event-state{font-size:9px}.event-time{min-width:125px;font-size:9px}.friendly-times{grid-template-columns:1fr}.technical-body{padding-left:9px;padding-right:9px}}
/* Scheduled tasks */
.task-panel{border:1px solid var(--line);border-radius:9px;background:var(--panel);overflow:hidden;min-height:max(380px,calc(100vh - 300px));display:flex;flex-direction:column}
#task-manager,#task-list{display:flex;flex-direction:column;flex:1}
#task-locked,#task-list>.task-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center}
.task-authbar{display:flex;justify-content:space-between;gap:18px;align-items:center;padding:18px 21px;background:#17252c;border-bottom:1px solid #30424d}
.task-authbar strong{font-size:14px;font-weight:500;color:#d5e5ed}
.task-authbar p{font-size:12px;color:#91aab9;margin:4px 0 0}
.task-auth-actions{display:flex;gap:8px;flex-shrink:0}
.task-empty{text-align:center;padding:44px 20px;color:#a5bdca}
.task-empty>svg{width:31px;height:31px;color:var(--green);margin-bottom:7px}
.task-empty h3{font-size:17px;font-weight:500;color:#d6e4ec;margin:6px 0}
.task-empty p{font-size:13px;line-height:1.9;margin:7px 0}
.task-empty>span{font-size:11px;color:#7993a4}
.task-toolbar{display:flex;justify-content:space-between;align-items:center;padding:15px 20px;gap:15px}
.task-toolbar>div{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.task-toolbar strong{font-size:13px;font-weight:500;color:#d5e3eb}
.task-toolbar span{font-size:10px;color:#7f99aa}
.task-table-heading,.task-row{display:grid;grid-template-columns:minmax(150px,1.1fr) minmax(180px,1.2fr) minmax(145px,.9fr) minmax(200px,1.2fr);gap:18px;padding:13px 20px;align-items:center}
.task-table-heading{background:#101b23;color:#7e9aaa;font-size:11px}
.task-row{border-top:1px solid #283b47;padding-top:20px;padding-bottom:20px}
.task-name-cell,.task-schedule-cell,.task-status-cell{min-width:0}
.task-name-cell>strong{font-size:14px;font-weight:500;overflow-wrap:anywhere}
.task-name-cell>p{font-size:11px;color:#829daa;line-height:1.8;margin:5px 0}
.task-row-meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:6px}
.task-row-meta span{font-size:10px;color:#8eacbe;padding:1px 6px;background:#1d303c;border-radius:4px}
.task-row-meta .task-danger-label{color:var(--amber);background:#3a2e1f}
.task-schedule-cell>strong{font-size:12px;font-weight:400;overflow-wrap:anywhere;display:block;color:#c4d6e0}
.task-schedule-cell>span{display:block;font:10px/1.8 var(--mono);color:#89a4b5;margin-top:6px}
.task-status-cell{display:flex;align-items:flex-start;gap:6px;flex-wrap:wrap}
.task-status-cell>small{font-size:10px;color:#809dac;flex-basis:100%;margin-top:3px}
.task-state,.task-enabled{font-size:10px;border-radius:4px;padding:2px 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid #3d5261;color:#a4bac9;background:#1a2a34;white-space:nowrap}
.task-state.success{background:#253c2b;border-color:#466341;color:#b7ed97}
.task-state.failed{background:#39282b;border-color:#734a49;color:#ffaaa1}
.task-state.running{background:#22384a;border-color:#42657c;color:#96d0ff}
.task-state.warning{background:#3a3020;border-color:#6c5734;color:#ebc181}
.task-enabled.enabled{border-color:#3e6451;color:#a4dca8;background:#21382b}
.task-row-actions{display:flex;gap:4px 9px;flex-wrap:wrap}
.task-action{background:none;border:none;padding:4px 0;font-size:12px;color:#a9d88f;white-space:nowrap}
.task-action:hover{color:#d2fabc;text-decoration:underline}
.task-action.danger{color:#d9958d}
.task-footnote{font-size:11px;color:#7996a7;background:#101b23;padding:13px 20px;border-top:1px solid #2b3b47;line-height:1.9}
.task-dialog{width:min(810px,calc(100% - 26px));max-height:90vh;background:#14212a;color:var(--text);border:1px solid #4b6272;border-radius:12px;padding:0;box-shadow:0 30px 100px #000b;overflow:hidden}
.task-dialog::backdrop{background:#030b12bf;backdrop-filter:blur(3px)}
.task-dialog.small-dialog{max-width:510px}
.task-dialog.log-dialog{width:min(1150px,calc(100% - 26px))}
.task-dialog>form{display:flex;flex-direction:column;max-height:90vh}
.dialog-heading{padding:21px 24px 17px;display:flex;justify-content:space-between;gap:16px;align-items:flex-start;border-bottom:1px solid #30414f;background:#192a34}
.dialog-heading h2{font-size:19px;font-weight:500;margin:0;color:#e6eff4}
.dialog-heading p{font-size:12px;color:#8eacbd;margin:5px 0 0;line-height:1.8}
.dialog-close{border:1px solid #3b5160;border-radius:5px;background:#1a2a36;color:#9eb8c9;line-height:1;width:29px;height:29px;font-size:22px;flex-shrink:0}
.dialog-close:hover{color:white;border-color:#7695aa}
.dialog-content{padding:22px 24px;overflow:auto;line-height:1.9;min-height:0}
.task-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px 20px}
.task-form-grid label{display:grid;gap:7px;color:#b3c9d7;font-size:12px;align-content:start;min-width:0}
.task-form-grid input:not([type=checkbox]):not([type=file]),.task-form-grid select,.task-form-grid textarea{width:100%;border:1px solid #3a5264;border-radius:5px;background:#0e1a23;color:#d8e6f0;padding:9px 11px;font:12px/1.65 var(--sans);height:38px;min-width:0}
.task-form-grid textarea{height:auto;resize:vertical;font-family:var(--mono);line-height:1.75;font-size:12px;min-height:110px;tab-size:2}
.task-form-grid input[type=checkbox]{accent-color:var(--green);width:15px;height:15px}
.task-form-grid small{font-size:11px;color:#7e9aab;line-height:1.7}
.full-field{grid-column:1/-1}
.task-form-divider{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #314651;padding-top:15px;margin-top:2px}
.task-form-divider strong{font-size:13px;font-weight:500;color:#d3e4ee}
.task-form-divider span{font:10px var(--mono);color:#7f9eaf}
.field-label-row{display:flex;justify-content:space-between;align-items:center;gap:10px}
.inline-button{border:none;background:none;color:var(--green);font-size:11px;padding:0}
.weekday-field>span{font-size:12px;color:#b1c9d8}
#task-weekdays{display:flex;gap:10px;margin-top:9px;flex-wrap:wrap}
#task-weekdays label{display:flex;gap:7px;align-items:center;background:#1b303d;border:1px solid #385164;border-radius:5px;padding:7px 10px;min-width:54px}
.schedule-preview{display:flex;gap:12px;align-items:center;background:#152a29;border:1px solid #365347;border-radius:5px;padding:11px 13px}
.schedule-preview span{font-size:11px;color:#a5c2b0;line-height:1.9}
.schedule-preview button{flex-shrink:0}
.template-hint{padding:12px 14px;border:1px solid #705938;color:#e3c493;background:#332d21;border-radius:5px;font-size:12px;line-height:1.8}
.task-advanced{border:1px solid #30495b;border-radius:5px}
.task-advanced>summary{padding:10px 13px;color:#9ebacb;cursor:pointer;font-size:12px}
.task-advanced .task-form-grid{padding:4px 13px 15px}
.task-form-grid .task-enabled-field{display:flex;align-items:flex-start;gap:10px;background:#1f3028;padding:13px 14px;border:1px solid #3b5141;border-radius:5px}
.task-enabled-field small{display:block;margin-top:3px;color:#8eae97}
.task-enabled-field input{margin-top:4px}
.dialog-footer{padding:15px 24px;display:flex;justify-content:space-between;gap:15px;align-items:center;border-top:1px solid #354a58;background:#182932}
.dialog-footer>span{font-size:11px;color:#8ba8ba}
.dialog-footer>div{display:flex;gap:8px}
.form-error{background:#3b2529;border:1px solid #75474b;color:#ffb3ad;border-radius:5px;font-size:12px;line-height:1.8;padding:10px 12px;margin:0}
#task-confirm-message{font-size:13px;color:#c4d8e5;margin:0;white-space:pre-wrap}
#task-confirm-code{max-height:230px;overflow:auto;font-size:11px}
.run-layout{display:grid;grid-template-columns:290px minmax(0,1fr);height:min(560px,70vh);min-height:300px}
.task-runs-list{overflow:auto;background:#101d27;border-right:1px solid #304756;padding:10px}
.run-item{background:none;border:1px solid transparent;display:grid;gap:5px;text-align:left;width:100%;color:#a5bdcc;border-radius:6px;padding:12px;margin-bottom:7px;font-size:11px}
.run-item:hover,.run-item.selected{background:#203a45;border-color:#3f6271}
.run-item>.task-state{justify-self:start}
.run-item strong{font:11px var(--mono);color:#c7dce9}
.run-item>span:not(.task-state){font-size:12px;overflow-wrap:anywhere}
.run-item small{font-size:10px;color:#87a5b9}
.task-no-runs{font-size:12px;color:#90aebe;line-height:1.9;padding:12px}
.task-run-output{display:flex;flex-direction:column;min-width:0;background:#0b1620}
.task-run-meta{padding:13px 17px;border-bottom:1px solid #2b4150;display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:11px;color:#9fb9c9}
#task-run-output{flex:1;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.9 var(--mono);color:#c0d6e4;padding:17px;margin:0;tab-size:2}
@media(max-width:1100px){.task-table-heading,.task-row{grid-template-columns:1fr 1.2fr 1fr}.task-table-heading>span:last-child{display:none}.task-row-actions{grid-column:1/-1;justify-content:flex-end;border-top:1px solid #293f4c;padding-top:10px}.task-row{gap:13px}.task-authbar{align-items:flex-start}.task-auth-actions{flex-wrap:wrap;justify-content:flex-end}}
@media(max-width:950px){.mobile-section-nav{grid-template-columns:repeat(5,1fr)}.mobile-section-nav a{gap:5px;font-size:11px}}
@media(max-width:720px){.task-authbar{flex-direction:column;padding:16px}.task-auth-actions{justify-content:flex-start}.task-toolbar{align-items:flex-start;padding:15px;flex-wrap:wrap}.task-table-heading{display:none}.task-row{grid-template-columns:1fr;padding:17px}.task-name-cell>strong{font-size:15px}.task-status-cell{align-items:center}.task-schedule-cell>strong{font-size:12px}.task-row-actions{justify-content:flex-start;gap:6px 17px}.task-action{font-size:13px}.task-status-cell>small{flex-basis:auto}.task-dialog{max-height:92vh}.task-dialog>form{max-height:92vh}.dialog-heading{padding:16px}.dialog-heading h2{font-size:17px}.dialog-heading p{font-size:11px}.dialog-content{padding:16px}.dialog-footer{padding:13px 16px;gap:9px}.dialog-footer>span{font-size:10px}.task-form-grid{grid-template-columns:1fr;gap:15px}.schedule-preview{flex-direction:column;align-items:flex-start}.task-form-divider{gap:10px}.run-layout{grid-template-columns:1fr;grid-template-rows:160px minmax(0,1fr);height:70vh}.task-runs-list{border-right:none;border-bottom:1px solid #2e4656}.run-item{grid-template-columns:auto 1fr;gap:6px 12px}.run-item small{grid-column:2}.task-empty{padding:30px 16px}.task-footnote{padding:12px 16px}}

176
static/tasks.js Normal file
View File

@ -0,0 +1,176 @@
/* Authenticated task management. Credentials are never saved in localStorage. */
(()=>{
'use strict';
let auth={logged_in:false},tasks=[],listBusy=false,listRefreshPending=false,selectedRun=null,runsTask=null,confirmResolve=null,runReadSequence=0;
const states={running:['执行中','running'],success:['执行成功','success'],failed:['执行失败','failed'],timeout:['超时已停止','failed'],cancelled:['已停止','neutral'],interrupted:['执行被中断','warning']};
function taskError(id,message){$(id).textContent=message;$(id).hidden=!message;}
async function request(path,body){
const options={cache:'no-store',signal:AbortSignal.timeout(28000),credentials:'same-origin'};
if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-Task-CSRF':auth.csrf||''};options.body=JSON.stringify(body);}
const response=await fetch('/api/tasks'+path,options);
const data=await response.json();
if(!response.ok){
if(response.status===401&&path!=='/login'){setAuth({logged_in:false});}
const error=new Error(data.error||'任务操作未完成。');error.status=response.status;throw error;
}
return data;
}
function setAuth(value){
auth=value;$('task-manager').hidden=!value.logged_in;$('task-locked').hidden=!!value.logged_in;
$('task-create').disabled=!value.logged_in;$('task-login-open').hidden=!!value.logged_in;
$('task-change-password').hidden=!value.logged_in;$('task-logout').hidden=!value.logged_in;
$('task-auth-title').textContent=value.logged_in?'管理员已登录':'定时任务管理未登录';
$('task-auth-description').textContent=value.logged_in?'可新增、编辑和执行任务。登录最长有效 8 小时;执行身份为 root。':'设备监控无需登录;创建、修改或执行服务器任务需要管理员登录。';
if(!value.logged_in){tasks=[];$('task-list').replaceChildren();}
queueNavigation();
}
async function refreshAuth(){
try{const value=await request('/auth');setAuth(value);if(value.logged_in)await refreshList();}
catch(error){$('task-auth-title').textContent='任务管理服务暂不可用';$('task-auth-description').textContent=error.message;$('task-create').disabled=true;}
}
function openLogin(){taskError('task-login-error','');$('task-login-password').value='';$('task-login-dialog').showModal();$('task-login-password').focus();}
$('task-login-open').addEventListener('click',openLogin);
$('task-login-form').addEventListener('submit',async event=>{
event.preventDefault();const button=event.submitter;button.disabled=true;taskError('task-login-error','');
try{const result=await request('/login',{username:$('task-login-user').value.trim(),password:$('task-login-password').value});setAuth(result);$('task-login-password').value='';$('task-login-dialog').close();await refreshList();toast('已登录定时任务管理。');}
catch(error){taskError('task-login-error',error.message);}finally{button.disabled=false;}
});
$('task-logout').addEventListener('click',async()=>{try{await request('/logout',{});setAuth({logged_in:false});document.querySelectorAll('.task-dialog[open]').forEach(dialog=>dialog.close());$('task-body').value='';$('task-run-output').textContent='';toast('已退出任务管理。');}catch(error){toast(error.message);}});
document.querySelectorAll('[data-close-dialog]').forEach(button=>button.addEventListener('click',()=>$(button.dataset.closeDialog).close()));
function confirmAction(title,message,code=''){
return new Promise(resolve=>{confirmResolve=resolve;$('task-confirm-heading').textContent=title;$('task-confirm-message').textContent=message;$('task-confirm-code').textContent=code;$('task-confirm-code').hidden=!code;$('task-confirm-dialog').showModal();});
}
function finishConfirm(value){$('task-confirm-dialog').close();const resolve=confirmResolve;confirmResolve=null;resolve?.(value);}
$('task-confirm-no').addEventListener('click',()=>finishConfirm(false));$('task-confirm-yes').addEventListener('click',()=>finishConfirm(true));$('task-confirm-dialog').addEventListener('cancel',event=>{event.preventDefault();finishConfirm(false);});
function stateBadge(state){const [text,cls]=states[state]||['尚未执行','neutral'];return el('span','task-state '+cls,text);}
function actionButton(label,action,task,cls=''){
const button=el('button','task-action '+cls,label);button.type='button';button.addEventListener('click',()=>taskAction(action,task,button));return button;
}
function renderList(data){
tasks=data.tasks||[];const holder=$('task-list');holder.replaceChildren();
$('task-count').textContent=`共 ${tasks.length} 个任务 · ${tasks.filter(x=>x.enabled).length} 个已启用 · ${tasks.filter(x=>x.running).length} 个执行中`;
$('task-server-time').textContent='服务器时间 '+when(data.server_time);
if(!tasks.length){const empty=el('div','task-empty');empty.append(icon('clock'),el('h3','','还没有计划任务'),el('p','','点击“新增任务”,选择模板或填写自己的命令。任务默认停用,可先测试再启用。'));holder.append(empty);return;}
tasks.forEach(task=>{
const row=el('article','task-row');row.dataset.taskId=task.id;
const name=el('div','task-name-cell');name.append(el('strong','',task.name));const meta=el('div','task-row-meta');meta.append(el('span','',({command:'命令',script:'脚本内容',file:'服务器脚本'})[task.mode]||task.mode),el('span','','root'));if(task.dangerous)meta.append(el('span','task-danger-label','重启/关机类'));name.append(meta);if(task.notes)name.append(el('p','',task.notes));
const schedule=el('div','task-schedule-cell');schedule.append(el('strong','',task.schedule_label),el('span','',task.enabled?(task.next_run?'下次:'+when(task.next_run):'没有下次执行时间/单次任务已到期'):'计划已停用,不会自动执行'));
const status=el('div','task-status-cell');status.append(el('span','task-enabled '+(task.enabled?'enabled':''),task.enabled?'计划已启用':'计划已停用'),stateBadge(task.running?'running':task.last_run?.state));if(task.last_run)status.append(el('small','','最近:'+when(task.last_run.started)));
const actions=el('div','task-row-actions');
if(task.running)actions.append(actionButton('停止本次','stop',task,'danger'));
else actions.append(actionButton('执行一次','run',task));
actions.append(actionButton('日志','logs',task),actionButton('编辑','edit',task),actionButton(task.enabled?'停用':'启用','enable',task));
if(!task.running)actions.append(actionButton('删除','delete',task,'danger'));
row.append(name,schedule,status,actions);holder.append(row);
});queueNavigation();
}
async function refreshList(showMessage=false){if(!auth.logged_in)return;if(listBusy){listRefreshPending=true;return;}listBusy=true;try{renderList(await request(''));if(showMessage)toast('任务状态已更新。');}catch(error){if(error.status!==401)toast(error.message);}finally{listBusy=false;if(listRefreshPending){listRefreshPending=false;refreshList();}}}
$('task-refresh').addEventListener('click',()=>refreshList(true));
async function taskAction(action,task,button){
button.disabled=true;
try{
if(action==='edit'){await openEditor(task.id);return;}
if(action==='logs'){await openRuns(task);return;}
if(action==='run'){
const detail=await request('/item?id='+encodeURIComponent(task.id));
const code=detail.definition.mode==='file'?detail.definition.path+' '+detail.definition.arguments:detail.definition.body;
if(!await confirmAction('立即执行“'+task.name+'”?',task.dangerous?'这可能会立刻重启或关闭服务器,中断计算任务和网页连接。只有确认现在可以中断业务时才继续。':'会立刻以 root 身份执行以下内容。手动执行不会自动启用这个任务的计划。',code.slice(0,1200)))return;
const result=await request('/run',{id:task.id});toast(result.message);await refreshList();await openRuns(task);return;
}
if(action==='enable'){
if(!await confirmAction(task.enabled?'停用这项计划?':'启用这项计划?',task.enabled?'只停止以后的定时触发,已经开始的执行不会被强制中断。':`执行周期:${task.schedule_label}。${task.dangerous?'此任务可能重启/关闭服务器,请确认执行时段允许中断业务。':'到计划时间会以 root 身份执行。'}`))return;
toast((await request('/enable',{id:task.id,enabled:!task.enabled})).message);
}
if(action==='delete'){
if(!await confirmAction('删除“'+task.name+'”?','将停止后续调度并删除任务定义,已有执行记录仍保留。'))return;
toast((await request('/delete',{id:task.id})).message);
}
if(action==='stop'){
if(!await confirmAction('停止本次执行?','将终止本任务的执行进程。脚本可能尚未完成修改,请先确认可以中断。'))return;
toast((await request('/stop',{id:task.id})).message);
}
await refreshList();
}catch(error){toast(error.message);}finally{button.disabled=false;}
}
function localDateValue(timestamp){const parts=new Intl.DateTimeFormat('sv-SE',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).format(new Date(timestamp*1000));return parts.replace(' ','T');}
function updateEditorFields(){
const mode=$('task-mode').value,cycle=$('task-cycle').value;
$('task-body-field').hidden=mode==='file';$('task-body').required=mode!=='file';$('task-body').disabled=mode==='file';
$('task-path-field').hidden=mode!=='file';$('task-path').required=mode==='file';$('task-path').disabled=mode!=='file';$('task-arguments-field').hidden=mode!=='file';
$('task-language-field').hidden=mode==='command';$('task-body-label').textContent=mode==='command'?'执行命令':'脚本内容';$('task-body').rows=mode==='command'?5:10;
for(const option of $('task-interpreter').options)option.disabled=option.value==='auto'&&mode!=='file';
if(mode==='script'&&$('task-interpreter').value==='auto')$('task-interpreter').value='bash';
document.querySelectorAll('[data-task-cycle]').forEach(wrapper=>{const shown=wrapper.dataset.taskCycle.split(' ').includes(cycle);wrapper.hidden=!shown;wrapper.querySelectorAll('input,select').forEach(input=>input.disabled=!shown);});
$('task-every').max=cycle==='hours'?'168':'1440';
$('task-save').textContent=$('task-enabled').checked?'保存并启用':'保存任务';
$('task-preview-text').textContent='修改后可检查执行周期;错过时间不补跑。';
}
function formDefinition(){
const kind=$('task-cycle').value;const schedule={kind};
if(['daily','weekly','monthly'].includes(kind))schedule.time=$('task-time').value;
if(kind==='weekly')schedule.weekdays=[...$('task-weekdays').querySelectorAll('input:checked')].map(x=>Number(x.value));
if(kind==='monthly')schedule.day=Number($('task-day').value);
if(kind==='minutes'||kind==='hours')schedule.every=Number($('task-every').value);
if(kind==='once')schedule.at=$('task-once').value;
if(kind==='cron')schedule.expression=$('task-cron').value;
return {id:$('task-id').value||undefined,name:$('task-name').value.trim(),template:$('task-template').value,
mode:$('task-mode').value,interpreter:$('task-interpreter').value,body:$('task-body').value,
path:$('task-path').value,arguments:$('task-arguments').value,cwd:$('task-cwd').value,
timeout:Number($('task-timeout').value),notes:$('task-notes').value,enabled:$('task-enabled').checked,schedule};
}
async function openEditor(id=null){
$('task-editor-form').reset();$('task-id').value='';$('task-template-hint').hidden=true;taskError('task-editor-error','');
$('task-once').value=localDateValue(Date.now()/1000+3600);$('task-enabled').checked=false;
$('task-editor-heading').textContent=id?'编辑定时任务':'新增定时任务';
if(id){const task=await request('/item?id='+encodeURIComponent(id)),d=task.definition,s=d.schedule;
$('task-id').value=id;$('task-name').value=d.name;$('task-template').value=d.template||'custom';$('task-mode').value=d.mode;
$('task-interpreter').value=d.interpreter;$('task-body').value=d.body;$('task-path').value=d.path;$('task-arguments').value=d.arguments;
$('task-cwd').value=d.cwd;$('task-timeout').value=d.timeout;$('task-notes').value=d.notes;$('task-enabled').checked=!!task.enabled;
$('task-cycle').value=s.kind;$('task-time').value=s.time||'03:00';$('task-day').value=s.day||1;$('task-every').value=s.every||60;$('task-cron').value=s.expression||'0 3 * * *';if(s.at)$('task-once').value=s.at.slice(0,16);
$('task-weekdays').querySelectorAll('input').forEach(input=>input.checked=(s.weekdays||[0]).includes(Number(input.value)));
}
updateEditorFields();$('task-editor-dialog').showModal();$('task-name').focus();
}
$('task-create').addEventListener('click',()=>openEditor().catch(error=>toast(error.message)));
['task-mode','task-cycle','task-enabled'].forEach(id=>$(id).addEventListener('change',updateEditorFields));
$('task-preview').addEventListener('click',async()=>{if(!$('task-editor-form').reportValidity())return;const button=$('task-preview');button.disabled=true;try{const result=await request('/preview',formDefinition());$('task-preview-text').textContent=result.schedule_label+';预计下次:'+(result.next_run?when(result.next_run):'没有可计算的将来时间');}catch(error){$('task-preview-text').textContent=error.message;}finally{button.disabled=false;}});
$('task-editor-form').addEventListener('submit',async event=>{
event.preventDefault();taskError('task-editor-error','');const definition=formDefinition();
const dangerous=/\b(reboot|shutdown|poweroff|halt)\b|\binit\s+[06]\b/i.test(definition.body+' '+definition.path);
if(definition.enabled&&!await confirmAction(dangerous?'保存并启用重启/关机类任务?':'保存并启用任务?',dangerous?'此任务将在计划时间中断服务器业务。请确认脚本和执行时段已核对。':'保存后会按设置的周期执行,不会立即运行。',definition.mode==='file'?definition.path:definition.body.slice(0,800)))return;
$('task-save').disabled=true;
try{const result=await request('/save',definition);$('task-editor-dialog').close();toast(result.message);await refreshList();}
catch(error){taskError('task-editor-error',error.message);}finally{$('task-save').disabled=false;}
});
$('task-template').addEventListener('change',()=>{
const type=$('task-template').value;$('task-enabled').checked=false;$('task-template-hint').hidden=type==='custom';
if(type==='hardware'){$('task-name').value='检查显卡状态';$('task-mode').value='command';$('task-body').value='/usr/bin/nvidia-smi';$('task-cycle').value='daily';$('task-time').value='08:00';$('task-template-hint').textContent='只读取显卡状态,结果会出现在执行日志。可先保存为停用,再执行一次测试。';}
if(type==='reboot'){$('task-name').value='服务器定时重启';$('task-mode').value='command';$('task-body').value='/usr/bin/systemctl reboot';$('task-cycle').value='weekly';$('task-time').value='03:00';$('task-weekdays').querySelectorAll('input').forEach(input=>input.checked=input.value==='6');$('task-template-hint').textContent='这条命令会重启整台服务器,正在运行的 GPU 任务会中断。模板默认停用,请先确认允许重启的时间。';}
if(type==='certificate'){$('task-name').value='SSL 证书续签';$('task-mode').value='script';$('task-interpreter').value='bash';$('task-cycle').value='daily';$('task-time').value='03:10';$('task-body').value='#!/usr/bin/env bash\nset -euo pipefail\n# 先确认已有域名和续签配置,且没有其他重复的自动续签计划。\nif command -v certbot >/dev/null 2>&1; then\n certbot renew --quiet\nelif [ -x /root/.acme.sh/acme.sh ]; then\n /root/.acme.sh/acme.sh --cron --home /root/.acme.sh\nelse\n echo "未检测到 Certbot 或 acme.sh,请先配置证书续签工具。" >&2\n exit 1\nfi\n# 如需重新加载 Web 服务,请按实际服务另行配置 deploy-hook。';$('task-template-hint').textContent='这是续签示例,不会安装或配置域名。请先确认 Certbot/acme.sh 的现有配置,并检查是否已有续签任务。';}
updateEditorFields();
});
$('task-import-file').addEventListener('click',()=>$('task-upload').click());
$('task-upload').addEventListener('change',async()=>{const file=$('task-upload').files[0];if(!file)return;if(file.size>65536){toast('脚本文件不能超过 64 KiB。');return;}$('task-mode').value='script';$('task-template').value='custom';$('task-interpreter').value=file.name.endsWith('.py')?'python3':'bash';$('task-body').value=await file.text();if(!$('task-name').value)$('task-name').value=file.name;updateEditorFields();$('task-upload').value='';});
async function openRuns(task=null){runsTask=task;selectedRun=null;$('task-runs-heading').textContent=task?'执行记录 · '+task.name:'全部任务执行记录';$('task-run-meta').textContent='选择一次执行,查看输出和错误。';$('task-run-output').textContent='尚未选择执行记录。';$('task-runs-dialog').showModal();await refreshRuns();}
async function refreshRuns(){
if(!auth.logged_in||!$('task-runs-dialog').open)return;
try{const data=await request(runsTask?'/runs?id='+encodeURIComponent(runsTask.id):'/history');const holder=$('task-runs-list');holder.replaceChildren();
if(!data.runs.length){holder.append(el('p','task-no-runs','还没有执行记录。可以等待计划时间,或手动执行一次。'));return;}
data.runs.forEach(run=>{const button=el('button','run-item'+(selectedRun===run.id?' selected':''));button.type='button';button.dataset.runId=run.id;button.append(stateBadge(run.state),el('strong','',when(run.started)),el('span','',run.task_name),el('small','',(run.trigger==='manual'?'手动执行':'定时执行')+' · '+(run.duration!=null?num(run.duration,1)+' 秒':run.state==='running'?'执行中':'耗时未记录')));button.addEventListener('click',()=>loadRun(run.id));holder.append(button);});
if(!selectedRun)selectedRun=data.runs[0].id;await loadRun(selectedRun);
}catch(error){if(error.status!==401)toast(error.message);}
}
async function loadRun(id){
selectedRun=id;const sequence=++runReadSequence;
try{const data=await request('/runlog?run_id='+encodeURIComponent(id));if(sequence!==runReadSequence)return;
$('task-run-meta').replaceChildren(stateBadge(data.run.state),el('span','',`开始 ${when(data.run.started)} · 退出码 ${data.run.exit_code??'尚未取得'} · 修订 ${data.run.revision}`));
const output=$('task-run-output'),stick=output.scrollHeight-output.scrollTop-output.clientHeight<50;output.textContent=(data.tail_only?'[仅显示最后 128 KiB]\n':'')+data.output;if(stick)output.scrollTop=output.scrollHeight;
$('task-runs-list').querySelectorAll('.run-item').forEach(button=>button.classList.toggle('selected',button.dataset.runId===id));
}catch(error){if(error.status!==401)$('task-run-output').textContent=error.message;}
}
$('task-all-runs').addEventListener('click',()=>openRuns());
$('task-change-password').addEventListener('click',()=>{$('task-password-form').reset();taskError('task-password-error','');$('task-password-dialog').showModal();});
$('task-password-form').addEventListener('submit',async event=>{event.preventDefault();taskError('task-password-error','');if($('task-new-password').value!==$('task-repeat-password').value){taskError('task-password-error','两次新密码输入不一致。');return;}const button=event.submitter;button.disabled=true;try{const result=await request('/password',{old_password:$('task-old-password').value,new_password:$('task-new-password').value});$('task-password-dialog').close();setAuth({logged_in:false});toast(result.message);}catch(error){taskError('task-password-error',error.message);}finally{button.disabled=false;}});
refreshAuth();setInterval(()=>{if(!document.hidden&&auth.logged_in)refreshList();},15000);setInterval(()=>{if(!document.hidden&&$('task-runs-dialog').open)refreshRuns();},3000);
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refreshAuth();});
})();

386
task_core.py Normal file
View File

@ -0,0 +1,386 @@
"""Root-owned task store and native systemd schedules; no third-party modules."""
from contextlib import contextmanager
import datetime as dt
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shlex
import sqlite3
import subprocess
import time
import uuid
BASE=Path(os.environ.get('HARDWARE_TASK_DATA','/var/lib/hardware-taskd'))
LOGS=Path(os.environ.get('HARDWARE_TASK_LOGS','/var/log/hardware-taskd'))
UNITS=Path(os.environ.get('HARDWARE_TASK_UNITS','/etc/systemd/system'))
APP=Path(__file__).resolve().parent
TZ=dt.timezone(dt.timedelta(hours=8))
WEEKDAYS=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
class TaskError(Exception):
def __init__(self,message,status=400):
self.status=status
super().__init__(message)
def iso(value=None):
return dt.datetime.fromtimestamp(time.time() if value is None else value,TZ).isoformat(timespec='seconds')
@contextmanager
def db():
con=sqlite3.connect(BASE/'tasks.sqlite3',timeout=10)
con.row_factory=sqlite3.Row
try:
yield con;con.commit()
except Exception:
con.rollback();raise
finally:
con.close()
def init_store():
BASE.mkdir(parents=True,exist_ok=True,mode=0o700)
LOGS.mkdir(parents=True,exist_ok=True,mode=0o700)
(BASE/'scripts').mkdir(exist_ok=True,mode=0o700)
with db() as con:
con.execute('PRAGMA journal_mode=WAL')
con.executescript('''
CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY,name TEXT,definition TEXT,
enabled INTEGER,deleted INTEGER DEFAULT 0,created REAL,updated REAL,revision INTEGER);
CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,task_id TEXT,task_name TEXT,
started REAL,finished REAL,state TEXT,exit_code INTEGER,trigger TEXT,boot TEXT,
script_hash TEXT,log_bytes INTEGER DEFAULT 0,truncated INTEGER DEFAULT 0,revision INTEGER,duration REAL,
log_purged INTEGER DEFAULT 0);
CREATE INDEX IF NOT EXISTS run_task ON runs(task_id,started DESC);
CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY AUTOINCREMENT,at REAL,
action TEXT,task_id TEXT,actor TEXT,client TEXT,detail TEXT);
CREATE TABLE IF NOT EXISTS requests(task_id TEXT PRIMARY KEY,trigger TEXT,expires REAL);
''')
def atom_write(path,text,mode=0o600):
temporary=Path(str(path)+'.tmp')
temporary.write_text(text,encoding='utf-8');os.chmod(temporary,mode);os.replace(temporary,path)
def validate_id(value):
if not isinstance(value,str) or not re.fullmatch('[0-9a-f]{32}',value):
raise TaskError('任务编号不正确。')
return value
def execute(args,timeout=15,check=True):
try:
p=subprocess.run(args,capture_output=True,text=True,timeout=timeout,env={**os.environ,'LC_ALL':'C','TZ':'Asia/Shanghai'})
except (OSError,subprocess.TimeoutExpired) as exc:
raise TaskError('系统调度操作未完成:'+str(exc),503)
if check and p.returncode:
raise TaskError('系统调度操作失败:'+(p.stderr or p.stdout)[-1200:],503)
return p
def number(value,minimum,maximum,label):
try:
if isinstance(value,bool):raise ValueError()
n=int(str(value))
except (ValueError,TypeError):
raise TaskError(label+'必须是整数。')
if not minimum<=n<=maximum:
raise TaskError(f'{label}必须在 {minimum} 至 {maximum} 之间。')
return n
def clock(value):
if not isinstance(value,str) or not re.fullmatch(r'\d{2}:\d{2}',value):
raise TaskError('执行时间格式应为 03:00。')
h,m=map(int,value.split(':'))
if h>23 or m>59:raise TaskError('执行时间不正确。')
return value+':00'
def cron_field(value,low,high,names=None):
value=value.upper()
if names:
value=re.sub('[A-Z]{3}',lambda m:str(names[m[0]]) if m[0] in names else m[0],value)
result=set()
for part in value.split(','):
pieces=part.split('/')
if len(pieces)>2:raise TaskError('Cron 的步长格式不正确。')
step=number(pieces[1],1,high-low+1,'Cron 步长') if len(pieces)==2 else 1
base=pieces[0]
if base=='*':start,end=low,high
elif re.fullmatch(r'\d+-\d+',base):start,end=map(int,base.split('-'))
elif base.isdigit():start=int(base);end=high if len(pieces)==2 else start
else:raise TaskError('Cron 仅支持数字、*、范围、逗号和步长;不支持 L、W、# 或 ?。')
if not low<=start<=end<=high:raise TaskError('Cron 字段数值超出允许范围。')
result.update(range(start,end+1,step))
return sorted(result)
def cron_calendars(expression):
parts=expression.split()
if len(parts)!=5:raise TaskError('Cron 需要五段:分 时 日 月 周,例如 0 3 * * *。')
minute=cron_field(parts[0],0,59);hour=cron_field(parts[1],0,23)
day=cron_field(parts[2],1,31)
month=cron_field(parts[3],1,12,{x:i+1 for i,x in enumerate(['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'])})
dow=cron_field(parts[4],0,7,{'SUN':0,'MON':1,'TUE':2,'WED':3,'THU':4,'FRI':5,'SAT':6})
join=lambda values:','.join(map(str,values))
months='*' if parts[3]=='*' else join(month)
hours='*' if parts[1]=='*' else join(hour)
minutes='*' if parts[0]=='*' else join(minute)
dates='*' if parts[2]=='*' else join(day)
days=','.join(['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][v] for v in sorted({v%7 for v in dow}))
timepart=f'{hours}:{minutes}:00 Asia/Shanghai'
if parts[4]=='*':return [f'*-{months}-{dates} {timepart}']
if parts[2]=='*':return [f'{days} *-{months}-* {timepart}']
# Traditional cron: restricted day-of-month and weekday are joined with OR.
return [f'*-{months}-{dates} {timepart}',f'{days} *-{months}-* {timepart}']
def schedule_definition(schedule):
if not isinstance(schedule,dict):raise TaskError('请设置执行周期。')
kind=schedule.get('kind','daily')
if kind=='daily':
value=clock(schedule.get('time','03:00'))
return [f'OnCalendar=*-*-* {value} Asia/Shanghai'],f'每天 {value[:5]}'
if kind=='weekly':
chosen=schedule.get('weekdays',[0])
if not isinstance(chosen,list) or not chosen:raise TaskError('至少选择一个星期。')
selected=sorted({number(v,0,6,'星期') for v in chosen})
value=clock(schedule.get('time','03:00'))
days=','.join(WEEKDAYS[v] for v in selected)
label='、'.join('周'+['一','二','三','四','五','六','日'][v] for v in selected)
return [f'OnCalendar={days} *-*-* {value} Asia/Shanghai'],f'每{label} {value[:5]}'
if kind=='monthly':
day=number(schedule.get('day',1),1,31,'每月日期');value=clock(schedule.get('time','03:00'))
return [f'OnCalendar=*-*-{day} {value} Asia/Shanghai'],f'每月 {day} 日 {value[:5]}'+('(当月没有该日则跳过)' if day>28 else '')
if kind in ('minutes','hours'):
every=number(schedule.get('every',1),1,1440 if kind=='minutes' else 168,'间隔')
seconds=every*(60 if kind=='minutes' else 3600)
return [f'OnActiveSec={seconds}s',f'OnUnitInactiveSec={seconds}s'],f'每 {every} '+('分钟' if kind=='minutes' else '小时')+'(上次执行结束后重新计时)'
if kind=='once':
try:
at=dt.datetime.fromisoformat(schedule.get('at',''))
if at.tzinfo is None:at=at.replace(tzinfo=TZ)
at=at.replace(second=0,microsecond=0)
except (ValueError,TypeError):raise TaskError('请填写正确的指定日期与时间。')
if at.timestamp()<time.time()+30:raise TaskError('指定执行时间必须至少晚于现在 30 秒。')
return [f'OnCalendar={at.astimezone(TZ).strftime("%Y-%m-%d %H:%M:00")} Asia/Shanghai'],f'仅执行一次:{at.astimezone(TZ).strftime("%Y-%m-%d %H:%M")}'
if kind=='cron':
expression=str(schedule.get('expression','')).strip()
if len(expression)>150:raise TaskError('Cron 表达式过长。')
return ['OnCalendar='+x for x in cron_calendars(expression)],'Cron:'+expression
raise TaskError('不支持的执行周期。')
def validate_definition(payload,check_paths=True):
name=str(payload.get('name','')).strip()
if not 1<=len(name)<=80 or any(ord(c)<32 for c in name):raise TaskError('任务名称需要 1 至 80 个字符,不能包含换行。')
mode=payload.get('mode','command');interpreter=payload.get('interpreter','bash')
if mode not in ('command','script','file'):raise TaskError('请选择命令、脚本内容或服务器脚本文件。')
if interpreter not in ('bash','python3','auto'):raise TaskError('脚本语言不正确。')
body=str(payload.get('body','')).replace('\r\n','\n').replace('\r','\n').lstrip('\ufeff').strip()
path=str(payload.get('path','')).strip()
arguments=str(payload.get('arguments','')).strip()
if mode in ('command','script') and not body:raise TaskError('请输入要执行的命令或脚本。')
if len(body.encode())>65536:raise TaskError('脚本内容不能超过 64 KiB。')
if mode=='script' and interpreter=='auto':raise TaskError('脚本内容请选择 Bash 或 Python 3。')
cwd=str(payload.get('cwd','/root')).strip() or '/root'
if not PurePosixPath(cwd).is_absolute() or '\x00' in cwd:raise TaskError('工作目录必须是绝对路径。')
if check_paths and not Path(cwd).is_dir():raise TaskError('工作目录不存在。')
if mode=='file':
if not PurePosixPath(path).is_absolute() or '\x00' in path:raise TaskError('脚本文件需要填写服务器上的绝对路径。')
if check_paths and not Path(path).is_file():raise TaskError('服务器上找不到这个脚本文件。')
if check_paths and interpreter=='auto' and not os.access(path,os.X_OK):raise TaskError('该文件没有执行权限,请选择 Bash/Python 3 解释器,或先设置文件执行权限。')
try:shlex.split(arguments)
except ValueError:raise TaskError('脚本参数的引号不匹配。')
lines,label=schedule_definition(payload.get('schedule',{}))
notes=str(payload.get('notes','')).strip()[:400]
timeout=number(payload.get('timeout',1800),10,86400,'最长执行秒数')
if not isinstance(payload.get('enabled',False),bool):raise TaskError('启用状态必须为是或否。')
dangerous=bool(re.search(r'\b(reboot|shutdown|poweroff|halt)\b|\binit\s+[06]\b',body+' '+path,re.I))
return {'name':name,'mode':mode,'interpreter':interpreter,'body':body,'path':path,'arguments':arguments,
'cwd':cwd,'schedule':payload.get('schedule',{}),'schedule_lines':lines,'schedule_label':label,
'timeout':timeout,'notes':notes,'enabled':bool(payload.get('enabled',False)),
'dangerous':dangerous,'template':str(payload.get('template','custom'))[:40]}
def calendar_preview(definition):
result=[]
for line in definition['schedule_lines']:
if line.startswith('OnCalendar='):
p=execute(['systemd-analyze','calendar','--iterations=1',line.split('=',1)[1]],timeout=5,check=False)
if p.returncode:raise TaskError('执行周期无效:'+(p.stderr or p.stdout)[-600:])
result.append(p.stdout.strip())
if result and all(re.search(r'Next elapse:\s*n/a',output) for output in result):
raise TaskError('这个周期没有可以执行的将来时间,请检查日期或 Cron 表达式。')
return result
def get_task(task_id,include_deleted=False):
validate_id(task_id)
with db() as con:
row=con.execute('SELECT * FROM tasks WHERE id=?'+('' if include_deleted else ' AND deleted=0'),(task_id,)).fetchone()
if not row:raise TaskError('找不到这个任务。',404)
item=dict(row);item['definition']=json.loads(item['definition']);return item
def unit_name(task_id):return 'hwjob-'+validate_id(task_id)
def running(task_id):
p=execute(['systemctl','is-active',unit_name(task_id)+'.service'],check=False,timeout=5)
return p.stdout.strip() in ('active','activating','deactivating')
def render_units(task_id,definition):
name=unit_name(task_id)
service=f'''[Unit]
Description=Dashboard scheduled task {task_id}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
ExecStart=/usr/bin/python3 {APP}/task_exec.py {task_id}
RuntimeMaxSec={definition['timeout']+10}
TimeoutStopSec=15
KillMode=mixed
Restart=no
UMask=0077
Environment=TZ=Asia/Shanghai
'''
timer=f'''[Unit]
Description=Dashboard task schedule {task_id}
[Timer]
{chr(10).join(definition['schedule_lines'])}
AccuracySec=1s
Persistent=false
Unit={name}.service
[Install]
WantedBy=timers.target
'''
return service,timer
def install_units(task_id,definition):
service,timer=render_units(task_id,definition)
name=unit_name(task_id)
atom_write(UNITS/(name+'.service'),service,0o644)
atom_write(UNITS/(name+'.timer'),timer,0o644)
execute(['systemctl','daemon-reload'])
def audit(action,task_id='',actor='admin',client='',detail=''):
with db() as con:
con.execute('INSERT INTO audit(at,action,task_id,actor,client,detail) VALUES(?,?,?,?,?,?)',
(time.time(),action,task_id,actor,client,detail[:2000]))
def save_task(payload,client=''):
task_id=payload.get('id')
old=get_task(task_id) if task_id else None
if old and running(task_id):raise TaskError('任务正在执行,请等待结束后再修改。',409)
definition=validate_definition(payload);calendar_preview(definition)
if not old:
with db() as con:
if con.execute('SELECT COUNT(*) FROM tasks WHERE deleted=0').fetchone()[0]>=100:raise TaskError('最多管理 100 个任务。')
task_id=uuid.uuid4().hex
if old:
execute(['systemctl','disable','--now',unit_name(task_id)+'.timer'],check=False)
if running(task_id):
if old['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer'],check=False)
raise TaskError('任务刚刚开始执行,请等待完成后再修改。',409)
now=time.time();revision=old['revision']+1 if old else 1
try:
install_units(task_id,definition)
with db() as con:
con.execute('''INSERT INTO tasks VALUES(?,?,?,?,0,?,?,?) ON CONFLICT(id) DO UPDATE SET
name=excluded.name,definition=excluded.definition,enabled=excluded.enabled,
updated=excluded.updated,revision=excluded.revision''',
(task_id,definition['name'],json.dumps(definition,ensure_ascii=False),int(definition['enabled']),
old['created'] if old else now,now,revision))
if definition['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer'])
except Exception:
if old:
install_units(task_id,old['definition'])
with db() as con:
con.execute('UPDATE tasks SET name=?,definition=?,enabled=?,revision=? WHERE id=?',
(old['name'],json.dumps(old['definition'],ensure_ascii=False),old['enabled'],old['revision'],task_id))
if old['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer'],check=False)
else:
execute(['systemctl','disable','--now',unit_name(task_id)+'.timer'],check=False)
with db() as con:con.execute('DELETE FROM tasks WHERE id=?',(task_id,))
for suffix in ('.service','.timer'):(UNITS/(unit_name(task_id)+suffix)).unlink(missing_ok=True)
execute(['systemctl','daemon-reload'],check=False)
raise
audit('update' if old else 'create',task_id,client=client,detail='revision '+str(revision))
return {'id':task_id,'message':'任务已保存。'+('已启用,将按计划执行。' if definition['enabled'] else '当前停用,不会自动执行。')}
def set_enabled(task_id,enabled,client=''):
task=get_task(task_id)
if enabled:
schedule_definition(task['definition']['schedule'])
calendar_preview(task['definition'])
execute(['systemctl','enable' if enabled else 'disable','--now',unit_name(task_id)+'.timer'])
with db() as con:
definition=task['definition'];definition['enabled']=bool(enabled)
con.execute('UPDATE tasks SET enabled=?,definition=?,updated=? WHERE id=?',
(int(bool(enabled)),json.dumps(definition,ensure_ascii=False),time.time(),task_id))
audit('enable' if enabled else 'disable',task_id,client=client)
return {'message':'已启用。' if enabled else '已停用后续调度;已经开始的执行不会被强制中断。'}
def delete_task(task_id,client=''):
get_task(task_id)
if running(task_id):raise TaskError('任务正在执行,请先等待完成或停止本次执行。',409)
execute(['systemctl','disable','--now',unit_name(task_id)+'.timer'])
for suffix in ('.service','.timer'):
(UNITS/(unit_name(task_id)+suffix)).unlink(missing_ok=True)
execute(['systemctl','daemon-reload'])
with db() as con:con.execute('UPDATE tasks SET deleted=1,enabled=0,updated=? WHERE id=?',(time.time(),task_id))
audit('delete',task_id,client=client)
return {'message':'任务已删除,已有执行记录仍保留在服务器。'}
def run_now(task_id,client=''):
task=get_task(task_id)
if running(task_id):raise TaskError('这个任务正在执行,不会重复启动。',409)
with db() as con:
con.execute('INSERT OR REPLACE INTO requests VALUES(?,?,?)',(task_id,'manual',time.time()+30))
execute(['systemctl','reset-failed',unit_name(task_id)+'.service'],check=False)
execute(['systemctl','start','--no-block',unit_name(task_id)+'.service'])
audit('run_now',task_id,client=client,detail='revision '+str(task['revision']))
return {'message':'已提交执行,请在执行日志中查看结果。'}
def stop_task(task_id,client=''):
get_task(task_id)
execute(['systemctl','stop',unit_name(task_id)+'.service'])
audit('stop_run',task_id,client=client)
return {'message':'已请求停止本次执行。'}
def list_tasks():
p=execute(['systemctl','list-timers','--all','--output=json','--no-pager','hwjob-*.timer'],check=False)
try:timers={x['unit']:x for x in json.loads(p.stdout or '[]')}
except (ValueError,TypeError):timers={}
p=execute(['systemctl','list-units','--all','--type=service','--output=json','--no-pager','hwjob-*.service'],check=False)
try:active={x['unit']:x for x in json.loads(p.stdout or '[]')}
except (ValueError,TypeError):active={}
with db() as con:
tasks=con.execute('SELECT * FROM tasks WHERE deleted=0 ORDER BY created DESC').fetchall()
result=[]
for row in tasks:
item=dict(row);definition=json.loads(item.pop('definition'))
timer=timers.get(unit_name(item['id'])+'.timer',{})
next_us=timer.get('next')
item['next_run']=next_us/1e6 if isinstance(next_us,(int,float)) and 0<next_us<2**63 else None
item['running']=active.get(unit_name(item['id'])+'.service',{}).get('active') in ('active','activating','deactivating')
item['schedule_label']=definition['schedule_label'];item['mode']=definition['mode']
item['dangerous']=definition.get('dangerous',False);item['notes']=definition.get('notes','')
last=con.execute('SELECT * FROM runs WHERE task_id=? ORDER BY started DESC LIMIT 1',(item['id'],)).fetchone()
item['last_run']=dict(last) if last else None
result.append(item)
return {'tasks':result,'server_time':iso(),'timezone':'Asia/Shanghai'}
def runs_for(task_id):
get_task(task_id,True)
with db() as con:
rows=con.execute('SELECT * FROM runs WHERE task_id=? ORDER BY started DESC LIMIT 40',(task_id,)).fetchall()
return {'runs':[dict(row) for row in rows]}
def read_run(run_id):
validate_id(run_id)
with db() as con:row=con.execute('SELECT * FROM runs WHERE id=?',(run_id,)).fetchone()
if not row:raise TaskError('找不到这次执行记录。',404)
path=LOGS/(run_id+'.log')
try:
with path.open('rb') as f:
f.seek(0,2);size=f.tell();f.seek(max(0,size-131072));content=f.read().decode('utf-8',errors='replace')
except OSError:
content='本次输出已按保留规则清理,执行结果仍保留。' if row['state']!='running' else '日志尚未写入。'
size=0
return {'run':dict(row),'output':content,'tail_only':size>131072}

104
task_exec.py Normal file
View File

@ -0,0 +1,104 @@
#!/usr/bin/python3
"""Run one approved, root-owned task definition; record bounded output and result."""
import fcntl
import hashlib
import json
import os
import selectors
import shlex
import signal
import subprocess
import sys
import time
import uuid
import task_core as core
LIMIT=512*1024
def main(task_id):
task=core.get_task(task_id)
definition=task['definition']
lock=(core.BASE/(task_id+'.lock')).open('a')
try:fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
except BlockingIOError:return 0
run_id=uuid.uuid4().hex;started=time.time();start_mono=time.monotonic()
boot=core.read_boot() if hasattr(core,'read_boot') else open('/proc/sys/kernel/random/boot_id').read().strip()
with core.db() as con:
requested=con.execute('SELECT * FROM requests WHERE task_id=?',(task_id,)).fetchone()
trigger='manual' if requested and requested['expires']>=started else 'scheduled'
con.execute('DELETE FROM requests WHERE task_id=?',(task_id,))
body=definition['body']
fingerprint=hashlib.sha256((body if definition['mode']!='file' else definition['path']+' '+definition['arguments']).encode()).hexdigest()
con.execute('INSERT INTO runs(id,task_id,task_name,started,state,trigger,boot,script_hash,revision) VALUES(?,?,?,?,?,?,?,?,?)',
(run_id,task_id,task['name'],started,'running',trigger,boot,fingerprint,task['revision']))
cancelled=[False]
def stop(*_):cancelled[0]=True
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
state='failed';exit_code=None;proc=None;written=0;truncated=False
log_path=core.LOGS/(run_id+'.log')
with log_path.open('wb',buffering=0) as output:
os.chmod(log_path,0o600)
def write(data):
nonlocal written,truncated
if isinstance(data,str):data=data.encode('utf-8')
available=max(0,LIMIT-written)
if available:
piece=data[:available];output.write(piece);written+=len(piece)
if len(data)>available:truncated=True
write(f'开始:{core.iso(started)}\n任务:{task["name"]}\n触发:{"手动" if trigger=="manual" else "定时"}\n执行身份:root\n修订:{task["revision"]}\n'+'─'*32+'\n')
try:
if definition['mode']=='command':
argv=['/bin/bash','-e','-o','pipefail','-c',body]
elif definition['mode']=='script':
script=core.BASE/'scripts'/(run_id+('.py' if definition['interpreter']=='python3' else '.sh'))
core.atom_write(script,body+'\n')
argv=['/usr/bin/python3',str(script)] if definition['interpreter']=='python3' else ['/bin/bash','-e','-o','pipefail',str(script)]
else:
argv=[definition['path'],*shlex.split(definition['arguments'])]
if definition['interpreter']=='bash':argv=['/bin/bash','-e','-o','pipefail',*argv]
elif definition['interpreter']=='python3':argv=['/usr/bin/python3',*argv]
env={'HOME':'/root','USER':'root','LOGNAME':'root','LANG':'C.UTF-8','TZ':'Asia/Shanghai',
'PATH':'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin',
'HARDWARE_TASK_ID':task_id,'HARDWARE_TASK_RUN_ID':run_id}
proc=subprocess.Popen(argv,cwd=definition['cwd'],env=env,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,start_new_session=True)
selector=selectors.DefaultSelector();selector.register(proc.stdout,selectors.EVENT_READ)
deadline=start_mono+definition['timeout'];exited_at=None
while selector.get_map() or proc.poll() is None:
if cancelled[0] or time.monotonic()>=deadline:
state='cancelled' if cancelled[0] else 'timeout'
try:os.killpg(proc.pid,signal.SIGTERM)
except ProcessLookupError:pass
try:proc.wait(timeout=3)
except subprocess.TimeoutExpired:
try:os.killpg(proc.pid,signal.SIGKILL)
except ProcessLookupError:pass
proc.wait(timeout=3)
break
for key,_ in selector.select(.25):
chunk=os.read(key.fileobj.fileno(),32768)
if chunk:write(chunk)
else:selector.unregister(key.fileobj)
if proc.poll() is not None:
exited_at=exited_at or time.monotonic()
if time.monotonic()-exited_at>1:break
selector.close();proc.stdout.close()
exit_code=proc.wait(timeout=3)
if state not in ('cancelled','timeout'):state='success' if exit_code==0 else 'failed'
except Exception as exc:
write('\n执行错误:'+str(exc)+'\n');state='failed'
if proc and proc.poll() is None:
try:os.killpg(proc.pid,signal.SIGKILL);proc.wait(timeout=3)
except (ProcessLookupError,subprocess.TimeoutExpired):pass
elapsed=time.monotonic()-start_mono
tail=f'\n'+('─'*32)+f'\n结束:{core.iso()}\n结果:{state}\n退出码:{exit_code}\n耗时:{elapsed:.2f} 秒\n'
if truncated:output.write('\n[输出超过 512 KiB,后续内容已省略。]\n'.encode())
output.write(tail.encode())
os.fsync(output.fileno())
with core.db() as con:
con.execute('UPDATE runs SET finished=?,state=?,exit_code=?,log_bytes=?,truncated=?,duration=? WHERE id=?',
(time.time(),state,exit_code,log_path.stat().st_size,int(truncated),elapsed,run_id))
lock.close()
return 0 if state=='success' else 1
if __name__=='__main__':
raise SystemExit(main(core.validate_id(sys.argv[1])))

193
taskd.py Normal file
View File

@ -0,0 +1,193 @@
#!/usr/bin/python3
"""Authenticated local task broker. Only the web service UID may connect to its socket."""
import argparse
import getpass
import hashlib
import hmac
import json
import os
from pathlib import Path
try:import pwd
except ImportError:pwd=None
import secrets
import socket
import socketserver
import struct
import re
import datetime as dt
import threading
import time
import task_core as core
ADMIN=Path('/etc/hardware-task-admin.json')
SOCKET=Path('/run/hardware-taskd/control.sock')
SESSIONS={};ATTEMPTS={};GUARD=threading.RLock();MUTATE=threading.RLock()
ALLOWED_UID=None
def password_record(password):
salt=secrets.token_hex(16)
return {'username':'admin','salt':salt,'hash':hashlib.pbkdf2_hmac('sha256',password.encode(),bytes.fromhex(salt),240000).hex()}
def verify(password):
try:
record=json.loads(ADMIN.read_text())
hashed=hashlib.pbkdf2_hmac('sha256',password.encode(),bytes.fromhex(record['salt']),240000).hex()
return hmac.compare_digest(record['hash'],hashed)
except (OSError,ValueError,KeyError):return False
def create_admin():
if ADMIN.exists():return
password=secrets.token_urlsafe(18)
core.atom_write(ADMIN,json.dumps(password_record(password)))
core.atom_write(Path('/root/hardware-task-admin-login.txt'),
'定时任务管理员登录信息\n\n网页:http://192.168.1.37:8090/#tasks\n账号:admin\n初始密码:'+password+
'\n\n此账号可以管理以 root 身份执行的服务器任务,请妥善保存。登录后可修改管理密码。\nSSH root 密码未被修改,也没有作为网页管理密码保存。\n')
print('初始登录信息已保存到 /root/hardware-task-admin-login.txt(仅 root 可读)。')
def session(token,csrf=None,write=False):
with GUARD:
record=SESSIONS.get(token)
if not record or record['expires']<time.monotonic():
SESSIONS.pop(token,None);raise core.TaskError('请先登录定时任务管理。',401)
if write and (not isinstance(csrf,str) or not hmac.compare_digest(record['csrf'],csrf)):
raise core.TaskError('操作验证已失效,请重新登录后再试。',403)
return record
def login(payload,client):
now=time.monotonic()
with GUARD:
recent=[x for x in ATTEMPTS.get(client,[]) if now-x<300]
if len(recent)>=6:raise core.TaskError('登录失败次数较多,请五分钟后重试。',429)
if payload.get('username')!='admin' or not verify(str(payload.get('password',''))):
ATTEMPTS[client]=recent+[now]
core.audit('login_failed',client=client)
raise core.TaskError('账号或密码不正确。',401)
ATTEMPTS.pop(client,None)
if len(SESSIONS)>200:
for key in list(SESSIONS):
if SESSIONS[key]['expires']<now:SESSIONS.pop(key,None)
token=secrets.token_urlsafe(32);csrf=secrets.token_urlsafe(24)
SESSIONS[token]={'username':'admin','csrf':csrf,'expires':now+8*3600}
core.audit('login',client=client)
return {'token':token,'csrf':csrf,'logged_in':True,'username':'admin','max_age':8*3600}
def dispatch(request):
action=request.get('action','');payload=request.get('payload') or {};token=request.get('token','');client=str(request.get('client','local'))[:100]
if not isinstance(payload,dict):raise core.TaskError('请求格式不正确。')
if action=='auth.login':return login(payload,client)
if action=='auth.status':
try:record=session(token)
except core.TaskError:return {'logged_in':False,'configured':ADMIN.exists()}
return {'logged_in':True,'username':record['username'],'csrf':record['csrf']}
write=action not in ('tasks.list','tasks.get','tasks.runs','tasks.runlog','tasks.history')
session(token,request.get('csrf'),write)
if action=='auth.logout':
with GUARD:SESSIONS.pop(token,None)
return {'message':'已退出任务管理。'}
if action=='auth.password':
new=str(payload.get('new_password',''))
if not 12<=len(new)<=200:raise core.TaskError('新密码长度需要 12 至 200 个字符。')
if not verify(str(payload.get('old_password',''))):raise core.TaskError('原管理密码不正确。',400)
core.atom_write(ADMIN,json.dumps(password_record(new)))
with GUARD:SESSIONS.clear()
core.audit('password_changed',client=client)
return {'message':'管理密码已修改,请重新登录。'}
if action=='tasks.list':return core.list_tasks()
if action=='tasks.get':return core.get_task(payload.get('id'))
if action=='tasks.runs':return core.runs_for(payload.get('id'))
if action=='tasks.runlog':return core.read_run(payload.get('run_id'))
if action=='tasks.history':
with core.db() as con:rows=con.execute('SELECT * FROM runs ORDER BY started DESC LIMIT 80').fetchall()
return {'runs':[dict(row) for row in rows]}
if action=='tasks.preview':
definition=core.validate_definition(payload)
calendars=core.calendar_preview(definition)
next_times=[]
for output in calendars:
match=re.search(r'Next elapse:.*?(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})',output)
if match:next_times.append(dt.datetime.strptime(match[1],'%Y-%m-%d %H:%M:%S').replace(tzinfo=core.TZ).timestamp())
if not calendars:
next_times=[time.time()+int(definition['schedule_lines'][0].split('=')[1][:-1])]
return {'schedule_label':definition['schedule_label'],'next_run':min(next_times) if next_times else None,'dangerous':definition['dangerous']}
with MUTATE:
if action=='tasks.save':return core.save_task(payload,client)
if action=='tasks.enable':
if not isinstance(payload.get('enabled'),bool):raise core.TaskError('启用状态格式不正确。')
return core.set_enabled(payload.get('id'),payload['enabled'],client)
if action=='tasks.delete':return core.delete_task(payload.get('id'),client)
if action=='tasks.run':return core.run_now(payload.get('id'),client)
if action=='tasks.stop':return core.stop_task(payload.get('id'),client)
raise core.TaskError('不支持这个任务操作。',404)
class Handler(socketserver.StreamRequestHandler):
def handle(self):
self.request.settimeout(25)
try:
_,uid,_=struct.unpack('3i',self.request.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in (0,ALLOWED_UID):raise core.TaskError('不允许连接任务服务。',403)
raw=self.rfile.readline(131073)
if len(raw)>131072:raise core.TaskError('请求内容过大。',413)
request=json.loads(raw)
response={'ok':True,'data':dispatch(request)}
except core.TaskError as exc:response={'ok':False,'error':str(exc),'status':exc.status}
except Exception as exc:
print('task broker error:',type(exc).__name__,str(exc),flush=True)
response={'ok':False,'error':'任务操作未完成,请查看任务管理服务日志。','status':500}
try:self.wfile.write(json.dumps(response,ensure_ascii=False).encode()+b'\n')
except (BrokenPipeError,ConnectionResetError):pass
class Server(getattr(socketserver,'ThreadingUnixStreamServer',socketserver.ThreadingTCPServer)):
daemon_threads=True
def maintenance():
while True:
try:
with core.db() as con:
rows=con.execute("SELECT * FROM runs WHERE state='running'").fetchall()
boot=Path('/proc/sys/kernel/random/boot_id').read_text().strip()
for row in rows:
if row['boot']!=boot or (time.time()-row['started']>30 and not core.running(row['task_id'])):
with core.db() as con:
con.execute("UPDATE runs SET state='interrupted',finished=? WHERE id=? AND state='running'",(time.time(),row['id']))
with (core.LOGS/(row['id']+'.log')).open('ab') as f:
f.write(('\n[执行被系统重启或进程中断,没有取得完整退出结果。]\n').encode())
cutoff=time.time()-90*86400
with core.db() as con:
old=con.execute('SELECT id FROM runs WHERE started<? AND state!=?',(cutoff,'running')).fetchall()
for row in old:
(core.LOGS/(row['id']+'.log')).unlink(missing_ok=True)
for suffix in ('.sh','.py'):(core.BASE/'scripts'/(row['id']+suffix)).unlink(missing_ok=True)
con.execute("DELETE FROM runs WHERE started<? AND state!='running'",(cutoff,))
con.execute('DELETE FROM audit WHERE at<?',(cutoff,))
protected={x['id'] for x in con.execute("SELECT id FROM runs WHERE state='running'")}
files=[p for folder in (core.LOGS,core.BASE/'scripts') for p in folder.iterdir()
if p.is_file() and re.fullmatch(r'[0-9a-f]{32}\.(log|sh|py)',p.name)]
files.sort(key=lambda p:p.stat().st_mtime)
total=sum(p.stat().st_size for p in files)
for path in files:
if total<=256*1024*1024:break
if path.stem in protected:continue
total-=path.stat().st_size;path.unlink()
if path.suffix=='.log':con.execute('UPDATE runs SET log_purged=1 WHERE id=?',(path.stem,))
except Exception as exc:print('task maintenance:',type(exc).__name__,str(exc),flush=True)
time.sleep(60)
def main():
global ALLOWED_UID
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['serve','init-admin','password'],default='serve',nargs='?');args=parser.parse_args()
core.init_store()
if args.mode=='init-admin':create_admin();return
if args.mode=='password':
password=getpass.getpass('新管理密码(至少12字符):')
if len(password)<12 or password!=getpass.getpass('再次输入:'):raise SystemExit('密码太短或两次输入不一致。')
core.atom_write(ADMIN,json.dumps(password_record(password)));print('密码已重置;请重启 hardware-taskd 服务使旧登录失效。');return
if not ADMIN.exists():raise SystemExit('请先运行 taskd.py init-admin。')
ALLOWED_UID=pwd.getpwnam('hardware-dashboard').pw_uid
SOCKET.parent.mkdir(parents=True,exist_ok=True)
SOCKET.unlink(missing_ok=True)
server=Server(str(SOCKET),Handler);os.chmod(SOCKET,0o660)
threading.Thread(target=maintenance,daemon=True).start()
print('Task broker ready on local Unix socket.',flush=True)
server.serve_forever()
if __name__=='__main__':main()

147
tests/test_dashboard.py Normal file
View File

@ -0,0 +1,147 @@
import importlib.util
import json
from pathlib import Path
import tempfile
import time
import unittest
spec=importlib.util.spec_from_file_location('dashboard',Path(__file__).resolve().parents[1]/'server.py')
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
class DashboardTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory(prefix='dashboard-qa-')
self.old=(m.DATA,m.LOGS)
m.DATA=Path(self.temp.name)/'state';m.LOGS=Path(self.temp.name)/'logs';m.LOGS.mkdir()
m.initialize()
m.LATEST.clear();m.LATEST.update({'ts':time.time(),'boot_id':'test-current','monotonic_s':1000,'issues':[]})
def tearDown(self):
m.DATA,m.LOGS=self.old;self.temp.cleanup()
def test_import_replay_and_clock_adjustment(self):
now=int(time.time())
row={'observed_at':m.iso(now),'first_at':m.iso(now+3600),'last_at':m.iso(now-3600),
'first_monotonic_us':'11000000','last_monotonic_us':'487000000',
'boot_id':'oldboot','source':'kernel','category':'power','severity':'ERROR','count':93,
'device':'0000:84:00.0','detail':'GPU does not have the necessary power cables connected.'}
p=m.LOGS/'events-test.jsonl';p.write_text(json.dumps(row)+'\n'+json.dumps(row)+'\n')
with m.connect() as db:
m.import_logs(db);m.import_logs(db)
self.assertEqual(db.execute('SELECT COUNT(*) FROM events').fetchone()[0],1)
result=m.events({'days':['7']})
self.assertEqual(result['total'],1)
self.assertEqual(result['items'][0]['state'],'history')
self.assertAlmostEqual(result['items'][0]['first_ts'],now-3600-476,delta=1)
self.assertTrue(result['items'][0]['time_note'])
self.assertEqual(result['items'][0]['raw']['first_at'],row['first_at'])
def test_history_does_not_invent_old_cpu_utilization(self):
now=int(time.time())-40
with m.connect() as db:
db.execute('INSERT INTO metrics VALUES(?,NULL,NULL,NULL,NULL,NULL,NULL,NULL,?,?)',
(now-1500,json.dumps([{'uuid':'gpu-a','name':'P100','temperature_c':40,'power_w':30}]),'legacy'))
for offset,cpu in [(0,40),(10,80)]:
db.execute('INSERT INTO metrics VALUES(?,?,?,?,?,?,?,?,?,?)',
(now+offset,cpu,60,45,1,2,3,4,json.dumps([{'uuid':'gpu-a','name':'P100','utilization':90,'temperature_c':50,'power_w':100}]),'live'))
data=m.history('1h')
legacy=[p for p in data['points'] if not p['live']]
self.assertTrue(legacy)
self.assertIsNone(legacy[0]['cpu'])
self.assertIsNone(legacy[0]['gpus']['gpu-a']['utilization'])
self.assertEqual(legacy[0]['gpus']['gpu-a']['temperature_c'],40)
self.assertEqual(data['devices']['gpu-a'],'P100')
self.assertTrue(any(p['cpu'] is not None for p in data['points']))
def test_filters_and_partial_tail(self):
now=int(time.time())
row={'observed_at':m.iso(now),'category':'storage','severity':'ERROR','detail':'nvme timeout'}
p=m.LOGS/'events-tail.jsonl';p.write_text(json.dumps(row))
with m.connect() as db:
m.import_logs(db)
self.assertEqual(db.execute('SELECT COUNT(*) FROM events').fetchone()[0],0)
with p.open('a') as f:f.write('\n')
with m.connect() as db:m.import_logs(db)
self.assertEqual(m.events({'category':['storage']})['total'],1)
self.assertEqual(m.events({'category':['power']})['total'],0)
self.assertEqual(m.events({'q':['timeout']})['total'],1)
def test_stale_status_is_unknown_not_recovered(self):
m.LATEST.update({'ts':time.time()-300})
now=int(time.time())
row={'observed_at':m.iso(now),'category':'power','severity':'ERROR','detail':'power error'}
(m.LOGS/'events-stale.jsonl').write_text(json.dumps(row)+'\n')
with m.connect() as db:m.import_logs(db)
self.assertEqual(m.events({})['items'][0]['state'],'unknown')
def test_link_reports_merge_and_keep_two_cards_separate(self):
now=int(time.time())
a={'id':'gpu-a','label':'第 1 张 P100','pci':'0000:03:00.0'}
b={'id':'gpu-b','label':'第 2 张 P100','pci':'0000:84:00.0'}
m.LATEST.update({'device_map':{'0000:00:03.0':a,'0000:03:00.0':a,'0000:80:03.0':b},
'gpus':[{'uuid':'gpu-a'},{'uuid':'gpu-b'}],'expected_gpu_count':2})
def row(device,detail,timestamp,mono):
return {'observed_at':m.iso(now),'first_at':m.iso(timestamp),'last_at':m.iso(timestamp),
'first_monotonic_us':str(mono),'last_monotonic_us':str(mono),
'boot_id':'test-current','source':'kernel','category':'pcie_correctable','severity':'WARN',
'device':device,'count':1,'detail':detail}
rows=[row('0000:00:03.0','PCIe Bus Error: severity=Correctable',now-50,950000000),
row('0000:00:03.0','[6] BadTLP',now-50,950000001),
row('0000:03:00.0','PCIe Bus Error: severity=Correctable',now-50,950000002),
row('0000:80:03.0','PCIe Bus Error: severity=Correctable',now-40,960000000)]
path=m.LOGS/'events-grouped.jsonl';path.write_text(''.join(json.dumps(x)+'\n' for x in rows))
with m.connect() as db:m.import_logs(db)
result=m.events({})
self.assertEqual(result['raw_total'],4)
self.assertEqual(result['total'],2)
first=next(x for x in result['items'] if x['component_id']=='gpu-a')
self.assertEqual(first['record_count'],3)
self.assertEqual(first['signal_reports'],2)
self.assertEqual(m.events({'view':['raw']})['total'],4)
original_id=first['id']
with path.open('a') as f:f.write(json.dumps(row('0000:03:00.0','PCIe Bus Error: severity=Correctable',now-10,990000000))+'\n')
with m.connect() as db:m.import_logs(db)
new=next(x for x in m.events({})['items'] if x['component_id']=='gpu-a')
self.assertEqual(new['id'],original_id)
self.assertEqual(new['signal_reports'],3)
def test_five_minute_quiet_gap_starts_another_incident(self):
now=int(time.time())
rows=[{'observed_at':m.iso(now),'first_at':m.iso(now-age),'last_at':m.iso(now-age),
'source':'kernel','boot_id':'oldboot','first_monotonic_us':str(1000000+i),
'last_monotonic_us':str(1000000+i),'device':'0000:03:00.0',
'category':'pcie_correctable','severity':'WARN','count':1,
'detail':'PCIe Bus Error: severity=Correctable'} for i,age in enumerate([1000,10])]
(m.LOGS/'events-gaps.jsonl').write_text(''.join(json.dumps(x)+'\n' for x in rows))
with m.connect() as db:m.import_logs(db)
self.assertEqual(m.events({})['total'],2)
def test_current_retry_is_warning_and_has_plain_action(self):
now=int(time.time())
m.LATEST.update({'gpus':[{'uuid':'gpu-a'},{'uuid':'gpu-b'}],'expected_gpu_count':2,
'issues':[{'category':'pcie_correctable','device':'0000:00:03.0'}],
'device_map':{'0000:00:03.0':{'id':'gpu-a','label':'第 1 张 P100','pci':'0000:03:00.0'}}})
row={'observed_at':m.iso(now),'first_at':m.iso(now-10),'last_at':m.iso(now-10),
'source':'kernel','boot_id':'test-current','first_monotonic_us':'990000000',
'last_monotonic_us':'990000000','device':'0000:00:03.0',
'category':'pcie_correctable','severity':'WARN','count':1,
'detail':'PCIe Bus Error: severity=Correctable'}
(m.LOGS/'events-active.jsonl').write_text(json.dumps(row)+'\n')
with m.connect() as db:m.import_logs(db)
item=m.events({})['items'][0]
self.assertEqual(item['severity'],'WARN')
self.assertEqual(item['state'],'active')
self.assertIn('显卡可用',item['state_label'])
self.assertIn('不能证明',item['meaning'])
self.assertEqual(len(item['next_steps']),3)
def test_problem_filter_hides_routine_information(self):
now=int(time.time())
rows=[{'observed_at':m.iso(now),'category':cat,'severity':severity,'detail':cat}
for cat,severity in [('boot','INFO'),('power','ERROR')]]
(m.LOGS/'events-levels.jsonl').write_text(''.join(json.dumps(x)+'\n' for x in rows))
with m.connect() as db:m.import_logs(db)
self.assertEqual(m.events({'severity':['problem']})['total'],1)
self.assertEqual(m.events({'severity':['INFO']})['total'],1)
if __name__=='__main__':unittest.main(verbosity=2)

99
tests/test_tasks.py Normal file
View File

@ -0,0 +1,99 @@
import importlib.util
import json
from pathlib import Path
import tempfile
import time
import unittest
from unittest.mock import patch
import task_core as core
import taskd
class TaskTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory(prefix='scheduled-task-qa-')
self.original=(core.BASE,core.LOGS,core.UNITS,taskd.ADMIN)
folder=Path(self.temp.name)
core.BASE=folder/'data';core.LOGS=folder/'logs';core.UNITS=folder/'units';core.UNITS.mkdir()
taskd.ADMIN=folder/'admin.json';taskd.SESSIONS.clear();taskd.ATTEMPTS.clear();core.init_store()
core.atom_write(taskd.ADMIN,json.dumps(taskd.password_record('only-a-test-password')))
def tearDown(self):
core.BASE,core.LOGS,core.UNITS,taskd.ADMIN=self.original
taskd.SESSIONS.clear();taskd.ATTEMPTS.clear();self.temp.cleanup()
def definition(self,**values):
return {'name':'测试任务','mode':'command','body':'printf hello','enabled':False,
'schedule':{'kind':'daily','time':'03:00'},**values}
def test_daily_weekly_monthly_and_interval(self):
self.assertEqual(core.schedule_definition({'kind':'daily','time':'03:15'})[0],['OnCalendar=*-*-* 03:15:00 Asia/Shanghai'])
self.assertIn('Mon,Sun',core.schedule_definition({'kind':'weekly','weekdays':[0,6],'time':'04:00'})[0][0])
self.assertIn('跳过',core.schedule_definition({'kind':'monthly','day':31,'time':'02:00'})[1])
interval=core.schedule_definition({'kind':'minutes','every':10})[0]
self.assertEqual(interval,['OnActiveSec=600s','OnUnitInactiveSec=600s'])
self.assertFalse(any('OnBootSec' in x for x in interval))
def test_cron_lists_ranges_steps_and_day_or(self):
output=core.cron_calendars('*/15 3 * * MON-FRI')
self.assertEqual(len(output),1)
self.assertIn('0,15,30,45:00',output[0])
self.assertIn('Mon,Tue,Wed,Thu,Fri',output[0])
output=core.cron_calendars('0 3 1 * 1')
self.assertEqual(len(output),2)
self.assertIn('*-*-1',output[0]);self.assertIn('Mon',output[1])
for bad in ['0 3 * *','*/0 * * * *','0 24 * * *','0 3 32 * *','0 3 * * 9','0 3 L * *']:
with self.subTest(bad=bad),self.assertRaises(core.TaskError):core.cron_calendars(bad)
def test_task_text_never_becomes_unit_instructions(self):
definition=core.validate_definition(self.definition(body='echo test\n# ExecStart=/bad/example'),check_paths=False)
service,timer=core.render_units('a'*32,definition)
self.assertNotIn('echo test',service);self.assertNotIn('/bad/example',service)
self.assertIn('task_exec.py '+'a'*32,service)
self.assertIn('Persistent=false',timer)
with self.assertRaises(core.TaskError):core.validate_definition(self.definition(name='name\nExecStart=bad'),check_paths=False)
with self.assertRaises(core.TaskError):core.validate_id('../../etc/shadow')
def test_validation_limits_and_past_dates(self):
for changes in [{'body':''},{'timeout':1},{'timeout':90000},{'body':'x'*65537},{'enabled':'false'},
{'schedule':{'kind':'once','at':'2020-01-01T03:00'}}]:
with self.subTest(changes=list(changes)),self.assertRaises(core.TaskError):core.validate_definition(self.definition(**changes),check_paths=False)
self.assertTrue(core.validate_definition(self.definition(body='systemctl reboot'),check_paths=False)['dangerous'])
def test_session_and_csrf_gate_privileged_operations(self):
with patch.object(core,'save_task') as save:
with self.assertRaises(core.TaskError) as error:taskd.dispatch({'action':'tasks.save','payload':self.definition()})
self.assertEqual(error.exception.status,401);save.assert_not_called()
login=taskd.login({'username':'admin','password':'only-a-test-password'},'qa')
with patch.object(core,'set_enabled') as toggle:
with self.assertRaises(core.TaskError) as error:taskd.dispatch({'action':'tasks.enable','token':login['token'],'csrf':'wrong','payload':{'id':'a'*32,'enabled':True}})
self.assertEqual(error.exception.status,403);toggle.assert_not_called()
self.assertTrue(taskd.dispatch({'action':'auth.status','token':login['token']})['logged_in'])
taskd.dispatch({'action':'auth.logout','token':login['token'],'csrf':login['csrf']})
self.assertFalse(taskd.dispatch({'action':'auth.status','token':login['token']})['logged_in'])
def test_password_change_revokes_all_sessions(self):
one=taskd.login({'username':'admin','password':'only-a-test-password'},'qa')
two=taskd.login({'username':'admin','password':'only-a-test-password'},'qa-2')
taskd.dispatch({'action':'auth.password','token':one['token'],'csrf':one['csrf'],
'payload':{'old_password':'only-a-test-password','new_password':'new-test-password-123'}})
self.assertFalse(taskd.verify('only-a-test-password'));self.assertTrue(taskd.verify('new-test-password-123'))
self.assertFalse(taskd.dispatch({'action':'auth.status','token':two['token']})['logged_in'])
def test_wrong_old_password_keeps_current_login(self):
login=taskd.login({'username':'admin','password':'only-a-test-password'},'qa')
with self.assertRaises(core.TaskError) as error:
taskd.dispatch({'action':'auth.password','token':login['token'],'csrf':login['csrf'],
'payload':{'old_password':'wrong-password','new_password':'new-test-password-123'}})
self.assertEqual(error.exception.status,400)
self.assertTrue(taskd.dispatch({'action':'auth.status','token':login['token']})['logged_in'])
def test_log_tail_is_bounded_and_missing_id_rejected(self):
task_id='b'*32;run_id='c'*32
with core.db() as con:
con.execute('INSERT INTO runs(id,task_id,task_name,started,state) VALUES(?,?,?,?,?)',(run_id,task_id,'test',time.time(),'success'))
(core.LOGS/(run_id+'.log')).write_text('x'*200000)
result=core.read_run(run_id)
self.assertTrue(result['tail_only']);self.assertEqual(len(result['output']),131072)
with self.assertRaises(core.TaskError):core.read_run('../password')
if __name__=='__main__':unittest.main(verbosity=2)