diff --git a/README.md b/README.md index f764729..4eda795 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [简体中文](README.zh-CN.md) | English -PIGWay Device Agent is a lightweight local monitoring service for Raspberry Pi and Linux. It monitors CPU, memory, temperature, disk, networking, services, processes, system health alerts, and journal events. It has no embedded Web UI and does not require a hardware plugin or Device Console. +PIGWay Device Agent is a lightweight local monitoring service for Raspberry Pi and Linux. It monitors CPU, memory, temperature, disk, networking, services, processes, system health alerts, and journal events. Ethernet monitoring reads the negotiated link speed and can alert when it remains below the configured minimum. It has no embedded Web UI and does not require a hardware plugin or Device Console. ### Installation diff --git a/README.zh-CN.md b/README.zh-CN.md index a755bee..ecdb101 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -3,7 +3,7 @@ 简体中文 | [English](README.md) -极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。 +极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。有线网络会读取真实协商速率,并可在持续低于配置阈值时告警。无 Web 界面;无需硬件插件,也无需管理中心。 ## 安装 diff --git a/SHA256SUMS b/SHA256SUMS index 402bd06..7ad229b 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,14 +1,14 @@ ecf1884bb89b86ed4573d4d922a971abde43772bf7b2ffa6a999a12fbfb72caf .gitignore -8e5eee4f796e1dcf9a9b4960541672fc10aae6615e96e7e2296297dcab1535d7 README.md -94851d7e4a635ae41f41d7b7e89ddba6c0b2c53e201415fc3e3f233a82a9eb8e README.zh-CN.md -aeb34068c0b9dd3afc8f1c3dd388cb658b010df37f171643d2f508fbbf853980 app/pigway_pi_control.py -641591fd59c71dc22b54e0b844d3192e46b1f8a924b59f32f5ec9c5354f9a75a app/pigway_pi_control_api.py +04c279ff4068517fb6772f5c2fe39d9aca12b7ec9154fc2e74e89e9d2a85a457 README.md +673d815a06e8f7b15df3f6d3b76dad3d38a3a3eb29c81480ec106debf0e85ea0 README.zh-CN.md +852889cbbf57d5e857794432426e3343b5d08b1395b3098a6e19fd648b4f4267 app/pigway_pi_control.py +757eb6a83ca07740a2389af9b51c6fd5d940b4eb86abbc603f231409416d45db app/pigway_pi_control_api.py 3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py -11a75ded35d7d16512a734814d85fe2f61101044d9de9352679be73355772008 config/pigway-pi-control.conf +d5a783b64a10fa43191a813ef73ef8f762e359b0bd42ec155724fa46216298b0 config/pigway-pi-control.conf e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md 2030b90b8c01d02c414a1fb44c96c84527b6cafcc8a6ead5634c5e9dcd5dad4b docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.md 75984d9dea668c04d35f79d7c454d93b835825ef2eff607b760294237016aa8f docs/RASPBERRY_PI_OS_TRIXIE_FRESH_INSTALL.zh-CN.md -5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh +d2706de954d0c3f1b4311c1bac52ef09b912919b96126fa6000a88786e969438 install.sh 5e16c1d5d592fa3e129cde0077da6b089784c82f452205a349d4824f48eb3a66 systemd/pigway-pi-control-api.service 272545c6f821a850caa545361f4f93e3218436c7fbc1b3dd01b83ac442e7793e systemd/pigway-pi-control.service 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 0a0e96a..6f82854 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform from pathlib import Path -APP_VERSION="3.7.1" +APP_VERSION="3.7.2" import sys sys.path.append("/usr/local/share/pigway-pi-control") from shutil import disk_usage @@ -104,9 +104,10 @@ prev_cpu=None page=0; page_since=time.monotonic() cpu_hi=mem_hi=0 net_ok=link_ok=True -net_iface=""; net_kind="NET"; net_metric="DOWN"; ip_label="IP4"; ip="NO IP" +net_iface=""; net_kind="NET"; net_metric="DOWN"; net_speed_mbps=None; ip_label="IP4"; ip="NO IP" link_failure_since=internet_failure_since=None wifi_weak_since=None; wifi_weak_active=False +ethernet_slow_since=None; ethernet_slow_active=False last_net=last_watch=0 watch_state=[] @@ -149,8 +150,9 @@ def primary_network(): if not dev: out=_run(["ip","-6","route","get","2606:4700:4700::1111"]) dev=re.search(r"\bdev\s+(\S+)",out); src=re.search(r"\bsrc\s+(\S+)",out); label="IP6" - if not dev: return "","NET","DOWN","IP4","NO IP" + if not dev: return "","NET","DOWN",None,"IP4","NO IP" iface=dev.group(1); ipval=src.group(1) if src else "" + speed_mbps=None if Path(f"/sys/class/net/{iface}/wireless").exists(): kind="WIF"; metric="--" try: @@ -162,12 +164,13 @@ def primary_network(): kind="ETH"; metric="UP" try: n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip()) + speed_mbps=n if n>0 else None metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n) except Exception: pass - return iface,kind,metric,label,(ipval or "NO IP") + return iface,kind,metric,speed_mbps,label,(ipval or "NO IP") def get_ip(): - return primary_network()[4] + return primary_network()[5] def internet(): try: @@ -219,6 +222,8 @@ DISK_CRIT=cf("alerts","disk_critical",95.0) WIFI_WEAK=cf("alerts","wifi_weak",-75.0) WIFI_RECOVER=cf("alerts","wifi_recover",-68.0) WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0) +ETHERNET_MIN_SPEED=cf("alerts","ethernet_min_speed_mbps",1000,int) +ETHERNET_SPEED_SECONDS=cf("alerts","ethernet_speed_seconds",15.0) DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no")) DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no")) @@ -229,7 +234,7 @@ ALERT_CATEGORIES={ "POWER_UNDERVOLT":"POWER", "MEMORY_HIGH":"MEMORY", "DISK_CRITICAL":"STORAGE","DISK_HIGH":"STORAGE", - "NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK", + "NETWORK_DOWN":"NETWORK","INTERNET_DOWN":"NETWORK","WIFI_WEAK":"NETWORK","ETHERNET_SPEED_LOW":"NETWORK", "SERVICE_DOWN":"SERVICE","PROCESS_DOWN":"SERVICE", } @@ -257,9 +262,9 @@ def diagnose(): "under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"): print(f"{key}={power.get(key,'UNKNOWN')}") if "error" in power: print("error="+power["error"]) - iface,kind,metric,label,address=primary_network() + iface,kind,metric,speed,label,address=primary_network() print("Network:") - print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}") + print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} link_speed_mbps={speed if speed is not None else 'N/A'} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}") print("Plugins (read-only API discovery; no hardware access):") for plugin in discover(cfg): print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}") @@ -286,6 +291,7 @@ ALERT_PRIORITIES={ "MEMORY_HIGH":45, "DISK_HIGH":40, "WIFI_WEAK":30, + "ETHERNET_SPEED_LOW":35, "NORMAL_HOME":0, } @@ -299,7 +305,7 @@ def transition(key,active,on_event,off_event,**fields): log("WARN" if active else "INFO",on_event if active else off_event,**fields) def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): - global wifi_weak_since,wifi_weak_active + global wifi_weak_since,wifi_weak_active,ethernet_slow_since,ethernet_slow_active global cpu_hi,mem_hi cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu=MEM_HIGH else 0 if mem0: + if not ethernet_slow_active: + if net_speed_mbps < ETHERNET_MIN_SPEED: + if ethernet_slow_since is None: ethernet_slow_since=time.monotonic() + if time.monotonic()-ethernet_slow_since >= ETHERNET_SPEED_SECONDS: ethernet_slow_active=True + else: ethernet_slow_since=None + elif net_speed_mbps >= ETHERNET_MIN_SPEED: + ethernet_slow_active=False; ethernet_slow_since=None + if ethernet_slow_active: + alert_manager.request("ETHERNET_SPEED_LOW",1,"ETHERNET SLOW",f"LINK {net_speed_mbps} Mbps",f"EXPECT {ETHERNET_MIN_SPEED}+","CHECK CABLE") + else: alert_manager.clear("ETHERNET_SPEED_LOW") + else: + ethernet_slow_since=None; ethernet_slow_active=False; alert_manager.clear("ETHERNET_SPEED_LOW") + transition("ethernet_speed_low",ethernet_slow_active,"ETHERNET_SPEED_LOW","ETHERNET_SPEED_RECOVERED", + iface=net_iface,speed_mbps=net_speed_mbps,minimum_mbps=ETHERNET_MIN_SPEED) if health.get("under_voltage_now"): alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")}) else: alert_manager.clear("POWER_UNDERVOLT") @@ -413,6 +434,7 @@ def write_status(cpu,temp,mem,disk): "root_readonly":bool(fs_readonly)}, "power":health, "network":{"interface":net_iface,"kind":net_kind,"metric":net_metric, + "link_speed_mbps":net_speed_mbps, "signal_percent":signal, "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)} @@ -461,7 +483,7 @@ try: if now>=monitor_ready_at and now-last_net >= NETWORK_INTERVAL: old_id=(net_iface,ip) - net_iface,net_kind,net_metric,ip_label,ip=primary_network() + net_iface,net_kind,net_metric,net_speed_mbps,ip_label,ip=primary_network() raw_link_ok=ip!="NO IP"; raw_net_ok=internet() if raw_link_ok else False if raw_link_ok: link_failure_since=None;link_ok=True diff --git a/app/pigway_pi_control_api.py b/app/pigway_pi_control_api.py index 6dd8f98..d423ae7 100644 --- a/app/pigway_pi_control_api.py +++ b/app/pigway_pi_control_api.py @@ -10,7 +10,7 @@ from pathlib import Path from urllib.parse import parse_qs,urlencode,urlparse from urllib.request import Request,urlopen -APP_VERSION="3.7.1" +APP_VERSION="3.7.2" CFG=Path("/etc/pigway-pi-control.conf") STATUS=Path("/run/pigway-pi-control/status.json") TOKEN=Path("/etc/pigway-pi-control-api.token") @@ -114,7 +114,9 @@ def validate_value(section,key,value,existing): def replace_ini_value(section,key,value): - text=CFG.read_text() + return replace_ini_text(CFG.read_text(),section,key,value) + +def replace_ini_text(text,section,key,value): section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text) if not section_match: if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n" @@ -278,6 +280,34 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""): subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10) return str(backup) +def process_monitors(): + cfg=load_cfg(); result=[] + for name,target in cfg.items("processes"): + target=target.strip() + if not target: continue + up=subprocess.run(["pgrep","-f","--",target],stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL,timeout=2).returncode==0 + result.append({"name":name.upper(),"target":target,"up":up}) + return result + +def edit_process_monitor(operation,name,target="",previous_name=""): + if operation not in {"save","delete"}: raise ValueError("invalid process monitor operation") + if not NAME_RE.fullmatch(name): raise ValueError("invalid process monitor name") + if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous process monitor name") + if operation=="save" and (not target or len(target)>256 or any(c in target for c in "\r\n\x00")): + raise ValueError("invalid process match expression") + text=CFG.read_text() + if operation=="delete": + if not load_cfg().has_option("processes",name): raise ValueError("process monitor not found") + text=replace_ini_text(text,"processes",name,None) + else: + if previous_name and previous_name.lower()!=name.lower(): text=replace_ini_text(text,"processes",previous_name,None) + text=replace_ini_text(text,"processes",name,target) + parsed=configparser.ConfigParser(); parsed.read_string(text) + backup=save_ini_text(text,"api") + subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10) + return 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") @@ -350,13 +380,15 @@ class Handler(BaseHTTPRequestHandler): if parsed.path=="/api/v1/services": query=parse_qs(parsed.query); host_id=query.get("host",[""])[0] self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return + if parsed.path=="/api/v1/processes": + self.send_json(200,{"processes":process_monitors(),"machine":machine_identity()}); return 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)}) def do_PUT(self): path=urlparse(self.path).path - if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control"}: self.send_json(404,{"error":"not found"}); return + if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/processes"}: self.send_json(404,{"error":"not found"}); return if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return try: body=self.read_json() @@ -375,6 +407,11 @@ class Handler(BaseHTTPRequestHandler): 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 + if path=="/api/v1/processes": + backup=edit_process_monitor(str(body.get("operation","")),str(body.get("name","")), + str(body.get("target","")),str(body.get("previous_name",""))) + print(f"PROCESS_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) subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10) diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 343ef6d..3d1742e 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -99,6 +99,11 @@ wifi_recover = -68 # 弱信号持续多少秒才正式告警。 wifi_weak_seconds = 30 +# 有线网卡协商速率低于此值并持续 ethernet_speed_seconds 后告警,单位 Mbps。 +# Raspberry Pi 4/5 的千兆网口建议保持 1000;设为 0 可关闭速率告警。 +ethernet_min_speed_mbps = 1000 +ethernet_speed_seconds = 15 + [dark_mode] # Raspberry Pi 4 板载 PWR / ACT LED。 diff --git a/install.sh b/install.sh index 84f967b..d38638a 100755 --- a/install.sh +++ b/install.sh @@ -31,12 +31,21 @@ fi # but ignored by the monitor and never exposed as monitor configuration. python3 - <<'PY' from pathlib import Path -import configparser -p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p) -with p.open('a') as f: - for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items(): - if not c.has_section(section): - f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items())) +import configparser,re +p=Path('/etc/pigway-pi-control.conf');text=p.read_text();c=configparser.ConfigParser();c.read_string(text) +for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{}, + 'alerts':{'ethernet_min_speed_mbps':'1000','ethernet_speed_seconds':'15'}, + 'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items(): + if not c.has_section(section): + text=text.rstrip()+'\n\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()) + else: + missing={k:v for k,v in values.items() if not c.has_option(section,k)} + if missing: + start=re.search(rf'(?mi)^\[{re.escape(section)}\][ \t]*$',text).end() + following=re.search(r'(?m)^\[[^\]\r\n]+\]',text[start:]) + end=start+(following.start() if following else len(text)-start) + text=text[:end].rstrip()+'\n'+''.join(k+' = '+v+'\n' for k,v in missing.items())+'\n'+text[end:].lstrip('\n') +p.write_text(text) PY if [ ! -s /etc/pigway-pi-control-api.token ]; then (umask 077; python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > /etc/pigway-pi-control-api.token)