Files
project-panel/app.py

829 lines
32 KiB
Python
Raw 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>
<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)