Compare commits

...
27 Commits
Author SHA1 Message Date
way ffb213f279 release: v1.6.12 official PIGWay branding
Verify / test (push) Successful in 6s
2026-10-19 19:22:29 +08:00
way 782ada6d76 feat: integrate official PIGWay v1.1 brand assets
Verify / test (push) Successful in 6s
2026-10-19 19:03:03 +08:00
way 78158a3112 ci: use verified official actions on the shared Server runner
Verify / test (push) Successful in 4s
2026-10-18 22:18:54 +08:00
way 799315f6c9 release: v1.6.11 open-source delivery and audit fixes
Verify / test (push) Failing after 13m45s
2026-10-18 18:45:03 +08:00
way 65f89bffb3 docs: organize project guides and consolidate historical records 2026-10-18 17:59:23 +08:00
way efdff94626 docs: align repository names and bilingual entry points; remove obsolete reports 2026-10-18 17:53:43 +08:00
way ddcf53504f release: publish v1.6.10 2026-10-18 14:43:04 +08:00
way e9d7cc536f fix: show hardware status only when attention is needed 2026-10-18 13:32:51 +08:00
way 18d91fc6f2 fix: use normal blue status for primary alert v1.6.9 2026-10-18 08:35:32 +08:00
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 420 additions and 33 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
# Fork code may run on GitHub-hosted runners, never the trusted Gitea runner.
if: github.server_url == 'https://github.com' || github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13'
- run: python -m unittest discover -s tests -v
- run: git diff --check
+3
View File
@@ -2,3 +2,6 @@ __pycache__/
*.pyc *.pyc
dist/ dist/
.DS_Store .DS_Store
.data/
.pytest_cache/
.ruff_cache/
+9
View File
@@ -0,0 +1,9 @@
# 贡献指南 / Contributing
安装和运行步骤见项目首页;使用 Python 3.13 或 3.14,修改保持小而集中。运行 `python3 -m unittest discover -s tests -v`,Shell 脚本使用 `bash -n`,提交前运行 `git diff --check`。
测试只能使用临时目录、模拟服务和设备桩;不得访问真实设备、执行生产写入或提交密钥、令牌、数据库、照片及部署回执。真实设备验证由维护者另行执行。
请通过仓库 Pull Request 提交,说明问题、行为变化和验证结果。行为修复应覆盖对应回归风险。新增代码按本项目 MIT 许可证提供,保留第三方许可及来源。
See README.md for installation. Keep changes focused, test with mocks, and submit a pull request describing the behavior and checks. Never include credentials or production data. Contributions use the MIT license; preserve third-party notices.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 PIGWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+26 -10
View File
@@ -1,18 +1,34 @@
# PIGWay Agent Console Plugin <p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
独立维护的树莓派接入插件:连接现有 PIGWay Device Agent,不在树莓派安装新进程。设备 API、状态转换、配置字段、服务/进程监控、硬件界面及其翻译均由本仓库维护。 # PIGWay Raspberry Pi Console Adapter
依赖 Python 标准库;接入 Console JSON-RPC v1 与可信前端扩展 `console-ui-v1`。插件 ID 保持 `pigway-agent`,已有设备可直接继续使用。 [简体中文](README.zh-CN.md) | English
打包: 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.12**.
```sh ```sh
mkdir -p dist mkdir -p dist
python3 package.py plugin dist/pigway-agent-1.0.0.zip python3 package.py plugin dist/pigway-agent-1.6.12.zip
``` ```
在控制中心的机器管理页面安装 ZIP,再启用。开发时可用 `PIGWAY_ADAPTER_PATH=/absolute/path/pigway-console-adapter-agent/plugin` 加载源码;Docker 推荐使用持久化数据卷中的安装包。 [Documentation](docs/README.md)
包根目录只有 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,不操作真实设备。
+32
View File
@@ -0,0 +1,32 @@
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
# PIGWay Raspberry Pi Console Plugin
简体中文 | [English](README.md)
磁盘呈现统一版本已上线,需要 Console v2.7.0:默认按设备返回顺序,同组支持上移/下移、显示名称及隐藏,所有显示偏好保存到 Console,首页与配置页共用。Agent 的名称和隐藏定义继续作为设备默认值保留。扫描仍只由配置页按钮触发。
当前 v1.6.12 支持统一磁盘显示设置与手动扫描,需要 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.12.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)
+5
View File
@@ -0,0 +1,5 @@
# pigway-console-adapter-raspberry-pi v1.6.12
正式 PIGWAY Brand Production v1.1 图形标、项目头像与来源说明随项目交付;中英文项目介绍页使用统一品牌图。本版以品牌资源和项目文档交付为主。
软件业务逻辑和设备控制边界沿用上一版本。Logo 使用已发布的 v1.1 原文件,不重新生成或修改颜色、比例。源码包保留 LICENSE 及原有第三方许可说明;插件 ZIP 保留对应许可证。
+7
View File
@@ -0,0 +1,7 @@
# 安全反馈 / Security
仅支持当前发布版本。请勿在公开 Issue 中发布密钥、令牌、个人数据或可直接利用的完整漏洞细节。
请通过仓库维护者 [way](https://tea.pigway.com/way) 的私有联系渠道报告,包含受影响版本、复现条件、影响和最小化示例。若尚未建立私有渠道,先在 Issue 请求私下联系,不附敏感细节。
Only the current release is supported. Contact the maintainer privately before disclosing exploit details; include the version, impact and a minimal reproduction. Do not attach credentials or private data to public issues.
+25 -8
View File
@@ -1,14 +1,31 @@
7d856a7ec552a841097cd14f2f3e357efcd05c13f70eb98a415789e615f6fbf2 .gitignore 6635c6d8b53076d7fd9fe2c3382469420ada532ce010970644e912b877495151 .github/workflows/verify.yml
f577e72ab33e2cc2dcb2fde5f598adab9dd0ae0602cef517c8281eb39bf00eb6 README.md 5b30f5e166b59ce8e59437ebdd753a417e0c6c6776b19bb073832aa2ccc72faa .gitignore
79f34290cfa1a6f0928162909848e6597d46183d0de6b213bf52b12546f5fc33 CONTRIBUTING.md
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
b4f23e1f87dfe3cc19384b421918631965fc6e2165996f3db383029099241eff README.md
2cda0b9dcfe65270735e2ec9bfbd2fadac6556b231405d4fb21fda36aa824cbc README.zh-CN.md
2d6de988d39aa8edc398f24bbb3e9906941e28eaf9b1c702876189ddc20c3ff7 RELEASE_NOTES.md
6cead6c1378cd840f655adc3d467e2cd6fd3ab368d7200a3afbfe85d9ed159be SECURITY.md
1f4df7c43b0f6d138ac40c130511646d6ee9f7f1d9010505479acc7fee2f0e63 docs/README.md
45b7bfb3d8f414e28d50c1f160aee2af8e4150dcd24b815569d48765b1eb413f docs/STORAGE.md
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
d081c599d821149853368b1a8637c57a16995344406ff5ec9d7619b66e0479b3 package.py d081c599d821149853368b1a8637c57a16995344406ff5ec9d7619b66e0479b3 package.py
b7c560b9f529ef8dcdf74f1bc74a36745fc005c2432ff80325e1c9e87573e7b2 plugin/manifest.json 6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc plugin/LICENSE
e5c04001dbd8ed8bba614f7b341237fc6487a9a8fe2f28ccb6f59278725b0a69 plugin/ui/config.js 0374334d71bc844b8c67c8aa5af7b75343148e86983eb64a09d6de0ff02544f5 plugin/manifest.json
d6a342605426357da7d2e65daa435e99d7234549cffa52e1a9229ef5bc36f33c plugin/ui/config.js
c98f34119436e3020ef20b325f91617943aefe662119cf76738e0226c2e91cbb plugin/ui/device.js c98f34119436e3020ef20b325f91617943aefe662119cf76738e0226c2e91cbb plugin/ui/device.js
89b9d7994e7477e18c3f43e6f0975e04d5106056ba581b2964db0f39183d6f8a plugin/ui/hardware.js 89b9d7994e7477e18c3f43e6f0975e04d5106056ba581b2964db0f39183d6f8a plugin/ui/hardware.js
af631694fcd7da21b77b554e6ee1bf2eaa93bd46a0b8b543e367f032d3236ecb plugin/ui/index.js af631694fcd7da21b77b554e6ee1bf2eaa93bd46a0b8b543e367f032d3236ecb plugin/ui/index.js
fadb6bf24d6fcf569fc4157847a3585a1008aad99ce5a0e2b87c9c0658f6d225 plugin/ui/overview.js 67f4d59e288f5390400c52af6a509e940b3ed17f8f520d6bf8bcf7bd345f9336 plugin/ui/overview.js
422e1f8323d29a4be757f39e8b60addd7764c436a1302673809e0c547fce5ff4 plugin/ui/schema.js 422e1f8323d29a4be757f39e8b60addd7764c436a1302673809e0c547fce5ff4 plugin/ui/schema.js
5d967b5d4e62eed7022bc04fe29e4ed670f2e288cf20adae0c25233f94a29079 plugin/ui/services.js 1bee0f9e5656a52bc97d9fafffbf5157dae062888f618046ad09470dc2685f3c plugin/ui/services.js
56e67d2839cbb865133e3b5f796a07f36595c0f6c30480f8e2fac79e7d51e03f plugin/ui/styles.css 6806df689f62de56f7b61f8b3cf3a33c494a09931a14f51d9d71c26d2c1f61ee plugin/ui/storage.js
061f5011b78cedcd0c69a32aa5106889d38f6863f4c613ae326cc9d16f90e2dd plugin/ui/styles.css
0b79b9c5b5f23727164e05b127caca72255416309166ba200ccbd15a9667473c plugin/ui/translations.js 0b79b9c5b5f23727164e05b127caca72255416309166ba200ccbd15a9667473c plugin/ui/translations.js
c12c7b8a3c2fdb5eaef8ca535fcae04d960d4c89c86740529166f3e25cd8c7c9 plugin/worker.py d5b22366a2c7b375e6f3e35cefb9f95da0ea3b8390b3ec4d1000492f00128f6e plugin/worker.py
494c90f6d154edc64aad3169653cc08c7f5fd4a6613341b4c0ff10dd7e0e1cb5 tests/browser_alert_semantics.cjs
40c038aa7689c127c0d90ddf390674f534a2bdee8fd3c344d09f8fb6d4bb3964 tests/test_metrics.py
40d10555aef08f8132f576976b7205b3e1c16d330196a11c6cc1c5cbc167c670 tests/test_storage_metrics.py
+7
View File
@@ -0,0 +1,7 @@
# 文档目录
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
- [磁盘发现、刷新与显示](STORAGE.md)
[贡献指南](../CONTRIBUTING.md) · [安全反馈](../SECURITY.md) · [许可证](../LICENSE)
+19
View File
@@ -0,0 +1,19 @@
# 磁盘发现与显示
## 使用
在 Console → 系统配置 → 选择设备 → 磁盘设置中点击“扫描磁盘”,显式发现新增磁盘。打开页面、普通刷新和重启不执行新增磁盘发现。扫描失败保留已有清单;不执行挂载、格式化或分区操作。
已知磁盘容量每周刷新,CPU、内存与网络按自身周期采集。摘要显示实际采集时间;缺失指标不补零,采集失败不据此确认告警恢复。
显示名称、同组顺序与隐藏偏好统一保存到 Console,首页与配置页共用;Agent 已有名称和隐藏定义作为默认值。隐藏只影响显示,不停止采集或删除历史。
## 适配器边界
适配器连接现有 Agent API,不在设备安装额外进程。扫描请求通过既有认证接口转交 Agent;Console 管理展示偏好和通用历史,不替代 Agent 的磁盘身份和持久化清单。
磁盘显示集成需要 Console v2.7.0 或更新的兼容实现;具体 Agent 与适配器版本要求见项目首页。
## 相关说明
[中文首页](../README.zh-CN.md) · [文档目录](README.md)
+15
View File
@@ -0,0 +1,15 @@
# PIGWAY 品牌资源 / Brand assets
来源:PIGWAY Brand Production v1.1 正式交付包。图形、颜色与比例保持原文件,不使用重绘、滤镜或替代字标。
- `pigway-logo.svg`:正式 Web 图形标。
- `pigway-logo.png`:正式 256px 透明图形标,用于项目介绍页。
- `pigway-avatar.png`:正式 512px 平台图标,用于 Tea 项目头像。
本项目的产品名称保持独立。品牌物料版本不跟随软件版本变化;网页小图标使用同一正式包的 Favicon 和 Apple Touch Icon。
## SHA-256
- `0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210` `pigway-logo.svg`
- `da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7` `pigway-logo.png`
- `f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3` `pigway-avatar.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 PIGWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7 -3
View File
@@ -1,11 +1,12 @@
{ {
"id": "pigway-agent", "id": "pigway-agent",
"name": "PIGWay Agent", "name": "PIGWay Raspberry Pi Agent",
"version": "1.0.0", "version": "1.6.12",
"protocol_version": 1, "protocol_version": 1,
"entrypoint": "worker.py", "entrypoint": "worker.py",
"runtime": "python3", "runtime": "python3",
"capabilities": [ "capabilities": [
"refresh",
"status", "status",
"logs", "logs",
"config", "config",
@@ -38,5 +39,8 @@
"contract": "console-ui-v1", "contract": "console-ui-v1",
"entrypoint": "ui/index.js" "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 { state as appState } from "/assets/js/state.js";
import { addStorageSettings } from "./storage.js";
import { renderDevice } from "./device.js"; import { renderDevice } from "./device.js";
import { renderHosts } from "/assets/js/devices.js"; import { renderHosts } from "/assets/js/devices.js";
import { import {
@@ -59,7 +61,7 @@ export function renderProcessList() {
"processList", "processList",
appState.processCache.map( appState.processCache.map(
(p) => (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"), pluginText("尚未监控任何普通进程", "No process monitors configured"),
); );
@@ -345,6 +347,7 @@ export function renderConfig() {
.forEach((b) => (b.onclick = () => deleteHost(b.dataset.deleteHost))); .forEach((b) => (b.onclick = () => deleteHost(b.dataset.deleteHost)));
bindBulkSync(); bindBulkSync();
updateConditional(); updateConditional();
addStorageSettings();
} }
export function markChanged() { export function markChanged() {
let n = 0; 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 { state as appState } from "/assets/js/state.js";
import { storageCards } from "./storage.js";
import { esc } from "/assets/js/shared.js"; import { esc } from "/assets/js/shared.js";
import { tr } from "/assets/js/i18n.js"; import { tr } from "/assets/js/i18n.js";
import { bytes, formatUptime, meter } from "/assets/js/overview.js"; import { bytes, formatUptime, meter } from "/assets/js/overview.js";
import { fanMetric, pluginSummary } from "./hardware.js"; import { fanMetric } from "./hardware.js";
import { ownerName } from "./schema.js"; import { ownerName, rgbName } from "./schema.js";
export function agentHostCard(entry) { export function agentHostCard(entry) {
const h = entry.host; const h = entry.host;
if (!entry.online) 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, const s = entry.status,
y = s.system || {}, y = s.system || {},
n = s.network || {}, n = s.network || {},
@@ -20,5 +23,17 @@ export function agentHostCard(entry) {
memTotal = y.memory_total_bytes, memTotal = y.memory_total_bytes,
diskUsed = y.disk_used_bytes, diskUsed = y.disk_used_bytes,
diskTotal = y.disk_total_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>${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>`;
} }
+3 -3
View File
@@ -1,5 +1,5 @@
import { state as appState } from "/assets/js/state.js"; 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 { get, rows } from "/assets/js/navigation.js";
import { loadConfig } from "/assets/js/settings.js"; import { loadConfig } from "/assets/js/settings.js";
@@ -31,7 +31,7 @@ export function renderServiceLists() {
"systemServices", "systemServices",
filtered.map( filtered.map(
(s) => (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", "monitoredServices",
monitored.map( monitored.map(
(s) => (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; 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 #!/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 concurrent.futures
import json import json
import math
import sys import sys
import threading import threading
from pathlib import Path 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 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): def collect(device):
value = request(device, '/status') value = request(device, '/status')
if not isinstance(value.get('system'), dict) or not isinstance(value.get('machine'), dict): if not isinstance(value.get('system'), dict) or not isinstance(value.get('machine'), dict):
raise AdapterError('INVALID_RESPONSE') 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 = [] metrics = []
units = {'cpu_percent': 'percent', 'memory_percent': 'percent', 'disk_percent': 'percent', 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(): for key, unit in units.items():
number = value['system'].get(key) number = value['system'].get(key)
if isinstance(number, (int, float)) and not isinstance(number, bool): if isinstance(number, (int, float)) and not isinstance(number, bool):
metrics.append({'resource_id': 'host', 'key': key, 'value': number, 'unit': unit}) 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'), return {'schema_version': 1, 'observed_at': value.get('timestamp'),
'identity': {'reported_name': value['machine'].get('name'), 'category': 'host', 'identity': {'reported_name': value['machine'].get('name'), 'category': 'host',
'details': value.get('device', {})}, 'details': value.get('device', {})},
'collection': {**declaration, 'interval_seconds': collection_interval(value)},
'capabilities': MANIFEST['capabilities'], 'metrics': metrics, 'capabilities': MANIFEST['capabilities'], 'metrics': metrics,
'resources': [{'id': 'host', 'type': 'host'}], 'resources': resources,
'alerts': value.get('display', {}).get('alerts', []), '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} 'view': value}
@@ -87,6 +131,9 @@ def dispatch(method, params):
return request(device, '/health') return request(device, '/health')
if method == 'device.collect': if method == 'device.collect':
return collect(device) 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': if method == 'device.logs.query':
return request(device, '/logs', query=params.get('query')) return request(device, '/logs', query=params.get('query'))
if method == 'device.read': 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-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)});
+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"], [])