Files
project-panel/templates_full.txt
hz4th_coder 779f42c98c fix: 服务发现支持无权限查看的端口(Docker host网络等)
- ss -tlnp 输出中没有 pid= 的端口不再跳过
- pid 为 None 时显示「受权限限制」, 进程名标记「未知进程」
- 当前发现: 8088, 9119, 16031, 16067, 18789
2026-06-01 18:32:10 +08:00

2939 lines
160 KiB
Plaintext
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>项目服务管理面板 v3.3.1</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>">
<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; }
.nav-buttons { position: fixed; right: 20px; bottom: 20px; z-index: 100; display: flex; flex-direction: column; gap: 8px; }
.nav-btn { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; background: #334155; border: 1px solid #475569; }
.nav-btn:hover { background: #475569; transform: scale(1.1); }
.nav-btn i { font-size: 20px; color: #94a3b8; }
.nav-btn:hover i { color: #f1f5f9; }
.filter-bar { position: sticky; top: 0; z-index: 50; display: flex; gap: 8px; padding: 12px 0; background: #0f172a; margin-bottom: 16px; flex-wrap: wrap; }
.filter-bar-btn { display: flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; background: #1e293b; border: 1px solid #334155; color: #94a3b8; font-size: 14px; cursor: pointer; transition: all 0.2s; }
.filter-bar-btn:hover { background: #334155; color: #f1f5f9; }
.filter-bar-btn.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
.filter-bar-btn.add-btn { background: #22c55e; border-color: #22c55e; color: #fff; }
.filter-bar-btn.add-btn:hover { background: #16a34a; }
.realtime-toggle { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 8px; background: #1e293b; border: 1px solid #334155; }
.realtime-toggle.active { background: #22c55e/20; border-color: #22c55e; }
.speed-badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 12px; border-radius: 6px; background: #334155; font-size: 13px; }
.speed-badge.upload { color: #f97316; }
.speed-badge.download { color: #3b82f6; }
.threshold-section { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.threshold-item { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid #334155; }
.threshold-item:last-child { border-bottom: none; }
.notification-toggle { display: flex; align-items: center; gap: 8px; padding: 12px; background: #334155; border-radius: 8px; margin-top: 12px; }
.notification-toggle label { cursor: pointer; }
.threshold-label { flex: 1; display: flex; align-items: center; gap: 8px; }
.threshold-input { width: 60px; background: #334155; border: 1px solid #475569; border-radius: 6px; padding: 4px 8px; color: #f1f5f9; text-align: center; }
.threshold-input:focus { outline: none; border-color: #3b82f6; }
.threshold-icon { font-size: 18px; }
.threshold-warning { color: #f97316; }
.alert-popup { position: fixed; top: 20px; right: 20px; z-index: 1000; background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); border-radius: 12px; padding: 16px 20px; color: white; box-shadow: 0 10px 40px rgba(0,0,0,0.3); animation: slideIn 0.3s ease-out; max-width: 320px; }
.alert-popup .alert-title { font-weight: bold; font-size: 16px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
.alert-popup .alert-content { font-size: 14px; }
.alert-popup .alert-close { position: absolute; top: 8px; right: 12px; cursor: pointer; opacity: 0.7; }
.alert-popup .alert-close:hover { opacity: 1; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
.tab-btn { border-bottom: 2px solid transparent; }
.tab-btn.active { border-bottom-color: #3b82f6; }
.cron-card { transition: all 0.2s; }
.cron-card:hover { border-color: #475569; }
.history-item { border-left: 3px solid #334155; padding-left: 12px; }
.history-item.rollback { border-left-color: #f59e0b; }
.history-item.update { border-left-color: #3b82f6; }
.history-item.create { border-left-color: #22c55e; }
.history-item.delete { border-left-color: #ef4444; }
.history-item.toggle { border-left-color: #8b5cf6; }
.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>
项目服务管理面板
<span class="text-sm text-gray-500">v2.1</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-timer-line"></i> Cron 管理
</button>
<button onclick="switchTab('docker')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="docker">
<i class="ri-ship-line"></i> Docker 监控
</button>
</div>
<!-- 项目服务 Tab -->
<div id="projectsTab">
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<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="filter-bar">
<button onclick="filterType('all')" class="filter-bar-btn active" data-type="all"><i class="ri-apps-line"></i> 全部</button>
<button onclick="filterType('web')" class="filter-bar-btn" data-type="web"><i class="ri-server-line"></i> Web服务</button>
<button onclick="filterType('custom')" class="filter-bar-btn" data-type="custom"><i class="ri-global-line"></i> 自定义</button>
<button onclick="filterType('cli')" class="filter-bar-btn" data-type="cli"><i class="ri-terminal-box-line"></i> CLI工具</button>
<button onclick="filterType('extension')" class="filter-bar-btn" data-type="extension"><i class="ri-chrome-line"></i> 插件</button>
<button onclick="showAddServiceModal()" class="filter-bar-btn add-btn"><i class="ri-add-circle-line"></i> 新增服务</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 id="discoverySection" class="hidden mt-4 pt-4 border-t border-gray-600">
<div class="flex items-center justify-between mb-3">
<h3 class="text-gray-200 text-sm font-semibold flex items-center gap-2">
<i class="ri-radar-line text-cyan-400"></i> 发现未注册服务
</h3>
<div class="flex items-center gap-2">
<span id="discoveryCount" class="text-xs text-gray-500"></span>
<button onclick="scanServices()" class="btn bg-cyan-600 hover:bg-cyan-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-scan-line"></i> 扫描
</button>
</div>
</div>
<div id="discoveryList" class="space-y-2">
<div class="text-gray-500 text-xs text-center py-2">点击「扫描」发现本机未注册的新服务</div>
</div>
</div>
</div>
<!-- 系统资源 Tab -->
<div id="systemTab" class="hidden">
<!-- 系统概览 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<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="sysUptime" class="text-lg font-bold text-green-400">-</p>
</div>
<i class="ri-time-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">启动时间</p>
<p id="sysBootTime" class="text-lg font-bold text-blue-400">-</p>
</div>
<i class="ri-calendar-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="sysProcessCount" class="text-lg font-bold text-yellow-400">-</p>
</div>
<i class="ri-terminal-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">CPU核心</p>
<p id="sysCpuCount" class="text-lg font-bold text-purple-400">-</p>
</div>
<i class="ri-cpu-line text-3xl text-purple-400 opacity-50"></i>
</div>
</div>
</div>
<!-- CPU & 内存 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2"><i class="ri-cpu-line text-blue-400"></i> CPU 使用率</h3>
<span id="cpuPercent" class="text-2xl font-bold text-blue-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4 mb-2">
<div id="cpuBar" class="bg-blue-500 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-gray-400 text-sm">物理核心: <span id="cpuPhysical">-</span> | 逻辑核心: <span id="cpuLogical">-</span></p>
<div class="mt-3 pt-3 border-t border-gray-700">
<p class="text-gray-400 text-sm flex items-center gap-4">
<span><i class="ri-temp-hot-line text-red-400"></i> CPU0: <span id="cpuTemp0" class="text-white font-medium">-</span>°C</span>
<span><i class="ri-temp-hot-line text-red-400"></i> CPU1: <span id="cpuTemp1" class="text-white font-medium">-</span>°C</span>
</p>
</div>
</div>
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2"><i class="ri-database-2-line text-green-400"></i> 内存使用</h3>
<span id="memPercent" class="text-2xl font-bold text-green-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4 mb-2">
<div id="memBar" class="bg-green-500 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-gray-400 text-sm">已用: <span id="memUsed">-</span> GB / 总量: <span id="memTotal">-</span> GB | 可用: <span id="memAvailable">-</span> GB</p>
</div>
</div>
<!-- 磁盘 & 网络 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2"><i class="ri-hard-drive-2-line text-orange-400"></i> 磁盘使用</h3>
<span id="diskPercent" class="text-2xl font-bold text-orange-400">-</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-4 mb-2">
<div id="diskBar" class="bg-orange-500 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-gray-400 text-sm">已用: <span id="diskUsed">-</span> GB / 总量: <span id="diskTotal">-</span> GB | 剩余: <span id="diskFree">-</span> GB</p>
</div>
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2"><i class="ri-wifi-line text-cyan-400"></i> 网络流量</h3>
</div>
<!-- 实时网速 -->
<div class="flex gap-3 mb-4">
<span class="speed-badge upload"><i class="ri-upload-line"></i> ↑ <span id="netSentSpeed">-</span></span>
<span class="speed-badge download"><i class="ri-download-line"></i> ↓ <span id="netRecvSpeed">-</span></span>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-gray-400 text-sm mb-1">累计发送</p>
<p id="netSent" class="text-xl font-bold text-cyan-400">-</p>
<p class="text-gray-500 text-xs">MB</p>
</div>
<div>
<p class="text-gray-400 text-sm mb-1">累计接收</p>
<p id="netRecv" class="text-xl font-bold text-purple-400">-</p>
<p class="text-gray-500 text-xs">MB</p>
</div>
</div>
</div>
</div>
<!-- 阈值监控设置 -->
<div class="threshold-section">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-gray-200 flex items-center gap-2">
<i class="ri-alarm-warning-line text-orange-400"></i> 监控阈值设置
</h3>
<span class="text-xs text-gray-500">超过阈值将弹窗警告</span>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-cpu-line threshold-icon text-blue-400"></i>
<span class="text-gray-300 text-sm">CPU 使用率</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdCpu" class="threshold-input" min="0" max="100" value="80" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">%</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-temp-hot-line threshold-icon text-red-400"></i>
<span class="text-gray-300 text-sm">CPU 温度</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdCpuTemp" class="threshold-input" min="30" max="120" value="80" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">°C</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-database-2-line threshold-icon text-green-400"></i>
<span class="text-gray-300 text-sm">内存使用率</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdMemory" class="threshold-input" min="0" max="100" value="85" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">%</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-hard-drive-2-line threshold-icon text-orange-400"></i>
<span class="text-gray-300 text-sm">磁盘使用率</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdDisk" class="threshold-input" min="0" max="100" value="90" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">%</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-timer-line threshold-icon text-purple-400"></i>
<span class="text-gray-300 text-sm">警告间隔</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdInterval" class="threshold-input" min="10" max="300" value="60" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">秒</span>
</div>
</div>
<!-- 桌面通知开关 -->
<div class="notification-toggle">
<input type="checkbox" id="enableDesktopNotify" onchange="toggleDesktopNotify()" class="w-4 h-4 cursor-pointer">
<label for="enableDesktopNotify" class="text-gray-300 text-sm cursor-pointer">
<i class="ri-notification-line text-yellow-400"></i> 系统桌面通知
</label>
<span id="notifyStatus" class="text-xs text-gray-500 ml-1"></span>
</div>
<!-- 邮件通知规则 -->
<div class="mt-4 pt-4 border-t border-gray-600">
<div class="flex items-center justify-between mb-3">
<h4 class="text-gray-200 text-sm font-medium flex items-center gap-2">
<i class="ri-mail-line text-green-400"></i> 邮件通知规则
</h4>
<div class="flex items-center gap-2">
<button onclick="showTestEmailModal()" class="btn bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-send-plane-line"></i> 测试发送
</button>
<button onclick="showAddEmailRuleModal()" class="btn bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-add-line"></i> 添加规则
</button>
</div>
</div>
<div id="emailRulesList" class="space-y-2 text-sm">
<div class="text-gray-500 text-center py-4">暂无邮件通知规则</div>
</div>
</div>
</div>
<!-- 控制按钮 -->
<div class="flex gap-3 mb-4 items-center">
<button onclick="loadSystemStats()" 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>
<div class="realtime-toggle" id="realtimeToggle">
<input type="checkbox" id="realtimeCheck" onchange="toggleRealtime()" class="w-4 h-4 cursor-pointer">
<label for="realtimeCheck" class="text-gray-300 text-sm cursor-pointer">实时监控</label>
<select id="realtimeIntervalSelect" class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-xs ml-2 cursor-pointer" onchange="updateRealtimeInterval()">
<option value="1">1秒</option>
<option value="2" selected>2秒</option>
<option value="3">3秒</option>
<option value="5">5秒</option>
<option value="10">10秒</option>
</select>
<span id="realtimeIndicator" class="text-xs text-green-400 ml-1 hidden">●</span>
</div>
<div class="flex items-center gap-2 ml-4">
<input type="checkbox" id="showProcessListCheck" class="w-4 h-4 cursor-pointer" onchange="toggleProcessList()">
<label for="showProcessListCheck" class="text-gray-300 text-xs cursor-pointer">显示进程列表</label>
</div>
</div>
<!-- 进程列表(实时监控时显示) -->
<div id="processListSection" class="hidden mt-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-gray-200 flex items-center gap-2">
<i class="ri-bar-chart-horizontal text-purple-400"></i> CPU占用最高的进程
</h3>
<div class="flex items-center gap-2">
<span class="text-gray-400 text-xs">显示数量:</span>
<select id="processLimitSelect" class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-xs" onchange="loadTopProcesses()">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="30">30</option>
</select>
</div>
</div>
<div id="processList" class="space-y-2">
<div class="text-gray-500 text-center py-4"><i class="ri-loader-4-line animate-spin"></i> 加载中...</div>
</div>
</div>
</div>
<!-- Cron 管理 Tab -->
<div id="cronTab" class="hidden">
<!-- Cron 统计 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<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="cronTotal" class="text-2xl font-bold text-orange-400">-</p>
</div>
<i class="ri-timer-line text-3xl text-orange-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="cronEnabled" 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">禁用</p>
<p id="cronDisabled" class="text-2xl font-bold text-red-400">-</p>
</div>
<i class="ri-pause-circle-line text-3xl text-red-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="cronHistoryCount" class="text-2xl font-bold text-purple-400">-</p>
</div>
<i class="ri-history-line text-3xl text-purple-400 opacity-50"></i>
</div>
</div>
</div>
<!-- Cron 操作按钮 -->
<div class="flex gap-2 mb-4">
<button onclick="showAddCronModal()" class="btn bg-green-600 hover:bg-green-700 px-3 py-2 rounded-lg flex items-center gap-1">
<i class="ri-add-line"></i> 添加任务
</button>
<button onclick="syncFromSystem()" class="btn bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded-lg flex items-center gap-1">
<i class="ri-refresh-line"></i> 从系统同步
</button>
<button onclick="loadCronTasks()" class="btn bg-gray-600 hover:bg-gray-700 px-3 py-2 rounded-lg flex items-center gap-1">
<i class="ri-reload-line"></i> 刷新列表
</button>
<div class="flex items-center gap-2 ml-4">
<span class="text-gray-400 text-sm">执行日志:</span>
<input type="checkbox" id="enableLogs" onchange="toggleLogs()" class="w-4 h-4">
</div>
</div>
<!-- Cron 任务列表 -->
<div id="cronTasksList" class="space-y-3">
<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>
<!-- Docker 监控 Tab -->
<div id="dockerTab" class="hidden">
<!-- Docker 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">总容器数</p>
<p id="dockerContainersTotal" class="text-xl font-bold text-blue-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">运行中</p>
<p id="dockerContainersRunning" class="text-xl font-bold text-green-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">已停止</p>
<p id="dockerContainersStopped" class="text-xl font-bold text-red-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">总镜像数</p>
<p id="dockerImagesTotal" class="text-xl font-bold text-purple-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">磁盘占用</p>
<p id="dockerDiskSize" class="text-xl font-bold text-yellow-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">可回收</p>
<p id="dockerReclaimable" class="text-xl font-bold text-orange-400">-</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2 mb-4 flex-wrap">
<button onclick="loadDockerData()" class="btn bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
<i class="ri-refresh-line"></i> 刷新
</button>
<button onclick="dockerPrune('containers')" class="btn bg-yellow-600 hover:bg-yellow-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
<i class="ri-delete-bin-line"></i> 清理容器
</button>
<button onclick="dockerPrune('images')" class="btn bg-orange-600 hover:bg-orange-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
<i class="ri-image-line"></i> 清理镜像
</button>
<button onclick="dockerPrune('all')" class="btn bg-red-600 hover:bg-red-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
<i class="ri-broom-line"></i> 全部清理
</button>
<span class="text-gray-500 text-xs flex items-center ml-2">版本: <span id="dockerVersion" class="text-gray-300 ml-1">-</span></span>
</div>
<!-- 容器列表 -->
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
<i class="ri-computer-line text-blue-400"></i> 容器列表
<span id="dockerContainerCount" class="text-xs text-gray-500"></span>
</h3>
<div id="dockerContainersList" class="space-y-2 mb-6">
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
</div>
<!-- 镜像列表 -->
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
<i class="ri-stack-line text-purple-400"></i> 镜像列表
<span id="dockerImageCount" class="text-xs text-gray-500"></span>
</h3>
<div id="dockerImagesList" class="space-y-1">
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
</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>
<!-- 添加/编辑 Cron 模态框 -->
<div id="cronModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="cronModalTitle" class="font-bold text-lg">添加 Cron 任务</h3>
<button onclick="closeCronModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="cronForm" onsubmit="saveCronTask(event)">
<input type="hidden" id="cronTaskId">
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">任务名称 *</label>
<input type="text" id="cronName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:服务监控">
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">时间表达式 *</label>
<input type="text" id="cronExpression" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如0 4 * * * (每天凌晨4点)">
<p id="cronHumanTime" class="text-sm text-blue-400 mt-1"></p>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">执行命令 *</label>
<textarea id="cronCommand" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" rows="3" placeholder="例如python3 /path/to/script.py"></textarea>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">任务分类</label>
<select id="cronCategory" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<option value="monitor">系统监控</option>
<option value="backup">数据备份</option>
<option value="cleanup">清理任务</option>
<option value="report">报表生成</option>
<option value="sync">数据同步</option>
<option value="notification">通知提醒</option>
<option value="other">其他任务</option>
</select>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">说明备注</label>
<textarea id="cronDescription" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" rows="2" placeholder="任务的详细说明..."></textarea>
</div>
<div class="flex items-center gap-2 mb-4">
<input type="checkbox" id="cronEnabled" checked class="w-4 h-4">
<label class="text-gray-300">启用任务</label>
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeCronModal()" 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 id="historyModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="historyModalTitle" class="font-bold text-lg">历史版本</h3>
<button onclick="closeHistoryModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<div id="historyList" class="space-y-3"></div>
</div>
</div>
<!-- 自定义链接模态框 -->
<div id="linkModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="linkModalTitle" class="font-bold text-lg">添加自定义链接</h3>
<button onclick="closeLinkModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="linkForm" onsubmit="saveCustomLink(event)">
<input type="hidden" id="linkProjectId">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">链接名称 *</label>
<input type="text" id="linkName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如API文档、管理后台">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">端口使用统一IP</label>
<input type="text" id="linkPort" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如19001" onchange="updateLinkPreview()">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">路径(可选)</label>
<input type="text" id="linkPath" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:/admin /api/docs" onchange="updateLinkPreview()">
<p id="linkPreview" class="text-sm text-cyan-400 mt-1"></p>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">完整URL不填则用端口生成</label>
<input type="text" id="linkFullUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://192.168.2.17:19000/docs">
</div>
<div id="existingLinks" class="mb-4"></div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeLinkModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg">添加</button>
</div>
</form>
</div>
</div>
<!-- 新增自定义服务模态框 -->
<div id="addServiceModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 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>
</div>
<form id="addServiceForm" onsubmit="saveCustomService(event)">
<div class="mb-3">
<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、监控面板">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">端口使用统一IP</label>
<input type="text" id="servicePort" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如8080" onchange="updateServicePreview()">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">路径(可选)</label>
<input type="text" id="servicePath" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:/admin" onchange="updateServicePreview()">
<p id="servicePreview" class="text-sm text-cyan-400 mt-1"></p>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">完整URL不填则用端口生成</label>
<input type="text" id="serviceFullUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://external.com:8080">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">后台URL可选</label>
<input type="text" id="serviceAdminUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如http://external.com:8080/admin">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">描述</label>
<input type="text" id="serviceDesc" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="简要描述">
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeAddServiceModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg">添加</button>
</div>
</form>
</div>
</div>
<!-- 邮件通知规则模态框 -->
<div id="emailRuleModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="emailRuleModalTitle" class="font-bold text-lg">添加邮件通知规则</h3>
<button onclick="closeEmailRuleModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="emailRuleForm" onsubmit="saveEmailRule(event)">
<input type="hidden" id="emailRuleId">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">规则名称 *</label>
<input type="text" id="emailRuleName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如CPU告警通知">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">监控资源 *(可多选组合)</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="cpu" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-cpu-line text-blue-400"></i>
<span class="text-gray-200 text-sm">CPU 使用率</span>
</label>
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="cpu_temp" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-temp-hot-line text-red-400"></i>
<span class="text-gray-200 text-sm">CPU 温度</span>
</label>
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="memory" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-database-2-line text-green-400"></i>
<span class="text-gray-200 text-sm">内存使用率</span>
</label>
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="disk" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-hard-drive-2-line text-orange-400"></i>
<span class="text-gray-200 text-sm">磁盘使用率</span>
</label>
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700 col-span-2">
<input type="checkbox" value="service_down" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-shut-down-line text-yellow-400"></i>
<span class="text-gray-200 text-sm">服务下线监控</span>
<span class="text-gray-500 text-xs">检测指定服务长期不在线</span>
</label>
</div>
<p class="text-gray-500 text-xs mt-1">勾选多项可随机组合监控,任一资源超标即触发通知</p>
</div>
<!-- 服务选择器(仅当勾选「服务下线监控」时显示) -->
<div class="mb-3" id="serviceDownSelector" style="display:none;">
<label class="block text-gray-400 text-sm mb-1">监控服务 *</label>
<select id="emailRuleServiceId" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" onchange="onServiceDownChange()">
<option value="">-- 请选择要监控的服务 --</option>
</select>
<p class="text-gray-500 text-xs mt-1">选择需要监控下线状态的服务</p>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">触发阈值 *</label>
<div class="flex items-center gap-2">
<input type="number" id="emailRuleThreshold" required min="1" class="w-20 bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="80">
<span class="text-gray-400" id="emailRuleThresholdUnit">% / °C</span>
</div>
<p class="text-gray-500 text-xs mt-1" id="emailRuleThresholdHint">对不同资源类型使用统一阈值如CPU用%则填百分比温度用°C则填温度值</p>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">触发机制 *</label>
<select id="emailRuleTriggerType" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" onchange="updateTriggerParams()">
<option value="single">一次性触发 - 超过阈值立即通知</option>
<option value="continuous">连续触发 - 连续超过阈值N次</option>
<option value="duration">持续时间触发 - 累计超过阈值N秒</option>
<option value="average">平均值触发 - 最近N次平均值超过阈值</option>
</select>
</div>
<div class="mb-3" id="triggerParamsDiv" style="display:none;">
<label class="block text-gray-400 text-sm mb-1" id="triggerParamsLabel">触发参数</label>
<div class="flex items-center gap-2">
<input type="number" id="emailRuleTriggerParam" min="1" max="100" value="3" class="w-20 bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<span class="text-gray-400" id="triggerParamUnit">次</span>
</div>
<p class="text-gray-500 text-xs mt-1" id="triggerParamHint">连续超过阈值多少次才触发</p>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">收件邮箱 *</label>
<input type="email" id="emailRuleAddress" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="alert@example.com">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">通知间隔</label>
<div class="flex items-center gap-2">
<input type="number" id="emailRuleInterval" min="60" max="3600" value="300" class="w-20 bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<span class="text-gray-400">秒最少60秒</span>
</div>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">静默时间区间</label>
<p class="text-gray-500 text-xs mb-2">在此时间段内不发送邮件通知(如夜间休息时段)</p>
<div class="flex items-center gap-2">
<input type="time" id="emailRuleSilentStart" class="bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="23:00">
<span class="text-gray-400">至</span>
<input type="time" id="emailRuleSilentEnd" class="bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="08:00">
<input type="checkbox" id="emailRuleSilentEnabled" class="w-4 h-4 cursor-pointer ml-2">
<label for="emailRuleSilentEnabled" class="text-gray-300 text-xs cursor-pointer">启用静默</label>
</div>
</div>
<div class="mb-3 flex items-center gap-2">
<input type="checkbox" id="emailRuleEnabled" checked class="w-4 h-4 cursor-pointer">
<label for="emailRuleEnabled" class="text-gray-300 text-sm cursor-pointer">启用此规则</label>
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeEmailRuleModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg">保存</button>
</div>
</form>
</div>
</div>
<!-- 测试邮件发送模态框 -->
<div id="testEmailModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6" style="max-width: 420px;">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg"><i class="ri-send-plane-line text-blue-400"></i> 测试邮件发送</h3>
<button onclick="closeTestEmailModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="testEmailForm" onsubmit="sendTestEmail(event)">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">收件邮箱 *</label>
<input type="email" id="testEmailAddress" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="your@email.com">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">邮件主题</label>
<input type="text" id="testEmailSubject" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="[测试邮件] 项目服务管理面板">
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">邮件内容</label>
<textarea id="testEmailBody" rows="3" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">这是一封测试邮件,用于验证邮件通知功能是否正常工作。
发送自: 项目服务管理面板
时间: ${new Date().toLocaleString()}</textarea>
</div>
<div id="testEmailResult" class="mb-3 hidden"></div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeTestEmailModal()" 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 flex items-center gap-1">
<i class="ri-send-plane-line"></i> 发送测试
</button>
</div>
</form>
</div>
</div>
<!-- 导航按钮 -->
<div class="nav-buttons">
<button onclick="scrollToTop()" class="nav-btn" title="回到顶部"><i class="ri-arrow-up-line"></i></button>
<button onclick="scrollToBottom()" class="nav-btn" title="滚动到底部"><i class="ri-arrow-down-line"></i></button>
</div>
<script>
let projects = [];
let cronTasks = [];
let currentTab = 'projects';
let currentFilter = 'all';
let externalIp = localStorage.getItem('externalIp') || '192.168.2.17';
let enableLogs = localStorage.getItem('enableLogs') === 'true';
document.getElementById('externalIp').value = externalIp;
document.getElementById('enableLogs').checked = enableLogs;
// Tab 切换
// 系统资源监控变量(必须先声明)
let realtimeTimer = null;
let realtimeInterval = 2; // 秒
let lastAlertTime = 0; // 上次警告时间
let thresholds = { cpu: 80, cpu_temp: 80, memory: 85, disk: 90, interval: 60 };
let desktopNotifyEnabled = false;
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', 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');
document.getElementById('dockerTab').classList.toggle('hidden', tab !== 'docker');
if (tab === 'docker') loadDockerData();
// 切换Tab时关闭实时监控
if (tab !== 'system' && realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = null;
document.getElementById('realtimeCheck').checked = false;
document.getElementById('showProcessListCheck').checked = false;
document.getElementById('realtimeToggle').classList.remove('active');
document.getElementById('realtimeIndicator').classList.add('hidden');
document.getElementById('processListSection').classList.add('hidden');
}
if (tab === 'cron') {
loadCronTasks();
}
if (tab === 'system') {
loadThresholds();
loadDesktopNotifySetting();
renderEmailRules();
loadSystemStats();
}
}
// 加载阈值设置
async function loadThresholds() {
try {
const response = await fetch('/api/alerts');
const config = await response.json();
if (config.thresholds) {
thresholds = config.thresholds;
document.getElementById('thresholdCpu').value = thresholds.cpu || 80;
document.getElementById('thresholdCpuTemp').value = thresholds.cpu_temp || 80;
document.getElementById('thresholdMemory').value = thresholds.memory || 85;
document.getElementById('thresholdDisk').value = thresholds.disk || 90;
document.getElementById('thresholdInterval').value = thresholds.interval || 60;
}
} catch (e) {
console.error('加载阈值配置失败:', e);
}
}
// 保存阈值设置
async function saveThresholds() {
thresholds.cpu = parseInt(document.getElementById('thresholdCpu').value) || 80;
thresholds.cpu_temp = parseInt(document.getElementById('thresholdCpuTemp').value) || 80;
thresholds.memory = parseInt(document.getElementById('thresholdMemory').value) || 85;
thresholds.disk = parseInt(document.getElementById('thresholdDisk').value) || 90;
thresholds.interval = parseInt(document.getElementById('thresholdInterval').value) || 60;
try {
// 先获取现有配置
let config = {};
try {
const response = await fetch('/api/alerts');
config = await response.json();
} catch (e) {}
// 更新阈值
config.thresholds = thresholds;
// 保存到服务器
await fetch('/api/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
} catch (e) {
console.error('保存阈值配置失败:', e);
}
}
// 桌面通知开关(已在外部声明 desktopNotifyEnabled
function toggleDesktopNotify() {
const checked = document.getElementById('enableDesktopNotify').checked;
const status = document.getElementById('notifyStatus');
if (checked) {
// 检查浏览器支持
if (!('Notification' in window)) {
status.textContent = '不支持';
status.className = 'text-xs text-red-500 ml-1';
document.getElementById('enableDesktopNotify').checked = false;
return;
}
// 检查权限状态
if (Notification.permission === 'granted') {
desktopNotifyEnabled = true;
status.textContent = '已启用';
status.className = 'text-xs text-green-400 ml-1';
localStorage.setItem('desktopNotify', 'true');
} else if (Notification.permission === 'denied') {
status.textContent = '已拒绝';
status.className = 'text-xs text-red-500 ml-1';
document.getElementById('enableDesktopNotify').checked = false;
} else {
// 请求权限
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
desktopNotifyEnabled = true;
status.textContent = '已启用';
status.className = 'text-xs text-green-400 ml-1';
localStorage.setItem('desktopNotify', 'true');
} else {
status.textContent = '已拒绝';
status.className = 'text-xs text-red-500 ml-1';
document.getElementById('enableDesktopNotify').checked = false;
}
});
}
} else {
desktopNotifyEnabled = false;
status.textContent = '';
localStorage.setItem('desktopNotify', 'false');
}
}
// 加载桌面通知设置
function loadDesktopNotifySetting() {
const saved = localStorage.getItem('desktopNotify');
if (saved === 'true' && Notification.permission === 'granted') {
desktopNotifyEnabled = true;
document.getElementById('enableDesktopNotify').checked = true;
document.getElementById('notifyStatus').textContent = '已启用';
document.getElementById('notifyStatus').className = 'text-xs text-green-400 ml-1';
}
}
// 发送桌面通知
function sendDesktopNotification(warnings) {
if (!desktopNotifyEnabled || Notification.permission !== 'granted') return;
const notification = new Notification('⚠️ 系统资源告警', {
body: warnings.join('\\n'),
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">📊</text></svg>',
tag: 'system-alert',
requireInteraction: false
});
// 点击后聚焦窗口
notification.onclick = () => {
window.focus();
notification.close();
};
// 5秒后自动关闭
setTimeout(() => notification.close(), 5000);
}
// 检查阈值并弹窗警告
function checkThresholds(data) {
const now = Date.now();
const intervalMs = thresholds.interval * 1000;
// 检查是否超过间隔时间
if (now - lastAlertTime < intervalMs) return;
const warnings = [];
if (data.cpu.percent >= thresholds.cpu) {
warnings.push(`CPU使用率 ${data.cpu.percent.toFixed(1)}% 超过阈值 ${thresholds.cpu}%`);
}
if (data.memory.percent >= thresholds.memory) {
warnings.push(`内存使用率 ${data.memory.percent.toFixed(1)}% 超过阈值 ${thresholds.memory}%`);
}
if (data.disk.percent >= thresholds.disk) {
warnings.push(`磁盘使用率 ${data.disk.percent.toFixed(1)}% 超过阈值 ${thresholds.disk}%`);
}
if (data.cpu.temp_package_0 !== null && data.cpu.temp_package_0 >= thresholds.cpu_temp) {
warnings.push(`CPU0 温度 ${data.cpu.temp_package_0.toFixed(1)}°C 超过阈值 ${thresholds.cpu_temp}°C`);
}
if (data.cpu.temp_package_1 !== null && data.cpu.temp_package_1 >= thresholds.cpu_temp) {
warnings.push(`CPU1 温度 ${data.cpu.temp_package_1.toFixed(1)}°C 超过阈值 ${thresholds.cpu_temp}°C`);
}
if (warnings.length > 0) {
lastAlertTime = now;
showAlertPopup(warnings);
}
// 检查邮件通知规则
checkEmailRules(data);
}
// 显示警告弹窗(同时发送桌面通知)
function showAlertPopup(warnings) {
// 发送桌面通知
sendDesktopNotification(warnings);
// 移除已有弹窗
const existing = document.querySelector('.alert-popup');
if (existing) existing.remove();
const popup = document.createElement('div');
popup.className = 'alert-popup';
popup.innerHTML = `
<span class="alert-close" onclick="this.parentElement.remove()"><i class="ri-close-line"></i></span>
<div class="alert-title"><i class="ri-alarm-warning-line"></i> 资源告警</div>
<div class="alert-content">${warnings.map(w => `<p>⚠️ ${w}</p>`).join('')}</div>
`;
document.body.appendChild(popup);
// 自动关闭10秒后
setTimeout(() => {
popup.style.animation = 'slideOut 0.3s ease-out forwards';
setTimeout(() => popup.remove(), 300);
}, 10000);
}
function updateRealtimeInterval() {
realtimeInterval = parseInt(document.getElementById('realtimeIntervalSelect').value) || 2;
// 如果正在监控,重新启动定时器
if (realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = setInterval(loadSystemStats, realtimeInterval * 1000);
}
}
async function loadSystemStats() {
try {
const res = await fetch('/api/system/stats');
const data = await res.json();
if (!data.available) {
document.getElementById('sysUptime').textContent = 'psutil未安装';
document.getElementById('sysBootTime').textContent = '-';
return;
}
// 系统概览
document.getElementById('sysUptime').textContent = data.system.uptime;
document.getElementById('sysBootTime').textContent = data.system.boot_time;
document.getElementById('sysProcessCount').textContent = data.system.process_count;
document.getElementById('sysCpuCount').textContent = data.cpu.logical + '核';
// CPU
document.getElementById('cpuPercent').textContent = data.cpu.percent.toFixed(1) + '%';
document.getElementById('cpuBar').style.width = data.cpu.percent + '%';
document.getElementById('cpuPhysical').textContent = data.cpu.count;
document.getElementById('cpuLogical').textContent = data.cpu.logical;
// CPU 温度
if (data.cpu.temp_package_0 !== null) {
document.getElementById('cpuTemp0').textContent = data.cpu.temp_package_0.toFixed(1);
}
if (data.cpu.temp_package_1 !== null) {
document.getElementById('cpuTemp1').textContent = data.cpu.temp_package_1.toFixed(1);
}
// 内存
document.getElementById('memPercent').textContent = data.memory.percent.toFixed(1) + '%';
document.getElementById('memBar').style.width = data.memory.percent + '%';
document.getElementById('memUsed').textContent = data.memory.used_gb;
document.getElementById('memTotal').textContent = data.memory.total_gb;
document.getElementById('memAvailable').textContent = data.memory.available_gb;
// 磁盘
document.getElementById('diskPercent').textContent = data.disk.percent.toFixed(1) + '%';
document.getElementById('diskBar').style.width = data.disk.percent + '%';
document.getElementById('diskUsed').textContent = data.disk.used_gb;
document.getElementById('diskTotal').textContent = data.disk.total_gb;
document.getElementById('diskFree').textContent = data.disk.free_gb;
// 网络 - 累计流量
document.getElementById('netSent').textContent = data.network.sent_mb;
document.getElementById('netRecv').textContent = data.network.recv_mb;
// 实时网速
document.getElementById('netSentSpeed').textContent = data.network.sent_speed || '-';
document.getElementById('netRecvSpeed').textContent = data.network.recv_speed || '-';
// 检查阈值
checkThresholds(data);
// 如果实时监控和进程列表都开启,加载进程列表
if (document.getElementById('realtimeCheck').checked && document.getElementById('showProcessListCheck').checked) {
loadTopProcesses();
}
} catch (e) {
console.error('系统资源加载失败:', e);
}
}
// 加载CPU占用最高的进程
async function loadTopProcesses() {
try {
const limit = document.getElementById('processLimitSelect').value;
const res = await fetch(`/api/system/processes?limit=${limit}`);
const data = await res.json();
if (!data.available) {
document.getElementById('processList').innerHTML = '<div class="text-red-400 text-center py-4">无法获取进程信息</div>';
return;
}
const list = document.getElementById('processList');
if (data.processes.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-center py-4">暂无进程数据</div>';
return;
}
list.innerHTML = data.processes.map(proc => `
<div class="card rounded-lg p-3 hover:border-gray-500 transition-colors">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<span class="text-purple-400 font-mono text-sm">${proc.pid}</span>
<div class="min-w-0 flex-1">
<div class="text-gray-200 truncate">${proc.name}</div>
<div class="text-gray-500 text-xs truncate">${proc.cmdline || proc.exe}</div>
</div>
</div>
<div class="flex items-center gap-4 text-xs shrink-0">
<div class="text-right">
<span class="text-blue-400 font-bold">${proc.cpu.toFixed(1)}%</span>
<span class="text-gray-500 ml-1">CPU</span>
</div>
<div class="text-right">
<span class="text-green-400 font-bold">${proc.memory.toFixed(1)}%</span>
<span class="text-gray-500 ml-1">内存</span>
</div>
<div class="text-gray-400">${proc.create_time}</div>
</div>
</div>
</div>
`).join('');
} catch (e) {
console.error('进程列表加载失败:', e);
}
}
function toggleRealtime() {
const checked = document.getElementById('realtimeCheck').checked;
const toggle = document.getElementById('realtimeToggle');
const indicator = document.getElementById('realtimeIndicator');
if (checked) {
toggle.classList.add('active');
indicator.classList.remove('hidden');
// 立即加载一次
loadSystemStats();
// 启动定时器
realtimeTimer = setInterval(loadSystemStats, realtimeInterval * 1000);
// 如果进程列表开关也开启,则显示并加载
if (document.getElementById('showProcessListCheck').checked) {
document.getElementById('processListSection').classList.remove('hidden');
loadTopProcesses();
}
} else {
toggle.classList.remove('active');
indicator.classList.add('hidden');
document.getElementById('processListSection').classList.add('hidden');
// 停止定时器
if (realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = null;
}
}
}
function toggleProcessList() {
const checked = document.getElementById('showProcessListCheck').checked;
const realtimeChecked = document.getElementById('realtimeCheck').checked;
const processSection = document.getElementById('processListSection');
if (checked && realtimeChecked) {
processSection.classList.remove('hidden');
loadTopProcesses();
} else {
processSection.classList.add('hidden');
}
}
// IP 保存
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();
}
// 项目加载和渲染(保持原有逻辑)
async function loadProjects() {
try {
const res = await fetch('/api/projects');
const data = await res.json();
projects = data.projects;
renderProjects();
updateStats();
updateConnectionStatus(true);
// 同时检查系统资源阈值(主页也有效)
loadThresholds();
fetch('/api/system/stats').then(r => r.json()).then(sysData => {
if (sysData.available) checkThresholds(sysData);
}).catch(() => {});
// 同时检查服务下线邮件规则
checkEmailRules(null);
} catch (e) {
console.error('加载失败:', e);
updateConnectionStatus(false);
}
}
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-bar-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.type === type);
});
renderProjects();
}
function renderProjects() {
const list = document.getElementById('projectsList');
// 合合自定义服务到项目列表
const customServices = getCustomServices();
const allProjects = [...projects, ...customServices];
const filtered = currentFilter === 'all' ? allProjects : allProjects.filter(p => p.type === currentFilter || (currentFilter === 'web' && p.isCustom));
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 activeStatus = getActiveStatus();
const grouped = {};
filtered.forEach(p => {
const groupType = p.isCustom ? 'custom' : p.type;
if (!grouped[groupType]) grouped[groupType] = [];
grouped[groupType].push(p);
});
let html = '';
const typeNames = {
'web': { name: 'Web服务', icon: 'ri-server-line', color: 'blue' },
'custom': { name: '自定义服务', icon: 'ri-global-line', color: 'cyan' },
'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' };
if (type === 'web') {
const activeProjs = projs.filter(p => activeStatus[p.id] !== false);
const archivedProjs = projs.filter(p => activeStatus[p.id] === false);
if (activeProjs.length > 0) {
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">(${activeProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
activeProjs.forEach(p => { html += renderProjectCard(p, true); });
html += '</div></div>';
}
if (archivedProjs.length > 0) {
html += `<div class="mb-6 opacity-60"><h2 class="text-lg font-semibold text-gray-400 mb-3 flex items-center gap-2"><i class="ri-archive-line text-gray-400"></i>归档服务<span class="text-sm text-gray-500">(${archivedProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
archivedProjs.forEach(p => { html += renderProjectCard(p, false); });
html += '</div></div>';
}
} else {
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 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
projs.forEach(p => { html += renderProjectCard(p, true); });
html += '</div></div>';
}
}
list.innerHTML = html;
}
function getActiveStatus() {
const stored = localStorage.getItem('serviceActiveStatus');
return stored ? JSON.parse(stored) : {};
}
function toggleServiceActive(id) {
const status = getActiveStatus();
status[id] = status[id] === false ? true : false;
localStorage.setItem('serviceActiveStatus', JSON.stringify(status));
renderProjects();
}
function renderProjectCard(p, isActive = true) {
// 自定义服务特殊处理
if (p.isCustom) {
return `
<div class="card rounded-lg p-3 hover:border-cyan-500 transition-colors border-cyan-500/30">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="status-dot bg-cyan-400" style="box-shadow: 0 0 8px #22d3ee;" title="外部服务"></div>
<h3 class="font-semibold text-sm truncate">${p.name}</h3>
</div>
<span class="text-xs text-cyan-400">外部</span>
</div>
<p class="text-gray-400 text-xs mb-2 truncate">${p.description || ''}</p>
<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>
${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>
</div>
</div>
`;
}
const statusInfo = getStatusInfo(p.status?.status);
// 处理后台链接:将 localhost 替换为 externalIp
let adminUrl = p.admin_url || '';
if (adminUrl && adminUrl.includes('localhost')) {
adminUrl = adminUrl.replace(/localhost/g, externalIp);
}
// 获取自定义链接
const customLinks = getCustomLinks(p.id);
return `
<div class="card rounded-lg p-3 hover:border-gray-500 transition-colors">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="status-dot ${statusInfo.class}" title="${statusInfo.text}"></div>
<h3 class="font-semibold text-sm truncate">${p.name}</h3>
</div>
<span class="text-xs ${statusInfo.textColor}">${statusInfo.text}</span>
</div>
${p.ports && p.ports.length > 0 ? `
<div class="flex items-center gap-1 text-xs mb-2 flex-wrap">
${p.ports.map(port => {
const portStatus = p.status?.ports?.[port];
const isRunning = portStatus?.running;
return `<a href="http://${externalIp}:${port}" target="_blank" class="px-2 py-0.5 rounded ${isRunning ? 'bg-green-500/20 text-green-400 hover:bg-green-500/30' : 'bg-red-500/20 text-red-400'}">${port}</a>`;
}).join('')}
${adminUrl ? `<a href="${adminUrl}" target="_blank" class="text-yellow-400 hover:text-yellow-300 ml-1">后台</a>` : ''}
${customLinks.map(link => `<a href="${link.url}" target="_blank" class="text-cyan-400 hover:text-cyan-300 ml-1">${link.name}</a>`).join('')}
<button onclick="showAddLinkModal('${p.id}', '${p.name}')" class="text-green-400 hover:text-green-300 ml-1" title="添加自定义链接"><i class="ri-add-line"></i></button>
</div>
` : ''}
${p.type === 'web' ? `
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-1">
${(p.status?.status === 'running' || p.status?.status === 'partial') ? `
<button onclick="stopProject('${p.id}')" class="btn bg-red-600 hover:bg-red-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="viewLog('${p.id}')" class="btn bg-gray-600 hover:bg-gray-700 px-2 py-0.5 rounded text-xs">日志</button>
</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 ? '点击归档' : '点击激活'}">
<i class="ri-${isActive ? 'checkbox-circle' : 'archive'}-line"></i>
</button>
</div>
` : ''}
</div>
`;
}
// ==================== 自定义链接管理 ====================
function getCustomLinks(projectId) {
const stored = localStorage.getItem('customLinks_' + projectId);
return stored ? JSON.parse(stored) : [];
}
function saveCustomLinks(projectId, links) {
localStorage.setItem('customLinks_' + projectId, JSON.stringify(links));
}
function showAddLinkModal(projectId, projectName) {
document.getElementById('linkModalTitle').textContent = `${projectName} - 添加链接`;
document.getElementById('linkProjectId').value = projectId;
document.getElementById('linkName').value = '';
document.getElementById('linkPort').value = '';
document.getElementById('linkPath').value = '';
document.getElementById('linkFullUrl').value = '';
document.getElementById('linkModal').classList.remove('hidden');
// 显示已有的自定义链接(供删除)
const existingLinks = getCustomLinks(projectId);
const existingList = document.getElementById('existingLinks');
if (existingLinks.length > 0) {
existingList.innerHTML = '<div class="text-gray-400 text-sm mb-2">已有链接:</div>' +
existingLinks.map(link => `
<div class="flex items-center justify-between bg-gray-700 px-2 py-1 rounded mb-1">
<span class="text-cyan-400 text-sm">${link.name}: ${link.url}</span>
<button onclick="deleteCustomLink('${projectId}', '${link.id}')" class="text-red-400 hover:text-red-300"><i class="ri-delete-bin-line"></i></button>
</div>
`).join('');
} else {
existingList.innerHTML = '';
}
}
function closeLinkModal() {
document.getElementById('linkModal').classList.add('hidden');
}
function saveCustomLink(event) {
event.preventDefault();
const projectId = document.getElementById('linkProjectId').value;
const name = document.getElementById('linkName').value.trim();
const port = document.getElementById('linkPort').value.trim();
const path = document.getElementById('linkPath').value.trim();
const fullUrl = document.getElementById('linkFullUrl').value.trim();
if (!name) {
alert('请输入链接名称');
return;
}
let url;
if (fullUrl) {
url = fullUrl;
} else if (port) {
url = `http://${externalIp}:${port}${path || ''}`;
} else {
alert('请输入端口或完整URL');
return;
}
const links = getCustomLinks(projectId);
const newLink = {
id: Date.now().toString(),
name: name,
url: url
};
links.push(newLink);
saveCustomLinks(projectId, links);
closeLinkModal();
renderProjects();
}
function deleteCustomLink(projectId, linkId) {
if (!confirm('确定删除此链接?')) return;
const links = getCustomLinks(projectId);
const filtered = links.filter(l => l.id !== linkId);
saveCustomLinks(projectId, filtered);
// 更新模态框中的显示
const existingLinks = getCustomLinks(projectId);
const existingList = document.getElementById('existingLinks');
if (existingLinks.length > 0) {
existingList.innerHTML = '<div class="text-gray-400 text-sm mb-2">已有链接:</div>' +
existingLinks.map(link => `
<div class="flex items-center justify-between bg-gray-700 px-2 py-1 rounded mb-1">
<span class="text-cyan-400 text-sm">${link.name}: ${link.url}</span>
<button onclick="deleteCustomLink('${projectId}', '${link.id}')" class="text-red-400 hover:text-red-300"><i class="ri-delete-bin-line"></i></button>
</div>
`).join('');
} else {
existingList.innerHTML = '<div class="text-gray-500 text-sm">暂无自定义链接</div>';
}
renderProjects();
}
// 自动生成URL预览
function updateLinkPreview() {
const port = document.getElementById('linkPort').value.trim();
const path = document.getElementById('linkPath').value.trim();
const preview = document.getElementById('linkPreview');
if (port) {
preview.textContent = `预览: http://${externalIp}:${port}${path || ''}`;
} else {
preview.textContent = '';
}
}
// ==================== 自定义服务管理 ====================
function getCustomServices() {
const stored = localStorage.getItem('customServices');
return stored ? JSON.parse(stored) : [];
}
function saveCustomServices(services) {
localStorage.setItem('customServices', JSON.stringify(services));
}
// ==================== 邮件通知规则管理 ====================
function getEmailRules() {
const stored = localStorage.getItem('emailNotifyRules');
return stored ? JSON.parse(stored) : [];
}
function saveEmailRules(rules) {
localStorage.setItem('emailNotifyRules', JSON.stringify(rules));
}
function renderEmailRules() {
const rules = getEmailRules();
const list = document.getElementById('emailRulesList');
if (rules.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-center py-4">暂无邮件通知规则</div>';
return;
}
const resourceNames = { 'cpu': 'CPU', 'cpu_temp': 'CPU温度', 'memory': '内存', 'disk': '磁盘', 'service_down': '服务下线' };
const resourceUnits = { 'cpu': '%', 'cpu_temp': '°C', 'memory': '%', 'disk': '%' };
list.innerHTML = rules.map(rule => {
const silentInfo = rule.silentEnabled ? `静默:${rule.silentStart}-${rule.silentEnd}` : '';
const triggerType = rule.triggerType || 'single';
const triggerParam = rule.triggerParam || 3;
let triggerDisplay = '一次性';
if (triggerType === 'continuous') triggerDisplay = `连续${triggerParam}次`;
else if (triggerType === 'duration') triggerDisplay = `持续${triggerParam}秒`;
else if (triggerType === 'average') triggerDisplay = `平均${triggerParam}次`;
// 显示资源列表(支持多资源和旧版单资源兼容)
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
let resourceDisplay;
if (resources.includes('service_down') && rule.serviceName) {
resourceDisplay = `🔴 ${rule.serviceName} 下线 ≥ ${rule.threshold}分钟`;
} else {
resourceDisplay = resources.map(r => resourceNames[r] || r).join('+') + ' ≥ ' + rule.threshold;
}
return `
<div class="flex items-center justify-between bg-gray-700/50 px-3 py-2 rounded-lg">
<div class="flex items-center gap-3 flex-wrap">
<span class="px-2 py-0.5 rounded text-xs ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}">${rule.enabled ? '启用' : '禁用'}</span>
<span class="text-gray-200">${rule.name}</span>
<span class="text-gray-400 text-xs">${resourceDisplay}</span>
<span class="text-blue-400/70 text-xs"><i class="ri-flashlight-line"></i> ${triggerDisplay}</span>
<span class="text-gray-400 text-xs">→ ${rule.email}</span>
${silentInfo ? `<span class="text-yellow-400/70 text-xs"><i class="ri-moon-line"></i> ${silentInfo}</span>` : ''}
</div>
<div class="flex items-center gap-1">
<button onclick="editEmailRule('${rule.id}')" class="text-blue-400 hover:text-blue-300 px-1" title="编辑"><i class="ri-edit-line"></i></button>
<button onclick="deleteEmailRule('${rule.id}')" class="text-red-400 hover:text-red-300 px-1" title="删除"><i class="ri-delete-bin-line"></i></button>
</div>
</div>
`;
}).join('');
}
function showAddEmailRuleModal() {
document.getElementById('emailRuleModalTitle').textContent = '添加邮件通知规则';
document.getElementById('emailRuleId').value = '';
document.getElementById('emailRuleName').value = '';
// 重置资源复选框
document.querySelectorAll('.email-rule-resource').forEach(cb => cb.checked = false);
document.getElementById('serviceDownSelector').style.display = 'none';
document.getElementById('emailRuleServiceId').value = '';
document.getElementById('emailRuleThreshold').value = 80;
document.getElementById('emailRuleThresholdUnit').textContent = '% / °C';
document.getElementById('emailRuleThresholdHint').textContent = '对不同资源类型使用统一阈值';
document.getElementById('emailRuleTriggerType').value = 'single';
document.getElementById('emailRuleTriggerType').disabled = false;
document.getElementById('emailRuleTriggerParam').value = 3;
document.getElementById('triggerParamsDiv').style.display = 'none';
document.getElementById('emailRuleAddress').value = '';
document.getElementById('emailRuleInterval').value = 300;
document.getElementById('emailRuleSilentStart').value = '23:00';
document.getElementById('emailRuleSilentEnd').value = '08:00';
document.getElementById('emailRuleSilentEnabled').checked = false;
document.getElementById('emailRuleEnabled').checked = true;
document.getElementById('emailRuleModal').classList.remove('hidden');
}
function closeEmailRuleModal() {
document.getElementById('emailRuleModal').classList.add('hidden');
}
function getSelectedResources() {
const checkboxes = document.querySelectorAll('.email-rule-resource:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function onResourceChange() {
const selected = getSelectedResources();
const unitEl = document.getElementById('emailRuleThresholdUnit');
const thresholdEl = document.getElementById('emailRuleThreshold');
const hintEl = document.getElementById('emailRuleThresholdHint');
const serviceSelector = document.getElementById('serviceDownSelector');
const triggerTypeEl = document.getElementById('emailRuleTriggerType');
const triggerParamsDiv = document.getElementById('triggerParamsDiv');
const hasServiceDown = selected.includes('service_down');
// 显示/隐藏服务选择器
if (hasServiceDown) {
serviceSelector.style.display = 'block';
populateServiceSelector();
// 服务下线使用分钟作为阈值单位
unitEl.textContent = '分钟';
thresholdEl.value = 5;
thresholdEl.max = 1440;
thresholdEl.min = 1;
hintEl.textContent = '服务连续离线超过此分钟数即触发通知';
// 服务下线固定为duration类型
if (triggerTypeEl) {
triggerTypeEl.value = 'duration';
triggerTypeEl.disabled = true;
}
if (triggerParamsDiv) triggerParamsDiv.style.display = 'none';
} else {
serviceSelector.style.display = 'none';
if (triggerTypeEl) triggerTypeEl.disabled = false;
updateTriggerParams();
if (selected.length === 0) {
unitEl.textContent = '% / °C';
hintEl.textContent = '对不同资源类型使用统一阈值';
} else if (selected.length === 1 && selected[0] === 'cpu_temp') {
unitEl.textContent = '°C';
if (parseInt(thresholdEl.value) > 120) thresholdEl.value = 80;
thresholdEl.max = 120;
thresholdEl.min = 30;
hintEl.textContent = 'CPU温度超过此值即触发通知';
} else {
unitEl.textContent = '%';
thresholdEl.max = 100;
thresholdEl.min = 1;
hintEl.textContent = '对不同资源类型使用统一阈值';
if (selected.includes('cpu_temp')) {
unitEl.textContent = '% / °C';
}
}
}
}
function populateServiceSelector() {
const sel = document.getElementById('emailRuleServiceId');
const currentVal = sel.value;
// 收集所有web类型服务
const webServices = projects.filter(p => p.type === 'web');
let options = '<option value="">-- 请选择要监控的服务 --</option>';
webServices.forEach(p => {
const portsStr = p.ports ? p.ports.join(',') : '';
const selected = p.id === currentVal ? ' selected' : '';
options += `<option value="${p.id}"${selected}>${p.name} (端口: ${portsStr})</option>`;
});
sel.innerHTML = options;
}
function onServiceDownChange() {
// 将来可以在这里做联动
}
function updateTriggerParams() {
const triggerType = document.getElementById('emailRuleTriggerType').value;
const paramsDiv = document.getElementById('triggerParamsDiv');
const labelEl = document.getElementById('triggerParamsLabel');
const unitEl = document.getElementById('triggerParamUnit');
const hintEl = document.getElementById('triggerParamHint');
const paramInput = document.getElementById('emailRuleTriggerParam');
// 根据触发类型设置不同的参数
switch (triggerType) {
case 'single':
paramsDiv.style.display = 'none';
break;
case 'continuous':
paramsDiv.style.display = 'flex';
labelEl.textContent = '连续次数';
unitEl.textContent = '次';
hintEl.textContent = '连续超过阈值多少次才触发';
paramInput.max = 100;
paramInput.value = 3;
break;
case 'duration':
paramsDiv.style.display = 'flex';
labelEl.textContent = '持续时间';
unitEl.textContent = '秒';
hintEl.textContent = '累计超过阈值多少秒才触发';
paramInput.max = 3600;
paramInput.value = 60;
break;
case 'average':
paramsDiv.style.display = 'flex';
labelEl.textContent = '采样次数';
unitEl.textContent = '次';
hintEl.textContent = '最近N次采样的平均值超过阈值才触发';
paramInput.max = 100;
paramInput.value = 5;
break;
}
}
function editEmailRule(ruleId) {
const rules = getEmailRules();
const rule = rules.find(r => r.id === ruleId);
if (!rule) return;
document.getElementById('emailRuleModalTitle').textContent = '编辑邮件通知规则';
document.getElementById('emailRuleId').value = rule.id;
document.getElementById('emailRuleName').value = rule.name;
// 重置所有复选框
document.querySelectorAll('.email-rule-resource').forEach(cb => cb.checked = false);
// 勾选已保存的资源支持新版resources数组和旧版resource字符串兼容
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
resources.forEach(r => {
const cb = document.querySelector(`.email-rule-resource[value="${r}"]`);
if (cb) cb.checked = true;
});
onResourceChange();
// 服务下线相关
if (rule.serviceId) {
document.getElementById('emailRuleServiceId').value = rule.serviceId;
}
document.getElementById('emailRuleThreshold').value = rule.threshold;
document.getElementById('emailRuleTriggerType').value = rule.triggerType || 'single';
document.getElementById('emailRuleTriggerParam').value = rule.triggerParam || 3;
updateTriggerParams();
document.getElementById('emailRuleAddress').value = rule.email;
document.getElementById('emailRuleInterval').value = rule.interval;
document.getElementById('emailRuleSilentStart').value = rule.silentStart || '23:00';
document.getElementById('emailRuleSilentEnd').value = rule.silentEnd || '08:00';
document.getElementById('emailRuleSilentEnabled').checked = rule.silentEnabled || false;
document.getElementById('emailRuleEnabled').checked = rule.enabled;
document.getElementById('emailRuleModal').classList.remove('hidden');
}
function saveEmailRule(event) {
event.preventDefault();
const ruleId = document.getElementById('emailRuleId').value;
const name = document.getElementById('emailRuleName').value.trim();
const resources = getSelectedResources();
const threshold = parseInt(document.getElementById('emailRuleThreshold').value);
const triggerType = document.getElementById('emailRuleTriggerType').value;
const triggerParam = parseInt(document.getElementById('emailRuleTriggerParam').value) || 3;
const email = document.getElementById('emailRuleAddress').value.trim();
const interval = parseInt(document.getElementById('emailRuleInterval').value) || 300;
const silentStart = document.getElementById('emailRuleSilentStart').value;
const silentEnd = document.getElementById('emailRuleSilentEnd').value;
const silentEnabled = document.getElementById('emailRuleSilentEnabled').checked;
const enabled = document.getElementById('emailRuleEnabled').checked;
const serviceId = document.getElementById('emailRuleServiceId').value;
if (!name || !email) {
alert('请填写完整信息');
return;
}
if (resources.length === 0) {
alert('请至少选择一个监控资源');
return;
}
// 服务下线必须选择具体服务
if (resources.includes('service_down') && !serviceId) {
alert('请选择要监控的服务');
return;
}
const rules = getEmailRules();
if (ruleId) {
// 编辑
const idx = rules.findIndex(r => r.id === ruleId);
if (idx >= 0) {
rules[idx] = { ...rules[idx], name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled, serviceId };
}
} else {
// 新增
const svc = projects.find(p => p.id === serviceId);
rules.push({
id: 'rule_' + Date.now(),
name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled,
serviceId, serviceName: svc ? svc.name : '',
offlineSince: null, // 服务下线开始时间
lastSent: 0,
exceedCount: 0,
exceedDuration: 0,
recentValues: []
});
}
saveEmailRules(rules);
closeEmailRuleModal();
renderEmailRules();
}
function deleteEmailRule(ruleId) {
if (!confirm('确定删除此邮件通知规则?')) return;
const rules = getEmailRules().filter(r => r.id !== ruleId);
saveEmailRules(rules);
renderEmailRules();
}
// 检查邮件规则并发送通知
async function checkEmailRules(data) {
const rules = getEmailRules().filter(r => r.enabled);
const now = Date.now();
const nowTime = new Date();
const currentHour = nowTime.getHours();
const currentMin = nowTime.getMinutes();
const currentTimeStr = `${currentHour.toString().padStart(2, '0')}:${currentMin.toString().padStart(2, '0')}`;
for (const rule of rules) {
// 检查间隔
if (now - rule.lastSent < rule.interval * 1000) continue;
// 检查静默时间
if (rule.silentEnabled && rule.silentStart && rule.silentEnd) {
const start = rule.silentStart;
const end = rule.silentEnd;
// 处理跨越午夜的情况如23:00到08:00
let inSilentPeriod = false;
if (start > end) {
// 跨越午夜如23:00-08:00则currentTime >= start 或 currentTime < end 都在静默区间
inSilentPeriod = (currentTimeStr >= start || currentTimeStr < end);
} else {
// 同一天内如01:00-06:00
inSilentPeriod = (currentTimeStr >= start && currentTimeStr < end);
}
if (inSilentPeriod) {
console.log(`规则 ${rule.name} 当前在静默时间段 ${start}-${end},跳过发送`);
continue;
}
}
// 检查阈值和触发机制(支持多资源)
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
const resourceNames = { 'cpu': 'CPU使用率', 'cpu_temp': 'CPU温度', 'memory': '内存使用率', 'disk': '磁盘使用率' };
// 收集所有超标的资源
const exceededResources = [];
for (const resource of resources) {
let value;
// 服务下线监控 - 特殊处理不需要data参数
if (resource === 'service_down') {
const svcId = rule.serviceId;
if (!svcId) continue;
const svc = projects.find(p => p.id === svcId);
if (!svc) continue;
const isOnline = svc.status?.status === 'running';
if (isOnline) {
rule.offlineSince = null;
} else {
if (!rule.offlineSince) {
rule.offlineSince = now;
}
const offlineMinutes = (now - rule.offlineSince) / 60000;
if (offlineMinutes >= rule.threshold) {
const svcName = svc.name || rule.serviceName || svcId;
exceededResources.push(`🔴 ${svcName} 已离线 ${Math.round(offlineMinutes)} 分钟 (阈值: ${rule.threshold} 分钟)`);
}
}
continue;
}
// 系统资源监控需要 data 参数
if (!data) continue;
if (resource === 'cpu_temp') {
// CPU温度取两个Package中较高的那个
const t0 = data.cpu?.temp_package_0;
const t1 = data.cpu?.temp_package_1;
if (t0 === null && t1 === null) continue;
if (t0 === null) value = t1;
else if (t1 === null) value = t0;
else value = Math.max(t0, t1);
} else {
value = data[resource]?.percent;
}
if (value === undefined || value === null) continue;
const isExceedThreshold = value >= rule.threshold;
// 初始化触发相关数据key后缀避免不同资源冲突
const ck = '_' + resource;
if (!rule['exceedCount' + ck]) rule['exceedCount' + ck] = 0;
if (!rule['exceedDuration' + ck]) rule['exceedDuration' + ck] = 0;
if (!rule['recentValues' + ck]) rule['recentValues' + ck] = [];
const triggerType = rule.triggerType || 'single';
const triggerParam = rule.triggerParam || 3;
let resourceTriggered = false;
switch (triggerType) {
case 'single':
if (isExceedThreshold) resourceTriggered = true;
break;
case 'continuous':
if (isExceedThreshold) {
rule['exceedCount' + ck]++;
if (rule['exceedCount' + ck] >= triggerParam) {
resourceTriggered = true;
rule['exceedCount' + ck] = 0;
}
} else {
rule['exceedCount' + ck] = 0;
}
break;
case 'duration':
if (isExceedThreshold) {
const intervalSec = realtimeInterval || 2;
rule['exceedDuration' + ck] += intervalSec;
if (rule['exceedDuration' + ck] >= triggerParam) {
resourceTriggered = true;
rule['exceedDuration' + ck] = 0;
}
} else {
rule['exceedDuration' + ck] = 0;
}
break;
case 'average':
rule['recentValues' + ck].push(value);
if (rule['recentValues' + ck].length > triggerParam) {
rule['recentValues' + ck].shift();
}
if (rule['recentValues' + ck].length >= triggerParam) {
const avg = rule['recentValues' + ck].reduce((a, b) => a + b, 0) / rule['recentValues' + ck].length;
if (avg >= rule.threshold) resourceTriggered = true;
}
break;
}
if (resourceTriggered) {
const unit = resource === 'cpu_temp' ? '°C' : '%';
exceededResources.push(`${resourceNames[resource]}: ${value.toFixed(1)}${unit} (阈值: ${rule.threshold}${unit})`);
}
}
// 保存更新的规则数据
saveEmailRules(getEmailRules().map(r => r.id === rule.id ? rule : r));
if (exceededResources.length === 0) continue;
// 发送邮件
try {
const res = await fetch('/api/email/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: rule.email,
subject: `[系统告警] ${rule.name}`,
body: `${rule.name} 触发告警\n\n${exceededResources.join('\n')}\n\n时间: ${new Date().toLocaleString()}\n\n请及时处理。`
})
});
if (res.ok) {
// 更新发送时间
rule.lastSent = now;
saveEmailRules(getEmailRules().map(r => r.id === rule.id ? rule : r));
}
} catch (e) {
console.error('邮件发送失败:', e);
}
}
}
// ==================== 测试邮件发送 ====================
function showTestEmailModal() {
document.getElementById('testEmailAddress').value = '';
document.getElementById('testEmailSubject').value = '[测试邮件] 项目服务管理面板';
document.getElementById('testEmailBody').value = '这是一封测试邮件,用于验证邮件通知功能是否正常工作。\n\n发送自: 项目服务管理面板\n时间: ' + new Date().toLocaleString();
document.getElementById('testEmailResult').classList.add('hidden');
document.getElementById('testEmailModal').classList.remove('hidden');
}
function closeTestEmailModal() {
document.getElementById('testEmailModal').classList.add('hidden');
}
async function sendTestEmail(event) {
event.preventDefault();
const email = document.getElementById('testEmailAddress').value.trim();
const subject = document.getElementById('testEmailSubject').value.trim();
const body = document.getElementById('testEmailBody').value.trim();
const resultEl = document.getElementById('testEmailResult');
const submitBtn = event.target.querySelector('button[type="submit"]');
if (!email) {
alert('请输入收件邮箱');
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> 发送中...';
resultEl.classList.add('hidden');
try {
const res = await fetch('/api/email/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: email, subject, body })
});
const data = await res.json();
if (res.ok) {
resultEl.className = 'mb-3 p-3 rounded-lg bg-green-500/20 text-green-400 text-sm';
resultEl.innerHTML = `<i class="ri-checkbox-circle-line"></i> 测试邮件发送成功!<br>收件人: ${email}`;
} else {
resultEl.className = 'mb-3 p-3 rounded-lg bg-red-500/20 text-red-400 text-sm';
resultEl.innerHTML = `<i class="ri-error-warning-line"></i> 发送失败: ${data.error || '未知错误'}`;
}
resultEl.classList.remove('hidden');
} catch (e) {
resultEl.className = 'mb-3 p-3 rounded-lg bg-red-500/20 text-red-400 text-sm';
resultEl.innerHTML = `<i class="ri-error-warning-line"></i> 网络错误: ${e.message}`;
resultEl.classList.remove('hidden');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="ri-send-plane-line"></i> 发送测试';
}
}
function showAddServiceModal() {
document.getElementById('serviceName').value = '';
document.getElementById('servicePort').value = '';
document.getElementById('servicePath').value = '';
document.getElementById('serviceFullUrl').value = '';
document.getElementById('serviceAdminUrl').value = '';
document.getElementById('serviceDesc').value = '';
document.getElementById('servicePreview').textContent = '';
document.getElementById('addServiceModal').classList.remove('hidden');
}
function closeAddServiceModal() {
document.getElementById('addServiceModal').classList.add('hidden');
}
function updateServicePreview() {
const port = document.getElementById('servicePort').value.trim();
const path = document.getElementById('servicePath').value.trim();
const preview = document.getElementById('servicePreview');
if (port) {
preview.textContent = `预览: http://${externalIp}:${port}${path || ''}`;
} else {
preview.textContent = '';
}
}
function saveCustomService(event) {
event.preventDefault();
const name = document.getElementById('serviceName').value.trim();
const port = document.getElementById('servicePort').value.trim();
const path = document.getElementById('servicePath').value.trim();
const fullUrl = document.getElementById('serviceFullUrl').value.trim();
const adminUrl = document.getElementById('serviceAdminUrl').value.trim();
const desc = document.getElementById('serviceDesc').value.trim();
if (!name) {
alert('请输入服务名称');
return;
}
let mainUrl;
if (fullUrl) {
mainUrl = fullUrl;
} else if (port) {
mainUrl = `http://${externalIp}:${port}${path || ''}`;
} else {
alert('请输入端口或完整URL');
return;
}
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);
closeAddServiceModal();
loadProjects();
}
function deleteCustomService(serviceId) {
if (!confirm('确定删除此自定义服务?')) return;
const services = getCustomServices();
const filtered = services.filter(s => s.id !== serviceId);
saveCustomServices(filtered);
loadProjects();
}
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 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');
}
// ==================== Cron 管理 ====================
async function loadCronTasks() {
try {
const res = await fetch('/api/cron/tasks');
const data = await res.json();
cronTasks = data.tasks;
renderCronTasks();
updateCronStats();
} catch (e) {
console.error('加载 Cron 任务失败:', e);
document.getElementById('cronTasksList').innerHTML = '<div class="text-center py-8 text-red-400"><i class="ri-error-warning-line text-2xl"></i><p class="mt-2 text-sm">加载失败: ' + e.message + '</p></div>';
}
}
function updateCronStats() {
const total = cronTasks.length;
const enabled = cronTasks.filter(t => t.enabled === 1).length;
const disabled = total - enabled;
document.getElementById('cronTotal').textContent = total;
document.getElementById('cronEnabled').textContent = enabled;
document.getElementById('cronDisabled').textContent = disabled;
// 获取历史总数
fetch('/api/cron/tasks').then(() => {
let historyCount = 0;
cronTasks.forEach(t => {
fetch(`/api/cron/tasks/${t.id}/history`).then(res => res.json()).then(data => {
historyCount += data.history.length;
document.getElementById('cronHistoryCount').textContent = historyCount;
});
});
});
}
function renderCronTasks() {
const list = document.getElementById('cronTasksList');
if (cronTasks.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">暂无 Cron 任务</p><p class="text-sm mt-1">点击"从系统同步"导入现有任务</p></div>';
return;
}
const categoryIcons = {
'monitor': { icon: 'ri-heart-pulse-line', color: 'blue' },
'backup': { icon: 'ri-archive-line', color: 'green' },
'cleanup': { icon: 'ri-delete-bin-line', color: 'yellow' },
'report': { icon: 'ri-file-chart-line', color: 'purple' },
'sync': { icon: 'ri-refresh-line', color: 'cyan' },
'notification': { icon: 'ri-notification-line', color: 'orange' },
'other': { icon: 'ri-more-line', color: 'gray' }
};
let html = '';
cronTasks.forEach(task => {
const catInfo = categoryIcons[task.category] || categoryIcons['other'];
html += `
<div class="cron-card card rounded-lg p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<i class="${catInfo.icon} text-${catInfo.color}-400"></i>
<h3 class="font-semibold">${task.name}</h3>
<span class="px-2 py-0.5 rounded text-xs ${task.enabled ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">${task.enabled ? '启用' : '禁用'}</span>
</div>
<div class="flex items-center gap-3 mb-2">
<code class="text-yellow-400 text-sm font-mono">${task.expression}</code>
<span class="text-gray-500">→</span>
<span class="text-blue-400 text-sm">${task.human_time || ''}</span>
${task.next_run ? `<span class="text-gray-500 text-xs">下次: ${task.next_run}</span>` : ''}
</div>
<div class="text-gray-300 text-sm font-mono break-all bg-gray-800/50 rounded p-2">${escapeHtml(task.command)}</div>
${task.description ? `<div class="text-gray-400 text-sm mt-2"><i class="ri-info-line"></i> ${task.description}</div>` : ''}
</div>
<div class="flex flex-col gap-1">
<button onclick="editCronTask(${task.id})" class="btn bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-xs flex items-center gap-1"><i class="ri-edit-line"></i> 编辑</button>
<button onclick="toggleCronTask(${task.id})" class="btn ${task.enabled ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-green-600 hover:bg-green-700'} px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-${task.enabled ? 'pause' : 'play'}-line"></i> ${task.enabled ? '禁用' : '启用'}
</button>
<button onclick="showHistory(${task.id}, '${task.name}')" class="btn bg-purple-600 hover:bg-purple-700 px-2 py-1 rounded text-xs flex items-center gap-1"><i class="ri-history-line"></i> 历史</button>
<button onclick="deleteCronTask(${task.id})" class="btn bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-xs flex items-center gap-1"><i class="ri-delete-bin-line"></i> 删除</button>
</div>
</div>
</div>
`;
});
list.innerHTML = html;
}
function showAddCronModal() {
document.getElementById('cronModalTitle').textContent = '添加 Cron 任务';
document.getElementById('cronTaskId').value = '';
document.getElementById('cronName').value = '';
document.getElementById('cronExpression').value = '';
document.getElementById('cronCommand').value = '';
document.getElementById('cronCategory').value = 'other';
document.getElementById('cronDescription').value = '';
document.getElementById('cronEnabled').checked = true;
document.getElementById('cronHumanTime').textContent = '';
document.getElementById('cronModal').classList.remove('hidden');
}
function editCronTask(id) {
const task = cronTasks.find(t => t.id === id);
if (!task) return;
document.getElementById('cronModalTitle').textContent = '编辑 Cron 任务';
document.getElementById('cronTaskId').value = id;
document.getElementById('cronName').value = task.name;
document.getElementById('cronExpression').value = task.expression;
document.getElementById('cronCommand').value = task.command;
document.getElementById('cronCategory').value = task.category;
document.getElementById('cronDescription').value = task.description || '';
document.getElementById('cronEnabled').checked = task.enabled === 1;
document.getElementById('cronHumanTime').textContent = task.human_time || '';
document.getElementById('cronModal').classList.remove('hidden');
}
function closeCronModal() {
document.getElementById('cronModal').classList.add('hidden');
}
async function saveCronTask(event) {
event.preventDefault();
const id = document.getElementById('cronTaskId').value;
const data = {
name: document.getElementById('cronName').value,
expression: document.getElementById('cronExpression').value,
command: document.getElementById('cronCommand').value,
category: document.getElementById('cronCategory').value,
description: document.getElementById('cronDescription').value,
enabled: document.getElementById('cronEnabled').checked ? 1 : 0
};
try {
let res;
if (id) {
res = await fetch(`/api/cron/tasks/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) });
} else {
res = await fetch('/api/cron/tasks', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) });
}
const result = await res.json();
if (result.error) {
alert('保存失败: ' + result.error);
} else {
alert(result.message);
closeCronModal();
loadCronTasks();
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
async function toggleCronTask(id) {
try {
const res = await fetch(`/api/cron/tasks/${id}/toggle`, { method: 'POST' });
const data = await res.json();
alert(data.message);
loadCronTasks();
} catch (e) {
alert('操作失败: ' + e.message);
}
}
async function deleteCronTask(id) {
if (!confirm('确定要删除此任务吗?删除后会从系统 crontab 中移除。')) return;
try {
const res = await fetch(`/api/cron/tasks/${id}`, { method: 'DELETE' });
const data = await res.json();
alert(data.message);
loadCronTasks();
} catch (e) {
alert('删除失败: ' + e.message);
}
}
async function showHistory(id, name) {
document.getElementById('historyModalTitle').textContent = `${name} - 历史版本`;
document.getElementById('historyModal').classList.remove('hidden');
try {
const res = await fetch(`/api/cron/tasks/${id}/history`);
const data = await res.json();
renderHistory(data.history, id);
} catch (e) {
document.getElementById('historyList').innerHTML = '<div class="text-center py-8 text-red-400">加载失败</div>';
}
}
function renderHistory(history, taskId) {
if (history.length === 0) {
document.getElementById('historyList').innerHTML = '<div class="text-center py-8 text-gray-400">暂无历史记录</div>';
return;
}
const operationLabels = {
'create': { text: '创建', class: 'create' },
'update': { text: '更新', class: 'update' },
'delete': { text: '删除', class: 'delete' },
'toggle': { text: '切换状态', class: 'toggle' },
};
let html = '';
history.forEach(h => {
const opInfo = operationLabels[h.operation] || { text: h.operation, class: 'update' };
const isRollback = h.operation.includes('rollback');
html += `
<div class="history-item ${isRollback ? 'rollback' : opInfo.class} py-2">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-gray-400 text-sm">v${h.version}</span>
<span class="text-xs px-2 py-0.5 rounded ${isRollback ? 'bg-yellow-500/20 text-yellow-400' : 'bg-blue-500/20 text-blue-400'}">${isRollback ? '回退' : opInfo.text}</span>
</div>
<div class="text-gray-300 text-sm mt-1">${h.created_at}</div>
${h.expression ? `<code class="text-yellow-400 text-xs font-mono">${h.expression}</code>` : ''}
${h.enabled !== null ? `<span class="text-xs ml-2 ${h.enabled ? 'text-green-400' : 'text-red-400'}">${h.enabled ? '启用' : '禁用'}</span>` : ''}
</div>
${!isRollback && h.version > 1 ? `<button onclick="rollbackTo(${taskId}, ${h.version})" class="btn bg-yellow-600 hover:bg-yellow-700 px-2 py-1 rounded text-xs"><i class="ri-history-line"></i> 回退</button>` : ''}
</div>
</div>
`;
});
document.getElementById('historyList').innerHTML = html;
}
async function rollbackTo(taskId, version) {
if (!confirm(`确定要回退到版本 ${version} 吗?`)) return;
try {
const res = await fetch(`/api/cron/tasks/${taskId}/rollback/${version}`, { method: 'POST' });
const data = await res.json();
alert(data.message);
closeHistoryModal();
loadCronTasks();
} catch (e) {
alert('回退失败: ' + e.message);
}
}
function closeHistoryModal() {
document.getElementById('historyModal').classList.add('hidden');
}
async function syncFromSystem() {
if (!confirm('从系统 crontab 同步任务到数据库?')) return;
try {
const res = await fetch('/api/cron/sync', { method: 'POST' });
const data = await res.json();
alert(data.message);
loadCronTasks();
} catch (e) {
alert('同步失败: ' + e.message);
}
}
function toggleLogs() {
enableLogs = document.getElementById('enableLogs').checked;
localStorage.setItem('enableLogs', enableLogs);
}
// 解析 cron 表达式显示友好时间
document.getElementById('cronExpression').addEventListener('input', function() {
const expr = this.value;
// 简单解析
const patterns = {
'0 4 * * *': '每天凌晨4点',
'0 0 * * *': '每天午夜0点',
'*/5 * * * *': '每5分钟',
'*/10 * * * *': '每10分钟',
'*/20 * * * *': '每20分钟',
'*/30 * * * *': '每30分钟',
'0 */1 * * *': '每小时',
'0 */2 * * *': '每2小时',
};
document.getElementById('cronHumanTime').textContent = patterns[expr] || '';
});
// ==================== 通用功能 ====================
function refreshAll() {
if (currentTab === 'projects') {
loadProjects();
} else {
loadCronTasks();
}
}
// 导出配置
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 = '';
}
function scrollToTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
function scrollToBottom() { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }
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';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 初始化
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);
}
}
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);
}
setInterval(() => {
fetch('/api/projects').then(() => updateConnectionStatus(true)).catch(() => updateConnectionStatus(false));
}, 10000);
// ==================== Docker 监控 ====================
async function loadDockerData() {
try {
const [containersRes, imagesRes, systemRes] = await Promise.all([
fetch('/api/docker/containers'),
fetch('/api/docker/images'),
fetch('/api/docker/system')
]);
const containers = (await containersRes.json()).containers || [];
const images = (await imagesRes.json()).images || [];
const system = await systemRes.json();
renderDockerStats(containers, images, system);
renderDockerContainers(containers);
renderDockerImages(images);
document.getElementById('dockerVersion').textContent = system.version || '-';
} catch (e) {
console.error('Docker数据加载失败:', e);
}
}
function renderDockerStats(containers, images, system) {
const total = containers.length;
const running = containers.filter(c => c.state === 'running').length;
const stopped = total - running;
const imgTotal = images.length;
document.getElementById('dockerContainersTotal').textContent = total;
document.getElementById('dockerContainersRunning').textContent = running;
document.getElementById('dockerContainersStopped').textContent = stopped;
document.getElementById('dockerImagesTotal').textContent = imgTotal;
document.getElementById('dockerContainerCount').textContent = `(${running}运行 / ${total}总计)`;
document.getElementById('dockerImageCount').textContent = `(${imgTotal}个)`;
const imagesDisk = system.diskUsage?.find(d => d.type === 'Images');
if (imagesDisk) {
document.getElementById('dockerDiskSize').textContent = imagesDisk.size;
document.getElementById('dockerReclaimable').textContent = imagesDisk.reclaimable;
}
}
function renderDockerContainers(containers) {
const list = document.getElementById('dockerContainersList');
if (containers.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-sm py-4 text-center">暂无容器</div>';
return;
}
list.innerHTML = containers.map(c => {
const isRunning = c.state === 'running';
const s = c.stats || {};
const portsDisplay = c.ports ? c.ports.split(',').slice(0, 3).join(', ') : '';
return `<div class="card rounded-lg p-3 ${isRunning ? 'border-green-500/30' : 'border-red-500/30'}">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full ${isRunning ? 'bg-green-400' : 'bg-red-400'}" style="${isRunning ? 'box-shadow: 0 0 8px #4ade80;' : ''}"></div>
<span class="font-semibold text-sm text-gray-200">${c.name}</span>
<span class="text-xs ${isRunning ? 'text-green-400' : 'text-red-400'}">${isRunning ? '运行中' : '已停止'}</span>
</div>
<div class="flex items-center gap-1">
${isRunning ? `<button onclick="dockerControl('${c.id}','stop')" class="btn bg-red-600/50 hover:bg-red-600 px-2 py-0.5 rounded text-xs">停止</button><button onclick="dockerControl('${c.id}','restart')" class="btn bg-yellow-600/50 hover:bg-yellow-600 px-2 py-0.5 rounded text-xs">重启</button>` : `<button onclick="dockerControl('${c.id}','start')" class="btn bg-green-600/50 hover:bg-green-600 px-2 py-0.5 rounded text-xs">启动</button>`}
<button onclick="showDockerLogs('${c.id}','${c.name}')" class="btn bg-gray-600/50 hover:bg-gray-600 px-2 py-0.5 rounded text-xs">日志</button>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div><span class="text-gray-500">镜像:</span> <span class="text-gray-300">${c.image}</span></div>
${isRunning ? `<div><span class="text-gray-500">CPU:</span> <span class="text-blue-400">${s.cpuPercent || '-'}</span></div><div><span class="text-gray-500">内存:</span> <span class="text-green-400">${s.memUsage || '-'} (${s.memPercent || '-'})</span></div><div><span class="text-gray-500">进程:</span> <span class="text-gray-300">${s.pids || '-'}</span></div><div><span class="text-gray-500">网络I/O:</span> <span class="text-cyan-400">${s.netIO || '-'}</span></div><div><span class="text-gray-500">磁盘I/O:</span> <span class="text-purple-400">${s.blockIO || '-'}</span></div>` : ''}
${portsDisplay ? `<div class="col-span-2"><span class="text-gray-500">端口:</span> <span class="text-yellow-400">${portsDisplay}</span></div>` : ''}
<div class="col-span-2"><span class="text-gray-500">运行时间:</span> <span class="text-gray-400">${c.runningFor || '-'}</span></div>
</div>
</div>`;
}).join('');
}
function renderDockerImages(images) {
const list = document.getElementById('dockerImagesList');
if (images.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-sm py-4 text-center">暂无镜像</div>';
return;
}
const valid = images.filter(i => !i.dangling);
const dangling = images.filter(i => i.dangling);
let html = '';
if (valid.length > 0) {
html += valid.map(i => `<div class="flex items-center justify-between bg-gray-700/30 px-3 py-1.5 rounded text-xs"><div class="flex items-center gap-2 flex-1 min-w-0"><span class="text-gray-200 truncate">${i.repository}:${i.tag || 'latest'}</span><span class="text-gray-500">${i.size}</span></div><span class="text-gray-500 ml-2 shrink-0">${(i.created || '').substring(0, 19)}</span></div>`).join('');
}
if (dangling.length > 0) {
html += `<div class="text-yellow-400/70 text-xs mt-2 mb-1"><i class="ri-error-warning-line"></i> 悬空镜像 (${dangling.length}个)</div>`;
html += dangling.map(i => `<div class="flex items-center justify-between bg-yellow-500/10 px-3 py-1.5 rounded text-xs opacity-70"><div class="flex items-center gap-2 flex-1 min-w-0"><span class="text-gray-400 truncate">&lt;none&gt;:&lt;none&gt;</span><span class="text-gray-500">${i.size}</span><span class="text-gray-500">${i.id}</span></div><span class="text-gray-500 ml-2 shrink-0">${(i.created || '').substring(0, 19)}</span></div>`).join('');
}
list.innerHTML = html;
}
async function dockerControl(containerId, action) {
const names = { start: '启动', stop: '停止', restart: '重启' };
if (!confirm(`确定要${names[action]}容器吗?`)) return;
try {
const res = await fetch(`/api/docker/container/${containerId}/${action}`, { method: 'POST' });
const data = await res.json();
if (data.error) { alert(`操作失败: ${data.error}`); return; }
setTimeout(loadDockerData, 1000);
} catch (e) { alert('操作失败: ' + e.message); }
}
async function showDockerLogs(containerId, name) {
const m = document.getElementById('logModal');
document.getElementById('logTitle').textContent = `${name} - 日志`;
document.getElementById('logContent').textContent = '加载中...';
m.classList.add('flex'); m.classList.remove('hidden');
try {
const res = await fetch(`/api/docker/container/${containerId}/logs?lines=200`);
const data = await res.json();
document.getElementById('logContent').textContent = data.logs || data.error || '无日志';
} catch (e) { document.getElementById('logContent').textContent = '加载失败: ' + e.message; }
}
async function dockerPrune(target) {
const names = { all: '所有资源(停止的容器+悬空镜像+构建缓存)', containers: '停止的容器', images: '悬空镜像', build: '构建缓存' };
if (!confirm(`确定清理${names[target]}?不可撤销。`)) return;
try {
const res = await fetch('/api/docker/prune', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({target}) });
alert((await res.json()).message || '完成');
setTimeout(loadDockerData, 1000);
} catch (e) { alert('失败: ' + e.message); }
}
// ==================== 服务发现 ====================
async function scanServices() {
const list = document.getElementById('discoveryList');
list.innerHTML = '<div class="text-gray-400 text-xs text-center py-2"><i class="ri-loader-4-line animate-spin"></i> 扫描中...</div>';
try {
// 收集前端已注册的自定义服务端口,传给后端排除
const customServices = getCustomServices();
const customPorts = customServices.map(s => parseInt(s.port)).filter(p => !isNaN(p));
const excludeParam = customPorts.length > 0 ? `?exclude_ports=${customPorts.join(',')}` : '';
const res = await fetch(`/api/discovery/services${excludeParam}`);
const data = await res.json();
const services = data.services || [];
document.getElementById('discoveryCount').textContent = `发现 ${services.length} 个`;
if (services.length === 0) {
list.innerHTML = '<div class="text-green-400 text-xs text-center py-2"><i class="ri-checkbox-circle-line"></i> 所有服务已注册,没有遗漏</div>';
} else {
list.innerHTML = services.map(s => `
<div class="flex items-center justify-between bg-gray-700/30 px-3 py-2 rounded-lg">
<div class="flex items-center gap-3 flex-1 min-w-0">
<span class="text-xs text-cyan-400 font-mono">:${s.port}</span>
<span class="text-gray-300 text-sm truncate">${s.suggested_name}</span>
<span class="text-gray-500 text-xs">PID ${s.pid != null ? s.pid : '受权限限制'}</span>
<span class="text-gray-500 text-xs truncate hidden md:inline">${(s.cmdline || '').substring(0, 60)}</span>
</div>
<button onclick="quickAddService(${s.port}, '${s.suggested_name.replace(/'/g, "\\'")}', '${s.suggested_type}', '${(s.cwd || '').replace(/'/g, "\\'")}')" class="btn bg-green-600/50 hover:bg-green-600 px-2 py-1 rounded text-xs shrink-0 ml-2">
<i class="ri-add-line"></i> 添加
</button>
</div>
`).join('');
}
} catch (e) {
list.innerHTML = `<div class="text-red-400 text-xs text-center py-2">扫描失败: ${e.message}</div>`;
}
}
function quickAddService(port, name, type, cwd) {
showAddServiceModal();
document.getElementById('serviceName').value = name;
document.getElementById('servicePort').value = port;
document.getElementById('serviceFullUrl').value = `http://${externalIp}:${port}`;
document.getElementById('serviceDesc').value = `自动发现 | ${type} | PID路径: ${cwd || '未知'}`;
updateServicePreview();
}
// 页面加载后显示发现区块
function showDiscoverySection() {
document.getElementById('discoverySection').classList.remove('hidden');
}
setTimeout(showDiscoverySection, 500);
</script>
</body>
</html>