#!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler
import subprocess, json, secrets, socket, datetime, time, threading, os, base64, gc, ssl as ssl_mod
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed

CP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
with open(CP, "r") as f: cfg = json.load(f)
SERVERS = cfg["servers"]; LU = cfg["login_user"]; LP = cfg["login_pass"]
PP = cfg.get("panel_port", 8888); CT = cfg.get("cache_interval", 30); ST = cfg.get("ssh_timeout", 8)
ts = {}; vc = {"data": None}; clk = threading.Lock()

def ssh_cmd(srv, cmd, timeout=ST):
    pwd = srv.get("password", ""); port = str(srv["port"]); user = srv["user"]; ip = srv["ip"]
    b64 = base64.b64encode(cmd.encode()).decode()
    args = ["ssh", "-o", "ConnectTimeout=" + str(timeout), "-o", "StrictHostKeyChecking=no", "-o", "PreferredAuthentications=password", "-p", port, user + "@" + ip, "echo " + b64 + " | base64 -d | bash"]
    try:
        if pwd: args = ["sshpass", "-p", pwd] + args
        r = subprocess.run(args, capture_output=True, text=True, timeout=timeout + 10)
        return r.stdout.strip()
    except Exception as e: print(f"[SSH错误] {srv.get('name','?')}: {e}"); return ""

def fmt(s):
    if s >= 1024 ** 3: return "%.2f GB" % (s / 1024 ** 3)
    if s >= 1024 ** 2: return "%.2f MB" % (s / 1024 ** 2)
    if s >= 1024: return "%.2f KB" % (s / 1024)
    return "%.0f B" % s

def pcolor(p):
    if p >= 80: return "#f87171"
    if p >= 60: return "#fbbf24"
    return "#34d399"

def cn_up(s):
    s = s.replace("up ", "")
    for en, cn in [("weeks", "周"), ("week", "周"), ("days", "天"), ("day", "天"), ("hours", "小时"), ("hour", "小时"), ("minutes", "分钟"), ("minute", "分钟")]:
        s = s.replace(en, cn)
    return s.strip()

def check_ssl(host, port=443, timeout=3):
    try:
        ctx = ssl_mod.create_default_context()
        ctx.check_hostname = False; ctx.verify_mode = ssl_mod.CERT_NONE
        with socket.create_connection((host, port), timeout=timeout) as sock:
            with ctx.wrap_socket(sock, server_hostname=host) as ssock:
                cert = ssock.getpeercert()
                end_str = cert.get("notAfter", "")
                end_dt = datetime.datetime.strptime(end_str, "%b %d %H:%M:%S %Y %Z")
                days = (end_dt - datetime.datetime.now()).days
                return days, end_dt.strftime("%Y-%m-%d")
    except Exception as e: print(f"[SSL检测错误] {host}:{port} - {e}"); return None, None

