Compare commits

...
7 Commits
12 changed files with 463 additions and 1133 deletions
+40 -250
View File
@@ -1,275 +1,65 @@
# PIGWay Pi Control v3.7.0
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理平台。提供本机状态、告警、事件日志和多机配置管理,可按需接入独立插件。
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
vcgencmd get_throttled
sudo ./install.sh
# 仅安装文件,不改变服务运行状态:
sudo ./install.sh --no-start
```
区分:
仅需要 Python 3。插件独立安装与运行,主程序不会自动安装或启动插件。
配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。
- 当前欠压:立即 `POWER LOW / UNDERVOLT` 告警
- 当前 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:
API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器显示查询方法,不直接打印秘密:
```bash
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
/etc/pigway-pi-control.conf
```
运行状态卡片仅为已接入插件显示摘要。插件配置根据能力生成:只有风扇就只有风扇设置,有 OLED/RGB 才有相应控件。保存插件配置不重启监控。暂停告警联动或断开接入不会停止插件的本地功能。
编辑:
默认 Unix socket:`/run/pigway-plugins/<id>/api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。插件声明自身能力,Web 据此展示状态和配置。其他设备可通过独立插件实现此协议。
监控和插件互不依赖:插件离线不影响监控、日志和 Web;监控离线不停止插件的本地功能。通信恢复后同步最新状态,过期告警不会继续显示。
当前可用插件:
| 插件 | 独立功能 | 仓库 |
| --- | --- | --- |
| PIGWay Cooling HAT | 本机状态采集、OLED 展示、风扇温控、RGB 灯效 | [pigway-cooling-hat](https://tea.pigway.com/way/pigway-cooling-hat) |
## 运维
```bash
sudo nano /etc/pigway-pi-control.conf
sudo systemctl restart pigway-pi-control
```
配置文件内已经写明每项用途、单位和修改方式。
## 安装 / 升级测试分支
```bash
sudo ./install.sh
```
安装器会:
- 安装必要依赖
- 检查 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 systemctl status pigway-pi-control.service pigway-pi-control-api.service
sudo journalctl -u pigway-pi-control.service -f
sudo /usr/local/sbin/pigway-pi-control --diagnose
```
## 版本路线
`--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。
卸载监控不会停止或删除独立插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
- **v3.7.0:多机状态卡片、聚合日志、机器配置选择与批量同步**
静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。
+10 -9
View File
@@ -1,10 +1,11 @@
4ac63c19d60ed3bb9d804727fe3d373a338f8203a2730a63876fb1845574822c README.md
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
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
f6bab4766d2b14b14572f25bdf98f2e95ae2759c6b96837343f0713b7e137dea README.md
8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.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
64c21a4e4d93a68fcca1d0b0126655a56f306a290cced6eed5350ab9d5b5004f web/index.html
73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md
Binary file not shown.
+75 -520
View File
@@ -1,12 +1,12 @@
#!/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
APP_VERSION="3.7.0"
from smbus2 import SMBus
from PIL import Image
import sys
sys.path.append("/usr/local/share/pigway-pi-control")
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"
STATUS_PATH="/run/pigway-pi-control/status.json"
running=True
@@ -84,23 +84,6 @@ def throttled_state():
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))}
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
page=0; page_since=time.monotonic()
cpu_hi=mem_hi=0
@@ -110,56 +93,6 @@ wifi_weak_since=None; wifi_weak_active=False
last_net=last_watch=0
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):
base=Path("/sys/class/leds")/name
try:
@@ -173,47 +106,6 @@ def apply_dark_mode():
if DARK_PWR: set_pi_led("PWR",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():
global prev_cpu
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
if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID
DEVICE_INFO=device_details()
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)
SERVICE_INTERVAL=cf("timing","service_interval",5.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_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_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))
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={
ALERT_CATEGORIES={
"ROOT_READONLY":"STORAGE",
"TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
"THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
@@ -374,7 +228,7 @@ def diagnose():
print("Agent:")
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("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
print("loop_errors=UNAVAILABLE scope=separate_diagnostic_process")
print("System:")
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
print("Power:")
@@ -387,27 +241,16 @@ def diagnose():
iface,kind,metric,label,address=primary_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("I2C:")
# SMBus receive-byte performs a read only; no register selection or display commands.
try:
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("Plugins (read-only API discovery; no hardware access):")
for plugin in discover(cfg):
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
print("Watches:")
for w in watches:
print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}")
return 0
DISPLAY_PRIORITIES={
ALERT_PRIORITIES={
"ROOT_READONLY":100,
"TEMP_CRITICAL":90,
"SOFT_TEMP_LIMIT":85,
@@ -427,8 +270,8 @@ DISPLAY_PRIORITIES={
"NORMAL_HOME":0,
}
def display_priority(request_id):
return DISPLAY_PRIORITIES[request_id.split("|",1)[0]]
def alert_priority(request_id):
return ALERT_PRIORITIES[request_id.split("|",1)[0]]
def transition(key,active,on_event,off_event,**fields):
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
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
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")
else: display.clear("CPU_HIGH")
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: alert_manager.clear("CPU_HIGH")
if temp>=TEMP_CRIT:
display.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
display.clear("TEMP_HIGH")
alert_manager.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
alert_manager.clear("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")
display.clear("TEMP_CRITICAL")
alert_manager.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
alert_manager.clear("TEMP_CRITICAL")
else:
display.clear("TEMP_HIGH"); display.clear("TEMP_CRITICAL")
if mem_hi>=MEM_HIGH_SEC: display.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
else: display.clear("MEMORY_HIGH")
alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
else: alert_manager.clear("MEMORY_HIGH")
if disk>=DISK_CRIT:
display.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
display.clear("DISK_HIGH")
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
alert_manager.clear("DISK_HIGH")
elif disk>=DISK_HIGH:
display.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
display.clear("DISK_CRITICAL")
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
alert_manager.clear("DISK_CRITICAL")
else:
display.clear("DISK_HIGH"); display.clear("DISK_CRITICAL")
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
if not link_ok:
display.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
display.clear("INTERNET_DOWN")
alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
alert_manager.clear("INTERNET_DOWN")
elif not net_ok:
display.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
display.clear("NETWORK_DOWN")
alert_manager.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
alert_manager.clear("NETWORK_DOWN")
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):
alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1]
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]})
else: display.clear(alert_id)
alert_manager.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
else: alert_manager.clear(alert_id)
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
rssi=float(net_metric)
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:
wifi_weak_active=False; wifi_weak_since=None
if wifi_weak_active:
display.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
else: display.clear("WIFI_WEAK")
alert_manager.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
else: alert_manager.clear("WIFI_WEAK")
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"):
display.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
else: display.clear("POWER_UNDERVOLT")
alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
else: alert_manager.clear("POWER_UNDERVOLT")
if health.get("throttled_now"):
display.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
else: display.clear("THROTTLED")
alert_manager.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
else: alert_manager.clear("THROTTLED")
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")
else: display.clear("FREQ_CAPPED")
alert_manager.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
else: alert_manager.clear("FREQ_CAPPED")
if health.get("soft_temp_limit_now"):
display.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
else: display.clear("SOFT_TEMP_LIMIT")
if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
else: display.clear("ROOT_READONLY")
alert_manager.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
else: alert_manager.clear("SOFT_TEMP_LIMIT")
if fs_readonly: alert_manager.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
else: alert_manager.clear("ROOT_READONLY")
def rgb_mode_for(request_id,severity):
if severity<=0: return "OFF"
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:
class AlertManager:
"""Current monitoring facts. Hardware plugins own all display decisions."""
def __init__(self):
now=time.monotonic()
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()
self.requests={}
def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
now=time.monotonic(); priority=display_priority(request_id)
request={"id":request_id,"priority":priority,"severity":severity,
"rgb_mode":rgb_mode_for(request_id,severity),"title":title[:21],
"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True,
"last_changed":now,"metadata":metadata or {}}
with self.lock:
current=self.requests.get(request_id)
if current is None:
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()
now=time.monotonic();old=self.requests.get(request_id)
value={"id":request_id,"priority":alert_priority(request_id),"severity":severity,
"category":ALERT_CATEGORIES[request_id.split("|",1)[0]].lower(),
"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
"first_seen":old["first_seen"] if old else now,"metadata":metadata or {}}
value["last_changed"]=old["last_changed"] if old and all(old[k]==v for k,v in value.items()) else now
self.requests[request_id]=value
if old is None:log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
def clear(self,request_id):
if request_id=="NORMAL_HOME": return
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])
if self.requests.pop(request_id,None) is not None:log("INFO","ALERT_RECOVERED",alert=request_id)
def status_snapshot(self):
with self.lock:
alerts=self._ordered_alerts_locked()
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],
}
values=sorted(self.requests.values(),key=lambda a:(-a["priority"],a["first_seen"],a["id"]))
return {"owner":values[0]["id"] if values else "NORMAL_HOME","alerts":values}
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():
log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"])
if bus is None: return
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)
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
publisher.close()
log("INFO","STOP",loop_errors=agent["loop_errors"])
try:Path(STATUS_PATH).unlink(missing_ok=True)
except Exception as e:log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
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()
disk_total,disk_used,disk_free=disk_usage("/")
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,
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
"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,
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
"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)}
for w,ok in zip(watches,watch_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)
temporary=path.with_suffix(".tmp")
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
@@ -856,18 +418,14 @@ watch_state=[]
last_sensor=last_status=0.0
last_net=last_watch=last_health=0.0
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={}
history_logged=set()
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:
bus=SMBus(1)
# 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()
apply_dark_mode()
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
while running:
try:
@@ -879,7 +437,7 @@ try:
if now-last_sensor >= SENSOR_INTERVAL:
sample_elapsed=now-last_sensor if last_sensor else 0.0
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:
old_id=(net_iface,ip)
@@ -912,10 +470,7 @@ try:
agent["last_health_ok"]=now; last_health=now
c,t,m,d=latest
display.update_home(c,t,m,d)
display.begin_update()
try: update_active_alerts(c,t,m,d,sample_elapsed)
finally: display.end_update()
update_active_alerts(c,t,m,d,sample_elapsed)
if now-last_status >= 1.0:
write_status(c,t,m,d); last_status=now
+76 -12
View File
@@ -1,4 +1,7 @@
#!/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
from concurrent.futures import ThreadPoolExecutor,as_completed
from http import HTTPStatus
@@ -12,7 +15,7 @@ CFG=Path("/etc/pigway-pi-control.conf")
STATUS=Path("/run/pigway-pi-control/status.json")
TOKEN=Path("/etc/pigway-pi-control-api.token")
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}$")
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]$")
@@ -21,6 +24,40 @@ NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorf
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
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():
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
@@ -92,12 +129,19 @@ def api_setting(key,default,cast=str):
def config_json():
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):
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()):
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)
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
elif FLASH_KEY_RE.fullmatch(key):
@@ -124,14 +168,14 @@ def validate_value(section,key,value,existing):
def replace_ini_value(section,key,value):
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 value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
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())
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:
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:]
@@ -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"):
raise ValueError(f"invalid value: {section}.{key}")
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:
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
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())
chunk=text[section_match.end():end]
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()
def change(section,key,value):
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 value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
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())
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:
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:]
@@ -408,6 +452,16 @@ class Handler(BaseHTTPRequestHandler):
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,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":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
@@ -424,10 +478,20 @@ class Handler(BaseHTTPRequestHandler):
def do_PUT(self):
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
try:
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":
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
+109
View File
@@ -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()
+9 -149
View File
@@ -1,33 +1,12 @@
# =============================================================================
# PIGWay Pi Control v3.7.0 - 用户配置文件
# =============================================================================
#
# 正式安装后的配置路径:
# /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 日志并修正本文件。
# =============================================================================
# PIGWay system monitoring. Hardware plugins are separately installed.
# 系统监控不依赖散热板;在 Web 的硬件插件页面确认接入后才发送状态。
[plugins]
# No automatic attachment. Example (prefer Web discovery):
# yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock
[device]
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
# 多台机器的 identifier 必须唯一;修改后重启硬件 Agent 和 API 服务。
# 多台机器的 identifier 必须唯一;修改后重启监控 Agent 和 API 服务。
identifier = auto
# Web 页面显示名称。auto 表示使用 identifier。
@@ -51,19 +30,10 @@ log_limit = 200
[timing]
# 传感器采样周期,单位:秒。
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
# CPU / 内存 / 温度 / 磁盘按此周期更新。
# 推荐:1。通常不要低于 1。
sensor_interval = 1
# OLED 当前页面刷新周期,单位:秒。
# 这只是刷新当前页面数据,不负责切换页面。
# 推荐:1。
oled_refresh_interval = 1
# OLED 在主页和当前告警页之间轮播的周期,单位:秒。
# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。
page_interval = 10
# 网络状态检查周期,单位:秒。
# 推荐:5。太短会产生不必要的网络探测。
network_interval = 5
@@ -74,45 +44,10 @@ service_interval = 5
# 本机健康检查周期,单位:秒。
# 检查 Raspberry Pi 欠压/降频状态、根文件系统只读状态等。
# 推荐:1。当前电源状态必须及时反映到 OLED/RGB;历史状态仅记录一次日志。
# 推荐: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]
# ---------------- CPU ----------------
# CPU 使用率达到此百分比后开始累计高负载时间。
@@ -152,7 +87,7 @@ disk_critical = 95
# ---------------- Wi-Fi 信号 ----------------
# 仅当 Wi-Fi 是 Linux 当前默认路由/主接口时生效。
# RSSI 单位 dBm,越接近0越强;约 -50很好,-70一般,-80较弱。
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发蓝色告警。
# 连续低于 wifi_weak 达到 wifi_weak_seconds 后触发弱信号告警。
wifi_weak = -75
# 已告警后恢复到该值以上才解除,形成滞回避免反复闪烁。
@@ -162,81 +97,6 @@ wifi_recover = -68
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]
# Raspberry Pi 4 板载 PWR / ACT LED。
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
+81
View File
@@ -0,0 +1,81 @@
# 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 returns
to its locally sampled home and configured normal RGB mode on expiry or detach.
It samples CPU, memory, disk, temperature and primary network independently.
API v1 system/network fields remain accepted for compatibility but never override
local telemetry. Monitor connectivity only supplies additional alerts.
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.
+30 -173
View File
@@ -1,197 +1,54 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
FORCE_I2C_CONFLICT=0
NO_START=0
for arg in "$@"; do
case "$arg" in
--force-i2c-conflict) FORCE_I2C_CONFLICT=1 ;;
-h|--help)
echo "Usage: sudo ./install.sh [--force-i2c-conflict]"
echo " --force-i2c-conflict Continue even when another process owns /dev/i2c-1."
exit 0
;;
*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
--no-start) NO_START=1 ;;
-h|--help) echo 'Usage: sudo ./install.sh [--no-start]'; exit 0 ;;
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
esac
done
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
if [ ! -f "$required" ]; then
echo "ERROR: required file missing: $required" >&2
exit 1
fi
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
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
done
echo "[1/8] 检查 Raspberry Pi I2C..."
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
# 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
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
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 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
# 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
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
else
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
fi
# Preserve the existing file while exposing new defaults. Existing values win.
python3 - /etc/pigway-pi-control.conf <<'PY'
import re,sys
# Legacy hardware sections are deliberately preserved for explicit migration,
# but ignored by the monitor and never exposed as monitor configuration.
python3 - <<'PY'
from pathlib import Path
path=Path(sys.argv[1])
text=path.read_text()
sections={
"device":(("identifier","auto"),("name","auto")),
"hosts":(),
"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)
import configparser
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
with p.open('a') as f:
for section,values in {'device':{'identifier':'auto','name':'auto'},'hosts':{},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
if not c.has_section(section):
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()))
PY
if [ ! -s /etc/pigway-pi-control-api.token ]; then
umask 077
python3 - <<'PY' > /etc/pigway-pi-control-api.token
import secrets
print(secrets.token_urlsafe(32))
PY
(umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)
fi
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-api.service /etc/systemd/system/pigway-pi-control-api.service
echo "[6/8] 启用/重启服务..."
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
systemctl restart pigway-pi-control.service
systemctl restart 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
if [ "$NO_START" -eq 0 ]; then
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
systemctl restart pigway-pi-control.service pigway-pi-control-api.service
systemctl is-active --quiet pigway-pi-control.service
systemctl is-active --quiet pigway-pi-control-api.service
fi
check "Service running" systemctl is-active --quiet pigway-pi-control.service
check "API/Web service running" systemctl is-active --quiet pigway-pi-control-api.service
check "API/Web version 3.7.0" grep -Fq 'APP_VERSION="3.7.0"' /usr/local/sbin/pigway-pi-control-api
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"
echo 'Monitor installed. Hardware plugins are installed and connected separately.'
echo 'Config: /etc/pigway-pi-control.conf'
echo 'API token: sudo cat /etc/pigway-pi-control-api.token'
+1 -1
View File
@@ -1,7 +1,7 @@
[Unit]
Description=PIGWay Pi Control API and Web
After=network.target pigway-pi-control.service
Wants=network.target pigway-pi-control.service
Wants=network.target
[Service]
Type=simple
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
Description=PIGWay system monitor
After=local-fs.target systemd-modules-load.service
Before=multi-user.target
+31 -18
View File
@@ -16,14 +16,14 @@
<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="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>
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',
'运行状态':'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',
'配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 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',
'正常运行灯效':'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',
@@ -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,{'端口':'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,{'硬件插件':'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);
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}
const $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={},fleetHosts=[],currentConfigHost='';const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const sectionMeta={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 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={'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]}色通道`));
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。'));
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 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 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 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 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:'监控对象恢复'};
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('_',' ')))}
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.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.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','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={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])}${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 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 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 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 pluginText(zh,en){return LANG==='zh'?zh:en}
function renderPlugins(){return `<div class="group"><p>${pluginText('仅发现插件不会接管插件的设备能力。接入后发送监控状态;暂停或断开联动不会停止插件的本地功能。','Discovery does not take control of plugin device capabilities. Connecting sends monitoring state; pausing or disconnecting the link does not stop the plugin’s local functions.')}</p><button type="button" id="refreshPlugins" onclick="loadPlugins()">${tr('刷新列表')}</button> <span id="pluginRefreshState" class="hint" role="status" aria-live="polite"></span><div id="pluginList"></div></div><div id="pluginEditor"></div>`}
function pluginSummary(plugins,host){if(!Object.keys(plugins).length)return `<div class="group"><strong>${pluginText('风扇与硬件插件','Fan and hardware plugins')}</strong><p class="muted">${pluginText('尚未接入硬件插件,风扇状态不可用。独立安装并启动插件后,可在插件管理中接入。','No hardware plugin connected; fan status is unavailable. Install and start a plugin independently, then connect it in plugin management.')}</p><button type="button" data-manage-plugin-host="${esc(host)}">${pluginText('管理插件','Manage plugin')}</button></div>`;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;const button=$('refreshPlugins'),feedback=$('pluginRefreshState');if(button?.disabled)return;if(button)button.disabled=true;if(feedback)feedback.textContent=pluginText('正在刷新…','Refreshing…');list.textContent=pluginText('正在发现本机插件…','Discovering local plugins…');try{const data=await get('/api/v1/plugins?host='+encodeURIComponent(currentConfigHost));pluginRows=data.plugins;if(feedback)feedback.textContent=pluginText('刷新完成,发现 '+pluginRows.length+' 个插件 · ','Refreshed, '+pluginRows.length+' plugins found · ')+new Date().toLocaleTimeString();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 running hardware plugins found. Refresh only queries plugins; it does not install or start services. Start the installed plugin on the target host, then refresh and connect. Monitoring works independently.')}</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;if(feedback)feedback.textContent=pluginText('刷新失败','Refresh failed')}finally{if(button)button.disabled=false}}
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 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>`}
@@ -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 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 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 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 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]||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'}
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}`}}
@@ -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)}}
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 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>${meter(y.cpu_percent)}<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()})}
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};