Compare commits

..
7 Commits
Author SHA1 Message Date
way c7c2b9e656 release: v3.11.4 official PIGWay branding
Verify / test (push) Successful in 7s
2026-10-19 19:22:35 +08:00
way fa7cc0705b feat: integrate official PIGWay v1.1 brand assets
Verify / test (push) Successful in 5s
2026-10-19 19:03:09 +08:00
way 8c23ef7aac ci: use verified official actions on the shared Server runner
Verify / test (push) Successful in 6s
2026-10-18 22:18:59 +08:00
way 5912a55215 release: v3.11.3 open-source delivery and audit fixes
Verify / test (push) Failing after 5m3s
2026-10-18 18:45:02 +08:00
way 9b83937d60 fix: validate atomic configuration updates and expire stale monitoring snapshots 2026-10-18 18:15:22 +08:00
way c82d9ab3c2 docs: organize project guides and consolidate historical records 2026-10-18 17:59:18 +08:00
way 8b7100daa5 docs: align repository names and bilingual entry points; remove obsolete reports 2026-10-18 17:53:43 +08:00
25 changed files with 367 additions and 165 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
# Fork code may run on GitHub-hosted runners, never the trusted Gitea runner.
if: github.server_url == 'https://github.com' || github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13'
- run: python -m unittest discover -s tests -v
- run: git diff --check
+2
View File
@@ -8,3 +8,5 @@ dist/
/storage-release.json
/ops/deploy-storage.py
/ops/release-storage.py
.pytest_cache/
.ruff_cache/
+9
View File
@@ -0,0 +1,9 @@
# 贡献指南 / Contributing
安装和运行步骤见项目首页;使用 Python 3.13 或 3.14,修改保持小而集中。运行 `python3 -m unittest discover -s tests -v`,Shell 脚本使用 `bash -n`,提交前运行 `git diff --check`。
测试只能使用临时目录、模拟服务和设备桩;不得访问真实设备、执行生产写入或提交密钥、令牌、数据库、照片及部署回执。真实设备验证由维护者另行执行。
请通过仓库 Pull Request 提交,说明问题、行为变化和验证结果。行为修复应覆盖对应回归风险。新增代码按本项目 MIT 许可证提供,保留第三方许可及来源。
See README.md for installation. Keep changes focused, test with mocks, and submit a pull request describing the behavior and checks. Never include credentials or production data. Contributions use the MIT license; preserve third-party notices.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 PIGWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -19
View File
@@ -1,8 +1,10 @@
# PIGWay Raspberry Pi Agent v3.11.2
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
# PIGWay Raspberry Pi Agent v3.11.4
[简体中文](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).
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](docs/STORAGE.md).
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.
@@ -58,20 +60,4 @@ Logs follow the system journal retention policy; no additional database is creat
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秒/每日容量刷新描述。
[Documentation](docs/README.md)
+6 -2
View File
@@ -1,8 +1,10 @@
# PIGWay Raspberry Pi Agent v3.11.2
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
# PIGWay Raspberry Pi Agent v3.11.4
简体中文 | [English](README.md)
新增磁盘由 Console → 系统配置 → 选择设备 → 磁盘设置 → 扫描磁盘手动发现;已保存清单跨重启保留,已知磁盘容量每周刷新。支持在 Agent 保存名称和隐藏设置。见 [部署与验证](STORAGE-DISCOVERY.md)。
新增磁盘由 Console → 系统配置 → 选择设备 → 磁盘设置 → 扫描磁盘手动发现;已保存清单跨重启保留,已知磁盘容量每周刷新。支持在 Agent 保存名称和隐藏设置。见 [部署与验证](docs/STORAGE.md)。
极轻的树莓派本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。有线网络会读取真实协商速率,并可在持续低于配置阈值时告警。无 Web 界面;无需硬件插件,也无需管理中心。
@@ -59,3 +61,5 @@ sudo journalctl -u pigway-pi-control.service -f
## 名称与兼容性
本项目支持范围明确为树莓派;其他 Linux 平台以后独立维护项目。仓库改名不改变现有 `pigway-pi-control` 服务名、配置路径和 API。远程 API 仍可选择启用,默认关闭。
[文档目录](docs/README.md)
+3 -5
View File
@@ -1,7 +1,5 @@
# Raspberry Pi Agent v3.11.2
# pigway-raspberry-pi-agent v3.11.4
新增磁盘只由用户手动扫描登记,已知磁盘容量及根分区兼容指标每周更新;服务重启复用运行缓存。保留现有CPU、内存、网络、硬件插件及监控协议。
正式 PIGWAY Brand Production v1.1 图形标、项目头像与来源说明随项目交付;中英文项目介绍页使用统一品牌图。本版以品牌资源和项目文档交付为主。
磁盘清单持久化,支持名称/显示设置;整机重启后容量缓存首次初始化不自动发现新盘。没有调整风扇、RGB或OLED控制协议。
验证:10项后端测试通过;pigway-gateway生产升级后原两个服务正常,容量周期为604800秒,配置和令牌校验和保留。未执行整机重启或七天自然周期测试。
软件业务逻辑和设备控制边界沿用上一版本。Logo 使用已发布的 v1.1 原文件,不重新生成或修改颜色、比例。源码包保留 LICENSE 及原有第三方许可说明;插件 ZIP 保留对应许可证。
+7
View File
@@ -0,0 +1,7 @@
# 安全反馈 / Security
仅支持当前发布版本。请勿在公开 Issue 中发布密钥、令牌、个人数据或可直接利用的完整漏洞细节。
请通过仓库维护者 [way](https://tea.pigway.com/way) 的私有联系渠道报告,包含受影响版本、复现条件、影响和最小化示例。若尚未建立私有渠道,先在 Issue 请求私下联系,不附敏感细节。
Only the current release is supported. Contact the maintainer privately before disclosing exploit details; include the version, impact and a minimal reproduction. Do not attach credentials or private data to public issues.
+25 -13
View File
@@ -1,20 +1,32 @@
ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore
948e700459c05a045234edfbbdd1efa240f9a0350221a40029b7416c0587e36c README.md
64a9708701f8c8d872a587e8a2845afff463797b06a2791319daadd705933d31 README.zh-CN.md
6635c6d8b53076d7fd9fe2c3382469420ada532ce010970644e912b877495151 .github/workflows/verify.yml
4035aba62136040755ffdf2faf1556a49215e829c16ee44263e0ab7530fbf521 .gitignore
79f34290cfa1a6f0928162909848e6597d46183d0de6b213bf52b12546f5fc33 CONTRIBUTING.md
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
9fe9181f11649cc72a2e3fc9f0edff4df8b57f2e9408e28c44628c7eaa298311 README.md
8d1ca6252993fb50e29b0540c4418f2f58ea0be112d398dbd69ace86bff0f1bc README.zh-CN.md
3ab9fe57e5dd6caa2ecb6651557f64a328a6395a63a7795db0471f3d166cb1da RELEASE_NOTES.md
6cead6c1378cd840f655adc3d467e2cd6fd3ab368d7200a3afbfe85d9ed159be SECURITY.md
c5853f283c0951b43f2bacbc49e66c26d3a605a184d427828e042eb9236d2740 app/network_rates.py
69877b4ad2b8307db69495d629988ff4bca838dea9e36d839165029fdb6ed4a8 app/pigway_pi_control.py
757eb6a83ca07740a2389af9b51c6fd5d940b4eb86abbc603f231409416d45db app/pigway_pi_control_api.py
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
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
f28ac6ca73f94dba9190ff815e8de77714ddcbf46b84546fb8f6378545db54df app/pigway_pi_control.py
36da02aac8edce97be94e2c3942bb23ffff0d23023b76efd5073d0876b32d9c9 app/pigway_pi_control_api.py
9e011761ddb9583c26505fd85d6b484c440b649cb6169922512020f95a5a7d42 app/plugin_api.py
6429ba4e65baae379e892b8c2f15a5a158924b72060a692f0a3b447f8bebfc80 app/storage_cache.py
14e0e3337dde66309418658d14d1c61f1742c0f327b76a57126c72f006448165 config/pigway-pi-control.conf
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
3041278ff521c303d95711d38f31f43dfd38c8ed94999a7a86514b1fa3e09f60 docs/README.md
6552741bb5639520c331a8c38d5d99c90f0ab3d9b1f11e571cb789c386bd0577 docs/STORAGE.md
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
a38fae07a9151923706ff708598bf24e89ef4ed7fb9449c3f7b4d013d6e1be93 install.sh
8a593c290da5bd0d037aee86c35264512afd574a2a0f60f2c29e9db63e0109bd systemd/pigway-pi-control-api.service
1cb55de6b104a233066495e9786319305fa4f1b6f25483c108ab4f5ac9aa5700 systemd/pigway-pi-control.service
a58ff2e2ae49f481618cbb8d83b7cfbaa08ed4361eecbcab54755c886a6163eb tests/test_audit_regressions.py
8b97229abfb31c68464eb5754bc3a8fec7a6a78e216d74a86b7cc89486700740 tests/test_network_rates.py
f78e31e0eeee4e02532885ace5c8f6f7011d084e4c7440b704408bc88495e40e tests/test_storage_cache.py
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
56a7b171f7b6d93721b4888908929b36095a4cb73700a041e37f9bc796309c5e tests/test_storage_cache.py
ab555730f3f3955a1338d5d4282addfb2ea1703f6a50541146dc49adbca5694b tests/test_storage_config.py
7ef40a31988bc258eb490d39732a1790e6e533b045c3f3ff277d2e0fd274dfa1 tests/test_storage_inventory.py
273fa86cdb77a0610ef64a3ad1788fecbe465bfbdf9568fa8928304b6be2cfaa uninstall.sh
-52
View File
@@ -1,52 +0,0 @@
# 磁盘手动扫描(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 未额外扫描,并执行生产浏览器检查。
+7 -6
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
from pathlib import Path
APP_VERSION="3.11.2"
APP_VERSION="3.11.4"
import sys
sys.path.append("/usr/local/share/pigway-pi-control")
from plugin_api import Publisher,registered,discover
@@ -185,7 +185,7 @@ def internet():
except: return False
def load_cfg():
c=configparser.ConfigParser(); c.read(CFG)
c=configparser.ConfigParser(interpolation=None); c.read(CFG)
watches=[]
if c.has_section("services"):
for label,target in c["services"].items():
@@ -331,10 +331,10 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
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.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%",f"USED >= {DISK_CRIT:g}%","CHECK STORAGE")
alert_manager.clear("DISK_HIGH")
elif disk>=DISK_HIGH:
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%",f"USED >= {DISK_HIGH:g}%","CHECK STORAGE")
alert_manager.clear("DISK_CRITICAL")
else:
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
@@ -408,13 +408,14 @@ class AlertManager:
"first_seen":old["first_seen"] if old else now,"metadata":metadata or {}}
value["last_changed"]=old["last_changed"] if old and all(old[k]==v for k,v in value.items()) else now
self.requests[request_id]=value
if old is None:log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
if old is None or old["severity"]!=severity:
log({1:"WARN",2:"CRITICAL",3:"EMERGENCY"}[severity],"ALERT_ACTIVE" if old is None else "ALERT_CHANGED",alert=request_id,severity=severity,detail=l2)
def clear(self,request_id):
if self.requests.pop(request_id,None) is not None:log("INFO","ALERT_RECOVERED",alert=request_id)
def status_snapshot(self):
values=sorted(self.requests.values(),key=lambda a:(-a["priority"],a["first_seen"],a["id"]))
values=sorted(self.requests.values(),key=lambda a:(-a["severity"],-a["priority"],a["first_seen"],a["id"]))
return {"owner":values[0]["id"] if values else "NORMAL_HOME","alerts":values}
+67 -52
View File
@@ -2,26 +2,30 @@
import sys
sys.path.append("/usr/local/share/pigway-pi-control")
from plugin_api import discover,registered,request as plugin_request,socket_path
import math,functools,tempfile
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
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
from urllib.parse import parse_qs,urlparse
APP_VERSION="3.11.2"
STORAGE_SCAN_LOCK=threading.Lock()
CONFIG_LOCK=threading.RLock()
def config_transaction(function):
@functools.wraps(function)
def locked(*args,**kwargs):
with CONFIG_LOCK:
return function(*args,**kwargs)
return locked
APP_VERSION="3.11.4"
CFG=Path("/etc/pigway-pi-control.conf")
STATUS=Path("/run/pigway-pi-control/status.json")
TOKEN=Path("/etc/pigway-pi-control-api.token")
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]$")
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$")
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
PROTECTED_UNITS={"pigway-pi-control-api.service"}
def plugin_inventory():
@@ -36,6 +40,7 @@ def find_plugin(plugin_id):
raise ValueError("unknown plugin")
@config_transaction
def plugin_operation(body):
plugin_id=str(body.get("id",""))
if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id")
@@ -66,7 +71,7 @@ def machine_identity():
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG)
cfg=configparser.ConfigParser(interpolation=None); cfg.read(CFG)
return cfg
def api_setting(key,default,cast=str):
@@ -91,36 +96,39 @@ def validate_value(section,key,value,existing):
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"} 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")
elif section=="led" and key=="alert_mode":
if value.lower() not in ("breathe","flash"): raise ValueError("alert_mode must be breathe or flash")
elif section=="led" and key=="custom_min_hold_ms":
if not 1500<=int(value)<=60000: raise ValueError("custom_min_hold_ms must be 1500..60000")
elif RGB_KEY_RE.fullmatch(key):
number=int(value)
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
elif FLASH_KEY_RE.fullmatch(key):
number=int(value)
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000")
elif section=="led" and key=="normal_mode":
if value.lower() not in NORMAL_MODES: raise ValueError("normal_mode must be off, solid, flash, flow, breathe, marquee, rainbow or colorful")
elif section=="led" and key=="normal_effect_speed":
if int(value) not in (1,2,3): raise ValueError("normal_effect_speed must be 1, 2 or 3")
elif section=="led" and key=="normal_effect_color":
if value.lower() not in EFFECT_COLORS: raise ValueError("normal_effect_color must be red, green, blue, yellow, purple, cyan or white")
elif section=="api" and key=="port":
number=int(value)
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
elif section=="api" and key=="log_limit":
number=int(value)
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
elif section=="api" and key=="bind":
ipaddress.ip_address(value)
if section in {"alerts","timing"}:
number=float(value)
if not math.isfinite(number):raise ValueError(f"{section}.{key} must be finite")
low,high=(0,86400)
if section=="timing":
low=0 if key in {"startup_grace_seconds","network_failure_seconds"} else 0.2
high=3600
elif key in {"cpu_high","cpu_recover","memory_high","memory_recover","disk_high","disk_critical"}:low,high=0,100
elif key in {"temp_high","temp_critical"}:low,high=-50,150
elif key in {"wifi_weak","wifi_recover"}:low,high=-120,0
elif key=="ethernet_min_speed_mbps":low,high=0,1000000
if not low<=number<=high:raise ValueError(f"{section}.{key} must be {low}..{high}")
if key in {"cpu_high_seconds","memory_high_seconds","ethernet_min_speed_mbps"} and not value.isdecimal():
raise ValueError(f"{key} must be a nonnegative integer")
elif section=="dark_mode" and value.lower() not in {"on","off","true","false","0","1","yes","no"}:
raise ValueError("LED setting must be on or off")
elif section=="services" and not UNIT_RE.fullmatch(value):raise ValueError("invalid systemd service name")
elif section=="api" and key in {"port","log_limit"}:
limit=65535 if key=="port" else 1000
if not 1<=int(value)<=limit:raise ValueError(f"api.{key} must be 1..{limit}")
elif section=="api" and key=="bind":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")
def validate_candidate(parsed):
for lower,upper in (("cpu_recover","cpu_high"),("memory_recover","memory_high"),
("temp_high","temp_critical"),("disk_high","disk_critical"),("wifi_weak","wifi_recover")):
if parsed.has_option("alerts",lower) and parsed.has_option("alerts",upper):
if not parsed.getfloat("alerts",lower)<parsed.getfloat("alerts",upper):
raise ValueError(f"alerts.{lower} must be less than alerts.{upper}")
def replace_ini_value(section,key,value):
return replace_ini_text(CFG.read_text(),section,key,value)
@@ -139,13 +147,23 @@ def replace_ini_text(text,section,key,value):
if value is not None:return text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
return text
@config_transaction
def save_ini_text(text,prefix="api"):
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.strftime('%Y%m%d-%H%M%S')}")
parsed=configparser.ConfigParser(interpolation=None); parsed.read_string(text)
validate_candidate(parsed)
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{time.time_ns()}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
temporary=None
try:
with tempfile.NamedTemporaryFile(mode="w",dir=CFG.parent,prefix=CFG.name+".",delete=False) as file:
temporary=Path(file.name);file.write(text)
temporary.chmod(0o644);temporary.replace(CFG)
finally:
if temporary is not None:temporary.unlink(missing_ok=True)
return str(backup)
@config_transaction
def update_ini(updates):
if not isinstance(updates,dict): raise ValueError("updates must be an object")
text=CFG.read_text()
@@ -175,12 +193,7 @@ def update_ini(updates):
text=text[:start]+replacement+text[stop:]
else:
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp")
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup)
return save_ini_text(text,"api")
def journal_query(since=None,until=None,severity="",event="",search="",sort="timestamp",order="desc",limit=100,offset=0):
command=["journalctl","-u","pigway-pi-control.service","--no-pager","-o","json"]
@@ -244,6 +257,7 @@ def service_inventory():
item["protected"]=item["unit"] in PROTECTED_UNITS
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
@config_transaction
def edit_service_monitor(operation,name,target,note="",previous_name=""):
if operation not in {"save","delete","pause","resume"}: raise ValueError("invalid monitor operation")
if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
@@ -281,10 +295,7 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
change("service_monitor_disabled",previous_name,None)
change("services",name,target); change("service_notes",name,note or None)
if was_paused: change("service_monitor_disabled",name,"true")
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
backup=save_ini_text(text,"api")
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return str(backup)
@@ -298,6 +309,7 @@ def process_monitors():
result.append({"name":name.upper(),"target":target,"up":up})
return result
@config_transaction
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")
@@ -311,7 +323,7 @@ def edit_process_monitor(operation,name,target="",previous_name=""):
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)
parsed=configparser.ConfigParser(interpolation=None); parsed.read_string(text)
backup=save_ini_text(text,"api")
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return backup
@@ -359,7 +371,10 @@ class Handler(BaseHTTPRequestHandler):
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
state=json.loads(STATUS.read_text())
if not 0<=time.time()-state.get("timestamp",0)<=10:
self.send_json(503,{"error":"agent status stale"});return
self.send_json(200,state); return
if parsed.path=="/api/v1/logs":
query=parse_qs(parsed.query)
value=lambda key,default="":query.get(key,[default])[0]
+9 -4
View File
@@ -4,6 +4,7 @@ from contextlib import closing
import http.client
import json
import socket
import time
import threading
import uuid
from pathlib import Path
@@ -65,14 +66,15 @@ class Publisher:
"""One replaceable snapshot, not an unbounded queue of animation commands."""
def __init__(self,plugins,source,log):
self.plugins=plugins;self.source=source;self.log=log
self.session=str(uuid.uuid4());self.revision=0;self.snapshot=None
self.session=str(uuid.uuid4());self.revision=0;self.snapshot=None;self.published_at=None
self.lock=threading.Lock();self.stop_event=threading.Event();self.status={}
self.threads=[threading.Thread(target=self.run,args=(key,path),name='plugin-'+key,daemon=True)
for key,path in plugins.items()]
for thread in self.threads:thread.start()
def publish(self,state):
with self.lock:self.snapshot=copy.deepcopy(state)
with self.lock:
self.snapshot=copy.deepcopy(state);self.published_at=time.monotonic()
def current_status(self):
with self.lock:return copy.deepcopy(self.status)
@@ -80,8 +82,11 @@ class Publisher:
def run(self,key,path):
previous_error=None;revision=0;descriptor=None
while not self.stop_event.is_set():
with self.lock:state=self.snapshot
if state is not None:
with self.lock:
state=self.snapshot
fresh=self.published_at is not None and time.monotonic()-self.published_at<3
# Never renew a hardware lease from a stalled monitoring loop.
if state is not None and fresh:
revision+=1
payload={'api_version':1,'source':self.source,'session':self.session,'revision':revision,
'ttl_seconds':10,'system':state['system'],'network':state['network'],'alerts':state['display']['alerts']}
+1 -1
View File
@@ -174,7 +174,7 @@ class StorageCache:
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:
if isinstance(at, (int, float)) and 0 <= self.clock() - at < 604800:
self.values, self.observed_at = values, at
if not self.error:self.next_update_at = at + 604800
except (OSError, ValueError, KeyError, TypeError):
-5
View File
@@ -1,5 +0,0 @@
# 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 配置。
-5
View File
@@ -1,5 +0,0 @@
# 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)。
+10
View File
@@ -0,0 +1,10 @@
# 文档目录
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
- [本地硬件插件协议](HARDWARE_PLUGIN_API.md)
- [Raspberry Pi OS Lite (Debian 13 Trixie) fresh installation](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md)
- [Raspberry Pi OS Lite(Debian 13 Trixie)全新安装](RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md)
- [磁盘发现、刷新与显示](STORAGE.md)
[贡献指南](../CONTRIBUTING.md) · [安全反馈](../SECURITY.md) · [许可证](../LICENSE)
+21
View File
@@ -0,0 +1,21 @@
# 磁盘发现与显示
## 使用
在 Console → 系统配置 → 选择设备 → 磁盘设置中点击“扫描磁盘”,显式发现新增磁盘。打开页面、普通刷新和重启不执行新增磁盘发现。扫描失败保留已有清单;不执行挂载、格式化或分区操作。
已知磁盘容量每周刷新,CPU、内存与网络按自身周期采集。摘要显示实际采集时间;缺失指标不补零,采集失败不据此确认告警恢复。
显示名称、同组顺序与隐藏偏好统一保存到 Console,首页与配置页共用;Agent 已有名称和隐藏定义作为默认值。隐藏只影响显示,不停止采集或删除历史。
## Agent 行为
认证接口 `PUT /api/v1/storage/scan` 读取块设备和挂载点,持久化清单到对应配置旁的 `.storage.json`。监控服务读取清单;新增磁盘须再次手动扫描。根分区指标保留兼容,服务重启可复用容量缓存,整机重启后的容量初始化不自动发现新磁盘。
UUID 优先作为标识,其次为设备序列号和分区编号,再退回路径并标记 `stable_identity=false`。重复挂载及 loop/swap 等伪设备不展示;未挂载设备不伪造使用率。配置过的磁盘在扫描未发现时保留为断开状态。
磁盘扫描不执行 SMART 或自检;SMART 能力以本项目的独立说明为准。
## 相关说明
[中文首页](../README.zh-CN.md) · [文档目录](README.md)
+15
View File
@@ -0,0 +1,15 @@
# PIGWAY 品牌资源 / Brand assets
来源:PIGWAY Brand Production v1.1 正式交付包。图形、颜色与比例保持原文件,不使用重绘、滤镜或替代字标。
- `pigway-logo.svg`:正式 Web 图形标。
- `pigway-logo.png`:正式 256px 透明图形标,用于项目介绍页。
- `pigway-avatar.png`:正式 512px 平台图标,用于 Tea 项目头像。
本项目的产品名称保持独立。品牌物料版本不跟随软件版本变化;网页小图标使用同一正式包的 Favicon 和 Apple Touch Icon。
## SHA-256
- `0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210` `pigway-logo.svg`
- `da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7` `pigway-logo.png`
- `f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3` `pigway-avatar.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

+3 -1
View File
@@ -35,7 +35,7 @@ fi
python3 - <<'PY'
from pathlib import Path
import configparser,re
p=Path('/etc/pigway-pi-control.conf');text=p.read_text();c=configparser.ConfigParser();c.read_string(text)
p=Path('/etc/pigway-pi-control.conf');text=p.read_text();c=configparser.ConfigParser(interpolation=None);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():
@@ -69,6 +69,8 @@ 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
install -d -m 0755 /usr/local/share/doc/pigway-raspberry-pi-agent
install -m 0644 LICENSE /usr/local/share/doc/pigway-raspberry-pi-agent/
systemctl daemon-reload
if [ "$NO_START" -eq 0 ]; then
systemctl enable pigway-pi-control.service >/dev/null
+132
View File
@@ -0,0 +1,132 @@
"""Regression checks for configuration, alert truth and stale snapshot handling."""
import ast
import concurrent.futures
import json
import sys
import tempfile
import threading
import time
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from urllib.error import HTTPError
from urllib.request import Request, urlopen
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'app'))
import pigway_pi_control_api as api
import plugin_api
from storage_cache import StorageCache
class ConfigTests(unittest.TestCase):
def setUp(self):
self.directory=tempfile.TemporaryDirectory();self.addCleanup(self.directory.cleanup)
self.config=Path(self.directory.name)/'agent.conf'
self.config.write_text((ROOT/'config/pigway-pi-control.conf').read_text())
self.override=patch.object(api,'CFG',self.config);self.override.start();self.addCleanup(self.override.stop)
def test_invalid_values_and_relationships_leave_configuration_unchanged(self):
original=self.config.read_text()
for updates in [
{'timing':{'sensor_interval':'-1'}},{'timing':{'sensor_interval':'0'}},
{'alerts':{'cpu_high':'nan'}},{'alerts':{'memory_high':'inf'}},
{'alerts':{'cpu_high':'101'}},{'alerts':{'cpu_high':'50'}},
{'alerts':{'temp_high':'80'}},{'alerts':{'disk_high':'99'}},
{'alerts':{'wifi_weak':'-60'}},{'alerts':{'cpu_high_seconds':'1.5'}},
{'services':{'BAD':'not-a-unit'}},
]:
with self.subTest(updates=updates):
with self.assertRaises(ValueError):api.update_ini(updates)
self.assertEqual(self.config.read_text(),original)
api.update_ini({'alerts':{'cpu_high':'60','cpu_recover':'50'}})
self.assertEqual(api.load_cfg().getfloat('alerts','cpu_high'),60)
def test_literal_percent_in_identity_and_process_matches(self):
api.update_ini({'device':{'name':'Office 100%'},'processes':{'APP':'worker --ratio=100%'}})
self.assertEqual(api.machine_identity()['name'],'Office 100%')
self.assertEqual(api.load_cfg().get('processes','app'),'worker --ratio=100%')
def test_parallel_writes_preserve_every_edit_and_backup(self):
def save(index):return api.update_ini({'processes':{f'APP{index}':f'worker-{index}'}})
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
backups=list(pool.map(save,range(20)))
self.assertEqual(len(set(backups)),20)
cfg=api.load_cfg()
for index in range(20):self.assertEqual(cfg.get('processes',f'app{index}'),f'worker-{index}')
self.assertFalse(list(self.config.parent.glob('agent.conf.*tmp*')))
class StateTests(unittest.TestCase):
def test_weekly_root_cache_survives_restart_after_first_day(self):
with tempfile.TemporaryDirectory() as directory:
reads=[]
def reader(_):reads.append(1);return SimpleNamespace(total=100,used=20,free=80)
path=Path(directory)/'cache'
StorageCache(path,reader,lambda:1000).sample()
restored=StorageCache(path,reader,lambda:101000)
restored.sample();self.assertEqual(len(reads),1)
self.assertEqual(restored.next_update_at,605800)
StorageCache(path,reader,lambda:605800).sample();self.assertEqual(len(reads),2)
def test_publisher_stops_renewing_stale_data_and_recovers(self):
now=[0];calls=[]
publisher=plugin_api.Publisher({},'test',lambda *a,**k:None)
class Event:
def is_set(self):return now[0]>=8
def wait(self,_):
now[0]+=1
if now[0]==6:publisher.publish(state)
publisher.stop_event=Event()
state={'system':{},'network':{},'display':{'alerts':[]}}
def request(path,method,endpoint,payload=None):
if method=='PUT':calls.append((now[0],payload['revision']))
return {'api_version':1}
with patch.object(plugin_api.time,'monotonic',lambda:now[0]),patch.object(plugin_api,'request',request):
publisher.publish(state);publisher.run('test','unused')
self.assertEqual(calls,[(0,1),(1,2),(2,3),(6,4),(7,5)])
def test_status_api_rejects_stale_agent_then_accepts_fresh_data(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'status.json';path.write_text(json.dumps({'timestamp':time.time()-60}))
token=Path(directory)/'token';token.write_text('test-token')
with patch.object(api,'STATUS',path),patch.object(api,'TOKEN',token):
server=ThreadingHTTPServer(('127.0.0.1',0),api.Handler)
worker=threading.Thread(target=server.serve_forever);worker.start()
try:
req=Request(f'http://127.0.0.1:{server.server_port}/api/v1/status',headers={'Authorization':'Bearer test-token'})
with self.assertRaises(HTTPError) as caught:urlopen(req)
self.assertEqual(caught.exception.code,503)
caught.exception.close()
path.write_text(json.dumps({'timestamp':time.time()}))
with urlopen(req) as response:self.assertEqual(response.status,200)
finally:server.shutdown();worker.join();server.server_close()
def test_alert_severity_owner_changes_and_configured_disk_text(self):
tree=ast.parse((ROOT/'app/pigway_pi_control.py').read_text())
names={'AlertManager','alert_priority','update_active_alerts'}
nodes=[n for n in tree.body if isinstance(n,(ast.ClassDef,ast.FunctionDef)) and n.name in names]
logs=[]
env={'time':time,'log':lambda *args,**kwargs:logs.append((args,kwargs)),
'ALERT_PRIORITIES':{'CPU_HIGH':45,'DISK_CRITICAL':55,'DISK_HIGH':40,'TEMP_HIGH':50,'TEMP_CRITICAL':90},
'ALERT_CATEGORIES':{'CPU_HIGH':'CPU','DISK_CRITICAL':'STORAGE','DISK_HIGH':'STORAGE','TEMP_HIGH':'CPU','TEMP_CRITICAL':'CPU'}}
exec(compile(ast.Module(body=nodes,type_ignores=[]),'monitor-functions','exec'),env)
manager=env['AlertManager']()
manager.request('CPU_HIGH',1,'CPU');manager.request('DISK_CRITICAL',2,'DISK')
self.assertEqual(logs[-1][0][0],'CRITICAL')
manager.request('TEMP_HIGH',3,'TEMP')
self.assertEqual(manager.status_snapshot()['owner'],'TEMP_HIGH')
self.assertEqual(logs[-1][0][0],'EMERGENCY')
before=len(logs);manager.request('TEMP_HIGH',3,'TEMP');self.assertEqual(len(logs),before)
manager.request('TEMP_HIGH',1,'TEMP');self.assertEqual(logs[-1][0][1],'ALERT_CHANGED')
env.update(alert_manager=manager,cpu_hi=0,mem_hi=0,CPU_HIGH=90,CPU_RECOVER=75,CPU_HIGH_SEC=30,
MEM_HIGH=90,MEM_RECOVER=85,MEM_HIGH_SEC=30,TEMP_HIGH=68,TEMP_CRIT=75,
DISK_HIGH=70,DISK_CRIT=80,link_ok=True,net_ok=True,watches=[],watch_state=[],
net_kind='NET',health={},fs_readonly=False,ethernet_slow_active=False,
transition=lambda *a,**k:None,net_iface='',net_speed_mbps=None,ETHERNET_MIN_SPEED=1000)
env['update_active_alerts'](10,40,20,85)
self.assertEqual(manager.requests['DISK_CRITICAL']['l3'],'USED >= 80%')
env['update_active_alerts'](10,40,20,75)
self.assertEqual(manager.requests['DISK_HIGH']['l3'],'USED >= 70%')
+1
View File
@@ -7,6 +7,7 @@ rm -f /usr/local/sbin/pigway-pi-control
rm -f /usr/local/sbin/pigway-pi-control-api
rm -f /etc/pigway-pi-control-api.token
rm -rf /usr/local/share/pigway-pi-control
rm -rf /usr/local/share/doc/pigway-raspberry-pi-agent
systemctl daemon-reload
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
echo "如确定不要配置,可手工:sudo rm /etc/pigway-pi-control.conf"