Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
747503d86d | ||
|
|
174ac747c5 | ||
|
|
b63fa39f32 | ||
|
|
e828fd11ee | ||
|
|
7aa8f838a6 | ||
|
|
e55ad3217f | ||
|
|
26c313df46 | ||
|
|
fc27079126 | ||
|
|
b0998555d9 | ||
|
|
f735c3fb4e | ||
|
|
436b87f40e | ||
|
|
302c38986e | ||
|
|
72a3a2e0f3 | ||
|
|
95494aa894 | ||
|
|
10f57a5699 | ||
|
|
11f8ffd5f6 | ||
|
|
75d0ce2d85 | ||
|
|
38382ba61c | ||
|
|
da5f53ad99 | ||
|
|
1087ec828f | ||
|
|
ece74e0a3f | ||
|
|
b60928c30f | ||
|
|
5f3e9353ed | ||
|
|
510f90288a |
@@ -3,3 +3,8 @@ __pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.log
|
||||
.data/
|
||||
dist/
|
||||
/storage-release.json
|
||||
/ops/deploy-storage.py
|
||||
/ops/release-storage.py
|
||||
|
||||
@@ -1,65 +1,77 @@
|
||||
# PIGWay Pi Control v3.7.0
|
||||
# PIGWay Raspberry Pi Agent v3.11.2
|
||||
|
||||
独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理平台。提供本机状态、告警、事件日志和多机配置管理,可按需接入独立插件。
|
||||
[简体中文](README.zh-CN.md) | English
|
||||
|
||||
## 功能
|
||||
Disk discovery is manual: Device Console → System configuration → select the device → Storage settings → Scan disks. The inventory persists across restarts; known filesystem capacity updates weekly. Names and visibility belong to the Agent. See [deployment and validation](STORAGE-DISCOVERY.md).
|
||||
|
||||
- CPU、温度、内存和磁盘容量/用量;主网络接口、IPv4/IPv6、Wi-Fi 信号百分比。
|
||||
- systemd 服务及进程监控;暂停监控与启停服务是不同操作。
|
||||
- Raspberry Pi 当前欠压、降频、频率受限、温度限制和根分区只读监控。
|
||||
- 当前故障统一管理;历史电源位仅记录日志,不生成当前告警。
|
||||
- 事件日志按日期、严重程度、事件类型和机器查询、排序。
|
||||
- 多机状态、机器标识、配置与独立的批量配置同步。
|
||||
- 中英文、跟随系统/深色/浅色主题。
|
||||
- 独立硬件插件发现、手动接入、暂停联动、断开、能力配置。
|
||||
|
||||
## 安装
|
||||
PIGWay Raspberry Pi Agent is a lightweight local monitoring service for Raspberry Pi. It monitors CPU, memory, temperature, disk, networking, services, processes, system health alerts, and journal events. Ethernet monitoring reads the negotiated link speed and can alert when it remains below the configured minimum. It has no embedded Web UI and does not require a hardware plugin or Device Console.
|
||||
|
||||
### Installation
|
||||
|
||||
See the [Trixie fresh-install guide](docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md) for Raspberry Pi OS Lite 64-bit based on Debian 13. The Agent itself does not access GPIO or I2C; OLED, RGB, and fan control belong to the independent hardware plugin.
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# 仅安装文件,不改变服务运行状态:
|
||||
sudo ./install.sh --no-start
|
||||
# Explicitly enable the API:
|
||||
sudo ./install.sh --enable-api
|
||||
# Keep the API disabled:
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
仅需要 Python 3。插件独立安装与运行,主程序不会自动安装或启动插件。
|
||||
配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。
|
||||
Interactive installation asks whether to enable the remote API and defaults to no. Non-interactive installation also keeps it disabled unless explicitly enabled. `--no-start` only updates files and configuration; it does not change the current service state.
|
||||
|
||||
API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器显示查询方法,不直接打印秘密:
|
||||
The configuration file is `/etc/pigway-pi-control.conf`. Restart the Agent after changing monitoring settings:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
### Optional API
|
||||
|
||||
The default is `[api] enabled = false`, with no network listener. To use centralized management, enable it and configure the bind address and port, then run:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now pigway-pi-control-api.service
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
服务分别是 `pigway-pi-control.service` 和 `pigway-pi-control-api.service`。
|
||||
Web 重启不会自动启动已停止的监控。监控与硬件插件的运行状态彼此独立。
|
||||
The default port is 6001. Every read and write request requires `Authorization: Bearer <token>`. The token belongs only to this device and is not printed directly by the installer. The API exposes health, status, logs, configuration, services, plugins, and plugin configuration under `/api/v1`. It does not serve a Web UI, register multiple machines, or proxy remote requests.
|
||||
|
||||
## 已接入插件与扩展
|
||||
### Console and hardware plugins
|
||||
|
||||
[Yahboom 散热板插件](https://tea.pigway.com/way/pigway-cooling-hat) 是独立项目、独立安装包及 systemd 服务。
|
||||
先在目标机器安装并运行插件,再进入 Web → 系统配置 → 硬件插件 → 发现并接入。
|
||||
仅发现插件不会接管插件的设备能力。接入后发送当前监控状态,无需开放新的网络端口。
|
||||
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) can run on a NAS or remote server for dashboards, configuration, and batch synchronization.
|
||||
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat) independently provides local telemetry, OLED, fan control, and RGB effects.
|
||||
|
||||
运行状态卡片仅为已接入插件显示摘要。插件配置根据能力生成:只有风扇就只有风扇设置,有 OLED/RGB 才有相应控件。保存插件配置不重启监控。暂停告警联动或断开接入不会停止插件的本地功能。
|
||||
The remote API and local plugin integration are independent. After enabling plugin integration, register `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` under `[plugins]` and restart the Agent, or attach it through the Console. Discovery never binds a plugin automatically. Pausing or disconnecting integration does not stop the plugin's local functions.
|
||||
|
||||
默认 Unix socket:`/run/pigway-plugins/<id>/api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。插件声明自身能力,Web 据此展示状态和配置。其他设备可通过独立插件实现此协议。
|
||||
|
||||
监控和插件互不依赖:插件离线不影响监控、日志和 Web;监控离线不停止插件的本地功能。通信恢复后同步最新状态,过期告警不会继续显示。
|
||||
|
||||
当前可用插件:
|
||||
|
||||
| 插件 | 独立功能 | 仓库 |
|
||||
| --- | --- | --- |
|
||||
| PIGWay Cooling HAT | 本机状态采集、OLED 展示、风扇温控、RGB 灯效 | [pigway-cooling-hat](https://tea.pigway.com/way/pigway-cooling-hat) |
|
||||
|
||||
## 运维
|
||||
### Operations
|
||||
|
||||
```bash
|
||||
sudo systemctl status pigway-pi-control.service pigway-pi-control-api.service
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
```
|
||||
|
||||
`--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。
|
||||
卸载监控不会停止或删除独立插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。
|
||||
Logs follow the system journal retention policy; no additional database is created. Uninstalling one project never uninstalls either of the others. See [Plugin API v1](docs/HARDWARE_PLUGIN_API.md).
|
||||
|
||||
静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。
|
||||
## Naming and compatibility
|
||||
|
||||
This project targets Raspberry Pi. Other Linux platforms are not covered by its support claim and may have separately maintained adapters. Repository naming does not change the existing `pigway-agent` adapter ID or `pigway-pi-control` service, configuration and API identifiers.
|
||||
|
||||
## v3.8.0 网络流量
|
||||
|
||||
主接口每个网络采集周期读取 sysfs 的 rx_bytes/tx_bytes,以单调时间差计算字节/秒,API 新增 rx_bytes_per_second 和 tx_bytes_per_second。首次采样、接口切换、计数器重置或读取失败返回空值;不修改监控告警、硬件插件控制或 API 可选安装行为。2026-10-14 已在 Gateway 部署,实机上下行速率与 Console 标准历史指标验证通过。
|
||||
|
||||
## v3.9.0 采样时间
|
||||
|
||||
API 状态新增 system.observed_at、network.observed_at 和 collection.interval_seconds。Console 可每秒读取缓存,网络等低频指标保留实际采样时间,供历史去重。数据来源与原网络采样周期保持。
|
||||
|
||||
生产 v3.9.0 与按设备周期采集的核验见 [部署记录](docs/DEPLOYMENT-v3.9.0-2026-10-15.md)。
|
||||
|
||||
## v3.10.0 独立采集周期
|
||||
|
||||
实时状态提供分组采样时间与下一次更新计划。容量每天读取一次,RAM 缓存跨服务重启恢复;读取失败延后一小时重试,避免反复查询存储。网络周期可独立配置,生产计划调整为 1 秒。
|
||||
|
||||
## v3.11.2 容量周期
|
||||
|
||||
当前已知磁盘容量及根盘兼容指标每周更新;新增磁盘只手动发现。容量缓存保留在/run并支持服务重启复用,整机重启后首次初始化容量缓存属于启动检查;不自动调用lsblk发现新设备。当前策略覆盖历史版本的30秒/每日容量刷新描述。
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# PIGWay Raspberry Pi Agent v3.11.2
|
||||
|
||||
简体中文 | [English](README.md)
|
||||
|
||||
新增磁盘由 Console → 系统配置 → 选择设备 → 磁盘设置 → 扫描磁盘手动发现;已保存清单跨重启保留,已知磁盘容量每周刷新。支持在 Agent 保存名称和隐藏设置。见 [部署与验证](STORAGE-DISCOVERY.md)。
|
||||
|
||||
|
||||
极轻的树莓派本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。有线网络会读取真实协商速率,并可在持续低于配置阈值时告警。无 Web 界面;无需硬件插件,也无需管理中心。
|
||||
|
||||
## 安装
|
||||
|
||||
Raspberry Pi OS Lite 64-bit(Debian 13 Trixie)的全新安装与兼容性说明见 [Trixie 全新安装指南](docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)。监控 Agent 本身不访问 GPIO/I²C;OLED、RGB 和风扇由独立硬件插件负责。
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# 显式启用 API:
|
||||
sudo ./install.sh --enable-api
|
||||
# 不启用 API:
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
|
||||
|
||||
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
## 可选 API
|
||||
|
||||
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now pigway-pi-control-api.service
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
默认端口6001。读取及写入都需要 `Authorization: Bearer <token>`。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。
|
||||
|
||||
API 提供 `/api/v1/health`、`/status`、`/logs`、`/config`、`/services`、`/plugins` 和 `/plugin/config`(均以 `/api/v1` 开头)。不提供网页、多机登记或远程转发。
|
||||
|
||||
## 独立管理中心与插件
|
||||
|
||||
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console):可部署在 NAS 或远端,登记设备地址与令牌,统一看板、配置和批量同步。
|
||||
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat):独立本地采集、OLED、风扇与 RGB。
|
||||
|
||||
远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。
|
||||
|
||||
## 运维
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
```
|
||||
|
||||
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
|
||||
|
||||
## 名称与兼容性
|
||||
|
||||
本项目支持范围明确为树莓派;其他 Linux 平台以后独立维护项目。仓库改名不改变现有 `pigway-pi-control` 服务名、配置路径和 API。远程 API 仍可选择启用,默认关闭。
|
||||
@@ -0,0 +1,7 @@
|
||||
# Raspberry Pi Agent v3.11.2
|
||||
|
||||
新增磁盘只由用户手动扫描登记,已知磁盘容量及根分区兼容指标每周更新;服务重启复用运行缓存。保留现有CPU、内存、网络、硬件插件及监控协议。
|
||||
|
||||
磁盘清单持久化,支持名称/显示设置;整机重启后容量缓存首次初始化不自动发现新盘。没有调整风扇、RGB或OLED控制协议。
|
||||
|
||||
验证:10项后端测试通过;pigway-gateway生产升级后原两个服务正常,容量周期为604800秒,配置和令牌校验和保留。未执行整机重启或七天自然周期测试。
|
||||
+19
-10
@@ -1,11 +1,20 @@
|
||||
f6bab4766d2b14b14572f25bdf98f2e95ae2759c6b96837343f0713b7e137dea README.md
|
||||
8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh
|
||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||
8b9df5b92218ff1c809d570141b2568228a1a4bdcb041eca29fcc6b51c61889c app/pigway_pi_control.py
|
||||
339f28978641242436e141add0cc923ed5ebb855bea9f6c9fe949b8eb8579527 app/pigway_pi_control_api.py
|
||||
ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore
|
||||
948e700459c05a045234edfbbdd1efa240f9a0350221a40029b7416c0587e36c README.md
|
||||
64a9708701f8c8d872a587e8a2845afff463797b06a2791319daadd705933d31 README.zh-CN.md
|
||||
c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py
|
||||
69877b4ad2b8307db69495d629988ff4bca838dea9e36d839165029fdb6ed4a8 app/pigway_pi_control.py
|
||||
757eb6a83ca07740a2389af9b51c6fd5d940b4eb86abbc603f231409416d45db app/pigway_pi_control_api.py
|
||||
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
||||
3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf
|
||||
20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service
|
||||
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
|
||||
64c21a4e4d93a68fcca1d0b0126655a56f306a290cced6eed5350ab9d5b5004f web/index.html
|
||||
73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md
|
||||
81ca44b8bdc384c7f27a39da8e785af2450241c65200edee61db95f48d67f058 app/storage_cache.py
|
||||
d5a783b64a10fa43191a813ef73ef8f762e359b0bd42ec155724fa46216298b0 config/pigway-pi-control.conf
|
||||
9d45f22221012a7fdb3406ce74a6113f618e9930fe460efb3869efd099e3b1d8 docs/DEPLOYMENT-v3.8.0-2026-10-14.md
|
||||
2a3cd7f58c46b7f864affd7ec64fbec38ea2d6306160cd8d4f0075a779251b5f docs/DEPLOYMENT-v3.9.0-2026-10-15.md
|
||||
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
|
||||
b54ec1f3e9fb15473910686c54142a85d387e44ea5df427a4cf1b06052e789b3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md
|
||||
9222a2b6a38f6b6d7d88b30c05413c68c05f69ee499c8479eca05ee7c62e87c3 docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md
|
||||
1b0aeaf4997bdb02b967d538cf276ad7462a3e22ec35fc0d046d779ef56cd1f8 install.sh
|
||||
8a593c290da5bd0d037aee86c35264512afd574a2a0f60f2c29e9db63e0109bd systemd/pigway-pi-control-api.service
|
||||
1cb55de6b104a233066495e9786319305fa4f1b6f25483c108ab4f5ac9aa5700 systemd/pigway-pi-control.service
|
||||
8b97229abfb31c68464eb5754bc3a8fec7a6a78e216d74a86b7cc89486700740 tests/test_network_rates.py
|
||||
f78e31e0eeee4e02532885ace5c8f6f7011d084e4c7440b704408bc88495e40e tests/test_storage_cache.py
|
||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# 磁盘手动扫描(2026-10-15)
|
||||
|
||||
后续显示层已升级为 Console v2.7.0 / Ubuntu 插件 v1.5.0 / Raspberry Pi 插件 v1.6.0 / Synology 插件 v1.9.0:默认原样使用设备返回顺序,取消 NAS 特殊倒序和优先级。显示名称、同组排序、隐藏统一保存到 Console,首页与配置共用,支持恢复默认顺序。设备已有名称/隐藏定义保留为默认上报值;本篇后文“名称保存在 Agent”等描述为当时的设备定义行为,不代表最新 Console 显示偏好的存储位置。最新验收见相邻 pigway-web-manager/docs/STORAGE-PRESENTATION-v2.7.0-2026-10-15.md;所有实际扫描行为保持手动。
|
||||
|
||||
## 已上线状态
|
||||
|
||||
Ubuntu Agent v1.3.1 / Console 插件 v1.4.1,Raspberry Pi Agent v3.11.1 / 插件 v1.5.1,Synology 插件 v1.8.1 已上线。两台 Agent 各识别两个实际分区,监控/API 服务 active,循环错误 0。
|
||||
|
||||
三种设备在线;中英文及 1440/820/390 三种屏宽的生产界面通过。设备名称、顺序和隐藏设置保留。Console 重启、页面加载及浏览器验收前后,NAS storage_observed_at 未改变,本次没有额外扫描 NAS 硬盘。尚未插入新物理磁盘,也未进行休眠/唤醒实测。
|
||||
|
||||
用户最终确认“三种设备都手动发现新增磁盘”。此前自动发现与 NAS 每 5 分钟扫描的候选方案已替换。
|
||||
|
||||
## 使用
|
||||
|
||||
Ubuntu、树莓派:Console → 系统配置 → 选择设备 → 磁盘设置 → 扫描磁盘。扫描后重新打开该页查看新磁盘,概览随正常轮询更新。名称和隐藏设置保存到 Agent,隐藏不停止历史采集。
|
||||
|
||||
NAS:与其他设备一样在配置 → 磁盘设置中点击“扫描磁盘”,首页只展示数据。磁盘清单、容量、温度、健康状态仅在手动扫描时更新;页面保留采集时间。CPU、内存和网络等快速采集继续工作。已有卷别名仍由 Console 保存。
|
||||
|
||||
v1.4.1 / v1.5.1 / v1.8.1 统一配置页扫描入口,保存按钮上方保留 20px 间距,扫描与保存反馈分别显示。按用户最终要求,NAS 每个机箱内硬盘采用 DSM 返回顺序反转;存储空间同样反转,主空间和 SSD 空间优先。生产三种屏宽、中英文已验证入口、间距、倒序及优先级;NAS 存储采集时间未改变。前两次发布因验收脚本固定计入网络卡片位置而自动回退,修正为直接验证存储卡片后通过。
|
||||
|
||||
扫描有 60 秒冷却,NAS 在途请求合并。页面加载、普通刷新、定时轮询和重启不会重新枚举磁盘。部署时执行过一次明确的初始扫描以保存现有 Agent 磁盘清单。
|
||||
|
||||
## 实现与边界
|
||||
|
||||
认证 PUT /api/v1/storage/scan 调用 lsblk,读取宿主机挂载点,将块设备清单保存到对应配置旁的 .storage.json(0600)。监控服务只读取清单;已知磁盘挂载和容量每 30 秒更新,新增磁盘必须手动扫描。根分区原有指标和告警保留兼容。
|
||||
|
||||
UUID 优先作为标识,其次为设备序列号和分区编号,再退回路径并标记 stable_identity=false。展示可用叶设备,去除重复挂载及 loop/swap 等伪设备。未挂载设备不伪造使用率。配置过的条目在后续扫描未发现时保留为断开;扫描失败保留原清单。
|
||||
|
||||
不执行挂载、格式化、分区或 SMART 指令。本次没有增加每磁盘断开/容量告警。
|
||||
|
||||
Agent 手工配置使用 status.system.storage_inventory[].id 的 32 位十六进制 key:
|
||||
|
||||
```ini
|
||||
[storage_names]
|
||||
0123456789abcdef0123456789abcdef = 媒体盘
|
||||
[storage_visible]
|
||||
0123456789abcdef0123456789abcdef = true
|
||||
```
|
||||
|
||||
NAS 普通 collect 不再调度存储任务,只有 device.refresh(group=storage) 触发慢查询。重启复用上次摘要;过旧健康数据标为未确认,不确认旧告警恢复。只展示 DSM hdd_info / vol_info 上报的硬盘、扩展柜和卷,未验证任意 USB 外接盘是否均由接口提供。
|
||||
|
||||
## 验证和备份
|
||||
|
||||
两种 Agent 覆盖挂载点转义、bind 去重、伪设备、未挂载状态、UUID 稳定性、百分号名称、隐藏设置、手动发现和重启后禁止自动枚举。两种插件的中英文/三屏宽测试覆盖 0% 使用率、名称转义、隐藏恢复和显式扫描;打开设置不会扫描。NAS 15 项 Python 回归及六种浏览器布局通过。
|
||||
|
||||
生产备份:
|
||||
|
||||
- Ubuntu:/var/backups/pigway-ubuntu-agent-storage-1792043168
|
||||
- Raspberry Pi:/var/backups/pigway-pi-control-storage-1792043209
|
||||
- Console 容器:/data/storage-release-backups/1792043974(旧插件与 SQLite 备份)
|
||||
|
||||
ops/deploy-storage.py 检查 SHA-256 清单、备份三个程序文件与配置,仅重启两个项目服务;验证失败恢复原文件。Console 更新验证三个插件版本、全部设备在线、显示设置保留、NAS 未额外扫描,并执行生产浏览器检查。
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Sample interface counters without summing physical and virtual interfaces."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class NetworkRates:
|
||||
def __init__(self, root='/sys/class/net'):
|
||||
self.root = Path(root)
|
||||
self.previous = None
|
||||
|
||||
def sample(self, interface, now):
|
||||
try:
|
||||
if not re.fullmatch(r'[\w.:-]{1,64}', interface):
|
||||
raise ValueError('invalid interface')
|
||||
directory = self.root / interface / 'statistics'
|
||||
rx = int((directory / 'rx_bytes').read_text())
|
||||
tx = int((directory / 'tx_bytes').read_text())
|
||||
if rx < 0 or tx < 0:
|
||||
raise ValueError('negative counter')
|
||||
except (OSError, ValueError, TypeError):
|
||||
self.previous = None
|
||||
return None, None
|
||||
previous = self.previous
|
||||
self.previous = interface, now, rx, tx
|
||||
if previous is None or previous[0] != interface or now <= previous[1]:
|
||||
return None, None
|
||||
elapsed = now - previous[1]
|
||||
return (None if rx < previous[2] else (rx - previous[2]) / elapsed,
|
||||
None if tx < previous[3] else (tx - previous[3]) / elapsed)
|
||||
+103
-20
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
|
||||
from pathlib import Path
|
||||
APP_VERSION="3.7.0"
|
||||
APP_VERSION="3.11.2"
|
||||
import sys
|
||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||
from shutil import disk_usage
|
||||
from plugin_api import Publisher,registered,discover
|
||||
from network_rates import NetworkRates
|
||||
from storage_cache import StorageCache,StorageInventory
|
||||
storage_cache=StorageCache("/run/pigway-pi-control/storage-cache.json")
|
||||
|
||||
CFG="/etc/pigway-pi-control.conf"
|
||||
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||
@@ -53,6 +55,22 @@ def device_details():
|
||||
except Exception: pass
|
||||
try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode()
|
||||
except Exception: pass
|
||||
# Device tree identifies ARM boards; DMI covers PCs and many other vendors.
|
||||
for key,files in {
|
||||
"manufacturer":["/sys/class/dmi/id/sys_vendor"],
|
||||
"model":["/sys/firmware/devicetree/base/model","/sys/class/dmi/id/product_name"],
|
||||
"revision":["/sys/class/dmi/id/product_version","/sys/class/dmi/id/board_version"],
|
||||
"serial":["/sys/class/dmi/id/product_serial"],
|
||||
}.items():
|
||||
if info.get(key):continue
|
||||
for filename in files:
|
||||
try:value=Path(filename).read_text().strip().strip("\0")
|
||||
except (OSError,UnicodeError):continue
|
||||
if value and value.lower() not in ("none","unknown","default string","to be filled by o.e.m."):
|
||||
info[key]=value;break
|
||||
if not info.get("manufacturer"):
|
||||
try:info["manufacturer"]=Path("/sys/firmware/devicetree/base/compatible").read_bytes().split(b"\0")[0].decode().split(',')[0]
|
||||
except (OSError,UnicodeError):pass
|
||||
try:
|
||||
os_release={}
|
||||
for line in Path("/etc/os-release").read_text().splitlines():
|
||||
@@ -88,8 +106,14 @@ prev_cpu=None
|
||||
page=0; page_since=time.monotonic()
|
||||
cpu_hi=mem_hi=0
|
||||
net_ok=link_ok=True
|
||||
net_iface=""; net_kind="NET"; net_metric="DOWN"; ip_label="IP4"; ip="NO IP"
|
||||
sensor_observed_at=None
|
||||
network_observed_at=None
|
||||
network_rates=NetworkRates()
|
||||
net_rx_bytes_per_second=None; net_tx_bytes_per_second=None
|
||||
net_iface=""; net_kind="NET"; net_metric="DOWN"; net_speed_mbps=None; ip_label="IP4"; ip="NO IP"
|
||||
link_failure_since=internet_failure_since=None
|
||||
wifi_weak_since=None; wifi_weak_active=False
|
||||
ethernet_slow_since=None; ethernet_slow_active=False
|
||||
last_net=last_watch=0
|
||||
watch_state=[]
|
||||
|
||||
@@ -121,7 +145,8 @@ def mem_pct():
|
||||
k,v=l.split(":",1); vals[k]=int(v.split()[0])
|
||||
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
|
||||
def disk_pct():
|
||||
d=disk_usage("/"); return 100*d.used/d.total
|
||||
d=storage_cache.sample()
|
||||
return 100*d["used"]/d["total"] if d else None
|
||||
def _run(args,timeout=2):
|
||||
try: return subprocess.check_output(args,text=True,stderr=subprocess.DEVNULL,timeout=timeout).strip()
|
||||
except Exception: return ""
|
||||
@@ -132,8 +157,9 @@ def primary_network():
|
||||
if not dev:
|
||||
out=_run(["ip","-6","route","get","2606:4700:4700::1111"])
|
||||
dev=re.search(r"\bdev\s+(\S+)",out); src=re.search(r"\bsrc\s+(\S+)",out); label="IP6"
|
||||
if not dev: return "","NET","DOWN","IP4","NO IP"
|
||||
if not dev: return "","NET","DOWN",None,"IP4","NO IP"
|
||||
iface=dev.group(1); ipval=src.group(1) if src else ""
|
||||
speed_mbps=None
|
||||
if Path(f"/sys/class/net/{iface}/wireless").exists():
|
||||
kind="WIF"; metric="--"
|
||||
try:
|
||||
@@ -145,12 +171,13 @@ def primary_network():
|
||||
kind="ETH"; metric="UP"
|
||||
try:
|
||||
n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip())
|
||||
speed_mbps=n if n>0 else None
|
||||
metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n)
|
||||
except Exception: pass
|
||||
return iface,kind,metric,label,(ipval or "NO IP")
|
||||
return iface,kind,metric,speed_mbps,label,(ipval or "NO IP")
|
||||
|
||||
def get_ip():
|
||||
return primary_network()[4]
|
||||
return primary_network()[5]
|
||||
|
||||
def internet():
|
||||
try:
|
||||
@@ -172,6 +199,7 @@ def load_cfg():
|
||||
if target and not target.startswith("#"): watches.append(("process",label.upper(),target))
|
||||
return c,watches
|
||||
cfg,watches=load_cfg()
|
||||
storage_inventory=StorageInventory(cache_path=Path(CFG).with_suffix(".storage.json"),capacity_path="/run/pigway-pi-control/storage-inventory-usage.json")
|
||||
|
||||
def cf(section,key,default,cast=float):
|
||||
try: return cast(cfg.get(section,key))
|
||||
@@ -186,6 +214,8 @@ SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
||||
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
||||
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
||||
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
|
||||
STARTUP_GRACE_SECONDS=max(0.0,cf("timing","startup_grace_seconds",15.0))
|
||||
NETWORK_FAILURE_SECONDS=max(0.0,cf("timing","network_failure_seconds",15.0))
|
||||
|
||||
CPU_HIGH=cf("alerts","cpu_high",90.0)
|
||||
CPU_RECOVER=cf("alerts","cpu_recover",75.0)
|
||||
@@ -200,6 +230,8 @@ DISK_CRIT=cf("alerts","disk_critical",95.0)
|
||||
WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
|
||||
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
||||
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
|
||||
ETHERNET_MIN_SPEED=cf("alerts","ethernet_min_speed_mbps",1000,int)
|
||||
ETHERNET_SPEED_SECONDS=cf("alerts","ethernet_speed_seconds",15.0)
|
||||
|
||||
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
@@ -210,7 +242,7 @@ ALERT_CATEGORIES={
|
||||
"POWER_UNDERVOLT":"POWER",
|
||||
"MEMORY_HIGH":"MEMORY",
|
||||
"DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE",
|
||||
"NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK",
|
||||
"NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK","ETHERNET_SPEED_LOW":"NETWORK",
|
||||
"SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE",
|
||||
}
|
||||
|
||||
@@ -238,9 +270,9 @@ def diagnose():
|
||||
"under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"):
|
||||
print(f"{key}={power.get(key,'UNKNOWN')}")
|
||||
if "error" in power: print("error="+power["error"])
|
||||
iface,kind,metric,label,address=primary_network()
|
||||
iface,kind,metric,speed,label,address=primary_network()
|
||||
print("Network:")
|
||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} link_speed_mbps={speed if speed is not None else 'N/A'} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
||||
print("Plugins (read-only API discovery; no hardware access):")
|
||||
for plugin in discover(cfg):
|
||||
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
|
||||
@@ -267,6 +299,7 @@ ALERT_PRIORITIES={
|
||||
"MEMORY_HIGH":45,
|
||||
"DISK_HIGH":40,
|
||||
"WIFI_WEAK":30,
|
||||
"ETHERNET_SPEED_LOW":35,
|
||||
"NORMAL_HOME":0,
|
||||
}
|
||||
|
||||
@@ -280,7 +313,7 @@ def transition(key,active,on_event,off_event,**fields):
|
||||
log("WARN" if active else "INFO",on_event if active else off_event,**fields)
|
||||
|
||||
def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
||||
global wifi_weak_since,wifi_weak_active
|
||||
global wifi_weak_since,wifi_weak_active,ethernet_slow_since,ethernet_slow_active
|
||||
global cpu_hi,mem_hi
|
||||
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
|
||||
mem_hi=mem_hi+sample_elapsed if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
|
||||
@@ -296,6 +329,7 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
||||
alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
|
||||
if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
||||
else: alert_manager.clear("MEMORY_HIGH")
|
||||
if disk is not None:
|
||||
if disk>=DISK_CRIT:
|
||||
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
||||
alert_manager.clear("DISK_HIGH")
|
||||
@@ -331,6 +365,21 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
||||
else: alert_manager.clear("WIFI_WEAK")
|
||||
else:
|
||||
wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK")
|
||||
if net_kind=="ETH" and net_speed_mbps is not None and ETHERNET_MIN_SPEED>0:
|
||||
if not ethernet_slow_active:
|
||||
if net_speed_mbps < ETHERNET_MIN_SPEED:
|
||||
if ethernet_slow_since is None: ethernet_slow_since=time.monotonic()
|
||||
if time.monotonic()-ethernet_slow_since >= ETHERNET_SPEED_SECONDS: ethernet_slow_active=True
|
||||
else: ethernet_slow_since=None
|
||||
elif net_speed_mbps >= ETHERNET_MIN_SPEED:
|
||||
ethernet_slow_active=False; ethernet_slow_since=None
|
||||
if ethernet_slow_active:
|
||||
alert_manager.request("ETHERNET_SPEED_LOW",1,"ETHERNET SLOW",f"LINK {net_speed_mbps} Mbps",f"EXPECT {ETHERNET_MIN_SPEED}+","CHECK CABLE")
|
||||
else: alert_manager.clear("ETHERNET_SPEED_LOW")
|
||||
else:
|
||||
ethernet_slow_since=None; ethernet_slow_active=False; alert_manager.clear("ETHERNET_SPEED_LOW")
|
||||
transition("ethernet_speed_low",ethernet_slow_active,"ETHERNET_SPEED_LOW","ETHERNET_SPEED_RECOVERED",
|
||||
iface=net_iface,speed_mbps=net_speed_mbps,minimum_mbps=ETHERNET_MIN_SPEED)
|
||||
if health.get("under_voltage_now"):
|
||||
alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
||||
else: alert_manager.clear("POWER_UNDERVOLT")
|
||||
@@ -378,22 +427,39 @@ def cleanup():
|
||||
def write_status(cpu,temp,mem,disk):
|
||||
display_state=alert_manager.status_snapshot()
|
||||
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
|
||||
disk_total,disk_used,disk_free=disk_usage("/")
|
||||
storage=storage_cache.sample()
|
||||
disks=storage_inventory.sample(cfg)
|
||||
disk_total,disk_used,disk_free=(storage[k] for k in ("total","used","free")) if storage else (None,None,None)
|
||||
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
||||
state={
|
||||
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
|
||||
"version":APP_VERSION,"timestamp":int(time.time()),
|
||||
"collection":{"interval_seconds":min(1.0,SENSOR_INTERVAL,NETWORK_INTERVAL,SERVICE_INTERVAL,HEALTH_INTERVAL),
|
||||
"refresh":{
|
||||
"storage_inventory":{"interval_seconds":604800,"observed_at":storage_inventory.observed_at,"next_update_at":storage_inventory.next_update_at,"error":storage_inventory.error},
|
||||
"system":{"interval_seconds":SENSOR_INTERVAL,"observed_at":sensor_observed_at,"next_update_at":sensor_observed_at+SENSOR_INTERVAL if sensor_observed_at else None},
|
||||
"network":{"interval_seconds":NETWORK_INTERVAL,"observed_at":network_observed_at,"next_update_at":network_observed_at+NETWORK_INTERVAL if network_observed_at else None},
|
||||
"storage":{"interval_seconds":604800,"observed_at":storage_cache.observed_at,"next_update_at":storage_cache.next_update_at,"error":storage_cache.error},
|
||||
"display":{"interval_seconds":1,"next_update_at":time.time()+1},
|
||||
"alerts":{"interval_seconds":1,"next_update_at":time.time()+1}}},"uptime_seconds":uptime_seconds(),
|
||||
"machine":{"id":DEVICE_ID,"name":DEVICE_NAME,"hostname":socket.gethostname()},
|
||||
"device":DEVICE_INFO,
|
||||
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
|
||||
"system":{"observed_at":sensor_observed_at,"load_average":load_average(),"cpu_percent":round(cpu,1),
|
||||
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
||||
"memory_percent":round(memory_calculated if memory_total else mem,1),
|
||||
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
|
||||
"memory_available_bytes":memory_available,
|
||||
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
|
||||
"storage_observed_at":storage_cache.observed_at,
|
||||
"storage_inventory":disks,"storage_inventory_observed_at":storage_inventory.observed_at,
|
||||
"storage_inventory_error":storage_inventory.error,
|
||||
"storage_scanned_at":storage_inventory.scanned_at,"storage_discovery_mode":"manual",
|
||||
"disk_percent":round(disk,1) if disk is not None else None,"disk_total_bytes":disk_total,
|
||||
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
||||
"root_readonly":bool(fs_readonly)},
|
||||
"power":health,
|
||||
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||
"network":{"observed_at":network_observed_at,"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||
"link_speed_mbps":net_speed_mbps,
|
||||
"rx_bytes_per_second":net_rx_bytes_per_second,
|
||||
"tx_bytes_per_second":net_tx_bytes_per_second,
|
||||
"signal_percent":signal,
|
||||
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)},
|
||||
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
||||
@@ -417,6 +483,7 @@ if args.diagnose: raise SystemExit(diagnose())
|
||||
watch_state=[]
|
||||
last_sensor=last_status=0.0
|
||||
last_net=last_watch=last_health=0.0
|
||||
monitor_ready_at=time.monotonic()+STARTUP_GRACE_SECONDS
|
||||
health={}; fs_readonly=False
|
||||
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0}
|
||||
event_state={}
|
||||
@@ -437,11 +504,27 @@ try:
|
||||
if now-last_sensor >= SENSOR_INTERVAL:
|
||||
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
||||
latest=(c,t,m,d); last_sensor=now; agent["last_sensor_ok"]=now
|
||||
latest=(c,t,m,d); sensor_observed_at=int(time.time()); last_sensor=now; agent["last_sensor_ok"]=now
|
||||
|
||||
if now-last_net >= NETWORK_INTERVAL:
|
||||
if now>=monitor_ready_at and now-last_net >= NETWORK_INTERVAL:
|
||||
old_id=(net_iface,ip)
|
||||
net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False
|
||||
net_iface,net_kind,net_metric,net_speed_mbps,ip_label,ip=primary_network()
|
||||
net_rx_bytes_per_second,net_tx_bytes_per_second=network_rates.sample(net_iface,now)
|
||||
network_observed_at=int(time.time())
|
||||
raw_link_ok=ip!="NO IP"; raw_net_ok=internet() if raw_link_ok else False
|
||||
if raw_link_ok:
|
||||
link_failure_since=None;link_ok=True
|
||||
else:
|
||||
if link_failure_since is None:link_failure_since=now
|
||||
link_ok=now-link_failure_since>=NETWORK_FAILURE_SECONDS
|
||||
link_ok=not link_ok
|
||||
if not raw_link_ok:
|
||||
internet_failure_since=None;net_ok=True
|
||||
elif raw_net_ok:
|
||||
internet_failure_since=None;net_ok=True
|
||||
else:
|
||||
if internet_failure_since is None:internet_failure_since=now
|
||||
net_ok=not (now-internet_failure_since>=NETWORK_FAILURE_SECONDS)
|
||||
new_id=(net_iface,ip)
|
||||
if new_id!=old_id:
|
||||
log("INFO","NET_SWITCH",old_if=old_id[0],old_ip=old_id[1],new_if=net_iface,new_ip=ip)
|
||||
@@ -449,7 +532,7 @@ try:
|
||||
transition("internet_down",link_ok and not net_ok,"INTERNET_DOWN","INTERNET_RECOVERED",iface=net_iface,ip=ip)
|
||||
last_net=now; agent["last_network_ok"]=now
|
||||
|
||||
if now-last_watch >= SERVICE_INTERVAL:
|
||||
if now>=monitor_ready_at and now-last_watch >= SERVICE_INTERVAL:
|
||||
watch_state=[check_watch(w) for w in watches]
|
||||
for w,ok in zip(watches,watch_state):
|
||||
transition("watch:"+w[0]+":"+w[1],not ok,"WATCH_DOWN","WATCH_RECOVERED",name=w[1],target=w[2])
|
||||
|
||||
+73
-167
@@ -7,15 +7,15 @@ from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
STORAGE_SCAN_LOCK=threading.Lock()
|
||||
from urllib.parse import parse_qs,urlencode,urlparse
|
||||
from urllib.request import Request,urlopen
|
||||
|
||||
APP_VERSION="3.7.0"
|
||||
APP_VERSION="3.11.2"
|
||||
CFG=Path("/etc/pigway-pi-control.conf")
|
||||
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
||||
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
|
||||
ALLOWED_SECTIONS={"device","services","processes","alerts","timing","dark_mode","api","storage_names","storage_visible"}
|
||||
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
||||
@@ -65,60 +65,6 @@ def machine_identity():
|
||||
if name.lower()=="auto": name=identifier
|
||||
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
|
||||
|
||||
def normalize_url(value):
|
||||
parsed=urlparse(str(value).strip())
|
||||
if parsed.scheme not in {"http","https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise ValueError("host URL must be an http(s) URL without credentials")
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
|
||||
def host_registry():
|
||||
local=machine_identity(); result=[local]
|
||||
cfg=load_cfg()
|
||||
if cfg.has_section("hosts"):
|
||||
for host_id,url in cfg.items("hosts"):
|
||||
try: base=normalize_url(url)
|
||||
except ValueError: continue
|
||||
if host_id==local["id"]: continue
|
||||
result.append({"id":host_id,"name":host_id,"hostname":"","local":False,"url":base})
|
||||
return result
|
||||
|
||||
def find_host(host_id):
|
||||
for host in host_registry():
|
||||
if host["id"]==host_id:return host
|
||||
raise ValueError("unknown host")
|
||||
|
||||
def remote_json(base,path,method="GET",payload=None,token=""):
|
||||
data=None if payload is None else json.dumps(payload,separators=(",",":")).encode()
|
||||
headers={"Accept":"application/json"}
|
||||
if data is not None: headers["Content-Type"]="application/json"
|
||||
if token: headers["Authorization"]=f"Bearer {token}"
|
||||
request=Request(base+path,data=data,headers=headers,method=method)
|
||||
with urlopen(request,timeout=5) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
def fleet_status():
|
||||
hosts=host_registry(); results=[]
|
||||
try:
|
||||
status=json.loads(STATUS.read_text())
|
||||
status.setdefault("machine",machine_identity())
|
||||
results.append({"host":hosts[0],"online":True,"status":status})
|
||||
except Exception as exc:
|
||||
results.append({"host":hosts[0],"online":False,"error":str(exc)})
|
||||
def fetch(host):
|
||||
try:
|
||||
status=remote_json(host["url"],"/api/v1/status")
|
||||
machine=status.get("machine",{})
|
||||
merged={**host,"name":machine.get("name") or host["name"],"hostname":machine.get("hostname","")}
|
||||
return {"host":merged,"online":True,"status":status}
|
||||
except Exception as exc:return {"host":host,"online":False,"error":str(exc)}
|
||||
peers=hosts[1:]
|
||||
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
|
||||
futures=[pool.submit(fetch,h) for h in peers]
|
||||
for future in as_completed(futures): results.append(future.result())
|
||||
order={h["id"]:i for i,h in enumerate(hosts)}
|
||||
results.sort(key=lambda x:order.get(x["host"]["id"],999))
|
||||
return {"hosts":results,"timestamp":int(time.time())}
|
||||
|
||||
def load_cfg():
|
||||
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||
return cfg
|
||||
@@ -129,11 +75,21 @@ def api_setting(key,default,cast=str):
|
||||
|
||||
def config_json():
|
||||
cfg=load_cfg()
|
||||
return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||
return {section:{k:v for k,v in cfg.items(section,raw=True) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||
|
||||
def validate_value(section,key,value,existing):
|
||||
if section in {"storage_names","storage_visible"}:
|
||||
if not re.fullmatch(r"[a-f0-9]{32}",key):raise ValueError("invalid storage identifier")
|
||||
if section=="storage_visible":
|
||||
if value.lower() not in {"true","false"}:raise ValueError("visibility must be true or false")
|
||||
elif len(value)>64 or any(ord(c)<32 for c in value):
|
||||
raise ValueError("storage name must be at most 64 characters without control characters")
|
||||
return
|
||||
if section=="api" and key=="enabled":
|
||||
if value.lower() not in {"true","false"}:raise ValueError("enabled must be true or false")
|
||||
return
|
||||
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
|
||||
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
||||
if section not in {"services","processes"} and key not in existing.get(section,set()):
|
||||
raise ValueError(f"unsupported option: {section}.{key}")
|
||||
if section=="hardware" and key=="cooling_hat_enabled":
|
||||
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
|
||||
@@ -163,16 +119,17 @@ def validate_value(section,key,value,existing):
|
||||
ipaddress.ip_address(value)
|
||||
elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value):
|
||||
raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
|
||||
elif section=="hosts":
|
||||
normalize_url(value)
|
||||
|
||||
|
||||
def replace_ini_value(section,key,value):
|
||||
text=CFG.read_text()
|
||||
return replace_ini_text(CFG.read_text(),section,key,value)
|
||||
|
||||
def replace_ini_text(text,section,key,value):
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return text
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
@@ -189,15 +146,6 @@ def save_ini_text(text,prefix="api"):
|
||||
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||
return str(backup)
|
||||
|
||||
def edit_host(operation,host_id,url=""):
|
||||
if operation not in {"save","delete"}: raise ValueError("invalid host operation")
|
||||
if not NAME_RE.fullmatch(host_id): raise ValueError("invalid host identifier")
|
||||
local=machine_identity()["id"]
|
||||
if host_id==local: raise ValueError("local host cannot be changed in the remote host list")
|
||||
if operation=="save": url=normalize_url(url)
|
||||
text=replace_ini_value("hosts",host_id,url if operation=="save" else None)
|
||||
return save_ini_text(text,"hosts")
|
||||
|
||||
def update_ini(updates):
|
||||
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
||||
text=CFG.read_text()
|
||||
@@ -217,7 +165,7 @@ def update_ini(updates):
|
||||
if not section_match:
|
||||
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
continue
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
|
||||
@@ -268,50 +216,6 @@ def journal_query(since=None,until=None,severity="",event="",search="",sort="tim
|
||||
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
||||
"event_types":event_types,"retention":"systemd-journal"}
|
||||
|
||||
def fleet_journal_query(since=None,until=None,severity="",event="",search="",machine="",
|
||||
sort="timestamp",order="desc",limit=100,offset=0):
|
||||
hosts=host_registry(); rows=[]; event_types=set(); machines=[]
|
||||
def decorate(payload,host):
|
||||
machine_info=payload.get("machine") or {"id":host["id"],"name":host["name"]}
|
||||
host_id=machine_info.get("id") or host["id"]
|
||||
host_name=machine_info.get("name") or host["name"]
|
||||
decorated=[]
|
||||
for row in payload.get("logs",[]):
|
||||
decorated.append({**row,"machine_id":host_id,"machine_name":host_name})
|
||||
return decorated,set(payload.get("event_types",[])),{"id":host_id,"name":host_name}
|
||||
local_payload=journal_query(since,until,severity,event,search,"timestamp","desc",50000,0)
|
||||
local_payload["machine"]=machine_identity()
|
||||
local_rows,local_events,local_machine=decorate(local_payload,hosts[0])
|
||||
rows.extend(local_rows); event_types.update(local_events); machines.append(local_machine)
|
||||
query={"limit":50000,"offset":0,"sort":"timestamp","order":"desc"}
|
||||
for key,value in (("since",since),("until",until),("severity",severity),("event",event),("search",search)):
|
||||
if value not in (None,""): query[key]=value
|
||||
def fetch(host):
|
||||
payload=remote_json(host["url"],"/api/v1/logs?"+urlencode(query))
|
||||
try:
|
||||
status=remote_json(host["url"],"/api/v1/status")
|
||||
payload["machine"]=status.get("machine",{})
|
||||
except Exception: pass
|
||||
return decorate(payload,host)
|
||||
peers=hosts[1:]
|
||||
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
|
||||
futures=[pool.submit(fetch,h) for h in peers]
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
remote_rows,remote_events,remote_machine=future.result()
|
||||
rows.extend(remote_rows); event_types.update(remote_events); machines.append(remote_machine)
|
||||
except Exception: pass
|
||||
if machine: rows=[row for row in rows if row["machine_id"]==machine]
|
||||
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
|
||||
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
|
||||
"event":lambda x:x["event"],"message":lambda x:x["message"],
|
||||
"machine":lambda x:(x["machine_name"],x["machine_id"])}
|
||||
rows.sort(key=keys[sort],reverse=order=="desc")
|
||||
total=len(rows)
|
||||
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
||||
"event_types":sorted(event_types),"machines":sorted(machines,key=lambda x:(x["name"],x["id"])),
|
||||
"retention":"systemd-journal"}
|
||||
|
||||
def service_inventory():
|
||||
cfg=load_cfg()
|
||||
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
|
||||
@@ -356,7 +260,7 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
@@ -384,6 +288,34 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
return str(backup)
|
||||
|
||||
def process_monitors():
|
||||
cfg=load_cfg(); result=[]
|
||||
for name,target in cfg.items("processes"):
|
||||
target=target.strip()
|
||||
if not target: continue
|
||||
up=subprocess.run(["pgrep","-f","--",target],stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=2).returncode==0
|
||||
result.append({"name":name.upper(),"target":target,"up":up})
|
||||
return result
|
||||
|
||||
def edit_process_monitor(operation,name,target="",previous_name=""):
|
||||
if operation not in {"save","delete"}: raise ValueError("invalid process monitor operation")
|
||||
if not NAME_RE.fullmatch(name): raise ValueError("invalid process monitor name")
|
||||
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous process monitor name")
|
||||
if operation=="save" and (not target or len(target)>256 or any(c in target for c in "\r\n\x00")):
|
||||
raise ValueError("invalid process match expression")
|
||||
text=CFG.read_text()
|
||||
if operation=="delete":
|
||||
if not load_cfg().has_option("processes",name): raise ValueError("process monitor not found")
|
||||
text=replace_ini_text(text,"processes",name,None)
|
||||
else:
|
||||
if previous_name and previous_name.lower()!=name.lower(): text=replace_ini_text(text,"processes",previous_name,None)
|
||||
text=replace_ini_text(text,"processes",name,target)
|
||||
parsed=configparser.ConfigParser(); parsed.read_string(text)
|
||||
backup=save_ini_text(text,"api")
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
return backup
|
||||
|
||||
def control_service(unit,action):
|
||||
if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name")
|
||||
if action not in {"start","stop"}: raise ValueError("action must be start or stop")
|
||||
@@ -422,19 +354,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed=urlparse(self.path)
|
||||
try:
|
||||
if parsed.path=="/":
|
||||
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
|
||||
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
|
||||
if not self.authorized():self.send_json(401,{"error":"bearer token required"});return
|
||||
if parsed.path=="/api/v1/health":
|
||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
||||
if parsed.path=="/api/v1/hosts":
|
||||
self.send_json(200,{"hosts":host_registry()}); return
|
||||
if parsed.path=="/api/v1/fleet/status":
|
||||
self.send_json(200,fleet_status()); return
|
||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()});return
|
||||
if parsed.path=="/api/v1/status":
|
||||
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
||||
self.send_json(200,json.loads(STATUS.read_text())); return
|
||||
if parsed.path in {"/api/v1/logs","/api/v1/fleet/logs"}:
|
||||
if parsed.path=="/api/v1/logs":
|
||||
query=parse_qs(parsed.query)
|
||||
value=lambda key,default="":query.get(key,[default])[0]
|
||||
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
|
||||
@@ -444,78 +370,51 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if since is not None and until is not None and since>until: raise ValueError("since must not be after until")
|
||||
sort=value("sort","timestamp"); order=value("order","desc")
|
||||
allowed_sort={"timestamp","severity","event","message"}
|
||||
if parsed.path=="/api/v1/fleet/logs": allowed_sort.add("machine")
|
||||
if sort not in allowed_sort: raise ValueError("invalid log sort")
|
||||
if order not in {"asc","desc"}: raise ValueError("invalid log order")
|
||||
severity=value("severity").upper(); event=value("event"); search=value("search")
|
||||
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
|
||||
if parsed.path=="/api/v1/fleet/logs":
|
||||
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
|
||||
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
||||
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
|
||||
plugin_id=query.get("id",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id)
|
||||
self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return
|
||||
if parsed.path.endswith("/plugins"):
|
||||
self.send_json(200,{"plugins":plugin_inventory()});return
|
||||
item=find_plugin(plugin_id)
|
||||
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
|
||||
if parsed.path=="/api/v1/config":
|
||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/config")); return
|
||||
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
|
||||
if parsed.path=="/api/v1/services":
|
||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/services")); return
|
||||
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
|
||||
if parsed.path=="/api/v1/processes":
|
||||
self.send_json(200,{"processes":process_monitors(),"machine":machine_identity()}); return
|
||||
self.send_json(404,{"error":"not found"})
|
||||
except ValueError as e: self.send_json(400,{"error":str(e)})
|
||||
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||
|
||||
def do_PUT(self):
|
||||
path=urlparse(self.path).path
|
||||
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
|
||||
if path=="/api/v1/storage/scan":
|
||||
if not self.authorized():self.send_json(401,{"error":"bearer token required"});return
|
||||
try:
|
||||
from storage_cache import StorageInventory
|
||||
with STORAGE_SCAN_LOCK:
|
||||
result=StorageInventory(cache_path=CFG.with_suffix(".storage.json")).scan()
|
||||
self.send_json(200,result)
|
||||
except Exception:
|
||||
self.send_json(503,{"error":"storage scan failed; previous inventory retained"})
|
||||
return
|
||||
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/processes"}: self.send_json(404,{"error":"not found"}); return
|
||||
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||
try:
|
||||
body=self.read_json()
|
||||
if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
host_id=str(body.get("host",""))
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id);token=str(body.get("target_token",""))
|
||||
if not token:raise ValueError("remote bearer token required")
|
||||
payload={k:v for k,v in body.items() if k not in {"host","target_token"}}
|
||||
self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return
|
||||
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
|
||||
item=find_plugin(str(body.get("id","")))
|
||||
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
|
||||
if path=="/api/v1/hosts":
|
||||
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
|
||||
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
|
||||
if path=="/api/v1/fleet/config":
|
||||
updates=body.get("updates"); targets=body.get("targets",[]); tokens=body.get("tokens",{})
|
||||
if not isinstance(targets,list) or not targets or len(targets)>32: raise ValueError("targets must contain 1..32 host identifiers")
|
||||
if not isinstance(tokens,dict): raise ValueError("tokens must be an object")
|
||||
results=[]; local_id=machine_identity()["id"]; restart_local_api=False
|
||||
for host_id in dict.fromkeys(str(x) for x in targets):
|
||||
try:
|
||||
if host_id==local_id:
|
||||
backup=update_ini(updates)
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
restart_local_api="api" in updates
|
||||
results.append({"id":host_id,"ok":True,"backup":backup})
|
||||
else:
|
||||
host=find_host(host_id); token=str(tokens.get(host_id,""))
|
||||
if not token: raise ValueError("remote bearer token required")
|
||||
response=remote_json(host["url"],"/api/v1/config","PUT",{"updates":updates},token)
|
||||
results.append({"id":host_id,"ok":True,"backup":response.get("backup","")})
|
||||
except Exception as exc: results.append({"id":host_id,"ok":False,"error":str(exc)})
|
||||
self.send_json(200,{"ok":all(x["ok"] for x in results),"results":results})
|
||||
if restart_local_api: threading.Timer(0.2,self.server.shutdown).start()
|
||||
return
|
||||
if path=="/api/v1/services/control":
|
||||
control_service(str(body.get("unit","")),str(body.get("action","")))
|
||||
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
|
||||
@@ -526,6 +425,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
str(body.get("previous_name","")))
|
||||
print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
|
||||
self.send_json(200,{"ok":True,"backup":backup}); return
|
||||
if path=="/api/v1/processes":
|
||||
backup=edit_process_monitor(str(body.get("operation","")),str(body.get("name","")),
|
||||
str(body.get("target","")),str(body.get("previous_name","")))
|
||||
print(f"PROCESS_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
|
||||
self.send_json(200,{"ok":True,"backup":backup}); return
|
||||
updates=body.get("updates")
|
||||
backup=update_ini(updates)
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
@@ -537,6 +441,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||
|
||||
def main():
|
||||
if not load_cfg().getboolean("api","enabled",fallback=False):
|
||||
print("API_DISABLED",flush=True);sys.exit(78)
|
||||
host=api_setting("bind","0.0.0.0")
|
||||
port=api_setting("port",6001,int)
|
||||
server=ThreadingHTTPServer((host,port),Handler)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Daily filesystem capacity cache, persisted in RAM across monitor restarts."""
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from shutil import disk_usage
|
||||
|
||||
class StorageInventory:
|
||||
"""Read host block devices without mounting, probing SMART, or writing disks."""
|
||||
|
||||
def __init__(self, clock=time.time, runner=None, mounts_path="/proc/1/mountinfo", usage=None, cache_path=None, capacity_path=None):
|
||||
import subprocess
|
||||
self.clock = clock
|
||||
self.runner = runner or (lambda: subprocess.check_output(
|
||||
["lsblk", "--json", "--bytes", "--paths", "--output",
|
||||
"NAME,KNAME,TYPE,SIZE,FSTYPE,UUID,LABEL,MODEL,SERIAL,MAJ:MIN"],
|
||||
text=True, timeout=5, stderr=subprocess.DEVNULL))
|
||||
self.mounts_path = Path(mounts_path)
|
||||
self.usage = usage or (lambda mount: disk_usage("/proc/1/root" + mount))
|
||||
self.next_update_at = 0
|
||||
self.observed_at = None
|
||||
self.error = False
|
||||
self.devices = []
|
||||
self.cache_path = Path(cache_path) if cache_path else None
|
||||
self.scanned_at = None
|
||||
self.capacity_path = Path(capacity_path) if capacity_path else None
|
||||
if self.capacity_path:
|
||||
try:
|
||||
saved=json.loads(self.capacity_path.read_text())
|
||||
at=saved['observed_at']
|
||||
if isinstance(at,(int,float)) and 0 <= self.clock()-at < 604800 and isinstance(saved['devices'],list):
|
||||
self.devices=saved['devices'];self.observed_at=at
|
||||
self.scanned_at=saved['scanned_at'];self.next_update_at=at+604800
|
||||
except (OSError,ValueError,KeyError,TypeError):pass
|
||||
|
||||
def scan(self):
|
||||
if self.cache_path is None:
|
||||
raise ValueError("storage inventory cache path required")
|
||||
try:
|
||||
previous = json.loads(self.cache_path.read_text())
|
||||
if 0 <= self.clock() - previous["scanned_at"] < 60:
|
||||
return {"accepted": True, "scanned_at": previous["scanned_at"], "cooldown": True}
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
data = json.loads(self.runner())
|
||||
self.discover(data)
|
||||
document = {"scanned_at": self.clock(), "inventory": data}
|
||||
temporary = self.cache_path.with_suffix(".tmp")
|
||||
with open(temporary,"w",opener=lambda path, flags: __import__("os").open(path, flags, 0o600)) as file:
|
||||
json.dump(document, file)
|
||||
temporary.replace(self.cache_path)
|
||||
return {"accepted": True, "scanned_at": document["scanned_at"], "cooldown": False}
|
||||
|
||||
def sample(self, config=None):
|
||||
data = None
|
||||
changed = False
|
||||
if self.cache_path is not None:
|
||||
try:
|
||||
document = json.loads(self.cache_path.read_text())
|
||||
changed = document["scanned_at"] != self.scanned_at
|
||||
data = document["inventory"]
|
||||
self.scanned_at = document["scanned_at"]
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
self.error = True
|
||||
if data is not None and (changed or self.clock() >= self.next_update_at):
|
||||
try:
|
||||
self.devices = self.discover(data)
|
||||
self.observed_at = self.clock()
|
||||
self.error = False
|
||||
except (OSError, ValueError, TypeError, KeyError, __import__("subprocess").SubprocessError):
|
||||
self.error = True
|
||||
self.next_update_at = self.clock() + (3600 if self.error else 604800)
|
||||
if self.capacity_path and not self.error:
|
||||
try:
|
||||
self.capacity_path.write_text(json.dumps(dict(devices=self.devices,observed_at=self.observed_at,scanned_at=self.scanned_at)))
|
||||
except OSError:pass
|
||||
names = dict(config.items("storage_names", raw=True)) if config is not None and config.has_section("storage_names") else {}
|
||||
visible = dict(config.items("storage_visible", raw=True)) if config is not None and config.has_section("storage_visible") else {}
|
||||
result = []
|
||||
for device in self.devices:
|
||||
item = dict(device)
|
||||
item["name"] = names.get(item["id"], "auto")
|
||||
if item["name"].lower() == "auto":
|
||||
item["name"] = item["label"] or item["mountpoint"] or item["device"]
|
||||
item["visible"] = visible.get(item["id"], "true").lower() != "false"
|
||||
if self.error:
|
||||
item["state"] = "unknown"
|
||||
result.append(item)
|
||||
known = {d["id"] for d in result}
|
||||
for identifier in sorted((names.keys() | visible.keys()) - known):
|
||||
result.append(dict(id=identifier, name=names.get(identifier, identifier),
|
||||
visible=visible.get(identifier, "true").lower() != "false",
|
||||
state="unknown" if self.error else "missing", device="", mountpoint="",
|
||||
label="", capacity_bytes=None, used_bytes=None, free_bytes=None,
|
||||
used_percent=None))
|
||||
return result
|
||||
|
||||
def discover(self, data):
|
||||
import hashlib
|
||||
import re
|
||||
mounts, sources = {}, {}
|
||||
for line in self.mounts_path.read_text().splitlines():
|
||||
left, right = line.split(" - ", 1)
|
||||
fields = left.split()
|
||||
# Bind mounts and Btrfs subvolumes must not duplicate filesystem capacity.
|
||||
mount = re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), fields[4])
|
||||
row = (fields[3] != "/", len(mount), mount)
|
||||
mounts.setdefault(fields[2], []).append(row)
|
||||
source = right.split()[1]
|
||||
if source.startswith("/dev/"):
|
||||
sources.setdefault(str(Path(source).resolve()), []).append(row)
|
||||
devices = data["blockdevices"]
|
||||
if not isinstance(devices, list):
|
||||
raise ValueError("invalid block device inventory")
|
||||
result, seen = [], set()
|
||||
|
||||
def walk(device, serial="", parent=""):
|
||||
kind = device.get("type")
|
||||
if kind in {"loop", "rom", "zram"}:
|
||||
return
|
||||
serial = str(device.get("serial") or serial).strip()
|
||||
children = device.get("children") or []
|
||||
uuid = str(device.get("uuid") or "").strip()
|
||||
fs = str(device.get("fstype") or "")
|
||||
# A parent disk with partitions/RAID/LVM is represented by its usable leaves.
|
||||
if not children and kind not in {"loop", "rom"} and fs not in {"swap", "linux_raid_member", "LVM2_member", "crypto_LUKS"}:
|
||||
partition = re.search(r"(\d+)$", str(device.get("kname") or device.get("name") or ""))
|
||||
suffix = ":" + partition[1] if kind == "part" and partition else ""
|
||||
source = "uuid:" + uuid if uuid else "serial:" + serial + suffix if serial else "device:" + str(device.get("name"))
|
||||
identifier = hashlib.sha256(source.encode()).hexdigest()[:32]
|
||||
if identifier not in seen:
|
||||
seen.add(identifier)
|
||||
candidates = mounts.get(device.get("maj:min"), []) or sources.get(str(Path(device["name"]).resolve()), [])
|
||||
mount = sorted(candidates)[0][2] if candidates else ""
|
||||
item = dict(id=identifier, device=str(device.get("name") or ""),
|
||||
label=str(device.get("label") or ""), uuid=uuid,
|
||||
stable_identity=bool(uuid or serial), model=str(device.get("model") or "").strip(),
|
||||
mountpoint=mount, filesystem=fs, state="mounted" if mount else "unmounted",
|
||||
capacity_bytes=int(device.get("size") or 0), used_bytes=None,
|
||||
free_bytes=None, used_percent=None)
|
||||
if mount:
|
||||
try:
|
||||
usage = self.usage(mount)
|
||||
if usage.total <= 0:
|
||||
raise ValueError("invalid filesystem capacity")
|
||||
item.update(capacity_bytes=usage.total, used_bytes=usage.used,
|
||||
free_bytes=usage.free, used_percent=round(100 * usage.used / usage.total, 1))
|
||||
except (OSError, ValueError):
|
||||
item["state"] = "unknown"
|
||||
result.append(item)
|
||||
for child in children:
|
||||
walk(child, serial, str(device.get("name") or parent))
|
||||
|
||||
for device in devices:
|
||||
walk(device)
|
||||
return sorted(result, key=lambda d: (d["mountpoint"] != "/", d["mountpoint"], d["device"]))
|
||||
|
||||
|
||||
|
||||
class StorageCache:
|
||||
def __init__(self, path, reader=disk_usage, clock=time.time):
|
||||
self.path, self.reader, self.clock = Path(path), reader, clock
|
||||
self.values = None
|
||||
self.observed_at = None
|
||||
self.next_update_at = 0
|
||||
self.error = False
|
||||
try:
|
||||
data = json.loads(self.path.read_text())
|
||||
if not isinstance(data,dict):raise ValueError('invalid cache')
|
||||
retry = data.get('next_update_at')
|
||||
if data.get('error') is True and isinstance(retry,(int,float)) and self.clock() < retry <= self.clock()+3600:
|
||||
self.error, self.next_update_at = True, retry
|
||||
values = data['values']
|
||||
if all(isinstance(values[k], int) and not isinstance(values[k], bool) and values[k] >= 0 for k in ('total', 'used', 'free')) and values['total'] > 0 and values['used'] <= values['total']:
|
||||
at = data['observed_at']
|
||||
if isinstance(at, (int, float)) and 0 <= self.clock() - at < 90000:
|
||||
self.values, self.observed_at = values, at
|
||||
if not self.error:self.next_update_at = at + 604800
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
|
||||
def sample(self):
|
||||
now = self.clock()
|
||||
if now < self.next_update_at:
|
||||
return self.values
|
||||
try:
|
||||
usage = self.reader('/')
|
||||
if usage.total <= 0 or not 0 <= usage.used <= usage.total or usage.free < 0:
|
||||
raise ValueError('invalid capacity')
|
||||
except (OSError, ValueError):
|
||||
self.error = True
|
||||
self.next_update_at = now + 3600
|
||||
self.persist()
|
||||
return self.values
|
||||
self.values = dict(total=usage.total, used=usage.used, free=usage.free)
|
||||
self.observed_at = now
|
||||
self.next_update_at = now + 604800
|
||||
self.error = False
|
||||
self.persist()
|
||||
return self.values
|
||||
|
||||
def persist(self):
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps(dict(values=self.values, observed_at=self.observed_at,
|
||||
next_update_at=self.next_update_at, error=self.error)))
|
||||
except OSError:
|
||||
# The in-memory sample remains valid even if the RAM cache cannot be persisted.
|
||||
pass
|
||||
@@ -13,15 +13,10 @@ identifier = auto
|
||||
name = auto
|
||||
|
||||
|
||||
[hosts]
|
||||
# 在作为聚合入口的机器上登记其他节点,每行格式:
|
||||
# 唯一标识 = http://节点IP:6001
|
||||
# 示例:
|
||||
# living-room = http://192.168.1.20:6001
|
||||
|
||||
|
||||
[api]
|
||||
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
||||
# 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。
|
||||
enabled = false
|
||||
# API 监听地址和端口。0.0.0.0 允许局域网访问;
|
||||
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||
bind = 0.0.0.0
|
||||
port = 6001
|
||||
@@ -47,6 +42,14 @@ service_interval = 5
|
||||
# 推荐:1。当前电源状态及时更新告警与插件快照;历史状态仅记录一次日志。
|
||||
health_interval = 1
|
||||
|
||||
# 开机后等待网络与被监控服务完成启动,再开始网络/服务告警检查。
|
||||
# 这不会延迟温度、电源、磁盘等本机健康检查。
|
||||
startup_grace_seconds = 15
|
||||
|
||||
# 网络或互联网必须连续失败达到此时间才产生告警。
|
||||
# 成功样本会立即清除等待状态,避免开机和链路切换瞬间误报。
|
||||
network_failure_seconds = 15
|
||||
|
||||
|
||||
[alerts]
|
||||
# ---------------- CPU ----------------
|
||||
@@ -96,6 +99,11 @@ wifi_recover = -68
|
||||
# 弱信号持续多少秒才正式告警。
|
||||
wifi_weak_seconds = 30
|
||||
|
||||
# 有线网卡协商速率低于此值并持续 ethernet_speed_seconds 后告警,单位 Mbps。
|
||||
# Raspberry Pi 4/5 的千兆网口建议保持 1000;设为 0 可关闭速率告警。
|
||||
ethernet_min_speed_mbps = 1000
|
||||
ethernet_speed_seconds = 15
|
||||
|
||||
|
||||
[dark_mode]
|
||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||
@@ -136,11 +144,12 @@ pi_act_led = off
|
||||
# 修改后:
|
||||
# sudo systemctl restart pigway-pi-control
|
||||
#
|
||||
# 默认监控 SSH 和向日葵核心后台服务。
|
||||
# 默认只监控 Raspberry Pi OS 自带的 SSH 服务。
|
||||
# 向日葵、Tailscale 等可选服务应在实际安装后再显式添加,避免全新系统误报。
|
||||
SSH = ssh.service
|
||||
AWESUN = runawesun.service
|
||||
|
||||
# 以下仅为示例,默认不启用:
|
||||
# AWESUN = runawesun.service
|
||||
# TAILSCALE = tailscaled.service
|
||||
# DOCKER = docker.service
|
||||
# SAMBA = smbd.service
|
||||
@@ -174,3 +183,9 @@ AWESUN = runawesun.service
|
||||
#
|
||||
# 示例默认全部注释,不会监控任何普通进程。
|
||||
# MYAPP = /opt/myapp/server
|
||||
|
||||
# Use status.system.storage_inventory IDs, or edit through Console.
|
||||
# auto uses the filesystem label, mountpoint, or device path.
|
||||
[storage_names]
|
||||
[storage_visible]
|
||||
# <32-character ID> = false hides a disk while retaining collection.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Agent v3.8.0 网络流量
|
||||
|
||||
2026-10-14 Gateway 已部署。单独的 NetworkRates 模块读取主接口 sysfs 计数器;网络更新周期计算速率,写入已有状态/API。首次采样、接口切换、复位、读取失败不伪造零值。idle 返回真实 0。安装器加入模块依赖,保持 API 可选安装。采样测试覆盖初始、零流量、传输、复位、切换及失效;Python 编译、安装器语法检查通过。实机服务 active,状态版本 3.8.0、eth0 上传/下载数值有效,未发现本轮循环错误;Console 两设备在线,接入插件生成对应历史指标。
|
||||
|
||||
旧程序备份 `/home/way/pigway-agent-backup-v3.8.0-20261014/pigway-pi-control`,目录 0700。仅更新监控程序和新增模块并重启监控服务,没有变更硬件插件或 API 配置。
|
||||
@@ -0,0 +1,5 @@
|
||||
# pigway-raspberry-pi-agent v3.9.0
|
||||
|
||||
2026-10-15 已与 Console v2.4.0 部署。API 状态提供 collection.interval_seconds、system.observed_at 和 network.observed_at。Gateway 实际版本 3.9.0,状态采样时间与 1 秒/5 秒更新实测通过。旧程序在 /home/way/pigway-agent-backup-v3.9.0-20261015,目录 0700。
|
||||
|
||||
生产两设备在线。认证 API 连续观测确认树莓派缓存每秒、网络 5 秒、NAS 实时约 10 秒;最近三个网络 10 秒历史桶各 2 个样本,没有每秒重复入库。Console 保留原名称、证书、存储缓存和一年历史策略。部署详情见 Console [生产记录](https://tea.pigway.com/way/pigway-device-console/src/branch/main/docs/DEPLOYMENT-v2.4.0-2026-10-15.md)。
|
||||
@@ -79,3 +79,13 @@ fallback; actual execution still depends on functioning hardware.
|
||||
Stopping a monitor, pausing its link, or unlinking a plugin does not stop the
|
||||
plugin service. No API/Web service has a systemd Wants dependency that starts the
|
||||
other application. Do not run two drivers for the same MCU, GPIO or fan.
|
||||
|
||||
## Optional listeners
|
||||
|
||||
Agent network API defaults to disabled (`[api] enabled=false`) and has no Web UI.
|
||||
When enabled, every request requires a device bearer token.
|
||||
Hardware plugin integration defaults to disabled (`[integration] enabled=false`):
|
||||
no Unix API socket is created, while local telemetry and hardware workers continue.
|
||||
Change integration locally and restart the plugin. Monitor-to-plugin linkage does
|
||||
not require the Agent network API. The independent Web Manager stores registered
|
||||
device addresses and tokens; it is not implicitly a monitored device.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Raspberry Pi OS Lite (Debian 13 Trixie) fresh installation
|
||||
|
||||
English | [简体中文](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)
|
||||
|
||||
## Compatibility
|
||||
|
||||
PIGWay Raspberry Pi Agent v3.7.1 supports Raspberry Pi OS Lite 64-bit based on Debian 13 Trixie and Python 3.13. The Agent reads Linux `/proc`, `/sys`, systemd, network state, and journal data. It has no GPIO, I2C, Pillow, or third-party Python dependency and does not need a virtual environment.
|
||||
|
||||
The Yahboom RGB Cooling HAT is an independent plugin. It uses Debian packages `python3-smbus2` and `python3-pil`, avoiding pip changes to the PEP 668 managed system Python. Its verified interface remains unchanged:
|
||||
|
||||
- I2C bus `/dev/i2c-1`; I2C must be enabled. SPI is not required.
|
||||
- MCU `0x0D`; 128x32 SSD1306-compatible OLED `0x3C`.
|
||||
- The fan uses six levels through MCU register `0x08`. It is not a Raspberry Pi GPIO PWM fan and has no verified RPM feedback.
|
||||
- The plugin is the sole process owner of the MCU, OLED, RGB, and fan.
|
||||
|
||||
Do not guess or change GPIO pins for this board. Other GPIO/PWM fans require their own driver.
|
||||
|
||||
## Fresh installation
|
||||
|
||||
Check the system first:
|
||||
|
||||
```bash
|
||||
cat /etc/os-release
|
||||
python3 --version
|
||||
raspi-config nonint get_i2c
|
||||
ls -l /dev/i2c-1
|
||||
ss -ltnp
|
||||
```
|
||||
|
||||
`raspi-config nonint get_i2c` must return `0`. Enabling I2C changes boot configuration and normally requires a reboot, so confirm SSH key access and stable power first:
|
||||
|
||||
```bash
|
||||
sudo raspi-config nonint do_i2c 0
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
Install the Agent first. Its API is opt-in:
|
||||
|
||||
```bash
|
||||
git clone https://tea.pigway.com/way/pigway-raspberry-pi-agent.git
|
||||
cd pigway-raspberry-pi-agent
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
To use Device Console, verify the port and explicitly enable the API:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh --enable-api
|
||||
sudo ss -ltnp | grep ':6001 '
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
Install the hardware plugin after `/dev/i2c-1` is available:
|
||||
|
||||
```bash
|
||||
git clone https://tea.pigway.com/way/pigway-yahboom-cooling-hat.git
|
||||
cd pigway-yahboom-cooling-hat
|
||||
sudo ./install.sh --enable-integration
|
||||
```
|
||||
|
||||
Register its Unix socket under `[plugins]` in `/etc/pigway-pi-control.conf`, then restart only the Agent:
|
||||
|
||||
```ini
|
||||
yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Validate both systemd services, Agent diagnostics, boot journal, and plugin status. Physically verify the four-line OLED and 10-second rotation, RGB alerts, and temperature-dependent fan levels. Plugin shutdown requests full fan speed and driver close never writes fan OFF. Since the board has no tachometer feedback, requested level is not measured RPM.
|
||||
|
||||
The Agent API is disabled by default and the plugin uses a local Unix socket, so the default installation opens no TCP port and does not conflict with Tailscale or an API gateway.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Raspberry Pi OS Lite(Debian 13 Trixie)全新安装
|
||||
|
||||
[English](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md) | 简体中文
|
||||
|
||||
## 兼容性结论
|
||||
|
||||
PIGWay Raspberry Pi Agent v3.7.1 兼容 Raspberry Pi OS Lite 64-bit、Debian 13 Trixie 与 Python 3.13。Agent 只读取 Linux 的 `/proc`、`/sys`、systemd、网络状态和 journal,不依赖 GPIO、I²C、Pillow 或第三方 Python 包,也不需要 venv。
|
||||
|
||||
Yahboom RGB Cooling HAT 已拆分为独立插件。插件使用 Debian 系统包 `python3-smbus2` 与 `python3-pil`,不会通过 pip 修改受 PEP 668 管理的系统 Python。硬件接口保持原实现:
|
||||
|
||||
- I²C 总线:`/dev/i2c-1`,需要启用 I²C;不需要 SPI。
|
||||
- 控制 MCU:`0x0D`;OLED:`0x3C`,SSD1306 兼容 128×32。
|
||||
- 风扇通过 MCU 寄存器 `0x08` 的六档控制,不使用 Raspberry Pi GPIO PWM 引脚,也没有可验证的 RPM 反馈。
|
||||
- RGB 由 MCU 寄存器控制;插件是 MCU、OLED、RGB 与风扇的唯一进程所有者。
|
||||
|
||||
因此不能把这块散热板当作直连 GPIO 风扇,也不应猜测或修改 GPIO 引脚。其他 GPIO/PWM 风扇需要独立驱动。
|
||||
|
||||
## 安装前检查
|
||||
|
||||
```bash
|
||||
cat /etc/os-release
|
||||
python3 --version
|
||||
raspi-config nonint get_i2c
|
||||
ls -l /dev/i2c-1
|
||||
ss -ltnp
|
||||
```
|
||||
|
||||
`raspi-config nonint get_i2c` 返回 `0` 才表示 I²C 已启用。启用 I²C 会修改启动配置,并且通常需要重启;远程操作前应确认 SSH 公钥登录与供电稳定。
|
||||
|
||||
```bash
|
||||
sudo raspi-config nonint do_i2c 0
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
重连后确认 `/dev/i2c-1` 存在。散热板插件安装器会安装诊断工具并检查 `0x0D`、`0x3C`,不会改 SSH、网络或 Wi-Fi。
|
||||
|
||||
## 安装顺序
|
||||
|
||||
先安装通用监控 Agent:
|
||||
|
||||
```bash
|
||||
git clone https://tea.pigway.com/way/pigway-raspberry-pi-agent.git
|
||||
cd pigway-raspberry-pi-agent
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
默认只监控 `ssh.service`,不会假定已安装向日葵或 Tailscale。需要 NAS 上的 Device Console 时,先确认端口未占用,再显式开启 API:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh --enable-api
|
||||
sudo ss -ltnp | grep ':6001 '
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
再安装独立硬件插件:
|
||||
|
||||
```bash
|
||||
git clone https://tea.pigway.com/way/pigway-yahboom-cooling-hat.git
|
||||
cd pigway-yahboom-cooling-hat
|
||||
sudo ./install.sh --enable-integration
|
||||
```
|
||||
|
||||
最后在 `/etc/pigway-pi-control.conf` 的 `[plugins]` 中登记:
|
||||
|
||||
```ini
|
||||
yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
|
||||
```
|
||||
|
||||
然后仅重启 Agent:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
systemctl --no-pager --full status pigway-pi-control.service
|
||||
systemctl --no-pager --full status pigway-cooling-hat.service
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -b --no-pager
|
||||
sudo journalctl -u pigway-cooling-hat.service -b --no-pager
|
||||
curl --unix-socket /run/pigway-plugins/yahboom-cooling-hat/api.sock http://localhost/v1/status
|
||||
```
|
||||
|
||||
实机还需肉眼确认 OLED 四行主页与10秒轮播、RGB 告警颜色/效果,并通过温度变化确认风扇档位。停止插件时会请求风扇全速,驱动关闭时不会写风扇 OFF;硬件无转速反馈,因此只能确认请求档位,不能声称测得真实 RPM。
|
||||
|
||||
API 默认关闭,不占用 TCP 端口。插件只使用本机 Unix socket,不会与 Tailscale 或 API 网关争用端口。两个服务都由 systemd 独立管理,异常退出自动重启,任一项目停止不会停止另一个项目。
|
||||
+44
-11
@@ -2,22 +2,27 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
NO_START=0
|
||||
API_ENABLED=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--enable-api) API_ENABLED=true ;;
|
||||
--disable-api) API_ENABLED=false ;;
|
||||
--no-start) NO_START=1 ;;
|
||||
-h|--help) echo 'Usage: sudo ./install.sh [--no-start]'; exit 0 ;;
|
||||
-h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--enable-api|--disable-api]'; exit 0 ;;
|
||||
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||
for required in app/pigway_pi_control.py app/plugin_api.py app/network_rates.py app/storage_cache.py app/pigway_pi_control_api.py config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
|
||||
done
|
||||
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
|
||||
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
|
||||
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
|
||||
mkdir -p /usr/local/share/pigway-pi-control/web
|
||||
command -v lsblk >/dev/null || { apt-get update; apt-get install -y util-linux; }
|
||||
mkdir -p /usr/local/share/pigway-pi-control
|
||||
install -m 0644 app/storage_cache.py /usr/local/share/pigway-pi-control/storage_cache.py
|
||||
install -m 0644 app/network_rates.py /usr/local/share/pigway-pi-control/network_rates.py
|
||||
install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
|
||||
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
|
||||
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
||||
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
||||
if [ -f /etc/pigway-pi-control.conf ]; then
|
||||
@@ -29,12 +34,21 @@ fi
|
||||
# but ignored by the monitor and never exposed as monitor configuration.
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import configparser
|
||||
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
|
||||
with p.open('a') as f:
|
||||
for section,values in {'device':{'identifier':'auto','name':'auto'},'hosts':{},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
|
||||
import configparser,re
|
||||
p=Path('/etc/pigway-pi-control.conf');text=p.read_text();c=configparser.ConfigParser();c.read_string(text)
|
||||
for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},
|
||||
'alerts':{'ethernet_min_speed_mbps':'1000','ethernet_speed_seconds':'15'},
|
||||
'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
|
||||
if not c.has_section(section):
|
||||
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()))
|
||||
text=text.rstrip()+'\n\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items())
|
||||
else:
|
||||
missing={k:v for k,v in values.items() if not c.has_option(section,k)}
|
||||
if missing:
|
||||
start=re.search(rf'(?mi)^\[{re.escape(section)}\][ \t]*$',text).end()
|
||||
following=re.search(r'(?m)^\[[^\]\r\n]+\]',text[start:])
|
||||
end=start+(following.start() if following else len(text)-start)
|
||||
text=text[:end].rstrip()+'\n'+''.join(k+' = '+v+'\n' for k,v in missing.items())+'\n'+text[end:].lstrip('\n')
|
||||
p.write_text(text)
|
||||
PY
|
||||
if [ ! -s /etc/pigway-pi-control-api.token ]; then
|
||||
(umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)
|
||||
@@ -42,12 +56,31 @@ fi
|
||||
chmod 0600 /etc/pigway-pi-control-api.token
|
||||
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
|
||||
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
|
||||
if [ -z "$API_ENABLED" ]; then
|
||||
API_ENABLED=false
|
||||
if [ -t 0 ]; then
|
||||
read -r -p 'Enable remote management API? [y/N] ' answer
|
||||
case "$answer" in y|Y|yes|YES) API_ENABLED=true ;; esac
|
||||
fi
|
||||
fi
|
||||
API_ENABLED="$API_ENABLED" python3 - <<'PYAPI'
|
||||
import os,sys
|
||||
sys.path.insert(0,'app')
|
||||
import pigway_pi_control_api as api
|
||||
api.save_ini_text(api.replace_ini_value('api','enabled',os.environ['API_ENABLED']),'install')
|
||||
PYAPI
|
||||
systemctl daemon-reload
|
||||
if [ "$NO_START" -eq 0 ]; then
|
||||
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
|
||||
systemctl restart pigway-pi-control.service pigway-pi-control-api.service
|
||||
systemctl enable pigway-pi-control.service >/dev/null
|
||||
systemctl restart pigway-pi-control.service
|
||||
systemctl is-active --quiet pigway-pi-control.service
|
||||
if [ "$API_ENABLED" = true ]; then
|
||||
systemctl enable pigway-pi-control-api.service >/dev/null
|
||||
systemctl restart pigway-pi-control-api.service
|
||||
systemctl is-active --quiet pigway-pi-control-api.service
|
||||
else
|
||||
systemctl disable --now pigway-pi-control-api.service
|
||||
fi
|
||||
fi
|
||||
echo 'Monitor installed. Hardware plugins are installed and connected separately.'
|
||||
echo 'Config: /etc/pigway-pi-control.conf'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=PIGWay Pi Control API and Web
|
||||
Description=PIGWay Raspberry Pi Agent optional local API
|
||||
After=network.target pigway-pi-control.service
|
||||
Wants=network.target
|
||||
|
||||
@@ -7,6 +7,8 @@ Wants=network.target
|
||||
Type=simple
|
||||
ExecStart=/usr/local/sbin/pigway-pi-control-api
|
||||
Restart=always
|
||||
RestartPreventExitStatus=78
|
||||
SuccessExitStatus=78
|
||||
RestartSec=2
|
||||
User=root
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[Unit]
|
||||
Description=PIGWay system monitor
|
||||
After=local-fs.target systemd-modules-load.service
|
||||
Before=multi-user.target
|
||||
Description=PIGWay Raspberry Pi Agent system monitor
|
||||
After=local-fs.target systemd-modules-load.service network-online.target ssh.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import sys,unittest,tempfile
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'app'))
|
||||
from network_rates import NetworkRates
|
||||
|
||||
class RatesTests(unittest.TestCase):
|
||||
def test_initial_idle_transfer_reset_and_switch(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
def write(interface,rx,tx):
|
||||
p=Path(root)/interface/'statistics';p.mkdir(parents=True,exist_ok=True)
|
||||
(p/'rx_bytes').write_text(str(rx));(p/'tx_bytes').write_text(str(tx))
|
||||
write('eth0',1000,2000);sampler=NetworkRates(root)
|
||||
self.assertEqual(sampler.sample('eth0',10),(None,None))
|
||||
self.assertEqual(sampler.sample('eth0',15),(0,0))
|
||||
write('eth0',1500,3000)
|
||||
self.assertEqual(sampler.sample('eth0',20),(100,200))
|
||||
write('eth0',10,20)
|
||||
self.assertEqual(sampler.sample('eth0',25),(None,None))
|
||||
write('wlan0',100,200)
|
||||
self.assertEqual(sampler.sample('wlan0',30),(None,None))
|
||||
self.assertEqual(sampler.sample('missing',35),(None,None))
|
||||
self.assertEqual(sampler.sample('eth0',40),(None,None))
|
||||
self.assertEqual(sampler.sample('../eth0',45),(None,None))
|
||||
@@ -0,0 +1,41 @@
|
||||
import sys,tempfile,unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'app'))
|
||||
from storage_cache import StorageCache
|
||||
|
||||
|
||||
class StorageTests(unittest.TestCase):
|
||||
def test_weekly_reads_and_restart_cache(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
now=[1000];reads=[]
|
||||
def reader(path):
|
||||
reads.append(path);return SimpleNamespace(total=1000,used=200,free=800)
|
||||
p=Path(directory)/'cache.json'
|
||||
cache=StorageCache(p,reader,lambda:now[0])
|
||||
self.assertEqual(cache.sample()['used'],200)
|
||||
for i in range(20):now[0]+=1;cache.sample()
|
||||
self.assertEqual(len(reads),1)
|
||||
restored=StorageCache(p,reader,lambda:now[0]);restored.sample()
|
||||
self.assertEqual(len(reads),1)
|
||||
now[0]=605800;restored.sample()
|
||||
self.assertEqual(len(reads),2)
|
||||
|
||||
def test_failure_does_not_fabricate_zero_or_retry_each_second(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
now=[1000];reads=[]
|
||||
def fail(path):reads.append(path);raise OSError('unavailable')
|
||||
cache=StorageCache(Path(directory)/'cache.json',fail,lambda:now[0])
|
||||
self.assertIsNone(cache.sample());self.assertTrue(cache.error)
|
||||
now[0]+=5;self.assertIsNone(cache.sample())
|
||||
self.assertEqual(len(reads),1)
|
||||
|
||||
def test_failed_read_cooldown_survives_restart(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
reads=[]
|
||||
def fail(path):reads.append(path);raise OSError('unavailable')
|
||||
p=Path(directory)/'cache.json'
|
||||
StorageCache(p,fail,lambda:1000).sample()
|
||||
restored=StorageCache(p,fail,lambda:1005)
|
||||
self.assertTrue(restored.error);self.assertIsNone(restored.sample())
|
||||
self.assertEqual(len(reads),1)
|
||||
@@ -0,0 +1,35 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "app"))
|
||||
stem = "pigway_ubuntu_agent_api" if (ROOT / "app/pigway_ubuntu_agent_api.py").exists() else "pigway_pi_control_api"
|
||||
spec = importlib.util.spec_from_file_location("storage_api_under_test", ROOT / "app" / (stem + ".py"))
|
||||
api = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(api)
|
||||
|
||||
|
||||
class StorageConfigTests(unittest.TestCase):
|
||||
def test_config_round_trip_and_invalid_edits_are_atomic(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory) / "agent.conf"
|
||||
config.write_text("[device]\nname = auto\n")
|
||||
identifier = "a" * 32
|
||||
with patch.object(api, "CFG", config):
|
||||
api.update_ini({"storage_names": {identifier: "媒体盘 100%"}, "storage_visible": {identifier: "false"}})
|
||||
self.assertEqual(api.config_json()["storage_names"][identifier], "媒体盘 100%")
|
||||
self.assertEqual(api.config_json()["storage_visible"][identifier], "false")
|
||||
original = config.read_text()
|
||||
for updates in [
|
||||
{"storage_visible": {identifier: "yes"}},
|
||||
{"storage_names": {"invalid-id": "Bad"}},
|
||||
{"storage_names": {identifier: "x" * 65}},
|
||||
{"storage_names": {identifier: "bad\tname"}},
|
||||
]:
|
||||
with self.assertRaises(ValueError):
|
||||
api.update_ini(updates)
|
||||
self.assertEqual(config.read_text(), original)
|
||||
@@ -0,0 +1,97 @@
|
||||
import configparser
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app"))
|
||||
from storage_cache import StorageInventory
|
||||
|
||||
Usage = namedtuple("Usage", "total used free")
|
||||
|
||||
|
||||
class StorageInventoryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.mounts = Path(self.tmp.name) / "mountinfo"
|
||||
self.mounts.write_text("1 0 8:1 / / rw - ext4 /dev/sda1 rw\n2 0 8:2 / /media/My\\040Disk rw - ext4 /dev/sda2 rw\n3 0 8:2 /folder /bind rw - ext4 /dev/sda2 rw\n")
|
||||
self.data = {"blockdevices": [
|
||||
{"name": "/dev/sda", "type": "disk", "serial": "serial", "children": [
|
||||
{"name": "/dev/sda1", "type": "part", "uuid": "root", "maj:min": "8:1", "fstype": "ext4", "size": 1000},
|
||||
{"name": "/dev/sda2", "type": "part", "uuid": "data", "maj:min": "8:2", "fstype": "ext4", "size": 2000},
|
||||
]},
|
||||
{"name": "/dev/sdb", "type": "disk", "size": 3000},
|
||||
{"name": "/dev/loop0", "type": "loop", "size": 4000},
|
||||
{"name": "/dev/zram0", "type": "disk", "fstype": "swap"},
|
||||
]}
|
||||
self.now = 100
|
||||
self.reader = StorageInventory(clock=lambda: self.now, runner=lambda: json.dumps(self.data),
|
||||
mounts_path=self.mounts, usage=lambda p: Usage(1000, 0, 1000),
|
||||
cache_path=Path(self.tmp.name)/"inventory.json")
|
||||
self.reader.scan()
|
||||
|
||||
def test_weekly_usage_cache_survives_service_restart(self):
|
||||
capacity=Path(self.tmp.name)/'usage.json'
|
||||
self.reader.capacity_path=capacity
|
||||
self.reader.sample()
|
||||
def forbidden(*args):raise AssertionError('Not due; should not read capacity or discover disks')
|
||||
restarted=StorageInventory(clock=lambda:self.now+100,runner=forbidden,mounts_path=self.mounts,
|
||||
usage=forbidden,cache_path=self.reader.cache_path,capacity_path=capacity)
|
||||
self.assertEqual(len(restarted.sample()),3)
|
||||
self.assertEqual(restarted.observed_at,self.now)
|
||||
self.assertEqual(restarted.next_update_at,self.now+604800)
|
||||
|
||||
def test_mounts_raw_disk_and_pseudo_filters(self):
|
||||
disks = self.reader.sample()
|
||||
self.assertEqual(len(disks), 3)
|
||||
self.assertEqual(disks[0]["mountpoint"], "/")
|
||||
self.assertEqual(next(d for d in disks if d.get("uuid") == "data")["mountpoint"], "/media/My Disk")
|
||||
self.assertEqual(disks[0]["used_percent"], 0)
|
||||
raw = next(d for d in disks if d["device"] == "/dev/sdb")
|
||||
self.assertEqual(raw["state"], "unmounted")
|
||||
self.assertIsNone(raw["used_bytes"])
|
||||
|
||||
def test_uuid_survives_renumber_and_configuration(self):
|
||||
disk = self.reader.sample()[0]
|
||||
self.data["blockdevices"][0]["children"][0]["name"] = "/dev/sdz1"
|
||||
self.now += 61
|
||||
self.reader.scan()
|
||||
config = configparser.ConfigParser()
|
||||
config.read_string(f'[storage_names]\n{disk["id"]} = 数据盘 100%\n[storage_visible]\n{disk["id"]} = false\n')
|
||||
result = next(d for d in self.reader.sample(config) if d["id"] == disk["id"])
|
||||
self.assertEqual(result["name"], "数据盘 100%")
|
||||
self.assertFalse(result["visible"])
|
||||
|
||||
def test_removal_and_discovery_error_are_distinct(self):
|
||||
disk = self.reader.sample()[0]
|
||||
config = configparser.ConfigParser()
|
||||
config.read_dict({"storage_names": {disk["id"]: "System"}})
|
||||
self.reader.runner = lambda: "invalid json"
|
||||
self.now += 61
|
||||
with self.assertRaises(ValueError):self.reader.scan()
|
||||
self.assertEqual(self.reader.sample(config)[0]["state"], "mounted")
|
||||
self.reader.runner = lambda: '{"blockdevices":[]}'
|
||||
self.now += 61
|
||||
self.reader.scan()
|
||||
self.assertEqual(self.reader.sample(config)[0]["state"], "missing")
|
||||
self.assertFalse(self.reader.error)
|
||||
|
||||
def test_hotplug_requires_manual_scan_and_inventory_survives_restart(self):
|
||||
self.assertEqual(len(self.reader.sample()), 3)
|
||||
self.data["blockdevices"].append({"name": "/dev/sdc", "type": "disk", "uuid": "new", "size": 9000})
|
||||
self.now += 29
|
||||
self.assertEqual(len(self.reader.sample()), 3)
|
||||
self.now += 100000
|
||||
self.assertEqual(len(self.reader.sample()), 3)
|
||||
self.reader.scan()
|
||||
self.assertEqual(len(self.reader.sample()), 4)
|
||||
def forbidden():raise AssertionError("automatic enumeration is forbidden")
|
||||
restored=StorageInventory(clock=lambda:self.now,runner=forbidden,mounts_path=self.mounts,
|
||||
usage=lambda p:Usage(1000,0,1000),cache_path=self.reader.cache_path)
|
||||
self.assertEqual(len(restored.sample()),4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
-117
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user