Compare commits

...

1 Commits

Author SHA1 Message Date
a155e8fcd9 feat: 更新项目服务、工具函数和模板 2026-06-25 12:11:16 +08:00
13 changed files with 446601 additions and 100 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

446278
logs/app.log

File diff suppressed because it is too large Load Diff

1
logs/watchdog.log Normal file
View File

@@ -0,0 +1 @@
[2026-06-01 20:29:22] 守护进程启动,监控端口 16022

View File

@@ -10,29 +10,35 @@
"id": "pdf-translate-web", "id": "pdf-translate-web",
"name": "PDF翻译助手", "name": "PDF翻译助手",
"type": "web", "type": "web",
"ports": [16021], "ports": [
"directory": "works/pdf-translate-web", 16021
],
"directory": "pdf-translate-web",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown", "start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'python.*pdf-translate-web.*app.py' || true", "stop_cmd": "pkill -f 'python.*pdf-translate-web.*app.py' || true",
"health_url": "http://localhost:16021/", "health_url": "http://localhost:16021/",
"description": "英文PDF翻译中文网站支持用户系统、会员体系", "description": "英文PDF翻译中文网站支持用户系统、会员体系",
"admin_url": "http://localhost:16021/admin", "admin_url": "http://localhost:16021/admin",
"git_repo": "http://121.40.164.32:12007/coder/pdf-translate-web", "git_repo": "http://121.40.164.32:12007/coder/pdf-translate-web",
"version": "v2.0.0" "version": "v2.0.0",
"log_file": ""
}, },
{ {
"id": "project-panel", "id": "project-panel",
"name": "项目服务管理面板", "name": "项目服务管理面板",
"type": "web", "type": "web",
"ports": [16022], "ports": [
"directory": "works/project-panel", 16022
],
"directory": "project-panel",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown", "start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'python.*project-panel.*app.py' || true", "stop_cmd": "pkill -f python.*project-panel.*app.py || true",
"health_url": "http://localhost:16022/", "health_url": "http://localhost:16022/",
"description": "统一管理所有 Web 服务项目,支持 Docker 监控、系统资源监控、Cron 管理", "description": "统一管理所有 Web 服务项目,支持 Docker 监控、系统资源监控、Cron 管理",
"admin_url": "http://localhost:16022", "admin_url": "http://localhost:16022",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/project-panel", "git_repo": "http://121.40.164.32:12007/hz4th_coder/project-panel",
"version": "v3.0.0" "version": "v3.0.0",
"log_file": "logs/app.log"
} }
], ],
"external_services": [ "external_services": [
@@ -40,7 +46,9 @@
"id": "meilisearch", "id": "meilisearch",
"name": "Meilisearch 搜索引擎", "name": "Meilisearch 搜索引擎",
"type": "external", "type": "external",
"ports": [16001], "ports": [
16001
],
"health_url": "http://localhost:16001/health", "health_url": "http://localhost:16001/health",
"description": "高性能搜索引擎,只读监控" "description": "高性能搜索引擎,只读监控"
} }

View File

