Compare commits

...

2 Commits

Author SHA1 Message Date
50341749b6 fix: 使用完整模板,保留所有网页功能
 包含所有原有功能:
- 阈值设置(CPU/内存/磁盘/间隔)
- 邮件规则管理
- 自定义链接添加
- 进程列表显示
- Docker容器监控
- Cron任务管理
- 系统资源实时监控
- 配置导入导出

📝 templates_full.txt: 从v3.3.5提取的完整HTML模板(2366行)
📝 templates.py: 动态加载完整模板并替换版本号
2026-06-01 15:54:45 +08:00
7904117b1d fix: 添加 cron_expression_to_human 函数到 utils.py 2026-06-01 12:31:38 +08:00
13 changed files with 2423 additions and 570 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -30314,3 +30314,6 @@ Press CTRL+C to quit
[2026-06-01 12:29:39] 访问地址: http://localhost:16022
[2026-06-01 12:29:39] 进程PID: 784244
[2026-06-01 12:29:39] ==================================================
[2026-06-01 12:30:38] ⚠️ 进程收到 SIGTERM 信号,即将退出!
[2026-06-01 12:31:16] 项目服务管理面板 v3.4.0 初始化完成
[2026-06-01 12:31:16] 启动 项目服务管理面板,端口: 16022

View File

