Files
homelab/stacks/portal/server.py
T

329 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
# ==============================================================================
# HomeLab All-in-One 专属中控台后端服务 (Port 3002)
# 提供 Apple/Shadcn 风格单页应用与全屋设备实时遥测 API (/api/telemetry, /api/action)
# ==============================================================================
import json
import os
import re
import socket
import subprocess
import threading
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
PORT = int(os.environ.get("PORT", "3002"))
BASE_DIR = Path(__file__).resolve().parent
INDEX_FILE = BASE_DIR / "index.html"
_cache = {"data": None, "ts": 0.0}
_cache_lock = threading.Lock()
def run_cmd(cmd: str, timeout: float = 2.0) -> str:
try:
env = os.environ.copy()
env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
res = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=timeout, env=env
)
return res.stdout.strip()
except Exception:
return ""
def check_tcp(host: str, port: int, timeout: float = 0.8) -> tuple[bool, int]:
t0 = time.monotonic()
try:
with socket.create_connection((host, port), timeout=timeout):
ms = int((time.monotonic() - t0) * 1000)
return True, max(1, ms)
except Exception:
return False, -1
def get_mac_mini_stats() -> dict:
# 1. CPU Load & Core count
load_str = run_cmd("sysctl -n vm.loadavg")
# Format: "{ 1.42 1.18 1.05 }"
loads = [float(x) for x in re.findall(r"[0-9]+\.[0-9]+", load_str)] or [0.0, 0.0, 0.0]
cores = int(run_cmd("sysctl -n hw.ncpu") or "12")
cpu_pct = min(100, round((loads[0] / max(1, cores)) * 100, 1))
# 2. Memory (24GB total)
total_bytes = int(run_cmd("sysctl -n hw.memsize") or str(24 * 1024**3))
total_gb = round(total_bytes / (1024**3), 1)
vm_out = run_cmd("vm_stat")
page_size = 16384
m_ps = re.search(r"page size of (\d+) bytes", vm_out)
if m_ps:
page_size = int(m_ps.group(1))
pages = {}
for line in vm_out.splitlines():
if ":" in line:
k, v = line.split(":", 1)
v_clean = re.sub(r"[^0-9]", "", v)
if v_clean:
pages[k.strip()] = int(v_clean)
used_pages = (
pages.get("Pages active", 0)
+ pages.get("Pages wired down", 0)
+ pages.get("Pages occupied by compressor", 0)
)
used_gb = round((used_pages * page_size) / (1024**3), 1)
mem_pct = min(100, round((used_gb / max(1.0, total_gb)) * 100, 1))
# 3. Disk (/System/Volumes/Data or /)
df_out = run_cmd("df -k /System/Volumes/Data || df -k /")
disk_total_gb, disk_used_gb, disk_pct = 512.0, 40.0, 8.0
lines = df_out.splitlines()
if len(lines) >= 2:
parts = lines[-1].split()
if len(parts) >= 5:
total_k = int(parts[1])
avail_k = int(parts[3])
used_k = total_k - avail_k
disk_total_gb = round(total_k / (1024**2), 1)
disk_used_gb = round(used_k / (1024**2), 1)
disk_pct = round((used_k / max(1, total_k)) * 100, 1)
# 4. Uptime
boot_out = run_cmd("sysctl -n kern.boottime")
m_sec = re.search(r"sec = (\d+)", boot_out)
uptime_str = "运行中"
if m_sec:
up_sec = max(0, int(time.time()) - int(m_sec.group(1)))
days = up_sec // 86400
hours = (up_sec % 86400) // 3600
mins = (up_sec % 3600) // 60
uptime_str = f"{days}天 {hours}小时" if days > 0 else f"{hours}小时 {mins}分"
# 5. Docker Containers
containers = []
docker_out = run_cmd("docker ps --format '{{.Names}}|{{.Status}}|{{.Image}}'")
for line in docker_out.splitlines():
if "|" in line:
name, status, img = line.split("|", 2)
containers.append({"name": name, "status": status, "image": img})
# 6. AGY tmux session
tmux_out = run_cmd("tmux list-sessions 2>/dev/null")
agy_running = "agy:" in tmux_out
return {
"chip": "Apple M6 (12核)",
"ip": "192.168.31.164",
"tailscale_ip": "100.108.205.40",
"cpu_pct": cpu_pct,
"load_1m": loads[0],
"cores": cores,
"mem_used_gb": used_gb,
"mem_total_gb": total_gb,
"mem_pct": mem_pct,
"disk_used_gb": disk_used_gb,
"disk_total_gb": disk_total_gb,
"disk_pct": disk_pct,
"uptime": uptime_str,
"containers": containers,
"agy_tmux": agy_running,
}
def get_nas_stats() -> dict:
nas_path = Path("/Users/ixvii0iv/nas/个人空间")
mounted = nas_path.exists() and os.path.ismount("/Users/ixvii0iv/nas/个人空间")
total_tb, used_tb, free_tb, pct = 20.0, 2.9, 17.1, 15.0
if mounted:
df_out = run_cmd("df -k /Users/ixvii0iv/nas/个人空间")
lines = df_out.splitlines()
if len(lines) >= 2:
parts = lines[-1].split()
if len(parts) >= 5:
total_k = int(parts[1])
avail_k = int(parts[3])
used_k = total_k - avail_k
total_tb = round(total_k / (1024**3), 2)
free_tb = round(avail_k / (1024**3), 2)
used_tb = round(used_k / (1024**3), 2)
pct = round((used_k / max(1, total_k)) * 100, 1)
latest_backup = "暂无记录"
backup_count = 0
if mounted:
ls_out = run_cmd("ls -lh /Users/ixvii0iv/nas/个人空间/MacMini_Backups/homelab_backup_*.tar.gz 2>/dev/null")
lines = [l for l in ls_out.splitlines() if "homelab_backup_" in l]
backup_count = len(lines)
if lines:
last_file = lines[-1].split()[-1]
fname = os.path.basename(last_file)
m = re.search(r"homelab_backup_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})", fname)
if m:
latest_backup = f"{m.group(2)}-{m.group(3)} {m.group(4)}:{m.group(5)} (共{backup_count}份)"
else:
latest_backup = f"已备份 ({backup_count}份)"
return {
"name": "极空间 Z4",
"ip": "192.168.31.185",
"online": mounted,
"latency_ms": 1 if mounted else -1,
"mounted": mounted,
"total_tb": total_tb,
"used_tb": used_tb,
"free_tb": free_tb,
"pct": pct,
"latest_backup": latest_backup,
"backup_count": backup_count,
}
def get_network_and_edge_stats() -> dict:
# 1. Home Public IP from DDNS log
home_ip = "124.90.142.21"
ddns_log = Path("/Users/ixvii0iv/homelab/logs/cf-ddns.log")
if ddns_log.exists():
try:
last_lines = ddns_log.read_text(errors="ignore").splitlines()[-10:]
for line in reversed(last_lines):
m = re.search(r"公网 IP:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", line)
if m:
home_ip = m.group(1)
break
except Exception:
pass
# 2. Query Xiaomi Router Mihomo API (192.168.31.1:9999) for AI group & SG node delay
ai_group = "狮城节点"
sg_node = "coco-sea1-reality-sony"
sg_delay_ms = -1
router_online, router_ms = check_tcp("192.168.31.1", 9999)
if router_online:
try:
with urllib.request.urlopen("http://192.168.31.1:9999/proxies", timeout=1.2) as r:
pdata = json.loads(r.read().decode()).get("proxies", {})
ai_group = pdata.get("Ai平台", {}).get("now", "狮城节点")
sg_group = pdata.get("狮城节点", {})
sg_node = sg_group.get("now", "coco-sea1-reality-sony")
hist = pdata.get(sg_node, {}).get("history", [])
if hist:
sg_delay_ms = hist[-1].get("delay", -1)
except Exception:
pass
# 3. Check Azure Cloud Edge (104.43.105.243:443)
azure_online, azure_ms = check_tcp("104.43.105.243", 443, timeout=1.2)
return {
"home_wan_ip": home_ip,
"router_ip": "192.168.31.1",
"router_online": router_online,
"router_ms": router_ms,
"ai_policy": f"Ai平台 → {ai_group}",
"sg_node": sg_node,
"sg_egress_ip": "104.43.105.243",
"sg_delay_ms": sg_delay_ms,
"azure_tailscale_ip": "100.115.204.51",
"azure_online": azure_online,
"azure_ms": azure_ms,
}
def collect_telemetry() -> dict:
now = time.time()
with _cache_lock:
if _cache["data"] and (now - _cache["ts"] < 2.0):
return _cache["data"]
with ThreadPoolExecutor(max_workers=4) as pool:
f_mini = pool.submit(get_mac_mini_stats)
f_nas = pool.submit(get_nas_stats)
f_net = pool.submit(get_network_and_edge_stats)
f_agy = pool.submit(check_tcp, "127.0.0.1", 7681)
f_vw = pool.submit(check_tcp, "127.0.0.1", 8222)
mini = f_mini.result()
nas = f_nas.result()
net = f_net.result()
agy_ok, _ = f_agy.result()
vw_ok, _ = f_vw.result()
data = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"mini": mini,
"nas": nas,
"network": net,
"services": {
"agy": agy_ok,
"vaultwarden": vw_ok,
"nas": nas["online"],
"clash": net["router_online"],
"router": net["router_online"],
"uptime": net["azure_online"],
},
}
with _cache_lock:
_cache["data"] = data
_cache["ts"] = now
return data
class PortalHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _send_json(self, obj: dict, status: int = 200):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path.startswith("/api/telemetry"):
self._send_json(collect_telemetry())
return
# Serve SPA index.html for root and any path
if INDEX_FILE.exists():
body = INDEX_FILE.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_error(404, "index.html not found")
def do_POST(self):
if self.path == "/api/action/backup_nas":
out = run_cmd("/Users/ixvii0iv/homelab/scripts/backup-to-nas.sh", timeout=15.0)
with _cache_lock:
_cache["ts"] = 0.0
self._send_json({"ok": True, "message": out or "备份已完成"})
return
if self.path == "/api/action/mount_nas":
out = run_cmd("/Users/ixvii0iv/homelab/scripts/mount-zspace.sh", timeout=10.0)
with _cache_lock:
_cache["ts"] = 0.0
self._send_json({"ok": True, "message": out or "极空间挂载巡检完成"})
return
if self.path == "/api/action/refresh_ddns":
out = run_cmd("/Users/ixvii0iv/homelab/scripts/cloudflare-ddns.sh", timeout=10.0)
with _cache_lock:
_cache["ts"] = 0.0
self._send_json({"ok": True, "message": out or "Cloudflare DDNS 已刷新"})
return
self._send_json({"ok": False, "message": "Unknown action"}, status=404)
if __name__ == "__main__":
server = ThreadingHTTPServer(("0.0.0.0", PORT), PortalHandler)
print(f"[HomeLab Portal] Listening on http://0.0.0.0:{PORT}")
server.serve_forever()