#!/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", 5)
ts = set(); 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:
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.settimeout(2)
            s.connect((srv["ip"], srv["port"])); s.close(); on = True
        except: on = False
    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[0].get("rx", 0); R["tx"] = dl[0].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)
        time.sleep(CT)

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

def ca(h):
    for c in h.headers.get("Cookie", "").split(";"):
        c = c.strip()
        if c.startswith("token=") and c.split("=", 1)[1] in ts: 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>'
        '*{margin:0;padding:0;box-sizing:border-box}'
        '@keyframes vSpin{0%{transform:translate(-50%,-50%) rotate(0deg)}100%{transform:translate(-50%,-50%) rotate(360deg)}}'
        '@keyframes vSpinRev{0%{transform:translate(-50%,-50%) rotate(360deg)}100%{transform:translate(-50%,-50%) rotate(0deg)}}'
        '@keyframes vPulse{0%,100%{opacity:.8;transform:translate(-50%,-50%) scale(1)}50%{opacity:1;transform:translate(-50%,-50%) scale(1.06)}}'
        '@keyframes cardFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}'
        'body{font-family:system-ui,-apple-system,sans-serif;background:#0b1018;color:#e2e8f0;display:flex;justify-content:center;align-items:center;min-height:100vh;overflow:hidden;position:relative}'
        'body::before{content:"";position:fixed;inset:0;background:radial-gradient(ellipse at center,#131c2b 0%,#0b1018 70%);z-index:0}'
        'body::after{content:"";position:fixed;inset:0;background-image:radial-gradient(rgba(255,255,255,.025) 1px,transparent 1px);background-size:36px 36px;z-index:1;pointer-events:none}'
        '.vortex{position:fixed;top:50%;left:50%;z-index:0;pointer-events:none}'
        '.vcore{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),0 0 160px rgba(139,92,246,.08);transform:translate(-50%,-50%);animation:vPulse 3s ease-in-out infinite}'
        '.vr{position:absolute;top:50%;left:50%;border-radius:50%}'
        '.vr1{width:130px;height:130px;border:1.5px solid transparent;border-top-color:rgba(99,102,241,.35);border-right-color:rgba(139,92,246,.15);animation:vSpin 5s linear infinite}'
        '.vr2{width:190px;height:190px;border:1.5px solid transparent;border-top-color:rgba(139,92,246,.25);border-left-color:rgba(99,102,241,.1);animation:vSpinRev 7s linear infinite}'
        '.vr3{width:250px;height:250px;border:1px solid transparent;border-top-color:rgba(99,102,241,.18);border-right-color:rgba(52,211,153,.08);animation:vSpin 9s linear infinite}'
        '.vr4{width:310px;height:310px;border:1px solid transparent;border-top-color:rgba(139,92,246,.12);border-left-color:rgba(99,102,241,.05);animation:vSpinRev 11s linear infinite}'
        '.va1{width:160px;height:160px;background:conic-gradient(from 0deg,transparent 0deg,rgba(99,102,241,.07) 50deg,transparent 90deg,transparent 170deg,rgba(139,92,246,.05) 210deg,transparent 260deg);filter:blur(3px);animation:vSpin 6s linear infinite}'
        '.va2{width:220px;height:220px;background:conic-gradient(from 90deg,transparent 0deg,rgba(99,102,241,.05) 40deg,transparent 80deg);filter:blur(2px);animation:vSpinRev 8s linear infinite}'
        '.b{position:relative;z-index:2;background:rgba(12,15,22,.75);backdrop-filter:blur(28px);-webkit-backdrop-filter:blur(28px);padding:48px;border-radius:20px;width:380px;border:1px solid rgba(255,255,255,.06);box-shadow:0 4px 60px rgba(0,0,0,.6);animation:cardFloat 5s ease-in-out infinite}'
        'h1{text-align:center;color:#f1f5f9;margin-bottom:6px;font-size:20px;font-weight:600;letter-spacing:-.4px}'
        '.sub{text-align:center;color:#64748b;font-size:13px;margin-bottom:32px}'
        '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:14px;outline:none;transition:border .2s,box-shadow .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:13px;margin-top:18px;border:none;border-radius:12px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-weight:600;font-size:14px;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="vortex"><div class="vcore"></div>'
        '<div class="vr vr1"></div><div class="vr vr2"></div><div class="vr vr3"></div><div class="vr vr4"></div>'
        '<div class="va1"></div><div class="va2"></div></div>'
        '<div class="b"><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 = ("*{margin:0;padding:0;box-sizing:border-box}"
    "body{font-family:system-ui,-apple-system,sans-serif;background:#0b1018;color:#e2e8f0;padding:20px;min-height:100vh;letter-spacing:-.2px}"
    ".header{text-align:center;padding:36px 0 24px}"
    ".header h1{font-size:22px;font-weight:600;color:#f1f5f9;letter-spacing:-.5px}"
    ".ut{color:#64748b;font-size:11px;margin-top:8px;letter-spacing:.3px}"
    ".dashboard{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;max-width:1000px;margin:0 auto 20px}"
    ".dash-card{flex:1 1 140px;min-width:120px;max-width:180px;position:relative;background:rgba(255,255,255,.025);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-radius:14px;padding:16px 12px 14px;text-align:center;border:1px solid rgba(255,255,255,.05);transition:all .3s;overflow:hidden}"
    ".dash-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;opacity:.5}"
    ".dash-card:nth-child(1)::before{background:#34d399}"
    ".dash-card:nth-child(2)::before{background:#60a5fa}"
    ".dash-card:nth-child(3)::before{background:#fbbf24}"
    ".dash-card:nth-child(4)::before{background:#a78bfa}"
    ".dash-card:nth-child(5)::before{background:#94a3b8}"
    ".dash-card:hover{transform:translateY(-2px);border-color:rgba(255,255,255,.1);box-shadow:0 8px 30px rgba(0,0,0,.4)}"
    ".dash-icon{font-size:16px;margin-bottom:8px;opacity:.6}"
    ".dash-val{font-size:24px;font-weight:700;color:#f1f5f9;margin-bottom:4px;letter-spacing:-.5px;line-height:1}"
    ".dash-lbl{font-size:9px;color:#64748b;text-transform:uppercase;letter-spacing:1.2px;font-weight:500}"
    ".grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:16px;max-width:1400px;margin:0 auto 24px}"
    ".card{background:rgba(255,255,255,.02);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-radius:16px;padding:24px;border:1px solid rgba(255,255,255,.05);transition:all .4s}"
    ".card-fold{border:none;margin:0}"
    ".card-sum{list-style:none;cursor:pointer;user-select:none}"
    ".card-sum::-webkit-details-marker{display:none}"
    ".card-sum::marker{display:none}"
    ".fold-arr-card{font-size:12px;color:#64748b;transition:transform .2s;flex-shrink:0;margin-left:6px;display:inline-block}"
    ".card-fold[open] .fold-arr-card{transform:rotate(90deg)}"
    ".card-expand{padding-top:6px}"
    ".card.offline{opacity:.4;filter:grayscale(50%)}"
    ".ch{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid rgba(255,255,255,.05)}"
    ".card-fold[open] .ch{border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:0}"
    ".sn{font-size:16px;font-weight:600;color:#f1f5f9;letter-spacing:-.3px}"
    ".si{color:#64748b;font-size:11px;font-family:'SF Mono','Fira Code',monospace;margin-top:2px}"
    ".sb{padding:4px 12px;border-radius:20px;font-size:10px;font-weight:600;letter-spacing:.4px}"
    ".sb.on{background:rgba(52,211,153,.1);color:#34d399}"
    ".sb.off{background:rgba(248,113,113,.1);color:#f87171}"
    ".mg{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:10px}"
    ".mb{background:rgba(0,0,0,.25);border-radius:10px;padding:12px 10px;text-align:center}"
    ".ml{font-size:10px;color:#64748b;margin-bottom:5px;letter-spacing:.6px;text-transform:uppercase}"
    ".mv{font-size:17px;font-weight:700;color:#f1f5f9;letter-spacing:-.3px}"
    ".ms{font-size:11px;font-weight:400;color:#94a3b8}"
    ".pb{width:100%;height:4px;background:rgba(255,255,255,.06);border-radius:2px;margin-top:6px;overflow:hidden}"
    ".pf{height:100%;border-radius:2px;transition:width .8s ease}"
    ".info-row{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;margin-bottom:14px;background:rgba(0,0,0,.15);border-radius:8px;font-size:11px;gap:12px}"
    ".info-item{display:flex;align-items:center;gap:6px;color:#94a3b8;white-space:nowrap}"
    ".info-item .inf{color:#64748b;font-size:10px;text-transform:uppercase;letter-spacing:.4px}"
    ".info-item .inv{color:#cbd5e1;font-weight:500}"
    ".ssl-ok{color:#34d399!important}"
    ".ssl-warn{color:#fbbf24!important}"
    ".ssl-bad{color:#f87171!important}"
    ".tg{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px}"
    ".tb{background:rgba(0,0,0,.25);border-radius:10px;padding:12px 10px;text-align:center}"
    ".tl{font-size:10px;color:#64748b;letter-spacing:.6px;text-transform:uppercase}"
    ".tv{font-size:15px;font-weight:700;letter-spacing:-.2px}"
    ".rx{color:#f87171!important}"
    ".tx{color:#60a5fa!important}"
    ".tt{color:#fbbf24!important}"
    ".st{font-size:11px;color:#94a3b8;font-weight:600;margin:14px 0 8px;letter-spacing:.3px}.fold{margin-top:4px;border:none}.fold-sum{display:flex;align-items:center;gap:6px;cursor:pointer;list-style:none;user-select:none;padding:4px 0}.fold-sum::-webkit-details-marker{display:none}.fold-arr{font-size:10px;color:#64748b;transition:transform .2s}.fold[open] .fold-arr{transform:rotate(90deg)}"
    ".dt{width:100%;font-size:11px;border-collapse:collapse}"
    ".dt th{color:#64748b;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.04);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.6px}"
    ".dt td{padding:6px 0;text-align:center;color:#cbd5e1}"
    ".om{text-align:center;color:#f87171;padding:24px;font-size:13px;background:rgba(248,113,113,.04);border-radius:10px;margin-top:12px}"
    ".msg{font-size:13px;color:#94a3b8;margin-top:8px}"
    ".ts{background:rgba(255,255,255,.015);border:1px solid rgba(255,255,255,.04);border-radius:16px;padding:24px;max-width:1400px;margin:0 auto 18px;text-align:center}"
    ".ts h2{color:#94a3b8;font-size:14px;font-weight:600;margin-bottom:10px;letter-spacing:-.2px}"
    ".ft{text-align:center;color:#475569;font-size:11px;padding:28px 0 20px;letter-spacing:.2px;border-top:1px solid rgba(255,255,255,.03);margin-top:10px}"
    ".lo{position:fixed;top:16px;right:20px;padding:8px 18px;background:rgba(30,35,45,.9);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);color:#94a3b8;text-decoration:none;border-radius:10px;font-size:12px;border:1px solid rgba(255,255,255,.1);z-index:999;transition:all .2s}"
    ".lo:hover{background:rgba(248,113,113,.1);color:#f87171;border-color:rgba(248,113,113,.2)}"
    ".tc{max-width:1400px;margin:0 auto 24px;background:rgba(255,255,255,.015);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-radius:16px;padding:24px;border:1px solid rgba(255,255,255,.05)}"
    ".tc-title{font-size:13px;color:#94a3b8;font-weight:600;margin-bottom:20px;letter-spacing:-.2px}"
    ".tc-row{display:flex;align-items:center;gap:16px;margin-bottom:14px}"
    ".tc-name{width:90px;font-size:13px;color:#94a3b8;text-align:right;flex-shrink:0;font-weight:500}"
    ".tc-track{flex:1;height:22px;background:rgba(255,255,255,.04);border-radius:11px;overflow:hidden}"
    ".tc-fill{height:100%;border-radius:11px;transition:width .8s ease}"
    ".tc-val{width:100px;font-size:13px;font-weight:600;color:#e2e8f0;flex-shrink:0;letter-spacing:-.1px}"
    ".chart-wrap{max-width:1400px;margin:0 auto 24px;background:rgba(255,255,255,.015);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-radius:16px;padding:24px;border:1px solid rgba(255,255,255,.05)}"
    ".chart-wrap canvas{width:100%!important;height:280px!important}"
    "@media(max-width:768px){.grid{grid-template-columns:1fr}.mg{grid-template-columns:repeat(3,1fr)}.info-row{flex-wrap:wrap}.dashboard{justify-content:center}}")

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"]))
    cards = ""
    for sd in sv_sorted:
        on = sd["status"] == "online"; sc = "on" if on else "off"
        st = "运行中" 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 = "ssl-ok"
                elif sdays > 7: ssl_cls = "ssl-warn"
                else: ssl_cls = "ssl-bad"
                ssl_html = ("<div class='info-item'>"
                    "<span class='inf'>SSL</span>"
                    "<span class='inv " + ssl_cls + "'>" + sd["ssl_domain"][:20] + "</span>"
                    "<span class='inv " + ssl_cls + "'>剩余 " + str(sdays) + " 天</span>"
                    "</div>")
            else:
                ssl_html = ("<div class='info-item'>"
                    "<span class='inf'>SSL</span>"
                    "<span class='inv ssl-bad'>检测失败</span></div>")

        c = ("<div class='card" + (" offline" if not on else "") + "' style='border-left:4px solid " + scl + "'><details class='card-fold'><summary class='card-sum'>"
            "<div class='ch'><div><div class='sn'>" + sd["name"] + "</div>"
            "<div class='si'>" + sd["ip"] + ":" + str(sd["port"]) + "</div></div>"
            "<span class='sb " + sc + "'>" + st + "</span><span class=\'fold-arr-card\'>▸</span></div>"
            "</summary>"
            "<div class=\'card-expand\'>"
            "<div class='mg'>"
            "<div class='mb'><div class='ml'>CPU</div><div class='mv' style='color:" + cpu_color + "'>" + cv + "</div></div>"
            "<div class='mb'><div class='ml'>内存</div><div class='mv'>" + str(mp) + "%</div>"
            "<div class='pb'><div class='pf' style='width:" + str(min(mp,100)) + "%;background:" + mc + "'></div></div></div>"
            "<div class='mb'><div class='ml'>磁盘</div><div class='mv'>" + str(dp) + "%</div>"
            "<div class='pb'><div class='pf' style='width:" + str(min(dp,100)) + "%;background:" + dc + "'></div></div></div></div>"
            "<div class='info-row'>"
            "<div class='info-item'><span class='inf'>运行</span><span class='inv'>" + uv + "</span></div><div class='info-item'><span class='inf'>重启</span><span class='inv'>" + bt + "</span></div>"
            "<div class='info-item'><span class='inf'>负载</span><span class='inv'>5m " + sd.get("cpu_load5","--") + " / 15m " + sd.get("cpu_load15","--") + "</span></div>"
            + ssl_html +
            "<div class='info-item'><span class='inf'>Swap</span><span class='inv' style='color:" + pcolor(sd.get("swap_pct",0)) + "'>" + str(sd.get("swap_pct",0)) + "%</span></div>"
            "</div>"
            "<div class='tg'>"
            "<div class='tb'><div class='tl'>上传</div><div class='tv tx'>" + fmt(tx) + "</div></div>"
            "<div class='tb'><div class='tl'>下载</div><div class='tv rx'>" + fmt(rx) + "</div></div>"
            "<div class='tb'><div class='tl'>合计</div><div class='tv tt'>" + fmt(rx + tx) + "</div></div></div>")
        if sd["month_rows"]: c += "<details class=fold><summary class=fold-sum><span class=st>月度</span><span class=fold-arr>▸</span></summary><table class='dt'><tr><th>月</th><th>上传</th><th>下载</th><th>总计</th></tr>" + sd["month_rows"] + "</table></details>"
        if sd["hour_rows"]: c += "<details class=fold><summary class=fold-sum><span class=st>小时</span><span class=fold-arr>▸</span></summary><table class='dt'><tr><th>时间</th><th>上传</th><th>下载</th></tr>" + sd["hour_rows"] + "</table></details>"
        if not on: c += "<div class='om'>主机离线</div>"
        c += "</div></details>"
        cards += c + "</div>"

    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)
    dashboard = ("<div class='dashboard'><div class='dash-card'><div class='dash-icon'>🟢</div><div class='dash-val'>" + str(online_count) + "/" + str(len(sv_sorted)) + "</div><div class='dash-lbl'>在线节点</div></div>"
        "<div class='dash-card'><div class='dash-icon'>💾</div><div class='dash-val'>" + str(round(avg_mem, 1)) + "%</div><div class='dash-lbl'>平均内存</div></div>"
        "<div class='dash-card'><div class='dash-icon'>⚡</div><div class='dash-val'>" + str(round(avg_cpu, 1)) + "</div><div class='dash-lbl'>平均CPU负载</div></div>"
        "<div class='dash-card'><div class='dash-icon'>🌐</div><div class='dash-val'>" + fmt(trx + ttx) + "</div><div class='dash-lbl'>今日总流量</div></div>"
        "<div class='dash-card'><div class='dash-icon'>⏰</div><div class='dash-val'>" + now.split(" ")[1] + "</div><div class='dash-lbl'>最后更新</div></div></div>")
    max_t = max((sd["rx"] + sd["tx"]) for sd in sv_sorted) if sv_sorted else 1
    tc = "<div class='tc'><div class='tc-title'>节点流量对比</div>"
    for sd in sv_sorted:
        tot = sd["rx"] + sd["tx"]; pct = int(tot / max_t * 100) if max_t > 0 else 0
        clr = "#34d399" if sd["status"] == "online" else "#64748b"
        tc += ("<div class='tc-row'><span class='tc-name'>" + sd["name"] + "</span>"
            "<div class='tc-track'><div class='tc-fill' style='width:" + str(pct) + "%;background:" + clr + "'></div></div>"
            "<span class='tc-val'>" + fmt(tot) + "</span></div>")
    tc += "</div>"

    hour_sum = defaultdict(lambda: [0, 0])
    for sd in sv_sorted:
        if sd["status"] == "online":
            for label, rx_val, tx_val in sd.get("hour_list", []):
                hour_sum[label][0] += rx_val
                hour_sum[label][1] += tx_val
    sorted_labels = sorted(hour_sum.keys())
    chart_labels = json.dumps(sorted_labels[-24:], ensure_ascii=False)
    chart_rx_data = json.dumps([hour_sum[l][0] for l in sorted_labels[-24:]], ensure_ascii=False)
    chart_tx_data = json.dumps([hour_sum[l][1] for l in sorted_labels[-24:]], ensure_ascii=False)

    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)
        wc = "#34d399" if ws.get("status") == "完成" else "#fbbf24"
        wh = ("<div class='ts' style='border-color:" + wc + "'><h2>Whos.tv 自动签到</h2>"
            "<div class='msg'><span style='color:" + wc + ";font-weight:600'>" + ws.get("status", "") + "</span> · " + ws.get("message", "") + " · " + ws.get("time", "") + "</div></div>")
    except Exception as e:
        print(f"[whos读取错误] {e}")
        wh = "<div class='ts'><h2>Whos.tv</h2><div class='msg' style='color:#64748b'>暂无签到数据</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'>"
        "<script src='https://cdn.jsdelivr.net/npm/chart.js@4'></script>"
        "<style>" + CSS + "</style></head><body>"
        "<a href='/logout' class='lo'>退出</a>"
        "<div class='header'><h1>多机流量监控中心</h1><div class='ut'>更新时间：" + now + "</div></div>"
        + dashboard + "<div class='grid'>" + cards + "</div>" + tc
        + "<div class='chart-wrap'><div class='tc-title'>24h 流量趋势</div><canvas id='trendChart'></canvas></div>"
        + "<div class='ts'><h2>今日总流量</h2><div class='msg'>"
        "上传 <span class='tx'>" + fmt(ttx) + "</span> · 下载 <span class='rx'>" + fmt(trx) + "</span> · 合计 <span class='tt'>" + fmt(trx + ttx) + "</span></div></div>"
        + wh
        + "<div class='ft'>SSH 采集 · vnStat · 异步缓存 · 磁盘/SSL</div>"
        "<script>"
        "(function(){"
        "var labels=" + chart_labels + ";"
        "var rxData=" + chart_rx_data + ";"
        "var txData=" + chart_tx_data + ";"
        "if(labels.length>0){"
        "new Chart(document.getElementById('trendChart').getContext('2d'),{"
        "type:'line',"
        "data:{labels:labels,datasets:["
        "{label:'下载',data:rxData,borderColor:'#f87171',backgroundColor:'rgba(248,113,113,.06)',fill:true,tension:.4,pointRadius:0,borderWidth:1.5},"
        "{label:'上传',data:txData,borderColor:'#60a5fa',backgroundColor:'rgba(96,165,250,.06)',fill:true,tension:.4,pointRadius:0,borderWidth:1.5}"
        "]},"
        "options:{responsive:true,maintainAspectRatio:false,"
        "plugins:{legend:{labels:{color:'#94a3b8',font:{size:11},usePointStyle:true,padding:20}}},"
        "scales:{x:{ticks:{color:'#64748b',font:{size:10},maxTicksLimit:12},grid:{color:'rgba(255,255,255,.03)'}},"
        "y:{ticks:{color:'#64748b',font:{size:10},callback:function(v){return v>=1073741824?(v/1073741824).toFixed(1)+'G':v>=1048576?(v/1048576).toFixed(1)+'M':v>=1024?(v/1024).toFixed(0)+'K':v}},"
        "grid:{color:'rgba(255,255,255,.03)'}}},"
        "interaction:{intersect:false,mode:'index'}"
        "}});"
        "}"
        "})();"
        "</script>"
        "</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.discard(c.split("=", 1)[1])
            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.add(tk)
            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()
    print("[启动] http://0.0.0.0:" + str(PP))
    HTTPServer(("0.0.0.0", PP), H).serve_forever()
