#!/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", "3004")) 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 = 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 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()