Compare commits

...
18 Commits
Author SHA1 Message Date
way 1bf3309af3 fix: finalize overview status presentation v1.6.8 2026-10-18 00:12:50 +08:00
way c9ce680a5a fix: release unified home card layout v1.6.7 2026-10-17 23:00:36 +08:00
way 9e1b9cf179 feat: unify Raspberry Pi disk presentation and manual discovery 2026-10-15 23:30:11 +08:00
way 721cec18e7 feat: follow Agent object plans and daily storage observations 2026-10-15 09:13:50 +08:00
way 09afd4397c docs: record unified metric semantics production verification 2026-10-15 08:27:11 +08:00
way 90478e5408 fix: show consistent missing percentage values in network overview 2026-10-15 06:43:06 +08:00
way 0b0e18de07 docs: record measured four-second balance and aligned updates 2026-10-15 01:16:46 +08:00
way 84d9a270dd perf: align read cadence with measured DSM four-second samples 2026-10-15 01:13:02 +08:00
way f5e668d545 perf: use two-second minimum Raspberry Pi collection cadence 2026-10-15 01:00:13 +08:00
way c947cbe934 docs: confirm cadence scheduling production verification 2026-10-15 00:51:15 +08:00
way 93d5f0cdd7 feat: declare collection cadence and preserve Agent sampling timestamps 2026-10-15 00:35:02 +08:00
way d6df234154 docs: confirm link label production verification 2026-10-14 23:57:52 +08:00
way d856b42e92 fix: label network card with link connectivity 2026-10-14 23:56:04 +08:00
way d3f79ebe8a fix: make traffic rates the primary network card value 2026-10-14 23:46:53 +08:00
way f576114db1 feat: align wired network card and collect traffic history 2026-10-14 23:43:41 +08:00
way 6a66a48a51 docs: link Raspberry Pi history production validation 2026-10-11 21:08:56 +08:00
way 20c781c750 feat: expose existing Raspberry Pi metrics for Console history 2026-10-11 20:57:42 +08:00
way d206ae4390 docs: clarify Raspberry Pi project names and integration scope 2026-10-11 16:29:27 +08:00
26 changed files with 384 additions and 28 deletions
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc
dist/
.DS_Store
.data/
+5
View File
@@ -0,0 +1,5 @@
# 树莓派接入插件 v1.2.0
2026-10-14 已部署。主网络卡片主文字改为“有线网络 · 链路速率”;接口/IP/状态与上下行速率独立两行,与群晖 v1.4.0 一致。Wi-Fi 信号与告警语义保留。Agent v3.8.0 提供 rx_bytes_per_second/tx_bytes_per_second,映射到标准历史指标;旧 Agent 缺少数值时不伪造速率,非数值、布尔和非有限值不写入历史。两项指标回归测试通过,核心全页三尺寸、中英文主题浏览器回归通过;真实生产快照额外验证两处网络卡片三尺寸与两语言,桌面截图已查看。
生产实际插件 1.2.0、Agent 3.8.0,两设备在线,树莓派上下行采集与标准历史指标通过认证 API 核验,临时会话删除。停服一致性备份 `/volume1/homes/way/pigway-console-backup-network-align-20261014/data.tar.gz`,目录 0700;旧插件在数据卷 adapter-backups/network-align-20261014。Console 镜像保持原版。
+5
View File
@@ -0,0 +1,5 @@
# 网络卡片 v1.2.1
2026-10-14 已部署。网络主文字改为下载/上传速率,删除 ETH/有线网络文字;下方保留 eth0、1000 Mbps、IP 和状态,不重复显示速率。两处网络卡片使用一致布局。模拟及真实设备快照三尺寸、中英文浏览器检查通过,无溢出,桌面截图已查看。生产认证 API 确认两设备在线、树莓派实时流量与历史标准指标有效。Agent 保持 v3.8.0,未再次升级。
停服一致性数据备份 /volume1/homes/way/pigway-console-backup-network-rates-layout-20261014/data.tar.gz,目录 0700;旧插件在数据卷 adapter-backups/network-rates-layout-20261014。Console 镜像及 NAS 每日存储采集保持。
+5
View File
@@ -0,0 +1,5 @@
# 树莓派接入插件 v1.2.2
2026-10-14 网络卡片改为按 network.link 显示已连接/已断开,字段缺失显示状态未确认,与 NAS 的接口连接语义一致。页面不再把 network.internet 用作接口状态文案。Agent 的既有互联网探测和告警逻辑不修改。两项后端测试及全页三尺寸中英文主题浏览器验证通过。部署使用停服一致性数据备份 /volume1/homes/way/pigway-console-backup-synology-vpi-link-v1.2.2-20261014/data.tar.gz,目录 0700;旧树莓派插件保存在数据卷 adapter-backups/vpi-link-v1.2.2/pigway-agent。
生产安装并启用 1.2.2,实际 UI 已使用 network.link 且不存在“互联网正常”文案;认证 API 两设备在线,实时流量和历史指标保留。验证会话删除。Tea ZIP 与 SHA256 附件下载校验一致。
+5
View File
@@ -0,0 +1,5 @@
# pigway-console-adapter-raspberry-pi v1.3.0
2026-10-15 已与 Console v2.4.0 部署。声明每秒缓存读取并透传 Agent 的 system/network 真实采样时间。树莓派网络仍每 5 秒采样,历史去重验证通过。
生产两设备在线。认证 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)。
+7
View File
@@ -0,0 +1,7 @@
# pigway-console-adapter-raspberry-pi v1.3.2 实测调优
2026-10-15 生产调优结论:统一 4 秒读取 CPU、内存、温度及网络速率。曾试 2 秒,但这台 DSM 的 utilization 实际采样时间通常每 4 秒更新,连续细粒度观察 CPU 时间为 7853/7857/7861/7865 等相差 4 秒的样本;不能把两次相同样本当成更快采样。只读接口测量 System.info 约 0.05–0.07 秒、Utilization.get 约 1.03–1.09 秒,请求耗时与真实样本周期不同。
最终 40 秒连续观测:两设备各 10 个独立实时样本、10 个网络样本,中位间隔均 4 秒;无离线或缺失采集组,缓存 API 最大耗时 0.032 秒(这是 Console 缓存读取,不是 DSM 接口耗时)。Pi 本地 CPU 等继续 1 秒,网络配置已从 5 秒改为 2 秒;Console 每 4 秒读取,覆盖更快缓存。NAS 网络配置 60 秒、存储 24 小时保持。
插件生产版本 Pi 1.3.2、NAS 1.6.1,Console 仍 2.4.0、Agent 仍 3.9.0。3 项 Pi 回归和 14 项 NAS 回归通过。停服一致性数据备份 /volume1/homes/way/pigway-console-backup-balanced-4s-20261015/data.tar.gz,目录 0700;旧插件在数据卷 adapter-backups/balanced-4s-20261015。Pi 原配置备份 /home/way/pigway-timing-backup-20261015.conf,权限 0600。仅保留 Console 原 latest 镜像,没有新增镜像。
+7
View File
@@ -0,0 +1,7 @@
# v1.3.3 指标语义统一
2026-10-15 已部署。运行状态与历史趋势统一处理器负载、内存占用、磁盘占用及英文名称;主网络英文统一 Primary network,无告警与状态未确认提示一致。缺失百分比显示 --,不显示孤立百分号或假零值进度条。温度、内存可用量/存储剩余量按数据来源保留,不改用户资源名称。
全页、历史五范围和三尺寸中英文主题浏览器验证通过;真实设备公开快照在本地渲染两处卡片,三尺寸两语言名称检查和缺失读数检查通过。生产认证 API 核对 Console 2.4.1、Pi 插件 1.3.3、NAS 插件 1.6.2,两设备在线、原名称保持,4 秒声明不变,验证会话删除。生产实际 UI 和历史名称代码核对通过。
停服一致性备份 /volume1/homes/way/pigway-console-backup-v2.4.1-20261015/{program,data}.tar.gz,目录 0700;旧插件在数据卷 adapter-backups/semantics-v2.4.1。实际镜像 sha256:127d80530abc728add1eb947e304387f7f941f8fa570386d951c6f9ae6eabc0e,仅保留 latest 标签,候选标签与旧镜像清理。没有主动刷新磁盘。Tea 三个 Release 附件下载校验一致。
+35 -5
View File
@@ -1,6 +1,10 @@
# PIGWay Agent Console Plugin
# PIGWay Raspberry Pi Console Plugin
独立维护的树莓派接入插件:连接现有 PIGWay Device Agent,不在树莓派安装新进程。设备 API、状态转换、配置字段、服务/进程监控、硬件界面及其翻译均由本仓库维护。
磁盘呈现统一版本已上线,需要 Console v2.7.0:默认按设备返回顺序,同组支持上移/下移、显示名称及隐藏,所有显示偏好保存到 Console,首页与配置页共用。Agent 的名称和隐藏定义继续作为设备默认值保留。扫描仍只由配置页按钮触发。生产验证见相邻 Console 的 docs/STORAGE-PRESENTATION-v2.7.0-2026-10-15.md。
当前 v1.6.0 支持统一磁盘显示设置与手动扫描,需要 Console v2.7.0、Agent v3.11.1;新磁盘仅手动发现,已知磁盘容量继续刷新。见 [生产验证](STORAGE-DISCOVERY.md)。
独立维护的树莓派接入插件:连接现有 PIGWay Raspberry Pi Agent,不在树莓派安装新进程。设备 API、状态转换、配置字段、服务/进程监控、硬件界面及其翻译均由本仓库维护。
依赖 Python 标准库;接入 Console JSON-RPC v1 与可信前端扩展 `console-ui-v1`。插件 ID 保持 `pigway-agent`,已有设备可直接继续使用。
@@ -8,11 +12,37 @@
```sh
mkdir -p dist
python3 package.py plugin dist/pigway-agent-1.0.0.zip
python3 package.py plugin dist/pigway-agent-1.1.0.zip
```
在控制中心的机器管理页面安装 ZIP,再启用。开发时可用 `PIGWAY_ADAPTER_PATH=/absolute/path/pigway-console-adapter-agent/plugin` 加载源码;Docker 推荐使用持久化数据卷中的安装包。
在控制中心的机器管理页面安装 ZIP,再启用。开发时可用 `PIGWAY_ADAPTER_PATH=/absolute/path/pigway-console-adapter-raspberry-pi/plugin` 加载源码;Docker 推荐使用持久化数据卷中的安装包。
包根目录只有 manifest、worker 与 ui;打包工具、文档和 Git 元数据不进入插件包。前端扩展通过 Console 的共享状态、请求、翻译与基础显示函数接入,不注入额外安装步骤。只应安装可信插件:前端模块与主页面共享 JavaScript 执行环境,加载失败、同步异常及 Promise 拒绝可降级,但不能隔离死循环或恶意代码;后端独立进程的隔离也不是安全沙箱。
正式仓库:https://tea.pigway.com/way/pigway-console-adapter-agent 。首次独立版本为 v1.0.0,需要 Console v2.0.0。与 Console 的集成回归使用后者 tests 下的模拟 Agent,不操作真实设备。
正式仓库:https://tea.pigway.com/way/pigway-console-adapter-raspberry-pi 。首次独立版本为 v1.0.0,需要 Console v2.0.0。与 Console 的集成回归使用后者 tests 下的模拟 Agent,不操作真实设备。
## 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.
## v1.1.0 历史指标
将现有 Agent API 的内存/磁盘字节数、开机时长与链路速度补充到标准 metrics,Console v2.1.0 可直接统一记录历史。不需要更新或新增树莓派进程。现有设备端没有上传下载流量计数,因此本版没有实际流量曲线;链路速度仅代表协商速率。
v1.1.0 已与 Console v2.1.0 部署,真实树莓派在线并开始记录历史;设备端 Agent 保持原版本。[部署验收记录](https://tea.pigway.com/way/pigway-device-console/src/branch/main/docs/DEPLOYMENT-v2.1.0-2026-10-11.md)。
生产已升级至 v1.2.0,网络卡片与上下行历史指标见 [部署记录](DEPLOYMENT-v1.2.0-2026-10-14.md)。
当前网络卡片以实时上下行速率为主文字,详见 [v1.2.1 部署记录](DEPLOYMENT-v1.2.1-2026-10-14.md)。
网络连接文案统一为接口链路状态,见 [v1.2.2](DEPLOYMENT-v1.2.2-2026-10-14.md)。
生产 v1.3.0 与按设备周期采集的核验见 [部署记录](DEPLOYMENT-v1.3.0-2026-10-15.md)。
当前生产实时指标统一 4 秒读取,实测及版本 v1.3.2 见 [调优记录](DEPLOYMENT-v1.3.2-2026-10-15.md)。
当前生产 v1.3.3 的指标用词、状态与缺失百分比统一见 [部署记录](DEPLOYMENT-v1.3.3-2026-10-15.md)。
## Console UI 语义统一
本次界面需要 Console v2.7.30 的公共语义模块:在线、运行中使用绿色,健康正常使用蓝色,警告/故障/未知分别使用黄/红/灰色;状态采用14px/20px纯文字。完整规则见相邻 Console 项目的 `docs/UI_SEMANTICS.md`。
+7
View File
@@ -0,0 +1,7 @@
# pigway-console-adapter-raspberry-pi v1.6.8
合并主要告警与 OLED/RGB 展示,在线状态右对齐,保留系统配置插件管理;统一现有状态与折叠展示。
验证:适配器单元测试及相关桌面、平板、手机浏览器检查通过。
发布包可供安装;发布不自动升级生产已安装适配器。树莓派合并展示已单独热更新,Ubuntu/NAS 隐藏空告警块尚未部署。
+25 -8
View File
@@ -1,14 +1,31 @@
7d856a7ec552a841097cd14f2f3e357efcd05c13f70eb98a415789e615f6fbf2 .gitignore
f577e72ab33e2cc2dcb2fde5f598adab9dd0ae0602cef517c8281eb39bf00eb6 README.md
3cfd361f7663a9a9c93b507d17bb1c7e98003d61316826f0a4ba4a93594f72d4 .gitignore
8ef77455daa560a7f25a1d8a4684c9e8ce9232745d844b0e3703c7f57830c85f DEPLOYMENT-v1.2.0-2026-10-14.md
176127d6a7c5abf61112ea019ccc8f62819d1b94c5084ae51b99cbe0369cc22f DEPLOYMENT-v1.2.1-2026-10-14.md
d4938f4fb971fb665e377ba966b523250401138cf7e1186cb57da55fbf904f3b DEPLOYMENT-v1.2.2-2026-10-14.md
ec04c97e0271455a41328b8d68e8e4a5d0e3ef5782ddb8fc62a08b7bfff2ef9d DEPLOYMENT-v1.3.0-2026-10-15.md
136db6c02d7181085f18c54bf1ca2b0bc56e3aa43e3a71a0e59926fc7fa7b18f DEPLOYMENT-v1.3.2-2026-10-15.md
d0870015c81d16af20cf38b775fa95b5ac1619cc5144fcda5007cb2f832e0e33 DEPLOYMENT-v1.3.3-2026-10-15.md
552b66fe8946ea96b12633fcf705c71b14d32679bd57ddd39fd5c4cc8910f1b2 README.md
c7432d981ad0a0bc66cdc6e1915620df60d9e31be4da67ad19dd32ceca92d884 RELEASE_NOTES.md
a669ccb22bf3aafec6fa558fd84108dca013cb8dc2988206b7b5fe58a1eb2035 STORAGE-DISCOVERY.md
0cf93f3b3d3219ab8229b59e98cbd82e42647a461231f7c767ea3d120f8e0910 docs/AUDIT-FIXES-2026-10-16.md
1eec71f194bfdd51b6ae5a9063a0760ccf24b46474901defdf19503a680b8fcd docs/HOME-CARD-ORDER-2026-10-17.md
b47aafb725706a03837aceabd1ac59b302ead066bf12bf947326a98cb38963ef docs/OVERVIEW-DISCLOSURES-2026-10-16.md
f0df601aafe839cfe06c069471071023158beced81c5d0b802fa0bb4ed3528bc docs/OVERVIEW-SUMMARY-BADGES-2026-10-16.md
d3528d8fd4eed197942e2c9a72d62e6c9ff6b3ab00f4be98c337b6e306d6e177 docs/STATUS-ALIGNMENT-2026-10-16.md
d081c599d821149853368b1a8637c57a16995344406ff5ec9d7619b66e0479b3 package.py
b7c560b9f529ef8dcdf74f1bc74a36745fc005c2432ff80325e1c9e87573e7b2 plugin/manifest.json
e5c04001dbd8ed8bba614f7b341237fc6487a9a8fe2f28ccb6f59278725b0a69 plugin/ui/config.js
7e985bc8ce58c9d83b6e61c5a47e123856f57281c23a389750471f988c13699e plugin/manifest.json
d6a342605426357da7d2e65daa435e99d7234549cffa52e1a9229ef5bc36f33c plugin/ui/config.js
c98f34119436e3020ef20b325f91617943aefe662119cf76738e0226c2e91cbb plugin/ui/device.js
89b9d7994e7477e18c3f43e6f0975e04d5106056ba581b2964db0f39183d6f8a plugin/ui/hardware.js
af631694fcd7da21b77b554e6ee1bf2eaa93bd46a0b8b543e367f032d3236ecb plugin/ui/index.js
fadb6bf24d6fcf569fc4157847a3585a1008aad99ce5a0e2b87c9c0658f6d225 plugin/ui/overview.js
b1d50c4ba896905b3c3ee8808072dfb41c04486b9364a5f0f49377059e280a78 plugin/ui/overview.js
422e1f8323d29a4be757f39e8b60addd7764c436a1302673809e0c547fce5ff4 plugin/ui/schema.js
5d967b5d4e62eed7022bc04fe29e4ed670f2e288cf20adae0c25233f94a29079 plugin/ui/services.js
56e67d2839cbb865133e3b5f796a07f36595c0f6c30480f8e2fac79e7d51e03f plugin/ui/styles.css
1bee0f9e5656a52bc97d9fafffbf5157dae062888f618046ad09470dc2685f3c plugin/ui/services.js
6806df689f62de56f7b61f8b3cf3a33c494a09931a14f51d9d71c26d2c1f61ee plugin/ui/storage.js
061f5011b78cedcd0c69a32aa5106889d38f6863f4c613ae326cc9d16f90e2dd plugin/ui/styles.css
0b79b9c5b5f23727164e05b127caca72255416309166ba200ccbd15a9667473c plugin/ui/translations.js
c12c7b8a3c2fdb5eaef8ca535fcae04d960d4c89c86740529166f3e25cd8c7c9 plugin/worker.py
d5b22366a2c7b375e6f3e35cefb9f95da0ea3b8390b3ec4d1000492f00128f6e plugin/worker.py
22c913e75736b78c54d90a60c7a2c8f739467dc7f8ed3f46b71be28e4bc3dec5 tests/browser_alert_semantics.cjs
40c038aa7689c127c0d90ddf390674f534a2bdee8fd3c344d09f8fb6d4bb3964 tests/test_metrics.py
40d10555aef08f8132f576976b7205b3e1c16d330196a11c6cc1c5cbc167c670 tests/test_storage_metrics.py
+52
View File
@@ -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 未额外扫描,并执行生产浏览器检查。
+5
View File
@@ -0,0 +1,5 @@
# 2026-10-16 主要告警展示(1.6.2)
概览把 Agent owner 的标题从“显示状态”改为“主要告警”,明确告警按严重程度排序、实际屏幕见硬件插件。避免未连接硬件或 OLED 轮播时把 Agent 告警摘要误认为屏幕实况。
原四项 Python 测试通过;tests/browser_alert_semantics.cjs 验证中英文及桌面、平板、手机布局,使用真实组件及 CSS、隔离硬件和网络依赖。运行需 PLAYWRIGHT_MODULE 指向可用 Playwright;PIGWAY_WEB_ROOT 可指定控制台 web 目录。已安装至生产控制台 2.7.23,API 确认版本 1.6.2,设备在线与显示偏好保持一致;备份:`/volume1/homes/way/pigway-console-log-zh-backup-1792125044`。
+7
View File
@@ -0,0 +1,7 @@
# 首页设备卡片统一约定
所有主机(包括 Server、Gateway、NAS 及后续新增设备)使用顺序:处理器负载 → 内存占用 → 主网络 → 存储分区,再显示设备专属信息。
NAS 系统温度放在处理器负载进度条下方,不单独占卡片;保留系统温度的原始含义。管理跳转图标紧跟设备名称,布局间距 2px,新标签页打开。Docker 管理入口使用 Server 的 DPanel 88 端口。
本次发布为增量更新,保留线上已有功能与配置。发布附件仅包含本次更新的运行时文件,不是可直接导入 Console 的完整适配器安装 ZIP。
+5
View File
@@ -0,0 +1,5 @@
# 概览折叠区块 1.6.3
“服务与进程监控”或“已读取范围内的告警”采用默认收起的 details/summary,沿用 SMART 展开样式。data-overview-details 配合 Console 2.7.24 保留每台设备在自动刷新中的展开状态。内容和监控行为保持不变。
已部署至生产;中英文、桌面/平板/手机验收通过。部署回执及验证详见控制台 docs/OVERVIEW-DISCLOSURES-2026-10-16.md。
@@ -0,0 +1,3 @@
# 概览汇总标签
折叠标题增加正常/异常数量/未确认汇总,暂无监控不视为正常。共享 Console 2.7.25 overview-health.js 判定,过期数据不显示绿色正常。已部署,中英文桌面/平板/手机及各状态模拟验收通过。完整回执见控制台 docs/OVERVIEW-SUMMARY-BADGES-2026-10-16.md。
+3
View File
@@ -0,0 +1,3 @@
# 概览状态纯文字展示
汇总调用改为 summaryStatus,配合 Console 2.7.26 的统一纯文字与对齐规则。已部署;中英文、深浅主题、桌面/平板/手机及长文本实际展开测量均通过。判断逻辑不变。详见控制台 docs/STATUS-ALIGNMENT-2026-10-16.md。
+7 -3
View File
@@ -1,11 +1,12 @@
{
"id": "pigway-agent",
"name": "PIGWay Agent",
"version": "1.0.0",
"name": "PIGWay Raspberry Pi Agent",
"version": "1.6.8",
"protocol_version": 1,
"entrypoint": "worker.py",
"runtime": "python3",
"capabilities": [
"refresh",
"status",
"logs",
"config",
@@ -38,5 +39,8 @@
"contract": "console-ui-v1",
"entrypoint": "ui/index.js"
},
"legacy_connection_fields": true
"legacy_connection_fields": true,
"collection": {
"interval_seconds": 1
}
}
+4 -1
View File
@@ -1,4 +1,6 @@
import { runtimeStatus } from "/assets/js/ui-semantics.js";
import { state as appState } from "/assets/js/state.js";
import { addStorageSettings } from "./storage.js";
import { renderDevice } from "./device.js";
import { renderHosts } from "/assets/js/devices.js";
import {
@@ -59,7 +61,7 @@ export function renderProcessList() {
"processList",
appState.processCache.map(
(p) =>
`<div class="row"><span><b>${esc(p.name)}</b> <span class="badge ${p.up ? "good" : "bad"}">${tr(p.up ? "正常" : "停止")}</span><br><span class="muted">${esc(p.target)}</span></span><span class="row-actions"><button data-edit-process="${esc(p.name)}">${tr("编辑")}</button><button class="danger" data-delete-process="${esc(p.name)}">${tr("删除")}</button></span></div>`,
`<div class="row"><span><b>${esc(p.name)}</b> ${runtimeStatus(p.up, appState.LANG)}<br><span class="muted">${esc(p.target)}</span></span><span class="row-actions"><button data-edit-process="${esc(p.name)}">${tr("编辑")}</button><button class="danger" data-delete-process="${esc(p.name)}">${tr("删除")}</button></span></div>`,
),
pluginText("尚未监控任何普通进程", "No process monitors configured"),
);
@@ -345,6 +347,7 @@ export function renderConfig() {
.forEach((b) => (b.onclick = () => deleteHost(b.dataset.deleteHost)));
bindBulkSync();
updateConditional();
addStorageSettings();
}
export function markChanged() {
let n = 0;
+19 -4
View File
@@ -1,14 +1,17 @@
import { runtimeStatus } from "/assets/js/ui-semantics.js";
import { monitorHealth, summaryStatus, dataIsCurrent } from "/assets/js/overview-health.js";
import { state as appState } from "/assets/js/state.js";
import { storageCards } from "./storage.js";
import { esc } from "/assets/js/shared.js";
import { tr } from "/assets/js/i18n.js";
import { bytes, formatUptime, meter } from "/assets/js/overview.js";
import { fanMetric, pluginSummary } from "./hardware.js";
import { ownerName } from "./schema.js";
import { fanMetric } from "./hardware.js";
import { ownerName, rgbName } from "./schema.js";
export function agentHostCard(entry) {
const h = entry.host;
if (!entry.online)
return `<article class="panel host-panel offline-card"><div class="host-head"><div><h2>${esc(h.name || h.id)}</h2><div class="device-details"><span>ID ${esc(h.id)}</span><span>${esc(h.url || tr("本机"))}</span></div></div><span class="badge bad">${tr("离线")}</span></div><div class="error">${esc(entry.error || tr("连接失败"))}</div></article>`;
return `<article class="panel host-panel offline-card"><div class="host-head"><div><h2>${esc(h.name || h.id)}</h2><div class="device-details"><span>ID ${esc(h.id)}</span><span>${esc(h.url || tr("本机"))}</span></div></div><span class="badge semantic-status" data-semantic="error">${tr("离线")}</span></div><div class="error">${esc(entry.error || tr("连接失败"))}</div></article>`;
const s = entry.status,
y = s.system || {},
n = s.network || {},
@@ -20,5 +23,17 @@ export function agentHostCard(entry) {
memTotal = y.memory_total_bytes,
diskUsed = y.disk_used_bytes,
diskTotal = y.disk_total_bytes;
return `<article class="panel host-panel"><div class="host-head"><div><div class="host-title"><h2>${esc(m.name || h.name || m.id)}</h2><span class="uptime-badge">${esc(formatUptime(s.uptime_seconds))}</span></div><div class="device-details"><span>ID ${esc(m.id || h.id)}</span><span>${appState.LANG === "zh" ? "主机名" : "Hostname"} ${esc(m.hostname || v.hostname || "--")}</span><span>${esc(v.manufacturer || "")} ${esc(v.model || "--")}</span><span>${appState.LANG === "zh" ? "序列号" : "Serial"} ${esc(v.serial || "--")}</span><span>${appState.LANG === "zh" ? "硬件版本" : "Revision"} ${esc(v.revision || "--")}</span><span>${esc(v.os || "--")}</span><span>${appState.LANG === "zh" ? "内核" : "Kernel"} ${esc(v.kernel || "--")}</span><span>${esc(v.architecture || "--")} · CPU ${esc(v.cpu_count || "--")}</span><span>${appState.LANG === "zh" ? "程序版本" : "Agent"} ${esc(s.version || "--")}</span></div></div><span class="badge good">${tr("在线")}</span></div><div class="host-grid"><div class="metric"><div class="muted">${tr("处理器负载")}</div><div class="value">${esc(y.cpu_percent)}%</div>${meter(y.cpu_percent)}<div class="muted">${appState.LANG === "zh" ? "频率" : "Frequency"} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C${fanMetric(s.plugins || {})}</div></div><div class="metric"><div class="muted">${tr("内存占用")}</div><div class="value">${esc(y.memory_percent)}%</div>${meter(y.memory_percent)}<div class="muted">${bytes(memUsed)} / ${bytes(memTotal)} · ${appState.LANG === "zh" ? "可用" : "Available"} ${bytes(y.memory_available_bytes)}</div></div><div class="metric"><div class="muted">${tr("磁盘占用")}</div><div class="value">${esc(y.disk_percent)}%</div>${meter(y.disk_percent)}<div class="muted">${bytes(diskUsed)} / ${bytes(diskTotal)} · ${appState.LANG === "zh" ? "剩余" : "Free"} ${bytes(y.disk_free_bytes)}</div></div><div class="metric"><div class="muted">${tr("主网络")}</div><div class="value">${n.kind === "WIF" && signal !== null && signal !== undefined ? `${esc(signal)}%` : esc(n.kind || "--")}</div>${n.kind === "WIF" ? meter(signal) : ""}<div class="muted">${esc(n.interface || "--")} · ${n.kind === "WIF" ? `${esc(n.metric)} dBm · ` : n.kind === "ETH" && n.link_speed_mbps ? `${esc(n.link_speed_mbps)} Mbps · ` : ""}${esc(n.ip_label)} ${esc(n.ip)} · ${n.internet ? (appState.LANG === "zh" ? "互联网正常" : "Internet online") : appState.LANG === "zh" ? "互联网断开" : "Internet down"}</div></div><div class="metric"><div class="muted">${tr("显示状态")}</div><div class="value">${esc(ownerName(d.owner))}</div><div class="muted">${appState.LANG === "zh" ? "告警状态独立于硬件插件" : "Alert state is independent of hardware plugins"}</div></div><div class="metric"><div class="muted">${tr("当前告警")}</div><div class="value">${(d.alerts || []).length}</div><div class="muted">${(d.alerts || []).map((a) => esc(ownerName(a.id))).join(" · ") || tr("无告警")}</div></div></div>${pluginSummary(s.plugins || {}, h.id)}<div class="group"><h3>${tr("服务与进程监控")}</h3><div class="rows">${(s.watches || []).map((w) => `<div class="row"><span>${esc(w.name)} · ${esc(w.target)}</span><span class="badge ${w.up ? "good" : "bad"}">${tr(w.up ? "正常" : "停止")}</span></div>`).join("") || `<div class="muted">${tr("暂无数据")}</div>`}</div></div></article>`;
const hardware = Object.values(s.plugins || {}).filter(p => p.capabilities?.display || p.capabilities?.rgb);
const current = dataIsCurrent(entry);
const ready = p => current && p.online === true && p.status?.available !== false;
const oled = hardware.find(p => p.capabilities?.display && ready(p) && p.status?.oled_page);
const rgb = hardware.find(p => p.capabilities?.rgb && ready(p) && p.status?.rgb_mode);
const displayText = [oled ? `OLED ${ownerName(oled.status.oled_page)}` : ownerName(d.owner),
rgb ? `RGB ${rgbName(rgb.status.rgb_mode)}` : ""].filter(Boolean).join(" · ");
const hardwareState = !hardware.length ? "unlinked" : !current ? "unknown" : hardware.some(p => p.online === false) ? "offline" : hardware.every(ready) ? "online" : "unknown";
const hardwareLabel = { online: tr("在线"), offline: tr("离线"), unknown: appState.LANG === "zh" ? "未确认" : "Unconfirmed", unlinked: appState.LANG === "zh" ? "未接入" : "Not connected" }[hardwareState];
const hardwareNames = hardware.map(p => p.name).filter(Boolean).join(" · ");
const percent = n => Number.isFinite(n) ? `${n.toFixed(1)}%` : "--";
const progress = n => Number.isFinite(n) ? meter(n) : "";
return `<article class="panel host-panel"><div class="host-head"><div><div class="host-title"><h2>${esc(m.name || h.name || m.id)}</h2><span class="uptime-badge">${esc(formatUptime(s.uptime_seconds))}</span></div><div class="device-details"><span>ID ${esc(m.id || h.id)}</span><span>${appState.LANG === "zh" ? "主机名" : "Hostname"} ${esc(m.hostname || v.hostname || "--")}</span><span>${esc(v.manufacturer || "")} ${esc(v.model || "--")}</span><span>${appState.LANG === "zh" ? "序列号" : "Serial"} ${esc(v.serial || "--")}</span><span>${appState.LANG === "zh" ? "硬件版本" : "Revision"} ${esc(v.revision || "--")}</span><span>${esc(v.os || "--")}</span><span>${appState.LANG === "zh" ? "内核" : "Kernel"} ${esc(v.kernel || "--")}</span><span>${esc(v.architecture || "--")} · CPU ${esc(v.cpu_count || "--")}</span><span>${appState.LANG === "zh" ? "程序版本" : "Agent"} ${esc(s.version || "--")}</span></div></div><span class="badge semantic-status" data-semantic="online">${tr("在线")}</span></div><div class="host-grid"><div class="metric" data-refresh-group="system"><div class="muted">${tr("处理器负载")}</div><div class="value">${percent(y.cpu_percent)}</div>${progress(y.cpu_percent)}<div class="muted">${appState.LANG === "zh" ? "频率" : "Frequency"} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C${fanMetric(s.plugins || {})}</div></div><div class="metric" data-refresh-group="system"><div class="muted">${tr("内存占用")}</div><div class="value">${percent(y.memory_percent)}</div>${progress(y.memory_percent)}<div class="muted">${bytes(memUsed)} / ${bytes(memTotal)} · ${appState.LANG === "zh" ? "可用" : "Available"} ${bytes(y.memory_available_bytes)}</div></div><div class="metric" data-refresh-group="network"><div class="muted">${tr("主网络")}</div><div class="value">↓ ${bytes(n.rx_bytes_per_second)}/s · ↑ ${bytes(n.tx_bytes_per_second)}/s</div><div class="muted">${esc(n.interface || "--")} · ${n.kind === "WIF" ? `${esc(n.metric)} dBm · ` : n.link_speed_mbps ? `${esc(n.link_speed_mbps)} Mbps · ` : ""}${esc(n.ip_label)} ${esc(n.ip)} · ${n.link === true ? (appState.LANG === "zh" ? "已连接" : "Connected") : n.link === false ? (appState.LANG === "zh" ? "已断开" : "Disconnected") : (appState.LANG === "zh" ? "状态未确认" : "Unconfirmed")}</div></div>${Array.isArray(y.storage_inventory) ? storageCards(y,entry.snapshot?.resources) : `<div class="metric" data-refresh-group="storage"><div class="muted">${tr("磁盘占用")}</div><div class="value">${percent(y.disk_percent)}</div>${progress(y.disk_percent)}<div class="muted">${bytes(diskUsed)} / ${bytes(diskTotal)} · ${appState.LANG === "zh" ? "剩余" : "Free"} ${bytes(y.disk_free_bytes)}</div></div>`}<div class="metric" data-refresh-group="display"><div class="pi-alert-heading"><span class="muted">${appState.LANG === "zh" ? "主要告警" : "Primary alert"}</span><span class="semantic-status" data-semantic="${hardwareState === 'online' ? 'online' : hardwareState === 'offline' ? 'error' : 'unknown'}">${esc(hardwareLabel)}</span></div><div class="value">${esc(displayText)}</div>${hardwareNames ? `<div class="muted">${esc(hardwareNames)}</div>` : ""}</div>${(d.alerts || []).length || !dataIsCurrent(entry) || !Array.isArray(d.alerts) ? `<div class="metric" data-refresh-group="alerts"><div class="muted">${tr("当前告警")}</div><div class="value">${(d.alerts || []).length}</div><div class="muted">${(d.alerts || []).map((a) => esc(ownerName(a.id))).join(" · ") || tr("无告警")}</div></div>` : ""}</div><details class="group" data-overview-details="services"><summary class="smart-summary"><strong class="smart-summary-title">${tr("服务与进程监控")}</strong>${summaryStatus(monitorHealth(s.watches, dataIsCurrent(entry)), appState.LANG)}</summary><div style="margin-top:12px"><div class="rows">${(s.watches || []).map((w) => `<div class="row"><span>${esc(w.name)} · ${esc(w.target)}</span>${runtimeStatus(w.up, appState.LANG)}</div>`).join("") || `<div class="muted">${tr("暂无数据")}</div>`}</div></div></details></article>`;
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { state as appState } from "/assets/js/state.js";
import { $, esc } from "/assets/js/shared.js";
import { pluginText as text, $, esc } from "/assets/js/shared.js";
import { get, rows } from "/assets/js/navigation.js";
import { loadConfig } from "/assets/js/settings.js";
@@ -31,7 +31,7 @@ export function renderServiceLists() {
"systemServices",
filtered.map(
(s) =>
`<div class="row"><span><b>${esc(s.unit)}</b><br><span class="muted">${esc(s.description || "暂无服务说明")} · ${esc(s.active === "active" ? "正在运行" : "未运行")}</span></span><span class="row-actions"><span class="badge ${s.monitored && !s.paused ? "good" : ""}">${s.monitored ? (s.paused ? "已暂停" : "已监控") : "未监控"}</span><button data-select-service="${esc(s.unit)}">${s.monitored ? "编辑监控" : "加入监控"}</button></span></div>`,
`<div class="row"><span><b>${esc(s.unit)}</b><br><span class="muted">${esc(s.description || "暂无服务说明")} · ${esc(s.active === "active" ? text("运行中", "Running") : s.active === "inactive" || s.active === "failed" ? text("已停止", "Stopped") : text("未确认", "Unconfirmed"))}</span></span><span class="row-actions"><span class="badge semantic-status" data-semantic="${s.monitored && !s.paused ? "enabled" : "neutral"}">${s.monitored ? (s.paused ? "已暂停" : "已监控") : "未监控"}</span><button data-select-service="${esc(s.unit)}">${s.monitored ? "编辑监控" : "加入监控"}</button></span></div>`,
),
"没有匹配的服务",
);
@@ -40,7 +40,7 @@ export function renderServiceLists() {
"monitoredServices",
monitored.map(
(s) =>
`<div class="row"><span><b>${esc(s.monitor_name)}</b> <span class="badge ${s.paused ? "" : s.active === "active" ? "good" : "bad"}">${s.paused ? "监控已暂停" : s.active === "active" ? "运行中" : "服务已停止"}</span><br><span class="muted">${esc(s.unit)}</span><br><span class="muted">备注:${esc(s.note || "未填写")}</span></span><span class="row-actions"><button data-monitor-action="${s.paused ? "resume" : "pause"}" data-unit="${esc(s.unit)}">${s.paused ? "恢复监控" : "暂停监控"}</button><button data-service-action="${s.active === "active" ? "stop" : "start"}" data-unit="${esc(s.unit)}" ${s.protected && s.active === "active" ? 'disabled title="API 服务不能停止自身"' : ""}>${s.active === "active" ? "停止服务" : "启动服务"}</button><button data-select-service="${esc(s.unit)}">编辑</button><button class="danger" data-delete-monitor="${esc(s.unit)}">删除监控</button></span></div>`,
`<div class="row"><span><b>${esc(s.monitor_name)}</b> <span class="badge semantic-status" data-semantic="${s.paused ? "neutral" : s.active === "active" ? "running" : s.active === "inactive" || s.active === "failed" ? "error" : "unknown"}">${s.paused ? text("监控已暂停", "Monitor paused") : s.active === "active" ? text("运行中", "Running") : s.active === "inactive" || s.active === "failed" ? text("已停止", "Stopped") : text("未确认", "Unconfirmed")}</span><br><span class="muted">${esc(s.unit)}</span><br><span class="muted">备注:${esc(s.note || "未填写")}</span></span><span class="row-actions"><button data-monitor-action="${s.paused ? "resume" : "pause"}" data-unit="${esc(s.unit)}">${s.paused ? "恢复监控" : "暂停监控"}</button><button data-service-action="${s.active === "active" ? "stop" : "start"}" data-unit="${esc(s.unit)}" ${s.protected && s.active === "active" ? 'disabled title="API 服务不能停止自身"' : ""}>${s.active === "active" ? "停止服务" : "启动服务"}</button><button data-select-service="${esc(s.unit)}">编辑</button><button class="danger" data-delete-monitor="${esc(s.unit)}">删除监控</button></span></div>`,
),
"尚未监控任何服务",
);
+20
View File
@@ -0,0 +1,20 @@
import { esc, pluginText as text } from "/assets/js/shared.js";
import { bytes, meter } from "/assets/js/overview.js";
const states = {mounted:["已挂载","Mounted"],unmounted:["未挂载","Unmounted"],missing:["设备未连接","Device disconnected"],unknown:["状态未确认","Unconfirmed"]};
export function storageCards(system, resources) {
if(!Array.isArray(system.storage_inventory))return "";
const inventory=new Map(system.storage_inventory.map(d=>["storage-"+d.id,d]));
const presented=resources?.filter(r=>inventory.has(r.id));
const disks=presented?.length?presented.map(r=>({...inventory.get(r.id),name:r.name,visible:!r.hidden})):system.storage_inventory;
return disks.filter(d=>d.visible!==false).map(d=>{
const percent=Number.isFinite(d.used_percent)&&d.state==="mounted"?d.used_percent:null;
const label=text(...(states[d.state]||states.unknown));
const detail=percent===null?label:`${bytes(d.used_bytes)} / ${bytes(d.capacity_bytes)} · ${text("剩余","Free")} ${bytes(d.free_bytes)}`;
const location=d.mountpoint?`${text("挂载点","Mount point")}: ${d.mountpoint}`:d.device?`${text("设备","Device")}: ${d.device}`:"";
return `<div class="metric" data-refresh-group="storage_inventory" data-storage-id="${esc(d.id)}" data-resource-id="storage-${esc(d.id)}"><div class="muted" title="${esc(location)}">${esc(d.name)}</div><div class="value">${percent===null?esc(label):percent.toFixed(1)+"%"}</div>${percent===null?"":meter(percent)}<div class="muted">${esc(detail)}</div></div>`;
}).join("");
}
export async function addStorageSettings() {
const editor=await import("/assets/js/storage-presentation.js");
return editor.installStoragePresentation();
}
+4
View File
@@ -52,3 +52,7 @@
grid-row: 1 / span 3;
}
}
/* Hardware display status belongs to the primary-alert metric. */
.pi-alert-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.pi-alert-heading .semantic-status { flex-shrink: 0; }
+51 -4
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""Official Agent adapter. This process alone knows Agent endpoints/authentication."""
"""Official Raspberry Pi adapter. This process alone knows Agent endpoints/authentication."""
import concurrent.futures
import json
import math
import sys
import threading
from pathlib import Path
@@ -55,24 +56,67 @@ def request(device, endpoint, method='GET', payload=None, query=None):
raise AdapterError('RESULT_UNKNOWN' if method == 'PUT' else 'DEVICE_UNAVAILABLE') from None
def collection_interval(value):
declaration = value.get('collection', {})
interval = declaration.get('interval_seconds') if isinstance(declaration, dict) else None
if not isinstance(interval, (int, float)) or isinstance(interval, bool) or not math.isfinite(interval) or not 1 <= interval <= 3600:
interval = MANIFEST['collection']['interval_seconds']
return max(MANIFEST['collection']['interval_seconds'], interval)
def collect(device):
value = request(device, '/status')
if not isinstance(value.get('system'), dict) or not isinstance(value.get('machine'), dict):
raise AdapterError('INVALID_RESPONSE')
declaration = value.get('collection') if isinstance(value.get('collection'),dict) else {}
plans = declaration.get('refresh') if isinstance(declaration.get('refresh'),dict) else {}
storage_plan = plans.get('storage') if isinstance(plans.get('storage'),dict) else {}
metrics = []
units = {'cpu_percent': 'percent', 'memory_percent': 'percent', 'disk_percent': 'percent',
'temperature_c': 'celsius', 'cpu_freq_mhz': 'megahertz'}
'temperature_c': 'celsius', 'cpu_freq_mhz': 'megahertz',
'memory_total_bytes': 'bytes', 'memory_used_bytes': 'bytes',
'disk_total_bytes': 'bytes', 'disk_used_bytes': 'bytes'}
for key, unit in units.items():
number = value['system'].get(key)
if isinstance(number, (int, float)) and not isinstance(number, bool):
metrics.append({'resource_id': 'host', 'key': key, 'value': number, 'unit': unit})
uptime = value.get('uptime_seconds')
if isinstance(uptime, (int, float)) and not isinstance(uptime, bool):
metrics.append({'resource_id': 'host', 'key': 'uptime_seconds', 'value': uptime, 'unit': 'seconds'})
speed = value.get('network', {}).get('link_speed_mbps')
if isinstance(speed, (int, float)) and not isinstance(speed, bool):
metrics.append({'resource_id': 'host', 'key': 'network_link_speed_mbps', 'value': speed, 'unit': 'megabits_per_second'})
for field, key in (('rx_bytes_per_second', 'network_rx_bytes_per_second'),
('tx_bytes_per_second', 'network_tx_bytes_per_second')):
rate = value.get('network', {}).get(field)
if isinstance(rate, (int, float)) and not isinstance(rate, bool) and math.isfinite(rate) and rate >= 0:
metrics.append({'resource_id': 'host', 'key': key, 'value': rate, 'unit': 'bytes_per_second'})
for metric in metrics:
source = value.get('network', {}) if metric['key'].startswith('network_') else value.get('system', {})
at = source.get('storage_observed_at') if metric['key'].startswith('disk_') else source.get('observed_at') if metric['key'] != 'uptime_seconds' else value.get('timestamp')
if isinstance(at, (int, float)) and not isinstance(at, bool) and math.isfinite(at) and at > 0:
metric['observed_at'] = at
resources = [{'id': 'host', 'type': 'host'}]
for disk in value['system'].get('storage_inventory', []):
rid = 'storage-' + disk['id']
resources.append({'id': rid, 'type': 'filesystem' if disk.get('filesystem') else 'disk', 'parent_id': 'host',
'name': disk['name'], 'state': disk['state'],
'details': {'device': disk.get('device', ''), 'mountpoint': disk.get('mountpoint', ''),
'visible': disk.get('visible', True)}})
if disk['state'] == 'mounted' and not value['system'].get('storage_inventory_error'):
for key, unit in [('capacity_bytes', 'bytes'), ('used_bytes', 'bytes'), ('free_bytes', 'bytes'), ('used_percent', 'percent')]:
number = disk.get(key)
if isinstance(number, (int, float)) and not isinstance(number, bool) and math.isfinite(number):
metrics.append({'resource_id': rid, 'key': key, 'value': number, 'unit': unit,
'observed_at': value['system']['storage_inventory_observed_at']})
return {'schema_version': 1, 'observed_at': value.get('timestamp'),
'identity': {'reported_name': value['machine'].get('name'), 'category': 'host',
'details': value.get('device', {})},
'collection': {**declaration, 'interval_seconds': collection_interval(value)},
'capabilities': MANIFEST['capabilities'], 'metrics': metrics,
'resources': [{'id': 'host', 'type': 'host'}],
'resources': resources,
'alerts': value.get('display', {}).get('alerts', []),
'alerts_complete': isinstance(value.get('display', {}).get('alerts'), list),
'alerts_complete': isinstance(value.get('display', {}).get('alerts'), list) and not storage_plan.get('error',False) and not value['system'].get('storage_inventory_error', False),
'view': value}
@@ -87,6 +131,9 @@ def dispatch(method, params):
return request(device, '/health')
if method == 'device.collect':
return collect(device)
if method == 'device.refresh':
if params.get('group') != 'storage':raise AdapterError('UNSUPPORTED')
return request(device, '/storage/scan', 'PUT', {})
if method == 'device.logs.query':
return request(device, '/logs', query=params.get('query'))
if method == 'device.read':
+37
View File
@@ -0,0 +1,37 @@
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const fs=require('fs');const path=require('path');
const adapter=path.resolve(__dirname,'..');
const web=process.env.PIGWAY_WEB_ROOT || path.resolve(adapter,'../pigway-web-manager/web');
const output=adapter+'/.data/audit-validation';fs.mkdirSync(output,{recursive:true});
(async()=>{const browser=await chromium.launch({headless:true});const page=await browser.newPage();let errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('http://audit.local/**',async route=>{const u=new URL(route.request().url());let body,type='text/javascript';
if(u.pathname==='/'){type='text/html';body=`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/assets/css/base.css"><link rel="stylesheet" href="/assets/css/layout.css"><link rel="stylesheet" href="/assets/css/controls.css"><link rel="stylesheet" href="/plugin/styles.css"><main id="cards"></main><script type="module">import {agentHostCard} from '/plugin/overview.js';import {state} from '/assets/js/state.js';window.render=(lang,plugins={})=>{state.LANG=lang;document.querySelector('#cards').innerHTML=agentHostCard({host:{id:'gateway',name:'Gateway'},online:true,status:{version:'3.11.2',machine:{id:'gateway',name:'家庭网关 100%',hostname:'gateway'},device:{model:'Raspberry Pi 4',os:'Debian 13',architecture:'aarch64',cpu_count:4},system:{cpu_percent:12,memory_percent:25,temperature_c:42,cpu_freq_mhz:1500,memory_used_bytes:1000000,memory_total_bytes:4000000,memory_available_bytes:3000000,storage_inventory:[]},network:{interface:'eth0',kind:'ETH',link_speed_mbps:1000,link:true,ip_label:'IP4',ip:'192.168.1.2',rx_bytes_per_second:10,tx_bytes_per_second:20},display:{owner:'DISK_CRITICAL',alerts:[{id:'DISK_CRITICAL'}]},plugins,watches:[]}})};render('zh');</script>`;}
else if(u.pathname==='/assets/js/state.js')body='export const state={LANG:"zh"};';
else if(u.pathname==='/assets/js/i18n.js')body='export const tr=x=>x;';
else if(u.pathname==='/assets/js/overview.js'){let source=fs.readFileSync(web+'/js/overview.js','utf8');body='import {state as appState} from "/assets/js/state.js";\n'+source.slice(source.indexOf('export function bytes'),source.indexOf('export const connectionLabels')); }
else if(u.pathname==='/plugin/hardware.js')body='export const fanMetric=()=>"";export const pluginSummary=()=>"";';
else {let file=u.pathname.startsWith('/plugin/')?adapter+'/plugin/ui/'+u.pathname.slice(8):web+'/'+u.pathname.replace('/assets/','');if(!fs.existsSync(file))throw Error(file);body=fs.readFileSync(file);if(file.endsWith('.css'))type='text/css';}
await route.fulfill({status:200,contentType:type,body});});
await page.goto('http://audit.local/');await page.waitForFunction(()=>typeof window.render==='function');
for(const [name,width,height] of [['desktop',1440,1000],['tablet',768,1024],['mobile',390,844]]){await page.setViewportSize({width,height});for(const lang of ['zh','en']){await page.evaluate(lang=>render(lang),lang);await page.waitForTimeout(100);const text=await page.locator('body').innerText();if(!text.includes(lang==='zh'?'主要告警':'Primary alert'))throw Error('Missing label');if(text.includes('显示状态'))throw Error('Old label');if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Overflow '+name);await page.screenshot({path:output+'/'+name+'-'+lang+'.png',fullPage:true});}}
const hardware={hat:{name:'Yahboom RGB Cooling HAT',online:true,capabilities:{display:true,rgb:true},status:{available:true,oled_page:'NORMAL_HOME',rgb_mode:'OFF'}}};
for(const width of [1440,768,390]){
await page.setViewportSize({width,height:1000});
await page.evaluate(p=>render('zh',p),hardware);
const primary=page.locator('[data-refresh-group="display"]');
if((await primary.locator('.value').innerText())!=='OLED 正常主页 · RGB 全灭')throw Error('Merged OLED/RGB text');
if((await primary.locator('.pi-alert-heading .semantic-status').innerText())!=='在线')throw Error('Missing hardware online status');
if(await page.locator('[data-manage-plugin-host]').count())throw Error('Overview must not expose plugin management');
if(await page.locator('.group.action-row').count())throw Error('Standalone hardware row remains');
if(!(await primary.innerText()).includes('Yahboom RGB Cooling HAT'))throw Error('Hardware name missing');
if(!(await primary.locator('.pi-alert-heading').evaluate(n=>Math.abs(n.lastElementChild.getBoundingClientRect().right-n.getBoundingClientRect().right)<1)))throw Error('Hardware status is not right aligned');
if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Merged display overflow');
await page.screenshot({path:output+'/merged-'+width+'.png',fullPage:true});
}
hardware.hat.online=false;
await page.evaluate(p=>render('zh',p),hardware);
if((await page.locator('.pi-alert-heading .semantic-status').innerText())!=='离线')throw Error('Offline hardware shown online');
if((await page.locator('[data-refresh-group="display"] .value').innerText()).includes('RGB 全灭'))throw Error('Stale RGB shown as current');
await page.evaluate(()=>render('zh',{}));
if((await page.locator('.pi-alert-heading .semantic-status').innerText())!=='未接入')throw Error('Absent hardware shown online');
if(errors.length)throw Error(errors.join('\n'));console.log('PASS: merged hardware display; online/offline/absent state; plugin management removed from overview; desktop/tablet/mobile; Chinese/English; no overflow or browser errors');await browser.close();})().catch(e=>{console.error(e);process.exit(1)});
+37
View File
@@ -0,0 +1,37 @@
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'plugin'))
import worker
class MetricsTests(unittest.TestCase):
def test_existing_agent_fields_become_standard_history_metrics(self):
value = {'timestamp': 123, 'machine': {'name':'Pi'}, 'system': {
'cpu_percent': 10, 'memory_total_bytes': 8000, 'memory_used_bytes': 4000,
'disk_total_bytes': 16000, 'disk_used_bytes': 5000},
'uptime_seconds': 1000, 'network': {'observed_at':120,'link_speed_mbps':1000,'rx_bytes_per_second':0,'tx_bytes_per_second':1234},
'display': {'alerts': []}}
with patch.object(worker,'request',return_value=value): result=worker.collect({})
values = {m['key']:m['value'] for m in result['metrics']}
self.assertEqual(values['memory_used_bytes'],4000)
self.assertEqual(values['uptime_seconds'],1000)
self.assertEqual(values['network_link_speed_mbps'],1000)
self.assertEqual(values['network_rx_bytes_per_second'],0)
self.assertEqual(values['network_tx_bytes_per_second'],1234)
self.assertEqual(next(m['observed_at'] for m in result['metrics'] if m['key']=='network_tx_bytes_per_second'),120)
self.assertEqual(result['collection']['interval_seconds'],1)
self.assertTrue(result['alerts_complete'])
def test_collection_minimum_and_slower_device(self):
self.assertEqual(worker.collection_interval({'collection':{'interval_seconds':1}}),1)
self.assertEqual(worker.collection_interval({'collection':{'interval_seconds':5}}),5)
self.assertEqual(worker.collection_interval({'collection':{'interval_seconds':True}}),1)
def test_absent_or_boolean_fields_not_invented(self):
value={'system': {'cpu_percent': True}, 'machine':{}, 'network': {'link_speed_mbps': None, 'rx_bytes_per_second':float('nan'), 'tx_bytes_per_second':True}}
with patch.object(worker,'request',return_value=value): result=worker.collect({})
self.assertEqual(result['metrics'],[])
if __name__=='__main__': unittest.main()
+25
View File
@@ -0,0 +1,25 @@
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugin"))
import worker
class StorageMetricsTests(unittest.TestCase):
def test_per_disk_history_has_own_observation_and_keeps_hidden_collection(self):
value = {"timestamp": 100, "machine": {}, "display": {"alerts": []}, "system": {
"storage_inventory_observed_at": 90, "storage_inventory_error": False,
"storage_inventory": [
{"id": "a"*32, "name": "Data", "filesystem": "ext4", "visible": False, "state": "mounted", "capacity_bytes": 1000, "used_bytes": 0, "free_bytes": 1000, "used_percent": 0},
{"id": "b"*32, "name": "USB", "state": "unmounted", "capacity_bytes": 2000},
]}}
with patch.object(worker, "request", return_value=value):
result = worker.collect({})
self.assertEqual(len(result["resources"]), 3)
self.assertEqual(len(result["metrics"]), 4)
self.assertTrue(all(m["observed_at"] == 90 for m in result["metrics"]))
self.assertEqual(result["metrics"][1]["value"], 0)
value["system"]["storage_inventory_error"] = True
with patch.object(worker, "request", return_value=value):
self.assertEqual(worker.collect({})["metrics"], [])