829 lines
32 KiB
Python
829 lines
32 KiB
Python
#!/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>
|
||
<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-4">
|
||
<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>
|
||
<span id="updateTime" class="text-gray-400 text-sm"></span>
|
||
</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>
|
||
</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';
|
||
|
||
async function loadProjects() {
|
||
try {
|
||
const res = await fetch('/api/projects');
|
||
const data = await res.json();
|
||
projects = data.projects;
|
||
renderProjects();
|
||
updateStats();
|
||
} catch (e) {
|
||
console.error('加载失败:', e);
|
||
}
|
||
}
|
||
|
||
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 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' };
|
||
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 gap-4">
|
||
`;
|
||
|
||
projs.forEach(p => {
|
||
html += renderProjectCard(p);
|
||
});
|
||
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
list.innerHTML = html;
|
||
}
|
||
|
||
function renderProjectCard(p) {
|
||
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://localhost:${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-xl p-4 hover:border-gray-500 transition-colors">
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-3 mb-2">
|
||
<div class="status-dot ${statusInfo.class}" title="${statusInfo.text}"></div>
|
||
<h3 class="font-semibold text-lg">${p.name}</h3>
|
||
<span class="type-badge ${typeColors[p.type]}">${p.type.toUpperCase()}</span>
|
||
${p.version ? `<span class="text-xs text-gray-500">${p.version}</span>` : ''}
|
||
</div>
|
||
<p class="text-gray-400 text-sm mb-2">${p.description || ''}</p>
|
||
${portsHtml}
|
||
<div class="flex items-center gap-4 mt-2 text-sm">
|
||
${linksHtml}
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<span class="text-sm ${statusInfo.textColor}">${statusInfo.text}</span>
|
||
${p.status?.health !== null && p.status?.health !== undefined ? `
|
||
<div class="text-xs mt-1 ${p.status.health ? 'text-green-400' : 'text-red-400'}">
|
||
<i class="ri-${p.status.health ? 'heart' : 'heart-line'}"></i>
|
||
${p.status.health ? '健康' : '异常'}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
${actionsHtml}
|
||
</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();
|
||
}
|
||
|
||
// 初始化
|
||
loadProjects();
|
||
|
||
// 每30秒自动刷新
|
||
setInterval(loadProjects, 30000);
|
||
</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': '删除成功'})
|
||
|
||
|
||
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) |