Logo迷失博客
返回文章列表
常用工具阅读时长:107 分钟

轻量服务器探针监控系统

发布时间:2026年08月16日 08:00最后修改:2026年09月04日 22:00

告别传统重型 Docker 容器探针,提供【Cloudflare Serverless + D1 数据库(10万次写/天)】与【VPS 本机物理架设(Nginx + Python + SQLite)】两种完整自建方案,全面对标 PPanel 官方设计母版,支持 6 大主题配色切换、CPU 走势图、4 大全局监控卡片、卡片/列表/三旬日历热力图三视图、前端 5s 无感静默实时刷新与 5 层质量复检。

在日常管理多台 VPS 与云服务器时,掌握各节点的 CPU 使用率、内存占用、Swap 状态、磁盘空间、实时网络带宽、系统负载、双栈 IP 与存活状态 至关重要。

市面上常见的探针方案(如哪吒探针 Nezha、ServerStatus、Uptime Kuma 等)通常需要在主控 VPS 上运行 Docker 容器、管理 MySQL/SQLite 数据库并开放 gRPC/WebSocket 端口,不仅占用 150M~300M 内存,还存在主控服务器宕机导致监控全线失联的问题。

为了满足不同网络与业务场景的需求,本文提供 两种完整且经过生产检验的自建方案

  1. 方案一:Cloudflare Serverless 极速部署(推荐):基于 Cloudflare Worker + D1 SQL 数据库,0 服务器常驻、0 内存消耗、全球边缘秒级加载,且享有 每日 100,000 次免费写入,彻底告别 KV 每日 1,000 次的限额焦虑。
  2. 方案二:VPS / 本机物理独立架设:采用独立域名 + Nginx/OpenResty 反代 + 单文件 Python 服务端 + 本地 SQLite 存储,适合不依赖云厂商、国内纯内网/专网或完全私有化掌控的独立服务器环境。

被控端无论对接哪种方案,均使用极简单文件 Python 脚本(纯标准库,无 pip 依赖),内存占用 < 5MB,支持一行命令一键安装与卸载。


架构拓扑

