轻量服务器探针监控系统
在日常管理多台 VPS 与云服务器时,掌握各节点的 CPU 使用率、内存占用、Swap 状态、磁盘空间、实时网络带宽、系统负载、双栈 IP 与存活状态 至关重要。
市面上常见的探针方案(如哪吒探针 Nezha、ServerStatus、Uptime Kuma 等)通常需要在主控 VPS 上运行 Docker 容器、管理 MySQL/SQLite 数据库并开放 gRPC/WebSocket 端口,不仅占用 150M~300M 内存,还存在主控服务器宕机导致监控全线失联的问题。
为了满足不同网络与业务场景的需求,本文提供 两种完整且经过生产检验的自建方案:
- 方案一:Cloudflare Serverless 极速部署(推荐):基于 Cloudflare Worker + D1 SQL 数据库,0 服务器常驻、0 内存消耗、全球边缘秒级加载,且享有 每日 100,000 次免费写入,彻底告别 KV 每日 1,000 次的限额焦虑。
- 方案二: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 层工业级稳定性防护:
- Nginx 前置缓冲与反代:所有 TCP 连接、SSL 握手与静态资产请求均由 Nginx 处理,Nginx 作为反代缓冲,后端 Python 仅需处理纯净的本地 HTTP 请求;
- 多线程并发模型(ThreadingHTTPServer):当多台机器偶然在同一秒发起请求时,服务端自动分发至工作线程并发处理,杜绝单线程排队阻塞;
- SQLite WAL 预写日志模式(Write-Ahead Logging):SQLite 开启 WAL 模式后,读与写完全分离、互不加锁阻塞,支持高频原子写入,彻底消除数据库锁死(Database is locked)异常。
方案一:Cloudflare Serverless 部署(推荐)
该方案将探针完全托管在 Cloudflare 全球边缘网络,永久免费且无需维护任何物理服务器。
第一步:创建 D1 数据库
- 登录 Cloudflare Dashboard;
- 进入 Workers & Pages -> D1 SQL Database;
- 点击 Create Database(创建数据库),名称填写
probe_db,点击创建。
(注:代码内置 Dual-Engine 双引擎适配器,若绑定 KV 命名空间 STATUS_KV 亦可无缝向下兼容运行)
第二步:创建 Worker 并绑定 D1
- 进入 Workers & Pages -> Overview,点击 Create Application -> Create Worker;
- 命名为
server-status,点击 Deploy 完成初始创建; - 进入该 Worker 的 Settings -> Bindings(变量与绑定):
- 点击 Add -> D1 Database Binding;
- Variable name(变量名)填写:
DB; - D1 database 选择刚才创建的
probe_db;
- 进入 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 种模式)
-
模式一(被控端一键彻底卸载 · 推荐): 在 Linux 服务器终端直接执行卸载命令:
curl -sSL https://x.example.com/u | bash💡 卸载脚本停止并清理本地程序与服务时,会自动提取本地服务配置中保存的 Token 与节点 ID,并向云端发起注销请求,本地与云端看板卡片同步秒级清空。
-
模式二(云端远程下线 · 免登服务器): 在 Web 看板管理面板直接点击卡片上的「下线节点」。云端标记退役墓碑后,被控端探针在下次心跳上报时收到指令,将自动在后台执行自我销毁与文件清理。
-
模式三(直接关机 / 平台重装 · 离线自动告警): 若服务器突发故障断电或在云平台直接重装系统,探针超时未上报后云端自动判定为「🔴 离线」,并自动向企业微信/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} 状态: ${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 < #2 < #3 - 推荐)</option>
<option value="numeric_desc" ${settings.sort_mode === 'numeric_desc' ? 'selected' : ''}>#ID 数字从大到小降序 (#3 > #2 > #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 + ' 状态: ' + 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 本机物理架构):适合企业专网、内网集群或希望完全掌控数据的团队,纯标准库无依赖,开箱即用!