def collect(srv):
    R = {"status": "offline", "cpu_load": "--", "memory_pct": 0, "disk_pct": 0,
         "uptime": "--", "rx": 0, "tx": 0, "month_rows": "", "hour_rows": "",
         "hour_list": [], "swap_pct": 0, "boot_time": "--", "cpu_load5": "--", "cpu_load15": "--",
         "ssl_days": None, "ssl_date": "", "ssl_domain": ""}
    if srv.get("local"):
        on = True
    else:
        on = False
        for _attempt in range(2):
            try:
                s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.settimeout(3)
                s.connect((srv["ip"], srv["port"])); s.close(); on = True; break
            except:
                if _attempt == 0: time.sleep(1)
    if not on: return R
    R["status"] = "online"
    NL = chr(10)
    cmd = NL.join([
        "echo ===START",
        "(free | grep -E 'Mem|内存') | awk '{print int($3/$2 * 100)}'", "echo ===MEM",
        "uptime | awk -F'load average:' '{print $2}' | tr -d ' '", "echo ===CPU",
        "uptime -p", "echo ===UPT",
        "vnstat --json m 2>/dev/null || echo {}", "echo ===VM",
        "vnstat --json d 2>/dev/null || echo {}", "echo ===VD",
        "vnstat --json h 2>/dev/null || echo {}", "echo ===VH",
        "df / | awk 'NR==2{print int($3/$2*100)}'", "echo ===DISK",
        "free | grep -E 'Swap|交换' | awk '{if($2>0) print int($3/$2*100); else print 0}'", "echo ===SWAP",
        "uptime -s", "echo ===BOOT",
        "echo ===END"])
    if srv.get("local"):
        try:
            cb = base64.b64encode(cmd.encode()).decode()
            r = subprocess.run(["bash", "-c", "echo " + cb + " | base64 -d | bash"], capture_output=True, text=True, timeout=15)
            raw = r.stdout.strip()
        except Exception as e: print(f"[本地采集错误] {srv['name']}: {e}"); raw = ""
    else: raw = ssh_cmd(srv, cmd)
    if not raw: R["status"] = "offline"; return R

    def btw(t, a, b):
        try:
            i = t.index(a) + len(a); j = t.index(b, i)
            return t[i:j].replace("\\n", "").strip()
        except: return ""

    ms = btw(raw, "===START", "===MEM")
    cs = btw(raw, "===MEM", "===CPU")
    us = btw(raw, "===CPU", "===UPT")
    vm = btw(raw, "===UPT", "===VM") or "{}"
    vd = btw(raw, "===VM", "===VD") or "{}"
    vh = btw(raw, "===VD", "===VH") or "{}"
    ds = btw(raw, "===VH", "===DISK") or "0"
    sp = btw(raw, "===DISK", "===SWAP") or "0"
    bt = btw(raw, "===SWAP", "===BOOT") or "--"
    try: R["memory_pct"] = int(ms)
    except: pass
    try: R["disk_pct"] = int(ds)
    except: pass
    try: R["swap_pct"] = int(sp)
    except: pass
    R["boot_time"] = bt.strip() if bt and bt != "--" else "--"
    loads = cs.split(",") if cs else ["--", "--", "--"]
    R["cpu_load"] = loads[0] if len(loads) > 0 else "--"
    R["cpu_load5"] = loads[1] if len(loads) > 1 else "--"
    R["cpu_load15"] = loads[2] if len(loads) > 2 else "--"
    R["uptime"] = cn_up(us) if us else "--"

    try:
        dm = json.loads(vm)
        for it in dm.get("interfaces", [{}])[0].get("traffic", {}).get("month", [])[:3]:
            r2, t2 = it.get("rx", 0), it.get("tx", 0); dt = it.get("date", {})
            m = "%s-%02d" % (dt.get("year", ""), dt.get("month", 0))
            R["month_rows"] += "<tr><td>" + m + "</td><td class='rx'>" + fmt(t2) + "</td><td class='rx'>" + fmt(r2) + "</td><td class='tt'>" + fmt(r2 + t2) + "</td></tr>"
    except Exception as e: print(f"[vnstat月度解析错误] {srv['name']}: {e}")
    try:
        dd = json.loads(vd)
        dl = dd.get("interfaces", [{}])[0].get("traffic", {}).get("day", [])
        if dl: R["rx"] = dl[-1].get("rx", 0); R["tx"] = dl[-1].get("tx", 0)
    except Exception as e: print(f"[vnstat日解析错误] {srv['name']}: {e}")
    try:
        dh = json.loads(vh)
        hour_all = dh.get("interfaces", [{}])[0].get("traffic", {}).get("hour", [])
        for it in hour_all[-24:]:
            r2, t2 = it.get("rx", 0), it.get("tx", 0); dt = it.get("date", {})
            hm = "%02d-%02d %02d:00" % (dt.get("month", 0), dt.get("day", 0), it.get("time", {}).get("hour", 0))
            R["hour_list"].append((hm, r2, t2))
        for it in hour_all[-8:]:
            r2, t2 = it.get("rx", 0), it.get("tx", 0); dt = it.get("date", {})
            hm = "%02d-%02d %02d:00" % (dt.get("month", 0), dt.get("day", 0), it.get("time", {}).get("hour", 0))
            R["hour_rows"] += "<tr><td>" + hm + "</td><td class='rx'>" + fmt(t2) + "</td><td class='rx'>" + fmt(r2) + "</td></tr>"
    except Exception as e: print(f"[vnstat小时解析错误] {srv['name']}: {e}")

    sd_domain = srv.get("ssl_domain", "")
    if sd_domain:
        sd_port = srv.get("ssl_port", 443)
        days, date_str = check_ssl(sd_domain, sd_port, timeout=4)
        R["ssl_days"] = days; R["ssl_date"] = date_str; R["ssl_domain"] = sd_domain
    return R

