diff --git a/scripts/backup-to-nas.sh b/scripts/backup-to-nas.sh index 702e9e2..92b6419 100755 --- a/scripts/backup-to-nas.sh +++ b/scripts/backup-to-nas.sh @@ -22,7 +22,7 @@ ARCHIVE="$NAS_BACKUP_DIR/homelab_backup_${TIMESTAMP}.tar.gz" tar -czf "$ARCHIVE" \ -C /Users/ixvii0iv/homelab \ stacks/vaultwarden/data \ - stacks/sun-panel/conf \ + stacks/portal \ configs \ scripts \ README.md PLAN.md CHANGELOG.md 2>/dev/null || true diff --git a/stacks/portal/index.html b/stacks/portal/index.html new file mode 100644 index 0000000..121192d --- /dev/null +++ b/stacks/portal/index.html @@ -0,0 +1,696 @@ + + + + + + HomeLab OS · M6 All-in-One 中控台 + + + + + + + +
+ + + +
+ + {{ toast }} +
+
+ + +
+
+
+
+ + + +
+
+
+

HomeLab OS

+ + + 4/4 节点在线 + +
+

+ Apple M6 Mac mini (192.168.31.164) ⇄ 极空间 Z4 (20TB) ⇄ SG Azure (104.43.105.243) +

+
+
+
+ + +
+
+ +
+ + +
+
+ + +
+ + +
+
+
+
+ Core Compute Server + + UP {{ telemetry.mini.uptime }} + +
+

+ 🖥️ Mac mini · {{ telemetry.mini.chip }} +

+
+ + {{ telemetry.mini.ip }} + +
+ + +
+ +
+
+ CPU 负载 (12核 M6 · Load {{ telemetry.mini.load_1m }}) + {{ telemetry.mini.cpu_pct }}% +
+
+
+
+
+ + +
+
+ 统一内存 ({{ telemetry.mini.mem_used_gb }} / {{ telemetry.mini.mem_total_gb }} GB) + {{ telemetry.mini.mem_pct }}% +
+
+
+
+
+ + +
+
+ 系统闪存 ({{ telemetry.mini.disk_used_gb }} / {{ telemetry.mini.disk_total_gb }} GB) + {{ telemetry.mini.disk_pct }}% +
+
+
+
+
+
+ + +
+
+ + AGY tmux: {{ telemetry.mini.agy_tmux ? '常驻运行中' : '待机' }} +
+
+ OrbStack 容器: {{ telemetry.mini.containers.length }} 运行中 +
+
+
+ + +
+
+
+
+ Storage Pool & Backup + + {{ telemetry.nas.mounted ? 'SMB 已挂载 ~/nas' : '未挂载' }} + +
+

+ 💾 极空间 Z4 · 20TB 存储中心 +

+
+ + {{ telemetry.nas.ip }} + +
+ + +
+
+
+ {{ telemetry.nas.free_tb }} TB + 剩余可用 +
+ + 已用 {{ telemetry.nas.used_tb }} TB / 总计 {{ telemetry.nas.total_tb }} TB ({{ telemetry.nas.pct }}%) + +
+
+
+
+
+ 挂载点: ~/nas/{共享文件,个人空间} + Time Machine: 手动按需 +
+
+ + +
+
+ 最近自动备份: {{ telemetry.nas.latest_backup }} +
+ +
+
+ + +
+
+
+
+ AI Egress & Cloud Edge + + SG 出口死锁保护 + +
+

+ 🛡️ 小米主路由 ⇄ 新加坡 Azure +

+
+ + {{ telemetry.network.sg_delay_ms > 0 ? telemetry.network.sg_delay_ms + ' ms' : '专线直连' }} + +
+ + +
+
+ AGY / AI 分流策略 + {{ telemetry.network.ai_policy }} +
+
+ AI 锁定公网出口 (SG) + {{ telemetry.network.sg_egress_ip }} ({{ telemetry.network.sg_node }}) +
+
+ 家庭联通公网 IPv4 + {{ telemetry.network.home_wan_ip }} (*.home.961504.xyz) +
+
+ + +
+ Tailscale P2P: 100.108.205.40 ⇄ 100.115.204.51 + +
+
+ +
+ + +
+
+
+

全屋核心服务矩阵 (Service Launchpad)

+ + 当前点击跳转模式: {{ activeModeLabel }} + +
+ +
+ + +
+ + +
+
+ ⌨️ 终端快捷指令 (点击即复制): +
+
+ +
+
+ + +
+
+
+
+
+ + + +
+ + 🤖 AGY 云端工作台 (tmux attach -t agy · 关窗不断连) + +
+
+ + ↗ 新标签页全屏打开 + + +
+
+ +
+
+ +
+ + + + diff --git a/stacks/portal/server.py b/stacks/portal/server.py new file mode 100755 index 0000000..9d99c6f --- /dev/null +++ b/stacks/portal/server.py @@ -0,0 +1,328 @@ +#!/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()