refactor: separate fleet manager from local monitoring
This commit is contained in:
@@ -1,65 +1,50 @@
|
||||
# PIGWay Pi Control v3.7.0
|
||||
|
||||
独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理平台。提供本机状态、告警、事件日志和多机配置管理,可按需接入独立插件。
|
||||
|
||||
## 功能
|
||||
|
||||
- CPU、温度、内存和磁盘容量/用量;主网络接口、IPv4/IPv6、Wi-Fi 信号百分比。
|
||||
- systemd 服务及进程监控;暂停监控与启停服务是不同操作。
|
||||
- Raspberry Pi 当前欠压、降频、频率受限、温度限制和根分区只读监控。
|
||||
- 当前故障统一管理;历史电源位仅记录日志,不生成当前告警。
|
||||
- 事件日志按日期、严重程度、事件类型和机器查询、排序。
|
||||
- 多机状态、机器标识、配置与独立的批量配置同步。
|
||||
- 中英文、跟随系统/深色/浅色主题。
|
||||
- 独立硬件插件发现、手动接入、暂停联动、断开、能力配置。
|
||||
极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# 仅安装文件,不改变服务运行状态:
|
||||
sudo ./install.sh --no-start
|
||||
# 显式启用 API:
|
||||
sudo ./install.sh --enable-api
|
||||
# 不启用 API:
|
||||
sudo ./install.sh --disable-api
|
||||
```
|
||||
|
||||
仅需要 Python 3。插件独立安装与运行,主程序不会自动安装或启动插件。
|
||||
配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。
|
||||
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
|
||||
|
||||
API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器会显示令牌查询方法,不直接显示令牌内容:
|
||||
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control.service
|
||||
```
|
||||
|
||||
## 可选 API
|
||||
|
||||
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now pigway-pi-control-api.service
|
||||
sudo cat /etc/pigway-pi-control-api.token
|
||||
```
|
||||
|
||||
服务分别是 `pigway-pi-control.service` 和 `pigway-pi-control-api.service`。
|
||||
Web 重启不会自动启动已停止的监控。监控与硬件插件的运行状态彼此独立。
|
||||
默认端口6001。读取及写入都需要 `Authorization: Bearer <token>`。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。
|
||||
|
||||
## 已接入插件与扩展
|
||||
API 提供 `/api/v1/health`、`/status`、`/logs`、`/config`、`/services`、`/plugins` 和 `/plugin/config`(均以 `/api/v1` 开头)。不提供网页、多机登记或远程转发。
|
||||
|
||||
[Yahboom 散热板插件](https://tea.pigway.com/way/pigway-cooling-hat) 是独立项目、独立安装包及 systemd 服务。
|
||||
先在目标机器安装并运行插件,再进入 Web → 系统配置 → 硬件插件 → 发现并接入。
|
||||
仅发现插件不会接管插件的设备能力。接入后发送当前监控状态,无需开放新的网络端口。
|
||||
## 独立管理中心与插件
|
||||
|
||||
运行状态卡片仅为已接入插件显示摘要。插件配置根据能力生成:只有风扇就只有风扇设置,有 OLED/RGB 才有相应控件。保存插件配置不重启监控。暂停告警联动或断开接入不会停止插件的本地功能。
|
||||
- [PIGWay Web Manager](https://tea.pigway.com/way/pigway-web-manager):可部署在 NAS 或远端,登记设备地址与令牌,统一看板、配置和批量同步。
|
||||
- [PIGWay Cooling HAT](https://tea.pigway.com/way/pigway-cooling-hat):独立本地采集、OLED、风扇与 RGB。
|
||||
|
||||
默认 Unix socket:`/run/pigway-plugins/<id>/api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。插件声明自身能力,Web 据此展示状态和配置。其他设备可通过独立插件实现此协议。
|
||||
|
||||
监控和插件互不依赖:插件离线不影响监控、日志和 Web;监控离线不停止插件的本地功能。通信恢复后同步最新状态,过期告警不会继续显示。
|
||||
|
||||
当前可用插件:
|
||||
|
||||
| 插件 | 独立功能 | 仓库 |
|
||||
| --- | --- | --- |
|
||||
| PIGWay Cooling HAT | 本机状态采集、OLED 展示、风扇温控、RGB 灯效 | [pigway-cooling-hat](https://tea.pigway.com/way/pigway-cooling-hat) |
|
||||
远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。
|
||||
|
||||
## 运维
|
||||
|
||||
```bash
|
||||
sudo systemctl status pigway-pi-control.service pigway-pi-control-api.service
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
sudo /usr/local/sbin/pigway-pi-control --diagnose
|
||||
sudo journalctl -u pigway-pi-control.service -f
|
||||
```
|
||||
|
||||
`--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。
|
||||
卸载监控不会停止或删除独立插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。
|
||||
|
||||
静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。
|
||||
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
|
||||
|
||||
+6
-7
@@ -1,11 +1,10 @@
|
||||
e83c52906953a723fc79208aecf08372a875e0bc2f74b4283329a475744f8984 README.md
|
||||
8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh
|
||||
70eb073f87f2e75830c545d809f28a188afc3cd2c2c6379418b9ad665f6339df README.md
|
||||
5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh
|
||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||
0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py
|
||||
339f28978641242436e141add0cc923ed5ebb855bea9f6c9fe949b8eb8579527 app/pigway_pi_control_api.py
|
||||
f91af58e7dd752fa97c168ae419e14e6ee5099ea58d8252568f7ce72affd219f app/pigway_pi_control_api.py
|
||||
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
|
||||
3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf
|
||||
20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service
|
||||
60665659712a0c7311e7bfa8fd09b300b0ea164cf53746feda91c7cc2a28145d config/pigway-pi-control.conf
|
||||
18a62bbe2b2a187a9184d7637f36cd22146f432236c2f95f4588ef6162de97dc systemd/pigway-pi-control-api.service
|
||||
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
|
||||
217474cf1fea5aebe54dea8b4c3434a7e3560d4fac8ad51d7d8faa9c7819a4f2 web/index.html
|
||||
73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md
|
||||
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
|
||||
|
||||
+15
-164
@@ -14,8 +14,7 @@ APP_VERSION="3.7.0"
|
||||
CFG=Path("/etc/pigway-pi-control.conf")
|
||||
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
||||
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
|
||||
ALLOWED_SECTIONS={"device","services","processes","alerts","timing","dark_mode","api"}
|
||||
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]$")
|
||||
@@ -65,60 +64,6 @@ def machine_identity():
|
||||
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():
|
||||
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||
return cfg
|
||||
@@ -132,8 +77,11 @@ def config_json():
|
||||
return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||
|
||||
def validate_value(section,key,value,existing):
|
||||
if section=="api" and key=="enabled":
|
||||
if value.lower() not in {"true","false"}:raise ValueError("enabled must be true or false")
|
||||
return
|
||||
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
|
||||
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
||||
if section not in {"services","processes"} and key not in existing.get(section,set()):
|
||||
raise ValueError(f"unsupported option: {section}.{key}")
|
||||
if section=="hardware" and key=="cooling_hat_enabled":
|
||||
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
|
||||
@@ -163,8 +111,7 @@ def validate_value(section,key,value,existing):
|
||||
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()
|
||||
@@ -172,7 +119,7 @@ def replace_ini_value(section,key,value):
|
||||
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)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",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)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
@@ -189,15 +136,6 @@ def save_ini_text(text,prefix="api"):
|
||||
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):
|
||||
if not isinstance(updates,dict): raise ValueError("updates must be an object")
|
||||
text=CFG.read_text()
|
||||
@@ -217,7 +155,7 @@ def update_ini(updates):
|
||||
if not section_match:
|
||||
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
continue
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
|
||||
@@ -268,50 +206,6 @@ def journal_query(since=None,until=None,severity="",event="",search="",sort="tim
|
||||
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
|
||||
"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():
|
||||
cfg=load_cfg()
|
||||
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
|
||||
@@ -356,7 +250,7 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
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)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",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)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
@@ -422,19 +316,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed=urlparse(self.path)
|
||||
try:
|
||||
if parsed.path=="/":
|
||||
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
|
||||
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
|
||||
if not self.authorized():self.send_json(401,{"error":"bearer token required"});return
|
||||
if parsed.path=="/api/v1/health":
|
||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
|
||||
if parsed.path=="/api/v1/hosts":
|
||||
self.send_json(200,{"hosts":host_registry()}); return
|
||||
if parsed.path=="/api/v1/fleet/status":
|
||||
self.send_json(200,fleet_status()); return
|
||||
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()});return
|
||||
if parsed.path=="/api/v1/status":
|
||||
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
|
||||
self.send_json(200,json.loads(STATUS.read_text())); return
|
||||
if parsed.path in {"/api/v1/logs","/api/v1/fleet/logs"}:
|
||||
if parsed.path=="/api/v1/logs":
|
||||
query=parse_qs(parsed.query)
|
||||
value=lambda key,default="":query.get(key,[default])[0]
|
||||
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
|
||||
@@ -444,33 +332,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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")
|
||||
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")
|
||||
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 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
|
||||
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
|
||||
plugin_id=query.get("id",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id)
|
||||
self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return
|
||||
if parsed.path.endswith("/plugins"):
|
||||
self.send_json(200,{"plugins":plugin_inventory()});return
|
||||
item=find_plugin(plugin_id)
|
||||
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
|
||||
if parsed.path=="/api/v1/config":
|
||||
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":
|
||||
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"})
|
||||
except ValueError as e: self.send_json(400,{"error":str(e)})
|
||||
@@ -478,44 +356,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
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","/api/v1/hosts","/api/v1/fleet/config"}: 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"}: 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()
|
||||
if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
host_id=str(body.get("host",""))
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id);token=str(body.get("target_token",""))
|
||||
if not token:raise ValueError("remote bearer token required")
|
||||
payload={k:v for k,v in body.items() if k not in {"host","target_token"}}
|
||||
self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return
|
||||
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
|
||||
item=find_plugin(str(body.get("id","")))
|
||||
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
|
||||
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":
|
||||
control_service(str(body.get("unit","")),str(body.get("action","")))
|
||||
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
|
||||
@@ -537,6 +386,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e: self.send_json(500,{"error":str(e)})
|
||||
|
||||
def main():
|
||||
if not load_cfg().getboolean("api","enabled",fallback=False):
|
||||
print("API_DISABLED",flush=True);sys.exit(78)
|
||||
host=api_setting("bind","0.0.0.0")
|
||||
port=api_setting("port",6001,int)
|
||||
server=ThreadingHTTPServer((host,port),Handler)
|
||||
|
||||
@@ -13,15 +13,10 @@ identifier = auto
|
||||
name = auto
|
||||
|
||||
|
||||
[hosts]
|
||||
# 在作为聚合入口的机器上登记其他节点,每行格式:
|
||||
# 唯一标识 = http://节点IP:6001
|
||||
# 示例:
|
||||
# living-room = http://192.168.1.20:6001
|
||||
|
||||
|
||||
[api]
|
||||
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
|
||||
# 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。
|
||||
enabled = false
|
||||
# API 监听地址和端口。0.0.0.0 允许局域网访问;
|
||||
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
|
||||
bind = 0.0.0.0
|
||||
port = 6001
|
||||
|
||||
@@ -79,3 +79,13 @@ fallback; actual execution still depends on functioning hardware.
|
||||
Stopping a monitor, pausing its link, or unlinking a plugin does not stop the
|
||||
plugin service. No API/Web service has a systemd Wants dependency that starts the
|
||||
other application. Do not run two drivers for the same MCU, GPIO or fan.
|
||||
|
||||
## Optional listeners
|
||||
|
||||
Agent network API defaults to disabled (`[api] enabled=false`) and has no Web UI.
|
||||
When enabled, every request requires a device bearer token.
|
||||
Hardware plugin integration defaults to disabled (`[integration] enabled=false`):
|
||||
no Unix API socket is created, while local telemetry and hardware workers continue.
|
||||
Change integration locally and restart the plugin. Monitor-to-plugin linkage does
|
||||
not require the Agent network API. The independent Web Manager stores registered
|
||||
device addresses and tokens; it is not implicitly a monitored device.
|
||||
|
||||
+28
-7
@@ -2,22 +2,24 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
NO_START=0
|
||||
API_ENABLED=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--enable-api) API_ENABLED=true ;;
|
||||
--disable-api) API_ENABLED=false ;;
|
||||
--no-start) NO_START=1 ;;
|
||||
-h|--help) echo 'Usage: sudo ./install.sh [--no-start]'; exit 0 ;;
|
||||
-h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--enable-api|--disable-api]'; exit 0 ;;
|
||||
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
|
||||
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
|
||||
done
|
||||
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
|
||||
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
|
||||
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
|
||||
mkdir -p /usr/local/share/pigway-pi-control/web
|
||||
mkdir -p /usr/local/share/pigway-pi-control
|
||||
install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
|
||||
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
|
||||
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
||||
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
|
||||
if [ -f /etc/pigway-pi-control.conf ]; then
|
||||
@@ -32,7 +34,7 @@ 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'},'hosts':{},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
|
||||
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()))
|
||||
PY
|
||||
@@ -42,12 +44,31 @@ fi
|
||||
chmod 0600 /etc/pigway-pi-control-api.token
|
||||
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
|
||||
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
|
||||
if [ -z "$API_ENABLED" ]; then
|
||||
API_ENABLED=false
|
||||
if [ -t 0 ]; then
|
||||
read -r -p 'Enable remote management API? [y/N] ' answer
|
||||
case "$answer" in y|Y|yes|YES) API_ENABLED=true ;; esac
|
||||
fi
|
||||
fi
|
||||
API_ENABLED="$API_ENABLED" python3 - <<'PYAPI'
|
||||
import os,sys
|
||||
sys.path.insert(0,'app')
|
||||
import pigway_pi_control_api as api
|
||||
api.save_ini_text(api.replace_ini_value('api','enabled',os.environ['API_ENABLED']),'install')
|
||||
PYAPI
|
||||
systemctl daemon-reload
|
||||
if [ "$NO_START" -eq 0 ]; then
|
||||
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
|
||||
systemctl restart pigway-pi-control.service pigway-pi-control-api.service
|
||||
systemctl enable pigway-pi-control.service >/dev/null
|
||||
systemctl restart pigway-pi-control.service
|
||||
systemctl is-active --quiet pigway-pi-control.service
|
||||
if [ "$API_ENABLED" = true ]; then
|
||||
systemctl enable pigway-pi-control-api.service >/dev/null
|
||||
systemctl restart pigway-pi-control-api.service
|
||||
systemctl is-active --quiet pigway-pi-control-api.service
|
||||
else
|
||||
systemctl disable --now pigway-pi-control-api.service
|
||||
fi
|
||||
fi
|
||||
echo 'Monitor installed. Hardware plugins are installed and connected separately.'
|
||||
echo 'Config: /etc/pigway-pi-control.conf'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=PIGWay Pi Control API and Web
|
||||
Description=PIGWay Pi Control optional local API
|
||||
After=network.target pigway-pi-control.service
|
||||
Wants=network.target
|
||||
|
||||
@@ -7,6 +7,8 @@ Wants=network.target
|
||||
Type=simple
|
||||
ExecStart=/usr/local/sbin/pigway-pi-control-api
|
||||
Restart=always
|
||||
RestartPreventExitStatus=78
|
||||
SuccessExitStatus=78
|
||||
RestartSec=2
|
||||
User=root
|
||||
NoNewPrivileges=true
|
||||
|
||||
-134
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user