feat: add standalone fleet web manager

This commit is contained in:
way
2026-09-27 22:28:03 +08:00
commit cc2320523e
10 changed files with 444 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
__pycache__
*.pyc
.data
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.DS_Store
.data/
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.13-slim
WORKDIR /app
COPY app ./app
COPY web ./web
RUN mkdir -p /data && chown -R 10001:10001 /data /app
USER 10001:10001
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIGWAY_DATA=/data PIGWAY_PORT=6001
EXPOSE 6001
CMD ["python3", "app/server.py"]
+48
View File
@@ -0,0 +1,48 @@
# PIGWay Web Manager
独立的多设备管理中心,可运行在 NAS、服务器或其他能访问设备 API 的主机上。所有设备都显式登记,没有默认“本机”。不采集所在主机状态,不控制硬件,也不依赖 systemd。
## Docker Compose
```bash
docker compose up -d --build
docker compose exec manager cat /data/manager.token
```
打开 `http://管理中心地址:6001`,输入管理中心令牌。在“机器管理”中添加设备地址及该设备的 API 令牌。管理中心令牌与设备令牌不同;设备令牌仅保存在服务端的数据卷,列表不会返回令牌。管理中心登录令牌仅保存在当前浏览器会话中。
用 `PIGWAY_PORT=6002 docker compose up -d --build` 更改外部端口。数据卷保存设备登记与令牌,应限制备份的访问权限。日志按需查询设备 journal,不建立日志数据库。
## 直接运行(仅 Python 标准库)
```bash
PIGWAY_DATA="$PWD/.data" PIGWAY_PORT=6001 python3 app/server.py
cat .data/manager.token
```
不需要 root、I2C、监控 Agent 或硬件插件。远端部署需要到设备 API 的网络可达性;跨公网通过 HTTPS 反向代理或 VPN,不使用明文 HTTP 传输令牌。HTTP 客户端拒绝重定向,避免把设备凭据转发到其他地址。
## 可选 systemd 安装
```bash
sudo ./install.sh --port 6001
sudo cat /var/lib/pigway-web-manager/manager.token
```
独立服务 `pigway-web-manager.service`,不启动任何 Agent 或插件。
## 功能
- 统一设备列表,添加、更新地址/令牌、移除及连接检测。
- 多机状态卡片、按设备查询 journal、配置与插件管理。
- 按层级选择配置项、全选/反选、批量同步,逐设备报告结果。
- 中英文、自动/浅色/深色主题。
- 单台离线不会停止其他设备查询;日志查询失败会提示部分结果。
最多登记32台设备;并发查询最多8台。单设备单次日志聚合上限50000条,超出时明确提示缩小日期范围,不静默截断。
## 三个项目
- [本地监控服务](https://tea.pigway.com/way/pigway-pi-control):本机监控、告警、日志与可选 API,默认不开启 API,无 Web。
- [硬件插件](https://tea.pigway.com/way/pigway-cooling-hat):独立显示、温控和灯效,可选本机接入,默认关闭。
- 管理中心停止或卸载,不会停止任何设备上的服务或插件。
+9
View File
@@ -0,0 +1,9 @@
535a162cf05549e3089c61b1850b25627b48bf606548a41e4c10bf461f5ccf97 .dockerignore
62dbe9a72fc425104d5dfe5c63d089cd7785e49ca94386463aa9372dee48163f .gitignore
e3d6adb86d19118a02a43aeb929e8fa8ca486e0721586652d908c1822f4a4dcc Dockerfile
170512d52464fa4ef36b5fd878bdd0b8267aed30ad35c06f19131ff877829dfb README.md
3c1531ef7c99dc3672d091715c88a0803eb117ad21601200a9b5753bf818fc76 app/server.py
d3cdab188f694f7216d3bb361394a2e160a8b3d425c1a904ede7372698582a3e compose.yaml
148e0523932cc4e65537ec2cff38ee93fda1a9447c411ae410cbec8b7b93d6bb install.sh
a9bbad4aa96390da1053cd0ef483f99ade6ca138349569dab004742666027c25 systemd/pigway-web-manager.service
138b907185479f91d10537b540381235bda83dbad08776406a8a243f37b16eac web/index.html
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Standalone fleet manager. No local sensors, systemd or hardware dependencies."""
import concurrent.futures
import json
import os
import re
import secrets
import threading
import time
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse,parse_qs,urlencode
from urllib.request import Request,build_opener,HTTPRedirectHandler
from urllib.error import HTTPError
DATA=Path(os.environ.get('PIGWAY_DATA','/data'))
WEB=Path(__file__).resolve().parent.parent/'web/index.html'
LOCK=threading.RLock()
MAX_HOSTS=32
def initialize():
DATA.mkdir(parents=True,exist_ok=True,mode=0o700)
token=DATA/'manager.token'
if not token.exists():
fd=os.open(token,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
with os.fdopen(fd,'w') as f:f.write(secrets.token_urlsafe(32)+'\n')
return token.read_text().strip()
def registry():
with LOCK:
path=DATA/'hosts.json'
return json.loads(path.read_text()) if path.exists() else []
def public(host):return {k:v for k,v in host.items() if k!='token'}
def host_by_id(identifier):
return next((h for h in registry() if h['id']==identifier),None) or fail('unknown host')
def fail(message):raise ValueError(message)
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self,*args,**kwargs):return None
def remote(host,path,method='GET',payload=None):
body=None if payload is None else json.dumps(payload).encode()
req=Request(host['url']+path,data=body,method=method,headers={'Authorization':'Bearer '+host['token'],'Content-Type':'application/json'})
try:
with build_opener(NoRedirect).open(req,timeout=5) as response:
raw=response.read(16*1024*1024+1)
if len(raw)>16*1024*1024:raise ValueError('device response exceeds size limit')
return json.loads(raw)
except HTTPError as exc:raise ValueError('device API returned HTTP '+str(exc.code)) from None
def edit_host(body):
identifier=body.get('id','')
if not isinstance(identifier,str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,64}',identifier):fail('invalid host identifier')
with LOCK:
hosts=registry();old=next((h for h in hosts if h['id']==identifier),None)
operation=body.get('operation')
if operation=='save':
url=str(body.get('url','')).rstrip('/');parsed=urlparse(url)
if parsed.scheme not in ('http','https') or not parsed.hostname or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:fail('use an HTTP(S) origin without credentials or path')
token=body.get('token') or (old or {}).get('token')
if not isinstance(token,str) or not token or len(token)>1024 or '\n' in token or '\r' in token:fail('device token required')
if not old and len(hosts)>=MAX_HOSTS:fail('at most 32 devices')
entry={'id':identifier,'name':identifier,'url':url,'token':token,'local':False}
hosts=[entry if h['id']==identifier else h for h in hosts] if old else hosts+[entry]
elif operation=='delete':hosts=[h for h in hosts if h['id']!=identifier]
else:fail('invalid operation')
path=DATA/'hosts.tmp'
with path.open('w') as f:json.dump(hosts,f)
path.chmod(0o600);path.replace(DATA/'hosts.json')
return {'hosts':[public(h) for h in hosts]}
def parallel(hosts,fn):
with concurrent.futures.ThreadPoolExecutor(max_workers=min(8,max(1,len(hosts)))) as pool:
return list(pool.map(fn,hosts))
def fleet_status():
def fetch(host):
try:return {'host':public(host),'online':True,'status':remote(host,'/api/v1/status')}
except Exception:return {'host':public(host),'online':False,'error':'Device unavailable; check address, token and API status'}
return {'hosts':parallel(registry(),fetch),'timestamp':int(time.time())}
def fleet_logs(query):
hosts=registry();selected=query.get('machine','')
if selected:hosts=[h for h in hosts if h['id']==selected]
params={k:v for k,v in query.items() if k in ('since','until','severity','event','search')}
params.update(limit=1000,sort='timestamp',order='desc')
def fetch(host):
try:
rows=[];offset=0;events=set()
while True:
value=remote(host,'/api/v1/logs?'+urlencode({**params,'offset':offset}))
batch=value['logs'];rows.extend({**r,'machine_id':host['id'],'machine_name':host['name']} for r in batch)
events.update(value.get('event_types',[]));offset+=len(batch)
if offset>=value['total'] or not batch:break
if offset>=50000:return rows,events,{'id':host['id'],'error':'Device log query exceeds 50000 rows; narrow date range'}
return rows,events,None
except Exception:return [],set(),{'id':host['id'],'error':'Device log query failed'}
results=parallel(hosts,fetch);rows=[row for result in results for row in result[0]]
ranks={'EMERGENCY':0,'ALERT':1,'CRITICAL':2,'ERROR':3,'WARN':4,'NOTICE':5,'INFO':6,'DEBUG':7}
sort=query.get('sort','timestamp');order=query.get('order','desc')
if sort not in ('timestamp','machine','severity','event','message') or order not in ('asc','desc'):fail('invalid sorting')
rows.sort(key=lambda r: ranks.get(r['severity'],99) if sort=='severity' else r['machine_name'] if sort=='machine' else r[sort],reverse=order=='desc')
offset=max(0,int(query.get('offset',0)));limit=min(1000,max(1,int(query.get('limit',100))))
return {'logs':rows[offset:offset+limit],'total':len(rows),'offset':offset,'limit':limit,'event_types':sorted(set().union(*(r[1] for r in results))),
'machines':[{'id':h['id'],'name':h['name']} for h in registry()],'errors':[r[2] for r in results if r[2]],'retention':'device-systemd-journal'}
class Handler(BaseHTTPRequestHandler):
def setup(self):super().setup();self.connection.settimeout(15)
def log_message(self,*args):pass
def send(self,code,data,html=False):
raw=data if html else json.dumps(data,ensure_ascii=False).encode()
self.send_response(code);self.send_header('Content-Type','text/html; charset=utf-8' if html else 'application/json')
self.send_header('Content-Length',str(len(raw)));self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff');self.send_header('X-Frame-Options','DENY')
self.send_header('Content-Security-Policy',"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
self.end_headers();self.wfile.write(raw)
def authorized(self):
return secrets.compare_digest(self.headers.get('Authorization',''),'Bearer '+self.server.token)
def handle_request(self,method):
parsed=urlparse(self.path);path=parsed.path;q={k:v[0] for k,v in parse_qs(parsed.query).items()}
if method=='GET' and path=='/':self.send(200,WEB.read_bytes(),True);return
if not self.authorized():self.send(401,{'error':'Manager token required'});return
try:
if method=='GET':
if path=='/api/v1/hosts':result={'hosts':[public(h) for h in registry()]}
elif path=='/api/v1/fleet/status':result=fleet_status()
elif path=='/api/v1/fleet/logs':result=fleet_logs(q)
elif path in ('/api/v1/config','/api/v1/services','/api/v1/plugins','/api/v1/plugin/config'):
host=host_by_id(q.get('host',''));result=remote(host,path+('?' +urlencode({'id':q['id']}) if 'id' in q else ''))
else:self.send(404,{'error':'not found'});return
else:
length=int(self.headers.get('Content-Length',0))
if not 0<length<=65536:fail('invalid body length')
body=json.loads(self.rfile.read(length))
if not isinstance(body,dict):fail('object required')
if path=='/api/v1/hosts':result=edit_host(body)
elif path=='/api/v1/hosts/check':result=remote(host_by_id(body.get('id')),'/api/v1/health')
elif path=='/api/v1/fleet/config':
targets=body.get('targets',[])
if not isinstance(targets,list) or not 1<=len(targets)<=32:fail('select 1..32 devices')
results=[]
for identifier in dict.fromkeys(targets):
try:remote(host_by_id(identifier),'/api/v1/config','PUT',{'updates':body.get('updates')});results.append({'id':identifier,'ok':True})
except Exception:results.append({'id':identifier,'ok':False,'error':'Device update failed; check connection and configuration'})
result={'results':results}
elif path in ('/api/v1/services','/api/v1/services/control','/api/v1/plugins','/api/v1/plugin/config'):
result=remote(host_by_id(body.get('host','')),path,'PUT',{k:v for k,v in body.items() if k not in ('host','target_token')})
else:self.send(404,{'error':'not found'});return
self.send(200,result)
except (ValueError,TypeError,KeyError) as exc:self.send(400,{'error':str(exc)})
except Exception:self.send(502,{'error':'Device unavailable or request failed'})
def do_GET(self):self.handle_request('GET')
def do_PUT(self):self.handle_request('PUT')
def main():
token=initialize();server=ThreadingHTTPServer((os.environ.get('PIGWAY_BIND','0.0.0.0'),int(os.environ.get('PIGWAY_PORT','6001'))),Handler);server.token=token
print('Manager ready. Read access token from '+str(DATA/'manager.token'),flush=True)
try:server.serve_forever()
finally:server.server_close()
if __name__=='__main__':main()
+17
View File
@@ -0,0 +1,17 @@
services:
manager:
build: .
ports:
- "${PIGWAY_BIND:-0.0.0.0}:${PIGWAY_PORT:-6001}:6001"
volumes:
- manager-data:/data
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
manager-data:
Executable
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
PORT=6001
if [ "${1:-}" = --port ] && [ "$#" = 2 ]; then PORT="$2"; elif [ "$#" != 0 ]; then echo 'Usage: sudo ./install.sh [--port 6001]' >&2; exit 2; fi
[[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 1024 && PORT <= 65535 )) || { echo 'Invalid port' >&2; exit 2; }
for f in app/server.py web/index.html systemd/pigway-web-manager.service; do
[ -f "$f" ] || { echo "ERROR: required file missing: $f" >&2; exit 1; }
done
[ "$(id -u)" = 0 ] || { echo 'Run as root' >&2; exit 1; }
command -v python3 >/dev/null
mkdir -p /usr/local/lib/pigway-web-manager/{app,web}
install -m 0644 app/server.py /usr/local/lib/pigway-web-manager/app/
install -m 0644 web/index.html /usr/local/lib/pigway-web-manager/web/
sed "s/PIGWAY_PORT=6001/PIGWAY_PORT=$PORT/" systemd/pigway-web-manager.service > /etc/systemd/system/pigway-web-manager.service
systemctl daemon-reload
systemctl enable --now pigway-web-manager.service
systemctl restart pigway-web-manager.service
systemctl is-active --quiet pigway-web-manager.service
echo "Web Manager port: $PORT"
echo 'Token: sudo cat /var/lib/pigway-web-manager/manager.token'
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=PIGWay standalone Web Manager
After=network.target
[Service]
Type=simple
DynamicUser=yes
StateDirectory=pigway-web-manager
StateDirectoryMode=0700
Environment=PIGWAY_DATA=/var/lib/pigway-web-manager
Environment=PIGWAY_PORT=6001
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/bin/python3 /usr/local/lib/pigway-web-manager/app/server.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+134
View File
File diff suppressed because one or more lines are too long