def collect_all():
    now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    sds = []; trx = 0; ttx = 0
    n = len(SERVERS)
    results = [None] * n
    with ThreadPoolExecutor(max_workers=min(n, 8)) as executor:
        futures = {executor.submit(collect, srv): i for i, srv in enumerate(SERVERS)}
        for future in as_completed(futures):
            idx = futures[future]; srv = SERVERS[idx]
            try: sd = future.result()
            except Exception as e:
                print(f"[采集线程异常] {srv['name']}: {e}")
                sd = {"status": "offline", "cpu_load": "--", "memory_pct": 0, "disk_pct": 0,
                      "uptime": "--", "rx": 0, "tx": 0, "month_rows": "", "hour_rows": "",
                      "hour_list": [], "swap_pct": 0, "boot_time": "--", "cpu_load5": "--", "cpu_load15": "--",
                      "ssl_days": None, "ssl_date": "", "ssl_domain": ""}
            sd["name"] = srv["name"]; sd["ip"] = srv["ip"]; sd["port"] = srv["port"]
            results[idx] = sd
    for sd in results:
        if sd is not None: sds.append(sd); trx += sd.get("rx", 0); ttx += sd.get("tx", 0)
    return {"now": now, "servers": sds, "total_rx": trx, "total_tx": ttx}

def cache_loop():
    cycle = 0
    while True:
        try:
            d = collect_all()
            with clk: vc["data"] = d
            cycle += 1
            if cycle % 100 == 0: gc.collect()
        except Exception as e: print("[缓存循环错误]", e)
        now_t = time.time()
        expired = [k for k, v in ts.items() if now_t - v > 86400]
        for k in expired: ts.pop(k, None)
        time.sleep(CT)

def gd():
    with clk:
        if vc["data"] is None: vc["data"] = collect_all()
        return vc["data"]

def ca(h):
    now = time.time()
    for c in h.headers.get("Cookie", "").split(";"):
        c = c.strip()
        if c.startswith("token="):
            tk = c.split("=", 1)[1]
            if tk in ts and now - ts[tk] < 86400: return True
    return False

def sl(h, msg=""):
    h2 = ('<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录 · 监控面板</title><style>'
        '@keyframes vp{0%,100%{opacity:.8;transform:translate(-50%,-50%) scale(1)}50%{opacity:1;transform:translate(-50%,-50%) scale(1.06)}}'
        '@keyframes cf{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}'
        '@keyframes vs{0%{transform:translate(-50%,-50%) rotate(0deg)}100%{transform:translate(-50%,-50%) rotate(360deg)}}'
        '@keyframes vr{0%{transform:translate(-50%,-50%) rotate(360deg)}100%{transform:translate(-50%,-50%) rotate(0deg)}}'
        '*{margin:0;padding:0;box-sizing:border-box}'
        'body{font-family:system-ui,-apple-system,sans-serif;background:#0b1018;display:flex;justify-content:center;align-items:center;min-height:100vh;overflow:hidden}'
        '.vx{position:fixed;top:50%;left:50%;pointer-events:none;z-index:0}'
        '.vc{position:absolute;top:50%;left:50%;width:60px;height:60px;border-radius:50%;background:radial-gradient(circle,rgba(0,0,0,.95) 0%,rgba(10,14,20,.7) 35%,transparent 65%);box-shadow:0 0 80px rgba(99,102,241,.18);transform:translate(-50%,-50%);animation:vp 3s ease-in-out infinite}'
        '.r1{position:absolute;top:50%;left:50%;width:130px;height:130px;border-radius:50%;border:1.5px solid transparent;border-top-color:rgba(99,102,241,.35);animation:vs 5s linear infinite}'
        '.r2{position:absolute;top:50%;left:50%;width:190px;height:190px;border-radius:50%;border:1.5px solid transparent;border-top-color:rgba(139,92,246,.25);animation:vr 7s linear infinite}'
        '.r3{position:absolute;top:50%;left:50%;width:250px;height:250px;border-radius:50%;border:1px solid transparent;border-top-color:rgba(99,102,241,.18);animation:vs 9s linear infinite}'
        '.r4{position:absolute;top:50%;left:50%;width:310px;height:310px;border-radius:50%;border:1px solid transparent;border-top-color:rgba(139,92,246,.12);animation:vr 11s linear infinite}'
        '.lcard{position:relative;z-index:2;background:rgba(12,15,22,.8);backdrop-filter:blur(28px);-webkit-backdrop-filter:blur(28px);padding:48px 36px;border-radius:20px;width:380px;border:1px solid rgba(255,255,255,.06);box-shadow:0 4px 60px rgba(0,0,0,.6);animation:cf 5s ease-in-out infinite}'
        'h1{text-align:center;color:#f1f5f9;margin-bottom:6px;font-size:20px;font-weight:700}'
        '.sub{text-align:center;color:#64748b;font-size:13px;margin-bottom:28px}'
        'input{width:100%;padding:12px 16px;margin:6px 0;border:1px solid rgba(255,255,255,.07);border-radius:12px;background:rgba(0,0,0,.35);color:#e2e8f0;font-size:16px;outline:none;transition:border .2s}'
        'input:focus{border-color:rgba(99,102,241,.4);box-shadow:0 0 0 3px rgba(99,102,241,.08)}'
        'button{width:100%;padding:14px;margin-top:18px;border:none;border-radius:12px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-weight:600;font-size:15px;cursor:pointer;transition:all .2s}'
        'button:hover{transform:translateY(-1px);box-shadow:0 4px 20px rgba(99,102,241,.3)}'
        '.msg{color:#f87171;text-align:center;margin-bottom:14px;font-size:13px}'
        '</style></head><body>'
        '<div class="vx"><div class="vc"></div><div class="r1"></div><div class="r2"></div><div class="r3"></div><div class="r4"></div></div>'
        '<div class="lcard"><h1>监控中心登录</h1><div class="sub">请输入凭据以访问面板</div><div class="msg">' + msg + '</div><form method="POST"><input name="user" placeholder="用户名" required><input name="pass" type="password" placeholder="密码" required><button type="submit">登 录</button></form></div></body></html>')
    h.send_response(200); h.send_header("Content-type", "text/html; charset=utf-8"); h.end_headers(); h.wfile.write(h2.encode())

