Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
799315f6c9 | ||
|
|
65f89bffb3 | ||
|
|
efdff94626 | ||
|
|
ddcf53504f | ||
|
|
e9d7cc536f | ||
|
|
18d91fc6f2 | ||
|
|
1bf3309af3 | ||
|
|
c9ce680a5a | ||
|
|
9e1b9cf179 | ||
|
|
721cec18e7 | ||
|
|
09afd4397c | ||
|
|
90478e5408 | ||
|
|
0b0e18de07 | ||
|
|
84d9a270dd |
@@ -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: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- run: python -m unittest discover -s tests -v
|
||||
- run: git diff --check
|
||||
@@ -3,3 +3,5 @@ __pycache__/
|
||||
dist/
|
||||
.DS_Store
|
||||
.data/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
# 树莓派接入插件 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 镜像保持原版。
|
||||
@@ -1,5 +0,0 @@
|
||||
# 网络卡片 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 每日存储采集保持。
|
||||
@@ -1,5 +0,0 @@
|
||||
# 树莓派接入插件 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 附件下载校验一致。
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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)。
|
||||
@@ -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.
|
||||
@@ -1,36 +1,32 @@
|
||||
# PIGWay Raspberry Pi Console Plugin
|
||||
# PIGWay Raspberry Pi Console Adapter
|
||||
|
||||
独立维护的树莓派接入插件:连接现有 PIGWay Raspberry Pi Agent,不在树莓派安装新进程。设备 API、状态转换、配置字段、服务/进程监控、硬件界面及其翻译均由本仓库维护。
|
||||
[简体中文](README.zh-CN.md) | English
|
||||
|
||||
依赖 Python 标准库;接入 Console JSON-RPC v1 与可信前端扩展 `console-ui-v1`。插件 ID 保持 `pigway-agent`,已有设备可直接继续使用。
|
||||
An independently maintained Device Console adapter for the PIGWay Raspberry Pi Agent. It installs no extra process on the Pi and retains adapter ID `pigway-agent` for existing devices.
|
||||
|
||||
打包:
|
||||
## Requirements and installation
|
||||
|
||||
The storage display and manual discovery integration requires Console v2.7.0 and Agent v3.11.1 or later. The shared UI semantics require Console v2.7.30. Runtime uses the Python standard library, JSON-RPC v1 and `console-ui-v1`.
|
||||
|
||||
Build the ZIP below, upload it under Machine management and enable it. Register the Agent API URL and device token. Use HTTPS or a trusted tunnel across untrusted networks. For local development, set `PIGWAY_ADAPTER_PATH` to this repository's absolute `plugin` directory. Production Docker installations keep installed plugins in the persistent Console data volume.
|
||||
|
||||
## Features and boundaries
|
||||
|
||||
- Host status, hardware integration, services/processes, network metrics and Console-managed history.
|
||||
- Disk discovery runs only from Storage settings → Scan disks. Known disk capacity continues to refresh; display order, names and visibility use the shared Console settings.
|
||||
- The adapter retains existing API and service identifiers, including `pigway-pi-control`; a repository rename does not change those interfaces. Other Linux platforms are outside its support claim.
|
||||
|
||||
Install only trusted plugins: frontend code shares the page JavaScript environment, and the backend process is not a security sandbox. The ZIP contains the manifest, worker, UI and LICENSE, not Console internals or Git metadata.
|
||||
|
||||
See [storage behavior](docs/STORAGE.md), [release notes](RELEASE_NOTES.md), and the [Console UI semantics](https://tea.pigway.com/way/pigway-device-console/src/branch/main/docs/UI_SEMANTICS.md). Dated deployment records describe historical validation, not a fresh online check.
|
||||
|
||||
## Build
|
||||
|
||||
Current adapter version: **1.6.11**.
|
||||
|
||||
```sh
|
||||
mkdir -p dist
|
||||
python3 package.py plugin dist/pigway-agent-1.1.0.zip
|
||||
python3 package.py plugin dist/pigway-agent-1.6.11.zip
|
||||
```
|
||||
|
||||
在控制中心的机器管理页面安装 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-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)。
|
||||
[Documentation](docs/README.md)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# PIGWay Raspberry Pi Console Plugin
|
||||
|
||||
简体中文 | [English](README.md)
|
||||
|
||||
磁盘呈现统一版本已上线,需要 Console v2.7.0:默认按设备返回顺序,同组支持上移/下移、显示名称及隐藏,所有显示偏好保存到 Console,首页与配置页共用。Agent 的名称和隐藏定义继续作为设备默认值保留。扫描仍只由配置页按钮触发。
|
||||
|
||||
当前 v1.6.11 支持统一磁盘显示设置与手动扫描,需要 Console v2.7.0、Agent v3.11.1;新磁盘仅手动发现,已知磁盘容量继续刷新。见 [磁盘发现说明](docs/STORAGE.md)。
|
||||
|
||||
独立维护的树莓派接入插件:连接现有 PIGWay Raspberry Pi Agent,不在树莓派安装新进程。设备 API、状态转换、配置字段、服务/进程监控、硬件界面及其翻译均由本仓库维护。
|
||||
|
||||
依赖 Python 标准库;接入 Console JSON-RPC v1 与可信前端扩展 `console-ui-v1`。插件 ID 保持 `pigway-agent`,已有设备可直接继续使用。
|
||||
|
||||
打包:
|
||||
|
||||
```sh
|
||||
mkdir -p dist
|
||||
python3 package.py plugin dist/pigway-agent-1.6.11.zip
|
||||
```
|
||||
|
||||
在控制中心的机器管理页面安装 ZIP,再启用。开发时可用 `PIGWAY_ADAPTER_PATH=/absolute/path/pigway-console-adapter-raspberry-pi/plugin` 加载源码;Docker 推荐使用持久化数据卷中的安装包。
|
||||
|
||||
包根目录包含 manifest、worker、ui 与 LICENSE;打包工具、文档和 Git 元数据不进入插件包。前端扩展通过 Console 的共享状态、请求、翻译与基础显示函数接入,不注入额外安装步骤。只应安装可信插件:前端模块与主页面共享 JavaScript 执行环境,加载失败、同步异常及 Promise 拒绝可降级,但不能隔离死循环或恶意代码;后端独立进程的隔离也不是安全沙箱。
|
||||
|
||||
正式仓库:https://tea.pigway.com/way/pigway-console-adapter-raspberry-pi 。首次独立版本为 v1.0.0,需要 Console v2.0.0。与 Console 的集成回归使用后者 tests 下的模拟 Agent,不操作真实设备。
|
||||
|
||||
## 命名与兼容性
|
||||
|
||||
本项目面向 Raspberry Pi,不承诺支持其他 Linux 平台。仓库名称不改变现有 `pigway-agent` 适配器 ID,以及 `pigway-pi-control` 服务、配置和 API 标识。
|
||||
|
||||
[文档目录](docs/README.md)
|
||||
@@ -0,0 +1,5 @@
|
||||
# pigway-console-adapter-raspberry-pi v1.6.11
|
||||
|
||||
统一开源许可证、贡献和安全反馈入口;完善当前版本的文档导航、源码分发和校验和。插件 ZIP 携带许可证。运行功能边界保持项目首页所述。
|
||||
|
||||
本轮 4 项 Python 测试通过。源码语法、文档入口、打包和校验和检查通过;生产运行状态以实际部署验收为准。
|
||||
@@ -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.
|
||||
+21
-9
@@ -1,15 +1,27 @@
|
||||
3cfd361f7663a9a9c93b507d17bb1c7e98003d61316826f0a4ba4a93594f72d4 .gitignore
|
||||
1f960195978886b318953a6dbc7f13a551d9bb2bab44aa88b1599c69c8a9ced5 README.md
|
||||
1db9d786fdc6c2ae84b7d317e981b23e303a5bc0b72704d43558b29373d97ade .github/workflows/verify.yml
|
||||
5b30f5e166b59ce8e59437ebdd753a417e0c6c6776b19bb073832aa2ccc72faa .gitignore
|
||||
79f34290cfa1a6f0928162909848e6597d46183d0de6b213bf52b12546f5fc33 CONTRIBUTING.md
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||
54a252d3512c4b4120103ac600364907d014a10c215f61afd281381bbf89c0c4 README.md
|
||||
4f3992613b137b83994941d740cb986e3176b78f319bdc2a4e1cc94b0d32b829 README.zh-CN.md
|
||||
8951d1ad1fdaed9312d4367f5bb4d7adfdab5af4c2dad9231796598e05c60f1e RELEASE_NOTES.md
|
||||
6cead6c1378cd840f655adc3d467e2cd6fd3ab368d7200a3afbfe85d9ed159be SECURITY.md
|
||||
1f4df7c43b0f6d138ac40c130511646d6ee9f7f1d9010505479acc7fee2f0e63 docs/README.md
|
||||
45b7bfb3d8f414e28d50c1f160aee2af8e4150dcd24b815569d48765b1eb413f docs/STORAGE.md
|
||||
d081c599d821149853368b1a8637c57a16995344406ff5ec9d7619b66e0479b3 package.py
|
||||
cf40d1302a8398497e4d6724ec0a22b24420e32ed2697ba3f4d039d193cf38ea plugin/manifest.json
|
||||
e5c04001dbd8ed8bba614f7b341237fc6487a9a8fe2f28ccb6f59278725b0a69 plugin/ui/config.js
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc plugin/LICENSE
|
||||
17893ebf3fa8f8e0e3b875c1ea214aa006cbd06dcf9b20678029f184599df5d9 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
|
||||
67f4d59e288f5390400c52af6a509e940b3ed17f8f520d6bf8bcf7bd345f9336 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
|
||||
0c37e4604a30c13923b45316d21c841164c48e8b632c3a3f8febd778b434c2f8 plugin/worker.py
|
||||
9ffd557bb01ccda24970af1cfc5ec395721d5b1e613bfc013feb27d074582b7e tests/test_metrics.py
|
||||
d5b22366a2c7b375e6f3e35cefb9f95da0ea3b8390b3ec4d1000492f00128f6e plugin/worker.py
|
||||
494c90f6d154edc64aad3169653cc08c7f5fd4a6613341b4c0ff10dd7e0e1cb5 tests/browser_alert_semantics.cjs
|
||||
40c038aa7689c127c0d90ddf390674f534a2bdee8fd3c344d09f8fb6d4bb3964 tests/test_metrics.py
|
||||
40d10555aef08f8132f576976b7205b3e1c16d330196a11c6cc1c5cbc167c670 tests/test_storage_metrics.py
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 文档目录
|
||||
|
||||
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
|
||||
|
||||
- [磁盘发现、刷新与显示](STORAGE.md)
|
||||
|
||||
[贡献指南](../CONTRIBUTING.md) · [安全反馈](../SECURITY.md) · [许可证](../LICENSE)
|
||||
@@ -0,0 +1,19 @@
|
||||
# 磁盘发现与显示
|
||||
|
||||
## 使用
|
||||
|
||||
在 Console → 系统配置 → 选择设备 → 磁盘设置中点击“扫描磁盘”,显式发现新增磁盘。打开页面、普通刷新和重启不执行新增磁盘发现。扫描失败保留已有清单;不执行挂载、格式化或分区操作。
|
||||
|
||||
已知磁盘容量每周刷新,CPU、内存与网络按自身周期采集。摘要显示实际采集时间;缺失指标不补零,采集失败不据此确认告警恢复。
|
||||
|
||||
显示名称、同组顺序与隐藏偏好统一保存到 Console,首页与配置页共用;Agent 已有名称和隐藏定义作为默认值。隐藏只影响显示,不停止采集或删除历史。
|
||||
|
||||
## 适配器边界
|
||||
|
||||
适配器连接现有 Agent API,不在设备安装额外进程。扫描请求通过既有认证接口转交 Agent;Console 管理展示偏好和通用历史,不替代 Agent 的磁盘身份和持久化清单。
|
||||
|
||||
磁盘显示集成需要 Console v2.7.0 或更新的兼容实现;具体 Agent 与适配器版本要求见项目首页。
|
||||
|
||||
## 相关说明
|
||||
|
||||
[中文首页](../README.zh-CN.md) · [文档目录](README.md)
|
||||
@@ -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.
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"id": "pigway-agent",
|
||||
"name": "PIGWay Raspberry Pi Agent",
|
||||
"version": "1.3.1",
|
||||
"version": "1.6.11",
|
||||
"protocol_version": 1,
|
||||
"entrypoint": "worker.py",
|
||||
"runtime": "python3",
|
||||
"capabilities": [
|
||||
"refresh",
|
||||
"status",
|
||||
"logs",
|
||||
"config",
|
||||
@@ -40,6 +41,6 @@
|
||||
},
|
||||
"legacy_connection_fields": true,
|
||||
"collection": {
|
||||
"interval_seconds": 2
|
||||
"interval_seconds": 1
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
@@ -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">↓ ${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><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>${hardwareState === "online" ? "" : `<span class="semantic-status" data-semantic="${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>`;
|
||||
}
|
||||
|
||||
@@ -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>`,
|
||||
),
|
||||
"尚未监控任何服务",
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
+23
-4
@@ -68,6 +68,9 @@ 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',
|
||||
@@ -90,17 +93,30 @@ def collect(device):
|
||||
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('observed_at') if metric['key'] != 'uptime_seconds' else value.get('timestamp')
|
||||
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': {'interval_seconds': collection_interval(value)},
|
||||
'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}
|
||||
|
||||
|
||||
@@ -115,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':
|
||||
|
||||
@@ -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-device-console/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').count())throw Error('Healthy hardware must not show redundant 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 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('.pi-alert-heading').evaluate(n=>Math.abs(n.lastElementChild.getBoundingClientRect().right-n.getBoundingClientRect().right)<1)))throw Error('Hardware issue status is not right aligned');
|
||||
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)});
|
||||
@@ -21,13 +21,13 @@ class MetricsTests(unittest.TestCase):
|
||||
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'],2)
|
||||
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}}),2)
|
||||
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}}),2)
|
||||
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}}
|
||||
|
||||
@@ -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"], [])
|
||||
Reference in New Issue
Block a user