diff --git a/README.md b/README.md index 8ad1a53..fea27c6 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - **实时状态监控**: 实时查看 CPU、内存、硬盘使用率、系统负载、网络速度等关键指标。 - **全球地图视图**: 在交互式世界地图上直观地展示您的服务器节点分布和在线状态。 - **历史数据图表**: 查看单个服务器过去24小时的性能历史图表,帮助分析趋势和问题。 +- **深度节点洞察**: 在详情面板中实时查看可用率、性能摘要、累计流量以及最近的故障记录。 - **掉线告警**: 当服务器离线时,可通过 Telegram Bot 自动发送通知,并在恢复时收到提醒。 - **多数据库支持**: 支持 SQLite, MySQL 和 PostgreSQL,安装过程简单灵活。 - **轻量级探针**: 客户端探针是一个简单的 Bash 脚本,资源占用极低,兼容性强。 diff --git a/api.php b/api.php index efd5bda..4f716b8 100755 --- a/api.php +++ b/api.php @@ -3,98 +3,52 @@ ini_set('display_errors', 0); error_reporting(0); header('Content-Type: application/json'); + require_once __DIR__ . '/includes/bootstrap.php'; $action = $_GET['action'] ?? 'get_all'; -$server_id = $_GET['id'] ?? null; +$serverId = $_GET['id'] ?? null; +$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 500; try { $pdo = get_pdo_connection(); - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - if ($action === 'get_history' && $server_id) { - // --- Action: Get detailed history for a single server (last 24h) --- - $sql_history = 'SELECT cpu_usage, mem_usage_percent, disk_usage_percent, load_avg, net_up_speed, net_down_speed, total_up, total_down, timestamp, processes, connections FROM server_stats WHERE server_id = ? ORDER BY timestamp DESC LIMIT 1440'; - $stmt_history = $pdo->prepare($sql_history); - $stmt_history->execute([$server_id]); - $history = $stmt_history->fetchAll(PDO::FETCH_ASSOC); + $service = new MonitoringService($pdo, $db_config); - $typed_history = array_map(function($record) { - foreach($record as $key => $value) { - if ($value !== null && is_numeric($value)) { - $record[$key] = strpos($value, '.') === false ? (int)$value : (float)$value; - } + switch ($action) { + case 'get_history': + if (!$serverId) { + linker_json_response(['error' => '缺少服务器 ID'], 400); } - return $record; - }, $history); - - echo json_encode(['history' => array_reverse($typed_history)]); - exit; + $history = $service->getServerHistory($serverId, $limit); + linker_json_response(['history' => $history]); + break; - } elseif ($action === 'get_all') { - // --- Action: Get main dashboard data (lightweight) --- - $response = [ - 'nodes' => [], - 'outages' => [], - 'site_name' => '灵刻监控' - ]; - - $stmt_site_name = $pdo->prepare("SELECT value FROM settings WHERE `key` = 'site_name'"); - $stmt_site_name->execute(); - if ($site_name = $stmt_site_name->fetchColumn()) { - $response['site_name'] = $site_name; - } + case 'get_summary': + linker_json_response(['summary' => $service->getSummaryOnly()]); + break; - $stmt_servers = $pdo->query("SELECT id, name, intro, tags, price_usd_yearly, latitude, longitude, country_code, system, arch, cpu_model, mem_total, disk_total FROM servers ORDER BY id ASC"); - $servers = $stmt_servers->fetchAll(PDO::FETCH_ASSOC); + case 'get_insights': + linker_json_response(['insights' => $service->getInsights()]); + break; - $stmt_status = $pdo->query("SELECT id, is_online, last_checked FROM server_status"); - $online_status_raw = $stmt_status->fetchAll(PDO::FETCH_ASSOC); - $online_status = []; - foreach ($online_status_raw as $status) { - $online_status[$status['id']] = $status; - } - - if ($db_config['type'] === 'pgsql') { - $sql_stats = "SELECT DISTINCT ON (server_id) * FROM server_stats ORDER BY server_id, timestamp DESC"; - } else { // Works for SQLite and MySQL - $sql_stats = "SELECT s.* FROM server_stats s JOIN (SELECT server_id, MAX(timestamp) AS max_ts FROM server_stats GROUP BY server_id) AS m ON s.server_id = m.server_id AND s.timestamp = m.max_ts"; - } - $stmt_stats = $pdo->query($sql_stats); - $latest_stats_raw = $stmt_stats->fetchAll(PDO::FETCH_ASSOC); - $latest_stats = []; - foreach($latest_stats_raw as $stat) { - $latest_stats[$stat['server_id']] = $stat; - } - - foreach ($servers as $node) { - $node_id = $node['id']; - $node['x'] = (float)($node['latitude'] ?? 0); - $node['y'] = (float)($node['longitude'] ?? 0); - $node['stats'] = $latest_stats[$node_id] ?? []; - - $status_info = $online_status[$node_id] ?? ['is_online' => false, 'last_checked' => 0]; - $node['is_online'] = (bool)$status_info['is_online']; - - if (!$node['is_online'] && $status_info['last_checked'] > 0) { - $node['anomaly_msg'] = '服务器掉线'; - $node['outage_duration'] = time() - $status_info['last_checked']; + case 'get_node_detail': + if (!$serverId) { + linker_json_response(['error' => '缺少服务器 ID'], 400); } - - $node['history'] = []; // History is no longer included in the main payload - - $response['nodes'][] = $node; - } - - $response['outages'] = $pdo->query("SELECT * FROM outages ORDER BY start_time DESC LIMIT 100")->fetchAll(PDO::FETCH_ASSOC); - - echo json_encode($response); + $node = $service->getServerDetails($serverId); + linker_json_response(['node' => $node]); + break; + + case 'get_all': + default: + $payload = $service->getDashboardPayload(); + linker_json_response($payload); + break; } - -} catch (Exception $e) { - http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); - exit; +} catch (Throwable $e) { + error_log('api.php error: ' . $e->getMessage()); + linker_json_response(['error' => '服务器内部错误,请稍后重试。'], 500); } + ?> diff --git a/assets/css/main.css b/assets/css/main.css index c15ab45..defd825 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -1,99 +1,1111 @@ - :root { --bg-color: #ffffff; --grid-color: #e0e0e0; --text-color: #000000; --border-color: #cccccc; --button-bg: #f0f0f0; --button-active-bg: #000000; --button-active-text: #ffffff; --anomaly-color: #ff4136; --warning-color: #ffc107; --signal-color: #0074d9; --uptime-good: #2ecc40; --uptime-bad: #ff4136; --map-land-fill: #f9f9f9; --progress-bg: #e9ecef; --progress-cpu: #007bff; --progress-mem: #ffc107; --progress-swap: #dc3545; --progress-disk: #17a2b8; } - * { box-sizing: border-box; margin: 0; padding: 0; } - body { font-family: "Victor Mono", monospace; background-color: var(--bg-color); color: var(--text-color); height: 100vh; overflow: hidden; background-image: linear-gradient(var(--grid-color) 1px, transparent 1px), linear-gradient(to right, var(--grid-color) 1px, var(--bg-color) 1px); background-size: 25px 25px; } - .monitor-container { display: flex; flex-direction: column; height: 100vh; padding: 15px; } - header { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; padding-bottom: 15px; } - main { flex-grow: 1; position: relative; overflow: hidden; } - footer { flex-shrink: 0; padding-top: 15px; text-align: center; font-size: 12px; color: #999; } - header h1 { font-size: 22px; font-weight: normal; } - .header-controls { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; } - .controls { display: flex; gap: 8px; flex-wrap: wrap; } - .view { position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease; overflow-y: auto; padding: 5px; } - .view.active { opacity: 1; visibility: visible; } - #map-view { display: flex; justify-content: center; align-items: center; } - #world-map-container { position: relative; width: 100%; max-width: 1200px; overflow: hidden; } - #world-map-svg { width: 100%; height: auto; } - #world-map-svg path { fill: var(--map-land-fill); stroke: var(--text-color); stroke-width: 0.7; } - .map-node { fill: var(--text-color); cursor: pointer; transition: r 0.2s ease, fill 0.2s ease; stroke: var(--bg-color); stroke-width: 0.5; } - .map-node.anomaly { fill: var(--anomaly-color); } - #tooltip { position: absolute; display: none; background-color: rgba(255, 255, 255, 0.95); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px 12px; font-size: 12px; pointer-events: none; white-space: nowrap; box-shadow: 0 2px 5px rgba(0,0,0,0.1); z-index: 100; transform: translate(-50%, -130%); } - #tooltip .subtitle { color: #666; } - #tooltip .anomaly-subtitle { color: var(--anomaly-color); font-weight: bold; } - .signal { position: absolute; height: 3px; background: var(--signal-color); border-radius: 2px; opacity: 0; pointer-events: none; transform-origin: left center; } - - /* Card View Styles */ - #standalones-view { padding: 1rem; display: flex; flex-direction: column; } - .tag-filters { flex-shrink: 0; padding-bottom: 1rem; } - .tag-filters button { font-family: "Victor Mono", monospace; border: 1px solid var(--border-color); background-color: var(--button-bg); color: var(--text-color); padding: 4px 10px; font-size: 12px; cursor: pointer; margin-right: 8px; margin-bottom: 5px; border-radius: 15px; } - .tag-filters button.active { background-color: var(--button-active-bg); color: var(--button-active-text); border-color: var(--button-active-bg); } - .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; width: 100%; } - .server-card { background: #fff; border: 1px solid var(--border-color); border-radius: 8px; padding: 1rem 1.5rem; cursor: pointer; transition: all 0.3s ease; position: relative; overflow: hidden; font-size: 0.9rem;} - .server-card:hover { transform: translateY(-5px); box-shadow: 0 8px 15px rgba(0,0,0,0.1); } - .server-card.offline::before, .server-card.high-load::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 8px; pointer-events: none; z-index: 1; animation: glow 1.5s infinite alternate; } - .server-card.offline::before { box-shadow: 0 0 15px 5px var(--anomaly-color); } - .server-card.high-load::before { box-shadow: 0 0 15px 5px var(--warning-color); } - @keyframes glow { from { opacity: 0.4; } to { opacity: 1; } } - - .card-header { display: flex; align-items: center; margin-bottom: 1.2rem; gap: 0.7rem;} - .card-header .name { font-size: 1.1rem; font-weight: bold; flex-grow: 1;} - .card-header .status-icon { width: 12px; height: 12px; border-radius: 50%; background-color: var(--uptime-good); } - .card-header .status-icon.down { background-color: var(--uptime-bad); } - - .stat-grid { display: grid; grid-template-columns: 50px 1fr; gap: 0.8rem; align-items: center; } - .stat-grid .label { color: #666; text-align: right; } - .stat-grid .value { font-weight: bold; } - .progress-bar { width: 100%; background-color: var(--progress-bg); border-radius: 4px; height: 1rem; overflow: hidden; display: flex; align-items: center; } - .progress-bar-inner { height: 100%; color: white; text-align: right; padding-right: 5px; font-size: 0.75rem; line-height: 1rem; white-space: nowrap; transition: width 0.3s ease; } - .progress-cpu { background-color: var(--progress-cpu); } - .progress-mem { background-color: var(--progress-mem); } - .progress-disk { background-color: var(--progress-disk); } - - /* Pagination */ - .pagination { display: flex; justify-content: center; padding: 1.5rem 0; gap: 0.5rem; } - .pagination button { font-family: "Victor Mono", monospace; border: 1px solid var(--border-color); background-color: var(--button-bg); padding: 5px 10px; cursor: pointer; border-radius: 4px; } - .pagination button.active { background-color: var(--button-active-bg); color: var(--button-active-text); } - .pagination button:disabled { cursor: not-allowed; opacity: 0.5; } - - /* Modal Styles */ - .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; justify-content: center; align-items: center; opacity: 0; visibility: hidden; transition: opacity 0.3s ease; } - .modal-overlay.active { opacity: 1; visibility: visible; } - .modal-content { background: #fff; padding: 2rem; border-radius: 8px; width: 90%; max-width: 900px; max-height: 90vh; overflow-y: auto; position: relative; transform: scale(0.9); transition: transform 0.3s ease; } - .modal-overlay.active .modal-content { transform: scale(1); } - .modal-close { position: absolute; top: 1rem; right: 1rem; background: none; border: none; font-size: 2rem; cursor: pointer; color: #999; } - .modal-header h2 { margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.8rem;} - .modal-header .subtitle { color: #666; margin-bottom: 1.5rem; } - .modal-info-section { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--grid-color); font-size: 0.9rem;} - .info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } - .modal-info-item strong { color: #555; display: block; margin-bottom: 0.3rem;} - .modal-info-item p { margin: 0; color: #333; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } - - .chart-grid { display: grid; grid-template-columns: 1fr; gap: 2rem; } - @media (min-width: 768px) { .chart-grid { grid-template-columns: 1fr 1fr; } } - .chart-container { margin-top: 1.5rem; } - .chart-container h3 { font-size: 1rem; margin-bottom: 0.5rem; text-align: center;} - .chart-svg { width: 100%; height: 150px; background: #f9f9f9; border-radius: 4px; display:flex; align-items:center; justify-content:center; } - .chart-svg .grid-line { stroke: #eee; stroke-width: 1; } - .chart-svg .line { fill: none; stroke: var(--signal-color); stroke-width: 2; } - .chart-svg .axis-text { font-size: 10px; fill: #999; } - - .timeline { position: relative; padding-left: 40px; border-left: 2px solid var(--grid-color); margin-left: 10px; } - .timeline-item { position: relative; margin-bottom: 30px; } - .timeline-item::before { content: ''; position: absolute; left: -47px; top: 5px; width: 12px; height: 12px; border-radius: 50%; background-color: var(--text-color); border: 2px solid var(--bg-color); } - .timeline-item.critical::before { background-color: var(--anomaly-color); } - .timeline-item .time { font-size: 12px; color: #666; margin-bottom: 5px; } - .timeline-item .title { font-weight: bold; margin-bottom: 8px; } - .timeline-item .content { font-size: 14px; line-height: 1.5; } - .controls button { font-family: "Victor Mono", monospace; border: 1px solid var(--border-color); background-color: var(--button-bg); color: var(--text-color); padding: 6px 12px; font-size: 12px; cursor: pointer; border-radius: 4px; transition: all 0.2s ease; white-space: nowrap; } - .controls button:hover { border-color: var(--text-color); background-color: #e8e8e8; } - .controls button.active { background-color: var(--button-active-bg); color: var(--button-active-text); border-color: var(--button-active-bg); } - .error-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255, 255, 255, 0.9); z-index: 1000; display: flex; justify-content: center; align-items: center; text-align: left; padding: 20px; color: #ff4136; } - .error-overlay pre { white-space: pre-wrap; word-wrap: break-word; } - @media (min-width: 640px) { - .monitor-container { padding: 25px; } - header h1 { font-size: 24px; } - .controls button { font-size: 13px; padding: 7px 14px; } - } - @media (max-width: 768px) { - .header-controls { flex-direction: column; align-items: flex-start; gap: 10px; } - .controls { justify-content: flex-start; } - } \ No newline at end of file +:root { + --bg-color: #f5f7fb; + --panel-bg: #ffffff; + --text-color: #1f2937; + --muted-text: #6b7280; + --border-color: #e5e7eb; + --border-strong: #d1d5db; + --button-bg: #f3f4f6; + --button-active-bg: #2563eb; + --button-active-text: #ffffff; + --anomaly-color: #ef4444; + --warning-color: #f59e0b; + --signal-color: #2563eb; + --uptime-good: #22c55e; + --uptime-bad: #ef4444; + --map-land-fill: #eef2ff; + --progress-bg: #e5e7eb; + --progress-cpu: #2563eb; + --progress-mem: #f59e0b; + --progress-disk: #10b981; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: "Victor Mono", monospace; + background: var(--bg-color); + color: var(--text-color); + height: 100vh; + overflow: hidden; +} + +.monitor-container { + display: flex; + flex-direction: column; + height: 100vh; + padding: 18px 24px; + gap: 14px; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding-bottom: 6px; +} + +header h1 { + font-size: 22px; + font-weight: 600; + color: #111827; +} + +.header-controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.controls { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.controls button { + font-family: "Victor Mono", monospace; + border: 1px solid var(--border-color); + background: var(--button-bg); + color: var(--text-color); + padding: 6px 12px; + font-size: 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +.controls button:hover { + border-color: var(--border-strong); + background: #e5e7eb; +} + +.controls button.active { + background: var(--button-active-bg); + color: var(--button-active-text); + border-color: var(--button-active-bg); +} + +.summary-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; +} + +.summary-item { + background: var(--panel-bg); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 78px; + transition: border-color 0.2s ease; +} + +.summary-item .label { + font-size: 0.68rem; + letter-spacing: 0.08em; + color: var(--muted-text); + text-transform: uppercase; +} + +.summary-item .value { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-color); +} + +.summary-item .subvalue { + font-size: 0.75rem; + color: var(--muted-text); +} + +main { + flex: 1; + position: relative; + overflow: hidden; +} + +footer { + flex-shrink: 0; + padding-top: 10px; + text-align: center; + font-size: 12px; + color: var(--muted-text); +} + +.view { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + visibility: hidden; + transition: opacity 0.25s ease, visibility 0.25s ease; + overflow-y: auto; + padding: 8px; +} + +.view.active { + opacity: 1; + visibility: visible; +} + +#map-view { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding-bottom: 16px; +} + +#world-map-container { + position: relative; + width: 100%; + max-width: 1100px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--panel-bg); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); +} + +#world-map-svg { + width: 100%; + height: auto; +} + +#world-map-svg path { + fill: var(--map-land-fill); + stroke: #9ca3af; + stroke-width: 0.6; +} + +.map-node { + fill: var(--text-color); + cursor: pointer; + transition: r 0.2s ease, fill 0.2s ease; + stroke: var(--panel-bg); + stroke-width: 0.5; +} + +.map-node.anomaly { + fill: var(--anomaly-color); +} + +.map-node.stale { + fill: var(--warning-color); +} + +#tooltip { + position: absolute; + display: none; + background: rgba(255, 255, 255, 0.95); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px 10px; + font-size: 12px; + color: var(--text-color); + pointer-events: none; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12); + transform: translate(-50%, -120%); + z-index: 20; +} + +#tooltip .subtitle { + color: var(--muted-text); +} + +#tooltip .anomaly-subtitle { + color: var(--anomaly-color); + font-weight: 600; +} + +.signal { + position: absolute; + height: 2px; + background: var(--signal-color); + border-radius: 999px; + opacity: 0; + pointer-events: none; + transform-origin: left center; +} + +#standalones-view { + padding: 10px 12px 30px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.list-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.search-field { + flex: 1 1 220px; + min-width: 200px; +} + +.search-field input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--panel-bg); + color: var(--text-color); + font-family: "Victor Mono", monospace; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.search-field input:focus { + outline: none; + border-color: var(--button-active-bg); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.status-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.status-filters button { + font-family: "Victor Mono", monospace; + border: 1px solid var(--border-color); + background: var(--panel-bg); + color: var(--text-color); + padding: 6px 10px; + font-size: 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; +} + +.status-filters button:hover { + border-color: var(--border-strong); + background: #e5e7eb; +} + +.status-filters button.active { + background: var(--button-active-bg); + color: var(--button-active-text); + border-color: var(--button-active-bg); +} + +.last-refresh { + margin-left: auto; + font-size: 0.75rem; + color: var(--muted-text); +} + +.tag-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag-filters button { + font-family: "Victor Mono", monospace; + border: 1px solid var(--border-color); + background: var(--panel-bg); + color: var(--text-color); + padding: 4px 12px; + font-size: 12px; + border-radius: 16px; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; +} + +.tag-filters button.active { + background: var(--button-active-bg); + color: var(--button-active-text); + border-color: var(--button-active-bg); +} + +.insights-drawer { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--panel-bg); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.insights-drawer summary { + list-style: none; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 14px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-color); +} + +.insights-drawer summary::-webkit-details-marker { + display: none; +} + +.insights-drawer .insights-updated { + font-size: 0.75rem; + font-weight: 400; + color: var(--muted-text); +} + +.insights-drawer[open] { + border-color: var(--button-active-bg); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08); +} + +.insights-drawer[open] summary { + border-bottom: 1px solid var(--border-color); +} + +.insights-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + padding: 12px 14px 14px; +} + +.insight-card { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--panel-bg); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.insight-card-title { + font-size: 0.78rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted-text); +} + +.insight-counts { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.insight-count { + font-size: 0.75rem; + padding: 3px 8px; + border-radius: 999px; + background: #f3f4f6; + color: var(--text-color); +} + +.insight-count.danger { + background: rgba(239, 68, 68, 0.12); + color: var(--anomaly-color); +} + +.insight-count.warning { + background: rgba(245, 158, 11, 0.12); + color: var(--warning-color); +} + +.insight-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.insight-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + font-size: 0.82rem; + border-left: 3px solid transparent; + padding-left: 8px; +} + +.insight-item .insight-node { + font-weight: 600; + color: var(--text-color); +} + +.insight-item .insight-meta { + font-size: 0.72rem; + color: var(--muted-text); + text-align: right; +} + +.insight-item.danger { + border-left-color: var(--anomaly-color); +} + +.insight-item.warning { + border-left-color: var(--warning-color); +} + +.insight-empty { + text-align: center; + font-size: 0.8rem; + color: var(--muted-text); + padding: 10px 0; +} + +.insight-metric-sections { + display: flex; + flex-direction: column; + gap: 10px; +} + +.insight-metric { + display: flex; + flex-direction: column; + gap: 6px; +} + +.insight-metric-title { + font-size: 0.76rem; + color: var(--muted-text); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.insight-metric-rows { + display: flex; + flex-direction: column; + gap: 4px; +} + +.insight-metric-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-size: 0.82rem; +} + +.insight-rank { + color: var(--muted-text); + min-width: 20px; +} + +.insight-value { + font-weight: 600; + color: var(--text-color); +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} + +.server-card { + background: var(--panel-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.1rem 1.2rem; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + position: relative; + overflow: hidden; + font-size: 0.88rem; + box-shadow: 0 8px 16px rgba(15, 23, 42, 0.05); +} + +.server-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 26px rgba(15, 23, 42, 0.12); + border-color: var(--border-strong); +} + +.server-card.offline::before, +.server-card.stale::before, +.server-card.high-load::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + pointer-events: none; + opacity: 0.22; +} + +.server-card.offline::before { + background: var(--anomaly-color); +} + +.server-card.stale::before { + background: var(--warning-color); +} + +.server-card.high-load::before { + background: rgba(245, 158, 11, 0.18); +} + +.card-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.9rem; + position: relative; + z-index: 1; +} + +.card-header .name { + font-size: 1.05rem; + font-weight: 600; + flex: 1; +} + +.card-header .header-title { + display: flex; + flex-direction: column; + gap: 0.35rem; + flex: 1; +} + +.card-header .status-icon { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--uptime-good); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); +} + +.card-header .status-icon.down { + background: var(--uptime-bad); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18); +} + +.card-header .status-icon.stale { + background: var(--warning-color); + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2); +} + +.status-note { + font-size: 0.76rem; + color: var(--muted-text); +} + +.status-note.warning { + color: var(--warning-color); +} + +.status-note.danger { + color: var(--anomaly-color); +} + +.stat-grid { + display: grid; + grid-template-columns: 60px 1fr; + gap: 0.75rem; + align-items: center; + position: relative; + z-index: 1; +} + +.stat-grid .label { + color: var(--muted-text); + text-align: right; + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.stat-grid .value { + font-weight: 600; + color: var(--text-color); +} + +.progress-bar { + width: 100%; + background: var(--progress-bg); + border-radius: 999px; + height: 0.85rem; + overflow: hidden; + display: flex; + align-items: center; +} + +.progress-bar-inner { + height: 100%; + color: #fff; + text-align: right; + padding-right: 6px; + font-size: 0.68rem; + line-height: 0.85rem; + white-space: nowrap; + transition: width 0.25s ease; +} + +.progress-cpu { + background: var(--progress-cpu); +} + +.progress-mem { + background: var(--progress-mem); +} + +.progress-disk { + background: var(--progress-disk); +} + +.pagination { + display: flex; + justify-content: center; + padding: 16px 0; + gap: 8px; +} + +.pagination button { + font-family: "Victor Mono", monospace; + border: 1px solid var(--border-color); + background: var(--panel-bg); + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; + color: var(--text-color); +} + +.pagination button:hover { + border-color: var(--border-strong); + background: #e5e7eb; +} + +.pagination button.active { + background: var(--button-active-bg); + color: var(--button-active-text); + border-color: var(--button-active-bg); +} + +.pagination button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.55); + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease; + z-index: 1000; +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal-content { + background: var(--panel-bg); + padding: 1.8rem 2rem; + border-radius: 14px; + width: 92%; + max-width: 900px; + max-height: 90vh; + overflow-y: auto; + position: relative; + transform: translateY(20px); + transition: transform 0.3s ease; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25); +} + +.modal-overlay.active .modal-content { + transform: translateY(0); +} + +.modal-close { + position: absolute; + top: 1rem; + right: 1rem; + background: none; + border: none; + font-size: 2rem; + cursor: pointer; + color: var(--muted-text); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1.2rem; + margin-bottom: 1.4rem; +} + +.title-stack { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.title-stack h2 { + font-size: 1.45rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.modal-subtitle { + color: var(--muted-text); + font-size: 0.9rem; +} + +.modal-meta { + display: flex; + flex-direction: column; + gap: 0.25rem; + text-align: right; + font-size: 0.75rem; + color: var(--muted-text); + min-width: 150px; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.7rem; + padding: 0.25rem 0.55rem; + border-radius: 999px; + background: rgba(37, 99, 235, 0.12); + color: var(--signal-color); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.status-badge.ok { + background: rgba(34, 197, 94, 0.15); + color: var(--uptime-good); +} + +.status-badge.warning { + background: rgba(245, 158, 11, 0.18); + color: var(--warning-color); +} + +.status-badge.danger { + background: rgba(239, 68, 68, 0.18); + color: var(--anomaly-color); +} + +.modal-info-section { + margin-bottom: 1.6rem; + padding: 1.2rem 1.4rem; + border-radius: 12px; + border: 1px solid var(--border-color); + background: #f9fafb; + font-size: 0.9rem; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.modal-info-item strong { + color: var(--muted-text); + display: block; + margin-bottom: 0.35rem; + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.modal-info-item p { + margin: 0; + color: var(--text-color); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.modal-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 1rem; + margin-bottom: 1.4rem; +} + +.modal-metrics .metric-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 1rem; + background: var(--panel-bg); + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.modal-metrics .metric-card.ok { + border-color: rgba(34, 197, 94, 0.4); +} + +.modal-metrics .metric-card.warning { + border-color: rgba(245, 158, 11, 0.4); +} + +.modal-metrics .metric-card.danger { + border-color: rgba(239, 68, 68, 0.4); +} + +.metric-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted-text); +} + +.metric-value { + font-size: 1.2rem; + font-weight: 600; +} + +.metric-subvalue { + font-size: 0.75rem; + color: var(--muted-text); +} + +.modal-history-summary { + margin-bottom: 1.2rem; +} + +.modal-history-summary h3 { + font-size: 1rem; + margin-bottom: 0.6rem; +} + +.modal-history-summary .history-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.8rem; +} + +.history-card { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.75rem; + background: var(--panel-bg); +} + +.history-card .label { + font-size: 0.7rem; + color: var(--muted-text); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.history-card .value { + font-size: 0.95rem; + font-weight: 600; +} + +.modal-outages { + margin-bottom: 1.4rem; +} + +.modal-outages h3 { + font-size: 1rem; + margin-bottom: 0.5rem; +} + +.outage-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.outage-item { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.75rem 0.9rem; +} + +.outage-item.active { + border-color: var(--anomaly-color); +} + +.outage-title { + font-weight: 600; + margin-bottom: 0.35rem; +} + +.outage-time { + font-size: 0.75rem; + color: var(--muted-text); + margin-bottom: 0.4rem; +} + +.outage-content { + font-size: 0.85rem; + color: var(--text-color); + line-height: 1.4; +} + +.chart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.chart-container { + background: #f9fafb; + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 0.9rem; +} + +.chart-container h3 { + font-size: 0.9rem; + margin-bottom: 0.6rem; + text-align: center; +} + +.chart-svg { + width: 100%; + height: 160px; + background: var(--panel-bg); + border-radius: 6px; +} + +.chart-svg .grid-line { + stroke: #e5e7eb; + stroke-width: 1; +} + +.chart-svg .line { + fill: none; + stroke: var(--signal-color); + stroke-width: 2; +} + +.chart-svg .axis-text { + font-size: 10px; + fill: #9ca3af; +} + +#outages-view { + padding: 12px 16px 30px; +} + +.timeline { + position: relative; + padding-left: 36px; + border-left: 2px solid var(--border-color); +} + +.timeline-item { + position: relative; + margin-bottom: 26px; +} + +.timeline-item::before { + content: ''; + position: absolute; + left: -39px; + top: 4px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-color); + border: 2px solid var(--panel-bg); +} + +.timeline-item.critical::before { + background: var(--anomaly-color); +} + +.timeline-item .time { + font-size: 12px; + color: var(--muted-text); + margin-bottom: 4px; +} + +.timeline-item .title { + font-weight: 600; + margin-bottom: 6px; +} + +.timeline-item .content { + font-size: 0.86rem; + color: var(--text-color); + line-height: 1.5; +} + +.error-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.92); + display: flex; + justify-content: center; + align-items: center; + text-align: left; + padding: 20px; + color: var(--anomaly-color); + z-index: 2000; +} + +.error-overlay pre { + white-space: pre-wrap; + word-wrap: break-word; +} + +@media (max-width: 768px) { + .monitor-container { + padding: 16px; + } + + header { + flex-direction: column; + align-items: flex-start; + } + + .controls { + width: 100%; + } + + .controls button { + flex: 1; + text-align: center; + } + + .summary-bar { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } + + #world-map-container { + box-shadow: none; + } + + .list-toolbar { + flex-direction: column; + align-items: stretch; + } + + .last-refresh { + margin-left: 0; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index 0e1db71..2a419ab 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,10 +1,12 @@ document.addEventListener('DOMContentLoaded', () => { - let monitorData = { nodes: [], outages: [], site_name: '灵刻监控' }; + let monitorData = { nodes: [], outages: [], site_name: '灵刻监控', summary: {} }; let activeButton = document.querySelector('.controls button[data-view="map"]'); let signalInterval = null; let activeTags = new Set(); let currentPage = 1; const itemsPerPage = 18; + let searchQuery = ''; + let statusFilter = 'all'; const tooltip = document.getElementById('tooltip'); @@ -13,6 +15,47 @@ const views = document.querySelectorAll('.view'); const modalOverlay = document.getElementById('details-modal'); const modalCloseBtn = document.getElementById('modal-close-btn'); + const lastDataRefresh = document.getElementById('last-data-refresh'); + const searchInput = document.getElementById('server-search'); + const statusFilterButtons = document.querySelectorAll('.status-filters button'); + const summaryElements = { + total: document.getElementById('summary-total'), + online: document.getElementById('summary-online'), + offline: document.getElementById('summary-offline'), + stale: document.getElementById('summary-stale'), + avgCpu: document.getElementById('summary-avg-cpu'), + avgMem: document.getElementById('summary-avg-mem'), + avgLoad: document.getElementById('summary-avg-load'), + activeAlerts: document.getElementById('summary-active-alerts'), + lastSync: document.getElementById('summary-last-sync') + }; + + const insightsDrawer = document.getElementById('insights-drawer'); + const insightsContainer = document.getElementById('insights-grid'); + const insightsTimestamp = document.getElementById('insights-generated-at'); + + if (insightsDrawer) { + insightsDrawer.addEventListener('toggle', () => { + insightsDrawer.dataset.userToggled = '1'; + }); + } + + if (searchInput) { + searchInput.addEventListener('input', (event) => { + searchQuery = event.target.value.trim().toLowerCase(); + currentPage = 1; + generateStandalonesView(monitorData.nodes); + }); + } + + statusFilterButtons.forEach(button => { + button.addEventListener('click', () => { + statusFilter = button.dataset.status || 'all'; + statusFilterButtons.forEach(btn => btn.classList.toggle('active', btn === button)); + currentPage = 1; + generateStandalonesView(monitorData.nodes); + }); + }); function displayError(message) { @@ -35,6 +78,8 @@ if (data.error) throw new Error(`API 错误: ${data.error}`); monitorData = data; + monitorData.summary = monitorData.summary || {}; + monitorData.insights = monitorData.insights || {}; document.title = monitorData.site_name; document.getElementById('site-title').textContent = monitorData.site_name; document.getElementById('copyright-footer').innerHTML = `Copyright 2025 ${monitorData.site_name}. Powered by Linker.`; @@ -59,10 +104,87 @@ return `${(seconds / 86400).toFixed(1)} 天`; } + function formatDateTime(timestamp) { + if (!timestamp) return ''; + const value = Number(timestamp); + const ms = value < 1e12 ? value * 1000 : value; + return new Date(ms).toLocaleString(navigator.language.startsWith('zh') ? 'zh-CN' : undefined); + } + + function formatRelativeTime(timestamp) { + if (!timestamp) return '无记录'; + const value = Number(timestamp); + const ms = value < 1e12 ? value * 1000 : value; + const diffSeconds = Math.round((Date.now() - ms) / 1000); + if (diffSeconds <= 0) return '刚刚'; + if (diffSeconds < 60) return `${diffSeconds} 秒前`; + if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)} 分钟前`; + if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)} 小时前`; + if (diffSeconds < 86400 * 7) return `${Math.floor(diffSeconds / 86400)} 天前`; + return new Date(ms).toLocaleDateString(navigator.language.startsWith('zh') ? 'zh-CN' : undefined); + } + + function formatPercent(value, fraction = 1) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return '--%'; + } + return `${Number(value).toFixed(fraction)}%`; + } + + function formatNumber(value, fraction = 2, fallback = '--') { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return fallback; + } + return Number(value).toFixed(fraction); + } + + function updateLastRefreshDisplay(timestamp) { + if (!lastDataRefresh) return; + if (!timestamp) { + lastDataRefresh.textContent = '--'; + lastDataRefresh.removeAttribute('title'); + return; + } + const relative = formatRelativeTime(timestamp); + const absolute = formatDateTime(timestamp); + lastDataRefresh.textContent = `${relative} · ${absolute}`; + lastDataRefresh.title = absolute; + } + + function updateSummaryBar(summary = {}) { + if (!summaryElements.total) return; + const safe = { + total_servers: summary.total_servers ?? monitorData.nodes.length, + online: summary.online ?? 0, + offline: summary.offline ?? Math.max(0, monitorData.nodes.length - (summary.online ?? 0)), + stale: summary.stale ?? 0, + avg_cpu: summary.avg_cpu ?? null, + avg_mem: summary.avg_mem ?? null, + avg_load: summary.avg_load ?? null, + active_alerts: summary.active_alerts ?? 0, + last_sync: summary.last_sync ?? null, + generated_at: summary.generated_at ?? null + }; + summaryElements.total.textContent = safe.total_servers; + summaryElements.online.textContent = safe.online; + summaryElements.offline.textContent = safe.offline; + summaryElements.stale.textContent = safe.stale; + summaryElements.avgCpu.textContent = formatPercent(safe.avg_cpu); + summaryElements.avgMem.textContent = formatPercent(safe.avg_mem); + summaryElements.avgLoad.textContent = safe.avg_load !== null && safe.avg_load !== undefined ? Number(safe.avg_load).toFixed(2) : '--'; + summaryElements.activeAlerts.textContent = safe.active_alerts; + summaryElements.lastSync.textContent = safe.last_sync ? formatRelativeTime(safe.last_sync) : '--'; + summaryElements.lastSync.title = safe.last_sync ? formatDateTime(safe.last_sync) : ''; + updateLastRefreshDisplay(safe.last_sync || safe.generated_at); + monitorData.summary = summary; + } + function renderAllViews() { + updateSummaryBar(monitorData.summary || {}); initializeMap(monitorData.nodes); generateStandalonesView(monitorData.nodes); generateOutagesView(monitorData.outages, monitorData.nodes); + renderInsights(monitorData.insights || {}); generateTagFilters(monitorData.nodes); } @@ -95,27 +217,52 @@ if(width > 0 && height > 0) mapContainer.style.aspectRatio = `${width} / ${height}`; } nodes.forEach(node => { - if (node.hasOwnProperty('x') && node.hasOwnProperty('y')) { - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('id', `map-node-${node.id}`); - circle.setAttribute('class', `map-node ${!node.is_online ? 'anomaly' : ''}`); - circle.setAttribute('cx', node.y); circle.setAttribute('cy', node.x); circle.setAttribute('r', 5); - svg.appendChild(circle); - circle.addEventListener('mouseenter', () => { - let content = `${getFlagEmoji(node.country_code)} ${node.name}`; - if (node.intro) content += `
${node.intro}`; - if (!node.is_online) content += `
${node.anomaly_msg || '状态异常'}`; - tooltip.innerHTML = content; - tooltip.style.display = 'block'; - }); - circle.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; }); - circle.addEventListener('click', () => showDetailsModal(node.id)); + const cy = Number(node?.x); + const cx = Number(node?.y); + if (!Number.isFinite(cx) || !Number.isFinite(cy)) { + return; + } + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('id', `map-node-${node.id}`); + circle.classList.add('map-node'); + if (!node.is_online) { + circle.classList.add('anomaly'); + } else if (node.is_stale) { + circle.classList.add('stale'); } + circle.setAttribute('cx', cx); + circle.setAttribute('cy', cy); + circle.setAttribute('r', 5); + svg.appendChild(circle); + circle.addEventListener('mouseenter', () => { + let content = `${getFlagEmoji(node.country_code)} ${node.name}`; + if (node.intro) content += `
${node.intro}`; + const statusParts = []; + if (!node.is_online) { + statusParts.push(node.anomaly_msg || '服务器离线'); + } else if (node.is_stale) { + statusParts.push('数据陈旧'); + } else { + statusParts.push('在线'); + } + const lastSignal = node.last_seen || node.last_reported; + if (lastSignal) { + statusParts.push(formatRelativeTime(lastSignal)); + } + if (statusParts.length) { + const statusClass = !node.is_online ? 'anomaly-subtitle' : 'subtitle'; + content += `
状态: ${statusParts.join(' · ')}`; + } + tooltip.innerHTML = content; + tooltip.style.display = 'block'; + }); + circle.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; }); + circle.addEventListener('click', () => showDetailsModal(node.id)); }); } function formatBytes(bytes, decimals = 2) { - if (!bytes || bytes === 0) return '0 Bytes'; + if (!bytes || bytes <= 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; @@ -126,52 +273,109 @@ function generateStandalonesView(nodes) { const container = document.querySelector('#standalones-view .card-grid'); container.innerHTML = ''; - + const filteredNodes = nodes.filter(node => { - const nodeTags = node.tags ? node.tags.split(',').map(t => t.trim()) : []; - return activeTags.size === 0 || [...activeTags].every(tag => nodeTags.includes(tag)); + const nodeTags = Array.isArray(node.tags_array) + ? node.tags_array + : (node.tags ? node.tags.split(',').map(t => t.trim()) : []); + const matchesTags = activeTags.size === 0 || [...activeTags].every(tag => nodeTags.includes(tag)); + const matchesStatus = statusFilter === 'all' + ? true + : (statusFilter === 'online' && node.is_online && !node.is_stale) + || (statusFilter === 'offline' && !node.is_online) + || (statusFilter === 'stale' && node.is_online && node.is_stale); + const matchesSearch = !searchQuery || [ + node.name, + node.id, + node.intro, + node.tags, + node.country_code, + node.system, + node.arch + ] + .filter(Boolean) + .some(value => value.toString().toLowerCase().includes(searchQuery)); + return matchesTags && matchesStatus && matchesSearch; }); + const totalPages = Math.max(1, Math.ceil(filteredNodes.length / itemsPerPage)); + if (currentPage > totalPages) { + currentPage = totalPages; + } const paginatedNodes = filteredNodes.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); paginatedNodes.forEach(node => { const stats = node.stats || {}; const isOffline = !node.is_online; - const isHighLoad = !isOffline && stats.load_avg > 2.0; - + const isStale = !!node.is_stale && node.is_online; + const loadAvg = Number(stats.load_avg ?? 0); + const isHighLoad = !isOffline && !isStale && Number.isFinite(loadAvg) && loadAvg > 2.0; + let cardClass = 'server-card'; - if (isOffline) cardClass += ' offline'; - if (isHighLoad) cardClass += ' high-load'; + if (isOffline) { + cardClass += ' offline'; + } else if (isStale) { + cardClass += ' stale'; + } else if (isHighLoad) { + cardClass += ' high-load'; + } const card = document.createElement('div'); card.className = cardClass; card.dataset.serverId = node.id; - const cpu = parseFloat(stats.cpu_usage || 0); - const mem = parseFloat(stats.mem_usage_percent || 0); - const disk = parseFloat(stats.disk_usage_percent || 0); - const uptime = isOffline ? (formatDuration(node.outage_duration || 0)) : (stats.uptime || '...'); + const clampPercent = (value) => Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)); + const cpuRaw = Number(stats.cpu_usage ?? 0); + const memRaw = Number(stats.mem_usage_percent ?? 0); + const diskRaw = Number(stats.disk_usage_percent ?? 0); + const cpuPercent = clampPercent(cpuRaw); + const memPercent = clampPercent(memRaw); + const diskPercent = clampPercent(diskRaw); + const uptime = isOffline ? formatDuration(node.outage_duration || 0) : (stats.uptime || '—'); + const loadDisplay = Number.isFinite(loadAvg) ? loadAvg.toFixed(2) : '--'; + const lastSignal = node.last_seen || node.last_reported; + const lastSeen = lastSignal ? formatRelativeTime(lastSignal) : '无记录'; + const statusIconClass = isOffline ? 'down' : (isStale ? 'stale' : ''); + const statusNoteClass = isOffline ? 'danger' : ((isStale || isHighLoad) ? 'warning' : ''); + let statusLabel = '状态: '; + if (isOffline) { + statusLabel += '离线'; + } else if (isStale) { + statusLabel += '数据陈旧'; + } else if (isHighLoad) { + statusLabel += '负载偏高'; + } else { + statusLabel += '在线'; + } + if (lastSignal) { + statusLabel += ` · ${lastSeen}`; + } card.innerHTML = `
-
-
${getFlagEmoji(node.country_code)} ${node.name}
+
+
+
${getFlagEmoji(node.country_code)} ${node.name}
+
${statusLabel}
+
CPU
-
${cpu.toFixed(0)}%
+
${cpuPercent.toFixed(0)}%
内存
-
${mem.toFixed(0)}%
+
${memPercent.toFixed(0)}%
硬盘
-
${disk.toFixed(0)}%
+
${diskPercent.toFixed(0)}%
网络
↑ ${formatBytes(stats.net_up_speed || 0)}/s | ↓ ${formatBytes(stats.net_down_speed || 0)}/s
流量
↑ ${formatBytes(stats.total_up || 0)} | ↓ ${formatBytes(stats.total_down || 0)}
负载
-
${parseFloat(stats.load_avg || 0).toFixed(2)}
+
${loadDisplay}
${isOffline ? '掉线时长' : '在线'}
${uptime}
+
更新
+
${lastSeen}
`; container.appendChild(card); @@ -199,7 +403,12 @@ const container = document.querySelector('.tag-filters'); const allTags = new Set(); nodes.forEach(node => { - if (node.tags) node.tags.split(',').forEach(tag => tag.trim() && allTags.add(tag.trim())); + const tags = Array.isArray(node.tags_array) + ? node.tags_array + : (node.tags ? node.tags.split(',').map(tag => tag.trim()) : []); + tags.forEach(tag => { + if (tag) allTags.add(tag); + }); }); container.innerHTML = `筛选: `; @@ -227,20 +436,48 @@ if (!node) return; const modalBody = document.getElementById('modal-body'); const isOffline = !node.is_online; - + const isStale = !!node.is_stale && node.is_online; + const tagsText = Array.isArray(node.tags_array) && node.tags_array.length ? node.tags_array.join(', ') : '未设置'; + const memText = node.mem_total ? formatBytes(node.mem_total, 2) : '未设置'; + const diskText = node.disk_total ? formatBytes(node.disk_total, 2) : '未设置'; + const cpuCores = node.cpu_cores ? `${node.cpu_cores} 核` : '未设置'; + const priceText = node.price_usd_yearly ? `≈ $${Number(node.price_usd_yearly).toFixed(2)}/年` : '未标注'; + const locationText = node.country_code ? `${getFlagEmoji(node.country_code)} ${node.country_code}` : '未设置'; + const lastSignal = node.last_seen || node.last_reported; + const lastSignalRelative = lastSignal ? formatRelativeTime(lastSignal) : '无记录'; + const lastSignalAbsolute = lastSignal ? formatDateTime(lastSignal) : '—'; + const statusTone = isOffline ? 'danger' : (isStale ? 'warning' : 'ok'); + const statusLabel = node.status_label || (isOffline ? '离线' : (isStale ? '数据陈旧' : '在线')); + const statusBadge = `${statusLabel}`; + modalBody.innerHTML = ` - + + + +

CPU 使用率 (%)

图表加载中...

内存使用率 (%)

图表加载中...
@@ -252,23 +489,47 @@ modalOverlay.classList.add('active'); try { - const response = await fetch(`./api.php?action=get_history&id=${serverId}`); - const data = await response.json(); - if (data.error) throw new Error(data.error); - - const history = data.history || []; + const historyLimit = 500; + const [detailResponse, historyResponse] = await Promise.all([ + fetch(`./api.php?action=get_node_detail&id=${serverId}`), + fetch(`./api.php?action=get_history&id=${serverId}&limit=${historyLimit}`) + ]); + + if (!detailResponse.ok) { + throw new Error(`加载详情失败: ${detailResponse.status}`); + } + if (!historyResponse.ok) { + throw new Error(`加载历史失败: ${historyResponse.status}`); + } + + const detailData = await detailResponse.json(); + const historyData = await historyResponse.json(); + if (detailData.error) throw new Error(detailData.error); + if (historyData.error) throw new Error(historyData.error); + + renderUptimeMetrics(detailData.node?.uptime); + renderHistorySummary(detailData.node?.history_snapshot); + renderRecentOutages(detailData.node?.recent_outages); + + const history = historyData.history || []; createSvgChart('cpu-chart', history.map(h => ({ x: h.timestamp, y: h.cpu_usage })), 100); createSvgChart('mem-chart', history.map(h => ({ x: h.timestamp, y: h.mem_usage_percent })), 100); createSvgChart('load-chart', history.map(h => ({ x: h.timestamp, y: h.load_avg }))); createSvgChart('net-chart', [ - { data: history.map(h => ({ x: h.timestamp, y: (h.net_up_speed || 0) / 1024 })), color: '#2ecc40' }, + { data: history.map(h => ({ x: h.timestamp, y: (h.net_up_speed || 0) / 1024 })), color: '#2ecc40' }, { data: history.map(h => ({ x: h.timestamp, y: (h.net_down_speed || 0) / 1024 })), color: '#0074d9' } ]); createSvgChart('proc-chart', history.map(h => ({ x: h.timestamp, y: h.processes }))); createSvgChart('conn-chart', history.map(h => ({ x: h.timestamp, y: h.connections }))); } catch (err) { console.error('Failed to load history:', err); - document.querySelector('.chart-grid').innerHTML = `

无法加载数据

`; + const chartGrid = document.querySelector('.chart-grid'); + if (chartGrid) { + chartGrid.innerHTML = `

无法加载数据

`; + } + renderUptimeMetrics(); + renderHistorySummary(); + renderRecentOutages(); } } @@ -330,6 +591,184 @@ }); } + function renderUptimeMetrics(uptimeData = {}) { + const container = document.getElementById('modal-quick-stats'); + if (!container) return; + + const periods = [ + { key: '24h', label: '24小时可用率' }, + { key: '7d', label: '7天可用率' }, + { key: '30d', label: '30天可用率' } + ]; + + container.innerHTML = ''; + + periods.forEach(({ key, label }) => { + const metric = uptimeData && uptimeData[key] ? uptimeData[key] : null; + const card = document.createElement('div'); + card.className = 'metric-card'; + + if (metric && typeof metric.uptime_percent === 'number') { + if (metric.uptime_percent >= 99.9) { + card.classList.add('ok'); + } else if (metric.uptime_percent >= 95) { + card.classList.add('warning'); + } else { + card.classList.add('danger'); + } + } + + const valueText = metric && typeof metric.uptime_percent === 'number' + ? `${formatNumber(metric.uptime_percent, 2)}%` + : '--'; + + const uptimeSeconds = metric ? Math.round(metric.uptime_seconds || 0) : 0; + const downtimeSeconds = metric ? Math.round(metric.downtime_seconds || 0) : 0; + const subtitle = metric + ? `在线 ${formatDuration(uptimeSeconds)} · 故障 ${formatDuration(downtimeSeconds)}` + : '无可用数据'; + + card.innerHTML = ` +
${label}
+
${valueText}
+
${subtitle}
+ `; + + container.appendChild(card); + }); + } + + function renderHistorySummary(snapshot) { + const container = document.getElementById('modal-history-summary'); + if (!container) return; + + container.innerHTML = ''; + + if (!snapshot || !snapshot.samples) { + const empty = document.createElement('div'); + empty.className = 'modal-section-empty'; + empty.textContent = '暂无近期性能数据。'; + container.appendChild(empty); + return; + } + + const header = document.createElement('h3'); + const windowDuration = snapshot.window?.duration; + const hasDuration = windowDuration !== null && windowDuration !== undefined; + header.textContent = hasDuration ? `近 ${formatDuration(windowDuration)} 指标摘要` : '近期指标摘要'; + container.appendChild(header); + + const metricsGrid = document.createElement('div'); + metricsGrid.className = 'history-grid'; + + const configs = [ + { key: 'cpu_usage', label: 'CPU', format: (val) => formatPercent(val), peak: (val) => `峰值 ${formatPercent(val)}` }, + { key: 'mem_usage_percent', label: '内存', format: (val) => formatPercent(val), peak: (val) => `峰值 ${formatPercent(val)}` }, + { key: 'disk_usage_percent', label: '硬盘', format: (val) => formatPercent(val), peak: (val) => `峰值 ${formatPercent(val)}` }, + { key: 'load_avg', label: '负载', format: (val) => formatNumber(val, 2), peak: (val) => `峰值 ${formatNumber(val, 2)}` }, + { key: 'net_up_speed', label: '上行速度', format: (val) => `${formatBytes(val || 0)}/s`, peak: (val) => `峰值 ${formatBytes(val || 0)}/s` }, + { key: 'net_down_speed', label: '下行速度', format: (val) => `${formatBytes(val || 0)}/s`, peak: (val) => `峰值 ${formatBytes(val || 0)}/s` } + ]; + + configs.forEach(config => { + const stats = snapshot.metrics?.[config.key]; + if (!stats) return; + + const item = document.createElement('div'); + item.className = 'history-metric'; + item.innerHTML = ` +
${config.label}
+
${config.format(stats.avg)}
+
${config.peak(stats.max)}
+ `; + metricsGrid.appendChild(item); + }); + + if (metricsGrid.children.length > 0) { + container.appendChild(metricsGrid); + } + + const rangeParts = []; + const startText = snapshot.window?.start ? formatDateTime(snapshot.window.start) : ''; + const endText = snapshot.window?.end ? formatDateTime(snapshot.window.end) : ''; + if (startText) { + rangeParts.push(startText); + } + if (endText) { + rangeParts.push(endText); + } + + const footnote = document.createElement('div'); + footnote.className = 'history-summary-footnote'; + footnote.textContent = `覆盖 ${snapshot.samples} 条记录${rangeParts.length === 2 ? ` · ${rangeParts[0]} → ${rangeParts[1]}` : ''}`; + container.appendChild(footnote); + + if (snapshot.transfer && (snapshot.transfer.up !== null || snapshot.transfer.down !== null)) { + const transferNote = document.createElement('div'); + transferNote.className = 'history-summary-footnote'; + const upText = snapshot.transfer.up !== null ? formatBytes(snapshot.transfer.up) : '--'; + const downText = snapshot.transfer.down !== null ? formatBytes(snapshot.transfer.down) : '--'; + transferNote.textContent = `期间累计流量 ↑ ${upText} / ↓ ${downText}`; + container.appendChild(transferNote); + } + } + + function renderRecentOutages(outages = []) { + const container = document.getElementById('modal-outages'); + if (!container) return; + + container.innerHTML = '

近期告警

'; + + if (!outages || outages.length === 0) { + const empty = document.createElement('div'); + empty.className = 'modal-section-empty'; + empty.textContent = '最近没有掉线记录。'; + container.appendChild(empty); + return; + } + + const list = document.createElement('ul'); + list.className = 'outage-list'; + + outages.forEach(outage => { + const item = document.createElement('li'); + item.className = 'outage-item'; + if (!outage.end_time) { + item.classList.add('active'); + } + + const title = document.createElement('div'); + title.className = 'outage-title'; + title.textContent = outage.title || '未命名告警'; + + const time = document.createElement('div'); + time.className = 'outage-time'; + const timeParts = []; + if (outage.start_time) { + timeParts.push(`发生 ${formatDateTime(outage.start_time)}`); + } + if (outage.end_time) { + timeParts.push(`恢复 ${formatDateTime(outage.end_time)}`); + } + if (outage.duration) { + timeParts.push(`持续 ${formatDuration(outage.duration)}`); + } + time.textContent = timeParts.join(' · ') || '时间未知'; + + const content = document.createElement('div'); + content.className = 'outage-content'; + content.textContent = outage.content || '无描述'; + + item.appendChild(title); + item.appendChild(time); + item.appendChild(content); + + list.appendChild(item); + }); + + container.appendChild(list); + } + function generateOutagesView(outages, nodes) { const container = document.querySelector('#outages-view .timeline'); container.innerHTML = ''; @@ -344,15 +783,318 @@ let content = outage.content; if (outage.end_time) { content += `
已恢复,持续时间约 ${formatDuration(outage.end_time - outage.start_time)}.`; + } else if (outage.duration) { + content += `
已持续 ${formatDuration(outage.duration)}.`; } const itemHTML = `
${startTime}
${outage.title} - ${nodeName}
${content}
`; container.innerHTML += itemHTML; }); } + function renderInsights(insights = {}) { + if (!insightsContainer) return; + + const renderEmpty = (message = '暂无洞察数据。') => { + insightsContainer.innerHTML = `
${message}
`; + if (insightsTimestamp) { + insightsTimestamp.textContent = '暂无数据'; + insightsTimestamp.removeAttribute('title'); + } + if (insightsDrawer) { + delete insightsDrawer.dataset.userToggled; + insightsDrawer.open = false; + } + }; + + if (!insights || Object.keys(insights).length === 0) { + renderEmpty(); + return; + } + + if (insightsTimestamp) { + if (insights.generated_at) { + insightsTimestamp.textContent = formatRelativeTime(insights.generated_at); + insightsTimestamp.title = formatDateTime(insights.generated_at); + } else { + insightsTimestamp.textContent = '—'; + insightsTimestamp.removeAttribute('title'); + } + } + + insightsContainer.innerHTML = ''; + + const issueCounts = insights.issue_counts || {}; + const issues = insights.issues || {}; + if (insightsDrawer && insightsDrawer.dataset.userToggled !== '1') { + const shouldOpen = (issueCounts.offline || 0) > 0 + || (issueCounts.stale || 0) > 0 + || (issueCounts.pressure || 0) > 0; + insightsDrawer.open = shouldOpen; + } + const categories = [ + { key: 'offline', label: '离线', className: 'danger' }, + { key: 'stale', label: '陈旧', className: 'warning' }, + { key: 'pressure', label: '性能预警', className: 'warning' } + ]; + + const describeTriggers = (triggers) => { + if (!Array.isArray(triggers) || triggers.length === 0) return ''; + return triggers.map(trigger => { + const type = trigger?.type || '指标'; + const value = Number(trigger?.value ?? 0); + switch (type) { + case 'cpu': + return `CPU ${formatNumber(value, 1)}%`; + case 'mem': + return `内存 ${formatNumber(value, 1)}%`; + case 'disk': + return `磁盘 ${formatNumber(value, 1)}%`; + case 'load': + return `负载 ${formatNumber(value, 2)}`; + default: + return `${type} ${formatNumber(value, 1)}`; + } + }).join(' / '); + }; + + const buildIssueMeta = (item) => { + const parts = []; + if (item.type === 'offline') { + const duration = Number(item.outage_duration ?? 0); + if (Number.isFinite(duration) && duration > 0) { + parts.push(`离线 ${formatDuration(duration)}`); + } + if (item.last_checked) { + parts.push(`检测 ${formatRelativeTime(item.last_checked)}`); + } else if (item.last_reported) { + parts.push(`上报 ${formatRelativeTime(item.last_reported)}`); + } + } else if (item.type === 'stale') { + if (item.last_reported) { + parts.push(`${formatRelativeTime(item.last_reported)} 更新`); + } + } else if (item.type === 'pressure') { + const triggerText = describeTriggers(item.triggers); + if (triggerText) { + parts.push(triggerText); + } + if (item.last_reported) { + parts.push(`采样 ${formatRelativeTime(item.last_reported)}`); + } + } + + if (item.detail) { + parts.push(item.detail); + } + + return parts.join(' · ') || '暂无额外信息'; + }; + + const focusCard = document.createElement('div'); + focusCard.className = 'insight-card'; + const focusTitle = document.createElement('div'); + focusTitle.className = 'insight-card-title'; + focusTitle.textContent = '重点关注'; + focusCard.appendChild(focusTitle); + + const countsWrap = document.createElement('div'); + countsWrap.className = 'insight-counts'; + categories.forEach(({ key, label, className }) => { + const badge = document.createElement('span'); + badge.className = `insight-count ${className}`; + badge.textContent = `${label} ${issueCounts[key] || 0}`; + countsWrap.appendChild(badge); + }); + focusCard.appendChild(countsWrap); + + const highlightList = document.createElement('div'); + highlightList.className = 'insight-list'; + const highlightItems = []; + categories.forEach(({ key }) => { + const list = Array.isArray(issues[key]) ? issues[key] : []; + list.forEach(item => { + highlightItems.push(Object.assign({ type: key }, item)); + }); + }); + + if (highlightItems.length === 0) { + const empty = document.createElement('div'); + empty.className = 'insight-empty'; + empty.textContent = '所有节点运行良好。'; + highlightList.appendChild(empty); + } else { + highlightItems.slice(0, 6).forEach(item => { + const row = document.createElement('div'); + row.className = 'insight-item'; + if (item.type === 'offline') { + row.classList.add('danger'); + } else if (item.type === 'pressure') { + row.classList.add('warning'); + } else { + row.classList.add('warning'); + } + + const nodeLabel = document.createElement('span'); + nodeLabel.className = 'insight-node'; + const flag = getFlagEmoji(item.country_code); + const name = item.name || item.id || '未知'; + nodeLabel.textContent = flag ? `${flag} ${name}` : name; + + const meta = document.createElement('span'); + meta.className = 'insight-meta'; + meta.textContent = buildIssueMeta(item); + + row.appendChild(nodeLabel); + row.appendChild(meta); + highlightList.appendChild(row); + }); + } + + focusCard.appendChild(highlightList); + insightsContainer.appendChild(focusCard); + + const metricsCard = document.createElement('div'); + metricsCard.className = 'insight-card'; + const metricsTitle = document.createElement('div'); + metricsTitle.className = 'insight-card-title'; + metricsTitle.textContent = '性能排行'; + metricsCard.appendChild(metricsTitle); + + const metrics = insights.top_metrics || {}; + const metricConfigs = [ + { key: 'cpu', label: 'CPU 使用率', format: (value) => `${formatNumber(value, 1)}%` }, + { key: 'mem', label: '内存使用率', format: (value) => `${formatNumber(value, 1)}%` }, + { key: 'disk', label: '磁盘使用率', format: (value) => `${formatNumber(value, 1)}%` }, + { key: 'load', label: '系统负载', format: (value) => formatNumber(value, 2) }, + { key: 'net', label: '网络总速率', format: (value) => `${formatBytes(value || 0)}/s` } + ]; + + const metricSections = document.createElement('div'); + metricSections.className = 'insight-metric-sections'; + let hasMetric = false; + + metricConfigs.forEach(config => { + const entries = Array.isArray(metrics[config.key]) ? metrics[config.key] : []; + if (!entries.length) return; + hasMetric = true; + + const section = document.createElement('div'); + section.className = 'insight-metric'; + + const sectionTitle = document.createElement('div'); + sectionTitle.className = 'insight-metric-title'; + sectionTitle.textContent = config.label; + section.appendChild(sectionTitle); + + const rows = document.createElement('div'); + rows.className = 'insight-metric-rows'; + + entries.slice(0, 3).forEach((entry, index) => { + const row = document.createElement('div'); + row.className = 'insight-metric-row'; + + const rank = document.createElement('span'); + rank.className = 'insight-rank'; + rank.textContent = `${index + 1}.`; + + const nodeLabel = document.createElement('span'); + nodeLabel.className = 'insight-node'; + const node = entry.node || {}; + const flag = getFlagEmoji(node.country_code); + const name = node.name || node.id || '未知'; + nodeLabel.textContent = flag ? `${flag} ${name}` : name; + + const value = document.createElement('span'); + value.className = 'insight-value'; + value.textContent = config.format(entry.value); + + row.appendChild(rank); + row.appendChild(nodeLabel); + row.appendChild(value); + rows.appendChild(row); + }); + + section.appendChild(rows); + metricSections.appendChild(section); + }); + + if (hasMetric) { + metricsCard.appendChild(metricSections); + } else { + const empty = document.createElement('div'); + empty.className = 'insight-empty'; + empty.textContent = '暂无性能数据。'; + metricsCard.appendChild(empty); + } + + insightsContainer.appendChild(metricsCard); + + const metaCard = document.createElement('div'); + metaCard.className = 'insight-card'; + const metaTitle = document.createElement('div'); + metaTitle.className = 'insight-card-title'; + metaTitle.textContent = '成本 & 标签'; + metaCard.appendChild(metaTitle); + + const metaBlocks = document.createElement('div'); + metaBlocks.className = 'insight-meta-blocks'; + + const costBlock = document.createElement('div'); + costBlock.className = 'insight-meta-block'; + costBlock.innerHTML = '
成本估算
'; + const costInfo = insights.cost; + if (costInfo && Number.isFinite(Number(costInfo.total_yearly)) && (costInfo.count || 0) > 0) { + const list = document.createElement('ul'); + list.className = 'insight-meta-list'; + list.innerHTML = ` +
  • 年化总计 $${formatNumber(costInfo.total_yearly, 2)}
  • +
  • 月度约 $${formatNumber(costInfo.monthly, 2)}
  • +
  • 平均每台 $${formatNumber(costInfo.average_yearly, 2)} / 年(${costInfo.count} 台)
  • + `; + costBlock.appendChild(list); + } else { + const empty = document.createElement('div'); + empty.className = 'insight-empty'; + empty.textContent = '暂无成本数据。'; + costBlock.appendChild(empty); + } + metaBlocks.appendChild(costBlock); + + const tagBlock = document.createElement('div'); + tagBlock.className = 'insight-meta-block'; + tagBlock.innerHTML = '
    高频标签
    '; + const tags = insights.tags && Array.isArray(insights.tags.top) ? insights.tags.top : []; + if (tags.length === 0) { + const empty = document.createElement('div'); + empty.className = 'insight-empty'; + empty.textContent = '暂无标签统计。'; + tagBlock.appendChild(empty); + } else { + const tagList = document.createElement('div'); + tagList.className = 'insight-tags'; + tags.slice(0, 10).forEach(tag => { + const badge = document.createElement('span'); + badge.className = 'insight-tag'; + badge.textContent = `${tag.tag} ×${tag.count}`; + tagList.appendChild(badge); + }); + tagBlock.appendChild(tagList); + if (insights.tags && insights.tags.unique) { + const footnote = document.createElement('div'); + footnote.className = 'insight-footnote'; + footnote.textContent = `共 ${insights.tags.unique} 个标签`; + tagBlock.appendChild(footnote); + } + } + metaBlocks.appendChild(tagBlock); + + metaCard.appendChild(metaBlocks); + insightsContainer.appendChild(metaCard); + } + function fireSignal(nodes) { if (!nodes) return; - const availableNodes = nodes.filter(n => n.is_online && n.x && n.y); + const availableNodes = nodes.filter(n => n.is_online && !n.is_stale && Number.isFinite(Number(n.x)) && Number.isFinite(Number(n.y))); if (availableNodes.length < 2) return; let startNode = availableNodes[Math.floor(Math.random() * availableNodes.length)]; let endNode = availableNodes[Math.floor(Math.random() * availableNodes.length)]; @@ -368,10 +1110,10 @@ const scaleX = containerRect.width / viewBox.width; const scaleY = containerRect.height / viewBox.height; - const startX = (startNode.y - viewBox.x) * scaleX; - const startY = (startNode.x - viewBox.y) * scaleY; - const endX = (endNode.y - viewBox.x) * scaleX; - const endY = (endNode.x - viewBox.y) * scaleY; + const startX = (Number(startNode.y) - viewBox.x) * scaleX; + const startY = (Number(startNode.x) - viewBox.y) * scaleY; + const endX = (Number(endNode.y) - viewBox.x) * scaleX; + const endY = (Number(endNode.x) - viewBox.y) * scaleY; const dx = endX - startX; const dy = endY - startY; const angle = Math.atan2(dy, dx) * 180 / Math.PI; diff --git a/includes/bootstrap.php b/includes/bootstrap.php index e51d436..a06bcba 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -10,8 +10,10 @@ // 加载配置文件 require_once __DIR__ . '/../config.php'; -// 加载数据库连接函数 +// 加载公共函数和服务 +require_once __DIR__ . '/helpers.php'; require_once __DIR__ . '/database.php'; +require_once __DIR__ . '/services/MonitoringService.php'; // 未来可以在这里添加其他通用函数库 // require_once __DIR__ . '/helpers.php'; diff --git a/includes/helpers.php b/includes/helpers.php new file mode 100644 index 0000000..23b4d34 --- /dev/null +++ b/includes/helpers.php @@ -0,0 +1,59 @@ +pdo = $pdo; + $this->dbConfig = $dbConfig; + $this->dbType = $dbConfig['type'] ?? 'sqlite'; + } + + public function getDashboardPayload(): array + { + $nodes = $this->getServersWithStatus(); + + return [ + 'site_name' => $this->getSiteName(), + 'summary' => $this->buildSummary($nodes), + 'nodes' => $nodes, + 'outages' => $this->getOutages(), + 'insights' => $this->buildInsights($nodes), + ]; + } + + public function getServerHistory(string $serverId, int $limit = 500): array + { + $limit = $this->sanitizeLimit($limit, 500, 5000); + + $optionalColumns = [ + 'cpu_usage', + 'mem_usage_percent', + 'disk_usage_percent', + 'uptime', + 'load_avg', + 'net_up_speed', + 'net_down_speed', + 'total_up', + 'total_down', + 'processes', + 'connections', + ]; + + $selectColumns = $this->filterServerStatsColumns(['timestamp'], $optionalColumns); + if (!in_array('timestamp', $selectColumns, true)) { + return []; + } + + $columnList = implode(', ', $selectColumns); + $sql = 'SELECT ' . $columnList . ' FROM server_stats WHERE server_id = :server_id ORDER BY timestamp DESC LIMIT :limit'; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':server_id', $serverId); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $rows = $this->hydrateMissingServerStatsColumns($rows, $optionalColumns); + + $floatFields = ['cpu_usage', 'mem_usage_percent', 'disk_usage_percent', 'load_avg']; + $intFields = ['timestamp', 'net_up_speed', 'net_down_speed', 'total_up', 'total_down', 'processes', 'connections']; + + $history = array_map(function ($row) use ($floatFields, $intFields) { + return linker_cast_numeric($row, $floatFields, $intFields); + }, $rows); + + return array_reverse($history); + } + + public function getServerDetails(string $serverId): array + { + $node = $this->findNodeById($serverId); + if ($node === null) { + throw new RuntimeException('指定的服务器不存在'); + } + + $history = $this->getServerHistory($serverId, 288); + $node['history_snapshot'] = $this->buildHistorySnapshot($history); + + $uptime = []; + $periods = [ + '24h' => 86400, + '7d' => 604800, + '30d' => 2592000, + ]; + foreach ($periods as $label => $seconds) { + $uptime[$label] = $this->calculateUptime($serverId, $seconds, $node['last_reported'] ?? null); + } + $node['uptime'] = $uptime; + $node['recent_outages'] = $this->getOutagesForServer($serverId, 5); + + return $node; + } + + public function getSummaryOnly(): array + { + return $this->buildSummary($this->getServersWithStatus()); + } + + public function getInsights(): array + { + return $this->buildInsights($this->getServersWithStatus()); + } + + private function getSiteName(): string + { + if ($this->siteNameCache !== null) { + return $this->siteNameCache; + } + + $default = '灵刻监控'; + try { + $stmt = $this->pdo->prepare("SELECT value FROM settings WHERE `key` = 'site_name'"); + $stmt->execute(); + $value = $stmt->fetchColumn(); + $this->siteNameCache = $value ? (string)$value : $default; + } catch (Throwable $e) { + $this->siteNameCache = $default; + } + + return $this->siteNameCache; + } + + private function getServersWithStatus(): array + { + if ($this->cachedNodes !== null) { + return $this->cachedNodes; + } + + $servers = $this->fetchServers(); + $statusMap = $this->fetchStatusMap(); + $statsMap = $this->fetchLatestStats(); + + $nodes = []; + $now = time(); + + foreach ($servers as $server) { + $node = $server; + $node['latitude'] = $server['latitude'] !== null ? (float)$server['latitude'] : null; + $node['longitude'] = $server['longitude'] !== null ? (float)$server['longitude'] : null; + $node['x'] = $node['latitude']; + $node['y'] = $node['longitude']; + + $status = $statusMap[$server['id']] ?? []; + $rawOnline = $status['is_online'] ?? null; + $rawLastChecked = $status['last_checked'] ?? null; + $node['last_checked'] = $rawLastChecked !== null ? (int)$rawLastChecked : null; + $node['is_online'] = $rawOnline !== null ? linker_boolval($rawOnline) : null; + + $latestStats = $statsMap[$server['id']] ?? []; + if (!empty($latestStats)) { + $latestStats = linker_cast_numeric( + $latestStats, + ['cpu_usage', 'mem_usage_percent', 'disk_usage_percent', 'load_avg'], + ['timestamp', 'net_up_speed', 'net_down_speed', 'total_up', 'total_down', 'processes', 'connections'] + ); + } + + $node['stats'] = $latestStats; + $node['last_reported'] = isset($latestStats['timestamp']) ? (int)$latestStats['timestamp'] : null; + $node['history'] = []; + + $lastSignal = $node['last_reported'] ?? $node['last_checked']; + $node['last_seen'] = $lastSignal; + $heartbeatFresh = $lastSignal !== null && ($now - $lastSignal) <= self::STALE_THRESHOLD_SECONDS * 2; + + if ($node['is_online'] === null) { + $node['is_online'] = $heartbeatFresh; + } elseif ($node['is_online'] === false && $heartbeatFresh) { + $node['is_online'] = true; + } + + $node['is_online'] = (bool)$node['is_online']; + $node['is_stale'] = $this->isStale($node['last_reported']); + $node['status_level'] = 'ok'; + $node['status_label'] = '在线'; + $node['anomaly_msg'] = null; + + if (!$node['is_online']) { + $node['status_level'] = 'critical'; + $node['status_label'] = '离线'; + $node['anomaly_msg'] = '服务器掉线'; + if ($lastSignal !== null) { + $node['outage_duration'] = max(0, $now - $lastSignal); + } + } elseif ($node['is_stale']) { + $node['status_level'] = 'warning'; + $node['status_label'] = '数据陈旧'; + $node['anomaly_msg'] = '数据长时间未更新'; + } elseif (!empty($latestStats) && isset($latestStats['load_avg']) && $latestStats['load_avg'] > 2.0) { + $node['status_level'] = 'warning'; + $node['status_label'] = '负载偏高'; + } + + if ($server['tags']) { + $tags = array_filter(array_map('trim', explode(',', $server['tags']))); + $node['tags_array'] = array_values(array_unique($tags)); + } else { + $node['tags_array'] = []; + } + + foreach (['mem_total', 'disk_total', 'cpu_cores'] as $field) { + if (isset($node[$field]) && $node[$field] !== null && $node[$field] !== '') { + $node[$field] = (int)$node[$field]; + } + } + + if (isset($node['price_usd_yearly']) && $node['price_usd_yearly'] !== null && $node['price_usd_yearly'] !== '') { + $node['price_usd_yearly'] = (float)$node['price_usd_yearly']; + } + + if (array_key_exists('ip', $node)) { + unset($node['ip']); + } + + $nodes[] = $node; + } + + return $this->cachedNodes = $nodes; + } + + private function findNodeById(string $serverId): ?array + { + foreach ($this->getServersWithStatus() as $node) { + if ((string)$node['id'] === (string)$serverId) { + return $node; + } + } + + return null; + } + + private function fetchServers(): array + { + $baseColumns = ['id', 'name', 'intro', 'tags', 'latitude', 'longitude', 'country_code', 'system', 'arch', 'cpu_model', 'mem_total', 'disk_total']; + $optionalColumns = ['price_usd_yearly', 'cpu_cores', 'ip']; + $allColumns = array_merge($baseColumns, $optionalColumns); + + $sql = 'SELECT ' . implode(', ', $allColumns) . ' FROM servers ORDER BY id ASC'; + + try { + $stmt = $this->pdo->query($sql); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $fallbackSql = 'SELECT ' . implode(', ', $baseColumns) . ' FROM servers ORDER BY id ASC'; + try { + $stmt = $this->pdo->query($fallbackSql); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $fallbackError) { + error_log('MonitoringService::fetchServers fallback failed: ' . $fallbackError->getMessage()); + return []; + } + + foreach ($rows as &$row) { + foreach ($optionalColumns as $column) { + if (!array_key_exists($column, $row)) { + $row[$column] = null; + } + } + } + + error_log('MonitoringService::fetchServers using fallback column set: ' . $e->getMessage()); + + return $rows; + } + } + + private function fetchStatusMap(): array + { + try { + $stmt = $this->pdo->query('SELECT id, is_online, last_checked FROM server_status'); + $statusRows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $map = []; + foreach ($statusRows as $row) { + $map[$row['id']] = [ + 'is_online' => $row['is_online'], + 'last_checked' => isset($row['last_checked']) ? (int)$row['last_checked'] : null, + ]; + } + return $map; + } catch (Throwable $e) { + error_log('MonitoringService::fetchStatusMap failed: ' . $e->getMessage()); + return []; + } + } + + private function fetchLatestStats(): array + { + $optionalColumns = [ + 'cpu_usage', + 'mem_usage_percent', + 'disk_usage_percent', + 'uptime', + 'load_avg', + 'net_up_speed', + 'net_down_speed', + 'total_up', + 'total_down', + 'processes', + 'connections', + ]; + + $selectColumns = $this->filterServerStatsColumns(['server_id', 'timestamp'], $optionalColumns); + if (!in_array('server_id', $selectColumns, true) || !in_array('timestamp', $selectColumns, true)) { + return []; + } + + $columnList = implode(', ', array_map(static function (string $column): string { + return 's.' . $column; + }, $selectColumns)); + + try { + if ($this->dbType === 'pgsql') { + $sql = 'SELECT DISTINCT ON (s.server_id) ' . $columnList . ' ' + . 'FROM server_stats s ORDER BY s.server_id, s.timestamp DESC'; + } else { + $sql = 'SELECT ' . $columnList . ' FROM server_stats s ' + . 'JOIN (SELECT server_id, MAX(timestamp) AS max_ts FROM server_stats GROUP BY server_id) m ' + . 'ON s.server_id = m.server_id AND s.timestamp = m.max_ts'; + } + + $stmt = $this->pdo->query($sql); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $rows = $this->hydrateMissingServerStatsColumns($rows, $optionalColumns); + + $map = []; + foreach ($rows as $row) { + $map[$row['server_id']] = $row; + } + + return $map; + } catch (Throwable $e) { + error_log('MonitoringService::fetchLatestStats failed: ' . $e->getMessage()); + return []; + } + } + + private function getOutages(int $limit = 500): array + { + $limit = max(1, min(500, $limit)); + try { + $stmt = $this->pdo->prepare('SELECT id, server_id, title, content, start_time, end_time FROM outages ORDER BY start_time DESC LIMIT :limit'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $outages = []; + foreach ($rows as $row) { + $outage = linker_cast_numeric($row, [], ['start_time', 'end_time']); + $outage['is_active'] = empty($outage['end_time']); + $end = $outage['end_time'] ?? time(); + $outage['duration'] = isset($outage['start_time']) ? max(0, $end - $outage['start_time']) : null; + $outages[] = $outage; + } + + return $outages; + } catch (Throwable $e) { + error_log('MonitoringService::getOutages failed: ' . $e->getMessage()); + return []; + } + } + + private function getOutagesForServer(string $serverId, int $limit = 5): array + { + $limit = max(1, min(50, $limit)); + + try { + $stmt = $this->pdo->prepare('SELECT id, title, content, start_time, end_time FROM outages WHERE server_id = :server_id ORDER BY start_time DESC LIMIT :limit'); + $stmt->bindValue(':server_id', $serverId); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + return []; + } + + return array_map(function (array $row): array { + $outage = linker_cast_numeric($row, [], ['start_time', 'end_time']); + $end = $outage['end_time'] ?? time(); + $outage['duration'] = isset($outage['start_time']) ? max(0, $end - (int)$outage['start_time']) : null; + $outage['is_active'] = empty($outage['end_time']); + return $outage; + }, $rows); + } + + private function buildHistorySnapshot(array $history): array + { + $samples = count($history); + + if ($samples === 0) { + return [ + 'samples' => 0, + 'window' => ['start' => null, 'end' => null, 'duration' => null], + 'metrics' => [], + 'transfer' => ['up' => null, 'down' => null], + ]; + } + + $first = $history[0]; + $last = $history[$samples - 1]; + + $metricKeys = ['cpu_usage', 'mem_usage_percent', 'disk_usage_percent', 'load_avg', 'net_up_speed', 'net_down_speed']; + $metrics = []; + + foreach ($metricKeys as $key) { + $values = array_filter(array_map(static function ($item) use ($key) { + return isset($item[$key]) && is_numeric($item[$key]) ? (float)$item[$key] : null; + }, $history), static fn($v) => $v !== null); + + if (empty($values)) { + continue; + } + + $metrics[$key] = [ + 'avg' => round(array_sum($values) / count($values), 2), + 'max' => max($values), + 'min' => min($values), + ]; + } + + return [ + 'samples' => $samples, + 'window' => [ + 'start' => $first['timestamp'] ?? null, + 'end' => $last['timestamp'] ?? null, + 'duration' => (isset($first['timestamp'], $last['timestamp'])) ? max(0, (int)$last['timestamp'] - (int)$first['timestamp']) : null, + ], + 'metrics' => $metrics, + 'transfer' => [ + 'up' => $this->calculateDelta($first['total_up'] ?? null, $last['total_up'] ?? null), + 'down' => $this->calculateDelta($first['total_down'] ?? null, $last['total_down'] ?? null), + ], + ]; + } + + private function calculateUptime(string $serverId, int $periodSeconds, ?int $lastReported = null): ?array + { + $periodSeconds = max(1, $periodSeconds); + $end = time(); + $start = $end - $periodSeconds; + + try { + $stmt = $this->pdo->prepare('SELECT start_time, end_time FROM outages WHERE server_id = :server_id AND (end_time IS NULL OR end_time >= :start) AND start_time <= :end ORDER BY start_time DESC'); + $stmt->bindValue(':server_id', $serverId); + $stmt->bindValue(':start', $start, PDO::PARAM_INT); + $stmt->bindValue(':end', $end, PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + return null; + } + + $downtime = 0; + foreach ($rows as $row) { + $row = linker_cast_numeric($row, [], ['start_time', 'end_time']); + if (!isset($row['start_time'])) { + continue; + } + $outageStart = (int)$row['start_time']; + $outageEnd = isset($row['end_time']) ? (int)$row['end_time'] : $end; + $overlapStart = max($start, $outageStart); + $overlapEnd = min($end, $outageEnd); + if ($overlapEnd > $overlapStart) { + $downtime += $overlapEnd - $overlapStart; + } + } + + $downtime = min(max(0, $downtime), $periodSeconds); + $uptime = $periodSeconds - $downtime; + + if (($lastReported === null || $lastReported < $start) && empty($rows)) { + return null; + } + + return [ + 'period_seconds' => $periodSeconds, + 'since' => $start, + 'uptime_seconds' => $uptime, + 'downtime_seconds' => $downtime, + 'uptime_percent' => round(($uptime / $periodSeconds) * 100, 2), + ]; + } + + private function calculateDelta($startValue, $endValue): ?float + { + if ($startValue === null || $endValue === null) { + return null; + } + + $start = (float)$startValue; + $end = (float)$endValue; + + if ($end < $start) { + return null; + } + + return $end - $start; + } + + private function buildInsights(array $nodes): array + { + $issues = [ + 'offline' => [], + 'stale' => [], + 'pressure' => [], + ]; + + $topCandidates = [ + 'cpu' => [], + 'mem' => [], + 'load' => [], + 'disk' => [], + 'net' => [], + ]; + + $tagCounts = []; + $costTotal = 0.0; + $costCount = 0; + + foreach ($nodes as $node) { + $insightNode = $this->createInsightNode($node); + $stats = $node['stats'] ?? []; + + if (isset($node['price_usd_yearly']) && is_numeric($node['price_usd_yearly'])) { + $costTotal += (float)$node['price_usd_yearly']; + $costCount++; + } + + $tags = $node['tags_array'] ?? []; + foreach ($tags as $tag) { + if ($tag === null || $tag === '') { + continue; + } + $tagCounts[$tag] = ($tagCounts[$tag] ?? 0) + 1; + } + + if (empty($node['is_online'])) { + $issues['offline'][] = array_merge($insightNode, [ + 'status_level' => 'critical', + 'status_label' => $node['status_label'] ?? '离线', + 'detail' => $node['anomaly_msg'] ?? null, + 'outage_duration' => isset($node['outage_duration']) ? (int)$node['outage_duration'] : null, + 'last_checked' => isset($node['last_checked']) ? (int)$node['last_checked'] : null, + 'last_reported' => isset($node['last_reported']) ? (int)$node['last_reported'] : null, + ]); + continue; + } + + if (!empty($node['is_stale'])) { + $issues['stale'][] = array_merge($insightNode, [ + 'status_level' => 'warning', + 'status_label' => $node['status_label'] ?? '数据陈旧', + 'detail' => $node['anomaly_msg'] ?? null, + 'last_reported' => isset($node['last_reported']) ? (int)$node['last_reported'] : null, + ]); + } + + $pressureTriggers = []; + if (isset($stats['cpu_usage']) && is_numeric($stats['cpu_usage']) && (float)$stats['cpu_usage'] >= 85.0) { + $pressureTriggers[] = ['type' => 'cpu', 'value' => (float)$stats['cpu_usage']]; + } + if (isset($stats['mem_usage_percent']) && is_numeric($stats['mem_usage_percent']) && (float)$stats['mem_usage_percent'] >= 85.0) { + $pressureTriggers[] = ['type' => 'mem', 'value' => (float)$stats['mem_usage_percent']]; + } + if (isset($stats['disk_usage_percent']) && is_numeric($stats['disk_usage_percent']) && (float)$stats['disk_usage_percent'] >= 90.0) { + $pressureTriggers[] = ['type' => 'disk', 'value' => (float)$stats['disk_usage_percent']]; + } + if (isset($stats['load_avg']) && is_numeric($stats['load_avg']) && (float)$stats['load_avg'] >= 3.0) { + $pressureTriggers[] = ['type' => 'load', 'value' => (float)$stats['load_avg']]; + } + + if (!empty($pressureTriggers)) { + $issues['pressure'][] = array_merge($insightNode, [ + 'status_level' => 'warning', + 'status_label' => '性能压力', + 'triggers' => $pressureTriggers, + 'last_reported' => isset($node['last_reported']) ? (int)$node['last_reported'] : null, + ]); + } + + if (isset($stats['cpu_usage']) && is_numeric($stats['cpu_usage'])) { + $topCandidates['cpu'][] = ['value' => (float)$stats['cpu_usage'], 'node' => $insightNode]; + } + if (isset($stats['mem_usage_percent']) && is_numeric($stats['mem_usage_percent'])) { + $topCandidates['mem'][] = ['value' => (float)$stats['mem_usage_percent'], 'node' => $insightNode]; + } + if (isset($stats['disk_usage_percent']) && is_numeric($stats['disk_usage_percent'])) { + $topCandidates['disk'][] = ['value' => (float)$stats['disk_usage_percent'], 'node' => $insightNode]; + } + if (isset($stats['load_avg']) && is_numeric($stats['load_avg'])) { + $topCandidates['load'][] = ['value' => (float)$stats['load_avg'], 'node' => $insightNode]; + } + + $netCombined = 0.0; + if (isset($stats['net_up_speed']) && is_numeric($stats['net_up_speed'])) { + $netCombined += (float)$stats['net_up_speed']; + } + if (isset($stats['net_down_speed']) && is_numeric($stats['net_down_speed'])) { + $netCombined += (float)$stats['net_down_speed']; + } + if ($netCombined > 0) { + $topCandidates['net'][] = ['value' => $netCombined, 'node' => $insightNode]; + } + } + + $issueCounts = [ + 'offline' => count($issues['offline']), + 'stale' => count($issues['stale']), + 'pressure' => count($issues['pressure']), + ]; + + $issues['offline'] = $this->prepareIssueList($issues['offline'], 'offline'); + $issues['stale'] = $this->prepareIssueList($issues['stale'], 'stale'); + $issues['pressure'] = $this->prepareIssueList($issues['pressure'], 'pressure'); + + if (!empty($tagCounts)) { + arsort($tagCounts); + } + + $tagSummary = []; + foreach ($tagCounts as $tag => $count) { + $tagSummary[] = ['tag' => $tag, 'count' => $count]; + } + + return [ + 'generated_at' => time(), + 'issue_counts' => $issueCounts, + 'issues' => $issues, + 'top_metrics' => $this->finalizeTopMetrics($topCandidates), + 'cost' => $costCount > 0 ? [ + 'total_yearly' => round($costTotal, 2), + 'monthly' => round($costTotal / 12, 2), + 'average_yearly' => round($costTotal / $costCount, 2), + 'count' => $costCount, + ] : null, + 'tags' => [ + 'unique' => count($tagCounts), + 'top' => array_slice($tagSummary, 0, 10), + ], + ]; + } + + private function prepareIssueList(array $items, string $type): array + { + if (empty($items)) { + return []; + } + + if ($type === 'offline') { + usort($items, static function (array $a, array $b): int { + return ($b['outage_duration'] ?? 0) <=> ($a['outage_duration'] ?? 0); + }); + } elseif ($type === 'stale') { + usort($items, static function (array $a, array $b): int { + return ($a['last_reported'] ?? PHP_INT_MAX) <=> ($b['last_reported'] ?? PHP_INT_MAX); + }); + } else { + usort($items, static function (array $a, array $b): int { + $aMax = 0.0; + if (!empty($a['triggers'])) { + foreach ($a['triggers'] as $trigger) { + if (isset($trigger['value']) && is_numeric($trigger['value'])) { + $aMax = max($aMax, (float)$trigger['value']); + } + } + } + + $bMax = 0.0; + if (!empty($b['triggers'])) { + foreach ($b['triggers'] as $trigger) { + if (isset($trigger['value']) && is_numeric($trigger['value'])) { + $bMax = max($bMax, (float)$trigger['value']); + } + } + } + + return $bMax <=> $aMax; + }); + } + + return array_values(array_slice($items, 0, 5)); + } + + private function finalizeTopMetrics(array $candidates): array + { + $result = []; + + foreach ($candidates as $metric => $items) { + if (empty($items)) { + $result[$metric] = []; + continue; + } + + usort($items, static fn(array $a, array $b): int => $b['value'] <=> $a['value']); + $items = array_slice($items, 0, 5); + + $result[$metric] = array_map(static function (array $item): array { + return [ + 'value' => round($item['value'], 2), + 'node' => $item['node'], + ]; + }, $items); + } + + return $result; + } + + private function createInsightNode(array $node): array + { + return [ + 'id' => isset($node['id']) ? (string)$node['id'] : '', + 'name' => $node['name'] ?? ('#' . ($node['id'] ?? '?')), + 'country_code' => $node['country_code'] ?? null, + 'tags' => array_values($node['tags_array'] ?? []), + 'status_level' => $node['status_level'] ?? null, + 'status_label' => $node['status_label'] ?? null, + 'is_online' => !empty($node['is_online']), + 'last_reported' => isset($node['last_reported']) ? (int)$node['last_reported'] : null, + ]; + } + + private function buildSummary(array $nodes): array + { + $total = count($nodes); + $online = 0; + $stale = 0; + $cpuSum = $memSum = $loadSum = 0.0; + $cpuCount = $memCount = $loadCount = 0; + $lastSync = null; + + foreach ($nodes as $node) { + if (!empty($node['is_online'])) { + $online++; + if (!empty($node['is_stale'])) { + $stale++; + } + } + + if (!empty($node['stats'])) { + if (isset($node['stats']['cpu_usage'])) { + $cpuSum += (float)$node['stats']['cpu_usage']; + $cpuCount++; + } + if (isset($node['stats']['mem_usage_percent'])) { + $memSum += (float)$node['stats']['mem_usage_percent']; + $memCount++; + } + if (isset($node['stats']['load_avg'])) { + $loadSum += (float)$node['stats']['load_avg']; + $loadCount++; + } + } + + $latestSignal = $node['last_seen'] ?? $node['last_reported'] ?? null; + if ($latestSignal !== null) { + $latestSignal = (int)$latestSignal; + $lastSync = $lastSync ? max($lastSync, $latestSignal) : $latestSignal; + } + } + + $offline = max(0, $total - $online); + $summary = [ + 'total_servers' => $total, + 'online' => $online, + 'offline' => $offline, + 'stale' => $stale, + 'avg_cpu' => $cpuCount > 0 ? round($cpuSum / $cpuCount, 1) : null, + 'avg_mem' => $memCount > 0 ? round($memSum / $memCount, 1) : null, + 'avg_load' => $loadCount > 0 ? round($loadSum / $loadCount, 2) : null, + 'active_alerts' => $this->getActiveAlertCount(), + 'last_sync' => $lastSync, + 'generated_at' => time(), + ]; + + return $summary; + } + + private function getActiveAlertCount(): int + { + try { + $stmt = $this->pdo->query('SELECT COUNT(*) FROM outages WHERE end_time IS NULL'); + return (int)$stmt->fetchColumn(); + } catch (Throwable $e) { + return 0; + } + } + + private function sanitizeLimit(int $limit, int $default = 500, int $max = 5000): int + { + if ($limit <= 0) { + $limit = $default; + } + return min($limit, $max); + } + + private function filterServerStatsColumns(array $required, array $optional): array + { + $available = $this->getServerStatsAvailableColumns(); + + $columns = []; + foreach ($required as $column) { + if (in_array($column, $available, true)) { + $columns[] = $column; + } + } + + foreach ($optional as $column) { + if (in_array($column, $available, true)) { + $columns[] = $column; + } + } + + return array_values(array_unique($columns)); + } + + private function hydrateMissingServerStatsColumns(array $rows, array $optionalColumns): array + { + if (empty($rows)) { + return $rows; + } + + foreach ($rows as &$row) { + foreach ($optionalColumns as $column) { + if (!array_key_exists($column, $row)) { + $row[$column] = null; + } + } + } + unset($row); + + return $rows; + } + + private function getServerStatsAvailableColumns(): array + { + if ($this->serverStatsColumns !== null) { + return $this->serverStatsColumns; + } + + try { + switch ($this->dbType) { + case 'mysql': + case 'mariadb': + $stmt = $this->pdo->query('SHOW COLUMNS FROM server_stats'); + $columns = array_map(static function (array $row): string { + return $row['Field']; + }, $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + break; + case 'pgsql': + $stmt = $this->pdo->prepare( + 'SELECT column_name FROM information_schema.columns ' + . 'WHERE table_name = :table AND table_schema = ANY (current_schemas(false))' + ); + $stmt->execute([':table' => 'server_stats']); + $columns = array_map(static function (array $row): string { + return $row['column_name']; + }, $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + break; + case 'sqlite': + default: + $stmt = $this->pdo->query('PRAGMA table_info(server_stats)'); + $columns = array_map(static function (array $row): string { + return $row['name']; + }, $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + break; + } + } catch (Throwable $e) { + $columns = []; + } + + if (empty($columns)) { + $columns = ['server_id', 'timestamp']; + } + + return $this->serverStatsColumns = $columns; + } + + private function isStale(?int $timestamp): bool + { + if (!$timestamp) { + return true; + } + return (time() - $timestamp) > self::STALE_THRESHOLD_SECONDS; + } +} diff --git a/index.html b/index.html index c5f3350..ec81837 100644 --- a/index.html +++ b/index.html @@ -25,6 +25,42 @@

    灵刻监控

    +
    +
    + 服务器总数 + 0 +
    +
    + 在线 + 0 +
    +
    + 离线 + 0 +
    +
    + 数据陈旧 + 0 +
    +
    + 平均资源占用 + --% + 内存 --% +
    +
    + 平均负载 + -- +
    +
    + 活跃告警 + 0 +
    +
    + 最近同步 + -- +
    +
    +
    @@ -980,7 +1016,28 @@

    灵刻监控

    +
    +
    + +
    +
    + + + + +
    +
    数据更新:--
    +
    +
    + + 智能洞察 + -- + +
    +
    正在加载洞察...
    +
    +