CSS = (":root{--bg:#0b1018;--card:rgba(255,255,255,.025);--bdr:rgba(255,255,255,.06);--t1:#f1f5f9;--t2:#64748b;--t3:#94a3b8;--ok:#34d399;--wn:#fbbf24;--bd:#f87171;--in:#60a5fa}"
    "*{margin:0;padding:0;box-sizing:border-box}"
    "body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--t1);padding:40px 24px;min-height:100vh;letter-spacing:-.2px}"
    "body::after{content:'';position:fixed;inset:0;background-image:radial-gradient(rgba(255,255,255,.015) 1px,transparent 1px);background-size:36px 36px;pointer-events:none;z-index:0}"
    "body>*{position:relative;z-index:1}"
    ".hdr{max-width:1400px;margin:0 auto 32px;display:flex;justify-content:space-between;align-items:flex-end}"
    ".hdr h1{font-size:24px;font-weight:700;letter-spacing:-.6px}"
    ".hdr .ut{color:var(--t2);font-size:13px;margin-top:4px}"
    ".lo{padding:8px 16px;background:rgba(255,255,255,.03);border:1px solid var(--bdr);color:var(--t2);text-decoration:none;border-radius:12px;font-size:13px;font-weight:500;transition:all .2s}"
    ".lo:hover{border-color:var(--bd);color:var(--bd)}"
    ".bento{max-width:1400px;margin:0 auto;display:grid;grid-template-columns:repeat(4,1fr);gap:20px}"
    ".bc{background:var(--card);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid var(--bdr);border-radius:16px;padding:24px;box-shadow:0 4px 24px rgba(0,0,0,.3);transition:transform .3s,box-shadow .3s,border-color .3s}"
    ".bc:hover{transform:translateY(-3px);box-shadow:0 12px 40px rgba(0,0,0,.4);border-color:rgba(255,255,255,.1)}"
    ".c2{grid-column:span 2}.c3{grid-column:span 3}.c4{grid-column:span 4}"
    ".ct{font-size:13px;font-weight:600;color:var(--t2);text-transform:uppercase;letter-spacing:.6px;margin-bottom:16px}"
    ".sg{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;height:100%}"
    ".si{display:flex;flex-direction:column;justify-content:center}"
    ".sv{font-size:32px;font-weight:700;letter-spacing:-1px;line-height:1.1;margin-bottom:4px}"
    ".sl{font-size:11px;color:var(--t2);font-weight:500;text-transform:uppercase;letter-spacing:.5px}"
    ".ncard{margin:0;padding:0;border:none;background:transparent;list-style:none}"
    ".ncard summary{cursor:pointer;user-select:none;padding:0}"
    ".ncard summary::-webkit-details-marker{display:none}"
    ".ncard summary::marker{display:none}"
    ".ncard summary .nh{border-bottom:none;padding-bottom:0;margin-bottom:0}"
    ".ncard[open] summary .nh{border-bottom:1px solid rgba(255,255,255,.05);padding-bottom:14px;margin-bottom:14px}"
    ".ncard[open] .arr{transform:rotate(90deg)}"
    ".arr{display:inline-block;font-size:13px;color:var(--t3);transition:transform .2s;margin-left:8px;vertical-align:middle}"
    ".nh{display:flex;justify-content:space-between;align-items:center}"
    ".nn{font-size:16px;font-weight:700;letter-spacing:-.3px}"
    ".ni{font-size:11px;color:var(--t2);font-family:monospace;margin-top:2px}"
    ".sd{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;padding:4px 12px;border-radius:20px;letter-spacing:.3px}"
    ".sd.on{background:rgba(52,211,153,.1);color:var(--ok)}"
    ".sd.off{background:rgba(248,113,113,.1);color:var(--bd)}"
    ".sd::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor}"
    ".rr{margin-bottom:12px}"
    ".rm{display:flex;justify-content:space-between;font-size:11px;font-weight:500;color:var(--t2);margin-bottom:5px}"
    ".rm .v{color:var(--t1);font-weight:600}"
    ".bt{width:100%;height:5px;background:rgba(255,255,255,.06);border-radius:3px;overflow:hidden}"
    ".bf{height:100%;border-radius:3px;transition:width .8s}"
    ".tb{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:16px;background:rgba(0,0,0,.2);padding:12px;border-radius:12px}"
    ".ti{text-align:center}"
    ".tl{font-size:10px;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin-bottom:2px}"
    ".tv{font-size:13px;font-weight:600}.tv.up{color:var(--in)}.tv.dn{color:var(--bd)}"
    ".dr{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;margin-top:12px;background:rgba(0,0,0,.15);border-radius:8px;font-size:11px;flex-wrap:wrap;gap:6px}"
    ".di{display:flex;align-items:center;gap:4px;color:var(--t3)}"
    ".di .dl{color:var(--t2);font-weight:500;text-transform:uppercase;font-size:10px;letter-spacing:.3px}"
    ".di .dv{font-weight:600;color:var(--t1)}"
    ".di .ok{color:var(--ok)}.di .wn{color:var(--wn)}.di .bd{color:var(--bd)}"
    ".offc{background:rgba(255,255,255,.01);border-style:dashed}.offc .nn{color:var(--t3)}"
    ".oph{height:100px;display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:13px;background:rgba(248,113,113,.03);border-radius:10px;border:1px dashed rgba(248,113,113,.15)}"
    ".tr{display:flex;align-items:center;gap:16px;margin-bottom:12px}.tr:last-child{margin-bottom:0}"
    ".tn{width:80px;font-size:13px;color:var(--t2);text-align:right;flex-shrink:0;font-weight:500}"
    ".tn.off{color:var(--t3);text-decoration:line-through}"
    ".ttk{flex:1;height:20px;background:rgba(255,255,255,.04);border-radius:10px;overflow:hidden}"
    ".tfl{height:100%;border-radius:10px;transition:width .8s}"
    ".tvl{width:80px;font-size:13px;font-weight:600;flex-shrink:0}.tvl.off{color:var(--t3)}"
    ".dt{width:100%;font-size:11px;border-collapse:collapse;margin-top:8px}"
    ".dt th{color:var(--t3);padding:6px 4px;border-bottom:1px solid rgba(255,255,255,.04);font-weight:500;text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.4px}"
    ".dt td{padding:6px 4px;text-align:left;color:var(--t2)}"
    ".rx{color:var(--bd)!important}.tx{color:var(--in)!important}.tt{color:var(--wn)!important}"
    ".ft{text-align:center;color:var(--t3);font-size:11px;padding:40px 0;margin-top:24px;letter-spacing:.3px;border-top:1px solid rgba(255,255,255,.03)}"
    ".msg{font-size:13px;color:var(--t3);margin-top:8px}"
    ".ts{background:var(--card);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid var(--bdr);border-radius:16px;padding:24px;text-align:center}"
    ".ts h2{color:var(--t2);font-size:14px;font-weight:600;margin-bottom:10px;letter-spacing:-.2px}"
    "@media(max-width:1200px){.bento{grid-template-columns:repeat(2,1fr)}.c3,.c4{grid-column:span 2}}"
    "@media(max-width:768px){body{padding:16px 10px}.hdr{flex-direction:column;align-items:flex-start;gap:12px;margin-bottom:20px}.hdr h1{font-size:20px}.bento{grid-template-columns:1fr;gap:14px}.c2,.c3,.c4{grid-column:span 1}.bc{padding:18px 14px}.sg{grid-template-columns:repeat(2,1fr);gap:12px}.sv{font-size:22px}.sl{font-size:10px}.nn{font-size:15px}.nh{flex-wrap:wrap;gap:8px}.dr{gap:4px;padding:6px 8px}.di{font-size:10px}.tr{gap:8px}.tn{width:55px;font-size:12px}.tvl{width:65px;font-size:12px}.tb{padding:10px 8px}.tv{font-size:12px}.ct{font-size:12px;margin-bottom:12px}}")

