利用Cloudflare Workers 搭建永久免费VPS 监控面板
前言
VPS 监控面板是一个部署在 Cloudflare Workers 上的简单 VPS 监控面板,使用 Cloudflare D1 数据库存储数据。本指南将引导你通过 Cloudflare 网页控制面板 完成部署,无需使用命令行工具。
PC端前台:

项目地址:https://github.com/kadidalax/cf-vps-monitor
部署步骤
1. 创建 D1 数据库
你需要一个 D1 数据库来存储面板数据(服务器列表、API 密钥、监控数据等)。
- 登录 Cloudflare 控制面板。
- 在左侧菜单中,找到并点击
存储和数据库。 - 在下拉菜单中,选择
D1 SQL 数据库。 - 点击
创建数据库。 - 为数据库命名(例如
vps-monitor-db),然后点击创建。
2. 创建并配置 Worker
接下来,创建 Worker 并将代码部署上去。
- 在左侧菜单中,点击
计算(Workers),选择Workers & Pages。 - 在概览页面,点击
创建。 - 选择
Start with Hello World!点击开始使用。 - 为你的 Worker 命名(例如
vps-monitor-worker),确保名称可用。 - 点击
部署。 - 部署完成后,点击
编辑代码进入 Worker 编辑器。 - 删除编辑器中现有的所有代码 。
- 打开本仓库的
worker.js文件,复制其全部内容。 - 将复制的代码粘贴到 Cloudflare Worker 编辑器中。
- 点击编辑器右上角的
部署按钮。
3. 添加环境变量
在 设置 → 变量和机密 中添加以下环境变量,以增加安全性:
- 变量名:
JWT_SECRET,类型:密钥, 值:任意30位左右的随机字符串 - 添加完保存并部署
4. 绑定 D1 数据库到 Worker
Worker 需要访问你之前创建的 D1 数据库。
- 在 Worker 的管理页面(编辑代码页面上方有 Worker 名称,点击它可以返回管理页面),选择
绑定标签页。 - 选择
D1数据库。 - 在
变量名称处输入DB(必须大写)。 - 在
D1 数据库下拉菜单中,选择你之前创建的数据库 (例如vps-monitor-db)。 - 点击
部署。 - 重要!初始化数据库: 复制你的Worker URL到浏览器,后面加上
/api/init-db,如vps-monitor.abo-vendor289.workers.dev/api/init-db,打开此链接后会看到{"success":true,"message":"数据库初始化完成"}即表明数据库已准备完毕。
5. 设置触发频率(检测网站用)
- 在 Worker 的管理页面选择
设置标签页。 - 在设置页面中,选择
触发事件子菜单。 - 点击
添加,选择Cron触发器。 - 选择
计划,执行 Worker 的频率选择小时,下面的框填入1(即每整点检测一次网站)。 - 点击
添加。
6. 访问面板
部署和绑定完成后,你的监控面板应该可以通过 Worker 的 URL 访问了。
- 在设置页面你会看到一个
.workers.dev的 URL,例如vps-monitor.abo-vendor289.workers.dev。 - 在浏览器中打开这个 URL,你应该能看到监控面板的前端界面。
使用面板
1. 初始登录
- 访问你的 Worker URL。
- 点击页面右上角的
登录或直接访问/login路径 (例如https://vps-monitor.abo-vendor289.workers.dev/login)。 - 使用凭据登录:
- 用户名:
admin - 密码:
monitor2025!
- 用户名:
- 登录后,立即修改密码!立即修改密码!立即修改密码!!!
2. 添加服务器
- 登录后台后,你应该会看到管理界面。
- 找到添加服务器的选项。
- 输入服务器的名称和可选的描述。
- 点击
保存。 - 面板会自动生成一个唯一的
服务器 ID和API 密钥,后台可以随时查看,部署 Agent 时需要用到。
3. 部署 Agent (探针)
Agent 是一个需要在你的 VPS 上运行的脚本,用于收集状态信息并发送回面板。
有两种方式安装Agent脚本:
第一种是直接从后台复制带有参数的命令一键安装(推荐)

第二种是:下载脚本并运行:
wget -O cf-vps-monitor.sh https://raw.githubusercontent.com/kadidalax/cf-vps-monitor/main/cf-vps-monitor.sh && chmod +x cf-vps-monitor.sh && ./cf-vps-monitor.sh
或者下载脚本并运行:
curl -O https://raw.githubusercontent.com/kadidalax/cf-vps-monitor/main/cf-vps-monitor.sh && chmod +x cf-vps-monitor.sh && ./cf-vps-monitor.sh
- 安装需要
服务器IDAPI密钥和你的worker网址 - 可以在后台点击
查看密钥来获取上述三个参数 - 按照提示输入安装完成后,Agent 会开始定期向你的面板发送数据。你应该能在面板上看到对应服务器的状态更新。
4. Agent 管理
安装脚本本身也提供了管理功能:
- 安装服务:
- 卸载服务:
- 查看状态:
- 查看日志:
- 停止服务:
- 重启服务:
- 修改配置:
5. 添加检测网站
- 登录后台后,你应该会看到管理界面。
- 点击
添加监控网站。 - 输入
网站名称(可选)和网站URL 如(https://example.com)。 - 点击
保存。
6. 配置Telegram 通知
- BotFather创建bot并获取
Bot Token。 @userinfobot获取自己的ID。- 将上述两项分别填入。
- 启用通知,点击
保存Telegram设置后会受到一条测试通知,说明配置正确。
7. 配置自定义背景和透明度
- 找一张好看的背景图。
- 上传到图床,得到该图的链接(如:https://i.111666.best/image/QbF51RYyzcHFTBnOhICxdY.jpg )
- 将此链接填入背景图片URL框,并勾选
启用自定义背景。 - 调整
面透明度滑块。 - 点击
保存背景设置
注意事项
- Worker 和 D1 每日配额: 本项目当前最大的限制是Worker请求数,主要是vps上报数据的消耗,每日请求数可以用这个公式计算:vps数量 *(86400/上报频率),得到的数字再除以100000就是已消耗百分比。
- 安全性: 默认密码非常不安全 ,请务必在首次登录后修改。Agent 使用的 API 密钥也应妥善保管。
- 错误处理: 如果面板或 Agent 遇到问题,可以检查 Worker 的日志(在 Cloudflare 控制面板 Worker 页面)和 Agent 的日志。
- 以上所有内容和代码均为AI生成,出现问题请直接拿着代码找AI吧。
最后附上workers代码
// VPS监控面板 - Cloudflare Worker解决方案
// 版本: 1.1.0
// ==================== 配置常量 ====================
// 默认管理员账户配置
const DEFAULT_ADMIN_CONFIG = {
USERNAME: 'admin',
PASSWORD: 'monitor2025!',
};
// 安全配置 - 增强验证
function getSecurityConfig(env) {
// 验证关键安全配置
if (!env.JWT_SECRET || env.JWT_SECRET === 'default-jwt-secret-please-set-in-worker-variables') {
throw new Error('JWT_SECRET must be set in environment variables for security');
}
return {
JWT_SECRET: env.JWT_SECRET,
TOKEN_EXPIRY: 2 * 60 * 60 * 1000, // 2小时
MAX_LOGIN_ATTEMPTS: 5,
LOGIN_ATTEMPT_WINDOW: 15 * 60 * 1000, // 15分钟
API_RATE_LIMIT: 60, // 每分钟60次
MIN_PASSWORD_LENGTH: 8,
ALLOWED_ORIGINS: env.ALLOWED_ORIGINS ? env.ALLOWED_ORIGINS.split(',').map(o => o.trim()) : [],
};
}
// ==================== 全局存储 ====================
const rateLimitStore = new Map();
const loginAttemptStore = new Map();
// VPS数据批量处理器
class VpsBatchProcessor {
constructor() {
this.batchBuffer = [];
this.lastBatch = Math.floor(Date.now() / 1000);
this.maxBatchSize = 100; // 最大批量大小
}
// 添加VPS上报数据到批量缓冲区
addReport(serverId, reportData, batchInterval) {
this.batchBuffer.push({
serverId,
timestamp: reportData.timestamp,
cpu: JSON.stringify(reportData.cpu),
memory: JSON.stringify(reportData.memory),
disk: JSON.stringify(reportData.disk),
network: JSON.stringify(reportData.network),
uptime: reportData.uptime
});
// 检查是否需要立即刷新(时间到或缓冲区满)
const now = Math.floor(Date.now() / 1000);
if (now - this.lastBatch >= batchInterval || this.batchBuffer.length >= this.maxBatchSize) {
return true; // 需要刷新
}
return false;
}
// 获取并清空批量数据
getBatchData() {
const data = [...this.batchBuffer];
this.batchBuffer = [];
this.lastBatch = Math.floor(Date.now() / 1000);
return data;
}
// 检查是否需要定时刷新
shouldFlush(batchInterval) {
const now = Math.floor(Date.now() / 1000);
return this.batchBuffer.length > 0 && (now - this.lastBatch >= batchInterval);
}
}
// 全局批量处理器实例
const vpsBatchProcessor = new VpsBatchProcessor();
// 批量写入VPS数据到数据库
async function flushVpsBatchData(env) {
const batchData = vpsBatchProcessor.getBatchData();
if (batchData.length === 0) return;
try {
// 使用D1的batch操作进行批量写入
const statements = batchData.map(report =>
env.DB.prepare(`
REPLACE INTO metrics (server_id, timestamp, cpu, memory, disk, network, uptime)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(
report.serverId,
report.timestamp,
report.cpu,
report.memory,
report.disk,
report.network,
report.uptime
)
);
await env.DB.batch(statements);
console.log(`批量写入${batchData.length}条VPS数据`);
} catch (error) {
console.error('批量写入VPS数据失败:', error);
// 如果批量写入失败,将数据重新加入缓冲区
vpsBatchProcessor.batchBuffer.unshift(...batchData);
throw error;
}
}
// 定时刷新VPS批量数据(在主请求处理中调用)
async function scheduleVpsBatchFlush(env, ctx) {
try {
const batchInterval = await getVpsReportInterval(env);
if (vpsBatchProcessor.shouldFlush(batchInterval)) {
ctx.waitUntil(flushVpsBatchData(env));
}
} catch (error) {
// 使用默认间隔60秒
if (vpsBatchProcessor.shouldFlush(60)) {
ctx.waitUntil(flushVpsBatchData(env));
}
}
}
// ==================== 配置缓存系统 ====================
class ConfigCache {
constructor() {
this.cache = new Map();
this.CACHE_TTL = {
TELEGRAM: 5 * 60 * 1000, // 5分钟
MONITORING: 5 * 60 * 1000, // 5分钟
SERVERS: 2 * 60 * 1000 // 2分钟
};
}
set(key, value, ttl) {
this.cache.set(key, {
value,
timestamp: Date.now(),
ttl
});
}
get(key) {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.value;
}
async getTelegramConfig(db) {
const cached = this.get('telegram_config');
if (cached) return cached;
const config = await db.prepare(
'SELECT bot_token, chat_id, enable_notifications FROM telegram_config WHERE id = 1'
).first();
if (config) {
this.set('telegram_config', config, this.CACHE_TTL.TELEGRAM);
}
return config;
}
async getMonitoringSettings(db) {
const cached = this.get('monitoring_settings');
if (cached) return cached;
const settings = await db.prepare(
'SELECT * FROM app_config WHERE key IN ("vps_report_interval", "site_check_interval")'
).all();
if (settings?.results) {
this.set('monitoring_settings', settings.results, this.CACHE_TTL.MONITORING);
return settings.results;
}
return [];
}
async getServerList(db, isAdmin = false) {
const cacheKey = isAdmin ? 'servers_admin' : 'servers_public';
const cached = this.get(cacheKey);
if (cached) return cached;
let query = 'SELECT id, name, description FROM servers';
if (!isAdmin) {
query += ' WHERE is_public = 1';
}
query += ' ORDER BY sort_order ASC NULLS LAST, name ASC';
const { results } = await db.prepare(query).all();
const servers = results || [];
this.set(cacheKey, servers, this.CACHE_TTL.SERVERS);
return servers;
}
clear() {
this.cache.clear();
}
clearKey(key) {
this.cache.delete(key);
}
}
// 全局配置缓存实例
const configCache = new ConfigCache();
// ==================== 定时任务优化 ====================
// 任务执行计数器
let taskCounter = 0;
let dbInitialized = false;
// ==================== 工具函数 ====================
// SQL安全验证 - 防止注入攻击
function validateSqlIdentifier(value, type) {
const whitelist = {
column: ['id', 'name', 'url', 'description', 'sort_order', 'is_public', 'last_checked', 'last_status', 'timestamp', 'cpu', 'memory', 'disk', 'network', 'uptime'],
table: ['servers', 'monitored_sites', 'metrics', 'site_status_history'],
order: ['ASC', 'DESC']
};
const allowed = whitelist[type];
if (!allowed || !allowed.includes(value)) {
throw new Error(`Invalid ${type}: ${value}`);
}
return value;
}
// 敏感信息脱敏
function maskSensitive(value, type = 'key') {
if (!value || typeof value !== 'string') return value;
return type === 'key' && value.length > 8 ? value.substring(0, 8) + '***' : '***';
}
// 增强的令牌撤销机制 - 修复JWT缓存安全问题
const revokedTokens = new Map(); // 改为Map存储撤销时间
function revokeToken(token) {
revokedTokens.set(token, Date.now());
// 清理JWT缓存中的对应令牌
jwtCache.delete(token);
// 定期清理过期的撤销记录(24小时后清理)
if (Math.random() < 0.01) {
const expireTime = Date.now() - 24 * 60 * 60 * 1000;
for (const [revokedToken, revokeTime] of revokedTokens.entries()) {
if (revokeTime < expireTime) {
revokedTokens.delete(revokedToken);
}
}
}
}
function isTokenRevoked(token) {
return revokedTokens.has(token);
}
// 安全的JSON解析 - 限制大小
async function parseJsonSafely(request, maxSize = 1024 * 1024) {
const contentLength = request.headers.get('content-length');
if (contentLength && parseInt(contentLength) > maxSize) {
throw new Error('Request body too large');
}
const text = await request.text();
if (text.length > maxSize) {
throw new Error('Request body too large');
}
return JSON.parse(text);
}
// 增强的管理员认证 - 修复权限检查问题
async function authenticateAdmin(request, env) {
const user = await authenticateRequest(request, env);
if (!user) return null;
// 验证用户确实存在于管理员表中且未被锁定
const adminUser = await env.DB.prepare(
'SELECT username, locked_until FROM admin_credentials WHERE username = ?'
).bind(user.username).first();
if (!adminUser || (adminUser.locked_until && Date.now() < adminUser.locked_until)) {
return null;
}
return user;
}
// 严格的管理员权限检查装饰器
function requireAdmin(handler) {
return async (request, env, corsHeaders, ...args) => {
const user = await authenticateAdmin(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
return handler(request, env, corsHeaders, user, ...args);
};
}
// 路径参数验证
function extractPathSegment(path, index) {
const segments = path.split('/');
// 支持负数索引(从末尾开始)
if (index < 0) {
index = segments.length + index;
}
if (index < 0 || index >= segments.length) return null;
const segment = segments[index];
return segment && /^[a-zA-Z0-9_-]{1,50}$/.test(segment) ? segment : null;
}
// 提取服务器ID的便捷函数
function extractAndValidateServerId(path) {
return extractPathSegment(path, -1);
}
// 增强的输入验证 - 修复SSRF漏洞
function validateInput(input, type, maxLength = 255) {
if (!input || typeof input !== 'string' || input.length > maxLength) {
return false;
}
const cleaned = input.trim();
const validators = {
serverName: () => {
if (!/^[\w\s\u4e00-\u9fa5.-]{2,50}$/.test(cleaned)) return false;
const sqlKeywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'SCRIPT', 'UNION', 'OR', 'AND'];
return !sqlKeywords.some(keyword => cleaned.toUpperCase().includes(keyword));
},
description: () => {
if (cleaned.length > 500) return false;
return !/<[^>]*>|javascript:|on\w+\s*=|<script/i.test(cleaned);
},
direction: () => ['up', 'down'].includes(input),
url: () => {
try {
const url = new URL(input);
if (!['http:', 'https:'].includes(url.protocol)) return false;
// 增强的内网地址检查 - 修复SSRF
const hostname = url.hostname.toLowerCase();
// IPv4内网检查
if (hostname === 'localhost' || hostname === '0.0.0.0' ||
hostname.startsWith('127.') || hostname.startsWith('10.') ||
hostname.startsWith('192.168.') || hostname.startsWith('169.254.') ||
(hostname.startsWith('172.') &&
parseInt(hostname.split('.')[1]) >= 16 &&
parseInt(hostname.split('.')[1]) <= 31)) {
return false;
}
// IPv6内网检查 - 修复方括号处理
if (hostname.includes(':')) {
// 移除方括号(如果存在)
const cleanHostname = hostname.replace(/^\[|\]$/g, '');
if (cleanHostname === '::1' || cleanHostname.startsWith('fc') ||
cleanHostname.startsWith('fd') || cleanHostname.startsWith('fe80')) {
return false;
}
}
// 域名黑名单检查
const blockedDomains = ['internal', 'local', 'intranet', 'corp'];
if (blockedDomains.some(domain => hostname.includes(domain))) {
return false;
}
// 端口限制 - 只允许标准HTTP/HTTPS端口
const port = url.port;
if (port && !['80', '443', '8080', '8443'].includes(port)) {
return false;
}
return input.length <= 2048;
} catch {
return false;
}
}
};
return validators[type] ? validators[type]() : cleaned.length > 0;
}
// ==================== 统一响应处理工具 ====================
// 创建标准API响应
function createApiResponse(data, status = 200, corsHeaders = {}) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 创建错误响应
function createErrorResponse(error, message, status = 500, corsHeaders = {}, details = null) {
const errorData = {
error,
message,
timestamp: Date.now()
};
if (details) errorData.details = details;
return createApiResponse(errorData, status, corsHeaders);
}
// 创建成功响应
function createSuccessResponse(data, corsHeaders = {}) {
return createApiResponse({ success: true, ...data }, 200, corsHeaders);
}
// ==================== 统一验证工具 ====================
// 获取Telegram配置(已移至ConfigCache类)
// 服务器认证验证
async function validateServerAuth(path, request, env) {
const serverId = extractAndValidateServerId(path);
if (!serverId) {
return { error: 'Invalid server ID', message: '无效的服务器ID格式' };
}
const apiKey = request.headers.get('X-API-Key');
if (!apiKey) {
return { error: 'API key required', message: '需要API密钥' };
}
try {
const serverData = await env.DB.prepare(
'SELECT id, name, api_key FROM servers WHERE id = ?'
).bind(serverId).first();
if (!serverData || serverData.api_key !== apiKey) {
return { error: 'Invalid credentials', message: '无效的服务器ID或API密钥' };
}
return { success: true, serverId, serverData };
} catch (error) {
return { error: 'Database error', message: '数据库查询失败' };
}
}
// ==================== 统一数据库错误处理 ====================
function handleDbError(error, corsHeaders, operation = 'database operation') {
if (error.message.includes('no such table')) {
return createErrorResponse(
'Database table missing',
'数据库表不存在,请重试',
503,
corsHeaders
);
}
return createErrorResponse(
'Internal server error',
'系统暂时不可用,请稍后重试',
500,
corsHeaders
);
}
// ==================== 缓存查询工具 ====================
// VPS上报间隔缓存
let vpsIntervalCache = {
value: null,
timestamp: 0,
ttl: 60000 // 1分钟缓存
};
// 获取VPS上报间隔(带缓存)
async function getVpsReportInterval(env) {
const now = Date.now();
// 检查缓存是否有效
if (vpsIntervalCache.value !== null && (now - vpsIntervalCache.timestamp) < vpsIntervalCache.ttl) {
return vpsIntervalCache.value;
}
try {
const result = await env.DB.prepare(
'SELECT value FROM app_config WHERE key = ?'
).bind('vps_report_interval_seconds').first();
const interval = result?.value ? parseInt(result.value, 10) : 60;
if (!isNaN(interval) && interval > 0) {
// 更新缓存
vpsIntervalCache.value = interval;
vpsIntervalCache.timestamp = now;
return interval;
}
} catch (error) {
// 静默处理错误,使用默认值
}
// 默认值也缓存
vpsIntervalCache.value = 60;
vpsIntervalCache.timestamp = now;
return 60;
}
// 清除VPS间隔缓存(当设置更新时调用)
function clearVpsIntervalCache() {
vpsIntervalCache.value = null;
vpsIntervalCache.timestamp = 0;
}
// ==================== VPS数据验证工具 ====================
// VPS数据默认值配置
const VPS_DATA_DEFAULTS = {
cpu: { usage_percent: 0, load_avg: [0, 0, 0] },
memory: { total: 0, used: 0, free: 0, usage_percent: 0 },
disk: { total: 0, used: 0, free: 0, usage_percent: 0 },
network: { upload_speed: 0, download_speed: 0, total_upload: 0, total_download: 0 }
};
// 简化的VPS数据验证和转换
function validateAndFixVpsField(data, field) {
if (!data || typeof data !== 'object') return VPS_DATA_DEFAULTS[field];
// 转换字符串数字为数字
const converted = {};
for (const [key, value] of Object.entries(data)) {
converted[key] = typeof value === 'string' ? (parseFloat(value) || 0) : (value || 0);
}
return converted;
}
// 简化的VPS数据验证
function validateAndFixVpsData(reportData) {
const requiredFields = ['timestamp', 'cpu', 'memory', 'disk', 'network', 'uptime'];
// 检查必需字段
for (const field of requiredFields) {
if (!reportData[field]) {
return { error: 'Invalid data format', message: `缺少字段: ${field}` };
}
}
// 修复数据类型
['cpu', 'memory', 'disk', 'network'].forEach(field => {
reportData[field] = validateAndFixVpsField(reportData[field], field);
});
// 修复时间戳和uptime
reportData.timestamp = parseInt(reportData.timestamp) || Math.floor(Date.now() / 1000);
reportData.uptime = parseInt(reportData.uptime) || 0;
return { success: true, data: reportData };
}
// ==================== 密码处理 ====================
async function hashPassword(password) {
// 生成16字节随机盐值
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
// 组合密码和盐值,进行1000次迭代(平衡安全性和性能)
const encoder = new TextEncoder();
let hash = encoder.encode(password + saltHex);
for (let i = 0; i < 1000; i++) {
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
}
const hashHex = Array.from(hash).map(b => b.toString(16).padStart(2, '0')).join('');
return `${saltHex}$${hashHex}`;
}
async function verifyPassword(password, hashedPassword) {
// 兼容新旧哈希格式
if (hashedPassword.includes('$')) {
// 新格式:salt$hash
const [saltHex, expectedHash] = hashedPassword.split('$');
const encoder = new TextEncoder();
let hash = encoder.encode(password + saltHex);
for (let i = 0; i < 1000; i++) {
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
}
const computedHash = Array.from(hash).map(b => b.toString(16).padStart(2, '0')).join('');
return computedHash === expectedHash;
} else {
// 旧格式:纯SHA-256(向后兼容)
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const computedHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return computedHash === hashedPassword;
}
}
// ==================== JWT处理 ====================
// JWT验证缓存
const jwtCache = new Map();
const JWT_CACHE_TTL = 60000; // 1分钟缓存
const MAX_CACHE_SIZE = 1000; // 最大缓存条目数
// 清理过期的缓存条目
function cleanupJWTCache() {
const now = Date.now();
for (const [key, value] of jwtCache.entries()) {
if (now - value.timestamp > JWT_CACHE_TTL) {
jwtCache.delete(key);
}
}
// 如果缓存过大,删除最旧的条目
if (jwtCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(jwtCache.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
const toDelete = entries.slice(0, jwtCache.size - MAX_CACHE_SIZE);
toDelete.forEach(([key]) => jwtCache.delete(key));
}
}
async function createJWT(payload, env) {
const config = getSecurityConfig(env);
const header = { alg: 'HS256', typ: 'JWT' };
const now = Date.now();
const jwtPayload = { ...payload, iat: now, exp: now + config.TOKEN_EXPIRY };
const encodedHeader = btoa(JSON.stringify(header));
const encodedPayload = btoa(JSON.stringify(jwtPayload));
const data = encodedHeader + '.' + encodedPayload;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(config.JWT_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const encodedSignature = btoa(String.fromCharCode(...new Uint8Array(signature)));
return data + '.' + encodedSignature;
}
// 安全的JWT验证函数 - 修复缓存安全问题
async function verifyJWTCached(token, env) {
// 首先检查令牌是否被撤销
if (isTokenRevoked(token)) {
jwtCache.delete(token);
return null;
}
// 检查缓存
const cached = jwtCache.get(token);
if (cached && Date.now() - cached.timestamp < JWT_CACHE_TTL) {
// 检查token是否过期
if (cached.payload.exp && Date.now() > cached.payload.exp) {
jwtCache.delete(token);
return null;
}
// 再次检查撤销状态(防止缓存期间被撤销)
if (isTokenRevoked(token)) {
jwtCache.delete(token);
return null;
}
return cached.payload;
}
// 缓存未命中,执行实际验证
const payload = await verifyJWT(token, env);
if (payload && !isTokenRevoked(token)) {
// 定期清理缓存
if (Math.random() < 0.01) {
cleanupJWTCache();
}
// 存入缓存
jwtCache.set(token, {
payload,
timestamp: Date.now()
});
}
return payload;
}
// 原始JWT验证函数(不使用缓存)
async function verifyJWT(token, env) {
try {
// 检查令牌是否被撤销
if (isTokenRevoked(token)) return null;
const config = getSecurityConfig(env);
const [encodedHeader, encodedPayload, encodedSignature] = token.split('.');
if (!encodedHeader || !encodedPayload || !encodedSignature) return null;
const data = encodedHeader + '.' + encodedPayload;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(config.JWT_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signature = Uint8Array.from(atob(encodedSignature), c => c.charCodeAt(0));
const isValid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!isValid) return null;
const payload = JSON.parse(atob(encodedPayload));
if (payload.exp && Date.now() > payload.exp) return null;
// 检查是否需要刷新令牌
const tokenAge = Date.now() - payload.iat;
const halfLife = config.TOKEN_EXPIRY / 2;
if (tokenAge > halfLife) {
payload.shouldRefresh = true;
}
return payload;
} catch (error) {
return null;
}
}
// ==================== 安全限制 ====================
function checkRateLimit(clientIP, endpoint, env) {
const config = getSecurityConfig(env);
const key = `${clientIP}:${endpoint}`;
const now = Date.now();
const windowStart = now - 60000;
if (!rateLimitStore.has(key)) {
rateLimitStore.set(key, []);
}
const requests = rateLimitStore.get(key);
const validRequests = requests.filter(timestamp => timestamp > windowStart);
if (validRequests.length >= config.API_RATE_LIMIT) {
return false;
}
validRequests.push(now);
rateLimitStore.set(key, validRequests);
return true;
}
function checkLoginAttempts(clientIP, env) {
const config = getSecurityConfig(env);
const now = Date.now();
const windowStart = now - config.LOGIN_ATTEMPT_WINDOW;
if (!loginAttemptStore.has(clientIP)) {
loginAttemptStore.set(clientIP, []);
}
const attempts = loginAttemptStore.get(clientIP);
const validAttempts = attempts.filter(timestamp => timestamp > windowStart);
return validAttempts.length < config.MAX_LOGIN_ATTEMPTS;
}
function recordLoginAttempt(clientIP) {
const now = Date.now();
if (!loginAttemptStore.has(clientIP)) {
loginAttemptStore.set(clientIP, []);
}
loginAttemptStore.get(clientIP).push(now);
}
function getClientIP(request) {
return request.headers.get('CF-Connecting-IP') ||
request.headers.get('X-Forwarded-For') ||
request.headers.get('X-Real-IP') ||
'127.0.0.1';
}
// ==================== 数据库结构 ====================
const D1_SCHEMAS = {
admin_credentials: `
CREATE TABLE IF NOT EXISTS admin_credentials (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_login INTEGER,
failed_attempts INTEGER DEFAULT 0,
locked_until INTEGER DEFAULT NULL,
must_change_password INTEGER DEFAULT 0,
password_changed_at INTEGER DEFAULT NULL
);`,
servers: `
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
api_key TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
sort_order INTEGER,
last_notified_down_at INTEGER DEFAULT NULL,
is_public INTEGER DEFAULT 1
);`,
metrics: `
CREATE TABLE IF NOT EXISTS metrics (
server_id TEXT PRIMARY KEY,
timestamp INTEGER,
cpu TEXT,
memory TEXT,
disk TEXT,
network TEXT,
uptime INTEGER,
FOREIGN KEY(server_id) REFERENCES servers(id) ON DELETE CASCADE
);`,
monitored_sites: `
CREATE TABLE IF NOT EXISTS monitored_sites (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
name TEXT,
added_at INTEGER NOT NULL,
last_checked INTEGER,
last_status TEXT DEFAULT 'PENDING',
last_status_code INTEGER,
last_response_time_ms INTEGER,
sort_order INTEGER,
last_notified_down_at INTEGER DEFAULT NULL,
is_public INTEGER DEFAULT 1
);`,
site_status_history: `
CREATE TABLE IF NOT EXISTS site_status_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
status TEXT NOT NULL,
status_code INTEGER,
response_time_ms INTEGER,
FOREIGN KEY(site_id) REFERENCES monitored_sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_site_status_history_site_id_timestamp ON site_status_history (site_id, timestamp DESC);`,
telegram_config: `
CREATE TABLE IF NOT EXISTS telegram_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
bot_token TEXT,
chat_id TEXT,
enable_notifications INTEGER DEFAULT 0,
updated_at INTEGER
);
INSERT OR IGNORE INTO telegram_config (id, bot_token, chat_id, enable_notifications, updated_at) VALUES (1, NULL, NULL, 0, NULL);`,
app_config: `
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT
);
INSERT OR IGNORE INTO app_config (key, value) VALUES ('vps_report_interval_seconds', '60');
INSERT OR IGNORE INTO app_config (key, value) VALUES ('custom_background_enabled', 'false');
INSERT OR IGNORE INTO app_config (key, value) VALUES ('custom_background_url', '');
INSERT OR IGNORE INTO app_config (key, value) VALUES ('page_opacity', '80');`
};
// ==================== 数据库初始化 ====================
async function ensureTablesExist(db, env) {
try {
const createTableStatements = Object.values(D1_SCHEMAS).map(sql => db.prepare(sql));
await db.batch(createTableStatements);
} catch (error) {
// 静默处理数据库创建错误
}
await createDefaultAdmin(db, env);
await applySchemaAlterations(db);
}
async function applySchemaAlterations(db) {
const alterStatements = [
"ALTER TABLE monitored_sites ADD COLUMN last_notified_down_at INTEGER DEFAULT NULL",
"ALTER TABLE servers ADD COLUMN last_notified_down_at INTEGER DEFAULT NULL",
"ALTER TABLE metrics ADD COLUMN uptime INTEGER DEFAULT NULL",
"ALTER TABLE admin_credentials ADD COLUMN password_hash TEXT",
"ALTER TABLE admin_credentials ADD COLUMN created_at INTEGER",
"ALTER TABLE admin_credentials ADD COLUMN last_login INTEGER",
"ALTER TABLE admin_credentials ADD COLUMN failed_attempts INTEGER DEFAULT 0",
"ALTER TABLE admin_credentials ADD COLUMN locked_until INTEGER DEFAULT NULL",
"ALTER TABLE admin_credentials ADD COLUMN must_change_password INTEGER DEFAULT 0",
"ALTER TABLE admin_credentials ADD COLUMN password_changed_at INTEGER DEFAULT NULL",
"ALTER TABLE servers ADD COLUMN is_public INTEGER DEFAULT 1",
"ALTER TABLE monitored_sites ADD COLUMN is_public INTEGER DEFAULT 1"
];
for (const alterSql of alterStatements) {
try {
await db.exec(alterSql);
} catch (e) {
// 静默处理重复列错误
}
}
}
async function isUsingDefaultPassword(username, password) {
return username === DEFAULT_ADMIN_CONFIG.USERNAME && password === DEFAULT_ADMIN_CONFIG.PASSWORD;
}
async function createDefaultAdmin(db, env) {
try {
const adminExists = await db.prepare(
"SELECT username FROM admin_credentials WHERE username = ?"
).bind(DEFAULT_ADMIN_CONFIG.USERNAME).first();
if (!adminExists) {
const adminPasswordHash = await hashPassword(DEFAULT_ADMIN_CONFIG.PASSWORD);
const now = Math.floor(Date.now() / 1000);
await db.prepare(`
INSERT INTO admin_credentials (username, password_hash, created_at, failed_attempts, must_change_password)
VALUES (?, ?, ?, 0, 0)
`).bind(DEFAULT_ADMIN_CONFIG.USERNAME, adminPasswordHash, now).run();
}
} catch (error) {
if (!error.message.includes('no such table')) {
throw error;
}
}
}
// ==================== 身份验证 ====================
// 优化的认证函数,使用JWT缓存和智能数据库查询
async function authenticateRequest(request, env) {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.substring(7);
const payload = await verifyJWTCached(token, env);
if (!payload) return null;
// 只有在token需要刷新时才查询数据库验证用户状态
// 这大大减少了数据库查询次数
if (payload.shouldRefresh) {
const user = await env.DB.prepare(
'SELECT username, locked_until FROM admin_credentials WHERE username = ?'
).bind(payload.username).first();
if (!user || (user.locked_until && Date.now() < user.locked_until)) {
return null;
}
}
return payload;
}
// 可选认证函数 - 用于前台API,支持游客和管理员两种模式
async function authenticateRequestOptional(request, env) {
try {
return await authenticateRequest(request, env);
} catch (error) {
return null; // 未登录或认证失败返回null
}
}
// ==================== CORS处理 ====================
function getSecureCorsHeaders(origin, env) {
const config = getSecurityConfig(env);
const allowedOrigins = config.ALLOWED_ORIGINS;
let allowedOrigin = 'null'; // 默认拒绝所有跨域请求
// 只有明确配置了允许的域名才允许跨域
if (allowedOrigins.length > 0 && origin) {
// 精确匹配
if (allowedOrigins.includes(origin)) {
allowedOrigin = origin;
} else {
// 子域名匹配 (*.example.com)
for (const allowed of allowedOrigins) {
if (allowed.startsWith('*.')) {
const domain = allowed.substring(2);
if (origin === domain || origin.endsWith(`.${domain}`)) {
allowedOrigin = origin;
break;
}
}
}
}
}
return {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
'Access-Control-Allow-Credentials': allowedOrigin !== 'null' ? 'true' : 'false',
'Access-Control-Max-Age': '86400',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';"
};
}
// ==================== API路由模块 ====================
// 认证路由处理器
async function handleAuthRoutes(path, method, request, env, corsHeaders, clientIP) {
// 登录处理
if (path === '/api/auth/login' && method === 'POST') {
try {
if (!checkLoginAttempts(clientIP, env)) {
return createErrorResponse(
'Too many login attempts',
'登录尝试次数过多,请15分钟后再试',
429,
corsHeaders
);
}
const { username, password } = await parseJsonSafely(request);
if (!username || !password) {
recordLoginAttempt(clientIP);
return createErrorResponse(
'Missing credentials',
'用户名和密码不能为空',
400,
corsHeaders
);
}
const user = await env.DB.prepare(
'SELECT username, password_hash, locked_until, failed_attempts FROM admin_credentials WHERE username = ?'
).bind(username).first();
if (!user) {
recordLoginAttempt(clientIP);
return createErrorResponse(
'Invalid credentials',
'用户名或密码错误',
401,
corsHeaders
);
}
if (user.locked_until && Date.now() < user.locked_until) {
return createErrorResponse(
'Account locked',
'账户已被锁定,请稍后再试',
423,
corsHeaders
);
}
const isValidPassword = await verifyPassword(password, user.password_hash);
if (!isValidPassword) {
recordLoginAttempt(clientIP);
const newFailedAttempts = (user.failed_attempts || 0) + 1;
const config = getSecurityConfig(env);
let lockedUntil = null;
if (newFailedAttempts >= config.MAX_LOGIN_ATTEMPTS) {
lockedUntil = Date.now() + config.LOGIN_ATTEMPT_WINDOW;
}
await env.DB.prepare(
'UPDATE admin_credentials SET failed_attempts = ?, locked_until = ? WHERE username = ?'
).bind(newFailedAttempts, lockedUntil, username).run();
return createErrorResponse(
'Invalid credentials',
'用户名或密码错误',
401,
corsHeaders
);
}
// 登录成功,重置失败次数
await env.DB.prepare(
'UPDATE admin_credentials SET failed_attempts = 0, locked_until = NULL, last_login = ? WHERE username = ?'
).bind(Date.now(), username).run();
const isUsingDefault = await isUsingDefaultPassword(username, password);
const token = await createJWT({ username, usingDefaultPassword: isUsingDefault }, env);
return createSuccessResponse({
token,
user: { username, usingDefaultPassword: isUsingDefault }
}, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '登录');
}
}
// 认证状态检查
if (path === '/api/auth/status' && method === 'GET') {
try {
const user = await authenticateRequest(request, env);
if (!user) {
return createApiResponse({ authenticated: false }, 200, corsHeaders);
}
const dbUser = await env.DB.prepare(
'SELECT username FROM admin_credentials WHERE username = ?'
).bind(user.username).first();
if (!dbUser) {
return createApiResponse({ authenticated: false }, 200, corsHeaders);
}
return createApiResponse({
authenticated: true,
user: {
username: user.username,
usingDefaultPassword: user.usingDefaultPassword || false
}
}, 200, corsHeaders);
} catch (error) {
return createApiResponse({ authenticated: false }, 200, corsHeaders);
}
}
// 修改密码
if (path === '/api/auth/change-password' && method === 'POST') {
try {
const user = await authenticateRequest(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要登录', 401, corsHeaders);
}
const { current_password, new_password } = await parseJsonSafely(request);
if (!current_password || !new_password) {
return createErrorResponse(
'Missing fields',
'当前密码和新密码不能为空',
400,
corsHeaders
);
}
const config = getSecurityConfig(env);
if (new_password.length < config.MIN_PASSWORD_LENGTH) {
return createErrorResponse(
'Password too short',
`密码长度至少为${config.MIN_PASSWORD_LENGTH}位`,
400,
corsHeaders
);
}
const dbUser = await env.DB.prepare(
'SELECT password_hash FROM admin_credentials WHERE username = ?'
).bind(user.username).first();
if (!dbUser || !await verifyPassword(current_password, dbUser.password_hash)) {
return createErrorResponse(
'Invalid current password',
'当前密码错误',
400,
corsHeaders
);
}
const newPasswordHash = await hashPassword(new_password);
await env.DB.prepare(
'UPDATE admin_credentials SET password_hash = ?, password_changed_at = ?, must_change_password = 0 WHERE username = ?'
).bind(newPasswordHash, Date.now(), user.username).run();
// 撤销当前令牌,强制重新登录
const authHeader = request.headers.get('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const currentToken = authHeader.substring(7);
revokeToken(currentToken);
}
return createSuccessResponse({
message: '密码修改成功,请重新登录',
requireReauth: true
}, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '修改密码');
}
}
return null; // 不匹配此模块的路由
}
// 服务器管理路由处理器
async function handleServerRoutes(path, method, request, env, corsHeaders) {
// 获取服务器列表(公开,支持管理员和游客模式)
if (path === '/api/servers' && method === 'GET') {
try {
const user = await authenticateRequestOptional(request, env);
const isAdmin = user !== null;
// 使用缓存机制获取服务器列表
const servers = await configCache.getServerList(env.DB, isAdmin);
return createApiResponse({ servers }, 200, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '获取服务器列表');
}
}
// 管理员获取服务器列表(包含详细信息)
if (path === '/api/admin/servers' && method === 'GET') {
const user = await authenticateAdmin(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
try {
const { results } = await env.DB.prepare(`
SELECT s.id, s.name, s.description, s.created_at, s.sort_order,
s.last_notified_down_at, s.api_key, s.is_public, m.timestamp as last_report
FROM servers s
LEFT JOIN metrics m ON s.id = m.server_id
ORDER BY s.sort_order ASC NULLS LAST, s.name ASC
`).all();
// 检查是否需要完整密钥(用于查看密钥和复制脚本功能)
const url = new URL(request.url);
const showFullKey = url.searchParams.get('full_key') === 'true';
// 根据参数决定是否脱敏API密钥
const servers = (results || []).map(server => ({
...server,
api_key: showFullKey ? server.api_key : maskSensitive(server.api_key)
}));
return createApiResponse({ servers }, 200, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '获取管理员服务器列表');
}
}
// 添加服务器(管理员)
if (path === '/api/admin/servers' && method === 'POST') {
const user = await authenticateAdmin(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
try {
const { name, description } = await parseJsonSafely(request);
if (!validateInput(name, 'serverName')) {
return createErrorResponse(
'Invalid server name',
'服务器名称格式无效',
400,
corsHeaders
);
}
const serverId = Math.random().toString(36).substring(2, 8);
// 生成32字节强随机API密钥
const apiKey = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, '0')).join('');
const now = Math.floor(Date.now() / 1000);
await env.DB.prepare(`
INSERT INTO servers (id, name, description, api_key, created_at, sort_order, is_public)
VALUES (?, ?, ?, ?, ?, 0, 1)
`).bind(serverId, name, description || '', apiKey, now).run();
// 清除服务器列表缓存
configCache.clearKey('servers_admin');
configCache.clearKey('servers_public');
return createSuccessResponse({
server: {
id: serverId,
name,
description: description || '',
api_key: maskSensitive(apiKey),
created_at: now
}
}, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '添加服务器');
}
}
// 更新服务器(管理员) - 修复权限检查
if (path.match(/\/api\/admin\/servers\/[^\/]+$/) && method === 'PUT') {
const user = await authenticateAdmin(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
try {
const serverId = extractAndValidateServerId(path);
if (!serverId) {
return createErrorResponse(
'Invalid server ID',
'无效的服务器ID格式',
400,
corsHeaders
);
}
const { name, description } = await request.json();
if (!validateInput(name, 'serverName')) {
return createErrorResponse(
'Invalid server name',
'服务器名称格式无效',
400,
corsHeaders
);
}
const info = await env.DB.prepare(`
UPDATE servers SET name = ?, description = ? WHERE id = ?
`).bind(name, description || '', serverId).run();
if (info.changes === 0) {
return createErrorResponse('Server not found', '服务器不存在', 404, corsHeaders);
}
// 清除服务器列表缓存
configCache.clearKey('servers_admin');
configCache.clearKey('servers_public');
return createSuccessResponse({
id: serverId,
name,
description: description || '',
message: '服务器更新成功'
}, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '更新服务器');
}
}
// 删除服务器(管理员)
if (path.match(/\/api\/admin\/servers\/[^\/]+$/) && method === 'DELETE') {
const user = await authenticateAdmin(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
try {
const serverId = extractAndValidateServerId(path);
if (!serverId) {
return createErrorResponse(
'Invalid server ID',
'无效的服务器ID格式',
400,
corsHeaders
);
}
// 危险操作需要确认
const url = new URL(request.url);
const confirmed = url.searchParams.get('confirm') === 'true';
if (!confirmed) {
return createErrorResponse(
'Confirmation required',
'删除操作需要确认,请添加 ?confirm=true 参数',
400,
corsHeaders
);
}
const info = await env.DB.prepare('DELETE FROM servers WHERE id = ?').bind(serverId).run();
if (info.changes === 0) {
return createErrorResponse('Server not found', '服务器不存在', 404, corsHeaders);
}
// 同时删除相关的监控数据
await env.DB.prepare('DELETE FROM metrics WHERE server_id = ?').bind(serverId).run();
// 清除服务器列表缓存
configCache.clearKey('servers_admin');
configCache.clearKey('servers_public');
return createSuccessResponse({ message: '服务器已删除' }, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '删除服务器');
}
}
return null; // 不匹配此模块的路由
}
// VPS监控路由处理器
async function handleVpsRoutes(path, method, request, env, corsHeaders, ctx) {
// VPS配置获取(使用API密钥认证)
if (path.startsWith('/api/config/') && method === 'GET') {
try {
const authResult = await validateServerAuth(path, request, env);
if (!authResult.success) {
return createErrorResponse(authResult.error, authResult.message,
authResult.error === 'Invalid server ID' ? 400 : 401, corsHeaders);
}
const { serverId, serverData } = authResult;
const reportInterval = await getVpsReportInterval(env);
const configData = {
success: true,
config: {
report_interval: reportInterval,
enabled_metrics: ['cpu', 'memory', 'disk', 'network', 'uptime'],
server_info: {
id: serverData.id,
name: serverData.name,
description: serverData.description || ''
}
},
timestamp: Math.floor(Date.now() / 1000)
};
return createApiResponse(configData, 200, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '配置获取');
}
}
// VPS数据上报
if (path.startsWith('/api/report/') && method === 'POST') {
try {
const authResult = await validateServerAuth(path, request, env);
if (!authResult.success) {
return createErrorResponse(authResult.error, authResult.message,
authResult.error === 'Invalid server ID' ? 400 : 401, corsHeaders);
}
const { serverId } = authResult;
// 解析和验证上报数据
let reportData;
try {
const rawBody = await request.text();
reportData = JSON.parse(rawBody);
} catch (parseError) {
return createErrorResponse(
'Invalid JSON format',
`JSON解析失败: ${parseError.message}`,
400,
corsHeaders,
'请检查上报的JSON格式是否正确'
);
}
const validationResult = validateAndFixVpsData(reportData);
if (!validationResult.success) {
return createErrorResponse(
validationResult.error,
validationResult.message,
400,
corsHeaders,
validationResult.details
);
}
reportData = validationResult.data;
// 获取当前的批量写入间隔(与VPS上报间隔一致)
const currentInterval = await getVpsReportInterval(env);
// 使用批量处理器处理VPS数据
const shouldFlush = vpsBatchProcessor.addReport(serverId, reportData, currentInterval);
// 如果需要刷新或使用ctx.waitUntil进行异步刷新
if (shouldFlush) {
ctx.waitUntil(flushVpsBatchData(env));
} else {
// 检查是否有定时需要刷新的数据
if (vpsBatchProcessor.shouldFlush(currentInterval)) {
ctx.waitUntil(flushVpsBatchData(env));
}
}
return createSuccessResponse({ interval: currentInterval }, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '数据上报');
}
}
// 批量VPS状态查询(公开,支持管理员和游客模式)
if (path === '/api/status/batch' && method === 'GET') {
try {
const user = await authenticateRequestOptional(request, env);
const isAdmin = user !== null;
// 使用JOIN查询一次性获取所有VPS状态
const { results } = await env.DB.prepare(`
SELECT s.id, s.name, s.description,
m.timestamp, m.cpu, m.memory, m.disk, m.network, m.uptime
FROM servers s
LEFT JOIN metrics m ON s.id = m.server_id
WHERE s.is_public = 1 OR ? = 1
ORDER BY s.sort_order ASC NULLS LAST, s.name ASC
`).bind(isAdmin ? 1 : 0).all();
// 处理数据格式,保持与单个查询API的兼容性
const servers = (results || []).map(row => {
const server = { id: row.id, name: row.name, description: row.description };
let metrics = null;
if (row.timestamp) {
metrics = {
timestamp: row.timestamp,
uptime: row.uptime
};
// 解析JSON字段
try {
if (row.cpu) metrics.cpu = JSON.parse(row.cpu);
if (row.memory) metrics.memory = JSON.parse(row.memory);
if (row.disk) metrics.disk = JSON.parse(row.disk);
if (row.network) metrics.network = JSON.parse(row.network);
} catch (parseError) {
// 静默处理JSON解析错误
}
}
return { server, metrics, error: false };
});
return createApiResponse({ servers }, 200, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '批量VPS状态查询');
}
}
// VPS状态查询(公开,无需认证)
if (path.startsWith('/api/status/') && method === 'GET') {
try {
const serverId = path.split('/')[3]; // 从 /api/status/{serverId} 提取ID
if (!serverId) {
return createErrorResponse('Invalid server ID', '无效的服务器ID', 400, corsHeaders);
}
// 查询服务器信息(移除权限限制,让前台能正常显示)
const serverData = await env.DB.prepare(
'SELECT id, name, description FROM servers WHERE id = ?'
).bind(serverId).first();
if (!serverData) {
return createErrorResponse('Server not found', '服务器不存在', 404, corsHeaders);
}
// 查询最新的VPS监控数据
const metricsData = await env.DB.prepare(`
SELECT * FROM metrics
WHERE server_id = ?
ORDER BY timestamp DESC
LIMIT 1
`).bind(serverId).first();
// 解析JSON字符串为对象
if (metricsData) {
try {
if (metricsData.cpu) metricsData.cpu = JSON.parse(metricsData.cpu);
if (metricsData.memory) metricsData.memory = JSON.parse(metricsData.memory);
if (metricsData.disk) metricsData.disk = JSON.parse(metricsData.disk);
if (metricsData.network) metricsData.network = JSON.parse(metricsData.network);
} catch (parseError) {
// 静默处理JSON解析错误
}
}
// 返回完整的监控数据(保持前端兼容性)
const publicInfo = {
server: serverData,
metrics: metricsData || null,
error: false
};
return createApiResponse(publicInfo, 200, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, 'VPS状态查询');
}
}
// VPS状态变化通知API
if (path === '/api/notify/offline' && method === 'POST') {
try {
const { serverId, serverName } = await request.json();
// 检查是否已发送过离线通知
const server = await env.DB.prepare('SELECT last_notified_down_at FROM servers WHERE id = ?').bind(serverId).first();
if (server?.last_notified_down_at) {
return createApiResponse({ success: true, message: 'Already notified' }, 200, corsHeaders);
}
const message = `🔴 VPS故障: 服务器 *${serverName}* 已离线超过5分钟`;
// 记录离线时间并发送通知
await env.DB.prepare('UPDATE servers SET last_notified_down_at = ? WHERE id = ?')
.bind(Math.floor(Date.now() / 1000), serverId).run();
ctx.waitUntil(sendTelegramNotificationOptimized(env.DB, message, 'high'));
return createApiResponse({ success: true }, 200, corsHeaders);
} catch (error) {
return createErrorResponse('Notification failed', '通知发送失败', 500, corsHeaders);
}
}
if (path === '/api/notify/recovery' && method === 'POST') {
try {
const { serverId, serverName } = await request.json();
const message = `✅ VPS恢复: 服务器 *${serverName}* 已恢复在线`;
// 清除离线记录
await env.DB.prepare('UPDATE servers SET last_notified_down_at = NULL WHERE id = ?')
.bind(serverId).run();
// 发送通知
ctx.waitUntil(sendTelegramNotificationOptimized(env.DB, message, 'high'));
return createApiResponse({ success: true }, 200, corsHeaders);
} catch (error) {
return createErrorResponse('Notification failed', '通知发送失败', 500, corsHeaders);
}
}
return null; // 不匹配此模块的路由
}
// ==================== API请求处理 ====================
async function handleApiRequest(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
const clientIP = getClientIP(request);
const origin = request.headers.get('Origin');
const corsHeaders = getSecureCorsHeaders(origin, env);
// OPTIONS请求处理
if (method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
// 速率限制检查(登录接口除外)
if (path !== '/api/auth/login' && !checkRateLimit(clientIP, path, env)) {
return createErrorResponse(
'Rate limit exceeded',
'请求过于频繁,请稍后再试',
429,
corsHeaders
);
}
// ==================== 路由分发 ====================
// 认证相关路由
if (path.startsWith('/api/auth/')) {
const authResult = await handleAuthRoutes(path, method, request, env, corsHeaders, clientIP);
if (authResult) return authResult;
}
// 服务器管理路由
if (path.startsWith('/api/servers') || path.startsWith('/api/admin/servers')) {
const serverResult = await handleServerRoutes(path, method, request, env, corsHeaders);
if (serverResult) return serverResult;
}
// VPS监控路由
if (path.startsWith('/api/config/') || path.startsWith('/api/report/') ||
path.startsWith('/api/status/') || path.startsWith('/api/notify/')) {
const vpsResult = await handleVpsRoutes(path, method, request, env, corsHeaders, ctx);
if (vpsResult) return vpsResult;
}
// 数据库初始化API(无需认证)
if (path === '/api/init-db' && ['POST', 'GET'].includes(method)) {
try {
await ensureTablesExist(env.DB, env);
return createSuccessResponse({
message: '数据库初始化完成'
}, corsHeaders);
} catch (error) {
return createErrorResponse(
'Database initialization failed',
`数据库初始化失败: ${error.message}`,
500,
corsHeaders
);
}
}
// ==================== 高级排序功能 ====================
// 批量服务器排序(管理员) - 修复权限检查
if (path === '/api/admin/servers/batch-reorder' && method === 'POST') {
const user = await authenticateAdmin(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
try {
const { serverIds } = await request.json(); // 按新顺序排列的服务器ID数组
if (!Array.isArray(serverIds) || serverIds.length === 0) {
return new Response(JSON.stringify({
error: 'Invalid server IDs',
message: '服务器ID数组无效'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 批量更新排序
const updateStmts = serverIds.map((serverId, index) =>
env.DB.prepare('UPDATE servers SET sort_order = ? WHERE id = ?').bind(index, serverId)
);
await env.DB.batch(updateStmts);
return new Response(JSON.stringify({
success: true,
message: '批量排序完成'
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 自动服务器排序(管理员) - 修复权限检查
if (path === '/api/admin/servers/auto-sort' && method === 'POST') {
const user = await authenticateAdmin(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
try {
const { sortBy, order } = await request.json(); // sortBy: 'custom'|'name'|'status', order: 'asc'|'desc'
const validSortFields = ['custom', 'name', 'status'];
const validOrders = ['asc', 'desc'];
if (!validSortFields.includes(sortBy) || !validOrders.includes(order)) {
return new Response(JSON.stringify({
error: 'Invalid sort parameters',
message: '无效的排序参数'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 如果是自定义排序,直接返回成功,不做任何操作
if (sortBy === 'custom') {
return new Response(JSON.stringify({
success: true,
message: '已设置为自定义排序'
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 获取所有服务器并排序 - 安全验证
const safeOrder = validateSqlIdentifier(order.toUpperCase(), 'order');
let orderClause = '';
if (sortBy === 'name') {
orderClause = `ORDER BY name ${safeOrder}`;
} else if (sortBy === 'status') {
orderClause = `ORDER BY (CASE WHEN m.timestamp IS NULL OR (strftime('%s', 'now') - m.timestamp) > 300 THEN 1 ELSE 0 END) ${safeOrder}, name ASC`;
}
const { results: servers } = await env.DB.prepare(`
SELECT s.id FROM servers s
LEFT JOIN metrics m ON s.id = m.server_id
${orderClause}
`).all();
// 批量更新排序
const updateStmts = servers.map((server, index) =>
env.DB.prepare('UPDATE servers SET sort_order = ? WHERE id = ?').bind(index, server.id)
);
await env.DB.batch(updateStmts);
return new Response(JSON.stringify({
success: true,
message: `已按${sortBy}${order === 'asc' ? '升序' : '降序'}排序`
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 服务器排序(管理员)- 保留原有的单个移动功能
if (path.match(/\/api\/admin\/servers\/[^\/]+\/reorder$/) && method === 'POST') {
try {
const serverId = extractPathSegment(path, 4);
if (!serverId) {
return new Response(JSON.stringify({
error: 'Invalid server ID',
message: '无效的服务器ID格式'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
const { direction } = await request.json();
if (!['up', 'down'].includes(direction)) {
return new Response(JSON.stringify({
error: 'Invalid direction'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 获取所有服务器排序信息
const results = await env.DB.batch([
env.DB.prepare('SELECT id, sort_order FROM servers ORDER BY sort_order ASC NULLS LAST, name ASC')
]);
const allServers = results[0].results;
const currentIndex = allServers.findIndex(s => s.id === serverId);
if (currentIndex === -1) {
return new Response(JSON.stringify({
error: 'Server not found'
}), {
status: 404,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 计算目标位置
let targetIndex = -1;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < allServers.length - 1) {
targetIndex = currentIndex + 1;
}
if (targetIndex !== -1) {
const currentServer = allServers[currentIndex];
const targetServer = allServers[targetIndex];
// 处理排序值交换
if (currentServer.sort_order === null || targetServer.sort_order === null) {
const updateStmts = allServers.map((server, index) =>
env.DB.prepare('UPDATE servers SET sort_order = ? WHERE id = ?').bind(index, server.id)
);
await env.DB.batch(updateStmts);
// 重新获取并交换
const updatedResults = await env.DB.batch([
env.DB.prepare('SELECT id, sort_order FROM servers ORDER BY sort_order ASC')
]);
const updatedServers = updatedResults[0].results;
const newCurrentIndex = updatedServers.findIndex(s => s.id === serverId);
let newTargetIndex = -1;
if (direction === 'up' && newCurrentIndex > 0) {
newTargetIndex = newCurrentIndex - 1;
} else if (direction === 'down' && newCurrentIndex < updatedServers.length - 1) {
newTargetIndex = newCurrentIndex + 1;
}
if (newTargetIndex !== -1) {
const newCurrentOrder = updatedServers[newCurrentIndex].sort_order;
const newTargetOrder = updatedServers[newTargetIndex].sort_order;
await env.DB.batch([
env.DB.prepare('UPDATE servers SET sort_order = ? WHERE id = ?').bind(newTargetOrder, serverId),
env.DB.prepare('UPDATE servers SET sort_order = ? WHERE id = ?').bind(newCurrentOrder, updatedServers[newTargetIndex].id)
]);
}
} else {
// 直接交换排序值
await env.DB.batch([
env.DB.prepare('UPDATE servers SET sort_order = ? WHERE id = ?').bind(targetServer.sort_order, serverId),
env.DB.prepare('UPDATE servers SET sort_order = ? WHERE id = ?').bind(currentServer.sort_order, targetServer.id)
]);
}
}
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 更新服务器显示状态(管理员)
if (path.match(/^\/api\/admin\/servers\/([^\/]+)\/visibility$/) && method === 'POST') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const serverId = path.split('/')[4];
const { is_public } = await request.json();
// 验证输入
if (typeof is_public !== 'boolean') {
return new Response(JSON.stringify({
error: 'Invalid input',
message: '显示状态必须为布尔值'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 更新服务器显示状态
await env.DB.prepare(`
UPDATE servers SET is_public = ? WHERE id = ?
`).bind(is_public ? 1 : 0, serverId).run();
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// ==================== 网站监控API ====================
// 获取监控站点列表(管理员)
if (path === '/api/admin/sites' && method === 'GET') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const { results } = await env.DB.prepare(`
SELECT id, name, url, added_at, last_checked, last_status, last_status_code,
last_response_time_ms, sort_order, last_notified_down_at, is_public
FROM monitored_sites
ORDER BY sort_order ASC NULLS LAST, name ASC, url ASC
`).all();
return new Response(JSON.stringify({ sites: results || [] }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
if (error.message.includes('no such table')) {
try {
await env.DB.exec(D1_SCHEMAS.monitored_sites);
return new Response(JSON.stringify({ sites: [] }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (createError) {
}
}
return new Response(JSON.stringify({
error: 'Internal server error',
message: '服务器内部错误'
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 添加监控站点(管理员)
if (path === '/api/admin/sites' && method === 'POST') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const { url, name } = await parseJsonSafely(request);
if (!url || !isValidHttpUrl(url)) {
return new Response(JSON.stringify({
error: 'Valid URL is required',
message: '请输入有效的URL'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
const siteId = Math.random().toString(36).substring(2, 12);
const addedAt = Math.floor(Date.now() / 1000);
// 获取下一个排序序号
const maxOrderResult = await env.DB.prepare(
'SELECT MAX(sort_order) as max_order FROM monitored_sites'
).first();
const nextSortOrder = (maxOrderResult?.max_order && typeof maxOrderResult.max_order === 'number')
? maxOrderResult.max_order + 1
: 0;
await env.DB.prepare(`
INSERT INTO monitored_sites (id, url, name, added_at, last_status, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
`).bind(siteId, url, name || '', addedAt, 'PENDING', nextSortOrder).run();
const siteData = {
id: siteId,
url,
name: name || '',
added_at: addedAt,
last_status: 'PENDING',
sort_order: nextSortOrder
};
// 立即执行健康检查
const newSiteForCheck = { id: siteId, url, name: name || '' };
if (ctx?.waitUntil) {
ctx.waitUntil(checkWebsiteStatus(newSiteForCheck, env.DB, ctx));
} else {
checkWebsiteStatus(newSiteForCheck, env.DB, ctx).catch(e => {
// 静默处理站点检查错误
});
}
return new Response(JSON.stringify({ site: siteData }), {
status: 201,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
if (error.message.includes('UNIQUE constraint failed')) {
return new Response(JSON.stringify({
error: 'URL already exists or ID conflict',
message: '该URL已被监控或ID冲突'
}), {
status: 409,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
if (error.message.includes('no such table')) {
try {
await env.DB.exec(D1_SCHEMAS.monitored_sites);
return new Response(JSON.stringify({
error: 'Database table created, please retry',
message: '数据库表已创建,请重试添加操作'
}), {
status: 503,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (createError) {
}
}
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 更新监控站点(管理员)
if (path.match(/\/api\/admin\/sites\/[^\/]+$/) && method === 'PUT') {
const user = await authenticateRequest(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
try {
const siteId = path.split('/').pop();
if (!siteId) {
return createErrorResponse('Invalid site ID', '无效的网站ID', 400, corsHeaders);
}
const { url, name } = await request.json();
if (!url || !url.trim()) {
return createErrorResponse('Invalid URL', 'URL不能为空', 400, corsHeaders);
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return createErrorResponse('Invalid URL format', 'URL必须以http://或https://开头', 400, corsHeaders);
}
const info = await env.DB.prepare(`
UPDATE monitored_sites SET url = ?, name = ? WHERE id = ?
`).bind(url.trim(), name?.trim() || '', siteId).run();
if (info.changes === 0) {
return createErrorResponse('Site not found', '网站不存在', 404, corsHeaders);
}
return createSuccessResponse({
id: siteId,
url: url.trim(),
name: name?.trim() || '',
message: '网站更新成功'
}, corsHeaders);
} catch (error) {
return handleDbError(error, corsHeaders, '更新监控站点');
}
}
// 删除监控站点(管理员)
if (path.match(/\/api\/admin\/sites\/[^\/]+$/) && method === 'DELETE') {
const user = await authenticateAdmin(request, env);
if (!user) {
return createErrorResponse('Unauthorized', '需要管理员权限', 401, corsHeaders);
}
try {
const siteId = extractAndValidateServerId(path);
if (!siteId) {
return createErrorResponse('Invalid site ID', '无效的站点ID格式', 400, corsHeaders);
}
// 危险操作需要确认
const url = new URL(request.url);
const confirmed = url.searchParams.get('confirm') === 'true';
if (!confirmed) {
return createErrorResponse(
'Confirmation required',
'删除操作需要确认,请添加 ?confirm=true 参数',
400,
corsHeaders
);
}
const info = await env.DB.prepare('DELETE FROM monitored_sites WHERE id = ?').bind(siteId).run();
if (info.changes === 0) {
return new Response(JSON.stringify({
error: 'Site not found'
}), {
status: 404,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 批量网站排序(管理员)
if (path === '/api/admin/sites/batch-reorder' && method === 'POST') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const { siteIds } = await request.json(); // 按新顺序排列的站点ID数组
if (!Array.isArray(siteIds) || siteIds.length === 0) {
return new Response(JSON.stringify({
error: 'Invalid site IDs',
message: '站点ID数组无效'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 批量更新排序
const updateStmts = siteIds.map((siteId, index) =>
env.DB.prepare('UPDATE monitored_sites SET sort_order = ? WHERE id = ?').bind(index, siteId)
);
await env.DB.batch(updateStmts);
return new Response(JSON.stringify({
success: true,
message: '批量排序完成'
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 自动网站排序(管理员)
if (path === '/api/admin/sites/auto-sort' && method === 'POST') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const { sortBy, order } = await request.json(); // sortBy: 'custom'|'name'|'url'|'status', order: 'asc'|'desc'
const validSortFields = ['custom', 'name', 'url', 'status'];
const validOrders = ['asc', 'desc'];
if (!validSortFields.includes(sortBy) || !validOrders.includes(order)) {
return new Response(JSON.stringify({
error: 'Invalid sort parameters',
message: '无效的排序参数'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 如果是自定义排序,直接返回成功,不做任何操作
if (sortBy === 'custom') {
return new Response(JSON.stringify({
success: true,
message: '已设置为自定义排序'
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 获取所有站点并排序 - 安全验证
const safeSortBy = validateSqlIdentifier(sortBy, 'column');
const safeOrder = validateSqlIdentifier(order.toUpperCase(), 'order');
const { results: sites } = await env.DB.prepare(`
SELECT id FROM monitored_sites
ORDER BY ${safeSortBy} ${safeOrder}
`).all();
// 批量更新排序
const updateStmts = sites.map((site, index) =>
env.DB.prepare('UPDATE monitored_sites SET sort_order = ? WHERE id = ?').bind(index, site.id)
);
await env.DB.batch(updateStmts);
return new Response(JSON.stringify({
success: true,
message: `已按${sortBy}${order === 'asc' ? '升序' : '降序'}排序`
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 网站排序(管理员)- 保留原有的单个移动功能
if (path.match(/\/api\/admin\/sites\/[^\/]+\/reorder$/) && method === 'POST') {
try {
const siteId = extractPathSegment(path, 4);
if (!siteId) {
return new Response(JSON.stringify({
error: 'Invalid site ID',
message: '无效的站点ID格式'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
const { direction } = await request.json();
if (!['up', 'down'].includes(direction)) {
return new Response(JSON.stringify({
error: 'Invalid direction'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 获取所有站点排序信息
const results = await env.DB.batch([
env.DB.prepare('SELECT id, sort_order FROM monitored_sites ORDER BY sort_order ASC NULLS LAST, name ASC, url ASC')
]);
const allSites = results[0].results;
const currentIndex = allSites.findIndex(s => s.id === siteId);
if (currentIndex === -1) {
return new Response(JSON.stringify({
error: 'Site not found'
}), {
status: 404,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 计算目标位置
let targetIndex = -1;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < allSites.length - 1) {
targetIndex = currentIndex + 1;
}
if (targetIndex !== -1) {
const currentSite = allSites[currentIndex];
const targetSite = allSites[targetIndex];
// 处理排序值交换
if (currentSite.sort_order === null || targetSite.sort_order === null) {
const updateStmts = allSites.map((site, index) =>
env.DB.prepare('UPDATE monitored_sites SET sort_order = ? WHERE id = ?').bind(index, site.id)
);
await env.DB.batch(updateStmts);
// 重新获取并交换
const updatedResults = await env.DB.batch([
env.DB.prepare('SELECT id, sort_order FROM monitored_sites ORDER BY sort_order ASC')
]);
const updatedSites = updatedResults[0].results;
const newCurrentIndex = updatedSites.findIndex(s => s.id === siteId);
let newTargetIndex = -1;
if (direction === 'up' && newCurrentIndex > 0) {
newTargetIndex = newCurrentIndex - 1;
} else if (direction === 'down' && newCurrentIndex < updatedSites.length - 1) {
newTargetIndex = newCurrentIndex + 1;
}
if (newTargetIndex !== -1) {
const newCurrentOrder = updatedSites[newCurrentIndex].sort_order;
const newTargetOrder = updatedSites[newTargetIndex].sort_order;
await env.DB.batch([
env.DB.prepare('UPDATE monitored_sites SET sort_order = ? WHERE id = ?').bind(newTargetOrder, siteId),
env.DB.prepare('UPDATE monitored_sites SET sort_order = ? WHERE id = ?').bind(newCurrentOrder, updatedSites[newTargetIndex].id)
]);
}
} else {
// 直接交换排序值
await env.DB.batch([
env.DB.prepare('UPDATE monitored_sites SET sort_order = ? WHERE id = ?').bind(targetSite.sort_order, siteId),
env.DB.prepare('UPDATE monitored_sites SET sort_order = ? WHERE id = ?').bind(currentSite.sort_order, targetSite.id)
]);
}
}
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 更新网站显示状态(管理员)
if (path.match(/^\/api\/admin\/sites\/([^\/]+)\/visibility$/) && method === 'POST') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const siteId = path.split('/')[4];
const { is_public } = await request.json();
// 验证输入
if (typeof is_public !== 'boolean') {
return new Response(JSON.stringify({
error: 'Invalid input',
message: '显示状态必须为布尔值'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 更新网站显示状态
await env.DB.prepare(`
UPDATE monitored_sites SET is_public = ? WHERE id = ?
`).bind(is_public ? 1 : 0, siteId).run();
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// ==================== 公共API ====================
// 获取所有监控站点状态(公开,支持管理员和游客模式)
if (path === '/api/sites/status' && method === 'GET') {
try {
// 检查是否为管理员登录状态
const user = await authenticateRequestOptional(request, env);
const isAdmin = user !== null;
let query = `
SELECT id, name, last_checked, last_status, last_status_code, last_response_time_ms
FROM monitored_sites
`;
if (!isAdmin) {
query += ` WHERE is_public = 1`;
}
query += ` ORDER BY sort_order ASC NULLS LAST, name ASC, id ASC`;
const { results } = await env.DB.prepare(query).all();
const sites = results || [];
// 为每个站点附加24小时历史数据
const nowSeconds = Math.floor(Date.now() / 1000);
const twentyFourHoursAgoSeconds = nowSeconds - (24 * 60 * 60);
for (const site of sites) {
try {
const { results: historyResults } = await env.DB.prepare(`
SELECT timestamp, status, status_code, response_time_ms
FROM site_status_history
WHERE site_id = ? AND timestamp >= ?
ORDER BY timestamp DESC
`).bind(site.id, twentyFourHoursAgoSeconds).all();
site.history = historyResults || [];
} catch (historyError) {
site.history = [];
}
}
return new Response(JSON.stringify({ sites }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
if (error.message.includes('no such table')) {
try {
await env.DB.exec(D1_SCHEMAS.monitored_sites);
return new Response(JSON.stringify({ sites: [] }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (createError) {
}
}
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// ==================== VPS配置API ====================
// 获取VPS上报间隔(公开,优化版本)
if (path === '/api/admin/settings/vps-report-interval' && method === 'GET') {
try {
// 使用统一的缓存查询函数
const interval = await getVpsReportInterval(env);
return new Response(JSON.stringify({ interval }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
// 任何错误都返回默认值,确保系统继续工作
return new Response(JSON.stringify({ interval: 60 }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 设置VPS上报间隔(管理员)
if (path === '/api/admin/settings/vps-report-interval' && method === 'POST') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const { interval } = await request.json();
if (typeof interval !== 'number' || interval <= 0 || !Number.isInteger(interval)) {
return new Response(JSON.stringify({
error: 'Invalid interval value. Must be a positive integer (seconds).'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
await env.DB.prepare('REPLACE INTO app_config (key, value) VALUES (?, ?)').bind(
'vps_report_interval_seconds',
interval.toString()
).run();
// 清除相关缓存
configCache.clearKey('monitoring_settings');
vpsIntervalCache.value = null; // 清除VPS间隔缓存
return new Response(JSON.stringify({ success: true, interval }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// ==================== Telegram配置API ====================
// 获取Telegram设置(管理员)
if (path === '/api/admin/telegram-settings' && method === 'GET') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const settings = await configCache.getTelegramConfig(env.DB);
return new Response(JSON.stringify(
settings || { bot_token: null, chat_id: null, enable_notifications: 0 }
), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
if (error.message.includes('no such table')) {
try {
await env.DB.exec(D1_SCHEMAS.telegram_config);
return new Response(JSON.stringify({
bot_token: null,
chat_id: null,
enable_notifications: 0
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (createError) {
}
}
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 设置Telegram配置(管理员)
if (path === '/api/admin/telegram-settings' && method === 'POST') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const { bot_token, chat_id, enable_notifications } = await request.json();
const updatedAt = Math.floor(Date.now() / 1000);
const enableNotifValue = (enable_notifications === true || enable_notifications === 1) ? 1 : 0;
await env.DB.prepare(`
UPDATE telegram_config SET bot_token = ?, chat_id = ?, enable_notifications = ?, updated_at = ? WHERE id = 1
`).bind(bot_token || null, chat_id || null, enableNotifValue, updatedAt).run();
// 清除缓存,确保下次获取最新配置
configCache.clearKey('telegram_config');
// 发送测试通知(高优先级,立即发送)
if (enableNotifValue === 1 && bot_token && chat_id) {
const testMessage = "✅ Telegram通知已在此监控面板激活。这是一条测试消息。";
if (ctx?.waitUntil) {
ctx.waitUntil(sendTelegramNotificationOptimized(env.DB, testMessage, 'high'));
} else {
sendTelegramNotificationOptimized(env.DB, testMessage, 'high').catch(e => {
// 静默处理测试通知错误
});
}
}
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// ==================== 背景设置API ====================
// 获取背景设置(公开API - 所有用户可访问)
if (path === '/api/background-settings' && method === 'GET') {
try {
// 查询三个背景配置项
const { results } = await env.DB.prepare(`
SELECT key, value FROM app_config
WHERE key IN ('custom_background_enabled', 'custom_background_url', 'page_opacity')
`).all();
// 转换为对象格式
const settings = {
enabled: false,
url: '',
opacity: 80
};
results.forEach(row => {
switch (row.key) {
case 'custom_background_enabled':
settings.enabled = row.value === 'true';
break;
case 'custom_background_url':
settings.url = row.value || '';
break;
case 'page_opacity':
settings.opacity = parseInt(row.value, 10) || 80;
break;
}
});
return new Response(JSON.stringify(settings), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
enabled: false,
url: '',
opacity: 80
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 设置背景配置(管理员)
if (path === '/api/admin/background-settings' && method === 'POST') {
const user = await authenticateRequest(request, env);
if (!user) {
return new Response(JSON.stringify({
error: 'Unauthorized',
message: '需要管理员权限'
}), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
try {
const { enabled, url, opacity } = await request.json();
// 验证输入参数
if (typeof enabled !== 'boolean') {
return new Response(JSON.stringify({
error: 'Invalid enabled value',
message: 'enabled必须是布尔值'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
if (enabled && url) {
if (typeof url !== 'string' || !url.startsWith('https://')) {
return new Response(JSON.stringify({
error: 'Invalid URL format',
message: '背景图片URL必须以https://开头'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
if (typeof opacity !== 'number' || opacity < 0 || opacity > 100) {
return new Response(JSON.stringify({
error: 'Invalid opacity value',
message: '透明度必须是0-100之间的数字'
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// 更新配置到数据库
await env.DB.batch([
env.DB.prepare('REPLACE INTO app_config (key, value) VALUES (?, ?)').bind(
'custom_background_enabled',
enabled.toString()
),
env.DB.prepare('REPLACE INTO app_config (key, value) VALUES (?, ?)').bind(
'custom_background_url',
url || ''
),
env.DB.prepare('REPLACE INTO app_config (key, value) VALUES (?, ?)').bind(
'page_opacity',
opacity.toString()
)
]);
// 清除监控设置缓存(背景设置也在app_config表中)
configCache.clearKey('monitoring_settings');
return new Response(JSON.stringify({
success: true,
settings: { enabled, url: url || '', opacity }
}), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 获取监控站点24小时历史状态(公开)
if (path.match(/\/api\/sites\/[^\/]+\/history$/) && method === 'GET') {
try {
const siteId = path.split('/')[3];
const nowSeconds = Math.floor(Date.now() / 1000);
const twentyFourHoursAgoSeconds = nowSeconds - (24 * 60 * 60);
const { results } = await env.DB.prepare(`
SELECT timestamp, status, status_code, response_time_ms
FROM site_status_history
WHERE site_id = ? AND timestamp >= ?
ORDER BY timestamp DESC
`).bind(siteId, twentyFourHoursAgoSeconds).all();
return new Response(JSON.stringify({ history: results || [] }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
if (error.message.includes('no such table')) {
try {
await env.DB.exec(D1_SCHEMAS.site_status_history);
return new Response(JSON.stringify({ history: [] }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (createError) {
}
}
return new Response(JSON.stringify({
error: 'Internal server error',
message: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
// 未找到匹配的API路由
return new Response(JSON.stringify({ error: 'API endpoint not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// --- Scheduled Task for Website Monitoring ---
// ==================== Telegram通知(已移至优化版本) ====================
// 旧的单服务器状态检查函数已移除,改为前端状态变化检测
// 旧的VPS离线检查函数已移除,改为前端状态变化检测 + 定时提醒
async function checkWebsiteStatus(site, db, ctx) { // Added ctx for waitUntil
const { id, url, name } = site; // Added name
const startTime = Date.now();
let newStatus = 'PENDING'; // Renamed to newStatus to avoid conflict
let newStatusCode = null; // Renamed
let newResponseTime = null; // Renamed
// Get current status and last notification time from DB
let previousStatus = 'PENDING';
let siteLastNotifiedDownAt = null;
try {
const siteDetailsStmt = db.prepare('SELECT last_status, last_notified_down_at FROM monitored_sites WHERE id = ?'); // Removed enable_frequent_down_notifications
const siteDetailsResult = await siteDetailsStmt.bind(id).first();
if (siteDetailsResult) {
previousStatus = siteDetailsResult.last_status || 'PENDING';
siteLastNotifiedDownAt = siteDetailsResult.last_notified_down_at;
}
} catch (error) {
// 静默处理错误
}
const NOTIFICATION_INTERVAL_SECONDS = 1 * 60 * 60; // 1 hour
try {
const response = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(15000) });
newResponseTime = Date.now() - startTime;
newStatusCode = response.status;
if (response.ok || (response.status >= 300 && response.status < 500)) { // 2xx, 3xx, and 4xx are considered UP
newStatus = 'UP';
} else {
newStatus = 'DOWN';
}
} catch (error) {
newResponseTime = Date.now() - startTime;
if (error.name === 'TimeoutError') {
newStatus = 'TIMEOUT';
} else {
newStatus = 'ERROR';
}
}
const checkTime = Math.floor(Date.now() / 1000);
const siteDisplayName = name || url;
let newSiteLastNotifiedDownAt = siteLastNotifiedDownAt; // Preserve by default
if (['DOWN', 'TIMEOUT', 'ERROR'].includes(newStatus)) {
const isFirstTimeDown = !['DOWN', 'TIMEOUT', 'ERROR'].includes(previousStatus);
if (isFirstTimeDown) {
// Site just went down
const message = `🔴 网站故障: *${siteDisplayName}* 当前状态 ${newStatus.toLowerCase()} (状态码: ${newStatusCode || '无'}).\n网址: ${url}`;
ctx.waitUntil(sendTelegramNotificationOptimized(db, message));
newSiteLastNotifiedDownAt = checkTime;
} else {
// Site is still down, check if 1-hour interval has passed for resend
const shouldResend = siteLastNotifiedDownAt === null || (checkTime - siteLastNotifiedDownAt > NOTIFICATION_INTERVAL_SECONDS);
if (shouldResend) {
const message = `🔴 网站持续故障: *${siteDisplayName}* 状态 ${newStatus.toLowerCase()} (状态码: ${newStatusCode || '无'}).\n网址: ${url}`;
ctx.waitUntil(sendTelegramNotificationOptimized(db, message));
newSiteLastNotifiedDownAt = checkTime;
}
}
} else if (newStatus === 'UP' && ['DOWN', 'TIMEOUT', 'ERROR'].includes(previousStatus)) {
// Site just came back up
const message = `✅ 网站恢复: *${siteDisplayName}* 已恢复在线!\n网址: ${url}`;
ctx.waitUntil(sendTelegramNotificationOptimized(db, message));
newSiteLastNotifiedDownAt = null; // Clear notification timestamp as site is up
}
// Update D1
try {
const updateSiteStmt = db.prepare(
'UPDATE monitored_sites SET last_checked = ?, last_status = ?, last_status_code = ?, last_response_time_ms = ?, last_notified_down_at = ? WHERE id = ?'
);
const recordHistoryStmt = db.prepare(
'INSERT INTO site_status_history (site_id, timestamp, status, status_code, response_time_ms) VALUES (?, ?, ?, ?, ?)'
);
await db.batch([
updateSiteStmt.bind(checkTime, newStatus, newStatusCode, newResponseTime, newSiteLastNotifiedDownAt, id),
recordHistoryStmt.bind(id, checkTime, newStatus, newStatusCode, newResponseTime)
]);
} catch (dbError) {
// 静默处理数据库更新错误
}
}
// ==================== 优化版本函数 ====================
// 优化版网站状态检查 - 减少超时时间,使用缓存
async function checkWebsiteStatusOptimized(site, db, ctx) {
const { id, url, name } = site;
const startTime = Date.now();
let newStatus = 'PENDING';
let newStatusCode = null;
let newResponseTime = null;
// 获取当前状态
let previousStatus = 'PENDING';
let siteLastNotifiedDownAt = null;
try {
const siteDetailsResult = await db.prepare(
'SELECT last_status, last_notified_down_at FROM monitored_sites WHERE id = ?'
).bind(id).first();
if (siteDetailsResult) {
previousStatus = siteDetailsResult.last_status || 'PENDING';
siteLastNotifiedDownAt = siteDetailsResult.last_notified_down_at;
}
} catch (error) {
// 静默处理错误
}
const NOTIFICATION_INTERVAL_SECONDS = 1 * 60 * 60; // 1小时
try {
// 优化:超时时间从15秒减少到10秒
const response = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
signal: AbortSignal.timeout(10000) // 10秒超时
});
newResponseTime = Date.now() - startTime;
newStatusCode = response.status;
if (response.ok || (response.status >= 300 && response.status < 500)) {
newStatus = 'UP';
} else {
newStatus = 'DOWN';
}
} catch (error) {
newResponseTime = Date.now() - startTime;
if (error.name === 'TimeoutError') {
newStatus = 'TIMEOUT';
} else {
newStatus = 'ERROR';
}
}
const checkTime = Math.floor(Date.now() / 1000);
const siteDisplayName = name || url;
let newSiteLastNotifiedDownAt = siteLastNotifiedDownAt;
// 通知逻辑保持不变
if (['DOWN', 'TIMEOUT', 'ERROR'].includes(newStatus)) {
const isFirstTimeDown = !['DOWN', 'TIMEOUT', 'ERROR'].includes(previousStatus);
if (isFirstTimeDown) {
const message = `🔴 网站故障: *${siteDisplayName}* 当前状态 ${newStatus.toLowerCase()} (状态码: ${newStatusCode || '无'}).\n网址: ${url}`;
ctx.waitUntil(sendTelegramNotificationOptimized(db, message));
newSiteLastNotifiedDownAt = checkTime;
} else {
const shouldResend = siteLastNotifiedDownAt === null || (checkTime - siteLastNotifiedDownAt > NOTIFICATION_INTERVAL_SECONDS);
if (shouldResend) {
const message = `🔴 网站持续故障: *${siteDisplayName}* 状态 ${newStatus.toLowerCase()} (状态码: ${newStatusCode || '无'}).\n网址: ${url}`;
ctx.waitUntil(sendTelegramNotificationOptimized(db, message));
newSiteLastNotifiedDownAt = checkTime;
}
}
} else if (newStatus === 'UP' && ['DOWN', 'TIMEOUT', 'ERROR'].includes(previousStatus)) {
const message = `✅ 网站恢复: *${siteDisplayName}* 已恢复在线!\n网址: ${url}`;
ctx.waitUntil(sendTelegramNotificationOptimized(db, message));
newSiteLastNotifiedDownAt = null;
}
// 批量更新数据库
try {
await db.batch([
db.prepare('UPDATE monitored_sites SET last_checked = ?, last_status = ?, last_status_code = ?, last_response_time_ms = ?, last_notified_down_at = ? WHERE id = ?')
.bind(checkTime, newStatus, newStatusCode, newResponseTime, newSiteLastNotifiedDownAt, id),
db.prepare('INSERT INTO site_status_history (site_id, timestamp, status, status_code, response_time_ms) VALUES (?, ?, ?, ?, ?)')
.bind(id, checkTime, newStatus, newStatusCode, newResponseTime)
]);
} catch (dbError) {
// 静默处理数据库更新错误
}
}
// 简化版VPS离线提醒检查 - 只负责持续离线提醒
async function checkVpsOfflineReminder(env, ctx) {
try {
const telegramConfig = await configCache.getTelegramConfig(env.DB);
if (!telegramConfig?.enable_notifications || !telegramConfig.bot_token || !telegramConfig.chat_id) {
return;
}
const currentTime = Math.floor(Date.now() / 1000);
const offlineThreshold = 5 * 60; // 5分钟
const reminderInterval = 60 * 60; // 1小时
// 查询持续离线的VPS(已有离线记录且仍然离线)
const { results: offlineServers } = await env.DB.prepare(`
SELECT s.id, s.name, s.last_notified_down_at, m.timestamp as last_report
FROM servers s
LEFT JOIN metrics m ON s.id = m.server_id
WHERE s.last_notified_down_at IS NOT NULL
AND (m.timestamp IS NULL OR m.timestamp < ?)
AND s.last_notified_down_at < ?
`).bind(currentTime - offlineThreshold, currentTime - reminderInterval).all();
for (const server of offlineServers) {
const serverDisplayName = server.name || server.id;
const offlineHours = Math.floor((currentTime - server.last_notified_down_at) / 3600);
const message = `🔴 VPS持续离线: 服务器 *${serverDisplayName}* 已离线${offlineHours}小时(每小时提醒)`;
ctx.waitUntil(sendTelegramNotificationOptimized(env.DB, message));
// 更新最后通知时间
ctx.waitUntil(env.DB.prepare('UPDATE servers SET last_notified_down_at = ? WHERE id = ?')
.bind(currentTime, server.id).run());
}
} catch (error) {
// 静默处理VPS离线提醒错误
}
}
// 简化版Telegram通知 - 直接发送
async function sendTelegramNotificationOptimized(db, message, priority = 'normal') {
try {
const telegramConfig = await configCache.getTelegramConfig(db);
if (!telegramConfig?.enable_notifications || !telegramConfig.bot_token || !telegramConfig.chat_id) {
return;
}
const telegramUrl = `https://api.telegram.org/bot${telegramConfig.bot_token}/sendMessage`;
const payload = {
chat_id: telegramConfig.chat_id,
text: message,
parse_mode: 'Markdown'
};
const response = await fetch(telegramUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} catch (error) {
// 静默处理Telegram通知错误
}
}
// ==================== 数据库维护系统 ====================
// 简洁的数据库维护函数
async function performDatabaseMaintenance(db) {
const thirtyDaysAgo = Math.floor(Date.now() / 1000) - (30 * 24 * 60 * 60);
try {
// 清理30天前的网站状态历史
const result = await db.prepare(
'DELETE FROM site_status_history WHERE timestamp < ?'
).bind(thirtyDaysAgo).run();
// 清理JWT缓存
cleanupJWTCache();
} catch (error) {
// 静默处理数据库维护错误
}
}
// ==================== 主函数导出 ====================
export default {
async fetch(request, env, ctx) {
// 优化:仅在必要时初始化数据库表
if (!dbInitialized) {
try {
await ensureTablesExist(env.DB, env);
dbInitialized = true;
} catch (error) {
// 静默处理数据库初始化失败
}
}
// 定时刷新VPS批量数据(在每个请求中检查)
scheduleVpsBatchFlush(env, ctx);
const url = new URL(request.url);
const path = url.pathname;
// API请求处理
if (path.startsWith('/api/')) {
return handleApiRequest(request, env, ctx);
}
// 安装脚本处理
if (path === '/install.sh') {
return handleInstallScript(request, url, env);
}
// 前端静态文件处理
return handleFrontendRequest(request, path);
},
async scheduled(event, env, ctx) {
taskCounter++;
ctx.waitUntil(
(async () => {
try {
// 智能数据库初始化 - 仅在必要时执行
if (!dbInitialized || taskCounter % 10 === 1) {
await ensureTablesExist(env.DB, env);
dbInitialized = true;
}
// ==================== 网站监控部分 ====================
const { results: sitesToCheck } = await env.DB.prepare(
'SELECT id, url, name FROM monitored_sites'
).all();
if (sitesToCheck?.length > 0) {
// 限制并发数量为5个,优化资源使用
const siteConcurrencyLimit = 5;
const sitePromises = [];
for (const site of sitesToCheck) {
sitePromises.push(checkWebsiteStatusOptimized(site, env.DB, ctx));
if (sitePromises.length >= siteConcurrencyLimit) {
await Promise.all(sitePromises);
sitePromises.length = 0;
}
}
if (sitePromises.length > 0) {
await Promise.all(sitePromises);
}
}
// ==================== VPS离线提醒检查 ====================
// 每小时执行一次,发送持续离线提醒
await checkVpsOfflineReminder(env, ctx);
// ==================== 数据库维护检查 ====================
// 每天执行一次数据库维护
if (taskCounter % 1440 === 0) {
await performDatabaseMaintenance(env.DB);
}
} catch (error) {
// 静默处理定时任务错误
}
})()
);
}
};
// ==================== 工具函数 ====================
// HTTP/HTTPS URL验证
function isValidHttpUrl(string) {
try {
const url = new URL(string);
return ['http:', 'https:'].includes(url.protocol);
} catch {
return false;
}
}
// ==================== 处理函数 ====================
// 安装脚本处理
async function handleInstallScript(request, url, env) {
const baseUrl = url.origin;
let vpsReportInterval = '60'; // 默认值
try {
// 确保app_config表存在
if (D1_SCHEMAS?.app_config) {
await env.DB.exec(D1_SCHEMAS.app_config);
} else {
}
// 使用统一的缓存查询函数
const interval = await getVpsReportInterval(env);
vpsReportInterval = interval.toString();
} catch (e) {
// 使用默认值
}
const script = `#!/bin/bash
# VPS监控脚本 - 安装程序
# 默认值
API_KEY=""
SERVER_ID=""
WORKER_URL="${baseUrl}"
INSTALL_DIR="/opt/vps-monitor"
SERVICE_NAME="vps-monitor"
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
-k|--key)
API_KEY="$2"
shift 2
;;
-s|--server)
SERVER_ID="$2"
shift 2
;;
-u|--url)
WORKER_URL="$2"
shift 2
;;
-d|--dir)
INSTALL_DIR="$2"
shift 2
;;
*)
echo "未知参数: $1"
exit 1
;;
esac
done
# 检查必要参数
if [ -z "$API_KEY" ] || [ -z "$SERVER_ID" ]; then
echo "错误: API密钥和服务器ID是必需的"
echo "用法: $0 -k API_KEY -s SERVER_ID [-u WORKER_URL] [-d INSTALL_DIR]"
exit 1
fi
# 检查权限
if [ "$(id -u)" -ne 0 ]; then
echo "错误: 此脚本需要root权限"
exit 1
fi
echo "=== VPS监控脚本安装程序 ==="
echo "安装目录: $INSTALL_DIR"
echo "Worker URL: $WORKER_URL"
# 创建安装目录
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR" || exit 1
# 创建监控脚本
cat > "$INSTALL_DIR/monitor.sh" << 'EOF'
#!/bin/bash
# 配置
API_KEY="__API_KEY__"
SERVER_ID="__SERVER_ID__"
WORKER_URL="__WORKER_URL__"
INTERVAL=${vpsReportInterval} # 上报间隔(秒)
# 日志函数
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# 获取CPU使用率
get_cpu_usage() {
cpu_usage=$(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\\([0-9.]*\\)%* id.*/\\1/" | awk '{print 100 - $1}')
cpu_load=$(cat /proc/loadavg | awk '{print $1","$2","$3}')
echo "{\"usage_percent\":$cpu_usage,\"load_avg\":[$cpu_load]}"
}
# 获取内存使用情况
get_memory_usage() {
total=$(free -k | grep Mem | awk '{print $2}')
used=$(free -k | grep Mem | awk '{print $3}')
free=$(free -k | grep Mem | awk '{print $4}')
usage_percent=$(echo "scale=1; $used * 100 / $total" | bc)
echo "{\"total\":$total,\"used\":$used,\"free\":$free,\"usage_percent\":$usage_percent}"
}
# 获取硬盘使用情况
get_disk_usage() {
disk_info=$(df -k / | tail -1)
total=$(echo "$disk_info" | awk '{print $2 / 1024 / 1024}')
used=$(echo "$disk_info" | awk '{print $3 / 1024 / 1024}')
free=$(echo "$disk_info" | awk '{print $4 / 1024 / 1024}')
usage_percent=$(echo "$disk_info" | awk '{print $5}' | tr -d '%')
echo "{\"total\":$total,\"used\":$used,\"free\":$free,\"usage_percent\":$usage_percent}"
}
# 获取网络使用情况
get_network_usage() {
# 检查是否安装了ifstat
if ! command -v ifstat &> /dev/null; then
log "ifstat未安装,无法获取网络速度"
echo "{\"upload_speed\":0,\"download_speed\":0,\"total_upload\":0,\"total_download\":0}"
return
fi
# 获取网络接口
interface=$(ip route | grep default | awk '{print $5}')
# 获取网络速度(KB/s)
network_speed=$(ifstat -i "$interface" 1 1 | tail -1)
download_speed=$(echo "$network_speed" | awk '{print $1 * 1024}')
upload_speed=$(echo "$network_speed" | awk '{print $2 * 1024}')
# 获取总流量
rx_bytes=$(cat /proc/net/dev | grep "$interface" | awk '{print $2}')
tx_bytes=$(cat /proc/net/dev | grep "$interface" | awk '{print $10}')
echo "{\"upload_speed\":$upload_speed,\"download_speed\":$download_speed,\"total_upload\":$tx_bytes,\"total_download\":$rx_bytes}"
}
# 上报数据
report_metrics() {
timestamp=$(date +%s)
cpu=$(get_cpu_usage)
memory=$(get_memory_usage)
disk=$(get_disk_usage)
network=$(get_network_usage)
data="{\"timestamp\":$timestamp,\"cpu\":$cpu,\"memory\":$memory,\"disk\":$disk,\"network\":$network}"
log "正在上报数据..."
log "API密钥: $API_KEY"
log "服务器ID: $SERVER_ID"
log "Worker URL: $WORKER_URL"
response=$(curl -s -X POST "$WORKER_URL/api/report/$SERVER_ID" \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d "$data")
if [[ "$response" == *"success"* ]]; then
log "数据上报成功"
else
log "数据上报失败: $response"
fi
}
# 安装依赖
install_dependencies() {
log "检查并安装依赖..."
# 检测包管理器
if command -v apt-get &> /dev/null; then
PKG_MANAGER="apt-get"
elif command -v yum &> /dev/null; then
PKG_MANAGER="yum"
else
log "不支持的系统,无法自动安装依赖"
return 1
fi
# 安装依赖
$PKG_MANAGER update -y
$PKG_MANAGER install -y bc curl ifstat
log "依赖安装完成"
return 0
}
# 主函数
main() {
log "VPS监控脚本启动"
# 安装依赖
install_dependencies
# 主循环
while true; do
report_metrics
sleep $INTERVAL
done
}
# 启动主函数
main
EOF
# 替换配置
sed -i "s|__API_KEY__|$API_KEY|g" "$INSTALL_DIR/monitor.sh"
sed -i "s|__SERVER_ID__|$SERVER_ID|g" "$INSTALL_DIR/monitor.sh"
sed -i "s|__WORKER_URL__|$WORKER_URL|g" "$INSTALL_DIR/monitor.sh"
# This line ensures the INTERVAL placeholder is replaced with the fetched value.
sed -i "s|^INTERVAL=.*|INTERVAL=${vpsReportInterval}|g" "$INSTALL_DIR/monitor.sh"
# 设置执行权限
chmod +x "$INSTALL_DIR/monitor.sh"
# 创建systemd服务
cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF
[Unit]
Description=VPS Monitor Service
After=network.target
[Service]
ExecStart=$INSTALL_DIR/monitor.sh
Restart=always
User=root
Group=root
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[Install]
WantedBy=multi-user.target
EOF
# 启动服务
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl start "$SERVICE_NAME"
echo "=== 安装完成 ==="
echo "服务已启动并设置为开机自启"
echo "查看服务状态: systemctl status $SERVICE_NAME"
echo "查看服务日志: journalctl -u $SERVICE_NAME -f"
`;
return new Response(script, {
headers: {
'Content-Type': 'text/plain',
'Content-Disposition': 'attachment; filename="install.sh"'
}
});
}
// 前端请求处理
function handleFrontendRequest(request, path) {
const routes = {
'/': () => new Response(getIndexHtml(), { headers: { 'Content-Type': 'text/html' } }),
'': () => new Response(getIndexHtml(), { headers: { 'Content-Type': 'text/html' } }),
'/login': () => new Response(getLoginHtml(), { headers: { 'Content-Type': 'text/html' } }),
'/login.html': () => new Response(getLoginHtml(), { headers: { 'Content-Type': 'text/html' } }),
'/admin': () => new Response(getAdminHtml(), { headers: { 'Content-Type': 'text/html' } }),
'/admin.html': () => new Response(getAdminHtml(), { headers: { 'Content-Type': 'text/html' } }),
'/css/style.css': () => new Response(getStyleCss(), { headers: { 'Content-Type': 'text/css' } }),
'/js/main.js': () => new Response(getMainJs(), { headers: { 'Content-Type': 'application/javascript' } }),
'/js/login.js': () => new Response(getLoginJs(), { headers: { 'Content-Type': 'application/javascript' } }),
'/js/admin.js': () => new Response(getAdminJs(), { headers: { 'Content-Type': 'application/javascript' } }),
'/favicon.svg': () => new Response(getFaviconSvg(), { headers: { 'Content-Type': 'image/svg+xml' } })
};
const handler = routes[path];
if (handler) {
return handler();
}
// 404页面
return new Response('Not Found', {
status: 404,
headers: { 'Content-Type': 'text/plain' }
});
}
// ==================== 前端代码 ====================
// 主页HTML
function getIndexHtml() {
return `<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VPS监控面板</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script>
// 立即设置主题,避免闪烁
(function() {
const theme = localStorage.getItem('vps-monitor-theme') || 'light';
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet" integrity="sha384-4LISF5TTJX/fLmGSxO53rV4miRxdg84mZsxmO8Rx5jGtp/LbrixFETvWa5a6sESd" crossorigin="anonymous">
<link href="/css/style.css" rel="stylesheet">
<style>
.server-row {
cursor: pointer; /* Indicate clickable rows */
}
.server-details-row {
/* display: none; /* Initially hidden - controlled by JS */ */
}
.server-details-row td {
padding: 1rem;
background-color: rgba(248, 249, 250, var(--page-opacity, 0.8)); /* Light background for details with transparency */
}
.server-details-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.detail-item {
background-color: rgba(233, 236, 239, var(--page-opacity, 0.8));
padding: 0.75rem;
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
}
/* 暗色主题下的详细信息项 */
[data-bs-theme="dark"] .detail-item {
background-color: rgba(52, 58, 64, var(--page-opacity, 0.8));
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.detail-item strong {
display: block;
margin-bottom: 0.25rem;
}
.history-bar-container {
display: inline-flex; /* Changed to inline-flex for centering within td */
flex-direction: row-reverse; /* Newest on the right */
align-items: center;
justify-content: center; /* Center the bars within this container */
height: 25px; /* Increased height */
gap: 2px; /* Space between bars */
}
.history-bar {
width: 8px; /* Increased width of each bar */
height: 100%;
/* margin-left: 1px; /* Replaced by gap */
border-radius: 1px;
}
.history-bar-up { background-color: #28a745; } /* Green */
.history-bar-down { background-color: #dc3545; } /* Red */
.history-bar-pending { background-color: #6c757d; } /* Gray */
/* Default styling for progress bar text (light mode) */
.progress span {
color: #000000; /* Black text for progress bars by default */
/* font-weight: bold; is handled by inline style in JS */
}
/* Center alignment for front-end monitoring tables */
/* Front-end server monitoring table headers and data */
.table > thead > tr > th:nth-child(1), /* 名称 */
.table > thead > tr > th:nth-child(2), /* 状态 */
.table > thead > tr > th:nth-child(3), /* CPU */
.table > thead > tr > th:nth-child(4), /* 内存 */
.table > thead > tr > th:nth-child(5), /* 硬盘 */
.table > thead > tr > th:nth-child(6), /* 上传 */
.table > thead > tr > th:nth-child(7), /* 下载 */
.table > thead > tr > th:nth-child(8), /* 总上传 */
.table > thead > tr > th:nth-child(9), /* 总下载 */
.table > thead > tr > th:nth-child(10), /* 运行时长 */
.table > thead > tr > th:nth-child(11), /* 最后更新 */
#serverTableBody tr > td:nth-child(1), /* 名称 */
#serverTableBody tr > td:nth-child(2), /* 状态 */
#serverTableBody tr > td:nth-child(3), /* CPU */
#serverTableBody tr > td:nth-child(4), /* 内存 */
#serverTableBody tr > td:nth-child(5), /* 硬盘 */
#serverTableBody tr > td:nth-child(6), /* 上传 */
#serverTableBody tr > td:nth-child(7), /* 下载 */
#serverTableBody tr > td:nth-child(8), /* 总上传 */
#serverTableBody tr > td:nth-child(9), /* 总下载 */
#serverTableBody tr > td:nth-child(10), /* 运行时长 */
#serverTableBody tr > td:nth-child(11) { /* 最后更新 */
text-align: center;
}
/* Front-end site monitoring table headers and data */
.table > thead > tr > th:nth-child(1), /* 名称 (site table) */
.table > thead > tr > th:nth-child(2), /* 状态 (site table) */
.table > thead > tr > th:nth-child(3), /* 状态码 (site table) */
.table > thead > tr > th:nth-child(4), /* 响应时间 (site table) */
.table > thead > tr > th:nth-child(5), /* 最后检查 (site table) */
.table > thead > tr > th:nth-child(6), /* 24h记录 (site table) */
#siteStatusTableBody tr > td:nth-child(1), /* 名称 */
#siteStatusTableBody tr > td:nth-child(2), /* 状态 */
#siteStatusTableBody tr > td:nth-child(3), /* 状态码 */
#siteStatusTableBody tr > td:nth-child(4), /* 响应时间 */
#siteStatusTableBody tr > td:nth-child(5), /* 最后检查 */
#siteStatusTableBody tr > td:nth-child(6) { /* 24h记录 */
text-align: center;
}
/* Backend admin tables - center align headers and data columns */
/* Admin server table headers */
.table thead tr th:nth-child(2), /* ID */
.table thead tr th:nth-child(3), /* 名称 */
.table thead tr th:nth-child(4), /* 描述 */
.table thead tr th:nth-child(5), /* 状态 */
.table thead tr th:nth-child(6), /* 最后更新 */
.table thead tr th:nth-child(9), /* 显示开关 */
/* Admin server table data */
#serverTableBody tr > td:nth-child(2), /* ID */
#serverTableBody tr > td:nth-child(3), /* 名称 */
#serverTableBody tr > td:nth-child(4), /* 描述 */
#serverTableBody tr > td:nth-child(5), /* 状态 */
#serverTableBody tr > td:nth-child(6), /* 最后更新 */
#serverTableBody tr > td:nth-child(9) { /* 显示开关 */
text-align: center;
}
/* Admin site table headers */
.table thead tr th:nth-child(2), /* 名称 */
.table thead tr th:nth-child(4), /* 状态 */
.table thead tr th:nth-child(5), /* 状态码 */
.table thead tr th:nth-child(6), /* 响应时间 */
.table thead tr th:nth-child(7), /* 最后检查 */
.table thead tr th:nth-child(8), /* 显示开关 */
/* Admin site table data */
#siteTableBody tr > td:nth-child(2), /* 名称 */
#siteTableBody tr > td:nth-child(4), /* 状态 */
#siteTableBody tr > td:nth-child(5), /* 状态码 */
#siteTableBody tr > td:nth-child(6), /* 响应时间 */
#siteTableBody tr > td:nth-child(7), /* 最后检查 */
#siteTableBody tr > td:nth-child(8) { /* 显示开关 */
text-align: center;
}
/* Dark Theme Adjustments */
[data-bs-theme="dark"] body {
background-color: #212529 !important; /* Bootstrap dark bg */
color: #ffffff !important; /* White text for dark mode */
}
[data-bs-theme="dark"] h1, [data-bs-theme="dark"] h2, [data-bs-theme="dark"] h3, [data-bs-theme="dark"] h4, [data-bs-theme="dark"] h5, [data-bs-theme="dark"] h6 {
color: #ffffff; /* White color for headings */
}
[data-bs-theme="dark"] a:not(.btn):not(.nav-link):not(.dropdown-item):not(.navbar-brand) {
color: #87cefa; /* LightSkyBlue for general links, good contrast on dark */
}
[data-bs-theme="dark"] a:not(.btn):not(.nav-link):not(.dropdown-item):not(.navbar-brand):hover {
color: #add8e6; /* Lighter blue on hover */
}
[data-bs-theme="dark"] .navbar-dark {
background-color: #343a40 !important; /* Darker navbar */
}
[data-bs-theme="dark"] .table {
color: #ffffff; /* White table text */
}
[data-bs-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > * {
--bs-table-accent-bg: rgba(255, 255, 255, 0.05); /* Darker stripe */
color: #ffffff; /* Ensure text in striped rows is white */
}
[data-bs-theme="dark"] .table-hover > tbody > tr:hover > * {
--bs-table-accent-bg: rgba(255, 255, 255, 0.075); /* Darker hover */
color: #ffffff; /* Ensure text in hovered rows is white */
}
[data-bs-theme="dark"] .server-details-row td {
background-color: rgba(33, 37, 41, var(--page-opacity, 0.8)); /* Darker details background with transparency */
border-top: 1px solid #495057;
}
[data-bs-theme="dark"] .detail-item {
background-color: rgba(52, 58, 64, var(--page-opacity, 0.8)); /* Darker detail item background with transparency */
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e0e0e0; /* Consistent text color */
}
[data-bs-theme="dark"] .progress {
background-color: #495057; /* Darker progress bar background */
}
[data-bs-theme="dark"] .progress span { /* Text on progress bar */
color: #000000 !important; /* Black text for progress bars */
text-shadow: none; /* Remove shadow for black text or use a very light one if needed */
}
[data-bs-theme="dark"] .footer.bg-light {
background-color: #343a40 !important; /* Darker footer */
border-top: 1px solid #495057;
}
/* 已移至统一的底部版权样式中 */
[data-bs-theme="dark"] .alert-info {
background-color: #17a2b8; /* Bootstrap info color, adjust if needed */
color: #fff;
border-color: #17a2b8;
}
[data-bs-theme="dark"] .btn-outline-light {
color: #f8f9fa;
border-color: #f8f9fa;
}
[data-bs-theme="dark"] .btn-outline-light:hover {
color: #212529;
background-color: #f8f9fa;
}
[data-bs-theme="dark"] .card {
background-color: #343a40;
border: 1px solid #495057;
}
[data-bs-theme="dark"] .card-header {
background-color: #495057;
border-bottom: 1px solid #5b6167;
}
[data-bs-theme="dark"] .modal-content {
background-color: #343a40;
color: #ffffff; /* White modal text */
}
[data-bs-theme="dark"] .modal-header {
border-bottom-color: #495057;
}
[data-bs-theme="dark"] .modal-footer {
border-top-color: #495057;
}
[data-bs-theme="dark"] .form-control {
background-color: #495057;
color: #ffffff; /* White form control text */
border-color: #5b6167;
}
[data-bs-theme="dark"] .form-control:focus {
background-color: #495057;
color: #ffffff; /* White form control text on focus */
border-color: #86b7fe; /* Bootstrap focus color */
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
[data-bs-theme="dark"] .form-label {
color: #adb5bd;
}
[data-bs-theme="dark"] .text-danger { /* Ensure custom text-danger is visible */
color: #ff8888 !important;
}
/* 通用text-muted主题适配 */
.text-muted { color: #212529 !important; }
[data-bs-theme="dark"] .text-muted { color: #ffffff !important; }
[data-bs-theme="dark"] span[style*="color: #000"] { /* For inline styled black text */
color: #ffffff !important; /* Change to white */
}
/* 拖拽排序样式 */
.server-row-draggable, .site-row-draggable {
transition: all 0.2s ease;
}
.server-row-draggable:hover, .site-row-draggable:hover {
background-color: rgba(0, 123, 255, 0.1) !important;
}
.server-row-draggable.drag-over-top, .site-row-draggable.drag-over-top {
border-top: 3px solid #007bff !important;
background-color: rgba(0, 123, 255, 0.1) !important;
}
.server-row-draggable.drag-over-bottom, .site-row-draggable.drag-over-bottom {
border-bottom: 3px solid #007bff !important;
background-color: rgba(0, 123, 255, 0.1) !important;
}
.server-row-draggable[draggable="true"], .site-row-draggable[draggable="true"] {
cursor: grab;
}
.server-row-draggable[draggable="true"]:active, .site-row-draggable[draggable="true"]:active {
cursor: grabbing;
}
/* 暗色主题下的拖拽样式 */
[data-bs-theme="dark"] .server-row-draggable:hover,
[data-bs-theme="dark"] .site-row-draggable:hover {
background-color: rgba(13, 110, 253, 0.2) !important;
}
[data-bs-theme="dark"] .server-row-draggable.drag-over-top,
[data-bs-theme="dark"] .site-row-draggable.drag-over-top {
border-top: 3px solid #0d6efd !important;
background-color: rgba(13, 110, 253, 0.2) !important;
}
[data-bs-theme="dark"] .server-row-draggable.drag-over-bottom,
[data-bs-theme="dark"] .site-row-draggable.drag-over-bottom {
border-bottom: 3px solid #0d6efd !important;
background-color: rgba(13, 110, 253, 0.2) !important;
}
</style>
</head>
<body>
<!-- Toast容器 -->
<div id="toastContainer" class="toast-container"></div>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="/">
<svg class="me-2" width="32" height="32" viewBox="0 0 32 32">
<defs>
<radialGradient id="navBg1" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#0277bd" stop-opacity="0.8"/>
</radialGradient>
<linearGradient id="navEcg1" x1="0%" x2="100%">
<stop offset="0%" stop-color="#f08"/>
<stop offset="50%" stop-color="#0f8"/>
<stop offset="100%" stop-color="#80f"/>
</linearGradient>
</defs>
<circle cx="16" cy="16" r="15" fill="url(#navBg1)" stroke="#0277bd" stroke-width="1.5"/>
<circle cx="16" cy="16" r="13" fill="none" stroke="#fff" stroke-width="1" opacity="0.4"/>
<line x1="4" y1="16" x2="28" y2="16" stroke="#b3e5fc" stroke-width="0.5" opacity="0.8"/>
<path id="navP1" d="M4 16L8 16L9 15L10 17L11 14L12 18L13 10L14 22L15 16L28 16" fill="none" stroke="url(#navEcg1)" stroke-width="2.8"/>
<path d="M4 16L8 16L9 15L10 17L11 14L12 18L13 10L14 22L15 16L28 16" fill="none" stroke="#fff" stroke-width="1.2" opacity="0.7"/>
<circle r="1.5" fill="#fff">
<animateMotion dur="2s" repeatCount="indefinite">
<mpath href="#navP1"/>
</animateMotion>
</circle>
<circle cx="16" cy="16" r="8" fill="none" stroke="#f08" stroke-width="0.5" opacity="0.6">
<animate attributeName="r" values="8;12;8" dur="3s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.6;0;0.6" dur="3s" repeatCount="indefinite"/>
</circle>
</svg>
VPS监控面板
</a>
<div class="d-flex align-items-center">
<a href="https://github.com/kadidalax/cf-vps-monitor" target="_blank" rel="noopener noreferrer" class="btn btn-outline-light btn-sm me-2" title="GitHub Repository">
<i class="bi bi-github"></i>
</a>
<button id="themeToggler" class="btn btn-outline-light btn-sm me-2" title="切换主题">
<i class="bi bi-moon-stars-fill"></i>
</button>
<a class="nav-link text-light" id="adminAuthLink" href="/login.html" style="white-space: nowrap;">管理员登录</a>
</div>
</div>
</nav>
<!-- 单一主卡片容器 -->
<div class="container mt-4">
<div class="card shadow-sm">
<div class="card-body">
<!-- 服务器监控部分 -->
<div class="mb-4">
<h5 class="card-title mb-3">
<i class="bi bi-server me-2"></i>服务器监控
</h5>
<div id="noServers" class="alert alert-info d-none">
暂无服务器数据,请先登录管理后台添加服务器。
</div>
<!-- 桌面端表格视图 -->
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>名称</th>
<th>状态</th>
<th>CPU</th>
<th>内存</th>
<th>硬盘</th>
<th>上传</th>
<th>下载</th>
<th>总上传</th>
<th>总下载</th>
<th>运行时长</th>
<th>最后更新</th>
</tr>
</thead>
<tbody id="serverTableBody">
<tr>
<td colspan="11" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片视图 -->
<div class="mobile-card-container" id="mobileServerContainer">
<div class="text-center p-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<div class="mt-2">加载服务器数据中...</div>
</div>
</div>
</div>
<!-- 分隔线 -->
<hr class="my-4">
<!-- 网站监控部分 -->
<div>
<h5 class="card-title mb-3">
<i class="bi bi-globe me-2"></i>网站在线状态
</h5>
<div id="noSites" class="alert alert-info d-none">
暂无监控网站数据。
</div>
<!-- 桌面端表格视图 -->
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>名称</th>
<th>状态</th>
<th>状态码</th>
<th>响应时间 (ms)</th>
<th>最后检查</th>
<th>24h记录</th>
</tr>
</thead>
<tbody id="siteStatusTableBody">
<tr>
<td colspan="6" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片视图 -->
<div class="mobile-card-container" id="mobileSiteContainer">
<div class="text-center p-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<div class="mt-2">加载网站数据中...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- End Website Status Section -->
<!-- Server Detailed row template (hidden by default) -->
<template id="serverDetailsTemplate">
<tr class="server-details-row d-none">
<td colspan="11">
<div class="server-details-content">
<!-- Detailed metrics will be populated here by JavaScript -->
</div>
</td>
</tr>
</template>
<footer class="footer fixed-bottom py-2 bg-light border-top">
<div class="container text-center">
<span class="text-muted small">VPS监控面板 © 2025</span>
<a href="https://github.com/kadidalax/cf-vps-monitor" target="_blank" rel="noopener noreferrer" class="ms-3 text-muted" title="GitHub Repository">
<i class="bi bi-github"></i>
</a>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script src="/js/main.js"></script>
</body>
</html>`;
}
function getLoginHtml() {
return `<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - VPS监控面板</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script>
// 立即设置主题,避免闪烁
(function() {
const theme = localStorage.getItem('vps-monitor-theme') || 'light';
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet" integrity="sha384-4LISF5TTJX/fLmGSxO53rV4miRxdg84mZsxmO8Rx5jGtp/LbrixFETvWa5a6sESd" crossorigin="anonymous">
<link href="/css/style.css" rel="stylesheet">
<style>
.server-row {
cursor: pointer; /* Indicate clickable rows */
}
.server-details-row {
/* display: none; /* Initially hidden - controlled by JS */ */
}
.server-details-row td {
padding: 1rem;
background-color: rgba(248, 249, 250, var(--page-opacity, 0.8)); /* Light background for details with transparency */
}
/* 暗色主题下的服务器详细信息行 */
[data-bs-theme="dark"] .server-details-row td {
background-color: rgba(33, 37, 41, var(--page-opacity, 0.8));
}
.server-details-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.detail-item {
background-color: rgba(233, 236, 239, var(--page-opacity, 0.8));
padding: 0.75rem;
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
}
/* 暗色主题下的详细信息项 */
[data-bs-theme="dark"] .detail-item {
background-color: rgba(52, 58, 64, var(--page-opacity, 0.8));
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.detail-item strong {
display: block;
margin-bottom: 0.25rem;
}
.history-bar-container {
display: inline-flex; /* Changed to inline-flex for centering within td */
flex-direction: row-reverse; /* Newest on the right */
align-items: center;
justify-content: center; /* Center the bars within this container */
height: 25px; /* Increased height */
gap: 2px; /* Space between bars */
}
.history-bar {
width: 8px; /* Increased width of each bar */
height: 100%;
/* margin-left: 1px; /* Replaced by gap */
border-radius: 1px;
}
.history-bar-up { background-color: #28a745; } /* Green */
.history-bar-down { background-color: #dc3545; } /* Red */
.history-bar-pending { background-color: #6c757d; } /* Gray */
/* Default styling for progress bar text (light mode) */
.progress span {
color: #000000; /* Black text for progress bars by default */
/* font-weight: bold; is handled by inline style in JS */
}
/* Center the "24h记录" (site table) and "上传" (server table) headers and their data cells */
.table > thead > tr > th:nth-child(6), /* Targets 6th header in both tables */
#siteStatusTableBody tr > td:nth-child(6), /* Targets 6th data cell in site status table */
#serverTableBody tr > td:nth-child(6) { /* Targets 6th data cell in server status table */
text-align: center;
}
/* Dark Theme Adjustments */
[data-bs-theme="dark"] body {
background-color: #212529; /* Bootstrap dark bg */
color: #ffffff; /* White text for dark mode */
}
[data-bs-theme="dark"] h1, [data-bs-theme="dark"] h2, [data-bs-theme="dark"] h3, [data-bs-theme="dark"] h4, [data-bs-theme="dark"] h5, [data-bs-theme="dark"] h6 {
color: #ffffff; /* White color for headings */
}
[data-bs-theme="dark"] a:not(.btn):not(.nav-link):not(.dropdown-item):not(.navbar-brand) {
color: #87cefa; /* LightSkyBlue for general links, good contrast on dark */
}
[data-bs-theme="dark"] a:not(.btn):not(.nav-link):not(.dropdown-item):not(.navbar-brand):hover {
color: #add8e6; /* Lighter blue on hover */
}
[data-bs-theme="dark"] .navbar-dark {
background-color: #343a40 !important; /* Darker navbar */
}
[data-bs-theme="dark"] .table {
color: #ffffff; /* White table text */
}
[data-bs-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > * {
--bs-table-accent-bg: rgba(255, 255, 255, 0.05); /* Darker stripe */
color: #ffffff; /* Ensure text in striped rows is white */
}
[data-bs-theme="dark"] .table-hover > tbody > tr:hover > * {
--bs-table-accent-bg: rgba(255, 255, 255, 0.075); /* Darker hover */
color: #ffffff; /* Ensure text in hovered rows is white */
}
[data-bs-theme="dark"] .server-details-row td {
background-color: rgba(33, 37, 41, var(--page-opacity, 0.8)); /* Darker details background with transparency */
border-top: 1px solid #495057;
}
[data-bs-theme="dark"] .detail-item {
background-color: rgba(52, 58, 64, var(--page-opacity, 0.8)); /* Darker detail item background with transparency */
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e0e0e0; /* Consistent text color */
}
[data-bs-theme="dark"] .progress {
background-color: #495057; /* Darker progress bar background */
}
[data-bs-theme="dark"] .progress span { /* Text on progress bar */
color: #000000 !important; /* Black text for progress bars */
text-shadow: none; /* Remove shadow for black text or use a very light one if needed */
}
[data-bs-theme="dark"] .footer.bg-light {
background-color: #343a40 !important; /* Darker footer */
border-top: 1px solid #495057;
}
/* 已移至统一的底部版权样式中 */
[data-bs-theme="dark"] .alert-info {
background-color: #17a2b8; /* Bootstrap info color, adjust if needed */
color: #fff;
border-color: #17a2b8;
}
[data-bs-theme="dark"] .btn-outline-light {
color: #f8f9fa;
border-color: #f8f9fa;
}
[data-bs-theme="dark"] .btn-outline-light:hover {
color: #212529;
background-color: #f8f9fa;
}
[data-bs-theme="dark"] .card {
background-color: #343a40;
border: 1px solid #495057;
}
[data-bs-theme="dark"] .card-header {
background-color: #495057;
border-bottom: 1px solid #5b6167;
}
[data-bs-theme="dark"] .modal-content {
background-color: #343a40;
color: #ffffff; /* White modal text */
}
[data-bs-theme="dark"] .modal-header {
border-bottom-color: #495057;
}
[data-bs-theme="dark"] .modal-footer {
border-top-color: #495057;
}
[data-bs-theme="dark"] .form-control {
background-color: #495057;
color: #ffffff; /* White form control text */
border-color: #5b6167;
}
[data-bs-theme="dark"] .form-control:focus {
background-color: #495057;
color: #ffffff; /* White form control text on focus */
border-color: #86b7fe; /* Bootstrap focus color */
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
[data-bs-theme="dark"] .form-label {
color: #adb5bd;
}
[data-bs-theme="dark"] .text-danger { /* Ensure custom text-danger is visible */
color: #ff8888 !important;
}
/* 已移至统一的通用text-muted样式中 */
[data-bs-theme="dark"] span[style*="color: #000"] { /* For inline styled black text */
color: #ffffff !important; /* Change to white */
}
</style>
</head>
<body>
<!-- Toast容器 -->
<div id="toastContainer" class="toast-container"></div>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="/">
<svg class="me-2" width="32" height="32" viewBox="0 0 32 32">
<defs>
<radialGradient id="navBg2" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#0277bd" stop-opacity="0.8"/>
</radialGradient>
<linearGradient id="navEcg2" x1="0%" x2="100%">
<stop offset="0%" stop-color="#f08"/>
<stop offset="50%" stop-color="#0f8"/>
<stop offset="100%" stop-color="#80f"/>
</linearGradient>
</defs>
<circle cx="16" cy="16" r="15" fill="url(#navBg2)" stroke="#0277bd" stroke-width="1.5"/>
<circle cx="16" cy="16" r="13" fill="none" stroke="#fff" stroke-width="1" opacity="0.4"/>
<line x1="4" y1="16" x2="28" y2="16" stroke="#b3e5fc" stroke-width="0.5" opacity="0.8"/>
<path id="navP2" d="M4 16L8 16L9 15L10 17L11 14L12 18L13 10L14 22L15 16L28 16" fill="none" stroke="url(#navEcg2)" stroke-width="2.8"/>
<path d="M4 16L8 16L9 15L10 17L11 14L12 18L13 10L14 22L15 16L28 16" fill="none" stroke="#fff" stroke-width="1.2" opacity="0.7"/>
<circle r="1.5" fill="#fff">
<animateMotion dur="2s" repeatCount="indefinite">
<mpath href="#navP2"/>
</animateMotion>
</circle>
<circle cx="16" cy="16" r="8" fill="none" stroke="#f08" stroke-width="0.5" opacity="0.6">
<animate attributeName="r" values="8;12;8" dur="3s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.6;0;0.6" dur="3s" repeatCount="indefinite"/>
</circle>
</svg>
VPS监控面板
</a>
<div class="d-flex align-items-center">
<button id="themeToggler" class="btn btn-outline-light btn-sm me-2" title="切换主题">
<i class="bi bi-moon-stars-fill"></i>
</button>
<a class="nav-link text-light" href="/" style="white-space: nowrap;">返回首页</a>
</div>
</div>
</nav>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="card-title mb-0">管理员登录</h4>
</div>
<div class="card-body">
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" id="password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">登录</button>
</div>
</form>
</div>
<div class="card-footer text-muted">
<small id="defaultCredentialsInfo">加载默认凭据信息中...</small>
</div>
</div>
</div>
</div>
</div>
<footer class="footer fixed-bottom py-2 bg-light border-top">
<div class="container text-center">
<span class="text-muted small">VPS监控面板 © 2025</span>
<a href="https://github.com/kadidalax/cf-vps-monitor" target="_blank" rel="noopener noreferrer" class="ms-3 text-muted" title="GitHub Repository">
<i class="bi bi-github"></i>
</a>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script src="/js/login.js"></script>
</body>
</html>`;
}
function getAdminHtml() {
return `<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - VPS监控面板</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script>
// 立即设置主题,避免闪烁
(function() {
const theme = localStorage.getItem('vps-monitor-theme') || 'light';
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet" integrity="sha384-4LISF5TTJX/fLmGSxO53rV4miRxdg84mZsxmO8Rx5jGtp/LbrixFETvWa5a6sESd" crossorigin="anonymous">
<link href="/css/style.css" rel="stylesheet">
</head>
<body>
<!-- Toast容器 -->
<div id="toastContainer" class="toast-container"></div>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="/">
<svg class="me-2" width="32" height="32" viewBox="0 0 32 32">
<defs>
<radialGradient id="navBg3" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#0277bd" stop-opacity="0.8"/>
</radialGradient>
<linearGradient id="navEcg3" x1="0%" x2="100%">
<stop offset="0%" stop-color="#f08"/>
<stop offset="50%" stop-color="#0f8"/>
<stop offset="100%" stop-color="#80f"/>
</linearGradient>
</defs>
<circle cx="16" cy="16" r="15" fill="url(#navBg3)" stroke="#0277bd" stroke-width="1.5"/>
<circle cx="16" cy="16" r="13" fill="none" stroke="#fff" stroke-width="1" opacity="0.4"/>
<line x1="4" y1="16" x2="28" y2="16" stroke="#b3e5fc" stroke-width="0.5" opacity="0.8"/>
<path id="navP3" d="M4 16L8 16L9 15L10 17L11 14L12 18L13 10L14 22L15 16L28 16" fill="none" stroke="url(#navEcg3)" stroke-width="2.8"/>
<path d="M4 16L8 16L9 15L10 17L11 14L12 18L13 10L14 22L15 16L28 16" fill="none" stroke="#fff" stroke-width="1.2" opacity="0.7"/>
<circle r="1.5" fill="#fff">
<animateMotion dur="2s" repeatCount="indefinite">
<mpath href="#navP3"/>
</animateMotion>
</circle>
<circle cx="16" cy="16" r="8" fill="none" stroke="#f08" stroke-width="0.5" opacity="0.6">
<animate attributeName="r" values="8;12;8" dur="3s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.6;0;0.6" dur="3s" repeatCount="indefinite"/>
</circle>
</svg>
VPS监控面板
</a>
<div class="d-flex align-items-center flex-wrap">
<a class="nav-link text-light me-2" href="/" style="white-space: nowrap;">返回首页</a>
<!-- PC端直接显示的按钮 -->
<a href="https://github.com/kadidalax/cf-vps-monitor" target="_blank" rel="noopener noreferrer" class="btn btn-outline-light btn-sm me-2 desktop-only" title="GitHub Repository">
<i class="bi bi-github"></i>
</a>
<button id="themeToggler" class="btn btn-outline-light btn-sm me-2" title="切换主题">
<i class="bi bi-moon-stars-fill"></i>
</button>
<button class="btn btn-outline-light btn-sm me-1 desktop-only" id="changePasswordBtnDesktop" title="修改密码">
<i class="bi bi-key"></i>
</button>
<!-- 移动端下拉菜单 -->
<div class="dropdown me-1 mobile-only">
<button class="btn btn-outline-light btn-sm dropdown-toggle" type="button" id="adminMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" title="更多选项">
<i class="bi bi-three-dots"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenuDropdown">
<li><a class="dropdown-item" href="https://github.com/kadidalax/cf-vps-monitor" target="_blank" rel="noopener noreferrer">
<i class="bi bi-github me-2"></i>GitHub
</a></li>
<li><button class="dropdown-item" id="changePasswordBtn">
<i class="bi bi-key me-2"></i>修改密码
</button></li>
</ul>
</div>
<button id="logoutBtn" class="btn btn-outline-light btn-sm" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">退出</button>
</div>
</div>
</nav>
<!-- 单一主管理卡片容器 -->
<div class="container mt-4">
<div class="card shadow-sm">
<div class="card-body">
<!-- 服务器管理部分 -->
<div class="mb-4">
<div class="admin-header-row mb-3">
<div class="admin-header-title">
<h5 class="card-title mb-0">
<i class="bi bi-server me-2"></i>服务器管理
</h5>
</div>
<div class="admin-header-content">
<!-- VPS Data Update Frequency Form -->
<form id="globalSettingsFormPartial" class="admin-settings-form">
<div class="settings-group">
<label for="vpsReportInterval" class="form-label">VPS数据更新频率 (秒):</label>
<div class="input-group">
<input type="number" class="form-control form-control-sm" id="vpsReportInterval" placeholder="例如: 60" min="1" style="width: 100px;">
<button type="button" id="saveVpsReportIntervalBtn" class="btn btn-info btn-sm">保存频率</button>
</div>
</div>
</form>
<!-- Action Buttons Group -->
<div class="admin-actions-group">
<!-- Server Auto Sort Dropdown -->
<div class="dropdown me-2">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="serverAutoSortDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-sort-alpha-down"></i> 自动排序
</button>
<ul class="dropdown-menu" aria-labelledby="serverAutoSortDropdown">
<li><a class="dropdown-item active" href="#" onclick="autoSortServers('custom')">自定义排序</a></li>
<li><a class="dropdown-item" href="#" onclick="autoSortServers('name')">按名称排序</a></li>
<li><a class="dropdown-item" href="#" onclick="autoSortServers('status')">按状态排序</a></li>
</ul>
</div>
<!-- Add Server Button -->
<button id="addServerBtn" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> 添加服务器
</button>
</div>
</div>
</div>
<!-- 桌面端表格视图 -->
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>排序</th>
<th>ID</th>
<th>名称</th>
<th>描述</th>
<th>状态</th>
<th>最后更新</th>
<th>API密钥</th>
<th>VPS脚本</th>
<th>显示 <i class="bi bi-question-circle text-muted" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="是否对游客展示此服务器"></i></th>
<th>操作</th>
</tr>
</thead>
<tbody id="serverTableBody">
<tr>
<td colspan="10" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片视图 -->
<div class="mobile-card-container" id="mobileAdminServerContainer">
<div class="text-center p-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<div class="mt-2">加载服务器数据中...</div>
</div>
</div>
</div>
<!-- 分隔线 -->
<hr class="my-4">
<!-- 网站监控管理部分 -->
<div>
<div class="admin-header-row mb-3">
<div class="admin-header-title">
<h5 class="card-title mb-0">
<i class="bi bi-globe me-2"></i>网站监控管理
</h5>
</div>
<div class="admin-header-content">
<!-- Action Buttons Group - 桌面端隐藏,移动端显示居中按钮 -->
<div class="admin-actions-group desktop-only">
<!-- Site Auto Sort Dropdown -->
<div class="dropdown me-2">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="siteAutoSortDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-sort-alpha-down"></i> 自动排序
</button>
<ul class="dropdown-menu" aria-labelledby="siteAutoSortDropdown">
<li><a class="dropdown-item active" href="#" onclick="autoSortSites('custom')">自定义排序</a></li>
<li><a class="dropdown-item" href="#" onclick="autoSortSites('name')">按名称排序</a></li>
<li><a class="dropdown-item" href="#" onclick="autoSortSites('url')">按URL排序</a></li>
<li><a class="dropdown-item" href="#" onclick="autoSortSites('status')">按状态排序</a></li>
</ul>
</div>
<button id="addSiteBtn" class="btn btn-success">
<i class="bi bi-plus-circle"></i> 添加监控网站
</button>
</div>
</div>
</div>
<!-- 桌面端表格视图 -->
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>排序</th>
<th>名称</th>
<th>URL</th>
<th>状态</th>
<th>状态码</th>
<th>响应时间 (ms)</th>
<th>最后检查</th>
<th>显示 <i class="bi bi-question-circle text-muted" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="是否对游客展示此网站"></i></th>
<th>操作</th>
</tr>
</thead>
<tbody id="siteTableBody">
<tr>
<td colspan="9" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片视图 -->
<div class="mobile-card-container" id="mobileAdminSiteContainer">
<div class="text-center p-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<div class="mt-2">加载网站数据中...</div>
</div>
</div>
</div>
<!-- 分隔线 -->
<hr class="my-4">
<!-- Telegram 通知设置部分 -->
<div>
<h5 class="card-title mb-3">
<i class="bi bi-telegram me-2"></i>Telegram 通知设置
</h5>
<form id="telegramSettingsForm">
<div class="mb-3">
<label for="telegramBotToken" class="form-label">Bot Token</label>
<input type="text" class="form-control" id="telegramBotToken" placeholder="请输入 Telegram Bot Token">
</div>
<div class="mb-3">
<label for="telegramChatId" class="form-label">Chat ID</label>
<input type="text" class="form-control" id="telegramChatId" placeholder="请输入接收通知的 Chat ID">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="enableTelegramNotifications">
<label class="form-check-label" for="enableTelegramNotifications">
启用通知
</label>
</div>
<button type="button" id="saveTelegramSettingsBtn" class="btn btn-info">保存Telegram设置</button>
</form>
</div>
<!-- 分隔线 -->
<hr class="my-4">
<!-- 背景设置部分 -->
<div>
<h5 class="card-title mb-3">
<i class="bi bi-image me-2"></i>背景设置
</h5>
<form id="backgroundSettingsForm">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="enableCustomBackground">
<label class="form-check-label" for="enableCustomBackground">
启用自定义背景
</label>
</div>
<div class="mb-3">
<label for="backgroundImageUrl" class="form-label">背景图片URL</label>
<input type="url" class="form-control" id="backgroundImageUrl" placeholder="请输入背景图片URL (必须以https://开头)">
<div class="form-text">建议使用高质量图片,支持JPG、PNG格式</div>
</div>
<div class="mb-3">
<label for="pageOpacity" class="form-label">页面透明度: <span id="opacityValue">80</span>%</label>
<input type="range" class="form-range" id="pageOpacity" min="0" max="100" value="80" step="1">
<div class="form-text">调整页面元素的透明度,数值越小越透明</div>
</div>
<button type="button" id="saveBackgroundSettingsBtn" class="btn btn-info">保存背景设置</button>
</form>
</div>
</div>
</div>
</div>
<!-- Global Settings Section (Now integrated above Server Management List) -->
<!-- The form is now part of the header for Server Management -->
<!-- End Global Settings Section -->
<!-- 服务器模态框 -->
<div class="modal fade" id="serverModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="serverModalTitle">添加服务器</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="serverForm">
<input type="hidden" id="serverId">
<div class="mb-3">
<label for="serverName" class="form-label">服务器名称</label>
<input type="text" class="form-control" id="serverName" required>
</div>
<div class="mb-3">
<label for="serverDescription" class="form-label">描述(可选)</label>
<textarea class="form-control" id="serverDescription" rows="2"></textarea>
</div>
<!-- Removed serverEnableFrequentNotifications checkbox -->
<div id="serverIdDisplayGroup" class="mb-3 d-none">
<label for="serverIdDisplay" class="form-label">服务器ID</label>
<div class="input-group">
<input type="text" class="form-control" id="serverIdDisplay" readonly>
<button class="btn btn-outline-secondary" type="button" id="copyServerIdBtn">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div id="apiKeyGroup" class="mb-3 d-none">
<label for="apiKey" class="form-label">API密钥</label>
<div class="input-group">
<input type="text" class="form-control" id="apiKey" readonly>
<button class="btn btn-outline-secondary" type="button" id="copyApiKeyBtn">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div id="workerUrlDisplayGroup" class="mb-3 d-none">
<label for="workerUrlDisplay" class="form-label">Worker 地址</label>
<div class="input-group">
<input type="text" class="form-control" id="workerUrlDisplay" readonly>
<button class="btn btn-outline-secondary" type="button" id="copyWorkerUrlBtn">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary" id="saveServerBtn">保存</button>
</div>
</div>
</div>
</div>
<!-- 网站监控模态框 -->
<div class="modal fade" id="siteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="siteModalTitle">添加监控网站</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="siteForm">
<input type="hidden" id="siteId">
<div class="mb-3">
<label for="siteName" class="form-label">网站名称(可选)</label>
<input type="text" class="form-control" id="siteName">
</div>
<div class="mb-3">
<label for="siteUrl" class="form-label">网站URL</label>
<input type="url" class="form-control" id="siteUrl" placeholder="https://example.com" required>
</div>
<!-- Removed siteEnableFrequentNotifications checkbox -->
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary" id="saveSiteBtn">保存</button>
</div>
</div>
</div>
</div>
<!-- 服务器删除确认模态框 -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">确认删除</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>确定要删除服务器 "<span id="deleteServerName"></span>" 吗?</p>
<p class="text-danger">此操作不可逆,所有相关的监控数据也将被删除。</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">删除</button>
</div>
</div>
</div>
</div>
<!-- 网站删除确认模态框 -->
<div class="modal fade" id="deleteSiteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">确认删除网站监控</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>确定要停止监控网站 "<span id="deleteSiteName"></span>" (<span id="deleteSiteUrl"></span>) 吗?</p>
<p class="text-danger">此操作不可逆。</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmDeleteSiteBtn">删除</button>
</div>
</div>
</div>
</div>
<!-- 修改密码模态框 -->
<div class="modal fade" id="passwordModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">修改密码</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="passwordForm">
<div class="mb-3">
<label for="currentPassword" class="form-label">当前密码</label>
<input type="password" class="form-control" id="currentPassword" required>
</div>
<div class="mb-3">
<label for="newPassword" class="form-label">新密码</label>
<input type="password" class="form-control" id="newPassword" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">确认新密码</label>
<input type="password" class="form-control" id="confirmPassword" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="savePasswordBtn">保存</button>
</div>
</div>
</div>
</div>
<footer class="footer fixed-bottom py-2 bg-light border-top">
<div class="container text-center">
<span class="text-muted small">VPS监控面板 © 2025</span>
<a href="https://github.com/kadidalax/cf-vps-monitor" target="_blank" rel="noopener noreferrer" class="ms-3 text-muted" title="GitHub Repository">
<i class="bi bi-github"></i>
</a>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script src="/js/admin.js"></script>
</body>
</html>`;
}
function getFaviconSvg() {
return `<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="bg" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#0277bd" stop-opacity="0.8"/>
</radialGradient>
<linearGradient id="ecg" x1="0%" x2="100%">
<stop offset="0%" stop-color="#f08"/>
<stop offset="50%" stop-color="#0f8"/>
<stop offset="100%" stop-color="#80f"/>
</linearGradient>
</defs>
<circle cx="16" cy="16" r="15" fill="url(#bg)" stroke="#0277bd" stroke-width="1.5"/>
<circle cx="16" cy="16" r="13" fill="none" stroke="#fff" stroke-width="1" opacity="0.4"/>
<line x1="4" y1="16" x2="28" y2="16" stroke="#b3e5fc" stroke-width="0.5" opacity="0.8"/>
<path id="p" d="M4 16L8 16L9 15L10 17L11 14L12 18L13 10L14 22L15 16L28 16" fill="none" stroke="url(#ecg)" stroke-width="2.8"/>
<path d="M4 16L8 16L9 15L10 17L11 14L12 18L13 10L14 22L15 16L28 16" fill="none" stroke="#fff" stroke-width="1.2" opacity="0.7"/>
<circle r="1.5" fill="#fff">
<animateMotion dur="2s" repeatCount="indefinite">
<mpath href="#p"/>
</animateMotion>
</circle>
<circle cx="16" cy="16" r="8" fill="none" stroke="#f08" stroke-width="0.5" opacity="0.6">
<animate attributeName="r" values="8;12;8" dur="3s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.6;0;0.6" dur="3s" repeatCount="indefinite"/>
</circle>
</svg>`;
}
function getStyleCss() {
return `/* 全局样式 */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.footer {
margin-top: auto;
}
/* 图表容器 */
.chart-container {
position: relative;
height: 200px;
width: 100%;
}
/* 卡片样式 */
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
margin-bottom: 1.5rem;
}
.card-header {
background-color: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
}
/* 进度条样式 */
.progress {
height: 0.75rem;
}
/* 表格样式 */
.table th {
font-weight: 600;
}
/* Modal centering and light theme transparency */
.modal-dialog {
display: flex;
align-items: center;
min-height: calc(100% - 1rem); /* Adjust as needed */
}
.modal-content {
background-color: rgba(255, 255, 255, 0.9); /* Semi-transparent white for light theme */
/* backdrop-filter: blur(5px); /* Optional: adds a blur effect to content behind modal */
}
/* 响应式调整 */
@media (max-width: 768px) {
.chart-container {
height: 150px;
}
/* 移动端隐藏表格,显示卡片 */
.table-responsive {
display: none !important;
}
.mobile-card-container {
display: block !important;
}
/* 移动端隐藏桌面端按钮 */
.desktop-only {
display: none !important;
}
/* 移动端导航栏优化 */
.navbar-brand {
font-size: 1rem;
margin-right: 0.5rem;
}
.container {
padding-left: 10px;
padding-right: 10px;
}
/* 移动端导航栏按钮组优化 */
.navbar .d-flex {
gap: 0.25rem;
flex-wrap: wrap;
}
.navbar .btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-width: auto;
border-width: 1px;
}
.navbar .nav-link {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
margin: 0;
}
/* 移动端导航栏下拉菜单优化 - 精简版 */
.dropdown-menu {
font-size: 0.875rem;
min-width: 150px;
z-index: 10000 !important; /* 统一使用最高层级 */
position: absolute !important; /* 使用absolute定位确保正确显示 */
/* 移除position: fixed,让Bootstrap自动处理定位 */
}
/* 确保导航栏有合适的层级但不创建层叠上下文 */
.navbar {
position: relative;
z-index: 1000; /* 给导航栏一个中等层级 */
}
.navbar .dropdown-item {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.navbar .dropdown-item i {
width: 1.2rem;
}
/* 移动端管理区域标题行优化 */
.admin-header-row {
display: flex;
flex-direction: column;
gap: 0.75rem; /* 减少移动端间隔 */
}
.admin-header-title h2 {
font-size: 1.5rem;
margin-bottom: 0;
}
.admin-header-content {
display: flex;
flex-direction: column;
gap: 0.5rem; /* 减少移动端间隔 */
}
.admin-settings-form {
order: 2; /* 设置表单在移动端显示在按钮组下方 */
}
.admin-actions-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
order: 1; /* 按钮组在移动端显示在上方 */
}
.settings-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.settings-group .form-label {
font-size: 0.875rem;
margin-bottom: 0;
font-weight: 500;
}
.settings-group .input-group {
max-width: 250px;
}
/* 超小屏幕优化 (小于400px) */
@media (max-width: 400px) {
.navbar-brand {
font-size: 0.9rem;
}
.navbar .btn-sm {
padding: 0.2rem 0.4rem;
font-size: 0.7rem;
}
.navbar .nav-link {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
}
.container {
padding-left: 8px;
padding-right: 8px;
}
}
/* 移动端按钮优化 */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
}
/* 桌面端隐藏卡片容器和移动端菜单 */
@media (min-width: 769px) {
.mobile-card-container {
display: none !important;
}
.mobile-only {
display: none !important;
}
}
/* 桌面端管理区域标题行样式 */
.admin-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.75rem; /* 减少桌面端间隔 */
}
.admin-header-title {
flex: 0 0 auto;
}
.admin-header-content {
display: flex;
align-items: center;
gap: 1rem;
flex: 1 1 auto;
justify-content: flex-end;
}
.admin-settings-form {
order: 1;
margin-right: auto; /* 推送到左侧 */
}
.admin-actions-group {
display: flex;
align-items: center;
gap: 0.5rem;
order: 2;
}
.settings-group {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.settings-group .form-label {
margin-bottom: 0;
white-space: nowrap;
font-size: 0.875rem;
}
}
/* 单一卡片布局样式 */
.card.shadow-sm {
border: none;
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1) !important;
}
.card-title {
color: var(--bs-primary);
font-weight: 600;
}
.card-title i {
color: var(--bs-primary);
}
/* 分隔线样式 */
hr.my-4 {
border-color: var(--bs-border-color-translucent);
opacity: 0.5;
}
/* 暗色主题下的单一卡片样式 */
[data-bs-theme="dark"] .card.shadow-sm {
background-color: var(--bs-dark);
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3) !important;
}
[data-bs-theme="dark"] .card-title {
color: #86b7fe;
}
[data-bs-theme="dark"] .card-title i {
color: #86b7fe;
}
/* VPS监控面板标题 - 蓝色加粗 */
.navbar-brand {
color: var(--bs-primary) !important;
font-weight: 600 !important;
}
[data-bs-theme="dark"] .navbar-brand {
color: #86b7fe !important;
}
/* 导航栏主题跟随 - 精简版 */
[data-bs-theme="light"] .navbar { background-color: #f8f9fa !important; }
[data-bs-theme="dark"] .navbar { background-color: #212529 !important; }
/* 导航栏文字主题跟随 */
[data-bs-theme="light"] .navbar .nav-link, [data-bs-theme="light"] .navbar a { color: #212529 !important; }
[data-bs-theme="dark"] .navbar .nav-link, [data-bs-theme="dark"] .navbar a { color: #ffffff !important; }
/* 导航栏按钮主题跟随 */
[data-bs-theme="light"] .navbar .btn-outline-light { border-color: #212529 !important; color: #212529 !important; }
[data-bs-theme="dark"] .navbar .btn-outline-light { border-color: #ffffff !important; color: #ffffff !important; }
/* 导航栏图标主题跟随 */
[data-bs-theme="light"] .navbar i { color: #212529 !important; }
[data-bs-theme="dark"] .navbar i { color: #ffffff !important; }
/* 底部版权信息 - 主题跟随调大 */
.footer .text-muted { font-size: 0.95rem !important; font-weight: 500; }
.footer a.text-muted { font-size: 1.1rem !important; }
.footer .text-muted { color: #212529 !important; }
[data-bs-theme="dark"] .footer .text-muted { color: #ffffff !important; }
[data-bs-theme="dark"] hr.my-4 {
border-color: rgba(255, 255, 255, 0.2);
}
/* 固定底部页脚样式 */
body {
padding-bottom: 60px; /* 为固定页脚留出空间 */
}
.footer.fixed-bottom {
height: 35px;
background-color: var(--bs-light) !important;
border-top: 1px solid var(--bs-border-color);
display: flex;
align-items: center;
}
/* 暗色主题下的页脚 */
[data-bs-theme="dark"] .footer.fixed-bottom {
background-color: var(--bs-dark) !important;
border-top-color: var(--bs-border-color);
}
/* 移动端卡片样式 */
.mobile-card-container {
display: none; /* 默认隐藏,通过媒体查询控制 */
position: relative;
z-index: 0; /* 降低容器层级,确保下拉菜单在上方 */
}
.mobile-server-card, .mobile-site-card {
background: var(--bs-card-bg, #fff);
border: 1px solid var(--bs-border-color, rgba(0,0,0,.125));
border-radius: 0.5rem;
margin-bottom: 0.75rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
overflow: hidden;
transition: box-shadow 0.15s ease-in-out, transform 0.15s ease-in-out;
position: relative;
z-index: 0; /* 降低卡片层级,确保下拉菜单在上方 */
}
@media (max-width: 768px) {
.mobile-server-card:hover, .mobile-site-card:hover {
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
}
}
.mobile-card-header {
padding: 0.75rem;
background-color: var(--bs-card-cap-bg, rgba(0,0,0,.03));
border-bottom: 1px solid var(--bs-border-color, rgba(0,0,0,.125));
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 0; /* 降低卡片头部层级,确保下拉菜单在上方 */
}
.mobile-card-header-left {
flex: 0 0 auto;
}
.mobile-card-header-right {
flex: 0 0 auto;
display: flex;
align-items: center;
font-size: 0.875rem;
}
.mobile-card-footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--bs-border-color, rgba(0,0,0,.125));
font-size: 0.875rem;
color: var(--bs-secondary);
}
@media (max-width: 768px) {
.mobile-card-header:hover {
background-color: var(--bs-card-cap-bg, rgba(0,0,0,.05));
}
}
.mobile-card-title {
font-weight: 600;
margin: 0;
font-size: 1rem;
line-height: 1.3;
}
.mobile-card-status {
flex-shrink: 0;
}
.mobile-card-body {
padding: 0.75rem;
}
.mobile-card-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0,0,0,.08));
}
.mobile-card-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
/* 两列布局样式 */
.mobile-card-two-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0,0,0,.08));
}
.mobile-card-two-columns:last-child {
border-bottom: none;
padding-bottom: 0.25rem;
}
.mobile-card-column-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-height: 2rem;
justify-content: center;
}
.mobile-card-column-item .mobile-card-label {
font-size: 0.7rem;
margin-bottom: 0;
color: var(--bs-secondary-color, #6c757d);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.mobile-card-column-item .mobile-card-value {
font-size: 0.85rem;
font-weight: 600;
text-align: left;
max-width: 100%;
word-break: break-word;
line-height: 1.2;
}
/* 移动端单行样式优化 */
@media (max-width: 768px) {
.mobile-card-row {
padding: 0.5rem 0;
min-height: 2rem;
align-items: center;
}
.mobile-card-label {
font-weight: 500;
font-size: 0.875rem;
}
.mobile-card-value {
font-weight: 600;
font-size: 0.875rem;
word-break: break-word;
}
}
.mobile-card-label {
font-weight: 500;
color: var(--bs-secondary-color, #6c757d);
font-size: 0.875rem;
}
.mobile-card-value {
text-align: right;
flex-shrink: 0;
max-width: 60%;
}
/* 移动端进度条优化 */
@media (max-width: 768px) {
.progress {
height: 1rem;
margin-top: 0.25rem;
border-radius: 0.5rem;
}
.progress span {
font-size: 0.75rem;
line-height: 1rem;
}
}
/* 移动端状态徽章优化 */
@media (max-width: 768px) {
.badge {
font-size: 0.75rem;
padding: 0.35em 0.65em;
border-radius: 0.375rem;
}
}
/* 移动端历史记录条优化 */
@media (max-width: 768px) {
.mobile-history-container .history-bar-container {
height: 1.5rem;
border-radius: 0.25rem;
overflow: hidden;
display: flex;
width: 100%;
gap: 1px;
}
.mobile-history-container .history-bar {
flex: 1;
min-width: 0;
border-radius: 1px;
height: 100%;
}
}
/* 移动端历史记录条优化 */
.mobile-history-container {
margin-top: 0.5rem;
}
.mobile-history-label {
font-size: 0.75rem;
color: var(--bs-secondary-color, #6c757d);
margin-bottom: 0.25rem;
}
/* 移动端按钮优化 */
@media (max-width: 768px) {
.mobile-card-body .btn-sm {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
border-radius: 0.375rem;
transition: all 0.15s ease-in-out;
}
.mobile-card-body .d-flex.gap-2 {
gap: 0.5rem !important;
}
.mobile-card-body .btn i {
font-size: 0.875rem;
}
/* 移动端触摸反馈 */
.mobile-card-header:active {
background-color: var(--bs-card-cap-bg, rgba(0,0,0,.08)) !important;
}
.mobile-card-body .btn:active {
opacity: 0.8;
}
/* 移动端容器标题优化 */
.container h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
/* 移动端卡片标题层次优化 */
.mobile-card-title {
font-size: 1rem;
line-height: 1.3;
font-weight: 600;
}
/* 移动端管理页面按钮优化 */
.admin-actions-group .btn {
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s ease-in-out;
}
.admin-actions-group .btn:active {
transform: scale(0.95);
}
.admin-actions-group .dropdown-toggle {
min-width: auto;
}
/* 移动端卡片间距优化 */
.mobile-server-card, .mobile-site-card {
margin-bottom: 1rem;
}
.mobile-card-body {
padding: 0.75rem;
}
.mobile-card-row {
padding: 0.375rem 0;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0,0,0,.08));
}
.mobile-card-row:last-child {
border-bottom: none;
}
}
/* 自定义浅绿色进度条 */
.bg-light-green {
background-color: #90ee90 !important; /* LightGreen */
}
/* Custom styles for non-disruptive alerts in admin page */
#serverAlert, #siteAlert, #telegramSettingsAlert {
position: fixed !important; /* Use !important to override Bootstrap if necessary */
top: 70px; /* Below navbar */
left: 50%;
transform: translateX(-50%);
z-index: 1055; /* Higher than Bootstrap modals (1050) */
padding: 0.75rem 1.25rem;
/* margin-bottom: 1rem; /* Not needed for fixed */
border: 1px solid transparent;
border-radius: 0.25rem;
min-width: 300px; /* Minimum width */
max-width: 90%; /* Max width */
text-align: center;
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);
/* Ensure d-none works to hide them, !important might be needed if Bootstrap's .alert.d-none is too specific */
}
#serverAlert.d-none, #siteAlert.d-none, #telegramSettingsAlert.d-none {
display: none !important;
}
/* Semi-transparent backgrounds for different alert types */
/* Light Theme Overrides for fixed alerts */
#serverAlert.alert-success, #siteAlert.alert-success, #telegramSettingsAlert.alert-success {
color: #0f5132; /* Bootstrap success text color */
background-color: rgba(209, 231, 221, 0.95) !important; /* Semi-transparent success, !important for specificity */
border-color: rgba(190, 221, 208, 0.95) !important;
}
#serverAlert.alert-danger, #siteAlert.alert-danger, #telegramSettingsAlert.alert-danger {
color: #842029; /* Bootstrap danger text color */
background-color: rgba(248, 215, 218, 0.95) !important; /* Semi-transparent danger */
border-color: rgba(245, 198, 203, 0.95) !important;
}
#serverAlert.alert-warning, #siteAlert.alert-warning, #telegramSettingsAlert.alert-warning { /* For siteAlert if it uses warning */
color: #664d03; /* Bootstrap warning text color */
background-color: rgba(255, 243, 205, 0.95) !important; /* Semi-transparent warning */
border-color: rgba(255, 238, 186, 0.95) !important;
}
[data-bs-theme="dark"] {
body {
background-color: #121212; /* 深色背景 */
color: #e0e0e0; /* 浅色文字 */
}
.card {
background-color: #1e1e1e; /* 卡片深色背景 */
border: 1px solid #333;
color: #e0e0e0; /* 卡片内文字颜色 */
}
.card-header {
background-color: #2a2a2a;
border-bottom: 1px solid #333;
color: #f5f5f5;
}
.table {
color: #e0e0e0; /* 表格文字颜色 */
}
.table th, .table td {
border-color: #333; /* 表格边框颜色 */
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
background-color: rgba(255, 255, 255, 0.05); /* 深色模式下的条纹 */
color: #e0e0e0;
}
.table-hover > tbody > tr:hover > * {
background-color: rgba(255, 255, 255, 0.075); /* 深色模式下的悬停 */
color: #f0f0f0;
}
.modal-content {
background-color: rgba(30, 30, 30, 0.9); /* Semi-transparent dark grey for dark theme */
color: #e0e0e0;
/* backdrop-filter: blur(5px); /* Optional: adds a blur effect to content behind modal */
}
.modal-header {
border-bottom-color: #333;
}
.modal-footer {
border-top-color: #333;
}
.form-control {
background-color: #2a2a2a;
color: #e0e0e0;
border-color: #333;
}
.form-control:focus {
background-color: #2a2a2a;
color: #e0e0e0;
border-color: #555;
box-shadow: 0 0 0 0.25rem rgba(100, 100, 100, 0.25);
}
.btn-outline-secondary {
color: #adb5bd;
border-color: #6c757d;
}
.btn-outline-secondary:hover {
color: #fff;
background-color: #6c757d;
border-color: #6c757d;
}
.navbar {
background-color: #1e1e1e !important; /* 确保覆盖 Bootstrap 默认 */
}
/* 暗色主题移动端卡片样式 */
.mobile-server-card, .mobile-site-card {
background: var(--bs-dark, #212529);
border-color: var(--bs-border-color, #495057);
}
.mobile-card-header {
background-color: rgba(255, 255, 255, 0.05);
border-bottom-color: var(--bs-border-color, #495057);
}
.mobile-card-title {
color: #ffffff !important;
}
.mobile-card-label {
color: #ced4da !important;
}
.mobile-card-value {
color: #ffffff !important;
}
.mobile-card-row {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.mobile-card-two-columns {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.mobile-card-column-item .mobile-card-label {
color: #ced4da !important;
}
.mobile-card-column-item .mobile-card-value {
color: #ffffff !important;
}
.mobile-history-label {
color: #ced4da !important;
}
/* 暗色主题下的空状态和错误状态文字 */
.mobile-card-container .text-muted {
color: #ced4da !important;
}
.mobile-card-container .text-danger {
color: #ff6b6b !important;
}
.mobile-card-container h6 {
color: #ffffff !important;
}
.mobile-card-container small {
color: #adb5bd !important;
}
/* 暗色主题下的移动端按钮优化 */
.mobile-card-body .btn-outline-primary {
color: #6ea8fe !important;
border-color: #6ea8fe !important;
}
.mobile-card-body .btn-outline-primary:hover {
color: #000 !important;
background-color: #6ea8fe !important;
border-color: #6ea8fe !important;
}
.mobile-card-body .btn-outline-info {
color: #6edff6 !important;
border-color: #6edff6 !important;
}
.mobile-card-body .btn-outline-info:hover {
color: #000 !important;
background-color: #6edff6 !important;
border-color: #6edff6 !important;
}
.mobile-card-body .btn-outline-danger {
color: #ea868f !important;
border-color: #ea868f !important;
}
.mobile-card-body .btn-outline-danger:hover {
color: #000 !important;
background-color: #ea868f !important;
border-color: #ea868f !important;
}
/* 暗色主题下的Badge徽章优化 */
.mobile-card-header .badge.bg-success {
background-color: #198754 !important;
color: #ffffff !important;
}
.mobile-card-header .badge.bg-danger {
background-color: #dc3545 !important;
color: #ffffff !important;
}
.mobile-card-header .badge.bg-warning {
background-color: #ffc107 !important;
color: #000000 !important;
}
.mobile-card-header .badge.bg-secondary {
background-color: #6c757d !important;
color: #ffffff !important;
}
.mobile-card-header .badge.bg-primary {
background-color: #0d6efd !important;
color: #ffffff !important;
}
/* 暗色主题下的移动端容器标题优化 */
.container h2 {
color: #ffffff !important;
}
/* 暗色主题下的移动端加载状态优化 */
.mobile-card-container .spinner-border {
color: #6ea8fe !important;
}
.mobile-card-container .mt-2 {
color: #ced4da !important;
}
/* 暗色主题下的导航栏按钮优化 */
.navbar .btn-outline-light {
color: #f8f9fa !important;
border-color: #f8f9fa !important;
}
.navbar .btn-outline-light:hover {
color: #000 !important;
background-color: #f8f9fa !important;
border-color: #f8f9fa !important;
}
.navbar .nav-link {
color: #f8f9fa !important;
}
.navbar .nav-link:hover {
color: #e9ecef !important;
}
.navbar-light .navbar-nav .nav-link {
color: #ccc;
}
.navbar-light .navbar-nav .nav-link:hover {
color: #fff;
}
.navbar-light .navbar-brand {
color: #fff;
}
.footer {
background-color: #1e1e1e !important;
color: #cccccc; /* 修复夜间模式页脚文本颜色 */
}
a {
color: #8ab4f8; /* 示例链接颜色 */
}
a:hover {
color: #a9c9fc;
}
/* Dark Theme Overrides for fixed alerts */
[data-bs-theme="dark"] #serverAlert.alert-success,
[data-bs-theme="dark"] #siteAlert.alert-success,
[data-bs-theme="dark"] #telegramSettingsAlert.alert-success {
color: #75b798; /* Lighter green text for dark theme */
background-color: rgba(40, 167, 69, 0.85) !important; /* Darker semi-transparent success */
border-color: rgba(34, 139, 57, 0.85) !important;
}
[data-bs-theme="dark"] #serverAlert.alert-danger,
[data-bs-theme="dark"] #siteAlert.alert-danger,
[data-bs-theme="dark"] #telegramSettingsAlert.alert-danger {
color: #ea868f; /* Lighter red text for dark theme */
background-color: rgba(220, 53, 69, 0.85) !important; /* Darker semi-transparent danger */
border-color: rgba(187, 45, 59, 0.85) !important;
}
[data-bs-theme="dark"] #serverAlert.alert-warning,
[data-bs-theme="dark"] #siteAlert.alert-warning,
[data-bs-theme="dark"] #telegramSettingsAlert.alert-warning {
color: #ffd373; /* Lighter yellow text for dark theme */
background-color: rgba(255, 193, 7, 0.85) !important; /* Darker semi-transparent warning */
border-color: rgba(217, 164, 6, 0.85) !important;
}
}
/* 拖拽排序样式 */
.server-row-draggable, .site-row-draggable {
transition: all 0.2s ease;
}
.server-row-draggable:hover, .site-row-draggable:hover {
background-color: rgba(0, 123, 255, 0.1) !important;
}
.server-row-draggable.drag-over-top, .site-row-draggable.drag-over-top {
border-top: 3px solid #007bff !important;
background-color: rgba(0, 123, 255, 0.1) !important;
}
.server-row-draggable.drag-over-bottom, .site-row-draggable.drag-over-bottom {
border-bottom: 3px solid #007bff !important;
background-color: rgba(0, 123, 255, 0.1) !important;
}
.server-row-draggable[draggable="true"], .site-row-draggable[draggable="true"] {
cursor: grab;
}
.server-row-draggable[draggable="true"]:active, .site-row-draggable[draggable="true"]:active {
cursor: grabbing;
}
/* 暗色主题下的拖拽样式 */
[data-bs-theme="dark"] .server-row-draggable:hover,
[data-bs-theme="dark"] .site-row-draggable:hover {
background-color: rgba(13, 110, 253, 0.2) !important;
}
[data-bs-theme="dark"] .server-row-draggable.drag-over-top,
[data-bs-theme="dark"] .site-row-draggable.drag-over-top {
border-top: 3px solid #0d6efd !important;
background-color: rgba(13, 110, 253, 0.2) !important;
}
[data-bs-theme="dark"] .server-row-draggable.drag-over-bottom,
[data-bs-theme="dark"] .site-row-draggable.drag-over-bottom {
border-bottom: 3px solid #0d6efd !important;
background-color: rgba(13, 110, 253, 0.2) !important;
}
/* ==================== 自定义背景和透明度控制系统 ==================== */
/* CSS变量定义 */
:root {
--custom-background-url: '';
--page-opacity: 0.8;
--text-contrast-light: rgba(0, 0, 0, 0.87);
--text-contrast-dark: rgba(255, 255, 255, 0.87);
--background-overlay-light: rgba(255, 255, 255, 0.9);
--background-overlay-dark: rgba(18, 18, 18, 0.9);
}
/* 背景图片显示 */
body.custom-background-enabled::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--custom-background-url);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
z-index: -1;
opacity: 1;
}
/* 启用自定义背景时的页面元素透明度调整 */
body.custom-background-enabled .navbar {
background-color: rgba(248, 249, 250, var(--page-opacity)) !important;
/* 移除导航栏的backdrop-filter,避免影响下拉菜单层级 */
/* backdrop-filter: blur(10px); */
/* -webkit-backdrop-filter: blur(10px); */
}
body.custom-background-enabled .card {
background-color: rgba(255, 255, 255, var(--page-opacity)) !important;
/* 移除大卡片的backdrop-filter,避免创建层叠上下文影响下拉菜单 */
/* backdrop-filter: blur(5px); */
/* -webkit-backdrop-filter: blur(5px); */
border: 1px solid rgba(0, 0, 0, 0.125);
}
body.custom-background-enabled .card-header {
background-color: rgba(0, 0, 0, calc(0.03 * var(--page-opacity))) !important;
border-bottom: 1px solid rgba(0, 0, 0, calc(0.125 * var(--page-opacity)));
}
body.custom-background-enabled .modal-content {
background-color: rgba(255, 255, 255, var(--page-opacity)) !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
body.custom-background-enabled .footer {
background-color: rgba(248, 249, 250, var(--page-opacity)) !important;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
/* 表格透明度调整 - 避免与卡片背景叠加 */
body.custom-background-enabled .table {
background-color: transparent !important;
}
body.custom-background-enabled .table th {
background-color: transparent !important;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
body.custom-background-enabled .table td {
background-color: transparent !important;
}
/* 输入框完全透明化 - 方案A */
body.custom-background-enabled .form-control {
background-color: transparent !important;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
}
body.custom-background-enabled .form-control:focus {
background-color: transparent !important;
border: 1px solid rgba(13, 110, 253, 0.6) !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15) !important;
}
/* 按钮透明度调整 */
body.custom-background-enabled .btn {
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
}
/* 滑块完全透明化 - 完整重置 */
body.custom-background-enabled .form-range {
-webkit-appearance: none !important;
appearance: none !important;
background: transparent !important;
outline: none !important;
}
/* WebKit浏览器 (Chrome, Safari) */
body.custom-background-enabled .form-range::-webkit-slider-track {
-webkit-appearance: none !important;
appearance: none !important;
background: transparent !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
height: 6px !important;
border-radius: 3px !important;
box-shadow: none !important;
outline: none !important;
margin: 0 !important;
padding: 0 !important;
box-sizing: border-box !important;
}
body.custom-background-enabled .form-range::-webkit-slider-runnable-track {
-webkit-appearance: none !important;
background: transparent !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
height: 6px !important;
border-radius: 3px !important;
box-shadow: none !important;
}
/* Firefox */
body.custom-background-enabled .form-range::-moz-range-track {
background: transparent !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
height: 6px !important;
border-radius: 3px !important;
box-shadow: none !important;
outline: none !important;
}
body.custom-background-enabled .form-range::-moz-range-progress {
background: transparent !important;
height: 6px !important;
border-radius: 3px !important;
}
/* 滑块按钮 - 垂直居中对齐 */
body.custom-background-enabled .form-range::-webkit-slider-thumb {
-webkit-appearance: none !important;
appearance: none !important;
background-color: rgba(13, 110, 253, 0.8) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
width: 20px !important;
height: 20px !important;
border-radius: 50% !important;
cursor: pointer !important;
margin-top: -7px !important;
box-sizing: border-box !important;
}
body.custom-background-enabled .form-range::-moz-range-thumb {
background-color: rgba(13, 110, 253, 0.8) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
width: 20px !important;
height: 20px !important;
border-radius: 50% !important;
cursor: pointer !important;
box-shadow: none !important;
margin-top: -8px !important;
box-sizing: border-box !important;
}
/* 下拉菜单透明度调整 - 确保最高层级显示 */
body.custom-background-enabled .dropdown-menu {
background-color: rgba(255, 255, 255, var(--page-opacity)) !important;
/* 移除backdrop-filter避免创建层叠上下文,确保z-index正常工作 */
/* backdrop-filter: blur(5px); */
/* -webkit-backdrop-filter: blur(5px); */
}
/* 移动端卡片透明度调整 - 移除backdrop-filter避免创建层叠上下文 */
body.custom-background-enabled .mobile-server-card,
body.custom-background-enabled .mobile-site-card {
background-color: rgba(255, 255, 255, var(--page-opacity)) !important;
/* backdrop-filter: blur(5px); 注释掉以避免创建层叠上下文遮挡下拉菜单 */
/* -webkit-backdrop-filter: blur(5px); */
}
body.custom-background-enabled .mobile-card-header {
background-color: rgba(0, 0, 0, calc(0.03 * var(--page-opacity))) !important;
}
/* 表格条纹和悬停效果 - 轻微背景色,不叠加透明度 */
body.custom-background-enabled .table-striped > tbody > tr:nth-of-type(odd) > * {
background-color: rgba(0, 0, 0, 0.02) !important;
}
body.custom-background-enabled .table-hover > tbody > tr:hover > * {
background-color: rgba(0, 0, 0, 0.04) !important;
}
/* 暗色主题下的自定义背景样式 */
[data-bs-theme="dark"] body.custom-background-enabled .navbar {
background-color: rgba(30, 30, 30, var(--page-opacity)) !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .card {
background-color: rgba(30, 30, 30, var(--page-opacity)) !important;
border-color: rgba(51, 51, 51, var(--page-opacity));
}
[data-bs-theme="dark"] body.custom-background-enabled .card-header {
background-color: rgba(42, 42, 42, var(--page-opacity)) !important;
border-bottom-color: rgba(51, 51, 51, var(--page-opacity));
}
[data-bs-theme="dark"] body.custom-background-enabled .modal-content {
background-color: rgba(30, 30, 30, var(--page-opacity)) !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .footer {
background-color: rgba(30, 30, 30, var(--page-opacity)) !important;
}
/* 暗色主题下的表格透明度调整 - 避免与卡片背景叠加 */
[data-bs-theme="dark"] body.custom-background-enabled .table {
background-color: transparent !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .table th {
background-color: transparent !important;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
[data-bs-theme="dark"] body.custom-background-enabled .table td {
background-color: transparent !important;
}
/* 暗色主题下的输入框完全透明化 - 方案A */
[data-bs-theme="dark"] body.custom-background-enabled .form-control {
background-color: transparent !important;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
color: rgba(255, 255, 255, 0.9) !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .form-control:focus {
background-color: transparent !important;
border: 1px solid rgba(13, 110, 253, 0.6) !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15) !important;
}
/* 暗色主题下的下拉菜单透明度调整 - 移除backdrop-filter */
[data-bs-theme="dark"] body.custom-background-enabled .dropdown-menu {
background-color: rgba(30, 30, 30, var(--page-opacity)) !important;
/* 移除backdrop-filter避免创建层叠上下文,确保z-index正常工作 */
/* backdrop-filter: blur(5px); */
/* -webkit-backdrop-filter: blur(5px); */
}
/* 暗色主题下的滑块完全透明化 - 完整重置 */
[data-bs-theme="dark"] body.custom-background-enabled .form-range {
-webkit-appearance: none !important;
appearance: none !important;
background: transparent !important;
outline: none !important;
}
/* WebKit浏览器 (Chrome, Safari) - 暗色主题 */
[data-bs-theme="dark"] body.custom-background-enabled .form-range::-webkit-slider-track {
-webkit-appearance: none !important;
appearance: none !important;
background: transparent !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
height: 6px !important;
border-radius: 3px !important;
box-shadow: none !important;
outline: none !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .form-range::-webkit-slider-runnable-track {
-webkit-appearance: none !important;
background: transparent !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
height: 6px !important;
border-radius: 3px !important;
box-shadow: none !important;
}
/* Firefox - 暗色主题 */
[data-bs-theme="dark"] body.custom-background-enabled .form-range::-moz-range-track {
background: transparent !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
height: 6px !important;
border-radius: 3px !important;
box-shadow: none !important;
outline: none !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .form-range::-moz-range-progress {
background: transparent !important;
height: 6px !important;
border-radius: 3px !important;
}
/* 滑块按钮 - 暗色主题 - 垂直居中对齐 */
[data-bs-theme="dark"] body.custom-background-enabled .form-range::-webkit-slider-thumb {
-webkit-appearance: none !important;
appearance: none !important;
background-color: rgba(13, 110, 253, 0.9) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
width: 20px !important;
height: 20px !important;
border-radius: 50% !important;
cursor: pointer !important;
margin-top: -7px !important;
box-sizing: border-box !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .form-range::-moz-range-thumb {
background-color: rgba(13, 110, 253, 0.9) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
width: 20px !important;
height: 20px !important;
border-radius: 50% !important;
cursor: pointer !important;
box-shadow: none !important;
margin-top: -8px !important;
box-sizing: border-box !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .mobile-server-card,
[data-bs-theme="dark"] body.custom-background-enabled .mobile-site-card {
background-color: rgba(33, 37, 41, var(--page-opacity)) !important;
border-color: rgba(73, 80, 87, var(--page-opacity));
}
[data-bs-theme="dark"] body.custom-background-enabled .mobile-card-header {
background-color: rgba(255, 255, 255, calc(0.05 * var(--page-opacity))) !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .table-striped > tbody > tr:nth-of-type(odd) > * {
background-color: rgba(255, 255, 255, 0.03) !important;
}
[data-bs-theme="dark"] body.custom-background-enabled .table-hover > tbody > tr:hover > * {
background-color: rgba(255, 255, 255, 0.05) !important;
}
/* 警告框透明度调整 */
body.custom-background-enabled #serverAlert,
body.custom-background-enabled #siteAlert,
body.custom-background-enabled #telegramSettingsAlert,
body.custom-background-enabled #backgroundSettingsAlert {
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.3);
}
/* ==================== 文字描边渲染系统 ==================== */
/* 文字加粗系统 - 精简版 */
p, div, span:not(.badge), td, th, .btn, button, a:not(.navbar-brand),
.form-control, .form-select, .form-check-label, input, textarea,
.card-header, .card-title, .card-body, .modal-content, .modal-title, .dropdown-menu,
.progress span, .alert, .breadcrumb, .list-group-item {
font-weight: 500;
}
/* 统一Toast弹窗系统 */
.toast-container {
position: fixed;
top: 15%;
left: 50%;
transform: translateX(-50%);
z-index: 10000; /* 确保在所有元素之上,包括模态框 */
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
}
.unified-toast {
pointer-events: auto;
min-width: 120px;
max-width: 90vw;
padding: 16px 50px 16px 24px;
margin-bottom: 12px;
border-radius: 12px;
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.2);
font-weight: 500;
font-size: 15px;
position: relative;
display: inline-flex;
align-items: center;
animation: toastIn 0.3s ease;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.unified-toast.hiding {
animation: toastOut 0.3s ease;
opacity: 0;
}
.unified-toast.success {
background: linear-gradient(135deg,
rgba(34, 197, 94, calc(0.7 * var(--page-opacity, 0.8))),
rgba(22, 163, 74, calc(0.7 * var(--page-opacity, 0.8))));
color: white;
border-color: rgba(34, 197, 94, calc(0.4 * var(--page-opacity, 0.8)));
}
.unified-toast.danger {
background: linear-gradient(135deg,
rgba(239, 68, 68, calc(0.7 * var(--page-opacity, 0.8))),
rgba(220, 38, 38, calc(0.7 * var(--page-opacity, 0.8))));
color: white;
border-color: rgba(239, 68, 68, calc(0.4 * var(--page-opacity, 0.8)));
}
.unified-toast.warning {
background: linear-gradient(135deg,
rgba(245, 158, 11, calc(0.7 * var(--page-opacity, 0.8))),
rgba(217, 119, 6, calc(0.7 * var(--page-opacity, 0.8))));
color: white;
border-color: rgba(245, 158, 11, calc(0.4 * var(--page-opacity, 0.8)));
}
.unified-toast.info {
background: linear-gradient(135deg,
rgba(59, 130, 246, calc(0.7 * var(--page-opacity, 0.8))),
rgba(37, 99, 235, calc(0.7 * var(--page-opacity, 0.8))));
color: white;
border-color: rgba(59, 130, 246, calc(0.4 * var(--page-opacity, 0.8)));
}
.toast-icon {
margin-right: 8px;
font-size: 16px;
flex-shrink: 0;
}
.toast-content {
flex: 1;
line-height: 1.4;
}
.toast-close {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
background: none;
border: none;
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
cursor: pointer;
padding: 6px;
border-radius: 50%;
width: 28px;
height: 28px;
}
.toast-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.toast-progress {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: rgba(255, 255, 255, 0.3);
border-radius: 0 0 12px 12px;
animation: progressBar 5s linear;
}
@keyframes toastIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes progressBar {
from { width: 100%; }
to { width: 0%; }
}
[data-bs-theme="dark"] .unified-toast {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.1);
}
/* 自定义导航栏高度 */
.navbar {
--bs-navbar-padding-y: 0.375rem;
min-height: 50px;
height: 50px;
}
.navbar-brand {
padding-top: 0.3125rem;
padding-bottom: 0.3125rem;
line-height: 1.25;
}
`;
}
function getMainJs() {
return `// main.js - 首页面的JavaScript逻辑
// Global variables
let vpsUpdateInterval = null;
let siteUpdateInterval = null;
let serverDataCache = {}; // Cache server data to avoid re-fetching for details
let vpsStatusCache = {}; // 用于跟踪VPS状态变化
const DEFAULT_VPS_REFRESH_INTERVAL_MS = 60000; // Default to 60 seconds for VPS data if backend setting fails
const DEFAULT_SITE_REFRESH_INTERVAL_MS = 60000; // Default to 60 seconds for Site data
// ==================== 统一API请求工具 ====================
// 获取认证头
function getAuthHeaders() {
const token = localStorage.getItem('auth_token');
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
return headers;
}
// ==================== VPS状态变化检测 ====================
// 检测VPS状态变化并发送通知
async function checkVpsStatusChanges(allStatuses) {
for (const data of allStatuses) {
const serverId = data.server.id;
const serverName = data.server.name;
const currentStatus = determineVpsStatus(data);
const previousStatus = vpsStatusCache[serverId];
// 首次加载或状态变化时检测
if (previousStatus === undefined || previousStatus !== currentStatus) {
if (currentStatus === 'offline') {
await notifyVpsOffline(serverId, serverName);
} else if (currentStatus === 'online' && previousStatus === 'offline') {
await notifyVpsRecovery(serverId, serverName);
}
}
vpsStatusCache[serverId] = currentStatus;
}
}
// 判断VPS状态
function determineVpsStatus(data) {
if (data.error) return 'error';
if (!data.metrics) return 'unknown';
const now = new Date();
const lastReportTime = new Date(data.metrics.timestamp * 1000);
const diffMinutes = (now - lastReportTime) / (1000 * 60);
return diffMinutes <= 5 ? 'online' : 'offline';
}
// 发送VPS离线通知
async function notifyVpsOffline(serverId, serverName) {
try {
// 使用完整URL
const baseUrl = window.location.origin;
await fetch(baseUrl + '/api/notify/offline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId, serverName })
});
} catch (error) {
}
}
// 发送VPS恢复通知
async function notifyVpsRecovery(serverId, serverName) {
try {
// 使用完整URL
const baseUrl = window.location.origin;
await fetch(baseUrl + '/api/notify/recovery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId, serverName })
});
} catch (error) {
}
}
// 统一API请求函数(用于需要认证的请求)
async function apiRequest(url, options = {}) {
const defaultOptions = {
headers: getAuthHeaders(),
...options
};
try {
const response = await fetch(url, defaultOptions);
// 处理认证失败
if (response.status === 401) {
localStorage.removeItem('auth_token');
if (window.location.pathname !== '/login.html') {
window.location.href = 'login.html';
}
throw new Error('认证失败,请重新登录');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || \`请求失败 (\${response.status})\`);
}
return await response.json();
} catch (error) {
throw error;
}
}
// 公开API请求函数(用于不需要认证的请求)
async function publicApiRequest(url, options = {}) {
const defaultOptions = {
headers: getAuthHeaders(), // 仍然发送token(如果有),但不强制要求
...options
};
try {
const response = await fetch(url, defaultOptions);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || \`请求失败 (\${response.status})\`);
}
return await response.json();
} catch (error) {
throw error;
}
}
// 显示错误消息
function showError(message, containerId = null) {
console.error('错误:', message);
if (containerId) {
const container = document.getElementById(containerId);
if (container) {
container.innerHTML = \`<div class="alert alert-danger">\${message}</div>\`;
}
}
}
// 显示成功消息
function showSuccess(message, containerId = null) {
if (containerId) {
const container = document.getElementById(containerId);
if (container) {
container.innerHTML = \`<div class="alert alert-success">\${message}</div>\`;
}
}
}
// Function to fetch VPS refresh interval and start periodic VPS data updates
async function initializeVpsDataUpdates() {
let vpsRefreshIntervalMs = DEFAULT_VPS_REFRESH_INTERVAL_MS;
try {
const data = await publicApiRequest('/api/admin/settings/vps-report-interval');
if (data && typeof data.interval === 'number' && data.interval > 0) {
vpsRefreshIntervalMs = data.interval * 1000; // Convert seconds to milliseconds
} else {
// 使用默认值
}
} catch (error) {
}
// Clear existing interval if any
if (vpsUpdateInterval) {
clearInterval(vpsUpdateInterval);
}
// VPS数据跟随后台设置频率刷新
vpsUpdateInterval = setInterval(() => {
loadAllServerStatuses();
}, vpsRefreshIntervalMs);
}
// 优化:网站状态每小时刷新一次
function initializeSiteDataUpdates() {
const hourlyRefreshInterval = 60 * 60 * 1000; // 1小时
// 清除任何现有的自动刷新间隔
if (siteUpdateInterval) {
clearInterval(siteUpdateInterval);
}
// 设置每小时刷新一次
siteUpdateInterval = setInterval(() => {
loadAllSiteStatuses();
}, hourlyRefreshInterval);
}
// 移除手动刷新按钮相关代码,改为自动刷新
// Execute after the page loads (only for main page)
document.addEventListener('DOMContentLoaded', function() {
// Check if we're on the main page by looking for the server table
const serverTableBody = document.getElementById('serverTableBody');
if (!serverTableBody) {
// Not on the main page, only initialize theme
initializeTheme();
return;
}
// Initialize theme
initializeTheme();
// Load initial data
loadAllServerStatuses();
loadAllSiteStatuses();
// Initialize periodic updates separately
initializeVpsDataUpdates();
initializeSiteDataUpdates();
// Add click event listener to the table body for row expansion
serverTableBody.addEventListener('click', handleRowClick);
// Check login status and update admin link
updateAdminLink();
});
// --- Theme Management ---
const THEME_KEY = 'vps-monitor-theme';
const LIGHT_THEME = 'light';
const DARK_THEME = 'dark';
function initializeTheme() {
const themeToggler = document.getElementById('themeToggler');
if (!themeToggler) return;
const storedTheme = localStorage.getItem(THEME_KEY) || LIGHT_THEME;
applyTheme(storedTheme);
themeToggler.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-bs-theme');
const newTheme = currentTheme === DARK_THEME ? LIGHT_THEME : DARK_THEME;
applyTheme(newTheme);
localStorage.setItem(THEME_KEY, newTheme);
});
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
const themeTogglerIcon = document.querySelector('#themeToggler i');
if (themeTogglerIcon) {
if (theme === DARK_THEME) {
themeTogglerIcon.classList.remove('bi-moon-stars-fill');
themeTogglerIcon.classList.add('bi-sun-fill');
} else {
themeTogglerIcon.classList.remove('bi-sun-fill');
themeTogglerIcon.classList.add('bi-moon-stars-fill');
}
}
}
// --- End Theme Management ---
// Check login status and update the admin link in the navbar
async function updateAdminLink() {
const adminLink = document.getElementById('adminAuthLink');
if (!adminLink) return; // Exit if link not found
try {
const token = localStorage.getItem('auth_token');
if (!token) {
// Not logged in (no token)
adminLink.textContent = '管理员登录';
adminLink.href = '/login.html';
return;
}
const data = await publicApiRequest('/api/auth/status');
if (data.authenticated) {
// Logged in
adminLink.textContent = '管理后台';
adminLink.href = '/admin.html';
} else {
// Invalid token or not authenticated
adminLink.textContent = '管理员登录';
adminLink.href = '/login.html';
localStorage.removeItem('auth_token'); // Clean up invalid token
}
} catch (error) {
// Network error, assume not logged in
adminLink.textContent = '管理员登录';
adminLink.href = '/login.html';
}
}
// Handle click on a server row
function handleRowClick(event) {
const clickedRow = event.target.closest('tr.server-row');
if (!clickedRow) return; // Not a server row
const serverId = clickedRow.getAttribute('data-server-id');
const detailsRow = clickedRow.nextElementSibling; // The details row is the next sibling
if (detailsRow && detailsRow.classList.contains('server-details-row')) {
// Toggle visibility
detailsRow.classList.toggle('d-none');
// If showing, populate with detailed data
if (!detailsRow.classList.contains('d-none')) {
populateDetailsRow(serverId, detailsRow);
}
}
}
// Populate the detailed row with data
function populateDetailsRow(serverId, detailsRow) {
const serverData = serverDataCache[serverId];
const detailsContentDiv = detailsRow.querySelector('.server-details-content');
if (!serverData || !serverData.metrics || !detailsContentDiv) {
detailsContentDiv.innerHTML = '<p class="text-muted">无详细数据</p>';
return;
}
const metrics = serverData.metrics;
let detailsHtml = '';
// CPU Details
if (metrics.cpu && metrics.cpu.load_avg) {
detailsHtml += \`
<div class="detail-item">
<strong>CPU负载 (1m, 5m, 15m):</strong> \${metrics.cpu.load_avg.join(', ')}
</div>
\`;
}
// Memory Details
if (metrics.memory) {
detailsHtml += \`
<div class="detail-item">
<strong>内存:</strong>
总计: \${formatDataSize(metrics.memory.total * 1024)}<br>
已用: \${formatDataSize(metrics.memory.used * 1024)}<br>
空闲: \${formatDataSize(metrics.memory.free * 1024)}
</div>
\`;
}
// Disk Details
if (metrics.disk) {
detailsHtml += \`
<div class="detail-item">
<strong>硬盘 (/):</strong>
总计: \${typeof metrics.disk.total === 'number' ? metrics.disk.total.toFixed(2) : '-'} GB<br>
已用: \${typeof metrics.disk.used === 'number' ? metrics.disk.used.toFixed(2) : '-'} GB<br>
空闲: \${typeof metrics.disk.free === 'number' ? metrics.disk.free.toFixed(2) : '-'} GB
</div>
\`;
}
// Network Totals
if (metrics.network) {
detailsHtml += \`
<div class="detail-item">
<strong>总流量:</strong>
上传: \${formatDataSize(metrics.network.total_upload)}<br>
下载: \${formatDataSize(metrics.network.total_download)}
</div>
\`;
}
detailsContentDiv.innerHTML = detailsHtml || '<p class="text-muted">无详细数据</p>';
}
// Load all server statuses
async function loadAllServerStatuses() {
try {
// 使用批量API一次性获取所有VPS状态
let batchData;
try {
batchData = await publicApiRequest('/api/status/batch');
} catch (error) {
// 如果批量API失败,可能是数据库未初始化,尝试初始化
await publicApiRequest('/api/init-db');
batchData = await publicApiRequest('/api/status/batch');
}
const allStatuses = batchData.servers || [];
const noServersAlert = document.getElementById('noServers');
const serverTableBody = document.getElementById('serverTableBody');
if (allStatuses.length === 0) {
noServersAlert.classList.remove('d-none');
serverTableBody.innerHTML = '<tr><td colspan="11" class="text-center">No server data available. Please log in to the admin panel to add servers.</td></tr>';
// Remove any existing detail rows if the server list becomes empty
removeAllDetailRows();
// 同时更新移动端卡片容器
renderMobileServerCards([]);
return;
} else {
noServersAlert.classList.add('d-none');
}
// Update the serverDataCache with the latest data
allStatuses.forEach(data => {
serverDataCache[data.server.id] = data;
});
// 检测VPS状态变化并发送通知
await checkVpsStatusChanges(allStatuses);
// 3. Render the table using DOM manipulation
renderServerTable(allStatuses);
} catch (error) {
const serverTableBody = document.getElementById('serverTableBody');
serverTableBody.innerHTML = '<tr><td colspan="11" class="text-center text-danger">Failed to load server data. Please refresh the page.</td></tr>';
removeAllDetailRows();
// 同时更新移动端卡片容器显示错误状态
showToast('danger', '加载服务器数据失败,请刷新页面重试');
}
}
// Remove all existing server detail rows
function removeAllDetailRows() {
document.querySelectorAll('.server-details-row').forEach(row => row.remove());
}
// Generate progress bar HTML
function getProgressBarHtml(percentage) {
if (typeof percentage !== 'number' || isNaN(percentage)) return '-';
const percent = Math.max(0, Math.min(100, percentage)); // Ensure percentage is between 0 and 100
let bgColorClass = 'bg-light-green'; // Use custom light green for < 50%
if (percent >= 80) {
bgColorClass = 'bg-danger'; // Red for >= 80%
} else if (percent >= 50) {
bgColorClass = 'bg-warning'; // Yellow for 50% - 79%
}
// Use relative positioning on the container and absolute for the text, centered over the whole bar
return \`
<div class="progress" style="height: 25px; font-size: 0.8em; position: relative; background-color: #e9ecef;">
<div class="progress-bar \${bgColorClass}" role="progressbar" style="width: \${percent}%;" aria-valuenow="\${percent}" aria-valuemin="0" aria-valuemax="100"></div>
<span style="position: absolute; width: 100%; text-align: center; line-height: 25px; font-weight: bold;">
\${percent.toFixed(1)}%
</span>
</div>
\`;
}
// 移动端辅助函数
function getServerStatusBadge(status) {
if (status === 'online') {
return { class: 'bg-success', text: '在线' };
} else if (status === 'offline') {
return { class: 'bg-danger', text: '离线' };
} else if (status === 'error') {
return { class: 'bg-warning text-dark', text: '错误' };
} else {
return { class: 'bg-secondary', text: '未知' };
}
}
// 移动端服务器卡片渲染函数
function renderMobileServerCards(allStatuses) {
const mobileContainer = document.getElementById('mobileServerContainer');
if (!mobileContainer) return;
mobileContainer.innerHTML = '';
if (!allStatuses || allStatuses.length === 0) {
mobileContainer.innerHTML = \`
<div class="text-center p-4">
<i class="bi bi-server text-muted" style="font-size: 3rem;"></i>
<div class="mt-3 text-muted">
<h6>暂无服务器数据</h6>
<small>请登录管理后台添加服务器</small>
</div>
</div>
\`;
return;
}
allStatuses.forEach(data => {
const serverId = data.server.id;
const serverName = data.server.name;
const metrics = data.metrics;
const hasError = data.error;
const card = document.createElement('div');
card.className = 'mobile-server-card';
card.setAttribute('data-server-id', serverId);
// 确定服务器状态
let status = 'unknown';
let lastUpdate = '从未';
if (hasError) {
status = 'error';
} else if (metrics) {
const now = new Date();
const lastReportTime = new Date(metrics.timestamp * 1000);
const diffMinutes = (now - lastReportTime) / (1000 * 60);
if (diffMinutes <= 5) {
status = 'online';
} else {
status = 'offline';
}
lastUpdate = lastReportTime.toLocaleString();
}
const statusInfo = getServerStatusBadge(status);
// 卡片头部
const cardHeader = document.createElement('div');
cardHeader.className = 'mobile-card-header';
cardHeader.innerHTML = \`
<div style="flex: 1;"></div>
<h6 class="mobile-card-title text-center" style="flex: 1;">\${serverName || '未命名服务器'}</h6>
<div style="flex: 1; display: flex; justify-content: flex-end;">
<span class="badge \${statusInfo.class}">\${statusInfo.text}</span>
</div>
\`;
// 卡片主体 - 显示所有信息
const cardBody = document.createElement('div');
cardBody.className = 'mobile-card-body';
// 获取所有数据
const cpuValue = metrics && metrics.cpu && typeof metrics.cpu.usage_percent === 'number' ? \`\${metrics.cpu.usage_percent.toFixed(1)}%\` : '-';
const memoryValue = metrics && metrics.memory && typeof metrics.memory.usage_percent === 'number' ? \`\${metrics.memory.usage_percent.toFixed(1)}%\` : '-';
const diskValue = metrics && metrics.disk && typeof metrics.disk.usage_percent === 'number' ? \`\${metrics.disk.usage_percent.toFixed(1)}%\` : '-';
const uptimeValue = metrics && metrics.uptime ? formatUptime(metrics.uptime) : '-';
const uploadSpeed = metrics && metrics.network ? formatNetworkSpeed(metrics.network.upload_speed) : '-';
const downloadSpeed = metrics && metrics.network ? formatNetworkSpeed(metrics.network.download_speed) : '-';
const totalUpload = metrics && metrics.network ? formatDataSize(metrics.network.total_upload) : '-';
const totalDownload = metrics && metrics.network ? formatDataSize(metrics.network.total_download) : '-';
// 上传速度 | 下载速度
const speedRow = document.createElement('div');
speedRow.className = 'mobile-card-two-columns';
speedRow.innerHTML = \`
<div class="mobile-card-column-item">
<span class="mobile-card-label">上传速度</span>
<span class="mobile-card-value">\${uploadSpeed}</span>
</div>
<div class="mobile-card-column-item">
<span class="mobile-card-label">下载速度</span>
<span class="mobile-card-value">\${downloadSpeed}</span>
</div>
\`;
cardBody.appendChild(speedRow);
// CPU | 内存
const cpuMemoryRow = document.createElement('div');
cpuMemoryRow.className = 'mobile-card-two-columns';
cpuMemoryRow.innerHTML = \`
<div class="mobile-card-column-item">
<span class="mobile-card-label">CPU</span>
<span class="mobile-card-value">\${cpuValue}</span>
</div>
<div class="mobile-card-column-item">
<span class="mobile-card-label">内存</span>
<span class="mobile-card-value">\${memoryValue}</span>
</div>
\`;
cardBody.appendChild(cpuMemoryRow);
// 硬盘 | 运行时长
const diskUptimeRow = document.createElement('div');
diskUptimeRow.className = 'mobile-card-two-columns';
diskUptimeRow.innerHTML = \`
<div class="mobile-card-column-item">
<span class="mobile-card-label">硬盘</span>
<span class="mobile-card-value">\${diskValue}</span>
</div>
<div class="mobile-card-column-item">
<span class="mobile-card-label">运行时长</span>
<span class="mobile-card-value">\${uptimeValue}</span>
</div>
\`;
cardBody.appendChild(diskUptimeRow);
// 总上传 | 总下载
const totalRow = document.createElement('div');
totalRow.className = 'mobile-card-two-columns';
totalRow.innerHTML = \`
<div class="mobile-card-column-item">
<span class="mobile-card-label">总上传</span>
<span class="mobile-card-value">\${totalUpload}</span>
</div>
<div class="mobile-card-column-item">
<span class="mobile-card-label">总下载</span>
<span class="mobile-card-value">\${totalDownload}</span>
</div>
\`;
cardBody.appendChild(totalRow);
// 最后更新 - 单行
const lastUpdateRow = document.createElement('div');
lastUpdateRow.className = 'mobile-card-row';
lastUpdateRow.innerHTML = \`
<span class="mobile-card-label">最后更新: \${lastUpdate}</span>
\`;
cardBody.appendChild(lastUpdateRow);
// 组装卡片
card.appendChild(cardHeader);
card.appendChild(cardBody);
mobileContainer.appendChild(card);
});
}
// 移动端网站卡片渲染函数
function renderMobileSiteCards(sites) {
const mobileContainer = document.getElementById('mobileSiteContainer');
if (!mobileContainer) return;
mobileContainer.innerHTML = '';
if (!sites || sites.length === 0) {
mobileContainer.innerHTML = \`
<div class="text-center p-4">
<i class="bi bi-globe text-muted" style="font-size: 3rem;"></i>
<div class="mt-3 text-muted">
<h6>暂无监控网站数据</h6>
<small>请登录管理后台添加监控网站</small>
</div>
</div>
\`;
return;
}
sites.forEach(site => {
const card = document.createElement('div');
card.className = 'mobile-site-card';
const statusInfo = getSiteStatusBadge(site.last_status);
const lastCheckTime = site.last_checked ? new Date(site.last_checked * 1000).toLocaleString() : '从未';
const responseTime = site.last_response_time_ms !== null ? \`\${site.last_response_time_ms} ms\` : '-';
// 卡片头部
const cardHeader = document.createElement('div');
cardHeader.className = 'mobile-card-header';
cardHeader.innerHTML = \`
<div style="flex: 1;"></div>
<h6 class="mobile-card-title text-center" style="flex: 1;">\${site.name || '未命名网站'}</h6>
<div style="flex: 1; display: flex; justify-content: flex-end;">
<span class="badge \${statusInfo.class}">\${statusInfo.text}</span>
</div>
\`;
// 卡片主体
const cardBody = document.createElement('div');
cardBody.className = 'mobile-card-body';
// 网站信息 - 两列布局
const statusCode = site.last_status_code || '-';
// 状态码 | 响应时间
const statusResponseRow = document.createElement('div');
statusResponseRow.className = 'mobile-card-two-columns';
statusResponseRow.innerHTML = \`
<div class="mobile-card-column-item">
<span class="mobile-card-label">状态码</span>
<span class="mobile-card-value">\${statusCode}</span>
</div>
<div class="mobile-card-column-item">
<span class="mobile-card-label">响应时间</span>
<span class="mobile-card-value">\${responseTime}</span>
</div>
\`;
cardBody.appendChild(statusResponseRow);
// 最后检查 - 单行
const lastCheckRow = document.createElement('div');
lastCheckRow.className = 'mobile-card-row';
lastCheckRow.innerHTML = \`
<span class="mobile-card-label">最后检查: \${lastCheckTime}</span>
\`;
cardBody.appendChild(lastCheckRow);
// 24小时历史记录 - 始终显示,即使没有数据
const historyContainer = document.createElement('div');
historyContainer.className = 'mobile-history-container';
historyContainer.innerHTML = \`
<div class="mobile-history-label">24小时记录</div>
<div class="history-bar-container"></div>
\`;
cardBody.appendChild(historyContainer);
// 使用统一的历史记录渲染函数
const historyBarContainer = historyContainer.querySelector('.history-bar-container');
renderSiteHistoryBar(historyBarContainer, site.history || []);
// 组装卡片
card.appendChild(cardHeader);
card.appendChild(cardBody);
mobileContainer.appendChild(card);
});
}
// Render the server table using DOM manipulation
function renderServerTable(allStatuses) {
const tableBody = document.getElementById('serverTableBody');
const detailsTemplate = document.getElementById('serverDetailsTemplate');
// 1. Store IDs of currently expanded servers
const expandedServerIds = new Set();
// Iterate over main server rows to find their expanded detail rows
tableBody.querySelectorAll('tr.server-row').forEach(mainRow => {
const detailRow = mainRow.nextElementSibling;
if (detailRow && detailRow.classList.contains('server-details-row') && !detailRow.classList.contains('d-none')) {
const serverId = mainRow.getAttribute('data-server-id');
if (serverId) {
expandedServerIds.add(serverId);
}
}
});
tableBody.innerHTML = ''; // Clear existing rows
allStatuses.forEach(data => {
const serverId = data.server.id;
const serverName = data.server.name;
const metrics = data.metrics;
const hasError = data.error;
let statusBadge = '<span class="badge bg-secondary">未知</span>';
let cpuHtml = '-';
let memoryHtml = '-';
let diskHtml = '-';
let uploadSpeed = '-';
let downloadSpeed = '-';
let totalUpload = '-';
let totalDownload = '-';
let uptime = '-';
let lastUpdate = '-';
if (hasError) {
statusBadge = '<span class="badge bg-warning text-dark">错误</span>';
} else if (metrics) {
const now = new Date();
const lastReportTime = new Date(metrics.timestamp * 1000);
const diffMinutes = (now - lastReportTime) / (1000 * 60);
if (diffMinutes <= 5) { // Considered online within 5 minutes
statusBadge = '<span class="badge bg-success">在线</span>';
} else {
statusBadge = '<span class="badge bg-danger">离线</span>';
}
cpuHtml = getProgressBarHtml(metrics.cpu.usage_percent);
memoryHtml = getProgressBarHtml(metrics.memory.usage_percent);
diskHtml = getProgressBarHtml(metrics.disk.usage_percent);
uploadSpeed = formatNetworkSpeed(metrics.network.upload_speed);
downloadSpeed = formatNetworkSpeed(metrics.network.download_speed);
totalUpload = formatDataSize(metrics.network.total_upload);
totalDownload = formatDataSize(metrics.network.total_download);
uptime = metrics.uptime ? formatUptime(metrics.uptime) : '-';
lastUpdate = lastReportTime.toLocaleString();
}
// Create the main row
const mainRow = document.createElement('tr');
mainRow.classList.add('server-row');
mainRow.setAttribute('data-server-id', serverId);
mainRow.innerHTML = \`
<td>\${serverName}</td>
<td>\${statusBadge}</td>
<td>\${cpuHtml}</td>
<td>\${memoryHtml}</td>
<td>\${diskHtml}</td>
<td><span style="color: #000;">\${uploadSpeed}</span></td>
<td><span style="color: #000;">\${downloadSpeed}</span></td>
<td><span style="color: #000;">\${totalUpload}</span></td>
<td><span style="color: #000;">\${totalDownload}</span></td>
<td><span style="color: #000;">\${uptime}</span></td>
<td><span style="color: #000;">\${lastUpdate}</span></td>
\`;
// Clone the details row template
const detailsRowElement = detailsTemplate.content.cloneNode(true).querySelector('tr');
// The template has d-none by default. We will remove it if needed.
// Set a unique attribute for easier selection if needed, though direct reference is used here.
// detailsRowElement.setAttribute('data-detail-for', serverId);
tableBody.appendChild(mainRow);
tableBody.appendChild(detailsRowElement);
// 2. If this server was previously expanded, re-expand it and populate its details
if (expandedServerIds.has(serverId)) {
detailsRowElement.classList.remove('d-none');
populateDetailsRow(serverId, detailsRowElement); // Populate content
}
});
// 3. 同时渲染移动端卡片
renderMobileServerCards(allStatuses);
}
// Format network speed
function formatNetworkSpeed(bytesPerSecond) {
if (typeof bytesPerSecond !== 'number' || isNaN(bytesPerSecond)) return '-';
if (bytesPerSecond < 1024) {
return \`\${bytesPerSecond.toFixed(1)} B/s\`;
} else if (bytesPerSecond < 1024 * 1024) {
return \`\${(bytesPerSecond / 1024).toFixed(1)} KB/s\`;
} else if (bytesPerSecond < 1024 * 1024 * 1024) {
return \`\${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s\`;
} else {
return \`\${(bytesPerSecond / (1024 * 1024 * 1024)).toFixed(1)} GB/s\`;
}
}
// Format data size
function formatDataSize(bytes) {
if (typeof bytes !== 'number' || isNaN(bytes)) return '-';
if (bytes < 1024) {
return \`\${bytes.toFixed(1)} B\`;
} else if (bytes < 1024 * 1024) {
return \`\${(bytes / 1024).toFixed(1)} KB\`;
} else if (bytes < 1024 * 1024 * 1024) {
return \`\${(bytes / (1024 * 1024)).toFixed(1)} MB\`;
} else if (bytes < 1024 * 1024 * 1024 * 1024) {
return \`\${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB\`;
} else {
return \`\${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB\`;
}
}
// Format uptime from seconds to a human-readable string
function formatUptime(totalSeconds) {
if (typeof totalSeconds !== 'number' || isNaN(totalSeconds) || totalSeconds < 0) {
return '-';
}
const days = Math.floor(totalSeconds / (3600 * 24));
totalSeconds %= (3600 * 24);
const hours = Math.floor(totalSeconds / 3600);
totalSeconds %= 3600;
const minutes = Math.floor(totalSeconds / 60);
let uptimeString = '';
if (days > 0) {
uptimeString += \`\${days}天 \`;
}
if (hours > 0) {
uptimeString += \`\${hours}小时 \`;
}
if (minutes > 0 || (days === 0 && hours === 0)) { // Show minutes if it's the only unit or if other units are zero
uptimeString += \`\${minutes}分钟\`;
}
return uptimeString.trim() || '0分钟'; // Default to 0 minutes if string is empty
}
// --- Website Status Functions ---
// Load all website statuses
async function loadAllSiteStatuses() {
try {
let data;
try {
data = await publicApiRequest('/api/sites/status');
} catch (error) {
// 如果获取网站状态失败,可能是数据库未初始化,尝试初始化
await publicApiRequest('/api/init-db');
data = await publicApiRequest('/api/sites/status');
}
const sites = data.sites || [];
const noSitesAlert = document.getElementById('noSites');
const siteStatusTableBody = document.getElementById('siteStatusTableBody');
if (sites.length === 0) {
noSitesAlert.classList.remove('d-none');
siteStatusTableBody.innerHTML = '<tr><td colspan="6" class="text-center">No websites are being monitored.</td></tr>'; // Colspan updated
// 同时更新移动端卡片容器
renderMobileSiteCards([]);
return;
} else {
noSitesAlert.classList.add('d-none');
}
renderSiteStatusTable(sites);
} catch (error) {
const siteStatusTableBody = document.getElementById('siteStatusTableBody');
siteStatusTableBody.innerHTML = '<tr><td colspan="6" class="text-center text-danger">Failed to load website status data. Please refresh the page.</td></tr>'; // Colspan updated
// 显示错误通知
showToast('danger', '加载网站数据失败,请刷新页面重试');
}
}
// Render the website status table
async function renderSiteStatusTable(sites) {
const tableBody = document.getElementById('siteStatusTableBody');
tableBody.innerHTML = ''; // Clear existing rows
for (const site of sites) {
const row = document.createElement('tr');
const statusInfo = getSiteStatusBadge(site.last_status);
const lastCheckTime = site.last_checked ? new Date(site.last_checked * 1000).toLocaleString() : '从未';
const responseTime = site.last_response_time_ms !== null ? \`\${site.last_response_time_ms} ms\` : '-';
const historyCell = document.createElement('td');
const historyContainer = document.createElement('div');
historyContainer.className = 'history-bar-container';
historyCell.appendChild(historyContainer);
row.innerHTML = \`
<td>\${site.name || '-'}</td>
<td><span class="badge \${statusInfo.class}">\${statusInfo.text}</span></td>
<td>\${site.last_status_code || '-'}</td>
<td>\${responseTime}</td>
<td>\${lastCheckTime}</td>
\`;
row.appendChild(historyCell);
tableBody.appendChild(row);
// 直接使用站点的历史数据渲染历史条
renderSiteHistoryBar(historyContainer, site.history || []);
}
// 同时渲染移动端卡片
renderMobileSiteCards(sites);
}
// Render 24h history bar for a site (unified function for PC and mobile)
function renderSiteHistoryBar(containerElement, history) {
let historyHtml = '';
const now = new Date();
for (let i = 0; i < 24; i++) {
const slotTime = new Date(now);
slotTime.setHours(now.getHours() - i);
const slotStart = new Date(slotTime);
slotStart.setMinutes(0, 0, 0);
const slotEnd = new Date(slotTime);
slotEnd.setMinutes(59, 59, 999);
const slotStartTimestamp = Math.floor(slotStart.getTime() / 1000);
const slotEndTimestamp = Math.floor(slotEnd.getTime() / 1000);
const recordForHour = history?.find(
r => r.timestamp >= slotStartTimestamp && r.timestamp <= slotEndTimestamp
);
let barClass = 'history-bar-pending';
let titleText = \`\${String(slotStart.getHours()).padStart(2, '0')}:00 - \${String((slotStart.getHours() + 1) % 24).padStart(2, '0')}:00: 无记录\`;
if (recordForHour) {
if (recordForHour.status === 'UP') {
barClass = 'history-bar-up';
} else if (['DOWN', 'TIMEOUT', 'ERROR'].includes(recordForHour.status)) {
barClass = 'history-bar-down';
}
const recordDate = new Date(recordForHour.timestamp * 1000);
titleText = \`\${recordDate.toLocaleString()}: \${recordForHour.status} (\${recordForHour.status_code || 'N/A'}), \${recordForHour.response_time_ms || '-'}ms\`;
}
historyHtml += \`<div class="history-bar \${barClass}" title="\${titleText}"></div>\`;
}
containerElement.innerHTML = historyHtml;
}
// Get website status badge class and text (copied from admin.js for reuse)
function getSiteStatusBadge(status) {
switch (status) {
case 'UP': return { class: 'bg-success', text: '正常' };
case 'DOWN': return { class: 'bg-danger', text: '故障' };
case 'TIMEOUT': return { class: 'bg-warning text-dark', text: '超时' };
case 'ERROR': return { class: 'bg-danger', text: '错误' };
case 'PENDING': return { class: 'bg-secondary', text: '待检测' };
default: return { class: 'bg-secondary', text: '未知' };
}
}
// ==================== 全局背景设置功能 ====================
// 全局背景设置加载函数
async function loadGlobalBackgroundSettings() {
try {
// 检查localStorage缓存(无痕模式兼容)
const cacheKey = 'background-settings-cache';
let cached = null;
let settings = null;
try {
cached = localStorage.getItem(cacheKey);
} catch (storageError) {
}
if (cached) {
try {
const cachedData = JSON.parse(cached);
const now = Date.now();
const cacheAge = now - cachedData.timestamp;
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
if (cacheAge < CACHE_DURATION) {
settings = cachedData;
}
} catch (parseError) {
}
}
// 缓存过期或不存在,从API获取
if (!settings) {
try {
const response = await fetch('/api/background-settings');
if (response.ok) {
const apiSettings = await response.json();
settings = {
enabled: apiSettings.enabled,
url: apiSettings.url,
opacity: apiSettings.opacity,
timestamp: Date.now()
};
// 尝试更新缓存(无痕模式可能失败,但不影响功能)
try {
localStorage.setItem(cacheKey, JSON.stringify(settings));
} catch (storageError) {
}
} else {
settings = { enabled: false, url: '', opacity: 80 };
}
} catch (error) {
settings = { enabled: false, url: '', opacity: 80 };
}
}
// 应用背景设置
applyGlobalBackgroundSettings(settings.enabled, settings.url, settings.opacity);
} catch (error) {
}
}
// 应用全局背景设置
function applyGlobalBackgroundSettings(enabled, url, opacity) {
const body = document.body;
if (enabled && url) {
// 验证URL格式
if (!url.startsWith('https://')) {
return;
}
// 预加载图片,确保加载成功
const img = new Image();
img.onload = function() {
// 图片加载成功,应用背景
body.style.setProperty('--custom-background-url', \`url(\${url})\`);
body.style.setProperty('--page-opacity', opacity / 100);
body.classList.add('custom-background-enabled');
};
img.onerror = function() {
// 图片加载失败,不应用背景
body.classList.remove('custom-background-enabled');
body.classList.remove('low-contrast', 'medium-contrast', 'high-contrast');
};
img.src = url;
} else {
// 移除背景设置
body.style.removeProperty('--custom-background-url');
body.style.removeProperty('--page-opacity');
body.classList.remove('custom-background-enabled');
}
}
// 页面加载时初始化背景设置
document.addEventListener('DOMContentLoaded', function() {
loadGlobalBackgroundSettings();
});
// 监听storage事件,实现跨页面设置同步
window.addEventListener('storage', function(e) {
if (e.key === 'background-settings-cache' && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue);
applyGlobalBackgroundSettings(newSettings.enabled, newSettings.url, newSettings.opacity);
} catch (error) {
}
}
});
`;
}
function getLoginJs() {
return `// login.js - 登录页面的JavaScript逻辑
// ==================== 统一API请求工具 ====================
// 注意:此处的apiRequest函数已移至主要位置,避免重复定义
// --- Theme Management (copied from main.js) ---
const THEME_KEY = 'vps-monitor-theme';
const LIGHT_THEME = 'light';
const DARK_THEME = 'dark';
function initializeTheme() {
const themeToggler = document.getElementById('themeToggler');
if (!themeToggler) return;
const storedTheme = localStorage.getItem(THEME_KEY) || LIGHT_THEME;
applyTheme(storedTheme);
themeToggler.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-bs-theme');
const newTheme = currentTheme === DARK_THEME ? LIGHT_THEME : DARK_THEME;
applyTheme(newTheme);
localStorage.setItem(THEME_KEY, newTheme);
});
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
const themeTogglerIcon = document.querySelector('#themeToggler i');
if (themeTogglerIcon) {
if (theme === DARK_THEME) {
themeTogglerIcon.classList.remove('bi-moon-stars-fill');
themeTogglerIcon.classList.add('bi-sun-fill');
} else {
themeTogglerIcon.classList.remove('bi-sun-fill');
themeTogglerIcon.classList.add('bi-moon-stars-fill');
}
}
}
// --- End Theme Management ---
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
// Initialize theme
initializeTheme();
// 获取登录表单元素
const loginForm = document.getElementById('loginForm');
const loginAlert = document.getElementById('loginAlert');
// 添加表单提交事件监听
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
// 获取用户输入
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
// 验证输入
if (!username || !password) {
showToast('warning', '请输入用户名和密码');
return;
}
// 执行登录
login(username, password);
});
// 加载默认凭据信息
loadDefaultCredentials();
// 检查是否已登录
checkLoginStatus();
});
// ==================== 统一API请求工具 ====================
// 获取认证头
function getAuthHeaders() {
const token = localStorage.getItem('auth_token');
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
return headers;
}
// 统一API请求函数
async function apiRequest(url, options = {}) {
const defaultOptions = {
headers: getAuthHeaders(),
...options
};
try {
const response = await fetch(url, defaultOptions);
// 处理认证失败
if (response.status === 401) {
localStorage.removeItem('auth_token');
if (window.location.pathname !== '/login.html') {
window.location.href = 'login.html';
}
throw new Error('认证失败,请重新登录');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || \`请求失败 (\${response.status})\`);
}
return await response.json();
} catch (error) {
throw error;
}
}
// 加载默认凭据信息(本地显示,无需API调用)
function loadDefaultCredentials() {
const credentialsInfo = document.getElementById('defaultCredentialsInfo');
if (credentialsInfo) {
credentialsInfo.innerHTML = '默认账号密码: <strong>admin</strong> / <strong>monitor2025!</strong><br><small class="text-danger fw-bold">建议首次登录后修改密码</small>';
}
}
// 检查登录状态
async function checkLoginStatus() {
try {
// 从localStorage获取token
const token = localStorage.getItem('auth_token');
if (!token) {
return;
}
const data = await apiRequest('/api/auth/status');
if (data.authenticated) {
// 已登录,重定向到管理后台
window.location.href = 'admin.html';
}
} catch (error) {
}
}
// 登录函数
async function login(username, password) {
try {
// 显示加载状态
const loginForm = document.getElementById('loginForm');
const submitBtn = loginForm.querySelector('button[type="submit"]');
const originalBtnText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 登录中...';
// 发送登录请求(不需要认证头)
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || \`登录失败 (\${response.status})\`);
}
const data = await response.json();
// 恢复按钮状态
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
// 保存token到localStorage
localStorage.setItem('auth_token', data.token);
// 直接跳转到管理后台
window.location.href = 'admin.html';
} catch (error) {
// 恢复按钮状态
const loginForm = document.getElementById('loginForm');
const submitBtn = loginForm.querySelector('button[type="submit"]');
submitBtn.disabled = false;
submitBtn.innerHTML = '登录';
showToast('danger', error.message || '登录请求失败,请稍后重试');
}
}
// ==================== 全局背景设置功能 ====================
// 全局背景设置加载函数(登录页面版本)
async function loadGlobalBackgroundSettings() {
try {
// 检查localStorage缓存(无痕模式兼容)
const cacheKey = 'background-settings-cache';
let cached = null;
let settings = null;
try {
cached = localStorage.getItem(cacheKey);
} catch (storageError) {
}
if (cached) {
try {
const cachedData = JSON.parse(cached);
const now = Date.now();
const cacheAge = now - cachedData.timestamp;
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
if (cacheAge < CACHE_DURATION) {
settings = cachedData;
}
} catch (parseError) {
}
}
// 缓存过期或不存在,从API获取
if (!settings) {
try {
const response = await fetch('/api/background-settings');
if (response.ok) {
const apiSettings = await response.json();
settings = {
enabled: apiSettings.enabled,
url: apiSettings.url,
opacity: apiSettings.opacity,
timestamp: Date.now()
};
// 尝试更新缓存(无痕模式可能失败,但不影响功能)
try {
localStorage.setItem(cacheKey, JSON.stringify(settings));
} catch (storageError) {
}
} else {
settings = { enabled: false, url: '', opacity: 80 };
}
} catch (error) {
settings = { enabled: false, url: '', opacity: 80 };
}
}
// 应用背景设置
applyGlobalBackgroundSettings(settings.enabled, settings.url, settings.opacity);
} catch (error) {
}
}
// 应用全局背景设置
function applyGlobalBackgroundSettings(enabled, url, opacity) {
const body = document.body;
if (enabled && url) {
// 验证URL格式
if (!url.startsWith('https://')) {
return;
}
// 预加载图片,确保加载成功
const img = new Image();
img.onload = function() {
// 图片加载成功,应用背景
body.style.setProperty('--custom-background-url', \`url(\${url})\`);
body.style.setProperty('--page-opacity', opacity / 100);
body.classList.add('custom-background-enabled');
};
img.onerror = function() {
// 图片加载失败,不应用背景
body.classList.remove('custom-background-enabled');
};
img.src = url;
} else {
// 移除背景设置
body.style.removeProperty('--custom-background-url');
body.style.removeProperty('--page-opacity');
body.classList.remove('custom-background-enabled');
}
}
// 页面加载时初始化背景设置
document.addEventListener('DOMContentLoaded', function() {
loadGlobalBackgroundSettings();
});
// 监听storage事件,实现跨页面设置同步
window.addEventListener('storage', function(e) {
if (e.key === 'background-settings-cache' && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue);
applyGlobalBackgroundSettings(newSettings.enabled, newSettings.url, newSettings.opacity);
} catch (error) {
}
}
});
`;
}
// Helper functions for updating server/site settings are no longer needed for frequent notifications
// as that feature is removed.
function getAdminJs() {
return `// admin.js - 管理后台的JavaScript逻辑
// ==================== 统一API请求工具 ====================
// 获取认证头
function getAuthHeaders() {
const token = localStorage.getItem('auth_token');
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
return headers;
}
// 统一API请求函数
async function apiRequest(url, options = {}) {
const defaultOptions = {
headers: getAuthHeaders(),
...options
};
try {
const response = await fetch(url, defaultOptions);
// 处理认证失败
if (response.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = 'login.html';
throw new Error('认证失败,请重新登录');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || \`请求失败 (\${response.status})\`);
}
return await response.json();
} catch (error) {
throw error;
}
}
// Global variables for VPS data updates
let vpsUpdateInterval = null;
const DEFAULT_VPS_REFRESH_INTERVAL_MS = 60000; // Default to 60 seconds for VPS data if backend setting fails
// Function to fetch VPS refresh interval and start periodic VPS data updates
async function initializeVpsDataUpdates() {
let vpsRefreshIntervalMs = DEFAULT_VPS_REFRESH_INTERVAL_MS;
try {
const data = await apiRequest('/api/admin/settings/vps-report-interval');
if (data && typeof data.interval === 'number' && data.interval > 0) {
vpsRefreshIntervalMs = data.interval * 1000; // Convert seconds to milliseconds
} else {
// 使用默认值
}
} catch (error) {
}
// Clear existing interval if any
if (vpsUpdateInterval) {
clearInterval(vpsUpdateInterval);
}
// Set up new periodic updates for VPS data ONLY
vpsUpdateInterval = setInterval(() => {
// Reload server list to get updated data
if (typeof loadServerList === 'function') {
loadServerList();
}
}, vpsRefreshIntervalMs);
}
// --- Theme Management (copied from main.js) ---
const THEME_KEY = 'vps-monitor-theme';
const LIGHT_THEME = 'light';
const DARK_THEME = 'dark';
function initializeTheme() {
const themeToggler = document.getElementById('themeToggler');
if (!themeToggler) return;
const storedTheme = localStorage.getItem(THEME_KEY) || LIGHT_THEME;
applyTheme(storedTheme);
themeToggler.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-bs-theme');
const newTheme = currentTheme === DARK_THEME ? LIGHT_THEME : DARK_THEME;
applyTheme(newTheme);
localStorage.setItem(THEME_KEY, newTheme);
});
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
const themeTogglerIcon = document.querySelector('#themeToggler i');
if (themeTogglerIcon) {
if (theme === DARK_THEME) {
themeTogglerIcon.classList.remove('bi-moon-stars-fill');
themeTogglerIcon.classList.add('bi-sun-fill');
} else {
themeTogglerIcon.classList.remove('bi-sun-fill');
themeTogglerIcon.classList.add('bi-moon-stars-fill');
}
}
}
// --- End Theme Management ---
// 工具提示现在使用浏览器原生title属性,无需JavaScript初始化
// 优化的清理函数 - 清理可能卡住的开关
function cleanupStuckToggles() {
const stuckToggles = document.querySelectorAll('[data-updating="true"]');
if (stuckToggles.length > 0) {
stuckToggles.forEach(toggle => {
toggle.disabled = false;
delete toggle.dataset.updating;
toggle.style.opacity = '1';
});
}
}
// 移除了复杂的waitForToggleReady函数,现在直接在API响应后更新UI状态
// 全局变量
let currentServerId = null;
let currentSiteId = null; // For site deletion
let serverList = [];
let siteList = []; // For monitored sites
let hasAddedNewServer = false; // 标记是否添加了新服务器
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', async function() {
// Initialize theme
initializeTheme();
// 检查登录状态 - 必须先完成认证检查
await checkLoginStatus();
// 初始化事件监听
initEventListeners();
// 初始化Bootstrap tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// 加载服务器列表
loadServerList();
// 加载监控网站列表
loadSiteList();
// 加载Telegram设置
loadTelegramSettings();
// 加载背景设置
loadBackgroundSettings();
// 加载全局设置 (VPS Report Interval) - will use serverAlert for notifications
loadGlobalSettings();
// 初始化管理后台的定时刷新机制
initializeVpsDataUpdates();
// 检查是否使用默认密码
checkDefaultPasswordUsage();
// 优化:停止自动清理以节省配额
// setInterval(cleanupStuckToggles, 30000);
});
// 检查登录状态
async function checkLoginStatus() {
try {
// 从localStorage获取token
const token = localStorage.getItem('auth_token');
if (!token) {
// 未登录,重定向到登录页面
window.location.href = 'login.html';
return;
}
const data = await apiRequest('/api/auth/status');
if (!data.authenticated) {
// 未登录,重定向到登录页面
window.location.href = 'login.html';
}
} catch (error) {
window.location.href = 'login.html';
}
}
// 检查是否使用默认密码
async function checkDefaultPasswordUsage() {
try {
// 从localStorage获取是否显示过默认密码提醒
const hasShownDefaultPasswordWarning = localStorage.getItem('hasShownDefaultPasswordWarning');
if (hasShownDefaultPasswordWarning === 'true') {
return; // 已经显示过提醒,不再显示
}
// 检查当前用户登录状态和默认密码使用情况
const token = localStorage.getItem('auth_token');
if (token) {
try {
const statusData = await apiRequest('/api/auth/status');
if (statusData.authenticated && statusData.user && statusData.user.usingDefaultPassword) {
// 显示默认密码提醒
showToast('warning',
'安全提醒:您正在使用默认密码登录。为了您的账户安全,建议尽快修改密码。点击右上角的"修改密码"按钮来更改密码。',
{ duration: 10000 }); // 10秒显示
// 标记已显示过提醒
localStorage.setItem('hasShownDefaultPasswordWarning', 'true');
}
} catch (error) {
}
}
} catch (error) {
}
}
// 初始化事件监听
function initEventListeners() {
// 添加服务器按钮
document.getElementById('addServerBtn').addEventListener('click', function() {
showServerModal();
});
// 保存服务器按钮
document.getElementById('saveServerBtn').addEventListener('click', function() {
saveServer();
});
// Helper function for copying text to clipboard and providing button feedback
function copyToClipboard(textToCopy, buttonElement) {
navigator.clipboard.writeText(textToCopy).then(() => {
const originalHtml = buttonElement.innerHTML;
buttonElement.innerHTML = '<i class="bi bi-check-lg"></i>'; // Using a larger check icon
buttonElement.classList.add('btn-success');
buttonElement.classList.remove('btn-outline-secondary');
setTimeout(() => {
buttonElement.innerHTML = originalHtml;
buttonElement.classList.remove('btn-success');
buttonElement.classList.add('btn-outline-secondary');
}, 2000);
}).catch(err => {
// 静默处理复制失败
const originalHtml = buttonElement.innerHTML;
buttonElement.innerHTML = '<i class="bi bi-x-lg"></i>'; // Error icon
buttonElement.classList.add('btn-danger');
buttonElement.classList.remove('btn-outline-secondary');
setTimeout(() => {
buttonElement.innerHTML = originalHtml;
buttonElement.classList.remove('btn-danger');
buttonElement.classList.add('btn-outline-secondary');
}, 2000);
});
}
// 复制API密钥按钮
document.getElementById('copyApiKeyBtn').addEventListener('click', function() {
const apiKeyInput = document.getElementById('apiKey');
copyToClipboard(apiKeyInput.value, this);
});
// 复制服务器ID按钮
document.getElementById('copyServerIdBtn').addEventListener('click', function() {
const serverIdInput = document.getElementById('serverIdDisplay');
copyToClipboard(serverIdInput.value, this);
});
// 复制Worker地址按钮
document.getElementById('copyWorkerUrlBtn').addEventListener('click', function() {
const workerUrlInput = document.getElementById('workerUrlDisplay');
copyToClipboard(workerUrlInput.value, this);
});
// 确认删除按钮
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
if (currentServerId) {
deleteServer(currentServerId);
}
});
// 修改密码按钮(移动端)
document.getElementById('changePasswordBtn').addEventListener('click', function() {
showPasswordModal();
});
// 修改密码按钮(PC端)
document.getElementById('changePasswordBtnDesktop').addEventListener('click', function() {
showPasswordModal();
});
// 保存密码按钮
document.getElementById('savePasswordBtn').addEventListener('click', function() {
changePassword();
});
// 退出登录按钮
document.getElementById('logoutBtn').addEventListener('click', function() {
logout();
});
// --- Site Monitoring Event Listeners ---
document.getElementById('addSiteBtn').addEventListener('click', function() {
showSiteModal();
});
document.getElementById('saveSiteBtn').addEventListener('click', function() {
saveSite();
});
document.getElementById('confirmDeleteSiteBtn').addEventListener('click', function() {
if (currentSiteId) {
deleteSite(currentSiteId);
}
});
// 保存Telegram设置按钮
document.getElementById('saveTelegramSettingsBtn').addEventListener('click', function() {
saveTelegramSettings();
});
// Background Settings Event Listeners
document.getElementById('saveBackgroundSettingsBtn').addEventListener('click', function() {
saveBackgroundSettings();
});
// 透明度滑块实时预览
document.getElementById('pageOpacity').addEventListener('input', function() {
updateOpacityPreview();
});
// 背景开关变化时的预览
document.getElementById('enableCustomBackground').addEventListener('change', function() {
const enabled = this.checked;
const url = document.getElementById('backgroundImageUrl').value.trim();
const opacity = parseInt(document.getElementById('pageOpacity').value, 10);
applyBackgroundSettings(enabled, url, opacity, false);
});
// URL输入框变化时的预览
document.getElementById('backgroundImageUrl').addEventListener('input', function() {
const enabled = document.getElementById('enableCustomBackground').checked;
const url = this.value.trim();
const opacity = parseInt(document.getElementById('pageOpacity').value, 10);
if (enabled) {
applyBackgroundSettings(enabled, url, opacity, false);
}
});
// Global Settings Event Listener
document.getElementById('saveVpsReportIntervalBtn').addEventListener('click', function() {
saveVpsReportInterval();
});
// 服务器模态框关闭事件监听器
const serverModal = document.getElementById('serverModal');
if (serverModal) {
serverModal.addEventListener('hidden.bs.modal', function() {
// 检查是否有新添加的服务器需要刷新列表
if (hasAddedNewServer) {
hasAddedNewServer = false; // 重置标记
loadServerList(); // 刷新服务器列表
}
});
}
// 初始化排序下拉菜单默认选择
setTimeout(() => {
// 确保DOM已完全加载
updateServerSortDropdownSelection('custom');
updateSiteSortDropdownSelection('custom');
}, 100);
}
// --- Server Management Functions ---
// 加载服务器列表
async function loadServerList() {
try {
const data = await apiRequest('/api/admin/servers');
serverList = data.servers || [];
// 简化逻辑:直接渲染,智能状态显示会处理更新中的按钮
renderServerTable(serverList);
} catch (error) {
showToast('danger', '加载服务器列表失败,请刷新页面重试');
}
}
// 渲染服务器表格
function renderServerTable(servers) {
const tableBody = document.getElementById('serverTableBody');
// 简化状态管理:不再需要复杂的状态保存机制
tableBody.innerHTML = '';
if (servers.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="10" class="text-center">暂无服务器数据</td>'; // Updated colspan
tableBody.appendChild(row);
// 同时更新移动端卡片
renderMobileAdminServerCards([]);
return;
}
servers.forEach((server, index) => {
const row = document.createElement('tr');
row.setAttribute('data-server-id', server.id);
row.classList.add('server-row-draggable');
row.draggable = true;
// 格式化最后更新时间
let lastUpdateText = '从未';
let statusBadge = '<span class="badge bg-secondary">未知</span>';
if (server.last_report) {
const lastUpdate = new Date(server.last_report * 1000);
lastUpdateText = lastUpdate.toLocaleString();
// 检查是否在线(最后报告时间在5分钟内)
const now = new Date();
const diffMinutes = (now - lastUpdate) / (1000 * 60);
if (diffMinutes <= 5) {
statusBadge = '<span class="badge bg-success">在线</span>';
} else {
statusBadge = '<span class="badge bg-danger">离线</span>';
}
}
// 智能状态显示:完整保存更新中按钮的所有状态
const existingToggle = document.querySelector('.server-visibility-toggle[data-server-id="' + server.id + '"]');
const isCurrentlyUpdating = existingToggle && existingToggle.dataset.updating === 'true';
const displayState = isCurrentlyUpdating ? existingToggle.checked : server.is_public;
const needsUpdatingState = isCurrentlyUpdating;
row.innerHTML =
'<td>' +
'<div class="btn-group">' +
'<i class="bi bi-grip-vertical text-muted me-2" style="cursor: grab;" title="拖拽排序"></i>' +
'<button class="btn btn-sm btn-outline-secondary move-server-btn" data-id="' + server.id + '" data-direction="up" ' + (index === 0 ? 'disabled' : '') + '>' +
'<i class="bi bi-arrow-up"></i>' +
'</button>' +
'<button class="btn btn-sm btn-outline-secondary move-server-btn" data-id="' + server.id + '" data-direction="down" ' + (index === servers.length - 1 ? 'disabled' : '') + '>' +
'<i class="bi bi-arrow-down"></i>' +
'</button>' +
'</div>' +
'</td>' +
'<td>' + server.id + '</td>' +
'<td>' + server.name + '</td>' +
'<td>' + (server.description || '-') + '</td>' +
'<td>' + statusBadge + '</td>' +
'<td>' + lastUpdateText + '</td>' +
'<td>' +
'<button class="btn btn-sm btn-outline-secondary view-key-btn" data-id="' + server.id + '">' +
'<i class="bi bi-key"></i> 查看密钥' +
'</button>' +
'</td>' +
'<td>' +
'<button class="btn btn-sm btn-outline-info copy-vps-script-btn" data-id="' + server.id + '" data-name="' + server.name + '" title="复制VPS安装脚本">' +
'<i class="bi bi-clipboard-plus"></i> 复制脚本' +
'</button>' +
'</td>' +
'<td>' +
'<div class="form-check form-switch">' +
'<input class="form-check-input server-visibility-toggle" type="checkbox" data-server-id="' + server.id + '" ' + (displayState ? 'checked' : '') + (needsUpdatingState ? ' data-updating="true"' : '') + '>' +
'</div>' +
'</td>' +
'<td>' +
'<div class="btn-group">' +
'<button class="btn btn-sm btn-outline-primary edit-server-btn" data-id="' + server.id + '">' +
'<i class="bi bi-pencil"></i>' +
'</button>' +
'<button class="btn btn-sm btn-outline-danger delete-server-btn" data-id="' + server.id + '" data-name="' + server.name + '">' +
'<i class="bi bi-trash"></i>' +
'</button>' +
'</div>' +
'</td>';
tableBody.appendChild(row);
});
// 初始化拖拽排序
initializeServerDragSort();
// 添加事件监听
document.querySelectorAll('.view-key-btn').forEach(btn => {
btn.addEventListener('click', function() {
const serverId = this.getAttribute('data-id');
viewApiKey(serverId);
});
});
document.querySelectorAll('.edit-server-btn').forEach(btn => {
btn.addEventListener('click', function() {
const serverId = this.getAttribute('data-id');
editServer(serverId);
});
});
document.querySelectorAll('.delete-server-btn').forEach(btn => {
btn.addEventListener('click', function() {
const serverId = this.getAttribute('data-id');
const serverName = this.getAttribute('data-name');
showDeleteConfirmation(serverId, serverName);
});
});
document.querySelectorAll('.move-server-btn').forEach(btn => {
btn.addEventListener('click', function() {
const serverId = this.getAttribute('data-id');
const direction = this.getAttribute('data-direction');
moveServer(serverId, direction);
});
});
document.querySelectorAll('.copy-vps-script-btn').forEach(btn => {
btn.addEventListener('click', function() {
const serverId = this.getAttribute('data-id');
const serverName = this.getAttribute('data-name');
copyVpsInstallScript(serverId, serverName, this);
});
});
// 优化的显示开关事件监听 - 直接处理状态切换
document.querySelectorAll('.server-visibility-toggle').forEach(toggle => {
toggle.addEventListener('click', function(event) {
// 如果开关正在更新中,忽略点击
if (this.disabled || this.dataset.updating === 'true') {
event.preventDefault();
return;
}
const serverId = this.getAttribute('data-server-id');
const targetState = this.checked; // 点击后的状态就是目标状态
const originalState = !this.checked; // 原始状态是目标状态的相反
// 立即设置为加载状态
this.disabled = true;
this.style.opacity = '0.6';
this.dataset.updating = 'true';
updateServerVisibility(serverId, targetState, originalState, this);
});
});
// 重新应用正在更新按钮的视觉状态(因为重新渲染会创建新元素)
document.querySelectorAll('.server-visibility-toggle[data-updating="true"]').forEach(toggle => {
toggle.disabled = true;
toggle.style.opacity = '0.6';
});
// 同时渲染移动端卡片
renderMobileAdminServerCards(servers);
}
// 初始化服务器拖拽排序
function initializeServerDragSort() {
const tableBody = document.getElementById('serverTableBody');
if (!tableBody) return;
let draggedElement = null;
let draggedOverElement = null;
// 为所有可拖拽行添加事件监听
const draggableRows = tableBody.querySelectorAll('.server-row-draggable');
draggableRows.forEach(row => {
row.addEventListener('dragstart', function(e) {
draggedElement = this;
this.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', this.outerHTML);
});
row.addEventListener('dragend', function(e) {
this.style.opacity = '';
draggedElement = null;
draggedOverElement = null;
// 移除所有拖拽样式
draggableRows.forEach(r => {
r.classList.remove('drag-over-top', 'drag-over-bottom');
});
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (this === draggedElement) return;
draggedOverElement = this;
// 移除其他行的拖拽样式
draggableRows.forEach(r => {
if (r !== this) {
r.classList.remove('drag-over-top', 'drag-over-bottom');
}
});
// 确定插入位置
const rect = this.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
if (e.clientY < midpoint) {
this.classList.add('drag-over-top');
this.classList.remove('drag-over-bottom');
} else {
this.classList.add('drag-over-bottom');
this.classList.remove('drag-over-top');
}
});
row.addEventListener('drop', function(e) {
e.preventDefault();
if (this === draggedElement) return;
const draggedServerId = draggedElement.getAttribute('data-server-id');
const targetServerId = this.getAttribute('data-server-id');
// 确定插入位置
const rect = this.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const insertBefore = e.clientY < midpoint;
// 执行拖拽排序
performServerDragSort(draggedServerId, targetServerId, insertBefore);
});
});
}
// 执行服务器拖拽排序
async function performServerDragSort(draggedServerId, targetServerId, insertBefore) {
try {
// 获取当前服务器列表的ID顺序
const currentOrder = serverList.map(server => server.id);
// 计算新的排序
const draggedIndex = currentOrder.indexOf(draggedServerId);
const targetIndex = currentOrder.indexOf(targetServerId);
if (draggedIndex === -1 || targetIndex === -1) {
throw new Error('无法找到服务器');
}
// 创建新的排序数组
const newOrder = [...currentOrder];
newOrder.splice(draggedIndex, 1); // 移除拖拽的元素
// 计算插入位置
let insertIndex = targetIndex;
if (draggedIndex < targetIndex) {
insertIndex = targetIndex - 1;
}
if (!insertBefore) {
insertIndex += 1;
}
newOrder.splice(insertIndex, 0, draggedServerId); // 插入到新位置
// 发送批量排序请求
await apiRequest('/api/admin/servers/batch-reorder', {
method: 'POST',
body: JSON.stringify({ serverIds: newOrder })
});
// 重新加载服务器列表
await loadServerList();
showToast('success', '服务器排序已更新');
} catch (error) {
showToast('danger', '拖拽排序失败: ' + error.message);
// 重新加载以恢复原始状态
loadServerList();
}
}
// Function to copy VPS installation script
async function copyVpsInstallScript(serverId, serverName, buttonElement) {
const originalButtonHtml = buttonElement.innerHTML;
buttonElement.disabled = true;
buttonElement.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 生成中...';
try {
// 获取包含完整API密钥的服务器信息
const response = await apiRequest('/api/admin/servers?full_key=true');
const server = response.servers.find(s => s.id === serverId);
if (!server || !server.api_key) {
throw new Error('未找到服务器或API密钥,请刷新页面重试');
}
const apiKey = server.api_key;
const workerUrl = window.location.origin;
// 使用GitHub上的脚本地址
const baseScriptUrl = "https://raw.githubusercontent.com/kadidalax/cf-vps-monitor/main/cf-vps-monitor.sh";
// 生成安装命令(让脚本自动从服务器获取上报间隔)
const scriptCommand = 'wget ' + baseScriptUrl + ' -O cf-vps-monitor.sh && chmod +x cf-vps-monitor.sh && ./cf-vps-monitor.sh -i -k ' + apiKey + ' -s ' + serverId + ' -u ' + workerUrl;
await navigator.clipboard.writeText(scriptCommand);
buttonElement.innerHTML = '<i class="bi bi-check-lg"></i> 已复制!';
buttonElement.classList.remove('btn-outline-info');
buttonElement.classList.add('btn-success');
showToast('success', '服务器 "' + serverName + '" 的安装脚本已复制到剪贴板');
} catch (error) {
showToast('danger', '复制脚本失败: ' + error.message);
buttonElement.innerHTML = '<i class="bi bi-x-lg"></i> 复制失败';
buttonElement.classList.remove('btn-outline-info');
buttonElement.classList.add('btn-danger');
} finally {
setTimeout(() => {
buttonElement.disabled = false;
buttonElement.innerHTML = originalButtonHtml;
buttonElement.classList.remove('btn-success', 'btn-danger');
buttonElement.classList.add('btn-outline-info');
}, 3000); // Revert button state after 3 seconds
}
}
// 更新服务器显示状态
async function updateServerVisibility(serverId, isPublic, originalState, toggleElement) {
const startTime = Date.now();
try {
const data = await apiRequest('/api/admin/servers/' + serverId + '/visibility', {
method: 'POST',
body: JSON.stringify({ is_public: isPublic })
});
const requestTime = Date.now() - startTime;
// 更新本地数据
const serverIndex = serverList.findIndex(s => s.id === serverId);
if (serverIndex !== -1) {
serverList[serverIndex].is_public = isPublic;
}
// 成功后设置最终正常状态 - 使用可靠的恢复机制
function restoreButtonState(retryCount = 0) {
const currentToggle = document.querySelector('.server-visibility-toggle[data-server-id="' + serverId + '"]');
if (currentToggle) {
currentToggle.checked = isPublic;
currentToggle.style.opacity = '1';
currentToggle.disabled = false;
delete currentToggle.dataset.updating;
// 直接显示成功提醒
showToast('success', '服务器显示状态已' + (isPublic ? '开启' : '关闭'));
} else if (retryCount < 3) {
setTimeout(() => restoreButtonState(retryCount + 1), 100);
} else {
// 静默处理按钮元素未找到
}
}
// 立即尝试恢复,如果失败则重试
restoreButtonState();
} catch (error) {
// 失败时恢复原始状态
const currentToggle = document.querySelector('.server-visibility-toggle[data-server-id="' + serverId + '"]');
if (currentToggle) {
currentToggle.checked = originalState;
currentToggle.style.opacity = '1';
currentToggle.disabled = false;
delete currentToggle.dataset.updating;
// 直接显示错误提醒,不需要等待状态变化
showToast('danger', '更新显示状态失败: ' + error.message);
} else {
// 如果找不到开关元素,立即显示错误
showToast('danger', '更新显示状态失败: ' + error.message);
}
}
}
// 移动服务器顺序
async function moveServer(serverId, direction) {
try {
await apiRequest('/api/admin/servers/' + serverId + '/reorder', {
method: 'POST',
body: JSON.stringify({ direction })
});
// 重新加载列表以反映新顺序
await loadServerList();
showToast('success', '服务器已成功' + (direction === 'up' ? '上移' : '下移'));
} catch (error) {
showToast('danger', '移动服务器失败: ' + error.message);
}
}
// 显示服务器模态框(添加模式)
function showServerModal() {
// 重置表单和标记
document.getElementById('serverForm').reset();
document.getElementById('serverId').value = '';
document.getElementById('apiKeyGroup').classList.add('d-none');
document.getElementById('serverIdDisplayGroup').classList.add('d-none');
document.getElementById('workerUrlDisplayGroup').classList.add('d-none');
hasAddedNewServer = false; // 重置新服务器标记
// 设置模态框标题
document.getElementById('serverModalTitle').textContent = '添加服务器';
// 显示模态框
const serverModal = new bootstrap.Modal(document.getElementById('serverModal'));
serverModal.show();
}
// 编辑服务器
function editServer(serverId) {
const server = serverList.find(s => s.id === serverId);
if (!server) return;
// 填充表单
document.getElementById('serverId').value = server.id;
document.getElementById('serverName').value = server.name;
document.getElementById('serverDescription').value = server.description || '';
document.getElementById('apiKeyGroup').classList.add('d-none');
document.getElementById('serverIdDisplayGroup').classList.add('d-none');
document.getElementById('workerUrlDisplayGroup').classList.add('d-none');
// 设置模态框标题
document.getElementById('serverModalTitle').textContent = '编辑服务器';
// 显示模态框
const serverModal = new bootstrap.Modal(document.getElementById('serverModal'));
serverModal.show();
}
// 保存服务器
async function saveServer() {
const serverId = document.getElementById('serverId').value;
const serverName = document.getElementById('serverName').value.trim();
const serverDescription = document.getElementById('serverDescription').value.trim();
// const enableFrequentNotifications = document.getElementById('serverEnableFrequentNotifications').checked; // Removed
if (!serverName) {
showToast('warning', '服务器名称不能为空');
return;
}
try {
let data;
if (serverId) {
// 更新服务器
data = await apiRequest('/api/admin/servers/' + serverId, {
method: 'PUT',
body: JSON.stringify({
name: serverName,
description: serverDescription
})
});
} else {
// 添加服务器
data = await apiRequest('/api/admin/servers', {
method: 'POST',
body: JSON.stringify({
name: serverName,
description: serverDescription
})
});
}
// 如果是新添加的服务器,流畅地切换到密钥显示(不隐藏模态框)
if (!serverId && data.server && data.server.api_key) {
hasAddedNewServer = true; // 标记已添加新服务器
// 直接在当前模态框中显示密钥信息,提供流畅的用户体验
// 不隐藏模态框,而是切换内容,让用户感觉是自然的过渡
showApiKeyInCurrentModal(data.server);
showToast('success', '服务器添加成功');
// 在后台异步刷新服务器列表
loadServerList().catch(error => {
});
} else {
// 编辑服务器的情况,正常隐藏模态框并刷新列表
const serverModal = bootstrap.Modal.getInstance(document.getElementById('serverModal'));
serverModal.hide();
await loadServerList();
showToast('success', serverId ? '服务器更新成功' : '服务器添加成功');
}
} catch (error) {
showToast('danger', '保存服务器失败,请稍后重试');
}
}
// 查看API密钥(获取完整密钥版本)
async function viewApiKey(serverId) {
try {
// 请求包含完整API密钥的服务器信息
const response = await apiRequest('/api/admin/servers?full_key=true');
const server = response.servers.find(s => s.id === serverId);
if (server && server.api_key) {
showApiKey(server);
} else {
showToast('danger', '未找到服务器信息或API密钥,请稍后重试');
}
} catch (error) {
showToast('danger', '查看API密钥失败,请稍后重试');
}
}
// 在当前模态框中显示API密钥(用于添加服务器后的流畅过渡)
function showApiKeyInCurrentModal(server) {
// 填充表单数据
document.getElementById('serverId').value = server.id;
document.getElementById('serverName').value = server.name;
document.getElementById('serverDescription').value = server.description || '';
// 显示API密钥、服务器ID和Worker URL
document.getElementById('apiKey').value = server.api_key;
document.getElementById('apiKeyGroup').classList.remove('d-none');
document.getElementById('serverIdDisplay').value = server.id;
document.getElementById('serverIdDisplayGroup').classList.remove('d-none');
document.getElementById('workerUrlDisplay').value = window.location.origin;
document.getElementById('workerUrlDisplayGroup').classList.remove('d-none');
// 更新模态框标题
document.getElementById('serverModalTitle').textContent = '服务器详细信息与密钥';
// 注意:不创建新的模态框,而是在当前模态框中切换内容
// 这样用户感觉是自然的内容过渡,而不是突然弹出新窗口
}
// 显示API密钥(用于查看密钥按钮)
function showApiKey(server) {
// 填充表单
document.getElementById('serverId').value = server.id; // Hidden input for form submission if needed
document.getElementById('serverName').value = server.name;
document.getElementById('serverDescription').value = server.description || '';
// Populate and show API Key, Server ID, and Worker URL
document.getElementById('apiKey').value = server.api_key;
document.getElementById('apiKeyGroup').classList.remove('d-none');
document.getElementById('serverIdDisplay').value = server.id;
document.getElementById('serverIdDisplayGroup').classList.remove('d-none');
document.getElementById('workerUrlDisplay').value = window.location.origin;
document.getElementById('workerUrlDisplayGroup').classList.remove('d-none');
// 设置模态框标题
document.getElementById('serverModalTitle').textContent = '服务器详细信息与密钥';
// 显示模态框
const serverModal = new bootstrap.Modal(document.getElementById('serverModal'));
serverModal.show();
}
// 显示删除确认
function showDeleteConfirmation(serverId, serverName) {
currentServerId = serverId;
document.getElementById('deleteServerName').textContent = serverName;
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
deleteModal.show();
}
// 删除服务器
async function deleteServer(serverId) {
try {
await apiRequest('/api/admin/servers/' + serverId + '?confirm=true', {
method: 'DELETE'
});
// 隐藏模态框
const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
deleteModal.hide();
// 重新加载服务器列表
loadServerList();
showToast('success', '服务器删除成功');
} catch (error) {
showToast('danger', '删除服务器失败,请稍后重试');
}
}
// --- Site Monitoring Functions (Continued) ---
// 更新网站显示状态
async function updateSiteVisibility(siteId, isPublic, originalState, toggleElement) {
const startTime = Date.now();
try {
await apiRequest('/api/admin/sites/' + siteId + '/visibility', {
method: 'POST',
body: JSON.stringify({ is_public: isPublic })
});
const requestTime = Date.now() - startTime;
// 更新本地数据
const siteIndex = siteList.findIndex(s => s.id === siteId);
if (siteIndex !== -1) {
siteList[siteIndex].is_public = isPublic;
}
// 成功后设置最终正常状态 - 使用可靠的恢复机制
function restoreButtonState(retryCount = 0) {
const currentToggle = document.querySelector('.site-visibility-toggle[data-site-id="' + siteId + '"]');
if (currentToggle) {
currentToggle.checked = isPublic;
currentToggle.style.opacity = '1';
currentToggle.disabled = false;
delete currentToggle.dataset.updating;
// 直接显示成功提醒
showToast('success', '网站显示状态已' + (isPublic ? '开启' : '关闭'));
} else if (retryCount < 3) {
setTimeout(() => restoreButtonState(retryCount + 1), 100);
} else {
// 静默处理网站按钮元素未找到
}
}
// 立即尝试恢复,如果失败则重试
restoreButtonState();
} catch (error) {
// 失败时恢复原始状态
const currentToggle = document.querySelector('.site-visibility-toggle[data-site-id="' + siteId + '"]');
if (currentToggle) {
currentToggle.checked = originalState;
currentToggle.style.opacity = '1';
currentToggle.disabled = false;
delete currentToggle.dataset.updating;
// 直接显示错误提醒,不需要等待状态变化
showToast('danger', '更新显示状态失败: ' + error.message);
} else {
// 如果找不到开关元素,立即显示错误
showToast('danger', '更新显示状态失败: ' + error.message);
}
}
}
// 移动网站顺序
async function moveSite(siteId, direction) {
try {
await apiRequest('/api/admin/sites/' + siteId + '/reorder', {
method: 'POST',
body: JSON.stringify({ direction })
});
// 重新加载列表以反映新顺序
await loadSiteList();
showToast('success', '网站已成功' + (direction === 'up' ? '上移' : '下移'));
} catch (error) {
showToast('danger', '移动网站失败: ' + error.message);
}
}
// --- Password Management Functions ---
// 显示密码修改模态框
function showPasswordModal() {
// 重置表单
document.getElementById('passwordForm').reset();
const passwordModal = new bootstrap.Modal(document.getElementById('passwordModal'));
passwordModal.show();
}
// 修改密码
async function changePassword() {
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// 验证输入
if (!currentPassword || !newPassword || !confirmPassword) {
showToast('warning', '所有密码字段都必须填写');
return;
}
if (newPassword !== confirmPassword) {
showToast('warning', '新密码和确认密码不匹配');
return;
}
try {
await apiRequest('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
// 隐藏模态框
const passwordModal = bootstrap.Modal.getInstance(document.getElementById('passwordModal'));
passwordModal.hide();
// 清除默认密码提醒标记,这样如果用户再次使用默认密码登录会重新提醒
localStorage.removeItem('hasShownDefaultPasswordWarning');
showToast('success', '密码修改成功');
} catch (error) {
showToast('danger', '密码修改请求失败,请稍后重试');
}
}
// --- Auth Functions ---
// 退出登录
function logout() {
// 清除localStorage中的token和提醒标记
localStorage.removeItem('auth_token');
localStorage.removeItem('hasShownDefaultPasswordWarning');
// 重定向到登录页面
window.location.href = 'login.html';
}
// --- Site Monitoring Functions ---
// 加载监控网站列表
async function loadSiteList() {
try {
const data = await apiRequest('/api/admin/sites');
siteList = data.sites || [];
// 简化逻辑:直接渲染,智能状态显示会处理更新中的按钮
renderSiteTable(siteList);
} catch (error) {
showToast('danger', '加载监控网站列表失败: ' + error.message);
}
}
// 渲染监控网站表格
function renderSiteTable(sites) {
const tableBody = document.getElementById('siteTableBody');
// 简化状态管理:不再需要复杂的状态保存机制
tableBody.innerHTML = '';
if (sites.length === 0) {
tableBody.innerHTML = '<tr><td colspan="9" class="text-center">暂无监控网站</td></tr>'; // Colspan updated
// 同时更新移动端卡片
renderMobileAdminSiteCards([]);
return;
}
sites.forEach((site, index) => { // Added index for sorting buttons
const row = document.createElement('tr');
row.setAttribute('data-site-id', site.id);
row.classList.add('site-row-draggable');
row.draggable = true;
const statusInfo = getSiteStatusBadge(site.last_status);
const lastCheckTime = site.last_checked ? new Date(site.last_checked * 1000).toLocaleString() : '从未';
const responseTime = site.last_response_time_ms !== null ? \`\${site.last_response_time_ms} ms\` : '-';
// 智能状态显示:完整保存更新中按钮的所有状态
const existingToggle = document.querySelector('.site-visibility-toggle[data-site-id="' + site.id + '"]');
const isCurrentlyUpdating = existingToggle && existingToggle.dataset.updating === 'true';
const displayState = isCurrentlyUpdating ? existingToggle.checked : site.is_public;
const needsUpdatingState = isCurrentlyUpdating;
row.innerHTML = \`
<td>
<div class="btn-group btn-group-sm">
<i class="bi bi-grip-vertical text-muted me-2" style="cursor: grab;" title="拖拽排序"></i>
<button class="btn btn-outline-secondary move-site-btn" data-id="\${site.id}" data-direction="up" \${index === 0 ? 'disabled' : ''} title="上移">
<i class="bi bi-arrow-up"></i>
</button>
<button class="btn btn-outline-secondary move-site-btn" data-id="\${site.id}" data-direction="down" \${index === sites.length - 1 ? 'disabled' : ''} title="下移">
<i class="bi bi-arrow-down"></i>
</button>
</div>
</td>
<td>\${site.name || '-'}</td>
<td><a href="\${site.url}" target="_blank" rel="noopener noreferrer">\${site.url}</a></td>
<td><span class="badge \${statusInfo.class}">\${statusInfo.text}</span></td>
<td>\${site.last_status_code || '-'}</td>
<td>\${responseTime}</td>
<td>\${lastCheckTime}</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input site-visibility-toggle" type="checkbox" data-site-id="\${site.id}" \${displayState ? 'checked' : ''}\${needsUpdatingState ? ' data-updating="true"' : ''}>
</div>
</td>
<td>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary edit-site-btn" data-id="\${site.id}" title="编辑">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger delete-site-btn" data-id="\${site.id}" data-name="\${site.name || site.url}" data-url="\${site.url}" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
\`;
tableBody.appendChild(row);
});
// 初始化拖拽排序
initializeSiteDragSort();
// Add event listeners for edit and delete buttons
document.querySelectorAll('.edit-site-btn').forEach(btn => {
btn.addEventListener('click', function() {
const siteId = this.getAttribute('data-id');
editSite(siteId);
});
});
document.querySelectorAll('.delete-site-btn').forEach(btn => {
btn.addEventListener('click', function() {
const siteId = this.getAttribute('data-id');
const siteName = this.getAttribute('data-name');
const siteUrl = this.getAttribute('data-url');
showDeleteSiteConfirmation(siteId, siteName, siteUrl);
});
});
// Add event listeners for move buttons
document.querySelectorAll('.move-site-btn').forEach(btn => {
btn.addEventListener('click', function() {
const siteId = this.getAttribute('data-id');
const direction = this.getAttribute('data-direction');
moveSite(siteId, direction);
});
});
// 优化的网站显示开关事件监听 - 直接处理状态切换
document.querySelectorAll('.site-visibility-toggle').forEach(toggle => {
toggle.addEventListener('click', function(event) {
// 如果开关正在更新中,忽略点击
if (this.disabled || this.dataset.updating === 'true') {
event.preventDefault();
return;
}
const siteId = this.getAttribute('data-site-id');
const targetState = this.checked; // 点击后的状态就是目标状态
const originalState = !this.checked; // 原始状态是目标状态的相反
// 立即设置为加载状态
this.disabled = true;
this.style.opacity = '0.6';
this.dataset.updating = 'true';
updateSiteVisibility(siteId, targetState, originalState, this);
});
});
// 重新应用正在更新按钮的视觉状态(因为重新渲染会创建新元素)
document.querySelectorAll('.site-visibility-toggle[data-updating="true"]').forEach(toggle => {
toggle.disabled = true;
toggle.style.opacity = '0.6';
});
// 同时渲染移动端卡片
renderMobileAdminSiteCards(sites);
}
// 初始化网站拖拽排序
function initializeSiteDragSort() {
const tableBody = document.getElementById('siteTableBody');
if (!tableBody) return;
let draggedElement = null;
let draggedOverElement = null;
// 为所有可拖拽行添加事件监听
const draggableRows = tableBody.querySelectorAll('.site-row-draggable');
draggableRows.forEach(row => {
row.addEventListener('dragstart', function(e) {
draggedElement = this;
this.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', this.outerHTML);
});
row.addEventListener('dragend', function(e) {
this.style.opacity = '';
draggedElement = null;
draggedOverElement = null;
// 移除所有拖拽样式
draggableRows.forEach(r => {
r.classList.remove('drag-over-top', 'drag-over-bottom');
});
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (this === draggedElement) return;
draggedOverElement = this;
// 移除其他行的拖拽样式
draggableRows.forEach(r => {
if (r !== this) {
r.classList.remove('drag-over-top', 'drag-over-bottom');
}
});
// 确定插入位置
const rect = this.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
if (e.clientY < midpoint) {
this.classList.add('drag-over-top');
this.classList.remove('drag-over-bottom');
} else {
this.classList.add('drag-over-bottom');
this.classList.remove('drag-over-top');
}
});
row.addEventListener('drop', function(e) {
e.preventDefault();
if (this === draggedElement) return;
const draggedSiteId = draggedElement.getAttribute('data-site-id');
const targetSiteId = this.getAttribute('data-site-id');
// 确定插入位置
const rect = this.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const insertBefore = e.clientY < midpoint;
// 执行拖拽排序
performSiteDragSort(draggedSiteId, targetSiteId, insertBefore);
});
});
}
// 执行网站拖拽排序
async function performSiteDragSort(draggedSiteId, targetSiteId, insertBefore) {
try {
// 获取当前网站列表的ID顺序
const currentOrder = siteList.map(site => site.id);
// 计算新的排序
const draggedIndex = currentOrder.indexOf(draggedSiteId);
const targetIndex = currentOrder.indexOf(targetSiteId);
if (draggedIndex === -1 || targetIndex === -1) {
throw new Error('无法找到网站');
}
// 创建新的排序数组
const newOrder = [...currentOrder];
newOrder.splice(draggedIndex, 1); // 移除拖拽的元素
// 计算插入位置
let insertIndex = targetIndex;
if (draggedIndex < targetIndex) {
insertIndex = targetIndex - 1;
}
if (!insertBefore) {
insertIndex += 1;
}
newOrder.splice(insertIndex, 0, draggedSiteId); // 插入到新位置
// 发送批量排序请求
await apiRequest('/api/admin/sites/batch-reorder', {
method: 'POST',
body: JSON.stringify({ siteIds: newOrder })
});
// 重新加载网站列表
await loadSiteList();
showToast('success', '网站排序已更新');
} catch (error) {
showToast('danger', '拖拽排序失败: ' + error.message);
// 重新加载以恢复原始状态
loadSiteList();
}
}
// 获取网站状态对应的Badge样式和文本
function getSiteStatusBadge(status) {
switch (status) {
case 'UP': return { class: 'bg-success', text: '正常' };
case 'DOWN': return { class: 'bg-danger', text: '故障' };
case 'TIMEOUT': return { class: 'bg-warning text-dark', text: '超时' };
case 'ERROR': return { class: 'bg-danger', text: '错误' };
case 'PENDING': return { class: 'bg-secondary', text: '待检测' };
default: return { class: 'bg-secondary', text: '未知' };
}
}
// 显示添加/编辑网站模态框 (handles both add and edit)
function showSiteModal(siteIdToEdit = null) {
const form = document.getElementById('siteForm');
form.reset();
const modalTitle = document.getElementById('siteModalTitle');
const siteIdInput = document.getElementById('siteId');
if (siteIdToEdit) {
const site = siteList.find(s => s.id === siteIdToEdit);
if (site) {
modalTitle.textContent = '编辑监控网站';
siteIdInput.value = site.id;
document.getElementById('siteName').value = site.name || '';
document.getElementById('siteUrl').value = site.url;
// document.getElementById('siteEnableFrequentNotifications').checked = site.enable_frequent_down_notifications || false; // Removed
} else {
showToast('danger', '未找到要编辑的网站信息');
return;
}
} else {
modalTitle.textContent = '添加监控网站';
siteIdInput.value = ''; // Clear ID for add mode
// document.getElementById('siteEnableFrequentNotifications').checked = false; // Removed
}
const siteModal = new bootstrap.Modal(document.getElementById('siteModal'));
siteModal.show();
}
// Function to call when edit button is clicked
function editSite(siteId) {
showSiteModal(siteId);
}
// 保存网站(添加或更新)
async function saveSite() {
const siteId = document.getElementById('siteId').value; // Get ID from hidden input
const siteName = document.getElementById('siteName').value.trim();
const siteUrl = document.getElementById('siteUrl').value.trim();
// const enableFrequentNotifications = document.getElementById('siteEnableFrequentNotifications').checked; // Removed
if (!siteUrl) {
showToast('warning', '请输入网站URL');
return;
}
if (!siteUrl.startsWith('http://') && !siteUrl.startsWith('https://')) {
showToast('warning', 'URL必须以 http:// 或 https:// 开头');
return;
}
const requestBody = {
url: siteUrl,
name: siteName
// enable_frequent_down_notifications: enableFrequentNotifications // Removed
};
let apiUrl = '/api/admin/sites';
let method = 'POST';
if (siteId) { // If siteId exists, it's an update
apiUrl = \`/api/admin/sites/\${siteId}\`;
method = 'PUT';
}
try {
const responseData = await apiRequest(apiUrl, {
method: method,
body: JSON.stringify(requestBody)
});
const siteModalInstance = bootstrap.Modal.getInstance(document.getElementById('siteModal'));
if (siteModalInstance) {
siteModalInstance.hide();
}
await loadSiteList(); // Reload the list
showToast('success', '监控网站' + (siteId ? '更新' : '添加') + '成功');
} catch (error) {
showToast('danger', '保存网站失败: ' + error.message);
}
}
// 显示删除网站确认模态框
function showDeleteSiteConfirmation(siteId, siteName, siteUrl) {
currentSiteId = siteId;
document.getElementById('deleteSiteName').textContent = siteName;
document.getElementById('deleteSiteUrl').textContent = siteUrl;
const deleteModal = new bootstrap.Modal(document.getElementById('deleteSiteModal'));
deleteModal.show();
}
// 删除网站监控
async function deleteSite(siteId) {
try {
await apiRequest(\`/api/admin/sites/\${siteId}?confirm=true\`, {
method: 'DELETE'
});
// Hide modal and reload list
const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deleteSiteModal'));
deleteModal.hide();
await loadSiteList(); // Reload list
showToast('success', '网站监控已删除');
currentSiteId = null; // Reset current ID
} catch (error) {
showToast('danger', '删除网站失败: ' + error.message);
}
}
// --- Utility Functions ---
// 统一Toast弹窗函数 (增强版)
function showToast(type, message, options = {}) {
const defaults = {
success: 3000,
info: 5000,
warning: 8000,
danger: 10000
};
const duration = options.duration || defaults[type] || 5000;
const persistent = options.persistent || false;
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'unified-toast ' + type;
const icons = {
success: 'bi-check-circle-fill',
danger: 'bi-x-circle-fill',
warning: 'bi-exclamation-triangle-fill',
info: 'bi-info-circle-fill'
};
toast.innerHTML =
'<i class="toast-icon bi ' + icons[type] + '"></i>' +
'<div class="toast-content">' + message + '</div>' +
'<button class="toast-close" onclick="hideToast(this.parentElement)">×</button>' +
(persistent ? '' : '<div class="toast-progress" style="animation-duration: ' + duration + 'ms"></div>');
container.appendChild(toast);
if (!persistent) {
setTimeout(() => hideToast(toast), duration);
}
return toast;
}
function hideToast(toast) {
if (!toast || toast.classList.contains('hiding')) return;
toast.classList.add('hiding');
setTimeout(function() {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}
// --- Telegram Settings Functions ---
// 加载Telegram通知设置
async function loadTelegramSettings() {
try {
const settings = await apiRequest('/api/admin/telegram-settings');
if (settings) {
document.getElementById('telegramBotToken').value = settings.bot_token || '';
document.getElementById('telegramChatId').value = settings.chat_id || '';
document.getElementById('enableTelegramNotifications').checked = !!settings.enable_notifications;
}
} catch (error) {
showToast('danger', '加载Telegram设置失败: ' + error.message);
}
}
// 保存Telegram通知设置
async function saveTelegramSettings() {
const botToken = document.getElementById('telegramBotToken').value.trim();
const chatId = document.getElementById('telegramChatId').value.trim();
let enableNotifications = document.getElementById('enableTelegramNotifications').checked;
// If Bot Token or Chat ID is empty, automatically disable notifications
if (!botToken || !chatId) {
enableNotifications = false;
document.getElementById('enableTelegramNotifications').checked = false; // Update the checkbox UI
if (document.getElementById('enableTelegramNotifications').checked && (botToken || chatId)) { // Only show warning if user intended to enable
showToast('warning', 'Bot Token 和 Chat ID 均不能为空才能启用通知。通知已自动禁用');
}
} else if (enableNotifications && (!botToken || !chatId)) { // This case should ideally not be hit due to above logic, but kept for safety
showToast('warning', '启用通知时,Bot Token 和 Chat ID 不能为空');
return;
}
try {
await apiRequest('/api/admin/telegram-settings', {
method: 'POST',
body: JSON.stringify({
bot_token: botToken,
chat_id: chatId,
enable_notifications: enableNotifications // Use the potentially modified value
})
});
showToast('success', 'Telegram设置已成功保存');
} catch (error) {
showToast('danger', '保存Telegram设置失败: ' + error.message);
}
}
// --- Background Settings Functions ---
// 加载背景设置
async function loadBackgroundSettings() {
try {
const settings = await apiRequest('/api/background-settings');
if (settings) {
document.getElementById('enableCustomBackground').checked = !!settings.enabled;
document.getElementById('backgroundImageUrl').value = settings.url || '';
document.getElementById('pageOpacity').value = settings.opacity || 80;
document.getElementById('opacityValue').textContent = settings.opacity || 80;
// 应用当前设置(不保存到数据库)
applyBackgroundSettings(settings.enabled, settings.url, settings.opacity, false);
}
} catch (error) {
showToast('danger', '加载背景设置失败: ' + error.message);
}
}
// 保存背景设置
async function saveBackgroundSettings() {
const enabled = document.getElementById('enableCustomBackground').checked;
const url = document.getElementById('backgroundImageUrl').value.trim();
const opacity = parseInt(document.getElementById('pageOpacity').value, 10);
// 验证输入
if (enabled && url) {
if (!url.startsWith('https://')) {
showToast('warning', '背景图片URL必须以https://开头');
return;
}
}
if (isNaN(opacity) || opacity < 0 || opacity > 100) {
showToast('warning', '透明度必须是0-100之间的数字');
return;
}
try {
await apiRequest('/api/admin/background-settings', {
method: 'POST',
body: JSON.stringify({
enabled: enabled,
url: url,
opacity: opacity
})
});
// 应用设置并保存到localStorage
applyBackgroundSettings(enabled, url, opacity, true);
showToast('success', '背景设置已成功保存');
} catch (error) {
showToast('danger', '保存背景设置失败: ' + error.message);
}
}
// 应用背景设置
function applyBackgroundSettings(enabled, url, opacity, saveToCache = false) {
const body = document.body;
if (enabled && url) {
// 设置背景图片
body.style.setProperty('--custom-background-url', \`url(\${url})\`);
body.style.setProperty('--page-opacity', opacity / 100);
body.classList.add('custom-background-enabled');
} else {
// 移除背景图片
body.style.removeProperty('--custom-background-url');
body.style.removeProperty('--page-opacity');
body.classList.remove('custom-background-enabled');
}
// 缓存设置到localStorage(可选)
if (saveToCache) {
const settings = { enabled, url, opacity, timestamp: Date.now() };
localStorage.setItem('background-settings-cache', JSON.stringify(settings));
}
}
// 实时预览透明度变化
function updateOpacityPreview() {
const opacity = parseInt(document.getElementById('pageOpacity').value, 10);
const enabled = document.getElementById('enableCustomBackground').checked;
const url = document.getElementById('backgroundImageUrl').value.trim();
// 更新显示的数值
document.getElementById('opacityValue').textContent = opacity;
// 实时预览(不保存)
if (enabled && url) {
document.body.style.setProperty('--page-opacity', opacity / 100);
}
}
// --- Global Settings Functions (VPS Report Interval) ---
async function loadGlobalSettings() {
try {
const settings = await apiRequest('/api/admin/settings/vps-report-interval');
if (settings && typeof settings.interval === 'number') {
document.getElementById('vpsReportInterval').value = settings.interval;
} else {
document.getElementById('vpsReportInterval').value = 60; // Default if not set
}
} catch (error) {
showToast('danger', '加载VPS报告间隔失败: ' + error.message);
document.getElementById('vpsReportInterval').value = 60; // Default on error
}
}
async function saveVpsReportInterval() {
const intervalInput = document.getElementById('vpsReportInterval');
const interval = parseInt(intervalInput.value, 10);
if (isNaN(interval) || interval < 1) { // Changed to interval < 1
showToast('warning', 'VPS报告间隔必须是一个大于或等于1的数字');
return;
}
// Removed warning for interval < 10
try {
await apiRequest('/api/admin/settings/vps-report-interval', {
method: 'POST',
body: JSON.stringify({ interval: interval })
});
showToast('success', 'VPS数据更新频率已成功保存。前端刷新间隔已立即更新');
// Immediately update the frontend refresh interval
// Check if we're on a page that has VPS data updates running
if (typeof initializeVpsDataUpdates === 'function') {
try {
await initializeVpsDataUpdates();
} catch (error) {
}
}
} catch (error) {
showToast('danger', '保存VPS报告间隔失败: ' + error.message);
}
}
// --- 自动排序功能 ---
// 服务器自动排序
async function autoSortServers(sortBy) {
try {
await apiRequest('/api/admin/servers/auto-sort', {
method: 'POST',
body: JSON.stringify({ sortBy: sortBy, order: 'asc' })
});
// 更新下拉菜单选中状态
updateServerSortDropdownSelection(sortBy);
// 重新加载服务器列表
await loadServerList();
showToast('success', '服务器已按' + getSortDisplayName(sortBy) + '排序');
} catch (error) {
showToast('danger', '服务器自动排序失败: ' + error.message);
}
}
// 网站自动排序
async function autoSortSites(sortBy) {
try {
await apiRequest('/api/admin/sites/auto-sort', {
method: 'POST',
body: JSON.stringify({ sortBy: sortBy, order: 'asc' })
});
// 更新下拉菜单选中状态
updateSiteSortDropdownSelection(sortBy);
// 重新加载网站列表
await loadSiteList();
showToast('success', '网站已按' + getSortDisplayName(sortBy) + '排序');
} catch (error) {
showToast('danger', '网站自动排序失败: ' + error.message);
}
}
// 获取排序字段的显示名称
function getSortDisplayName(sortBy) {
const displayNames = {
'custom': '自定义',
'name': '名称',
'status': '状态',
'created_at': '创建时间',
'added_at': '添加时间',
'url': 'URL'
};
return displayNames[sortBy] || sortBy;
}
// 更新服务器排序下拉菜单选中状态
function updateServerSortDropdownSelection(selectedSortBy) {
const dropdown = document.querySelector('#serverAutoSortDropdown + .dropdown-menu');
if (!dropdown) return;
// 移除所有active类
dropdown.querySelectorAll('.dropdown-item').forEach(item => {
item.classList.remove('active');
});
// 为选中的项添加active类
const selectedItem = dropdown.querySelector(\`[onclick="autoSortServers('\${selectedSortBy}')"]\`);
if (selectedItem) {
selectedItem.classList.add('active');
}
}
// 更新网站排序下拉菜单选中状态
function updateSiteSortDropdownSelection(selectedSortBy) {
const dropdown = document.querySelector('#siteAutoSortDropdown + .dropdown-menu');
if (!dropdown) return;
// 移除所有active类
dropdown.querySelectorAll('.dropdown-item').forEach(item => {
item.classList.remove('active');
});
// 为选中的项添加active类
const selectedItem = dropdown.querySelector(\`[onclick="autoSortSites('\${selectedSortBy}')"]\`);
if (selectedItem) {
selectedItem.classList.add('active');
}
}
// 管理页面移动端服务器卡片渲染函数
function renderMobileAdminServerCards(servers) {
const mobileContainer = document.getElementById('mobileAdminServerContainer');
if (!mobileContainer) return;
mobileContainer.innerHTML = '';
if (!servers || servers.length === 0) {
mobileContainer.innerHTML = '<div class="text-center p-3 text-muted">暂无服务器数据</div>';
return;
}
servers.forEach(server => {
const card = document.createElement('div');
card.className = 'mobile-server-card';
card.setAttribute('data-server-id', server.id);
// 状态显示逻辑(与PC端一致)
let statusBadge = '<span class="badge bg-secondary">未知</span>';
let lastUpdateText = '从未';
if (server.last_report) {
const lastUpdate = new Date(server.last_report * 1000);
lastUpdateText = lastUpdate.toLocaleString();
// 检查是否在线(最后报告时间在5分钟内)
const now = new Date();
const diffMinutes = (now - lastUpdate) / (1000 * 60);
if (diffMinutes <= 5) {
statusBadge = '<span class="badge bg-success">在线</span>';
} else {
statusBadge = '<span class="badge bg-danger">离线</span>';
}
}
// 卡片头部
const cardHeader = document.createElement('div');
cardHeader.className = 'mobile-card-header';
cardHeader.innerHTML = \`
<div class="mobile-card-header-left">
\${statusBadge}
</div>
<h6 class="mobile-card-title text-center">\${server.name || '未命名服务器'}</h6>
<div class="mobile-card-header-right">
<span class="me-2">显示</span>
<div class="form-check form-switch d-inline-block">
<input class="form-check-input server-visibility-toggle" type="checkbox"
data-server-id="\${server.id}" \${server.is_public ? 'checked' : ''}>
</div>
</div>
\`;
// 卡片主体
const cardBody = document.createElement('div');
cardBody.className = 'mobile-card-body';
// 描述 - 单行
if (server.description) {
const descRow = document.createElement('div');
descRow.className = 'mobile-card-row';
descRow.innerHTML = \`
<span class="mobile-card-label">描述</span>
<span class="mobile-card-value">\${server.description}</span>
\`;
cardBody.appendChild(descRow);
}
// 四个按钮 - 两行两列布局
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'mobile-card-buttons-grid';
buttonsContainer.innerHTML = \`
<div class="d-flex gap-2 mb-2">
<button class="btn btn-outline-secondary btn-sm flex-fill" onclick="showServerApiKey('\${server.id}')">
<i class="bi bi-key"></i> 查看密钥
</button>
<button class="btn btn-outline-info btn-sm flex-fill" onclick="copyVpsInstallScript('\${server.id}', '\${server.name}', this)">
<i class="bi bi-clipboard"></i> 复制脚本
</button>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary btn-sm flex-fill" onclick="editServer('\${server.id}')">
<i class="bi bi-pencil"></i> 编辑
</button>
<button class="btn btn-outline-danger btn-sm flex-fill" onclick="deleteServer('\${server.id}')">
<i class="bi bi-trash"></i> 删除
</button>
</div>
\`;
cardBody.appendChild(buttonsContainer);
// 最后更新时间 - 底部单行(与PC端功能一致)
const lastUpdateRow = document.createElement('div');
lastUpdateRow.className = 'mobile-card-row mobile-card-footer';
lastUpdateRow.innerHTML = \`
<span class="mobile-card-label">最后更新: \${lastUpdateText}</span>
\`;
cardBody.appendChild(lastUpdateRow);
// 组装卡片
card.appendChild(cardHeader);
card.appendChild(cardBody);
mobileContainer.appendChild(card);
});
// 为移动端显示开关添加事件监听器
document.querySelectorAll('.server-visibility-toggle').forEach(toggle => {
toggle.addEventListener('change', function() {
const serverId = this.dataset.serverId;
const isPublic = this.checked;
toggleServerVisibility(serverId, isPublic);
});
});
}
// 切换服务器显示状态
async function toggleServerVisibility(serverId, isPublic) {
try {
const toggle = document.querySelector(\`.server-visibility-toggle[data-server-id="\${serverId}"]\`);
if (toggle) {
toggle.disabled = true;
toggle.style.opacity = '0.6';
}
await apiRequest(\`/api/admin/servers/\${serverId}/visibility\`, {
method: 'POST',
body: JSON.stringify({ is_public: isPublic })
});
// 更新本地数据
const serverIndex = serverList.findIndex(s => s.id === serverId);
if (serverIndex !== -1) {
serverList[serverIndex].is_public = isPublic;
}
if (toggle) {
toggle.disabled = false;
toggle.style.opacity = '1';
}
showToast('success', '服务器显示状态已' + (isPublic ? '开启' : '关闭'));
} catch (error) {
// 恢复开关状态
const toggle = document.querySelector(\`.server-visibility-toggle[data-server-id="\${serverId}"]\`);
if (toggle) {
toggle.checked = !isPublic;
toggle.disabled = false;
toggle.style.opacity = '1';
}
showToast('danger', '切换显示状态失败: ' + error.message);
}
}
// 管理页面移动端网站卡片渲染函数
function renderMobileAdminSiteCards(sites) {
const mobileContainer = document.getElementById('mobileAdminSiteContainer');
if (!mobileContainer) return;
mobileContainer.innerHTML = '';
// 添加居中的排序和添加网站按钮
const mobileActionsContainer = document.createElement('div');
mobileActionsContainer.className = 'text-center mb-3';
mobileActionsContainer.innerHTML = \`
<div class="d-flex gap-2 justify-content-center">
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-sort-alpha-down"></i> 自动排序
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item active" href="#" onclick="autoSortSites('custom')">自定义排序</a></li>
<li><a class="dropdown-item" href="#" onclick="autoSortSites('name')">按名称排序</a></li>
<li><a class="dropdown-item" href="#" onclick="autoSortSites('url')">按URL排序</a></li>
<li><a class="dropdown-item" href="#" onclick="autoSortSites('status')">按状态排序</a></li>
</ul>
</div>
<button id="addSiteBtnMobile" class="btn btn-success" onclick="showSiteModal()">
<i class="bi bi-plus-circle"></i> 添加监控网站
</button>
</div>
\`;
mobileContainer.appendChild(mobileActionsContainer);
if (!sites || sites.length === 0) {
const noDataDiv = document.createElement('div');
noDataDiv.className = 'text-center p-3 text-muted';
noDataDiv.textContent = '暂无监控网站数据';
mobileContainer.appendChild(noDataDiv);
return;
}
sites.forEach(site => {
const card = document.createElement('div');
card.className = 'mobile-site-card';
const statusInfo = getSiteStatusBadge(site.last_status);
const lastCheckTime = site.last_checked ? new Date(site.last_checked * 1000).toLocaleString() : '从未';
const responseTime = site.last_response_time_ms !== null ? \`\${site.last_response_time_ms} ms\` : '-';
// 卡片头部 - 完全参考服务器卡片布局:状态在左上角,网站名在中间,显示开关在右上角
const cardHeader = document.createElement('div');
cardHeader.className = 'mobile-card-header';
cardHeader.innerHTML = \`
<div class="mobile-card-header-left">
<span class="badge \${statusInfo.class}">\${statusInfo.text}</span>
</div>
<h6 class="mobile-card-title text-center">\${site.name || '未命名网站'}</h6>
<div class="mobile-card-header-right">
<span class="me-2">显示</span>
<div class="form-check form-switch d-inline-block">
<input class="form-check-input site-visibility-toggle" type="checkbox"
data-site-id="\${site.id}" \${site.is_public ? 'checked' : ''}>
</div>
</div>
\`;
// 卡片主体
const cardBody = document.createElement('div');
cardBody.className = 'mobile-card-body';
// URL 和网站链接 - 单行
const urlRow = document.createElement('div');
urlRow.className = 'mobile-card-row';
urlRow.innerHTML = \`
<span class="mobile-card-label" style="word-break: break-all;">
URL: \${site.url}<a href="\${site.url}" target="_blank" rel="noopener noreferrer" class="text-decoration-none" style="margin-left: 4px;"><i class="bi bi-box-arrow-up-right"></i></a>
</span>
\`;
cardBody.appendChild(urlRow);
// 最后检查 - 单行
const lastCheckRow = document.createElement('div');
lastCheckRow.className = 'mobile-card-row';
lastCheckRow.innerHTML = \`
<span class="mobile-card-label">最后检查: \${lastCheckTime}</span>
\`;
cardBody.appendChild(lastCheckRow);
// 操作按钮 - 编辑和删除
const actionsRow = document.createElement('div');
actionsRow.className = 'mobile-card-row';
actionsRow.innerHTML = \`
<div class="d-flex gap-2 w-100">
<button class="btn btn-outline-primary btn-sm flex-fill" onclick="editSite('\${site.id}')">
<i class="bi bi-pencil"></i> 编辑
</button>
<button class="btn btn-outline-danger btn-sm flex-fill" onclick="deleteSite('\${site.id}')">
<i class="bi bi-trash"></i> 删除
</button>
</div>
\`;
cardBody.appendChild(actionsRow);
// 组装卡片
card.appendChild(cardHeader);
card.appendChild(cardBody);
mobileContainer.appendChild(card);
});
// 为移动端网站显示开关添加事件监听器
document.querySelectorAll('.site-visibility-toggle').forEach(toggle => {
toggle.addEventListener('change', function() {
const siteId = this.dataset.siteId;
const isPublic = this.checked;
toggleSiteVisibility(siteId, isPublic);
});
});
}
// 切换网站显示状态
async function toggleSiteVisibility(siteId, isPublic) {
try {
const toggle = document.querySelector(\`.site-visibility-toggle[data-site-id="\${siteId}"]\`);
if (toggle) {
toggle.disabled = true;
toggle.style.opacity = '0.6';
}
await apiRequest(\`/api/admin/sites/\${siteId}/visibility\`, {
method: 'POST',
body: JSON.stringify({ is_public: isPublic })
});
// 更新本地数据
const siteIndex = siteList.findIndex(s => s.id === siteId);
if (siteIndex !== -1) {
siteList[siteIndex].is_public = isPublic;
}
if (toggle) {
toggle.disabled = false;
toggle.style.opacity = '1';
}
showToast('success', '网站显示状态已' + (isPublic ? '开启' : '关闭'));
} catch (error) {
// 恢复开关状态
const toggle = document.querySelector(\`.site-visibility-toggle[data-site-id="\${siteId}"]\`);
if (toggle) {
toggle.checked = !isPublic;
toggle.disabled = false;
toggle.style.opacity = '1';
}
showToast('danger', '切换显示状态失败: ' + error.message);
}
}
// 移动端查看服务器API密钥
function showServerApiKey(serverId) {
viewApiKey(serverId);
}
// ==================== 全局背景设置同步功能 ====================
// 监听storage事件,实现跨页面设置同步
window.addEventListener('storage', function(e) {
if (e.key === 'background-settings-cache' && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue);
// 使用管理页面的背景设置应用函数
applyBackgroundSettings(newSettings.enabled, newSettings.url, newSettings.opacity, false);
} catch (error) {
}
}
});
// 页面加载时也检查并应用缓存的背景设置
document.addEventListener('DOMContentLoaded', function() {
// 延迟执行,确保loadBackgroundSettings()先执行
setTimeout(function() {
const cached = localStorage.getItem('background-settings-cache');
if (cached) {
try {
const cachedData = JSON.parse(cached);
const now = Date.now();
const cacheAge = now - cachedData.timestamp;
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
if (cacheAge < CACHE_DURATION) {
// 缓存有效,确保设置已应用
applyBackgroundSettings(cachedData.enabled, cachedData.url, cachedData.opacity, false);
}
} catch (error) {
}
}
}, 100);
});
`;
}
附上监控脚本
#!/bin/bash
# cf-vps-monitor - Cloudflare Worker VPS监控脚本
# 版本: 1.1.0
# 支持所有常见Linux系统,无需root权限
set -euo pipefail
# 初始化系统类型变量
OS=$(uname -s)
export OS
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 全局变量 - 集中式文件管理
SCRIPT_DIR="$HOME/.cf-vps-monitor"
CONFIG_FILE="$SCRIPT_DIR/config/config"
LOG_FILE="$SCRIPT_DIR/logs/monitor.log"
PID_FILE="$SCRIPT_DIR/run/monitor.pid"
SERVICE_FILE="$SCRIPT_DIR/bin/vps-monitor-service.sh"
INSTALL_MANIFEST="$SCRIPT_DIR/system/install.manifest"
# 默认配置
DEFAULT_INTERVAL=10
DEFAULT_WORKER_URL=""
DEFAULT_SERVER_ID=""
DEFAULT_API_KEY=""
# 打印带颜色的消息
print_message() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
# 日志函数(环境适配)
log() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] $message" >> "$LOG_FILE"
# 只在非服务模式下输出到控制台(避免重复日志)
if [[ "${SERVICE_MODE:-false}" != "true" ]]; then
echo "[$timestamp] $message"
fi
}
# 错误处理
error_exit() {
local message="$1"
print_message "$RED" "错误: $message"
log "ERROR: $message"
exit 1
}
# 检查命令是否存在
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# ==================== 系统兼容性层 ====================
# 检测systemd可用性
is_systemd_available() {
command_exists systemctl && systemctl --version >/dev/null 2>&1
}
# 检测用户级systemd可用性
is_user_systemd_available() {
if is_root_user; then
is_systemd_available
else
is_systemd_available && \
[[ -n "${XDG_RUNTIME_DIR:-}" ]] && \
systemctl --user --version >/dev/null 2>&1
fi
}
# 跨平台sed命令
safe_sed() {
local pattern="$1"
local file="$2"
if [[ "$OS" == "FreeBSD" ]] || [[ "$OS" == "Darwin" ]]; then
sed -i '' "$pattern" "$file" 2>/dev/null || true
else
sed -i "$pattern" "$file" 2>/dev/null || true
fi
}
# 安全的systemctl命令
safe_systemctl() {
if is_systemd_available; then
systemctl "$@" 2>/dev/null || true
else
return 1
fi
}
# 检查系统资源(防止fork错误)
check_system_resources() {
# 检查进程数限制(特别针对FreeBSD)
local max_proc=$(ulimit -u 2>/dev/null || echo "1024")
local current_proc=$(ps aux 2>/dev/null | wc -l || echo "100")
if [[ $current_proc -gt $((max_proc * 80 / 100)) ]]; then
print_message "$YELLOW" "警告: 进程数接近限制 ($current_proc/$max_proc)"
if [[ "$OS" == "FreeBSD" ]]; then
print_message "$CYAN" "FreeBSD建议: 增加用户进程限制或稍后重试"
fi
return 1
fi
return 0
}
# 验证PID有效性
validate_pid() {
local pid="$1"
[[ "$pid" =~ ^[0-9]+$ ]] && [[ "$pid" != "$$" ]] && kill -0 "$pid" 2>/dev/null
}
# 获取进程命令行(FreeBSD兼容)
get_process_command() {
local pid="$1"
if [[ "$OS" == "FreeBSD" ]]; then
# FreeBSD兼容语法
ps -p "$pid" -o command 2>/dev/null | tail -n +2 | head -1 || echo "unknown"
else
# Linux标准语法
ps -p "$pid" -o cmd= 2>/dev/null || echo "unknown"
fi
}
# 统一的监控进程检测函数(精确检测)
find_monitor_processes() {
local pids=""
# 层次1: PID文件检测(最可靠)
if [[ -f "$PID_FILE" ]]; then
local file_pid=$(cat "$PID_FILE" 2>/dev/null)
if validate_pid "$file_pid"; then
pids="$file_pid"
fi
fi
# 层次2: 精确脚本路径匹配
if [[ -z "$pids" ]] && [[ -f "${SERVICE_FILE:-}" ]]; then
if [[ "$OS" == "FreeBSD" ]]; then
pids=$(ps axww | grep "$SERVICE_FILE" | grep -v grep | awk '{print $1}')
else
pids=$(ps aux | grep "$SERVICE_FILE" | grep -v grep | awk '{print $2}')
fi
fi
# 层次3: 验证所有PID并确认命令行
local valid_pids=""
for pid in $pids; do
if validate_pid "$pid"; then
local cmd=$(get_process_command "$pid")
# 确认命令行确实包含我们的脚本
if [[ "$cmd" =~ (vps-monitor-service|cf-vps-monitor) ]]; then
valid_pids="$valid_pids $pid"
fi
fi
done
echo "$valid_pids" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//'
}
# 检查监控服务是否运行
is_monitor_running() {
local pids=$(find_monitor_processes)
[[ -n "$pids" ]]
}
# 获取用户类型描述
get_user_type_description() {
if is_root_user; then
echo "系统管理员"
else
echo "普通用户"
fi
}
# 简洁的监控服务诊断
diagnose_monitor_service() {
print_message "$CYAN" "=== 监控服务诊断 ==="
# 检查关键文件
print_message "$BLUE" "文件状态:"
[[ -f "$SERVICE_FILE" ]] && print_message "$GREEN" " ✓ 服务脚本存在" || print_message "$RED" " ✗ 服务脚本不存在"
[[ -f "$CONFIG_FILE" ]] && print_message "$GREEN" " ✓ 配置文件存在" || print_message "$RED" " ✗ 配置文件不存在"
[[ -d "$(dirname "$LOG_FILE")" ]] && print_message "$GREEN" " ✓ 日志目录存在" || print_message "$RED" " ✗ 日志目录不存在"
# 显示相关进程(FreeBSD优化)
print_message "$BLUE" "相关进程:"
if [[ "$OS" == "FreeBSD" ]]; then
local processes=$(ps axww | grep -E "(monitor|vps)" | grep -v grep | grep -v diagnose)
else
local processes=$(ps aux | grep -E "(monitor|vps)" | grep -v grep | grep -v diagnose)
fi
if [[ -n "$processes" ]]; then
echo "$processes"
else
print_message "$YELLOW" " 无相关进程"
fi
print_message "$CYAN" "===================="
}
# 移除所有自启动设置
remove_autostart_settings() {
print_message "$BLUE" "移除自启动设置..."
local removed_count=0
# 1. 移除systemd服务
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
fi
if [[ -f "$service_path" ]] && is_systemd_available; then
local systemd_cmd="systemctl"
[[ ! $(is_root_user) ]] && systemd_cmd="systemctl --user"
$systemd_cmd stop cf-vps-monitor.service 2>/dev/null || true
$systemd_cmd disable cf-vps-monitor.service 2>/dev/null || true
rm -f "$service_path"
$systemd_cmd daemon-reload 2>/dev/null || true
print_message "$GREEN" " ✓ systemd服务已移除"
removed_count=$((removed_count + 1))
fi
# 2. 移除crontab条目
if command_exists crontab; then
local current_crontab=$(crontab -l 2>/dev/null || echo "")
if echo "$current_crontab" | grep -q "cf-vps-monitor"; then
echo "$current_crontab" | grep -v "cf-vps-monitor" | crontab - 2>/dev/null
print_message "$GREEN" " ✓ crontab自启动已移除"
removed_count=$((removed_count + 1))
fi
fi
# 3. 移除shell profile自启动(FreeBSD兼容)
local profile_files=(".bashrc" ".bash_profile" ".profile")
for profile in "${profile_files[@]}"; do
local profile_path="$HOME/$profile"
if [[ -f "$profile_path" ]] && grep -q "cf-vps-monitor auto-start" "$profile_path" 2>/dev/null; then
# FreeBSD兼容的sed语法
if [[ "$OS" == "FreeBSD" ]] || [[ "$OS" == "Darwin" ]]; then
sed -i '' '/# === cf-vps-monitor auto-start BEGIN ===/,/# === cf-vps-monitor auto-start END ===/d' "$profile_path" 2>/dev/null
else
sed -i '/# === cf-vps-monitor auto-start BEGIN ===/,/# === cf-vps-monitor auto-start END ===/d' "$profile_path" 2>/dev/null
fi
print_message "$GREEN" " ✓ 已从 $profile 移除自启动代码"
removed_count=$((removed_count + 1))
break
fi
done
# 显示结果
if [[ $removed_count -gt 0 ]]; then
print_message "$GREEN" "✓ 已移除 $removed_count 种自启动设置"
else
print_message "$YELLOW" "未找到需要移除的自启动设置"
fi
}
# 添加自启动设置
add_autostart_settings() {
print_message "$BLUE" "配置自启动设置..."
local added_count=0
# 1. 尝试配置systemd服务
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
fi
if is_systemd_available && [[ ! -f "$service_path" ]]; then
# 创建服务目录
mkdir -p "$(dirname "$service_path")" 2>/dev/null
# 创建systemd服务文件
cat > "$service_path" << EOF
[Unit]
Description=CF VPS Monitor Service
After=network.target
[Service]
Type=simple
ExecStart=$SERVICE_FILE
Restart=always
RestartSec=10
User=$USER
WorkingDirectory=$HOME
[Install]
WantedBy=default.target
EOF
local systemd_cmd="systemctl"
[[ ! $(is_root_user) ]] && systemd_cmd="systemctl --user"
$systemd_cmd daemon-reload 2>/dev/null || true
$systemd_cmd enable cf-vps-monitor.service 2>/dev/null || true
print_message "$GREEN" " ✓ systemd服务已配置"
added_count=$((added_count + 1))
fi
# 2. 配置crontab自启动
if command_exists crontab; then
local current_crontab=$(crontab -l 2>/dev/null || echo "")
if ! echo "$current_crontab" | grep -q "cf-vps-monitor"; then
local crontab_entry="@reboot sleep 30 && pgrep -f 'cf-vps-monitor|vps-monitor-service' >/dev/null || $SERVICE_FILE"
(echo "$current_crontab"; echo "$crontab_entry") | crontab - 2>/dev/null
print_message "$GREEN" " ✓ crontab自启动已配置"
added_count=$((added_count + 1))
fi
fi
# 3. 配置shell profile自启动
local profile="$HOME/.bashrc"
if [[ -f "$profile" ]] && ! grep -q "cf-vps-monitor auto-start" "$profile" 2>/dev/null; then
cat >> "$profile" << EOF
# === cf-vps-monitor auto-start BEGIN ===
# VPS监控服务自启动检测 (最后保障)
if [ -n "\$PS1" ] && [ "\$TERM" != "dumb" ]; then
if ! pgrep -f 'cf-vps-monitor|vps-monitor-service' >/dev/null 2>&1; then
(sleep 5 && nohup "$SERVICE_FILE" >/dev/null 2>&1 &) &
fi
fi
# === cf-vps-monitor auto-start END ===
EOF
print_message "$GREEN" " ✓ shell profile自启动已配置"
added_count=$((added_count + 1))
fi
# 显示结果
if [[ $added_count -gt 0 ]]; then
print_message "$GREEN" "✓ 已配置 $added_count 种自启动设置"
else
print_message "$YELLOW" "自启动设置已存在,无需重复配置"
fi
}
# 统一的命令接口
get_system_command() {
local cmd_type="$1"
local fallback="${2:-}"
case "$cmd_type" in
"memory_info")
if [[ "$OS" == "FreeBSD" ]] || [[ "$OS" == "OpenBSD" ]] || [[ "$OS" == "NetBSD" ]]; then
echo "sysctl"
elif [[ -f /proc/meminfo ]]; then
echo "proc"
elif command_exists free; then
echo "free"
else
echo "$fallback"
fi
;;
"disk_usage")
if command_exists df; then
echo "df"
elif command_exists du; then
echo "du"
else
echo "$fallback"
fi
;;
"network_stats")
if [[ -f /proc/net/dev ]]; then
echo "proc"
elif command_exists netstat; then
echo "netstat"
elif command_exists ss; then
echo "ss"
else
echo "$fallback"
fi
;;
"process_info")
if command_exists ps; then
echo "ps"
elif [[ -d /proc ]]; then
echo "proc"
else
echo "$fallback"
fi
;;
"cpu_info")
if [[ "$OS" == "FreeBSD" ]] || [[ "$OS" == "OpenBSD" ]] || [[ "$OS" == "NetBSD" ]]; then
echo "sysctl"
elif [[ -f /proc/stat ]]; then
echo "proc"
elif command_exists top; then
echo "top"
elif command_exists vmstat; then
echo "vmstat"
else
echo "$fallback"
fi
;;
*)
echo "$fallback"
;;
esac
}
# 跨平台的命令执行
execute_system_command() {
local cmd_type="$1"
local command_method="$2"
shift 2
local args=("$@")
case "$cmd_type:$command_method" in
"memory_info:sysctl")
sysctl vm.stats.vm 2>/dev/null || sysctl hw.physmem hw.usermem 2>/dev/null
;;
"memory_info:proc")
cat /proc/meminfo 2>/dev/null
;;
"memory_info:free")
free -b 2>/dev/null || free 2>/dev/null
;;
"disk_usage:df")
df -B1 "${args[@]}" 2>/dev/null || df "${args[@]}" 2>/dev/null
;;
"network_stats:proc")
cat /proc/net/dev 2>/dev/null
;;
"network_stats:netstat")
netstat -i 2>/dev/null
;;
"cpu_info:sysctl")
sysctl kern.cp_time 2>/dev/null
;;
"cpu_info:proc")
cat /proc/stat 2>/dev/null
;;
"cpu_info:top")
timeout 3 top -bn1 2>/dev/null | head -10
;;
"cpu_info:vmstat")
timeout 3 vmstat 1 2 2>/dev/null | tail -1
;;
*)
return 1
;;
esac
}
# 检测系统信息(优化版 - 减少fork操作)
detect_system() {
# 一次性获取系统基本信息(减少fork)
local system_info=$(uname -srm)
IFS=' ' read -r OS KERNEL_VERSION ARCH <<< "$system_info"
# FreeBSD特殊优化(避免不必要的检测)
if [[ "$OS" == "FreeBSD" ]]; then
IS_CONTAINER="false"
CONTAINER_TYPE="none"
VIRTUALIZATION="none"
VER=$(echo "$KERNEL_VERSION" | cut -d'-' -f1)
DISTRO_ID="freebsd"
DISTRO_NAME="FreeBSD"
print_message "$GREEN" "检测到系统: FreeBSD $VER"
elif [[ "$OS" == "Darwin" ]]; then
IS_CONTAINER="false"
CONTAINER_TYPE="none"
VIRTUALIZATION="none"
VER=$(sw_vers -productVersion 2>/dev/null || echo "$KERNEL_VERSION")
DISTRO_ID="macos"
DISTRO_NAME="macOS"
print_message "$GREEN" "检测到系统: macOS $VER"
else
# Linux系统的简化检测
IS_CONTAINER="false"
CONTAINER_TYPE="none"
VIRTUALIZATION="none"
# 简化的容器检测(只检查明显标志)
if [[ -f /.dockerenv ]]; then
IS_CONTAINER="true"
CONTAINER_TYPE="docker"
fi
# 简化的发行版检测
if [[ -f /etc/os-release ]]; then
local os_info=$(cat /etc/os-release 2>/dev/null)
DISTRO_ID=$(echo "$os_info" | grep '^ID=' | cut -d= -f2 | tr -d '"' || echo "linux")
VER=$(echo "$os_info" | grep '^VERSION_ID=' | cut -d= -f2 | tr -d '"' || echo "unknown")
DISTRO_NAME=$(echo "$os_info" | grep '^NAME=' | cut -d= -f2 | tr -d '"' || echo "Linux")
else
DISTRO_ID="linux"
VER="unknown"
DISTRO_NAME="Linux"
fi
print_message "$GREEN" "检测到系统: $DISTRO_NAME $VER"
fi
# 确保变量在全局可用
export OS ARCH KERNEL_VERSION VER DISTRO_ID DISTRO_NAME
export IS_CONTAINER CONTAINER_TYPE VIRTUALIZATION
}
# 检测包管理器(增强版)
detect_package_manager() {
PKG_MANAGER=""
PKG_INSTALL=""
PKG_UPDATE=""
PKG_SEARCH=""
PKG_INFO=""
# 根据系统类型和发行版检测包管理器
case "$OS" in
FreeBSD|OpenBSD|NetBSD)
if command_exists pkg; then
PKG_MANAGER="pkg"
PKG_INSTALL="pkg install -y"
PKG_UPDATE="pkg update"
PKG_SEARCH="pkg search"
PKG_INFO="pkg info"
elif command_exists pkg_add && [[ "$OS" == "OpenBSD" ]]; then
PKG_MANAGER="pkg_add"
PKG_INSTALL="pkg_add"
PKG_UPDATE="pkg_add -u"
PKG_SEARCH="pkg_info -Q"
PKG_INFO="pkg_info"
fi
;;
Darwin)
if command_exists brew; then
PKG_MANAGER="brew"
PKG_INSTALL="brew install"
PKG_UPDATE="brew update"
PKG_SEARCH="brew search"
PKG_INFO="brew info"
elif command_exists port; then
PKG_MANAGER="port"
PKG_INSTALL="port install"
PKG_UPDATE="port selfupdate"
PKG_SEARCH="port search"
PKG_INFO="port info"
fi
;;
Linux|*)
# 按优先级和发行版特性检测包管理器
if command_exists apt-get; then
PKG_MANAGER="apt-get"
PKG_INSTALL="apt-get install -y"
PKG_UPDATE="apt-get update"
PKG_SEARCH="apt-cache search"
PKG_INFO="apt-cache show"
elif command_exists apt; then
PKG_MANAGER="apt"
PKG_INSTALL="apt install -y"
PKG_UPDATE="apt update"
PKG_SEARCH="apt search"
PKG_INFO="apt show"
elif command_exists dnf; then
PKG_MANAGER="dnf"
PKG_INSTALL="dnf install -y"
PKG_UPDATE="dnf update -y"
PKG_SEARCH="dnf search"
PKG_INFO="dnf info"
elif command_exists yum; then
PKG_MANAGER="yum"
PKG_INSTALL="yum install -y"
PKG_UPDATE="yum update -y"
PKG_SEARCH="yum search"
PKG_INFO="yum info"
elif command_exists zypper; then
PKG_MANAGER="zypper"
PKG_INSTALL="zypper install -y"
PKG_UPDATE="zypper refresh"
PKG_SEARCH="zypper search"
PKG_INFO="zypper info"
elif command_exists pacman; then
PKG_MANAGER="pacman"
PKG_INSTALL="pacman -S --noconfirm"
PKG_UPDATE="pacman -Sy"
PKG_SEARCH="pacman -Ss"
PKG_INFO="pacman -Si"
elif command_exists apk; then
PKG_MANAGER="apk"
PKG_INSTALL="apk add"
PKG_UPDATE="apk update"
PKG_SEARCH="apk search"
PKG_INFO="apk info"
elif command_exists emerge; then
PKG_MANAGER="emerge"
PKG_INSTALL="emerge"
PKG_UPDATE="emerge --sync"
PKG_SEARCH="emerge --search"
PKG_INFO="emerge --info"
elif command_exists xbps-install; then
PKG_MANAGER="xbps"
PKG_INSTALL="xbps-install -y"
PKG_UPDATE="xbps-install -S"
PKG_SEARCH="xbps-query -Rs"
PKG_INFO="xbps-query -R"
elif command_exists swupd; then
PKG_MANAGER="swupd"
PKG_INSTALL="swupd bundle-add"
PKG_UPDATE="swupd update"
PKG_SEARCH="swupd search"
PKG_INFO="swupd bundle-info"
elif command_exists nix-env; then
PKG_MANAGER="nix"
PKG_INSTALL="nix-env -i"
PKG_UPDATE="nix-channel --update"
PKG_SEARCH="nix-env -qa"
PKG_INFO="nix-env -qa --description"
elif command_exists snap; then
PKG_MANAGER="snap"
PKG_INSTALL="snap install"
PKG_UPDATE="snap refresh"
PKG_SEARCH="snap find"
PKG_INFO="snap info"
elif command_exists flatpak; then
PKG_MANAGER="flatpak"
PKG_INSTALL="flatpak install -y"
PKG_UPDATE="flatpak update -y"
PKG_SEARCH="flatpak search"
PKG_INFO="flatpak info"
fi
;;
esac
if [[ -n "$PKG_MANAGER" ]]; then
print_message "$GREEN" "检测到包管理器: $PKG_MANAGER"
else
print_message "$YELLOW" "警告: 未检测到支持的包管理器,将尝试手动安装依赖"
fi
# 导出变量供其他函数使用
export PKG_MANAGER PKG_INSTALL PKG_UPDATE PKG_SEARCH PKG_INFO
}
# 检查并安装依赖(无需root权限的方法)
install_dependencies() {
print_message "$BLUE" "检查系统依赖..."
local missing_deps=()
# 检查必需的命令
if ! command_exists curl; then
missing_deps+=("curl")
fi
if ! command_exists bc; then
missing_deps+=("bc")
fi
# 检查可选的命令
local optional_missing=()
if ! command_exists ifstat; then
optional_missing+=("ifstat")
fi
if ! command_exists jq; then
optional_missing+=("jq")
fi
# 报告可选依赖状态
if [[ ${#optional_missing[@]} -gt 0 ]]; then
print_message "$YELLOW" "可选依赖未安装: ${optional_missing[*]}"
print_message "$YELLOW" "这些依赖缺失不会影响基本功能,但可能限制某些特性"
fi
if [[ ${#missing_deps[@]} -eq 0 ]]; then
print_message "$GREEN" "所有必需依赖已安装"
return 0
fi
print_message "$YELLOW" "缺少必需依赖: ${missing_deps[*]}"
# 根据不同发行版调整包名
local adjusted_deps=()
for dep in "${missing_deps[@]}"; do
case "$dep" in
"bc")
if [[ "$DISTRO_ID" == "alpine" ]]; then
adjusted_deps+=("bc")
else
adjusted_deps+=("bc")
fi
;;
"curl")
adjusted_deps+=("curl")
;;
*)
adjusted_deps+=("$dep")
;;
esac
done
# 尝试安装依赖
if [[ -n "$PKG_MANAGER" ]]; then
if command_exists sudo && sudo -n true 2>/dev/null; then
print_message "$BLUE" "尝试使用sudo安装依赖..."
# 先更新包列表(对于某些包管理器)
if [[ "$PKG_MANAGER" == "apt-get" ]] || [[ "$PKG_MANAGER" == "apt" ]]; then
sudo $PKG_UPDATE
fi
for dep in "${adjusted_deps[@]}"; do
print_message "$BLUE" "安装 $dep..."
if ! sudo $PKG_INSTALL "$dep"; then
print_message "$YELLOW" "警告: 无法安装 $dep"
fi
done
else
print_message "$YELLOW" "需要sudo权限安装依赖,请手动执行:"
print_message "$CYAN" " sudo $PKG_INSTALL ${adjusted_deps[*]}"
fi
else
print_message "$YELLOW" "未检测到包管理器,请手动安装依赖"
print_message "$CYAN" "常见安装命令:"
print_message "$CYAN" " Ubuntu/Debian: sudo apt-get install ${adjusted_deps[*]}"
print_message "$CYAN" " CentOS/RHEL: sudo yum install ${adjusted_deps[*]}"
print_message "$CYAN" " Fedora: sudo dnf install ${adjusted_deps[*]}"
print_message "$CYAN" " Alpine: sudo apk add ${adjusted_deps[*]}"
fi
# 简化的依赖检查
if ! command_exists curl && ! command_exists wget; then
print_message "$RED" "错误: curl和wget都不可用"
print_message "$CYAN" "请安装curl或wget后重试"
return 1
fi
if ! command_exists bc; then
print_message "$YELLOW" "警告: bc未安装,某些计算功能可能受限"
fi
print_message "$GREEN" "依赖检查完成"
}
# 创建集中式目录结构
create_directories() {
print_message "$BLUE" "创建集中式目录结构..."
# 创建主目录和子目录
mkdir -p "$SCRIPT_DIR"/{bin,config,logs,tmp,cache,run,system/{templates,backups}} || error_exit "无法创建目录结构"
# 创建安装清单文件
touch "$INSTALL_MANIFEST"
# 设置临时目录环境变量
export TMPDIR="$SCRIPT_DIR/tmp"
print_message "$GREEN" "✓ 集中式目录结构创建完成"
print_message "$CYAN" " 主目录: $SCRIPT_DIR"
}
# 加载配置
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
source "$CONFIG_FILE"
else
WORKER_URL="$DEFAULT_WORKER_URL"
SERVER_ID="$DEFAULT_SERVER_ID"
API_KEY="$DEFAULT_API_KEY"
INTERVAL="$DEFAULT_INTERVAL"
fi
}
# 记录安装项到安装清单
record_installation() {
local type="$1" # 文件类型
local path="$2" # 文件路径
local action="$3" # 执行的操作
local backup="$4" # 备份信息
echo "$type:$path:$action:$backup" >> "$INSTALL_MANIFEST"
}
# 保存配置
save_config() {
cat > "$CONFIG_FILE" << EOF
# VPS监控配置文件
WORKER_URL="$WORKER_URL"
SERVER_ID="$SERVER_ID"
API_KEY="$API_KEY"
INTERVAL="$INTERVAL"
EOF
print_message "$GREEN" "配置已保存到 $CONFIG_FILE"
}
# 获取CPU使用率
get_cpu_usage() {
local cpu_usage
local cpu_load
# FreeBSD系统
if [[ "$OS" == "FreeBSD" ]]; then
# 使用sysctl获取CPU使用率
if command_exists sysctl; then
local cpu_idle=$(sysctl -n kern.cp_time 2>/dev/null | awk '{print $5}' 2>/dev/null || echo "0")
local cpu_total=$(sysctl -n kern.cp_time 2>/dev/null | awk '{sum=0; for(i=1;i<=NF;i++) sum+=$i; print sum}' 2>/dev/null || echo "0")
# 确保获取到有效数值
cpu_idle=$(sanitize_integer "$cpu_idle" "0")
cpu_total=$(sanitize_integer "$cpu_total" "0")
if [[ $cpu_total -gt 0 && $cpu_idle -le $cpu_total ]]; then
cpu_usage=$(echo "scale=1; 100 - ($cpu_idle * 100 / $cpu_total)" | bc 2>/dev/null || echo "0")
# 确保cpu_usage是有效的数字
cpu_usage=$(sanitize_number "$cpu_usage" "0")
else
cpu_usage="0"
fi
else
cpu_usage="0"
fi
# FreeBSD负载平均值
local load1="0" load5="0" load15="0"
if command_exists sysctl; then
load1=$(sysctl -n vm.loadavg 2>/dev/null | awk '{print $2}' 2>/dev/null || echo "0")
load5=$(sysctl -n vm.loadavg 2>/dev/null | awk '{print $3}' 2>/dev/null || echo "0")
load15=$(sysctl -n vm.loadavg 2>/dev/null | awk '{print $4}' 2>/dev/null || echo "0")
# 清理负载数值
load1=$(sanitize_number "$load1" "0")
load5=$(sanitize_number "$load5" "0")
load15=$(sanitize_number "$load15" "0")
fi
cpu_load="$load1,$load5,$load15"
else
# Linux系统 - 多种方法提高兼容性
cpu_usage="0"
# 方法1: 使用/proc/stat(最准确的方法)
if [[ -f /proc/stat ]]; then
local cpu_line=$(head -n1 /proc/stat 2>/dev/null)
if [[ -n "$cpu_line" ]]; then
local cpu_times=($cpu_line)
if [[ ${#cpu_times[@]} -ge 8 ]]; then
local idle=${cpu_times[4]}
local iowait=${cpu_times[5]:-0}
local total=0
# 计算总CPU时间(user + nice + system + idle + iowait + irq + softirq + steal)
for i in {1..7}; do
if [[ -n "${cpu_times[i]}" && "${cpu_times[i]}" =~ ^[0-9]+$ ]]; then
total=$((total + cpu_times[i]))
fi
done
if [[ $total -gt 0 ]]; then
cpu_usage=$(echo "scale=1; 100 - (($idle + $iowait) * 100 / $total)" | bc 2>/dev/null || echo "0")
fi
fi
fi
fi
# 方法2: 使用top命令(如果/proc/stat不可用)
if [[ "$cpu_usage" == "0" ]] && command_exists top; then
# 尝试不同的top输出格式
local top_output=$(timeout 3 top -bn1 2>/dev/null | head -10)
if [[ -n "$top_output" ]]; then
# 匹配不同格式的CPU行
if [[ "$top_output" =~ %Cpu\(s\):[[:space:]]*([0-9.]+)[[:space:]]*us.*[[:space:]]+([0-9.]+)[[:space:]]*id ]]; then
# 格式: %Cpu(s): 12.5 us, 2.1 sy, 0.0 ni, 85.4 id
local idle_percent="${BASH_REMATCH[2]}"
cpu_usage=$(echo "scale=1; 100 - $idle_percent" | bc 2>/dev/null || echo "0")
elif [[ "$top_output" =~ CPU:[[:space:]]*([0-9.]+)%[[:space:]]*us.*[[:space:]]+([0-9.]+)%[[:space:]]*id ]]; then
# 格式: CPU: 12.5% us, 2.1% sy, 85.4% id
local idle_percent="${BASH_REMATCH[2]}"
cpu_usage=$(echo "scale=1; 100 - $idle_percent" | bc 2>/dev/null || echo "0")
fi
fi
fi
# 方法3: 使用vmstat命令(备用方法)
if [[ "$cpu_usage" == "0" ]] && command_exists vmstat; then
local vmstat_output=$(timeout 3 vmstat 1 2 2>/dev/null | tail -1)
if [[ -n "$vmstat_output" ]]; then
local idle_percent=$(echo "$vmstat_output" | awk '{print $(NF-2)}' 2>/dev/null || echo "100")
if [[ "$idle_percent" =~ ^[0-9]+$ ]]; then
cpu_usage=$((100 - idle_percent))
fi
fi
fi
# 确保cpu_usage是有效的数字
cpu_usage=$(sanitize_number "$cpu_usage" "0")
# 获取负载平均值 - 多种方法
local load1="0" load5="0" load15="0"
if [[ -f /proc/loadavg ]]; then
local load_data=$(cat /proc/loadavg 2>/dev/null | awk '{print $1" "$2" "$3}' || echo "0 0 0")
read -r load1 load5 load15 <<< "$load_data"
elif command_exists uptime; then
# 尝试从uptime命令获取负载
local uptime_output=$(uptime 2>/dev/null)
if [[ "$uptime_output" =~ load[[:space:]]+average:[[:space:]]*([0-9.]+),[[:space:]]*([0-9.]+),[[:space:]]*([0-9.]+) ]]; then
load1="${BASH_REMATCH[1]}"
load5="${BASH_REMATCH[2]}"
load15="${BASH_REMATCH[3]}"
elif [[ "$uptime_output" =~ ([0-9.]+)[[:space:]]+([0-9.]+)[[:space:]]+([0-9.]+)$ ]]; then
load1="${BASH_REMATCH[1]}"
load5="${BASH_REMATCH[2]}"
load15="${BASH_REMATCH[3]}"
fi
fi
# 清理和验证每个负载值
load1=$(sanitize_number "$load1" "0")
load5=$(sanitize_number "$load5" "0")
load15=$(sanitize_number "$load15" "0")
cpu_load="$load1,$load5,$load15"
fi
echo "{\"usage_percent\":$cpu_usage,\"load_avg\":[$cpu_load]}"
}
# 获取内存使用情况
get_memory_usage() {
local total used free usage_percent
# FreeBSD系统
if [[ "$OS" == "FreeBSD" ]]; then
if command_exists sysctl; then
# FreeBSD内存信息
local page_size=$(sysctl -n hw.pagesize 2>/dev/null || echo "4096")
local total_pages=$(sysctl -n vm.stats.vm.v_page_count 2>/dev/null || echo "0")
local free_pages=$(sysctl -n vm.stats.vm.v_free_count 2>/dev/null || echo "0")
local inactive_pages=$(sysctl -n vm.stats.vm.v_inactive_count 2>/dev/null || echo "0")
local cache_pages=$(sysctl -n vm.stats.vm.v_cache_count 2>/dev/null || echo "0")
# 清理和验证数值
page_size=$(sanitize_integer "$page_size" "4096")
total_pages=$(sanitize_integer "$total_pages" "0")
free_pages=$(sanitize_integer "$free_pages" "0")
inactive_pages=$(sanitize_integer "$inactive_pages" "0")
cache_pages=$(sanitize_integer "$cache_pages" "0")
# 计算内存(转换为KB)
if [[ $page_size -gt 0 && $total_pages -gt 0 ]]; then
total=$(( (total_pages * page_size) / 1024 ))
free=$(( ((free_pages + inactive_pages + cache_pages) * page_size) / 1024 ))
used=$((total - free))
# 确保数值合理
if [[ $used -lt 0 ]]; then
used=0
fi
if [[ $free -lt 0 ]]; then
free=0
fi
else
total=0
used=0
free=0
fi
else
total=0
used=0
free=0
fi
else
# Linux系统 - 修复内存计算逻辑,确保 used + free = total
total=0
used=0
free=0
# 方法1: 使用free命令(最常用且最准确)
if command_exists free; then
local mem_info=$(free -k 2>/dev/null | grep "^Mem:")
if [[ -n "$mem_info" ]]; then
total=$(echo "$mem_info" | awk '{print $2}')
# 尝试获取available列(第7列,现代Linux系统)
local available=$(echo "$mem_info" | awk '{print $7}' 2>/dev/null || echo "")
if [[ "$available" =~ ^[0-9]+$ ]]; then
# 如果有available列,使用它作为真正的可用内存
free=$available
used=$((total - free))
else
# 如果没有available列,使用传统方法计算
local mem_free=$(echo "$mem_info" | awk '{print $4}' 2>/dev/null || echo "0")
local buff_cache=$(echo "$mem_info" | awk '{print $6}' 2>/dev/null || echo "0")
# 验证数据有效性
if [[ "$mem_free" =~ ^[0-9]+$ ]] && [[ "$buff_cache" =~ ^[0-9]+$ ]]; then
free=$((mem_free + buff_cache))
used=$((total - free))
else
# 如果解析失败,使用第3列作为used,但需要重新计算free
local raw_used=$(echo "$mem_info" | awk '{print $3}' 2>/dev/null || echo "0")
if [[ "$raw_used" =~ ^[0-9]+$ ]]; then
used=$raw_used
free=$((total - used))
fi
fi
fi
fi
fi
# 方法2: 直接读取/proc/meminfo(备用方法)
if [[ "$total" == "0" ]] && [[ -f /proc/meminfo ]]; then
total=$(grep "^MemTotal:" /proc/meminfo | awk '{print $2}' 2>/dev/null || echo "0")
local mem_free=$(grep "^MemFree:" /proc/meminfo | awk '{print $2}' 2>/dev/null || echo "0")
local buffers=$(grep "^Buffers:" /proc/meminfo | awk '{print $2}' 2>/dev/null || echo "0")
local cached=$(grep "^Cached:" /proc/meminfo | awk '{print $2}' 2>/dev/null || echo "0")
local sreclaimable=$(grep "^SReclaimable:" /proc/meminfo | awk '{print $2}' 2>/dev/null || echo "0")
# 计算实际可用内存(包括可回收的内存)
free=$((mem_free + buffers + cached + sreclaimable))
used=$((total - free))
fi
# 方法3: 容器环境特殊处理
if [[ "${CONTAINER_ENV:-false}" == "true" && -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]]; then
local cgroup_limit=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo "0")
local cgroup_usage=$(cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null || echo "0")
# 如果cgroup限制合理(不是一个巨大的数字),使用cgroup数据
if [[ "$cgroup_limit" =~ ^[0-9]+$ && "$cgroup_limit" -lt 274877906944 ]]; then # 256GB
total=$((cgroup_limit / 1024)) # 转换为KB
used=$((cgroup_usage / 1024)) # 转换为KB
free=$((total - used))
fi
fi
# 确保所有值都是有效数字
total=$(sanitize_integer "$total" "0")
used=$(sanitize_integer "$used" "0")
free=$(sanitize_integer "$free" "0")
# 数据一致性验证和修正 - 优化版本
if [[ $total -gt 0 ]]; then
# 确保所有值都是有效数字
total=$(sanitize_integer "$total" "0")
used=$(sanitize_integer "$used" "0")
free=$(sanitize_integer "$free" "0")
# 确保 used + free = total 的一致性
local sum=$((used + free))
local diff=$((sum - total))
# 如果差异超过1%,说明数据有问题,需要修正
local tolerance=$((total / 100))
if [[ $tolerance -lt 1024 ]]; then
tolerance=1024 # 最小容差1MB
fi
if [[ ${diff#-} -gt $tolerance ]]; then
# 数据不一致,优先保证total的准确性
if [[ $free -gt $total ]]; then
# free过大,重置为total
free=$total
used=0
elif [[ $used -gt $total ]]; then
# used过大,重置
used=$total
free=0
else
# 重新计算used,保证一致性
used=$((total - free))
fi
# 最终安全检查
if [[ $used -lt 0 ]]; then
used=0
free=$total
fi
if [[ $free -lt 0 ]]; then
free=0
used=$total
fi
fi
else
# 如果没有获取到数据,设置默认值
total=0
used=0
free=0
fi
fi
# 计算使用百分比
if [[ $total -gt 0 ]]; then
usage_percent=$(echo "scale=1; $used * 100 / $total" | bc 2>/dev/null || echo "0")
# 确保usage_percent是有效的数字
if ! [[ "$usage_percent" =~ ^[0-9]+\.?[0-9]*$ ]]; then
usage_percent="0"
fi
else
usage_percent="0"
fi
echo "{\"total\":$total,\"used\":$used,\"free\":$free,\"usage_percent\":$usage_percent}"
}
# 获取磁盘使用情况
get_disk_usage() {
local total used free usage_percent
# 多种方法获取磁盘信息,提高兼容性
if command_exists df; then
# 使用-k参数确保输出单位一致(KB)
local disk_info=$(df -k / 2>/dev/null | tail -1)
if [[ -n "$disk_info" ]]; then
# 从KB转换为GB,使用awk进行更安全的计算
total=$(echo "$disk_info" | awk '{printf "%.2f", $2 / 1024 / 1024}' 2>/dev/null || echo "0")
used=$(echo "$disk_info" | awk '{printf "%.2f", $3 / 1024 / 1024}' 2>/dev/null || echo "0")
free=$(echo "$disk_info" | awk '{printf "%.2f", $4 / 1024 / 1024}' 2>/dev/null || echo "0")
usage_percent=$(echo "$disk_info" | awk '{print $5}' | tr -d '%' 2>/dev/null || echo "0")
# 验证数据有效性
total=$(sanitize_number "$total" "0")
used=$(sanitize_number "$used" "0")
free=$(sanitize_number "$free" "0")
usage_percent=$(sanitize_integer "$usage_percent" "0")
else
total="0"
used="0"
free="0"
usage_percent="0"
fi
else
# 如果df不可用,尝试其他方法
total="0"
used="0"
free="0"
usage_percent="0"
fi
# 容器环境特殊处理
if [[ "${CONTAINER_ENV:-false}" == "true" && "$total" == "0" ]]; then
# 在容器中,尝试获取当前目录的磁盘使用情况
if command_exists df; then
local container_disk=$(df -k . 2>/dev/null | tail -1)
if [[ -n "$container_disk" ]]; then
total=$(echo "$container_disk" | awk '{printf "%.2f", $2 / 1024 / 1024}' 2>/dev/null || echo "0")
used=$(echo "$container_disk" | awk '{printf "%.2f", $3 / 1024 / 1024}' 2>/dev/null || echo "0")
free=$(echo "$container_disk" | awk '{printf "%.2f", $4 / 1024 / 1024}' 2>/dev/null || echo "0")
usage_percent=$(echo "$container_disk" | awk '{print $5}' | tr -d '%' 2>/dev/null || echo "0")
total=$(sanitize_number "$total" "0")
used=$(sanitize_number "$used" "0")
free=$(sanitize_number "$free" "0")
usage_percent=$(sanitize_integer "$usage_percent" "0")
fi
fi
fi
echo "{\"total\":$total,\"used\":$used,\"free\":$free,\"usage_percent\":$usage_percent}"
}
# 获取网络使用情况
get_network_usage() {
local upload_speed=0
local download_speed=0
local total_upload=0
local total_download=0
# FreeBSD系统
if [[ "$OS" == "FreeBSD" ]]; then
# 获取默认网络接口
local interface=""
# FreeBSD使用不同的route命令格式
if command_exists route; then
# 获取默认路由的接口
interface=$(route -n get default 2>/dev/null | grep 'interface:' | awk '{print $2}')
fi
# 如果没有找到,尝试查找活跃接口
if [[ -z "$interface" ]] && command_exists netstat; then
# 查找有流量的接口(排除lo)
interface=$(netstat -i -b | awk 'NR>1 && $1 !~ /^lo/ && ($7 > 0 || $10 > 0) {print $1; exit}')
fi
# 如果还是没找到,使用第一个非lo接口
if [[ -z "$interface" ]] && command_exists ifconfig; then
interface=$(ifconfig -l | tr ' ' '\n' | grep -v '^lo' | head -1)
fi
if [[ -n "$interface" ]] && command_exists netstat; then
# 使用netstat获取网络统计
# FreeBSD netstat -i -b 输出格式:
# Name Mtu Network Address Ipkts Ierrs Idrop Ibytes Opkts Oerrs Obytes Coll
# 同一接口可能有多行,我们只取第一行(Link层的统计)
local net_stats=$(netstat -i -b 2>/dev/null | grep "^$interface" | grep "<Link#" | head -1 2>/dev/null || echo "")
if [[ -n "$net_stats" ]]; then
local raw_download=$(echo "$net_stats" | awk '{print $8}' 2>/dev/null || echo "0") # Ibytes
local raw_upload=$(echo "$net_stats" | awk '{print $11}' 2>/dev/null || echo "0") # Obytes
# 清理和验证数值
total_download=$(sanitize_integer "$raw_download" "0")
total_upload=$(sanitize_integer "$raw_upload" "0")
else
# 如果没有找到Link统计,尝试其他方法
local net_stats_alt=$(netstat -i -b 2>/dev/null | grep "^$interface" | head -1 2>/dev/null || echo "")
if [[ -n "$net_stats_alt" ]]; then
local raw_download=$(echo "$net_stats_alt" | awk '{print $8}' 2>/dev/null || echo "0")
local raw_upload=$(echo "$net_stats_alt" | awk '{print $11}' 2>/dev/null || echo "0")
total_download=$(sanitize_integer "$raw_download" "0")
total_upload=$(sanitize_integer "$raw_upload" "0")
fi
fi
# 计算速度(简单方法)
local speed_file="/tmp/vps_monitor_net_${interface}"
local current_time=$(date +%s)
if [[ -f "$speed_file" ]]; then
local last_data=$(cat "$speed_file")
local last_time=$(echo "$last_data" | cut -d' ' -f1)
local last_rx=$(echo "$last_data" | cut -d' ' -f2)
local last_tx=$(echo "$last_data" | cut -d' ' -f3)
local time_diff=$((current_time - last_time))
if [[ $time_diff -gt 0 ]]; then
download_speed=$(( (total_download - last_rx) / time_diff ))
upload_speed=$(( (total_upload - last_tx) / time_diff ))
# 确保速度不为负数
[[ $download_speed -lt 0 ]] && download_speed=0
[[ $upload_speed -lt 0 ]] && upload_speed=0
fi
fi
# 保存当前数据供下次使用
echo "$current_time $total_download $total_upload" > "$speed_file"
fi
else
# Linux系统
# 获取默认网络接口 - 多种方法提高兼容性
local interface=""
# 方法1: 使用ip命令(现代Linux)
if command_exists ip; then
interface=$(ip route show default 2>/dev/null | awk '/default/ {print $5}' | head -1)
fi
# 方法2: 使用route命令(传统方法)
if [[ -z "$interface" ]] && command_exists route; then
interface=$(route -n 2>/dev/null | awk '/^0.0.0.0/ {print $8}' | head -1)
fi
# 方法3: 检查/proc/net/route(直接读取内核路由表)
if [[ -z "$interface" && -f "/proc/net/route" ]]; then
interface=$(awk '/^[^I]/ && $2 == "00000000" {print $1; exit}' /proc/net/route 2>/dev/null)
fi
# 方法4: 查找活跃的网络接口(改进版)
if [[ -z "$interface" && -f "/proc/net/dev" ]]; then
# 查找有流量的接口(排除lo和虚拟接口)
interface=$(awk '/^ *[^:]*:/ {
gsub(/:/, "", $1)
# 排除回环和虚拟接口
if ($1 != "lo" && $1 !~ /^(docker|br-|veth|tun|tap|virbr|vmnet)/) {
# 检查是否有流量(接收或发送字节数 > 1MB)
if ($2 > 1048576 || $10 > 1048576) {
print $1
exit
}
}
}' /proc/net/dev)
fi
# 方法5: 如果还是没找到,使用第一个物理网络接口
if [[ -z "$interface" && -f "/proc/net/dev" ]]; then
# 优先选择常见的物理接口名称
for prefix in eth ens enp eno wlan wlp; do
interface=$(awk -v prefix="$prefix" '/^ *[^:]*:/ {
gsub(/:/, "", $1)
if ($1 ~ "^" prefix) {
print $1
exit
}
}' /proc/net/dev)
if [[ -n "$interface" ]]; then
break
fi
done
fi
# 方法6: 最后的备选方案
if [[ -z "$interface" && -f "/proc/net/dev" ]]; then
interface=$(awk '/^ *[^:]*:/ {
gsub(/:/, "", $1)
if ($1 != "lo" && $1 !~ /^(docker|br-|veth|tun|tap|virbr|vmnet)/) {
print $1
exit
}
}' /proc/net/dev)
fi
if [[ -n "$interface" && -f "/proc/net/dev" ]]; then
# 获取总流量
local net_line=$(grep "^ *$interface:" /proc/net/dev 2>/dev/null)
if [[ -n "$net_line" ]]; then
# 解析网络统计数据
# 格式: interface: bytes packets errs drop fifo frame compressed multicast
local stats=($net_line)
total_download=${stats[1]} # 接收字节数
total_upload=${stats[9]} # 发送字节数
# 确保是数字
if ! [[ "$total_download" =~ ^[0-9]+$ ]]; then
total_download=0
fi
if ! [[ "$total_upload" =~ ^[0-9]+$ ]]; then
total_upload=0
fi
fi
# 尝试获取实时速度
if command_exists ifstat && [[ -n "$interface" ]]; then
# 使用ifstat获取实时速度
local network_speed=$(timeout 3 ifstat -i "$interface" 1 1 2>/dev/null | tail -1)
if [[ -n "$network_speed" && "$network_speed" != *"no statistics"* ]]; then
download_speed=$(echo "$network_speed" | awk '{printf "%.0f", $1 * 1024}' 2>/dev/null || echo "0")
upload_speed=$(echo "$network_speed" | awk '{printf "%.0f", $2 * 1024}' 2>/dev/null || echo "0")
fi
else
# 如果没有ifstat,使用简单的方法计算速度
local speed_file="/tmp/vps_monitor_net_${interface}"
local current_time=$(date +%s)
if [[ -f "$speed_file" ]]; then
local last_data=$(cat "$speed_file")
local last_time=$(echo "$last_data" | cut -d' ' -f1)
local last_rx=$(echo "$last_data" | cut -d' ' -f2)
local last_tx=$(echo "$last_data" | cut -d' ' -f3)
local time_diff=$((current_time - last_time))
if [[ $time_diff -gt 0 ]]; then
download_speed=$(( (total_download - last_rx) / time_diff ))
upload_speed=$(( (total_upload - last_tx) / time_diff ))
fi
fi
# 保存当前数据供下次使用
echo "$current_time $total_download $total_upload" > "$speed_file"
fi
fi
fi
# 确保所有值都是数字
[[ "$upload_speed" =~ ^[0-9]+$ ]] || upload_speed=0
[[ "$download_speed" =~ ^[0-9]+$ ]] || download_speed=0
[[ "$total_upload" =~ ^[0-9]+$ ]] || total_upload=0
[[ "$total_download" =~ ^[0-9]+$ ]] || total_download=0
echo "{\"upload_speed\":$upload_speed,\"download_speed\":$download_speed,\"total_upload\":$total_upload,\"total_download\":$total_download}"
}
# 获取系统运行时间
get_uptime() {
local uptime_seconds=0
# FreeBSD系统
if [[ "$OS" == "FreeBSD" ]]; then
if command_exists sysctl; then
# FreeBSD使用sysctl获取启动时间
local boot_time_raw=$(sysctl -n kern.boottime 2>/dev/null | awk '{print $4}' | tr -d ',' 2>/dev/null || echo "0")
local boot_time=$(sanitize_integer "$boot_time_raw" "0")
local current_time=$(date +%s)
if [[ $boot_time -gt 0 && $current_time -gt $boot_time ]]; then
uptime_seconds=$((current_time - boot_time))
else
# 如果无法获取启动时间,尝试其他方法
if command_exists uptime; then
# 尝试解析uptime命令输出
local uptime_str=$(uptime 2>/dev/null | grep -o 'up [^,]*' | sed 's/up //' || echo "0")
# 简化处理,假设格式为 "X days" 或 "X:Y"
if [[ "$uptime_str" =~ ([0-9]+).*day ]]; then
uptime_seconds=$((${BASH_REMATCH[1]} * 86400))
else
uptime_seconds=0
fi
else
uptime_seconds=0
fi
fi
else
uptime_seconds=0
fi
else
# Linux系统
if [[ -f /proc/uptime ]]; then
uptime_seconds=$(cut -d. -f1 /proc/uptime)
elif command_exists uptime; then
# 解析uptime命令输出
local uptime_str=$(uptime | awk '{print $3}')
# 这里简化处理,实际可能需要更复杂的解析
uptime_seconds=$(echo "$uptime_str" | sed 's/,//' | awk '{print $1 * 86400}' 2>/dev/null || echo "0")
fi
fi
echo "$uptime_seconds"
}
# 验证和清理数值
sanitize_number() {
local value="$1"
local default_value="${2:-0}"
value=$(echo "$value" | sed 's/[^0-9.]//g')
if [[ "$value" =~ ^[0-9]*\.?[0-9]+$ ]] || [[ "$value" =~ ^[0-9]+\.?[0-9]*$ ]]; then
[[ "$value" =~ ^\. ]] && value="0$value"
[[ "$value" =~ \.$ ]] && value="${value}0"
echo "$value"
else
echo "$default_value"
fi
}
# 验证和清理整数
sanitize_integer() {
local value="$1"
local default_value="${2:-0}"
value=$(echo "$value" | sed 's/[^0-9]//g')
[[ "$value" =~ ^[0-9]+$ ]] && echo "$value" || echo "$default_value"
}
# 清理JSON字符串
clean_json_string() {
local input="$1"
# 移除可能的控制字符和非打印字符
echo "$input" | tr -d '\000-\037' | tr -d '\177-\377'
}
# 获取服务器配置(带简单重试)
get_config() {
local max_attempts=3
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
log "正在获取服务器配置... (第 $attempt/$max_attempts 次)"
local response=$(curl -s -w "%{http_code}" -X GET "$WORKER_URL/api/config/$SERVER_ID" \
-H "X-API-Key: $API_KEY" 2>/dev/null || echo "000")
local http_code="${response: -3}"
local response_body="${response%???}"
if [[ "$http_code" == "200" ]]; then
log "配置获取成功"
# 简化的间隔解析
local new_interval=$(echo "$response_body" | sed -n 's/.*"interval":\([0-9]\+\).*/\1/p')
if [[ -n "$new_interval" && "$new_interval" =~ ^[0-9]+$ && "$new_interval" -gt 0 ]]; then
if [[ "$new_interval" != "$INTERVAL" ]]; then
log "服务器返回新的上报间隔: ${new_interval}秒 (当前: ${INTERVAL}秒)"
INTERVAL="$new_interval"
save_config
log "上报间隔已更新为: ${INTERVAL}秒"
fi
fi
save_config_cache "$response_body"
return 0
else
log "配置获取失败 (HTTP $http_code)"
case "$http_code" in
"401") log "认证失败 - 请检查API密钥" ;;
"404") log "服务器不存在 - 请检查服务器ID" ;;
"000") log "网络连接失败" ;;
esac
if [[ $attempt -lt $max_attempts ]]; then
log "等待2秒后重试..."
sleep 2
fi
fi
attempt=$((attempt + 1))
done
log "配置获取最终失败"
return 1
}
# 本地缓存管理
CACHE_DIR="$SCRIPT_DIR/cache"
CONFIG_CACHE_FILE="$CACHE_DIR/config.json"
METRICS_CACHE_FILE="$CACHE_DIR/last_metrics.json"
# 初始化缓存目录
init_cache() {
if [[ ! -d "$CACHE_DIR" ]]; then
mkdir -p "$CACHE_DIR"
log "创建缓存目录: $CACHE_DIR"
fi
}
# 保存配置到缓存
save_config_cache() {
local config_data="$1"
init_cache
echo "$config_data" > "$CONFIG_CACHE_FILE"
log "配置已缓存到本地"
}
# 从缓存加载配置
load_config_cache() {
if [[ -f "$CONFIG_CACHE_FILE" ]]; then
local cached_config=$(cat "$CONFIG_CACHE_FILE")
if [[ -n "$cached_config" ]]; then
log "从缓存加载配置"
echo "$cached_config"
return 0
fi
fi
return 1
}
# 上报监控数据
report_metrics() {
local timestamp=$(date +%s)
local cpu_raw=$(get_cpu_usage)
local memory_raw=$(get_memory_usage)
local disk_raw=$(get_disk_usage)
local network_raw=$(get_network_usage)
local uptime_raw=$(get_uptime)
# 验证运行时间
local uptime=$(sanitize_integer "$uptime_raw" "0")
# 清理JSON数据
cpu_raw=$(clean_json_string "$cpu_raw")
memory_raw=$(clean_json_string "$memory_raw")
disk_raw=$(clean_json_string "$disk_raw")
network_raw=$(clean_json_string "$network_raw")
# 简单验证JSON格式
[[ ! "$cpu_raw" =~ ^\{.*\}$ ]] && cpu_raw='{"usage_percent":0,"load_avg":[0,0,0]}'
[[ ! "$memory_raw" =~ ^\{.*\}$ ]] && memory_raw='{"total":0,"used":0,"free":0,"usage_percent":0}'
[[ ! "$disk_raw" =~ ^\{.*\}$ ]] && disk_raw='{"total":0,"used":0,"free":0,"usage_percent":0}'
[[ ! "$network_raw" =~ ^\{.*\}$ ]] && network_raw='{"upload_speed":0,"download_speed":0,"total_upload":0,"total_download":0}'
# 构建JSON数据
local data="{\"timestamp\":$timestamp,\"cpu\":$cpu_raw,\"memory\":$memory_raw,\"disk\":$disk_raw,\"network\":$network_raw,\"uptime\":$uptime}"
log "正在上报数据到 $WORKER_URL/api/report/$SERVER_ID"
local response=$(curl -s -w "%{http_code}" -X POST "$WORKER_URL/api/report/$SERVER_ID" \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d "$data" 2>/dev/null || echo "000")
local http_code="${response: -3}"
local response_body="${response%???}"
if [[ "$http_code" == "200" ]]; then
log "数据上报成功"
# 尝试从响应中解析新的间隔设置
if command_exists jq; then
# 如果有jq命令,使用jq解析
local new_interval=$(echo "$response_body" | jq -r '.interval // empty' 2>/dev/null)
if [[ -n "$new_interval" && "$new_interval" =~ ^[0-9]+$ && "$new_interval" -gt 0 ]]; then
if [[ "$new_interval" != "$INTERVAL" ]]; then
log "服务器返回新的上报间隔: ${new_interval}秒 (当前: ${INTERVAL}秒)"
INTERVAL="$new_interval"
# 更新配置文件
save_config
log "上报间隔已更新为: ${INTERVAL}秒"
# 创建重启标记文件,让主循环重启服务以应用新间隔
touch "$SCRIPT_DIR/restart_needed"
fi
fi
else
# 如果没有jq,使用简单的文本解析
local new_interval=$(echo "$response_body" | sed -n 's/.*"interval":\([0-9]\+\).*/\1/p')
if [[ -n "$new_interval" && "$new_interval" =~ ^[0-9]+$ && "$new_interval" -gt 0 ]]; then
if [[ "$new_interval" != "$INTERVAL" ]]; then
log "服务器返回新的上报间隔: ${new_interval}秒 (当前: ${INTERVAL}秒)"
INTERVAL="$new_interval"
# 更新配置文件
save_config
log "上报间隔已更新为: ${INTERVAL}秒"
# 创建重启标记文件,让主循环重启服务以应用新间隔
touch "$SCRIPT_DIR/restart_needed"
fi
fi
fi
return 0
else
# 错误分类处理
case "$http_code" in
"400"|"413")
log "数据上报失败 (HTTP $http_code): 数据格式或大小问题"
return 1 # 不可重试的错误
;;
"401"|"403")
log "数据上报失败 (HTTP $http_code): 认证失败"
return 1 # 不可重试的错误
;;
"404")
log "数据上报失败 (HTTP $http_code): 服务器不存在"
return 1 # 不可重试的错误
;;
"429"|"500"|"502"|"503"|"504"|"000")
log "数据上报失败 (HTTP $http_code): 可重试的错误"
return 2 # 可重试的错误
;;
*)
log "数据上报失败 (HTTP $http_code): 未知错误"
return 1 # 默认不可重试
;;
esac
fi
}
# 创建监控服务脚本
create_service_script() {
# 获取当前脚本的绝对路径
local main_script_path=$(realpath "$0")
cat > "$SERVICE_FILE" << EOF
#!/bin/bash
# cf-vps-monitor服务脚本 - 集中式文件管理
SCRIPT_DIR="$SCRIPT_DIR"
CONFIG_FILE="\$SCRIPT_DIR/config/config"
LOG_FILE="\$SCRIPT_DIR/logs/monitor.log"
PID_FILE="\$SCRIPT_DIR/run/monitor.pid"
MAIN_SCRIPT="$main_script_path"
# 设置服务模式标志(避免日志重复)
export SERVICE_MODE=true
# 确保日志目录存在
mkdir -p "\$(dirname "\$LOG_FILE")" 2>/dev/null
# 加载配置
if [[ -f "\$CONFIG_FILE" ]]; then
source "\$CONFIG_FILE"
else
echo "配置文件不存在: \$CONFIG_FILE"
exit 1
fi
# 从主脚本加载监控函数(简化版)
source_monitoring_functions() {
# 直接source主脚本,但设置标志避免执行主程序
if [[ -f "\$MAIN_SCRIPT" ]]; then
# 设置标志表示只加载函数
export FUNCTIONS_ONLY=true
source "\$MAIN_SCRIPT"
unset FUNCTIONS_ONLY
else
log "错误: 找不到主脚本 \$MAIN_SCRIPT"
exit 1
fi
}
# 加载监控函数
source_monitoring_functions
# 清理JSON字符串
clean_json_string() {
local input="\$1"
# 移除可能的控制字符和非打印字符
echo "\$input" | tr -d '\\000-\\037' | tr -d '\\177-\\377'
}
# 上报监控数据
report_metrics() {
local timestamp=\$(date +%s)
local cpu_raw=\$(get_cpu_usage)
local memory_raw=\$(get_memory_usage)
local disk_raw=\$(get_disk_usage)
local network_raw=\$(get_network_usage)
local uptime_raw=\$(get_uptime)
# 验证运行时间
local uptime=\$(sanitize_integer "\$uptime_raw" "0")
# 清理JSON数据
cpu_raw=\$(clean_json_string "\$cpu_raw")
memory_raw=\$(clean_json_string "\$memory_raw")
disk_raw=\$(clean_json_string "\$disk_raw")
network_raw=\$(clean_json_string "\$network_raw")
# 验证各个JSON组件(使用更宽松的验证)
if [[ -z "\$cpu_raw" || "\$cpu_raw" == "{}" || ! "\$cpu_raw" =~ ^\{.*\}\$ ]]; then
cpu_raw='{\\"usage_percent\\":0,\\"load_avg\\":[0,0,0]}'
fi
if [[ -z "\$memory_raw" || "\$memory_raw" == "{}" || ! "\$memory_raw" =~ ^\{.*\}\$ ]]; then
memory_raw='{\\"total\\":0,\\"used\\":0,\\"free\\":0,\\"usage_percent\\":0}'
fi
if [[ -z "\$disk_raw" || "\$disk_raw" == "{}" || ! "\$disk_raw" =~ ^\{.*\}\$ ]]; then
disk_raw='{\\"total\\":0,\\"used\\":0,\\"free\\":0,\\"usage_percent\\":0}'
fi
if [[ -z "\$network_raw" || "\$network_raw" == "{}" || ! "\$network_raw" =~ ^\{.*\}\$ ]]; then
network_raw='{\\"upload_speed\\":0,\\"download_speed\\":0,\\"total_upload\\":0,\\"total_download\\":0}'
fi
# 构建JSON数据
local data="{\\"timestamp\\":\$timestamp,\\"cpu\\":\$cpu_raw,\\"memory\\":\$memory_raw,\\"disk\\":\$disk_raw,\\"network\\":\$network_raw,\\"uptime\\":\$uptime}"
log "正在上报数据..."
local response=\$(curl -s -w "%{http_code}" -X POST "\$WORKER_URL/api/report/\$SERVER_ID" \\
-H "Content-Type: application/json" \\
-H "X-API-Key: \$API_KEY" \\
-d "\$data" 2>/dev/null || echo "000")
local http_code="\${response: -3}"
local response_body="\${response%???}"
if [[ "\$http_code" == "200" ]]; then
log "数据上报成功"
# 尝试从响应中解析新的间隔设置
# 使用简单的文本解析(避免依赖jq)
local new_interval=\$(echo "\$response_body" | sed -n 's/.*"interval":\\([0-9]\\+\\).*/\\1/p')
if [[ -n "\$new_interval" && "\$new_interval" =~ ^[0-9]+\$ && "\$new_interval" -gt 0 ]]; then
if [[ "\$new_interval" != "\$INTERVAL" ]]; then
log "服务器返回新的上报间隔: \${new_interval}秒 (当前: \${INTERVAL}秒)"
INTERVAL="\$new_interval"
# 更新配置文件
cat > "\$CONFIG_FILE" << EOL
# VPS监控配置文件
WORKER_URL="\$WORKER_URL"
SERVER_ID="\$SERVER_ID"
API_KEY="\$API_KEY"
INTERVAL="\$INTERVAL"
EOL
log "上报间隔已更新为: \${INTERVAL}秒"
# 创建重启标记文件,让主循环重新加载配置
touch "\$SCRIPT_DIR/restart_needed"
fi
fi
return 0
else
log "数据上报失败 (HTTP \$http_code): \$response_body"
# 简化的错误处理
case "\$http_code" in
"400") log "数据格式错误" ;;
"401") log "认证失败 - 请检查API密钥" ;;
"404") log "服务器不存在 - 请检查服务器ID" ;;
"429") log "请求过于频繁 - 将自动重试" ;;
"500"|"503") log "服务器错误 - 将在下个周期重试" ;;
"000") log "网络连接失败" ;;
*) log "未知错误 (HTTP \$http_code)" ;;
esac
return 1
fi
}
# 获取服务器配置
get_config() {
log "正在获取服务器配置..."
local response=\$(curl -s -w "%{http_code}" -X GET "\$WORKER_URL/api/config/\$SERVER_ID" \\
-H "X-API-Key: \$API_KEY" 2>/dev/null || echo "000")
local http_code="\${response: -3}"
local response_body="\${response%???}"
if [[ "\$http_code" == "200" ]]; then
log "配置获取成功"
# 尝试解析配置
local new_interval=""
# 使用改进的文本解析(避免依赖jq)
# 方法1: 使用grep + cut
new_interval=\$(echo "\$response_body" | grep -o '"report_interval":[0-9]*' | cut -d':' -f2 2>/dev/null)
# 方法2: 如果方法1失败,使用awk备用方案
if [[ -z "\$new_interval" ]]; then
new_interval=\$(echo "\$response_body" | awk -F'"report_interval":' '{if(NF>1) print \$2}' | awk -F',' '{print \$1}' | tr -d ' ' 2>/dev/null)
fi
# 验证并更新间隔设置
if [[ -n "\$new_interval" && "\$new_interval" =~ ^[0-9]+\$ && "\$new_interval" -gt 0 ]]; then
if [[ "\$new_interval" != "\$INTERVAL" ]]; then
log "检测到新的上报间隔: \${new_interval}秒 (当前: \${INTERVAL}秒)"
INTERVAL="\$new_interval"
# 更新配置文件
cat > "\$CONFIG_FILE" << EOL
# VPS监控配置文件
WORKER_URL="\$WORKER_URL"
SERVER_ID="\$SERVER_ID"
API_KEY="\$API_KEY"
INTERVAL="\$INTERVAL"
EOL
log "上报间隔已更新为: \${INTERVAL}秒"
return 0
else
log "配置无变化,当前间隔: \${INTERVAL}秒"
return 0
fi
else
log "警告: 无法解析配置中的上报间隔,保持当前设置"
return 1
fi
else
log "配置获取失败 (HTTP \$http_code): \$response_body"
# 简化的错误处理
case "\$http_code" in
"401") log "认证失败 - 请检查API密钥" ;;
"404") log "服务器不存在 - 请检查服务器ID" ;;
"429") log "请求过于频繁 - 将稍后重试" ;;
"500"|"503") log "服务器错误 - 将稍后重试" ;;
"000") log "网络连接失败" ;;
*) log "未知错误 (HTTP \$http_code)" ;;
esac
return 1
fi
}
# 主循环
main() {
log "VPS监控服务启动 (PID: \$\$)"
echo \$\$ > "\$PID_FILE"
# 信号处理
trap 'log "收到终止信号,正在停止..."; rm -f "\$PID_FILE"; exit 0' TERM INT
# 启动时获取一次配置
log "启动时获取服务器配置..."
get_config || log "启动时配置获取失败,使用当前配置"
local config_check_counter=0
local config_check_interval=10 # 每10个周期检查一次配置(约10分钟)
while true; do
# 定期检查配置更新
if [[ \$config_check_counter -ge \$config_check_interval ]]; then
log "定期检查配置更新..."
get_config || log "配置检查失败,继续使用当前配置"
config_check_counter=0
else
config_check_counter=\$((config_check_counter + 1))
fi
if ! report_metrics; then
log "上报失败,将在下个周期重试"
fi
# 检查是否需要重启以应用新的间隔设置
if [[ -f "\$SCRIPT_DIR/restart_needed" ]]; then
log "检测到间隔设置变更,正在重新加载配置..."
rm -f "\$SCRIPT_DIR/restart_needed"
# 重新加载配置
if [[ -f "\$CONFIG_FILE" ]]; then
source "\$CONFIG_FILE"
log "已重新加载配置,新的上报间隔: \${INTERVAL}秒"
fi
fi
sleep "\$INTERVAL"
done
}
# 启动主函数
main
EOF
chmod +x "$SERVICE_FILE"
print_message "$GREEN" "监控服务脚本创建完成: $SERVICE_FILE"
}
# ==================== 用户类型检测和systemd命令选择机制 ====================
# 检测当前用户类型
detect_user_type() {
if [[ $EUID -eq 0 ]]; then
echo "root"
else
echo "user"
fi
}
# 检查是否为root用户
is_root_user() {
[[ $EUID -eq 0 ]]
}
# 检查systemd服务可用性(根据用户类型)
check_systemd_availability() {
if ! command_exists systemctl; then
return 1
fi
if is_root_user; then
# root用户检查系统级systemd
systemctl status >/dev/null 2>&1
else
# 普通用户检查用户级systemd
systemctl --user status >/dev/null 2>&1
fi
}
# 创建systemd服务(兼容版)
create_systemd_service() {
print_message "$BLUE" "配置systemd服务..."
# 检查systemd可用性
if ! is_user_systemd_available; then
print_message "$YELLOW" "systemd不可用,跳过systemd服务配置"
return 1
fi
# 确定服务文件路径
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
mkdir -p "$(dirname "$service_path")"
fi
# 生成服务文件内容
print_message "$CYAN" " 生成systemd服务文件: $service_path"
# 创建服务文件模板目录
mkdir -p "$SCRIPT_DIR/system/templates"
# 生成服务文件内容
if is_root_user; then
cat > "$SCRIPT_DIR/system/templates/systemd.service" << EOF
[Unit]
Description=cf-vps-monitor Service - VPS Monitoring Agent
Documentation=https://github.com/kadidalax/cf-vps-monitor
After=network.target network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$SERVICE_FILE
Restart=always
RestartSec=10
User=root
Group=root
WorkingDirectory=$SCRIPT_DIR
[Install]
WantedBy=multi-user.target
EOF
else
cat > "$SCRIPT_DIR/system/templates/systemd.service" << EOF
[Unit]
Description=cf-vps-monitor Service - VPS Monitoring Agent
Documentation=https://github.com/kadidalax/cf-vps-monitor
After=network.target network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$SERVICE_FILE
Restart=always
RestartSec=10
WorkingDirectory=$SCRIPT_DIR
[Install]
WantedBy=default.target
EOF
fi
# 复制模板到系统位置
cp "$SCRIPT_DIR/system/templates/systemd.service" "$service_path"
# 记录到安装清单
record_installation "systemd" "$service_path" "create" "none"
# 重新加载systemd配置
if is_root_user; then
safe_systemctl daemon-reload
safe_systemctl enable cf-vps-monitor.service
else
safe_systemctl --user daemon-reload
safe_systemctl --user enable cf-vps-monitor.service
fi
print_message "$GREEN" "✓ systemd服务创建完成: $service_path"
return 0
}
# ==================== systemd lingering支持 ====================
# 简化的lingering启用
enable_lingering() {
# root用户不需要lingering
if is_root_user; then
return 0
fi
# 检查loginctl是否可用
if ! command_exists loginctl; then
return 1
fi
# 尝试启用lingering(静默处理)
loginctl enable-linger "$USER" 2>/dev/null || true
return 0
}
# 启动监控服务
start_service() {
local user_desc=$(get_user_type_description)
print_message "$BLUE" "启动监控服务 ($user_desc)..."
# 1. 检查是否已有进程在运行
if is_monitor_running; then
local pids=$(find_monitor_processes)
local first_pid=$(echo "$pids" | awk '{print $1}')
print_message "$YELLOW" "监控服务已在运行 (PID: $first_pid)"
return 0
fi
# 2. 清理旧的PID文件
rm -f "$PID_FILE" 2>/dev/null || true
# 3. 尝试使用systemd启动(简化版)
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
fi
if [[ -f "$service_path" ]] && is_systemd_available; then
local systemd_cmd="systemctl"
[[ ! $(is_root_user) ]] && systemd_cmd="systemctl --user"
if $systemd_cmd start cf-vps-monitor.service 2>/dev/null; then
$systemd_cmd enable cf-vps-monitor.service 2>/dev/null || true
print_message "$GREEN" "✓ 监控服务已启动 (systemd)"
# 自动配置其他自启动设置
echo
add_autostart_settings
echo
print_message "$CYAN" "提示: 已配置自启动设置,重启后监控服务会自动启动"
return 0
fi
fi
# 4. 传统方式启动
print_message "$BLUE" "使用传统方式启动服务..."
if [[ ! -f "$SERVICE_FILE" ]]; then
print_message "$RED" "✗ 服务脚本不存在: $SERVICE_FILE"
print_message "$CYAN" "请先运行安装命令"
return 1
fi
chmod +x "$SERVICE_FILE" 2>/dev/null || true
if command_exists nohup; then
nohup "$SERVICE_FILE" >> "$LOG_FILE" 2>&1 &
else
"$SERVICE_FILE" >> "$LOG_FILE" 2>&1 &
fi
local pid=$!
echo "$pid" > "$PID_FILE"
# 5. 验证启动成功
sleep 2
if kill -0 "$pid" 2>/dev/null; then
print_message "$GREEN" "✓ 监控服务已启动 (PID: $pid)"
print_message "$CYAN" "日志文件: $LOG_FILE"
# 自动配置自启动设置
echo
add_autostart_settings
echo
print_message "$CYAN" "提示: 已配置自启动设置,重启后监控服务会自动启动"
return 0
else
print_message "$RED" "✗ 监控服务启动失败"
if [[ -f "$LOG_FILE" ]]; then
print_message "$YELLOW" "查看日志: tail -f $LOG_FILE"
fi
rm -f "$PID_FILE"
return 1
fi
}
# 渐进式停止单个进程(改进版)
stop_single_process() {
local pid="$1"
# 首先验证PID
if ! validate_pid "$pid"; then
print_message "$YELLOW" " ⚠ PID $pid 无效或进程不存在"
return 1
fi
# 获取正确的进程信息
local cmd=$(get_process_command "$pid")
print_message "$BLUE" "停止进程: $cmd (PID: $pid)"
# 1. 温和停止(SIGTERM)
if kill "$pid" 2>/dev/null; then
sleep 2
# 2. 检查是否还在运行
if ! kill -0 "$pid" 2>/dev/null; then
print_message "$GREEN" " ✓ 进程已正常停止"
return 0
fi
# 3. 强制停止(SIGKILL)
if kill -9 "$pid" 2>/dev/null; then
sleep 1
# 4. 最终确认
if ! kill -0 "$pid" 2>/dev/null; then
print_message "$GREEN" " ✓ 进程已强制停止"
return 0
else
print_message "$RED" " ✗ 进程无法停止"
return 1
fi
fi
fi
print_message "$YELLOW" " ⚠ 无法发送信号"
return 1
}
# 停止监控服务
stop_service() {
local user_desc=$(get_user_type_description)
print_message "$BLUE" "停止监控服务 ($user_desc)..."
local stopped=false
# 1. 尝试使用systemd停止(简化版)
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
fi
if [[ -f "$service_path" ]] && is_systemd_available; then
local systemd_cmd="systemctl"
[[ ! $(is_root_user) ]] && systemd_cmd="systemctl --user"
if $systemd_cmd is-active cf-vps-monitor.service >/dev/null 2>&1; then
$systemd_cmd stop cf-vps-monitor.service 2>/dev/null
$systemd_cmd disable cf-vps-monitor.service 2>/dev/null || true
stopped=true
fi
fi
# 2. 查找并停止所有相关进程(使用精确检测)
local pids=$(find_monitor_processes)
if [[ -n "$pids" ]]; then
local stopped_count=0
local total_count=0
for pid in $pids; do
total_count=$((total_count + 1))
if stop_single_process "$pid"; then
stopped_count=$((stopped_count + 1))
stopped=true
fi
done
if [[ $stopped_count -gt 0 ]]; then
print_message "$GREEN" "✓ 已停止 $stopped_count/$total_count 个监控进程"
fi
fi
# 3. 清理PID文件
rm -f "$PID_FILE" 2>/dev/null || true
# 4. 结果报告和自启动清理
if [[ "$stopped" == "true" ]]; then
print_message "$GREEN" "✓ 监控服务已停止"
# 自动移除自启动设置
echo
remove_autostart_settings
echo
print_message "$CYAN" "提示: 已移除自启动设置,重启后监控服务不会自动启动"
print_message "$CYAN" "如需重新启用监控,请使用启动功能"
else
print_message "$YELLOW" "没有发现运行中的监控服务"
fi
}
# 检查服务状态
check_service_status() {
local user_desc=$(get_user_type_description)
print_message "$BLUE" "检查监控服务状态 ($user_desc)..."
echo
# 1. 使用精确检测逻辑
if is_monitor_running; then
local pids=$(find_monitor_processes)
local pid_count=$(echo "$pids" | wc -w)
print_message "$GREEN" "✓ 监控服务正在运行"
if [[ $pid_count -eq 1 ]]; then
local pid=$(echo "$pids" | awk '{print $1}')
local cmd=$(get_process_command "$pid")
print_message "$CYAN" " 进程信息: PID $pid"
print_message "$CYAN" " 命令行: $cmd"
else
print_message "$YELLOW" " 发现多个进程实例 ($pid_count 个):"
for pid in $pids; do
local cmd=$(get_process_command "$pid")
print_message "$CYAN" " PID $pid: $cmd"
done
fi
else
print_message "$RED" "✗ 监控服务未运行"
fi
# 2. 检查systemd状态(如果可用)
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
fi
if [[ -f "$service_path" ]] && is_systemd_available; then
local systemd_cmd="systemctl"
[[ ! $(is_root_user) ]] && systemd_cmd="systemctl --user"
echo
print_message "$BLUE" "systemd服务状态:"
if $systemd_cmd is-active cf-vps-monitor.service >/dev/null 2>&1; then
print_message "$GREEN" " ✓ systemd服务活跃"
$systemd_cmd status cf-vps-monitor.service --no-pager -l 2>/dev/null || true
else
print_message "$YELLOW" " ✗ systemd服务未活跃"
fi
fi
# 3. 检查PID文件状态
echo
print_message "$BLUE" "PID文件状态:"
if [[ -f "$PID_FILE" ]]; then
local file_pid=$(cat "$PID_FILE" 2>/dev/null)
if [[ -n "$file_pid" ]]; then
if kill -0 "$file_pid" 2>/dev/null; then
print_message "$GREEN" " ✓ PID文件有效 (PID: $file_pid)"
else
print_message "$YELLOW" " ⚠ PID文件存在但进程不存在 (清理中...)"
rm -f "$PID_FILE"
fi
else
print_message "$YELLOW" " ⚠ PID文件为空"
fi
else
print_message "$YELLOW" " ✗ PID文件不存在"
fi
# 4. 显示配置信息
echo
print_message "$BLUE" "配置信息:"
if [[ -f "$CONFIG_FILE" ]]; then
load_config
print_message "$CYAN" " Worker URL: $WORKER_URL"
print_message "$CYAN" " Server ID: $SERVER_ID"
print_message "$CYAN" " API Key: ${API_KEY:0:8}..."
print_message "$CYAN" " 上报间隔: ${INTERVAL}秒"
else
print_message "$YELLOW" " ✗ 配置文件不存在"
fi
# 5. 显示日志文件信息
echo
print_message "$BLUE" "日志文件:"
if [[ -f "$LOG_FILE" ]]; then
local log_size=$(du -h "$LOG_FILE" 2>/dev/null | cut -f1)
local log_lines=$(wc -l < "$LOG_FILE" 2>/dev/null || echo "0")
print_message "$CYAN" " 文件: $LOG_FILE"
print_message "$CYAN" " 大小: $log_size"
print_message "$CYAN" " 行数: $log_lines"
else
print_message "$YELLOW" " ✗ 日志文件不存在"
fi
# 显示自启动状态
echo
print_message "$CYAN" "自启动配置状态:"
local active_count=0
# 检查systemd服务状态
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
print_message "$CYAN" " systemd服务 (系统管理员):"
if [[ -f "$service_path" ]] && command_exists systemctl; then
if systemctl is-enabled cf-vps-monitor.service >/dev/null 2>&1; then
print_message "$GREEN" " ✓ 服务已启用"
active_count=$((active_count + 1))
print_message "$GREEN" " ✓ 系统级服务 (重启后自动运行)"
else
print_message "$YELLOW" " ✗ 服务未启用"
fi
else
print_message "$YELLOW" " ✗ systemd服务文件不存在"
fi
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
print_message "$CYAN" " systemd服务 (普通用户):"
if [[ -f "$service_path" ]] && command_exists systemctl; then
if systemctl --user is-enabled cf-vps-monitor.service >/dev/null 2>&1; then
print_message "$GREEN" " ✓ 服务已启用"
active_count=$((active_count + 1))
else
print_message "$YELLOW" " ✗ 服务未启用"
fi
else
print_message "$YELLOW" " ✗ systemd服务文件不存在"
fi
fi
# 检查crontab状态
print_message "$CYAN" " crontab自启动:"
if check_crontab_autostart; then
print_message "$GREEN" " ✓ 已配置 (重启后自动运行)"
active_count=$((active_count + 1))
else
print_message "$YELLOW" " ✗ 未配置"
fi
# 检查profile状态
print_message "$CYAN" " shell profile自启动:"
if [[ -f "$HOME/.bashrc" ]] && grep -q "cf-vps-monitor auto-start" "$HOME/.bashrc" 2>/dev/null; then
print_message "$GREEN" " ✓ 已配置 (登录时自动运行)"
active_count=$((active_count + 1))
else
print_message "$YELLOW" " ✗ 未配置"
fi
# 自启动状态总结
echo
print_message "$BLUE" "自启动保障总结:"
echo " 活跃方案数: $active_count / 3"
if [[ $active_count -eq 0 ]]; then
print_message "$RED" " 状态: 无自启动保障"
print_message "$YELLOW" " 建议: 重新安装服务以配置自启动"
elif [[ $active_count -eq 1 ]]; then
print_message "$YELLOW" " 状态: 基本保障"
print_message "$CYAN" " 建议: 重新安装服务以配置完整保障"
elif [[ $active_count -eq 2 ]]; then
print_message "$GREEN" " 状态: 良好保障"
else
print_message "$GREEN" " 状态: 完全保障 (推荐)"
fi
# 如果检测有问题,提供诊断选项
if ! is_monitor_running && [[ $active_count -gt 0 ]]; then
echo
print_message "$YELLOW" "提示: 配置了自启动但服务未运行,输入 'd' 查看详细诊断"
echo -n "是否查看诊断信息? (d/N): "
read -r -t 10 diag_choice
if [[ "$diag_choice" =~ ^[Dd]$ ]]; then
echo
diagnose_monitor_service
fi
fi
}
# ==================== crontab自启动方案 ====================
# 设置crontab自启动
setup_crontab_autostart() {
print_message "$BLUE" "配置crontab自启动..."
# 检查crontab可用性
if ! command_exists crontab; then
return 1
fi
# 获取当前crontab(减少fork操作)
local current_crontab=$(crontab -l 2>/dev/null || echo "")
# 检查是否已配置
if echo "$current_crontab" | grep -q "cf-vps-monitor"; then
print_message "$GREEN" "✓ crontab自启动已存在"
return 0
fi
# 备份当前crontab
local backup_file="$SCRIPT_DIR/system/backups/crontab_backup"
echo "$current_crontab" > "$backup_file"
# 优先级启动条目(简化进程检测)
local crontab_entry="@reboot sleep 30 && pgrep -f 'cf-vps-monitor|vps-monitor-service' >/dev/null || $SERVICE_FILE"
# 添加新条目(减少临时文件操作)
if (echo "$current_crontab"; echo "$crontab_entry") | crontab - 2>/dev/null; then
# 记录到安装清单
record_installation "crontab" "$USER" "add" "$backup_file"
print_message "$GREEN" "✓ crontab自启动已配置"
return 0
else
return 1
fi
}
# 检查crontab自启动状态
check_crontab_autostart() {
if ! command_exists crontab; then
return 1
fi
if crontab -l 2>/dev/null | grep -q "$SERVICE_FILE"; then
return 0
else
return 1
fi
}
# ==================== shell profile自启动方案 ====================
# 设置shell profile自启动
setup_profile_autostart() {
print_message "$BLUE" "配置shell profile自启动..."
local profile="$HOME/.bashrc"
# 检查是否已配置
if grep -q "cf-vps-monitor auto-start" "$profile" 2>/dev/null; then
return 0
fi
# 备份原文件
local backup_file="$SCRIPT_DIR/system/backups/bashrc_backup"
cp "$profile" "$backup_file" 2>/dev/null || touch "$backup_file"
# 添加自启动代码
cat >> "$profile" << EOF
# === cf-vps-monitor auto-start BEGIN ===
# VPS监控服务自启动检测 (最后保障)
if [ -n "\$PS1" ] && [ "\$TERM" != "dumb" ]; then
if ! pgrep -f 'cf-vps-monitor|vps-monitor-service' >/dev/null 2>&1; then
(sleep 5 && nohup "$SERVICE_FILE" >/dev/null 2>&1 &) &
fi
fi
# === cf-vps-monitor auto-start END ===
EOF
# 记录到安装清单
record_installation "profile" "$profile" "modify" "$backup_file"
print_message "$GREEN" "✓ shell profile自启动已配置"
return 0
}
# ==================== 多重自启动方案协调器 ====================
# 配置优先级自启动
setup_auto_start() {
print_message "$BLUE" "配置优先级自启动机制..."
echo
# 检查服务脚本
if [[ ! -f "$SERVICE_FILE" ]]; then
print_message "$RED" "✗ 服务脚本不存在,请先运行安装"
return 1
fi
local success_count=0
local total_attempts=3
# 优先级1: systemd服务
if is_user_systemd_available; then
print_message "$CYAN" "优先级1: systemd服务"
if create_systemd_service; then
success_count=$((success_count + 1))
print_message "$GREEN" " ✓ systemd服务已配置"
if ! is_root_user; then
enable_lingering >/dev/null 2>&1
fi
else
print_message "$YELLOW" " ✗ systemd服务配置失败"
fi
else
print_message "$YELLOW" " - systemd不可用,跳过"
total_attempts=$((total_attempts - 1))
fi
# 优先级2: crontab备用
print_message "$CYAN" "优先级2: crontab备用"
if setup_crontab_autostart; then
success_count=$((success_count + 1))
print_message "$GREEN" " ✓ crontab备用已配置"
else
print_message "$YELLOW" " ✗ crontab备用配置失败"
fi
# 优先级3: shell profile保障
print_message "$CYAN" "优先级3: shell profile保障"
if setup_profile_autostart; then
success_count=$((success_count + 1))
print_message "$GREEN" " ✓ profile保障已配置"
else
print_message "$YELLOW" " ✗ profile保障配置失败"
fi
echo
if [[ $success_count -eq 0 ]]; then
print_message "$RED" "✗ 所有自启动方案配置失败"
return 1
else
print_message "$GREEN" "✓ 配置了 $success_count/$total_attempts 种自启动方案"
if [[ $success_count -eq $total_attempts ]]; then
print_message "$GREEN" "完全保障"
elif [[ $success_count -ge 2 ]]; then
print_message "$GREEN" "良好保障"
else
print_message "$YELLOW" "基本保障"
fi
fi
print_message "$CYAN" "优先级: systemd > crontab > profile"
return 0
}
# 查看日志
view_logs() {
if [[ ! -f "$LOG_FILE" ]]; then
print_message "$YELLOW" "日志文件不存在: $LOG_FILE"
return
fi
print_message "$BLUE" "显示最近50行日志:"
echo "----------------------------------------"
tail -n 50 "$LOG_FILE"
echo "----------------------------------------"
print_message "$CYAN" "日志文件位置: $LOG_FILE"
}
# 测试连接
test_connection() {
print_message "$BLUE" "测试连接到监控服务器..."
load_config
if [[ -z "$WORKER_URL" || -z "$SERVER_ID" || -z "$API_KEY" ]]; then
print_message "$RED" "配置不完整,请先配置监控参数"
return 1
fi
print_message "$BLUE" "正在测试配置获取..."
if get_config; then
print_message "$GREEN" "✓ 配置获取测试成功"
else
print_message "$YELLOW" "⚠ 配置获取测试失败,但不影响基本功能"
fi
print_message "$BLUE" "正在测试数据上报..."
if report_metrics; then
print_message "$GREEN" "✓ 数据上报测试成功"
else
print_message "$RED" "✗ 数据上报测试失败,请检查配置和网络"
return 1
fi
print_message "$GREEN" "✓ 连接测试完成"
}
# 配置监控参数
configure_monitor() {
print_message "$BLUE" "配置监控参数"
echo
load_config
# Server ID
echo -n "请输入Server ID"
if [[ -n "$SERVER_ID" ]]; then
echo -n " (当前: $SERVER_ID)"
fi
echo -n ": "
read -r input_server_id
if [[ -n "$input_server_id" ]]; then
SERVER_ID="$input_server_id"
fi
# API Key
echo -n "请输入API Key"
if [[ -n "$API_KEY" ]]; then
echo -n " (当前: ${API_KEY:0:8}...)"
fi
echo -n ": "
read -r input_api_key
if [[ -n "$input_api_key" ]]; then
API_KEY="$input_api_key"
fi
# Worker URL
echo -n "请输入Worker URL"
if [[ -n "$WORKER_URL" ]]; then
echo -n " (当前: $WORKER_URL)"
fi
echo -n ": "
read -r input_url
if [[ -n "$input_url" ]]; then
WORKER_URL="$input_url"
fi
# 设置默认上报间隔为10秒,脚本会自动从服务器获取最新配置
if [[ -z "$INTERVAL" ]]; then
INTERVAL="10"
fi
print_message "$CYAN" "上报间隔设置为: ${INTERVAL}秒 (脚本运行后会自动从服务器获取最新配置)"
# 验证配置
if [[ -z "$WORKER_URL" || -z "$SERVER_ID" || -z "$API_KEY" ]]; then
print_message "$RED" "配置不完整,请确保所有必需参数都已填写"
return 1
fi
# 保存配置
save_config
print_message "$GREEN" "配置保存成功"
# 询问是否测试连接
echo
echo -n "是否测试连接? (y/N): "
read -r test_choice
if [[ "$test_choice" =~ ^[Yy]$ ]]; then
test_connection
fi
}
# 安装监控服务
install_monitor() {
print_message "$BLUE" "开始安装VPS监控服务..."
echo
# 检查系统资源(防止fork错误)
if ! check_system_resources; then
print_message "$YELLOW" "系统资源紧张,启用简化模式"
fi
# 检测系统
detect_system
detect_package_manager
# 安装依赖
install_dependencies
# 创建目录结构
create_directories
# 配置监控参数
if ! configure_monitor; then
error_exit "配置失败,安装中止"
fi
# 创建服务脚本
create_service_script
# 创建systemd服务(如果可用)
local systemd_available=false
if create_systemd_service; then
systemd_available=true
fi
# 配置多重自启动保障
echo
print_message "$BLUE" "配置自启动机制..."
if setup_auto_start; then
print_message "$GREEN" "✓ 自启动机制配置完成"
else
print_message "$YELLOW" "⚠ 自启动配置部分失败,但不影响基本功能"
fi
# 启动服务
echo
if start_service; then
print_message "$GREEN" "✓ VPS监控服务安装并启动成功"
echo
print_message "$CYAN" "安装信息:"
echo " 安装目录: $SCRIPT_DIR"
echo " 配置文件: $CONFIG_FILE"
echo " 日志文件: $LOG_FILE"
echo " 服务脚本: $SERVICE_FILE"
if [[ "$systemd_available" == "true" ]]; then
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
fi
echo " systemd服务: $service_path"
print_message "$GREEN" " 启动方式: systemd服务"
else
print_message "$GREEN" " 启动方式: 传统后台进程"
fi
echo
print_message "$GREEN" "✓ 已配置多重自启动保障,VPS重启后将自动运行"
echo
print_message "$YELLOW" "提示: 使用 '$0 status' 检查服务状态和自启动状态"
print_message "$YELLOW" "提示: 使用 '$0 logs' 查看运行日志"
else
error_exit "服务启动失败"
fi
}
# 集中式彻底卸载监控服务
uninstall_monitor() {
print_message "$YELLOW" "警告: 这将删除VPS监控服务及其数据"
echo -n "确认卸载? (y/N): "
read -r confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
print_message "$BLUE" "取消卸载"
return 0
fi
print_message "$BLUE" "开始集中式卸载VPS监控服务..."
# 1. 停止服务
stop_service
# 2. 保护脚本本身
local script_path=$(realpath "$0")
local need_backup=false
if [[ "$script_path" == "$SCRIPT_DIR"/* ]]; then
need_backup=true
local backup_script="/tmp/cf-vps-monitor-backup.sh"
cp "$script_path" "$backup_script"
chmod +x "$backup_script"
print_message "$CYAN" "已备份脚本到: $backup_script"
fi
# 3. 清理系统集成文件(兼容所有系统)
if [[ -f "$INSTALL_MANIFEST" ]]; then
print_message "$CYAN" "清理系统集成文件..."
while IFS=':' read -r type path action backup; do
case "$type" in
"systemd")
print_message "$CYAN" " 移除systemd服务: $path"
rm -f "$path" 2>/dev/null || true
# 只在有systemctl的系统上重载
if command_exists systemctl; then
if is_root_user; then
systemctl daemon-reload 2>/dev/null || true
else
systemctl --user daemon-reload 2>/dev/null || true
fi
fi
;;
"crontab")
print_message "$CYAN" " 清理crontab条目"
(crontab -l 2>/dev/null || echo "") | grep -v "cf-vps-monitor" | crontab - 2>/dev/null || true
;;
"profile")
print_message "$CYAN" " 清理profile修改: $path"
# 兼容FreeBSD的sed语法
if [[ "$OS" == "FreeBSD" ]] || [[ "$OS" == "Darwin" ]]; then
sed -i '' '/# === cf-vps-monitor auto-start BEGIN ===/,/# === cf-vps-monitor auto-start END ===/d' "$path" 2>/dev/null || true
else
sed -i '/# === cf-vps-monitor auto-start BEGIN ===/,/# === cf-vps-monitor auto-start END ===/d' "$path" 2>/dev/null || true
fi
;;
esac
done < "$INSTALL_MANIFEST" 2>/dev/null || true
fi
# 4. 强制删除安装目录(多重保障)
print_message "$BLUE" "删除安装目录: $SCRIPT_DIR"
# 确保不在目标目录内执行删除
cd / 2>/dev/null || cd "$HOME" 2>/dev/null || true
# 尝试删除,如果失败提供详细信息
if ! rm -rf "$SCRIPT_DIR" 2>/dev/null; then
print_message "$YELLOW" "标准删除失败,尝试强制删除..."
# 尝试逐个删除文件
if [[ -d "$SCRIPT_DIR" ]]; then
find "$SCRIPT_DIR" -type f -exec rm -f {} \; 2>/dev/null || true
find "$SCRIPT_DIR" -type d -exec rmdir {} \; 2>/dev/null || true
# 最后尝试删除主目录
rmdir "$SCRIPT_DIR" 2>/dev/null || rm -rf "$SCRIPT_DIR" 2>/dev/null || true
fi
fi
# 5. 验证删除结果并提供反馈
if [[ -d "$SCRIPT_DIR" ]]; then
print_message "$YELLOW" "⚠ 安装目录仍然存在: $SCRIPT_DIR"
print_message "$CYAN" "可能原因: 文件被占用或权限不足"
print_message "$CYAN" "手动删除: rm -rf '$SCRIPT_DIR'"
# 显示目录内容帮助诊断
if [[ -r "$SCRIPT_DIR" ]]; then
print_message "$CYAN" "目录内容:"
ls -la "$SCRIPT_DIR" 2>/dev/null || true
fi
else
print_message "$GREEN" "✓ VPS监控服务已彻底卸载"
if [[ "$need_backup" == "true" ]]; then
print_message "$YELLOW" "注意: 当前脚本已被删除,但备份在: $backup_script"
else
print_message "$CYAN" "当前脚本未被删除,可以重新安装"
fi
fi
}
# 显示帮助信息
show_help() {
echo "VPS监控脚本 v2.0"
echo
echo "用法: $0 [选项] [参数]"
echo
echo "基本选项:"
echo " install 安装监控服务"
echo " uninstall 彻底卸载监控服务"
echo " start 启动监控服务"
echo " stop 停止监控服务"
echo " restart 重启监控服务"
echo " status 查看服务状态"
echo " logs 查看运行日志"
echo " config 配置监控参数"
echo " test 测试连接"
echo " menu 显示交互菜单"
echo " help 显示此帮助信息"
echo
echo "一键安装参数:"
echo " -i, --install 一键安装模式"
echo " -s, --server-id ID 服务器ID"
echo " -k, --api-key KEY API密钥"
echo " -u, --worker-url URL Worker地址"
echo
echo "示例:"
echo " $0 install # 交互式安装"
echo " $0 status # 查看服务状态"
echo " $0 logs # 查看日志"
echo
echo "一键安装示例:"
echo " $0 -i -s server123 -k abc123 -u https://worker.example.com"
echo
echo "注意: 上报间隔会自动从服务器获取,无需手动设置"
}
# 显示交互菜单
show_menu() {
while true; do
clear
print_message "$CYAN" "=================================="
print_message "$CYAN" " VPS监控服务管理菜单"
print_message "$CYAN" "=================================="
echo
echo "1. 安装监控服务"
echo "2. 启动监控服务"
echo
echo "3. 停止监控服务"
echo "4. 重启监控服务"
echo
echo "5. 查看服务状态"
echo "6. 查看运行日志"
echo
echo "7. 配置监控参数"
echo "8. 测试连接"
echo
print_message "$CYAN" "特殊操作:"
echo "9. 彻底卸载服务"
echo "0. 退出"
echo
print_message "$YELLOW" "请选择操作 (0-9): "
read -r choice
case $choice in
1)
echo
install_monitor
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
2)
echo
start_service
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
3)
echo
stop_service
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
4)
echo
stop_service
sleep 1
start_service
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
5)
echo
check_service_status
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
6)
echo
view_logs
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
7)
echo
configure_monitor
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
8)
echo
test_connection
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
9)
echo
uninstall_monitor
echo
print_message "$BLUE" "按任意键继续..."
read -r
;;
0)
print_message "$GREEN" "感谢使用VPS监控服务!"
exit 0
;;
*)
print_message "$RED" "无效选择,请重新输入"
sleep 1
;;
esac
done
}
# 解析命令行参数
parse_arguments() {
local install_mode=false
local server_id=""
local api_key=""
local worker_url=""
while [[ $# -gt 0 ]]; do
case $1 in
-i|--install)
install_mode=true
shift
;;
-s|--server-id)
server_id="$2"
shift 2
;;
-k|--api-key)
api_key="$2"
shift 2
;;
-u|--worker-url)
worker_url="$2"
shift 2
;;
-h|--help)
show_help
exit 0
;;
*)
# 如果是基本命令,返回处理
return 1
;;
esac
done
# 如果是一键安装模式
if [[ "$install_mode" == "true" ]]; then
one_click_install "$server_id" "$api_key" "$worker_url"
exit $?
fi
return 1
}
# 一键安装函数
one_click_install() {
local server_id="$1"
local api_key="$2"
local worker_url="$3"
print_message "$BLUE" "开始一键安装VPS监控服务..."
echo
# 验证必需参数
if [[ -z "$server_id" || -z "$api_key" || -z "$worker_url" ]]; then
print_message "$RED" "错误: 缺少必需参数"
echo "必需参数: -s <服务器ID> -k <API密钥> -u <Worker地址>"
echo "使用 '$0 --help' 查看详细帮助"
return 1
fi
# 设置默认间隔为10秒(会自动从服务器获取最新配置)
local interval="10"
print_message "$CYAN" "安装参数:"
echo " 服务器ID: $server_id"
echo " API密钥: ${api_key:0:8}..."
echo " Worker地址: $worker_url"
echo " 初始上报间隔: ${interval}秒 (运行后会自动从服务器获取最新配置)"
echo
# 检测系统
detect_system
detect_package_manager
# 安装依赖
install_dependencies
# 创建目录结构
create_directories
# 设置配置参数
WORKER_URL="$worker_url"
SERVER_ID="$server_id"
API_KEY="$api_key"
INTERVAL="$interval"
# 保存配置
save_config
print_message "$GREEN" "配置保存成功"
# 测试连接
print_message "$BLUE" "测试连接..."
if ! report_metrics; then
print_message "$YELLOW" "警告: 连接测试失败,但将继续安装"
print_message "$YELLOW" "请检查网络连接和配置参数"
else
print_message "$GREEN" "✓ 连接测试成功"
fi
# 创建服务脚本
create_service_script
# 创建systemd服务(如果可用)
local systemd_available=false
if create_systemd_service; then
systemd_available=true
fi
# 配置多重自启动保障
echo
print_message "$BLUE" "配置自启动机制..."
if setup_auto_start; then
print_message "$GREEN" "✓ 自启动机制配置完成"
else
print_message "$YELLOW" "⚠ 自启动配置部分失败,但不影响基本功能"
fi
# 启动服务
echo
if start_service; then
print_message "$GREEN" "✓ VPS监控服务一键安装成功"
echo
print_message "$CYAN" "安装信息:"
echo " 安装目录: $SCRIPT_DIR"
echo " 配置文件: $CONFIG_FILE"
echo " 日志文件: $LOG_FILE"
echo " 服务脚本: $SERVICE_FILE"
if [[ "$systemd_available" == "true" ]]; then
local service_path
if is_root_user; then
service_path="/etc/systemd/system/cf-vps-monitor.service"
else
service_path="$HOME/.config/systemd/user/cf-vps-monitor.service"
fi
echo " systemd服务: $service_path"
print_message "$GREEN" " 启动方式: systemd服务"
else
print_message "$GREEN" " 启动方式: 传统后台进程"
fi
echo
print_message "$GREEN" "✓ 已配置多重自启动保障,VPS重启后将自动运行"
echo
print_message "$YELLOW" "提示: 使用 '$0 status' 检查服务状态和自启动状态"
print_message "$YELLOW" "提示: 使用 '$0 logs' 查看运行日志"
return 0
else
print_message "$RED" "✗ 服务启动失败"
return 1
fi
}
# 主函数
main() {
# 首先尝试解析命令行参数
if parse_arguments "$@"; then
return
fi
# 如果没有参数,显示菜单
if [[ $# -eq 0 ]]; then
show_menu
return
fi
# 处理命令行参数
case "$1" in
install)
install_monitor
;;
uninstall)
uninstall_monitor
;;
start)
start_service
;;
stop)
stop_service
;;
restart)
stop_service
sleep 1
start_service
;;
status)
check_service_status
;;
logs)
view_logs
;;
config)
configure_monitor
;;
test)
test_connection
;;
menu)
show_menu
;;
help|--help|-h)
show_help
;;
*)
print_message "$RED" "未知选项: $1"
echo
show_help
exit 1
;;
esac
}
# 函数加载模式支持(用于服务脚本)
if [[ "${FUNCTIONS_ONLY:-false}" == "true" ]]; then
# 只加载函数,不执行主程序
return 0 2>/dev/null || exit 0
fi
# 脚本入口点
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
