- 端口号/后台后添加绿色+按钮 - 点击弹出模态框输入自定义链接 - 支持端口+路径生成URL或完整URL - 链接保存到localStorage,支持删除 - 使用网页统一设置的IP生成链接
1992 lines
85 KiB
Python
1992 lines
85 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
项目服务管理面板 v2.2.0
|
||
端口: 19013
|
||
新增: 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
|
||
|
||
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} 个任务'})
|
||
|
||
|
||
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 '无法计算'
|
||
|
||
|
||
@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>项目服务管理面板 v2.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; }
|
||
.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('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="flex gap-2 mb-4">
|
||
<button onclick="filterType('all')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="all">全部</button>
|
||
<button onclick="filterType('web')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="web">Web服务</button>
|
||
<button onclick="filterType('cli')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="cli">CLI工具</button>
|
||
<button onclick="filterType('extension')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="extension">插件</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>
|
||
|
||
<!-- 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 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 切换
|
||
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('cronTab').classList.toggle('hidden', tab !== 'cron');
|
||
|
||
if (tab === 'cron') {
|
||
loadCronTasks();
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
} 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-btn').forEach(btn => {
|
||
btn.classList.toggle('bg-blue-600', btn.dataset.type === type);
|
||
btn.classList.toggle('bg-gray-700', btn.dataset.type !== type);
|
||
});
|
||
renderProjects();
|
||
}
|
||
|
||
function renderProjects() {
|
||
const list = document.getElementById('projectsList');
|
||
const filtered = currentFilter === 'all' ? projects : projects.filter(p => p.type === currentFilter);
|
||
|
||
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 => {
|
||
if (!grouped[p.type]) grouped[p.type] = [];
|
||
grouped[p.type].push(p);
|
||
});
|
||
|
||
let html = '';
|
||
const typeNames = {
|
||
'web': { name: 'Web服务', icon: 'ri-server-line', color: 'blue' },
|
||
'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) {
|
||
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 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) |