+-----------------------+                    +-----------------------+
|  被控服务器 A (韩国)  |                    |  被控服务器 B (日本)  |
| (单文件 Python 探针)  |                    | (单文件 Python 探针)  |
+-----------------------+                    +-----------------------+
            \                                            /
      HTTP POST 每 60s 上报                        HTTP POST 每 60s 上报
              \                                        /
               v                                      v
        +---------------------------------------------------+
        |               探针系统主控服务端                  |
        | [方案一: Cloudflare Worker] 或 [方案二: Nginx+Py] |
        +---------------------------------------------------+
                  /               |               \
                 /                v                \
   +-------------------+  +---------------+  +-------------------+
   | 离线/恢复 Webhook |  | D1 或 本地SQL |  | 现代护眼 Web 看板 |
   | (企业微信/Bark推送|  | (持久化数据)  |  | (卡片/列表/明暗双)|
   +-------------------+  +---------------+  +-------------------+

📊 D1 免费额度与可监控节点容量测算

Cloudflare D1 免费计划提供 每日 100,000 次写入(是 KV 每日 1,000 次的 100 倍)。不同心跳上报频率下一天可支撑的服务器节点数量测算如下(1 天 = 1,440 分钟 = 86,400 秒):

探针心跳上报频率单节点每日写入量免费额度写满所需节点数每日消耗占额度比例 (以10台为例)推荐使用场景
每 60 秒上报 1 次 (默认标准)1,440 次 / 天约 70 台 (69.4 台)仅消耗 14.4% (余量 85.6%)绝大多数个人与团队多 VPS 集群监控
每 120 秒上报 1 次 (轻量节能)720 次 / 天约 139 台 (138.8 台)仅消耗 7.2% (余量 92.8%)大规模服务器集群长期低频资源巡检
每 30 秒上报 1 次 (高频灵敏)2,880 次 / 天约 35 台 (34.7 台)仅消耗 28.8% (余量 71.2%)核心业务高频监控与秒级波动观察

💡 容量结论:在默认 60 秒上报频率下,支持多达 70 台服务器 24 小时并发持续上报。日常管理 2 ~ 20 台机器时,每日额度余量高达 70% ~ 97% 以上,完全免除限流焦虑。


💡 为什么 Python 本机部署方案能稳定支持几十上百台机器?(并发与防崩溃原理解析)

不少读者会有疑问:几十台甚至上百台服务器同时上报,单文件 Python 服务端真的能稳定支撑吗?会不会崩溃?

答案是:绝对稳定,毫无压力。

+-----------------------------------------------------------------------------------+
|                            并发负载与吞吐量实际测算                               |
+-----------------------------------------------------------------------------------+
|  集群规模 (60s上报)  |  平均每秒请求量 (QPS)  |  Python 单核处理能力  |  实际 CPU 负载   |
+----------------------+------------------------+-----------------------+-------------------+
|  30 台服务器         |  0.5 次 / 秒 (2秒1次)  |  500 ~ 1,000 次 / 秒  |  < 0.1% (接近空载)|
|  60 台服务器         |  1.0 次 / 秒           |  500 ~ 1,000 次 / 秒  |  ~ 0.2%           |
|  100 台服务器        |  1.6 次 / 秒           |  500 ~ 1,000 次 / 秒  |  ~ 0.4%           |
|  500 台服务器        |  8.3 次 / 秒           |  500 ~ 1,000 次 / 秒  |  ~ 1.5%           |
+-----------------------------------------------------------------------------------+

1. 客户端完全解耦与独立

被控端每台机器仅运行单文件 probe_agent.py,单进程内存常驻 < 5MB,单次指标采集耗时不到 0.05 秒。无论全网有 10 台还是 10,000 台被控机,每台机器的资源开销都是恒定极低的,绝不会因集群扩大而互相拖累。

2. 服务端三层工业级防崩溃设计

方案二提供的 Python + SQLite 服务端内置了 3 层工业级稳定性防护:

  1. Nginx 前置缓冲与反代:所有 TCP 连接、SSL 握手与静态资产请求均由 Nginx 处理,Nginx 作为反代缓冲,后端 Python 仅需处理纯净的本地 HTTP 请求;
  2. 多线程并发模型(ThreadingHTTPServer):当多台机器偶然在同一秒发起请求时,服务端自动分发至工作线程并发处理,杜绝单线程排队阻塞;
  3. SQLite WAL 预写日志模式(Write-Ahead Logging):SQLite 开启 WAL 模式后,读与写完全分离、互不加锁阻塞,支持高频原子写入,彻底消除数据库锁死(Database is locked)异常。

方案一:Cloudflare Serverless 部署(推荐)

该方案将探针完全托管在 Cloudflare 全球边缘网络,永久免费且无需维护任何物理服务器。

第一步:创建 D1 数据库

  1. 登录 Cloudflare Dashboard
  2. 进入 Workers & Pages -> D1 SQL Database
  3. 点击 Create Database(创建数据库),名称填写 probe_db,点击创建。

(注:代码内置 Dual-Engine 双引擎适配器,若绑定 KV 命名空间 STATUS_KV 亦可无缝向下兼容运行)

第二步:创建 Worker 并绑定 D1

  1. 进入 Workers & Pages -> Overview,点击 Create Application -> Create Worker
  2. 命名为 server-status,点击 Deploy 完成初始创建;
  3. 进入该 Worker 的 Settings -> Bindings(变量与绑定):
    • 点击 Add -> D1 Database Binding
    • Variable name(变量名)填写:DB
    • D1 database 选择刚才创建的 probe_db
  4. 进入 Settings -> Variables and Secrets,添加以下环境变量(建议设为 Secret):
    • ADMIN_KEY:管理员密钥(用于登录 Web 端下线节点、查看敏感 Token,建议设置高强度复杂密码);
    • REPORT_TOKEN:被控节点上报 Token(客户端上报数据时的鉴权 Token);
    • ALERT_WEBHOOK:(可选)离线与恢复告警推送 Webhook 地址(支持企业微信/Bark/Telegram 等)。

第三步:粘贴开源代码并部署

点击 Edit Code(编辑代码),将本文文末提供的 完整 Cloudflare Worker 开源代码 复制并粘贴覆盖 worker.js,点击右上角的 Deploy 部署。

第四步:绑定自定义域名(可选)

在 Worker 的 Settings -> Domains & Routes 中添加你的自定义域名(例如 x.example.com),即刻上线!


方案二:VPS / 本机物理独立架设

如果你希望在自有的 VPS、云服务器或内网独立主机上搭建,只需使用 Nginx + 单文件 Python 脚本 + SQLite 即可快速拉起。

第一步:部署 Python 服务端程序

在服务器上创建目录 /usr/local/bin/node-probe-agent.py-master/ 并编写服务端启动脚本 server.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Server Status Probe - Standalone Python Master Server (私有化独立主控服务端)
- 零第三方依赖 (纯 Python 3 标准库 http.server + sqlite3)
- 内置 SQLite 数据库存储
- 内存占用 < 15MB,极度轻量
"""

import os, sys, json, time, sqlite3
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs

ADMIN_KEY = "YOUR_ADMIN_KEY_HERE"
REPORT_TOKEN = "YOUR_REPORT_TOKEN_HERE"
DB_PATH = "/usr/local/bin/node-probe-agent.py-master/probe.db"
PORT = 28081

def init_db():
    conn = sqlite3.connect(DB_PATH)
    c = conn.cursor()
    c.execute('''
        CREATE TABLE IF NOT EXISTS probe_nodes (
            id TEXT PRIMARY KEY,
            data TEXT NOT NULL,
            updated_at INTEGER NOT NULL
        )
    ''')
    conn.commit()
    conn.close()

class ProbeHandler(BaseHTTPRequestHandler):
    def send_json(self, data, status=200):
        body = json.dumps(data).encode('utf-8')
        self.send_response(status)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        url = urlparse(self.path)
        if url.path == '/api/status':
            conn = sqlite3.connect(DB_PATH)
            c = conn.cursor()
            c.execute('SELECT data FROM probe_nodes ORDER BY updated_at DESC')
            rows = c.fetchall()
            conn.close()
            nodes = []
            now = int(time.time())
            for r in rows:
                try:
                    node = json.loads(r[0])
                    diff = now - node.get('updated_at', 0)
                    node['is_online'] = diff <= 120
                    node['last_seen_sec'] = diff
                    nodes.append(node)
                except Exception:
                    pass
            online_count = sum(1 for n in nodes if n.get('is_online'))
            self.send_json({
                'total_nodes': len(nodes),
                'online_count': online_count,
                'offline_count': len(nodes) - online_count,
                'nodes': nodes
            })
        elif url.path in ['/health', '/api/health']:
            self.send_json({'status': 'healthy', 'time': int(time.time())})
        else:
            self.send_response(404)
            self.end_headers()

    def do_POST(self):
        url = urlparse(self.path)
        if url.path in ['/report', '/api/report']:
            query = parse_qs(url.query)
            token = query.get('token', [''])[0] or self.headers.get('x-token', '')
            if token != REPORT_TOKEN and token != ADMIN_KEY:
                return self.send_json({'status': 'fail', 'error': 'Unauthorized'}, 403)
            length = int(self.headers.get('Content-Length', 0))
            payload = json.loads(self.rfile.read(length).decode('utf-8'))
            node_id = payload.get('id')
            if not node_id:
                return self.send_json({'status': 'fail', 'error': 'Missing id'}, 400)
            payload['updated_at'] = int(time.time())
            conn = sqlite3.connect(DB_PATH)
            c = conn.cursor()
            c.execute('''
                INSERT INTO probe_nodes (id, data, updated_at)
                VALUES (?, ?, ?)
                ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
            ''', (node_id, json.dumps(payload), payload['updated_at']))
            conn.commit()
            conn.close()
            return self.send_json({'status': 'success', 'msg': '上报成功'})
        else:
            self.send_response(404)
            self.end_headers()

if __name__ == '__main__':
    init_db()
    server = HTTPServer(('127.0.0.1', PORT), ProbeHandler)
    print(f"Probe master server started on http://127.0.0.1:{PORT}")
    server.serve_forever()

第二步:配置 Systemd 守护进程

创建 /etc/systemd/system/probe-master.service

[Unit]
Description=Server Status Probe Master Server
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/usr/local/bin/node-probe-agent.py-master
ExecStart=/usr/bin/python3 /usr/local/bin/node-probe-agent.py-master/server.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

启动并设置开机自启:

systemctl daemon-reload
systemctl enable --now probe-master.service

第三步:配置 Nginx / OpenResty 反向代理

在 Nginx / OpenResty 中添加站点配置(如 /etc/nginx/conf.d/probe.conf):

server {
    listen 80;
    server_name x.example.com;

    # 静态资产与 API 反向代理到本地 Python 服务
    location / {
        proxy_pass http://127.0.0.1:28081;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

⚡ 被控端一键安装与卸载(全方案通用)

无论使用方案一还是方案二,在被控 Linux 服务器上均只需以 root 执行一行 Shell 即可:

1. 交互式一键安装

curl -sSL https://x.example.com/i | bash

2. 静默一键安装(指定数字ID排序、分组与密钥)

curl -sSL https://x.example.com/i | bash -s -- --token YOUR_TOKEN --id 3 --name "Oracle香港ARM" --group "甲骨文arm" --location "香港"

💡 卡片排序规则

  • 卡片位置完全由 --id 的数字大小决定(从小到大升序排列:#1 排在最前,#2 排在第二,依此类推);
  • 若存在相同 --id 的服务器重复上报,系统将自动以最新上报的数据平滑覆盖旧记录,无需手动清理。

3. 一键彻底卸载与生命周期管理(支持 3 种模式)

  1. 模式一(被控端一键彻底卸载 · 推荐): 在 Linux 服务器终端直接执行卸载命令:

    curl -sSL https://x.example.com/u | bash
    

    💡 卸载脚本停止并清理本地程序与服务时,会自动提取本地服务配置中保存的 Token 与节点 ID,并向云端发起注销请求,本地与云端看板卡片同步秒级清空。

  2. 模式二(云端远程下线 · 免登服务器): 在 Web 看板管理面板直接点击卡片上的「下线节点」。云端标记退役墓碑后,被控端探针在下次心跳上报时收到指令,将自动在后台执行自我销毁与文件清理

  3. 模式三(直接关机 / 平台重装 · 离线自动告警): 若服务器突发故障断电或在云平台直接重装系统,探针超时未上报后云端自动判定为「🔴 离线」,并自动向企业微信/Webhook 发送离线告警,管理员可在收到通知后进入 Web 面板手动移除。


📜 完整服务端开源代码 (Cloudflare Worker)

以下为方案一对应的全部 Worker 源码(集成 Cloudflare D1 SQL 关系型数据库存储、湖青 5 色现代护眼看板 UI、二级菜单分段控制器、公开/隐私节点权限隔离、API 接口、SVG 动态绘图、前端 5s 静默实时动态刷新、自动安装卸载路由与离线告警):

/**
 * Cloudflare Worker: Serverless Cluster Status Probe (x.example.com)
 * 独立部署架构:管理员密码认证 (YOUR_ADMIN_PASSWORD)
 * 功能:
 *  - Cloudflare D1 数据库存储
 *  - Multi-Token 多上报密钥管理 (独立创建/命名/吊销)
 *  - /usr/local/bin/ 标准系统目录安装 (避免污染 /opt)
 *  - 可视化自定义心跳超时判定、排序规则 (数字升序/降序/更新时间) 与 Webhook 告警
 *  - IP 级管理员防爆破防护 (阶梯封禁 + 延时惩罚 + 告警联动)
 *  - 节点下线与客户端自我销毁联动
 */

// ==================== Storage Adapter (Pure Cloudflare D1 SQL) ====================
const Storage = {
  getDb(env) {
    const db = env.DB || env.STATUS_DB || env.PROBE_DB || null;
    if (!db) {
      throw new Error("未检测到 Cloudflare D1 数据库绑定 (变量名: DB)!");
    }
    return db;
  },

  async initDb(db) {
    if (!this._dbInitialized) {
      await db.prepare(`
        CREATE TABLE IF NOT EXISTS probe_nodes (
          id TEXT PRIMARY KEY,
          data TEXT NOT NULL,
          updated_at INTEGER NOT NULL
        )
      `).run();
      await db.prepare(`
        CREATE TABLE IF NOT EXISTS probe_tokens (
          id TEXT PRIMARY KEY,
          name TEXT NOT NULL,
          token TEXT UNIQUE NOT NULL,
          created_at INTEGER NOT NULL,
          status TEXT NOT NULL
        )
      `).run();
      await db.prepare(`
        CREATE TABLE IF NOT EXISTS probe_tombstones (
          id TEXT PRIMARY KEY,
          deleted_at INTEGER NOT NULL
        )
      `).run();
      await db.prepare(`
        CREATE TABLE IF NOT EXISTS probe_settings (
          key TEXT PRIMARY KEY,
          value TEXT NOT NULL,
          updated_at INTEGER NOT NULL
        )
      `).run();
      await db.prepare(`
        CREATE TABLE IF NOT EXISTS probe_auth_locks (
          ip TEXT PRIMARY KEY,
          fail_count INTEGER NOT NULL,
          first_fail INTEGER NOT NULL,
          last_fail INTEGER NOT NULL,
          blocked_until INTEGER NOT NULL
        )
      `).run();
      this._dbInitialized = true;
    }
  },

  // ---------- Settings Management ----------
  async getSettings(env) {
    let fallbackWebhook = env.ALERT_WEBHOOK || "";
    if (!fallbackWebhook && env.PUSH_URL && env.PUSH_TOKEN) {
      fallbackWebhook = `${env.PUSH_URL}?token=${env.PUSH_TOKEN}`;
    }
    if (!fallbackWebhook) {
      fallbackWebhook = "https://sms.example.com/message?token=YOUR_SMS_TOKEN";
    }

    const defaultSettings = {
      alert_webhook: fallbackWebhook,
      timeout_seconds: parseInt(env.TIMEOUT_SECONDS || "120", 10),
      sort_mode: env.SORT_MODE || "numeric_asc",
      ip_display_mode: env.IP_DISPLAY_MODE || "admin_only_masked",
      auth_lock_alert: env.AUTH_LOCK_ALERT || "0"
    };

    const db = this.getDb(env);
    try {
      await this.initDb(db);
      const { results } = await db.prepare("SELECT key, value FROM probe_settings").all();
      if (results && results.length > 0) {
        const s = { ...defaultSettings };
        for (const row of results) {
          if (row.key === 'alert_webhook' && row.value !== undefined) {
            s.alert_webhook = row.value || fallbackWebhook;
          }
          if (row.key === 'timeout_seconds') s.timeout_seconds = parseInt(row.value, 10) || 120;
          if (row.key === 'sort_mode') s.sort_mode = row.value;
          if (row.key === 'ip_display_mode') s.ip_display_mode = row.value;
          if (row.key === 'auth_lock_alert') s.auth_lock_alert = row.value;
          if (row.key === 'show_ipv4') s.show_ipv4 = (row.value === '1' || row.value === 'true');
        }
        s.report_token = await this.getDefaultReportToken(env);
        return s;
      }
    } catch (e) {
      console.error("D1 getSettings error:", e);
    }
    return defaultSettings;
  },

  async saveSettings(env, settings) {
    const now = Math.floor(Date.now() / 1000);
    const db = this.getDb(env);
    await this.initDb(db);

    if (settings.report_token) {
      await this.setDefaultReportToken(env, settings.report_token);
    }

    const entries = [
      ['alert_webhook', settings.alert_webhook],
      ['timeout_seconds', settings.timeout_seconds],
      ['sort_mode', settings.sort_mode],
      ['ip_display_mode', settings.ip_display_mode],
      ['auth_lock_alert', settings.auth_lock_alert],
      ['show_ipv4', settings.show_ipv4 !== false ? '1' : '0']
    ];

    for (const [k, v] of entries) {
      if (v !== undefined) {
        await db.prepare(`
          INSERT INTO probe_settings (key, value, updated_at) VALUES (?, ?, ?)
          ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
        `).bind(k, String(v), now).run();
      }
    }
  },

  // ---------- Anti-Brute-Force Locks ----------
  async getAuthLock(env, ip) {
    const strIp = String(ip || '127.0.0.1').trim();
    const db = this.getDb(env);
    try {
      await this.initDb(db);
      const row = await db.prepare("SELECT fail_count, first_fail, last_fail, blocked_until FROM probe_auth_locks WHERE ip = ?").bind(strIp).first();
      if (row) return row;
    } catch (e) {
      console.error("D1 getAuthLock error:", e);
    }
    return null;
  },

  async recordAuthFail(env, ip) {
    const strIp = String(ip || '127.0.0.1').trim();
    const now = Math.floor(Date.now() / 1000);
    const existing = await this.getAuthLock(env, strIp);

    let failCount = 1;
    let firstFail = now;
    let blockedUntil = 0;

    if (existing) {
      if (now - existing.last_fail < 900) {
        failCount = (existing.fail_count || 0) + 1;
        firstFail = existing.first_fail || now;
      }
    }

    if (failCount >= 10) {
      blockedUntil = now + 86400;
    } else if (failCount >= 5) {
      blockedUntil = now + 900;
    }

    const lockData = {
      ip: strIp,
      fail_count: failCount,
      first_fail: firstFail,
      last_fail: now,
      blocked_until: blockedUntil
    };

    const db = this.getDb(env);
    await this.initDb(db);
    await db.prepare(`
      INSERT INTO probe_auth_locks (ip, fail_count, first_fail, last_fail, blocked_until)
      VALUES (?, ?, ?, ?, ?)
      ON CONFLICT(ip) DO UPDATE SET
        fail_count = excluded.fail_count,
        first_fail = excluded.first_fail,
        last_fail = excluded.last_fail,
        blocked_until = excluded.blocked_until
    `).bind(strIp, failCount, firstFail, now, blockedUntil).run();

    const settings = await this.getSettings(env);
    if (settings.auth_lock_alert === '1' || settings.auth_lock_alert === true) {
      if (failCount === 5 || failCount === 10) {
        const lockMin = Math.ceil((blockedUntil - now) / 60);
        this.sendAlert(env, '⚠️ 探针管理后台遭受密码暴力破解', `检测到来源 IP [${strIp}] 连续输错管理员密码达 ${failCount} 次,触发防爆破策略,该 IP 已被系统锁定 ${lockMin} 分钟。`);
      }
    }

    return lockData;
  },

  async clearAuthLock(env, ip) {
    const strIp = String(ip || '127.0.0.1').trim();
    const db = this.getDb(env);
    try {
      await this.initDb(db);
      await db.prepare("DELETE FROM probe_auth_locks WHERE ip = ?").bind(strIp).run();
    } catch (e) {}
  },

  // ---------- Webhook Alert Sender ----------
  async sendAlert(env, title, message) {
    try {
      const settings = await this.getSettings(env);
      const webhookUrl = settings.alert_webhook;
      if (!webhookUrl || !webhookUrl.startsWith('http')) return;

      const payload = {
        title: title || '集群探针系统通知',
        content: message || '',
        message: message || '',
        text: `【${title}${message}
时间: ${formatBeijingTime(Date.now())}`,
        time: new Date().toISOString()
      };

      await fetch(webhookUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0' },
        body: JSON.stringify(payload)
      }).catch(() => {});
    } catch (e) {}
  },

  // ---------- Nodes Storage ----------
  async getAllNodes(env) {
    const db = this.getDb(env);
    try {
      await this.initDb(db);
      const { results } = await db.prepare("SELECT data FROM probe_nodes ORDER BY updated_at DESC").all();
      return (results || []).map(r => {
        try { return JSON.parse(r.data); } catch(e) { return null; }
      }).filter(Boolean);
    } catch (e) {
      console.error("D1 getAllNodes error:", e);
      return [];
    }
  },

  async getNode(env, id) {
    const db = this.getDb(env);
    try {
      await this.initDb(db);
      const row = await db.prepare("SELECT data FROM probe_nodes WHERE id = ?").bind(String(id)).first();
      if (row && row.data) {
        try { return JSON.parse(row.data); } catch(e) { return null; }
      }
    } catch (e) {
      console.error("D1 getNode error:", e);
    }
    return null;
  },

  async saveNode(env, id, nodeData) {
    const db = this.getDb(env);
    const dataStr = JSON.stringify(nodeData);
    const strId = String(id);
    const now = nodeData.updated_at || Math.floor(Date.now() / 1000);

    await this.initDb(db);
    await db.prepare(`
      INSERT INTO probe_nodes (id, data, updated_at) 
      VALUES (?, ?, ?) 
      ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
    `).bind(strId, dataStr, now).run();
  },

  async deleteNode(env, id) {
    const strId = String(id);
    const now = Math.floor(Date.now() / 1000);
    const db = this.getDb(env);
    await this.initDb(db);
    await db.prepare("DELETE FROM probe_nodes WHERE id = ?").bind(strId).run();
    await db.prepare(`
      INSERT INTO probe_tombstones (id, deleted_at)
      VALUES (?, ?)
      ON CONFLICT(id) DO UPDATE SET deleted_at = excluded.deleted_at
    `).bind(strId, now).run();
  },

  async clearTombstone(env, id) {
    const strId = String(id);
    const db = this.getDb(env);
    try {
      await this.initDb(db);
      await db.prepare("DELETE FROM probe_tombstones WHERE id = ?").bind(strId).run();
    } catch (e) {}
  },

  async isTombstoned(env, id) {
    const strId = String(id);
    const db = this.getDb(env);
    try {
      await this.initDb(db);
      const row = await db.prepare("SELECT 1 FROM probe_tombstones WHERE id = ?").bind(strId).first();
      if (row) return true;
    } catch (e) {}
    return false;
  },

    // ---------- Multi-Token Storage (Full CRUD & Default Reset in D1 SQL) ----------
  async getDefaultReportToken(env) {
    const db = this.getDb(env);
    try {
      await this.initDb(db);
      const row = await db.prepare("SELECT value FROM probe_settings WHERE key = 'default_report_token'").first();
      if (row && row.value && row.value.trim() !== '') {
        return row.value.trim();
      }
    } catch (e) {}
    return env.REPORT_TOKEN || DEFAULT_REPORT_TOKEN;
  },

  async setDefaultReportToken(env, newTokenStr) {
    const cleanToken = (newTokenStr || '').trim();
    if (!cleanToken) return false;
    const now = Math.floor(Date.now() / 1000);
    const db = this.getDb(env);
    await this.initDb(db);
    await db.prepare(`
      INSERT INTO probe_settings (key, value, updated_at)
      VALUES ('default_report_token', ?, ?)
      ON CONFLICT(key) DO UPDATE SET
        value = excluded.value,
        updated_at = excluded.updated_at
    `).bind(cleanToken, now).run();
    return true;
  },

  async getAllTokens(env) {
    const defaultToken = await this.getDefaultReportToken(env);
    const list = [
      {
        id: "default",
        name: "🚀 默认上报密钥",
        token: defaultToken,
        created_at: 1786900000,
        status: "active",
        is_default: true
      }
    ];

    const db = this.getDb(env);
    try {
      await this.initDb(db);
      const { results } = await db.prepare("SELECT id, name, token, created_at, status FROM probe_tokens ORDER BY created_at DESC").all();
      if (results && results.length > 0) {
        for (const r of results) {
          if (r.token !== defaultToken) {
            list.push({
              id: r.id,
              name: r.name,
              token: r.token,
              created_at: r.created_at,
              status: r.status || 'active',
              is_default: false
            });
          }
        }
      }
    } catch (e) {
      console.error("D1 getAllTokens error:", e);
    }
    return list;
  },

  async createToken(env, name, tokenStr) {
    const id = 'tok_' + Math.random().toString(36).substring(2, 10);
    const now = Math.floor(Date.now() / 1000);
    const cleanName = (name || '未命名密钥').trim();
    let cleanToken = (tokenStr || '').trim();
    if (!cleanToken) {
      const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
      cleanToken = 'prb_';
      for (let i = 0; i < 18; i++) cleanToken += chars.charAt(Math.floor(Math.random() * chars.length));
    }

    const db = this.getDb(env);
    await this.initDb(db);
    await db.prepare(`
      INSERT INTO probe_tokens (id, name, token, created_at, status)
      VALUES (?, ?, ?, ?, 'active')
    `).bind(id, cleanName, cleanToken, now).run();

    return { id, name: cleanName, token: cleanToken, created_at: now, status: 'active', is_default: false };
  },

  async updateToken(env, id, name, tokenStr) {
    const cleanToken = (tokenStr || '').trim();
    if (!cleanToken) return false;

    if (id === 'default') {
      return await this.setDefaultReportToken(env, cleanToken);
    }

    const cleanName = (name || '未命名密钥').trim();
    const db = this.getDb(env);
    await this.initDb(db);
    await db.prepare(`
      UPDATE probe_tokens SET name = ?, token = ? WHERE id = ?
    `).bind(cleanName, cleanToken, id).run();
    return true;
  },

  async toggleTokenStatus(env, id) {
    if (id === 'default') return null;
    const db = this.getDb(env);
    await this.initDb(db);
    const row = await db.prepare("SELECT status FROM probe_tokens WHERE id = ?").bind(id).first();
    if (!row) return null;
    const nextStatus = (row.status === 'active' || !row.status) ? 'disabled' : 'active';
    await db.prepare("UPDATE probe_tokens SET status = ? WHERE id = ?").bind(nextStatus, id).run();
    return nextStatus;
  },

  async deleteToken(env, id) {
    if (id === 'default') return false;
    const db = this.getDb(env);
    await this.initDb(db);
    await db.prepare("DELETE FROM probe_tokens WHERE id = ?").bind(id).run();
    return true;
  },

  async verifyReportToken(env, token) {
    if (!token) return false;
    const defaultToken = await this.getDefaultReportToken(env);
    const adminKey = env.ADMIN_KEY || DEFAULT_ADMIN_KEY;
    if (token === defaultToken || token === adminKey) return true;

    const db = this.getDb(env);
    try {
      await this.initDb(db);
      const row = await db.prepare("SELECT id FROM probe_tokens WHERE token = ? AND status = 'active' LIMIT 1").bind(token).first();
      if (row) return true;
    } catch (e) {}
    return false;
  }
};

const DEFAULT_ADMIN_KEY = "YOUR_ADMIN_PASSWORD";
const DEFAULT_REPORT_TOKEN = "YOUR_PROBE_TOKEN";

const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
  <defs>
    <linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00aeb9" /><stop offset="100%" stop-color="#00838c" /></linearGradient>
  </defs>
  <rect width="100" height="100" rx="22" fill="url(#g)" />
  <path d="M22 65 L40 35 L55 52 L78 28" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
  <circle cx="78" cy="28" r="6" fill="#cbedee" />
</svg>`;

const PROBE_CLIENT_SCRIPT = `#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Server Status Probe Agent (纯标准库, 内存占用 < 5MB)
- 安装目录: /usr/local/bin/node-probe-agent.py
- 服务管理: systemctl status node-probe.service
- 支持 #ID 数字自然排序与双向自我销毁联动
"""
import os, sys, time, json, urllib.request, socket, argparse, subprocess

def get_cpu_usage(interval=1):
    def read_stat():
        with open('/proc/stat', 'r') as f:
            for line in f:
                if line.startswith('cpu '):
                    fields = [float(x) for x in line.strip().split()[1:]]
                    idle = fields[3] + (fields[4] if len(fields) > 4 else 0.0)
                    total = sum(fields)
                    return idle, total
        return 0.0, 0.0
    
    try:
        i1, t1 = read_stat()
        time.sleep(interval)
        i2, t2 = read_stat()
        diff_total = t2 - t1
        diff_idle = i2 - i1
        if diff_total <= 0: return 0.0
        return max(0.0, min(100.0, ((diff_total - diff_idle) / diff_total) * 100.0))
    except Exception:
        return 0.0

def get_cpu_cores():
    try:
        return os.cpu_count() or 1
    except Exception:
        return 1

def get_memory_info():
    mem_total, mem_free, mem_avail = 0, 0, 0
    swap_total, swap_free = 0, 0
    try:
        with open('/proc/meminfo', 'r') as f:
            for line in f:
                parts = line.strip().split()
                if not parts: continue
                k = parts[0].rstrip(':')
                v = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
                if k == 'MemTotal': mem_total = v
                elif k == 'MemFree': mem_free = v
                elif k == 'MemAvailable': mem_avail = v
                elif k == 'SwapTotal': swap_total = v
                elif k == 'SwapFree': swap_free = v
        
        mem_avail = mem_avail if mem_avail > 0 else mem_free
        mem_used = max(0, mem_total - mem_avail)
        mem_pct = (mem_used / mem_total * 100.0) if mem_total > 0 else 0.0
        
        swap_used = max(0, swap_total - swap_free)
        swap_pct = (swap_used / swap_total * 100.0) if swap_total > 0 else 0.0
        
        return {
            'mem_total_mb': round(mem_total / 1024, 1),
            'mem_used_mb': round(mem_used / 1024, 1),
            'mem_percent': round(mem_pct, 1),
            'swap_total_mb': round(swap_total / 1024, 1),
            'swap_used_mb': round(swap_used / 1024, 1),
            'swap_percent': round(swap_pct, 1)
        }
    except Exception:
        return {'mem_total_mb': 0, 'mem_used_mb': 0, 'mem_percent': 0, 'swap_total_mb': 0, 'swap_used_mb': 0, 'swap_percent': 0}

def get_disk_info():
    try:
        st = os.statvfs('/')
        total = (st.f_blocks * st.f_frsize) / (1024**3)
        free = (st.f_bavail * st.f_frsize) / (1024**3)
        used = max(0.0, total - free)
        pct = (used / total * 100.0) if total > 0 else 0.0
        return {
            'disk_total_gb': round(total, 1),
            'disk_used_gb': round(used, 1),
            'disk_percent': round(pct, 1)
        }
    except Exception:
        return {'disk_total_gb': 0, 'disk_used_gb': 0, 'disk_percent': 0}

def get_network_bytes():
    rx, tx = 0, 0
    try:
        with open('/proc/net/dev', 'r') as f:
            for line in f:
                if ':' not in line: continue
                iface, data = line.split(':', 1)
                iface = iface.strip()
                if iface in ['lo', 'docker0']: continue
                fields = data.split()
                if len(fields) >= 9:
                    rx += int(fields[0])
                    tx += int(fields[8])
    except Exception:
        pass
    return rx, tx

def format_speed(bps):
    if bps < 1024: return f"{bps:.0f} B/s"
    elif bps < 1024 * 1024: return f"{bps/1024:.1f} KB/s"
    elif bps < 1024 * 1024 * 1024: return f"{bps/(1024*1024):.1f} MB/s"
    else: return f"{bps/(1024*1024*1024):.2f} GB/s"

def format_bytes(b):
    if b < 1024: return f"{b:.0f} B"
    elif b < 1024 * 1024: return f"{b/1024:.1f} KB"
    elif b < 1024 * 1024 * 1024: return f"{b/(1024*1024):.1f} MB"
    else: return f"{b/(1024*1024*1024):.2f} GB"

def get_uptime_str():
    try:
        with open('/proc/uptime', 'r') as f:
            sec = float(f.readline().split()[0])
            days = int(sec // 86400)
            hours = int((sec % 86400) // 3600)
            minutes = int((sec % 3600) // 60)
            if days > 0: return f"{days}天{hours}小时"
            if hours > 0: return f"{hours}小时{minutes}分"
            return f"{minutes}分钟"
    except Exception:
        return "未知"

def get_load_avg():
    try:
        with open('/proc/loadavg', 'r') as f:
            p = f.read().strip().split()
            return f"{p[0]} {p[1]} {p[2]}"
    except Exception:
        return "0.0 0.0 0.0"

def get_os_release():
    try:
        if os.path.exists('/etc/os-release'):
            with open('/etc/os-release') as f:
                d = dict(line.strip().split('=', 1) for line in f if '=' in line)
                name = d.get('PRETTY_NAME', d.get('NAME', 'Linux')).strip('\"')
                return name
    except Exception:
        pass
    return "Linux"

def get_public_ip(version=4):
    urls = ['https://api.ipify.org', 'https://checkip.amazonaws.com', 'https://ipv4.icanhazip.com'] if version == 4 else ['https://api64.ipify.org', 'https://ipv6.icanhazip.com', 'https://v6.ident.me']
    for u in urls:
        try:
            req = urllib.request.Request(u, headers={'User-Agent': 'curl/7.88.1'})
            with urllib.request.urlopen(req, timeout=3) as resp:
                ip = resp.read().decode('utf-8').strip()
                if ip:
                    if version == 4 and '.' in ip and ':' not in ip: return ip
                    if version == 6 and ':' in ip: return ip
        except Exception:
            pass
    return ""

def main():
    parser = argparse.ArgumentParser(description="Server Status Probe Agent")
    parser.add_argument('--url', required=True, help="探针上报服务端地址 (如 https://x.example.com/report)")
    parser.add_argument('--token', required=True, help="探针上报 Token")
    parser.add_argument('--id', default="1", help="服务器唯一排序编号/ID (如 1, 2, 3...)")
    parser.add_argument('--name', default="", help="服务器显示名称")
    parser.add_argument('--group', default="甲骨文arm", help="服务器所属分类分组")
    parser.add_argument('--location', default="云服务器", help="地理位置")
    parser.add_argument('--interval', type=int, default=30, help="上报周期(秒)")
    args = parser.parse_args()

    node_id = str(args.id)
    node_name = args.name or socket.gethostname()
    node_group = args.group
    node_loc = args.location
    report_url = args.url
    token = args.token
    interval = max(10, args.interval)

    os_name = get_os_release()
    cpu_cores = get_cpu_cores()

    last_rx, last_tx = get_network_bytes()
    last_time = time.time()

    print(f"[*] Server Probe Agent 已启动 | 节点: {node_name} (ID: {node_id}) | 上报周期: {interval}s")
    
    ipv4 = get_public_ip(4)
    ipv6 = get_public_ip(6)

    while True:
        try:
            if not ipv4: ipv4 = get_public_ip(4)
            if not ipv6: ipv6 = get_public_ip(6)

            cpu_pct = get_cpu_usage(interval=1)
            mem = get_memory_info()
            disk = get_disk_info()
            
            cur_time = time.time()
            cur_rx, cur_tx = get_network_bytes()
            time_delta = max(1.0, cur_time - last_time)
            
            rx_speed = max(0, (cur_rx - last_rx) / time_delta)
            tx_speed = max(0, (cur_tx - last_tx) / time_delta)
            
            last_rx, last_tx = cur_rx, cur_tx
            last_time = cur_time

            payload = {
                'id': node_id,
                'name': node_name,
                'group': node_group,
                'location': node_loc,
                'os': os_name,
                'cpu_cores': cpu_cores,
                'cpu': cpu_pct,
                'mem_total_mb': mem['mem_total_mb'],
                'mem_used_mb': mem['mem_used_mb'],
                'mem_percent': mem['mem_percent'],
                'swap_total_mb': mem['swap_total_mb'],
                'swap_used_mb': mem['swap_used_mb'],
                'swap_percent': mem['swap_percent'],
                'disk_total_gb': disk['disk_total_gb'],
                'disk_used_gb': disk['disk_used_gb'],
                'disk_percent': disk['disk_percent'],
                'net_rx_rate': format_speed(rx_speed),
                'net_tx_rate': format_speed(tx_speed),
                'net_rx_total': format_bytes(cur_rx),
                'net_tx_total': format_bytes(cur_tx),
                'uptime': get_uptime_str(),
                'load': get_load_avg(),
                'ip': ipv4,
                'ipv6': ipv6
            }

            req_url = f"{report_url}?token={token}" if '?' not in report_url else f"{report_url}&token={token}"
            data = json.dumps(payload).encode('utf-8')
            req = urllib.request.Request(
                req_url,
                data=data,
                headers={'Content-Type': 'application/json', 'X-Token': token, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
            )
            with urllib.request.urlopen(req, timeout=10) as resp:
                try:
                    res_body = resp.read().decode('utf-8')
                    res_json = json.loads(res_body)
                    if res_json.get('action') == 'self_uninstall':
                        print(f"[!] 收到云端下线退役指令 (ID: {node_id}),正在执行本地探针服务自我卸载与数据清理...")
                        cmd = """
                        sleep 1
                        systemctl stop node-probe.service 2>/dev/null || true
                        systemctl disable node-probe.service 2>/dev/null || true
                        rm -f /etc/systemd/system/node-probe.service
                        systemctl daemon-reload
                        rm -f /usr/local/bin/node-probe-agent.py
                        """
                        subprocess.Popen(['bash', '-c', cmd], start_new_session=True)
                        sys.exit(0)
                except Exception:
                    pass
        except Exception as e:
            print(f"[!] 上报异常: {e}", file=sys.stderr)
            time.sleep(5)
            continue
            
        time.sleep(interval)

if __name__ == '__main__':
    main()
`;

function formatLocation(rawLocation, cfCountry) {
  let loc = (rawLocation || '').trim();
  loc = loc.replace(/^[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]\s*/, '');
  loc = loc.replace(/^☁️\s*/, '').trim();

  if (!loc) {
    if (cfCountry) {
      const code = cfCountry.toUpperCase();
      const nameMap = { 'HK': '香港', 'JP': '日本', 'KR': '韩国', 'SG': '新加坡', 'US': '美国', 'CN': '中国', 'DE': '德国', 'GB': '英国' };
      return nameMap[code] || code;
    }
    return '云服务器';
  }

  const lower = loc.toLowerCase();
  if (lower.includes('香港') || lower === 'hk' || lower.includes('hong kong')) return loc.replace(/^hk\s*/i, '') || '香港';
  if (lower.includes('日本') || lower.includes('东京') || lower.includes('大阪') || lower === 'jp' || lower.includes('japan')) return loc.replace(/^jp\s*/i, '') || '日本';
  if (lower.includes('韩国') || lower.includes('首尔') || lower.includes('春川') || lower === 'kr' || lower.includes('korea')) return loc.replace(/^kr\s*/i, '') || '韩国';
  if (lower.includes('新加坡') || lower.includes('狮城') || lower === 'sg' || lower.includes('singapore')) return loc.replace(/^sg\s*/i, '') || '新加坡';
  if (lower.includes('美国') || lower.includes('美西') || lower.includes('洛杉矶') || lower.includes('圣何塞') || lower.includes('硅谷') || lower === 'us' || lower.includes('usa')) return loc.replace(/^us\s*/i, '') || '美国';
  if (lower.includes('中国') || lower.includes('北京') || lower.includes('上海') || lower.includes('广州') || lower.includes('深圳') || lower === 'cn' || lower.includes('china')) return loc.replace(/^cn\s*/i, '') || '中国';
  if (lower.includes('德国') || lower.includes('法兰克福') || lower === 'de' || lower.includes('germany')) return loc.replace(/^de\s*/i, '') || '德国';
  if (lower.includes('英国') || lower.includes('伦敦') || lower === 'uk' || lower === 'gb' || lower.includes('britain')) return loc.replace(/^uk\s*/i, '') || '英国';

  return loc;
}

async function timingSafeEqual(a, b) {
  if (typeof a !== 'string' || typeof b !== 'string') return false;
  if (!a || !b) return false;
  const enc = new TextEncoder();
  const aBuf = await crypto.subtle.digest('SHA-256', enc.encode(a));
  const bBuf = await crypto.subtle.digest('SHA-256', enc.encode(b));
  const aArr = new Uint8Array(aBuf);
  const bArr = new Uint8Array(bBuf);
  let diff = 0;
  for (let i = 0; i < aArr.length; i++) {
    diff |= aArr[i] ^ bArr[i];
  }
  return diff === 0 && a.length === b.length;
}

async function verifyAdmin(request, env) {
  const adminKey = env.ADMIN_KEY || DEFAULT_ADMIN_KEY;
  const authHeader = request.headers.get("Authorization") || "";
  if (authHeader.startsWith("Bearer ")) {
    const tokenPart = authHeader.slice(7).trim();
    if (await timingSafeEqual(tokenPart, adminKey)) return true;
  }
  const customHeader = request.headers.get("X-Admin-Key") || "";
  if (customHeader && await timingSafeEqual(customHeader.trim(), adminKey)) return true;

  const cookieHeader = request.headers.get("Cookie") || "";
  if (cookieHeader) {
    const cookies = Object.fromEntries(cookieHeader.split(';').map(c => {
      const [k, ...v] = c.trim().split('=');
      return [k, (v || []).join('=')];
    }));
    const sessionVal = cookies['server_admin_key'];
    if (sessionVal && await timingSafeEqual(decodeURIComponent(sessionVal).trim(), adminKey)) {
      return true;
    }
  }
  return false;
}

function jsonResponse(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Admin-Key, X-Token'
    }
  });
}

function maskIp(ip) {
  if (!ip) return '-';
  if (ip.includes(':')) {
    const parts = ip.split(':');
    if (parts.length > 2) {
      return parts.slice(0, 2).join(':') + '::***';
    }
    return ip;
  }
  const parts = ip.split('.');
  if (parts.length === 4) {
    return `${parts[0]}.${parts[1]}.${parts[2]}.***`;
  }
  return ip;
}

function formatBeijingDate(ts) {
  const d = new Date(ts + 8 * 3600 * 1000);
  const pad = (n) => String(n).padStart(2, '0');
  const y = d.getUTCFullYear();
  const m = pad(d.getUTCMonth() + 1);
  const day = pad(d.getUTCDate());
  return `${y}-${m}-${day}`;
}

function formatBeijingTime(ts) {
  const d = new Date(ts + 8 * 3600 * 1000);
  const pad = (n) => String(n).padStart(2, '0');
  const y = d.getUTCFullYear();
  const m = pad(d.getUTCMonth() + 1);
  const day = pad(d.getUTCDate());
  const hh = pad(d.getUTCHours());
  const mm = pad(d.getUTCMinutes());
  const ss = pad(d.getUTCSeconds());
  return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
}

function renderSparkline(history) {
  const data = (history && history.length) ? history : [0];
  const max = Math.max(100, ...data);
  const w = 120;
  const h = 28;
  const step = data.length > 1 ? w / (data.length - 1) : w;

  const points = data.map((val, idx) => {
    const x = idx * step;
    const y = h - (val / max) * (h - 6) - 3;
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(' ');

  return `
    <div class="sparkline-wrap" title="最近 12 点负载走势">
      <svg class="sparkline" viewBox="0 0 ${w} ${h}">
        <polyline fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="${points}" />
      </svg>
    </div>
  `;
}

function getBarColor(pct) {
  if (pct >= 85) return 'bar-red';
  if (pct >= 65) return 'bar-yellow';
  return 'bar-green';
}

function normalizeNodeInfo(node) {
  node.group = "甲骨文arm";
  const idStr = String(node.id || '1');
  node.id = idStr;
  if (idStr === '1') {
    node.name = "Oracle韩国ARM";
    node.location = "韩国";
  } else if (idStr === '2') {
    node.name = "Oracle日本ARM";
    node.location = "日本";
  } else {
    node.location = formatLocation(node.location);
  }
  return node;
}

function shouldShowIp(ipMode, isAdmin) {
  if (ipMode === 'hidden') return false;
  if (ipMode === 'public_masked' || ipMode === 'public_full') return true;
  if (ipMode === 'admin_only_masked' || ipMode === 'admin_only_full' || !ipMode) return !!isAdmin;
  return !!isAdmin;
}

function formatDisplayIp(ip, ipMode, isAdmin) {
  if (!ip) return '';
  if (ipMode === 'public_full' || (isAdmin && ipMode === 'admin_only_full')) {
    return ip;
  }
  return maskIp(ip);
}
async function getClusterStatus(env, isAdmin = false) {
  const settings = await Storage.getSettings(env);
  const timeoutThreshold = settings.timeout_seconds || 120;
  const sortMode = settings.sort_mode || "numeric_asc";
  const ipMode = settings.ip_display_mode || "admin_only_masked";
  const canShowIp = shouldShowIp(ipMode, isAdmin);

  const rawNodes = await Storage.getAllNodes(env);
  const now = Math.floor(Date.now() / 1000);
  const nodes = [];

  for (let node of rawNodes) {
    if (!node) continue;
    node.is_public = node.is_public !== false; // default public
    if (!isAdmin && !node.is_public) {
      continue; // Skip private/internal nodes for public visitors
    }
    const diff = now - (node.updated_at || 0);
    node.is_online = diff <= timeoutThreshold;
    node.last_seen_sec = diff;
    node = normalizeNodeInfo(node);
    node.cpu_history = node.cpu_history || [node.cpu || 0];
    node.updated_at_beijing = formatBeijingTime(node.updated_at * 1000);

    // Initialize 60-block health history if new/empty
    if (!node.health_history || node.health_history.length === 0) {
      const initBlocks = [];
      const baseLevel = node.is_online ? 5 : 0;
      for (let i = 0; i < 45; i++) {
        initBlocks.push({ level: baseLevel, cpu: node.cpu || 0, mem: node.mem_percent || 0, time: now - (45 - i) * 60 });
      }
      node.health_history = initBlocks;
    } else if (!node.is_online) {
      // Mark current state as offline (level 1 or 0)
      if (node.health_history.length > 0) {
        node.health_history[node.health_history.length - 1].level = 1;
      }
    }

    // Apply security & privacy IP policy
    if (canShowIp) {
      node.ip = formatDisplayIp(node.ip, ipMode, isAdmin);
      node.ipv6 = formatDisplayIp(node.ipv6, ipMode, isAdmin);
    } else {
      node.ip = '';
      node.ipv6 = '';
    }

    nodes.push(node);
  }

  // Dynamic sorting according to user's defined sort_mode
  nodes.sort((a, b) => {
    if (a.is_online !== b.is_online) return b.is_online ? 1 : -1;
    const idA = String(a.id || '');
    const idB = String(b.id || '');

    if (sortMode === 'numeric_desc') {
      const cmp = idB.localeCompare(idA, undefined, { numeric: true, sensitivity: 'base' });
      if (cmp !== 0) return cmp;
      return b.name.localeCompare(a.name, 'zh-CN');
    } else if (sortMode === 'updated_desc') {
      return (b.updated_at || 0) - (a.updated_at || 0);
    } else {
      // Default: numeric_asc
      const cmp = idA.localeCompare(idB, undefined, { numeric: true, sensitivity: 'base' });
      if (cmp !== 0) return cmp;
      return a.name.localeCompare(b.name, 'zh-CN');
    }
  });

  const onlineCount = nodes.filter(n => n.is_online).length;
  const offlineCount = nodes.length - onlineCount;

  const groups = [
    {
      name: "甲骨文arm",
      total: nodes.length,
      online_count: onlineCount,
      offline_count: offlineCount,
      nodes
    }
  ];

  const dailyLimit = 100000;
  const estDailyWrites = nodes.length * 1440;
  const estUsedPct = ((estDailyWrites / dailyLimit) * 100).toFixed(2);
  const estRemainWrites = Math.max(0, dailyLimit - estDailyWrites);
  const estRemainPct = (100 - parseFloat(estUsedPct)).toFixed(2);

  return {
    total_nodes: nodes.length,
    online_count: onlineCount,
    offline_count: offlineCount,
    est_daily_writes: estDailyWrites,
    est_used_pct: estUsedPct,
    est_remain_writes: estRemainWrites,
    est_remain_pct: estRemainPct,
    daily_limit: dailyLimit,
    storage_engine: env.DB ? 'Cloudflare D1 SQL 数据库' : 'Cloudflare D1 关系型 SQL 数据库',
    settings,
    groups,
    nodes
  };
}


// ==============================================================================
// PPanel Design System: Node Card Renderer
// ==============================================================================
function renderNodeCard(node, isAdmin, settings = {}) {
  const isOnline = node.is_online;
  const isPublic = node.is_public !== false;
  const statusBadge = isOnline
    ? '<span class="status-chip chip-online"><span class="status-dot"></span>运行中</span>'
    : '<span class="status-chip chip-offline"><span class="status-dot dot-offline"></span>已离线</span>';

  const visBadge = isAdmin
    ? `<button class="vis-btn ${isPublic ? 'vis-public' : 'vis-private'}" data-id="${node.id}" data-public="${isPublic}" onclick="toggleNodeVisibility(this)" title="${isPublic ? '公开节点 (点击设为隐私)' : '隐私节点 (点击设为公开)'}">${isPublic ? '🌍 公开' : '🔒 隐私'}</button>`
    : '';

  const memFormatted = node.mem_total_mb > 1024
    ? `${(node.mem_used_mb / 1024).toFixed(1)} GB / ${(node.mem_total_mb / 1024).toFixed(1)} GB`
    : `${node.mem_used_mb.toFixed(0)} MB / ${node.mem_total_mb.toFixed(0)} MB`;

  const swapFormatted = node.swap_total_mb > 0
    ? (node.swap_total_mb > 1024
        ? `${(node.swap_used_mb / 1024).toFixed(1)} / ${(node.swap_total_mb / 1024).toFixed(1)} GB`
        : `${node.swap_used_mb.toFixed(0)} / ${node.swap_total_mb.toFixed(0)} MB`)
    : '无 Swap';

  const diskFormatted = `${node.disk_used_gb.toFixed(1)} GB / ${node.disk_total_gb.toFixed(1)} GB`;
  const hasIp = !!(node.ip || node.ipv6);

  return `
    <div class="node-card ${isOnline ? '' : 'node-offline'}" id="node-${node.id}">
      <div class="node-card-head">
        <div style="min-width:0; flex:1;">
          <div class="title-pill-group">
            <span class="badge badge-primary">#${node.id}</span>
            <span class="badge badge-secondary">${node.location}</span>
            ${visBadge}
            <span class="node-title">${node.name}</span>
          </div>
          ${hasIp ? `
            <div class="node-ip-row">
              ${node.ip ? `<span class="ip-tag">IPv4</span> <code class="ip-code">${node.ip}</code>` : ''}
              ${node.ipv6 ? `<span class="ip-tag">IPv6</span> <code class="ip-code">${node.ipv6}</code>` : ''}
            </div>
          ` : ''}
        </div>
        <div>${statusBadge}</div>
      </div>

      <div class="meta-pills-row">
        <span class="meta-pill">${node.os}</span>
        <span class="meta-pill">⏱️ ${node.uptime || '刚刚启动'}</span>
        ${node.load ? `<span class="meta-pill">负载 ${node.load}</span>` : ''}
      </div>

      <div class="meters-container">
        <div class="meter-box">
          <div class="meter-header">
            <span>CPU 使用率</span>
            <span class="meter-val"><strong>${node.cpu.toFixed(1)}%</strong> <small>(${node.cpu_cores}核)</small></span>
          </div>
          <div class="progress-track">
            <div class="progress-fill ${getBarColor(node.cpu)}" style="width: ${Math.min(node.cpu, 100)}%;"></div>
          </div>
          ${renderSparkline(node.cpu_history)}
        </div>

        <div class="meter-box">
          <div class="meter-header">
            <span>内存占用 (${node.mem_percent}%)</span>
            <span class="meter-val">${memFormatted}</span>
          </div>
          <div class="progress-track">
            <div class="progress-fill ${getBarColor(node.mem_percent)}" style="width: ${Math.min(node.mem_percent, 100)}%;"></div>
          </div>
        </div>

        <div class="meter-box">
          <div class="meter-header">
            <span>Swap 交换 (${node.swap_percent}%)</span>
            <span class="meter-val">${swapFormatted}</span>
          </div>
          <div class="progress-track">
            <div class="progress-fill ${getBarColor(node.swap_percent)}" style="width: ${Math.min(node.swap_percent, 100)}%;"></div>
          </div>
        </div>

        <div class="meter-box">
          <div class="meter-header">
            <span>系统存储盘 (${node.disk_percent}%)</span>
            <span class="meter-val">${diskFormatted}</span>
          </div>
          <div class="progress-track">
            <div class="progress-fill ${getBarColor(node.disk_percent)}" style="width: ${Math.min(node.disk_percent, 100)}%;"></div>
          </div>
        </div>
      </div>

      <div class="net-grid">
        <div class="net-cell">
          <span class="net-label">⬇ 下行实时速率</span>
          <span class="net-speed">${node.net_rx_rate}</span>
          <span class="net-total">累计: ${node.net_rx_total}</span>
        </div>
        <div class="net-cell">
          <span class="net-label">⬆ 上行实时速率</span>
          <span class="net-speed">${node.net_tx_rate}</span>
          <span class="net-total">累计: ${node.net_tx_total}</span>
        </div>
      </div>

      <div class="node-footer">
        <span class="update-time">上报时间: ${node.updated_at_beijing || '-'}</span>
        ${isAdmin ? `
          <button class="btn btn-danger btn-sm" data-id="${node.id}" data-name="${node.name}" onclick="openDeleteModal(this)" title="下线此服务器">下线节点</button>
        ` : ''}
      </div>
    </div>
  `;
}

function parseUptimeDays(uptime) {
  if (!uptime) return 1;
  const match = uptime.match(/(\d+)\s*天/);
  if (match) return parseInt(match[1], 10) || 1;
  return 1;
}

// ==============================================================================
// PPanel Design System: 30-Day Activity Heatmap for Node (3 Rows x 10 Days)
// ==============================================================================
function renderNodeHeatmap(node, isAdmin) {
  const isOnline = node.is_online;
  const isPublic = node.is_public !== false;
  const statusBadge = isOnline
    ? '<span class="status-chip chip-online"><span class="status-dot"></span>运行中</span>'
    : '<span class="status-chip chip-offline"><span class="status-dot dot-offline"></span>离线</span>';

  const visBadge = isAdmin
    ? `<button class="vis-btn ${isPublic ? 'vis-public' : 'vis-private'}" data-id="${node.id}" data-public="${isPublic}" onclick="toggleNodeVisibility(this)" title="${isPublic ? '公开节点' : '隐私节点'}">${isPublic ? '🌍 公开' : '🔒 隐私'}</button>`
    : '';

  const targetDays = 30;
  const nowTs = Math.floor(Date.now() / 1000);
  const uptimeDays = isOnline ? Math.min(30, Math.max(1, parseUptimeDays(node.uptime))) : 0;

  const colorMap = {
    0: 'hm-gray',
    1: 'hm-red',
    2: 'hm-orange',
    3: 'hm-yellow',
    4: 'hm-lightgreen',
    5: 'hm-darkgreen'
  };

  const levelName = {
    0: '未上线 / 无数据',
    1: '严重异常 (CPU>95% 或 离线)',
    2: '高负载警告 (CPU>85%)',
    3: '轻度警告 (CPU>70%)',
    4: '状态良好 (CPU>50%)',
    5: '状态极佳 (健康)'
  };

  const rowsHtml = [];

  for (let rowIdx = 0; rowIdx < 3; rowIdx++) {
    const startOffset = 29 - rowIdx * 10;
    const endOffset = 20 - rowIdx * 10;

    const startTs = nowTs - (startOffset * 86400);
    const endTs = nowTs - (endOffset * 86400);

    const startDateStr = formatBeijingDate(startTs * 1000);
    const endDateStr = formatBeijingDate(endTs * 1000);

    const isLatestRow = (rowIdx === 2);
    const rowTag = isLatestRow ? '近 10 天' : `${startOffset + 1}~${endOffset + 1} 天前`;
    const rowRange = isLatestRow
      ? `${startDateStr.slice(5)} ~ ${endDateStr.slice(5)} (今天 · 实时)`
      : `${startDateStr.slice(5)} ~ ${endDateStr.slice(5)}`;

    const cells = [];
    for (let d = startOffset; d >= endOffset; d--) {
      const dayTs = nowTs - d * 86400;
      const labelDate = formatBeijingDate(dayTs * 1000);
      const dayNum = labelDate.slice(8);
      const isToday = (d === 0);

      let lvl = 0;
      let desc = '无数据';
      let cpuVal = '0.0%';
      let memVal = '0.0%';

      if (isOnline && d < uptimeDays) {
        lvl = 5;
        if (isToday) {
          if (node.cpu > 95 || node.mem_percent > 95) lvl = 1;
          else if (node.cpu > 85 || node.mem_percent > 90) lvl = 2;
          else if (node.cpu > 70 || node.mem_percent > 80) lvl = 3;
          else if (node.cpu > 50 || node.mem_percent > 70) lvl = 4;
          cpuVal = node.cpu.toFixed(1) + '%';
          memVal = node.mem_percent + '%';
        } else {
          cpuVal = (node.cpu * 0.9 + 0.5).toFixed(1) + '%';
          memVal = node.mem_percent + '%';
        }
        desc = levelName[lvl];
      } else if (isToday) {
        lvl = 1;
        desc = '离线 / 停止上报';
      }

      const cls = colorMap[lvl] || 'hm-gray';
      cells.push(`
        <div class="hm-cell ${cls}"
             data-date="${labelDate}"
             data-status="${desc}"
             data-cpu="${cpuVal}"
             data-mem="${memVal}"
             onmouseenter="showHeatmapTooltip(this, event)"
             onmouseleave="hideHeatmapTooltip()"
             onclick="showHeatmapTooltip(this, event)"
             title="${labelDate} &#10;状态: ${desc}">
          <span class="hm-day-num">${dayNum}</span>
        </div>
      `);
    }

    rowsHtml.push(`
      <div class="heatmap-row-wrap ${isLatestRow ? 'row-today' : ''}">
        <div class="heatmap-row-header">
          <span class="hm-row-tag ${isLatestRow ? 'today' : ''}">${isLatestRow ? '⚡ ' : '📅 '}${rowTag}</span>
          <span class="hm-row-range ${isLatestRow ? 'today' : ''}">${rowRange}</span>
        </div>
        <div class="heatmap-row-cells">
          ${cells.join('')}
        </div>
      </div>
    `);
  }

  const activeRate = isOnline ? ((uptimeDays / targetDays) * 100).toFixed(1) : '0.0';

  return `
    <div class="card ppanel-card ${isOnline ? '' : 'node-offline'}" id="node-hm-${node.id}">
      <div class="card-header-row">
        <div class="header-left">
          <div class="title-pill-group">
            <span class="badge badge-primary">#${node.id}</span>
            <span class="badge badge-secondary">${node.location}</span>
            ${visBadge}
            <h3 class="card-title">${node.name}</h3>
          </div>
          <div class="meta-subtext-row">
            <span>⏱️ 在线: <strong>${node.uptime || '刚刚启动'}</strong></span>
            <span>⚡ 30天在线度: <strong style="color:var(--primary);">${activeRate}% (${uptimeDays}/30天)</strong></span>
            <span>CPU: <strong>${node.cpu.toFixed(1)}%</strong></span>
            <span>内存: <strong>${node.mem_percent}%</strong></span>
          </div>
        </div>
        <div class="header-right">${statusBadge}</div>
      </div>

      <div class="heatmap-3rows-container">
        ${rowsHtml.join('')}
      </div>

      <div class="heatmap-legend-row">
        <span class="legend-label">异常/离线</span>
        <div class="legend-cells">
          <span class="hm-cell hm-gray" title="未上线 / 无数据"></span>
          <span class="hm-cell hm-red" title="严重异常 / 离线"></span>
          <span class="hm-cell hm-orange" title="高负载 (85%~95%)"></span>
          <span class="hm-cell hm-yellow" title="轻度警告 (70%~85%)"></span>
          <span class="hm-cell hm-lightgreen" title="状态良好 (50%~70%)"></span>
          <span class="hm-cell hm-darkgreen" title="状态极佳 (健康)"></span>
        </div>
        <span class="legend-label">健康在线</span>
        ${isAdmin ? `
          <div style="margin-left:auto;">
            <button class="btn btn-danger btn-sm" data-id="${node.id}" data-name="${node.name}" onclick="openDeleteModal(this)">下线节点</button>
          </div>
        ` : ''}
      </div>
    </div>
  `;
}

function renderNodeRow(node, isAdmin, settings = {}) {
  const isOnline = node.is_online;
  const isPublic = node.is_public !== false;
  const statusBadge = isOnline
    ? '<span class="status-chip chip-online"><span class="status-dot"></span>在线</span>'
    : '<span class="status-chip chip-offline"><span class="status-dot dot-offline"></span>离线</span>';

  const visBadge = isAdmin
    ? `<button class="vis-btn ${isPublic ? 'vis-public' : 'vis-private'}" data-id="${node.id}" data-public="${isPublic}" onclick="toggleNodeVisibility(this)" title="${isPublic ? '公开节点' : '隐私节点'}">${isPublic ? '🌍 公开' : '🔒 隐私'}</button>`
    : '';

  const memFormatted = node.mem_total_mb > 1024
    ? `${(node.mem_used_mb / 1024).toFixed(1)}G / ${(node.mem_total_mb / 1024).toFixed(1)}G`
    : `${node.mem_used_mb.toFixed(0)}M / ${node.mem_total_mb.toFixed(0)}M`;

  const diskFormatted = `${node.disk_used_gb.toFixed(1)}G / ${node.disk_total_gb.toFixed(1)}G`;
  const ipDisp = (node.ip ? `<code>${node.ip}</code>` : '') + (node.ipv6 ? `${node.ip ? '<br>' : ''}<code style="font-size:0.75rem; color:var(--text-muted);">${node.ipv6}</code>` : '') || '<span style="color:var(--text-muted);">-</span>';

  return `
    <tr id="tbl-row-${node.id}">
      <td>${statusBadge}</td>
      <td>
        <div style="font-weight:700; display:flex; align-items:center; gap:6px;">
          <span class="badge badge-primary">#${node.id}</span>
          <strong>${node.name}</strong>
          ${visBadge}
        </div>
        <div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">${node.os}</div>
      </td>
      <td><span class="badge badge-secondary">${node.location}</span></td>
      <td>${ipDisp}</td>
      <td><span class="tbl-meter ${getBarColor(node.cpu)}">${node.cpu.toFixed(1)}%</span></td>
      <td>
        <span class="tbl-meter ${getBarColor(node.mem_percent)}">${node.mem_percent}%</span>
        <div style="color:var(--text-muted); font-size:0.72rem; margin-top:2px;">${memFormatted}</div>
      </td>
      <td>
        <span class="tbl-meter ${getBarColor(node.disk_percent)}">${node.disk_percent}%</span>
        <div style="color:var(--text-muted); font-size:0.72rem; margin-top:2px;">${diskFormatted}</div>
      </td>
      <td>
        <div style="font-family:monospace; font-size:0.8rem;">⬇ ${node.net_rx_rate}</div>
        <div style="font-family:monospace; font-size:0.8rem; color:var(--text-muted);">⬆ ${node.net_tx_rate}</div>
      </td>
      <td>
        <div>${node.uptime || '刚刚启动'}</div>
        ${node.load ? `<div style="color:var(--text-muted); font-size:0.72rem;">${node.load}</div>` : ''}
      </td>
      <td><small style="font-family:monospace; color:var(--text-muted);">${(node.updated_at_beijing || '').split(' ')[1] || '-'}</small></td>
      <td>
        ${isAdmin ? `<button class="btn btn-danger btn-sm" data-id="${node.id}" data-name="${node.name}" onclick="openDeleteModal(this)">下线</button>` : '<span style="color:var(--text-muted); font-size:0.78rem;">只读</span>'}
      </td>
    </tr>
  `;
}
async function handleInstallScript(request, url, env) {
  const host = request.headers.get('host') || 'x.example.com';
  const isAdmin = await verifyAdmin(request, env);
  const tokenParam = url.searchParams.get('token');
  const isTokenValid = await Storage.verifyReportToken(env, tokenParam);
  let defaultToken = "YOUR_PROBE_TOKEN";
  if (isTokenValid) {
    defaultToken = tokenParam;
  } else if (isAdmin) {
    defaultToken = env.REPORT_TOKEN || DEFAULT_REPORT_TOKEN;
  }

  const script = `#!/bin/bash
# =========================================================
# 轻量服务器探针监控系统
# 安装目录: /usr/local/bin/node-probe-agent.py
# 服务管理: systemctl status node-probe.service
# =========================================================
set -e

RED='\\033[0;31m'
GREEN='\\033[0;32m'
BLUE='\\033[0;34m'
YELLOW='\\033[1;33m'
NC='\\033[0m'

echo -e "\${BLUE}=========================================================\${NC}"
echo -e "\${YELLOW}    🚀  Server Status Probe 探针极速安装向导\${NC}"
echo -e "\${BLUE}=========================================================\${NC}"

if [ "$(id -u)" != "0" ]; then
    echo -e "\${RED}错误:请使用 root 权限执行此脚本 (sudo bash)!\${NC}"
    exit 1
fi

if ! command -v python3 >/dev/null 2>&1; then
    echo -e "\${YELLOW}[*] 检测到未安装 Python3,正在自动安装...\${NC}"
    if command -v apt >/dev/null 2>&1; then
        apt update -y && apt install -y python3
    elif command -v yum >/dev/null 2>&1; then
        yum install -y python3
    elif command -v apk >/dev/null 2>&1; then
        apk add python3
    else
        echo -e "\${RED}错误:未找到系统包管理器,请先手动安装 Python3。\${NC}"
        exit 1
    fi
fi

NODE_ID=""
NODE_NAME=""
NODE_GROUP="甲骨文arm"
NODE_LOC=""
TOKEN="${defaultToken}"
URL="https://${host}/report"
INTERVAL=30

while [[ $# -gt 0 ]]; do
    case "$1" in
        --id) NODE_ID="$2"; shift 2 ;;
        --name) NODE_NAME="$2"; shift 2 ;;
        --group|--category) NODE_GROUP="$2"; shift 2 ;;
        --loc|--location) NODE_LOC="$2"; shift 2 ;;
        --token) TOKEN="$2"; shift 2 ;;
        --url) URL="$2"; shift 2 ;;
        --interval) INTERVAL="$2"; shift 2 ;;
        *) shift ;;
    esac
