Files
homelab/stacks/portal/server.py
T

347 lines
13 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
import mimetypes
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
PORT = int(os.environ.get("PORT", "3004"))
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "dist"
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 = 1
b_log = Path("/Users/ixvii0iv/homelab/logs/nas-backup.log")
if b_log.exists():
lines = [l for l in b_log.read_text(errors="ignore").splitlines() if "homelab_backup_" in l]
backup_count = len(lines)
if lines:
last_line = lines[-1]
m = re.search(r"homelab_backup_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})", last_line)
if m:
latest_backup = f"{m.group(2)}-{m.group(3)} {m.group(4)}:{m.group(5)}"
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 static assets from Vite build output (dist/) if available
clean_path = self.path.split("?", 1)[0].lstrip("/")
if clean_path and DIST_DIR.exists():
asset_file = (DIST_DIR / clean_path).resolve()
if str(asset_file).startswith(str(DIST_DIR.resolve())) and asset_file.is_file():
body = asset_file.read_bytes()
mime, _ = mimetypes.guess_type(str(asset_file))
self.send_response(200)
self.send_header("Content-Type", mime or "application/octet-stream")
self.send_header("Cache-Control", "public, max-age=86400")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
# Serve SPA index.html from dist/index.html (React+Vite build) or fallback index.html
spa_index = (DIST_DIR / "index.html") if (DIST_DIR / "index.html").exists() else INDEX_FILE
if spa_index.exists():
body = spa_index.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__":
# 同时监听主入口 3002 (https://nav.961504.xyz) 与预览端口 3004
srv_preview = ThreadingHTTPServer(("0.0.0.0", 3004), PortalHandler)
threading.Thread(target=srv_preview.serve_forever, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", 3002), PortalHandler)
print("[HomeLab Portal V3] Listening on http://0.0.0.0:3002 and :3004")
server.serve_forever()