feat: add lightweight API and web console
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Pigway Pi Control v3.5.0 System Health
|
# Pigway Pi Control v3.6.0
|
||||||
|
|
||||||
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
||||||
|
|
||||||
v3.5.0 完成本地检测、事件状态、日志、诊断、显示仲裁、可配置 RGB 告警和关机收尾,为后续轻量 API/Web 管理界面提供稳定基础。
|
v3.6.0 在本地硬件监控、显示仲裁和可配置 RGB 告警基础上,增加轻量状态/日志/配置 API 与同源 Web 管理界面。
|
||||||
|
|
||||||
## 已有核心功能
|
## 已有核心功能
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ v3.5.0 完成本地检测、事件状态、日志、诊断、显示仲裁、可
|
|||||||
- Wi-Fi RSSI / Ethernet 链路速率
|
- Wi-Fi RSSI / Ethernet 链路速率
|
||||||
- 当前默认路由与 IP 同步切换
|
- 当前默认路由与 IP 同步切换
|
||||||
- 风扇自动温控、回差防抖
|
- 风扇自动温控、回差防抖
|
||||||
- RGB:内置灯效的颜色与速度表示严重程度
|
- RGB:自定义颜色表示故障对象,频闪速度表示严重程度
|
||||||
- systemd 服务与普通进程监控
|
- systemd 服务与普通进程监控
|
||||||
- Raspberry Pi PWR / ACT 运行时 Dark Mode
|
- Raspberry Pi PWR / ACT 运行时 Dark Mode
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高
|
|||||||
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
|
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
level=INFO event=START version=3.3.0
|
level=INFO event=START version=3.6.0
|
||||||
level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ...
|
level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ...
|
||||||
level=WARN event=NETWORK_DOWN ...
|
level=WARN event=NETWORK_DOWN ...
|
||||||
level=INFO event=NETWORK_RECOVERED ...
|
level=INFO event=NETWORK_RECOVERED ...
|
||||||
@@ -136,6 +136,50 @@ RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜
|
|||||||
|
|
||||||
Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。
|
Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
配置写入接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PUT /api/v1/config
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
读取 token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cat /etc/pigway-pi-control-api.token
|
||||||
|
```
|
||||||
|
|
||||||
|
配置更新仅允许 `[services]`、`[processes]`、`[alerts]`、`[fan]`、`[led]`、`[timing]`、`[dark_mode]`、`[display]` 和 `[api]`。每次写入先生成时间戳备份,再原地更新目标键并重启硬件 Agent;修改 `[api]` 时 API 服务也会自动重启。
|
||||||
|
|
||||||
## 用户配置
|
## 用户配置
|
||||||
|
|
||||||
正式配置只有一个:
|
正式配置只有一个:
|
||||||
@@ -165,21 +209,24 @@ sudo ./install.sh
|
|||||||
- 检查 I²C
|
- 检查 I²C
|
||||||
- 禁用旧 `temp-control.service`
|
- 禁用旧 `temp-control.service`
|
||||||
- 安装字体、程序、配置和 systemd unit
|
- 安装字体、程序、配置和 systemd unit
|
||||||
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅向 `[led]` 补入缺失的 RGB 颜色与频闪选项,已有用户值优先
|
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
|
||||||
- 启动服务
|
- 生成只读 root token,安装 API/Web 文件
|
||||||
|
- 启动硬件 Agent 与 API/Web 服务
|
||||||
- 执行版本、OLED、UI、P0健康监控和诊断入口自检
|
- 执行版本、OLED、UI、P0健康监控和诊断入口自检
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status pigway-pi-control --no-pager -l
|
systemctl status pigway-pi-control --no-pager -l
|
||||||
|
journalctl -u pigway-pi-control-api -f
|
||||||
journalctl -u pigway-pi-control -f
|
journalctl -u pigway-pi-control -f
|
||||||
sudo systemctl restart pigway-pi-control
|
sudo systemctl restart pigway-pi-control
|
||||||
|
sudo systemctl restart pigway-pi-control-api
|
||||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||||
```
|
```
|
||||||
|
|
||||||
## 版本路线
|
## 版本路线
|
||||||
|
|
||||||
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
|
||||||
- **v3.5.0:System Health、显示仲裁与可配置 RGB 告警**
|
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
|
||||||
- 后续:只读 `/api/v1/...` Agent API,再由 NAS 统一汇总多台设备
|
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
||||||
|
|||||||
+8
-5
@@ -1,7 +1,10 @@
|
|||||||
dfe901d5f7e3a8ef897203c38cf9e4d47eca63e0d718f74b99102c874edfc3b3 README.md
|
e43448e7a3d6291aa06717069cf1a3dcbb3fba71fce709f17292d61f89543b43 README.md
|
||||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||||
43aca7bda6e8edb59734e06ccd373a9426c73fbf359727a906b6e2ca405fc344 app/pigway_pi_control.py
|
29f59e41052e1e251bf2f38274cdd321fc195602220b61ac8890202128d7c50a app/pigway_pi_control.py
|
||||||
02e67b147ae68378bed1dcb88245b636c72fba3dabe4c119aab61219eee89995 config/pigway-pi-control.conf
|
f32be5b63fe2be51e39c106c232ae3cc14dab7c0c3719e2609b9e93a7802b6c3 app/pigway_pi_control_api.py
|
||||||
d84774f2fe2bfe380b7b29d83d42ddc17938fb03f78a746df07fc2e49f635e64 install.sh
|
9f3b2be557b3cd93c309f8a1cadce7d34b23ca06c1ff69231d934e8180fefb48 config/pigway-pi-control.conf
|
||||||
|
f54b7d0f8c19052efb7b305b074c88aa110483160d2029b3ce44b4d1a15c3f59 install.sh
|
||||||
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
|
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
|
||||||
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh
|
5d7562c496de597f61b62af48f8f6350ad270e1ebf725426d9f1e5b5d698055e systemd/pigway-pi-control-api.service
|
||||||
|
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||||
|
4b4066d104202f664f5df07c97d4e256dde32b5f4426e6a955a3ecef5e08df60 web/index.html
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse
|
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
APP_VERSION="3.5.0"
|
APP_VERSION="3.6.0"
|
||||||
from smbus2 import SMBus
|
from smbus2 import SMBus
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from shutil import disk_usage
|
from shutil import disk_usage
|
||||||
|
|
||||||
OLED=0x3C; MCU=0x0D; W,H=128,32
|
OLED=0x3C; MCU=0x0D; W,H=128,32
|
||||||
CFG="/etc/pigway-pi-control.conf"
|
CFG="/etc/pigway-pi-control.conf"
|
||||||
|
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||||
running=True
|
running=True
|
||||||
|
|
||||||
def stop(*_):
|
def stop(*_):
|
||||||
@@ -616,6 +617,17 @@ class DisplayController:
|
|||||||
def owner_snapshot(self):
|
def owner_snapshot(self):
|
||||||
with self.lock: return dict(self.requests[self.current_owner_id])
|
with self.lock: return dict(self.requests[self.current_owner_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],
|
||||||
|
}
|
||||||
|
|
||||||
def _rgb_worker(self):
|
def _rgb_worker(self):
|
||||||
previous_error=None; applied_mode="OFF"; applied_flash=False; animation_started=time.monotonic()
|
previous_error=None; applied_mode="OFF"; applied_flash=False; animation_started=time.monotonic()
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
@@ -660,6 +672,29 @@ def cleanup():
|
|||||||
for name,action in (("fan",lambda:mcu(0x08,0x00)),("bus_close",bus.close)):
|
for name,action in (("fan",lambda:mcu(0x08,0x00)),("bus_close",bus.close)):
|
||||||
try: action()
|
try: action()
|
||||||
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
|
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))
|
||||||
|
|
||||||
|
def write_status(cpu,temp,mem,disk):
|
||||||
|
display_state=display.status_snapshot()
|
||||||
|
state={
|
||||||
|
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
|
||||||
|
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
|
||||||
|
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
||||||
|
"memory_percent":round(mem,1),"disk_percent":round(disk,1),
|
||||||
|
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
||||||
|
"power":health,
|
||||||
|
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||||
|
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)},
|
||||||
|
"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"]},
|
||||||
|
}
|
||||||
|
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=(",",":")))
|
||||||
|
temporary.chmod(0o644); temporary.replace(path)
|
||||||
|
|
||||||
# Dispatch only after all shared checks and configuration have been initialized.
|
# Dispatch only after all shared checks and configuration have been initialized.
|
||||||
parser=argparse.ArgumentParser()
|
parser=argparse.ArgumentParser()
|
||||||
@@ -668,7 +703,7 @@ args=parser.parse_args()
|
|||||||
if args.diagnose: raise SystemExit(diagnose())
|
if args.diagnose: raise SystemExit(diagnose())
|
||||||
|
|
||||||
watch_state=[]
|
watch_state=[]
|
||||||
last_sensor=last_render=0.0
|
last_sensor=last_render=last_status=0.0
|
||||||
last_net=last_watch=last_health=0.0
|
last_net=last_watch=last_health=0.0
|
||||||
health={}; fs_readonly=False
|
health={}; fs_readonly=False
|
||||||
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0}
|
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0}
|
||||||
@@ -731,6 +766,8 @@ try:
|
|||||||
if now-last_render >= OLED_REFRESH:
|
if now-last_render >= OLED_REFRESH:
|
||||||
display.refresh()
|
display.refresh()
|
||||||
last_render=now
|
last_render=now
|
||||||
|
if now-last_status >= 1.0:
|
||||||
|
write_status(c,t,m,d); last_status=now
|
||||||
|
|
||||||
time.sleep(0.20)
|
time.sleep(0.20)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs,urlparse
|
||||||
|
|
||||||
|
APP_VERSION="3.6.0"
|
||||||
|
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={"services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
||||||
|
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||||
|
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service)_[rgb]$")
|
||||||
|
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency)_flash_(on|off)_ms$")
|
||||||
|
|
||||||
|
def load_cfg():
|
||||||
|
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
def api_setting(key,default,cast=str):
|
||||||
|
try:return cast(load_cfg().get("api",key))
|
||||||
|
except Exception:return default
|
||||||
|
|
||||||
|
def config_json():
|
||||||
|
cfg=load_cfg()
|
||||||
|
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||||
|
|
||||||
|
def validate_value(section,key,value,existing):
|
||||||
|
if section not in {"services","processes"} and key not in existing.get(section,set()):
|
||||||
|
raise ValueError(f"unsupported option: {section}.{key}")
|
||||||
|
if 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):
|
||||||
|
number=int(value)
|
||||||
|
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000")
|
||||||
|
elif section=="api" and key=="port":
|
||||||
|
number=int(value)
|
||||||
|
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
|
||||||
|
elif section=="api" and key=="log_limit":
|
||||||
|
number=int(value)
|
||||||
|
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
|
||||||
|
elif section=="api" and key=="bind":
|
||||||
|
ipaddress.ip_address(value)
|
||||||
|
|
||||||
|
def update_ini(updates):
|
||||||
|
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
||||||
|
text=CFG.read_text()
|
||||||
|
current=load_cfg(); existing={section:set(current.options(section)) for section in current.sections()}
|
||||||
|
for section,values in updates.items():
|
||||||
|
if section not in ALLOWED_SECTIONS or not isinstance(values,dict):
|
||||||
|
raise ValueError(f"unsupported section: {section}")
|
||||||
|
for key,value in values.items():
|
||||||
|
if not NAME_RE.fullmatch(str(key)): raise ValueError(f"invalid key: {key}")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"null is not allowed: {section}.{key}")
|
||||||
|
value=str(value).strip()
|
||||||
|
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)
|
||||||
|
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():])
|
||||||
|
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)
|
||||||
|
if key_match:
|
||||||
|
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||||
|
replacement=key_match.group(1)+value
|
||||||
|
text=text[:start]+replacement+text[stop:]
|
||||||
|
else:
|
||||||
|
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
|
||||||
|
parsed=configparser.ConfigParser(); parsed.read_string(text)
|
||||||
|
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||||
|
shutil.copy2(CFG,backup)
|
||||||
|
temporary=CFG.with_suffix(".tmp")
|
||||||
|
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||||
|
return str(backup)
|
||||||
|
|
||||||
|
def journal(limit):
|
||||||
|
result=subprocess.run(["journalctl","-u","pigway-pi-control.service","-n",str(limit),
|
||||||
|
"--no-pager","-o","json"],capture_output=True,text=True,timeout=5)
|
||||||
|
if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed")
|
||||||
|
rows=[]
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
try:
|
||||||
|
item=json.loads(line)
|
||||||
|
rows.append({"timestamp":item.get("__REALTIME_TIMESTAMP"),"priority":item.get("PRIORITY"),
|
||||||
|
"message":item.get("MESSAGE","")})
|
||||||
|
except json.JSONDecodeError: pass
|
||||||
|
return rows
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version="PigwayAPI/3.6"
|
||||||
|
|
||||||
|
def log_message(self,fmt,*args):
|
||||||
|
return
|
||||||
|
|
||||||
|
def send_json(self,status,payload):
|
||||||
|
data=json.dumps(payload,ensure_ascii=False,separators=(",",":")).encode()
|
||||||
|
self.send_response(status); self.send_header("Content-Type","application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data)
|
||||||
|
|
||||||
|
def security_headers(self):
|
||||||
|
self.send_header("Cache-Control","no-store")
|
||||||
|
self.send_header("X-Content-Type-Options","nosniff")
|
||||||
|
self.send_header("X-Frame-Options","DENY")
|
||||||
|
self.send_header("Content-Security-Policy","default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||||
|
|
||||||
|
def authorized(self):
|
||||||
|
try: expected=TOKEN.read_text().strip()
|
||||||
|
except OSError: return False
|
||||||
|
supplied=self.headers.get("Authorization","")
|
||||||
|
return supplied.startswith("Bearer ") and secrets.compare_digest(supplied[7:],expected)
|
||||||
|
|
||||||
|
def read_json(self):
|
||||||
|
length=int(self.headers.get("Content-Length","0"))
|
||||||
|
if length<=0 or length>65536: raise ValueError("invalid request size")
|
||||||
|
return json.loads(self.rfile.read(length))
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed=urlparse(self.path)
|
||||||
|
try:
|
||||||
|
if parsed.path=="/":
|
||||||
|
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
|
||||||
|
if parsed.path=="/api/v1/health":
|
||||||
|
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
||||||
|
if parsed.path=="/api/v1/status":
|
||||||
|
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
||||||
|
self.send_json(200,json.loads(STATUS.read_text())); return
|
||||||
|
if parsed.path=="/api/v1/logs":
|
||||||
|
limit=min(max(int(parse_qs(parsed.query).get("limit",[api_setting("log_limit",200,int)])[0]),1),1000)
|
||||||
|
self.send_json(200,{"logs":journal(limit)}); return
|
||||||
|
if parsed.path=="/api/v1/config":
|
||||||
|
self.send_json(200,{"config":config_json(),"write_requires_token":True}); return
|
||||||
|
self.send_json(404,{"error":"not found"})
|
||||||
|
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
if urlparse(self.path).path!="/api/v1/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(); updates=body.get("updates")
|
||||||
|
backup=update_ini(updates)
|
||||||
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
|
restart_api="api" in updates
|
||||||
|
print(f"CONFIG_UPDATED sections={','.join(sorted(updates))} backup={backup}",flush=True)
|
||||||
|
self.send_json(200,{"ok":True,"backup":backup,"api_restart":restart_api})
|
||||||
|
if restart_api: threading.Timer(0.2,self.server.shutdown).start()
|
||||||
|
except (ValueError,json.JSONDecodeError) as e: self.send_json(400,{"error":str(e)})
|
||||||
|
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||||
|
|
||||||
|
def main():
|
||||||
|
host=api_setting("bind","0.0.0.0")
|
||||||
|
port=api_setting("port",6001,int)
|
||||||
|
server=ThreadingHTTPServer((host,port),Handler)
|
||||||
|
print(f"API_START version={APP_VERSION} bind={host} port={port}",flush=True)
|
||||||
|
try: server.serve_forever()
|
||||||
|
finally: server.server_close()
|
||||||
|
|
||||||
|
if __name__=="__main__": main()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Pigway Pi Control v3.5.0 - 用户配置文件
|
# Pigway Pi Control v3.6.0 - 用户配置文件
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# 正式安装后的配置路径:
|
# 正式安装后的配置路径:
|
||||||
@@ -25,6 +25,14 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
[api]
|
||||||
|
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
||||||
|
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||||
|
bind = 0.0.0.0
|
||||||
|
port = 6001
|
||||||
|
log_limit = 200
|
||||||
|
|
||||||
|
|
||||||
[timing]
|
[timing]
|
||||||
# 传感器采样周期,单位:秒。
|
# 传感器采样周期,单位:秒。
|
||||||
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
||||||
|
|||||||
+43
-18
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
for required in app/pigway_pi_control.py app/oled_font_5x7.bin config/pigway-pi-control.conf systemd/pigway-pi-control.service; do
|
for required in app/pigway_pi_control.py app/pigway_pi_control_api.py app/oled_font_5x7.bin web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||||
if [ ! -f "$required" ]; then
|
if [ ! -f "$required" ]; then
|
||||||
echo "ERROR: required file missing: $required" >&2
|
echo "ERROR: required file missing: $required" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -37,26 +37,29 @@ fi
|
|||||||
pkill -f '/temp_control/temp_control' 2>/dev/null || true
|
pkill -f '/temp_control/temp_control' 2>/dev/null || true
|
||||||
mkdir -p /usr/local/share/pigway-pi-control
|
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
|
install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_font_5x7.bin
|
||||||
|
mkdir -p /usr/local/share/pigway-pi-control/web
|
||||||
|
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
|
||||||
|
|
||||||
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
||||||
|
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
||||||
|
|
||||||
# Keep the complete existing configuration, including comments and custom sections.
|
# Keep the complete existing configuration, including comments and custom sections.
|
||||||
# New options use runtime defaults until the user chooses to add them.
|
# The merge below adds only missing documented options.
|
||||||
if [ -f /etc/pigway-pi-control.conf ]; then
|
if [ -f /etc/pigway-pi-control.conf ]; then
|
||||||
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
||||||
else
|
else
|
||||||
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
|
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Preserve the existing file verbatim while exposing newly configurable RGB
|
# Preserve the existing file while exposing new defaults. Existing values win.
|
||||||
# defaults. Missing keys are inserted into [led]; existing user values win.
|
|
||||||
python3 - /etc/pigway-pi-control.conf <<'PY'
|
python3 - /etc/pigway-pi-control.conf <<'PY'
|
||||||
import re,sys
|
import re,sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
path=Path(sys.argv[1])
|
path=Path(sys.argv[1])
|
||||||
text=path.read_text()
|
text=path.read_text()
|
||||||
defaults=(
|
sections={
|
||||||
|
"led":(
|
||||||
("cpu_r",255),("cpu_g",0),("cpu_b",0),
|
("cpu_r",255),("cpu_g",0),("cpu_b",0),
|
||||||
("power_r",255),("power_g",40),("power_b",0),
|
("power_r",255),("power_g",40),("power_b",0),
|
||||||
("memory_r",255),("memory_g",0),("memory_b",255),
|
("memory_r",255),("memory_g",0),("memory_b",255),
|
||||||
@@ -66,33 +69,49 @@ defaults=(
|
|||||||
("warning_flash_on_ms",1500),("warning_flash_off_ms",1500),
|
("warning_flash_on_ms",1500),("warning_flash_off_ms",1500),
|
||||||
("critical_flash_on_ms",750),("critical_flash_off_ms",750),
|
("critical_flash_on_ms",750),("critical_flash_off_ms",750),
|
||||||
("emergency_flash_on_ms",300),("emergency_flash_off_ms",300),
|
("emergency_flash_on_ms",300),("emergency_flash_off_ms",300),
|
||||||
)
|
),
|
||||||
missing=[(key,value) for key,value in defaults
|
"api":(("bind","0.0.0.0"),("port",6001),("log_limit",200)),
|
||||||
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",text)]
|
}
|
||||||
if missing:
|
for section,defaults in sections.items():
|
||||||
block="\n# RGB object colors (0..255) and flash timing (milliseconds).\n"
|
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
|
||||||
block+="".join(f"{key} = {value}\n" for key,value in missing)
|
|
||||||
match=re.search(r"(?m)^\[led\]\s*$",text)
|
|
||||||
if match:
|
if match:
|
||||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[match.end():])
|
next_section=re.search(r"(?m)^\[.+\]\s*$",text[match.end():])
|
||||||
pos=match.end()+(next_section.start() if next_section else len(text)-match.end())
|
end=match.end()+(next_section.start() if next_section else len(text)-match.end())
|
||||||
text=text[:pos].rstrip()+"\n"+block+"\n"+text[pos:].lstrip("\n")
|
chunk=text[match.end():end]
|
||||||
else:
|
else:
|
||||||
text=text.rstrip()+"\n\n[led]\n"+block
|
text=text.rstrip()+f"\n\n[{section}]\n"
|
||||||
path.write_text(text)
|
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"
|
||||||
|
block=note+"".join(f"{key} = {value}\n" for key,value in missing)
|
||||||
|
text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n")
|
||||||
|
path.write_text(text)
|
||||||
PY
|
PY
|
||||||
|
if [ ! -s /etc/pigway-pi-control-api.token ]; then
|
||||||
|
umask 077
|
||||||
|
python3 - <<'PY' > /etc/pigway-pi-control-api.token
|
||||||
|
import secrets
|
||||||
|
print(secrets.token_urlsafe(32))
|
||||||
|
PY
|
||||||
|
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.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] 启用/重启服务..."
|
echo "[6/8] 启用/重启服务..."
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable pigway-pi-control.service >/dev/null
|
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
|
||||||
systemctl restart pigway-pi-control.service
|
systemctl restart pigway-pi-control.service
|
||||||
|
systemctl restart pigway-pi-control-api.service
|
||||||
|
|
||||||
echo "[7/8] 安装后自检..."
|
echo "[7/8] 安装后自检..."
|
||||||
FAIL=0
|
FAIL=0
|
||||||
check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; }
|
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
|
APP=/usr/local/sbin/pigway-pi-control
|
||||||
check "Version 3.5.0" grep -Fq 'APP_VERSION="3.5.0"' "$APP"
|
check "Version 3.6.0" grep -Fq 'APP_VERSION="3.6.0"' "$APP"
|
||||||
check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP"
|
check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP"
|
||||||
check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$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 "OLED pages 0..3" grep -Fq 'cmd(0x22);cmd(0);cmd(3)' "$APP"
|
||||||
@@ -102,6 +121,9 @@ check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP
|
|||||||
legacy_stopped() { ! systemctl is-active --quiet temp-control.service; }
|
legacy_stopped() { ! systemctl is-active --quiet temp-control.service; }
|
||||||
check "Legacy service stopped" legacy_stopped
|
check "Legacy service stopped" legacy_stopped
|
||||||
check "Service running" systemctl is-active --quiet pigway-pi-control.service
|
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.6.0" grep -Fq 'APP_VERSION="3.6.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 "P0 health monitor" grep -Fq 'throttled_state' "$APP"
|
||||||
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP"
|
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP"
|
||||||
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
|
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
|
||||||
@@ -109,6 +131,9 @@ echo "[8/8] 状态..."
|
|||||||
|
|
||||||
|
|
||||||
systemctl --no-pager --full status pigway-pi-control.service || true
|
systemctl --no-pager --full status pigway-pi-control.service || true
|
||||||
|
systemctl --no-pager --full status pigway-pi-control-api.service || true
|
||||||
echo
|
echo
|
||||||
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
||||||
echo "配置:/etc/pigway-pi-control.conf"
|
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"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Pigway Pi Control API and Web
|
||||||
|
After=network.target pigway-pi-control.service
|
||||||
|
Wants=network.target pigway-pi-control.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/sbin/pigway-pi-control-api
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
User=root
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+4
-1
@@ -1,8 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
systemctl disable --now pigway-pi-control.service 2>/dev/null || true
|
systemctl disable --now pigway-pi-control-api.service pigway-pi-control.service 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/pigway-pi-control-api.service
|
||||||
rm -f /etc/systemd/system/pigway-pi-control.service
|
rm -f /etc/systemd/system/pigway-pi-control.service
|
||||||
rm -f /usr/local/sbin/pigway-pi-control
|
rm -f /usr/local/sbin/pigway-pi-control
|
||||||
|
rm -f /usr/local/sbin/pigway-pi-control-api
|
||||||
|
rm -f /etc/pigway-pi-control-api.token
|
||||||
rm -rf /usr/local/share/pigway-pi-control
|
rm -rf /usr/local/share/pigway-pi-control
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
|
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Pigway Pi Control</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark;--bg:#0b1118;--panel:#121b25;--line:#263546;--text:#e7edf5;--muted:#91a4b8;--good:#50d890;--warn:#ffbd4a;--bad:#ff6b6b;--blue:#58a6ff}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top right,#14263b 0,#0b1118 42%);color:var(--text);font:14px/1.45 system-ui,-apple-system,sans-serif}main{max-width:1180px;margin:auto;padding:28px 20px 60px}header{display:flex;justify-content:space-between;align-items:end;gap:18px;margin-bottom:22px}h1{font-size:27px;margin:0}h2{font-size:17px;margin:0 0 14px}.sub,.muted{color:var(--muted)}.live{display:flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--bad)}.dot.ok{background:var(--good);box-shadow:0 0 12px var(--good)}nav{display:flex;gap:8px;margin:18px 0}button{border:1px solid var(--line);background:#162230;color:var(--text);border-radius:8px;padding:9px 13px;cursor:pointer}button:hover,button.active{border-color:var(--blue);background:#1b3047}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.card,.panel{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 12px 28px #0004}.value{font-size:23px;font-weight:700;margin-top:4px}.wide{grid-column:span 2}.panel{margin-top:12px}.rows{display:grid;gap:8px}.row{display:flex;justify-content:space-between;gap:16px;padding:8px 0;border-bottom:1px solid #203040}.row:last-child{border:0}.badge{border-radius:999px;padding:3px 8px;background:#243447}.badge.good{color:var(--good)}.badge.bad{color:var(--bad)}.hidden{display:none}.log{font:12px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;padding:8px 0;border-bottom:1px solid #203040}.config-section{margin:10px 0;border:1px solid var(--line);border-radius:10px;padding:12px}.config-section summary{cursor:pointer;font-weight:700}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.field label{display:block;color:var(--muted);font-size:12px}.field input{width:100%;margin-top:4px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:8px}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:12px 0}.toolbar input{min-width:280px;flex:1;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.error{color:var(--bad)}@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}.fields{grid-template-columns:1fr}.wide{grid-column:span 2}}@media(max-width:470px){.grid{grid-template-columns:1fr}.wide{grid-column:span 1}header{align-items:start;flex-direction:column}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><main>
|
||||||
|
<header><div><h1>Pigway Pi Control</h1><div class="sub" id="identity">正在连接 Raspberry Pi…</div></div><div class="live"><span class="dot" id="dot"></span><span id="liveText">离线</span></div></header>
|
||||||
|
<nav><button class="active" data-tab="overview">状态</button><button data-tab="logs">日志</button><button data-tab="config">配置</button></nav>
|
||||||
|
<section id="overview">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="muted">CPU</div><div class="value" id="cpu">--</div><div class="muted" id="freq">--</div></div>
|
||||||
|
<div class="card"><div class="muted">温度</div><div class="value" id="temp">--</div><div class="muted" id="fan">--</div></div>
|
||||||
|
<div class="card"><div class="muted">内存</div><div class="value" id="memory">--</div></div>
|
||||||
|
<div class="card"><div class="muted">磁盘</div><div class="value" id="disk">--</div></div>
|
||||||
|
<div class="card wide"><div class="muted">网络</div><div class="value" id="network">--</div><div class="muted" id="address">--</div></div>
|
||||||
|
<div class="card wide"><div class="muted">显示控制</div><div class="value" id="owner">--</div><div class="muted" id="rgb">--</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel"><h2>当前告警</h2><div class="rows" id="alerts"><div class="muted">无告警</div></div></div>
|
||||||
|
<div class="panel"><h2>服务与进程</h2><div class="rows" id="watches"></div></div>
|
||||||
|
</section>
|
||||||
|
<section id="logs" class="hidden"><div class="panel"><div class="toolbar"><button id="refreshLogs">刷新日志</button><span class="muted">最近200条 Agent journal</span></div><div id="logRows"></div></div></section>
|
||||||
|
<section id="config" class="hidden"><div class="panel"><h2>用户配置</h2><p class="muted">保存前请输入安装时生成的 API token。修改会备份配置并重启硬件 Agent;API 端口变化会自动重启本服务。</p><div class="toolbar"><input id="token" type="password" placeholder="Bearer token"><button id="saveConfig">保存并应用</button><span id="saveState"></span></div><div id="configRows"></div></div></section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const $=id=>document.getElementById(id), tabs=[...document.querySelectorAll('nav button')];let original={};
|
||||||
|
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
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);const j=await r.json();if(!r.ok)throw Error(j.error||r.statusText);return j}
|
||||||
|
function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
|
||||||
|
async function loadStatus(){try{const s=await get('/api/v1/status'),y=s.system,n=s.network,d=s.display;$('dot').classList.add('ok');$('liveText').textContent='在线';$('identity').textContent=`v${s.version} · 运行 ${Math.floor(s.uptime_seconds/3600)} 小时 · ${new Date(s.timestamp*1000).toLocaleTimeString()}`;$('cpu').textContent=`${y.cpu_percent}%`;$('freq').textContent=`${y.cpu_freq_mhz} MHz`;$('temp').textContent=`${y.temperature_c}°C`;$('fan').textContent=`风扇 ${y.fan_name}`;$('memory').textContent=`${y.memory_percent}%`;$('disk').textContent=`${y.disk_percent}%`;$('network').textContent=`${n.kind} ${n.metric}`;$('address').textContent=`${n.interface} · ${n.ip_label} ${n.ip} · Internet ${n.internet?'OK':'DOWN'}`;$('owner').textContent=d.owner;$('rgb').textContent=`OLED ${d.oled_page} · RGB ${d.rgb_mode}`;rows('alerts',d.alerts.map(a=>`<div class="row"><span><b>${esc(a.title)}</b><br><span class="muted">${esc(a.l2)} ${esc(a.l3)}</span></span><span class="badge bad">S${a.severity} · P${a.priority}</span></div>`),'无告警');rows('watches',s.watches.map(w=>`<div class="row"><span><b>${esc(w.name)}</b><br><span class="muted">${esc(w.type)} · ${esc(w.target)}</span></span><span class="badge ${w.up?'good':'bad'}">${w.up?'UP':'DOWN'}</span></div>`))}catch(e){$('dot').classList.remove('ok');$('liveText').textContent='离线';$('identity').textContent=e.message}}
|
||||||
|
async function loadLogs(){try{const d=await get('/api/v1/logs?limit=200');rows('logRows',d.logs.reverse().map(x=>`<div class="log">${esc(x.message)}</div>`))}catch(e){$('logRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
|
||||||
|
async function loadConfig(){try{const d=await get('/api/v1/config');original=d.config;$('configRows').innerHTML=Object.entries(original).map(([section,values])=>`<details class="config-section"><summary>[${esc(section)}]</summary><div class="fields">${Object.entries(values).map(([k,v])=>`<div class="field"><label>${esc(k)}<input data-section="${esc(section)}" data-key="${esc(k)}" value="${esc(v)}"></label></div>`).join('')}</div></details>`).join('')}catch(e){$('configRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
|
||||||
|
$('refreshLogs').onclick=loadLogs;$('token').value=localStorage.getItem('pigwayToken')||'';
|
||||||
|
$('saveConfig').onclick=async()=>{const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);const updates={};document.querySelectorAll('#configRows input').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.value}});if(!Object.keys(updates).length){$('saveState').textContent='没有变化';return}try{$('saveState').textContent='正在应用…';const d=await get('/api/v1/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({updates})});$('saveState').textContent=`已保存,备份 ${d.backup}`;setTimeout(loadConfig,1500)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}};
|
||||||
|
loadStatus();setInterval(loadStatus,3000);
|
||||||
|
</script></body></html>
|
||||||
Reference in New Issue
Block a user