@@ -68,9 +68,11 @@ def register_project_routes(app):
if project.get('start_cmds'): if project.get('start_cmds'):
for name, info in project['start_cmds'].items(): for name, info in project['start_cmds'].items():
log_message(f"[API-START] 项目={project_id} 命令={info['cmd']} 目录={cwd}")
run_command(info['cmd'], cwd, project_id, f'start-{name}') run_command(info['cmd'], cwd, project_id, f'start-{name}')
else: else:
cmd = project.get('start_cmd', 'python3 app.py') cmd = project.get('start_cmd', 'python3 app.py')
log_message(f"[API-START] 项目={project_id} 命令={cmd} 目录={cwd}")
run_command(cmd, cwd, project_id, 'start') run_command(cmd, cwd, project_id, 'start')
return jsonify({'message': '服务启动中...'}) return jsonify({'message': '服务启动中...'})
@@ -128,7 +130,9 @@ def register_project_routes(app):
'directory': data.get('directory', ''), 'directory': data.get('directory', ''),
'start_cmd': data.get('start_cmd', ''), 'start_cmd': data.get('start_cmd', ''),
'stop_cmd': data.get('stop_cmd', ''), 'stop_cmd': data.get('stop_cmd', ''),
'log_file': data.get('log_file', ''),
'health_url': data.get('health_url', ''), 'health_url': data.get('health_url', ''),
'admin_url': data.get('admin_url', ''),
'description': data.get('description', ''), 'description': data.get('description', ''),
'git_repo': data.get('git_repo', ''), 'git_repo': data.get('git_repo', ''),
'version': data.get('version', 'v1.0.0') 'version': data.get('version', 'v1.0.0')
@@ -152,7 +156,7 @@ def register_project_routes(app):
return jsonify({'error': '项目不存在'}), 404 return jsonify({'error': '项目不存在'}), 404
# 更新字段 # 更新字段
for key in ['name', 'ports', 'directory', 'start_cmd', 'stop_cmd', 'health_url', 'description', 'git_repo', 'version']: for key in ['name', 'ports', 'directory', 'log_file', 'start_cmd', 'stop_cmd', 'health_url', 'admin_url', 'description', 'git_repo', 'version']:
if key in data: if key in data:
project[key] = data[key] project[key] = data[key]
@@ -162,6 +166,55 @@ def register_project_routes(app):
return jsonify({'message': '项目更新成功'}) return jsonify({'message': '项目更新成功'})
@app.route('/api/projects/<project_id>/log')
def api_project_log(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
lines = request.args.get('lines', 200, type=int)
lines = max(1, min(lines, 5000)) # 限制 1-5000 行
log_file = project.get('log_file', '')
if not log_file:
# 默认:项目目录下的 logs/app.log
cwd = project.get('directory', '')
if cwd:
from config import WORKSPACE_DIR
import os
abs_dir = cwd if cwd.startswith('/') else os.path.join(WORKSPACE_DIR, cwd)
log_file = os.path.join(abs_dir, 'logs', 'app.log')
# 支持相对路径和绝对路径
import os
if not log_file.startswith('/'):
cwd = project.get('directory', '')
if cwd:
from config import WORKSPACE_DIR
abs_dir = cwd if cwd.startswith('/') else os.path.join(WORKSPACE_DIR, cwd)
log_file = os.path.join(abs_dir, log_file)
if not os.path.exists(log_file):
return jsonify({'log': f'日志文件不存在: {log_file}'})
try:
# 读取最后 N 行(高效方式)
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
all_lines = f.readlines()
tail_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
content = ''.join(tail_lines)
return jsonify({
'log': content if content else '(日志为空)',
'file': log_file,
'total_lines': len(all_lines),
'shown_lines': len(tail_lines)
})
except Exception as e:
return jsonify({'error': f'读取日志失败: {str(e)}'}), 500
@app.route('/api/projects/<project_id>', methods=['DELETE']) @app.route('/api/projects/<project_id>', methods=['DELETE'])
def api_delete_project(project_id): def api_delete_project(project_id):
"""删除项目""" """删除项目"""

View File

@@ -701,15 +701,16 @@
</div> </div>
</div> </div>
<!-- 新增自定义服务模态框 --> <!-- 新增/编辑自定义服务模态框 -->
<div id="addServiceModal" class="modal-overlay hidden"> <div id="addServiceModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6"> <div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg">新增自定义服务</h3> <h3 id="addServiceModalTitle" class="font-bold text-lg">新增自定义服务</h3>
<button onclick="closeAddServiceModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button> <button onclick="closeAddServiceModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div> </div>
<form id="addServiceForm" onsubmit="saveCustomService(event)"> <form id="addServiceForm" onsubmit="saveCustomService(event)">
<input type="hidden" id="editServiceId">
<div class="mb-3"> <div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">服务名称 *</label> <label class="block text-gray-400 text-sm mb-1">服务名称 *</label>
<input type="text" id="serviceName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如外部API、监控面板"> <input type="text" id="serviceName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如外部API、监控面板">
@@ -912,6 +913,83 @@
</div> </div>
</div> </div>
<!-- 编辑项目配置模态框 -->
<div id="editProjectModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="editProjectModalTitle" class="font-bold text-lg">编辑项目配置</h3>
<button onclick="closeEditProjectModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="editProjectForm" onsubmit="saveProjectEdit(event)">
<input type="hidden" id="editProjectId">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">项目名称</label>
<input type="text" id="editProjectName" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">项目目录 <span class="text-xs text-gray-500">(相对或绝对路径)</span></label>
<input type="text" id="editProjectDir" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="相对works/my-project 或 绝对:/home/user/project">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">端口(逗号分隔)</label>
<input type="text" id="editProjectPorts" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如16022,16023">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">健康检查URL</label>
<input type="text" id="editProjectHealthUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://localhost:16022/">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">后台URL</label>
<input type="text" id="editProjectAdminUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://localhost:16022/admin">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">Git 仓库地址</label>
<input type="text" id="editProjectGitRepo" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://.../project.git">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">版本</label>
<input type="text" id="editProjectVersion" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="v1.0.0">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">📁 日志文件路径 <span class="text-xs text-gray-500">(相对或绝对路径)</span></label>
<input type="text" id="editProjectLogFile" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg font-mono text-sm" placeholder="相对logs/app.log 或绝对路径。留空则默认 项目目录/logs/app.log">
<p class="text-xs text-gray-500 mt-1">服务日志文件的位置,点击「日志」按钮时读取此文件</p>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">📋 启动命令</label>
<textarea id="editProjectStartCmd" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg font-mono text-sm" rows="3" placeholder="例如nohup python3 app.py > logs/app.log 2>&1 & disown"></textarea>
<p class="text-xs text-gray-500 mt-1">服务启动时执行的命令</p>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">🛑 停止命令</label>
<textarea id="editProjectStopCmd" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg font-mono text-sm" rows="2" placeholder="例如pkill -f 'python.*project.*app.py' || true"></textarea>
<p class="text-xs text-gray-500 mt-1">服务停止时执行的命令</p>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">描述</label>
<input type="text" id="editProjectDesc" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeEditProjectModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg">💾 保存</button>
</div>
</form>
</div>
</div>
<!-- 导航按钮 --> <!-- 导航按钮 -->
<div class="nav-buttons"> <div class="nav-buttons">
<button onclick="scrollToTop()" class="nav-btn" title="回到顶部"><i class="ri-arrow-up-line"></i></button> <button onclick="scrollToTop()" class="nav-btn" title="回到顶部"><i class="ri-arrow-up-line"></i></button>
@@ -1404,6 +1482,16 @@
const res = await fetch('/api/projects'); const res = await fetch('/api/projects');
const data = await res.json(); const data = await res.json();
projects = data.projects; projects = data.projects;
// 加载外部服务列表
try {
const extRes = await fetch('/api/external-services');
const extData = await extRes.json();
window._externalServices = extData.services || [];
} catch (e) {
window._externalServices = [];
}
renderProjects(); renderProjects();
updateStats(); updateStats();
updateConnectionStatus(true); updateConnectionStatus(true);
@@ -1544,7 +1632,8 @@
<div class="flex items-center gap-1 text-xs flex-wrap"> <div class="flex items-center gap-1 text-xs flex-wrap">
<a href="${p.url}" target="_blank" class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30">访问</a> <a href="${p.url}" target="_blank" class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30">访问</a>
${p.admin_url ? `<a href="${p.admin_url}" target="_blank" class="text-yellow-400 hover:text-yellow-300 ml-1">后台</a>` : ''} ${p.admin_url ? `<a href="${p.admin_url}" target="_blank" class="text-yellow-400 hover:text-yellow-300 ml-1">后台</a>` : ''}
<button onclick="deleteCustomService('${p.id}')" class="text-red-400 hover:text-red-300 ml-2" title="删除服务"><i class="ri-delete-bin-line"></i></button> <button onclick="editCustomService('${p.id}')" class="text-blue-400 hover:text-blue-300 ml-1" title="编辑服务"><i class="ri-edit-line"></i></button>
<button onclick="deleteCustomService('${p.id}')" class="text-red-400 hover:text-red-300 ml-1" title="删除服务"><i class="ri-delete-bin-line"></i></button>
</div> </div>
</div> </div>
`; `;
@@ -1590,6 +1679,7 @@
<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="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="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> <button onclick="viewLog('${p.id}')" class="btn bg-gray-600 hover:bg-gray-700 px-2 py-0.5 rounded text-xs">日志</button>
<button onclick="editProject('${p.id}')" class="btn bg-blue-600 hover:bg-blue-700 px-2 py-0.5 rounded text-xs">编辑</button>
</div> </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 ? '点击归档' : '点击激活'}"> <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> <i class="ri-${isActive ? 'checkbox-circle' : 'archive'}-line"></i>
@@ -1819,7 +1909,7 @@
return Array.from(checkboxes).map(cb => cb.value); return Array.from(checkboxes).map(cb => cb.value);
} }
function onResourceChange() { function onResourceChange(preselectedServiceId) {
const selected = getSelectedResources(); const selected = getSelectedResources();
const unitEl = document.getElementById('emailRuleThresholdUnit'); const unitEl = document.getElementById('emailRuleThresholdUnit');
const thresholdEl = document.getElementById('emailRuleThreshold'); const thresholdEl = document.getElementById('emailRuleThreshold');
@@ -1833,7 +1923,7 @@
// 显示/隐藏服务选择器 // 显示/隐藏服务选择器
if (hasServiceDown) { if (hasServiceDown) {
serviceSelector.style.display = 'block'; serviceSelector.style.display = 'block';
populateServiceSelector(); populateServiceSelector(preselectedServiceId);
// 服务下线使用分钟作为阈值单位 // 服务下线使用分钟作为阈值单位
unitEl.textContent = '分钟'; unitEl.textContent = '分钟';
thresholdEl.value = 5; thresholdEl.value = 5;
@@ -1872,17 +1962,63 @@
} }
} }
function populateServiceSelector() { function populateServiceSelector(preselectedId) {
const sel = document.getElementById('emailRuleServiceId'); const sel = document.getElementById('emailRuleServiceId');
const currentVal = sel.value; const currentVal = preselectedId || sel.value;
// 收集所有web类型服务
const webServices = projects.filter(p => p.type === 'web'); // 收集所有可监控的服务
let options = '<option value="">-- 请选择要监控的服务 --</option>'; const allServices = [];
webServices.forEach(p => {
const portsStr = p.ports ? p.ports.join(',') : ''; // 1. Web 类型项目
const selected = p.id === currentVal ? ' selected' : ''; const webProjects = projects.filter(p => p.type === 'web');
options += `<option value="${p.id}"${selected}>${p.name} (端口: ${portsStr})</option>`; webProjects.forEach(p => {
allServices.push({
id: p.id,
name: p.name,
ports: p.ports || [],
type: 'web',
description: p.description || ''
});
}); });
// 2. 自定义服务localStorage
const customServices = getCustomServices();
customServices.forEach(s => {
const port = s.port ? [parseInt(s.port)] : [];
allServices.push({
id: s.id,
name: s.name,
ports: port,
type: 'custom',
description: s.description || '自定义外部服务'
});
});
// 3. 外部服务(从 projects.json 中的 external_services
const externalServices = window._externalServices || [];
externalServices.forEach(s => {
allServices.push({
id: s.id,
name: s.name,
ports: s.ports || [],
type: 'external',
description: s.description || ''
});
});
const typeLabels = { web: 'Web服务', custom: '自定义', external: '外部' };
let options = '<option value="">-- 请选择要监控的服务 --</option>';
allServices.forEach(s => {
const portsStr = s.ports.length > 0 ? s.ports.join(',') : '无端口';
const label = s.type !== 'web' ? ` [${typeLabels[s.type]}]` : '';
const selected = s.id === currentVal ? ' selected' : '';
options += `<option value="${s.id}"${selected}>${s.name}${label} (端口: ${portsStr})</option>`;
});
if (allServices.length === 0) {
options += '<option value="" disabled>暂无可用服务</option>';
}
sel.innerHTML = options; sel.innerHTML = options;
} }
@@ -1946,11 +2082,8 @@
const cb = document.querySelector(`.email-rule-resource[value="${r}"]`); const cb = document.querySelector(`.email-rule-resource[value="${r}"]`);
if (cb) cb.checked = true; if (cb) cb.checked = true;
}); });
onResourceChange(); onResourceChange(rule.serviceId);
// 服务下线相关 // 服务下线相关 - serviceId 已通过 onResourceChange 传入
if (rule.serviceId) {
document.getElementById('emailRuleServiceId').value = rule.serviceId;
}
document.getElementById('emailRuleThreshold').value = rule.threshold; document.getElementById('emailRuleThreshold').value = rule.threshold;
document.getElementById('emailRuleTriggerType').value = rule.triggerType || 'single'; document.getElementById('emailRuleTriggerType').value = rule.triggerType || 'single';
document.getElementById('emailRuleTriggerParam').value = rule.triggerParam || 3; document.getElementById('emailRuleTriggerParam').value = rule.triggerParam || 3;
@@ -2261,6 +2394,8 @@
} }
function showAddServiceModal() { function showAddServiceModal() {
document.getElementById('addServiceModalTitle').textContent = '新增自定义服务';
document.getElementById('editServiceId').value = '';
document.getElementById('serviceName').value = ''; document.getElementById('serviceName').value = '';
document.getElementById('servicePort').value = ''; document.getElementById('servicePort').value = '';
document.getElementById('servicePath').value = ''; document.getElementById('servicePath').value = '';
@@ -2271,6 +2406,36 @@
document.getElementById('addServiceModal').classList.remove('hidden'); document.getElementById('addServiceModal').classList.remove('hidden');
} }
function editCustomService(serviceId) {
const services = getCustomServices();
const svc = services.find(s => s.id === serviceId);
if (!svc) return;
document.getElementById('addServiceModalTitle').textContent = `编辑自定义服务: ${svc.name}`;
document.getElementById('editServiceId').value = svc.id;
document.getElementById('serviceName').value = svc.name || '';
document.getElementById('servicePort').value = svc.port || '';
// 尝试从 url 中解析 path
let path = '';
if (svc.url && svc.port) {
const prefix = `http://${externalIp}:${svc.port}`;
if (svc.url.startsWith(prefix)) {
path = svc.url.substring(prefix.length);
}
}
document.getElementById('servicePath').value = path;
document.getElementById('serviceFullUrl').value = svc.url || '';
document.getElementById('serviceAdminUrl').value = svc.admin_url || '';
document.getElementById('serviceDesc').value = svc.description || '';
if (svc.port) {
updateServicePreview();
}
document.getElementById('addServiceModal').classList.remove('hidden');
}
function closeAddServiceModal() { function closeAddServiceModal() {
document.getElementById('addServiceModal').classList.add('hidden'); document.getElementById('addServiceModal').classList.add('hidden');
} }
@@ -2290,6 +2455,7 @@
function saveCustomService(event) { function saveCustomService(event) {
event.preventDefault(); event.preventDefault();
const editId = document.getElementById('editServiceId').value;
const name = document.getElementById('serviceName').value.trim(); const name = document.getElementById('serviceName').value.trim();
const port = document.getElementById('servicePort').value.trim(); const port = document.getElementById('servicePort').value.trim();
const path = document.getElementById('servicePath').value.trim(); const path = document.getElementById('servicePath').value.trim();
@@ -2313,19 +2479,35 @@
} }
const services = getCustomServices(); const services = getCustomServices();
const newService = {
id: 'custom_' + Date.now(),
name: name,
port: port || '',
url: mainUrl,
admin_url: adminUrl || null,
description: desc || '自定义外部服务',
type: 'custom',
isCustom: true
};
services.push(newService);
saveCustomServices(services);
if (editId) {
// 编辑模式:更新已有服务
const idx = services.findIndex(s => s.id === editId);
if (idx === -1) {
alert('服务不存在');
return;
}
services[idx].name = name;
services[idx].port = port || '';
services[idx].url = mainUrl;
services[idx].admin_url = adminUrl || null;
services[idx].description = desc || '自定义外部服务';
} else {
// 新增模式
const newService = {
id: 'custom_' + Date.now(),
name: name,
port: port || '',
url: mainUrl,
admin_url: adminUrl || null,
description: desc || '自定义外部服务',
type: 'custom',
isCustom: true
};
services.push(newService);
}
saveCustomServices(services);
closeAddServiceModal(); closeAddServiceModal();
loadProjects(); loadProjects();
} }
@@ -2393,16 +2575,21 @@
} }
} }
async function viewLog(id) { async function viewLog(id, lines = 200) {
const project = projects.find(p => p.id === id); const project = projects.find(p => p.id === id);
document.getElementById('logTitle').textContent = `${project?.name || id} - 日志`; document.getElementById('logTitle').textContent = `${project?.name || id} - 日志 (最近${lines}行)`;
document.getElementById('logContent').textContent = '加载中...';
document.getElementById('logModal').classList.remove('hidden'); document.getElementById('logModal').classList.remove('hidden');
document.getElementById('logModal').classList.add('flex'); document.getElementById('logModal').classList.add('flex');
try { try {
const res = await fetch(`/api/projects/${id}/log`); const res = await fetch(`/api/projects/${id}/log?lines=${lines}`);
const data = await res.json(); const data = await res.json();
document.getElementById('logContent').textContent = data.log || '暂无日志'; let display = data.log || '暂无日志';
if (data.file) {
display = `📁 日志文件: ${data.file}\n📊 共 ${data.total_lines} 行,显示最近 ${data.shown_lines} 行\n${'─'.repeat(60)}\n\n${display}`;
}
document.getElementById('logContent').textContent = display;
} catch (e) { } catch (e) {
document.getElementById('logContent').textContent = '获取日志失败: ' + e.message; document.getElementById('logContent').textContent = '获取日志失败: ' + e.message;
} }
@@ -2413,6 +2600,86 @@
document.getElementById('logModal').classList.remove('flex'); document.getElementById('logModal').classList.remove('flex');
} }
// ==================== 项目编辑 ====================
function editProject(id) {
const project = projects.find(p => p.id === id);
if (!project || project.type !== 'web') return;
document.getElementById('editProjectModalTitle').textContent = `${project.name} - 编辑配置`;
document.getElementById('editProjectId').value = project.id;
document.getElementById('editProjectName').value = project.name || '';
document.getElementById('editProjectDir').value = project.directory || '';
document.getElementById('editProjectPorts').value = (project.ports || []).join(',');
document.getElementById('editProjectHealthUrl').value = project.health_url || '';
document.getElementById('editProjectAdminUrl').value = project.admin_url || '';
document.getElementById('editProjectGitRepo').value = project.git_repo || '';
document.getElementById('editProjectVersion').value = project.version || '';
document.getElementById('editProjectLogFile').value = project.log_file || '';
document.getElementById('editProjectStartCmd').value = project.start_cmd || '';
document.getElementById('editProjectStopCmd').value = project.stop_cmd || '';
document.getElementById('editProjectDesc').value = project.description || '';
document.getElementById('editProjectModal').classList.remove('hidden');
}
function closeEditProjectModal() {
document.getElementById('editProjectModal').classList.add('hidden');
}
async function saveProjectEdit(event) {
event.preventDefault();
const id = document.getElementById('editProjectId').value;
const name = document.getElementById('editProjectName').value.trim();
const directory = document.getElementById('editProjectDir').value.trim();
const portsStr = document.getElementById('editProjectPorts').value.trim();
const healthUrl = document.getElementById('editProjectHealthUrl').value.trim();
const adminUrl = document.getElementById('editProjectAdminUrl').value.trim();
const gitRepo = document.getElementById('editProjectGitRepo').value.trim();
const version = document.getElementById('editProjectVersion').value.trim();
const logFile = document.getElementById('editProjectLogFile').value.trim();
const startCmd = document.getElementById('editProjectStartCmd').value.trim();
const stopCmd = document.getElementById('editProjectStopCmd').value.trim();
const description = document.getElementById('editProjectDesc').value.trim();
if (!name) { alert('请输入项目名称'); return; }
const ports = portsStr ? portsStr.split(',').map(p => parseInt(p.trim())).filter(p => !isNaN(p)) : [];
const data = {
name,
ports,
directory,
start_cmd: startCmd,
stop_cmd: stopCmd,
health_url: healthUrl,
admin_url: adminUrl,
git_repo: gitRepo,
log_file: logFile,
version,
description
};
try {
const res = await fetch(`/api/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.error) {
alert('保存失败: ' + result.error);
} else {
alert(result.message || '保存成功');
closeEditProjectModal();
loadProjects();
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
// ==================== Cron 管理 ==================== // ==================== Cron 管理 ====================
async function loadCronTasks() { async function loadCronTasks() {

View File

@@ -50,9 +50,11 @@ def load_projects_full():
def save_projects(projects): def save_projects(projects):
"""保存项目配置""" """保存项目配置(保留 server 和 external_services"""
full_data = load_projects_full()
full_data['projects'] = projects
with open(PROJECTS_FILE, 'w', encoding='utf-8') as f: with open(PROJECTS_FILE, 'w', encoding='utf-8') as f:
json.dump({'projects': projects}, f, ensure_ascii=False, indent=2) json.dump(full_data, f, ensure_ascii=False, indent=2)
def check_port(port): def check_port(port):