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 = `
${node.intro || '暂无简介'}
+${node.intro || 'N/A'}
${node.system || 'N/A'}
${node.arch || 'N/A'}
${node.cpu_model || 'N/A'}
${formatBytes(node.mem_total, 2) || 'N/A'}
${formatBytes(node.disk_total, 2) || 'N/A'}
${formatDuration(node.outage_duration || 0)}
${locationText}
${node.system || '未设置'}
${node.arch || '未设置'}
${node.cpu_model || '未设置'}
${cpuCores}
${memText}
${diskText}
${priceText}
${tagsText}
${formatDuration(node.outage_duration || 0)}
无法加载数据
`; + 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 = ` +