Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d744824a8 | ||
|
|
b08721fd15 | ||
|
|
cd9bdd5970 | ||
|
|
08c00aa370 | ||
|
|
13f6155096 | ||
|
|
97598a48ad | ||
|
|
01c07658c0 | ||
|
|
4ba4d461db | ||
|
|
45c7b8c168 | ||
|
|
b39c7e5279 | ||
|
|
2e5562fb3e | ||
|
|
03fd6d1cd8 | ||
|
|
8cb71cbe9f | ||
|
|
73907320c8 | ||
|
|
ad25c08eb1 | ||
|
|
5d541d5ef6 |
@@ -73,7 +73,7 @@ CHECK STORAGE
|
|||||||
|
|
||||||
### 5. RGB 正常状态与告警引擎
|
### 5. RGB 正常状态与告警引擎
|
||||||
|
|
||||||
没有 active alert 时,RGB 默认全灭。`[led] normal_mode` 可改为自定义颜色常亮/闪烁,或 MCU 已确认的流水、呼吸、跑马、彩虹和炫彩效果。常亮与闪烁使用 0..255 的自定义 R/G/B;内置流水/呼吸使用 MCU 固定的红、绿、蓝、黄、紫、青、白七色和慢/中/快三档速度,跑马/彩虹/炫彩由 MCU 自行配色。内置效果只在模式切换时写一次寄存器,不循环刷 I²C。
|
没有 active alert 时,RGB 默认全灭。`[led] normal_mode` 可改为自定义颜色常亮/闪烁,或 MCU 已确认的流水、呼吸、跑马、彩虹和炫彩效果。常亮与闪烁使用 0..255 的自定义 R/G/B;内置流水/呼吸使用 MCU 固定的红、绿、蓝、黄、紫、青、白七色和慢/中/快三档速度,跑马/彩虹/炫彩由 MCU 自行配色。内置效果只在模式切换时按 speed → color → effect 的实机校准顺序写一次寄存器,不循环刷 I²C。
|
||||||
|
|
||||||
任何 active alert 都会立即抢占正常效果。RGB 告警模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。
|
任何 active alert 都会立即抢占正常效果。RGB 告警模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。
|
||||||
|
|
||||||
@@ -162,8 +162,13 @@ GET /api/v1/health
|
|||||||
GET /api/v1/status
|
GET /api/v1/status
|
||||||
GET /api/v1/logs?limit=200
|
GET /api/v1/logs?limit=200
|
||||||
GET /api/v1/config
|
GET /api/v1/config
|
||||||
|
GET /api/v1/services
|
||||||
```
|
```
|
||||||
|
|
||||||
|
日志接口支持 `since`、`until` Unix 时间戳,`severity`、`event`、`search` 筛选,`timestamp` / `severity` / `event` / `message` 排序,以及 `limit` / `offset` 分页。Web 提供今天、昨天、本周、本月和全部快捷范围。日志直接读取 systemd journal 的现有保留数据,不建立重复的 SQLite 日志副本。
|
||||||
|
|
||||||
|
Web 界面支持中文和 English 切换,并将结构化 journal 事件类型显示为当前界面的语言;journal 内部稳定的事件标识保持不变,便于脚本筛选。外观可选择自动、深色或浅色,自动模式跟随浏览器/操作系统配色。语言和外观偏好只保存在当前浏览器中。
|
||||||
|
|
||||||
配置写入接口:
|
配置写入接口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -174,6 +179,10 @@ 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。API 服务不能通过自己的请求停止自身,其他服务由用户自行管理。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。
|
||||||
|
|
||||||
|
“暂停监控/恢复监控”只改变 PIGWay 是否检查该服务,不会启动或停止 systemd unit;暂停状态保存在 `[service_monitor_disabled]`。只有明确标注为“启动服务/停止服务”的按钮才会调用 systemctl。
|
||||||
|
|
||||||
读取 token:
|
读取 token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
40154b1fabac7bdfc7e4a6cd1a88ffa42e6842ef1da625594d5d9e4b904e99b9 README.md
|
d11f57b075bf1310515456d8c8b9bf1c5bb33753d8c9b10d08984f7014a8efd8 README.md
|
||||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||||
c3062ad1122c36a762bc3ca9d44ebcb5a6a5a37446a85fb68312de4fd8aa5db9 app/pigway_pi_control.py
|
ba34a479584345590f1903851b6d7e13000c4034c0c9db39e591eb4382be4229 app/pigway_pi_control.py
|
||||||
3a13b4e8f9d306e77f82eba78b78fe9b1932488884d6f3f976754bfa94f0153d 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
|
||||||
1276369d600e9fd560d03e26146bcaeb40a353829978d1ebaa578d385b27af72 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
|
||||||
7694998270db13407c197707ee41f8fb8d8d9ffa8d2325f20d7dd7719ab46815 web/index.html
|
f5ea0e6784143c35728b9d3b9aba0490527d14e469e040a4db5a9d37d3ab2c55 web/index.html
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -471,12 +473,11 @@ def rgb_normal_effect(mode):
|
|||||||
effect_name=mode.removeprefix("NORMAL_").lower()
|
effect_name=mode.removeprefix("NORMAL_").lower()
|
||||||
state=("effect",effect_name,NORMAL_EFFECT_SPEED,NORMAL_EFFECT_COLOR)
|
state=("effect",effect_name,NORMAL_EFFECT_SPEED,NORMAL_EFFECT_COLOR)
|
||||||
if _last_rgb==state: return
|
if _last_rgb==state: return
|
||||||
# Seller protocol: close the previous RGB state, then set effect, speed and
|
# Hardware-calibrated order: speed and color are parameters; the final
|
||||||
# the limited built-in palette. Write this sequence once per mode change.
|
# effect register write commits and starts the selected MCU animation.
|
||||||
mcu(0x07,0x00); time.sleep(0.001)
|
mcu(0x05,NORMAL_EFFECT_SPEED); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
mcu(0x04,RGB_EFFECTS[effect_name]); time.sleep(0.001)
|
mcu(0x06,RGB_EFFECT_COLORS[NORMAL_EFFECT_COLOR]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||||
mcu(0x05,NORMAL_EFFECT_SPEED); time.sleep(0.001)
|
mcu(0x04,RGB_EFFECTS[effect_name])
|
||||||
mcu(0x06,RGB_EFFECT_COLORS[NORMAL_EFFECT_COLOR])
|
|
||||||
_last_rgb=state
|
_last_rgb=state
|
||||||
|
|
||||||
def render_home(cpu,temp,mem,disk):
|
def render_home(cpu,temp,mem,disk):
|
||||||
@@ -667,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
|
||||||
@@ -684,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
|
||||||
@@ -692,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:
|
||||||
|
|||||||
+138
-10
@@ -12,10 +12,12 @@ 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={"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$")
|
||||||
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]$")
|
||||||
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={"pigway-pi-control-api.service"}
|
||||||
|
|
||||||
def load_cfg():
|
def load_cfg():
|
||||||
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||||
@@ -89,18 +91,118 @@ 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():
|
||||||
|
cfg=load_cfg()
|
||||||
|
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
|
||||||
|
notes=dict(cfg.items("service_notes")) if cfg.has_section("service_notes") else {}
|
||||||
|
files=subprocess.run(["systemctl","list-unit-files","--type=service","--no-legend","--no-pager"],
|
||||||
|
capture_output=True,text=True,timeout=10,check=True)
|
||||||
|
units={}
|
||||||
|
for line in files.stdout.splitlines():
|
||||||
|
parts=line.split()
|
||||||
|
if len(parts)>=2 and UNIT_RE.fullmatch(parts[0]):
|
||||||
|
units[parts[0]]={"unit":parts[0],"enabled":parts[1],"active":"inactive","description":""}
|
||||||
|
states=subprocess.run(["systemctl","list-units","--all","--type=service","--no-legend","--no-pager","--plain"],
|
||||||
|
capture_output=True,text=True,timeout=10,check=True)
|
||||||
|
for line in states.stdout.splitlines():
|
||||||
|
parts=line.split(None,4)
|
||||||
|
if len(parts)>=4 and UNIT_RE.fullmatch(parts[0]):
|
||||||
|
item=units.setdefault(parts[0],{"unit":parts[0],"enabled":"unknown"})
|
||||||
|
item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""})
|
||||||
|
for name,target in monitored.values():
|
||||||
|
item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""})
|
||||||
|
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():
|
||||||
|
item.setdefault("monitored",False); item.setdefault("paused",False)
|
||||||
|
item.setdefault("monitor_name",""); item.setdefault("note","")
|
||||||
|
item["protected"]=item["unit"] in PROTECTED_UNITS
|
||||||
|
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
|
||||||
|
|
||||||
|
def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||||
|
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 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 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)
|
||||||
|
previous=previous_name or name
|
||||||
|
was_paused=cfg.getboolean("service_monitor_disabled",previous,fallback=False)
|
||||||
|
text=CFG.read_text()
|
||||||
|
def change(section,key,value):
|
||||||
|
nonlocal 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
|
||||||
|
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()
|
||||||
|
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||||
|
elif value is not None:
|
||||||
|
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
|
||||||
|
if operation=="delete":
|
||||||
|
if old_target is None: raise ValueError("monitor not found")
|
||||||
|
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:
|
||||||
|
if previous_name and previous_name.lower()!=name.lower():
|
||||||
|
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)
|
||||||
|
if was_paused: change("service_monitor_disabled",name,"true")
|
||||||
|
parsed=configparser.ConfigParser(); parsed.read_string(text)
|
||||||
|
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||||
|
shutil.copy2(CFG,backup)
|
||||||
|
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
|
||||||
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
|
return str(backup)
|
||||||
|
|
||||||
|
def control_service(unit,action):
|
||||||
|
if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name")
|
||||||
|
if action not in {"start","stop"}: raise ValueError("action must be start or stop")
|
||||||
|
if unit in PROTECTED_UNITS and action=="stop": raise ValueError("protected remote-management service cannot be stopped here")
|
||||||
|
result=subprocess.run(["systemctl",action,unit],capture_output=True,text=True,timeout=20)
|
||||||
|
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.6"
|
||||||
@@ -142,18 +244,44 @@ 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":
|
||||||
|
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):
|
||||||
if urlparse(self.path).path!="/api/v1/config": self.send_json(404,{"error":"not found"}); return
|
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 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(); updates=body.get("updates")
|
body=self.read_json()
|
||||||
|
if path=="/api/v1/services/control":
|
||||||
|
control_service(str(body.get("unit","")),str(body.get("action","")))
|
||||||
|
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
|
||||||
|
self.send_json(200,{"ok":True}); return
|
||||||
|
if path=="/api/v1/services":
|
||||||
|
backup=edit_service_monitor(str(body.get("operation","")),str(body.get("name","")),
|
||||||
|
str(body.get("target","")),str(body.get("note","")),
|
||||||
|
str(body.get("previous_name","")))
|
||||||
|
print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
|
||||||
|
self.send_json(200,{"ok":True,"backup":backup}); return
|
||||||
|
updates=body.get("updates")
|
||||||
backup=update_ini(updates)
|
backup=update_ini(updates)
|
||||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||||
restart_api="api" in updates
|
restart_api="api" in updates
|
||||||
|
|||||||
@@ -271,6 +271,12 @@ AWESUN = runawesun.service
|
|||||||
# NGINX = nginx.service
|
# NGINX = nginx.service
|
||||||
|
|
||||||
|
|
||||||
|
[service_monitor_disabled]
|
||||||
|
# Web 中“暂停监控”的状态保存在这里。
|
||||||
|
# 名称对应 [services] 左侧名称;true 表示保留配置和备注,但 Agent 暂不检查。
|
||||||
|
# 通常由 Web 自动维护,无需手工编辑。
|
||||||
|
|
||||||
|
|
||||||
[processes]
|
[processes]
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 普通进程监控
|
# 普通进程监控
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ sections={
|
|||||||
("normal_r",0),("normal_g",0),("normal_b",0),
|
("normal_r",0),("normal_g",0),("normal_b",0),
|
||||||
("normal_flash_on_ms",1000),("normal_flash_off_ms",1000),
|
("normal_flash_on_ms",1000),("normal_flash_off_ms",1000),
|
||||||
("normal_effect_speed",2),("normal_effect_color","green"),
|
("normal_effect_speed",2),("normal_effect_color","green"),
|
||||||
|
("write_delay_ms",10),
|
||||||
("cpu_r",255),("cpu_g",0),("cpu_b",0),
|
("cpu_r",255),("cpu_g",0),("cpu_b",0),
|
||||||
("power_r",255),("power_g",40),("power_b",0),
|
("power_r",255),("power_g",40),("power_b",0),
|
||||||
("memory_r",255),("memory_g",0),("memory_b",255),
|
("memory_r",255),("memory_g",0),("memory_b",255),
|
||||||
|
|||||||
+72
-41
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user