refactor: separate monitoring from hardware plugins
This commit is contained in:
@@ -1,275 +1,68 @@
|
|||||||
# PIGWay Pi Control v3.7.0
|
# PIGWay Pi Control v3.7.0
|
||||||
|
|
||||||
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理。无需购买散热板,无 I2C、Pillow、smbus2 或 OLED 字体依赖。
|
||||||
|
|
||||||
v3.7.0 在现有本机硬件监控基础上增加多机状态、日志和配置管理;未登记其他节点时继续作为完整的单机版本运行。
|
## 功能
|
||||||
|
|
||||||
## 已有核心功能
|
- CPU、温度、内存和磁盘容量/用量;主网络接口、IPv4/IPv6、Wi-Fi 信号百分比。
|
||||||
|
- systemd 服务及进程监控;暂停监控与启停服务是不同操作。
|
||||||
|
- Raspberry Pi 当前欠压、降频、频率受限、温度限制和根分区只读监控。
|
||||||
|
- 当前故障统一管理;历史电源位仅记录日志,不生成当前告警。
|
||||||
|
- 事件日志按日期、严重程度、事件类型和机器查询、排序。
|
||||||
|
- 多机状态、机器标识、配置与独立的批量配置同步。
|
||||||
|
- 中英文、跟随系统/深色/浅色主题。
|
||||||
|
- 独立硬件插件发现、手动接入、暂停联动、断开、能力配置。
|
||||||
|
|
||||||
- SSD1306 128×32 OLED,四行固定主页
|
## 安装
|
||||||
- CPU / 内存 / 温度 / 磁盘监控
|
|
||||||
- Wi-Fi RSSI / Ethernet 链路速率
|
|
||||||
- 当前默认路由与 IP 同步切换
|
|
||||||
- 风扇自动温控、回差防抖
|
|
||||||
- RGB:自定义颜色表示故障对象,频闪速度表示严重程度
|
|
||||||
- systemd 服务与普通进程监控
|
|
||||||
- Raspberry Pi PWR / ACT 运行时 Dark Mode
|
|
||||||
|
|
||||||
主页示例:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CPU 18.6% MEM 16.2%
|
|
||||||
TMP 46.3C FAN OFF
|
|
||||||
DSK 22.4% WIF -32
|
|
||||||
IP4 192.168.89.130
|
|
||||||
```
|
|
||||||
|
|
||||||
## v3.5.0 System Health
|
|
||||||
|
|
||||||
### 1. 供电与降频健康
|
|
||||||
|
|
||||||
周期读取:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vcgencmd get_throttled
|
sudo ./install.sh
|
||||||
|
# 仅安装文件,不改变服务运行状态:
|
||||||
|
sudo ./install.sh --no-start
|
||||||
```
|
```
|
||||||
|
|
||||||
区分:
|
仅需要 Python 3。安装器不检查 I2C,不安装或启动任何散热板驱动。
|
||||||
|
配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。
|
||||||
|
|
||||||
- 当前欠压:立即 `POWER LOW / UNDERVOLT` 告警
|
API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器显示查询方法,不直接打印秘密:
|
||||||
- 当前 CPU throttled / frequency capped:立即告警
|
|
||||||
- 本次启动历史上曾欠压/降频:只写一次历史事件日志,不持续 RGB 告警
|
|
||||||
- 状态恢复:写 `RECOVERED` 类事件
|
|
||||||
|
|
||||||
注意:这里检测的是 **Pi 的供电质量**,不是 UPS 电池百分比。
|
|
||||||
|
|
||||||
### 2. 文件系统健康
|
|
||||||
|
|
||||||
检测根文件系统 `/` 是否被内核切成只读。如果出现只读状态,产生严重告警:
|
|
||||||
|
|
||||||
```text
|
|
||||||
FILESYSTEM RO
|
|
||||||
ROOT READ ONLY
|
|
||||||
CHECK STORAGE
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 本机诊断信息
|
|
||||||
|
|
||||||
内部维护:
|
|
||||||
|
|
||||||
- uptime
|
|
||||||
- load average
|
|
||||||
- CPU 当前频率
|
|
||||||
- 当前/历史 throttled flags
|
|
||||||
- 当前主网络接口 / IP
|
|
||||||
- 根文件系统只读状态
|
|
||||||
- sensor / network / service / health 最近成功时间
|
|
||||||
- 主循环异常计数
|
|
||||||
- I²C相关异常计数基础字段
|
|
||||||
|
|
||||||
### 4. DisplayController、OLED 与 RGB
|
|
||||||
|
|
||||||
每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个最高优先级 owner;同一 ID 更新原请求而不重复入队,恢复时删除请求,同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 与 RGB 分别由专属工作线程驱动,监控主循环只更新共享显示状态;OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即唤醒 OLED 线程。告警页显示时 RGB 同步表达该页对象;回到主页时 RGB 保持最高优先级 active owner,全部恢复后才 OFF。相邻 OLED 页面需要的 RGB mode 相同时,RGB 线程不写寄存器、不重置动画相位。 风扇与 RGB 共用 MCU 0x0D;RGB 正在告警闪烁时,风扇档位命令排到灭灯阶段写入,并在写入后重新确认 RGB OFF,以 `FAN_LEVEL` 事件记录实际写入。历史 power flags 只进入 diagnose 和一次性 Journal 记录。
|
|
||||||
|
|
||||||
### 5. RGB 正常状态与告警引擎
|
|
||||||
|
|
||||||
没有 active alert 时,RGB 默认全灭。`[led] normal_mode` 可改为自定义颜色常亮/闪烁,或 MCU 已确认的流水、呼吸、跑马、彩虹和炫彩效果。常亮与闪烁使用 0..255 的自定义 R/G/B;内置流水/呼吸使用 MCU 固定的红、绿、蓝、黄、紫、青、白七色和慢/中/快三档速度,跑马/彩虹/炫彩由 MCU 自行配色。内置效果只在模式切换时按 speed → color → effect 的实机校准顺序写一次寄存器,不循环刷 I²C。
|
|
||||||
|
|
||||||
任何 active alert 都会立即抢占正常效果。RGB 告警模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。
|
|
||||||
|
|
||||||
### 6. 结构化事件日志
|
|
||||||
|
|
||||||
日志继续交给 systemd journal,不额外制造长期 `.log` 文件。
|
|
||||||
|
|
||||||
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
|
|
||||||
|
|
||||||
```text
|
|
||||||
level=INFO event=START version=3.7.0
|
|
||||||
level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ...
|
|
||||||
level=WARN event=NETWORK_DOWN ...
|
|
||||||
level=INFO event=NETWORK_RECOVERED ...
|
|
||||||
level=WARN event=WATCH_DOWN name=AWESUN ...
|
|
||||||
level=INFO event=WATCH_RECOVERED name=AWESUN ...
|
|
||||||
level=WARN event=POWER_LOW raw=0x1
|
|
||||||
level=INFO event=POWER_RECOVERED raw=0x0
|
|
||||||
level=WARN event=ALERT_ACTIVE alert=TEMP_HIGH ...
|
|
||||||
level=INFO event=ALERT_RECOVERED ...
|
|
||||||
```
|
|
||||||
|
|
||||||
实时查看:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u pigway-pi-control -f
|
|
||||||
```
|
|
||||||
|
|
||||||
只看本次启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u pigway-pi-control -b --no-pager
|
|
||||||
```
|
|
||||||
|
|
||||||
搜索某类事件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u pigway-pi-control --no-pager | grep 'event=POWER'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. 诊断命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
|
||||||
```
|
|
||||||
|
|
||||||
输出 Agent 版本、系统 uptime、配置路径、load、CPU 频率、根文件系统、全部当前/历史 power 位、主网络接口、完整 IP 和 Internet 状态,以及所有 service/process watch 状态。
|
|
||||||
|
|
||||||
诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。
|
|
||||||
|
|
||||||
RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜色配合低频亮灭最稳定;每次点亮完整写入 selector/R/G/B,寄存器间隔默认10ms。旧版 `brightness`、呼吸周期和颜色选项可以继续留在用户配置中,但频闪引擎会忽略它们。
|
|
||||||
|
|
||||||
### 8. 关机收尾
|
|
||||||
|
|
||||||
收到 systemd SIGTERM / 系统关机时:
|
|
||||||
|
|
||||||
1. 写 STOP 日志
|
|
||||||
2. RGB OFF
|
|
||||||
3. 风扇 OFF
|
|
||||||
4. OLED 清屏并 Display OFF
|
|
||||||
5. 关闭 I²C handle
|
|
||||||
|
|
||||||
Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。
|
|
||||||
|
|
||||||
## v3.7.0 多机监控
|
|
||||||
|
|
||||||
每台机器在 `[device]` 中设置稳定标识和显示名称;保留 `auto` 时读取本机 hostname。作为 Web 聚合入口的机器可在 `[hosts]` 中登记其他已安装 PIGWay Pi Control 的节点:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[device]
|
|
||||||
identifier = living-room
|
|
||||||
name = 客厅树莓派
|
|
||||||
|
|
||||||
[hosts]
|
|
||||||
study = http://192.168.1.20:6001
|
|
||||||
nas-room = http://192.168.1.21:6001
|
|
||||||
```
|
|
||||||
|
|
||||||
未设置 `[hosts]` 时,API 和 Web 保持单机使用方式。聚合入口通过各节点现有 HTTP API 读取状态、journal 和配置;节点不可达时会在机器卡片上显示离线,不影响其他节点。
|
|
||||||
|
|
||||||
新增接口:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /api/v1/hosts
|
|
||||||
PUT /api/v1/hosts
|
|
||||||
GET /api/v1/fleet/status
|
|
||||||
GET /api/v1/fleet/logs
|
|
||||||
PUT /api/v1/fleet/config
|
|
||||||
```
|
|
||||||
|
|
||||||
运行状态按机器显示独立卡片,包括机器标识、hostname、型号、序列号、板卡版本、操作系统、内核、架构、CPU 核数和 Agent 版本。内存和根分区同时显示百分比、已用量、总量及可用/剩余量;Wi-Fi 以 0–100% 为主值,并保留原始 dBm。OLED 的 Wi-Fi 主值同样使用百分比。
|
|
||||||
|
|
||||||
事件日志增加机器列、机器筛选和机器排序。系统配置可选择目标机器;批量同步只发送当前已修改的配置项,并逐台返回成功或失败结果。远端 Bearer Token 只保存在当前浏览器的 localStorage 中,不写入聚合机器配置文件。
|
|
||||||
|
|
||||||
## v3.6.0 API 与 Web
|
|
||||||
|
|
||||||
安装后访问:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://树莓派IP:6001/
|
|
||||||
```
|
|
||||||
|
|
||||||
Web 与 JSON API 由独立的 `pigway-pi-control-api.service` 提供。API 服务不访问 I²C;硬件 Agent 每秒原子更新 `/run/pigway-pi-control/status.json`,API 只读取该快照。默认监听地址和端口可在 `[api]` 中修改:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[api]
|
|
||||||
bind = 0.0.0.0
|
|
||||||
port = 6001
|
|
||||||
log_limit = 200
|
|
||||||
```
|
|
||||||
|
|
||||||
只读接口:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /api/v1/health
|
|
||||||
GET /api/v1/status
|
|
||||||
GET /api/v1/logs?limit=200
|
|
||||||
GET /api/v1/config
|
|
||||||
GET /api/v1/services
|
|
||||||
```
|
|
||||||
|
|
||||||
日志接口支持 `since`、`until` Unix 时间戳,`severity`、`event`、`search` 筛选,`timestamp` / `severity` / `event` / `message` 排序,以及 `limit` / `offset` 分页。Web 提供今天、昨天、本周、本月和全部快捷范围。日志直接读取 systemd journal 的现有保留数据,不建立重复的 SQLite 日志副本。
|
|
||||||
|
|
||||||
Web 界面支持中文和 English 切换,并将结构化 journal 事件类型显示为当前界面的语言;journal 内部稳定的事件标识保持不变,便于脚本筛选。外观可选择自动、深色或浅色,自动模式跟随浏览器/操作系统配色。语言和外观偏好只保存在当前浏览器中。
|
|
||||||
|
|
||||||
配置写入接口:
|
|
||||||
|
|
||||||
```text
|
|
||||||
PUT /api/v1/config
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
|
|
||||||
```
|
|
||||||
|
|
||||||
服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。API 服务不能通过自己的请求停止自身,其他服务由用户自行管理。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。
|
|
||||||
|
|
||||||
“暂停监控/恢复监控”只改变 PIGWay 是否检查该服务,不会启动或停止 systemd unit;暂停状态保存在 `[service_monitor_disabled]`。只有明确标注为“启动服务/停止服务”的按钮才会调用 systemctl。
|
|
||||||
|
|
||||||
读取 token:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cat /etc/pigway-pi-control-api.token
|
sudo cat /etc/pigway-pi-control-api.token
|
||||||
```
|
```
|
||||||
|
|
||||||
配置更新仅允许 `[services]`、`[processes]`、`[alerts]`、`[fan]`、`[led]`、`[timing]`、`[dark_mode]`、`[display]` 和 `[api]`。每次写入先生成时间戳备份,再原地更新目标键并重启硬件 Agent;修改 `[api]` 时 API 服务也会自动重启。
|
服务分别是 `pigway-pi-control.service` 和 `pigway-pi-control-api.service`。
|
||||||
|
Web 重启不会自动启动已停止的监控。监控与硬件插件的运行状态彼此独立。
|
||||||
|
|
||||||
## 用户配置
|
## 接入可选硬件
|
||||||
|
|
||||||
正式配置只有一个:
|
[Yahboom 散热板插件](https://tea.pigway.com/way/pigway-cooling-hat) 是独立项目、独立安装包及 systemd 服务。
|
||||||
|
先在目标机器安装并运行插件,再进入 Web → 系统配置 → 硬件插件 → 发现并接入。
|
||||||
|
发现不等于接管;只有接入后才发送当前监控状态。无需开放新的网络端口。
|
||||||
|
|
||||||
```text
|
运行状态卡片仅为已接入插件显示摘要。插件配置根据能力生成:只有风扇就只有风扇设置,有 OLED/RGB 才有相应控件。保存插件配置不重启监控。暂停告警联动或断开接入不停止插件本地温控。
|
||||||
/etc/pigway-pi-control.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
编辑:
|
默认 Unix socket:`/run/pigway-plugins/<id>/api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。当前只实现 Yahboom 驱动;GPIO/PWM、其他 I2C 板和系统风扇驱动需要各自适配,不假装兼容。
|
||||||
|
|
||||||
|
## 从旧的集成版本迁移
|
||||||
|
|
||||||
|
监控安装器保留原配置及注释;`[hardware]`、`[fan]`、`[led]`、`[display]` 和 OLED timing 参数不再由监控执行或显示。
|
||||||
|
在散热板仓库中可显式迁移这些参数:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo nano /etc/pigway-pi-control.conf
|
sudo ./install.sh --import-monitor-config /etc/pigway-pi-control.conf
|
||||||
sudo systemctl restart pigway-pi-control
|
|
||||||
```
|
```
|
||||||
|
|
||||||
配置文件内已经写明每项用途、单位和修改方式。
|
不能让旧集成 Agent 和新插件同时控制硬件。迁移前停止旧 Agent,再安装独立监控和插件;新监控本身不会打开 I2C。字体原样迁入散热板仓库,不重新生成。原主板 PWR/ACT 配置继续作为监控本机设置兼容保留。
|
||||||
|
|
||||||
## 安装 / 升级测试分支
|
## 运维
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./install.sh
|
sudo systemctl status pigway-pi-control.service pigway-pi-control-api.service
|
||||||
```
|
sudo journalctl -u pigway-pi-control.service -f
|
||||||
|
|
||||||
安装器会:
|
|
||||||
|
|
||||||
- 安装必要依赖
|
|
||||||
- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用
|
|
||||||
- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务
|
|
||||||
- 确认需要多个进程共用 I²C 时,可用 `sudo ./install.sh --force-i2c-conflict` 强制继续;冲突进程仍由用户自行管理
|
|
||||||
- 安装字体、程序、配置和 systemd unit
|
|
||||||
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
|
|
||||||
- 生成只读 root token,安装 API/Web 文件
|
|
||||||
- 启动硬件 Agent 与 API/Web 服务
|
|
||||||
- 执行版本、OLED、UI、P0健康监控和诊断入口自检
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status pigway-pi-control --no-pager -l
|
|
||||||
journalctl -u pigway-pi-control-api -f
|
|
||||||
journalctl -u pigway-pi-control -f
|
|
||||||
sudo systemctl restart pigway-pi-control
|
|
||||||
sudo systemctl restart pigway-pi-control-api
|
|
||||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||||
```
|
```
|
||||||
|
|
||||||
## 版本路线
|
`--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。
|
||||||
|
卸载监控不会停止或删除散热板插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。
|
||||||
|
|
||||||
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。
|
||||||
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
|
|
||||||
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
|
||||||
- **v3.7.0:多机状态卡片、聚合日志、机器配置选择与批量同步**
|
|
||||||
|
|||||||
+10
-9
@@ -1,10 +1,11 @@
|
|||||||
4ac63c19d60ed3bb9d804727fe3d373a338f8203a2730a63876fb1845574822c README.md
|
ffd1790c882ca5dbcaebdfadc8f24c504159900213eebaa22e61fcdbf3facfe0 README.md
|
||||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh
|
||||||
e925e9b4720b24f2c802cdb746cfa9e2f5af1de84a7533acb09ab1efcce73a82 app/pigway_pi_control.py
|
|
||||||
034931590d83969f9f24222cc57800ab193588beea3d3dcedb98f1690000370d app/pigway_pi_control_api.py
|
|
||||||
ee0f682e8016f75f906d974f00c3e3340edddf30447d441ddfd61a7a34c9e83c config/pigway-pi-control.conf
|
|
||||||
f9fbb4defa45b43abe0ce66973b1f4d553369b3a43c01d04f206c573f7dc96ca install.sh
|
|
||||||
33a681e896cae05b9e9d5f89c5387036c9c1ea48d4db937ae03e5cdb7322705d systemd/pigway-pi-control.service
|
|
||||||
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
|
||||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||||
27454feccb83ad23342fa9d66caed60d568beeed4862852df98a3bc82bf5b6e3 web/index.html
|
8b9df5b92218ff1c809d570141b2568228a1a4bdcb041eca29fcc6b51c61889c app/pigway_pi_control.py
|
||||||
|
339f28978641242436e141add0cc923ed5ebb855bea9f6c9fe949b8eb8579527 app/pigway_pi_control_api.py
|
||||||
|
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
||||||
|
3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf
|
||||||
|
20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service
|
||||||
|
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
|
||||||
|
c953710a48728927c9327ba9f94440d0368233e4bd5562d4deb3b1adf0b51851 web/index.html
|
||||||
|
fa7ab991a7a39cce72b218e407dcefbcede99c8d8eb3d9c161a5296d506c41a9 docs/HARDWARE_PLUGIN_API.md
|
||||||
|
|||||||
Binary file not shown.
+73
-518
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json,os,platform
|
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
APP_VERSION="3.7.0"
|
APP_VERSION="3.7.0"
|
||||||
from smbus2 import SMBus
|
import sys
|
||||||
from PIL import Image
|
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||||
from shutil import disk_usage
|
from shutil import disk_usage
|
||||||
|
from plugin_api import Publisher,registered,discover
|
||||||
|
|
||||||
OLED=0x3C; MCU=0x0D; W,H=128,32
|
|
||||||
CFG="/etc/pigway-pi-control.conf"
|
CFG="/etc/pigway-pi-control.conf"
|
||||||
STATUS_PATH="/run/pigway-pi-control/status.json"
|
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||||
running=True
|
running=True
|
||||||
@@ -84,23 +84,6 @@ def throttled_state():
|
|||||||
if not m: return {"raw":"UNKNOWN","error":"vcgencmd get_throttled unavailable or invalid"}
|
if not m: return {"raw":"UNKNOWN","error":"vcgencmd get_throttled unavailable or invalid"}
|
||||||
return {"raw":f"0x{v:x}","under_voltage_now":bool(v&1),"freq_capped_now":bool(v&2),"throttled_now":bool(v&4),"soft_temp_limit_now":bool(v&8),"under_voltage_occurred":bool(v&(1<<16)),"freq_capped_occurred":bool(v&(1<<17)),"throttled_occurred":bool(v&(1<<18)),"soft_temp_limit_occurred":bool(v&(1<<19))}
|
return {"raw":f"0x{v:x}","under_voltage_now":bool(v&1),"freq_capped_now":bool(v&2),"throttled_now":bool(v&4),"soft_temp_limit_now":bool(v&8),"under_voltage_occurred":bool(v&(1<<16)),"freq_capped_occurred":bool(v&(1<<17)),"throttled_occurred":bool(v&(1<<18)),"soft_temp_limit_occurred":bool(v&(1<<19))}
|
||||||
|
|
||||||
bus=None
|
|
||||||
bus_lock=threading.RLock()
|
|
||||||
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
|
|
||||||
FONT5=b""
|
|
||||||
agent={"loop_errors":0,"i2c_errors":0}
|
|
||||||
|
|
||||||
def bus_write(method,*args):
|
|
||||||
with bus_lock:
|
|
||||||
try: return getattr(bus,method)(*args)
|
|
||||||
except OSError:
|
|
||||||
agent["i2c_errors"]+=1
|
|
||||||
raise
|
|
||||||
|
|
||||||
fan_level=0
|
|
||||||
fan_initialized=False
|
|
||||||
pending_fan=None
|
|
||||||
pending_fan_lock=threading.Lock()
|
|
||||||
prev_cpu=None
|
prev_cpu=None
|
||||||
page=0; page_since=time.monotonic()
|
page=0; page_since=time.monotonic()
|
||||||
cpu_hi=mem_hi=0
|
cpu_hi=mem_hi=0
|
||||||
@@ -110,56 +93,6 @@ wifi_weak_since=None; wifi_weak_active=False
|
|||||||
last_net=last_watch=0
|
last_net=last_watch=0
|
||||||
watch_state=[]
|
watch_state=[]
|
||||||
|
|
||||||
def cmd(c): bus_write("write_byte_data",OLED,0x00,c)
|
|
||||||
def oled_init():
|
|
||||||
# Seller-native physical 128x32 configuration.
|
|
||||||
for c in [0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
|
|
||||||
0x20,0x00,0xA1,0xC8,0xDA,0x02,0x81,0x7F,0xD9,0xF1,
|
|
||||||
0xDB,0x40,0xA4,0xA6,0xAF]:
|
|
||||||
cmd(c)
|
|
||||||
|
|
||||||
def draw_text_5x7(pix,x,y,text):
|
|
||||||
for ch in text:
|
|
||||||
code=ord(ch)
|
|
||||||
if code<0 or code>255: code=ord("?")
|
|
||||||
off=code*5
|
|
||||||
if off+5>len(FONT5): code=ord("?"); off=code*5
|
|
||||||
for cx in range(5):
|
|
||||||
col=FONT5[off+cx]
|
|
||||||
for cy in range(7):
|
|
||||||
if col & (1<<cy):
|
|
||||||
xx=x+cx; yy=y+cy
|
|
||||||
if 0<=xx<W and 0<=yy<H: pix[xx,yy]=1
|
|
||||||
x+=6
|
|
||||||
if x>W-6: break
|
|
||||||
|
|
||||||
def oled_show(lines_lr, title_zh=False):
|
|
||||||
im=Image.new("1",(W,H),0); pix=im.load()
|
|
||||||
for row,(left,right) in enumerate(lines_lr[:4]):
|
|
||||||
y=row*8
|
|
||||||
draw_text_5x7(pix,0,y,left)
|
|
||||||
if right:
|
|
||||||
draw_text_5x7(pix,max(0,W-len(right)*6),y,right)
|
|
||||||
data=[]
|
|
||||||
for pg in range(4):
|
|
||||||
for x in range(W):
|
|
||||||
v=0
|
|
||||||
for bit in range(8):
|
|
||||||
if pix[x,pg*8+bit]: v|=1<<bit
|
|
||||||
data.append(v)
|
|
||||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
|
||||||
for i in range(0,len(data),16):
|
|
||||||
bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16])
|
|
||||||
|
|
||||||
|
|
||||||
def mcu(reg,val): bus_write("write_byte_data",MCU,reg,val)
|
|
||||||
_last_rgb=None
|
|
||||||
def rgb_off():
|
|
||||||
global _last_rgb
|
|
||||||
if _last_rgb != ("off",):
|
|
||||||
mcu(0x07,0x00) # seller closeRGB(): true OFF
|
|
||||||
_last_rgb=("off",)
|
|
||||||
|
|
||||||
def set_pi_led(name,off=True):
|
def set_pi_led(name,off=True):
|
||||||
base=Path("/sys/class/leds")/name
|
base=Path("/sys/class/leds")/name
|
||||||
try:
|
try:
|
||||||
@@ -173,47 +106,6 @@ def apply_dark_mode():
|
|||||||
if DARK_PWR: set_pi_led("PWR",True)
|
if DARK_PWR: set_pi_led("PWR",True)
|
||||||
if DARK_ACT: set_pi_led("ACT",True)
|
if DARK_ACT: set_pi_led("ACT",True)
|
||||||
|
|
||||||
FAN_REG=[0x00,0x04,0x06,0x08,0x09,0x01]
|
|
||||||
FAN_NAME=["OFF","L1","L2","L3","L4","MAX"]
|
|
||||||
def fan_target(t):
|
|
||||||
if t<FAN_T[0]: return 0
|
|
||||||
if t<FAN_T[1]: return 1
|
|
||||||
if t<FAN_T[2]: return 2
|
|
||||||
if t<FAN_T[3]: return 3
|
|
||||||
if t<FAN_T[4]: return 4
|
|
||||||
return 5
|
|
||||||
def update_fan(t):
|
|
||||||
global fan_level,fan_initialized,pending_fan
|
|
||||||
want=fan_target(t); old=fan_level if fan_initialized else None
|
|
||||||
if want>fan_level: fan_level=want
|
|
||||||
elif want<fan_level:
|
|
||||||
boundary = FAN_T[max(0,fan_level-1)] - FAN_HYST
|
|
||||||
if t < boundary: fan_level-=1
|
|
||||||
if not fan_initialized or old!=fan_level:
|
|
||||||
with pending_fan_lock:
|
|
||||||
pending_fan=(old,fan_level,float(t))
|
|
||||||
fan_initialized=True
|
|
||||||
|
|
||||||
def apply_pending_fan(keep_rgb_off=False):
|
|
||||||
global pending_fan
|
|
||||||
with pending_fan_lock:
|
|
||||||
command=pending_fan
|
|
||||||
pending_fan=None
|
|
||||||
if command is None: return
|
|
||||||
old,new,temp=command
|
|
||||||
try:
|
|
||||||
mcu(0x08,FAN_REG[new])
|
|
||||||
# Some revisions of the shared MCU briefly expose their previous RGB
|
|
||||||
# state after a fan-register write. When the flash engine is already
|
|
||||||
# in its dark phase, immediately reassert OFF without changing phase.
|
|
||||||
if keep_rgb_off: mcu(0x07,0x00)
|
|
||||||
except Exception:
|
|
||||||
with pending_fan_lock:
|
|
||||||
if pending_fan is None: pending_fan=command
|
|
||||||
raise
|
|
||||||
log("INFO","FAN_LEVEL",old=FAN_NAME[old] if old is not None else "UNKNOWN",
|
|
||||||
new=FAN_NAME[new],temp_c=f"{temp:.1f}",register=f"0x{FAN_REG[new]:02x}")
|
|
||||||
|
|
||||||
def cpu_pct():
|
def cpu_pct():
|
||||||
global prev_cpu
|
global prev_cpu
|
||||||
p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9]))
|
p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9]))
|
||||||
@@ -290,10 +182,7 @@ if not DEVICE_ID or DEVICE_ID.lower()=="auto": DEVICE_ID=socket.gethostname()
|
|||||||
DEVICE_NAME=cf("device","name",DEVICE_ID,str).strip() or DEVICE_ID
|
DEVICE_NAME=cf("device","name",DEVICE_ID,str).strip() or DEVICE_ID
|
||||||
if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID
|
if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID
|
||||||
DEVICE_INFO=device_details()
|
DEVICE_INFO=device_details()
|
||||||
|
|
||||||
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
||||||
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
|
||||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
|
||||||
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
||||||
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
||||||
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
|
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
|
||||||
@@ -312,44 +201,9 @@ WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
|
|||||||
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
||||||
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
|
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
|
||||||
|
|
||||||
FAN_T=[cf("fan","start",50.0),cf("fan","level2",53.0),cf("fan","level3",56.0),
|
|
||||||
cf("fan","level4",59.0),cf("fan","max",62.0)]
|
|
||||||
FAN_HYST=cf("fan","hysteresis",1.5)
|
|
||||||
ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","row3_y",16,int),cf("display","row4_y",24,int)]
|
|
||||||
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||||
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||||
RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int))
|
ALERT_CATEGORIES={
|
||||||
WARNING_FLASH=(max(50,cf("led","warning_flash_on_ms",1500,int)),max(50,cf("led","warning_flash_off_ms",1500,int)))
|
|
||||||
CRITICAL_FLASH=(max(50,cf("led","critical_flash_on_ms",750,int)),max(50,cf("led","critical_flash_off_ms",750,int)))
|
|
||||||
EMERGENCY_FLASH=(max(50,cf("led","emergency_flash_on_ms",300,int)),max(50,cf("led","emergency_flash_off_ms",300,int)))
|
|
||||||
NORMAL_MODE=cf("led","normal_mode","off",str).strip().lower()
|
|
||||||
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
|
|
||||||
if NORMAL_MODE not in NORMAL_MODES: NORMAL_MODE="off"
|
|
||||||
NORMAL_COLOR=tuple(max(0,min(255,cf("led",f"normal_{channel}",value,int)))
|
|
||||||
for channel,value in zip(("r","g","b"),(0,0,0)))
|
|
||||||
NORMAL_FLASH=(max(50,cf("led","normal_flash_on_ms",1000,int)),
|
|
||||||
max(50,cf("led","normal_flash_off_ms",1000,int)))
|
|
||||||
NORMAL_EFFECT_SPEED=max(1,min(3,cf("led","normal_effect_speed",2,int)))
|
|
||||||
NORMAL_EFFECT_COLOR=cf("led","normal_effect_color","green",str).strip().lower()
|
|
||||||
RGB_EFFECT_COLORS={"red":0,"green":1,"blue":2,"yellow":3,"purple":4,"cyan":5,"white":6}
|
|
||||||
if NORMAL_EFFECT_COLOR not in RGB_EFFECT_COLORS: NORMAL_EFFECT_COLOR="green"
|
|
||||||
RGB_EFFECTS={"flow":0,"breathe":1,"marquee":2,"rainbow":3,"colorful":4}
|
|
||||||
NORMAL_RGB_MODE={"off":"OFF","solid":"NORMAL_SOLID","flash":"NORMAL_FLASH",
|
|
||||||
**{name:f"NORMAL_{name.upper()}" for name in RGB_EFFECTS}}[NORMAL_MODE]
|
|
||||||
|
|
||||||
def rgb_cfg(name,default):
|
|
||||||
return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int)))
|
|
||||||
for channel,value in zip(("r","g","b"),default))
|
|
||||||
|
|
||||||
RGB_GROUP_VALUES={
|
|
||||||
"CPU":rgb_cfg("cpu",(255,0,0)),
|
|
||||||
"POWER":rgb_cfg("power",(255,40,0)),
|
|
||||||
"MEMORY":rgb_cfg("memory",(255,0,255)),
|
|
||||||
"STORAGE":rgb_cfg("storage",(255,255,255)),
|
|
||||||
"NETWORK":rgb_cfg("network",(0,0,255)),
|
|
||||||
"SERVICE":rgb_cfg("service",(0,255,96)),
|
|
||||||
}
|
|
||||||
RGB_OBJECT_GROUPS={
|
|
||||||
"ROOT_READONLY":"STORAGE",
|
"ROOT_READONLY":"STORAGE",
|
||||||
"TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
|
"TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
|
||||||
"THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
|
"THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
|
||||||
@@ -374,7 +228,7 @@ def diagnose():
|
|||||||
print("Agent:")
|
print("Agent:")
|
||||||
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
||||||
print(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
|
print(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
|
||||||
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
print("loop_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||||
print("System:")
|
print("System:")
|
||||||
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
||||||
print("Power:")
|
print("Power:")
|
||||||
@@ -387,27 +241,16 @@ def diagnose():
|
|||||||
iface,kind,metric,label,address=primary_network()
|
iface,kind,metric,label,address=primary_network()
|
||||||
print("Network:")
|
print("Network:")
|
||||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
||||||
print("I2C:")
|
print("Plugins (read-only API discovery; no hardware access):")
|
||||||
# SMBus receive-byte performs a read only; no register selection or display commands.
|
for plugin in discover(cfg):
|
||||||
try:
|
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
|
||||||
with SMBus(1) as probe:
|
|
||||||
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
|
|
||||||
try:
|
|
||||||
probe.read_byte(address)
|
|
||||||
print(f"0x{address:02X} {name}=PRESENT")
|
|
||||||
except OSError as e:
|
|
||||||
status="MISSING" if e.errno in (errno.ENXIO,getattr(errno,"EREMOTEIO",121)) else "ERROR"
|
|
||||||
print(f"0x{address:02X} {name}={status} error={e}")
|
|
||||||
except OSError as e:
|
|
||||||
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
|
|
||||||
print(f"0x{address:02X} {name}=ERROR error={e}")
|
|
||||||
print("Watches:")
|
print("Watches:")
|
||||||
for w in watches:
|
for w in watches:
|
||||||
print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}")
|
print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
DISPLAY_PRIORITIES={
|
ALERT_PRIORITIES={
|
||||||
"ROOT_READONLY":100,
|
"ROOT_READONLY":100,
|
||||||
"TEMP_CRITICAL":90,
|
"TEMP_CRITICAL":90,
|
||||||
"SOFT_TEMP_LIMIT":85,
|
"SOFT_TEMP_LIMIT":85,
|
||||||
@@ -427,8 +270,8 @@ DISPLAY_PRIORITIES={
|
|||||||
"NORMAL_HOME":0,
|
"NORMAL_HOME":0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def display_priority(request_id):
|
def alert_priority(request_id):
|
||||||
return DISPLAY_PRIORITIES[request_id.split("|",1)[0]]
|
return ALERT_PRIORITIES[request_id.split("|",1)[0]]
|
||||||
|
|
||||||
def transition(key,active,on_event,off_event,**fields):
|
def transition(key,active,on_event,off_event,**fields):
|
||||||
old=event_state.get(key,False)
|
old=event_state.get(key,False)
|
||||||
@@ -441,39 +284,39 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
|||||||
global cpu_hi,mem_hi
|
global cpu_hi,mem_hi
|
||||||
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
|
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
|
||||||
mem_hi=mem_hi+sample_elapsed if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
|
mem_hi=mem_hi+sample_elapsed if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
|
||||||
if cpu_hi>=CPU_HIGH_SEC: display.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD")
|
if cpu_hi>=CPU_HIGH_SEC: alert_manager.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD")
|
||||||
else: display.clear("CPU_HIGH")
|
else: alert_manager.clear("CPU_HIGH")
|
||||||
if temp>=TEMP_CRIT:
|
if temp>=TEMP_CRIT:
|
||||||
display.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
alert_manager.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
|
||||||
display.clear("TEMP_HIGH")
|
alert_manager.clear("TEMP_HIGH")
|
||||||
elif temp>=TEMP_HIGH:
|
elif temp>=TEMP_HIGH:
|
||||||
display.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
alert_manager.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
|
||||||
display.clear("TEMP_CRITICAL")
|
alert_manager.clear("TEMP_CRITICAL")
|
||||||
else:
|
else:
|
||||||
display.clear("TEMP_HIGH"); display.clear("TEMP_CRITICAL")
|
alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
|
||||||
if mem_hi>=MEM_HIGH_SEC: display.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
||||||
else: display.clear("MEMORY_HIGH")
|
else: alert_manager.clear("MEMORY_HIGH")
|
||||||
if disk>=DISK_CRIT:
|
if disk>=DISK_CRIT:
|
||||||
display.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
||||||
display.clear("DISK_HIGH")
|
alert_manager.clear("DISK_HIGH")
|
||||||
elif disk>=DISK_HIGH:
|
elif disk>=DISK_HIGH:
|
||||||
display.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
||||||
display.clear("DISK_CRITICAL")
|
alert_manager.clear("DISK_CRITICAL")
|
||||||
else:
|
else:
|
||||||
display.clear("DISK_HIGH"); display.clear("DISK_CRITICAL")
|
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
|
||||||
if not link_ok:
|
if not link_ok:
|
||||||
display.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
||||||
display.clear("INTERNET_DOWN")
|
alert_manager.clear("INTERNET_DOWN")
|
||||||
elif not net_ok:
|
elif not net_ok:
|
||||||
display.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
|
alert_manager.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
|
||||||
display.clear("NETWORK_DOWN")
|
alert_manager.clear("NETWORK_DOWN")
|
||||||
else:
|
else:
|
||||||
display.clear("NETWORK_DOWN"); display.clear("INTERNET_DOWN")
|
alert_manager.clear("NETWORK_DOWN"); alert_manager.clear("INTERNET_DOWN")
|
||||||
for w,ok in zip(watches,watch_state):
|
for w,ok in zip(watches,watch_state):
|
||||||
alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1]
|
alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1]
|
||||||
if not ok:
|
if not ok:
|
||||||
display.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
|
alert_manager.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
|
||||||
else: display.clear(alert_id)
|
else: alert_manager.clear(alert_id)
|
||||||
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
|
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
|
||||||
rssi=float(net_metric)
|
rssi=float(net_metric)
|
||||||
if not wifi_weak_active:
|
if not wifi_weak_active:
|
||||||
@@ -484,339 +327,56 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
|||||||
elif rssi >= WIFI_RECOVER:
|
elif rssi >= WIFI_RECOVER:
|
||||||
wifi_weak_active=False; wifi_weak_since=None
|
wifi_weak_active=False; wifi_weak_since=None
|
||||||
if wifi_weak_active:
|
if wifi_weak_active:
|
||||||
display.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
|
alert_manager.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
|
||||||
else: display.clear("WIFI_WEAK")
|
else: alert_manager.clear("WIFI_WEAK")
|
||||||
else:
|
else:
|
||||||
wifi_weak_since=None; wifi_weak_active=False; display.clear("WIFI_WEAK")
|
wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK")
|
||||||
if health.get("under_voltage_now"):
|
if health.get("under_voltage_now"):
|
||||||
display.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
||||||
else: display.clear("POWER_UNDERVOLT")
|
else: alert_manager.clear("POWER_UNDERVOLT")
|
||||||
if health.get("throttled_now"):
|
if health.get("throttled_now"):
|
||||||
display.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
alert_manager.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||||
else: display.clear("THROTTLED")
|
else: alert_manager.clear("THROTTLED")
|
||||||
if health.get("freq_capped_now"):
|
if health.get("freq_capped_now"):
|
||||||
display.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
alert_manager.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||||
else: display.clear("FREQ_CAPPED")
|
else: alert_manager.clear("FREQ_CAPPED")
|
||||||
if health.get("soft_temp_limit_now"):
|
if health.get("soft_temp_limit_now"):
|
||||||
display.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
|
alert_manager.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
|
||||||
else: display.clear("SOFT_TEMP_LIMIT")
|
else: alert_manager.clear("SOFT_TEMP_LIMIT")
|
||||||
if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
|
if fs_readonly: alert_manager.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
|
||||||
else: display.clear("ROOT_READONLY")
|
else: alert_manager.clear("ROOT_READONLY")
|
||||||
|
|
||||||
def rgb_mode_for(request_id,severity):
|
class AlertManager:
|
||||||
if severity<=0: return "OFF"
|
"""Current monitoring facts. Hardware plugins own all display decisions."""
|
||||||
alert_type=request_id.split("|",1)[0]
|
|
||||||
color=RGB_OBJECT_GROUPS[alert_type]
|
|
||||||
level="EMERGENCY" if severity>=3 else "CRITICAL" if severity>=2 else "WARNING"
|
|
||||||
return f"{color}_{level}_FLASH"
|
|
||||||
|
|
||||||
def rgb_flash(mode,on):
|
|
||||||
global _last_rgb
|
|
||||||
color=mode.split("_",1)[0]
|
|
||||||
channels=RGB_GROUP_VALUES[color]
|
|
||||||
state=(mode,bool(on))
|
|
||||||
if _last_rgb==state: return
|
|
||||||
if not on:
|
|
||||||
mcu(0x07,0x00)
|
|
||||||
else:
|
|
||||||
# Keep the seller's selector -> R -> G -> B protocol contiguous. A
|
|
||||||
# visible delay between these writes lets the MCU render stale partial
|
|
||||||
# channel data, which appears as a brief unrelated color or chase.
|
|
||||||
mcu(0x00,0xff)
|
|
||||||
mcu(0x01,channels[0])
|
|
||||||
mcu(0x02,channels[1])
|
|
||||||
mcu(0x03,channels[2])
|
|
||||||
_last_rgb=state
|
|
||||||
|
|
||||||
def rgb_static(channels,state_name):
|
|
||||||
global _last_rgb
|
|
||||||
state=(state_name,channels)
|
|
||||||
if _last_rgb==state: return
|
|
||||||
mcu(0x07,0x00)
|
|
||||||
mcu(0x00,0xff)
|
|
||||||
mcu(0x01,channels[0])
|
|
||||||
mcu(0x02,channels[1])
|
|
||||||
mcu(0x03,channels[2])
|
|
||||||
_last_rgb=state
|
|
||||||
|
|
||||||
def rgb_normal_effect(mode):
|
|
||||||
global _last_rgb
|
|
||||||
effect_name=mode.removeprefix("NORMAL_").lower()
|
|
||||||
state=("effect",effect_name,NORMAL_EFFECT_SPEED,NORMAL_EFFECT_COLOR)
|
|
||||||
if _last_rgb==state: return
|
|
||||||
# Hardware-calibrated order: speed and color are parameters; the final
|
|
||||||
# effect register write commits and starts the selected MCU animation.
|
|
||||||
mcu(0x05,NORMAL_EFFECT_SPEED); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
|
||||||
mcu(0x06,RGB_EFFECT_COLORS[NORMAL_EFFECT_COLOR]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
|
||||||
mcu(0x04,RGB_EFFECTS[effect_name])
|
|
||||||
_last_rgb=state
|
|
||||||
|
|
||||||
def render_home(cpu,temp,mem,disk):
|
|
||||||
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
|
||||||
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
|
||||||
im=Image.new("1",(W,H),0); pix=im.load()
|
|
||||||
display_ip=ip[:7]+".."+ip[-7:] if ip_label=="IP6" and len(ip)>18 else ip
|
|
||||||
left=[
|
|
||||||
f"CPU {cpu:.1f}%",
|
|
||||||
f"TMP {temp:.1f}C",
|
|
||||||
f"DSK {disk:.1f}%",
|
|
||||||
ip_label+" "+display_ip,
|
|
||||||
]
|
|
||||||
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
|
||||||
right=[
|
|
||||||
f"MEM {mem:.1f}%",
|
|
||||||
f"FAN {FAN_NAME[fan_level]}",
|
|
||||||
f"WIF {signal}%" if signal is not None else f"{net_kind} {net_metric}",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
for row in range(4):
|
|
||||||
y=row*8
|
|
||||||
draw_text_5x7(pix,0,y,left[row])
|
|
||||||
if right[row]:
|
|
||||||
draw_text_5x7(pix,72,y,right[row])
|
|
||||||
data=[]
|
|
||||||
for pg in range(4):
|
|
||||||
for x in range(W):
|
|
||||||
v=0
|
|
||||||
for bit in range(8):
|
|
||||||
if pix[x,pg*8+bit]: v|=1<<bit
|
|
||||||
data.append(v)
|
|
||||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
|
||||||
for i in range(0,len(data),16):
|
|
||||||
bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16])
|
|
||||||
|
|
||||||
|
|
||||||
def render_alert(a,index,total):
|
|
||||||
oled_show([(a["title"],f"{index}/{total}"),
|
|
||||||
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayController:
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
now=time.monotonic()
|
self.requests={}
|
||||||
self.lock=threading.RLock()
|
|
||||||
self.requests={"NORMAL_HOME":{
|
|
||||||
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":NORMAL_RGB_MODE,
|
|
||||||
"title":"","l2":"","l3":"","l4":"","active":True,
|
|
||||||
"first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)},
|
|
||||||
}}
|
|
||||||
self.current_owner_id="NORMAL_HOME"
|
|
||||||
self.current_priority=0
|
|
||||||
self.current_rgb_mode="OFF"
|
|
||||||
self.owner_since=now
|
|
||||||
self.current_oled_page_id="NORMAL_HOME"
|
|
||||||
self.page_started=now
|
|
||||||
self.batch_depth=0
|
|
||||||
self.stop_event=threading.Event()
|
|
||||||
self.oled_wake=threading.Event()
|
|
||||||
self.oled_thread=threading.Thread(target=self._oled_worker,name="oled",daemon=True)
|
|
||||||
self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True)
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
oled_init(); rgb_off(); self.oled_thread.start(); self.rgb_thread.start()
|
|
||||||
|
|
||||||
def update_home(self,cpu,temp,mem,disk):
|
|
||||||
with self.lock:
|
|
||||||
request=self.requests["NORMAL_HOME"]
|
|
||||||
values=(cpu,temp,mem,disk)
|
|
||||||
if request["metadata"].get("home")!=values:
|
|
||||||
request["metadata"]={"home":values}; request["last_changed"]=time.monotonic()
|
|
||||||
|
|
||||||
def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
|
def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
|
||||||
now=time.monotonic(); priority=display_priority(request_id)
|
now=time.monotonic();old=self.requests.get(request_id)
|
||||||
request={"id":request_id,"priority":priority,"severity":severity,
|
value={"id":request_id,"priority":alert_priority(request_id),"severity":severity,
|
||||||
"rgb_mode":rgb_mode_for(request_id,severity),"title":title[:21],
|
"category":ALERT_CATEGORIES[request_id.split("|",1)[0]].lower(),
|
||||||
"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True,
|
"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
|
||||||
"last_changed":now,"metadata":metadata or {}}
|
"first_seen":old["first_seen"] if old else now,"metadata":metadata or {}}
|
||||||
with self.lock:
|
value["last_changed"]=old["last_changed"] if old and all(old[k]==v for k,v in value.items()) else now
|
||||||
current=self.requests.get(request_id)
|
self.requests[request_id]=value
|
||||||
if current is None:
|
if old is None:log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
|
||||||
request["first_seen"]=now; self.requests[request_id]=request
|
|
||||||
log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
|
|
||||||
else:
|
|
||||||
request["first_seen"]=current["first_seen"]
|
|
||||||
changed=any(current.get(k)!=request.get(k) for k in
|
|
||||||
("priority","severity","rgb_mode","title","l2","l3","l4","metadata"))
|
|
||||||
if not changed: request["last_changed"]=current["last_changed"]
|
|
||||||
self.requests[request_id]=request
|
|
||||||
if self.batch_depth==0:
|
|
||||||
self._arbitrate_locked()
|
|
||||||
self._sync_rgb_locked()
|
|
||||||
|
|
||||||
def clear(self,request_id):
|
def clear(self,request_id):
|
||||||
if request_id=="NORMAL_HOME": return
|
if self.requests.pop(request_id,None) is not None:log("INFO","ALERT_RECOVERED",alert=request_id)
|
||||||
with self.lock:
|
|
||||||
if self.requests.pop(request_id,None) is not None:
|
|
||||||
log("INFO","ALERT_RECOVERED",alert=request_id)
|
|
||||||
if self.batch_depth==0:
|
|
||||||
self._arbitrate_locked()
|
|
||||||
if self.current_oled_page_id not in self.requests:
|
|
||||||
self._select_page_locked(self.current_owner_id)
|
|
||||||
else: self._sync_rgb_locked()
|
|
||||||
|
|
||||||
def begin_update(self):
|
|
||||||
with self.lock: self.batch_depth+=1
|
|
||||||
|
|
||||||
def end_update(self):
|
|
||||||
with self.lock:
|
|
||||||
if self.batch_depth<=0: raise RuntimeError("display update batch imbalance")
|
|
||||||
self.batch_depth-=1
|
|
||||||
if self.batch_depth==0:
|
|
||||||
self._arbitrate_locked()
|
|
||||||
if self.current_oled_page_id not in self.requests:
|
|
||||||
self._select_page_locked(self.current_owner_id)
|
|
||||||
else: self._sync_rgb_locked()
|
|
||||||
|
|
||||||
def _arbitrate_locked(self):
|
|
||||||
current=self.requests.get(self.current_owner_id)
|
|
||||||
ordered=sorted(self.requests.values(),key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
|
||||||
candidate=ordered[0]
|
|
||||||
if current is not None and candidate["priority"]<=current["priority"]: return
|
|
||||||
self._change_owner_locked(candidate)
|
|
||||||
|
|
||||||
def _change_owner_locked(self,request):
|
|
||||||
old=self.current_owner_id
|
|
||||||
if request["id"]==old: return
|
|
||||||
self.current_owner_id=request["id"]
|
|
||||||
self.current_priority=request["priority"]
|
|
||||||
self.current_rgb_mode=request["rgb_mode"]
|
|
||||||
self.owner_since=time.monotonic()
|
|
||||||
log("INFO","DISPLAY_OWNER",old=old,new=request["id"],priority=request["priority"])
|
|
||||||
self._select_page_locked(request["id"])
|
|
||||||
|
|
||||||
def _ordered_alerts_locked(self):
|
|
||||||
return sorted((r for r in self.requests.values() if r["id"]!="NORMAL_HOME"),
|
|
||||||
key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
|
||||||
|
|
||||||
def _select_page_locked(self,page_id,wake=True):
|
|
||||||
request=self.requests.get(page_id,self.requests["NORMAL_HOME"])
|
|
||||||
self.current_oled_page_id=request["id"]
|
|
||||||
self.page_started=time.monotonic()
|
|
||||||
self._sync_rgb_locked()
|
|
||||||
if wake: self.oled_wake.set()
|
|
||||||
|
|
||||||
def _sync_rgb_locked(self):
|
|
||||||
# Alert pages show that alert's object color. HOME keeps the highest-
|
|
||||||
# priority active owner visible instead of briefly switching RGB off.
|
|
||||||
rgb_request=self.requests.get(self.current_oled_page_id)
|
|
||||||
if self.current_oled_page_id=="NORMAL_HOME":
|
|
||||||
rgb_request=self.requests.get(self.current_owner_id,self.requests["NORMAL_HOME"])
|
|
||||||
if rgb_request is None:
|
|
||||||
rgb_request=self.requests["NORMAL_HOME"]
|
|
||||||
wanted_mode=rgb_request["rgb_mode"]
|
|
||||||
if wanted_mode!=self.current_rgb_mode:
|
|
||||||
self.current_rgb_mode=wanted_mode
|
|
||||||
|
|
||||||
def refresh(self):
|
|
||||||
with self.lock:
|
|
||||||
alerts=self._ordered_alerts_locked()
|
|
||||||
pages=["NORMAL_HOME"]+[a["id"] for a in alerts]
|
|
||||||
if self.current_oled_page_id not in pages:
|
|
||||||
self._select_page_locked(self.current_owner_id,wake=False)
|
|
||||||
elif len(pages)>1 and time.monotonic()-self.page_started>=PAGE_INTERVAL:
|
|
||||||
pos=pages.index(self.current_oled_page_id)
|
|
||||||
self._select_page_locked(pages[(pos+1)%len(pages)],wake=False)
|
|
||||||
request=dict(self.requests[self.current_oled_page_id])
|
|
||||||
alert_ids=[a["id"] for a in alerts]
|
|
||||||
if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"])
|
|
||||||
else: render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
|
|
||||||
|
|
||||||
def owner_snapshot(self):
|
|
||||||
with self.lock: return dict(self.requests[self.current_owner_id])
|
|
||||||
|
|
||||||
def status_snapshot(self):
|
def status_snapshot(self):
|
||||||
with self.lock:
|
values=sorted(self.requests.values(),key=lambda a:(-a["priority"],a["first_seen"],a["id"]))
|
||||||
alerts=self._ordered_alerts_locked()
|
return {"owner":values[0]["id"] if values else "NORMAL_HOME","alerts":values}
|
||||||
return {
|
|
||||||
"owner":self.current_owner_id,
|
|
||||||
"oled_page":self.current_oled_page_id,
|
|
||||||
"rgb_mode":self.current_rgb_mode,
|
|
||||||
"alerts":[{k:a[k] for k in ("id","priority","severity","title","l2","l3","l4","first_seen","last_changed")}
|
|
||||||
for a in alerts],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _oled_worker(self):
|
|
||||||
previous_error=None
|
|
||||||
while not self.stop_event.is_set():
|
|
||||||
self.oled_wake.clear()
|
|
||||||
try:
|
|
||||||
self.refresh()
|
|
||||||
if previous_error is not None: log("INFO","OLED_RECOVERED")
|
|
||||||
previous_error=None
|
|
||||||
except Exception as e:
|
|
||||||
if repr(e)!=previous_error: log("ERROR","OLED_ERROR",error=repr(e))
|
|
||||||
previous_error=repr(e)
|
|
||||||
self.oled_wake.wait(OLED_REFRESH)
|
|
||||||
|
|
||||||
def _rgb_worker(self):
|
|
||||||
previous_error=None; applied_mode="OFF"; applied_flash=False
|
|
||||||
flash_on=None; animation_started=time.monotonic()
|
|
||||||
while not self.stop_event.is_set():
|
|
||||||
try:
|
|
||||||
with self.lock: wanted_mode=self.current_rgb_mode
|
|
||||||
wanted_flash=wanted_mode.endswith("_FLASH")
|
|
||||||
if wanted_mode!=applied_mode:
|
|
||||||
with bus_lock:
|
|
||||||
if wanted_mode=="OFF": rgb_off()
|
|
||||||
elif wanted_mode=="NORMAL_SOLID": rgb_static(NORMAL_COLOR,"normal_solid")
|
|
||||||
elif wanted_mode=="NORMAL_FLASH": rgb_static(NORMAL_COLOR,"normal_flash_on")
|
|
||||||
elif wanted_mode.startswith("NORMAL_"): rgb_normal_effect(wanted_mode)
|
|
||||||
elif wanted_flash:
|
|
||||||
rgb_off()
|
|
||||||
rgb_flash(wanted_mode,True)
|
|
||||||
else: raise ValueError(f"unsupported RGB mode: {wanted_mode}")
|
|
||||||
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
|
|
||||||
applied_mode=wanted_mode; animation_started=time.monotonic()
|
|
||||||
flash_on=True if wanted_mode.endswith("_FLASH") else None
|
|
||||||
applied_flash=applied_mode.endswith("_FLASH")
|
|
||||||
if applied_flash:
|
|
||||||
elapsed_ms=(time.monotonic()-animation_started)*1000.0
|
|
||||||
if applied_mode=="NORMAL_FLASH": on_ms,off_ms=NORMAL_FLASH
|
|
||||||
elif "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH
|
|
||||||
elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH
|
|
||||||
else: on_ms,off_ms=WARNING_FLASH
|
|
||||||
on=elapsed_ms%(on_ms+off_ms)<on_ms
|
|
||||||
if on!=flash_on:
|
|
||||||
with bus_lock:
|
|
||||||
if applied_mode=="NORMAL_FLASH":
|
|
||||||
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
|
|
||||||
else: rgb_flash(applied_mode,on)
|
|
||||||
flash_on=on
|
|
||||||
# Fan and RGB share the same MCU. During an alert flash, send
|
|
||||||
# fan changes only while RGB is already dark so MCU-internal
|
|
||||||
# register handling cannot expose a transient unrelated color.
|
|
||||||
if not applied_flash or flash_on is False:
|
|
||||||
with bus_lock: apply_pending_fan(keep_rgb_off=applied_flash)
|
|
||||||
if previous_error is not None: log("INFO","RGB_RECOVERED")
|
|
||||||
previous_error=None
|
|
||||||
except Exception as e:
|
|
||||||
if repr(e)!=previous_error: log("ERROR","RGB_ERROR",error=repr(e))
|
|
||||||
previous_error=repr(e)
|
|
||||||
delay=0.04 if applied_flash else 0.25
|
|
||||||
self.stop_event.wait(delay)
|
|
||||||
|
|
||||||
def shutdown(self):
|
|
||||||
self.stop_event.set()
|
|
||||||
self.oled_wake.set()
|
|
||||||
if self.oled_thread.is_alive(): self.oled_thread.join()
|
|
||||||
if self.rgb_thread.is_alive(): self.rgb_thread.join()
|
|
||||||
for name,action in (("rgb",rgb_off),("oled_clear",lambda:oled_show([("","")]*4)),
|
|
||||||
("oled_off",lambda:cmd(0xAE))):
|
|
||||||
try: action()
|
|
||||||
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
|
|
||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"])
|
publisher.close()
|
||||||
if bus is None: return
|
log("INFO","STOP",loop_errors=agent["loop_errors"])
|
||||||
display.shutdown()
|
|
||||||
for name,action in (("fan",lambda:mcu(0x08,0x00)),("bus_close",bus.close)):
|
|
||||||
try: action()
|
|
||||||
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
|
|
||||||
try:Path(STATUS_PATH).unlink(missing_ok=True)
|
try:Path(STATUS_PATH).unlink(missing_ok=True)
|
||||||
except Exception as e:log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
|
except Exception as e:log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
|
||||||
|
|
||||||
def write_status(cpu,temp,mem,disk):
|
def write_status(cpu,temp,mem,disk):
|
||||||
display_state=display.status_snapshot()
|
display_state=alert_manager.status_snapshot()
|
||||||
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
|
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
|
||||||
disk_total,disk_used,disk_free=disk_usage("/")
|
disk_total,disk_used,disk_free=disk_usage("/")
|
||||||
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
||||||
@@ -831,7 +391,7 @@ def write_status(cpu,temp,mem,disk):
|
|||||||
"memory_available_bytes":memory_available,
|
"memory_available_bytes":memory_available,
|
||||||
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
|
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
|
||||||
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
||||||
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
"root_readonly":bool(fs_readonly)},
|
||||||
"power":health,
|
"power":health,
|
||||||
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||||
"signal_percent":signal,
|
"signal_percent":signal,
|
||||||
@@ -839,8 +399,10 @@ def write_status(cpu,temp,mem,disk):
|
|||||||
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
||||||
for w,ok in zip(watches,watch_state)],
|
for w,ok in zip(watches,watch_state)],
|
||||||
"display":display_state,
|
"display":display_state,
|
||||||
"agent":{"loop_errors":agent["loop_errors"],"i2c_errors":agent["i2c_errors"]},
|
"agent":{"loop_errors":agent["loop_errors"]},
|
||||||
|
"plugins":publisher.current_status(),
|
||||||
}
|
}
|
||||||
|
publisher.publish(state)
|
||||||
path=Path(STATUS_PATH); path.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
|
path=Path(STATUS_PATH); path.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
|
||||||
temporary=path.with_suffix(".tmp")
|
temporary=path.with_suffix(".tmp")
|
||||||
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
|
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
|
||||||
@@ -856,18 +418,14 @@ watch_state=[]
|
|||||||
last_sensor=last_status=0.0
|
last_sensor=last_status=0.0
|
||||||
last_net=last_watch=last_health=0.0
|
last_net=last_watch=last_health=0.0
|
||||||
health={}; fs_readonly=False
|
health={}; fs_readonly=False
|
||||||
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0}
|
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0}
|
||||||
event_state={}
|
event_state={}
|
||||||
history_logged=set()
|
history_logged=set()
|
||||||
latest=(0.0,0.0,0.0,0.0)
|
latest=(0.0,0.0,0.0,0.0)
|
||||||
display=DisplayController()
|
alert_manager=AlertManager()
|
||||||
|
publisher=Publisher({k:v for k,v in registered(cfg).items() if not cfg.getboolean("plugin_paused",k,fallback=False)},DEVICE_ID,log)
|
||||||
try:
|
try:
|
||||||
bus=SMBus(1)
|
apply_dark_mode()
|
||||||
# Quench the MCU's power-on RGB effect before OLED initialization and all
|
|
||||||
# other startup work. The display controller remains the sole RGB owner.
|
|
||||||
rgb_off()
|
|
||||||
FONT5=Path(FONT_PATH).read_bytes()
|
|
||||||
display.start(); apply_dark_mode()
|
|
||||||
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
||||||
while running:
|
while running:
|
||||||
try:
|
try:
|
||||||
@@ -879,7 +437,7 @@ try:
|
|||||||
if now-last_sensor >= SENSOR_INTERVAL:
|
if now-last_sensor >= SENSOR_INTERVAL:
|
||||||
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
||||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
||||||
latest=(c,t,m,d); update_fan(t); last_sensor=now; agent["last_sensor_ok"]=now
|
latest=(c,t,m,d); last_sensor=now; agent["last_sensor_ok"]=now
|
||||||
|
|
||||||
if now-last_net >= NETWORK_INTERVAL:
|
if now-last_net >= NETWORK_INTERVAL:
|
||||||
old_id=(net_iface,ip)
|
old_id=(net_iface,ip)
|
||||||
@@ -912,10 +470,7 @@ try:
|
|||||||
agent["last_health_ok"]=now; last_health=now
|
agent["last_health_ok"]=now; last_health=now
|
||||||
|
|
||||||
c,t,m,d=latest
|
c,t,m,d=latest
|
||||||
display.update_home(c,t,m,d)
|
update_active_alerts(c,t,m,d,sample_elapsed)
|
||||||
display.begin_update()
|
|
||||||
try: update_active_alerts(c,t,m,d,sample_elapsed)
|
|
||||||
finally: display.end_update()
|
|
||||||
if now-last_status >= 1.0:
|
if now-last_status >= 1.0:
|
||||||
write_status(c,t,m,d); last_status=now
|
write_status(c,t,m,d); last_status=now
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||||
|
from plugin_api import discover,registered,request as plugin_request,socket_path
|
||||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
||||||
from concurrent.futures import ThreadPoolExecutor,as_completed
|
from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -12,7 +15,7 @@ CFG=Path("/etc/pigway-pi-control.conf")
|
|||||||
STATUS=Path("/run/pigway-pi-control/status.json")
|
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||||
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||||
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
||||||
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
|
||||||
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||||
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
||||||
@@ -21,6 +24,40 @@ NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorf
|
|||||||
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
||||||
PROTECTED_UNITS={"pigway-pi-control-api.service"}
|
PROTECTED_UNITS={"pigway-pi-control-api.service"}
|
||||||
|
|
||||||
|
def plugin_inventory():
|
||||||
|
cfg=load_cfg();items=discover(cfg)
|
||||||
|
for item in items:item["paused"]=cfg.getboolean("plugin_paused",item["id"],fallback=False)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def find_plugin(plugin_id):
|
||||||
|
for item in plugin_inventory():
|
||||||
|
if item["id"]==plugin_id:return item
|
||||||
|
raise ValueError("unknown plugin")
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_operation(body):
|
||||||
|
plugin_id=str(body.get("id",""))
|
||||||
|
if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id")
|
||||||
|
item=find_plugin(plugin_id);operation=body.get("operation")
|
||||||
|
if operation=="link":
|
||||||
|
if not item["online"]:raise ValueError("plugin is offline")
|
||||||
|
text=replace_ini_value("plugins",plugin_id,socket_path(item["socket"]))
|
||||||
|
save_ini_text(text,"plugin-link")
|
||||||
|
text=replace_ini_value("plugin_paused",plugin_id,"false")
|
||||||
|
elif operation in {"pause","resume","unlink"}:
|
||||||
|
if not item["linked"]:raise ValueError("plugin is not linked")
|
||||||
|
text=replace_ini_value("plugins",plugin_id,None) if operation=="unlink" else replace_ini_value("plugin_paused",plugin_id,"true" if operation=="pause" else "false")
|
||||||
|
else:raise ValueError("invalid plugin operation")
|
||||||
|
backup=save_ini_text(text,"plugin")
|
||||||
|
# Only reload a monitor that is already running. Never start hardware here.
|
||||||
|
subprocess.run(["systemctl","try-restart","pigway-pi-control.service"],check=True,timeout=15)
|
||||||
|
if operation in {"pause","unlink"}:
|
||||||
|
try:plugin_request(item["socket"],"DELETE","/v1/state",{})
|
||||||
|
except Exception:pass # An offline plugin expires its lease independently.
|
||||||
|
return {"ok":True,"backup":backup}
|
||||||
|
|
||||||
|
|
||||||
def machine_identity():
|
def machine_identity():
|
||||||
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
|
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
|
||||||
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
|
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
|
||||||
@@ -92,12 +129,19 @@ def api_setting(key,default,cast=str):
|
|||||||
|
|
||||||
def config_json():
|
def config_json():
|
||||||
cfg=load_cfg()
|
cfg=load_cfg()
|
||||||
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||||
|
|
||||||
def validate_value(section,key,value,existing):
|
def validate_value(section,key,value,existing):
|
||||||
|
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
|
||||||
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
||||||
raise ValueError(f"unsupported option: {section}.{key}")
|
raise ValueError(f"unsupported option: {section}.{key}")
|
||||||
if RGB_KEY_RE.fullmatch(key):
|
if section=="hardware" and key=="cooling_hat_enabled":
|
||||||
|
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
|
||||||
|
elif section=="led" and key=="alert_mode":
|
||||||
|
if value.lower() not in ("breathe","flash"): raise ValueError("alert_mode must be breathe or flash")
|
||||||
|
elif section=="led" and key=="custom_min_hold_ms":
|
||||||
|
if not 1500<=int(value)<=60000: raise ValueError("custom_min_hold_ms must be 1500..60000")
|
||||||
|
elif RGB_KEY_RE.fullmatch(key):
|
||||||
number=int(value)
|
number=int(value)
|
||||||
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
|
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
|
||||||
elif FLASH_KEY_RE.fullmatch(key):
|
elif FLASH_KEY_RE.fullmatch(key):
|
||||||
@@ -124,14 +168,14 @@ def validate_value(section,key,value,existing):
|
|||||||
|
|
||||||
def replace_ini_value(section,key,value):
|
def replace_ini_value(section,key,value):
|
||||||
text=CFG.read_text()
|
text=CFG.read_text()
|
||||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||||
if not section_match:
|
if not section_match:
|
||||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||||
return text
|
return text
|
||||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||||
chunk=text[section_match.end():end]
|
chunk=text[section_match.end():end]
|
||||||
key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||||
if key_match:
|
if key_match:
|
||||||
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||||
return text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
return text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||||
@@ -169,11 +213,11 @@ def update_ini(updates):
|
|||||||
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"):
|
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"):
|
||||||
raise ValueError(f"invalid value: {section}.{key}")
|
raise ValueError(f"invalid value: {section}.{key}")
|
||||||
validate_value(section,str(key),value,existing)
|
validate_value(section,str(key),value,existing)
|
||||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||||
if not section_match:
|
if not section_match:
|
||||||
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||||
continue
|
continue
|
||||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||||
chunk=text[section_match.end():end]
|
chunk=text[section_match.end():end]
|
||||||
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
|
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
|
||||||
@@ -308,14 +352,14 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
|||||||
text=CFG.read_text()
|
text=CFG.read_text()
|
||||||
def change(section,key,value):
|
def change(section,key,value):
|
||||||
nonlocal text
|
nonlocal text
|
||||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||||
if not section_match:
|
if not section_match:
|
||||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||||
return
|
return
|
||||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||||
chunk=text[section_match.end():end]
|
chunk=text[section_match.end():end]
|
||||||
key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||||
if key_match:
|
if key_match:
|
||||||
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||||
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||||
@@ -408,6 +452,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if parsed.path=="/api/v1/fleet/logs":
|
if parsed.path=="/api/v1/fleet/logs":
|
||||||
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
|
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
|
||||||
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
||||||
|
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||||
|
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
|
||||||
|
plugin_id=query.get("id",[""])[0]
|
||||||
|
if host_id and host_id!=machine_identity()["id"]:
|
||||||
|
host=find_host(host_id)
|
||||||
|
self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return
|
||||||
|
if parsed.path.endswith("/plugins"):
|
||||||
|
self.send_json(200,{"plugins":plugin_inventory()});return
|
||||||
|
item=find_plugin(plugin_id)
|
||||||
|
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
|
||||||
if parsed.path=="/api/v1/config":
|
if parsed.path=="/api/v1/config":
|
||||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||||
if host_id and host_id!=machine_identity()["id"]:
|
if host_id and host_id!=machine_identity()["id"]:
|
||||||
@@ -424,10 +478,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_PUT(self):
|
def do_PUT(self):
|
||||||
path=urlparse(self.path).path
|
path=urlparse(self.path).path
|
||||||
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
|
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
|
||||||
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||||
try:
|
try:
|
||||||
body=self.read_json()
|
body=self.read_json()
|
||||||
|
if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||||
|
host_id=str(body.get("host",""))
|
||||||
|
if host_id and host_id!=machine_identity()["id"]:
|
||||||
|
host=find_host(host_id);token=str(body.get("target_token",""))
|
||||||
|
if not token:raise ValueError("remote bearer token required")
|
||||||
|
payload={k:v for k,v in body.items() if k not in {"host","target_token"}}
|
||||||
|
self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return
|
||||||
|
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
|
||||||
|
item=find_plugin(str(body.get("id","")))
|
||||||
|
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
|
||||||
if path=="/api/v1/hosts":
|
if path=="/api/v1/hosts":
|
||||||
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
|
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
|
||||||
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
|
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Hardware-independent client for the versioned local plugin API."""
|
||||||
|
import copy
|
||||||
|
from contextlib import closing
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLUGIN_ROOT=Path('/run/pigway-plugins')
|
||||||
|
|
||||||
|
|
||||||
|
def socket_path(value):
|
||||||
|
path=Path(value)
|
||||||
|
if not path.is_absolute() or '..' in path.parts or path.name!='api.sock' or PLUGIN_ROOT not in path.parents:
|
||||||
|
raise ValueError('plugin socket must be /run/pigway-plugins/<plugin>/api.sock')
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
class Connection(http.client.HTTPConnection):
|
||||||
|
def __init__(self,path):
|
||||||
|
super().__init__('localhost',timeout=.8);self.path=socket_path(path)
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
self.sock=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
||||||
|
self.sock.settimeout(self.timeout)
|
||||||
|
try:self.sock.connect(self.path)
|
||||||
|
except Exception:self.sock.close();raise
|
||||||
|
|
||||||
|
|
||||||
|
def request(path,method,endpoint,payload=None):
|
||||||
|
with closing(Connection(path)) as conn:
|
||||||
|
body=None if payload is None else json.dumps(payload,allow_nan=False).encode()
|
||||||
|
conn.request(method,endpoint,body,{'Content-Type':'application/json'})
|
||||||
|
response=conn.getresponse();raw=response.read(262145)
|
||||||
|
if len(raw)>262144:raise ValueError('plugin response too large')
|
||||||
|
value=json.loads(raw)
|
||||||
|
if response.status>=400:raise ValueError(value.get('error','plugin request failed'))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def registered(cfg):
|
||||||
|
if not cfg.has_section('plugins'):return {}
|
||||||
|
return {k:socket_path(v) for k,v in cfg.items('plugins') if v.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def discover(cfg):
|
||||||
|
linked=registered(cfg);paths={v for v in linked.values()}
|
||||||
|
paths.update(str(p) for p in PLUGIN_ROOT.glob('*/api.sock'))
|
||||||
|
result=[]
|
||||||
|
for path in sorted(paths)[:32]:
|
||||||
|
key=next((k for k,v in linked.items() if v==path),None)
|
||||||
|
try:
|
||||||
|
d=request(path,'GET','/v1/descriptor')
|
||||||
|
if d.get('api_version')!=1:raise ValueError('incompatible plugin API version')
|
||||||
|
status=request(path,'GET','/v1/status')
|
||||||
|
result.append({'id':key or d['id'],'socket':path,'linked':key is not None,'online':True,'descriptor':d,'status':status})
|
||||||
|
except Exception as exc:
|
||||||
|
result.append({'id':key or Path(path).parent.name,'socket':path,'linked':key is not None,'online':False,'error':str(exc)})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class Publisher:
|
||||||
|
"""One replaceable snapshot, not an unbounded queue of animation commands."""
|
||||||
|
def __init__(self,plugins,source,log):
|
||||||
|
self.plugins=plugins;self.source=source;self.log=log
|
||||||
|
self.session=str(uuid.uuid4());self.revision=0;self.snapshot=None
|
||||||
|
self.lock=threading.Lock();self.stop_event=threading.Event();self.status={}
|
||||||
|
self.threads=[threading.Thread(target=self.run,args=(key,path),name='plugin-'+key,daemon=True)
|
||||||
|
for key,path in plugins.items()]
|
||||||
|
for thread in self.threads:thread.start()
|
||||||
|
|
||||||
|
def publish(self,state):
|
||||||
|
with self.lock:self.snapshot=copy.deepcopy(state)
|
||||||
|
|
||||||
|
def current_status(self):
|
||||||
|
with self.lock:return copy.deepcopy(self.status)
|
||||||
|
|
||||||
|
def run(self,key,path):
|
||||||
|
previous_error=None;revision=0;descriptor=None
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
with self.lock:state=self.snapshot
|
||||||
|
if state is not None:
|
||||||
|
revision+=1
|
||||||
|
payload={'api_version':1,'source':self.source,'session':self.session,'revision':revision,
|
||||||
|
'ttl_seconds':10,'system':state['system'],'network':state['network'],'alerts':state['display']['alerts']}
|
||||||
|
try:
|
||||||
|
if descriptor is None:
|
||||||
|
descriptor=request(path,'GET','/v1/descriptor')
|
||||||
|
if descriptor.get('api_version')!=1:raise ValueError('incompatible plugin API version')
|
||||||
|
request(path,'PUT','/v1/state',payload)
|
||||||
|
status=request(path,'GET','/v1/status')
|
||||||
|
with self.lock:self.status[key]={'online':True,'status':status,'name':descriptor.get('name',key),'capabilities':descriptor.get('capabilities',{})}
|
||||||
|
if previous_error is not None:self.log('INFO','PLUGIN_RECOVERED',plugin=key)
|
||||||
|
previous_error=None
|
||||||
|
except Exception as exc:
|
||||||
|
descriptor=None
|
||||||
|
error=str(exc)
|
||||||
|
with self.lock:self.status[key]={'online':False,'error':error}
|
||||||
|
if previous_error!=error:self.log('WARN','PLUGIN_UNAVAILABLE',plugin=key,error=error)
|
||||||
|
previous_error=error
|
||||||
|
self.stop_event.wait(1)
|
||||||
|
try:request(path,'DELETE','/v1/state',{'source':self.source,'session':self.session})
|
||||||
|
except Exception:pass # Server-side leases expire if transport is unavailable.
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.stop_event.set()
|
||||||
|
for thread in self.threads:thread.join()
|
||||||
@@ -1,33 +1,12 @@
|
|||||||
# =============================================================================
|
# PIGWay system monitoring. Hardware plugins are separately installed.
|
||||||
# PIGWay Pi Control v3.7.0 - 用户配置文件
|
# 系统监控不依赖散热板;在 Web 的硬件插件页面确认接入后才发送状态。
|
||||||
# =============================================================================
|
[plugins]
|
||||||
#
|
# No automatic attachment. Example (prefer Web discovery):
|
||||||
# 正式安装后的配置路径:
|
# yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
|
||||||
# /etc/pigway-pi-control.conf
|
|
||||||
#
|
|
||||||
# 编辑:
|
|
||||||
# sudo nano /etc/pigway-pi-control.conf
|
|
||||||
#
|
|
||||||
# 保存后让配置生效:
|
|
||||||
# sudo systemctl restart pigway-pi-control
|
|
||||||
#
|
|
||||||
# 查看运行状态:
|
|
||||||
# systemctl status pigway-pi-control --no-pager -l
|
|
||||||
#
|
|
||||||
# 查看实时日志:
|
|
||||||
# journalctl -u pigway-pi-control -f
|
|
||||||
#
|
|
||||||
# 重要:
|
|
||||||
# 1. 只建议修改本文件,不要直接修改 /usr/local/sbin/pigway-pi-control。
|
|
||||||
# 2. “#” 开头是注释,不生效。要启用示例项,删除行首的 #。
|
|
||||||
# 3. 数值单位和建议范围见每项上方注释。
|
|
||||||
# 4. 配置写错导致服务无法启动时,可查看 journalctl 日志并修正本文件。
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
[device]
|
[device]
|
||||||
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
|
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
|
||||||
# 多台机器的 identifier 必须唯一;修改后重启硬件 Agent 和 API 服务。
|
# 多台机器的 identifier 必须唯一;修改后重启监控 Agent 和 API 服务。
|
||||||
identifier = auto
|
identifier = auto
|
||||||
|
|
||||||
# Web 页面显示名称。auto 表示使用 identifier。
|
# Web 页面显示名称。auto 表示使用 identifier。
|
||||||
@@ -51,19 +30,10 @@ log_limit = 200
|
|||||||
|
|
||||||
[timing]
|
[timing]
|
||||||
# 传感器采样周期,单位:秒。
|
# 传感器采样周期,单位:秒。
|
||||||
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
# CPU / 内存 / 温度 / 磁盘按此周期更新。
|
||||||
# 推荐:1。通常不要低于 1。
|
# 推荐:1。通常不要低于 1。
|
||||||
sensor_interval = 1
|
sensor_interval = 1
|
||||||
|
|
||||||
# OLED 当前页面刷新周期,单位:秒。
|
|
||||||
# 这只是刷新当前页面数据,不负责切换页面。
|
|
||||||
# 推荐:1。
|
|
||||||
oled_refresh_interval = 1
|
|
||||||
|
|
||||||
# OLED 在主页和当前告警页之间轮播的周期,单位:秒。
|
|
||||||
# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。
|
|
||||||
page_interval = 10
|
|
||||||
|
|
||||||
# 网络状态检查周期,单位:秒。
|
# 网络状态检查周期,单位:秒。
|
||||||
# 推荐:5。太短会产生不必要的网络探测。
|
# 推荐:5。太短会产生不必要的网络探测。
|
||||||
network_interval = 5
|
network_interval = 5
|
||||||
@@ -74,45 +44,10 @@ service_interval = 5
|
|||||||
|
|
||||||
# 本机健康检查周期,单位:秒。
|
# 本机健康检查周期,单位:秒。
|
||||||
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
|
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
|
||||||
# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。
|
# 推荐:1。当前电源状态及时更新告警与插件快照;历史状态仅记录一次日志。
|
||||||
health_interval = 1
|
health_interval = 1
|
||||||
|
|
||||||
|
|
||||||
[fan]
|
|
||||||
# 风扇温控档位,单位:摄氏度。
|
|
||||||
#
|
|
||||||
# 默认曲线:
|
|
||||||
# < 50°C OFF
|
|
||||||
# 50~53°C L1
|
|
||||||
# 53~56°C L2
|
|
||||||
# 56~59°C L3
|
|
||||||
# 59~62°C L4
|
|
||||||
# >= 62°C MAX
|
|
||||||
#
|
|
||||||
# 如希望更安静,可适当提高阈值;如更重视散热,可降低。
|
|
||||||
# 建议保持 start < level2 < level3 < level4 < max。
|
|
||||||
|
|
||||||
# 低于此温度,风扇完全停止。
|
|
||||||
start = 50
|
|
||||||
|
|
||||||
# L2 起始温度;start 到 level2 之间为 L1。
|
|
||||||
level2 = 53
|
|
||||||
|
|
||||||
# L3 起始温度。
|
|
||||||
level3 = 56
|
|
||||||
|
|
||||||
# L4 起始温度。
|
|
||||||
level4 = 59
|
|
||||||
|
|
||||||
# MAX 全速起始温度。
|
|
||||||
max = 62
|
|
||||||
|
|
||||||
# 降档回差,单位:°C。
|
|
||||||
# 防止温度在临界值附近上下波动时风扇频繁升降档。
|
|
||||||
# 推荐:1.0~2.0。
|
|
||||||
hysteresis = 1.5
|
|
||||||
|
|
||||||
|
|
||||||
[alerts]
|
[alerts]
|
||||||
# ---------------- CPU ----------------
|
# ---------------- CPU ----------------
|
||||||
# CPU 使用率达到此百分比后开始累计高负载时间。
|
# CPU 使用率达到此百分比后开始累计高负载时间。
|
||||||
@@ -152,7 +87,7 @@ disk_critical = 95
|
|||||||
# ---------------- Wi-Fi 信号 ----------------
|
# ---------------- Wi-Fi 信号 ----------------
|
||||||
# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
|
# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
|
||||||
# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
|
# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
|
||||||
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发蓝色告警。
|
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发弱信号告警。
|
||||||
wifi_weak = -75
|
wifi_weak = -75
|
||||||
|
|
||||||
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
|
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
|
||||||
@@ -162,81 +97,6 @@ wifi_recover = -68
|
|||||||
wifi_weak_seconds = 30
|
wifi_weak_seconds = 30
|
||||||
|
|
||||||
|
|
||||||
[led]
|
|
||||||
# 正常运行、没有任何 active alert 时的 RGB 表现:
|
|
||||||
# off = 全灭(默认,最安静且 I2C 写入最少)
|
|
||||||
# solid = 三灯使用 normal_r/g/b 自定义颜色常亮
|
|
||||||
# flash = 三灯使用 normal_r/g/b 和 normal_flash_*_ms 自定义颜色闪烁
|
|
||||||
# flow = MCU 内置流水灯;使用有限颜色和速度
|
|
||||||
# breathe = MCU 内置呼吸灯;使用有限颜色和速度
|
|
||||||
# marquee = MCU 内置跑马灯;MCU 自行决定颜色变化
|
|
||||||
# rainbow = MCU 内置彩虹灯;MCU 自行决定颜色变化
|
|
||||||
# colorful = MCU 内置炫彩灯;MCU 自行决定颜色变化
|
|
||||||
# 任何 active alert 都会立即抢占正常效果;全部恢复后才重新应用这里的模式。
|
|
||||||
normal_mode = off
|
|
||||||
|
|
||||||
# solid / flash 自定义颜色,三通道范围均为 0..255。
|
|
||||||
normal_r = 0
|
|
||||||
normal_g = 0
|
|
||||||
normal_b = 0
|
|
||||||
|
|
||||||
# flash 亮灯/灭灯时间,单位毫秒;范围 50..60000。
|
|
||||||
normal_flash_on_ms = 1000
|
|
||||||
normal_flash_off_ms = 1000
|
|
||||||
|
|
||||||
# MCU 内置效果速度:1=慢,2=中(默认),3=快。
|
|
||||||
normal_effect_speed = 2
|
|
||||||
# flow / breathe 可使用的 MCU 内置有限颜色:
|
|
||||||
# red / green / blue / yellow / purple / cyan / white
|
|
||||||
# marquee / rainbow / colorful 会忽略该颜色值。
|
|
||||||
normal_effect_color = green
|
|
||||||
|
|
||||||
# DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示
|
|
||||||
# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级:
|
|
||||||
# CPU/频率/温度/散热 = 红色
|
|
||||||
# 供电/欠压 = 橙色闪烁(实机校准,避免内置黄色偏绿)
|
|
||||||
# 内存 = 紫色
|
|
||||||
# 磁盘 = 白色
|
|
||||||
# 网络/Wi-Fi = 蓝色
|
|
||||||
# 服务/进程 = 青色
|
|
||||||
# 颜色通道范围为 0..255。以下是本机实测后的默认值。
|
|
||||||
cpu_r = 255
|
|
||||||
cpu_g = 0
|
|
||||||
cpu_b = 0
|
|
||||||
power_r = 255
|
|
||||||
power_g = 40
|
|
||||||
power_b = 0
|
|
||||||
memory_r = 255
|
|
||||||
memory_g = 0
|
|
||||||
memory_b = 255
|
|
||||||
storage_r = 255
|
|
||||||
storage_g = 255
|
|
||||||
storage_b = 255
|
|
||||||
network_r = 0
|
|
||||||
network_g = 0
|
|
||||||
network_b = 255
|
|
||||||
service_r = 0
|
|
||||||
service_g = 255
|
|
||||||
service_b = 96
|
|
||||||
|
|
||||||
# 各严重等级可分别设置亮灯和灭灯时长,单位:毫秒,最小值 50。
|
|
||||||
# 默认完整周期:Warning 3秒、Critical 1.5秒、Emergency 0.6秒。
|
|
||||||
warning_flash_on_ms = 1500
|
|
||||||
warning_flash_off_ms = 1500
|
|
||||||
critical_flash_on_ms = 750
|
|
||||||
critical_flash_off_ms = 750
|
|
||||||
emergency_flash_on_ms = 300
|
|
||||||
emergency_flash_off_ms = 300
|
|
||||||
# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序,
|
|
||||||
# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。
|
|
||||||
write_delay_ms = 10
|
|
||||||
|
|
||||||
# v3.3.0 RC 期间使用过的 brightness、*_breathe_seconds、rgb_update_ms、
|
|
||||||
# warning_color、critical_color、warning_flash_ms、critical_flash_ms 和
|
|
||||||
# emergency_flash_ms
|
|
||||||
# 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。
|
|
||||||
|
|
||||||
|
|
||||||
[dark_mode]
|
[dark_mode]
|
||||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||||
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Local hardware plugin API v1
|
||||||
|
|
||||||
|
The monitor and plugins are separate applications. The monitor has no hardware
|
||||||
|
library dependency. Plugins alone own their hardware. Installing or discovering
|
||||||
|
a plugin does not connect it or start another service.
|
||||||
|
|
||||||
|
## Transport and authorization
|
||||||
|
|
||||||
|
HTTP/1.0 or HTTP/1.1 over `/run/pigway-plugins/<id>/api.sock`. No TCP listener.
|
||||||
|
The parent directory is root-owned, mode 0750, and the socket is 0660 with group
|
||||||
|
`pigway-hardware`. Root and deliberately enrolled group members may control the
|
||||||
|
plugin. The monitor Web API authenticates mutations with its existing bearer
|
||||||
|
token before proxying local requests. Remote machine operations require that
|
||||||
|
machine's token too. API v1 clients reject incompatible descriptor versions.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /v1/descriptor`: `api_version`, `id`, `name`, `version`, `capabilities`, `schema`.
|
||||||
|
- `GET /v1/status`: availability, connection state and capability-specific actual
|
||||||
|
or commanded state. A missing tachometer must never be presented as measured RPM.
|
||||||
|
- `GET /v1/config`: `config` plus `schema`.
|
||||||
|
- `PUT /v1/config`: `{"updates":{"section":{"key":"value"}}}`; validate the complete
|
||||||
|
candidate before persisting or applying anything. Does not restart the monitor.
|
||||||
|
- `PUT /v1/state`: replace the complete monitoring snapshot, never append commands.
|
||||||
|
- `DELETE /v1/state`: release a lease. Publisher passes `source` and `session`;
|
||||||
|
authorized local administration may use `{}` to detach the current source.
|
||||||
|
|
||||||
|
Example snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": 1,
|
||||||
|
"source": "raspberrypi",
|
||||||
|
"session": "a-new-uuid-for-each-monitor-process",
|
||||||
|
"revision": 1,
|
||||||
|
"ttl_seconds": 10,
|
||||||
|
"system": {"cpu_percent": 12, "temperature_c": 50, "memory_percent": 20, "disk_percent": 30},
|
||||||
|
"network": {"kind": "WIF", "metric": "-60", "ip_label": "IP4", "ip": "192.168.1.2"},
|
||||||
|
"alerts": [{"id":"SERVICE_DOWN|APP","category":"service","severity":2,"priority":60,
|
||||||
|
"title":"SERVICE DOWN","l2":"APP","l3":"CHECK SERVICE","l4":"CRITICAL"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alert categories: `cpu`, `power`, `memory`, `storage`, `network`, `service`.
|
||||||
|
The producer sends only current alerts, never historical power flags. Empty
|
||||||
|
`alerts` clears all previous alerts. A source/session owns its lease until
|
||||||
|
release or expiry; a competing source and repeated/out-of-order revisions are
|
||||||
|
rejected. Leases use the receiving process's monotonic clock, 3–30 seconds.
|
||||||
|
A new monitor session retries until the old lease expires if the old process
|
||||||
|
could not release it. Each plugin has an independent bounded-time publisher and
|
||||||
|
one replaceable latest snapshot, so an offline plugin cannot queue stale frames
|
||||||
|
or block monitoring or other plugins.
|
||||||
|
|
||||||
|
## Capability-driven UI
|
||||||
|
|
||||||
|
`fan` declares levels or PWM, RPM availability, autonomous operation and ownership.
|
||||||
|
`display` declares dimensions and pages. `rgb` declares modes, preset colors and
|
||||||
|
experimental custom flashing. Absent capability means absent controls. A plugin
|
||||||
|
with only a fan does not imply a screen or lighting device. New hardware needs
|
||||||
|
its own driver; I2C, PWM GPIO and power-only fans are not interchangeable.
|
||||||
|
|
||||||
|
`schema` groups fields by section. Each field provides bilingual `label`, `type`
|
||||||
|
(`number`/`enum`), numeric limits or choices, unit and optional visibility rules.
|
||||||
|
Optional `color_group` and `channel` (`r`/`g`/`b`) associate three numeric fields
|
||||||
|
with a color picker. Plugins remain authoritative for all validation.
|
||||||
|
|
||||||
|
## Failures and independent cooling
|
||||||
|
|
||||||
|
Monitoring continues without installed or reachable plugins. Hardware plugins
|
||||||
|
continue local temperature control without the monitor. Expired snapshots must
|
||||||
|
not keep showing stale alerts as live faults. The Yahboom implementation displays
|
||||||
|
MONITOR OFFLINE and requests RGB OFF; explicit detach returns to standalone mode.
|
||||||
|
Its thermal read failure and service shutdown request full fan speed as a cooling
|
||||||
|
fallback; actual execution still depends on functioning hardware.
|
||||||
|
|
||||||
|
Stopping a monitor, pausing its link, or unlinking a plugin does not stop the
|
||||||
|
plugin service. No API/Web service has a systemd Wants dependency that starts the
|
||||||
|
other application. Do not run two drivers for the same MCU, GPIO or fan.
|
||||||
+29
-172
@@ -1,197 +1,54 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
NO_START=0
|
||||||
FORCE_I2C_CONFLICT=0
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--force-i2c-conflict) FORCE_I2C_CONFLICT=1 ;;
|
--no-start) NO_START=1 ;;
|
||||||
-h|--help)
|
-h|--help) echo 'Usage: sudo ./install.sh [--no-start]'; exit 0 ;;
|
||||||
echo "Usage: sudo ./install.sh [--force-i2c-conflict]"
|
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
|
||||||
echo " --force-i2c-conflict Continue even when another process owns /dev/i2c-1."
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||||
for required in app/pigway_pi_control.py app/pigway_pi_control_api.py app/oled_font_5x7.bin web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
|
||||||
if [ ! -f "$required" ]; then
|
|
||||||
echo "ERROR: required file missing: $required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
|
||||||
echo "[1/8] 检查 Raspberry Pi I2C..."
|
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
|
||||||
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
|
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
|
||||||
|
|
||||||
# A running copy of this project is expected during an upgrade. Any other
|
|
||||||
# process holding the shared bus could race OLED/MCU writes, so report it and
|
|
||||||
# let the user decide how that process should be managed.
|
|
||||||
i2c_conflict_pids() {
|
|
||||||
local allowed_pid pid
|
|
||||||
allowed_pid="$(systemctl show -p MainPID --value pigway-pi-control.service 2>/dev/null || true)"
|
|
||||||
for pid in $(fuser /dev/i2c-1 2>/dev/null || true); do
|
|
||||||
case "$pid" in
|
|
||||||
''|*[!0-9]*) continue ;;
|
|
||||||
esac
|
|
||||||
[ "$pid" = "$$" ] && continue
|
|
||||||
[ "$pid" = "$allowed_pid" ] && continue
|
|
||||||
kill -0 "$pid" 2>/dev/null && echo "$pid"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
I2C_CONFLICTS="$(i2c_conflict_pids)"
|
|
||||||
if [ -n "$I2C_CONFLICTS" ]; then
|
|
||||||
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
|
|
||||||
echo "WARNING: /dev/i2c-1 is already in use by another process:" >&2
|
|
||||||
else
|
|
||||||
echo "ERROR: /dev/i2c-1 is already in use by another process:" >&2
|
|
||||||
fi
|
|
||||||
while read -r pid; do
|
|
||||||
ps -p "$pid" -o pid=,user=,comm=,args= >&2 || true
|
|
||||||
done <<< "$I2C_CONFLICTS"
|
|
||||||
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
|
|
||||||
echo "WARNING: continuing because --force-i2c-conflict was specified; no conflicting process will be stopped." >&2
|
|
||||||
else
|
|
||||||
echo "Stop or reconfigure the conflicting OLED/LED controller, then run the installer again." >&2
|
|
||||||
echo "If concurrent control is intentional, rerun with --force-i2c-conflict." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[2/8] 等待 APT/DPKG..."
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi
|
|
||||||
[ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; }
|
|
||||||
echo "软件包管理器忙,等待 5 秒... ($i/60)"
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "[3/8] 安装依赖..."
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y python3 python3-pil python3-smbus2 i2c-tools
|
|
||||||
|
|
||||||
echo "[4/8] 检查硬件地址..."
|
|
||||||
SCAN="$(i2cdetect -y 1)"; echo "$SCAN"
|
|
||||||
echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C"
|
|
||||||
echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D"
|
|
||||||
|
|
||||||
echo "[5/8] 安装程序与配置..."
|
|
||||||
mkdir -p /usr/local/share/pigway-pi-control
|
|
||||||
install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_font_5x7.bin
|
|
||||||
mkdir -p /usr/local/share/pigway-pi-control/web
|
mkdir -p /usr/local/share/pigway-pi-control/web
|
||||||
|
install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
|
||||||
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
|
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
|
||||||
|
|
||||||
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
||||||
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
||||||
|
|
||||||
# Keep the complete existing configuration, including comments and custom sections.
|
|
||||||
# The merge below adds only missing documented options.
|
|
||||||
if [ -f /etc/pigway-pi-control.conf ]; then
|
if [ -f /etc/pigway-pi-control.conf ]; then
|
||||||
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
||||||
else
|
else
|
||||||
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
|
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
|
||||||
fi
|
fi
|
||||||
|
# Legacy hardware sections are deliberately preserved for explicit migration,
|
||||||
# Preserve the existing file while exposing new defaults. Existing values win.
|
# but ignored by the monitor and never exposed as monitor configuration.
|
||||||
python3 - /etc/pigway-pi-control.conf <<'PY'
|
python3 - <<'PY'
|
||||||
import re,sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import configparser
|
||||||
path=Path(sys.argv[1])
|
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
|
||||||
text=path.read_text()
|
with p.open('a') as f:
|
||||||
sections={
|
for section,values in {'device':{'identifier':'auto','name':'auto'},'hosts':{},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
|
||||||
"device":(("identifier","auto"),("name","auto")),
|
if not c.has_section(section):
|
||||||
"hosts":(),
|
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()))
|
||||||
"led":(
|
|
||||||
("normal_mode","off"),
|
|
||||||
("normal_r",0),("normal_g",0),("normal_b",0),
|
|
||||||
("normal_flash_on_ms",1000),("normal_flash_off_ms",1000),
|
|
||||||
("normal_effect_speed",2),("normal_effect_color","green"),
|
|
||||||
("write_delay_ms",10),
|
|
||||||
("cpu_r",255),("cpu_g",0),("cpu_b",0),
|
|
||||||
("power_r",255),("power_g",40),("power_b",0),
|
|
||||||
("memory_r",255),("memory_g",0),("memory_b",255),
|
|
||||||
("storage_r",255),("storage_g",255),("storage_b",255),
|
|
||||||
("network_r",0),("network_g",0),("network_b",255),
|
|
||||||
("service_r",0),("service_g",255),("service_b",96),
|
|
||||||
("warning_flash_on_ms",1500),("warning_flash_off_ms",1500),
|
|
||||||
("critical_flash_on_ms",750),("critical_flash_off_ms",750),
|
|
||||||
("emergency_flash_on_ms",300),("emergency_flash_off_ms",300),
|
|
||||||
),
|
|
||||||
"api":(("bind","0.0.0.0"),("port",6001),("log_limit",200)),
|
|
||||||
}
|
|
||||||
for section,defaults in sections.items():
|
|
||||||
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
|
|
||||||
if match:
|
|
||||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[match.end():])
|
|
||||||
end=match.end()+(next_section.start() if next_section else len(text)-match.end())
|
|
||||||
chunk=text[match.end():end]
|
|
||||||
else:
|
|
||||||
text=text.rstrip()+f"\n\n[{section}]\n"
|
|
||||||
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
|
|
||||||
end=len(text); chunk=""
|
|
||||||
missing=[(key,value) for key,value in defaults
|
|
||||||
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)]
|
|
||||||
if missing:
|
|
||||||
note="# Added by installer; existing user values are preserved.\n"
|
|
||||||
if section=="led" and any(key.startswith("normal_") for key,_ in missing):
|
|
||||||
note+=("# Normal RGB: normal_mode = off/solid/flash/flow/breathe/marquee/rainbow/colorful.\n"
|
|
||||||
"# normal_r/g/b = 0..255; normal_flash_*_ms = 50..60000; effect speed = 1..3.\n"
|
|
||||||
"# Effect color = red/green/blue/yellow/purple/cyan/white (flow/breathe only).\n")
|
|
||||||
block=note+"".join(f"{key} = {value}\n" for key,value in missing)
|
|
||||||
text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n")
|
|
||||||
path.write_text(text)
|
|
||||||
PY
|
PY
|
||||||
if [ ! -s /etc/pigway-pi-control-api.token ]; then
|
if [ ! -s /etc/pigway-pi-control-api.token ]; then
|
||||||
umask 077
|
(umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)
|
||||||
python3 - <<'PY' > /etc/pigway-pi-control-api.token
|
|
||||||
import secrets
|
|
||||||
print(secrets.token_urlsafe(32))
|
|
||||||
PY
|
|
||||||
fi
|
fi
|
||||||
chmod 0600 /etc/pigway-pi-control-api.token
|
chmod 0600 /etc/pigway-pi-control-api.token
|
||||||
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service
|
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
|
||||||
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/pigway-pi-control-api.service
|
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
|
||||||
|
|
||||||
echo "[6/8] 启用/重启服务..."
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
if [ "$NO_START" -eq 0 ]; then
|
||||||
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
|
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
|
||||||
systemctl restart pigway-pi-control.service
|
systemctl restart pigway-pi-control.service pigway-pi-control-api.service
|
||||||
systemctl restart pigway-pi-control-api.service
|
systemctl is-active --quiet pigway-pi-control.service
|
||||||
|
systemctl is-active --quiet pigway-pi-control-api.service
|
||||||
echo "[7/8] 安装后自检..."
|
|
||||||
FAIL=0
|
|
||||||
check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; }
|
|
||||||
APP=/usr/local/sbin/pigway-pi-control
|
|
||||||
check "Version 3.7.0" grep -Fq 'APP_VERSION="3.7.0"' "$APP"
|
|
||||||
check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP"
|
|
||||||
check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$APP"
|
|
||||||
check "OLED pages 0..3" grep -Fq 'cmd(0x22);cmd(0);cmd(3)' "$APP"
|
|
||||||
check "UI CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP"
|
|
||||||
check "UI IP4/IP6" grep -Fq 'ip_label' "$APP"
|
|
||||||
check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP"
|
|
||||||
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
|
|
||||||
printf " %-30s WARN (forced)\n" "I2C owner conflict"
|
|
||||||
else
|
|
||||||
no_other_i2c_owner() { [ -z "$(i2c_conflict_pids)" ]; }
|
|
||||||
check "No other I2C owner" no_other_i2c_owner
|
|
||||||
fi
|
fi
|
||||||
check "Service running" systemctl is-active --quiet pigway-pi-control.service
|
echo 'Monitor installed. Hardware plugins are installed and connected separately.'
|
||||||
check "API/Web service running" systemctl is-active --quiet pigway-pi-control-api.service
|
echo 'Config: /etc/pigway-pi-control.conf'
|
||||||
check "API/Web version 3.7.0" grep -Fq 'APP_VERSION="3.7.0"' /usr/local/sbin/pigway-pi-control-api
|
echo 'API token: sudo cat /etc/pigway-pi-control-api.token'
|
||||||
check "Web UI installed" test -s /usr/local/share/pigway-pi-control/web/index.html
|
|
||||||
check "P0 health monitor" grep -Fq 'throttled_state' "$APP"
|
|
||||||
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP"
|
|
||||||
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
|
|
||||||
echo "[8/8] 状态..."
|
|
||||||
|
|
||||||
|
|
||||||
systemctl --no-pager --full status pigway-pi-control.service || true
|
|
||||||
systemctl --no-pager --full status pigway-pi-control-api.service || true
|
|
||||||
echo
|
|
||||||
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
|
||||||
echo "配置:/etc/pigway-pi-control.conf"
|
|
||||||
echo "Web/API:http://$(hostname -I | awk '{print $1}'):$(awk -F= '/^port[[:space:]]*=/{gsub(/[[:space:]]/,"",$2);print $2;exit}' /etc/pigway-pi-control.conf)"
|
|
||||||
echo "配置写入 token:sudo cat /etc/pigway-pi-control-api.token"
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=PIGWay Pi Control API and Web
|
Description=PIGWay Pi Control API and Web
|
||||||
After=network.target pigway-pi-control.service
|
After=network.target pigway-pi-control.service
|
||||||
Wants=network.target pigway-pi-control.service
|
Wants=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
|
Description=PIGWay system monitor
|
||||||
After=local-fs.target systemd-modules-load.service
|
After=local-fs.target systemd-modules-load.service
|
||||||
Before=multi-user.target
|
Before=multi-user.target
|
||||||
|
|
||||||
|
|||||||
+31
-18
@@ -16,14 +16,14 @@
|
|||||||
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="config">系统配置</button></nav>
|
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="config">系统配置</button></nav>
|
||||||
<section id="overview"><div id="hostCards"><div class="panel muted">正在读取主机状态…</div></div></section>
|
<section id="overview"><div id="hostCards"><div class="panel muted">正在读取主机状态…</div></div></section>
|
||||||
<section id="logs" class="hidden"><div class="panel"><h2>事件日志</h2><p class="muted">查询 systemd journal 中仍在保留的全部硬件 Agent 事件。</p><div class="log-filter-top"><label>开始日期<input id="logStart" type="date"></label><label>结束日期<input id="logEnd" type="date"></label><div class="quick-range"><button data-log-range="today">今天</button><button data-log-range="yesterday">昨天</button><button data-log-range="week">本周</button><button data-log-range="month">本月</button><button data-log-range="all">全部</button></div><label>关键词<input id="logSearch" placeholder="搜索日志内容"></label></div><div class="log-filter-bottom"><label>严重程度<select id="logSeverity"><option value="">全部程度</option><option value="DEBUG">调试</option><option value="INFO">信息</option><option value="NOTICE">注意</option><option value="WARN">警告</option><option value="ERROR">错误</option><option value="CRITICAL">严重</option><option value="ALERT,EMERGENCY">紧急</option></select></label><label>机器标识<select id="logMachine"><option value="">全部机器</option></select></label><label>事件类型<select id="logEvent"><option value="">全部事件</option></select></label><div class="log-actions"><span class="muted" id="logSummary"></span><button id="refreshLogs">查询</button><button id="resetLogs">重置</button></div></div><div class="log-table-wrap"><table class="log-table"><thead><tr><th><button class="sort-button" data-log-sort="timestamp">时间</button></th><th><button class="sort-button" data-log-sort="machine">机器</button></th><th><button class="sort-button" data-log-sort="severity">程度</button></th><th><button class="sort-button" data-log-sort="event">事件类型</button></th><th><button class="sort-button" data-log-sort="message">内容</button></th></tr></thead><tbody id="logRows"></tbody></table></div><div class="log-pager"><button id="logPrev">上一页</button><span class="muted" id="logPage"></span><button id="logNext">下一页</button></div></div></section>
|
<section id="logs" class="hidden"><div class="panel"><h2>事件日志</h2><p class="muted">查询 systemd journal 中仍在保留的全部硬件 Agent 事件。</p><div class="log-filter-top"><label>开始日期<input id="logStart" type="date"></label><label>结束日期<input id="logEnd" type="date"></label><div class="quick-range"><button data-log-range="today">今天</button><button data-log-range="yesterday">昨天</button><button data-log-range="week">本周</button><button data-log-range="month">本月</button><button data-log-range="all">全部</button></div><label>关键词<input id="logSearch" placeholder="搜索日志内容"></label></div><div class="log-filter-bottom"><label>严重程度<select id="logSeverity"><option value="">全部程度</option><option value="DEBUG">调试</option><option value="INFO">信息</option><option value="NOTICE">注意</option><option value="WARN">警告</option><option value="ERROR">错误</option><option value="CRITICAL">严重</option><option value="ALERT,EMERGENCY">紧急</option></select></label><label>机器标识<select id="logMachine"><option value="">全部机器</option></select></label><label>事件类型<select id="logEvent"><option value="">全部事件</option></select></label><div class="log-actions"><span class="muted" id="logSummary"></span><button id="refreshLogs">查询</button><button id="resetLogs">重置</button></div></div><div class="log-table-wrap"><table class="log-table"><thead><tr><th><button class="sort-button" data-log-sort="timestamp">时间</button></th><th><button class="sort-button" data-log-sort="machine">机器</button></th><th><button class="sort-button" data-log-sort="severity">程度</button></th><th><button class="sort-button" data-log-sort="event">事件类型</button></th><th><button class="sort-button" data-log-sort="message">内容</button></th></tr></thead><tbody id="logRows"></tbody></table></div><div class="log-pager"><button id="logPrev">上一页</button><span class="muted" id="logPage"></span><button id="logNext">下一页</button></div></div></section>
|
||||||
<section id="config" class="hidden"><div class="panel"><h2>系统配置</h2><p class="muted">配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。</p><div class="toolbar config-target-toolbar"><label class="host-select">配置机器<select id="configHost"></select></label><label class="host-select">配置写入令牌(Bearer Token)<span class="token-control"><input id="token" type="password" placeholder="当前机器配置令牌(Bearer Token)"><button id="toggleToken" type="button">显示令牌</button></span></label></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar" id="mainSavebar"><button id="saveConfig">保存到当前机器</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
|
<section id="config" class="hidden"><div class="panel"><h2>系统配置</h2><p class="muted">配置按用途分组。监控设置与硬件插件配置独立保存。</p><div class="toolbar config-target-toolbar"><label class="host-select">配置机器<select id="configHost"></select></label><label class="host-select">配置写入令牌(Bearer Token)<span class="token-control"><input id="token" type="password" placeholder="当前机器配置令牌(Bearer Token)"><button id="toggleToken" type="button">显示令牌</button></span></label></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar" id="mainSavebar"><button id="saveConfig">保存到当前机器</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
|
||||||
</main><script>
|
</main><script>
|
||||||
let LANG=localStorage.getItem('pigwayLanguage')||'zh';
|
let LANG=localStorage.getItem('pigwayLanguage')||'zh';
|
||||||
const EN={
|
const EN={"硬件插件":"Hardware plugins","独立安装,按实际能力提供风扇、屏幕或灯光控制":"Install independently; fan, display and lighting controls follow advertised capabilities","配置按用途分组。监控设置与硬件插件配置独立保存。":"Settings are grouped by purpose. Monitoring and hardware plugin settings are saved independently.","未启用":"Disabled","硬件插件已启用":"Hardware plugin enabled","硬件插件已关闭":"Hardware plugin disabled","硬件插件不可用":"Hardware plugin unavailable","硬件插件": "Hardware plugin", "可选 Yahboom 散热板;未安装时保持关闭": "Optional Yahboom cooling HAT; leave disabled when absent", "启用散热板(OLED、风扇、RGB)": "Enable cooling HAT (OLED, fan, RGB)", "告警灯效模式": "Alert lighting mode", "自定义闪烁最小保持时间": "Minimum custom flash hold", "默认关闭;启用前运行安装器 --enable-cooling-hat 安装依赖。": "Disabled by default; install dependencies with --enable-cooling-hat before enabling.", "推荐内置呼吸;自定义闪烁仍可能异色或延迟。": "Built-in breathing recommended; custom flashing may still show wrong colors or delays.", "范围 1500–60000 毫秒。实际亮灭时间不会低于此值。": "Range 1500–60000 ms. Each light/dark state lasts at least this long.", "内置呼吸(推荐)": "Built-in breathing (recommended)", "自定义颜色闪烁(实验性)": "Custom color flashing (experimental)", "散热板插件关闭时,灯光与风扇设置不控制硬件。": "Lighting and fan settings do not control hardware while the plugin is disabled.", "内置呼吸:CPU 红、电源黄、内存紫、存储白、网络蓝、服务青。": "Built-in breathing: CPU red, power yellow, memory purple, storage white, network blue, services cyan.", "普通、严重、紧急对应 MCU 速度档 1、2、3;不支持自定义呼吸颜色或精确周期。": "Warning, critical and emergency use MCU speed levels 1, 2 and 3; custom breathing colors and exact periods are unsupported.", "启用": "Enabled",
|
||||||
'语言':'Language','颜色模式':'Appearance','中文':'中文','自动':'Auto','深色':'Dark','浅色':'Light','◐ 跟随系统':'◐ Follow system','☀ 浅色模式':'☀ Light mode','☾ 深色模式':'☾ Dark mode','正在连接树莓派…':'Connecting to Raspberry Pi…','在线':'Online','离线':'Offline',
|
'语言':'Language','颜色模式':'Appearance','中文':'中文','自动':'Auto','深色':'Dark','浅色':'Light','◐ 跟随系统':'◐ Follow system','☀ 浅色模式':'☀ Light mode','☾ 深色模式':'☾ Dark mode','正在连接树莓派…':'Connecting to Raspberry Pi…','在线':'Online','离线':'Offline',
|
||||||
'运行状态':'Status','事件日志':'Event log','系统配置':'Settings','处理器负载':'CPU load','处理器温度':'CPU temperature','内存占用':'Memory usage','磁盘占用':'Disk usage','主网络':'Primary network','显示状态':'Display status','当前告警':'Active alerts','无告警':'No alerts','服务与进程监控':'Service and process monitors',
|
'运行状态':'Status','事件日志':'Event log','系统配置':'Settings','处理器负载':'CPU load','处理器温度':'CPU temperature','内存占用':'Memory usage','磁盘占用':'Disk usage','主网络':'Primary network','显示状态':'Display status','当前告警':'Active alerts','无告警':'No alerts','服务与进程监控':'Service and process monitors',
|
||||||
'查询 systemd journal 中仍在保留的全部硬件 Agent 事件。':'Query all retained hardware Agent events in the systemd journal.','今天':'Today','昨天':'Yesterday','本周':'This week','本月':'This month','全部':'All','开始日期':'Start date','结束日期':'End date','严重程度':'Severity','全部程度':'All severities','调试':'Debug','信息':'Info','注意':'Notice','警告':'Warning','错误':'Error','严重':'Critical','紧急':'Emergency','事件类型':'Event type','全部事件':'All events','风扇档位变化':'Fan level changed','关键词':'Keyword','搜索日志内容':'Search log messages','查询':'Search','重置':'Reset','时间':'Time','程度':'Severity','内容':'Message','上一页':'Previous','下一页':'Next',
|
'查询 systemd journal 中仍在保留的全部硬件 Agent 事件。':'Query all retained hardware Agent events in the systemd journal.','今天':'Today','昨天':'Yesterday','本周':'This week','本月':'This month','全部':'All','开始日期':'Start date','结束日期':'End date','严重程度':'Severity','全部程度':'All severities','调试':'Debug','信息':'Info','注意':'Notice','警告':'Warning','错误':'Error','严重':'Critical','紧急':'Emergency','事件类型':'Event type','全部事件':'All events','风扇档位变化':'Fan level changed','关键词':'Keyword','搜索日志内容':'Search log messages','查询':'Search','重置':'Reset','时间':'Time','程度':'Severity','内容':'Message','上一页':'Previous','下一页':'Next',
|
||||||
'配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。':'Settings are grouped by purpose and only relevant options are shown. Saving creates a backup and restarts the hardware Agent.','配置写入令牌(Bearer Token)':'Configuration Bearer Token','显示令牌':'Show token','隐藏令牌':'Hide token','保存并应用':'Save and apply','尚未修改':'No changes',
|
'配置按用途分组。监控设置与硬件插件配置独立保存。':'Settings are grouped by purpose and only relevant options are shown. Saving creates a backup and restarts the hardware Agent.','配置写入令牌(Bearer Token)':'Configuration Bearer Token','显示令牌':'Show token','隐藏令牌':'Hide token','保存并应用':'Save and apply','尚未修改':'No changes',
|
||||||
'灯光设置':'Lighting','正常灯效与告警颜色':'Normal effects and alert colors','告警阈值':'Alert thresholds','处理器、内存、温度、磁盘和 Wi-Fi':'CPU, memory, temperature, disk and Wi-Fi','风扇温控':'Fan control','温度档位与降档回差':'Temperature levels and hysteresis','刷新周期':'Refresh intervals','传感器、屏幕、网络和健康检查':'Sensors, display, network and health checks','屏幕布局':'Display layout','OLED 四行文字位置':'OLED four-line positions','板载指示灯':'Board LEDs','树莓派电源灯与活动灯':'Raspberry Pi power and activity LEDs','服务监控':'Service monitoring','监控 systemd 服务':'Monitor systemd services','进程监控':'Process monitoring','监控普通进程关键字':'Monitor process keywords','Web 与 API':'Web and API','监听地址、端口和日志数量':'Bind address, port and log count',
|
'灯光设置':'Lighting','正常灯效与告警颜色':'Normal effects and alert colors','告警阈值':'Alert thresholds','处理器、内存、温度、磁盘和 Wi-Fi':'CPU, memory, temperature, disk and Wi-Fi','风扇温控':'Fan control','温度档位与降档回差':'Temperature levels and hysteresis','刷新周期':'Refresh intervals','传感器、屏幕、网络和健康检查':'Sensors, display, network and health checks','屏幕布局':'Display layout','OLED 四行文字位置':'OLED four-line positions','板载指示灯':'Board LEDs','树莓派电源灯与活动灯':'Raspberry Pi power and activity LEDs','服务监控':'Service monitoring','监控 systemd 服务':'Monitor systemd services','进程监控':'Process monitoring','监控普通进程关键字':'Monitor process keywords','Web 与 API':'Web and API','监听地址、端口和日志数量':'Bind address, port and log count',
|
||||||
'正常运行灯效':'Normal lighting','没有告警时显示;告警出现后自动让位。':'Shown when no alert is active; alerts take priority.','告警对象颜色':'Alert category colors','告警对象决定颜色,严重程度决定闪烁速度。':'The alert category determines color; severity determines flash timing.','普通告警闪烁节奏':'Warning flash timing','严重告警闪烁节奏':'Critical flash timing','紧急告警闪烁节奏':'Emergency flash timing','硬件高级参数':'Advanced hardware settings','自定义颜色':'Custom color','点击色块打开色盘':'Click the swatch to open the color picker','颜色':'Color','红色通道':'Red channel','绿色通道':'Green channel','蓝色通道':'Blue channel','范围 0–255。':'Range: 0–255.','亮灯时间':'On time','灭灯时间':'Off time','毫秒':'ms','秒':'s','像素':'px','条':'entries',
|
'正常运行灯效':'Normal lighting','没有告警时显示;告警出现后自动让位。':'Shown when no alert is active; alerts take priority.','告警对象颜色':'Alert category colors','告警对象决定颜色,严重程度决定闪烁速度。':'The alert category determines color; severity determines flash timing.','普通告警闪烁节奏':'Warning flash timing','严重告警闪烁节奏':'Critical flash timing','紧急告警闪烁节奏':'Emergency flash timing','硬件高级参数':'Advanced hardware settings','自定义颜色':'Custom color','点击色块打开色盘':'Click the swatch to open the color picker','颜色':'Color','红色通道':'Red channel','绿色通道':'Green channel','蓝色通道':'Blue channel','范围 0–255。':'Range: 0–255.','亮灯时间':'On time','灭灯时间':'Off time','毫秒':'ms','秒':'s','像素':'px','条':'entries',
|
||||||
'当前系统服务':'System services','点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。':'Click “Add monitor” to fill the form below, or enter a future service manually.','刷新列表':'Refresh list','搜索服务名称或说明':'Search service name or description','正在读取 systemd 服务…':'Loading systemd services…','已监控服务':'Monitored services','“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。':'“Pause monitor” stops only status checks; “Stop service” operates systemd.','新建服务监控':'Add service monitor','编辑服务监控':'Edit service monitor','OLED 显示名称':'OLED display name','systemd 服务名称':'systemd service name','备注说明':'Description','保存监控':'Save monitor','清空':'Clear','未监控':'Not monitored','已监控':'Monitored','已暂停':'Paused','加入监控':'Add monitor','编辑监控':'Edit monitor','暂停监控':'Pause monitor','恢复监控':'Resume monitor','停止服务':'Stop service','启动服务':'Start service','编辑':'Edit','删除监控':'Delete monitor','正在运行':'Running','未运行':'Stopped','监控已暂停':'Monitor paused','运行中':'Running','服务已停止':'Service stopped','备注:':'Note: ','未填写':'Not provided','暂无服务说明':'No service description','没有匹配的服务':'No matching services','尚未监控任何服务':'No monitored services','正在保存…':'Saving…','保存成功':'Saved','暂无配置项':'No settings','暂无数据':'No data',
|
'当前系统服务':'System services','点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。':'Click “Add monitor” to fill the form below, or enter a future service manually.','刷新列表':'Refresh list','搜索服务名称或说明':'Search service name or description','正在读取 systemd 服务…':'Loading systemd services…','已监控服务':'Monitored services','“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。':'“Pause monitor” stops only status checks; “Stop service” operates systemd.','新建服务监控':'Add service monitor','编辑服务监控':'Edit service monitor','OLED 显示名称':'OLED display name','systemd 服务名称':'systemd service name','备注说明':'Description','保存监控':'Save monitor','清空':'Clear','未监控':'Not monitored','已监控':'Monitored','已暂停':'Paused','加入监控':'Add monitor','编辑监控':'Edit monitor','暂停监控':'Pause monitor','恢复监控':'Resume monitor','停止服务':'Stop service','启动服务':'Start service','编辑':'Edit','删除监控':'Delete monitor','正在运行':'Running','未运行':'Stopped','监控已暂停':'Monitor paused','运行中':'Running','服务已停止':'Service stopped','备注:':'Note: ','未填写':'Not provided','暂无服务说明':'No service description','没有匹配的服务':'No matching services','尚未监控任何服务':'No monitored services','正在保存…':'Saving…','保存成功':'Saved','暂无配置项':'No settings','暂无数据':'No data',
|
||||||
@@ -39,25 +39,38 @@ Object.assign(EN,{
|
|||||||
Object.assign(EN,{'机器标识':'Host ID','机器':'Host','全部机器':'All hosts','配置机器':'Configure host','多机配置同步':'Multi-host configuration sync','勾选目标机器后,将当前页面中已修改的配置项批量同步到这些机器。':'Select target hosts to copy the changed settings on this page.','当前机器配置令牌(Bearer Token)':'Current host Bearer Token','保存到当前机器':'Save to current host','批量同步已修改项':'Sync changed settings','设备标识':'Device identity','自定义本机显示名称和稳定标识':'Set the local display name and stable identifier','显示名称':'Display name','机器列表':'Host list','登记需要聚合监控的其他节点':'Register nodes for fleet monitoring','已登记机器':'Registered hosts','尚未登记其他机器':'No remote hosts registered','添加或更新机器':'Add or update host','节点地址':'Node URL','保存机器':'Save host','正在读取主机状态…':'Loading host status…','可用':'Available','剩余':'Free','本机':'Local','连接失败':'Connection failed'});
|
Object.assign(EN,{'机器标识':'Host ID','机器':'Host','全部机器':'All hosts','配置机器':'Configure host','多机配置同步':'Multi-host configuration sync','勾选目标机器后,将当前页面中已修改的配置项批量同步到这些机器。':'Select target hosts to copy the changed settings on this page.','当前机器配置令牌(Bearer Token)':'Current host Bearer Token','保存到当前机器':'Save to current host','批量同步已修改项':'Sync changed settings','设备标识':'Device identity','自定义本机显示名称和稳定标识':'Set the local display name and stable identifier','显示名称':'Display name','机器列表':'Host list','登记需要聚合监控的其他节点':'Register nodes for fleet monitoring','已登记机器':'Registered hosts','尚未登记其他机器':'No remote hosts registered','添加或更新机器':'Add or update host','节点地址':'Node URL','保存机器':'Save host','正在读取主机状态…':'Loading host status…','可用':'Available','剩余':'Free','本机':'Local','连接失败':'Connection failed'});
|
||||||
Object.assign(EN,{'端口':'port','0.0.0.0 允许局域网访问;127.0.0.1 仅本机。':'0.0.0.0 allows LAN access; 127.0.0.1 is local only.','范围 1–65535;保存后 API 自动重启。':'Range: 1–65535. The API restarts after saving.','API 服务不能停止自身':'The API service cannot stop itself','已自动填写,可继续修改。':'Filled automatically; you can continue editing.','请先选择同步目标机器':'Select target hosts first'});
|
Object.assign(EN,{'端口':'port','0.0.0.0 允许局域网访问;127.0.0.1 仅本机。':'0.0.0.0 allows LAN access; 127.0.0.1 is local only.','范围 1–65535;保存后 API 自动重启。':'Range: 1–65535. The API restarts after saving.','API 服务不能停止自身':'The API service cannot stop itself','已自动填写,可继续修改。':'Filled automatically; you can continue editing.','请先选择同步目标机器':'Select target hosts first'});
|
||||||
Object.assign(EN,{'批量同步':'Batch sync','从当前配置机器复制所选配置项到目标机器':'Copy selected settings from the current configuration host to target hosts','来源机器':'Source host','目标机器':'Target hosts','配置项':'Settings','全部选择':'Select all','全部取消':'Clear all','反选':'Invert selection','开始批量同步':'Start batch sync','机器标识和机器列表不会参与同步。':'Host identity and host registry are never synchronized.','请选择至少一个配置项':'Select at least one setting','请选择至少一台目标机器':'Select at least one target host','不能选择来源机器作为目标':'The source host cannot also be a target','目标机器令牌':'Target host token','项已选择':'settings selected'});
|
Object.assign(EN,{'批量同步':'Batch sync','从当前配置机器复制所选配置项到目标机器':'Copy selected settings from the current configuration host to target hosts','来源机器':'Source host','目标机器':'Target hosts','配置项':'Settings','全部选择':'Select all','全部取消':'Clear all','反选':'Invert selection','开始批量同步':'Start batch sync','机器标识和机器列表不会参与同步。':'Host identity and host registry are never synchronized.','请选择至少一个配置项':'Select at least one setting','请选择至少一台目标机器':'Select at least one target host','不能选择来源机器作为目标':'The source host cannot also be a target','目标机器令牌':'Target host token','项已选择':'settings selected'});
|
||||||
|
Object.assign(EN,{'硬件插件':'Hardware plugins','配置按用途分组。监控设置与硬件插件配置独立保存。':'Monitoring settings and hardware plugin settings are saved independently.'});
|
||||||
const ZH=Object.fromEntries(Object.entries(EN).map(([zh,en])=>[en,zh])),tr=s=>LANG==='en'?(EN[s]||s):(ZH[s]||s);
|
const ZH=Object.fromEntries(Object.entries(EN).map(([zh,en])=>[en,zh])),tr=s=>LANG==='en'?(EN[s]||s):(ZH[s]||s);
|
||||||
function translateNode(root=document){const map=LANG==='en'?EN:ZH,translate=value=>{const trim=value.trim();return trim&&map[trim]?value.replace(trim,map[trim]):value};const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;while(n=walker.nextNode())n.nodeValue=translate(n.nodeValue);root.querySelectorAll?.('[placeholder],[title],[aria-label]').forEach(e=>['placeholder','title','aria-label'].forEach(a=>{const v=e.getAttribute(a);if(v)e.setAttribute(a,translate(v))}))}
|
function translateNode(root=document){const map=LANG==='en'?EN:ZH,translate=value=>{const trim=value.trim();return trim&&map[trim]?value.replace(trim,map[trim]):value};const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;while(n=walker.nextNode())n.nodeValue=translate(n.nodeValue);root.querySelectorAll?.('[placeholder],[title],[aria-label]').forEach(e=>['placeholder','title','aria-label'].forEach(a=>{const v=e.getAttribute(a);if(v)e.setAttribute(a,translate(v))}))}
|
||||||
function applyTheme(value){const resolved=value==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):value;document.documentElement.dataset.theme=resolved}
|
function applyTheme(value){const resolved=value==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):value;document.documentElement.dataset.theme=resolved}
|
||||||
const $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={},fleetHosts=[],currentConfigHost='';const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={},fleetHosts=[],currentConfigHost='';const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const sectionMeta={device:['设备标识','自定义本机显示名称和稳定标识'],hosts:['机器列表','登记需要聚合监控的其他节点'],led:['灯光设置','正常灯效与告警颜色'],alerts:['告警阈值','处理器、内存、温度、磁盘和 Wi-Fi'],fan:['风扇温控','温度档位与降档回差'],timing:['刷新周期','传感器、屏幕、网络和健康检查'],display:['屏幕布局','OLED 四行文字位置'],dark_mode:['板载指示灯','树莓派电源灯与活动灯'],services:['服务监控','监控 systemd 服务'],processes:['进程监控','监控普通进程关键字'],api:['Web 与 API','监听地址、端口和日志数量'],bulk_sync:['批量同步','从当前配置机器复制所选配置项到目标机器']};
|
const sectionMeta={plugins:['硬件插件','独立安装,按实际能力提供风扇、屏幕或灯光控制'],hardware:['硬件插件','可选 Yahboom 散热板;未安装时保持关闭'],device:['设备标识','自定义本机显示名称和稳定标识'],hosts:['机器列表','登记需要聚合监控的其他节点'],led:['灯光设置','正常灯效与告警颜色'],alerts:['告警阈值','处理器、内存、温度、磁盘和 Wi-Fi'],fan:['风扇温控','温度档位与降档回差'],timing:['刷新周期','传感器、屏幕、网络和健康检查'],display:['屏幕布局','OLED 四行文字位置'],dark_mode:['板载指示灯','树莓派电源灯与活动灯'],services:['服务监控','监控 systemd 服务'],processes:['进程监控','监控普通进程关键字'],api:['Web 与 API','监听地址、端口和日志数量'],bulk_sync:['批量同步','从当前配置机器复制所选配置项到目标机器']};
|
||||||
const labels={'led.normal_mode':'正常状态灯效','led.normal_r':'红色通道','led.normal_g':'绿色通道','led.normal_b':'蓝色通道','led.normal_flash_on_ms':'亮灯时间','led.normal_flash_off_ms':'灭灯时间','led.normal_effect_speed':'内置效果速度','led.normal_effect_color':'内置效果颜色','led.warning_flash_on_ms':'普通告警亮灯','led.warning_flash_off_ms':'普通告警灭灯','led.critical_flash_on_ms':'严重告警亮灯','led.critical_flash_off_ms':'严重告警灭灯','led.emergency_flash_on_ms':'紧急告警亮灯','led.emergency_flash_off_ms':'紧急告警灭灯','led.write_delay_ms':'RGB 寄存器写入间隔','alerts.cpu_high':'处理器高负载阈值','alerts.cpu_recover':'处理器恢复阈值','alerts.cpu_high_seconds':'处理器持续时间','alerts.memory_high':'内存高占用阈值','alerts.memory_recover':'内存恢复阈值','alerts.memory_high_seconds':'内存持续时间','alerts.temp_high':'高温告警阈值','alerts.temp_critical':'严重高温阈值','alerts.disk_high':'磁盘高占用阈值','alerts.disk_critical':'磁盘严重阈值','alerts.wifi_weak':'Wi-Fi 弱信号阈值','alerts.wifi_recover':'Wi-Fi 恢复阈值','alerts.wifi_weak_seconds':'弱信号持续时间','fan.start':'风扇启动温度','fan.level2':'二档起始温度','fan.level3':'三档起始温度','fan.level4':'四档起始温度','fan.max':'全速起始温度','fan.hysteresis':'降档温度回差','timing.sensor_interval':'传感器采样周期','timing.oled_refresh_interval':'OLED 刷新周期','timing.page_interval':'OLED 页面轮播周期','timing.network_interval':'网络检查周期','timing.service_interval':'服务检查周期','timing.health_interval':'健康检查周期','display.row1_y':'第一行纵坐标','display.row2_y':'第二行纵坐标','display.row3_y':'第三行纵坐标','display.row4_y':'第四行纵坐标','dark_mode.pi_pwr_led':'树莓派电源灯','dark_mode.pi_act_led':'树莓派活动灯','api.bind':'监听地址','api.port':'监听端口','api.log_limit':'默认日志条数','device.identifier':'机器标识','device.name':'显示名称'};
|
const labels={'hardware.cooling_hat_enabled':'启用散热板(OLED、风扇、RGB)','led.alert_mode':'告警灯效模式','led.custom_min_hold_ms':'自定义闪烁最小保持时间','led.normal_mode':'正常状态灯效','led.normal_r':'红色通道','led.normal_g':'绿色通道','led.normal_b':'蓝色通道','led.normal_flash_on_ms':'亮灯时间','led.normal_flash_off_ms':'灭灯时间','led.normal_effect_speed':'内置效果速度','led.normal_effect_color':'内置效果颜色','led.warning_flash_on_ms':'普通告警亮灯','led.warning_flash_off_ms':'普通告警灭灯','led.critical_flash_on_ms':'严重告警亮灯','led.critical_flash_off_ms':'严重告警灭灯','led.emergency_flash_on_ms':'紧急告警亮灯','led.emergency_flash_off_ms':'紧急告警灭灯','led.write_delay_ms':'RGB 寄存器写入间隔','alerts.cpu_high':'处理器高负载阈值','alerts.cpu_recover':'处理器恢复阈值','alerts.cpu_high_seconds':'处理器持续时间','alerts.memory_high':'内存高占用阈值','alerts.memory_recover':'内存恢复阈值','alerts.memory_high_seconds':'内存持续时间','alerts.temp_high':'高温告警阈值','alerts.temp_critical':'严重高温阈值','alerts.disk_high':'磁盘高占用阈值','alerts.disk_critical':'磁盘严重阈值','alerts.wifi_weak':'Wi-Fi 弱信号阈值','alerts.wifi_recover':'Wi-Fi 恢复阈值','alerts.wifi_weak_seconds':'弱信号持续时间','fan.start':'风扇启动温度','fan.level2':'二档起始温度','fan.level3':'三档起始温度','fan.level4':'四档起始温度','fan.max':'全速起始温度','fan.hysteresis':'降档温度回差','timing.sensor_interval':'传感器采样周期','timing.oled_refresh_interval':'OLED 刷新周期','timing.page_interval':'OLED 页面轮播周期','timing.network_interval':'网络检查周期','timing.service_interval':'服务检查周期','timing.health_interval':'健康检查周期','display.row1_y':'第一行纵坐标','display.row2_y':'第二行纵坐标','display.row3_y':'第三行纵坐标','display.row4_y':'第四行纵坐标','dark_mode.pi_pwr_led':'树莓派电源灯','dark_mode.pi_act_led':'树莓派活动灯','api.bind':'监听地址','api.port':'监听端口','api.log_limit':'默认日志条数','device.identifier':'机器标识','device.name':'显示名称'};
|
||||||
['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>labels[`led.${g}_${c}`]=`${({r:'红',g:'绿',b:'蓝'})[c]}色通道`));
|
['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>labels[`led.${g}_${c}`]=`${({r:'红',g:'绿',b:'蓝'})[c]}色通道`));
|
||||||
const hints={'led.normal_mode':'仅在没有当前告警时生效;告警会立即抢占。','led.normal_r':'范围 0–255。','led.normal_g':'范围 0–255。','led.normal_b':'范围 0–255。','led.normal_flash_on_ms':'范围 50–60000 毫秒。','led.normal_flash_off_ms':'范围 50–60000 毫秒。','led.normal_effect_speed':'MCU 内置速度档位。','led.normal_effect_color':'仅流水灯和呼吸灯使用。','led.write_delay_ms':'MCU 内置灯效参数写入间隔,通常保持 10 毫秒。','alerts.wifi_weak':'单位 dBm,数值越接近 0 信号越强。','alerts.wifi_recover':'单位 dBm,应高于弱信号阈值。','fan.hysteresis':'避免临界温度附近频繁切换档位。','api.bind':'0.0.0.0 允许局域网访问;127.0.0.1 仅本机。','api.port':'范围 1–65535;保存后 API 自动重启。','dark_mode.pi_pwr_led':'选择“关闭”时由程序熄灭板载灯。','dark_mode.pi_act_led':'选择“关闭”时由程序熄灭板载灯。'};
|
const hints={'hardware.cooling_hat_enabled':'默认关闭;启用前运行安装器 --enable-cooling-hat 安装依赖。','led.alert_mode':'推荐内置呼吸;自定义闪烁仍可能异色或延迟。','led.custom_min_hold_ms':'范围 1500–60000 毫秒。实际亮灭时间不会低于此值。','led.normal_mode':'仅在没有当前告警时生效;告警会立即抢占。','led.normal_r':'范围 0–255。','led.normal_g':'范围 0–255。','led.normal_b':'范围 0–255。','led.normal_flash_on_ms':'范围 50–60000 毫秒。','led.normal_flash_off_ms':'范围 50–60000 毫秒。','led.normal_effect_speed':'MCU 内置速度档位。','led.normal_effect_color':'仅流水灯和呼吸灯使用。','led.write_delay_ms':'MCU 内置灯效参数写入间隔,通常保持 10 毫秒。','alerts.wifi_weak':'单位 dBm,数值越接近 0 信号越强。','alerts.wifi_recover':'单位 dBm,应高于弱信号阈值。','fan.hysteresis':'避免临界温度附近频繁切换档位。','api.bind':'0.0.0.0 允许局域网访问;127.0.0.1 仅本机。','api.port':'范围 1–65535;保存后 API 自动重启。','dark_mode.pi_pwr_led':'选择“关闭”时由程序熄灭板载灯。','dark_mode.pi_act_led':'选择“关闭”时由程序熄灭板载灯。'};
|
||||||
['normal','cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>hints[`led.${g}_${c}`]='范围 0–255。'));
|
['normal','cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>hints[`led.${g}_${c}`]='范围 0–255。'));
|
||||||
const options={'led.normal_mode':[['off','全灭'],['solid','自定义颜色常亮'],['flash','自定义颜色闪烁'],['flow','流水灯'],['breathe','呼吸灯'],['marquee','跑马灯'],['rainbow','彩虹灯'],['colorful','炫彩灯']],'led.normal_effect_speed':[['1','慢速'],['2','中速'],['3','快速']],'led.normal_effect_color':[['red','红色'],['green','绿色'],['blue','蓝色'],['yellow','黄色'],['purple','紫色'],['cyan','青色'],['white','白色']],'dark_mode.pi_pwr_led':[['off','关闭'],['on','不干预']],'dark_mode.pi_act_led':[['off','关闭'],['on','不干预']]};
|
const options={'hardware.cooling_hat_enabled':[['false','关闭'],['true','启用']],'led.alert_mode':[['breathe','内置呼吸(推荐)'],['flash','自定义颜色闪烁(实验性)']],'led.normal_mode':[['off','全灭'],['solid','自定义颜色常亮'],['flash','自定义颜色闪烁'],['flow','流水灯'],['breathe','呼吸灯'],['marquee','跑马灯'],['rainbow','彩虹灯'],['colorful','炫彩灯']],'led.normal_effect_speed':[['1','慢速'],['2','中速'],['3','快速']],'led.normal_effect_color':[['red','红色'],['green','绿色'],['blue','蓝色'],['yellow','黄色'],['purple','紫色'],['cyan','青色'],['white','白色']],'dark_mode.pi_pwr_led':[['off','关闭'],['on','不干预']],'dark_mode.pi_act_led':[['off','关闭'],['on','不干预']]};
|
||||||
const numberRules={'led.normal_r':[0,255,1],'led.normal_g':[0,255,1],'led.normal_b':[0,255,1],'led.normal_flash_on_ms':[50,60000,10],'led.normal_flash_off_ms':[50,60000,10],'led.warning_flash_on_ms':[50,60000,10],'led.warning_flash_off_ms':[50,60000,10],'led.critical_flash_on_ms':[50,60000,10],'led.critical_flash_off_ms':[50,60000,10],'led.emergency_flash_on_ms':[50,60000,10],'led.emergency_flash_off_ms':[50,60000,10],'led.write_delay_ms':[1,1000,1],'alerts.cpu_high':[0,100,1],'alerts.cpu_recover':[0,100,1],'alerts.cpu_high_seconds':[1,86400,1],'alerts.memory_high':[0,100,1],'alerts.memory_recover':[0,100,1],'alerts.memory_high_seconds':[1,86400,1],'alerts.temp_high':[0,120,.1],'alerts.temp_critical':[0,120,.1],'alerts.disk_high':[0,100,1],'alerts.disk_critical':[0,100,1],'alerts.wifi_weak':[-120,0,1],'alerts.wifi_recover':[-120,0,1],'alerts.wifi_weak_seconds':[1,86400,1],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.1],'display.row1_y':[0,31,1],'display.row2_y':[0,31,1],'display.row3_y':[0,31,1],'display.row4_y':[0,31,1],'api.port':[1,65535,1],'api.log_limit':[1,1000,1]};['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>numberRules[`led.${g}_${c}`]=[0,255,1]));
|
const numberRules={'led.custom_min_hold_ms':[1500,60000,100],'led.normal_r':[0,255,1],'led.normal_g':[0,255,1],'led.normal_b':[0,255,1],'led.normal_flash_on_ms':[50,60000,10],'led.normal_flash_off_ms':[50,60000,10],'led.warning_flash_on_ms':[50,60000,10],'led.warning_flash_off_ms':[50,60000,10],'led.critical_flash_on_ms':[50,60000,10],'led.critical_flash_off_ms':[50,60000,10],'led.emergency_flash_on_ms':[50,60000,10],'led.emergency_flash_off_ms':[50,60000,10],'led.write_delay_ms':[1,1000,1],'alerts.cpu_high':[0,100,1],'alerts.cpu_recover':[0,100,1],'alerts.cpu_high_seconds':[1,86400,1],'alerts.memory_high':[0,100,1],'alerts.memory_recover':[0,100,1],'alerts.memory_high_seconds':[1,86400,1],'alerts.temp_high':[0,120,.1],'alerts.temp_critical':[0,120,.1],'alerts.disk_high':[0,100,1],'alerts.disk_critical':[0,100,1],'alerts.wifi_weak':[-120,0,1],'alerts.wifi_recover':[-120,0,1],'alerts.wifi_weak_seconds':[1,86400,1],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.1],'display.row1_y':[0,31,1],'display.row2_y':[0,31,1],'display.row3_y':[0,31,1],'display.row4_y':[0,31,1],'api.port':[1,65535,1],'api.log_limit':[1,1000,1]};['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>numberRules[`led.${g}_${c}`]=[0,255,1]));
|
||||||
const units={'led.normal_flash_on_ms':'毫秒','led.normal_flash_off_ms':'毫秒','led.warning_flash_on_ms':'毫秒','led.warning_flash_off_ms':'毫秒','led.critical_flash_on_ms':'毫秒','led.critical_flash_off_ms':'毫秒','led.emergency_flash_on_ms':'毫秒','led.emergency_flash_off_ms':'毫秒','led.write_delay_ms':'毫秒','alerts.cpu_high':'%','alerts.cpu_recover':'%','alerts.cpu_high_seconds':'秒','alerts.memory_high':'%','alerts.memory_recover':'%','alerts.memory_high_seconds':'秒','alerts.temp_high':'°C','alerts.temp_critical':'°C','alerts.disk_high':'%','alerts.disk_critical':'%','alerts.wifi_weak':'dBm','alerts.wifi_recover':'dBm','alerts.wifi_weak_seconds':'秒','fan.start':'°C','fan.level2':'°C','fan.level3':'°C','fan.level4':'°C','fan.max':'°C','fan.hysteresis':'°C','timing.sensor_interval':'秒','timing.oled_refresh_interval':'秒','timing.page_interval':'秒','timing.network_interval':'秒','timing.service_interval':'秒','timing.health_interval':'秒','display.row1_y':'像素','display.row2_y':'像素','display.row3_y':'像素','display.row4_y':'像素','api.port':'端口','api.log_limit':'条'};
|
const units={'led.custom_min_hold_ms':'毫秒','led.normal_flash_on_ms':'毫秒','led.normal_flash_off_ms':'毫秒','led.warning_flash_on_ms':'毫秒','led.warning_flash_off_ms':'毫秒','led.critical_flash_on_ms':'毫秒','led.critical_flash_off_ms':'毫秒','led.emergency_flash_on_ms':'毫秒','led.emergency_flash_off_ms':'毫秒','led.write_delay_ms':'毫秒','alerts.cpu_high':'%','alerts.cpu_recover':'%','alerts.cpu_high_seconds':'秒','alerts.memory_high':'%','alerts.memory_recover':'%','alerts.memory_high_seconds':'秒','alerts.temp_high':'°C','alerts.temp_critical':'°C','alerts.disk_high':'%','alerts.disk_critical':'%','alerts.wifi_weak':'dBm','alerts.wifi_recover':'dBm','alerts.wifi_weak_seconds':'秒','fan.start':'°C','fan.level2':'°C','fan.level3':'°C','fan.level4':'°C','fan.max':'°C','fan.hysteresis':'°C','timing.sensor_interval':'秒','timing.oled_refresh_interval':'秒','timing.page_interval':'秒','timing.network_interval':'秒','timing.service_interval':'秒','timing.health_interval':'秒','display.row1_y':'像素','display.row2_y':'像素','display.row3_y':'像素','display.row4_y':'像素','api.port':'端口','api.log_limit':'条'};
|
||||||
const sectionOrder=['device','hosts','led','alerts','fan','timing','display','dark_mode','services','processes','api'],bulkSections=sectionOrder.filter(s=>!['device','hosts'].includes(s)),alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']];
|
const sectionOrder=['device','hosts','plugins','alerts','timing','dark_mode','services','processes','api'],bulkSections=sectionOrder.filter(s=>!['device','hosts','plugins'].includes(s)),alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']];
|
||||||
const ownerNames={NORMAL_HOME:'正常主页',ROOT_READONLY:'根文件系统只读',TEMP_CRITICAL:'处理器温度严重',POWER_UNDERVOLT:'供电欠压',THROTTLED:'处理器降频',FREQ_CAPPED:'频率受限',NETWORK_DOWN:'网络断开',INTERNET_DOWN:'互联网断开',SERVICE_DOWN:'服务停止',PROCESS_DOWN:'进程停止',DISK_CRITICAL:'磁盘严重不足',TEMP_HIGH:'处理器温度偏高',CPU_HIGH:'处理器负载过高',MEMORY_HIGH:'内存占用过高',DISK_HIGH:'磁盘空间不足',WIFI_WEAK:'Wi-Fi 信号弱'},rgbNames={OFF:'全灭',NORMAL_SOLID:'正常 · 自定义常亮',NORMAL_FLASH:'正常 · 自定义闪烁',NORMAL_FLOW:'正常 · 流水灯',NORMAL_BREATHE:'正常 · 呼吸灯',NORMAL_MARQUEE:'正常 · 跑马灯',NORMAL_RAINBOW:'正常 · 彩虹灯',NORMAL_COLORFUL:'正常 · 炫彩灯'},eventNames={FAN_LEVEL:'风扇档位变化',START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NET_SWITCH:'网络接口切换',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复',POWER_HISTORY:'历史电源状态',HISTORY_FLAG:'历史电源状态',ROOT_READONLY:'根文件系统只读',RGB_ERROR:'RGB 控制错误',RGB_MODE:'RGB 模式切换',RGB_RECOVERED:'RGB 控制恢复',SYSTEM:'系统事件',WATCH_DOWN:'监控对象停止',WATCH_RECOVERED:'监控对象恢复'};
|
const ownerNames={NORMAL_HOME:'正常主页',ROOT_READONLY:'根文件系统只读',TEMP_CRITICAL:'处理器温度严重',POWER_UNDERVOLT:'供电欠压',THROTTLED:'处理器降频',FREQ_CAPPED:'频率受限',NETWORK_DOWN:'网络断开',INTERNET_DOWN:'互联网断开',SERVICE_DOWN:'服务停止',PROCESS_DOWN:'进程停止',DISK_CRITICAL:'磁盘严重不足',TEMP_HIGH:'处理器温度偏高',CPU_HIGH:'处理器负载过高',MEMORY_HIGH:'内存占用过高',DISK_HIGH:'磁盘空间不足',WIFI_WEAK:'Wi-Fi 信号弱'},rgbNames={DISABLED:'未启用',OFF:'全灭',NORMAL_SOLID:'正常 · 自定义常亮',NORMAL_FLASH:'正常 · 自定义闪烁',NORMAL_FLOW:'正常 · 流水灯',NORMAL_BREATHE:'正常 · 呼吸灯',NORMAL_MARQUEE:'正常 · 跑马灯',NORMAL_RAINBOW:'正常 · 彩虹灯',NORMAL_COLORFUL:'正常 · 炫彩灯'},eventNames={HARDWARE_ENABLED:'硬件插件已启用',HARDWARE_DISABLED:'硬件插件已关闭',HARDWARE_UNAVAILABLE:'硬件插件不可用',FAN_LEVEL:'风扇档位变化',START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NET_SWITCH:'网络接口切换',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复',POWER_HISTORY:'历史电源状态',HISTORY_FLAG:'历史电源状态',ROOT_READONLY:'根文件系统只读',RGB_ERROR:'RGB 控制错误',RGB_MODE:'RGB 模式切换',RGB_RECOVERED:'RGB 控制恢复',SYSTEM:'系统事件',WATCH_DOWN:'监控对象停止',WATCH_RECOVERED:'监控对象恢复'};
|
||||||
function ownerName(id){const [type,suffix]=String(id||'').split('|',2),name=ownerNames[type]||type;return tr(name)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return tr(rgbNames[mode]);const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${tr(groups[p[0]]||p[0])} · ${tr(levels[p[1]]||p[1])}${LANG==='zh'?'闪烁':' flash'}`}function eventName(id){return tr(eventNames[id]||(LANG==='zh'?`未知事件(${id})`:String(id).toLowerCase().replaceAll('_',' ')))}
|
function ownerName(id){const [type,suffix]=String(id||'').split('|',2),name=ownerNames[type]||type;return tr(name)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return tr(rgbNames[mode]);const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${tr(groups[p[0]]||p[0])} · ${tr(levels[p[1]]||p[1])}${p[2]==='BREATHE'?(LANG==='zh'?'呼吸':' breathing'):(LANG==='zh'?'闪烁':' flash')}`}function eventName(id){return tr(eventNames[id]||(LANG==='zh'?`未知事件(${id})`:String(id).toLowerCase().replaceAll('_',' ')))}
|
||||||
function field(section,key,value,extra=''){const id=`${section}.${key}`,opts=options[id],rule=numberRules[id],label=tr(labels[id]||key),help=tr(hints[id]||''),unit=tr(units[id]||'');let control;if(opts)control=`<select data-section="${esc(section)}" data-key="${esc(key)}">${opts.map(([v,l])=>`<option value="${v}" ${String(value)===v?'selected':''}>${tr(l)}</option>`).join('')}</select>`;else{const input=`<input ${rule?'type="number"':''} ${rule?`min="${rule[0]}" max="${rule[1]}" step="${rule[2]}"`:''} data-section="${esc(section)}" data-key="${esc(key)}" value="${esc(value)}">`;control=unit?`<div class="input-unit">${input}<span class="unit">${unit}</span></div>`:input}return`<div class="field ${extra}"><label>${esc(label)}${control}</label>${help?`<div class="hint">${esc(help)}</div>`:''}</div>`}
|
function field(section,key,value,extra=''){const id=`${section}.${key}`,opts=options[id],rule=numberRules[id],label=tr(labels[id]||key),help=tr(hints[id]||''),unit=tr(units[id]||'');let control;if(opts)control=`<select data-section="${esc(section)}" data-key="${esc(key)}">${opts.map(([v,l])=>`<option value="${v}" ${String(value)===v?'selected':''}>${tr(l)}</option>`).join('')}</select>`;else{const input=`<input ${rule?'type="number"':''} ${rule?`min="${rule[0]}" max="${rule[1]}" step="${rule[2]}"`:''} data-section="${esc(section)}" data-key="${esc(key)}" value="${esc(value)}">`;control=unit?`<div class="input-unit">${input}<span class="unit">${unit}</span></div>`:input}return`<div class="field ${extra}"><label>${esc(label)}${control}</label>${help?`<div class="hint">${esc(help)}</div>`:''}</div>`}
|
||||||
function rgbGroup(values,prefix,title){const r=Number(values[`${prefix}_r`]||0),g=Number(values[`${prefix}_g`]||0),b=Number(values[`${prefix}_b`]||0),hex='#'+[r,g,b].map(x=>Math.max(0,Math.min(255,x)).toString(16).padStart(2,'0')).join('');return`<div class="group"><div class="group-head"><h3>${tr(title)}</h3><span class="muted">${tr('点击色块打开色盘')}</span></div><div class="rgb-row"><div class="color-field"><label>${tr('颜色')}</label><div class="swatch-wrap"><span class="swatch" data-swatch="${prefix}" style="background:${hex}"></span><input class="color-picker" type="color" data-color-prefix="${prefix}" value="${hex}" title="${LANG==='zh'?'选择':'Choose ' }${esc(tr(title))}"></div></div>${['r','g','b'].map(c=>field('led',`${prefix}_${c}`,values[`${prefix}_${c}`])).join('')}</div></div>`}
|
function pluginText(zh,en){return LANG==='zh'?zh:en}
|
||||||
function normalGroup(v){return`<div class="group"><div class="group-head"><div><h3>正常运行灯效</h3><div class="hint">没有告警时显示;告警出现后自动让位。</div></div></div><div class="fields">${field('led','normal_mode',v.normal_mode,'span2')}<div class="normal-field span2" data-modes="solid flash">${rgbGroup(v,'normal','自定义颜色')}</div><div class="normal-field" data-modes="flash">${field('led','normal_flash_on_ms',v.normal_flash_on_ms)}</div><div class="normal-field" data-modes="flash">${field('led','normal_flash_off_ms',v.normal_flash_off_ms)}</div><div class="normal-field" data-modes="flow breathe marquee rainbow colorful">${field('led','normal_effect_speed',v.normal_effect_speed)}</div><div class="normal-field" data-modes="flow breathe">${field('led','normal_effect_color',v.normal_effect_color)}</div></div></div>`}
|
function renderPlugins(){return `<div class="group"><p>${pluginText('仅发现插件不会接管灯光。接入后发送监控状态;暂停或断开不会停止本地温控。','Discovery does not take over lighting. Connecting sends monitoring state; pausing or disconnecting leaves autonomous cooling running.')}</p><button type="button" onclick="loadPlugins()">${tr('刷新列表')}</button><div id="pluginList"></div></div><div id="pluginEditor"></div>`}
|
||||||
function renderLed(v){const flash=['warning','critical','emergency'].map((x,i)=>`<div class="group"><h3>${tr(['普通告警闪烁节奏','严重告警闪烁节奏','紧急告警闪烁节奏'][i])}</h3><div class="fields">${field('led',`${x}_flash_on_ms`,v[`${x}_flash_on_ms`])}${field('led',`${x}_flash_off_ms`,v[`${x}_flash_off_ms`])}</div></div>`).join(''),advanced=v.write_delay_ms===undefined?'':`<div class="group"><h3>硬件高级参数</h3><div class="fields">${field('led','write_delay_ms',v.write_delay_ms)}</div></div>`;return normalGroup(v)+`<div class="group"><div class="group-head"><div><h3>告警对象颜色</h3><div class="hint">告警对象决定颜色,严重程度决定闪烁速度。</div></div></div>${alertColorGroups.map(([p,t])=>rgbGroup(v,p,tr(t))).join('')}</div>`+flash+advanced}
|
function pluginSummary(plugins,host){return Object.entries(plugins).map(([id,p])=>{const st=p.status||{},cap=p.capabilities||{},details=[];if(p.online){if(cap.fan)details.push(pluginText('风扇设置','Fan setting')+' '+esc(st.fan_name||'—'));if(cap.display)details.push('OLED '+esc(ownerName(st.oled_page||'')));if(cap.rgb)details.push('RGB '+esc(rgbName(st.rgb_mode||'')))}return `<div class="group"><strong>${esc(p.name||id)}</strong> · ${p.online?tr('在线'):tr('离线')} ${details.join(' · ')} <button type="button" data-manage-plugin-host="${esc(host)}">${pluginText('管理插件','Manage plugin')}</button></div>`}).join('')}
|
||||||
|
|
||||||
|
let pluginRows=[],pluginDraft=null,pluginSelected='';
|
||||||
|
async function loadPlugins(){const list=$('pluginList');if(!list)return;list.textContent=pluginText('正在发现本机插件…','Discovering local plugins…');try{const data=await get('/api/v1/plugins?host='+encodeURIComponent(currentConfigHost));pluginRows=data.plugins;list.innerHTML=pluginRows.map(p=>`<div class="group"><h3>${esc(p.descriptor?.name||p.id)}</h3><div class="hint">${esc(p.socket)} · ${p.online?tr('在线'):tr('离线')} · ${p.linked?(p.paused?pluginText('联动已暂停','Link paused'):pluginText('已接入','Connected')):pluginText('尚未接入','Not connected')}</div>${p.error?`<p class="error">${esc(p.error)}</p>`:''}${p.status?.errors&&Object.keys(p.status.errors).length?`<p class="error">${esc(JSON.stringify(p.status.errors))}</p>`:''}<p>${Object.keys(p.descriptor?.capabilities||{}).map(k=>esc(({fan:pluginText('风扇','Fan'),display:pluginText('屏幕','Display'),rgb:pluginText('灯光','Lighting')})[k]||k)).join(' · ')}</p><div class="toolbar">${p.linked?`<button data-plugin-op="${p.paused?'resume':'pause'}" data-plugin-id="${esc(p.id)}">${p.paused?pluginText('恢复告警联动','Resume alert link'):pluginText('暂停告警联动','Pause alert link')}</button><button data-plugin-op="unlink" data-plugin-id="${esc(p.id)}">${pluginText('断开接入','Disconnect')}</button>`:`<button data-plugin-op="link" data-plugin-id="${esc(p.id)}" ${p.online?'':'disabled'}>${pluginText('接入插件','Connect plugin')}</button>`}<button data-plugin-edit="${esc(p.id)}" ${p.online?'':'disabled'}>${pluginText('插件配置','Plugin settings')}</button></div></div>`).join('')||`<p class="muted">${pluginText('未发现硬件插件。请在目标机器上独立安装并启动所需插件;没有散热板也不影响监控。','No hardware plugins found. Install and start the required plugin on the target host. Monitoring does not require a cooling board.')}</p>`;list.querySelectorAll('[data-plugin-op]').forEach(b=>b.onclick=()=>pluginAction(b.dataset.pluginId,b.dataset.pluginOp));list.querySelectorAll('[data-plugin-edit]').forEach(b=>b.onclick=()=>editPlugin(b.dataset.pluginEdit))}catch(e){list.textContent=e.message}}
|
||||||
|
async function pluginPut(path,payload){const local=fleetHosts.find(h=>h.local);saveHostToken(currentConfigHost,$('token').value.trim());return get(path,{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${hostToken(local?.id||'')}`},body:JSON.stringify({...payload,host:currentConfigHost,target_token:hostToken(currentConfigHost)})})}
|
||||||
|
async function pluginAction(id,operation){try{await pluginPut('/api/v1/plugins',{id,operation});await loadPlugins()}catch(e){alert(e.message)}}
|
||||||
|
function capabilityField(section,key,value,spec){const unit=LANG==='zh'?({s:'秒',ms:'毫秒'}[spec.unit]||spec.unit):spec.unit,label=spec.label?.[LANG]||spec.label?.en||key,id=`${section}.${key}`,vis=spec.visible?` data-cap-visible="${esc(JSON.stringify(spec.visible))}"`:'';return `<label class="field"${vis}>${esc(label)}${spec.type==='enum'?`<select data-cap-section="${esc(section)}" data-cap-key="${esc(key)}">${spec.choices.map(x=>`<option value="${esc(x)}" ${String(value)===String(x)?'selected':''}>${esc(pluginChoice(x))}</option>`).join('')}</select>`:`<div class="input-unit"><input type="number" step="${section==='rgb'?1:'any'}" min="${esc(spec.min)}" max="${esc(spec.max)}" data-cap-section="${esc(section)}" data-cap-key="${esc(key)}" value="${esc(value)}"><span class="unit">${esc(unit)}</span></div><span class="hint">${pluginText('范围','Range')} ${spec.min}–${spec.max} ${esc(unit)}</span>`}</label>`}
|
||||||
|
function pluginChoice(x){const zh={off:'全灭',solid:'自定义常亮',flash:'自定义闪烁(实验性)',breathe:'内置呼吸',flow:'流水灯',marquee:'跑马灯',rainbow:'彩虹灯',colorful:'炫彩灯',red:'红色',green:'绿色',blue:'蓝色',yellow:'黄色',purple:'紫色',cyan:'青色',white:'白色'};return LANG==='zh'?(zh[x]||x):x}
|
||||||
|
function capabilityFields(section,fields,values){const seen=new Set();return Object.entries(fields).map(([key,spec])=>{if(!spec.color_group)return capabilityField(section,key,values[key],spec);if(seen.has(spec.color_group))return '';seen.add(spec.color_group);const members=Object.entries(fields).filter(([,v])=>v.color_group===spec.color_group),hex='#'+['r','g','b'].map(c=>Number(values[members.find(([,v])=>v.channel===c)[0]]).toString(16).padStart(2,'0')).join('');return `<div class="group span2" data-cap-visible="${esc(JSON.stringify(spec.visible||{}))}"><div style="display:grid;grid-template-columns:52px repeat(3,minmax(0,1fr));gap:10px;align-items:start"><label style="text-align:center">${pluginText('颜色','Color')}<input type="color" data-cap-color="${esc(spec.color_group)}" value="${hex}" aria-label="${pluginText('选择颜色','Choose color')}" style="width:38px;height:34px;padding:0;margin-top:5px"></label>${members.map(([k,v])=>capabilityField(section,k,values[k],{...v,visible:null})).join('')}</div></div>`}).join('')}
|
||||||
|
async function editPlugin(id){try{pluginSelected=id;pluginDraft=await get('/api/v1/plugin/config?host='+encodeURIComponent(currentConfigHost)+'&id='+encodeURIComponent(id));const data=pluginDraft,editor=$('pluginEditor');editor.innerHTML=`<div class="group"><h3>${esc(id)}</h3><p class="hint">${pluginText('配置由插件保存并立即应用,不重启监控。','Settings are saved and applied by the plugin without restarting monitoring.')}</p>${(data.notes||[]).map(n=>`<p class="hint">${esc(n[LANG]||n.en||'')}</p>`).join('')}${Object.entries(data.schema).map(([section,fields])=>`<div class="group"><h3>${({fan:pluginText('风扇','Fan'),oled:pluginText('屏幕','Display'),rgb:pluginText('灯光','Lighting')})[section]||esc(section)}</h3><div class="fields">${capabilityFields(section,fields,data.config[section])}</div></div>`).join('')}<button id="savePlugin">${pluginText('保存插件配置','Save plugin settings')}</button> <span id="pluginSaveState"></span></div>`;editor.oninput=e=>{if(e.target.dataset.capColor){const group=e.target.dataset.capColor,hex=e.target.value;['r','g','b'].forEach((c,i)=>{editor.querySelector(`[data-cap-key="${group}_${c}"]`).value=parseInt(hex.slice(1+2*i,3+2*i),16)})}for(const picker of editor.querySelectorAll('[data-cap-color]')){picker.value='#'+['r','g','b'].map(c=>Math.max(0,Math.min(255,Number(editor.querySelector(`[data-cap-key="${picker.dataset.capColor}_${c}"]`).value))).toString(16).padStart(2,'0')).join('')}updatePluginVisibility()};updatePluginVisibility();$('savePlugin').onclick=savePluginConfig}catch(e){$('pluginEditor').textContent=e.message}}
|
||||||
|
function updatePluginVisibility(){document.querySelectorAll('[data-cap-visible]').forEach(el=>{const rules=JSON.parse(el.dataset.capVisible);el.classList.toggle('hidden',!Object.entries(rules).every(([key,values])=>values.includes(document.querySelector(`[data-cap-key="${key}"]`)?.value)))})}
|
||||||
|
async function savePluginConfig(){const updates={};for(const el of document.querySelectorAll('[data-cap-key]')){if(!el.reportValidity())return;const s=el.dataset.capSection,k=el.dataset.capKey;(updates[s]??={})[k]=el.value}try{await pluginPut('/api/v1/plugin/config',{id:pluginSelected,updates});$('pluginSaveState').textContent=pluginText('保存成功','Saved')}catch(e){$('pluginSaveState').textContent=e.message}}
|
||||||
|
document.addEventListener('click',async e=>{const b=e.target.closest('[data-manage-plugin-host]');if(!b)return;currentConfigHost=b.dataset.managePluginHost;await ensureHosts();$('configHost').value=currentConfigHost;document.querySelector('[data-tab="config"]').click();await loadConfig();document.querySelector('[data-config-section="plugins"]')?.click()});
|
||||||
function renderGeneral(s,v){const [t,d]=sectionMeta[s]||[s,''];return`<div class="group"><div class="group-head"><div><h3>${tr(t)}</h3><div class="hint">${tr(d)}</div></div></div><div class="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<div class="muted">暂无配置项</div>'}</div></div>`}
|
function renderGeneral(s,v){const [t,d]=sectionMeta[s]||[s,''];return`<div class="group"><div class="group-head"><div><h3>${tr(t)}</h3><div class="hint">${tr(d)}</div></div></div><div class="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<div class="muted">暂无配置项</div>'}</div></div>`}
|
||||||
function renderServices(){return`<div class="group"><div class="group-head"><div><h3>当前系统服务</h3><div class="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><button id="refreshServices">刷新列表</button></div><input class="service-search" id="serviceSearch" placeholder="搜索服务名称或说明"><div class="service-list rows" id="systemServices"><div class="muted">正在读取 systemd 服务…</div></div></div><div class="group"><h3>已监控服务</h3><div class="hint">“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。</div><div class="rows" id="monitoredServices"></div></div><div class="group"><h3 id="serviceFormTitle">新建服务监控</h3><div class="service-form"><label>OLED 显示名称<input id="monitorName" maxlength="64" placeholder="例如:DOCKER"></label><label>systemd 服务名称<input id="monitorTarget" maxlength="128" placeholder="例如:docker.service"></label><label class="full">备注说明<input id="monitorNote" maxlength="120" placeholder="例如:家庭相册容器服务"></label><div class="full"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div>`}
|
function renderServices(){return`<div class="group"><div class="group-head"><div><h3>当前系统服务</h3><div class="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><button id="refreshServices">刷新列表</button></div><input class="service-search" id="serviceSearch" placeholder="搜索服务名称或说明"><div class="service-list rows" id="systemServices"><div class="muted">正在读取 systemd 服务…</div></div></div><div class="group"><h3>已监控服务</h3><div class="hint">“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。</div><div class="rows" id="monitoredServices"></div></div><div class="group"><h3 id="serviceFormTitle">新建服务监控</h3><div class="service-form"><label>OLED 显示名称<input id="monitorName" maxlength="64" placeholder="例如:DOCKER"></label><label>systemd 服务名称<input id="monitorTarget" maxlength="128" placeholder="例如:docker.service"></label><label class="full">备注说明<input id="monitorNote" maxlength="120" placeholder="例如:家庭相册容器服务"></label><div class="full"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div>`}
|
||||||
function renderHosts(v){const entries=Object.entries(v||{});return`<div class="group"><h3>已登记机器</h3><div class="rows">${entries.map(([id,url])=>`<div class="row"><span><b>${esc(id)}</b><br><span class="muted">${esc(url)}</span></span><button class="danger" data-delete-host="${esc(id)}">删除</button></div>`).join('')||'<div class="muted">尚未登记其他机器</div>'}</div></div><div class="group"><h3>添加或更新机器</h3><div class="service-form"><label>机器标识<input id="hostId" maxlength="64" placeholder="例如:living-room"></label><label>节点地址<input id="hostUrl" maxlength="200" placeholder="例如:http://192.168.1.20:6001"></label><div class="full"><button id="saveHost">保存机器</button> <span id="hostMessage" class="muted"></span></div></div></div>`}
|
function renderHosts(v){const entries=Object.entries(v||{});return`<div class="group"><h3>已登记机器</h3><div class="rows">${entries.map(([id,url])=>`<div class="row"><span><b>${esc(id)}</b><br><span class="muted">${esc(url)}</span></span><button class="danger" data-delete-host="${esc(id)}">删除</button></div>`).join('')||'<div class="muted">尚未登记其他机器</div>'}</div></div><div class="group"><h3>添加或更新机器</h3><div class="service-form"><label>机器标识<input id="hostId" maxlength="64" placeholder="例如:living-room"></label><label>节点地址<input id="hostUrl" maxlength="200" placeholder="例如:http://192.168.1.20:6001"></label><div class="full"><button id="saveHost">保存机器</button> <span id="hostMessage" class="muted"></span></div></div></div>`}
|
||||||
@@ -66,8 +79,8 @@ function bulkValue(section,key,value){const item=options[`${section}.${key}`]?.f
|
|||||||
function renderBulkSync(){const groups=bulkSections.filter(s=>original[s]&&Object.keys(original[s]).length).map(s=>`<div class="group bulk-section"><label class="bulk-section-title"><input type="checkbox" data-bulk-section="${esc(s)}">${tr(sectionMeta[s]?.[0]||s)}</label><div class="bulk-items">${Object.entries(original[s]).map(([k,v])=>`<label class="bulk-item"><input type="checkbox" data-bulk-item="${esc(s)}.${esc(k)}"><span>${esc(tr(labels[`${s}.${k}`]||k))}<small class="bulk-value">${esc(bulkValue(s,k,v))}</small></span></label>`).join('')}</div></div>`).join('');return`<div class="group"><h3>${tr('来源机器')}</h3><div>${esc(fleetHosts.find(h=>h.id===currentConfigHost)?.name||currentConfigHost)} · ${esc(currentConfigHost)}</div></div>${renderBulkTargets()}<div class="bulk-tools"><button id="bulkAll">${tr('全部选择')}</button><button id="bulkNone">${tr('全部取消')}</button><button id="bulkInvert">${tr('反选')}</button><span class="muted" id="bulkCount"></span></div><div><h3>${tr('配置项')}</h3>${groups}</div><div class="savebar"><button id="bulkApply">${tr('开始批量同步')}</button> <span id="bulkState" class="muted"></span></div>`}
|
function renderBulkSync(){const groups=bulkSections.filter(s=>original[s]&&Object.keys(original[s]).length).map(s=>`<div class="group bulk-section"><label class="bulk-section-title"><input type="checkbox" data-bulk-section="${esc(s)}">${tr(sectionMeta[s]?.[0]||s)}</label><div class="bulk-items">${Object.entries(original[s]).map(([k,v])=>`<label class="bulk-item"><input type="checkbox" data-bulk-item="${esc(s)}.${esc(k)}"><span>${esc(tr(labels[`${s}.${k}`]||k))}<small class="bulk-value">${esc(bulkValue(s,k,v))}</small></span></label>`).join('')}</div></div>`).join('');return`<div class="group"><h3>${tr('来源机器')}</h3><div>${esc(fleetHosts.find(h=>h.id===currentConfigHost)?.name||currentConfigHost)} · ${esc(currentConfigHost)}</div></div>${renderBulkTargets()}<div class="bulk-tools"><button id="bulkAll">${tr('全部选择')}</button><button id="bulkNone">${tr('全部取消')}</button><button id="bulkInvert">${tr('反选')}</button><span class="muted" id="bulkCount"></span></div><div><h3>${tr('配置项')}</h3>${groups}</div><div class="savebar"><button id="bulkApply">${tr('开始批量同步')}</button> <span id="bulkState" class="muted"></span></div>`}
|
||||||
function updateBulkSelection(){document.querySelectorAll('[data-bulk-section]').forEach(parent=>{const items=[...document.querySelectorAll(`[data-bulk-item^="${parent.dataset.bulkSection}."]`)];parent.checked=items.length>0&&items.every(x=>x.checked);parent.indeterminate=items.some(x=>x.checked)&&!parent.checked});const n=document.querySelectorAll('[data-bulk-item]:checked').length;if($('bulkCount'))$('bulkCount').textContent=LANG==='zh'?`已选择 ${n} 项`:`${n} settings selected`}
|
function updateBulkSelection(){document.querySelectorAll('[data-bulk-section]').forEach(parent=>{const items=[...document.querySelectorAll(`[data-bulk-item^="${parent.dataset.bulkSection}."]`)];parent.checked=items.length>0&&items.every(x=>x.checked);parent.indeterminate=items.some(x=>x.checked)&&!parent.checked});const n=document.querySelectorAll('[data-bulk-item]:checked').length;if($('bulkCount'))$('bulkCount').textContent=LANG==='zh'?`已选择 ${n} 项`:`${n} settings selected`}
|
||||||
function bindBulkSync(){if(!$('bulkApply'))return;document.querySelectorAll('[data-bulk-section]').forEach(x=>x.onchange=()=>{document.querySelectorAll(`[data-bulk-item^="${x.dataset.bulkSection}."]`).forEach(i=>i.checked=x.checked);updateBulkSelection()});document.querySelectorAll('[data-bulk-item]').forEach(x=>x.onchange=updateBulkSelection);$('bulkAll').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=true);updateBulkSelection()};$('bulkNone').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=false);updateBulkSelection()};$('bulkInvert').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=!x.checked);updateBulkSelection()};document.querySelectorAll('[data-bulk-token]').forEach(x=>x.onchange=()=>saveHostToken(x.dataset.bulkToken,x.value.trim()));$('bulkApply').onclick=applyBulkConfig;updateBulkSelection()}
|
function bindBulkSync(){if(!$('bulkApply'))return;document.querySelectorAll('[data-bulk-section]').forEach(x=>x.onchange=()=>{document.querySelectorAll(`[data-bulk-item^="${x.dataset.bulkSection}."]`).forEach(i=>i.checked=x.checked);updateBulkSelection()});document.querySelectorAll('[data-bulk-item]').forEach(x=>x.onchange=updateBulkSelection);$('bulkAll').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=true);updateBulkSelection()};$('bulkNone').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=false);updateBulkSelection()};$('bulkInvert').onclick=()=>{document.querySelectorAll('[data-bulk-item]').forEach(x=>x.checked=!x.checked);updateBulkSelection()};document.querySelectorAll('[data-bulk-token]').forEach(x=>x.onchange=()=>saveHostToken(x.dataset.bulkToken,x.value.trim()));$('bulkApply').onclick=applyBulkConfig;updateBulkSelection()}
|
||||||
function updateConditional(){const mode=document.querySelector('[data-section="led"][data-key="normal_mode"]')?.value||'off';document.querySelectorAll('.normal-field').forEach(x=>x.classList.toggle('hidden',!x.dataset.modes.split(' ').includes(mode)));updateSwatches()}function updateSwatches(){document.querySelectorAll('[data-swatch]').forEach(s=>{const p=s.dataset.swatch,read=c=>Math.max(0,Math.min(255,Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0))),hex='#'+['r','g','b'].map(c=>Math.round(read(c)).toString(16).padStart(2,'0')).join('');s.style.background=hex;const picker=document.querySelector(`[data-color-prefix="${p}"]`);if(picker)picker.value=hex})}
|
function updateConditional(){const alertMode=document.querySelector('[data-key="alert_mode"]')?.value||'breathe';document.querySelectorAll('.alert-flash').forEach(e=>e.classList.toggle('hidden',alertMode!=='flash'));document.querySelectorAll('.alert-breathe').forEach(e=>e.classList.toggle('hidden',alertMode!=='breathe'));const mode=document.querySelector('[data-section="led"][data-key="normal_mode"]')?.value||'off';document.querySelectorAll('.normal-field').forEach(x=>x.classList.toggle('hidden',!x.dataset.modes.split(' ').includes(mode)));updateSwatches()}function updateSwatches(){document.querySelectorAll('[data-swatch]').forEach(s=>{const p=s.dataset.swatch,read=c=>Math.max(0,Math.min(255,Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0))),hex='#'+['r','g','b'].map(c=>Math.round(read(c)).toString(16).padStart(2,'0')).join('');s.style.background=hex;const picker=document.querySelector(`[data-color-prefix="${p}"]`);if(picker)picker.value=hex})}
|
||||||
function renderConfig(){const ss=[...sectionOrder.filter(s=>original[s]),'bulk_sync'];$('configMenu').innerHTML=ss.map((s,i)=>`<button class="${i===0?'active':''}" data-config-section="${s}">${tr(sectionMeta[s]?.[0]||s)}</button>`).join('');$('configRows').innerHTML=ss.map((s,i)=>`<section class="config-page ${i===0?'active':''}" data-config-page="${s}"><h2>${tr(sectionMeta[s]?.[0]||s)}</h2><p class="muted">${tr(sectionMeta[s]?.[1]||'')}</p>${s==='bulk_sync'?renderBulkSync():s==='led'?renderLed(original[s]):s==='services'?renderServices():s==='hosts'?renderHosts(original[s]):renderGeneral(s,original[s])}</section>`).join('');$('configMenu').onclick=e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('[data-config-section]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-config-page]').forEach(x=>x.classList.toggle('active',x.dataset.configPage===b.dataset.configSection));$('mainSavebar').classList.toggle('hidden',b.dataset.configSection==='bulk_sync');if(b.dataset.configSection==='services')loadServices()};$('configRows').oninput=e=>{if(e.target.matches('[data-color-prefix]')){const p=e.target.dataset.colorPrefix,v=e.target.value;['r','g','b'].forEach((c,i)=>{document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`).value=parseInt(v.slice(1+i*2,3+i*2),16)})}if(e.target.matches('[data-section="led"],[data-color-prefix]'))updateConditional();if(e.target.matches('[data-section]'))markChanged()};$('refreshServices')?.addEventListener('click',loadServices);$('serviceSearch')?.addEventListener('input',renderServiceLists);$('saveMonitor')?.addEventListener('click',saveMonitor);$('resetMonitor')?.addEventListener('click',resetMonitorForm);$('saveHost')?.addEventListener('click',saveHost);document.querySelectorAll('[data-delete-host]').forEach(b=>b.onclick=()=>deleteHost(b.dataset.deleteHost));bindBulkSync();updateConditional()}
|
function renderConfig(){const ss=[...sectionOrder.filter(s=>original[s]||s==='plugins'),'bulk_sync'];$('configMenu').innerHTML=ss.map((s,i)=>`<button class="${i===0?'active':''}" data-config-section="${s}">${tr(sectionMeta[s]?.[0]||s)}</button>`).join('');$('configRows').innerHTML=ss.map((s,i)=>`<section class="config-page ${i===0?'active':''}" data-config-page="${s}"><h2>${tr(sectionMeta[s]?.[0]||s)}</h2><p class="muted">${tr(sectionMeta[s]?.[1]||'')}</p>${s==='bulk_sync'?renderBulkSync():s==='plugins'?renderPlugins():s==='services'?renderServices():s==='hosts'?renderHosts(original[s]):renderGeneral(s,original[s])}</section>`).join('');$('configMenu').onclick=e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('[data-config-section]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-config-page]').forEach(x=>x.classList.toggle('active',x.dataset.configPage===b.dataset.configSection));$('mainSavebar').classList.toggle('hidden',['bulk_sync','plugins'].includes(b.dataset.configSection));if(b.dataset.configSection==='services')loadServices();if(b.dataset.configSection==='plugins')loadPlugins()};$('configRows').oninput=e=>{if(e.target.matches('[data-color-prefix]')){const p=e.target.dataset.colorPrefix,v=e.target.value;['r','g','b'].forEach((c,i)=>{document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`).value=parseInt(v.slice(1+i*2,3+i*2),16)})}if(e.target.matches('[data-section="led"],[data-color-prefix]'))updateConditional();if(e.target.matches('[data-section]'))markChanged()};$('refreshServices')?.addEventListener('click',loadServices);$('serviceSearch')?.addEventListener('input',renderServiceLists);$('saveMonitor')?.addEventListener('click',saveMonitor);$('resetMonitor')?.addEventListener('click',resetMonitorForm);$('saveHost')?.addEventListener('click',saveHost);document.querySelectorAll('[data-delete-host]').forEach(b=>b.onclick=()=>deleteHost(b.dataset.deleteHost));bindBulkSync();updateConditional()}
|
||||||
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?(LANG==='zh'?`有 ${n} 项尚未保存`:`${n} unsaved changes`):tr('尚未修改');$('saveState').className=n?'':'muted'}
|
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?(LANG==='zh'?`有 ${n} 项尚未保存`:`${n} unsaved changes`):tr('尚未修改');$('saveState').className=n?'':'muted'}
|
||||||
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(LANG==='zh'?`服务器返回异常 (${r.status})`:`Invalid server response (${r.status})`)}if(!r.ok)throw Error(j.error||(LANG==='zh'?`请求失败 (${r.status})`:`Request failed (${r.status})`));return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
|
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(LANG==='zh'?`服务器返回异常 (${r.status})`:`Invalid server response (${r.status})`)}if(!r.ok)throw Error(j.error||(LANG==='zh'?`请求失败 (${r.status})`:`Request failed (${r.status})`));return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
|
||||||
let serviceCache=[],editingServiceName='',serviceReadOnly=false;function tokenHeaders(){const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);return{'Content-Type':'application/json','Authorization':`Bearer ${token}`}}
|
let serviceCache=[],editingServiceName='',serviceReadOnly=false;function tokenHeaders(){const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);return{'Content-Type':'application/json','Authorization':`Bearer ${token}`}}
|
||||||
@@ -81,7 +94,7 @@ async function monitorAction(unit,action){const s=serviceCache.find(x=>x.unit===
|
|||||||
async function serviceAction(unit,action){if(!confirm(LANG==='zh'?`确定${action==='start'?'启动服务':'停止服务'} ${unit} 吗?\n这会直接执行 systemctl ${action}。`:`${action==='start'?'Start':'Stop'} ${unit}?\nThis runs systemctl ${action} directly.`))return;try{await get('/api/v1/services/control',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({unit,action})});setTimeout(loadServices,700)}catch(e){alert(e.message)}}
|
async function serviceAction(unit,action){if(!confirm(LANG==='zh'?`确定${action==='start'?'启动服务':'停止服务'} ${unit} 吗?\n这会直接执行 systemctl ${action}。`:`${action==='start'?'Start':'Stop'} ${unit}?\nThis runs systemctl ${action} directly.`))return;try{await get('/api/v1/services/control',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({unit,action})});setTimeout(loadServices,700)}catch(e){alert(e.message)}}
|
||||||
function bytes(value){const n=Number(value||0);if(!n)return'--';const units=['B','KB','MB','GB','TB'];let x=n,i=0;while(x>=1024&&i<units.length-1){x/=1024;i++}return`${x>=100||i===0?x.toFixed(0):x.toFixed(1)} ${units[i]}`}
|
function bytes(value){const n=Number(value||0);if(!n)return'--';const units=['B','KB','MB','GB','TB'];let x=n,i=0;while(x>=1024&&i<units.length-1){x/=1024;i++}return`${x>=100||i===0?x.toFixed(0):x.toFixed(1)} ${units[i]}`}
|
||||||
function meter(percent){const p=Math.max(0,Math.min(100,Number(percent||0)));return`<div class="progress"><span style="width:${p}%"></span></div>`}
|
function meter(percent){const p=Math.max(0,Math.min(100,Number(percent||0)));return`<div class="progress"><span style="width:${p}%"></span></div>`}
|
||||||
function hostCard(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>`;const s=entry.status,y=s.system||{},n=s.network||{},d=s.display||{},v=s.device||{},m=s.machine||h,signal=n.signal_percent,memUsed=y.memory_used_bytes,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><h2>${esc(m.name||h.name||m.id)}</h2><div class="device-details"><span>ID ${esc(m.id||h.id)}</span><span>${LANG==='zh'?'主机名':'Hostname'} ${esc(m.hostname||v.hostname||'--')}</span><span>${esc(v.model||'--')}</span><span>${LANG==='zh'?'序列号':'Serial'} ${esc(v.serial||'--')}</span><span>${LANG==='zh'?'硬件版本':'Revision'} ${esc(v.revision||'--')}</span><span>${esc(v.os||'--')}</span><span>${LANG==='zh'?'内核':'Kernel'} ${esc(v.kernel||'--')}</span><span>${esc(v.architecture||'--')} · CPU ${esc(v.cpu_count||'--')}</span><span>${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><div class="muted">${LANG==='zh'?'频率':'Frequency'} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C · ${LANG==='zh'?'风扇':'Fan'} ${esc(y.fan_name)}</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)} · ${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)} · ${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 · `:''}${esc(n.ip_label)} ${esc(n.ip)} · ${n.internet?(LANG==='zh'?'互联网正常':'Internet online'):(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">OLED: ${esc(ownerName(d.oled_page))} · RGB: ${esc(rgbName(d.rgb_mode))}</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><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>`}
|
function hostCard(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>`;const s=entry.status,y=s.system||{},n=s.network||{},d=s.display||{},v=s.device||{},m=s.machine||h,signal=n.signal_percent,memUsed=y.memory_used_bytes,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><h2>${esc(m.name||h.name||m.id)}</h2><div class="device-details"><span>ID ${esc(m.id||h.id)}</span><span>${LANG==='zh'?'主机名':'Hostname'} ${esc(m.hostname||v.hostname||'--')}</span><span>${esc(v.model||'--')}</span><span>${LANG==='zh'?'序列号':'Serial'} ${esc(v.serial||'--')}</span><span>${LANG==='zh'?'硬件版本':'Revision'} ${esc(v.revision||'--')}</span><span>${esc(v.os||'--')}</span><span>${LANG==='zh'?'内核':'Kernel'} ${esc(v.kernel||'--')}</span><span>${esc(v.architecture||'--')} · CPU ${esc(v.cpu_count||'--')}</span><span>${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><div class="muted">${LANG==='zh'?'频率':'Frequency'} ${esc(y.cpu_freq_mhz)} MHz · ${esc(y.temperature_c)}°C</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)} · ${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)} · ${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 · `:''}${esc(n.ip_label)} ${esc(n.ip)} · ${n.internet?(LANG==='zh'?'互联网正常':'Internet online'):(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">${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||{},m.id||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>`}
|
||||||
function renderHostCards(entries){const root=$('hostCards'),seen=new Set;entries.forEach((entry,index)=>{const id=String(entry.host.id),template=document.createElement('template');template.innerHTML=hostCard(entry);const next=template.content.firstElementChild;next.dataset.hostId=id;let current=[...root.children].find(x=>x.dataset.hostId===id);if(current){current.className=next.className;current.innerHTML=next.innerHTML;if(root.children[index]!==current)root.insertBefore(current,root.children[index]||null)}else root.insertBefore(next,root.children[index]||null);seen.add(id)});[...root.children].forEach(x=>{if(!seen.has(x.dataset.hostId))x.remove()})}
|
function renderHostCards(entries){const root=$('hostCards'),seen=new Set;entries.forEach((entry,index)=>{const id=String(entry.host.id),template=document.createElement('template');template.innerHTML=hostCard(entry);const next=template.content.firstElementChild;next.dataset.hostId=id;let current=[...root.children].find(x=>x.dataset.hostId===id);if(current){current.className=next.className;current.innerHTML=next.innerHTML;if(root.children[index]!==current)root.insertBefore(current,root.children[index]||null)}else root.insertBefore(next,root.children[index]||null);seen.add(id)});[...root.children].forEach(x=>{if(!seen.has(x.dataset.hostId))x.remove()})}
|
||||||
async function loadStatus(){try{const fleet=await get('/api/v1/fleet/status'),online=fleet.hosts.filter(x=>x.online);$('dot').classList.toggle('ok',online.length>0);$('liveText').textContent=LANG==='zh'?`${online.length}/${fleet.hosts.length} 在线`:`${online.length}/${fleet.hosts.length} online`;$('identity').textContent=LANG==='zh'?`多机监控 · ${fleet.hosts.length} 台设备 · ${new Date(fleet.timestamp*1000).toLocaleTimeString()}`:`Fleet monitoring · ${fleet.hosts.length} hosts · ${new Date(fleet.timestamp*1000).toLocaleTimeString()}`;renderHostCards(fleet.hosts);fleetHosts=fleet.hosts.map(x=>x.host);refreshHostSelectors()}catch(e){$('dot').classList.remove('ok');$('liveText').textContent=tr('离线');$('identity').textContent=e.message}}
|
async function loadStatus(){try{const fleet=await get('/api/v1/fleet/status'),online=fleet.hosts.filter(x=>x.online);$('dot').classList.toggle('ok',online.length>0);$('liveText').textContent=LANG==='zh'?`${online.length}/${fleet.hosts.length} 在线`:`${online.length}/${fleet.hosts.length} online`;$('identity').textContent=LANG==='zh'?`多机监控 · ${fleet.hosts.length} 台设备 · ${new Date(fleet.timestamp*1000).toLocaleTimeString()}`:`Fleet monitoring · ${fleet.hosts.length} hosts · ${new Date(fleet.timestamp*1000).toLocaleTimeString()}`;renderHostCards(fleet.hosts);fleetHosts=fleet.hosts.map(x=>x.host);refreshHostSelectors()}catch(e){$('dot').classList.remove('ok');$('liveText').textContent=tr('离线');$('identity').textContent=e.message}}
|
||||||
const severityNames={DEBUG:'调试',INFO:'信息',NOTICE:'注意',WARN:'警告',ERROR:'错误',CRITICAL:'严重',ALERT:'紧急',EMERGENCY:'紧急'};let logState={sort:'timestamp',order:'desc',offset:0,limit:100,total:0};
|
const severityNames={DEBUG:'调试',INFO:'信息',NOTICE:'注意',WARN:'警告',ERROR:'错误',CRITICAL:'严重',ALERT:'紧急',EMERGENCY:'紧急'};let logState={sort:'timestamp',order:'desc',offset:0,limit:100,total:0};
|
||||||
|
|||||||
Reference in New Issue
Block a user