Compare commits

..
Author SHA1 Message Date
way 5d744824a8 feat: add bilingual interface and appearance modes 2026-09-27 13:59:11 +08:00
way b08721fd15 Merge fix/monitor-pause-rgb-edges 2026-09-27 13:49:10 +08:00
way cd9bdd5970 fix: separate monitor pause and stabilize RGB edges 2026-09-27 13:49:09 +08:00
way 08c00aa370 Merge feature/log-query 2026-09-27 13:42:59 +08:00
way 13f6155096 feat: add searchable event log history 2026-09-27 13:42:58 +08:00
way 97598a48ad Merge fix/service-controls-layout 2026-09-27 13:29:12 +08:00
way 01c07658c0 fix: refine service monitor controls 2026-09-27 13:29:11 +08:00
way 4ba4d461db Merge fix/web-rgb-alignment 2026-09-27 13:24:07 +08:00
way 45c7b8c168 fix: align RGB color controls 2026-09-27 13:24:06 +08:00
way b39c7e5279 Merge feature/web-controls 2026-09-27 11:20:46 +08:00
6 changed files with 137 additions and 46 deletions
+7 -1
View File
@@ -165,6 +165,10 @@ GET /api/v1/config
GET /api/v1/services GET /api/v1/services
``` ```
日志接口支持 `since`、`until` Unix 时间戳,`severity`、`event`、`search` 筛选,`timestamp` / `severity` / `event` / `message` 排序,以及 `limit` / `offset` 分页。Web 提供今天、昨天、本周、本月和全部快捷范围。日志直接读取 systemd journal 的现有保留数据,不建立重复的 SQLite 日志副本。
Web 界面支持中文和 English 切换,并将结构化 journal 事件类型显示为当前界面的语言;journal 内部稳定的事件标识保持不变,便于脚本筛选。外观可选择自动、深色或浅色,自动模式跟随浏览器/操作系统配色。语言和外观偏好只保存在当前浏览器中。
配置写入接口: 配置写入接口:
```text ```text
@@ -175,7 +179,9 @@ Content-Type: application/json
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}} {"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
``` ```
服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。SSH、向日葵、Tailscale 和 PIGWay 自身服务不能从 Web 停止,以免中断远程管理或控制进程。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。 服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。API 服务不能通过自己的请求停止自身,其他服务由用户自行管理。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。
“暂停监控/恢复监控”只改变 PIGWay 是否检查该服务,不会启动或停止 systemd unit;暂停状态保存在 `[service_monitor_disabled]`。只有明确标注为“启动服务/停止服务”的按钮才会调用 systemctl。
读取 token: 读取 token:
+5 -5
View File
@@ -1,10 +1,10 @@
5442d660875ec28909b1183e4e2aee604e9f2e82d229c3806498392e7a4546e3 README.md d11f57b075bf1310515456d8c8b9bf1c5bb33753d8c9b10d08984f7014a8efd8 README.md
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
d71d949643d31461f2e62ded3818e1a0f1711d1420756ad0cc32eb04cde0fad3 app/pigway_pi_control.py ba34a479584345590f1903851b6d7e13000c4034c0c9db39e591eb4382be4229 app/pigway_pi_control.py
bf52a12d3208b5330f3e2980187e70330b930e950cfd5165b4e6605febfef65d app/pigway_pi_control_api.py a833c5598813b3695ac5a65daacf7b503abd8036f20f2e6cb75d8a99307a1095 app/pigway_pi_control_api.py
75fa662ed21138a8e394de83e46281dab039433cc0d7a8f787e1a049f4b47ec8 config/pigway-pi-control.conf 72dc248b8f7020b81bc4069e52cc11175a7b42e00d3b484cb6ca5beca624a90b config/pigway-pi-control.conf
f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 install.sh f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 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
2a3e37414845e023850af248a8ae4f5aacc06814cee88950e6e2564a34bac2c3 web/index.html f5ea0e6784143c35728b9d3b9aba0490527d14e469e040a4db5a9d37d3ab2c55 web/index.html
+8 -2
View File
@@ -210,7 +210,9 @@ def load_cfg():
if c.has_section("services"): if c.has_section("services"):
for label,target in c["services"].items(): for label,target in c["services"].items():
target=target.strip() target=target.strip()
if target and not target.startswith("#"): watches.append(("service",label.upper(),target)) paused=c.getboolean("service_monitor_disabled",label,fallback=False)
if target and not target.startswith("#") and not paused:
watches.append(("service",label.upper(),target))
if c.has_section("processes"): if c.has_section("processes"):
for label,target in c["processes"].items(): for label,target in c["processes"].items():
target=target.strip() target=target.strip()
@@ -666,7 +668,8 @@ class DisplayController:
} }
def _rgb_worker(self): def _rgb_worker(self):
previous_error=None; applied_mode="OFF"; applied_flash=False; animation_started=time.monotonic() previous_error=None; applied_mode="OFF"; applied_flash=False
flash_on=None; animation_started=time.monotonic()
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
with self.lock: wanted_mode=self.current_rgb_mode with self.lock: wanted_mode=self.current_rgb_mode
@@ -683,6 +686,7 @@ class DisplayController:
else: raise ValueError(f"unsupported RGB mode: {wanted_mode}") else: raise ValueError(f"unsupported RGB mode: {wanted_mode}")
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode) log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
applied_mode=wanted_mode; animation_started=time.monotonic() applied_mode=wanted_mode; animation_started=time.monotonic()
flash_on=True if wanted_mode.endswith("_FLASH") else None
applied_flash=applied_mode.endswith("_FLASH") applied_flash=applied_mode.endswith("_FLASH")
if applied_flash: if applied_flash:
elapsed_ms=(time.monotonic()-animation_started)*1000.0 elapsed_ms=(time.monotonic()-animation_started)*1000.0
@@ -691,10 +695,12 @@ class DisplayController:
elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH
else: on_ms,off_ms=WARNING_FLASH else: on_ms,off_ms=WARNING_FLASH
on=elapsed_ms%(on_ms+off_ms)<on_ms on=elapsed_ms%(on_ms+off_ms)<on_ms
if on!=flash_on:
with bus_lock: with bus_lock:
if applied_mode=="NORMAL_FLASH": if applied_mode=="NORMAL_FLASH":
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off() rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
else: rgb_flash(applied_mode,on) else: rgb_flash(applied_mode,on)
flash_on=on
if previous_error is not None: log("INFO","RGB_RECOVERED") if previous_error is not None: log("INFO","RGB_RECOVERED")
previous_error=None previous_error=None
except Exception as e: except Exception as e:
+55 -14
View File
@@ -17,8 +17,7 @@ RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$") FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$")
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"} NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"} EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
PROTECTED_UNITS={"ssh.service","sshd.service","runawesun.service","tailscaled.service", PROTECTED_UNITS={"pigway-pi-control-api.service"}
"pigway-pi-control.service","pigway-pi-control-api.service"}
def load_cfg(): def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG) cfg=configparser.ConfigParser(); cfg.read(CFG)
@@ -92,18 +91,39 @@ def update_ini(updates):
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG) temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup) return str(backup)
def journal(limit): def journal_query(since=None,until=None,severity="",event="",search="",sort="timestamp",order="desc",limit=100,offset=0):
result=subprocess.run(["journalctl","-u","pigway-pi-control.service","-n",str(limit), command=["journalctl","-u","pigway-pi-control.service","--no-pager","-o","json"]
"--no-pager","-o","json"],capture_output=True,text=True,timeout=5) if since is not None: command.extend(["--since",f"@{since}"])
if until is not None: command.extend(["--until",f"@{until}"])
result=subprocess.run(command,capture_output=True,text=True,timeout=15)
if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed") if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed")
rows=[] rows=[]
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
try: try:
item=json.loads(line) item=json.loads(line)
rows.append({"timestamp":item.get("__REALTIME_TIMESTAMP"),"priority":item.get("PRIORITY"), message=item.get("MESSAGE","")
"message":item.get("MESSAGE","")}) level_match=re.search(r"(?:^|\s)level=([A-Z]+)(?:\s|$)",message)
event_match=re.search(r"(?:^|\s)event=([^ ]+)",message)
priority=str(item.get("PRIORITY","6"))
level=(level_match.group(1) if level_match else
{"0":"EMERGENCY","1":"ALERT","2":"CRITICAL","3":"ERROR","4":"WARN","5":"NOTICE","6":"INFO","7":"DEBUG"}.get(priority,"INFO"))
rows.append({"timestamp":int(item.get("__REALTIME_TIMESTAMP",0)),"priority":priority,
"severity":level,"event":event_match.group(1) if event_match else "SYSTEM",
"message":message})
except json.JSONDecodeError: pass except json.JSONDecodeError: pass
return rows severities={x.strip().upper() for x in severity.split(",") if x.strip()}
if severities: rows=[x for x in rows if x["severity"] in severities]
event_types=sorted({x["event"] for x in rows})
if event: rows=[x for x in rows if x["event"]==event]
if search:
needle=search.casefold(); rows=[x for x in rows if needle in x["message"].casefold()]
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"]}
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":event_types,"retention":"systemd-journal"}
def service_inventory(): def service_inventory():
cfg=load_cfg() cfg=load_cfg()
@@ -125,19 +145,23 @@ def service_inventory():
item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""}) item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""})
for name,target in monitored.values(): for name,target in monitored.values():
item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""}) item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""})
item.update({"monitored":True,"monitor_name":name.upper(),"note":notes.get(name,"")}) paused=cfg.getboolean("service_monitor_disabled",name,fallback=False)
item.update({"monitored":True,"paused":paused,"monitor_name":name.upper(),"note":notes.get(name,"")})
for item in units.values(): for item in units.values():
item.setdefault("monitored",False); item.setdefault("monitor_name",""); item.setdefault("note","") item.setdefault("monitored",False); item.setdefault("paused",False)
item.setdefault("monitor_name",""); item.setdefault("note","")
item["protected"]=item["unit"] in PROTECTED_UNITS item["protected"]=item["unit"] in PROTECTED_UNITS
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"])) return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
def edit_service_monitor(operation,name,target,note="",previous_name=""): def edit_service_monitor(operation,name,target,note="",previous_name=""):
if operation not in {"save","delete"}: raise ValueError("operation must be save or delete") if operation not in {"save","delete","pause","resume"}: raise ValueError("invalid monitor operation")
if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name") if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
if not UNIT_RE.fullmatch(target): raise ValueError("invalid systemd service name") if not UNIT_RE.fullmatch(target): raise ValueError("invalid systemd service name")
if len(note)>120 or any(c in note for c in "\r\n\x00"): raise ValueError("invalid note") if len(note)>120 or any(c in note for c in "\r\n\x00"): raise ValueError("invalid note")
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous monitor name") if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous monitor name")
cfg=load_cfg(); old_target=cfg.get("services",name,fallback=None) cfg=load_cfg(); old_target=cfg.get("services",name,fallback=None)
previous=previous_name or name
was_paused=cfg.getboolean("service_monitor_disabled",previous,fallback=False)
text=CFG.read_text() text=CFG.read_text()
def change(section,key,value): def change(section,key,value):
nonlocal text nonlocal text
@@ -156,11 +180,16 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n") text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
if operation=="delete": if operation=="delete":
if old_target is None: raise ValueError("monitor not found") if old_target is None: raise ValueError("monitor not found")
change("services",name,None); change("service_notes",name,None) change("services",name,None); change("service_notes",name,None); change("service_monitor_disabled",name,None)
elif operation in {"pause","resume"}:
if old_target is None: raise ValueError("monitor not found")
change("service_monitor_disabled",name,"true" if operation=="pause" else None)
else: else:
if previous_name and previous_name.lower()!=name.lower(): if previous_name and previous_name.lower()!=name.lower():
change("services",previous_name,None); change("service_notes",previous_name,None) change("services",previous_name,None); change("service_notes",previous_name,None)
change("service_monitor_disabled",previous_name,None)
change("services",name,target); change("service_notes",name,note or None) change("services",name,target); change("service_notes",name,note or None)
if was_paused: change("service_monitor_disabled",name,"true")
parsed=configparser.ConfigParser(); parsed.read_string(text) parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}") backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup) shutil.copy2(CFG,backup)
@@ -215,13 +244,25 @@ class Handler(BaseHTTPRequestHandler):
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=="/api/v1/logs":
limit=min(max(int(parse_qs(parsed.query).get("limit",[api_setting("log_limit",200,int)])[0]),1),1000) query=parse_qs(parsed.query)
self.send_json(200,{"logs":journal(limit)}); return value=lambda key,default="":query.get(key,[default])[0]
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),500)
offset=max(int(value("offset",0)),0)
since=int(value("since")) if value("since") 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")
sort=value("sort","timestamp"); order=value("order","desc")
if sort not in {"timestamp","severity","event","message"}: raise ValueError("invalid log sort")
if order not in {"asc","desc"}: raise ValueError("invalid log order")
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")
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 self.send_json(200,{"config":config_json(),"write_requires_token":True}); return
if parsed.path=="/api/v1/services": if parsed.path=="/api/v1/services":
self.send_json(200,{"services":service_inventory()}); return self.send_json(200,{"services":service_inventory()}); 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 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):
+6
View File
@@ -271,6 +271,12 @@ AWESUN = runawesun.service
# NGINX = nginx.service # NGINX = nginx.service
[service_monitor_disabled]
# Web 中“暂停监控”的状态保存在这里。
# 名称对应 [services] 左侧名称;true 表示保留配置和备注,但 Agent 暂不检查。
# 通常由 Web 自动维护,无需手工编辑。
[processes] [processes]
# ============================================================================= # =============================================================================
# 普通进程监控 # 普通进程监控
+52 -20
View File
File diff suppressed because one or more lines are too long