Files
project-panel/app.py

3082 lines
141 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
项目服务管理面板 v2.2.0
端口: 19013
新增: Web服务卡片添加自定义链接入口+按钮)
新增: 自定义新增外部web服务功能新增服务按钮
"""
import os
import json
import subprocess
import signal
import threading
import sys
import sqlite3
import shutil
import re
from datetime import datetime
from flask import Flask, render_template_string, jsonify, request, g
import urllib.request
import urllib.error
# 系统资源监控
try:
import psutil
import time
HAS_PSUTIL = True
# 网速计算用的上次数据
_last_net_stats = {'bytes_sent': 0, 'bytes_recv': 0, 'time': 0}
except ImportError:
HAS_PSUTIL = False
app = Flask(__name__)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WORKSPACE_DIR = os.path.dirname(BASE_DIR) # works目录
ROOT_DIR = os.path.dirname(WORKSPACE_DIR) # workspace-coder目录
PROJECTS_FILE = os.path.join(BASE_DIR, 'projects.json')
LOG_FILE = os.path.join(BASE_DIR, 'logs', 'app.log')
DB_FILE = os.path.join(BASE_DIR, 'cron_manager.db')
CRON_BACKUP_DIR = os.path.join(BASE_DIR, 'cron_backups')
# 进程状态缓存
process_cache = {}
def get_db():
"""获取数据库连接"""
if 'db' not in g:
g.db = sqlite3.connect(DB_FILE)
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(error):
"""关闭数据库连接"""
if hasattr(g, 'db'):
g.db.close()
def init_db():
"""初始化数据库"""
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
c = conn.cursor()
# Cron 任务表
c.execute('''CREATE TABLE IF NOT EXISTS cron_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
expression TEXT NOT NULL,
command TEXT NOT NULL,
description TEXT,
category TEXT DEFAULT 'other',
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)''')
# Cron 历史版本表
c.execute('''CREATE TABLE IF NOT EXISTS cron_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
version INTEGER NOT NULL,
name TEXT,
expression TEXT,
command TEXT,
description TEXT,
category TEXT,
enabled INTEGER,
operation TEXT,
operator TEXT DEFAULT 'system',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES cron_tasks(id)
)''')
# Cron 执行日志表
c.execute('''CREATE TABLE IF NOT EXISTS cron_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER,
execution_time TEXT DEFAULT CURRENT_TIMESTAMP,
status TEXT,
output TEXT,
FOREIGN KEY (task_id) REFERENCES cron_tasks(id)
)''')
# 创建索引
c.execute('CREATE INDEX IF NOT EXISTS idx_cron_history_task ON cron_history(task_id)')
c.execute('CREATE INDEX IF NOT EXISTS idx_cron_logs_task ON cron_logs(task_id)')
conn.commit()
conn.close()
def log_message(msg):
"""写入日志"""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
log_line = f"[{timestamp}] {msg}\n"
print(log_line.strip())
try:
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
with open(LOG_FILE, 'a') as f:
f.write(log_line)
except:
pass
def signal_handler(signum, frame):
"""处理信号"""
signal_names = {
signal.SIGTERM: 'SIGTERM',
signal.SIGINT: 'SIGINT',
signal.SIGHUP: 'SIGHUP',
}
sig_name = signal_names.get(signum, f'SIGNAL_{signum}')
log_message(f"⚠️ 进程收到 {sig_name} 信号,即将退出!")
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
try:
signal.signal(signal.SIGHUP, signal_handler)
except:
pass
def load_projects():
"""加载项目配置"""
with open(PROJECTS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)['projects']
def save_projects(projects):
"""保存项目配置"""
with open(PROJECTS_FILE, 'w', encoding='utf-8') as f:
json.dump({'projects': projects}, f, ensure_ascii=False, indent=2)
def check_port(port):
"""检查端口是否有进程监听"""
try:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(('localhost', port))
sock.close()
return result == 0
except:
return False
def check_health(url):
"""检查健康检查URL"""
try:
req = urllib.request.Request(url, method='GET')
with urllib.request.urlopen(req, timeout=3) as response:
return response.status == 200
except:
return False
def get_process_on_port(port):
"""获取占用端口的进程信息"""
try:
result = subprocess.run(
f"lsof -i :{port} -t 2>/dev/null | head -1",
shell=True, capture_output=True, text=True, timeout=5
)
pid = result.stdout.strip()
if pid:
cmd_result = subprocess.run(
f"ps -p {pid} -o pid,ppid,user,cmd --no-headers 2>/dev/null",
shell=True, capture_output=True, text=True
)
return {'pid': int(pid), 'info': cmd_result.stdout.strip()}
except:
pass
return None
def get_project_status(project):
"""获取项目状态"""
project_type = project.get('type', 'web')
if project_type == 'web':
ports = project.get('ports', [])
if not ports:
return {'status': 'unknown', 'message': '未配置端口'}
port_status = {}
all_running = True
for port in ports:
running = check_port(port)
port_status[port] = {'running': running, 'process': get_process_on_port(port) if running else None}
if not running:
all_running = False
health_ok = None
if all_running and project.get('health_url'):
health_ok = check_health(project['health_url'])
return {
'status': 'running' if all_running else ('partial' if any(p['running'] for p in port_status.values()) else 'stopped'),
'ports': port_status,
'health': health_ok
}
elif project_type == 'cron':
cron_expr = project.get('cron', '')
cron_cmd = project.get('cron_cmd', '')
try:
result = subprocess.run(f"crontab -l 2>/dev/null | grep -F '{cron_cmd}'", shell=True, capture_output=True, text=True)
cron_configured = bool(result.stdout.strip())
except:
cron_configured = False
return {'status': 'configured' if cron_configured else 'not_configured', 'cron': cron_expr, 'cron_configured': cron_configured}
elif project_type == 'extension':
return {'status': 'completed', 'message': '浏览器插件,手动安装'}
elif project_type == 'cli':
return {'status': 'ready', 'message': '命令行工具,按需运行'}
return {'status': 'unknown'}
def run_command(cmd, cwd, project_id, action):
"""异步执行命令"""
def _run():
try:
log_file = os.path.join(BASE_DIR, 'logs', f'{project_id}.log')
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, 'a') as f:
f.write(f"\n{'='*50}\n[{datetime.now().isoformat()}] {action}\nCommand: {cmd}\nDirectory: {cwd}\n")
process = subprocess.Popen(cmd, shell=True, cwd=cwd, stdout=open(log_file, 'a'), stderr=subprocess.STDOUT, start_new_session=True)
process_cache[project_id] = process.pid
except Exception as e:
with open(os.path.join(BASE_DIR, 'logs', 'error.log'), 'a') as f:
f.write(f"[{datetime.now().isoformat()}] Error: {e}\n")
thread = threading.Thread(target=_run)
thread.daemon = True
thread.start()
def stop_process(port):
"""停止占用端口的进程"""
try:
result = subprocess.run(f"lsof -i :{port} -t 2>/dev/null", shell=True, capture_output=True, text=True, timeout=5)
pids = [p for p in result.stdout.strip().split('\n') if p]
for pid in pids:
try:
os.kill(int(pid), signal.SIGTERM)
except:
pass
return len(pids)
except:
return 0
# ==================== Cron 管理 API ====================
def backup_crontab():
"""备份当前 crontab"""
os.makedirs(CRON_BACKUP_DIR, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_file = os.path.join(CRON_BACKUP_DIR, f'crontab_{timestamp}.txt')
try:
result = subprocess.run("crontab -l 2>/dev/null", shell=True, capture_output=True, text=True)
with open(backup_file, 'w') as f:
f.write(result.stdout)
return backup_file
except:
return None
def sync_crontab_from_db():
"""从数据库同步到系统 crontab"""
db = get_db()
c = db.cursor()
c.execute("SELECT expression, command, enabled FROM cron_tasks WHERE enabled = 1")
tasks = c.fetchall()
lines = []
for task in tasks:
lines.append(f"{task['expression']} {task['command']}")
# 写入临时文件
tmp_file = '/tmp/crontab_sync.tmp'
with open(tmp_file, 'w') as f:
f.write('\n'.join(lines) + '\n')
# 安装 crontab
subprocess.run(f"crontab {tmp_file}", shell=True)
os.remove(tmp_file)
# 备份
backup_crontab()
def get_system_crons():
"""获取系统所有 cron 任务"""
crons = []
try:
result = subprocess.run("crontab -l 2>/dev/null", shell=True, capture_output=True, text=True, timeout=5)
for line in result.stdout.strip().split('\n'):
line = line.strip()
if line and not line.startswith('#'):
parts = line.split()
if len(parts) >= 6:
crons.append({'source': 'user', 'expression': ' '.join(parts[:5]), 'command': ' '.join(parts[5:]), 'line': line})
except:
pass
try:
if os.path.exists('/etc/crontab'):
with open('/etc/crontab', 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
parts = line.split()
if len(parts) >= 7:
crons.append({'source': 'system', 'expression': ' '.join(parts[:5]), 'user': parts[5], 'command': ' '.join(parts[6:]), 'line': line})
except:
pass
try:
if os.path.exists('/etc/cron.d'):
for filename in os.listdir('/etc/cron.d'):
filepath = os.path.join('/etc/cron.d', filename)
if os.path.isfile(filepath):
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
parts = line.split()
if len(parts) >= 7:
crons.append({'source': 'cron.d', 'file': filename, 'expression': ' '.join(parts[:5]), 'user': parts[5], 'command': ' '.join(parts[6:]), 'line': line})
except:
pass
return crons
def parse_cron_expression(expr):
"""解析 cron 表达式为人类可读格式"""
parts = expr.split()
if len(parts) != 5:
return expr
minute, hour, day, month, weekday = parts
desc = []
if minute == '*':
desc.append('每分钟')
elif minute.startswith('*/'):
desc.append(f'{minute[2:]}分钟')
else:
desc.append(f'{minute}')
if hour != '*':
if minute == '*':
desc = [f'{hour}点每分钟']
else:
desc = [f'{hour}{minute}']
if day != '*':
desc.append(f'{day}')
if month != '*':
desc.append(f'{month}')
if weekday != '*':
weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
try:
desc.append(weekdays[int(weekday)])
except:
desc.append(f'{weekday}')
return ' '.join(desc) if desc else expr
def cron_expression_to_human(expr):
"""更友好的人类可读格式"""
parts = expr.split()
if len(parts) != 5:
return expr
minute, hour, day, month, weekday = parts
# 特殊模式
patterns = [
('0 4 * * *', '每天凌晨4点'),
('0 0 * * *', '每天午夜0点'),
('*/5 * * * *', '每5分钟'),
('*/10 * * * *', '每10分钟'),
('*/20 * * * *', '每20分钟'),
('*/30 * * * *', '每30分钟'),
('0 */1 * * *', '每小时'),
('0 */2 * * *', '每2小时'),
('0 9 * * 1', '每周一9点'),
('0 9 * * 1-5', '每周一到五9点'),
]
for pattern, desc in patterns:
if expr == pattern:
return desc
# 通用解析
return parse_cron_expression(expr)
@app.route('/api/cron/tasks')
def api_cron_tasks():
"""获取数据库中的 Cron 任务列表"""
db = get_db()
c = db.cursor()
c.execute("SELECT * FROM cron_tasks ORDER BY created_at DESC")
tasks = [dict(row) for row in c.fetchall()]
# 添加人类可读描述和下次执行时间
for task in tasks:
task['human_time'] = cron_expression_to_human(task['expression'])
task['next_run'] = get_next_run_time(task['expression'])
return jsonify({'tasks': tasks})
@app.route('/api/cron/tasks', methods=['POST'])
def api_cron_add():
"""添加新的 Cron 任务"""
data = request.json
if not data.get('name') or not data.get('expression') or not data.get('command'):
return jsonify({'error': '缺少必要字段'}), 400
db = get_db()
c = db.cursor()
# 插入任务
c.execute('''INSERT INTO cron_tasks (name, expression, command, description, category, enabled)
VALUES (?, ?, ?, ?, ?, ?)''',
(data['name'], data['expression'], data['command'], data.get('description', ''), data.get('category', 'other'), 1))
task_id = c.lastrowid
db.commit()
# 记录历史版本1
c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation)
VALUES (?, 1, ?, ?, ?, ?, ?, ?, 'create')''',
(task_id, data['name'], data['expression'], data['command'], data.get('description', ''), data.get('category', 'other'), 1))
db.commit()
# 同步到系统 crontab
sync_crontab_from_db()
log_message(f"添加 Cron 任务: {data['name']} ({data['expression']})")
return jsonify({'message': '任务添加成功', 'task_id': task_id})
@app.route('/api/cron/tasks/<int:task_id>', methods=['PUT'])
def api_cron_update(task_id):
"""更新 Cron 任务"""
data = request.json
db = get_db()
c = db.cursor()
# 获取当前任务
c.execute("SELECT * FROM cron_tasks WHERE id = ?", (task_id,))
old_task = c.fetchone()
if not old_task:
return jsonify({'error': '任务不存在'}), 404
# 获取当前版本号
c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,))
max_version = c.fetchone()[0] or 0
new_version = max_version + 1
# 更新任务
update_fields = []
update_values = []
for field in ['name', 'expression', 'command', 'description', 'category', 'enabled']:
if field in data:
update_fields.append(f"{field} = ?")
update_values.append(data[field])
if update_fields:
update_fields.append("updated_at = CURRENT_TIMESTAMP")
c.execute(f"UPDATE cron_tasks SET {','.join(update_fields)} WHERE id = ?", update_values + [task_id])
db.commit()
# 记录历史
c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'update')''',
(task_id, new_version, data.get('name', old_task['name']), data.get('expression', old_task['expression']),
data.get('command', old_task['command']), data.get('description', old_task['description']),
data.get('category', old_task['category']), data.get('enabled', old_task['enabled'])))
db.commit()
# 同步到系统 crontab
sync_crontab_from_db()
log_message(f"更新 Cron 任务 ID={task_id}: 版本 {new_version}")
return jsonify({'message': '任务更新成功', 'version': new_version})
@app.route('/api/cron/tasks/<int:task_id>', methods=['DELETE'])
def api_cron_delete(task_id):
"""删除 Cron 任务"""
db = get_db()
c = db.cursor()
c.execute("SELECT * FROM cron_tasks WHERE id = ?", (task_id,))
task = c.fetchone()
if not task:
return jsonify({'error': '任务不存在'}), 404
# 记录删除历史
c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,))
max_version = c.fetchone()[0] or 0
c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'delete')''',
(task_id, max_version + 1, task['name'], task['expression'], task['command'], task['description'], task['category'], task['enabled']))
db.commit()
# 删除任务
c.execute("DELETE FROM cron_tasks WHERE id = ?", (task_id,))
db.commit()
# 同步到系统 crontab
sync_crontab_from_db()
log_message(f"删除 Cron 任务 ID={task_id}: {task['name']}")
return jsonify({'message': '任务删除成功'})
@app.route('/api/cron/tasks/<int:task_id>/toggle', methods=['POST'])
def api_cron_toggle(task_id):
"""启用/禁用 Cron 任务"""
db = get_db()
c = db.cursor()
c.execute("SELECT enabled FROM cron_tasks WHERE id = ?", (task_id,))
task = c.fetchone()
if not task:
return jsonify({'error': '任务不存在'}), 404
new_enabled = 0 if task['enabled'] == 1 else 1
c.execute("UPDATE cron_tasks SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (new_enabled, task_id))
db.commit()
# 记录历史
c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,))
max_version = c.fetchone()[0] or 0
c.execute('''INSERT INTO cron_history (task_id, version, operation, enabled)
VALUES (?, ?, ?, ?)''', (task_id, max_version + 1, 'toggle', new_enabled))
db.commit()
# 同步到系统 crontab
sync_crontab_from_db()
log_message(f"切换 Cron 任务 ID={task_id} 状态: {new_enabled}")
return jsonify({'message': '状态已切换', 'enabled': new_enabled})
@app.route('/api/cron/tasks/<int:task_id>/history')
def api_cron_history(task_id):
"""获取任务历史版本"""
db = get_db()
c = db.cursor()
c.execute("SELECT * FROM cron_history WHERE task_id = ? ORDER BY version DESC", (task_id,))
history = [dict(row) for row in c.fetchall()]
for h in history:
if h['expression']:
h['human_time'] = cron_expression_to_human(h['expression'])
return jsonify({'history': history})
@app.route('/api/cron/tasks/<int:task_id>/rollback/<int:version>', methods=['POST'])
def api_cron_rollback(task_id, version):
"""回退到指定版本"""
db = get_db()
c = db.cursor()
c.execute("SELECT * FROM cron_history WHERE task_id = ? AND version = ?", (task_id, version))
history = c.fetchone()
if not history:
return jsonify({'error': '版本不存在'}), 404
# 更新任务到历史版本
c.execute('''UPDATE cron_tasks SET name = ?, expression = ?, command = ?, description = ?, category = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?''',
(history['name'], history['expression'], history['command'], history['description'], history['category'], history['enabled'], task_id))
db.commit()
# 记录回退操作
c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,))
max_version = c.fetchone()[0] or 0
c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(task_id, max_version + 1, history['name'], history['expression'], history['command'], history['description'], history['category'], history['enabled'], f'rollback to v{version}'))
db.commit()
# 同步到系统 crontab
sync_crontab_from_db()
log_message(f"回退 Cron 任务 ID={task_id} 到版本 {version}")
return jsonify({'message': f'已回退到版本 {version}'})
@app.route('/api/cron/tasks/<int:task_id>/logs')
def api_cron_logs(task_id):
"""获取任务执行日志"""
enable_logs = request.args.get('enable', 'true') == 'true'
if not enable_logs:
return jsonify({'logs': [], 'message': '日志功能已关闭'})
db = get_db()
c = db.cursor()
c.execute("SELECT * FROM cron_logs WHERE task_id = ? ORDER BY execution_time DESC LIMIT 100", (task_id,))
logs = [dict(row) for row in c.fetchall()]
return jsonify({'logs': logs})
@app.route('/api/cron/categories')
def api_cron_categories():
"""获取任务分类列表"""
categories = [
{'id': 'monitor', 'name': '系统监控', 'icon': 'ri-heart-pulse-line', 'color': 'blue'},
{'id': 'backup', 'name': '数据备份', 'icon': 'ri-archive-line', 'color': 'green'},
{'id': 'cleanup', 'name': '清理任务', 'icon': 'ri-delete-bin-line', 'color': 'yellow'},
{'id': 'report', 'name': '报表生成', 'icon': 'ri-file-chart-line', 'color': 'purple'},
{'id': 'sync', 'name': '数据同步', 'icon': 'ri-refresh-line', 'color': 'cyan'},
{'id': 'notification', 'name': '通知提醒', 'icon': 'ri-notification-line', 'color': 'orange'},
{'id': 'other', 'name': '其他任务', 'icon': 'ri-more-line', 'color': 'gray'},
]
return jsonify({'categories': categories})
@app.route('/api/cron/sync', methods=['POST'])
def api_cron_sync():
"""从系统 crontab 同步到数据库"""
crons = get_system_crons()
user_crons = [c for c in crons if c['source'] == 'user']
db = get_db()
c = db.cursor()
synced_count = 0
for cron in user_crons:
# 检查是否已存在
c.execute("SELECT id FROM cron_tasks WHERE expression = ? AND command = ?", (cron['expression'], cron['command']))
existing = c.fetchone()
if not existing:
# 尝试从命令推断分类和名称
name = guess_cron_name(cron['command'])
category = guess_cron_category(cron['command'])
c.execute('''INSERT INTO cron_tasks (name, expression, command, description, category, enabled)
VALUES (?, ?, ?, ?, ?, 1)''',
(name, cron['expression'], cron['command'], '', category))
synced_count += 1
db.commit()
log_message(f"从系统 crontab 同步了 {synced_count} 个任务")
return jsonify({'message': f'同步完成,新增 {synced_count} 个任务'})
# ==================== 系统资源监控 API ====================
@app.route('/api/system/stats')
def api_system_stats():
"""获取系统资源信息"""
if not HAS_PSUTIL:
return jsonify({'error': 'psutil未安装', 'available': False})
try:
# CPU
cpu_percent = psutil.cpu_percent(interval=0.5)
cpu_count = psutil.cpu_count(logical=False) or psutil.cpu_count()
cpu_count_logical = psutil.cpu_count(logical=True)
# 内存
mem = psutil.virtual_memory()
mem_total_gb = round(mem.total / (1024**3), 2)
mem_used_gb = round(mem.used / (1024**3), 2)
mem_available_gb = round(mem.available / (1024**3), 2)
mem_percent = mem.percent
# 磁盘
disk = psutil.disk_usage('/')
disk_total_gb = round(disk.total / (1024**3), 2)
disk_used_gb = round(disk.used / (1024**3), 2)
disk_free_gb = round(disk.free / (1024**3), 2)
disk_percent = disk.percent
# 网络流量和网速计算
net = psutil.net_io_counters()
current_time = time.time()
# 计算网速 (bytes/s)
time_diff = current_time - _last_net_stats['time'] if _last_net_stats['time'] > 0 else 1
sent_speed = (net.bytes_sent - _last_net_stats['bytes_sent']) / time_diff if time_diff > 0 else 0
recv_speed = (net.bytes_recv - _last_net_stats['bytes_recv']) / time_diff if time_diff > 0 else 0
# 保存当前数据用于下次计算
_last_net_stats['bytes_sent'] = net.bytes_sent
_last_net_stats['bytes_recv'] = net.bytes_recv
_last_net_stats['time'] = current_time
# 转换为 KB/s 或 MB/s
sent_speed_kb = sent_speed / 1024
recv_speed_kb = recv_speed / 1024
sent_speed_display = f"{sent_speed_kb:.1f} KB/s" if sent_speed_kb < 1024 else f"{sent_speed_kb/1024:.2f} MB/s"
recv_speed_display = f"{recv_speed_kb:.1f} KB/s" if recv_speed_kb < 1024 else f"{recv_speed_kb/1024:.2f} MB/s"
net_sent_mb = round(net.bytes_sent / (1024**2), 2)
net_recv_mb = round(net.bytes_recv / (1024**2), 2)
# 系统启动时间
boot_time = datetime.fromtimestamp(psutil.boot_time()).strftime('%Y-%m-%d %H:%M:%S')
uptime_seconds = int(datetime.now().timestamp() - psutil.boot_time())
uptime_hours = uptime_seconds // 3600
uptime_minutes = (uptime_seconds % 3600) // 60
# 进程数
process_count = len(psutil.pids())
return jsonify({
'available': True,
'cpu': {
'percent': cpu_percent,
'count': cpu_count,
'logical': cpu_count_logical
},
'memory': {
'total_gb': mem_total_gb,
'used_gb': mem_used_gb,
'available_gb': mem_available_gb,
'percent': mem_percent
},
'disk': {
'total_gb': disk_total_gb,
'used_gb': disk_used_gb,
'free_gb': disk_free_gb,
'percent': disk_percent
},
'network': {
'sent_mb': net_sent_mb,
'recv_mb': net_recv_mb,
'sent_speed': sent_speed_display,
'recv_speed': recv_speed_display
},
'system': {
'boot_time': boot_time,
'uptime': f'{uptime_hours}小时{uptime_minutes}分钟',
'process_count': process_count
}
})
except Exception as e:
return jsonify({'error': str(e), 'available': False})
def guess_cron_name(command):
"""从命令推断任务名称"""
keywords = {
'service-monitor': '服务监控',
'board_monitor': 'A股板块监控',
'daily-summary': '每日总结',
'backup': '数据备份',
'cleanup': '清理任务',
'xian-favor': 'Xian Favor 备份',
}
for keyword, name in keywords.items():
if keyword in command:
return name
# 从文件名推断
match = re.search(r'/([^/]+\.py)', command)
if match:
return match.group(1).replace('.py', '').replace('_', ' ')
return '未命名任务'
def guess_cron_category(command):
"""从命令推断任务分类"""
if 'monitor' in command or 'check' in command:
return 'monitor'
if 'backup' in command:
return 'backup'
if 'cleanup' in command or 'clean' in command:
return 'cleanup'
if 'report' in command or 'summary' in command:
return 'report'
if 'sync' in command:
return 'sync'
if 'notify' in command or 'mail' in command:
return 'notification'
return 'other'
def get_next_run_time(expression):
"""获取下次执行时间"""
try:
from croniter import croniter
base = datetime.now()
cron = croniter(expression, base)
next_time = cron.get_next(datetime)
return next_time.strftime('%Y-%m-%d %H:%M:%S')
except:
return '无法计算'
# ==================== 邮件通知 API ====================
@app.route('/api/email/send', methods=['POST'])
def api_email_send():
"""发送邮件通知"""
data = request.get_json()
if not data:
return jsonify({'error': '无效数据'}), 400
to_email = data.get('to')
subject = data.get('subject')
body = data.get('body')
if not to_email or not subject or not body:
return jsonify({'error': '缺少必要参数'}), 400
try:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
# SMTP配置 (从环境变量或默认值)
smtp_host = os.environ.get('SMTP_HOST', 'mail.tphai.com')
smtp_port = int(os.environ.get('SMTP_PORT', 587))
smtp_user = os.environ.get('SMTP_USER', 'favor@tphai.com')
smtp_pass = os.environ.get('SMTP_PASS', 'favor@!')
server = smtplib.SMTP(smtp_host, smtp_port)
server.ehlo()
server.login(smtp_user, smtp_pass)
msg = MIMEMultipart()
msg['From'] = smtp_user
msg['To'] = to_email
msg['Subject'] = subject
msg['Date'] = datetime.now().strftime('%a, %d %b %Y %H:%M:%S +0800')
msg['Reply-To'] = to_email
msg.attach(MIMEText(body, 'plain', 'utf-8'))
server.sendmail(smtp_user, to_email, msg.as_string())
server.quit()
return jsonify({'success': True, 'message': '邮件已发送'})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/email/config', methods=['GET', 'POST'])
def api_email_config():
"""获取/设置邮件配置"""
if request.method == 'GET':
return jsonify({
'host': os.environ.get('SMTP_HOST', 'mail.tphai.com'),
'port': int(os.environ.get('SMTP_PORT', 587)),
'user': os.environ.get('SMTP_USER', 'favor@tphai.com')
})
else:
data = request.get_json()
# 只保存到环境变量说明,实际使用需要配置环境变量
return jsonify({'message': '邮件配置需要通过环境变量设置'})
@app.route('/api/crons')
def api_crons():
"""获取系统 cron 列表(兼容旧接口)"""
crons = get_system_crons()
for cron in crons:
cron['description'] = parse_cron_expression(cron['expression'])
return jsonify({'crons': crons})
# ==================== 项目管理 API ====================
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/projects')
def api_projects():
projects = load_projects()
for project in projects:
project['status'] = get_project_status(project)
return jsonify({'projects': projects})
@app.route('/api/projects/<project_id>')
def api_project(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
project['status'] = get_project_status(project)
return jsonify(project)
@app.route('/api/projects/<project_id>/start', methods=['POST'])
def api_start(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
if project['type'] != 'web':
return jsonify({'error': '只有Web服务支持启动'}), 400
directory = project.get('directory', '')
if directory.startswith('/'):
cwd = directory
else:
cwd = os.path.join(ROOT_DIR, directory)
if not os.path.exists(cwd):
return jsonify({'error': f'目录不存在: {cwd}'}), 400
if project.get('start_cmds'):
for name, info in project['start_cmds'].items():
run_command(info['cmd'], cwd, project_id, f'start-{name}')
else:
cmd = project.get('start_cmd', 'python3 app.py')
run_command(cmd, cwd, project_id, 'start')
return jsonify({'message': '服务启动中...'})
@app.route('/api/projects/<project_id>/stop', methods=['POST'])
def api_stop(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
if project['type'] != 'web':
return jsonify({'error': '只有Web服务支持停止'}), 400
ports = project.get('ports', [])
stopped = 0
for port in ports:
stopped += stop_process(port)
return jsonify({'message': f'已停止 {stopped} 个进程'})
@app.route('/api/projects/<project_id>/run', methods=['POST'])
def api_run(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
if project['type'] != 'cli':
return jsonify({'error': '只有CLI工具支持运行'}), 400
directory = project.get('directory', '')
if directory.startswith('/'):
cwd = directory
else:
cwd = os.path.join(ROOT_DIR, directory)
cmd = project.get('run_cmd', '')
if not cmd:
return jsonify({'error': '未配置运行命令'}), 400
run_command(cmd, cwd, project_id, 'run')
return jsonify({'message': 'CLI工具已开始运行...'})
@app.route('/api/projects/<project_id>/log')
def api_log(project_id):
log_file = os.path.join(BASE_DIR, 'logs', f'{project_id}.log')
if os.path.exists(log_file):
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()[-1000:]
return jsonify({'log': ''.join(lines)})
return jsonify({'log': '暂无日志'})
@app.route('/api/projects/add', methods=['POST'])
def api_add_project():
data = request.json
if not data.get('name') or not data.get('type'):
return jsonify({'error': '缺少必要字段'}), 400
projects = load_projects()
project_id = re.sub(r'[^a-z0-9-]', '-', data['name'].lower())
if any(p['id'] == project_id for p in projects):
return jsonify({'error': '项目ID已存在'}), 400
new_project = {
'id': project_id,
'name': data['name'],
'type': data['type'],
'description': data.get('description', ''),
'directory': data.get('directory', ''),
'version': data.get('version', 'v1.0.0'),
'git_repo': data.get('git_repo'),
}
if data['type'] == 'web':
new_project['ports'] = data.get('ports', [])
new_project['start_cmd'] = data.get('start_cmd', 'python3 app.py')
new_project['health_url'] = data.get('health_url')
new_project['admin_url'] = data.get('admin_url')
elif data['type'] == 'cron':
new_project['cron'] = data.get('cron')
new_project['cron_cmd'] = data.get('cron_cmd')
elif data['type'] == 'cli':
new_project['run_cmd'] = data.get('run_cmd')
projects.append(new_project)
save_projects(projects)
return jsonify({'message': '项目添加成功', 'project': new_project})
@app.route('/api/projects/<project_id>', methods=['PUT'])
def api_update_project(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
data = request.json
for key, value in data.items():
if key != 'id':
project[key] = value
save_projects(projects)
return jsonify({'message': '更新成功', 'project': project})
@app.route('/api/projects/<project_id>', methods=['DELETE'])
def api_delete_project(project_id):
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
projects = [p for p in projects if p['id'] != project_id]
save_projects(projects)
return jsonify({'message': '删除成功'})
# ==================== HTML 模板 ====================
HTML_TEMPLATE = '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>项目服务管理面板 v3.1</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
body { background: #0f172a; }
.card { background: #1e293b; border: 1px solid #334155; }
.status-dot { width: 12px; height: 12px; border-radius: 50%; }
.status-running { background: #22c55e; box-shadow: 0 0 10px #22c55e; }
.status-stopped { background: #ef4444; }
.status-partial { background: #f59e0b; }
.status-ready { background: #3b82f6; }
.status-configured { background: #22c55e; }
.status-not-configured { background: #6b7280; }
.status-completed { background: #8b5cf6; }
.btn { transition: all 0.2s; }
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.type-badge { font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; }
.nav-buttons { position: fixed; right: 20px; bottom: 20px; z-index: 100; display: flex; flex-direction: column; gap: 8px; }
.nav-btn { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; background: #334155; border: 1px solid #475569; }
.nav-btn:hover { background: #475569; transform: scale(1.1); }
.nav-btn i { font-size: 20px; color: #94a3b8; }
.nav-btn:hover i { color: #f1f5f9; }
.filter-bar { position: sticky; top: 0; z-index: 50; display: flex; gap: 8px; padding: 12px 0; background: #0f172a; margin-bottom: 16px; flex-wrap: wrap; }
.filter-bar-btn { display: flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; background: #1e293b; border: 1px solid #334155; color: #94a3b8; font-size: 14px; cursor: pointer; transition: all 0.2s; }
.filter-bar-btn:hover { background: #334155; color: #f1f5f9; }
.filter-bar-btn.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
.filter-bar-btn.add-btn { background: #22c55e; border-color: #22c55e; color: #fff; }
.filter-bar-btn.add-btn:hover { background: #16a34a; }
.realtime-toggle { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 8px; background: #1e293b; border: 1px solid #334155; }
.realtime-toggle.active { background: #22c55e/20; border-color: #22c55e; }
.speed-badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 12px; border-radius: 6px; background: #334155; font-size: 13px; }
.speed-badge.upload { color: #f97316; }
.speed-badge.download { color: #3b82f6; }
.threshold-section { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.threshold-item { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid #334155; }
.threshold-item:last-child { border-bottom: none; }
.notification-toggle { display: flex; align-items: center; gap: 8px; padding: 12px; background: #334155; border-radius: 8px; margin-top: 12px; }
.notification-toggle label { cursor: pointer; }
.threshold-label { flex: 1; display: flex; align-items: center; gap: 8px; }
.threshold-input { width: 60px; background: #334155; border: 1px solid #475569; border-radius: 6px; padding: 4px 8px; color: #f1f5f9; text-align: center; }
.threshold-input:focus { outline: none; border-color: #3b82f6; }
.threshold-icon { font-size: 18px; }
.threshold-warning { color: #f97316; }
.alert-popup { position: fixed; top: 20px; right: 20px; z-index: 1000; background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); border-radius: 12px; padding: 16px 20px; color: white; box-shadow: 0 10px 40px rgba(0,0,0,0.3); animation: slideIn 0.3s ease-out; max-width: 320px; }
.alert-popup .alert-title { font-weight: bold; font-size: 16px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
.alert-popup .alert-content { font-size: 14px; }
.alert-popup .alert-close { position: absolute; top: 8px; right: 12px; cursor: pointer; opacity: 0.7; }
.alert-popup .alert-close:hover { opacity: 1; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
.tab-btn { border-bottom: 2px solid transparent; }
.tab-btn.active { border-bottom-color: #3b82f6; }
.cron-card { transition: all 0.2s; }
.cron-card:hover { border-color: #475569; }
.history-item { border-left: 3px solid #334155; padding-left: 12px; }
.history-item.rollback { border-left-color: #f59e0b; }
.history-item.update { border-left-color: #3b82f6; }
.history-item.create { border-left-color: #22c55e; }
.history-item.delete { border-left-color: #ef4444; }
.history-item.toggle { border-left-color: #8b5cf6; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 50; display: flex; align-items: center; justify-content: center; }
.modal-content { max-width: 600px; width: 90%; max-height: 80vh; overflow: auto; }
</style>
</head>
<body class="min-h-screen text-gray-100">
<div class="container mx-auto px-4 py-8">
<!-- 头部 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
<i class="ri-dashboard-3-line text-blue-400"></i>
项目服务管理面板
<span class="text-sm text-gray-500">v2.1</span>
</h1>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-gray-400 text-sm">对外IP:</span>
<input type="text" id="externalIp" value="192.168.2.17" class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-sm w-28" onchange="saveExternalIp()">
</div>
<button onclick="refreshAll()" class="btn bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg flex items-center gap-2">
<i class="ri-refresh-line"></i> 刷新
</button>
<div class="flex items-center gap-1">
<span class="text-gray-400 text-xs">间隔:</span>
<input type="number" id="refreshInterval" value="30" min="5" max="300" class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-xs w-12" onchange="updateRefreshInterval()">
<span class="text-gray-500 text-xs">s</span>
</div>
<span id="updateTime" class="text-gray-400 text-sm"></span>
<div id="connectionStatus" class="flex items-center gap-1 px-2 py-1 rounded bg-green-500/20">
<div class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
<span class="text-green-400 text-xs">已连接</span>
</div>
</div>
</div>
<!-- Tab 切换 -->
<div class="flex gap-4 mb-6 border-b border-gray-700">
<button onclick="switchTab('projects')" class="tab-btn active px-4 py-2 text-gray-300 hover:text-white" data-tab="projects">
<i class="ri-apps-line"></i> 项目服务
</button>
<button onclick="switchTab('system')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="system">
<i class="ri-dashboard-2-line"></i> 系统资源
</button>
<button onclick="switchTab('cron')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="cron">
<i class="ri-timer-line"></i> Cron 管理
</button>
</div>
<!-- 项目服务 Tab -->
<div id="projectsTab">
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">Web服务</p>
<p id="webCount" class="text-2xl font-bold text-blue-400">-</p>
</div>
<i class="ri-server-line text-3xl text-blue-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">运行中</p>
<p id="runningCount" class="text-2xl font-bold text-green-400">-</p>
</div>
<i class="ri-play-circle-line text-3xl text-green-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">Cron任务</p>
<p id="cronCount" class="text-2xl font-bold text-yellow-400">-</p>
</div>
<i class="ri-time-line text-3xl text-yellow-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">CLI工具</p>
<p id="cliCount" class="text-2xl font-bold text-purple-400">-</p>
</div>
<i class="ri-terminal-box-line text-3xl text-purple-400 opacity-50"></i>
</div>
</div>
</div>
<!-- 筛选器(顶部固定) -->
<div class="filter-bar">
<button onclick="filterType('all')" class="filter-bar-btn active" data-type="all"><i class="ri-apps-line"></i> 全部</button>
<button onclick="filterType('web')" class="filter-bar-btn" data-type="web"><i class="ri-server-line"></i> Web服务</button>
<button onclick="filterType('custom')" class="filter-bar-btn" data-type="custom"><i class="ri-global-line"></i> 自定义</button>
<button onclick="filterType('cli')" class="filter-bar-btn" data-type="cli"><i class="ri-terminal-box-line"></i> CLI工具</button>
<button onclick="filterType('extension')" class="filter-bar-btn" data-type="extension"><i class="ri-chrome-line"></i> 插件</button>
<button onclick="showAddServiceModal()" class="filter-bar-btn add-btn"><i class="ri-add-circle-line"></i> 新增服务</button>
</div>
<!-- 项目列表 -->
<div id="projectsList" class="space-y-4">
<div class="text-center py-12 text-gray-400"><i class="ri-loader-4-line text-4xl animate-spin"></i><p class="mt-2">加载中...</p></div>
</div>
</div>
<!-- 系统资源 Tab -->
<div id="systemTab" class="hidden">
<!-- 系统概览 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">系统运行时间</p>
<p id="sysUptime" class="text-lg font-bold text-green-400">-</p>
</div>
<i class="ri-time-line text-3xl text-green-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">启动时间</p>
<p id="sysBootTime" class="text-lg font-bold text-blue-400">-</p>
</div>
<i class="ri-calendar-line text-3xl text-blue-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">进程数</p>
<p id="sysProcessCount" class="text-lg font-bold text-yellow-400">-</p>
</div>
<i class="ri-terminal-line text-3xl text-yellow-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">CPU核心</p>
<p id="sysCpuCount" class="text-lg font-bold text-purple-400">-</p>
</div>
<i class="ri-cpu-line text-3xl text-purple-400 opacity-50"></i>
</div>
</div>
</div>
<!-- CPU & 内存 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2"><i class="ri-cpu-line text-blue-400"></i> CPU 使用率</h3>
<span id="cpuPercent" class="text-2xl font-bold text-blue-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4 mb-2">
<div id="cpuBar" class="bg-blue-500 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-gray-400 text-sm">物理核心: <span id="cpuPhysical">-</span> | 逻辑核心: <span id="cpuLogical">-</span></p>
</div>
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2"><i class="ri-memory-card-line text-green-400"></i> 内存使用</h3>
<span id="memPercent" class="text-2xl font-bold text-green-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4 mb-2">
<div id="memBar" class="bg-green-500 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-gray-400 text-sm">已用: <span id="memUsed">-</span> GB / 总量: <span id="memTotal">-</span> GB | 可用: <span id="memAvailable">-</span> GB</p>
</div>
</div>
<!-- 磁盘 & 网络 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2"><i class="ri-hard-drive-2-line text-orange-400"></i> 磁盘使用</h3>
<span id="diskPercent" class="text-2xl font-bold text-orange-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4 mb-2">
<div id="diskBar" class="bg-orange-500 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-gray-400 text-sm">已用: <span id="diskUsed">-</span> GB / 总量: <span id="diskTotal">-</span> GB | 剩余: <span id="diskFree">-</span> GB</p>
</div>
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2"><i class="ri-wifi-line text-cyan-400"></i> 网络流量</h3>
</div>
<!-- 实时网速 -->
<div class="flex gap-3 mb-4">
<span class="speed-badge upload"><i class="ri-upload-line"></i> ↑ <span id="netSentSpeed">-</span></span>
<span class="speed-badge download"><i class="ri-download-line"></i> ↓ <span id="netRecvSpeed">-</span></span>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-gray-400 text-sm mb-1">累计发送</p>
<p id="netSent" class="text-xl font-bold text-cyan-400">-</p>
<p class="text-gray-500 text-xs">MB</p>
</div>
<div>
<p class="text-gray-400 text-sm mb-1">累计接收</p>
<p id="netRecv" class="text-xl font-bold text-purple-400">-</p>
<p class="text-gray-500 text-xs">MB</p>
</div>
</div>
</div>
</div>
<!-- 阈值监控设置 -->
<div class="threshold-section">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-gray-200 flex items-center gap-2">
<i class="ri-alarm-warning-line text-orange-400"></i> 监控阈值设置
</h3>
<span class="text-xs text-gray-500">超过阈值将弹窗警告</span>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-cpu-line threshold-icon text-blue-400"></i>
<span class="text-gray-300 text-sm">CPU 使用率</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdCpu" class="threshold-input" min="0" max="100" value="80" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">%</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-memory-card-line threshold-icon text-green-400"></i>
<span class="text-gray-300 text-sm">内存使用率</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdMemory" class="threshold-input" min="0" max="100" value="85" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">%</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-hard-drive-2-line threshold-icon text-orange-400"></i>
<span class="text-gray-300 text-sm">磁盘使用率</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdDisk" class="threshold-input" min="0" max="100" value="90" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">%</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-timer-line threshold-icon text-purple-400"></i>
<span class="text-gray-300 text-sm">警告间隔</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdInterval" class="threshold-input" min="10" max="300" value="60" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">秒</span>
</div>
</div>
<!-- 桌面通知开关 -->
<div class="notification-toggle">
<input type="checkbox" id="enableDesktopNotify" onchange="toggleDesktopNotify()" class="w-4 h-4 cursor-pointer">
<label for="enableDesktopNotify" class="text-gray-300 text-sm cursor-pointer">
<i class="ri-notification-line text-yellow-400"></i> 系统桌面通知
</label>
<span id="notifyStatus" class="text-xs text-gray-500 ml-1"></span>
</div>
<!-- 邮件通知规则 -->
<div class="mt-4 pt-4 border-t border-gray-600">
<div class="flex items-center justify-between mb-3">
<h4 class="text-gray-200 text-sm font-medium flex items-center gap-2">
<i class="ri-mail-line text-green-400"></i> 邮件通知规则
</h4>
<button onclick="showAddEmailRuleModal()" class="btn bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-add-line"></i> 添加规则
</button>
</div>
<div id="emailRulesList" class="space-y-2 text-sm">
<div class="text-gray-500 text-center py-4">暂无邮件通知规则</div>
</div>
</div>
</div>
<!-- 控制按钮 -->
<div class="flex gap-3 mb-4 items-center">
<button onclick="loadSystemStats()" class="btn bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg flex items-center gap-2">
<i class="ri-refresh-line"></i> 刷新数据
</button>
<div class="realtime-toggle" id="realtimeToggle">
<input type="checkbox" id="realtimeCheck" onchange="toggleRealtime()" class="w-4 h-4 cursor-pointer">
<label for="realtimeCheck" class="text-gray-300 text-sm cursor-pointer">实时监控</label>
<select id="realtimeIntervalSelect" class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-xs ml-2 cursor-pointer" onchange="updateRealtimeInterval()">
<option value="1">1秒</option>
<option value="2" selected>2秒</option>
<option value="3">3秒</option>
<option value="5">5秒</option>
<option value="10">10秒</option>
</select>
<span id="realtimeIndicator" class="text-xs text-green-400 ml-1 hidden">●</span>
</div>
</div>
</div>
<!-- Cron 管理 Tab -->
<div id="cronTab" class="hidden">
<!-- Cron 统计 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">总任务数</p>
<p id="cronTotal" class="text-2xl font-bold text-orange-400">-</p>
</div>
<i class="ri-timer-line text-3xl text-orange-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">启用</p>
<p id="cronEnabled" class="text-2xl font-bold text-green-400">-</p>
</div>
<i class="ri-play-circle-line text-3xl text-green-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">禁用</p>
<p id="cronDisabled" class="text-2xl font-bold text-red-400">-</p>
</div>
<i class="ri-pause-circle-line text-3xl text-red-400 opacity-50"></i>
</div>
</div>
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">历史记录</p>
<p id="cronHistoryCount" class="text-2xl font-bold text-purple-400">-</p>
</div>
<i class="ri-history-line text-3xl text-purple-400 opacity-50"></i>
</div>
</div>
</div>
<!-- Cron 操作按钮 -->
<div class="flex gap-2 mb-4">
<button onclick="showAddCronModal()" class="btn bg-green-600 hover:bg-green-700 px-3 py-2 rounded-lg flex items-center gap-1">
<i class="ri-add-line"></i> 添加任务
</button>
<button onclick="syncFromSystem()" class="btn bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded-lg flex items-center gap-1">
<i class="ri-refresh-line"></i> 从系统同步
</button>
<button onclick="loadCronTasks()" class="btn bg-gray-600 hover:bg-gray-700 px-3 py-2 rounded-lg flex items-center gap-1">
<i class="ri-reload-line"></i> 刷新列表
</button>
<div class="flex items-center gap-2 ml-4">
<span class="text-gray-400 text-sm">执行日志:</span>
<input type="checkbox" id="enableLogs" onchange="toggleLogs()" class="w-4 h-4">
</div>
</div>
<!-- Cron 任务列表 -->
<div id="cronTasksList" class="space-y-3">
<div class="text-center py-12 text-gray-400"><i class="ri-loader-4-line text-4xl animate-spin"></i><p class="mt-2">加载中...</p></div>
</div>
</div>
</div>
<!-- 日志模态框 -->
<div id="logModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="card rounded-xl w-11/12 max-w-4xl max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<h3 id="logTitle" class="font-bold text-lg">日志</h3>
<button onclick="closeLogModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<div class="flex-1 overflow-auto p-4">
<pre id="logContent" class="text-sm text-gray-300 font-mono whitespace-pre-wrap"></pre>
</div>
</div>
</div>
<!-- 添加/编辑 Cron 模态框 -->
<div id="cronModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="cronModalTitle" class="font-bold text-lg">添加 Cron 任务</h3>
<button onclick="closeCronModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="cronForm" onsubmit="saveCronTask(event)">
<input type="hidden" id="cronTaskId">
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">任务名称 *</label>
<input type="text" id="cronName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:服务监控">
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">时间表达式 *</label>
<input type="text" id="cronExpression" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如0 4 * * * (每天凌晨4点)">
<p id="cronHumanTime" class="text-sm text-blue-400 mt-1"></p>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">执行命令 *</label>
<textarea id="cronCommand" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" rows="3" placeholder="例如python3 /path/to/script.py"></textarea>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">任务分类</label>
<select id="cronCategory" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<option value="monitor">系统监控</option>
<option value="backup">数据备份</option>
<option value="cleanup">清理任务</option>
<option value="report">报表生成</option>
<option value="sync">数据同步</option>
<option value="notification">通知提醒</option>
<option value="other">其他任务</option>
</select>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">说明备注</label>
<textarea id="cronDescription" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" rows="2" placeholder="任务的详细说明..."></textarea>
</div>
<div class="flex items-center gap-2 mb-4">
<input type="checkbox" id="cronEnabled" checked class="w-4 h-4">
<label class="text-gray-300">启用任务</label>
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeCronModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg">保存</button>
</div>
</form>
</div>
</div>
<!-- 历史记录模态框 -->
<div id="historyModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="historyModalTitle" class="font-bold text-lg">历史版本</h3>
<button onclick="closeHistoryModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<div id="historyList" class="space-y-3"></div>
</div>
</div>
<!-- 自定义链接模态框 -->
<div id="linkModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="linkModalTitle" class="font-bold text-lg">添加自定义链接</h3>
<button onclick="closeLinkModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="linkForm" onsubmit="saveCustomLink(event)">
<input type="hidden" id="linkProjectId">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">链接名称 *</label>
<input type="text" id="linkName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如API文档、管理后台">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">端口使用统一IP</label>
<input type="text" id="linkPort" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如19001" onchange="updateLinkPreview()">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">路径(可选)</label>
<input type="text" id="linkPath" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:/admin /api/docs" onchange="updateLinkPreview()">
<p id="linkPreview" class="text-sm text-cyan-400 mt-1"></p>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">完整URL不填则用端口生成</label>
<input type="text" id="linkFullUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://192.168.2.17:19000/docs">
</div>
<div id="existingLinks" class="mb-4"></div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeLinkModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg">添加</button>
</div>
</form>
</div>
</div>
<!-- 新增自定义服务模态框 -->
<div id="addServiceModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg">新增自定义服务</h3>
<button onclick="closeAddServiceModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="addServiceForm" onsubmit="saveCustomService(event)">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">服务名称 *</label>
<input type="text" id="serviceName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如外部API、监控面板">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">端口使用统一IP</label>
<input type="text" id="servicePort" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如8080" onchange="updateServicePreview()">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">路径(可选)</label>
<input type="text" id="servicePath" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:/admin" onchange="updateServicePreview()">
<p id="servicePreview" class="text-sm text-cyan-400 mt-1"></p>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">完整URL不填则用端口生成</label>
<input type="text" id="serviceFullUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://external.com:8080">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">后台URL可选</label>
<input type="text" id="serviceAdminUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://external.com:8080/admin">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">描述</label>
<input type="text" id="serviceDesc" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="简要描述">
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeAddServiceModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg">添加</button>
</div>
</form>
</div>
</div>
<!-- 邮件通知规则模态框 -->
<div id="emailRuleModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="emailRuleModalTitle" class="font-bold text-lg">添加邮件通知规则</h3>
<button onclick="closeEmailRuleModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="emailRuleForm" onsubmit="saveEmailRule(event)">
<input type="hidden" id="emailRuleId">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">规则名称 *</label>
<input type="text" id="emailRuleName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如CPU告警通知">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">监控资源 *</label>
<select id="emailRuleResource" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<option value="cpu">CPU 使用率</option>
<option value="memory">内存使用率</option>
<option value="disk">磁盘使用率</option>
</select>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">触发阈值 *</label>
<div class="flex items-center gap-2">
<input type="number" id="emailRuleThreshold" required min="1" max="100" value="80" class="w-20 bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<span class="text-gray-400">%</span>
</div>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">收件邮箱 *</label>
<input type="email" id="emailRuleAddress" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="alert@example.com">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">通知间隔</label>
<div class="flex items-center gap-2">
<input type="number" id="emailRuleInterval" min="60" max="3600" value="300" class="w-20 bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<span class="text-gray-400">秒最少60秒</span>
</div>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">静默时间区间</label>
<p class="text-gray-500 text-xs mb-2">在此时间段内不发送邮件通知(如夜间休息时段)</p>
<div class="flex items-center gap-2">
<input type="time" id="emailRuleSilentStart" class="bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="23:00">
<span class="text-gray-400">至</span>
<input type="time" id="emailRuleSilentEnd" class="bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="08:00">
<input type="checkbox" id="emailRuleSilentEnabled" class="w-4 h-4 cursor-pointer ml-2">
<label for="emailRuleSilentEnabled" class="text-gray-300 text-xs cursor-pointer">启用静默</label>
</div>
</div>
<div class="mb-3 flex items-center gap-2">
<input type="checkbox" id="emailRuleEnabled" checked class="w-4 h-4 cursor-pointer">
<label for="emailRuleEnabled" class="text-gray-300 text-sm cursor-pointer">启用此规则</label>
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeEmailRuleModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg">保存</button>
</div>
</form>
</div>
</div>
<!-- 导航按钮 -->
<div class="nav-buttons">
<button onclick="scrollToTop()" class="nav-btn" title="回到顶部"><i class="ri-arrow-up-line"></i></button>
<button onclick="scrollToBottom()" class="nav-btn" title="滚动到底部"><i class="ri-arrow-down-line"></i></button>
</div>
<script>
let projects = [];
let cronTasks = [];
let currentTab = 'projects';
let currentFilter = 'all';
let externalIp = localStorage.getItem('externalIp') || '192.168.2.17';
let enableLogs = localStorage.getItem('enableLogs') === 'true';
document.getElementById('externalIp').value = externalIp;
document.getElementById('enableLogs').checked = enableLogs;
// Tab 切换
// 系统资源监控变量(必须先声明)
let realtimeTimer = null;
let realtimeInterval = 2; // 秒
let lastAlertTime = 0; // 上次警告时间
let thresholds = { cpu: 80, memory: 85, disk: 90, interval: 60 };
let desktopNotifyEnabled = false;
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.getElementById('projectsTab').classList.toggle('hidden', tab !== 'projects');
document.getElementById('systemTab').classList.toggle('hidden', tab !== 'system');
document.getElementById('cronTab').classList.toggle('hidden', tab !== 'cron');
// 切换Tab时关闭实时监控
if (tab !== 'system' && realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = null;
document.getElementById('realtimeCheck').checked = false;
document.getElementById('realtimeToggle').classList.remove('active');
document.getElementById('realtimeIndicator').classList.add('hidden');
}
if (tab === 'cron') {
loadCronTasks();
}
if (tab === 'system') {
loadThresholds();
loadDesktopNotifySetting();
renderEmailRules();
loadSystemStats();
}
}
// 加载阈值设置
function loadThresholds() {
const saved = localStorage.getItem('systemThresholds');
if (saved) {
thresholds = JSON.parse(saved);
document.getElementById('thresholdCpu').value = thresholds.cpu || 80;
document.getElementById('thresholdMemory').value = thresholds.memory || 85;
document.getElementById('thresholdDisk').value = thresholds.disk || 90;
document.getElementById('thresholdInterval').value = thresholds.interval || 60;
}
}
// 保存阈值设置
function saveThresholds() {
thresholds.cpu = parseInt(document.getElementById('thresholdCpu').value) || 80;
thresholds.memory = parseInt(document.getElementById('thresholdMemory').value) || 85;
thresholds.disk = parseInt(document.getElementById('thresholdDisk').value) || 90;
thresholds.interval = parseInt(document.getElementById('thresholdInterval').value) || 60;
localStorage.setItem('systemThresholds', JSON.stringify(thresholds));
}
// 桌面通知开关(已在外部声明 desktopNotifyEnabled
function toggleDesktopNotify() {
const checked = document.getElementById('enableDesktopNotify').checked;
const status = document.getElementById('notifyStatus');
if (checked) {
// 检查浏览器支持
if (!('Notification' in window)) {
status.textContent = '不支持';
status.className = 'text-xs text-red-500 ml-1';
document.getElementById('enableDesktopNotify').checked = false;
return;
}
// 检查权限状态
if (Notification.permission === 'granted') {
desktopNotifyEnabled = true;
status.textContent = '已启用';
status.className = 'text-xs text-green-400 ml-1';
localStorage.setItem('desktopNotify', 'true');
} else if (Notification.permission === 'denied') {
status.textContent = '已拒绝';
status.className = 'text-xs text-red-500 ml-1';
document.getElementById('enableDesktopNotify').checked = false;
} else {
// 请求权限
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
desktopNotifyEnabled = true;
status.textContent = '已启用';
status.className = 'text-xs text-green-400 ml-1';
localStorage.setItem('desktopNotify', 'true');
} else {
status.textContent = '已拒绝';
status.className = 'text-xs text-red-500 ml-1';
document.getElementById('enableDesktopNotify').checked = false;
}
});
}
} else {
desktopNotifyEnabled = false;
status.textContent = '';
localStorage.setItem('desktopNotify', 'false');
}
}
// 加载桌面通知设置
function loadDesktopNotifySetting() {
const saved = localStorage.getItem('desktopNotify');
if (saved === 'true' && Notification.permission === 'granted') {
desktopNotifyEnabled = true;
document.getElementById('enableDesktopNotify').checked = true;
document.getElementById('notifyStatus').textContent = '已启用';
document.getElementById('notifyStatus').className = 'text-xs text-green-400 ml-1';
}
}
// 发送桌面通知
function sendDesktopNotification(warnings) {
if (!desktopNotifyEnabled || Notification.permission !== 'granted') return;
const notification = new Notification('⚠️ 系统资源告警', {
body: warnings.join('\\n'),
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">📊</text></svg>',
tag: 'system-alert',
requireInteraction: false
});
// 点击后聚焦窗口
notification.onclick = () => {
window.focus();
notification.close();
};
// 5秒后自动关闭
setTimeout(() => notification.close(), 5000);
}
// 检查阈值并弹窗警告
function checkThresholds(data) {
const now = Date.now();
const intervalMs = thresholds.interval * 1000;
// 检查是否超过间隔时间
if (now - lastAlertTime < intervalMs) return;
const warnings = [];
if (data.cpu.percent >= thresholds.cpu) {
warnings.push(`CPU使用率 ${data.cpu.percent.toFixed(1)}% 超过阈值 ${thresholds.cpu}%`);
}
if (data.memory.percent >= thresholds.memory) {
warnings.push(`内存使用率 ${data.memory.percent.toFixed(1)}% 超过阈值 ${thresholds.memory}%`);
}
if (data.disk.percent >= thresholds.disk) {
warnings.push(`磁盘使用率 ${data.disk.percent.toFixed(1)}% 超过阈值 ${thresholds.disk}%`);
}
if (warnings.length > 0) {
lastAlertTime = now;
showAlertPopup(warnings);
}
// 检查邮件通知规则
checkEmailRules(data);
}
// 显示警告弹窗(同时发送桌面通知)
function showAlertPopup(warnings) {
// 发送桌面通知
sendDesktopNotification(warnings);
// 移除已有弹窗
const existing = document.querySelector('.alert-popup');
if (existing) existing.remove();
const popup = document.createElement('div');
popup.className = 'alert-popup';
popup.innerHTML = `
<span class="alert-close" onclick="this.parentElement.remove()"><i class="ri-close-line"></i></span>
<div class="alert-title"><i class="ri-alarm-warning-line"></i> 资源告警</div>
<div class="alert-content">${warnings.map(w => `<p>⚠️ ${w}</p>`).join('')}</div>
`;
document.body.appendChild(popup);
// 自动关闭10秒后
setTimeout(() => {
popup.style.animation = 'slideOut 0.3s ease-out forwards';
setTimeout(() => popup.remove(), 300);
}, 10000);
}
function updateRealtimeInterval() {
realtimeInterval = parseInt(document.getElementById('realtimeIntervalSelect').value) || 2;
// 如果正在监控,重新启动定时器
if (realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = setInterval(loadSystemStats, realtimeInterval * 1000);
}
}
async function loadSystemStats() {
try {
const res = await fetch('/api/system/stats');
const data = await res.json();
if (!data.available) {
document.getElementById('sysUptime').textContent = 'psutil未安装';
document.getElementById('sysBootTime').textContent = '-';
return;
}
// 系统概览
document.getElementById('sysUptime').textContent = data.system.uptime;
document.getElementById('sysBootTime').textContent = data.system.boot_time;
document.getElementById('sysProcessCount').textContent = data.system.process_count;
document.getElementById('sysCpuCount').textContent = data.cpu.logical + '';
// CPU
document.getElementById('cpuPercent').textContent = data.cpu.percent.toFixed(1) + '%';
document.getElementById('cpuBar').style.width = data.cpu.percent + '%';
document.getElementById('cpuPhysical').textContent = data.cpu.count;
document.getElementById('cpuLogical').textContent = data.cpu.logical;
// 内存
document.getElementById('memPercent').textContent = data.memory.percent.toFixed(1) + '%';
document.getElementById('memBar').style.width = data.memory.percent + '%';
document.getElementById('memUsed').textContent = data.memory.used_gb;
document.getElementById('memTotal').textContent = data.memory.total_gb;
document.getElementById('memAvailable').textContent = data.memory.available_gb;
// 磁盘
document.getElementById('diskPercent').textContent = data.disk.percent.toFixed(1) + '%';
document.getElementById('diskBar').style.width = data.disk.percent + '%';
document.getElementById('diskUsed').textContent = data.disk.used_gb;
document.getElementById('diskTotal').textContent = data.disk.total_gb;
document.getElementById('diskFree').textContent = data.disk.free_gb;
// 网络 - 累计流量
document.getElementById('netSent').textContent = data.network.sent_mb;
document.getElementById('netRecv').textContent = data.network.recv_mb;
// 实时网速
document.getElementById('netSentSpeed').textContent = data.network.sent_speed || '-';
document.getElementById('netRecvSpeed').textContent = data.network.recv_speed || '-';
// 检查阈值
checkThresholds(data);
} catch (e) {
console.error('系统资源加载失败:', e);
}
}
function toggleRealtime() {
const checked = document.getElementById('realtimeCheck').checked;
const toggle = document.getElementById('realtimeToggle');
const indicator = document.getElementById('realtimeIndicator');
if (checked) {
toggle.classList.add('active');
indicator.classList.remove('hidden');
// 立即加载一次
loadSystemStats();
// 启动定时器
realtimeTimer = setInterval(loadSystemStats, realtimeInterval * 1000);
} else {
toggle.classList.remove('active');
indicator.classList.add('hidden');
// 停止定时器
if (realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = null;
}
}
}
// IP 保存
function saveExternalIp() {
externalIp = document.getElementById('externalIp').value.trim();
localStorage.setItem('externalIp', externalIp);
renderProjects();
}
// 项目加载和渲染(保持原有逻辑)
async function loadProjects() {
try {
const res = await fetch('/api/projects');
const data = await res.json();
projects = data.projects;
renderProjects();
updateStats();
updateConnectionStatus(true);
// 同时检查系统资源阈值(主页也有效)
loadThresholds();
fetch('/api/system/stats').then(r => r.json()).then(sysData => {
if (sysData.available) checkThresholds(sysData);
}).catch(() => {});
} catch (e) {
console.error('加载失败:', e);
updateConnectionStatus(false);
}
}
function updateStats() {
const webProjects = projects.filter(p => p.type === 'web');
const runningProjects = webProjects.filter(p => p.status?.status === 'running');
const cronProjects = projects.filter(p => p.type === 'cron');
const cliProjects = projects.filter(p => p.type === 'cli' || p.type === 'extension');
document.getElementById('webCount').textContent = webProjects.length;
document.getElementById('runningCount').textContent = `${runningProjects.length}/${webProjects.length}`;
document.getElementById('cronCount').textContent = cronProjects.length;
document.getElementById('cliCount').textContent = cliProjects.length;
document.getElementById('updateTime').textContent = new Date().toLocaleTimeString();
}
function filterType(type) {
currentFilter = type;
document.querySelectorAll('.filter-bar-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.type === type);
});
renderProjects();
}
function renderProjects() {
const list = document.getElementById('projectsList');
// 合合自定义服务到项目列表
const customServices = getCustomServices();
const allProjects = [...projects, ...customServices];
const filtered = currentFilter === 'all' ? allProjects : allProjects.filter(p => p.type === currentFilter || (currentFilter === 'web' && p.isCustom));
if (filtered.length === 0) {
list.innerHTML = '<div class="text-center py-12 text-gray-400"><i class="ri-folder-open-line text-4xl"></i><p class="mt-2">暂无项目</p></div>';
return;
}
const activeStatus = getActiveStatus();
const grouped = {};
filtered.forEach(p => {
const groupType = p.isCustom ? 'custom' : p.type;
if (!grouped[groupType]) grouped[groupType] = [];
grouped[groupType].push(p);
});
let html = '';
const typeNames = {
'web': { name: 'Web服务', icon: 'ri-server-line', color: 'blue' },
'custom': { name: '自定义服务', icon: 'ri-global-line', color: 'cyan' },
'cron': { name: 'Cron任务', icon: 'ri-time-line', color: 'yellow' },
'cli': { name: 'CLI工具', icon: 'ri-terminal-box-line', color: 'purple' },
'extension': { name: '浏览器插件', icon: 'ri-chrome-line', color: 'pink' }
};
for (const [type, projs] of Object.entries(grouped)) {
const typeInfo = typeNames[type] || { name: type, icon: 'ri-folder-line', color: 'gray' };
if (type === 'web') {
const activeProjs = projs.filter(p => activeStatus[p.id] !== false);
const archivedProjs = projs.filter(p => activeStatus[p.id] === false);
if (activeProjs.length > 0) {
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2"><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${activeProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
activeProjs.forEach(p => { html += renderProjectCard(p, true); });
html += '</div></div>';
}
if (archivedProjs.length > 0) {
html += `<div class="mb-6 opacity-60"><h2 class="text-lg font-semibold text-gray-400 mb-3 flex items-center gap-2"><i class="ri-archive-line text-gray-400"></i>归档服务<span class="text-sm text-gray-500">(${archivedProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
archivedProjs.forEach(p => { html += renderProjectCard(p, false); });
html += '</div></div>';
}
} else {
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2"><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${projs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
projs.forEach(p => { html += renderProjectCard(p, true); });
html += '</div></div>';
}
}
list.innerHTML = html;
}
function getActiveStatus() {
const stored = localStorage.getItem('serviceActiveStatus');
return stored ? JSON.parse(stored) : {};
}
function toggleServiceActive(id) {
const status = getActiveStatus();
status[id] = status[id] === false ? true : false;
localStorage.setItem('serviceActiveStatus', JSON.stringify(status));
renderProjects();
}
function renderProjectCard(p, isActive = true) {
// 自定义服务特殊处理
if (p.isCustom) {
return `
<div class="card rounded-lg p-3 hover:border-cyan-500 transition-colors border-cyan-500/30">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="status-dot bg-cyan-400" style="box-shadow: 0 0 8px #22d3ee;" title="外部服务"></div>
<h3 class="font-semibold text-sm truncate">${p.name}</h3>
</div>
<span class="text-xs text-cyan-400">外部</span>
</div>
<p class="text-gray-400 text-xs mb-2 truncate">${p.description || ''}</p>
<div class="flex items-center gap-1 text-xs flex-wrap">
<a href="${p.url}" target="_blank" class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30">访问</a>
${p.admin_url ? `<a href="${p.admin_url}" target="_blank" class="text-yellow-400 hover:text-yellow-300 ml-1">后台</a>` : ''}
<button onclick="deleteCustomService('${p.id}')" class="text-red-400 hover:text-red-300 ml-2" title="删除服务"><i class="ri-delete-bin-line"></i></button>
</div>
</div>
`;
}
const statusInfo = getStatusInfo(p.status?.status);
// 处理后台链接:将 localhost 替换为 externalIp
let adminUrl = p.admin_url || '';
if (adminUrl && adminUrl.includes('localhost')) {
adminUrl = adminUrl.replace(/localhost/g, externalIp);
}
// 获取自定义链接
const customLinks = getCustomLinks(p.id);
return `
<div class="card rounded-lg p-3 hover:border-gray-500 transition-colors">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="status-dot ${statusInfo.class}" title="${statusInfo.text}"></div>
<h3 class="font-semibold text-sm truncate">${p.name}</h3>
</div>
<span class="text-xs ${statusInfo.textColor}">${statusInfo.text}</span>
</div>
${p.ports && p.ports.length > 0 ? `
<div class="flex items-center gap-1 text-xs mb-2 flex-wrap">
${p.ports.map(port => {
const portStatus = p.status?.ports?.[port];
const isRunning = portStatus?.running;
return `<a href="http://${externalIp}:${port}" target="_blank" class="px-2 py-0.5 rounded ${isRunning ? 'bg-green-500/20 text-green-400 hover:bg-green-500/30' : 'bg-red-500/20 text-red-400'}">${port}</a>`;
}).join('')}
${adminUrl ? `<a href="${adminUrl}" target="_blank" class="text-yellow-400 hover:text-yellow-300 ml-1">后台</a>` : ''}
${customLinks.map(link => `<a href="${link.url}" target="_blank" class="text-cyan-400 hover:text-cyan-300 ml-1">${link.name}</a>`).join('')}
<button onclick="showAddLinkModal('${p.id}', '${p.name}')" class="text-green-400 hover:text-green-300 ml-1" title="添加自定义链接"><i class="ri-add-line"></i></button>
</div>
` : ''}
${p.type === 'web' ? `
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-1">
${(p.status?.status === 'running' || p.status?.status === 'partial') ? `
<button onclick="stopProject('${p.id}')" class="btn bg-red-600 hover:bg-red-700 px-2 py-0.5 rounded text-xs">停止</button>
<button onclick="restartProject('${p.id}')" class="btn bg-yellow-600 hover:bg-yellow-700 px-2 py-0.5 rounded text-xs">重启</button>
` : `<button onclick="startProject('${p.id}')" class="btn bg-green-600 hover:bg-green-700 px-2 py-0.5 rounded text-xs">启动</button>`}
<button onclick="viewLog('${p.id}')" class="btn bg-gray-600 hover:bg-gray-700 px-2 py-0.5 rounded text-xs">日志</button>
</div>
<button onclick="toggleServiceActive('${p.id}')" class="text-xs ${isActive ? 'text-green-400 hover:text-green-300' : 'text-gray-400 hover:text-gray-300'}" title="${isActive ? '点击归档' : '点击激活'}">
<i class="ri-${isActive ? 'checkbox-circle' : 'archive'}-line"></i>
</button>
</div>
` : ''}
</div>
`;
}
// ==================== 自定义链接管理 ====================
function getCustomLinks(projectId) {
const stored = localStorage.getItem('customLinks_' + projectId);
return stored ? JSON.parse(stored) : [];
}
function saveCustomLinks(projectId, links) {
localStorage.setItem('customLinks_' + projectId, JSON.stringify(links));
}
function showAddLinkModal(projectId, projectName) {
document.getElementById('linkModalTitle').textContent = `${projectName} - 添加链接`;
document.getElementById('linkProjectId').value = projectId;
document.getElementById('linkName').value = '';
document.getElementById('linkPort').value = '';
document.getElementById('linkPath').value = '';
document.getElementById('linkFullUrl').value = '';
document.getElementById('linkModal').classList.remove('hidden');
// 显示已有的自定义链接(供删除)
const existingLinks = getCustomLinks(projectId);
const existingList = document.getElementById('existingLinks');
if (existingLinks.length > 0) {
existingList.innerHTML = '<div class="text-gray-400 text-sm mb-2">已有链接:</div>' +
existingLinks.map(link => `
<div class="flex items-center justify-between bg-gray-700 px-2 py-1 rounded mb-1">
<span class="text-cyan-400 text-sm">${link.name}: ${link.url}</span>
<button onclick="deleteCustomLink('${projectId}', '${link.id}')" class="text-red-400 hover:text-red-300"><i class="ri-delete-bin-line"></i></button>
</div>
`).join('');
} else {
existingList.innerHTML = '';
}
}
function closeLinkModal() {
document.getElementById('linkModal').classList.add('hidden');
}
function saveCustomLink(event) {
event.preventDefault();
const projectId = document.getElementById('linkProjectId').value;
const name = document.getElementById('linkName').value.trim();
const port = document.getElementById('linkPort').value.trim();
const path = document.getElementById('linkPath').value.trim();
const fullUrl = document.getElementById('linkFullUrl').value.trim();
if (!name) {
alert('请输入链接名称');
return;
}
let url;
if (fullUrl) {
url = fullUrl;
} else if (port) {
url = `http://${externalIp}:${port}${path || ''}`;
} else {
alert('请输入端口或完整URL');
return;
}
const links = getCustomLinks(projectId);
const newLink = {
id: Date.now().toString(),
name: name,
url: url
};
links.push(newLink);
saveCustomLinks(projectId, links);
closeLinkModal();
renderProjects();
}
function deleteCustomLink(projectId, linkId) {
if (!confirm('确定删除此链接?')) return;
const links = getCustomLinks(projectId);
const filtered = links.filter(l => l.id !== linkId);
saveCustomLinks(projectId, filtered);
// 更新模态框中的显示
const existingLinks = getCustomLinks(projectId);
const existingList = document.getElementById('existingLinks');
if (existingLinks.length > 0) {
existingList.innerHTML = '<div class="text-gray-400 text-sm mb-2">已有链接:</div>' +
existingLinks.map(link => `
<div class="flex items-center justify-between bg-gray-700 px-2 py-1 rounded mb-1">
<span class="text-cyan-400 text-sm">${link.name}: ${link.url}</span>
<button onclick="deleteCustomLink('${projectId}', '${link.id}')" class="text-red-400 hover:text-red-300"><i class="ri-delete-bin-line"></i></button>
</div>
`).join('');
} else {
existingList.innerHTML = '<div class="text-gray-500 text-sm">暂无自定义链接</div>';
}
renderProjects();
}
// 自动生成URL预览
function updateLinkPreview() {
const port = document.getElementById('linkPort').value.trim();
const path = document.getElementById('linkPath').value.trim();
const preview = document.getElementById('linkPreview');
if (port) {
preview.textContent = `预览: http://${externalIp}:${port}${path || ''}`;
} else {
preview.textContent = '';
}
}
// ==================== 自定义服务管理 ====================
function getCustomServices() {
const stored = localStorage.getItem('customServices');
return stored ? JSON.parse(stored) : [];
}
function saveCustomServices(services) {
localStorage.setItem('customServices', JSON.stringify(services));
}
// ==================== 邮件通知规则管理 ====================
function getEmailRules() {
const stored = localStorage.getItem('emailNotifyRules');
return stored ? JSON.parse(stored) : [];
}
function saveEmailRules(rules) {
localStorage.setItem('emailNotifyRules', JSON.stringify(rules));
}
function renderEmailRules() {
const rules = getEmailRules();
const list = document.getElementById('emailRulesList');
if (rules.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-center py-4">暂无邮件通知规则</div>';
return;
}
const resourceNames = { 'cpu': 'CPU', 'memory': '内存', 'disk': '磁盘' };
list.innerHTML = rules.map(rule => {
const silentInfo = rule.silentEnabled ? `静默:${rule.silentStart}-${rule.silentEnd}` : '';
return `
<div class="flex items-center justify-between bg-gray-700/50 px-3 py-2 rounded-lg">
<div class="flex items-center gap-3 flex-wrap">
<span class="px-2 py-0.5 rounded text-xs ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}">${rule.enabled ? '启用' : '禁用'}</span>
<span class="text-gray-200">${rule.name}</span>
<span class="text-gray-400 text-xs">${resourceNames[rule.resource]} ≥ ${rule.threshold}%</span>
<span class="text-gray-400 text-xs">→ ${rule.email}</span>
${silentInfo ? `<span class="text-yellow-400/70 text-xs"><i class="ri-moon-line"></i> ${silentInfo}</span>` : ''}
</div>
<div class="flex items-center gap-1">
<button onclick="editEmailRule('${rule.id}')" class="text-blue-400 hover:text-blue-300 px-1" title="编辑"><i class="ri-edit-line"></i></button>
<button onclick="deleteEmailRule('${rule.id}')" class="text-red-400 hover:text-red-300 px-1" title="删除"><i class="ri-delete-bin-line"></i></button>
</div>
</div>
`;
}).join('');
}
function showAddEmailRuleModal() {
document.getElementById('emailRuleModalTitle').textContent = '添加邮件通知规则';
document.getElementById('emailRuleId').value = '';
document.getElementById('emailRuleName').value = '';
document.getElementById('emailRuleResource').value = 'cpu';
document.getElementById('emailRuleThreshold').value = 80;
document.getElementById('emailRuleAddress').value = '';
document.getElementById('emailRuleInterval').value = 300;
document.getElementById('emailRuleSilentStart').value = '23:00';
document.getElementById('emailRuleSilentEnd').value = '08:00';
document.getElementById('emailRuleSilentEnabled').checked = false;
document.getElementById('emailRuleEnabled').checked = true;
document.getElementById('emailRuleModal').classList.remove('hidden');
}
function closeEmailRuleModal() {
document.getElementById('emailRuleModal').classList.add('hidden');
}
function editEmailRule(ruleId) {
const rules = getEmailRules();
const rule = rules.find(r => r.id === ruleId);
if (!rule) return;
document.getElementById('emailRuleModalTitle').textContent = '编辑邮件通知规则';
document.getElementById('emailRuleId').value = rule.id;
document.getElementById('emailRuleName').value = rule.name;
document.getElementById('emailRuleResource').value = rule.resource;
document.getElementById('emailRuleThreshold').value = rule.threshold;
document.getElementById('emailRuleAddress').value = rule.email;
document.getElementById('emailRuleInterval').value = rule.interval;
document.getElementById('emailRuleSilentStart').value = rule.silentStart || '23:00';
document.getElementById('emailRuleSilentEnd').value = rule.silentEnd || '08:00';
document.getElementById('emailRuleSilentEnabled').checked = rule.silentEnabled || false;
document.getElementById('emailRuleEnabled').checked = rule.enabled;
document.getElementById('emailRuleModal').classList.remove('hidden');
}
function saveEmailRule(event) {
event.preventDefault();
const ruleId = document.getElementById('emailRuleId').value;
const name = document.getElementById('emailRuleName').value.trim();
const resource = document.getElementById('emailRuleResource').value;
const threshold = parseInt(document.getElementById('emailRuleThreshold').value);
const email = document.getElementById('emailRuleAddress').value.trim();
const interval = parseInt(document.getElementById('emailRuleInterval').value) || 300;
const silentStart = document.getElementById('emailRuleSilentStart').value;
const silentEnd = document.getElementById('emailRuleSilentEnd').value;
const silentEnabled = document.getElementById('emailRuleSilentEnabled').checked;
const enabled = document.getElementById('emailRuleEnabled').checked;
if (!name || !email) {
alert('请填写完整信息');
return;
}
const rules = getEmailRules();
if (ruleId) {
// 编辑
const idx = rules.findIndex(r => r.id === ruleId);
if (idx >= 0) {
rules[idx] = { ...rules[idx], name, resource, threshold, email, interval, silentStart, silentEnd, silentEnabled, enabled };
}
} else {
// 新增
rules.push({
id: 'rule_' + Date.now(),
name, resource, threshold, email, interval, silentStart, silentEnd, silentEnabled, enabled,
lastSent: 0
});
}
saveEmailRules(rules);
closeEmailRuleModal();
renderEmailRules();
}
function deleteEmailRule(ruleId) {
if (!confirm('确定删除此邮件通知规则?')) return;
const rules = getEmailRules().filter(r => r.id !== ruleId);
saveEmailRules(rules);
renderEmailRules();
}
// 检查邮件规则并发送通知
async function checkEmailRules(data) {
const rules = getEmailRules().filter(r => r.enabled);
const now = Date.now();
const nowTime = new Date();
const currentHour = nowTime.getHours();
const currentMin = nowTime.getMinutes();
const currentTimeStr = `${currentHour.toString().padStart(2, '0')}:${currentMin.toString().padStart(2, '0')}`;
for (const rule of rules) {
// 检查间隔
if (now - rule.lastSent < rule.interval * 1000) continue;
// 检查静默时间
if (rule.silentEnabled && rule.silentStart && rule.silentEnd) {
const start = rule.silentStart;
const end = rule.silentEnd;
// 处理跨越午夜的情况如23:00到08:00
let inSilentPeriod = false;
if (start > end) {
// 跨越午夜如23:00-08:00则currentTime >= start 或 currentTime < end 都在静默区间
inSilentPeriod = (currentTimeStr >= start || currentTimeStr < end);
} else {
// 同一天内如01:00-06:00
inSilentPeriod = (currentTimeStr >= start && currentTimeStr < end);
}
if (inSilentPeriod) {
console.log(`规则 ${rule.name} 当前在静默时间段 ${start}-${end},跳过发送`);
continue;
}
}
// 检查阈值
const value = data[rule.resource]?.percent;
if (value === undefined || value < rule.threshold) continue;
// 发送邮件
try {
const res = await fetch('/api/email/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: rule.email,
subject: `[系统告警] ${rule.name}`,
body: `${rule.name} 触发告警\n\n${rule.resource.toUpperCase()} 使用率: ${value.toFixed(1)}%\n阈值: ${rule.threshold}%\n时间: ${new Date().toLocaleString()}\n\n请及时处理。`
})
});
if (res.ok) {
// 更新发送时间
rule.lastSent = now;
saveEmailRules(getEmailRules().map(r => r.id === rule.id ? rule : r));
}
} catch (e) {
console.error('邮件发送失败:', e);
}
}
}
function showAddServiceModal() {
document.getElementById('serviceName').value = '';
document.getElementById('servicePort').value = '';
document.getElementById('servicePath').value = '';
document.getElementById('serviceFullUrl').value = '';
document.getElementById('serviceAdminUrl').value = '';
document.getElementById('serviceDesc').value = '';
document.getElementById('servicePreview').textContent = '';
document.getElementById('addServiceModal').classList.remove('hidden');
}
function closeAddServiceModal() {
document.getElementById('addServiceModal').classList.add('hidden');
}
function updateServicePreview() {
const port = document.getElementById('servicePort').value.trim();
const path = document.getElementById('servicePath').value.trim();
const preview = document.getElementById('servicePreview');
if (port) {
preview.textContent = `预览: http://${externalIp}:${port}${path || ''}`;
} else {
preview.textContent = '';
}
}
function saveCustomService(event) {
event.preventDefault();
const name = document.getElementById('serviceName').value.trim();
const port = document.getElementById('servicePort').value.trim();
const path = document.getElementById('servicePath').value.trim();
const fullUrl = document.getElementById('serviceFullUrl').value.trim();
const adminUrl = document.getElementById('serviceAdminUrl').value.trim();
const desc = document.getElementById('serviceDesc').value.trim();
if (!name) {
alert('请输入服务名称');
return;
}
let mainUrl;
if (fullUrl) {
mainUrl = fullUrl;
} else if (port) {
mainUrl = `http://${externalIp}:${port}${path || ''}`;
} else {
alert('请输入端口或完整URL');
return;
}
const services = getCustomServices();
const newService = {
id: 'custom_' + Date.now(),
name: name,
url: mainUrl,
admin_url: adminUrl || null,
description: desc || '自定义外部服务',
type: 'custom',
isCustom: true
};
services.push(newService);
saveCustomServices(services);
closeAddServiceModal();
loadProjects();
}
function deleteCustomService(serviceId) {
if (!confirm('确定删除此自定义服务?')) return;
const services = getCustomServices();
const filtered = services.filter(s => s.id !== serviceId);
saveCustomServices(filtered);
loadProjects();
}
function getStatusInfo(status) {
const map = {
'running': { text: '运行中', class: 'status-running', textColor: 'text-green-400' },
'stopped': { text: '已停止', class: 'status-stopped', textColor: 'text-red-400' },
'partial': { text: '部分运行', class: 'status-partial', textColor: 'text-yellow-400' },
'ready': { text: '就绪', class: 'status-ready', textColor: 'text-blue-400' },
'configured': { text: '已配置', class: 'status-configured', textColor: 'text-green-400' },
'not_configured': { text: '未配置', class: 'status-not-configured', textColor: 'text-gray-400' },
'completed': { text: '已完成', class: 'status-completed', textColor: 'text-purple-400' },
'unknown': { text: '未知', class: 'bg-gray-500', textColor: 'text-gray-400' }
};
return map[status] || map['unknown'];
}
async function startProject(id) {
if (!confirm('确定要启动此服务吗?')) return;
try {
const res = await fetch(`/api/projects/${id}/start`, { method: 'POST' });
const data = await res.json();
alert(data.message || data.error);
setTimeout(loadProjects, 2000);
} catch (e) {
alert('启动失败: ' + e.message);
}
}
async function stopProject(id) {
if (!confirm('确定要停止此服务吗?')) return;
try {
const res = await fetch(`/api/projects/${id}/stop`, { method: 'POST' });
const data = await res.json();
alert(data.message || data.error);
setTimeout(loadProjects, 1000);
} catch (e) {
alert('停止失败: ' + e.message);
}
}
async function restartProject(id) {
if (!confirm('确定要重启此服务吗?')) return;
try {
await fetch(`/api/projects/${id}/stop`, { method: 'POST' });
setTimeout(async () => {
const res = await fetch(`/api/projects/${id}/start`, { method: 'POST' });
const data = await res.json();
alert(data.message || data.error);
loadProjects();
}, 2000);
} catch (e) {
alert('重启失败: ' + e.message);
}
}
async function viewLog(id) {
const project = projects.find(p => p.id === id);
document.getElementById('logTitle').textContent = `${project?.name || id} - 日志`;
document.getElementById('logModal').classList.remove('hidden');
document.getElementById('logModal').classList.add('flex');
try {
const res = await fetch(`/api/projects/${id}/log`);
const data = await res.json();
document.getElementById('logContent').textContent = data.log || '暂无日志';
} catch (e) {
document.getElementById('logContent').textContent = '获取日志失败: ' + e.message;
}
}
function closeLogModal() {
document.getElementById('logModal').classList.add('hidden');
document.getElementById('logModal').classList.remove('flex');
}
// ==================== Cron 管理 ====================
async function loadCronTasks() {
try {
const res = await fetch('/api/cron/tasks');
const data = await res.json();
cronTasks = data.tasks;
renderCronTasks();
updateCronStats();
} catch (e) {
console.error('加载 Cron 任务失败:', e);
document.getElementById('cronTasksList').innerHTML = '<div class="text-center py-8 text-red-400"><i class="ri-error-warning-line text-2xl"></i><p class="mt-2 text-sm">加载失败: ' + e.message + '</p></div>';
}
}
function updateCronStats() {
const total = cronTasks.length;
const enabled = cronTasks.filter(t => t.enabled === 1).length;
const disabled = total - enabled;
document.getElementById('cronTotal').textContent = total;
document.getElementById('cronEnabled').textContent = enabled;
document.getElementById('cronDisabled').textContent = disabled;
// 获取历史总数
fetch('/api/cron/tasks').then(() => {
let historyCount = 0;
cronTasks.forEach(t => {
fetch(`/api/cron/tasks/${t.id}/history`).then(res => res.json()).then(data => {
historyCount += data.history.length;
document.getElementById('cronHistoryCount').textContent = historyCount;
});
});
});
}
function renderCronTasks() {
const list = document.getElementById('cronTasksList');
if (cronTasks.length === 0) {
list.innerHTML = '<div class="text-center py-12 text-gray-400"><i class="ri-folder-open-line text-4xl"></i><p class="mt-2">暂无 Cron 任务</p><p class="text-sm mt-1">点击"从系统同步"导入现有任务</p></div>';
return;
}
const categoryIcons = {
'monitor': { icon: 'ri-heart-pulse-line', color: 'blue' },
'backup': { icon: 'ri-archive-line', color: 'green' },
'cleanup': { icon: 'ri-delete-bin-line', color: 'yellow' },
'report': { icon: 'ri-file-chart-line', color: 'purple' },
'sync': { icon: 'ri-refresh-line', color: 'cyan' },
'notification': { icon: 'ri-notification-line', color: 'orange' },
'other': { icon: 'ri-more-line', color: 'gray' }
};
let html = '';
cronTasks.forEach(task => {
const catInfo = categoryIcons[task.category] || categoryIcons['other'];
html += `
<div class="cron-card card rounded-lg p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<i class="${catInfo.icon} text-${catInfo.color}-400"></i>
<h3 class="font-semibold">${task.name}</h3>
<span class="px-2 py-0.5 rounded text-xs ${task.enabled ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">${task.enabled ? '启用' : '禁用'}</span>
</div>
<div class="flex items-center gap-3 mb-2">
<code class="text-yellow-400 text-sm font-mono">${task.expression}</code>
<span class="text-gray-500">→</span>
<span class="text-blue-400 text-sm">${task.human_time || ''}</span>
${task.next_run ? `<span class="text-gray-500 text-xs">下次: ${task.next_run}</span>` : ''}
</div>
<div class="text-gray-300 text-sm font-mono break-all bg-gray-800/50 rounded p-2">${escapeHtml(task.command)}</div>
${task.description ? `<div class="text-gray-400 text-sm mt-2"><i class="ri-info-line"></i> ${task.description}</div>` : ''}
</div>
<div class="flex flex-col gap-1">
<button onclick="editCronTask(${task.id})" class="btn bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-xs flex items-center gap-1"><i class="ri-edit-line"></i> 编辑</button>
<button onclick="toggleCronTask(${task.id})" class="btn ${task.enabled ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-green-600 hover:bg-green-700'} px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-${task.enabled ? 'pause' : 'play'}-line"></i> ${task.enabled ? '禁用' : '启用'}
</button>
<button onclick="showHistory(${task.id}, '${task.name}')" class="btn bg-purple-600 hover:bg-purple-700 px-2 py-1 rounded text-xs flex items-center gap-1"><i class="ri-history-line"></i> 历史</button>
<button onclick="deleteCronTask(${task.id})" class="btn bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-xs flex items-center gap-1"><i class="ri-delete-bin-line"></i> 删除</button>
</div>
</div>
</div>
`;
});
list.innerHTML = html;
}
function showAddCronModal() {
document.getElementById('cronModalTitle').textContent = '添加 Cron 任务';
document.getElementById('cronTaskId').value = '';
document.getElementById('cronName').value = '';
document.getElementById('cronExpression').value = '';
document.getElementById('cronCommand').value = '';
document.getElementById('cronCategory').value = 'other';
document.getElementById('cronDescription').value = '';
document.getElementById('cronEnabled').checked = true;
document.getElementById('cronHumanTime').textContent = '';
document.getElementById('cronModal').classList.remove('hidden');
}
function editCronTask(id) {
const task = cronTasks.find(t => t.id === id);
if (!task) return;
document.getElementById('cronModalTitle').textContent = '编辑 Cron 任务';
document.getElementById('cronTaskId').value = id;
document.getElementById('cronName').value = task.name;
document.getElementById('cronExpression').value = task.expression;
document.getElementById('cronCommand').value = task.command;
document.getElementById('cronCategory').value = task.category;
document.getElementById('cronDescription').value = task.description || '';
document.getElementById('cronEnabled').checked = task.enabled === 1;
document.getElementById('cronHumanTime').textContent = task.human_time || '';
document.getElementById('cronModal').classList.remove('hidden');
}
function closeCronModal() {
document.getElementById('cronModal').classList.add('hidden');
}
async function saveCronTask(event) {
event.preventDefault();
const id = document.getElementById('cronTaskId').value;
const data = {
name: document.getElementById('cronName').value,
expression: document.getElementById('cronExpression').value,
command: document.getElementById('cronCommand').value,
category: document.getElementById('cronCategory').value,
description: document.getElementById('cronDescription').value,
enabled: document.getElementById('cronEnabled').checked ? 1 : 0
};
try {
let res;
if (id) {
res = await fetch(`/api/cron/tasks/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) });
} else {
res = await fetch('/api/cron/tasks', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) });
}
const result = await res.json();
if (result.error) {
alert('保存失败: ' + result.error);
} else {
alert(result.message);
closeCronModal();
loadCronTasks();
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
async function toggleCronTask(id) {
try {
const res = await fetch(`/api/cron/tasks/${id}/toggle`, { method: 'POST' });
const data = await res.json();
alert(data.message);
loadCronTasks();
} catch (e) {
alert('操作失败: ' + e.message);
}
}
async function deleteCronTask(id) {
if (!confirm('确定要删除此任务吗?删除后会从系统 crontab 中移除。')) return;
try {
const res = await fetch(`/api/cron/tasks/${id}`, { method: 'DELETE' });
const data = await res.json();
alert(data.message);
loadCronTasks();
} catch (e) {
alert('删除失败: ' + e.message);
}
}
async function showHistory(id, name) {
document.getElementById('historyModalTitle').textContent = `${name} - 历史版本`;
document.getElementById('historyModal').classList.remove('hidden');
try {
const res = await fetch(`/api/cron/tasks/${id}/history`);
const data = await res.json();
renderHistory(data.history, id);
} catch (e) {
document.getElementById('historyList').innerHTML = '<div class="text-center py-8 text-red-400">加载失败</div>';
}
}
function renderHistory(history, taskId) {
if (history.length === 0) {
document.getElementById('historyList').innerHTML = '<div class="text-center py-8 text-gray-400">暂无历史记录</div>';
return;
}
const operationLabels = {
'create': { text: '创建', class: 'create' },
'update': { text: '更新', class: 'update' },
'delete': { text: '删除', class: 'delete' },
'toggle': { text: '切换状态', class: 'toggle' },
};
let html = '';
history.forEach(h => {
const opInfo = operationLabels[h.operation] || { text: h.operation, class: 'update' };
const isRollback = h.operation.includes('rollback');
html += `
<div class="history-item ${isRollback ? 'rollback' : opInfo.class} py-2">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-gray-400 text-sm">v${h.version}</span>
<span class="text-xs px-2 py-0.5 rounded ${isRollback ? 'bg-yellow-500/20 text-yellow-400' : 'bg-blue-500/20 text-blue-400'}">${isRollback ? '回退' : opInfo.text}</span>
</div>
<div class="text-gray-300 text-sm mt-1">${h.created_at}</div>
${h.expression ? `<code class="text-yellow-400 text-xs font-mono">${h.expression}</code>` : ''}
${h.enabled !== null ? `<span class="text-xs ml-2 ${h.enabled ? 'text-green-400' : 'text-red-400'}">${h.enabled ? '启用' : '禁用'}</span>` : ''}
</div>
${!isRollback && h.version > 1 ? `<button onclick="rollbackTo(${taskId}, ${h.version})" class="btn bg-yellow-600 hover:bg-yellow-700 px-2 py-1 rounded text-xs"><i class="ri-history-line"></i> 回退</button>` : ''}
</div>
</div>
`;
});
document.getElementById('historyList').innerHTML = html;
}
async function rollbackTo(taskId, version) {
if (!confirm(`确定要回退到版本 ${version} 吗?`)) return;
try {
const res = await fetch(`/api/cron/tasks/${taskId}/rollback/${version}`, { method: 'POST' });
const data = await res.json();
alert(data.message);
closeHistoryModal();
loadCronTasks();
} catch (e) {
alert('回退失败: ' + e.message);
}
}
function closeHistoryModal() {
document.getElementById('historyModal').classList.add('hidden');
}
async function syncFromSystem() {
if (!confirm('从系统 crontab 同步任务到数据库?')) return;
try {
const res = await fetch('/api/cron/sync', { method: 'POST' });
const data = await res.json();
alert(data.message);
loadCronTasks();
} catch (e) {
alert('同步失败: ' + e.message);
}
}
function toggleLogs() {
enableLogs = document.getElementById('enableLogs').checked;
localStorage.setItem('enableLogs', enableLogs);
}
// 解析 cron 表达式显示友好时间
document.getElementById('cronExpression').addEventListener('input', function() {
const expr = this.value;
// 简单解析
const patterns = {
'0 4 * * *': '每天凌晨4点',
'0 0 * * *': '每天午夜0点',
'*/5 * * * *': '每5分钟',
'*/10 * * * *': '每10分钟',
'*/20 * * * *': '每20分钟',
'*/30 * * * *': '每30分钟',
'0 */1 * * *': '每小时',
'0 */2 * * *': '每2小时',
};
document.getElementById('cronHumanTime').textContent = patterns[expr] || '';
});
// ==================== 通用功能 ====================
function refreshAll() {
if (currentTab === 'projects') {
loadProjects();
} else {
loadCronTasks();
}
}
function scrollToTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
function scrollToBottom() { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }
function updateConnectionStatus(ok) {
const statusEl = document.getElementById('connectionStatus');
if (ok) {
statusEl.innerHTML = '<div class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div><span class="text-green-400 text-xs">已连接</span>';
statusEl.className = 'flex items-center gap-1 px-2 py-1 rounded bg-green-500/20';
} else {
statusEl.innerHTML = '<div class="w-2 h-2 rounded-full bg-red-400"></div><span class="text-red-400 text-xs">断开</span>';
statusEl.className = 'flex items-center gap-1 px-2 py-1 rounded bg-red-500/20';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 初始化
loadProjects();
let refreshIntervalMs = parseInt(localStorage.getItem('refreshInterval') || '30') * 1000;
document.getElementById('refreshInterval').value = refreshIntervalMs / 1000;
let refreshTimer = setInterval(() => { if (currentTab === 'projects') loadProjects(); }, refreshIntervalMs);
function updateRefreshInterval() {
const seconds = Math.max(5, Math.min(300, parseInt(document.getElementById('refreshInterval').value) || 30));
document.getElementById('refreshInterval').value = seconds;
localStorage.setItem('refreshInterval', seconds);
clearInterval(refreshTimer);
refreshTimer = setInterval(() => { if (currentTab === 'projects') loadProjects(); }, seconds * 1000);
}
setInterval(() => {
fetch('/api/projects').then(() => updateConnectionStatus(true)).catch(() => updateConnectionStatus(false));
}, 10000);
</script>
</body>
</html>
'''
if __name__ == '__main__':
os.makedirs(os.path.join(BASE_DIR, 'logs'), exist_ok=True)
os.makedirs(CRON_BACKUP_DIR, exist_ok=True)
init_db()
log_message("=" * 50)
log_message("项目服务管理面板 v2.0.0 启动")
log_message("访问地址: http://localhost:19013")
log_message(f"进程PID: {os.getpid()}")
log_message("=" * 50)
app.run(host='0.0.0.0', port=19013, debug=False)