- ss -tlnp 输出中没有 pid= 的端口不再跳过 - pid 为 None 时显示「受权限限制」, 进程名标记「未知进程」 - 当前发现: 8088, 9119, 16031, 16067, 18789
2939 lines
160 KiB
Plaintext
2939 lines
160 KiB
Plaintext
<!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"><none>:<none></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> |