done

if [ "$TOKEN" = "YOUR_PROBE_TOKEN" ] || [ -z "$TOKEN" ]; then
    read -p "请输入探针专属上报 Token: " input_token
    TOKEN="\${input_token}"
fi

if [ -z "$TOKEN" ] || [ "$TOKEN" = "YOUR_PROBE_TOKEN" ]; then
    echo -e "\${RED}错误:未提供有效的上报 Token,探针安装已终止!\${NC}"
    exit 1
fi

if [ -z "$NODE_ID" ]; then
    DEFAULT_ID="3"
    read -p "请输入服务器排序编号/节点ID (如 1, 2, 3... 用于卡片排列先后) [默认: $DEFAULT_ID]: " input_id
    NODE_ID="\${input_id:-\$DEFAULT_ID}"
fi

if [ -z "$NODE_NAME" ]; then
    DEFAULT_NAME="$(hostname)"
    read -p "请输入服务器显示名称 [默认: $DEFAULT_NAME]: " input_name
    NODE_NAME="\${input_name:-\$DEFAULT_NAME}"
fi

if [ -z "$NODE_LOC" ]; then
    read -p "请输入服务器地理位置 (如 香港 / 日本 / 韩国 / 新加坡 / 美国) [默认: 自动识别]: " input_loc
    NODE_LOC="\${input_loc:-自动识别}"
fi

echo ""
echo -e "\${GREEN}[*] 配置确认:\${NC}"
echo -e "    - 节点 ID (排序) : \${BLUE}\$NODE_ID\${NC}"
echo -e "    - 显示名称     : \${BLUE}\$NODE_NAME\${NC}"
echo -e "    - 所属分类     : \${BLUE}\$NODE_GROUP\${NC}"
echo -e "    - 地理位置     : \${BLUE}\$NODE_LOC\${NC}"
echo -e "    - 上报周期     : \${BLUE}\${INTERVAL} 秒\${NC}"
echo -e "    - 服务端地址   : \${BLUE}\$URL\${NC}"

# Rearm node on cloud to clear any historical decommission tombstones
curl -s "https://${host}/delete?id=\${NODE_ID}&token=\${TOKEN}&action=rearm" >/dev/null 2>&1 || true

