Files
project-panel/app.py
hubian f917d15278 fix: 后台链接使用 externalIp 替代 localhost
- 后台链接现在会自动将 localhost 替换为页面配置的对外IP
- 用户可通过页面顶部的对外IP输入框调整访问地址
2026-04-20 23:20:31 +08:00

1830 lines
77 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.1.0
端口: 19013
修复: 后台链接使用 externalIp 替代 localhost
"""
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 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);
}
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">
${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>` : ''}
</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 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)