Files
project-panel/app.py

1329 lines
56 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
"""
项目服务管理面板
端口: 19013
"""
import os
import json
import subprocess
import signal
import threading
from datetime import datetime
from flask import Flask, render_template_string, jsonify, request
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')
# 进程状态缓存
process_cache = {}
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任务是否配置
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")
f.write(f"[{datetime.now().isoformat()}] {action}\n")
f.write(f"Command: {cmd}\n")
f.write(f"Directory: {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 = result.stdout.strip().split('\n')
pids = [p for p in pids if p]
for pid in pids:
try:
os.kill(int(pid), signal.SIGTERM)
except:
pass
return len(pids)
except:
return 0
# 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>项目服务管理面板</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; }
</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-8">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
<i class="ri-dashboard-3-line text-blue-400"></i>
项目服务管理面板
</h1>
<p class="text-gray-400 mt-1">统一管理所有项目和服务</p>
</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()" placeholder="输入IP">
</div>
<button onclick="scrollToCrons()" class="btn bg-orange-600 hover:bg-orange-700 px-3 py-2 rounded-lg flex items-center gap-2">
<i class="ri-timer-line"></i> Cron 列表
</button>
<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()" title="刷新间隔(秒)">
<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>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<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-6">
<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('cron')" class="filter-btn px-3 py-1 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm" data-type="cron">
Cron任务
</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>
<!-- 系统 Cron 列表 -->
<div id="cronSection" class="card rounded-xl p-6 mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<i class="ri-timer-line text-orange-400"></i>
主机 Cron 列表
<span id="systemCronCount" class="text-sm text-gray-400 ml-2">-</span>
</h2>
<button onclick="loadCrons()" class="btn bg-orange-600 hover:bg-orange-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-refresh-line"></i> 刷新
</button>
</div>
<!-- Cron 概述 -->
<div id="cronSummary" class="mb-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
<div class="text-center py-4 text-gray-400 text-sm">加载中...</div>
</div>
<!-- Cron 详细列表 -->
<div id="cronsList" class="space-y-3">
<div class="text-center py-8 text-gray-400">
<i class="ri-loader-4-line text-2xl animate-spin"></i>
<p class="mt-2 text-sm">加载中...</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>
<script>
let projects = [];
let currentFilter = 'all';
let externalIp = localStorage.getItem('externalIp') || '192.168.2.17';
// 初始化IP输入框
document.getElementById('externalIp').value = externalIp;
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' };
// Web服务需要分成活跃和归档两组
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');
if (stored) {
return JSON.parse(stored);
}
return {};
}
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);
const typeColors = {
'web': 'bg-blue-500/20 text-blue-400',
'cron': 'bg-yellow-500/20 text-yellow-400',
'cli': 'bg-purple-500/20 text-purple-400',
'extension': 'bg-pink-500/20 text-pink-400'
};
let portsHtml = '';
if (p.ports && p.ports.length > 0) {
portsHtml = `
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-400">端口:</span>
${p.ports.map(port => {
const portStatus = p.status?.ports?.[port];
const isRunning = portStatus?.running;
return `<span class="px-2 py-0.5 rounded ${isRunning ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">${port}</span>`;
}).join('')}
</div>
`;
}
let actionsHtml = '';
if (p.type === 'web') {
const isRunning = p.status?.status === 'running';
const isPartial = p.status?.status === 'partial';
actionsHtml = `
<div class="flex items-center gap-2 mt-3">
${isRunning || isPartial ? `
<button onclick="stopProject('${p.id}')" class="btn bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-stop-line"></i> 停止
</button>
<button onclick="restartProject('${p.id}')" class="btn bg-yellow-600 hover:bg-yellow-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-restart-line"></i> 重启
</button>
` : `
<button onclick="startProject('${p.id}')" class="btn bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-play-line"></i> 启动
</button>
`}
<button onclick="viewLog('${p.id}')" class="btn bg-gray-600 hover:bg-gray-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-file-list-line"></i> 日志
</button>
</div>
`;
} else if (p.type === 'cli') {
actionsHtml = `
<div class="flex items-center gap-2 mt-3">
<button onclick="runCli('${p.id}')" class="btn bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-play-line"></i> 运行
</button>
<button onclick="viewLog('${p.id}')" class="btn bg-gray-600 hover:bg-gray-700 px-3 py-1 rounded text-sm flex items-center gap-1">
<i class="ri-file-list-line"></i> 日志
</button>
</div>
`;
} else if (p.type === 'cron') {
const configured = p.status?.cron_configured;
actionsHtml = `
<div class="flex items-center gap-2 mt-3">
<span class="text-sm ${configured ? 'text-green-400' : 'text-gray-400'}">
<i class="ri-${configured ? 'check' : 'close'}-line"></i>
${configured ? '已配置' : '未配置'}
</span>
<span class="text-sm text-gray-500">${p.cron || ''}</span>
</div>
`;
}
let linksHtml = '';
if (p.ports && p.ports.length > 0) {
const mainPort = p.ports[0];
linksHtml = `
<a href="http://${externalIp}:${mainPort}" target="_blank" class="text-blue-400 hover:text-blue-300 text-sm">
<i class="ri-external-link-line"></i> 访问
</a>
`;
if (p.admin_url) {
linksHtml += `
<span class="text-gray-600">|</span>
<a href="${p.admin_url}" target="_blank" class="text-yellow-400 hover:text-yellow-300 text-sm">
<i class="ri-settings-3-line"></i> 后台
</a>
`;
}
}
if (p.git_repo) {
linksHtml += `
<span class="text-gray-600">|</span>
<a href="${p.git_repo}" target="_blank" class="text-gray-400 hover:text-gray-300 text-sm">
<i class="ri-git-repository-line"></i> Git
</a>
`;
}
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('')}
${p.admin_url ? `<a href="${p.admin_url}" target="_blank" class="text-yellow-400 hover:text-yellow-300">后台</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>
` : ''}
${p.type === 'cron' ? `
<div class="text-xs text-gray-400 mt-1">
<i class="ri-${p.status?.cron_configured ? 'check' : 'close'}-line"></i>
${p.status?.cron_configured ? '已配置' : '未配置'}
${p.cron ? ` · ${p.cron}` : ''}
</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 runCli(id) {
if (!confirm('确定要运行此CLI工具吗')) return;
try {
const res = await fetch(`/api/projects/${id}/run`, { method: 'POST' });
const data = await res.json();
alert(data.message || data.error);
} 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');
}
function refreshAll() {
loadProjects();
}
function scrollToCrons() {
const cronSection = document.getElementById('cronSection');
if (cronSection) {
cronSection.scrollIntoView({ behavior: 'smooth' });
}
}
// 连接状态检查
let connectionOk = true;
function updateConnectionStatus(ok) {
connectionOk = 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';
}
}
async function checkConnection() {
try {
const res = await fetch('/api/projects', { timeout: 5000 });
if (res.ok) {
updateConnectionStatus(true);
return true;
}
} catch (e) {
updateConnectionStatus(false);
console.error('连接断开:', e);
}
return false;
}
// 初始化
loadProjects();
loadCrons();
// 动态刷新间隔
let refreshIntervalMs = parseInt(localStorage.getItem('refreshInterval') || '30') * 1000;
document.getElementById('refreshInterval').value = refreshIntervalMs / 1000;
let refreshTimer = setInterval(loadProjects, refreshIntervalMs);
function updateRefreshInterval() {
const seconds = parseInt(document.getElementById('refreshInterval').value) || 30;
const clampedSeconds = Math.max(5, Math.min(300, seconds)); // 限制5-300秒
document.getElementById('refreshInterval').value = clampedSeconds;
localStorage.setItem('refreshInterval', clampedSeconds);
refreshIntervalMs = clampedSeconds * 1000;
// 清除旧定时器,设置新定时器
clearInterval(refreshTimer);
refreshTimer = setInterval(loadProjects, refreshIntervalMs);
console.log('刷新间隔已更新为:', clampedSeconds, '');
}
// 每10秒检查连接状态
setInterval(checkConnection, 10000);
async function loadCrons() {
try {
const res = await fetch('/api/crons');
const data = await res.json();
renderCrons(data.crons);
document.getElementById('systemCronCount').textContent = data.crons.length;
} catch (e) {
console.error('加载Cron失败:', e);
document.getElementById('cronsList').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 renderCrons(crons) {
const list = document.getElementById('cronsList');
document.getElementById('systemCronCount').textContent = `${crons.length} 个`;
if (crons.length === 0) {
list.innerHTML = `
<div class="text-center py-8 text-gray-400">
<i class="ri-folder-open-line text-2xl"></i>
<p class="mt-2 text-sm">暂无 Cron 任务</p>
</div>
`;
return;
}
// 分类统计并生成概述
const userCrons = crons.filter(c => c.source === 'user');
const systemCrons = crons.filter(c => c.source === 'system');
const cronDCrons = crons.filter(c => c.source === 'cron.d');
// 分析用户任务用途
const userTaskTypes = analyzeUserCrons(userCrons);
// 生成概述
let summaryHtml = `
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-blue-500/10 rounded-lg p-3 border border-blue-500/30">
<div class="flex items-center gap-2 mb-2">
<i class="ri-user-line text-blue-400"></i>
<span class="text-blue-400 font-medium">用户任务</span>
<span class="text-blue-300 text-sm">${userCrons.length} 个</span>
</div>
<div class="text-gray-300 text-sm space-y-1">
${userTaskTypes.map(t => `<div><span class="text-gray-400">${t.count}个</span> ${t.name}</div>`).join('')}
</div>
</div>
<div class="bg-red-500/10 rounded-lg p-3 border border-red-500/30">
<div class="flex items-center gap-2 mb-2">
<i class="ri-settings-line text-red-400"></i>
<span class="text-red-400 font-medium">系统任务</span>
<span class="text-red-300 text-sm">${systemCrons.length} 个</span>
</div>
<div class="text-gray-300 text-sm space-y-1">
<div><span class="text-gray-400">每小时</span> 执行 cron.hourly</div>
<div><span class="text-gray-400">每天</span> 执行 cron.daily</div>
<div><span class="text-gray-400">每周</span> 执行 cron.weekly</div>
<div><span class="text-gray-400">每月</span> 执行 cron.monthly</div>
</div>
</div>
<div class="bg-green-500/10 rounded-lg p-3 border border-green-500/30">
<div class="flex items-center gap-2 mb-2">
<i class="ri-file-list-line text-green-400"></i>
<span class="text-green-400 font-medium">cron.d</span>
<span class="text-green-300 text-sm">${cronDCrons.length} 个</span>
</div>
<div class="text-gray-300 text-sm space-y-1">
${cronDCrons.map(c => `<div><span class="text-gray-400">${c.file}</span> ${c.description || ''}</div>`).join('')}
</div>
</div>
</div>
`;
document.getElementById('cronSummary').innerHTML = summaryHtml;
const sourceColors = {
'user': { bg: 'bg-blue-500/20', text: 'text-blue-400', label: '用户' },
'system': { bg: 'bg-red-500/20', text: 'text-red-400', label: '系统' },
'cron.d': { bg: 'bg-green-500/20', text: 'text-green-400', label: 'cron.d' }
};
let html = '';
crons.forEach((cron, index) => {
const sourceInfo = sourceColors[cron.source] || { bg: 'bg-gray-500/20', text: 'text-gray-400', label: cron.source };
html += `
<div class="bg-gray-800/50 rounded-lg p-3 border border-gray-700">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 rounded text-xs ${sourceInfo.bg} ${sourceInfo.text}">${sourceInfo.label}</span>
${cron.file ? `<span class="text-xs text-gray-500">${cron.file}</span>` : ''}
${cron.user ? `<span class="text-xs text-gray-400">用户: ${cron.user}</span>` : ''}
</div>
<div class="flex items-center gap-3">
<code class="text-yellow-400 text-sm font-mono">${cron.expression}</code>
<span class="text-gray-500 text-xs">→</span>
<span class="text-green-400 text-sm">${cron.description || ''}</span>
</div>
<div class="mt-2 text-gray-300 text-sm font-mono break-all">
${escapeHtml(cron.command)}
</div>
</div>
<div class="text-right">
<span class="text-xs text-gray-500">#${index + 1}</span>
</div>
</div>
</div>
`;
});
list.innerHTML = html;
}
function analyzeUserCrons(crons) {
const types = [];
// 服务监控
const monitors = crons.filter(c => c.command.includes('service-monitor') || c.command.includes('monitor.py') || c.command.includes('cpu-monitor') || c.command.includes('disk-monitor'));
if (monitors.length > 0) types.push({ name: '系统监控', count: monitors.length });
// 股票/板块相关
const stocks = crons.filter(c => c.command.includes('stock') || c.command.includes('board_monitor'));
if (stocks.length > 0) types.push({ name: 'A股数据/板块监控', count: stocks.length });
// 每日总结
const summaries = crons.filter(c => c.command.includes('daily-summary') || c.command.includes('summary'));
if (summaries.length > 0) types.push({ name: '每日总结', count: summaries.length });
// 清理脚本
const cleanups = crons.filter(c => c.command.includes('cleanup') || c.command.includes('clean'));
if (cleanups.length > 0) types.push({ name: '清理脚本', count: cleanups.length });
// 其他
const others = crons.filter(c =>
!c.command.includes('service-monitor') &&
!c.command.includes('monitor.py') &&
!c.command.includes('cpu-monitor') &&
!c.command.includes('disk-monitor') &&
!c.command.includes('stock') &&
!c.command.includes('board_monitor') &&
!c.command.includes('daily-summary') &&
!c.command.includes('summary') &&
!c.command.includes('cleanup') &&
!c.command.includes('clean')
);
if (others.length > 0) types.push({ name: '其他任务', count: others.length });
return types;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>
'''
@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():
cmd = info['cmd']
run_command(cmd, cwd, project_id, f'start-{name}')
return jsonify({'message': '服务启动中...'})
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:
# 最后1000行
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()
# 生成ID
import re
project_id = re.sub(r'[^a-z0-9-]', '-', data['name'].lower())
# 检查ID是否重复
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': # 不允许修改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': '删除成功'})
def get_system_crons():
"""获取系统所有 cron 任务"""
crons = []
# 获取当前用户的 crontab
try:
result = subprocess.run(
"crontab -l 2>/dev/null",
shell=True, capture_output=True, text=True, timeout=5
)
user_crontab = result.stdout.strip()
if user_crontab:
for line in user_crontab.split('\n'):
line = line.strip()
if line and not line.startswith('#'):
# 解析 cron 行
parts = line.split()
if len(parts) >= 6:
cron_expr = ' '.join(parts[:5])
command = ' '.join(parts[5:])
crons.append({
'source': 'user',
'expression': cron_expr,
'command': command,
'line': line
})
except:
pass
# 获取系统级 cron (/etc/crontab)
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:
# 系统 crontab 有额外的 user 字段
cron_expr = ' '.join(parts[:5])
user = parts[5]
command = ' '.join(parts[6:])
crons.append({
'source': 'system',
'expression': cron_expr,
'user': user,
'command': command,
'line': line
})
except:
pass
# 获取 /etc/cron.d/ 目录下的任务
try:
cron_d_dir = '/etc/cron.d'
if os.path.exists(cron_d_dir):
for filename in os.listdir(cron_d_dir):
filepath = os.path.join(cron_d_dir, 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:
cron_expr = ' '.join(parts[:5])
user = parts[5]
command = ' '.join(parts[6:])
crons.append({
'source': 'cron.d',
'file': filename,
'expression': cron_expr,
'user': user,
'command': command,
'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}']
elif minute.startswith('*/'):
# 保持 "每X分钟"
pass
# 日期
if day != '*':
desc.append(f'{day}')
# 月份
if month != '*':
months = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']
try:
desc.append(f'{months[int(month)-1]}')
except:
desc.append(f'{month}')
# 星期
if weekday != '*':
weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
try:
# 0=周日, 1=周一...
desc.append(weekdays[int(weekday)])
except:
desc.append(f'{weekday}')
return ' '.join(desc) if desc else expr
@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})
if __name__ == '__main__':
os.makedirs(os.path.join(BASE_DIR, 'logs'), exist_ok=True)
print("=" * 50)
print("项目服务管理面板")
print("访问地址: http://localhost:19013")
print("=" * 50)
app.run(host='0.0.0.0', port=19013, debug=False)