def gh(data):
    now = data["now"]; sv = data["servers"]; trx = data["total_rx"]; ttx = data["total_tx"]
    sv_sorted = sorted(sv, key=lambda x: (0 if x["status"] == "online" else 1, x["name"]))
    online_count = sum(1 for s in sv_sorted if s["status"] == "online")
    avg_mem = sum(s["memory_pct"] for s in sv_sorted if s["status"] == "online") / max(1, online_count)
    avg_cpu = sum(float(s["cpu_load"]) for s in sv_sorted if s["cpu_load"] != "--" and s["status"] == "online") / max(1, online_count)

    overview = ("<div class='bc c3'><div class='sg'>"
        "<div class='si'><span class='sv' style='color:var(--ok)'>" + str(online_count) + " <span style='font-size:15px;color:var(--t3);font-weight:400'>/ " + str(len(sv_sorted)) + "</span></span><span class='sl'>在线节点</span></div>"
        "<div class='si'><span class='sv'>" + str(round(avg_mem, 1)) + "%</span><span class='sl'>平均内存</span></div>"
        "<div class='si'><span class='sv'>" + str(round(avg_cpu, 1)) + "</span><span class='sl'>平均负载</span></div>"
        "<div class='si'><span class='sv' style='color:var(--in)'>" + fmt(trx + ttx) + "</span><span class='sl'>今日总流量</span></div>"
        "</div></div>")

    cards = ""
    for sd in sv_sorted:
        on = sd["status"] == "online"; sc = "on" if on else "off"
        st_text = "运行中" if on else "已离线"; scl = "#34d399" if on else "#f87171"
        mp = sd["memory_pct"] if on else 0; mc = pcolor(mp)
        dp = sd["disk_pct"] if on else 0; dc = pcolor(dp)
        cv = sd["cpu_load"] if on else "--"; uv = sd["uptime"] if on else "--"
        bt = sd.get("boot_time", "--")
        if bt != "--" and len(bt) > 10:
            try:
                bt_dt = datetime.datetime.strptime(bt, "%Y-%m-%d %H:%M:%S")
                bt = bt_dt.strftime("%m-%d %H:%M")
            except: pass
        rx, tx = sd["rx"], sd["tx"]
        cv_float = 0
        try: cv_float = float(cv)
        except: pass
        cpu_color = pcolor(cv_float * 25) if cv != "--" else "#64748b"

        ssl_html = ""
        if on and sd["ssl_domain"]:
            sdays = sd["ssl_days"]
            if sdays is not None:
                if sdays > 30: ssl_cls = "ok"
                elif sdays > 7: ssl_cls = "wn"
                else: ssl_cls = "bd"
                ssl_html = "<div class='di'><span class='dl'>SSL</span><span class='dv " + ssl_cls + "'>" + str(sdays) + "天</span></div>"
            else:
                ssl_html = "<div class='di'><span class='dl'>SSL</span><span class='dv bd'>失败</span></div>"

        sp = sd.get("swap_pct", 0)
        swap_cls = "bd" if sp >= 80 else ("wn" if sp >= 60 else "ok")

        card_start = "<div class='bc" + (" offc" if not on else "") + "' style='border-left:4px solid " + scl + "'><details class='ncard' open>"
        card_content = (card_start +
            "<summary><div class='nh'><div><div class='nn'>" + sd["name"] + "</div>"
            "<div class='ni'>" + sd["ip"] + ":" + str(sd["port"]) + "</div></div>"
            "<span class='sd " + sc + "'>" + st_text + " <span class='arr'>▸</span></span></div></summary>")

        if on:
            card_content += (
                "<div class='rr'><div class='rm'><span>CPU (" + cv + ")</span><span class='v' style='color:" + cpu_color + "'>" + str(min(round(cv_float * 25), 100)) + "%</span></div>"
                "<div class='bt'><div class='bf' style='width:" + str(min(round(cv_float * 25), 100)) + "%;background:" + cpu_color + "'></div></div></div>"
                "<div class='rr'><div class='rm'><span>内存</span><span class='v'>" + str(mp) + "%</span></div>"
                "<div class='bt'><div class='bf' style='width:" + str(min(mp, 100)) + "%;background:" + mc + "'></div></div></div>"
                "<div class='rr'><div class='rm'><span>磁盘</span><span class='v'>" + str(dp) + "%</span></div>"
                "<div class='bt'><div class='bf' style='width:" + str(min(dp, 100)) + "%;background:" + dc + "'></div></div></div>"
                "<div class='tb'><div class='ti'><div class='tl'>上传</div><div class='tv up'>" + fmt(tx) + "</div></div>"
                "<div class='ti'><div class='tl'>下载</div><div class='tv dn'>" + fmt(rx) + "</div></div>"
                "<div class='ti'><div class='tl'>合计</div><div class='tv'>" + fmt(rx + tx) + "</div></div></div>"
                "<div class='dr'>"
                "<div class='di'><span class='dl'>运行</span><span class='dv'>" + uv + "</span></div>"
                "<div class='di'><span class='dl'>重启</span><span class='dv'>" + bt + "</span></div>"
                "<div class='di'><span class='dl'>负载</span><span class='dv'>5m " + sd.get("cpu_load5", "--") + " / 15m " + sd.get("cpu_load15", "--") + "</span></div>"
                + ssl_html +
                "<div class='di'><span class='dl'>Swap</span><span class='dv " + swap_cls + "'>" + str(sp) + "%</span></div>"
                "</div>")
            if sd["month_rows"]:
                card_content += ("<details style='margin-top:14px;border:none'><summary style='cursor:pointer;list-style:none;font-size:11px;font-weight:600;color:var(--t2);user-select:none'>▸ 月度明细</summary>"
                    "<table class='dt'><tr><th>月</th><th>上传</th><th>下载</th><th>总计</th></tr>" + sd["month_rows"] + "</table></details>")
            if sd["hour_rows"]:
                card_content += ("<details style='margin-top:10px;border:none'><summary style='cursor:pointer;list-style:none;font-size:11px;font-weight:600;color:var(--t2);user-select:none'>▸ 小时明细</summary>"
                    "<table class='dt'><tr><th>时间</th><th>上传</th><th>下载</th></tr>" + sd["hour_rows"] + "</table></details>")
        else:
            card_content += "<div class='oph'>⚠ 节点失去响应，请检查 SSH</div>"
        card_content += "</details></div>"
        cards += card_content

    max_t = max((sd["rx"] + sd["tx"]) for sd in sv_sorted) if sv_sorted else 1
    tc = "<div class='bc c4'><div class='ct'>节点今日流量对比</div>"
    for sd in sv_sorted:
        tot = sd["rx"] + sd["tx"]; pct = int(tot / max_t * 100) if max_t > 0 else 0
        clr = "var(--ok)" if sd["status"] == "online" else "var(--t3)"
        tn_cls = "" if sd["status"] == "online" else " off"
        tvl_cls = "" if sd["status"] == "online" else " off"
        tvl_text = fmt(tot) if sd["status"] == "online" else "离线"
        tc += ("<div class='tr'><span class='tn" + tn_cls + "'>" + sd["name"] + "</span>"
            "<div class='ttk'><div class='tfl' style='width:" + str(pct) + "%;background:" + clr + "'></div></div>"
            "<span class='tvl" + tvl_cls + "'>" + tvl_text + "</span></div>")
    tc += "</div>"


    sf = os.path.join(os.path.dirname(os.path.abspath(__file__)), "whos_status.json")
    wh = ""
    try:
        with open(sf, "r") as f: ws = json.load(f)
        wh_ok = ws.get("status") == "完成"
        wh_color = "var(--ok)" if wh_ok else "var(--wn)"
        
        # 积分信息
        today_pts = ws.get("today_points", 0)
        total_pts = ws.get("total_points", 0)
        streak = ws.get("streak_days", 0)
        share_cnt = ws.get("share_count", 0)
        
        wh = ('<div class="bc" style="border-top:3px solid ' + wh_color + '">'
            '<div class="ct" style="margin-bottom:12px">自动化任务 <span style="color:' + wh_color + ';font-size:11px;font-weight:600">● 运行中</span></div>'
            '<div style="font-size:14px;font-weight:700;margin-bottom:12px">Whos.tv 自动签到</div>'
            '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:14px;padding:14px;background:rgba(0,0,0,.2);border-radius:12px">'
            '<div style="text-align:center"><div style="font-size:10px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">当前积分</div><div style="font-size:22px;font-weight:700;color:var(--wn)">' + str(total_pts) + '</div></div>'
            '<div style="text-align:center"><div style="font-size:10px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">今日积分</div><div style="font-size:22px;font-weight:700;color:var(--ok)">' + str(today_pts) + '</div></div>'
            '<div style="text-align:center"><div style="font-size:10px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">连续签到</div><div style="font-size:22px;font-weight:700;color:var(--in)">' + str(streak) + '天</div></div>'
            '<div style="text-align:center"><div style="font-size:10px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">今日分享</div><div style="font-size:22px;font-weight:700;color:var(--ok)">' + str(share_cnt) + '/5</div></div>'
            '</div>'
            '<div style="font-size:12px;color:var(--t2);line-height:1.8">'
            '状态：<span style="color:' + wh_color + ';font-weight:600">' + ws.get("status", "") + '</span><br>'
            '信息：' + ws.get("message", "") + '<br>'
            '<span style="color:var(--t3);font-size:11px">更新时间：' + ws.get("time", "") + '</span>'
            '</div></div>')
    except Exception as e:
        print(f"[whos读取错误] {e}")
        wh = '<div class="bc" style="border-top:3px solid var(--t3)"><div class="ct" style="margin-bottom:8px">自动化任务</div><div style="font-size:13px;color:var(--t3)">暂无签到数据</div></div>'

    pg = ("<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>多机监控</title>"
        "<meta http-equiv='refresh' content='120'>"
        "<style>" + CSS + "</style></head><body>"
        "<a href='/logout' class='lo'>退出</a>"
        "<div class='hdr'><div><h1>多机流量监控中心</h1><div class='ut'>更新时间：" + now + "</div></div></div>"
        "<div class='bento'>"
        + overview + cards + tc
        + "<div class='bc c4' style='text-align:center;border-top:3px solid var(--in)'>"
        "<div style='font-size:13px;color:var(--t2);font-weight:600;margin-bottom:10px;text-transform:uppercase;letter-spacing:.6px'>今日总流量</div>"
        "<div style='font-size:15px;color:var(--t3)'>上传 <span style='color:var(--in);font-weight:700'>" + fmt(ttx) + "</span> · 下载 <span style='color:var(--bd);font-weight:700'>" + fmt(trx) + "</span> · 合计 <span style='color:var(--wn);font-weight:700'>" + fmt(trx + ttx) + "</span></div></div>"
        + wh
        + "<div class='ft'>SSH 采集 · vnStat · 异步缓存 · 暗色 Bento 仪表盘</div>"
        "</div>"
        "</body></html>")
    return pg

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/logout":
            for c in self.headers.get("Cookie", "").split(";"):
                c = c.strip()
                if c.startswith("token="): ts.pop(c.split("=", 1)[1], None)
            self.send_response(302); self.send_header("Set-Cookie", "token=;Path=/;Max-Age=0")
            self.send_header("Location", "/"); self.end_headers(); return
        if not ca(self): sl(self); return
        self.send_response(200); self.send_header("Content-type", "text/html; charset=utf-8")
        self.end_headers(); self.wfile.write(gh(gd()).encode())

    def do_POST(self):
        from urllib.parse import unquote
        body = self.rfile.read(int(self.headers.get("Content-Length", 0))).decode()
        pm = {}
        for p in body.split("&"):
            if "=" in p: k, v = p.split("=", 1); pm[k] = unquote(v)
        if pm.get("user", "") == LU and pm.get("pass", "") == LP:
            tk = secrets.token_hex(16); ts[tk] = time.time()
            self.send_response(302); self.send_header("Set-Cookie", "token=" + tk + ";Path=/;HttpOnly")
            self.send_header("Location", "/"); self.end_headers()
        else: sl(self, "密码错误")
    def log_message(self, f, *a): pass

if __name__ == "__main__":
    threading.Thread(target=cache_loop, daemon=True).start()
    threading.Thread(target=lambda: gd(), daemon=True).start()
    print("[启动] http://0.0.0.0:" + str(PP))
    HTTPServer(("0.0.0.0", PP), H).serve_forever()
