Compare commits
3
Commits
974121de99
...
f505f3c23b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f505f3c23b | ||
|
|
b7a77a4661 | ||
|
|
9824c54d6b |
@@ -1,8 +1,8 @@
|
|||||||
# PIGWay Pi Control v3.6.0
|
# PIGWay Pi Control v3.7.0
|
||||||
|
|
||||||
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
|
||||||
|
|
||||||
v3.6.0 在本地硬件监控、显示仲裁和可配置 RGB 告警基础上,增加轻量状态/日志/配置 API 与同源 Web 管理界面。
|
v3.7.0 在现有本机硬件监控基础上增加多机状态、日志和配置管理;未登记其他节点时继续作为完整的单机版本运行。
|
||||||
|
|
||||||
## 已有核心功能
|
## 已有核心功能
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ CHECK STORAGE
|
|||||||
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
|
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
level=INFO event=START version=3.6.0
|
level=INFO event=START version=3.7.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 ...
|
||||||
@@ -138,6 +138,36 @@ RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜
|
|||||||
|
|
||||||
Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。
|
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
|
## v3.6.0 API 与 Web
|
||||||
|
|
||||||
安装后访问:
|
安装后访问:
|
||||||
@@ -242,3 +272,4 @@ 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 告警
|
||||||
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
|
||||||
|
- **v3.7.0:多机状态卡片、聚合日志、机器配置选择与批量同步**
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
d11f57b075bf1310515456d8c8b9bf1c5bb33753d8c9b10d08984f7014a8efd8 README.md
|
96dfd20c3ad16ca62a46d66eb5ffb82b1f7f987e66c4dae5310609b1989b7dae README.md
|
||||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||||
ba34a479584345590f1903851b6d7e13000c4034c0c9db39e591eb4382be4229 app/pigway_pi_control.py
|
d0fa68951097a90eb6f51b36e56dc935d8fd7632c3de0ad6bff0b448960a7c03 app/pigway_pi_control.py
|
||||||
a833c5598813b3695ac5a65daacf7b503abd8036f20f2e6cb75d8a99307a1095 app/pigway_pi_control_api.py
|
034931590d83969f9f24222cc57800ab193588beea3d3dcedb98f1690000370d app/pigway_pi_control_api.py
|
||||||
72dc248b8f7020b81bc4069e52cc11175a7b42e00d3b484cb6ca5beca624a90b config/pigway-pi-control.conf
|
ee0f682e8016f75f906d974f00c3e3340edddf30447d441ddfd61a7a34c9e83c config/pigway-pi-control.conf
|
||||||
f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 install.sh
|
f9fbb4defa45b43abe0ce66973b1f4d553369b3a43c01d04f206c573f7dc96ca install.sh
|
||||||
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
|
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
|
||||||
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
||||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||||
f5ea0e6784143c35728b9d3b9aba0490527d14e469e040a4db5a9d37d3ab2c55 web/index.html
|
3a40109f11bea2d44e9d61bed4a90499a5047c4fc534f97aaa90becbf9e57017 web/index.html
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json
|
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json,os,platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
APP_VERSION="3.6.0"
|
APP_VERSION="3.7.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
|
||||||
@@ -29,6 +29,41 @@ def load_average():
|
|||||||
x=Path("/proc/loadavg").read_text().split();return (float(x[0]),float(x[1]),float(x[2]))
|
x=Path("/proc/loadavg").read_text().split();return (float(x[0]),float(x[1]),float(x[2]))
|
||||||
except Exception:return (0.0,0.0,0.0)
|
except Exception:return (0.0,0.0,0.0)
|
||||||
|
|
||||||
|
def memory_usage():
|
||||||
|
try:
|
||||||
|
values={}
|
||||||
|
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||||
|
key,value=line.split(":",1); values[key]=int(value.split()[0])*1024
|
||||||
|
total=values["MemTotal"]; available=values.get("MemAvailable",values.get("MemFree",0))
|
||||||
|
used=max(0,total-available)
|
||||||
|
return total,used,available,(used*100.0/total if total else 0.0)
|
||||||
|
except Exception:return 0,0,0,0.0
|
||||||
|
|
||||||
|
def wifi_percent(metric):
|
||||||
|
try:return max(0,min(100,round((float(metric)+100.0)*2.0)))
|
||||||
|
except Exception:return None
|
||||||
|
|
||||||
|
def device_details():
|
||||||
|
info={}
|
||||||
|
try:
|
||||||
|
for line in Path("/proc/cpuinfo").read_text().splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key,value=(x.strip() for x in line.split(":",1))
|
||||||
|
if key in ("Serial","Revision","Hardware","Model"): info[key.lower()]=value
|
||||||
|
except Exception: pass
|
||||||
|
try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode()
|
||||||
|
except Exception: pass
|
||||||
|
try:
|
||||||
|
os_release={}
|
||||||
|
for line in Path("/etc/os-release").read_text().splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
key,value=line.split("=",1); os_release[key]=value.strip().strip('"')
|
||||||
|
info["os"]=os_release.get("PRETTY_NAME",platform.platform())
|
||||||
|
except Exception: info["os"]=platform.platform()
|
||||||
|
info.update({"hostname":socket.gethostname(),"kernel":platform.release(),
|
||||||
|
"architecture":platform.machine(),"cpu_count":os.cpu_count() or 0})
|
||||||
|
return info
|
||||||
|
|
||||||
def cpu_freq_mhz():
|
def cpu_freq_mhz():
|
||||||
for q in ("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq","/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq"):
|
for q in ("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq","/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq"):
|
||||||
try:return int(Path(q).read_text().strip())/1000.0
|
try:return int(Path(q).read_text().strip())/1000.0
|
||||||
@@ -224,6 +259,12 @@ def cf(section,key,default,cast=float):
|
|||||||
try: return cast(cfg.get(section,key))
|
try: return cast(cfg.get(section,key))
|
||||||
except: return default
|
except: return default
|
||||||
|
|
||||||
|
DEVICE_ID=cf("device","identifier","auto",str).strip()
|
||||||
|
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)
|
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
||||||
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
||||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||||
@@ -306,6 +347,7 @@ def check_watch(w):
|
|||||||
def diagnose():
|
def diagnose():
|
||||||
print("Agent:")
|
print("Agent:")
|
||||||
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
||||||
|
print(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
|
||||||
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||||
print("System:")
|
print("System:")
|
||||||
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
||||||
@@ -318,7 +360,7 @@ def diagnose():
|
|||||||
if "error" in power: print("error="+power["error"])
|
if "error" in power: print("error="+power["error"])
|
||||||
iface,kind,metric,label,address=primary_network()
|
iface,kind,metric,label,address=primary_network()
|
||||||
print("Network:")
|
print("Network:")
|
||||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} ip_label={label} IP={address} internet={internet()}")
|
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
||||||
print("I2C:")
|
print("I2C:")
|
||||||
# SMBus receive-byte performs a read only; no register selection or display commands.
|
# SMBus receive-byte performs a read only; no register selection or display commands.
|
||||||
try:
|
try:
|
||||||
@@ -491,10 +533,11 @@ def render_home(cpu,temp,mem,disk):
|
|||||||
f"DSK {disk:.1f}%",
|
f"DSK {disk:.1f}%",
|
||||||
ip_label+" "+display_ip,
|
ip_label+" "+display_ip,
|
||||||
]
|
]
|
||||||
|
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
||||||
right=[
|
right=[
|
||||||
f"MEM {mem:.1f}%",
|
f"MEM {mem:.1f}%",
|
||||||
f"FAN {FAN_NAME[fan_level]}",
|
f"FAN {FAN_NAME[fan_level]}",
|
||||||
f"{net_kind} {net_metric}",
|
f"WIF {signal}%" if signal is not None else f"{net_kind} {net_metric}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for row in range(4):
|
for row in range(4):
|
||||||
@@ -729,14 +772,24 @@ def cleanup():
|
|||||||
|
|
||||||
def write_status(cpu,temp,mem,disk):
|
def write_status(cpu,temp,mem,disk):
|
||||||
display_state=display.status_snapshot()
|
display_state=display.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
|
||||||
state={
|
state={
|
||||||
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
|
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
|
||||||
|
"machine":{"id":DEVICE_ID,"name":DEVICE_NAME,"hostname":socket.gethostname()},
|
||||||
|
"device":DEVICE_INFO,
|
||||||
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
|
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
|
||||||
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
|
||||||
"memory_percent":round(mem,1),"disk_percent":round(disk,1),
|
"memory_percent":round(memory_calculated if memory_total else mem,1),
|
||||||
|
"memory_total_bytes":memory_total,"memory_used_bytes":memory_used,
|
||||||
|
"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),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
||||||
"power":health,
|
"power":health,
|
||||||
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||||
|
"signal_percent":signal,
|
||||||
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)},
|
"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)}
|
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
||||||
for w,ok in zip(watches,watch_state)],
|
for w,ok in zip(watches,watch_state)],
|
||||||
|
|||||||
+193
-12
@@ -1,16 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time
|
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
||||||
|
from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs,urlparse
|
from urllib.parse import parse_qs,urlencode,urlparse
|
||||||
|
from urllib.request import Request,urlopen
|
||||||
|
|
||||||
APP_VERSION="3.6.0"
|
APP_VERSION="3.7.0"
|
||||||
CFG=Path("/etc/pigway-pi-control.conf")
|
CFG=Path("/etc/pigway-pi-control.conf")
|
||||||
STATUS=Path("/run/pigway-pi-control/status.json")
|
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||||
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||||
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
||||||
ALLOWED_SECTIONS={"services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
||||||
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||||
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
||||||
@@ -19,6 +21,67 @@ NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorf
|
|||||||
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
||||||
PROTECTED_UNITS={"pigway-pi-control-api.service"}
|
PROTECTED_UNITS={"pigway-pi-control-api.service"}
|
||||||
|
|
||||||
|
def machine_identity():
|
||||||
|
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
|
||||||
|
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
|
||||||
|
name=cfg.get("device","name",fallback=identifier).strip() or identifier
|
||||||
|
if name.lower()=="auto": name=identifier
|
||||||
|
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
|
||||||
|
|
||||||
|
def normalize_url(value):
|
||||||
|
parsed=urlparse(str(value).strip())
|
||||||
|
if parsed.scheme not in {"http","https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||||
|
raise ValueError("host URL must be an http(s) URL without credentials")
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||||
|
|
||||||
|
def host_registry():
|
||||||
|
local=machine_identity(); result=[local]
|
||||||
|
cfg=load_cfg()
|
||||||
|
if cfg.has_section("hosts"):
|
||||||
|
for host_id,url in cfg.items("hosts"):
|
||||||
|
try: base=normalize_url(url)
|
||||||
|
except ValueError: continue
|
||||||
|
if host_id==local["id"]: continue
|
||||||
|
result.append({"id":host_id,"name":host_id,"hostname":"","local":False,"url":base})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def find_host(host_id):
|
||||||
|
for host in host_registry():
|
||||||
|
if host["id"]==host_id:return host
|
||||||
|
raise ValueError("unknown host")
|
||||||
|
|
||||||
|
def remote_json(base,path,method="GET",payload=None,token=""):
|
||||||
|
data=None if payload is None else json.dumps(payload,separators=(",",":")).encode()
|
||||||
|
headers={"Accept":"application/json"}
|
||||||
|
if data is not None: headers["Content-Type"]="application/json"
|
||||||
|
if token: headers["Authorization"]=f"Bearer {token}"
|
||||||
|
request=Request(base+path,data=data,headers=headers,method=method)
|
||||||
|
with urlopen(request,timeout=5) as response:
|
||||||
|
return json.loads(response.read())
|
||||||
|
|
||||||
|
def fleet_status():
|
||||||
|
hosts=host_registry(); results=[]
|
||||||
|
try:
|
||||||
|
status=json.loads(STATUS.read_text())
|
||||||
|
status.setdefault("machine",machine_identity())
|
||||||
|
results.append({"host":hosts[0],"online":True,"status":status})
|
||||||
|
except Exception as exc:
|
||||||
|
results.append({"host":hosts[0],"online":False,"error":str(exc)})
|
||||||
|
def fetch(host):
|
||||||
|
try:
|
||||||
|
status=remote_json(host["url"],"/api/v1/status")
|
||||||
|
machine=status.get("machine",{})
|
||||||
|
merged={**host,"name":machine.get("name") or host["name"],"hostname":machine.get("hostname","")}
|
||||||
|
return {"host":merged,"online":True,"status":status}
|
||||||
|
except Exception as exc:return {"host":host,"online":False,"error":str(exc)}
|
||||||
|
peers=hosts[1:]
|
||||||
|
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
|
||||||
|
futures=[pool.submit(fetch,h) for h in peers]
|
||||||
|
for future in as_completed(futures): results.append(future.result())
|
||||||
|
order={h["id"]:i for i,h in enumerate(hosts)}
|
||||||
|
results.sort(key=lambda x:order.get(x["host"]["id"],999))
|
||||||
|
return {"hosts":results,"timestamp":int(time.time())}
|
||||||
|
|
||||||
def load_cfg():
|
def load_cfg():
|
||||||
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||||
return cfg
|
return cfg
|
||||||
@@ -32,7 +95,7 @@ def config_json():
|
|||||||
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||||
|
|
||||||
def validate_value(section,key,value,existing):
|
def validate_value(section,key,value,existing):
|
||||||
if section not in {"services","processes"} and key not in existing.get(section,set()):
|
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
||||||
raise ValueError(f"unsupported option: {section}.{key}")
|
raise ValueError(f"unsupported option: {section}.{key}")
|
||||||
if RGB_KEY_RE.fullmatch(key):
|
if RGB_KEY_RE.fullmatch(key):
|
||||||
number=int(value)
|
number=int(value)
|
||||||
@@ -54,6 +117,42 @@ def validate_value(section,key,value,existing):
|
|||||||
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
|
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
|
||||||
elif section=="api" and key=="bind":
|
elif section=="api" and key=="bind":
|
||||||
ipaddress.ip_address(value)
|
ipaddress.ip_address(value)
|
||||||
|
elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value):
|
||||||
|
raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
|
||||||
|
elif section=="hosts":
|
||||||
|
normalize_url(value)
|
||||||
|
|
||||||
|
def replace_ini_value(section,key,value):
|
||||||
|
text=CFG.read_text()
|
||||||
|
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",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():])
|
||||||
|
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)
|
||||||
|
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:]
|
||||||
|
if value is not None:return text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def save_ini_text(text,prefix="api"):
|
||||||
|
parsed=configparser.ConfigParser(); parsed.read_string(text)
|
||||||
|
backup=CFG.with_name(f"{CFG.name}.bak.{prefix}-{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 edit_host(operation,host_id,url=""):
|
||||||
|
if operation not in {"save","delete"}: raise ValueError("invalid host operation")
|
||||||
|
if not NAME_RE.fullmatch(host_id): raise ValueError("invalid host identifier")
|
||||||
|
local=machine_identity()["id"]
|
||||||
|
if host_id==local: raise ValueError("local host cannot be changed in the remote host list")
|
||||||
|
if operation=="save": url=normalize_url(url)
|
||||||
|
text=replace_ini_value("hosts",host_id,url if operation=="save" else None)
|
||||||
|
return save_ini_text(text,"hosts")
|
||||||
|
|
||||||
def update_ini(updates):
|
def update_ini(updates):
|
||||||
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
||||||
@@ -125,6 +224,50 @@ def journal_query(since=None,until=None,severity="",event="",search="",sort="tim
|
|||||||
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
||||||
"event_types":event_types,"retention":"systemd-journal"}
|
"event_types":event_types,"retention":"systemd-journal"}
|
||||||
|
|
||||||
|
def fleet_journal_query(since=None,until=None,severity="",event="",search="",machine="",
|
||||||
|
sort="timestamp",order="desc",limit=100,offset=0):
|
||||||
|
hosts=host_registry(); rows=[]; event_types=set(); machines=[]
|
||||||
|
def decorate(payload,host):
|
||||||
|
machine_info=payload.get("machine") or {"id":host["id"],"name":host["name"]}
|
||||||
|
host_id=machine_info.get("id") or host["id"]
|
||||||
|
host_name=machine_info.get("name") or host["name"]
|
||||||
|
decorated=[]
|
||||||
|
for row in payload.get("logs",[]):
|
||||||
|
decorated.append({**row,"machine_id":host_id,"machine_name":host_name})
|
||||||
|
return decorated,set(payload.get("event_types",[])),{"id":host_id,"name":host_name}
|
||||||
|
local_payload=journal_query(since,until,severity,event,search,"timestamp","desc",50000,0)
|
||||||
|
local_payload["machine"]=machine_identity()
|
||||||
|
local_rows,local_events,local_machine=decorate(local_payload,hosts[0])
|
||||||
|
rows.extend(local_rows); event_types.update(local_events); machines.append(local_machine)
|
||||||
|
query={"limit":50000,"offset":0,"sort":"timestamp","order":"desc"}
|
||||||
|
for key,value in (("since",since),("until",until),("severity",severity),("event",event),("search",search)):
|
||||||
|
if value not in (None,""): query[key]=value
|
||||||
|
def fetch(host):
|
||||||
|
payload=remote_json(host["url"],"/api/v1/logs?"+urlencode(query))
|
||||||
|
try:
|
||||||
|
status=remote_json(host["url"],"/api/v1/status")
|
||||||
|
payload["machine"]=status.get("machine",{})
|
||||||
|
except Exception: pass
|
||||||
|
return decorate(payload,host)
|
||||||
|
peers=hosts[1:]
|
||||||
|
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
|
||||||
|
futures=[pool.submit(fetch,h) for h in peers]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
try:
|
||||||
|
remote_rows,remote_events,remote_machine=future.result()
|
||||||
|
rows.extend(remote_rows); event_types.update(remote_events); machines.append(remote_machine)
|
||||||
|
except Exception: pass
|
||||||
|
if machine: rows=[row for row in rows if row["machine_id"]==machine]
|
||||||
|
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
|
||||||
|
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
|
||||||
|
"event":lambda x:x["event"],"message":lambda x:x["message"],
|
||||||
|
"machine":lambda x:(x["machine_name"],x["machine_id"])}
|
||||||
|
rows.sort(key=keys[sort],reverse=order=="desc")
|
||||||
|
total=len(rows)
|
||||||
|
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
||||||
|
"event_types":sorted(event_types),"machines":sorted(machines,key=lambda x:(x["name"],x["id"])),
|
||||||
|
"retention":"systemd-journal"}
|
||||||
|
|
||||||
def service_inventory():
|
def service_inventory():
|
||||||
cfg=load_cfg()
|
cfg=load_cfg()
|
||||||
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
|
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
|
||||||
@@ -205,7 +348,7 @@ def control_service(unit,action):
|
|||||||
if result.returncode: raise RuntimeError(result.stderr.strip() or f"systemctl {action} failed")
|
if result.returncode: raise RuntimeError(result.stderr.strip() or f"systemctl {action} failed")
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version="PIGWayAPI/3.6"
|
server_version="PIGWayAPI/3.7"
|
||||||
|
|
||||||
def log_message(self,fmt,*args):
|
def log_message(self,fmt,*args):
|
||||||
return
|
return
|
||||||
@@ -240,37 +383,75 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
|
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":
|
if parsed.path=="/api/v1/health":
|
||||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
||||||
|
if parsed.path=="/api/v1/hosts":
|
||||||
|
self.send_json(200,{"hosts":host_registry()}); return
|
||||||
|
if parsed.path=="/api/v1/fleet/status":
|
||||||
|
self.send_json(200,fleet_status()); return
|
||||||
if parsed.path=="/api/v1/status":
|
if parsed.path=="/api/v1/status":
|
||||||
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
||||||
self.send_json(200,json.loads(STATUS.read_text())); return
|
self.send_json(200,json.loads(STATUS.read_text())); return
|
||||||
if parsed.path=="/api/v1/logs":
|
if parsed.path in {"/api/v1/logs","/api/v1/fleet/logs"}:
|
||||||
query=parse_qs(parsed.query)
|
query=parse_qs(parsed.query)
|
||||||
value=lambda key,default="":query.get(key,[default])[0]
|
value=lambda key,default="":query.get(key,[default])[0]
|
||||||
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),500)
|
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
|
||||||
offset=max(int(value("offset",0)),0)
|
offset=max(int(value("offset",0)),0)
|
||||||
since=int(value("since")) if value("since") else None
|
since=int(value("since")) if value("since") else None
|
||||||
until=int(value("until")) if value("until") else None
|
until=int(value("until")) if value("until") else None
|
||||||
if since is not None and until is not None and since>until: raise ValueError("since must not be after until")
|
if since is not None and until is not None and since>until: raise ValueError("since must not be after until")
|
||||||
sort=value("sort","timestamp"); order=value("order","desc")
|
sort=value("sort","timestamp"); order=value("order","desc")
|
||||||
if sort not in {"timestamp","severity","event","message"}: raise ValueError("invalid log sort")
|
allowed_sort={"timestamp","severity","event","message"}
|
||||||
|
if parsed.path=="/api/v1/fleet/logs": allowed_sort.add("machine")
|
||||||
|
if sort not in allowed_sort: raise ValueError("invalid log sort")
|
||||||
if order not in {"asc","desc"}: raise ValueError("invalid log order")
|
if order not in {"asc","desc"}: raise ValueError("invalid log order")
|
||||||
severity=value("severity").upper(); event=value("event"); search=value("search")
|
severity=value("severity").upper(); event=value("event"); search=value("search")
|
||||||
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
|
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
|
||||||
|
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
|
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
||||||
if parsed.path=="/api/v1/config":
|
if parsed.path=="/api/v1/config":
|
||||||
self.send_json(200,{"config":config_json(),"write_requires_token":True}); return
|
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||||
|
if host_id and host_id!=machine_identity()["id"]:
|
||||||
|
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/config")); return
|
||||||
|
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
|
||||||
if parsed.path=="/api/v1/services":
|
if parsed.path=="/api/v1/services":
|
||||||
self.send_json(200,{"services":service_inventory()}); return
|
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||||
|
if host_id and host_id!=machine_identity()["id"]:
|
||||||
|
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/services")); return
|
||||||
|
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
|
||||||
self.send_json(404,{"error":"not found"})
|
self.send_json(404,{"error":"not found"})
|
||||||
except ValueError as e: self.send_json(400,{"error":str(e)})
|
except ValueError as e: self.send_json(400,{"error":str(e)})
|
||||||
except Exception as e: self.send_json(500,{"error":str(e)})
|
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||||
|
|
||||||
def do_PUT(self):
|
def do_PUT(self):
|
||||||
path=urlparse(self.path).path
|
path=urlparse(self.path).path
|
||||||
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control"}: self.send_json(404,{"error":"not found"}); return
|
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 not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||||
try:
|
try:
|
||||||
body=self.read_json()
|
body=self.read_json()
|
||||||
|
if path=="/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
|
||||||
|
if path=="/api/v1/fleet/config":
|
||||||
|
updates=body.get("updates"); targets=body.get("targets",[]); tokens=body.get("tokens",{})
|
||||||
|
if not isinstance(targets,list) or not targets or len(targets)>32: raise ValueError("targets must contain 1..32 host identifiers")
|
||||||
|
if not isinstance(tokens,dict): raise ValueError("tokens must be an object")
|
||||||
|
results=[]; local_id=machine_identity()["id"]; restart_local_api=False
|
||||||
|
for host_id in dict.fromkeys(str(x) for x in targets):
|
||||||
|
try:
|
||||||
|
if host_id==local_id:
|
||||||
|
backup=update_ini(updates)
|
||||||
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
|
restart_local_api="api" in updates
|
||||||
|
results.append({"id":host_id,"ok":True,"backup":backup})
|
||||||
|
else:
|
||||||
|
host=find_host(host_id); token=str(tokens.get(host_id,""))
|
||||||
|
if not token: raise ValueError("remote bearer token required")
|
||||||
|
response=remote_json(host["url"],"/api/v1/config","PUT",{"updates":updates},token)
|
||||||
|
results.append({"id":host_id,"ok":True,"backup":response.get("backup","")})
|
||||||
|
except Exception as exc: results.append({"id":host_id,"ok":False,"error":str(exc)})
|
||||||
|
self.send_json(200,{"ok":all(x["ok"] for x in results),"results":results})
|
||||||
|
if restart_local_api: threading.Timer(0.2,self.server.shutdown).start()
|
||||||
|
return
|
||||||
if path=="/api/v1/services/control":
|
if path=="/api/v1/services/control":
|
||||||
control_service(str(body.get("unit","")),str(body.get("action","")))
|
control_service(str(body.get("unit","")),str(body.get("action","")))
|
||||||
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
|
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PIGWay Pi Control v3.6.0 - 用户配置文件
|
# PIGWay Pi Control v3.7.0 - 用户配置文件
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# 正式安装后的配置路径:
|
# 正式安装后的配置路径:
|
||||||
@@ -25,6 +25,22 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
[device]
|
||||||
|
# 多机监控中使用的稳定机器标识。auto 表示读取本机 hostname。
|
||||||
|
# 多台机器的 identifier 必须唯一;修改后重启硬件 Agent 和 API 服务。
|
||||||
|
identifier = auto
|
||||||
|
|
||||||
|
# Web 页面显示名称。auto 表示使用 identifier。
|
||||||
|
name = auto
|
||||||
|
|
||||||
|
|
||||||
|
[hosts]
|
||||||
|
# 在作为聚合入口的机器上登记其他节点,每行格式:
|
||||||
|
# 唯一标识 = http://节点IP:6001
|
||||||
|
# 示例:
|
||||||
|
# living-room = http://192.168.1.20:6001
|
||||||
|
|
||||||
|
|
||||||
[api]
|
[api]
|
||||||
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
||||||
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||||
|
|||||||
+4
-2
@@ -102,6 +102,8 @@ from pathlib import Path
|
|||||||
path=Path(sys.argv[1])
|
path=Path(sys.argv[1])
|
||||||
text=path.read_text()
|
text=path.read_text()
|
||||||
sections={
|
sections={
|
||||||
|
"device":(("identifier","auto"),("name","auto")),
|
||||||
|
"hosts":(),
|
||||||
"led":(
|
"led":(
|
||||||
("normal_mode","off"),
|
("normal_mode","off"),
|
||||||
("normal_r",0),("normal_g",0),("normal_b",0),
|
("normal_r",0),("normal_g",0),("normal_b",0),
|
||||||
@@ -163,7 +165,7 @@ 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.6.0" grep -Fq 'APP_VERSION="3.6.0"' "$APP"
|
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 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"
|
||||||
@@ -178,7 +180,7 @@ else
|
|||||||
fi
|
fi
|
||||||
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 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 "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 "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"
|
||||||
|
|||||||
+34
-21
@@ -8,19 +8,17 @@
|
|||||||
</style>
|
</style>
|
||||||
<style>
|
<style>
|
||||||
.log-filter{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;align-items:end}.log-filter label{color:var(--muted);font-size:12px}.log-filter input,.log-filter select{width:100%;margin-top:5px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.quick-range{display:flex;gap:7px;flex-wrap:wrap;margin:12px 0}.log-table-wrap{overflow:auto;border:1px solid var(--line);border-radius:9px}.log-table{width:100%;border-collapse:collapse;min-width:760px}.log-table th,.log-table td{text-align:left;padding:10px;border-bottom:1px solid #203040;vertical-align:top}.log-table th{position:sticky;top:0;background:#152130;z-index:1}.sort-button{border:0;background:transparent;padding:0;font-weight:700}.sort-button:hover{background:transparent}.log-message{font:12px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;word-break:break-word}.log-pager{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:12px}@media(max-width:800px){.log-filter{grid-template-columns:1fr 1fr}}@media(max-width:470px){.log-filter{grid-template-columns:1fr}}
|
.log-filter{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;align-items:end}.log-filter label{color:var(--muted);font-size:12px}.log-filter input,.log-filter select{width:100%;margin-top:5px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.quick-range{display:flex;gap:7px;flex-wrap:wrap;margin:12px 0}.log-table-wrap{overflow:auto;border:1px solid var(--line);border-radius:9px}.log-table{width:100%;border-collapse:collapse;min-width:760px}.log-table th,.log-table td{text-align:left;padding:10px;border-bottom:1px solid #203040;vertical-align:top}.log-table th{position:sticky;top:0;background:#152130;z-index:1}.sort-button{border:0;background:transparent;padding:0;font-weight:700}.sort-button:hover{background:transparent}.log-message{font:12px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;word-break:break-word}.log-pager{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:12px}@media(max-width:800px){.log-filter{grid-template-columns:1fr 1fr}}@media(max-width:470px){.log-filter{grid-template-columns:1fr}}
|
||||||
.header-actions{display:flex;align-items:end;gap:12px;flex-wrap:wrap;justify-content:flex-end}.preference{display:grid;gap:4px;color:var(--muted);font-size:12px}.preference select{border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:7px 28px 7px 9px}[data-theme="light"]{color-scheme:light;--bg:#eef3f8;--panel:#fff;--panel2:#f6f9fc;--line:#c8d4e0;--text:#172333;--muted:#5d7085;--good:#168653;--bad:#c93d3d;--blue:#176fc1}[data-theme="light"] body{background:radial-gradient(circle at top right,#d9e9f8 0,#eef3f8 45%)}[data-theme="light"] nav{background:#eef3f8e8}[data-theme="light"] button{background:#e7eef6}[data-theme="light"] button:hover,[data-theme="light"] button.active{background:#d9e9f8}[data-theme="light"] .card,[data-theme="light"] .panel,[data-theme="light"] .group{box-shadow:0 10px 24px #3452}[data-theme="light"] .field input,[data-theme="light"] .field select,[data-theme="light"] .service-form input,[data-theme="light"] .service-search,[data-theme="light"] .toolbar input,[data-theme="light"] .log-filter input,[data-theme="light"] .log-filter select,[data-theme="light"] .preference select{background:#fff}[data-theme="light"] .savebar{background:#f7fafcef;box-shadow:0 8px 25px #3453}[data-theme="light"] .log-table th{background:#e7eef6}[data-theme="light"] .row,[data-theme="light"] .log-table th,[data-theme="light"] .log-table td,[data-theme="light"] .log,[data-theme="light"] .service-list{border-color:#d6e0ea}@media(max-width:470px){.header-actions{justify-content:flex-start}}
|
.header-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.preference{display:grid;gap:4px;color:var(--muted);font-size:12px}.preference select{height:35px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:7px 28px 7px 9px}.header-actions .live{align-self:flex-end;height:35px}.service-list{scrollbar-width:none}.service-list:hover{scrollbar-width:thin;scrollbar-color:var(--line) transparent}.service-list::-webkit-scrollbar{width:0}.service-list:hover::-webkit-scrollbar{width:7px}.service-list::-webkit-scrollbar-track{background:transparent}.service-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px}[data-theme="light"]{color-scheme:light;--bg:#eef3f8;--panel:#fff;--panel2:#f6f9fc;--line:#c8d4e0;--text:#172333;--muted:#5d7085;--good:#168653;--bad:#c93d3d;--blue:#176fc1}[data-theme="light"] body{background:radial-gradient(circle at top right,#d9e9f8 0,#eef3f8 45%)}[data-theme="light"] nav{background:#eef3f8e8}[data-theme="light"] button{background:#e7eef6}[data-theme="light"] button:hover,[data-theme="light"] button.active{background:#d9e9f8}[data-theme="light"] .card,[data-theme="light"] .panel,[data-theme="light"] .group{box-shadow:0 10px 24px #3452}[data-theme="light"] .field input,[data-theme="light"] .field select,[data-theme="light"] .service-form input,[data-theme="light"] .service-search,[data-theme="light"] .toolbar input,[data-theme="light"] .log-filter input,[data-theme="light"] .log-filter select,[data-theme="light"] .preference select{background:#fff}[data-theme="light"] .badge{background:#e5edf5;color:#31465c}[data-theme="light"] .badge.good{background:#d9f3e5;color:#087444}[data-theme="light"] .badge.bad{background:#fde5e5;color:#b72f2f}[data-theme="light"] .savebar{background:#f7fafcef;box-shadow:0 8px 25px #3453}[data-theme="light"] .log-table th{background:#e7eef6}[data-theme="light"] .row,[data-theme="light"] .log-table th,[data-theme="light"] .log-table td,[data-theme="light"] .log,[data-theme="light"] .service-list{border-color:#d6e0ea}@media(max-width:470px){.header-actions{justify-content:flex-start}}.host-panel{margin-top:14px}.host-head{display:flex;justify-content:space-between;gap:16px;align-items:start;margin-bottom:12px}.device-details{display:flex;gap:8px 16px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:5px}.host-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.metric{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px}.metric .value{font-size:20px}.progress{height:7px;background:color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;overflow:hidden;margin:8px 0}.progress span{display:block;height:100%;background:var(--blue);border-radius:inherit}.host-select{display:grid;gap:4px;color:var(--muted);font-size:12px}.host-select select{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--panel2);color:var(--text);padding:9px}.host-checks{display:flex;gap:12px;flex-wrap:wrap}.host-checks label{display:flex;gap:6px;align-items:center}.offline-card{border-color:var(--bad)}@media(max-width:900px){.host-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:550px){.host-grid{grid-template-columns:1fr}.host-head{flex-direction:column}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body><main>
|
<body><main>
|
||||||
<header><div><h1>PIGWay Pi Control</h1><div class="sub" id="identity">正在连接树莓派…</div></div><div class="header-actions"><label class="preference"><span>语言</span><select id="language"><option value="zh">中文</option><option value="en">English</option></select></label><label class="preference"><span>颜色模式</span><select id="theme"><option value="auto">自动</option><option value="dark">深色</option><option value="light">浅色</option></select></label><div class="live"><span class="dot" id="dot"></span><span id="liveText">离线</span></div></div></header>
|
<header><div><h1>PIGWay Pi Control</h1><div class="sub" id="identity">正在连接树莓派…</div></div><div class="header-actions"><label class="preference"><span>语言</span><select id="language"><option value="zh">中文</option><option value="en">English</option></select></label><label class="preference"><span>颜色模式</span><select id="theme"><option value="auto">自动</option><option value="dark">深色</option><option value="light">浅色</option></select></label><div class="live"><span class="dot" id="dot"></span><span id="liveText">离线</span></div></div></header>
|
||||||
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="config">系统配置</button></nav>
|
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="config">系统配置</button></nav>
|
||||||
<section id="overview"><div class="grid">
|
<section id="overview"><div id="hostCards"><div class="panel muted">正在读取主机状态…</div></div></section>
|
||||||
<div class="card"><div class="muted">处理器负载</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>
|
<section id="logs" class="hidden"><div class="panel"><h2>事件日志</h2><p class="muted">查询 systemd journal 中仍在保留的全部硬件 Agent 事件。</p><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><div class="log-filter"><label>开始日期<input id="logStart" type="date"></label><label>结束日期<input id="logEnd" type="date"></label><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><label>关键词<input id="logSearch" placeholder="搜索日志内容"></label></div><div class="toolbar"><button id="refreshLogs">查询</button><button id="resetLogs">重置</button><span class="muted" id="logSummary"></span></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>
|
||||||
</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="config" class="hidden"><div class="panel"><h2>系统配置</h2><p class="muted">配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。</p><div class="toolbar"><label class="host-select">配置机器<select id="configHost"></select></label><input id="token" type="password" placeholder="当前机器配置令牌(Bearer Token)"><button id="toggleToken">显示令牌</button></div><div class="group"><div class="group-head"><div><h3>多机配置同步</h3><div class="hint">勾选目标机器后,将当前页面中已修改的配置项批量同步到这些机器。</div></div></div><div id="syncHosts" class="host-checks"></div></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar"><button id="saveConfig">保存到当前机器</button> <button id="syncConfig">批量同步已修改项</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
|
||||||
<section id="logs" class="hidden"><div class="panel"><h2>事件日志</h2><p class="muted">查询 systemd journal 中仍在保留的全部硬件 Agent 事件。</p><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><div class="log-filter"><label>开始日期<input id="logStart" type="date"></label><label>结束日期<input id="logEnd" type="date"></label><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="logEvent"><option value="">全部事件</option></select></label><label>关键词<input id="logSearch" placeholder="搜索日志内容"></label></div><div class="toolbar"><button id="refreshLogs">查询</button><button id="resetLogs">重置</button><span class="muted" id="logSummary"></span></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="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"><input id="token" type="password" placeholder="配置写入令牌(Bearer Token)"><button id="toggleToken">显示令牌</button></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar"><button id="saveConfig">保存并应用</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
|
|
||||||
</main><script>
|
</main><script>
|
||||||
const LANG=localStorage.getItem('pigwayLanguage')||'zh';
|
let LANG=localStorage.getItem('pigwayLanguage')||'zh';
|
||||||
const EN={
|
const EN={
|
||||||
'语言':'Language','颜色模式':'Appearance','中文':'中文','自动':'Auto','深色':'Dark','浅色':'Light','正在连接树莓派…':'Connecting to Raspberry Pi…','在线':'Online','离线':'Offline',
|
'语言':'Language','颜色模式':'Appearance','中文':'中文','自动':'Auto','深色':'Dark','浅色':'Light','正在连接树莓派…':'Connecting to Raspberry Pi…','在线':'Online','离线':'Offline',
|
||||||
'运行状态':'Status','事件日志':'Event log','系统配置':'Settings','处理器负载':'CPU load','处理器温度':'CPU temperature','内存占用':'Memory usage','磁盘占用':'Disk usage','主网络':'Primary network','显示状态':'Display status','当前告警':'Active alerts','无告警':'No alerts','服务与进程监控':'Service and process monitors',
|
'运行状态':'Status','事件日志':'Event log','系统配置':'Settings','处理器负载':'CPU load','处理器温度':'CPU temperature','内存占用':'Memory usage','磁盘占用':'Disk usage','主网络':'Primary network','显示状态':'Display status','当前告警':'Active alerts','无告警':'No alerts','服务与进程监控':'Service and process monitors',
|
||||||
@@ -38,19 +36,21 @@ Object.assign(EN,{
|
|||||||
'风扇启动温度':'Fan start temperature','二档起始温度':'Level 2 temperature','三档起始温度':'Level 3 temperature','四档起始温度':'Level 4 temperature','全速起始温度':'Full-speed temperature','降档温度回差':'Downshift hysteresis','传感器采样周期':'Sensor interval','OLED 刷新周期':'OLED refresh interval','OLED 页面轮播周期':'OLED page interval','网络检查周期':'Network check interval','服务检查周期':'Service check interval','健康检查周期':'Health check interval','第一行纵坐标':'Row 1 Y position','第二行纵坐标':'Row 2 Y position','第三行纵坐标':'Row 3 Y position','第四行纵坐标':'Row 4 Y position','树莓派电源灯':'Raspberry Pi power LED','树莓派活动灯':'Raspberry Pi activity LED','监听地址':'Bind address','监听端口':'Listen port','默认日志条数':'Default log entries',
|
'风扇启动温度':'Fan start temperature','二档起始温度':'Level 2 temperature','三档起始温度':'Level 3 temperature','四档起始温度':'Level 4 temperature','全速起始温度':'Full-speed temperature','降档温度回差':'Downshift hysteresis','传感器采样周期':'Sensor interval','OLED 刷新周期':'OLED refresh interval','OLED 页面轮播周期':'OLED page interval','网络检查周期':'Network check interval','服务检查周期':'Service check interval','健康检查周期':'Health check interval','第一行纵坐标':'Row 1 Y position','第二行纵坐标':'Row 2 Y position','第三行纵坐标':'Row 3 Y position','第四行纵坐标':'Row 4 Y position','树莓派电源灯':'Raspberry Pi power LED','树莓派活动灯':'Raspberry Pi activity LED','监听地址':'Bind address','监听端口':'Listen port','默认日志条数':'Default log entries',
|
||||||
'处理器、温度与散热':'CPU, temperature and cooling','供电与欠压':'Power and undervoltage','内存':'Memory','存储':'Storage','网络与 Wi-Fi':'Network and Wi-Fi','服务与进程':'Services and processes','仅在没有当前告警时生效;告警会立即抢占。':'Only active when there are no alerts; alerts take priority immediately.','MCU 内置速度档位。':'MCU built-in speed level.','仅流水灯和呼吸灯使用。':'Used only by flow and breathe modes.','硬件校准项,通常保持 10 毫秒。':'Hardware calibration setting; normally keep 10 ms.','单位 dBm,数值越接近 0 信号越强。':'Unit: dBm. Values closer to 0 mean a stronger signal.','单位 dBm,应高于弱信号阈值。':'Unit: dBm. Must be above the weak-signal threshold.','避免临界温度附近频繁切换档位。':'Prevents rapid level changes near a threshold.','选择“关闭”时由程序熄灭板载灯。':'Choose “Off” to let the program turn off the board LED.'
|
'处理器、温度与散热':'CPU, temperature and cooling','供电与欠压':'Power and undervoltage','内存':'Memory','存储':'Storage','网络与 Wi-Fi':'Network and Wi-Fi','服务与进程':'Services and processes','仅在没有当前告警时生效;告警会立即抢占。':'Only active when there are no alerts; alerts take priority immediately.','MCU 内置速度档位。':'MCU built-in speed level.','仅流水灯和呼吸灯使用。':'Used only by flow and breathe modes.','硬件校准项,通常保持 10 毫秒。':'Hardware calibration setting; normally keep 10 ms.','单位 dBm,数值越接近 0 信号越强。':'Unit: dBm. Values closer to 0 mean a stronger signal.','单位 dBm,应高于弱信号阈值。':'Unit: dBm. Must be above the weak-signal threshold.','避免临界温度附近频繁切换档位。':'Prevents rapid level changes near a threshold.','选择“关闭”时由程序熄灭板载灯。':'Choose “Off” to let the program turn off the board LED.'
|
||||||
});
|
});
|
||||||
const tr=s=>LANG==='en'?(EN[s]||s):s;
|
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'});
|
||||||
function translateNode(root=document){if(LANG!=='en')return;const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;while(n=walker.nextNode()){const raw=n.nodeValue,trim=raw.trim();if(trim&&EN[trim])n.nodeValue=raw.replace(trim,EN[trim])}root.querySelectorAll?.('[placeholder],[title]').forEach(e=>['placeholder','title'].forEach(a=>{const v=e.getAttribute(a);if(v&&EN[v])e.setAttribute(a,EN[v])}))}
|
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'});
|
||||||
|
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]').forEach(e=>['placeholder','title'].forEach(a=>{const v=e.getAttribute(a);if(v)e.setAttribute(a,translate(v))}))}
|
||||||
function applyTheme(value){const resolved=value==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):value;document.documentElement.dataset.theme=resolved}
|
function applyTheme(value){const resolved=value==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):value;document.documentElement.dataset.theme=resolved}
|
||||||
const $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={};const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={},fleetHosts=[],currentConfigHost='';const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const sectionMeta={led:['灯光设置','正常灯效与告警颜色'],alerts:['告警阈值','处理器、内存、温度、磁盘和 Wi-Fi'],fan:['风扇温控','温度档位与降档回差'],timing:['刷新周期','传感器、屏幕、网络和健康检查'],display:['屏幕布局','OLED 四行文字位置'],dark_mode:['板载指示灯','树莓派电源灯与活动灯'],services:['服务监控','监控 systemd 服务'],processes:['进程监控','监控普通进程关键字'],api:['Web 与 API','监听地址、端口和日志数量']};
|
const sectionMeta={device:['设备标识','自定义本机显示名称和稳定标识'],hosts:['机器列表','登记需要聚合监控的其他节点'],led:['灯光设置','正常灯效与告警颜色'],alerts:['告警阈值','处理器、内存、温度、磁盘和 Wi-Fi'],fan:['风扇温控','温度档位与降档回差'],timing:['刷新周期','传感器、屏幕、网络和健康检查'],display:['屏幕布局','OLED 四行文字位置'],dark_mode:['板载指示灯','树莓派电源灯与活动灯'],services:['服务监控','监控 systemd 服务'],processes:['进程监控','监控普通进程关键字'],api:['Web 与 API','监听地址、端口和日志数量']};
|
||||||
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':'默认日志条数'};
|
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':'显示名称'};
|
||||||
['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>labels[`led.${g}_${c}`]=`${({r:'红',g:'绿',b:'蓝'})[c]}色通道`));
|
['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>labels[`led.${g}_${c}`]=`${({r:'红',g:'绿',b:'蓝'})[c]}色通道`));
|
||||||
const hints={'led.normal_mode':'仅在没有当前告警时生效;告警会立即抢占。','led.normal_r':'范围 0–255。','led.normal_g':'范围 0–255。','led.normal_b':'范围 0–255。','led.normal_flash_on_ms':'范围 50–60000 毫秒。','led.normal_flash_off_ms':'范围 50–60000 毫秒。','led.normal_effect_speed':'MCU 内置速度档位。','led.normal_effect_color':'仅流水灯和呼吸灯使用。','led.write_delay_ms':'硬件校准项,通常保持 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={'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':'硬件校准项,通常保持 10 毫秒。','alerts.wifi_weak':'单位 dBm,数值越接近 0 信号越强。','alerts.wifi_recover':'单位 dBm,应高于弱信号阈值。','fan.hysteresis':'避免临界温度附近频繁切换档位。','api.bind':'0.0.0.0 允许局域网访问;127.0.0.1 仅本机。','api.port':'范围 1–65535;保存后 API 自动重启。','dark_mode.pi_pwr_led':'选择“关闭”时由程序熄灭板载灯。','dark_mode.pi_act_led':'选择“关闭”时由程序熄灭板载灯。'};
|
||||||
['normal','cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>hints[`led.${g}_${c}`]='范围 0–255。'));
|
['normal','cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>hints[`led.${g}_${c}`]='范围 0–255。'));
|
||||||
const options={'led.normal_mode':[['off','全灭'],['solid','自定义颜色常亮'],['flash','自定义颜色闪烁'],['flow','流水灯'],['breathe','呼吸灯'],['marquee','跑马灯'],['rainbow','彩虹灯'],['colorful','炫彩灯']],'led.normal_effect_speed':[['1','慢速'],['2','中速'],['3','快速']],'led.normal_effect_color':[['red','红色'],['green','绿色'],['blue','蓝色'],['yellow','黄色'],['purple','紫色'],['cyan','青色'],['white','白色']],'dark_mode.pi_pwr_led':[['off','关闭'],['on','不干预']],'dark_mode.pi_act_led':[['off','关闭'],['on','不干预']]};
|
const options={'led.normal_mode':[['off','全灭'],['solid','自定义颜色常亮'],['flash','自定义颜色闪烁'],['flow','流水灯'],['breathe','呼吸灯'],['marquee','跑马灯'],['rainbow','彩虹灯'],['colorful','炫彩灯']],'led.normal_effect_speed':[['1','慢速'],['2','中速'],['3','快速']],'led.normal_effect_color':[['red','红色'],['green','绿色'],['blue','蓝色'],['yellow','黄色'],['purple','紫色'],['cyan','青色'],['white','白色']],'dark_mode.pi_pwr_led':[['off','关闭'],['on','不干预']],'dark_mode.pi_act_led':[['off','关闭'],['on','不干预']]};
|
||||||
const numberRules={'led.normal_r':[0,255,1],'led.normal_g':[0,255,1],'led.normal_b':[0,255,1],'led.normal_flash_on_ms':[50,60000,10],'led.normal_flash_off_ms':[50,60000,10],'led.warning_flash_on_ms':[50,60000,10],'led.warning_flash_off_ms':[50,60000,10],'led.critical_flash_on_ms':[50,60000,10],'led.critical_flash_off_ms':[50,60000,10],'led.emergency_flash_on_ms':[50,60000,10],'led.emergency_flash_off_ms':[50,60000,10],'led.write_delay_ms':[1,1000,1],'alerts.cpu_high':[0,100,1],'alerts.cpu_recover':[0,100,1],'alerts.cpu_high_seconds':[1,86400,1],'alerts.memory_high':[0,100,1],'alerts.memory_recover':[0,100,1],'alerts.memory_high_seconds':[1,86400,1],'alerts.temp_high':[0,120,.1],'alerts.temp_critical':[0,120,.1],'alerts.disk_high':[0,100,1],'alerts.disk_critical':[0,100,1],'alerts.wifi_weak':[-120,0,1],'alerts.wifi_recover':[-120,0,1],'alerts.wifi_weak_seconds':[1,86400,1],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.1],'display.row1_y':[0,31,1],'display.row2_y':[0,31,1],'display.row3_y':[0,31,1],'display.row4_y':[0,31,1],'api.port':[1,65535,1],'api.log_limit':[1,1000,1]};['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>numberRules[`led.${g}_${c}`]=[0,255,1]));
|
const numberRules={'led.normal_r':[0,255,1],'led.normal_g':[0,255,1],'led.normal_b':[0,255,1],'led.normal_flash_on_ms':[50,60000,10],'led.normal_flash_off_ms':[50,60000,10],'led.warning_flash_on_ms':[50,60000,10],'led.warning_flash_off_ms':[50,60000,10],'led.critical_flash_on_ms':[50,60000,10],'led.critical_flash_off_ms':[50,60000,10],'led.emergency_flash_on_ms':[50,60000,10],'led.emergency_flash_off_ms':[50,60000,10],'led.write_delay_ms':[1,1000,1],'alerts.cpu_high':[0,100,1],'alerts.cpu_recover':[0,100,1],'alerts.cpu_high_seconds':[1,86400,1],'alerts.memory_high':[0,100,1],'alerts.memory_recover':[0,100,1],'alerts.memory_high_seconds':[1,86400,1],'alerts.temp_high':[0,120,.1],'alerts.temp_critical':[0,120,.1],'alerts.disk_high':[0,100,1],'alerts.disk_critical':[0,100,1],'alerts.wifi_weak':[-120,0,1],'alerts.wifi_recover':[-120,0,1],'alerts.wifi_weak_seconds':[1,86400,1],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.1],'display.row1_y':[0,31,1],'display.row2_y':[0,31,1],'display.row3_y':[0,31,1],'display.row4_y':[0,31,1],'api.port':[1,65535,1],'api.log_limit':[1,1000,1]};['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>numberRules[`led.${g}_${c}`]=[0,255,1]));
|
||||||
const units={'led.normal_flash_on_ms':'毫秒','led.normal_flash_off_ms':'毫秒','led.warning_flash_on_ms':'毫秒','led.warning_flash_off_ms':'毫秒','led.critical_flash_on_ms':'毫秒','led.critical_flash_off_ms':'毫秒','led.emergency_flash_on_ms':'毫秒','led.emergency_flash_off_ms':'毫秒','led.write_delay_ms':'毫秒','alerts.cpu_high':'%','alerts.cpu_recover':'%','alerts.cpu_high_seconds':'秒','alerts.memory_high':'%','alerts.memory_recover':'%','alerts.memory_high_seconds':'秒','alerts.temp_high':'°C','alerts.temp_critical':'°C','alerts.disk_high':'%','alerts.disk_critical':'%','alerts.wifi_weak':'dBm','alerts.wifi_recover':'dBm','alerts.wifi_weak_seconds':'秒','fan.start':'°C','fan.level2':'°C','fan.level3':'°C','fan.level4':'°C','fan.max':'°C','fan.hysteresis':'°C','timing.sensor_interval':'秒','timing.oled_refresh_interval':'秒','timing.page_interval':'秒','timing.network_interval':'秒','timing.service_interval':'秒','timing.health_interval':'秒','display.row1_y':'像素','display.row2_y':'像素','display.row3_y':'像素','display.row4_y':'像素','api.port':'端口','api.log_limit':'条'};
|
const units={'led.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=['led','alerts','fan','timing','display','dark_mode','services','processes','api'],alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']];
|
const sectionOrder=['device','hosts','led','alerts','fan','timing','display','dark_mode','services','processes','api'],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={START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NET_SWITCH:'网络接口切换',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复',POWER_HISTORY:'历史电源状态',HISTORY_FLAG:'历史电源状态',ROOT_READONLY:'根文件系统只读',RGB_ERROR:'RGB 控制错误',RGB_MODE:'RGB 模式切换',RGB_RECOVERED:'RGB 控制恢复',SYSTEM:'系统事件',WATCH_DOWN:'监控对象停止',WATCH_RECOVERED:'监控对象恢复'};
|
const ownerNames={NORMAL_HOME:'正常主页',ROOT_READONLY:'根文件系统只读',TEMP_CRITICAL:'处理器温度严重',POWER_UNDERVOLT:'供电欠压',THROTTLED:'处理器降频',FREQ_CAPPED:'频率受限',NETWORK_DOWN:'网络断开',INTERNET_DOWN:'互联网断开',SERVICE_DOWN:'服务停止',PROCESS_DOWN:'进程停止',DISK_CRITICAL:'磁盘严重不足',TEMP_HIGH:'处理器温度偏高',CPU_HIGH:'处理器负载过高',MEMORY_HIGH:'内存占用过高',DISK_HIGH:'磁盘空间不足',WIFI_WEAK:'Wi-Fi 信号弱'},rgbNames={OFF:'全灭',NORMAL_SOLID:'正常 · 自定义常亮',NORMAL_FLASH:'正常 · 自定义闪烁',NORMAL_FLOW:'正常 · 流水灯',NORMAL_BREATHE:'正常 · 呼吸灯',NORMAL_MARQUEE:'正常 · 跑马灯',NORMAL_RAINBOW:'正常 · 彩虹灯',NORMAL_COLORFUL:'正常 · 炫彩灯'},eventNames={START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NET_SWITCH:'网络接口切换',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复',POWER_HISTORY:'历史电源状态',HISTORY_FLAG:'历史电源状态',ROOT_READONLY:'根文件系统只读',RGB_ERROR:'RGB 控制错误',RGB_MODE:'RGB 模式切换',RGB_RECOVERED:'RGB 控制恢复',SYSTEM:'系统事件',WATCH_DOWN:'监控对象停止',WATCH_RECOVERED:'监控对象恢复'};
|
||||||
function ownerName(id){const [type,suffix]=String(id||'').split('|',2),name=ownerNames[type]||type;return tr(name)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return tr(rgbNames[mode]);const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${tr(groups[p[0]]||p[0])} · ${tr(levels[p[1]]||p[1])}${LANG==='zh'?'闪烁':' flash'}`}function eventName(id){return tr(eventNames[id]||(LANG==='zh'?`未知事件(${id})`:String(id).toLowerCase().replaceAll('_',' ')))}
|
function ownerName(id){const [type,suffix]=String(id||'').split('|',2),name=ownerNames[type]||type;return tr(name)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return tr(rgbNames[mode]);const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${tr(groups[p[0]]||p[0])} · ${tr(levels[p[1]]||p[1])}${LANG==='zh'?'闪烁':' flash'}`}function eventName(id){return tr(eventNames[id]||(LANG==='zh'?`未知事件(${id})`:String(id).toLowerCase().replaceAll('_',' ')))}
|
||||||
function field(section,key,value,extra=''){const id=`${section}.${key}`,opts=options[id],rule=numberRules[id],label=tr(labels[id]||key),help=tr(hints[id]||''),unit=tr(units[id]||'');let control;if(opts)control=`<select data-section="${esc(section)}" data-key="${esc(key)}">${opts.map(([v,l])=>`<option value="${v}" ${String(value)===v?'selected':''}>${tr(l)}</option>`).join('')}</select>`;else{const input=`<input ${rule?'type="number"':''} ${rule?`min="${rule[0]}" max="${rule[1]}" step="${rule[2]}"`:''} data-section="${esc(section)}" data-key="${esc(key)}" value="${esc(value)}">`;control=unit?`<div class="input-unit">${input}<span class="unit">${unit}</span></div>`:input}return`<div class="field ${extra}"><label>${esc(label)}${control}</label>${help?`<div class="hint">${esc(help)}</div>`:''}</div>`}
|
function field(section,key,value,extra=''){const id=`${section}.${key}`,opts=options[id],rule=numberRules[id],label=tr(labels[id]||key),help=tr(hints[id]||''),unit=tr(units[id]||'');let control;if(opts)control=`<select data-section="${esc(section)}" data-key="${esc(key)}">${opts.map(([v,l])=>`<option value="${v}" ${String(value)===v?'selected':''}>${tr(l)}</option>`).join('')}</select>`;else{const input=`<input ${rule?'type="number"':''} ${rule?`min="${rule[0]}" max="${rule[1]}" step="${rule[2]}"`:''} data-section="${esc(section)}" data-key="${esc(key)}" value="${esc(value)}">`;control=unit?`<div class="input-unit">${input}<span class="unit">${unit}</span></div>`:input}return`<div class="field ${extra}"><label>${esc(label)}${control}</label>${help?`<div class="hint">${esc(help)}</div>`:''}</div>`}
|
||||||
@@ -59,25 +59,38 @@ function normalGroup(v){return`<div class="group"><div class="group-head"><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 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 renderGeneral(s,v){const [t,d]=sectionMeta[s]||[s,''];return`<div class="group"><div class="group-head"><div><h3>${tr(t)}</h3><div class="hint">${tr(d)}</div></div></div><div class="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<div class="muted">暂无配置项</div>'}</div></div>`}
|
function renderGeneral(s,v){const [t,d]=sectionMeta[s]||[s,''];return`<div class="group"><div class="group-head"><div><h3>${tr(t)}</h3><div class="hint">${tr(d)}</div></div></div><div class="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<div class="muted">暂无配置项</div>'}</div></div>`}
|
||||||
function renderServices(){return`<div class="group"><div class="group-head"><div><h3>当前系统服务</h3><div class="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><button id="refreshServices">刷新列表</button></div><input class="service-search" id="serviceSearch" placeholder="搜索服务名称或说明"><div class="service-list rows" id="systemServices"><div class="muted">正在读取 systemd 服务…</div></div></div><div class="group"><h3>已监控服务</h3><div class="hint">“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。</div><div class="rows" id="monitoredServices"></div></div><div class="group"><h3 id="serviceFormTitle">新建服务监控</h3><div class="service-form"><label>OLED 显示名称<input id="monitorName" maxlength="64" placeholder="例如:DOCKER"></label><label>systemd 服务名称<input id="monitorTarget" maxlength="128" placeholder="例如:docker.service"></label><label class="full">备注说明<input id="monitorNote" maxlength="120" placeholder="例如:家庭相册容器服务"></label><div class="full"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div>`}
|
function renderServices(){return`<div class="group"><div class="group-head"><div><h3>当前系统服务</h3><div class="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><button id="refreshServices">刷新列表</button></div><input class="service-search" id="serviceSearch" placeholder="搜索服务名称或说明"><div class="service-list rows" id="systemServices"><div class="muted">正在读取 systemd 服务…</div></div></div><div class="group"><h3>已监控服务</h3><div class="hint">“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。</div><div class="rows" id="monitoredServices"></div></div><div class="group"><h3 id="serviceFormTitle">新建服务监控</h3><div class="service-form"><label>OLED 显示名称<input id="monitorName" maxlength="64" placeholder="例如:DOCKER"></label><label>systemd 服务名称<input id="monitorTarget" maxlength="128" placeholder="例如:docker.service"></label><label class="full">备注说明<input id="monitorNote" maxlength="120" placeholder="例如:家庭相册容器服务"></label><div class="full"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div>`}
|
||||||
|
function renderHosts(v){const entries=Object.entries(v||{});return`<div class="group"><h3>已登记机器</h3><div class="rows">${entries.map(([id,url])=>`<div class="row"><span><b>${esc(id)}</b><br><span class="muted">${esc(url)}</span></span><button class="danger" data-delete-host="${esc(id)}">删除</button></div>`).join('')||'<div class="muted">尚未登记其他机器</div>'}</div></div><div class="group"><h3>添加或更新机器</h3><div class="service-form"><label>机器标识<input id="hostId" maxlength="64" placeholder="例如:living-room"></label><label>节点地址<input id="hostUrl" maxlength="200" placeholder="例如:http://192.168.1.20:6001"></label><div class="full"><button id="saveHost">保存机器</button> <span id="hostMessage" class="muted"></span></div></div></div>`}
|
||||||
function updateConditional(){const mode=document.querySelector('[data-section="led"][data-key="normal_mode"]')?.value||'off';document.querySelectorAll('.normal-field').forEach(x=>x.classList.toggle('hidden',!x.dataset.modes.split(' ').includes(mode)));updateSwatches()}function updateSwatches(){document.querySelectorAll('[data-swatch]').forEach(s=>{const p=s.dataset.swatch,read=c=>Math.max(0,Math.min(255,Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0))),hex='#'+['r','g','b'].map(c=>Math.round(read(c)).toString(16).padStart(2,'0')).join('');s.style.background=hex;const picker=document.querySelector(`[data-color-prefix="${p}"]`);if(picker)picker.value=hex})}
|
function updateConditional(){const 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]);$('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==='led'?renderLed(original[s]):s==='services'?renderServices():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));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);updateConditional()}
|
function renderConfig(){const ss=sectionOrder.filter(s=>original[s]);$('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==='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));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));updateConditional()}
|
||||||
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?(LANG==='zh'?`有 ${n} 项尚未保存`:`${n} unsaved changes`):tr('尚未修改');$('saveState').className=n?'':'muted'}
|
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?(LANG==='zh'?`有 ${n} 项尚未保存`:`${n} unsaved changes`):tr('尚未修改');$('saveState').className=n?'':'muted'}
|
||||||
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(LANG==='zh'?`服务器返回异常 (${r.status})`:`Invalid server response (${r.status})`)}if(!r.ok)throw Error(j.error||(LANG==='zh'?`请求失败 (${r.status})`:`Request failed (${r.status})`));return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
|
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(LANG==='zh'?`服务器返回异常 (${r.status})`:`Invalid server response (${r.status})`)}if(!r.ok)throw Error(j.error||(LANG==='zh'?`请求失败 (${r.status})`:`Request failed (${r.status})`));return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
|
||||||
let serviceCache=[],editingServiceName='';function tokenHeaders(){const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);return{'Content-Type':'application/json','Authorization':`Bearer ${token}`}}
|
let serviceCache=[],editingServiceName='',serviceReadOnly=false;function tokenHeaders(){const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);return{'Content-Type':'application/json','Authorization':`Bearer ${token}`}}
|
||||||
async function loadServices(){if(!$('systemServices'))return;try{const d=await get('/api/v1/services');serviceCache=d.services;renderServiceLists()}catch(e){$('systemServices').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
|
async function loadServices(){if(!$('systemServices'))return;try{serviceReadOnly=!fleetHosts.find(x=>x.id===currentConfigHost)?.local;const d=await get(`/api/v1/services?host=${encodeURIComponent(currentConfigHost)}`);serviceCache=d.services;renderServiceLists()}catch(e){$('systemServices').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
|
||||||
function renderServiceLists(){if(!$('systemServices'))return;const q=$('serviceSearch').value.trim().toLowerCase(),filtered=serviceCache.filter(s=>!q||`${s.unit} ${s.description} ${s.note}`.toLowerCase().includes(q));rows('systemServices',filtered.map(s=>`<div class="row"><span><b>${esc(s.unit)}</b><br><span class="muted">${esc(s.description||'暂无服务说明')} · ${esc(s.active==='active'?'正在运行':'未运行')}</span></span><span class="row-actions"><span class="badge ${s.monitored&&!s.paused?'good':''}">${s.monitored?(s.paused?'已暂停':'已监控'):'未监控'}</span><button data-select-service="${esc(s.unit)}">${s.monitored?'编辑监控':'加入监控'}</button></span></div>`),'没有匹配的服务');const monitored=serviceCache.filter(s=>s.monitored);rows('monitoredServices',monitored.map(s=>`<div class="row"><span><b>${esc(s.monitor_name)}</b> <span class="badge ${s.paused?'':s.active==='active'?'good':'bad'}">${s.paused?'监控已暂停':s.active==='active'?'运行中':'服务已停止'}</span><br><span class="muted">${esc(s.unit)}</span><br><span class="muted">备注:${esc(s.note||'未填写')}</span></span><span class="row-actions"><button data-monitor-action="${s.paused?'resume':'pause'}" data-unit="${esc(s.unit)}">${s.paused?'恢复监控':'暂停监控'}</button><button data-service-action="${s.active==='active'?'stop':'start'}" data-unit="${esc(s.unit)}" ${s.protected&&s.active==='active'?'disabled title="API 服务不能停止自身"':''}>${s.active==='active'?'停止服务':'启动服务'}</button><button data-select-service="${esc(s.unit)}">编辑</button><button class="danger" data-delete-monitor="${esc(s.unit)}">删除监控</button></span></div>`),'尚未监控任何服务');document.querySelectorAll('[data-select-service]').forEach(b=>b.onclick=()=>selectService(b.dataset.selectService));document.querySelectorAll('[data-monitor-action]').forEach(b=>b.onclick=()=>monitorAction(b.dataset.unit,b.dataset.monitorAction));document.querySelectorAll('[data-service-action]').forEach(b=>b.onclick=()=>serviceAction(b.dataset.unit,b.dataset.serviceAction));document.querySelectorAll('[data-delete-monitor]').forEach(b=>b.onclick=()=>deleteMonitor(b.dataset.deleteMonitor))}
|
function renderServiceLists(){if(!$('systemServices'))return;const q=$('serviceSearch').value.trim().toLowerCase(),filtered=serviceCache.filter(s=>!q||`${s.unit} ${s.description} ${s.note}`.toLowerCase().includes(q));rows('systemServices',filtered.map(s=>`<div class="row"><span><b>${esc(s.unit)}</b><br><span class="muted">${esc(s.description||'暂无服务说明')} · ${esc(s.active==='active'?'正在运行':'未运行')}</span></span><span class="row-actions"><span class="badge ${s.monitored&&!s.paused?'good':''}">${s.monitored?(s.paused?'已暂停':'已监控'):'未监控'}</span><button data-select-service="${esc(s.unit)}">${s.monitored?'编辑监控':'加入监控'}</button></span></div>`),'没有匹配的服务');const monitored=serviceCache.filter(s=>s.monitored);rows('monitoredServices',monitored.map(s=>`<div class="row"><span><b>${esc(s.monitor_name)}</b> <span class="badge ${s.paused?'':s.active==='active'?'good':'bad'}">${s.paused?'监控已暂停':s.active==='active'?'运行中':'服务已停止'}</span><br><span class="muted">${esc(s.unit)}</span><br><span class="muted">备注:${esc(s.note||'未填写')}</span></span><span class="row-actions"><button data-monitor-action="${s.paused?'resume':'pause'}" data-unit="${esc(s.unit)}">${s.paused?'恢复监控':'暂停监控'}</button><button data-service-action="${s.active==='active'?'stop':'start'}" data-unit="${esc(s.unit)}" ${s.protected&&s.active==='active'?'disabled title="API 服务不能停止自身"':''}>${s.active==='active'?'停止服务':'启动服务'}</button><button data-select-service="${esc(s.unit)}">编辑</button><button class="danger" data-delete-monitor="${esc(s.unit)}">删除监控</button></span></div>`),'尚未监控任何服务');if(serviceReadOnly)document.querySelectorAll('#configRows button').forEach(b=>b.disabled=true);document.querySelectorAll('[data-select-service]').forEach(b=>b.onclick=()=>selectService(b.dataset.selectService));document.querySelectorAll('[data-monitor-action]').forEach(b=>b.onclick=()=>monitorAction(b.dataset.unit,b.dataset.monitorAction));document.querySelectorAll('[data-service-action]').forEach(b=>b.onclick=()=>serviceAction(b.dataset.unit,b.dataset.serviceAction));document.querySelectorAll('[data-delete-monitor]').forEach(b=>b.onclick=()=>deleteMonitor(b.dataset.deleteMonitor))}
|
||||||
function selectService(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s)return;editingServiceName=s.monitor_name||'';$('monitorTarget').value=s.unit;$('monitorName').value=s.monitor_name||s.unit.replace(/\.service$/,'').replace(/[^A-Za-z0-9_.-]/g,'_').slice(0,16).toUpperCase();$('monitorNote').value=s.note||s.description||'';$('serviceFormTitle').textContent=s.monitored?'编辑服务监控':'新建服务监控';$('serviceMessage').textContent='已自动填写,可继续修改。';$('monitorName').scrollIntoView({behavior:'smooth',block:'center'})}
|
function selectService(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s)return;editingServiceName=s.monitor_name||'';$('monitorTarget').value=s.unit;$('monitorName').value=s.monitor_name||s.unit.replace(/\.service$/,'').replace(/[^A-Za-z0-9_.-]/g,'_').slice(0,16).toUpperCase();$('monitorNote').value=s.note||s.description||'';$('serviceFormTitle').textContent=s.monitored?'编辑服务监控':'新建服务监控';$('serviceMessage').textContent='已自动填写,可继续修改。';$('monitorName').scrollIntoView({behavior:'smooth',block:'center'})}
|
||||||
function resetMonitorForm(){editingServiceName='';$('monitorName').value='';$('monitorTarget').value='';$('monitorNote').value='';$('serviceFormTitle').textContent='新建服务监控';$('serviceMessage').textContent='';$('serviceMessage').className='muted'}
|
function resetMonitorForm(){editingServiceName='';$('monitorName').value='';$('monitorTarget').value='';$('monitorNote').value='';$('serviceFormTitle').textContent='新建服务监控';$('serviceMessage').textContent='';$('serviceMessage').className='muted'}
|
||||||
async function saveMonitor(){const body={operation:'save',name:$('monitorName').value.trim(),target:$('monitorTarget').value.trim(),note:$('monitorNote').value.trim(),previous_name:editingServiceName};try{$('serviceMessage').textContent='正在保存…';await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify(body)});resetMonitorForm();await loadServices();$('serviceMessage').textContent='保存成功';$('serviceMessage').className='success'}catch(e){$('serviceMessage').textContent=e.message;$('serviceMessage').className='error'}}
|
async function saveMonitor(){const body={operation:'save',name:$('monitorName').value.trim(),target:$('monitorTarget').value.trim(),note:$('monitorNote').value.trim(),previous_name:editingServiceName};try{$('serviceMessage').textContent='正在保存…';await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify(body)});resetMonitorForm();await loadServices();$('serviceMessage').textContent='保存成功';$('serviceMessage').className='success'}catch(e){$('serviceMessage').textContent=e.message;$('serviceMessage').className='error'}}
|
||||||
async function deleteMonitor(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s||!confirm(LANG==='zh'?`确定删除 ${s.monitor_name} 的监控吗?\n不会删除或停止 ${unit}。`:`Remove monitoring for ${s.monitor_name}?\nThis will not remove or stop ${unit}.`))return;try{await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({operation:'delete',name:s.monitor_name,target:s.unit,note:''})});await loadConfig();document.querySelector('[data-config-section="services"]')?.click()}catch(e){alert(e.message)}}
|
async function deleteMonitor(unit){const s=serviceCache.find(x=>x.unit===unit);if(!s||!confirm(LANG==='zh'?`确定删除 ${s.monitor_name} 的监控吗?\n不会删除或停止 ${unit}。`:`Remove monitoring for ${s.monitor_name}?\nThis will not remove or stop ${unit}.`))return;try{await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({operation:'delete',name:s.monitor_name,target:s.unit,note:''})});await loadConfig();document.querySelector('[data-config-section="services"]')?.click()}catch(e){alert(e.message)}}
|
||||||
async function monitorAction(unit,action){const s=serviceCache.find(x=>x.unit===unit);if(!s)return;try{await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({operation:action,name:s.monitor_name,target:s.unit,note:s.note||''})});await loadServices()}catch(e){alert(e.message)}}
|
async function monitorAction(unit,action){const s=serviceCache.find(x=>x.unit===unit);if(!s)return;try{await get('/api/v1/services',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({operation:action,name:s.monitor_name,target:s.unit,note:s.note||''})});await loadServices()}catch(e){alert(e.message)}}
|
||||||
async function serviceAction(unit,action){if(!confirm(LANG==='zh'?`确定${action==='start'?'启动服务':'停止服务'} ${unit} 吗?\n这会直接执行 systemctl ${action}。`:`${action==='start'?'Start':'Stop'} ${unit}?\nThis runs systemctl ${action} directly.`))return;try{await get('/api/v1/services/control',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({unit,action})});setTimeout(loadServices,700)}catch(e){alert(e.message)}}
|
async function serviceAction(unit,action){if(!confirm(LANG==='zh'?`确定${action==='start'?'启动服务':'停止服务'} ${unit} 吗?\n这会直接执行 systemctl ${action}。`:`${action==='start'?'Start':'Stop'} ${unit}?\nThis runs systemctl ${action} directly.`))return;try{await get('/api/v1/services/control',{method:'PUT',headers:tokenHeaders(),body:JSON.stringify({unit,action})});setTimeout(loadServices,700)}catch(e){alert(e.message)}}
|
||||||
async function loadStatus(){try{const s=await get('/api/v1/status'),y=s.system,n=s.network,d=s.display;$('dot').classList.add('ok');$('liveText').textContent=tr('在线');$('identity').textContent=LANG==='zh'?`版本 ${s.version} · 已运行 ${Math.floor(s.uptime_seconds/3600)} 小时 · ${new Date(s.timestamp*1000).toLocaleTimeString()}`:`Version ${s.version} · Up ${Math.floor(s.uptime_seconds/3600)} hours · ${new Date(s.timestamp*1000).toLocaleTimeString()}`;$('cpu').textContent=`${y.cpu_percent}%`;$('freq').textContent=LANG==='zh'?`频率 ${y.cpu_freq_mhz} MHz`:`Frequency ${y.cpu_freq_mhz} MHz`;$('temp').textContent=`${y.temperature_c}°C`;const fanZh=({OFF:'关闭',L1:'一档',L2:'二档',L3:'三档',L4:'四档',MAX:'全速'})[y.fan_name]||y.fan_name,fanEn=({OFF:'Off',L1:'Level 1',L2:'Level 2',L3:'Level 3',L4:'Level 4',MAX:'Full speed'})[y.fan_name]||y.fan_name;$('fan').textContent=LANG==='zh'?`风扇 ${fanZh}`:`Fan ${fanEn}`;$('memory').textContent=`${y.memory_percent}%`;$('disk').textContent=`${y.disk_percent}%`;$('network').textContent=`${tr(n.kind==='WIF'?'无线网络':n.kind==='ETH'?'有线网络':n.kind)} ${n.metric}`;$('address').textContent=LANG==='zh'?`${n.interface} · ${n.ip_label==='IP4'?'IPv4':'IPv6'} ${n.ip} · 互联网${n.internet?'正常':'断开'}`:`${n.interface} · ${n.ip_label==='IP4'?'IPv4':'IPv6'} ${n.ip} · Internet ${n.internet?'online':'down'}`;$('owner').textContent=ownerName(d.owner);$('rgb').textContent=`OLED: ${ownerName(d.oled_page)} · RGB: ${rgbName(d.rgb_mode)}`;rows('alerts',d.alerts.map(a=>`<div class="row"><span><b>${esc(ownerName(a.id))}</b><br><span class="muted">${esc(a.l2)} ${esc(a.l3)}</span></span><span class="badge bad">${LANG==='zh'?`等级 ${a.severity} · 优先级 ${a.priority}`:`Severity ${a.severity} · Priority ${a.priority}`}</span></div>`),tr('当前没有告警'));rows('watches',s.watches.map(w=>`<div class="row"><span><b>${esc(w.name)}</b><br><span class="muted">${tr(w.type==='service'?'系统服务':'普通进程')} · ${esc(w.target)}</span></span><span class="badge ${w.up?'good':'bad'}">${tr(w.up?'正常':'停止')}</span></div>`))}catch(e){$('dot').classList.remove('ok');$('liveText').textContent=tr('离线');$('identity').textContent=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>`}
|
||||||
|
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()}`;$('hostCards').innerHTML=fleet.hosts.map(hostCard).join('');fleetHosts=fleet.hosts.map(x=>x.host);refreshHostSelectors()}catch(e){$('dot').classList.remove('ok');$('liveText').textContent=tr('离线');$('identity').textContent=e.message}}
|
||||||
const severityNames={DEBUG:'调试',INFO:'信息',NOTICE:'注意',WARN:'警告',ERROR:'错误',CRITICAL:'严重',ALERT:'紧急',EMERGENCY:'紧急'};let logState={sort:'timestamp',order:'desc',offset:0,limit:100,total:0};
|
const severityNames={DEBUG:'调试',INFO:'信息',NOTICE:'注意',WARN:'警告',ERROR:'错误',CRITICAL:'严重',ALERT:'紧急',EMERGENCY:'紧急'};let logState={sort:'timestamp',order:'desc',offset:0,limit:100,total:0};
|
||||||
function localDateValue(d){return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}
|
function localDateValue(d){return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}
|
||||||
function setLogRange(range){const now=new Date(),today=new Date(now.getFullYear(),now.getMonth(),now.getDate()),start=new Date(today),end=new Date(today);if(range==='all'){$('logStart').value='';$('logEnd').value='';return}if(range==='yesterday'){start.setDate(start.getDate()-1);end.setDate(end.getDate()-1)}else if(range==='week'){start.setDate(start.getDate()-((start.getDay()+6)%7))}else if(range==='month'){start.setDate(1)}$('logStart').value=localDateValue(start);$('logEnd').value=localDateValue(end)}
|
function setLogRange(range){const now=new Date(),today=new Date(now.getFullYear(),now.getMonth(),now.getDate()),start=new Date(today),end=new Date(today);if(range==='all'){$('logStart').value='';$('logEnd').value='';return}if(range==='yesterday'){start.setDate(start.getDate()-1);end.setDate(end.getDate()-1)}else if(range==='week'){start.setDate(start.getDate()-((start.getDay()+6)%7))}else if(range==='month'){start.setDate(1)}$('logStart').value=localDateValue(start);$('logEnd').value=localDateValue(end)}
|
||||||
function logTimestamp(value,end=false){if(!value)return'';const d=new Date(`${value}T00:00:00`);if(end)d.setDate(d.getDate()+1);return Math.floor(d.getTime()/1000)-(end?1:0)}
|
function logTimestamp(value,end=false){if(!value)return'';const d=new Date(`${value}T00:00:00`);if(end)d.setDate(d.getDate()+1);return Math.floor(d.getTime()/1000)-(end?1:0)}
|
||||||
async function loadLogs(reset=true){if(reset)logState.offset=0;try{const p=new URLSearchParams({limit:logState.limit,offset:logState.offset,sort:logState.sort,order:logState.order}),since=logTimestamp($('logStart').value),until=logTimestamp($('logEnd').value,true),severity=$('logSeverity').value,event=$('logEvent').value,search=$('logSearch').value.trim();if(since)p.set('since',since);if(until)p.set('until',until);if(severity)p.set('severity',severity);if(event)p.set('event',event);if(search)p.set('search',search);const d=await get(`/api/v1/logs?${p}`);logState.total=d.total;const selected=$('logEvent').value;$('logEvent').innerHTML=`<option value="">${tr('全部事件')}</option>`+d.event_types.map(x=>`<option value="${esc(x)}" ${x===selected?'selected':''}>${esc(eventName(x))}</option>`).join('');$('logRows').innerHTML=d.logs.length?d.logs.map(x=>`<tr><td>${esc(new Date(x.timestamp/1000).toLocaleString())}</td><td><span class="badge ${['ERROR','CRITICAL','ALERT','EMERGENCY'].includes(x.severity)?'bad':''}">${esc(tr(severityNames[x.severity]||x.severity))}</span></td><td>${esc(eventName(x.event))}</td><td class="log-message">${esc(x.message)}</td></tr>`).join(''):`<tr><td colspan="4" class="muted">${tr('没有符合条件的日志')}</td></tr>`;$('logSummary').textContent=LANG==='zh'?`共 ${d.total} 条 · 数据来源:systemd journal`:`${d.total} events · Source: systemd journal`;$('logPage').textContent=d.total?`${d.offset+1}–${Math.min(d.offset+d.limit,d.total)} / ${d.total}`:(LANG==='zh'?'0 条':'0 events');$('logPrev').disabled=d.offset===0;$('logNext').disabled=d.offset+d.limit>=d.total;document.querySelectorAll('[data-log-sort]').forEach(b=>{const name=b.textContent.replace(/[ ↑↓]$/,'');b.textContent=name+(b.dataset.logSort===logState.sort?(logState.order==='asc'?' ↑':' ↓'):'')})}catch(e){$('logRows').innerHTML=`<tr><td colspan="4" class="error">${esc(e.message)}</td></tr>`}}
|
async function loadLogs(reset=true){if(reset)logState.offset=0;try{const p=new URLSearchParams({limit:logState.limit,offset:logState.offset,sort:logState.sort,order:logState.order}),since=logTimestamp($('logStart').value),until=logTimestamp($('logEnd').value,true),severity=$('logSeverity').value,event=$('logEvent').value,machine=$('logMachine').value,search=$('logSearch').value.trim();if(since)p.set('since',since);if(until)p.set('until',until);if(severity)p.set('severity',severity);if(event)p.set('event',event);if(machine)p.set('machine',machine);if(search)p.set('search',search);const d=await get(`/api/v1/fleet/logs?${p}`);logState.total=d.total;const selectedEvent=$('logEvent').value,selectedMachine=$('logMachine').value;$('logEvent').innerHTML=`<option value="">${tr('全部事件')}</option>`+d.event_types.map(x=>`<option value="${esc(x)}" ${x===selectedEvent?'selected':''}>${esc(eventName(x))}</option>`).join('');$('logMachine').innerHTML=`<option value="">${LANG==='zh'?'全部机器':'All hosts'}</option>`+d.machines.map(x=>`<option value="${esc(x.id)}" ${x.id===selectedMachine?'selected':''}>${esc(x.name)} · ${esc(x.id)}</option>`).join('');$('logRows').innerHTML=d.logs.length?d.logs.map(x=>`<tr><td>${esc(new Date(x.timestamp/1000).toLocaleString())}</td><td>${esc(x.machine_name||x.machine_id)}</td><td><span class="badge ${['ERROR','CRITICAL','ALERT','EMERGENCY'].includes(x.severity)?'bad':''}">${esc(tr(severityNames[x.severity]||x.severity))}</span></td><td>${esc(eventName(x.event))}</td><td class="log-message">${esc(x.message)}</td></tr>`).join(''):`<tr><td colspan="5" class="muted">${tr('没有符合条件的日志')}</td></tr>`;$('logSummary').textContent=LANG==='zh'?`共 ${d.total} 条 · 数据来源:各机器 systemd journal`:`${d.total} events · Source: each host's systemd journal`;$('logPage').textContent=d.total?`${d.offset+1}–${Math.min(d.offset+d.limit,d.total)} / ${d.total}`:(LANG==='zh'?'0 条':'0 events');$('logPrev').disabled=d.offset===0;$('logNext').disabled=d.offset+d.limit>=d.total;document.querySelectorAll('[data-log-sort]').forEach(b=>{const names={timestamp:LANG==='zh'?'时间':'Time',machine:LANG==='zh'?'机器':'Host',severity:LANG==='zh'?'程度':'Severity',event:LANG==='zh'?'事件类型':'Event type',message:LANG==='zh'?'内容':'Message'},name=names[b.dataset.logSort];b.textContent=name+(b.dataset.logSort===logState.sort?(logState.order==='asc'?' ↑':' ↓'):'')})}catch(e){$('logRows').innerHTML=`<tr><td colspan="5" class="error">${esc(e.message)}</td></tr>`}}
|
||||||
async function loadConfig(){try{const d=await get('/api/v1/config');original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
|
function hostToken(id){return localStorage.getItem(`pigwayToken:${id}`)||localStorage.getItem('pigwayToken')||''}
|
||||||
document.querySelectorAll('[data-log-range]').forEach(b=>b.onclick=()=>{setLogRange(b.dataset.logRange);loadLogs()});document.querySelectorAll('[data-log-sort]').forEach(b=>b.onclick=()=>{const s=b.dataset.logSort;if(logState.sort===s)logState.order=logState.order==='asc'?'desc':'asc';else{logState.sort=s;logState.order=s==='timestamp'?'desc':'asc'}loadLogs()});$('refreshLogs').onclick=()=>loadLogs();$('resetLogs').onclick=()=>{setLogRange('today');$('logSeverity').value='';$('logEvent').value='';$('logSearch').value='';logState.sort='timestamp';logState.order='desc';loadLogs()};$('logPrev').onclick=()=>{logState.offset=Math.max(0,logState.offset-logState.limit);loadLogs(false)};$('logNext').onclick=()=>{if(logState.offset+logState.limit<logState.total){logState.offset+=logState.limit;loadLogs(false)}};const language=$('language'),theme=$('theme'),themeMedia=matchMedia('(prefers-color-scheme: dark)');language.value=LANG;theme.value=localStorage.getItem('pigwayTheme')||'auto';applyTheme(theme.value);language.onchange=()=>{localStorage.setItem('pigwayLanguage',language.value);location.reload()};theme.onchange=()=>{localStorage.setItem('pigwayTheme',theme.value);applyTheme(theme.value)};themeMedia.addEventListener?.('change',()=>{if(theme.value==='auto')applyTheme('auto')});translateNode();new MutationObserver(ms=>ms.forEach(m=>m.addedNodes.forEach(n=>{if(n.nodeType===Node.ELEMENT_NODE)translateNode(n);else if(n.nodeType===Node.TEXT_NODE&&EN[n.nodeValue.trim()])n.nodeValue=n.nodeValue.replace(n.nodeValue.trim(),EN[n.nodeValue.trim()])}))).observe(document.body,{childList:true,subtree:true});document.documentElement.lang=LANG==='zh'?'zh-CN':'en';setLogRange('today');$('token').value=localStorage.getItem('pigwayToken')||'';$('toggleToken').onclick=()=>{const i=$('token'),show=i.type==='password';i.type=show?'text':'password';$('toggleToken').textContent=tr(show?'隐藏令牌':'显示令牌')};$('saveConfig').onclick=async()=>{const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);const updates={};document.querySelectorAll('#configRows [data-section]').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=tr('没有需要保存的修改');return}try{$('saveConfig').disabled=true;$('saveState').textContent=tr('正在保存并应用…');const d=await get('/api/v1/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({updates})});$('saveState').textContent=LANG==='zh'?`保存成功,已创建备份 ${d.backup}`:`Saved; backup created at ${d.backup}`;$('saveState').className='success';setTimeout(loadConfig,1500)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}finally{$('saveConfig').disabled=false}};loadStatus();setInterval(loadStatus,3000);
|
function saveHostToken(id,value){localStorage.setItem(`pigwayToken:${id}`,value);if(fleetHosts.find(x=>x.id===id)?.local)localStorage.setItem('pigwayToken',value)}
|
||||||
|
function refreshHostSelectors(){if(!fleetHosts.length)return;const current=$('configHost').value||currentConfigHost||fleetHosts[0].id,checked=new Set([...document.querySelectorAll('#syncHosts input:checked')].map(x=>x.value));$('configHost').innerHTML=fleetHosts.map(h=>`<option value="${esc(h.id)}" ${h.id===current?'selected':''}>${esc(h.name||h.id)} · ${esc(h.id)}</option>`).join('');currentConfigHost=$('configHost').value;$('syncHosts').innerHTML=fleetHosts.map(h=>`<label><input type="checkbox" value="${esc(h.id)}" ${checked.has(h.id)?'checked':''}>${esc(h.name||h.id)}</label>`).join('')}
|
||||||
|
function changedUpdates(){const updates={};document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(original[i.dataset.section]&&i.value!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.value}});return updates}
|
||||||
|
async function ensureHosts(){if(fleetHosts.length)return;const d=await get('/api/v1/hosts');fleetHosts=d.hosts;refreshHostSelectors()}
|
||||||
|
async function loadConfig(){try{await ensureHosts();currentConfigHost=$('configHost').value||currentConfigHost||fleetHosts[0].id;$('token').value=hostToken(currentConfigHost);const d=await get(`/api/v1/config?host=${encodeURIComponent(currentConfigHost)}`);original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
|
||||||
|
async function saveHost(){const id=$('hostId').value.trim(),url=$('hostUrl').value.trim(),message=$('hostMessage');try{message.textContent='正在保存…';const local=fleetHosts.find(x=>x.local),token=hostToken(local?.id||'');const d=await get('/api/v1/hosts',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({operation:'save',id,url})});fleetHosts=d.hosts;refreshHostSelectors();await loadConfig();message.textContent='保存成功'}catch(e){message.textContent=e.message;message.className='error'}}
|
||||||
|
async function deleteHost(id){if(!confirm(LANG==='zh'?`确定删除机器 ${id} 吗?`:`Delete host ${id}?`))return;try{const local=fleetHosts.find(x=>x.local),token=hostToken(local?.id||'');const d=await get('/api/v1/hosts',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({operation:'delete',id})});fleetHosts=d.hosts;currentConfigHost=local?.id||'';refreshHostSelectors();await loadConfig()}catch(e){alert(e.message)}}
|
||||||
|
async function applyConfig(targets){const updates=changedUpdates();if(targets.length>1){delete updates.device;delete updates.hosts;}if(!Object.keys(updates).length){$('saveState').textContent=tr('没有需要保存的修改');return}const local=fleetHosts.find(x=>x.local),tokens=Object.fromEntries(targets.map(id=>[id,hostToken(id)]));try{$('saveConfig').disabled=true;$('syncConfig').disabled=true;$('saveState').textContent=tr('正在保存并应用…');saveHostToken(currentConfigHost,$('token').value.trim());tokens[currentConfigHost]=$('token').value.trim();const d=await get('/api/v1/fleet/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${hostToken(local?.id||'')}`},body:JSON.stringify({targets,updates,tokens})});const failed=d.results.filter(x=>!x.ok);$('saveState').textContent=failed.length?(LANG==='zh'?`部分失败:${failed.map(x=>`${x.id}: ${x.error}`).join(';')}`:`Partial failure: ${failed.map(x=>`${x.id}: ${x.error}`).join('; ')}`):(LANG==='zh'?`已同步到 ${d.results.length} 台机器`:`Synced to ${d.results.length} hosts`);$('saveState').className=failed.length?'error':'success';if(!failed.length)setTimeout(loadConfig,1200)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}finally{$('saveConfig').disabled=false;$('syncConfig').disabled=false}}
|
||||||
|
document.querySelectorAll('[data-log-range]').forEach(b=>b.onclick=()=>{setLogRange(b.dataset.logRange);loadLogs()});document.querySelectorAll('[data-log-sort]').forEach(b=>b.onclick=()=>{const value=b.dataset.logSort;if(logState.sort===value)logState.order=logState.order==='asc'?'desc':'asc';else{logState.sort=value;logState.order=value==='timestamp'?'desc':'asc'}loadLogs()});$('refreshLogs').onclick=()=>loadLogs();$('resetLogs').onclick=()=>{setLogRange('today');$('logSeverity').value='';$('logMachine').value='';$('logEvent').value='';$('logSearch').value='';logState.sort='timestamp';logState.order='desc';loadLogs()};$('logPrev').onclick=()=>{logState.offset=Math.max(0,logState.offset-logState.limit);loadLogs(false)};$('logNext').onclick=()=>{if(logState.offset+logState.limit<logState.total){logState.offset+=logState.limit;loadLogs(false)}};
|
||||||
|
const language=$('language'),theme=$('theme'),themeMedia=matchMedia('(prefers-color-scheme: dark)');language.value=LANG;theme.value=localStorage.getItem('pigwayTheme')||'auto';applyTheme(theme.value);language.onchange=()=>{LANG=language.value;localStorage.setItem('pigwayLanguage',LANG);location.reload()};theme.onchange=()=>{localStorage.setItem('pigwayTheme',theme.value);applyTheme(theme.value)};themeMedia.addEventListener?.('change',()=>{if(theme.value==='auto')applyTheme('auto')});translateNode();new MutationObserver(ms=>ms.forEach(m=>m.addedNodes.forEach(n=>{if(n.nodeType===Node.ELEMENT_NODE)translateNode(n);else if(n.nodeType===Node.TEXT_NODE){const map=LANG==='en'?EN:ZH,trim=n.nodeValue.trim();if(map[trim])n.nodeValue=n.nodeValue.replace(trim,map[trim])}}))).observe(document.body,{childList:true,subtree:true});document.documentElement.lang=LANG==='zh'?'zh-CN':'en';setLogRange('today');$('toggleToken').onclick=()=>{const i=$('token'),show=i.type==='password';i.type=show?'text':'password';$('toggleToken').textContent=tr(show?'隐藏令牌':'显示令牌')};$('token').onchange=()=>saveHostToken(currentConfigHost,$('token').value.trim());$('configHost').onchange=()=>{currentConfigHost=$('configHost').value;$('token').value=hostToken(currentConfigHost);loadConfig()};$('saveConfig').onclick=()=>applyConfig([currentConfigHost]);$('syncConfig').onclick=()=>{const targets=[...document.querySelectorAll('#syncHosts input:checked')].map(x=>x.value);if(!targets.length){$('saveState').textContent=LANG==='zh'?'请先选择同步目标机器':'Select target hosts first';return}applyConfig(targets)};loadStatus();setInterval(loadStatus,3000);
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
|
|||||||
Reference in New Issue
Block a user