@@ -1,590 +1,45 @@
#!/usr/bin/env python3
"""
HTML 模板
HTML 模板 - 包含所有完整功能
"""
from config import APP_NAME, APP_VERSION
def get_html_template():
"""返回完整的HTML模板"""
"""返回完整的HTML模板(包含所有原有功能)"""
# 读取完整模板文件
import os
template_file = os.path.join(os.path.dirname(__file__), 'templates_full.txt')
try:
with open(template_file, 'r', encoding='utf-8') as f:
template_content = f.read()
# 替换版本号
template_content = template_content.replace('v2.1', APP_VERSION)
template_content = template_content.replace('v3.3.1', APP_VERSION)
return template_content
except:
# 如果文件不存在,返回基本模板
return get_basic_template()
def get_basic_template():
"""基本模板(备用)"""
return '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>''' + APP_NAME + '''</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.remarkable.ai/css/remixicon.css" rel="stylesheet">
<style>
body { background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); }
.card { background: rgba(30, 41, 59, 0.6); backdrop-filter: blur(10px); border: 1px solid rgba(71, 85, 105, 0.4); }
.btn { transition: all 0.2s; }
.btn:hover { transform: translateY(-1px); }
.status-running { background: rgba(34, 197, 94, 0.2); border-color: rgba(34, 197, 94, 0.5); color: #22c55e; }
.status-stopped { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.5); color: #ef4444; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 50; display: flex; align-items: center; justify-content: center; }
.modal-content { max-width: 600px; width: 90%; max-height: 80vh; overflow: auto; }
</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-6">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
<i class="ri-dashboard-3-line text-blue-400"></i>
''' + APP_NAME + '''
<span class="text-sm text-gray-500">''' + APP_VERSION + '''</span>
</h1>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-gray-400 text-sm">对外IP:</span>
<input type="text" id="externalIp" value="121.40.164.32" class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-sm w-28" onchange="saveExternalIp()">
</div>
<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>
<button onclick="exportConfig()" class="btn bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg flex items-center gap-2">
<i class="ri-download-line"></i> 导出配置
</button>
<label class="btn bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded-lg flex items-center gap-2 cursor-pointer">
<i class="ri-upload-line"></i> 导入配置
<input type="file" accept=".json" onchange="importConfig(event)" class="hidden">
</label>
<div class="flex items-center gap-1">
<span class="text-gray-400 text-xs">间隔:</span>
<input type="number" id="refreshInterval" value="30" min="5" max="300" class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-xs w-12" onchange="updateRefreshInterval()">
<span class="text-gray-500 text-xs">s</span>
</div>
<span id="updateTime" class="text-gray-400 text-sm"></span>
<div id="connectionStatus" class="flex items-center gap-1 px-2 py-1 rounded bg-green-500/20">
<div class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
<span class="text-green-400 text-xs">已连接</span>
</div>
</div>
</div>
<!-- Tab 切换 -->
<div class="flex gap-4 mb-6 border-b border-gray-700">
<button onclick="switchTab('projects')" class="tab-btn active px-4 py-2 text-gray-300 hover:text-white" data-tab="projects">
<i class="ri-apps-line"></i> 项目服务
</button>
<button onclick="switchTab('system')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="system">
<i class="ri-dashboard-2-line"></i> 系统监控
</button>
<button onclick="switchTab('cron')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="cron">
<i class="ri-time-line"></i> Cron管理
</button>
</div>
<!-- 项目服务 Tab -->
<div id="projectsTab" class="tab-content">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 项目卡片将通过JS动态生成 -->
</div>
</div>
<!-- 系统监控 Tab -->
<div id="systemTab" class="tab-content hidden">
<div class="card rounded-xl p-6 mb-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<i class="ri-activity-line text-blue-400"></i> 系统资源监控
</h2>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="realtimeCheck" onchange="toggleRealtimeMonitor()">
<span class="text-sm text-gray-400">实时监控</span>
</label>
</div>
</div>
<!-- CPU -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-gray-400 flex items-center gap-2">
<i class="ri-cpu-line text-blue-400"></i> CPU使用率
</span>
<span id="cpuPercent" class="text-2xl font-bold text-blue-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4">
<div id="cpuBar" class="bg-blue-500 h-4 rounded-full transition-all duration-500" style="width: 0%"></div>
</div>
</div>
<!-- 内存 -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-gray-400 flex items-center gap-2">
<i class="ri-database-2-line text-green-400"></i> 内存使用率
</span>
<span id="memPercent" class="text-2xl font-bold text-green-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4">
<div id="memBar" class="bg-green-500 h-4 rounded-full transition-all duration-500" style="width: 0%"></div>
</div>
</div>
<!-- 磁盘 -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-gray-400 flex items-center gap-2">
<i class="ri-hard-drive-2-line text-orange-400"></i> 磁盘使用率
</span>
<span id="diskPercent" class="text-2xl font-bold text-orange-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4">
<div id="diskBar" class="bg-orange-500 h-4 rounded-full transition-all duration-500" style="width: 0%"></div>
</div>
</div>
<!-- 网速 -->
<div class="grid grid-cols-2 gap-4">
<div>
<div class="flex items-center gap-2 mb-2">
<i class="ri-upload-line text-cyan-400"></i>
<span class="text-gray-400">上传速度</span>
</div>
<span id="uploadSpeed" class="text-xl font-bold text-cyan-400">-</span>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<i class="ri-download-line text-purple-400"></i>
<span class="text-gray-400">下载速度</span>
</div>
<span id="downloadSpeed" class="text-xl font-bold text-purple-400">-</span>
</div>
</div>
</div>
</div>
<!-- Cron管理 Tab -->
<div id="cronTab" class="tab-content hidden">
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<i class="ri-time-line text-blue-400"></i> Cron任务管理
</h2>
<div class="flex gap-2">
<button onclick="syncCronFromSystem()" class="btn bg-gray-600 hover:bg-gray-700 px-3 py-2 rounded-lg flex items-center gap-1">
<i class="ri-refresh-line"></i> 从系统同步
</button>
<button onclick="showAddCronModal()" class="btn bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded-lg flex items-center gap-1">
<i class="ri-add-line"></i> 添加任务
</button>
</div>
</div>
<!-- Cron任务列表 -->
<div id="cronTasksList" class="space-y-3">
<!-- 任务将通过JS动态生成 -->
</div>
</div>
</div>
<h1 class="text-3xl font-bold">''' + APP_NAME + ''' ''' + APP_VERSION + '''</h1>
<p>模板加载失败,请检查 templates_full.txt 文件</p>
</div>
<!-- 添加Cron任务模态框 -->
<div id="addCronModal" class="modal-overlay hidden">
<div class="modal-content card rounded-xl p-6">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="ri-add-circle-line text-blue-400"></i> 添加Cron任务
</h3>
<form onsubmit="addCronTask(event)">
<div class="mb-4">
<label class="text-gray-400 text-sm mb-1 block">任务名称</label>
<input type="text" id="cronName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
</div>
<div class="mb-4">
<label class="text-gray-400 text-sm mb-1 block">Cron表达式</label>
<input type="text" id="cronExpression" required placeholder="0 4 * * *" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
</div>
<div class="mb-4">
<label class="text-gray-400 text-sm mb-1 block">执行命令</label>
<input type="text" id="cronCommand" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
</div>
<div class="mb-4">
<label class="text-gray-400 text-sm mb-1 block">描述</label>
<input type="text" id="cronDescription" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
</div>
<div class="flex gap-2">
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg">添加</button>
<button type="button" onclick="hideAddCronModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
</div>
</form>
</div>
</div>
<script>
let currentTab = 'projects';
let projects = [];
let externalIp = '121.40.164.32';
let thresholds = { cpu: 80, memory: 85, disk: 90, interval: 60 };
// Tab切换
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
btn.classList.toggle('border-b-2', btn.dataset.tab === tab);
btn.classList.toggle('border-blue-500', btn.dataset.tab === tab);
});
document.getElementById('projectsTab').classList.toggle('hidden', tab !== 'projects');
document.getElementById('systemTab').classList.toggle('hidden', tab !== 'system');
document.getElementById('cronTab').classList.toggle('hidden', tab !== 'cron');
if (tab === 'cron') {
loadCronTasks();
}
if (tab === 'system') {
loadThresholds();
loadSystemStats();
}
}
// 加载项目列表
async function loadProjects() {
try {
const res = await fetch('/api/projects');
const data = await res.json();
projects = data.projects;
renderProjects();
updateConnectionStatus(true);
} catch (e) {
updateConnectionStatus(false);
}
}
// 渲染项目卡片
function renderProjects() {
const container = document.querySelector('#projectsTab .grid');
container.innerHTML = projects.map(p => `
<div class="card rounded-xl p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold flex items-center gap-2">
<i class="ri-${p.type === 'web' ? 'app' : 'server'}-line text-blue-400"></i>
${p.name}
</h3>
<span class="px-2 py-1 rounded text-xs ${p.status === 'running' ? 'status-running' : 'status-stopped'}">
${p.status === 'running' ? '运行中' : '已停止'}
</span>
</div>
<p class="text-gray-400 text-sm mb-3">${p.description || ''}</p>
<div class="flex items-center gap-2 text-xs text-gray-500 mb-3">
<i class="ri-git-branch-line"></i> ${p.version || 'v1.0'}
<span class="ml-2">端口: ${p.ports.join(', ')}</span>
</div>
<div class="flex gap-2">
${p.type === 'web' ? `
<button onclick="startProject('${p.id}')" class="btn bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm">
<i class="ri-play-line"></i>
</button>
<button onclick="stopProject('${p.id}')" class="btn bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm">
<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">
<i class="ri-restart-line"></i>
</button>
` : ''}
<a href="${p.health_url || '#'}" target="_blank" class="btn bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded text-sm">
<i class="ri-external-link-line"></i>
</a>
</div>
</div>
`).join('');
document.getElementById('updateTime').textContent = new Date().toLocaleTimeString();
}
// 项目操作
async function startProject(id) {
await fetch(`/api/projects/${id}/start`, { method: 'POST' });
setTimeout(loadProjects, 2000);
}
async function stopProject(id) {
await fetch(`/api/projects/${id}/stop`, { method: 'POST' });
setTimeout(loadProjects, 1000);
}
async function restartProject(id) {
await fetch(`/api/projects/${id}/restart`, { method: 'POST' });
setTimeout(loadProjects, 3000);
}
// 加载Cron任务
async function loadCronTasks() {
try {
const res = await fetch('/api/cron/tasks');
const data = await res.json();
renderCronTasks(data.tasks);
} catch (e) {
console.error('加载Cron任务失败:', e);
}
}
// 渲染Cron任务列表
function renderCronTasks(tasks) {
const container = document.getElementById('cronTasksList');
container.innerHTML = tasks.map(t => `
<div class="card rounded-lg p-4 flex items-center justify-between">
<div>
<h4 class="font-bold">${t.name}</h4>
<p class="text-gray-400 text-sm">${t.human_time || t.expression}</p>
<p class="text-gray-500 text-xs mt-1">${t.command}</p>
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-1 rounded text-xs ${t.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}">
${t.enabled ? '启用' : '禁用'}
</span>
<button onclick="toggleCron(${t.id})" class="btn bg-gray-600 hover:bg-gray-700 px-2 py-1 rounded text-sm">
<i class="ri-toggle-line"></i>
</button>
<button onclick="deleteCron(${t.id})" class="btn bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-sm">
<i class="ri-delete-bin-line"></i>
</button>
</div>
</div>
`).join('');
}
// Cron操作
async function toggleCron(id) {
await fetch(`/api/cron/tasks/${id}/toggle`, { method: 'POST' });
loadCronTasks();
}
async function deleteCron(id) {
if (confirm('确定删除此任务?')) {
await fetch(`/api/cron/tasks/${id}`, { method: 'DELETE' });
loadCronTasks();
}
}
async function syncCronFromSystem() {
await fetch('/api/cron/sync', { method: 'POST' });
loadCronTasks();
}
function showAddCronModal() {
document.getElementById('addCronModal').classList.remove('hidden');
}
function hideAddCronModal() {
document.getElementById('addCronModal').classList.add('hidden');
}
async function addCronTask(e) {
e.preventDefault();
const data = {
name: document.getElementById('cronName').value,
expression: document.getElementById('cronExpression').value,
command: document.getElementById('cronCommand').value,
description: document.getElementById('cronDescription').value
};
await fetch('/api/cron/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
hideAddCronModal();
loadCronTasks();
}
// 系统监控
async function loadSystemStats() {
try {
const res = await fetch('/api/system/stats');
const data = await res.json();
if (data.available) {
document.getElementById('cpuPercent').textContent = data.cpu.percent + '%';
document.getElementById('cpuBar').style.width = data.cpu.percent + '%';
document.getElementById('memPercent').textContent = data.memory.percent + '%';
document.getElementById('memBar').style.width = data.memory.percent + '%';
document.getElementById('diskPercent').textContent = data.disk.percent + '%';
document.getElementById('diskBar').style.width = data.disk.percent + '%';
document.getElementById('uploadSpeed').textContent = data.network.upload_speed_kb + ' KB/s';
document.getElementById('downloadSpeed').textContent = data.network.download_speed_kb + ' KB/s';
}
} catch (e) {
console.error('加载系统状态失败:', e);
}
}
async function loadThresholds() {
try {
const response = await fetch('/api/alerts');
const config = await response.json();
if (config.thresholds) {
thresholds = config.thresholds;
}
} catch (e) {
console.error('加载阈值配置失败:', e);
}
}
// 导出配置
async function exportConfig() {
try {
const response = await fetch('/api/config/export');
const config = await response.json();
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `project-panel-config-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
alert('配置导出成功!');
} catch (e) {
alert('导出失败: ' + e.message);
}
}
// 导入配置
async function importConfig(event) {
const file = event.target.files[0];
if (!file) return;
if (!confirm('导入配置将覆盖当前所有设置,是否继续?')) {
event.target.value = '';
return;
}
try {
const text = await file.text();
const config = JSON.parse(text);
const response = await fetch('/api/config/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const result = await response.json();
if (result.error) {
alert('导入失败: ' + result.error);
} else {
alert(`配置导入成功!\\n项目数: ${result.projects_count}\\n外部服务数: ${result.services_count}`);
location.reload();
}
} catch (e) {
alert('导入失败: ' + e.message);
}
event.target.value = '';
}
// 网页设置
async function saveExternalIp() {
externalIp = document.getElementById('externalIp').value.trim();
try {
await fetch('/api/web-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ external_ip: externalIp, refresh_interval: parseInt(document.getElementById('refreshInterval').value) || 30 })
});
} catch (e) {
console.error('保存失败:', e);
}
renderProjects();
}
function updateRefreshInterval() {
const seconds = Math.max(5, Math.min(300, parseInt(document.getElementById('refreshInterval').value) || 30));
document.getElementById('refreshInterval').value = seconds;
fetch('/api/web-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ external_ip: externalIp, refresh_interval: seconds })
}).catch(e => console.error('保存失败:', e));
clearInterval(refreshTimer);
refreshTimer = setInterval(() => { if (currentTab === 'projects') loadProjects(); }, seconds * 1000);
}
function refreshAll() {
if (currentTab === 'projects') {
loadProjects();
} else if (currentTab === 'cron') {
loadCronTasks();
} else {
loadSystemStats();
}
}
function updateConnectionStatus(ok) {
const statusEl = document.getElementById('connectionStatus');
if (ok) {
statusEl.innerHTML = '<div class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div><span class="text-green-400 text-xs">已连接</span>';
statusEl.className = 'flex items-center gap-1 px-2 py-1 rounded bg-green-500/20';
} else {
statusEl.innerHTML = '<div class="w-2 h-2 rounded-full bg-red-400"></div><span class="text-red-400 text-xs">断开</span>';
statusEl.className = 'flex items-center gap-1 px-2 py-1 rounded bg-red-500/20';
}
}
// 初始化
loadProjects();
loadWebSettings();
let refreshIntervalMs = 30 * 1000;
let refreshTimer = setInterval(() => { if (currentTab === 'projects') loadProjects(); }, refreshIntervalMs);
// 加载网页设置
async function loadWebSettings() {
try {
const response = await fetch('/api/web-settings');
const settings = await response.json();
if (settings.external_ip) {
externalIp = settings.external_ip;
document.getElementById('externalIp').value = settings.external_ip;
}
if (settings.refresh_interval) {
document.getElementById('refreshInterval').value = settings.refresh_interval;
clearInterval(refreshTimer);
refreshIntervalMs = settings.refresh_interval * 1000;
refreshTimer = setInterval(() => { if (currentTab === 'projects') loadProjects(); }, refreshIntervalMs);
}
} catch (e) {
console.error('加载网页设置失败:', e);
}
}
// 实时监控
let realtimeTimer = null;
function toggleRealtimeMonitor() {
const checked = document.getElementById('realtimeCheck').checked;
if (checked) {
realtimeTimer = setInterval(loadSystemStats, 2000);
} else {
clearInterval(realtimeTimer);
realtimeTimer = null;
}
}
// 定期检查连接状态
setInterval(() => {
fetch('/api/projects').then(() => updateConnectionStatus(true)).catch(() => updateConnectionStatus(false));
}, 10000);
</script>
</body>
</html>
'''
</html>'''

2366
templates_full.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -195,6 +195,35 @@ def parse_cron_expression(expr):
return ' '.join(desc) if desc else expr
def cron_expression_to_human(expr):
"""更友好的人类可读格式"""
parts = expr.split()
if len(parts) != 5:
return expr
minute, hour, day, month, weekday = parts
# 特殊模式
patterns = [
('0 4 * * *', '每天凌晨4点'),
('0 0 * * *', '每天午夜0点'),
('*/5 * * * *', '每5分钟'),
('*/10 * * * *', '每10分钟'),
('*/20 * * * *', '每20分钟'),
('*/30 * * * *', '每30分钟'),
('0 */1 * * *', '每小时'),
('0 */2 * * *', '每2小时'),
('0 9 * * 1', '每周一9点'),
('0 9 * * 1-5', '每周一到五9点'),
]
for pattern, desc in patterns:
if expr == pattern:
return desc
# 通用解析
return parse_cron_expression(expr)
def guess_cron_name(command):
"""从命令推断任务名称"""