#!/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()