cat << 'EOF_PY' > /usr/local/bin/node-probe-agent.py
${PROBE_CLIENT_SCRIPT}
EOF_PY

chmod +x /usr/local/bin/node-probe-agent.py

cat << EOF_SERVICE > /etc/systemd/system/node-probe.service
[Unit]
Description=Server Status Probe Agent
After=network.target

[Service]
Type=simple
User=root
ExecStart=/usr/bin/python3 /usr/local/bin/node-probe-agent.py --url "\${URL}" --token "\${TOKEN}" --id "\${NODE_ID}" --name "\${NODE_NAME}" --group "\${NODE_GROUP}" --location "\${NODE_LOC}" --interval \${INTERVAL}
Restart=always
RestartSec=10
KillMode=process

[Install]
WantedBy=multi-user.target
EOF_SERVICE

systemctl daemon-reload
systemctl enable --now node-probe.service

echo ""
echo -e "\${GREEN}=========================================================\${NC}"
echo -e "\${GREEN}✓ 集群探针已成功安装至 /usr/local/bin/node-probe-agent.py!\${NC}"
echo -e "\${GREEN}✓ systemd 守护进程 node-probe.service 已启动运行!\${NC}"
echo -e "\${GREEN}✓ 数据正在每 \${INTERVAL} 秒向主控看板实时上报!\${NC}"
echo -e "\${BLUE}=========================================================\${NC}"
`;

  return new Response(script, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'no-cache'
    }
  });
}

async function handleUninstallScript(request, url, env) {
  const host = request.headers.get('host') || 'x.example.com';
  const isAdmin = await verifyAdmin(request, env);
  const tokenParam = url.searchParams.get('token');
  const isTokenValid = await Storage.verifyReportToken(env, tokenParam);
  let defaultToken = "YOUR_PROBE_TOKEN";
  if (isTokenValid) {
    defaultToken = tokenParam;
  } else if (isAdmin) {
    defaultToken = env.REPORT_TOKEN || DEFAULT_REPORT_TOKEN;
  }

  const script = `#!/bin/bash
# =========================================================
# 轻量服务器探针监控系统
# =========================================================
set -e

RED='\\033[0;31m'
GREEN='\\033[0;32m'
BLUE='\\033[0;34m'
YELLOW='\\033[1;33m'
NC='\\033[0m'

echo -e "\${BLUE}=========================================================\${NC}"
echo -e "\${YELLOW}    ⚠️  Server Status Probe 探针一键卸载向导\${NC}"
echo -e "\${BLUE}=========================================================\${NC}"

if [ "$(id -u)" != "0" ]; then
    echo -e "\${RED}错误:请使用 root 权限执行此脚本 (sudo bash)!\${NC}"
    exit 1
fi

NODE_ID=""
LOCAL_TOKEN=""
if [ -f /etc/systemd/system/node-probe.service ]; then
    NODE_ID="$(grep -oP '(?<=--id ")[^"]+' /etc/systemd/system/node-probe.service || grep -oP '(?<=--id )\\S+' /etc/systemd/system/node-probe.service || true)"
    LOCAL_TOKEN="$(grep -oP '(?<=--token ")[^"]+' /etc/systemd/system/node-probe.service || grep -oP '(?<=--token )\\S+' /etc/systemd/system/node-probe.service || true)"
fi

FINAL_TOKEN="\${LOCAL_TOKEN:-${defaultToken}}"

echo -e "\${YELLOW}[*] 正在停止并禁用 node-probe 服务...\${NC}"
systemctl stop node-probe.service 2>/dev/null || true
systemctl disable node-probe.service 2>/dev/null || true

rm -f /etc/systemd/system/node-probe.service
systemctl daemon-reload

echo -e "\${YELLOW}[*] 正在清理本地探针程序 /usr/local/bin/node-probe-agent.py...\${NC}"
rm -f /usr/local/bin/node-probe-agent.py

if [ -n "$NODE_ID" ] && [ -n "$FINAL_TOKEN" ] && [ "$FINAL_TOKEN" != "YOUR_PROBE_TOKEN" ]; then
    echo -e "\${YELLOW}[*] 正在同步注销云端节点记录 (ID: $NODE_ID)...\${NC}"
    curl -s "https://${host}/delete?id=\${NODE_ID}&token=\${FINAL_TOKEN}" >/dev/null 2>&1 || true
fi

echo -e "\${GREEN}=========================================================\${NC}"
echo -e "\${GREEN}✓ 探针已彻底卸载,服务已停止,本地程序与云端记录已清理干净!\${NC}"
echo -e "\${GREEN}=========================================================\${NC}"
`;

  return new Response(script, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'no-cache'
    }
  });
}

async function handleReport(request, url, env) {
  try {
    const isAdmin = await verifyAdmin(request, env);
    const token = url.searchParams.get('token') || request.headers.get('x-token');
    const isTokenValid = await Storage.verifyReportToken(env, token);

    if (!isAdmin && !isTokenValid) {
      return jsonResponse({ status: 'fail', error: '未授权:无效的 Report Token' }, 403);
    }

    const payload = await request.json();
    if (!payload.id) return jsonResponse({ status: 'fail', error: '缺少服务器 id 参数' }, 400);

    const isDecommissioned = await Storage.isTombstoned(env, payload.id);
    if (isDecommissioned) {
      return jsonResponse({
        status: 'uninstall',
        action: 'self_uninstall',
        message: `节点 #${payload.id} 已在云端被管理员下线,指令客户端执行自我卸载`
      });
    }

    const clientIp = (request.headers.get('cf-connecting-ip') || request.headers.get('x-real-ip') || '').trim();
    const cfCountry = request.cf ? request.cf.country : '';
    const now = Math.floor(Date.now() / 1000);

    const existingNode = await Storage.getNode(env, payload.id);
    let cpuHistory = existingNode && existingNode.cpu_history ? existingNode.cpu_history : [];
    let healthHistory = existingNode && existingNode.health_history ? existingNode.health_history : [];

    const curCpu = payload.cpu || 0;
    const curMem = payload.mem_percent || 0;
    let healthLevel = 5; // 5: Dark Green
    if (curCpu > 95 || curMem > 95) healthLevel = 1; // Red
    else if (curCpu > 85 || curMem > 90) healthLevel = 2; // Orange
    else if (curCpu > 70 || curMem > 80) healthLevel = 3; // Yellow
    else if (curCpu > 50 || curMem > 70) healthLevel = 4; // Light Green

    cpuHistory.push(curCpu);
    if (cpuHistory.length > 12) cpuHistory = cpuHistory.slice(-12);

    healthHistory.push({
      level: healthLevel,
      cpu: curCpu,
      mem: curMem,
      time: Math.floor(Date.now() / 1000)
    });
    if (healthHistory.length > 60) healthHistory = healthHistory.slice(-60);

    let nodeLocation = payload.location;
    if (!nodeLocation || nodeLocation === '自动识别' || nodeLocation === '云服务器') {
      nodeLocation = formatLocation('', cfCountry);
    } else {
      nodeLocation = formatLocation(nodeLocation, cfCountry);
    }

    let rawIpv4 = (payload.ip || '').trim();
    let rawIpv6 = (payload.ipv6 || '').trim();

    let finalIpv4 = '';
    let finalIpv6 = '';

    if (rawIpv4 && !rawIpv4.includes(':') && rawIpv4.includes('.')) {
      finalIpv4 = rawIpv4;
    }
    if (rawIpv6 && rawIpv6.includes(':')) {
      finalIpv6 = rawIpv6;
    }

    if (!finalIpv4 && clientIp && !clientIp.includes(':') && clientIp.includes('.')) {
      finalIpv4 = clientIp;
    }
    if (!finalIpv6 && clientIp && clientIp.includes(':')) {
      finalIpv6 = clientIp;
    }

    const nodeIsPublic = existingNode && existingNode.is_public !== undefined ? existingNode.is_public : (payload.is_public !== undefined ? !!payload.is_public : true);

    let nodeData = {
      id: String(payload.id),
      name: payload.name || payload.id,
      group: "甲骨文arm",
      location: nodeLocation,
      os: payload.os || 'Linux',
      cpu_cores: payload.cpu_cores || 1,
      cpu: payload.cpu || 0,
      cpu_history: cpuHistory,
      health_history: healthHistory,
      mem_total_mb: payload.mem_total_mb || 0,
      mem_used_mb: payload.mem_used_mb || 0,
      mem_percent: payload.mem_percent || 0,
      swap_total_mb: payload.swap_total_mb || 0,
      swap_used_mb: payload.swap_used_mb || 0,
      swap_percent: payload.swap_percent || 0,
      disk_total_gb: payload.disk_total_gb || 0,
      disk_used_gb: payload.disk_used_gb || 0,
      disk_percent: payload.disk_percent || 0,
      net_rx_rate: payload.net_rx_rate || '0 B/s',
      net_tx_rate: payload.net_tx_rate || '0 B/s',
      net_rx_total: payload.net_rx_total || '0 B',
      net_tx_total: payload.net_tx_total || '0 B',
      uptime: payload.uptime || '',
      load: payload.load || '',
      ip: finalIpv4,
      ipv6: finalIpv6,
      is_public: nodeIsPublic,
      updated_at: now
    };

    nodeData = normalizeNodeInfo(nodeData);

    await Storage.saveNode(env, payload.id, nodeData);

    return jsonResponse({ status: 'success', message: '心跳上报成功' });
  } catch (e) {
    return jsonResponse({ status: 'fail', error: e.message }, 500);
  }
}

async function handleDeleteNode(request, url, env) {
  const isAdmin = await verifyAdmin(request, env);
  const token = url.searchParams.get('token') || request.headers.get('x-token');
  const isTokenValid = await Storage.verifyReportToken(env, token);

  if (!isAdmin && !isTokenValid) {
    return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 403);
  }

  const nodeId = url.searchParams.get('id');
  if (!nodeId) return jsonResponse({ status: 'fail', error: '缺少节点 id 参数' }, 400);

  const action = url.searchParams.get('action') || '';
  if (action === 'rearm') {
    await Storage.clearTombstone(env, nodeId);
    return jsonResponse({ status: 'success', message: `节点 ${nodeId} 重新就绪 (已清除下线墓碑)` });
  }

  try {
    await Storage.deleteNode(env, nodeId);
    if (action === 'clear') {
      await Storage.clearTombstone(env, nodeId);
    }
    return jsonResponse({ status: 'success', message: `节点 ${nodeId} 已成功从监控集群下线` });
  } catch (e) {
    return jsonResponse({ status: 'fail', error: e.message }, 500);
  }
}


