feat: 项目服务管理面板 v1.0.0
功能: - 项目列表展示(Web服务、Cron任务、CLI工具、插件) - 实时状态检测(端口检测、健康检查) - 服务控制(启动、停止、重启) - 日志查看 - 类型筛选 - 支持动态添加新项目(API) 项目配置存储在 projects.json,便于扩展新项目
This commit is contained in:
828
app.py
Normal file
828
app.py
Normal file
@@ -0,0 +1,828 @@
|
||||
#!/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(os.path.dirname(os.path.abspath(__file__)))
|
||||
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(WORKSPACE_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(WORKSPACE_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)
|
||||
Reference in New Issue
Block a user