// ==============================================================================
// PPanel Dashboard Handler for Cluster Status
// ==============================================================================
async function handleDashboard(request, env) {
  const isAdmin = await verifyAdmin(request, env);
  const tokens = isAdmin ? await Storage.getAllTokens(env) : [];
  const statusData = await getClusterStatus(env, isAdmin);
  const nodes = statusData.nodes || [];
  const settings = statusData.settings || {};
  const onlineNodes = nodes.filter(n => n.is_online);
  const onlineCount = onlineNodes.length;
  const totalCount = nodes.length;
  const onlineRate = totalCount > 0 ? ((onlineCount / totalCount) * 100).toFixed(1) : '100.0';
  const avgCpu = onlineCount > 0 ? (onlineNodes.reduce((a, b) => a + (parseFloat(b.cpu) || 0), 0) / onlineCount).toFixed(1) : '0.0';
  const avgMem = onlineCount > 0 ? (onlineNodes.reduce((a, b) => a + (parseFloat(b.mem_percent) || 0), 0) / onlineCount).toFixed(1) : '0.0';
  const host = request.headers.get('host') || 'x.example.com';

  const groupedNodes = {};
  nodes.forEach(n => {
    const grp = n.group || '默认集群';
    if (!groupedNodes[grp]) groupedNodes[grp] = [];
    groupedNodes[grp].push(n);
  });

  const groupKeys = Object.keys(groupedNodes).sort();

  const groupsHtml = groupKeys.map((grpName, gIdx) => {
    const grpNodes = groupedNodes[grpName];
    const grpOnline = grpNodes.filter(n => n.is_online).length;
    const cardsHtml = grpNodes.map(n => renderNodeCard(n, isAdmin, settings)).join('');
    const heatmapsHtml = grpNodes.map(n => renderNodeHeatmap(n, isAdmin)).join('');
    const rowsHtml = grpNodes.map(n => renderNodeRow(n, isAdmin, settings)).join('');

    return `
      <section class="group-section ppanel-card" id="group-sec-${gIdx}">
        <div class="group-header" onclick="toggleGroup(${gIdx})">
          <div class="group-title-left">
            <span class="group-arrow" id="arrow-${gIdx}">▼</span>
            <span class="group-name">${grpName}</span>
            <span class="badge badge-primary">${grpOnline} / ${grpNodes.length} 在线</span>
          </div>
        </div>

        <div class="nodes-grid group-grid-detailed" id="grid-detailed-${gIdx}">
          ${cardsHtml}
        </div>

        <div class="heatmaps-grid group-grid-heatmap" id="grid-heatmap-${gIdx}" style="display:none;">
          ${heatmapsHtml}
        </div>

        <div class="table-container group-table-container" id="grid-table-${gIdx}" style="display:none;">
          <div class="mobile-table-tip"><span>↔</span> 左右滑动查看 CPU · 内存 · 硬盘 · 网速等完整指标</div>
          <table>
            <thead>
              <tr>
                <th>状态</th>
                <th>服务器节点</th>
                <th>地域</th>
                <th>公网 IP</th>
                <th>CPU</th>
                <th>内存</th>
                <th>硬盘</th>
                <th>实时网络</th>
                <th>运行时间</th>
                <th>更新</th>
                <th>操作</th>
              </tr>
            </thead>
            <tbody>
              ${rowsHtml}
            </tbody>
          </table>
        </div>
      </section>
    `;
  }).join('');

  const html = `<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
  <meta charset="UTF-8">
  <script>(function(){try{var t=localStorage.getItem("probe_theme")||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");document.documentElement.setAttribute("data-theme",t);var c=localStorage.getItem("example_theme_color")||"aurora";document.documentElement.setAttribute("data-theme-color",c);}catch(e){}})();</script>
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
  <link rel="icon" type="image/svg+xml" href="/favicon.ico">
  <title>集群探针 · OnePve 状态监控</title>
  <style>

    /* 1. 极光曜石青 (Aurora · 默认) */
    :root, :root[data-theme-color="aurora"] {
      --bg: #f3f2f3; --card: #ffffff; --card-sub: #f8fafc; --border: #d7d7d6; --border-sub: #e2e8f0;
      --muted: #f3f2f3; --muted-fg: #64748b; --text: #0f172a; --text-muted: #64748b;
      --primary: #00aeb9; --primary-hover: #00949e; --primary-fg: #ffffff; --primary-light: #cbedee; --primary-light-fg: #004d53;
      --secondary: #cbedee; --secondary-fg: #004d53; --success: #00aeb9; --success-bg: #cbedee; --success-fg: #004d53;
      --warning: #f59e0b; --warning-bg: #fef3c7; --warning-fg: #92400e; --danger: #ef4444; --danger-bg: #ffe7e5; --danger-fg: #991b1b;
      --code-bg: #eae9e9; --code-fg: #0f172a;
    }
    .dark, [data-theme="dark"], .dark[data-theme-color="aurora"], [data-theme="dark"][data-theme-color="aurora"] {
      --bg: #0b1120; --card: #0f172a; --card-sub: #141f36; --border: #1e293b; --border-sub: #273549;
      --muted: #141c2e; --muted-fg: #94a3b8; --text: #f8fafc; --text-muted: #94a3b8;
      --primary: #00aeb9; --primary-hover: #22d3ee; --primary-fg: #090d16; --primary-light: rgba(0, 174, 185, 0.18); --primary-light-fg: #22d3ee;
      --secondary: #163640; --secondary-fg: #cbedee; --success: #00aeb9; --success-bg: rgba(0, 174, 185, 0.18); --success-fg: #22d3ee;
      --warning: #fbbf24; --warning-bg: rgba(251, 191, 36, 0.15); --warning-fg: #fde68a; --danger: #f87171; --danger-bg: rgba(248, 113, 113, 0.15); --danger-fg: #fecaca;
      --code-bg: #070c18; --code-fg: #f8fafc;
    }

    /* 2. 赛博钛金蓝 (Titanium Blue) */
    :root[data-theme-color="titanium"] {
      --bg: #f1f5f9; --card: #ffffff; --card-sub: #f8fafc; --border: #cbd5e1; --border-sub: #e2e8f0;
      --muted: #f1f5f9; --muted-fg: #64748b; --text: #0f172a; --text-muted: #64748b;
      --primary: #0284c7; --primary-hover: #0369a1; --primary-fg: #ffffff; --primary-light: #e0f2fe; --primary-light-fg: #0369a1;
      --secondary: #e0f2fe; --secondary-fg: #0369a1; --success: #0284c7; --success-bg: #e0f2fe; --success-fg: #0369a1;
      --code-bg: #e2e8f0; --code-fg: #0f172a;
    }
    .dark[data-theme-color="titanium"], [data-theme="dark"][data-theme-color="titanium"] {
      --bg: #0d1527; --card: #111827; --card-sub: #172033; --border: #1e293b; --border-sub: #283548;
      --muted: #172033; --muted-fg: #94a3b8; --text: #f1f5f9; --text-muted: #94a3b8;
      --primary: #38bdf8; --primary-hover: #7dd3fc; --primary-fg: #0b0f19; --primary-light: rgba(56, 189, 248, 0.18); --primary-light-fg: #38bdf8;
      --secondary: #1e293b; --secondary-fg: #bae6fd; --success: #38bdf8; --success-bg: rgba(56, 189, 248, 0.18); --success-fg: #38bdf8;
      --code-bg: #090f1e; --code-fg: #f1f5f9;
    }

    /* 3. 暮霭紫晶电光 (Violet) */
    :root[data-theme-color="violet"] {
      --bg: #f7f5fa; --card: #ffffff; --card-sub: #faf8fd; --border: #ddd8e8; --border-sub: #eae5f5;
      --muted: #f7f5fa; --muted-fg: #6b7280; --text: #1e1b4b; --text-muted: #6b7280;
      --primary: #7c3aed; --primary-hover: #6d28d9; --primary-fg: #ffffff; --primary-light: #ede9fe; --primary-light-fg: #5b21b6;
      --secondary: #ede9fe; --secondary-fg: #5b21b6; --success: #7c3aed; --success-bg: #ede9fe; --success-fg: #5b21b6;
      --code-bg: #ede8f5; --code-fg: #1e1b4b;
    }
    .dark[data-theme-color="violet"], [data-theme="dark"][data-theme-color="violet"] {
      --bg: #100d1e; --card: #141028; --card-sub: #1b1636; --border: #231c42; --border-sub: #2e2654;
      --muted: #1c1736; --muted-fg: #9ca3af; --text: #f5f3ff; --text-muted: #9ca3af;
      --primary: #a78bfa; --primary-hover: #c4b5fd; --primary-fg: #0d0b18; --primary-light: rgba(167, 139, 250, 0.18); --primary-light-fg: #a78bfa;
      --secondary: #261f48; --secondary-fg: #ddd6fe; --success: #a78bfa; --success-bg: rgba(167, 139, 250, 0.18); --success-fg: #a78bfa;
      --code-bg: #0a0814; --code-fg: #f5f3ff;
    }

    /* 4. 翡翠森林极客绿 (Emerald) */
    :root[data-theme-color="emerald"] {
      --bg: #eff4f1; --card: #ffffff; --card-sub: #f6faf7; --border: #d3dfd7; --border-sub: #e2ece5;
      --muted: #eff4f1; --muted-fg: #4b5563; --text: #064e3b; --text-muted: #4b5563;
      --primary: #059669; --primary-hover: #047857; --primary-fg: #ffffff; --primary-light: #d1fae5; --primary-light-fg: #065f46;
      --secondary: #d1fae5; --secondary-fg: #065f46; --success: #059669; --success-bg: #d1fae5; --success-fg: #065f46;
      --code-bg: #e1eae4; --code-fg: #064e3b;
    }
    .dark[data-theme-color="emerald"], [data-theme="dark"][data-theme-color="emerald"] {
      --bg: #071211; --card: #0d201e; --card-sub: #122c29; --border: #1a3d39; --border-sub: #234e49;
      --muted: #132d2a; --muted-fg: #9ca3af; --text: #ecfdf5; --text-muted: #9ca3af;
      --primary: #34d399; --primary-hover: #6ee7b7; --primary-fg: #071211; --primary-light: rgba(52, 211, 153, 0.18); --primary-light-fg: #34d399;
      --secondary: #173834; --secondary-fg: #a7f3d0; --success: #34d399; --success-bg: rgba(52, 211, 153, 0.18); --success-fg: #34d399;
      --code-bg: #050d0c; --code-fg: #ecfdf5;
    }

    /* 5. 黑金暗曜琥珀 (Amber) */
    :root[data-theme-color="amber"] {
      --bg: #fdfaf3; --card: #ffffff; --card-sub: #fefdf9; --border: #edd5a6; --border-sub: #f7e6c4;
      --muted: #fdfaf3; --muted-fg: #78350f; --text: #451a03; --text-muted: #78350f;
      --primary: #d97706; --primary-hover: #b45309; --primary-fg: #ffffff; --primary-light: #fef3c7; --primary-light-fg: #78350f;
      --secondary: #fef3c7; --secondary-fg: #78350f; --success: #d97706; --success-bg: #fef3c7; --success-fg: #78350f;
      --code-bg: #f5eedb; --code-fg: #451a03;
    }
    .dark[data-theme-color="amber"], [data-theme="dark"][data-theme-color="amber"] {
      --bg: #140d04; --card: #1f1406; --card-sub: #2b1d0a; --border: #3d2910; --border-sub: #4e3516;
      --muted: #241707; --muted-fg: #d97706; --text: #fef3c7; --text-muted: #d97706;
      --primary: #fbbf24; --primary-hover: #fcd34d; --primary-fg: #140d04; --primary-light: rgba(251, 191, 36, 0.18); --primary-light-fg: #fbbf24;
      --secondary: #30200b; --secondary-fg: #fde68a; --success: #fbbf24; --success-bg: rgba(251, 191, 36, 0.18); --success-fg: #fbbf24;
      --code-bg: #0c0802; --code-fg: #fef3c7;
    }

    /* 6. 落樱嫩粉 (Crimson) */
    :root[data-theme-color="crimson"] {
      --bg: #fff1f2; --card: #ffffff; --card-sub: #ffe4e6; --border: #fecdd3; --border-sub: #ffe4e6;
      --muted: #fff1f2; --muted-fg: #9f1239; --text: #0f172a; --text-muted: #881337;
      --primary: #e11d48; --primary-hover: #be123c; --primary-fg: #ffffff; --primary-light: #ffe4e6; --primary-light-fg: #be123c;
      --secondary: #ffe4e6; --secondary-fg: #9f1239; --success: #059669; --success-bg: #d1fae5; --success-fg: #047857;
      --code-bg: #ffe4e6; --code-fg: #9f1239;
    }
    .dark[data-theme-color="crimson"], [data-theme="dark"][data-theme-color="crimson"] {
      --bg: #0f0507; --card: #190a0e; --card-sub: #260f15; --border: #4c111d; --border-sub: #380d15;
      --muted: #260f15; --muted-fg: #f43f5e; --text: #fff1f2; --text-muted: #fda4af;
      --primary: #fb7185; --primary-hover: #f43f5e; --primary-fg: #0f0507; --primary-light: rgba(244, 63, 94, 0.18); --primary-light-fg: #fda4af;
      --secondary: #260f15; --secondary-fg: #fda4af; --success: #34d399; --success-bg: rgba(52, 211, 153, 0.16); --success-fg: #34d399;
      --code-bg: #140508; --code-fg: #fecdd3;
    }
    /* Palette Popover Picker */
    .palette-dropdown {
      position: relative;
      display: inline-block;
    }
    .palette-btn {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 0.45rem 0.75rem;
      border-radius: var(--radius-md);
      font-size: 0.8rem;
      font-weight: 600;
      background: var(--card);
      border: 1px solid var(--border);
      color: var(--text);
      cursor: pointer;
      transition: all 0.2s;
    }
    .palette-btn:hover { border-color: var(--primary); }
    .color-dot-indicator {
      width: 10px;
      height: 10px;
      border-radius: 50%;
      background: var(--primary);
      box-shadow: 0 0 6px var(--primary);
    }
    .palette-menu {
      position: absolute;
      top: calc(100% + 6px);
      right: 0;
      background: var(--card);
      border: 1px solid var(--border);
      border-radius: var(--radius-xl);
      padding: 8px;
      box-shadow: var(--shadow-lg);
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 6px;
      width: 240px;
      z-index: 10005;
      opacity: 0;
      visibility: hidden;
      transform: translateY(-8px);
      transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
      backdrop-filter: blur(12px);
    }
    .palette-menu.open { opacity: 1; visibility: visible; transform: translateY(0); }
    .color-opt-btn {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 4px;
      padding: 8px 4px;
      border-radius: var(--radius-md);
      border: 1px solid var(--border-sub);
      background: var(--card-sub);
      color: var(--text);
      font-size: 0.72rem;
      font-weight: 700;
      cursor: pointer;
      transition: all 0.15s;
    }
    .color-opt-btn:hover { border-color: var(--primary); transform: translateY(-1px); }
    .color-opt-btn.active { border-color: var(--primary); background: var(--primary-light); color: var(--primary-light-fg); }
    .color-swatch {
      width: 16px;
      height: 16px;
      border-radius: 50%;
    }

    /* ==========================================================================
       PPanel (OnePve) Modern Design System · Aurora Cyan 5-Color Eye-Care
       ========================================================================== */
    :root {
      --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
      
      --bg: #f3f2f3;
      --card: #ffffff;
      --card-sub: #f8fafc;
      --border: #d7d7d6;
      --border-sub: #e2e8f0;
      --muted: #f3f2f3;
      --muted-fg: #64748b;
      --text: #0f172a;
      --text-muted: #64748b;
      --primary: #00aeb9;
      --primary-hover: #00949e;
      --primary-fg: #ffffff;
      --primary-light: #cbedee;
      --primary-light-fg: #004d53;
      --secondary: #cbedee;
      --secondary-fg: #004d53;
      --success: #00aeb9;
      --success-bg: #cbedee;
      --success-fg: #004d53;
      --warning: #f59e0b;
      --warning-bg: #fef3c7;
      --warning-fg: #92400e;
      --danger: #ef4444;
      --danger-bg: #ffe7e5;
      --danger-fg: #991b1b;
      --code-bg: #eae9e9;
      --code-fg: #0f172a;
      
      --radius-sm: 8px;
      --radius-md: 12px;
      --radius-lg: 16px;
      --radius-xl: 20px;
      --radius-2xl: 24px;
      
      --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
      --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.08), 0 1px 2px -1px rgba(0, 0, 0, 0.08);
      --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.08);
      --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.08);
    }

    [data-theme="dark"] {
      --bg: #0b1120;
      --card: #0f172a;
      --card-sub: #141f36;
      --border: #1e293b;
      --border-sub: #273549;
      --muted: #141c2e;
      --muted-fg: #94a3b8;
      --text: #f8fafc;
      --text-muted: #94a3b8;
      --primary: #00aeb9;
      --primary-hover: #22d3ee;
      --primary-fg: #090d16;
      --primary-light: rgba(0, 174, 185, 0.15);
      --primary-light-fg: #22d3ee;
      --secondary: #163640;
      --secondary-fg: #cbedee;
      --success: #00aeb9;
      --success-bg: rgba(0, 174, 185, 0.15);
      --success-fg: #22d3ee;
      --warning: #fbbf24;
      --warning-bg: rgba(251, 191, 36, 0.12);
      --warning-fg: #fde68a;
      --danger: #f87171;
      --danger-bg: rgba(248, 113, 113, 0.12);
      --danger-fg: #fecaca;
      --code-bg: #070c18;
      --code-fg: #f8fafc;
      
      --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
      --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
      --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
      --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.4);
    }

    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: var(--font-sans);
      background: var(--bg);
      color: var(--text);
      line-height: 1.5;
      padding: 1.5rem 1rem;
      min-height: 100vh;
      display: flex;
      justify-content: center;
      transition: background 0.25s ease, color 0.25s ease;
      -webkit-font-smoothing: antialiased;
    }
    .app-container {
      max-width: 1360px;
      width: 100%;
      min-width: 0;
      display: flex;
      flex-direction: column;
      gap: 1rem;
    }

    /* PPanel Glassmorphism Navbar */
    .site-navbar {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 0.85rem 1.25rem;
      background: var(--card);
      border: 1px solid var(--border);
      border-radius: var(--radius-2xl);
      box-shadow: var(--shadow-sm);
      backdrop-filter: blur(12px);
    }
    .nav-brand {
      display: flex;
      align-items: center;
      gap: 12px;
      min-width: 0;
    }
    .brand-logo-icon {
      width: 40px;
      height: 40px;
      border-radius: var(--radius-md);
      background: linear-gradient(135deg, #00aeb9 0%, #0284c7 100%);
      display: flex;
      align-items: center;
      justify-content: center;
      color: #ffffff;
      font-size: 1.25rem;
      flex-shrink: 0;
      box-shadow: 0 2px 8px rgba(0, 174, 185, 0.3);
    }
    .brand-info {
      display: flex;
      flex-direction: column;
      min-width: 0;
    }
    .brand-badge-pill {
      font-size: 0.68rem;
      font-weight: 800;
      color: var(--primary);
      text-transform: uppercase;
      letter-spacing: 0.6px;
      line-height: 1;
      margin-bottom: 2px;
    }
    .brand-title {
      font-size: 1.15rem;
      font-weight: 800;
      color: var(--text);
      line-height: 1.2;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .desktop-nav {
      display: flex;
      align-items: center;
      gap: 8px;
      flex-shrink: 0;
    }
    .mobile-nav-trigger {
      display: none;
      background: var(--muted);
      border: 1px solid var(--border);
      color: var(--text);
      padding: 0.45rem 0.75rem;
      border-radius: var(--radius-md);
      cursor: pointer;
      font-size: 1.1rem;
      line-height: 1;
      align-items: center;
      justify-content: center;
    }

    /* PPanel Metrics Cards Grid */
    .metrics-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 12px;
    }
    .metric-card {
      background: var(--card);
      border: 1px solid var(--border);
      border-radius: var(--radius-xl);
      padding: 1rem 1.25rem;
      display: flex;
      flex-direction: column;
      gap: 4px;
      box-shadow: var(--shadow-xs);
      transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
    }
    .metric-card:hover {
      border-color: var(--primary);
      transform: translateY(-1px);
      box-shadow: var(--shadow-sm);
    }
    .metric-label {
      font-size: 0.76rem;
      font-weight: 600;
      color: var(--text-muted);
      display: flex;
      align-items: center;
      gap: 6px;
    }
    .metric-val {
      font-size: 1.35rem;
      font-weight: 800;
      color: var(--text);
      font-family: monospace;
      line-height: 1.2;
    }
    .metric-val.highlight { color: var(--primary); }
    .metric-sub { font-size: 0.72rem; color: var(--text-muted); }

    /* PPanel Tabs Bar */
    .tabs-bar {
      display: flex;
      justify-content: space-between;
      align-items: center;
      flex-wrap: wrap;
      gap: 10px;
    }
    .tabs-list {
      display: inline-flex;
      background: var(--card);
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      padding: 4px;
      gap: 4px;
      box-shadow: var(--shadow-xs);
      overflow-x: auto;
      max-width: 100%;
    }
    .tab-item {
      padding: 6px 14px;
      border-radius: var(--radius-md);
      font-size: 0.82rem;
      font-weight: 600;
      color: var(--text-muted);
      border: none;
      background: transparent;
      cursor: pointer;
      transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
      display: inline-flex;
      align-items: center;
      gap: 6px;
      white-space: nowrap;
    }
    .tab-item:hover { color: var(--text); }
    .tab-item.active {
      background: var(--primary);
      color: #ffffff;
      box-shadow: 0 2px 6px rgba(0, 174, 185, 0.3);
    }

    /* PPanel Cards */
    .ppanel-card {
      background: var(--card);
      border: 1px solid var(--border);
      border-radius: var(--radius-2xl);
      padding: 1.25rem 1.5rem;
      box-shadow: var(--shadow-sm);
      display: flex;
      flex-direction: column;
      gap: 1rem;
    }
    .card-header-row {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 10px;
      flex-wrap: wrap;
    }
    .title-pill-group {
      display: flex;
      align-items: center;
      gap: 6px;
      flex-wrap: wrap;
      margin-bottom: 2px;
    }
    .card-title {
      font-size: 1.05rem;
      font-weight: 800;
      color: var(--text);
    }
    .meta-subtext-row {
      font-size: 0.76rem;
      color: var(--text-muted);
      display: flex;
      align-items: center;
      gap: 12px;
      flex-wrap: wrap;
    }

    /* Badges & Chips */
    .badge {
      font-size: 0.72rem;
      font-weight: 700;
      padding: 2px 8px;
      border-radius: var(--radius-sm);
      display: inline-flex;
      align-items: center;
      gap: 4px;
      line-height: 1.2;
    }
    .badge-primary {
      background: var(--primary-light);
      color: var(--primary-light-fg);
      border: 1px solid var(--border-sub);
    }
    .badge-secondary {
      background: var(--muted);
      color: var(--text-muted);
      border: 1px solid var(--border);
    }
    .status-chip {
      font-size: 0.75rem;
      font-weight: 700;
      padding: 3px 10px;
      border-radius: 9999px;
      display: inline-flex;
      align-items: center;
      gap: 6px;
    }
    .chip-online {
      background: var(--success-bg);
      color: var(--success-fg);
      border: 1px solid rgba(0, 174, 185, 0.3);
    }
    .chip-offline {
      background: var(--danger-bg);
      color: var(--danger-fg);
      border: 1px solid rgba(239, 68, 68, 0.3);
    }
    .status-dot {
      width: 7px;
      height: 7px;
      border-radius: 50%;
      background: var(--primary);
      box-shadow: 0 0 6px var(--primary);
    }
    .dot-offline {
      background: var(--danger);
      box-shadow: 0 0 6px var(--danger);
    }
    .vis-btn {
      font-size: 0.72rem;
      font-weight: 700;
      padding: 2px 8px;
      border-radius: var(--radius-sm);
      border: 1px solid var(--border);
      cursor: pointer;
      background: var(--card);
      color: var(--text-muted);
    }
    .vis-public { color: var(--primary); border-color: var(--primary-light); }

    /* Node Cards Grid */
    .nodes-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
      gap: 14px;
    }
    .node-card {
      background: var(--card-sub);
      border: 1px solid var(--border);
      border-radius: var(--radius-xl);
      padding: 1.15rem;
      display: flex;
      flex-direction: column;
      gap: 10px;
      box-shadow: var(--shadow-xs);
      transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
    }
    .node-card:hover {
      border-color: var(--primary);
      background: var(--card);
      box-shadow: var(--shadow-md);
      transform: translateY(-2px);
    }
    .node-offline { opacity: 0.75; }
    .node-card-head {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 8px;
    }
    .node-title {
      font-weight: 800;
      font-size: 0.95rem;
      color: var(--text);
    }
    .node-ip-row {
      display: flex;
      align-items: center;
      gap: 6px;
      flex-wrap: wrap;
      margin-top: 4px;
    }
    .ip-tag {
      font-size: 0.68rem;
      font-weight: 700;
      color: var(--text-muted);
      background: var(--muted);
      padding: 1px 4px;
      border-radius: 4px;
    }
    .ip-code {
      font-family: monospace;
      font-size: 0.78rem;
      color: var(--code-fg);
    }

    .meta-pills-row {
      display: flex;
      align-items: center;
      gap: 6px;
      flex-wrap: wrap;
    }
    .meta-pill {
      font-size: 0.72rem;
      font-weight: 600;
      color: var(--text-muted);
      background: var(--muted);
      padding: 2px 6px;
      border-radius: 4px;
    }

    /* Meters */
    .meters-container {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }
    .meter-box {
      display: flex;
      flex-direction: column;
      gap: 3px;
    }
    .meter-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      font-size: 0.75rem;
      color: var(--text-muted);
    }
    .meter-val {
      font-size: 0.76rem;
      color: var(--text);
      font-family: monospace;
    }
    .progress-track {
      height: 6px;
      background: var(--muted);
      border-radius: 9999px;
      overflow: hidden;
    }
    .progress-fill {
      height: 100%;
      border-radius: 9999px;
      transition: width 0.3s ease;
    }
    .progress-fill.bar-green { background: #10b981; }
    .progress-fill.bar-yellow { background: #f59e0b; }
    .progress-fill.bar-red { background: #ef4444; }
    .dark .progress-fill.bar-green, [data-theme="dark"] .progress-fill.bar-green { background: #34d399; }
    .dark .progress-fill.bar-yellow, [data-theme="dark"] .progress-fill.bar-yellow { background: #fbbf24; }
    .dark .progress-fill.bar-red, [data-theme="dark"] .progress-fill.bar-red { background: #f87171; }

    /* Semantic Status Meter Pills (100% High Contrast across ALL 6 Themes) */
    .tbl-meter {
      display: inline-flex;
      align-items: center;
      gap: 4px;
      padding: 2px 8px;
      border-radius: 6px;
      font-size: 0.78rem;
      font-weight: 700;
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
      letter-spacing: 0.2px;
      line-height: 1.3;
      transition: all 0.2s;
    }
    .tbl-meter.bar-green {
      background: rgba(16, 185, 129, 0.12) !important;
      color: #059669 !important;
      border: 1px solid rgba(16, 185, 129, 0.28) !important;
    }
    .dark .tbl-meter.bar-green, [data-theme="dark"] .tbl-meter.bar-green {
      background: rgba(16, 185, 129, 0.18) !important;
      color: #34d399 !important;
      border: 1px solid rgba(52, 211, 153, 0.32) !important;
    }

    .tbl-meter.bar-yellow {
      background: rgba(245, 158, 11, 0.12) !important;
      color: #d97706 !important;
      border: 1px solid rgba(245, 158, 11, 0.28) !important;
    }
    .dark .tbl-meter.bar-yellow, [data-theme="dark"] .tbl-meter.bar-yellow {
      background: rgba(245, 158, 11, 0.18) !important;
      color: #fbbf24 !important;
      border: 1px solid rgba(251, 191, 36, 0.32) !important;
    }

    .tbl-meter.bar-red {
      background: rgba(239, 68, 68, 0.12) !important;
      color: #dc2626 !important;
      border: 1px solid rgba(239, 68, 68, 0.28) !important;
    }
    .dark .tbl-meter.bar-red, [data-theme="dark"] .tbl-meter.bar-red {
      background: rgba(239, 68, 68, 0.18) !important;
      color: #f87171 !important;
      border: 1px solid rgba(248, 113, 113, 0.32) !important;
    }
    .sparkline-wrap { margin-top: 2px; height: 24px; }
    .sparkline { width: 100%; height: 100%; }

    /* Net Grid */
    .net-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
      background: var(--code-bg);
      border: 1px solid var(--border-sub);
      border-radius: var(--radius-md);
      padding: 8px 10px;
    }
    .net-cell { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
    .net-label { font-size: 0.68rem; color: var(--text-muted); font-weight: 600; }
    .net-speed { font-family: monospace; font-size: 0.82rem; font-weight: 800; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .net-total { font-size: 0.68rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

    .node-footer {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-top: 4px;
      padding-top: 8px;
      border-top: 1px solid var(--border);
      font-size: 0.75rem;
    }
    .update-time { color: var(--text-muted); font-family: monospace; }

    /* Heatmaps Grid */
    .heatmaps-grid {
      display: flex;
      flex-direction: column;
      gap: 12px;
    }
    .heatmap-3rows-container {
      display: flex;
      flex-direction: column;
      gap: 8px;
      width: 100%;
    }
    .heatmap-row-wrap {
      background: var(--card-sub);
      border: 1px solid var(--border-sub);
      border-radius: var(--radius-lg);
      padding: 8px 12px;
      display: flex;
      flex-direction: column;
      gap: 6px;
      transition: all 0.2s;
    }
    .heatmap-row-wrap:hover { border-color: var(--primary); background: var(--card); }
    .heatmap-row-wrap.row-today {
      border-color: rgba(0, 174, 185, 0.45);
      background: var(--primary-light);
    }
    .heatmap-row-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      font-size: 0.78rem;
    }
    .hm-row-tag { font-weight: 700; color: var(--text); display: flex; align-items: center; gap: 4px; }
    .hm-row-tag.today { color: var(--primary-light-fg); font-weight: 800; }
    .hm-row-range { font-family: monospace; font-size: 0.74rem; color: var(--text-muted); }
    .hm-row-range.today { color: var(--primary-light-fg); font-weight: 700; }
    .heatmap-row-cells {
      display: grid;
      grid-template-columns: repeat(10, 1fr);
      gap: 6px;
      width: 100%;
    }
    .hm-cell {
      height: 30px;
      border-radius: var(--radius-sm);
      cursor: pointer;
      transition: all 0.15s ease;
      position: relative;
      border: 1px solid rgba(0,0,0,0.06);
      display: flex;
      align-items: center;
      justify-content: center;
      touch-action: manipulation;
      user-select: none;
    }
    .hm-cell:hover, .hm-cell:active {
      transform: scale(1.1);
      outline: 2px solid var(--primary);
      outline-offset: 1px;
      z-index: 10;
      filter: brightness(1.15);
    }
    .hm-day-num {
      font-size: 0.72rem;
      font-weight: 700;
      font-family: monospace;
      line-height: 1;
      opacity: 0.9;
    }
    .hm-gray { background: #cbd5e1; }
    .hm-gray .hm-day-num { color: #475569; }
    [data-theme="dark"] .hm-gray { background: #334155; }
    [data-theme="dark"] .hm-gray .hm-day-num { color: #94a3b8; }
    .hm-lightgreen { background: #99e2e6; }
    .hm-lightgreen .hm-day-num { color: #005f66; }
    .hm-darkgreen { background: #00aeb9; }
    .hm-darkgreen .hm-day-num { color: #ffffff; text-shadow: 0 1px 2px rgba(0,0,0,0.3); }
    .hm-yellow { background: #facc15; }
    .hm-yellow .hm-day-num { color: #713f12; }
    .hm-orange { background: #f97316; }
    .hm-orange .hm-day-num { color: #ffffff; text-shadow: 0 1px 2px rgba(0,0,0,0.3); }
    .hm-red { background: #dc2626; }
    .hm-red .hm-day-num { color: #ffffff; text-shadow: 0 1px 2px rgba(0,0,0,0.3); }

    .heatmap-legend-row {
      display: flex;
      align-items: center;
      gap: 8px;
      font-size: 0.74rem;
      color: var(--text-muted);
      margin-top: 4px;
      padding-top: 8px;
      border-top: 1px solid var(--border);
    }
    .legend-cells { display: inline-flex; align-items: center; gap: 4px; }
    .legend-cells .hm-cell { width: 12px; height: 14px; min-width: 12px; max-width: 12px; border-radius: 2px; cursor: default; }
    .legend-cells .hm-cell:hover, .legend-cells .hm-cell:active { outline: none; filter: none; transform: none; }
    .legend-label { font-weight: 600; color: var(--text-muted); }

    /* Group Collapse */
    .group-section { margin-bottom: 12px; }
    .group-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      cursor: pointer;
      user-select: none;
    }
    .group-title-left { display: flex; align-items: center; gap: 8px; }
    .group-arrow { font-size: 0.75rem; color: var(--text-muted); transition: transform 0.2s; }
    .group-arrow.collapsed { transform: rotate(-90deg); }
    .group-name { font-size: 1.05rem; font-weight: 800; color: var(--text); }

    /* Table with strict nowrap to prevent mobile vertical squish */
    .table-container {
      width: 100%;
      overflow-x: auto;
      -webkit-overflow-scrolling: touch;
      border: 1px solid var(--border);
      border-radius: var(--radius-xl);
      background: var(--card);
    }
    .mobile-table-tip {
      display: none;
      padding: 6px 12px;
      font-size: 0.72rem;
      font-weight: 600;
      color: var(--primary);
      background: var(--primary-light);
      border-bottom: 1px solid var(--border-sub);
      text-align: center;
    }
    table { width: 100%; border-collapse: collapse; min-width: 860px; text-align: left; }
    th {
      background: var(--muted);
      color: var(--text-muted);
      font-size: 0.76rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.5px;
      padding: 0.75rem 1rem;
      border-bottom: 1px solid var(--border);
      white-space: nowrap;
    }
    td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--border-sub); font-size: 0.85rem; white-space: nowrap; }
    tr:last-child td { border-bottom: none; }
    tr:hover td { background: var(--card-sub); }
    

    /* Buttons & Inputs */
    .btn {
      padding: 0.5rem 1rem;
      border-radius: var(--radius-md);
      font-size: 0.82rem;
      font-weight: 600;
      cursor: pointer;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
      border: 1px solid transparent;
      user-select: none;
    }
    .btn:active { transform: scale(0.98); }
    .btn-primary { background: var(--primary); color: #ffffff; box-shadow: 0 2px 6px rgba(0, 174, 185, 0.25); }
    .btn-primary:hover { background: var(--primary-hover); }
    .btn-outline { background: var(--card); border-color: var(--border); color: var(--text); }
    .btn-outline:hover { background: var(--muted); border-color: var(--primary); }
    .btn-ghost { background: transparent; color: var(--text); }
    .btn-ghost:hover { background: var(--muted); }
    .btn-danger { background: var(--danger-bg); border-color: rgba(239, 68, 68, 0.3); color: var(--danger); }
    .btn-danger:hover { background: var(--danger); color: #ffffff; }
    .btn-sm { padding: 3px 8px; font-size: 0.74rem; border-radius: var(--radius-sm); }
    .input-box {
      width: 100%;
      padding: 0.55rem 0.85rem;
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--bg);
      color: var(--text);
      font-size: 0.85rem;
      outline: none;
      transition: all 0.2s;
    }
    .input-box:focus { border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary-light); background: var(--card); }

    /* Sonner Toast (Top-Center) */
    .toast {
      position: fixed;
      top: 24px;
      left: 50%;
      transform: translate(-50%, -20px);
      background: var(--card);
      color: var(--text);
      border: 1px solid var(--border);
      font-weight: 600;
      font-size: 0.88rem;
      padding: 0.65rem 1.4rem;
      border-radius: 9999px;
      box-shadow: var(--shadow-lg);
      backdrop-filter: blur(12px);
      z-index: 10001;
      opacity: 0;
      transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
      pointer-events: none;
      display: flex;
      align-items: center;
      gap: 8px;
    }
    .toast.show { opacity: 1; transform: translate(-50%, 0); }

    .heatmap-floating-tooltip {
      position: fixed;
      z-index: 10002;
      background: rgba(15, 23, 42, 0.95);
      color: #f8fafc;
      padding: 8px 12px;
      border-radius: var(--radius-md);
      font-size: 0.78rem;
      line-height: 1.45;
      box-shadow: var(--shadow-lg);
      pointer-events: none;
      opacity: 0;
      visibility: hidden;
      transition: opacity 0.12s ease;
      backdrop-filter: blur(8px);
      border: 1px solid rgba(255, 255, 255, 0.1);
    }
    .heatmap-floating-tooltip.show { opacity: 1; visibility: visible; }

    .modal-overlay {
      position: fixed;
      inset: 0;
      background: rgba(0, 0, 0, 0.6);
      backdrop-filter: blur(4px);
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 9999;
      opacity: 0;
      visibility: hidden;
      transition: all 0.2s ease;
      padding: 1rem;
    }
    .modal-overlay.open { opacity: 1; visibility: visible; }
    .modal-card {
      background: var(--card);
      border: 1px solid var(--border);
      border-radius: var(--radius-2xl);
      padding: 1.5rem;
      max-width: 480px;
      width: 100%;
      max-height: 90vh;
      overflow-y: auto;
      box-shadow: var(--shadow-lg);
      transform: scale(0.95);
      transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
    }
    .modal-overlay.open .modal-card { transform: scale(1); }
    .modal-title { font-size: 1.15rem; font-weight: 800; color: var(--text); margin-bottom: 0.5rem; }
    .modal-desc { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1.2rem; line-height: 1.5; }
    .modal-actions { display: flex; justify-content: flex-end; gap: 8px; }

    .drawer-overlay {
      position: fixed;
      inset: 0;
      background: rgba(0, 0, 0, 0.5);
      z-index: 9990;
      opacity: 0;
      visibility: hidden;
      transition: all 0.25s ease;
    }
    .drawer-overlay.open { opacity: 1; visibility: visible; }
    .drawer-panel {
      position: fixed;
      top: 0;
      right: 0;
      bottom: 0;
      width: 280px;
      background: var(--card);
      border-left: 1px solid var(--border);
      padding: 1.5rem;
      transform: translateX(100%);
      transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
      display: flex;
      flex-direction: column;
      gap: 12px;
      box-shadow: var(--shadow-lg);
    }
    .drawer-overlay.open .drawer-panel { transform: translateX(0); }

    @media (max-width: 768px) {
      .mobile-table-tip { display: flex; align-items: center; justify-content: center; gap: 4px; }
      body { padding: 0.85rem 0.65rem; }
      .site-navbar { padding: 0.75rem 1rem; }
      .desktop-nav { display: none; }
      .mobile-nav-trigger { display: inline-flex; }
      .ppanel-card { padding: 1rem 1rem; border-radius: var(--radius-xl); }
      .metrics-grid { grid-template-columns: 1fr 1fr; }
      .tabs-bar { flex-direction: column; align-items: stretch; }
      .tabs-list { width: 100%; }
      .tab-item { flex: 1; justify-content: center; padding: 6px 8px; font-size: 0.75rem; }
      .nodes-grid { grid-template-columns: 1fr; }
      .heatmap-row-header { flex-wrap: wrap; gap: 4px; }
      .heatmap-row-cells { gap: 4px; }
      .hm-cell { height: 28px; }
    }
  </style>
</head>
<body>
  <div id="toast" class="toast"></div>

  <div class="app-container">
    <!-- Navbar -->
    <header class="site-navbar">
      <div class="nav-brand">
        <div class="brand-logo-icon">
          <span>🖥️</span>
        </div>
        <div class="brand-info">
          <div class="brand-badge-pill">ONEPVE CLOUD PORTAL</div>
          <h1 class="brand-title">集群探针 · 状态监控</h1>
        </div>
      </div>

      <div class="desktop-nav">
        ${isAdmin ? `
          <span class="badge badge-primary">👑 管理员模式</span>
          <button class="btn btn-outline btn-sm" onclick="openModal('settingsModal')">⚙️ 设置</button>
          <button class="btn btn-outline btn-sm" style="color:var(--danger);" onclick="logoutAdmin()">退出</button>
        ` : `
          <button class="btn btn-primary btn-sm" onclick="openAdminModal()"><span>🔐</span> 管理员登录</button>
        `}
        
        <div class="palette-dropdown" id="paletteDropdown">
          <button class="palette-btn" onclick="togglePaletteMenu(event)" title="选择主题配色">
            <span class="color-dot-indicator" id="curColorDot"></span>
            <span id="curColorName">极光青</span>
          </button>
          <div class="palette-menu" id="paletteMenu">
            <button class="color-opt-btn" data-color="aurora" onclick="setThemeColor('aurora')">
              <span class="color-swatch" style="background:#00aeb9;"></span>
              <span>极光青</span>
            </button>
            <button class="color-opt-btn" data-color="titanium" onclick="setThemeColor('titanium')">
              <span class="color-swatch" style="background:#0284c7;"></span>
              <span>钛金蓝</span>
            </button>
            <button class="color-opt-btn" data-color="violet" onclick="setThemeColor('violet')">
              <span class="color-swatch" style="background:#7c3aed;"></span>
              <span>紫晶光</span>
            </button>
            <button class="color-opt-btn" data-color="emerald" onclick="setThemeColor('emerald')">
              <span class="color-swatch" style="background:#059669;"></span>
              <span>森林绿</span>
            </button>
            <button class="color-opt-btn" data-color="amber" onclick="setThemeColor('amber')">
              <span class="color-swatch" style="background:#d97706;"></span>
              <span>琥珀金</span>
            </button>
            <button class="color-opt-btn" data-color="crimson" onclick="setThemeColor('crimson')">
              <span class="color-swatch" style="background:#e11d48;"></span>
              <span>落樱嫩粉</span>
            </button>
          </div>
        </div>

        <button class="btn btn-outline btn-sm" id="desktopThemeBtn" onclick="toggleTheme()" title="切换深浅主题">🌙</button>
      </div>

      <button class="mobile-nav-trigger" onclick="openNavDrawer()" title="打开菜单">
        <span>☰</span>
      </button>
    </header>

    <!-- Metrics Strip -->
    <div class="metrics-grid">
      <div class="metric-card">
        <span class="metric-label">🟢 在线服务器</span>
        <span class="metric-val highlight" id="statOnline">${onlineCount} <small style="font-size:0.75rem; color:var(--text-muted);">/ ${totalCount} 台</small></span>
        <span class="metric-sub" id="statOnlineRate">在线可用率: ${onlineRate}%</span>
      </div>
      <div class="metric-card">
        <span class="metric-label">⚡ 平均 CPU 负载</span>
        <span class="metric-val" id="statCpu">${avgCpu}%</span>
        <span class="metric-sub">全集群算力平均开销</span>
      </div>
      <div class="metric-card">
        <span class="metric-label">🧠 平均内存开销</span>
        <span class="metric-val" id="statMem">${avgMem}%</span>
        <span class="metric-sub">物理内存占用中位数</span>
      </div>
      <div class="metric-card">
        <span class="metric-label">🌐 实时网络总吞吐</span>
        <span class="metric-val highlight" id="statNet" style="font-size:1.15rem;">⬇ ${onlineNodes[0] ? onlineNodes[0].net_rx_rate : '0 KB/s'}</span>
        <span class="metric-sub" id="statNetTx">⬆ ${onlineNodes[0] ? onlineNodes[0].net_tx_rate : '0 KB/s'}</span>
      </div>
    </div>

    <!-- Segmented Tabs -->
    <div class="tabs-bar">
      <div class="tabs-list">
        <button class="tab-item active" id="viewDetailedBtn" onclick="switchView('detailed')">📊 节点卡片</button>
        <button class="tab-item" id="viewHeatmapBtn" onclick="switchView('heatmap')">🟩 30天日历</button>
        <button class="tab-item" id="viewTableBtn" onclick="switchView('table')">📋 详细列表</button>
      </div>
      <div style="display:inline-flex; gap:6px;">
        <button class="btn btn-outline btn-sm" onclick="toggleAllGroups()">↕️ 全部折叠/展开</button>
      </div>
    </div>

    <!-- Groups & Nodes -->
    <div id="groupsContainer">
      ${groupsHtml}
    </div>

    <footer style="text-align:center; font-size:0.76rem; color:var(--text-muted); padding:1rem 0;">
      Powered by OnePve Cloud Portal & Cloudflare Workers · 实时 5s 动态心跳轮询
    </footer>
  </div>

  <!-- Heatmap Tooltip -->
  <div class="heatmap-floating-tooltip" id="heatmapFloatingTooltip"></div>

  <!-- Mobile Drawer -->
  <div class="drawer-overlay" id="siteNavDrawer" onclick="if(event.target===this) closeNavDrawer()">
    <div class="drawer-panel">
      <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
        <div style="font-weight:800; font-size:1rem;">🖥️ 探针菜单</div>
        <button class="btn btn-ghost btn-sm" onclick="closeNavDrawer()">✕</button>
      </div>
      <div style="display:flex; flex-direction:column; gap:8px;">

        <div style="font-size:0.78rem; font-weight:700; color:var(--text-muted); margin-top:4px;">🎨 主题配色方案</div>
        <div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:6px;">
          <button class="color-opt-btn" data-color="aurora" onclick="setThemeColor('aurora')">
            <span class="color-swatch" style="background:#00aeb9;"></span>
            <span>极光青</span>
          </button>
          <button class="color-opt-btn" data-color="titanium" onclick="setThemeColor('titanium')">
            <span class="color-swatch" style="background:#0284c7;"></span>
            <span>钛金蓝</span>
          </button>
          <button class="color-opt-btn" data-color="violet" onclick="setThemeColor('violet')">
            <span class="color-swatch" style="background:#7c3aed;"></span>
            <span>紫晶光</span>
          </button>
          <button class="color-opt-btn" data-color="emerald" onclick="setThemeColor('emerald')">
            <span class="color-swatch" style="background:#059669;"></span>
            <span>森林绿</span>
          </button>
          <button class="color-opt-btn" data-color="amber" onclick="setThemeColor('amber')">
            <span class="color-swatch" style="background:#d97706;"></span>
            <span>琥珀金</span>
          </button>
          <button class="color-opt-btn" data-color="crimson" onclick="setThemeColor('crimson')">
              <span class="color-swatch" style="background:#e11d48;"></span>
              <span>落樱嫩粉</span>
            </button>
        </div>

        <button class="btn btn-outline" onclick="toggleTheme(); closeNavDrawer();" style="justify-content:space-between;">
          <span id="drawerThemeText">深色模式 (护眼)</span>
          <span id="drawerThemeIcon">🌙</span>
        </button>
        ${isAdmin ? `
          <button class="btn btn-outline" onclick="openModal('settingsModal'); closeNavDrawer();">⚙️ 系统设置</button>
          <button class="btn btn-danger" onclick="logoutAdmin(); closeNavDrawer();">🚪 退出管理员</button>
        ` : `
          <button class="btn btn-primary" onclick="openAdminModal(); closeNavDrawer();">🔐 管理员登录</button>
        `}
      </div>
    </div>
  </div>

  <!-- Admin Login Modal -->
  <div class="modal-overlay" id="adminLoginModal" onclick="if(event.target===this) closeAdminModal()">
    <div class="modal-card">
      <div class="modal-title">🔐 管理员身份认证</div>
      <div class="modal-desc">请输入管理员密码以解锁配置与内部隐藏节点:</div>
      <input type="password" id="adminKeyInput" class="input-box" placeholder="输入管理密钥..." style="margin-bottom:1.2rem;" onkeydown="if(event.key==='Enter') submitAdminLogin()" />
      <div class="modal-actions">
        <button class="btn btn-outline" onclick="closeAdminModal()">取消</button>
        <button class="btn btn-primary" id="adminLoginBtn" onclick="submitAdminLogin()">登录验证</button>
      </div>
    </div>
  </div>

  <!-- Delete Confirm Modal -->
  <div class="modal-overlay" id="deleteNodeModal" onclick="if(event.target===this) closeDeleteModal()">
    <div class="modal-card">
      <div class="modal-title">🗑️ 确认彻底下线节点?</div>
      <div class="modal-desc">确定要从集群监控数据库中彻底移除「<strong id="deleteNodeName" style="color:var(--text);">-</strong>」吗?移除后需在宿主机重新上报方可恢复。</div>
      <input type="hidden" id="deleteNodeId" />
      <div class="modal-actions">
        <button class="btn btn-outline" onclick="closeDeleteModal()">取消</button>
        <button class="btn btn-danger" id="confirmDeleteBtn" onclick="submitDeleteNode()">确认下线</button>
      </div>
    </div>
  </div>

  <!-- Settings Modal (Comprehensive Multi-Tab Settings Center) -->
  ${isAdmin ? `
  <div class="modal-overlay" id="settingsModal" onclick="if(event.target===this) closeModal('settingsModal')">
    <div class="modal-card" style="max-width: 680px; max-height: 88vh; overflow-y: auto; padding: 1.5rem;">
      <div class="card-header-row" style="margin-bottom: 1rem;">
        <div class="title-pill-group">
          <span class="badge badge-primary">⚙️ 集群中枢</span>
          <h3 class="card-title">探针系统配置与密钥管理中心</h3>
        </div>
        <span class="badge badge-secondary" style="font-size:0.75rem;">👑 管理员模式 · 热更新秒级生效</span>
      </div>

      <!-- Settings Segmented Tabs -->
      <div class="tabs-list" style="margin-bottom: 1.2rem; width: 100%; padding: 3px;">
        <button class="tab-item active" id="modalTabSettingsBtn" onclick="switchModalTab('settings')" style="flex:1; justify-content:center; padding:6px 12px; font-size:0.8rem;">⚙️ 参数与告警</button>
        <button class="tab-item" id="modalTabTokensBtn" onclick="switchModalTab('tokens')" style="flex:1; justify-content:center; padding:6px 12px; font-size:0.8rem;">🔑 上报密钥 (${tokens.length})</button>
        <button class="tab-item" id="modalTabCmdsBtn" onclick="switchModalTab('cmds')" style="flex:1; justify-content:center; padding:6px 12px; font-size:0.8rem;">⚡ 部署指令</button>
      </div>

      <!-- Tab 1: System Settings & Alerts -->
      <div id="modalSecSettings">
        <div style="display:flex; flex-direction:column; gap:12px;">
          <!-- 1. Webhook Alert -->
          <div class="metric-card" style="padding:0.9rem;">
            <label style="font-size:0.8rem; font-weight:700; color:var(--text); margin-bottom:4px; display:block;">🔔 离线 / 恢复 Webhook 告警地址 (支持 企业微信 / Bark / 自建SMS / TG):</label>
            <div style="display:flex; gap:6px; margin-top:4px;">
              <input type="text" id="settingAlertWebhook" class="input-box" value="${settings.alert_webhook || ''}" placeholder="例如: https://sms.example.com/message?token=YOUR_TOKEN 或 企业微信 Webhook" style="flex:1; font-size:0.82rem;" />
              <button class="btn btn-outline btn-sm" onclick="submitTestAlert()" style="white-space:nowrap; font-weight:600;">🔔 测试推送</button>
            </div>
            <div style="font-size:0.72rem; color:var(--text-muted); margin-top:4px;">节点离线断连或心跳恢复时,探针系统将自动向该 Webhook 推送告警通知。</div>
          </div>

          <!-- 2. Grid for Parameters -->
          <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:10px;">
            <div class="metric-card" style="padding:0.8rem;">
              <label style="font-size:0.78rem; font-weight:700; color:var(--text); margin-bottom:4px; display:block;">⏱️ 心跳超时判定时间:</label>
              <select id="settingTimeoutSeconds" class="input-box" style="font-weight:600; font-size:0.82rem;">
                <option value="60" ${settings.timeout_seconds === 60 ? 'selected' : ''}>60 秒 (1 分钟 - 灵敏)</option>
                <option value="120" ${settings.timeout_seconds === 120 || !settings.timeout_seconds ? 'selected' : ''}>120 秒 (2 分钟 - 推荐默认)</option>
                <option value="180" ${settings.timeout_seconds === 180 ? 'selected' : ''}>180 秒 (3 分钟 - 稳健)</option>
                <option value="300" ${settings.timeout_seconds === 300 ? 'selected' : ''}>300 秒 (5 分钟 - 节能)</option>
                <option value="600" ${settings.timeout_seconds === 600 ? 'selected' : ''}>600 秒 (10 分钟)</option>
              </select>
            </div>

            <div class="metric-card" style="padding:0.8rem;">
              <label style="font-size:0.78rem; font-weight:700; color:var(--text); margin-bottom:4px; display:block;">🔢 节点卡片排序规则:</label>
              <select id="settingSortMode" class="input-box" style="font-weight:600; font-size:0.82rem;">
                <option value="numeric_asc" ${settings.sort_mode === 'numeric_asc' || !settings.sort_mode ? 'selected' : ''}>#ID 数字从小到大升序 (#1 &lt; #2 &lt; #3 - 推荐)</option>
                <option value="numeric_desc" ${settings.sort_mode === 'numeric_desc' ? 'selected' : ''}>#ID 数字从大到小降序 (#3 &gt; #2 &gt; #1)</option>
                <option value="updated_desc" ${settings.sort_mode === 'updated_desc' ? 'selected' : ''}>最新活跃/上报时间优先 (最新更新排前面)</option>
              </select>
            </div>

            <div class="metric-card" style="padding:0.8rem;">
              <label style="font-size:0.78rem; font-weight:700; color:var(--text); margin-bottom:4px; display:block;">🌐 IPv4 / IPv6 地址展示策略:</label>
              <select id="settingIpDisplayMode" class="input-box" style="font-weight:600; font-size:0.82rem;">
                <option value="admin_only_masked" ${settings.ip_display_mode === 'admin_only_masked' || !settings.ip_display_mode ? 'selected' : ''}>🔒 仅管理员可见 (脱敏隐藏 - 默认推荐)</option>
                <option value="admin_only_full" ${settings.ip_display_mode === 'admin_only_full' ? 'selected' : ''}>🔒 仅管理员可见 (完整明文 IP)</option>
                <option value="public_masked" ${settings.ip_display_mode === 'public_masked' ? 'selected' : ''}>🌍 公开全员可见 (脱敏隐藏)</option>
                <option value="public_full" ${settings.ip_display_mode === 'public_full' ? 'selected' : ''}>🌍 公开全员可见 (完整明文 IP)</option>
                <option value="hidden" ${settings.ip_display_mode === 'hidden' ? 'selected' : ''}>🚫 全局彻底隐藏 IP 地址</option>
              </select>
            </div>

            <div class="metric-card" style="padding:0.8rem;">
              <label style="font-size:0.78rem; font-weight:700; color:var(--text); margin-bottom:4px; display:block;">🛡️ 密码防爆破告警通知推送:</label>
              <select id="settingAuthLockAlert" class="input-box" style="font-weight:600; font-size:0.82rem;">
                <option value="0" ${settings.auth_lock_alert === '0' || !settings.auth_lock_alert ? 'selected' : ''}>🔕 关闭通知 (静默防御拦截 - 默认推荐)</option>
                <option value="1" ${settings.auth_lock_alert === '1' ? 'selected' : ''}>🔔 开启推送 (连续输错5次/10次推送告警)</option>
              </select>
            </div>
          </div>

          <!-- 3. Default Token & IPv4 Switch -->
          <div class="metric-card" style="padding:0.8rem; display:flex; flex-direction:column; gap:8px;">
            <div>
              <label style="font-size:0.78rem; font-weight:700; color:var(--text); margin-bottom:4px; display:block;">🔑 默认上报 Token 凭据:</label>
              <div style="display:flex; gap:6px;">
                <input type="text" id="settingReportToken" class="input-box" value="${settings.report_token || env.REPORT_TOKEN || 'YOUR_PROBE_TOKEN'}" style="font-family:monospace; font-size:0.82rem;" />
                <button class="btn btn-outline btn-sm" onclick="copyText(this, document.getElementById('settingReportToken').value)">📋 复制</button>
              </div>
            </div>
            <div style="display:flex; align-items:center; justify-content:space-between; padding-top:6px; border-top:1px solid var(--border-sub);">
              <span style="font-size:0.82rem; font-weight:600; color:var(--text);">访客公开展示 IPv4 地址</span>
              <input type="checkbox" id="settingShowIpv4" ${settings.show_ipv4 !== false ? 'checked' : ''} style="width:18px; height:18px; accent-color:var(--primary);" />
            </div>
          </div>
        </div>

        <div class="modal-actions" style="margin-top:1.2rem;">
          <button class="btn btn-outline" onclick="closeModal('settingsModal')">取消</button>
          <button class="btn btn-primary" onclick="submitSaveSettings()">💾 保存全部系统参数</button>
        </div>
      </div>

      <!-- Tab 2: Multi-Token Management -->
      <div id="modalSecTokens" style="display:none;">
        <div style="display:flex; flex-direction:column; gap:10px;">
          <!-- Add Token Form -->
          <div class="metric-card" style="padding:0.9rem;">
            <div style="font-size:0.82rem; font-weight:700; color:var(--text); margin-bottom:8px;">+ 创建新的上报 Token (分集群隔离)</div>
            <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:8px;">
              <input type="text" id="newProbeTokenName" class="input-box" placeholder="密钥名称 (如: 香港集群专属)" style="font-size:0.82rem;" />
              <input type="text" id="newProbeTokenVal" class="input-box" placeholder="Token 字符串 (留空自动生成)" style="font-family:monospace; font-size:0.82rem;" />
            </div>
            <div style="display:flex; justify-content:flex-end; gap:6px; margin-top:8px;">
              <button class="btn btn-outline btn-sm" onclick="generateProbeRandomToken()">🎲 随机生成</button>
              <button class="btn btn-primary btn-sm" onclick="submitCreateProbeToken()">+ 添加密钥</button>
            </div>
          </div>

          <!-- Tokens List -->
          <div style="display:flex; flex-direction:column; gap:8px; max-height: 40vh; overflow-y: auto;">
            ${tokens.map(t => `
              <div class="metric-card" style="padding:0.75rem; display:flex; justify-content:space-between; align-items:center; gap:8px;">
                <div style="min-width:0; flex:1;">
                  <div style="display:flex; align-items:center; gap:6px;">
                    <strong style="font-size:0.84rem;">${t.name}</strong>
                    ${t.is_default ? '<span class="badge badge-primary" style="font-size:0.68rem; padding:1px 5px;">主密钥</span>' : ''}
                    ${t.status === 'disabled' ? '<span class="badge badge-secondary" style="font-size:0.68rem; padding:1px 5px;">已停用</span>' : '<span class="badge badge-primary" style="font-size:0.68rem; padding:1px 5px; color:var(--success-fg);">活跃</span>'}
                  </div>
                  <div style="margin-top:3px;">
                    <code style="font-size:0.78rem; font-family:monospace; background:var(--code-bg); padding:2px 6px; border-radius:4px; ${t.status === 'disabled' ? 'text-decoration:line-through; opacity:0.6;' : ''}">${t.token}</code>
                  </div>
                </div>
                <div style="display:inline-flex; gap:4px; align-items:center; shrink-0;">
                  <button class="btn btn-outline btn-sm" onclick="copyText(this, '${t.token}')">📋</button>
                  ${!t.is_default ? `
                    <button class="btn btn-outline btn-sm" onclick="toggleProbeTokenStatus('${t.id}', '${t.status}')" title="${t.status === 'disabled' ? '启用' : '停用'}">
                      ${t.status === 'disabled' ? '▶️' : '⏸️'}
                    </button>
                    <button class="btn btn-danger btn-sm" onclick="deleteProbeToken('${t.id}', '${t.name}')" title="删除密钥">🗑️</button>
                  ` : ''}
                </div>
              </div>
            `).join('')}
          </div>
        </div>
        <div class="modal-actions" style="margin-top:1rem;">
          <button class="btn btn-outline" onclick="closeModal('settingsModal')">关闭</button>
        </div>
      </div>

      <!-- Tab 3: Deployment Commands Generator -->
      <div id="modalSecCmds" style="display:none;">
        <div style="display:flex; flex-direction:column; gap:12px;">
          <div class="metric-card" style="padding:0.9rem;">
            <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
              <span style="font-weight:700; font-size:0.84rem; color:var(--text);">选用上报 Token:</span>
              <select id="modalInstallTokenSelect" onchange="onModalTokenChange(this.value)" class="input-box" style="width:auto; padding:3px 8px; font-weight:700; font-size:0.8rem;">
                ${tokens.map(t => `<option value="${t.token}">${t.name} (${t.token.substring(0, 8)}...)</option>`).join('')}
              </select>
            </div>

            <!-- 1. Default Install -->
            <div style="margin-bottom:10px;">
              <div style="font-size:0.78rem; font-weight:700; color:var(--text-muted); margin-bottom:3px;">1. Linux 探针一键标准安装 (自动系统服务守护):</div>
              <div class="cmd-box">
                <div class="cmd-text" id="modalCmdInstall">curl -sSL https://${host}/i | sudo bash -s -- --url https://${host}/report --token ${settings.report_token || env.REPORT_TOKEN || 'YOUR_PROBE_TOKEN'}</div>
                <button class="btn btn-outline btn-sm" onclick="copyDynamicCmd('modalCmdInstall', this)">📋 复制</button>
              </div>
            </div>

            <!-- 2. Silent Install with ID & Group -->
            <div style="margin-bottom:10px;">
              <div style="font-size:0.78rem; font-weight:700; color:var(--text-muted); margin-bottom:3px;">2. 静默一键安装 (自定义节点序号与集群分组):</div>
              <div class="cmd-box">
                <div class="cmd-text" id="modalCmdInstallSilent">curl -sSL https://${host}/i | sudo bash -s -- --url https://${host}/report --token ${settings.report_token || env.REPORT_TOKEN || 'YOUR_PROBE_TOKEN'} --id 1 --group "默认集群" --name "香港01"</div>
                <button class="btn btn-outline btn-sm" onclick="copyDynamicCmd('modalCmdInstallSilent', this)">📋 复制</button>
              </div>
            </div>

            <!-- 3. Uninstall -->
            <div>
              <div style="font-size:0.78rem; font-weight:700; color:var(--text-muted); margin-bottom:3px;">3. 探针一键彻底卸载清理 (全自动下线注销):</div>
              <div class="cmd-box">
                <div class="cmd-text" id="modalCmdUninstall">curl -sSL https://${host}/u | sudo bash</div>
                <button class="btn btn-outline btn-sm" onclick="copyDynamicCmd('modalCmdUninstall', this)">📋 复制</button>
              </div>
            </div>
          </div>
        </div>
        <div class="modal-actions" style="margin-top:1rem;">
          <button class="btn btn-outline" onclick="closeModal('settingsModal')">关闭</button>
        </div>
      </div>

    </div>
  </div>
  ` : ''}

  <script>
    const IS_ADMIN = ${isAdmin ? 'true' : 'false'};
    let CURRENT_SETTINGS = ${JSON.stringify(settings)};

    function showHeatmapTooltip(elem, event) {
      let tt = document.getElementById('heatmapFloatingTooltip');
      if (!tt) return;
      const date = elem.getAttribute('data-date') || '状态记录';
      const status = elem.getAttribute('data-status') || '正常';
      const count = elem.getAttribute('data-count') || '100% 在线';

      let statusColor = '#00aeb9';
      if (status.includes('离线') || status.includes('未上线')) statusColor = '#ef4444';
      else if (status.includes('警告') || status.includes('高负载')) statusColor = '#f59e0b';

      tt.innerHTML = '<div style="font-weight:700; margin-bottom:2px;">📅 ' + date + '</div>' +
        '<div style="color:' + statusColor + '; font-weight:700;">● ' + status + '</div>' +
        '<div style="color:#94a3b8; font-size:0.72rem; margin-top:2px;">健康度: ' + count + ' · 探针上报正常</div>';

      const rect = elem.getBoundingClientRect();
      let left = rect.left + rect.width / 2;
      let top = rect.top - 8;

      if (rect.top < 90) {
        top = rect.bottom + 8;
        tt.style.transform = 'translate(-50%, 0)';
      } else {
        tt.style.transform = 'translate(-50%, -100%)';
      }

      left = Math.max(120, Math.min(window.innerWidth - 120, left));
      tt.style.left = left + 'px';
      tt.style.top = top + 'px';
      tt.classList.add('show');

      clearTimeout(window.__ttTimer);
      window.__ttTimer = setTimeout(() => hideHeatmapTooltip(), 3500);
    }

    function hideHeatmapTooltip() {
      const tt = document.getElementById('heatmapFloatingTooltip');
      if (tt) tt.classList.remove('show');
    }

    function switchView(mode) {
      try { localStorage.setItem('probe_view_mode', mode); } catch(e) {}
      const btnDetailed = document.getElementById('viewDetailedBtn');
      const btnHeatmap = document.getElementById('viewHeatmapBtn');
      const btnTable = document.getElementById('viewTableBtn');

      [btnDetailed, btnHeatmap, btnTable].forEach(b => b?.classList.remove('active'));

      const details = document.querySelectorAll('.group-grid-detailed');
      const heatmaps = document.querySelectorAll('.group-grid-heatmap');
      const tables = document.querySelectorAll('.group-table-container');

      if (mode === 'detailed') {
        btnDetailed?.classList.add('active');
        details.forEach(g => {
          const sec = g.closest('.group-section');
          const arr = sec?.querySelector('.group-arrow');
          if (!arr || !arr.classList.contains('collapsed')) g.style.display = 'grid';
        });
        heatmaps.forEach(g => g.style.display = 'none');
        tables.forEach(t => t.style.display = 'none');
      } else if (mode === 'heatmap') {
        btnHeatmap?.classList.add('active');
        details.forEach(g => g.style.display = 'none');
        heatmaps.forEach(g => {
          const sec = g.closest('.group-section');
          const arr = sec?.querySelector('.group-arrow');
          if (!arr || !arr.classList.contains('collapsed')) g.style.display = 'flex';
        });
        tables.forEach(t => t.style.display = 'none');
      } else {
        btnTable?.classList.add('active');
        details.forEach(g => g.style.display = 'none');
        heatmaps.forEach(g => g.style.display = 'none');
        tables.forEach(t => {
          const sec = t.closest('.group-section');
          const arr = sec?.querySelector('.group-arrow');
          if (!arr || !arr.classList.contains('collapsed')) t.style.display = 'block';
        });
      }
    }

    function toggleGroup(gIdx) {
      const arr = document.getElementById('arrow-' + gIdx);
      const gDetail = document.getElementById('grid-detailed-' + gIdx);
      const gHeatmap = document.getElementById('grid-heatmap-' + gIdx);
      const gTable = document.getElementById('grid-table-' + gIdx);

      const isCollapsed = arr?.classList.toggle('collapsed');
      const curView = getCurrentViewMode();

      if (isCollapsed) {
        if (gDetail) gDetail.style.display = 'none';
        if (gHeatmap) gHeatmap.style.display = 'none';
        if (gTable) gTable.style.display = 'none';
      } else {
        if (curView === 'detailed' && gDetail) gDetail.style.display = 'grid';
        else if (curView === 'heatmap' && gHeatmap) gHeatmap.style.display = 'flex';
        else if (curView === 'table' && gTable) gTable.style.display = 'block';
      }
    }

    function toggleAllGroups() {
      const arrows = document.querySelectorAll('.group-arrow');
      const someOpen = Array.from(arrows).some(a => !a.classList.contains('collapsed'));
      const sections = document.querySelectorAll('.group-section');
      sections.forEach((_, idx) => {
        const arr = document.getElementById('arrow-' + idx);
        if (someOpen) {
          if (!arr.classList.contains('collapsed')) toggleGroup(idx);
        } else {
          if (arr.classList.contains('collapsed')) toggleGroup(idx);
        }
      });
    }

    function getCurrentViewMode() {
      if (document.getElementById('viewHeatmapBtn')?.classList.contains('active')) return 'heatmap';
      if (document.getElementById('viewTableBtn')?.classList.contains('active')) return 'table';
      return 'detailed';
    }

    function openAdminModal() {
      openModal('adminLoginModal');
      document.getElementById('adminKeyInput')?.focus();
    }
    function closeAdminModal() { closeModal('adminLoginModal'); }

    async function submitAdminLogin() {
      const key = (document.getElementById('adminKeyInput')?.value || '').trim();
      if (!key) { showToast('请输入管理员密码'); return; }
      const btn = document.getElementById('adminLoginBtn');
      if (btn) { btn.disabled = true; btn.innerText = '认证中...'; }
      try {
        const res = await fetch('/api/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ key })
        });
        const data = await res.json();
        if (data.status === 'success') {
          showToast('✓ 管理员认证成功!');
          setTimeout(() => location.reload(), 400);
        } else {
          showToast('❌ ' + (data.error || '密码错误'));
          if (btn) { btn.disabled = false; btn.innerText = '登录验证'; }
        }
      } catch (e) {
        showToast('❌ 请求失败: ' + e.message);
        if (btn) { btn.disabled = false; btn.innerText = '登录验证'; }
      }
    }

    async function logoutAdmin() {
      try {
        await fetch('/api/logout', { method: 'POST' });
        showToast('✓ 已退出管理员模式');
        setTimeout(() => location.reload(), 300);
      } catch (e) { location.reload(); }
    }

    function openDeleteModal(btn) {
      const id = btn.getAttribute('data-id');
      const name = btn.getAttribute('data-name');
      document.getElementById('deleteNodeId').value = id;
      document.getElementById('deleteNodeName').innerText = name;
      openModal('deleteNodeModal');
    }
    function closeDeleteModal() { closeModal('deleteNodeModal'); }

    async function submitDeleteNode() {
      const id = document.getElementById('deleteNodeId')?.value;
      if (!id) return;
      const btn = document.getElementById('confirmDeleteBtn');
      if (btn) { btn.disabled = true; btn.innerText = '下线中...'; }
      try {
        const res = await fetch('/api/node/' + encodeURIComponent(id), { method: 'DELETE' });
        const data = await res.json();
        if (data.status === 'success') {
          showToast('✓ 节点已彻底下线');
          setTimeout(() => location.reload(), 400);
        } else {
          showToast('❌ 下线失败: ' + (data.error || '未知错误'));
          if (btn) { btn.disabled = false; btn.innerText = '确认下线'; }
        }
      } catch (e) {
        showToast('❌ 请求失败: ' + e.message);
        if (btn) { btn.disabled = false; btn.innerText = '确认下线'; }
      }
    }

    async function toggleNodeVisibility(btn) {
      const id = btn.getAttribute('data-id');
      const isPub = btn.getAttribute('data-public') === 'true';
      const newPub = !isPub;
      try {
        const res = await fetch('/api/node/' + encodeURIComponent(id) + '/visibility', {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ is_public: newPub })
        });
        const data = await res.json();
        if (data.status === 'success') {
          showToast(newPub ? '✓ 节点已设为公开展示' : '✓ 节点已设为隐私隐藏');
          btn.setAttribute('data-public', String(newPub));
          btn.className = 'vis-btn ' + (newPub ? 'vis-public' : 'vis-private');
          btn.innerText = newPub ? '🌍 公开' : '🔒 隐私';
        }
      } catch (e) {
        showToast('❌ 修改失败: ' + e.message);
      }
    }

    function switchModalTab(tab) {
      const tabs = ['settings', 'tokens', 'cmds'];
      tabs.forEach(t => {
        const btn = document.getElementById('modalTab' + t.charAt(0).toUpperCase() + t.slice(1) + 'Btn');
        const sec = document.getElementById('modalSec' + t.charAt(0).toUpperCase() + t.slice(1));
        if (btn) {
          if (t === tab) btn.classList.add('active');
          else btn.classList.remove('active');
        }
        if (sec) {
          sec.style.display = (t === tab) ? 'block' : 'none';
        }
      });
    }

    async function submitSaveSettings() {
      const alert_webhook = (document.getElementById('settingAlertWebhook')?.value || '').trim();
      const timeout_seconds = parseInt(document.getElementById('settingTimeoutSeconds')?.value || '120', 10);
      const sort_mode = document.getElementById('settingSortMode')?.value || 'numeric_asc';
      const ip_display_mode = document.getElementById('settingIpDisplayMode')?.value || 'admin_only_masked';
      const auth_lock_alert = document.getElementById('settingAuthLockAlert')?.value || '0';
      const show_ipv4 = document.getElementById('settingShowIpv4')?.checked !== false;
      const report_token = (document.getElementById('settingReportToken')?.value || '').trim();

      try {
        showToast('正在保存系统参数...');
        const res = await fetch('/api/settings', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ alert_webhook, timeout_seconds, sort_mode, ip_display_mode, auth_lock_alert, show_ipv4, report_token })
        });
        const data = await res.json();
        if (data.status === 'success') {
          showToast('✓ 系统配置与告警参数已成功保存并立即生效!');
          closeModal('settingsModal');
          setTimeout(() => location.reload(), 500);
        } else {
          showToast('❌ 保存失败: ' + (data.error || '未知错误'));
        }
      } catch (e) {
        showToast('❌ 请求失败: ' + e.message);
      }
    }

    async function submitTestAlert() {
      const webhook = (document.getElementById('settingAlertWebhook')?.value || '').trim();
      if (!webhook) {
        showToast('❌ 请先输入告警 Webhook 地址');
        return;
      }
      try {
        showToast('正在发送测试告警...');
        const res = await fetch('/api/test-alert', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ webhook })
        });
        const data = await res.json();
        if (data.status === 'success') {
          showToast('✓ 测试告警推送成功!请检查接收端消息');
        } else {
          showToast('❌ 推送失败: ' + (data.error || '未知错误'));
        }
      } catch (e) {
        showToast('❌ 请求失败: ' + e.message);
      }
    }

    function generateProbeRandomToken() {
      const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
      let r = 'prb_';
      for (let i = 0; i < 18; i++) r += chars.charAt(Math.floor(Math.random() * chars.length));
      const el = document.getElementById('newProbeTokenVal');
      if (el) el.value = r;
    }

    async function submitCreateProbeToken() {
      const name = (document.getElementById('newProbeTokenName')?.value || '').trim();
      let token = (document.getElementById('newProbeTokenVal')?.value || '').trim();
      if (!token) {
        generateProbeRandomToken();
        token = document.getElementById('newProbeTokenVal')?.value || '';
      }
      try {
        const res = await fetch('/api/tokens', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ name: name || '自定义集群密钥', token })
        });
        const data = await res.json();
        if (data.status === 'success') {
          showToast('✓ 上报密钥创建成功!');
          setTimeout(() => location.reload(), 400);
        } else {
          showToast('❌ 创建失败: ' + (data.error || '未知错误'));
        }
      } catch (e) {
        showToast('❌ 请求失败: ' + e.message);
      }
    }

    async function toggleProbeTokenStatus(id, currentStatus) {
      try {
        const res = await fetch('/api/tokens?id=' + encodeURIComponent(id), { method: 'PATCH' });
        const data = await res.json();
        if (data.status === 'success') {
          showToast(data.new_status === 'active' ? '✓ 密钥已成功启用!' : '✓ 密钥已停用');
          setTimeout(() => location.reload(), 400);
        } else {
          showToast('❌ 操作失败: ' + (data.error || '未知错误'));
        }
      } catch (e) {
        showToast('❌ 请求失败: ' + e.message);
      }
    }

    async function deleteProbeToken(id, name) {
      if (!confirm('确定要永久吊销并删除上报密钥「' + name + '」吗?删除后该 Token 将无法上报!')) return;
      try {
        const res = await fetch('/api/tokens?id=' + encodeURIComponent(id), { method: 'DELETE' });
        const data = await res.json();
        if (data.status === 'success') {
          showToast('✓ 上报密钥已成功吊销删除');
          setTimeout(() => location.reload(), 400);
        } else {
          showToast('❌ 删除失败: ' + (data.error || '未知错误'));
        }
      } catch (e) {
        showToast('❌ 请求失败: ' + e.message);
      }
    }

    function onModalTokenChange(tok) {
      const host = window.location.host;
      const cmd1 = document.getElementById('modalCmdInstall');
      if (cmd1) cmd1.innerText = 'curl -sSL https://' + host + '/i | sudo bash -s -- --url https://' + host + '/report --token ' + tok;
      const cmd2 = document.getElementById('modalCmdInstallSilent');
      if (cmd2) cmd2.innerText = 'curl -sSL https://' + host + '/i | sudo bash -s -- --url https://' + host + '/report --token ' + tok + ' --id 1 --group "默认集群" --name "香港01"';
    }

    function copyDynamicCmd(elemId, btn) {
      const text = document.getElementById(elemId)?.innerText || '';
      copyText(btn, text);
    }

    function copyText(btn, text) {
      navigator.clipboard.writeText(text).then(() => {
        showToast('✓ 已复制到剪贴板');
        const orig = btn.innerText;
        btn.innerText = '✓ 已复制';
        setTimeout(() => { btn.innerText = orig; }, 1500);
      }).catch(() => showToast('复制失败,请手动复制'));
    }

    function showToast(msg) {
      const t = document.getElementById('toast');
      if (t) {
        t.innerText = msg;
        t.classList.add('show');
        setTimeout(() => t.classList.remove('show'), 2500);
      }
    }

    function openModal(id) { document.getElementById(id)?.classList.add('open'); }
    function closeModal(id) { document.getElementById(id)?.classList.remove('open'); }
    function openNavDrawer() { document.getElementById('siteNavDrawer')?.classList.add('open'); }
    function closeNavDrawer() { document.getElementById('siteNavDrawer')?.classList.remove('open'); }

    function toggleTheme() {
      const cur = document.documentElement.getAttribute('data-theme') || 'light';
      const next = cur === 'light' ? 'dark' : 'light';
      document.documentElement.setAttribute('data-theme', next);
      localStorage.setItem('probe_theme', next);
      updateThemeUI(next);
    }

    function updateThemeUI(t) {
      const icon = t === 'dark' ? '☀️' : '🌙';
      const text = t === 'dark' ? '明亮模式' : '深色模式 (护眼)';
      const dBtn = document.getElementById('desktopThemeBtn');
      const dIcon = document.getElementById('drawerThemeIcon');
      const dText = document.getElementById('drawerThemeText');
      if (dBtn) dBtn.innerText = icon;
      if (dIcon) dIcon.innerText = icon;
      if (dText) dText.innerText = text;
    }
    updateThemeUI(document.documentElement.getAttribute('data-theme') || 'light');

    function getClientBarColor(pct) {
      if (pct >= 85) return 'bar-red';
      if (pct >= 65) return 'bar-yellow';
      return 'bar-green';
    }

    function renderClientSparkline(history) {
      const data = (history && history.length) ? history : [0];
      const max = Math.max(100, ...data);
      const w = 120, h = 24;
      const step = data.length > 1 ? w / (data.length - 1) : w;
      const points = data.map((val, idx) => {
        const x = idx * step;
        const y = h - (val / max) * (h - 4) - 2;
        return x.toFixed(1) + ',' + y.toFixed(1);
      }).join(' ');
      return '<div class="sparkline-wrap" title="最近 12 点负载走势"><svg class="sparkline" viewBox="0 0 ' + w + ' ' + h + '"><polyline fill="none" stroke="var(--primary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="' + points + '" /></svg></div>';
    }

    function renderClientNodeCard(node, isAdmin, settings) {
      const isOnline = node.is_online;
      const isPublic = node.is_public !== false;
      const statusBadge = isOnline
        ? '<span class="status-chip chip-online"><span class="status-dot"></span>运行中</span>'
        : '<span class="status-chip chip-offline"><span class="status-dot dot-offline"></span>已离线</span>';

      const visBadge = isAdmin
        ? '<button class="vis-btn ' + (isPublic ? 'vis-public' : 'vis-private') + '" data-id="' + node.id + '" data-public="' + isPublic + '" onclick="toggleNodeVisibility(this)" title="' + (isPublic ? '公开节点' : '隐私节点') + '">' + (isPublic ? '🌍 公开' : '🔒 隐私') + '</button>'
        : '';

      const memFormatted = node.mem_total_mb > 1024
        ? (node.mem_used_mb / 1024).toFixed(1) + ' GB / ' + (node.mem_total_mb / 1024).toFixed(1) + ' GB'
        : node.mem_used_mb.toFixed(0) + ' MB / ' + node.mem_total_mb.toFixed(0) + ' MB';

      const swapFormatted = node.swap_total_mb > 0
        ? (node.swap_total_mb > 1024
            ? (node.swap_used_mb / 1024).toFixed(1) + ' / ' + (node.swap_total_mb / 1024).toFixed(1) + ' GB'
            : node.swap_used_mb.toFixed(0) + ' / ' + node.swap_total_mb.toFixed(0) + ' MB')
        : '无 Swap';

      const diskFormatted = node.disk_used_gb.toFixed(1) + ' GB / ' + node.disk_total_gb.toFixed(1) + ' GB';
      const hasIp = !!(node.ip || node.ipv6);

      return '<div class="node-card ' + (isOnline ? '' : 'node-offline') + '" id="node-' + node.id + '">' +
        '<div class="node-card-head">' +
          '<div style="min-width:0; flex:1;">' +
            '<div class="title-pill-group">' +
              '<span class="badge badge-primary">#' + node.id + '</span>' +
              '<span class="badge badge-secondary">' + node.location + '</span>' +
              visBadge +
              '<span class="node-title">' + node.name + '</span>' +
            '</div>' +
            (hasIp ? '<div class="node-ip-row">' +
              (node.ip ? '<span class="ip-tag">IPv4</span> <code class="ip-code">' + node.ip + '</code>' : '') +
              (node.ipv6 ? '<span class="ip-tag">IPv6</span> <code class="ip-code">' + node.ipv6 + '</code>' : '') +
            '</div>' : '') +
          '</div>' +
          '<div>' + statusBadge + '</div>' +
        '</div>' +
        '<div class="meta-pills-row">' +
          '<span class="meta-pill">' + node.os + '</span>' +
          '<span class="meta-pill">⏱️ ' + (node.uptime || '刚刚启动') + '</span>' +
          (node.load ? '<span class="meta-pill">负载 ' + node.load + '</span>' : '') +
        '</div>' +
        '<div class="meters-container">' +
          '<div class="meter-box">' +
            '<div class="meter-header"><span>CPU 使用率</span><span class="meter-val"><strong>' + node.cpu.toFixed(1) + '%</strong> <small>(' + node.cpu_cores + '核)</small></span></div>' +
            '<div class="progress-track"><div class="progress-fill ' + getClientBarColor(node.cpu) + '" style="width: ' + Math.min(node.cpu, 100) + '%;"></div></div>' +
            renderClientSparkline(node.cpu_history) +
          '</div>' +
          '<div class="meter-box">' +
            '<div class="meter-header"><span>内存占用 (' + node.mem_percent + '%)</span><span class="meter-val">' + memFormatted + '</span></div>' +
            '<div class="progress-track"><div class="progress-fill ' + getClientBarColor(node.mem_percent) + '" style="width: ' + Math.min(node.mem_percent, 100) + '%;"></div></div>' +
          '</div>' +
          '<div class="meter-box">' +
            '<div class="meter-header"><span>Swap 交换 (' + node.swap_percent + '%)</span><span class="meter-val">' + swapFormatted + '</span></div>' +
            '<div class="progress-track"><div class="progress-fill ' + getClientBarColor(node.swap_percent) + '" style="width: ' + Math.min(node.swap_percent, 100) + '%;"></div></div>' +
          '</div>' +
          '<div class="meter-box">' +
            '<div class="meter-header"><span>系统存储盘 (' + node.disk_percent + '%)</span><span class="meter-val">' + diskFormatted + '</span></div>' +
            '<div class="progress-track"><div class="progress-fill ' + getClientBarColor(node.disk_percent) + '" style="width: ' + Math.min(node.disk_percent, 100) + '%;"></div></div>' +
          '</div>' +
        '</div>' +
        '<div class="net-grid">' +
          '<div class="net-cell"><span class="net-label">⬇ 下行实时速率</span><span class="net-speed">' + node.net_rx_rate + '</span><span class="net-total">累计: ' + node.net_rx_total + '</span></div>' +
          '<div class="net-cell"><span class="net-label">⬆ 上行实时速率</span><span class="net-speed">' + node.net_tx_rate + '</span><span class="net-total">累计: ' + node.net_tx_total + '</span></div>' +
        '</div>' +
        '<div class="node-footer">' +
          '<span class="update-time">上报时间: ' + (node.updated_at_beijing || '-') + '</span>' +
          (isAdmin ? '<button class="btn btn-danger btn-sm" data-id="' + node.id + '" data-name="' + node.name + '" onclick="openDeleteModal(this)" title="下线此服务器">下线节点</button>' : '') +
        '</div>' +
      '</div>';
    }

    function renderClientNodeRow(node, isAdmin, settings) {
      const isOnline = node.is_online;
      const isPublic = node.is_public !== false;
      const statusBadge = isOnline
        ? '<span class="status-chip chip-online"><span class="status-dot"></span>在线</span>'
        : '<span class="status-chip chip-offline"><span class="status-dot dot-offline"></span>离线</span>';

      const visBadge = isAdmin
        ? '<button class="vis-btn ' + (isPublic ? 'vis-public' : 'vis-private') + '" data-id="' + node.id + '" data-public="' + isPublic + '" onclick="toggleNodeVisibility(this)" title="' + (isPublic ? '公开节点' : '隐私节点') + '">' + (isPublic ? '🌍 公开' : '🔒 隐私') + '</button>'
        : '';

      const memFormatted = node.mem_total_mb > 1024
        ? (node.mem_used_mb / 1024).toFixed(1) + 'G / ' + (node.mem_total_mb / 1024).toFixed(1) + 'G'
        : node.mem_used_mb.toFixed(0) + 'M / ' + node.mem_total_mb.toFixed(0) + 'M';

      const diskFormatted = node.disk_used_gb.toFixed(1) + 'G / ' + node.disk_total_gb.toFixed(1) + 'G';
      const ipDisp = (node.ip ? '<code>' + node.ip + '</code>' : '') + (node.ipv6 ? (node.ip ? '<br>' : '') + '<code style="font-size:0.75rem; color:var(--text-muted);">' + node.ipv6 + '</code>' : '') || '<span style="color:var(--text-muted);">-</span>';

      return '<tr id="tbl-row-' + node.id + '">' +
        '<td>' + statusBadge + '</td>' +
        '<td>' +
          '<div style="font-weight:700; display:flex; align-items:center; gap:6px;"><span class="badge badge-primary">#' + node.id + '</span> <strong>' + node.name + '</strong> ' + visBadge + '</div>' +
          '<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">' + node.os + '</div>' +
        '</td>' +
        '<td><span class="badge badge-secondary">' + node.location + '</span></td>' +
        '<td>' + ipDisp + '</td>' +
        '<td><span class="tbl-meter ' + getClientBarColor(node.cpu) + '">' + node.cpu.toFixed(1) + '%</span></td>' +
        '<td><span class="tbl-meter ' + getClientBarColor(node.mem_percent) + '">' + node.mem_percent + '%</span><div style="color:var(--text-muted); font-size:0.72rem; margin-top:2px;">' + memFormatted + '</div></td>' +
        '<td><span class="tbl-meter ' + getClientBarColor(node.disk_percent) + '">' + node.disk_percent + '%</span><div style="color:var(--text-muted); font-size:0.72rem; margin-top:2px;">' + diskFormatted + '</div></td>' +
        '<td><div style="font-family:monospace; font-size:0.8rem;">⬇ ' + node.net_rx_rate + '</div><div style="font-family:monospace; font-size:0.8rem; color:var(--text-muted);">⬆ ' + node.net_tx_rate + '</div></td>' +
        '<td><div>' + (node.uptime || '刚刚启动') + '</div>' + (node.load ? '<div style="color:var(--text-muted); font-size:0.72rem;">' + node.load + '</div>' : '') + '</td>' +
        '<td><small style="font-family:monospace; color:var(--text-muted);">' + ((node.updated_at_beijing || '').split(' ')[1] || '-') + '</small></td>' +
        '<td>' + (isAdmin ? '<button class="btn btn-danger btn-sm" data-id="' + node.id + '" data-name="' + node.name + '" onclick="openDeleteModal(this)">下线</button>' : '<span style="color:var(--text-muted); font-size:0.78rem;">只读</span>') + '</td>' +
      '</tr>';
    }

    function renderClientNodeHeatmap(node, isAdmin) {
      const isOnline = node.is_online;
      const isPublic = node.is_public !== false;
      const statusBadge = isOnline
        ? '<span class="status-chip chip-online"><span class="status-dot"></span>运行中</span>'
        : '<span class="status-chip chip-offline"><span class="status-dot dot-offline"></span>离线</span>';

      const visBadge = isAdmin
        ? '<button class="vis-btn ' + (isPublic ? 'vis-public' : 'vis-private') + '" data-id="' + node.id + '" data-public="' + isPublic + '" onclick="toggleNodeVisibility(this)" title="' + (isPublic ? '公开节点' : '隐私节点') + '">' + (isPublic ? '🌍 公开' : '🔒 隐私') + '</button>'
        : '';

      const targetDays = 30;
      let uptimeDays = 1;
      if (node.uptime) {
        const match = node.uptime.match(/(\\d+)\\s*天/);
        if (match) uptimeDays = parseInt(match[1], 10) || 1;
      }
      uptimeDays = isOnline ? Math.min(30, Math.max(1, uptimeDays)) : 0;

      const colorMap = { 0: 'hm-gray', 1: 'hm-red', 2: 'hm-orange', 3: 'hm-yellow', 4: 'hm-lightgreen', 5: 'hm-darkgreen' };
      const levelName = { 0: '未上线 / 无数据', 1: '严重异常 (CPU>95% 或 离线)', 2: '高负载警告 (CPU>85%)', 3: '轻度警告 (CPU>70%)', 4: '状态良好 (CPU>50%)', 5: '状态极佳 (健康)' };

      const pad = (n) => String(n).padStart(2, '0');
      const now = Date.now() + 8 * 3600 * 1000;

      const rowsHtml = [];
      for (let rowIdx = 0; rowIdx < 3; rowIdx++) {
        const startOffset = 29 - rowIdx * 10;
        const endOffset = 20 - rowIdx * 10;

        const startD = new Date(now - startOffset * 86400 * 1000);
        const endD = new Date(now - endOffset * 86400 * 1000);

        const startMonthDay = pad(startD.getUTCMonth() + 1) + '-' + pad(startD.getUTCDate());
        const endMonthDay = pad(endD.getUTCMonth() + 1) + '-' + pad(endD.getUTCDate());

        const isLatestRow = (rowIdx === 2);
        const rowTag = isLatestRow ? '近 10 天' : (startOffset + 1) + '~' + (endOffset + 1) + ' 天前';
        const rowRange = isLatestRow
          ? startMonthDay + ' ~ ' + endMonthDay + ' (今天 · 实时)'
          : startMonthDay + ' ~ ' + endMonthDay;

        const cells = [];
        for (let d = startOffset; d >= endOffset; d--) {
          const curD = new Date(now - d * 86400 * 1000);
          const labelDate = curD.getUTCFullYear() + '-' + pad(curD.getUTCMonth() + 1) + '-' + pad(curD.getUTCDate());
          const dayNum = pad(curD.getUTCDate());
          const isToday = (d === 0);

          let lvl = 0, desc = '无数据', cpuVal = '0.0%', memVal = '0.0%';
          if (isOnline && d < uptimeDays) {
            lvl = 5;
            if (isToday) {
              if (node.cpu > 95 || node.mem_percent > 95) lvl = 1;
              else if (node.cpu > 85 || node.mem_percent > 90) lvl = 2;
              else if (node.cpu > 70 || node.mem_percent > 80) lvl = 3;
              else if (node.cpu > 50 || node.mem_percent > 70) lvl = 4;
              cpuVal = node.cpu.toFixed(1) + '%';
              memVal = node.mem_percent + '%';
            } else {
              cpuVal = (node.cpu * 0.9 + 0.5).toFixed(1) + '%';
              memVal = node.mem_percent + '%';
            }
            desc = levelName[lvl];
          } else if (isToday) {
            lvl = 1;
            desc = '离线 / 停止上报';
          }

          const cls = colorMap[lvl] || 'hm-gray';
          cells.push('<div class="hm-cell ' + cls + '" data-date="' + labelDate + '" data-status="' + desc + '" data-cpu="' + cpuVal + '" data-mem="' + memVal + '" onmouseenter="showHeatmapTooltip(this, event)" onmouseleave="hideHeatmapTooltip()" onclick="showHeatmapTooltip(this, event)" title="' + labelDate + ' &#10;状态: ' + desc + '"><span class="hm-day-num">' + dayNum + '</span></div>');
        }

        rowsHtml.push('<div class="heatmap-row-wrap ' + (isLatestRow ? 'row-today' : '') + '">' +
          '<div class="heatmap-row-header">' +
            '<span class="hm-row-tag ' + (isLatestRow ? 'today' : '') + '">' + (isLatestRow ? '⚡ ' : '📅 ') + rowTag + '</span>' +
            '<span class="hm-row-range ' + (isLatestRow ? 'today' : '') + '">' + rowRange + '</span>' +
          '</div>' +
          '<div class="heatmap-row-cells">' + cells.join('') + '</div>' +
        '</div>');
      }

      const activeRate = isOnline ? ((uptimeDays / targetDays) * 100).toFixed(1) : '0.0';

      return '<div class="card ppanel-card ' + (isOnline ? '' : 'node-offline') + '" id="node-hm-' + node.id + '">' +
        '<div class="card-header-row">' +
          '<div class="header-left">' +
            '<div class="title-pill-group">' +
              '<span class="badge badge-primary">#' + node.id + '</span>' +
              '<span class="badge badge-secondary">' + node.location + '</span>' +
              visBadge +
              '<h3 class="card-title">' + node.name + '</h3>' +
            '</div>' +
            '<div class="meta-subtext-row">' +
              '<span>⏱️ 在线: <strong>' + (node.uptime || '刚刚启动') + '</strong></span>' +
              '<span>⚡ 30天在线度: <strong style="color:var(--primary);">' + activeRate + '% (' + uptimeDays + '/30天)</strong></span>' +
              '<span>CPU: <strong>' + node.cpu.toFixed(1) + '%</strong></span>' +
              '<span>内存: <strong>' + node.mem_percent + '%</strong></span>' +
            '</div>' +
          '</div>' +
          '<div class="header-right">' + statusBadge + '</div>' +
        '</div>' +
        '<div class="heatmap-3rows-container">' + rowsHtml.join('') + '</div>' +
        '<div class="heatmap-legend-row">' +
          '<span class="legend-label">异常/离线</span>' +
          '<div class="legend-cells">' +
            '<span class="hm-cell hm-gray" title="未上线 / 无数据"></span>' +
            '<span class="hm-cell hm-red" title="严重异常 / 离线"></span>' +
            '<span class="hm-cell hm-orange" title="高负载 (85%~95%)"></span>' +
            '<span class="hm-cell hm-yellow" title="轻度警告 (70%~85%)"></span>' +
            '<span class="hm-cell hm-lightgreen" title="状态良好 (50%~70%)"></span>' +
            '<span class="hm-cell hm-darkgreen" title="状态极佳 (健康)"></span>' +
          '</div>' +
          '<span class="legend-label">健康在线</span>' +
          (isAdmin ? '<div style="margin-left:auto;"><button class="btn btn-danger btn-sm" data-id="' + node.id + '" data-name="' + node.name + '" onclick="openDeleteModal(this)">下线节点</button></div>' : '') +
        '</div>' +
      '</div>';
    }

    function updateClusterUI(data) {
      if (!data) return;
      const nodes = data.nodes || [];
      const onlineNodes = nodes.filter(n => n.is_online);
      const onlineCount = onlineNodes.length;
      const totalCount = nodes.length;
      const onlineRate = totalCount > 0 ? ((onlineCount / totalCount) * 100).toFixed(1) : '100.0';
      const avgCpu = onlineCount > 0 ? (onlineNodes.reduce((sum, n) => sum + (parseFloat(n.cpu) || 0), 0) / onlineCount).toFixed(1) : '0.0';
      const avgMem = onlineCount > 0 ? (onlineNodes.reduce((sum, n) => sum + (parseFloat(n.mem_percent) || 0), 0) / onlineCount).toFixed(1) : '0.0';

      const sOnline = document.getElementById('statOnline');
      if (sOnline) sOnline.innerHTML = onlineCount + ' <small style="font-size:0.75rem; color:var(--text-muted);">/ ' + totalCount + ' 台</small>';
      const sOnlineRate = document.getElementById('statOnlineRate');
      if (sOnlineRate) sOnlineRate.innerText = '在线可用率: ' + onlineRate + '%';
      const sCpu = document.getElementById('statCpu');
      if (sCpu) sCpu.innerText = avgCpu + '%';
      const sMem = document.getElementById('statMem');
      if (sMem) sMem.innerText = avgMem + '%';
      const sNet = document.getElementById('statNet');
      if (sNet && onlineNodes.length > 0) sNet.innerHTML = '⬇ ' + onlineNodes[0].net_rx_rate;
      const sNetTx = document.getElementById('statNetTx');
      if (sNetTx && onlineNodes.length > 0) sNetTx.innerText = '⬆ ' + onlineNodes[0].net_tx_rate;

      const grouped = {};
      nodes.forEach(n => {
        const grp = n.group || '默认集群';
        if (!grouped[grp]) grouped[grp] = { nodes: [] };
        grouped[grp].nodes.push(n);
      });

      Object.keys(grouped).forEach((grpName, gIdx) => {
        const grp = grouped[grpName];
        const grpOnline = grp.nodes.filter(n => n.is_online).length;
        const grpBadge = document.querySelector('#group-sec-' + gIdx + ' .badge-primary');
        if (grpBadge) grpBadge.innerText = grpOnline + ' / ' + grp.nodes.length + ' 在线';

        const detailedGrid = document.getElementById('grid-detailed-' + gIdx);
        const heatmapGrid = document.getElementById('grid-heatmap-' + gIdx);
        const tableTbody = document.querySelector('#grid-table-' + gIdx + ' tbody');

        if (detailedGrid) detailedGrid.innerHTML = grp.nodes.map(n => renderClientNodeCard(n, IS_ADMIN, CURRENT_SETTINGS)).join('');
        if (heatmapGrid) heatmapGrid.innerHTML = grp.nodes.map(n => renderClientNodeHeatmap(n, IS_ADMIN)).join('');
        if (tableTbody) tableTbody.innerHTML = grp.nodes.map(n => renderClientNodeRow(n, IS_ADMIN, CURRENT_SETTINGS)).join('');
      });
    }

    setInterval(async () => {
      try {
        const res = await fetch('/api/status');
        if (!res.ok) return;
        const data = await res.json();
        updateClusterUI(data);
      } catch (e) {}
    }, 5000);

    document.addEventListener('DOMContentLoaded', () => {
      const savedMode = localStorage.getItem('probe_view_mode');
      if (savedMode && ['detailed', 'heatmap', 'table'].includes(savedMode)) {
        switchView(savedMode);
      }
    });

    const COLOR_NAMES = {
      aurora: '极光青',
      titanium: '钛金蓝',
      violet: '紫晶光',
      emerald: '森林绿',
      amber: '琥珀金',
      crimson: '落樱嫩粉'
    };

    function setThemeColor(c) {
      document.documentElement.setAttribute('data-theme-color', c);
      localStorage.setItem('example_theme_color', c);
      updateThemeColorUI(c);
      closePaletteMenu();
    }

    function updateThemeColorUI(c) {
      const name = COLOR_NAMES[c] || '极光青';
      const curName = document.getElementById('curColorName');
      if (curName) curName.innerText = name;

      document.querySelectorAll('.color-opt-btn').forEach(btn => {
        if (btn.getAttribute('data-color') === c) btn.classList.add('active');
        else btn.classList.remove('active');
      });
    }

    function togglePaletteMenu(e) {
      e.stopPropagation();
      document.getElementById('paletteMenu')?.classList.toggle('open');
    }

    function closePaletteMenu() {
      document.getElementById('paletteMenu')?.classList.remove('open');
    }

    document.addEventListener('click', (e) => {
      if (!e.target.closest('#paletteDropdown')) {
        closePaletteMenu();
      }
    });

    (function initColor() {
      const saved = localStorage.getItem('example_theme_color') || 'aurora';
      document.documentElement.setAttribute('data-theme-color', saved);
      updateThemeColorUI(saved);
    })();

  </script>
</body>
</html>
`;

  return new Response(html, {
    headers: {
      'Content-Type': 'text/html; charset=utf-8',
      'Cache-Control': 'no-cache, no-store, must-revalidate'
    }
  });
}
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const path = url.pathname;

    if (path === '/favicon.ico' || path === '/favicon.svg') {
      return new Response(FAVICON_SVG, {
        headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' }
      });
    }

    if (request.method === 'OPTIONS') {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Admin-Key, X-Token'
        }
      });
    }

    if (path === '/health' || path === '/api/health') {
      return jsonResponse({ status: 'healthy', time: new Date().toISOString() });
    }

    if (path === '/i' || path === '/install' || path === '/agent.sh') {
      return await handleInstallScript(request, url, env);
    }

    if (path === '/u' || path === '/uninstall') {
      return await handleUninstallScript(request, url, env);
    }

    if (path === '/report' || path === '/api/report') {
      return await handleReport(request, url, env);
    }

    // ---------- Node Visibility Toggle API ----------
    if (path === '/api/node-visibility' || path === '/api/node/visibility') {
      const isAdmin = await verifyAdmin(request, env);
      if (!isAdmin) return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 401);
      const body = await request.json().catch(() => ({}));
      const nodeId = String(body.id || '');
      if (!nodeId) return jsonResponse({ status: 'fail', error: '缺少节点 id 参数' }, 400);
      const node = await Storage.getNode(env, nodeId);
      if (!node) return jsonResponse({ status: 'fail', error: '节点不存在' }, 404);
      node.is_public = body.is_public !== undefined ? !!body.is_public : !node.is_public;
      await Storage.saveNode(env, nodeId, node);
      return jsonResponse({
        status: 'success',
        is_public: node.is_public,
        message: `节点 #${nodeId} 已成功设置为 ${node.is_public ? '【🌍 公开可见】' : '【🔒 仅管理员可见 (隐私内部节点)】'}`
      });
    }

    // ---------- Anti-Brute-Force Protected Auth ----------
    if (path === '/api/auth' || path === '/api/login') {
      const clientIp = (request.headers.get('cf-connecting-ip') || request.headers.get('x-real-ip') || '127.0.0.1').trim();
      const now = Math.floor(Date.now() / 1000);

      const body = await request.json().catch(() => ({}));
      const adminKey = (env.ADMIN_KEY || DEFAULT_ADMIN_KEY || '').trim();
      const inputKey = (body.key || body.admin_key || body.password || '').trim();

      if (inputKey && (inputKey === adminKey || await timingSafeEqual(inputKey, adminKey))) {
        await Storage.clearAuthLock(env, clientIp);
        const headers = new Headers({
          'Content-Type': 'application/json; charset=utf-8',
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Admin-Key'
        });
        headers.set('Set-Cookie', `server_admin_key=${encodeURIComponent(adminKey)}; Path=/; Max-Age=2592000; Secure; SameSite=Lax`);
        return new Response(JSON.stringify({ status: 'success', message: '认证成功' }), { headers });
      }

      const lock = await Storage.getAuthLock(env, clientIp);
      if (lock && lock.blocked_until > now) {
        const remainMin = Math.ceil((lock.blocked_until - now) / 60);
        return jsonResponse({
          status: 'fail',
          error: `密码连续错误过多,该 IP 已被系统封锁。请在 ${remainMin} 分钟后再试`
        }, 429);
      }

      // Record failure & introduce 1s slowdown
      const updatedLock = await Storage.recordAuthFail(env, clientIp);
      await new Promise(r => setTimeout(r, 1000));

      if (updatedLock.blocked_until > now) {
        const lockMin = Math.ceil((updatedLock.blocked_until - now) / 60);
        return jsonResponse({
          status: 'fail',
          error: `密码错误达 ${updatedLock.fail_count} 次,该 IP 已被锁定 ${lockMin} 分钟`
        }, 429);
      }

      const remainTries = Math.max(0, 5 - updatedLock.fail_count);
      return jsonResponse({
        status: 'fail',
        error: `管理员密码错误 (还剩 ${remainTries} 次尝试机会)`
      }, 401);
    }

    if (path === '/api/logout') {
      const headers = new Headers({
        'Content-Type': 'application/json; charset=utf-8',
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Admin-Key'
      });
      headers.set('Set-Cookie', 'server_admin_key=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax');
      return new Response(JSON.stringify({ status: 'success', message: '已退出登录' }), { headers });
    }

    // ---------- Settings API ----------
    if (path === '/api/settings') {
      const isAdmin = await verifyAdmin(request, env);
      if (!isAdmin) return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 401);

      if (request.method === 'POST') {
        const body = await request.json().catch(() => ({}));
        await Storage.saveSettings(env, body);
        return jsonResponse({ status: 'success', message: '系统配置已保存' });
      } else {
        const settings = await Storage.getSettings(env);
        return jsonResponse({ status: 'success', settings });
      }
    }

    if (path === '/api/test-alert') {
      const isAdmin = await verifyAdmin(request, env);
      if (!isAdmin) return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 401);

      const body = await request.json().catch(() => ({}));
      const webhook = (body.webhook || '').trim();
      if (!webhook) return jsonResponse({ status: 'fail', error: '缺少 webhook 参数' }, 400);

      try {
        const testRes = await fetch(webhook, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0' },
          body: JSON.stringify({
            title: '🔔 集群探针 Webhook 测试推送',
            content: '这是一条来自探针系统的测试告警通知,说明告警通道配置正常!',
            message: '这是一条来自探针系统的测试告警通知,说明告警通道配置正常!',
            text: '【探针测试告警】通道连通性测试成功!',
            time: new Date().toISOString()
          })
        });
        if (testRes.ok) {
          return jsonResponse({ status: 'success', message: '测试告警推送成功' });
        } else {
          return jsonResponse({ status: 'fail', error: `Webhook 响应 HTTP ${testRes.status}` }, 400);
        }
      } catch (e) {
        return jsonResponse({ status: 'fail', error: e.message }, 500);
      }
    }

    // ---------- Multi-Token API ----------
    if (path === '/api/tokens') {
      const isAdmin = await verifyAdmin(request, env);
      if (!isAdmin) return jsonResponse({ status: 'fail', error: '未授权:请先登录管理员' }, 401);

      if (request.method === 'POST') {
        const body = await request.json().catch(() => ({}));
        const created = await Storage.createToken(env, body.name, body.token);
        return jsonResponse({ status: 'success', token: created });
      } else if (request.method === 'PUT') {
        const body = await request.json().catch(() => ({}));
        if (!body.id || !body.token) {
          return jsonResponse({ status: 'fail', error: '缺少必填字段 (id, token)' }, 400);
        }
        const ok = await Storage.updateToken(env, body.id, body.name, body.token);
        if (ok) return jsonResponse({ status: 'success', message: 'Token 更新成功' });
        return jsonResponse({ status: 'fail', error: '更新失败' }, 400);
      } else if (request.method === 'PATCH') {
        const id = url.searchParams.get('id');
        if (!id) return jsonResponse({ status: 'fail', error: '缺少 token id' }, 400);
        const newStatus = await Storage.toggleTokenStatus(env, id);
        if (newStatus) return jsonResponse({ status: 'success', new_status: newStatus, message: `Token 状态已切换为 ${newStatus}` });
        return jsonResponse({ status: 'fail', error: '操作失败或不可修改默认 Token' }, 400);
      } else if (request.method === 'DELETE') {
        const id = url.searchParams.get('id');
        if (!id) return jsonResponse({ status: 'fail', error: '缺少 token id' }, 400);
        const ok = await Storage.deleteToken(env, id);
        if (ok) return jsonResponse({ status: 'success', message: 'Token 已成功吊销删除' });
        return jsonResponse({ status: 'fail', error: '删除失败或不可删除默认 Token' }, 400);
      } else {
        const tokens = await Storage.getAllTokens(env);
        return jsonResponse({ status: 'success', tokens });
      }
    }

    if (path === '/delete' || path === '/api/delete' || path === '/remove') {
      return await handleDeleteNode(request, url, env);
    }

    if (path === '/api/status') {
      const isAdmin = await verifyAdmin(request, env);
      const status = await getClusterStatus(env, isAdmin);
      return jsonResponse(status);
    }

    if (path === '/' || path === '/index.html') {
      return await handleDashboard(request, env);
    }

    return Response.redirect(`${url.origin}/`, 302);
  }
};

总结

  • 方案一(Cloudflare Serverless + D1):适合绝大多数用户,0 运维成本,10 万次/天免费写入,全球 CDN 加速;
  • 方案二(VPS 本机物理架构):适合企业专网、内网集群或希望完全掌控数据的团队,纯标准库无依赖,开箱即用!

推荐阅读