First Commit
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# MySQL Configuration
|
||||||
|
MYSQL_HOST=localhost
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=your_password
|
||||||
|
MYSQL_DATABASE=display_wall
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Data refresh interval (ms)
|
||||||
|
REFRESH_INTERVAL=5000
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 数据可视化展示大屏
|
||||||
|
|
||||||
|
多源 Prometheus 服务器监控展示大屏,支持对接多个 Prometheus 实例,实时展示所有服务器的 CPU、内存、磁盘、网络等关键指标。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 🔌 **多数据源管理** - MySQL 存储配置,支持对接多个 Prometheus 实例
|
||||||
|
- 📊 **NodeExporter 数据查询** - 自动聚合所有 Prometheus 中的 NodeExporter 数据
|
||||||
|
- 🌐 **网络流量统计** - 24 小时网络流量趋势图,总流量统计
|
||||||
|
- ⚡ **实时带宽监控** - 所有服务器网络带宽求和,实时显示
|
||||||
|
- 💻 **资源使用概览** - CPU、内存、磁盘的总使用率和详细统计
|
||||||
|
- 🖥️ **服务器列表** - 所有服务器的详细指标一览表
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 环境要求
|
||||||
|
|
||||||
|
- Node.js >= 16
|
||||||
|
- MySQL >= 5.7
|
||||||
|
|
||||||
|
### 2. 配置
|
||||||
|
|
||||||
|
复制环境变量文件并修改:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env` 文件,配置 MySQL 连接信息:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MYSQL_HOST=localhost
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=your_password
|
||||||
|
MYSQL_DATABASE=display_wall
|
||||||
|
PORT=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run init-db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 安装依赖并启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:3000` 即可看到展示大屏。
|
||||||
|
|
||||||
|
### 5. 配置 Prometheus 数据源
|
||||||
|
|
||||||
|
点击右上角的 ⚙️ 按钮,添加你的 Prometheus 地址(如 `http://prometheus.example.com:9090`)。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**: Node.js + Express
|
||||||
|
- **数据库**: MySQL (mysql2)
|
||||||
|
- **数据源**: Prometheus HTTP API
|
||||||
|
- **前端**: 原生 HTML/CSS/JavaScript
|
||||||
|
- **图表**: 自定义 Canvas 渲染
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/sources` | 获取所有数据源 |
|
||||||
|
| POST | `/api/sources` | 添加数据源 |
|
||||||
|
| PUT | `/api/sources/:id` | 更新数据源 |
|
||||||
|
| DELETE | `/api/sources/:id` | 删除数据源 |
|
||||||
|
| POST | `/api/sources/test` | 测试数据源连接 |
|
||||||
|
| GET | `/api/metrics/overview` | 获取聚合指标概览 |
|
||||||
|
| GET | `/api/metrics/network-history` | 获取24h网络流量历史 |
|
||||||
|
| GET | `/api/metrics/cpu-history` | 获取CPU使用率历史 |
|
||||||
1118
package-lock.json
generated
Normal file
1118
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "data-visualization-display-wall",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Data Visualization Display Wall - Multi-Prometheus Monitoring Dashboard",
|
||||||
|
"main": "server/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node server/index.js",
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"init-db": "node server/init-db.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"mysql2": "^3.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1050
public/css/style.css
Normal file
1050
public/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
310
public/index.html
Normal file
310
public/index.html
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="多源Prometheus服务器监控展示大屏 - 实时CPU、内存、磁盘、网络统计">
|
||||||
|
<title>数据可视化展示大屏</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Animated Background -->
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="bg-glow bg-glow-1"></div>
|
||||||
|
<div class="bg-glow bg-glow-2"></div>
|
||||||
|
<div class="bg-glow bg-glow-3"></div>
|
||||||
|
|
||||||
|
<!-- App Container -->
|
||||||
|
<div id="app">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header" id="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="logo">
|
||||||
|
<svg class="logo-icon" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="8" stroke="url(#logoGrad)" stroke-width="2.5"/>
|
||||||
|
<path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="url(#logoGrad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<circle cx="12" cy="14" r="2" fill="url(#logoGrad)"/>
|
||||||
|
<circle cx="20" cy="10" r="2" fill="url(#logoGrad)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="logoGrad" x1="0" y1="0" x2="32" y2="32">
|
||||||
|
<stop offset="0%" stop-color="#6366f1"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<h1 class="logo-text">数据可视化展示大屏</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="server-count" id="serverCount">
|
||||||
|
<span class="dot dot-pulse"></span>
|
||||||
|
<span id="serverCountText">0 台服务器</span>
|
||||||
|
</span>
|
||||||
|
<span class="source-count" id="sourceCount">0 个数据源</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="clock" id="clock"></div>
|
||||||
|
<button class="btn-settings" id="btnSettings" title="配置管理">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Dashboard -->
|
||||||
|
<main class="dashboard" id="dashboard">
|
||||||
|
<!-- Top Stat Cards -->
|
||||||
|
<section class="stat-cards">
|
||||||
|
<div class="stat-card stat-card-servers" id="cardServers">
|
||||||
|
<div class="stat-card-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||||
|
<circle cx="6" cy="6" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="6" cy="18" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<span class="stat-card-label">服务器总数</span>
|
||||||
|
<span class="stat-card-value" id="totalServers">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-cpu" id="cardCpu">
|
||||||
|
<div class="stat-card-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||||
|
<rect x="9" y="9" width="6" height="6"/>
|
||||||
|
<line x1="9" y1="2" x2="9" y2="4"/><line x1="15" y1="2" x2="15" y2="4"/>
|
||||||
|
<line x1="9" y1="20" x2="9" y2="22"/><line x1="15" y1="20" x2="15" y2="22"/>
|
||||||
|
<line x1="2" y1="9" x2="4" y2="9"/><line x1="2" y1="15" x2="4" y2="15"/>
|
||||||
|
<line x1="20" y1="9" x2="22" y2="9"/><line x1="20" y1="15" x2="22" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<span class="stat-card-label">CPU 使用率</span>
|
||||||
|
<span class="stat-card-value" id="cpuPercent">0%</span>
|
||||||
|
<span class="stat-card-sub" id="cpuDetail">0 / 0 核心</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-mem" id="cardMem">
|
||||||
|
<div class="stat-card-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<path d="M7 7h4v4H7zM13 7h4v4h-4zM7 13h4v4H7zM13 13h4v4h-4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<span class="stat-card-label">内存使用率</span>
|
||||||
|
<span class="stat-card-value" id="memPercent">0%</span>
|
||||||
|
<span class="stat-card-sub" id="memDetail">0 / 0 GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-disk" id="cardDisk">
|
||||||
|
<div class="stat-card-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<span class="stat-card-label">磁盘使用率</span>
|
||||||
|
<span class="stat-card-value" id="diskPercent">0%</span>
|
||||||
|
<span class="stat-card-sub" id="diskDetail">0 / 0 GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-bandwidth" id="cardBandwidth">
|
||||||
|
<div class="stat-card-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<span class="stat-card-label">实时总带宽</span>
|
||||||
|
<span class="stat-card-value" id="totalBandwidth">0 B/s</span>
|
||||||
|
<span class="stat-card-sub" id="bandwidthDetail">↓ 0 ↑ 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Center Charts -->
|
||||||
|
<section class="charts-section">
|
||||||
|
<!-- Network Traffic 24h Chart -->
|
||||||
|
<div class="chart-card chart-card-wide" id="networkChart">
|
||||||
|
<div class="chart-card-header">
|
||||||
|
<h2 class="chart-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="chart-title-icon">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||||
|
</svg>
|
||||||
|
网络流量趋势 (24h)
|
||||||
|
</h2>
|
||||||
|
<div class="chart-legend">
|
||||||
|
<span class="legend-item"><span class="legend-dot legend-rx"></span>接收 (RX)</span>
|
||||||
|
<span class="legend-item"><span class="legend-dot legend-tx"></span>发送 (TX)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-body">
|
||||||
|
<canvas id="networkCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-footer">
|
||||||
|
<div class="traffic-stat">
|
||||||
|
<span class="traffic-label">24h 接收总量</span>
|
||||||
|
<span class="traffic-value" id="traffic24hRx">0 B</span>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-stat">
|
||||||
|
<span class="traffic-label">24h 发送总量</span>
|
||||||
|
<span class="traffic-value" id="traffic24hTx">0 B</span>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-stat traffic-stat-total">
|
||||||
|
<span class="traffic-label">24h 总流量</span>
|
||||||
|
<span class="traffic-value" id="traffic24hTotal">0 B</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resource Gauges -->
|
||||||
|
<div class="chart-card chart-card-gauges" id="gaugesCard">
|
||||||
|
<div class="chart-card-header">
|
||||||
|
<h2 class="chart-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="chart-title-icon">
|
||||||
|
<path d="M12 20V10M18 20V4M6 20v-4"/>
|
||||||
|
</svg>
|
||||||
|
资源使用概览
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="gauges-container">
|
||||||
|
<div class="gauge-wrapper">
|
||||||
|
<div class="gauge" id="gaugeCpu">
|
||||||
|
<svg viewBox="0 0 120 120">
|
||||||
|
<circle class="gauge-bg" cx="60" cy="60" r="52"/>
|
||||||
|
<circle class="gauge-fill gauge-fill-cpu" cx="60" cy="60" r="52" id="gaugeCpuFill"/>
|
||||||
|
</svg>
|
||||||
|
<div class="gauge-center">
|
||||||
|
<span class="gauge-value" id="gaugeCpuValue">0%</span>
|
||||||
|
<span class="gauge-label">CPU</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-wrapper">
|
||||||
|
<div class="gauge" id="gaugeRam">
|
||||||
|
<svg viewBox="0 0 120 120">
|
||||||
|
<circle class="gauge-bg" cx="60" cy="60" r="52"/>
|
||||||
|
<circle class="gauge-fill gauge-fill-ram" cx="60" cy="60" r="52" id="gaugeRamFill"/>
|
||||||
|
</svg>
|
||||||
|
<div class="gauge-center">
|
||||||
|
<span class="gauge-value" id="gaugeRamValue">0%</span>
|
||||||
|
<span class="gauge-label">RAM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-wrapper">
|
||||||
|
<div class="gauge" id="gaugeDisk">
|
||||||
|
<svg viewBox="0 0 120 120">
|
||||||
|
<circle class="gauge-bg" cx="60" cy="60" r="52"/>
|
||||||
|
<circle class="gauge-fill gauge-fill-disk" cx="60" cy="60" r="52" id="gaugeDiskFill"/>
|
||||||
|
</svg>
|
||||||
|
<div class="gauge-center">
|
||||||
|
<span class="gauge-value" id="gaugeDiskValue">0%</span>
|
||||||
|
<span class="gauge-label">DISK</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Server List -->
|
||||||
|
<section class="server-list-section" id="serverListSection">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card-header">
|
||||||
|
<h2 class="chart-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="chart-title-icon">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||||
|
<circle cx="6" cy="6" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="6" cy="18" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
服务器详情
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="server-table-wrap">
|
||||||
|
<table class="server-table" id="serverTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>服务器</th>
|
||||||
|
<th>数据源</th>
|
||||||
|
<th>CPU</th>
|
||||||
|
<th>内存</th>
|
||||||
|
<th>磁盘</th>
|
||||||
|
<th>网络 ↓</th>
|
||||||
|
<th>网络 ↑</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="serverTableBody">
|
||||||
|
<tr class="empty-row">
|
||||||
|
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div class="modal-overlay" id="settingsModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Prometheus 数据源管理</h2>
|
||||||
|
<button class="modal-close" id="modalClose">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Add Source Form -->
|
||||||
|
<div class="add-source-form" id="addSourceForm">
|
||||||
|
<h3>添加数据源</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceName">名称</label>
|
||||||
|
<input type="text" id="sourceName" placeholder="例:生产环境" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-wide">
|
||||||
|
<label for="sourceUrl">Prometheus URL</label>
|
||||||
|
<input type="url" id="sourceUrl" placeholder="http://prometheus.example.com:9090" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group form-group-wide">
|
||||||
|
<label for="sourceDesc">描述 (可选)</label>
|
||||||
|
<input type="text" id="sourceDesc" placeholder="数据源描述" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-test" id="btnTest">测试连接</button>
|
||||||
|
<button class="btn btn-add" id="btnAdd">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-message" id="formMessage"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source List -->
|
||||||
|
<div class="source-list" id="sourceList">
|
||||||
|
<h3>已配置数据源</h3>
|
||||||
|
<div class="source-items" id="sourceItems">
|
||||||
|
<div class="source-empty">暂无数据源</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/utils.js"></script>
|
||||||
|
<script src="/js/chart.js"></script>
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
433
public/js/app.js
Normal file
433
public/js/app.js
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
/**
|
||||||
|
* Main Application - Data Visualization Display Wall
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---- Config ----
|
||||||
|
const REFRESH_INTERVAL = 5000; // 5 seconds
|
||||||
|
const NETWORK_HISTORY_INTERVAL = 60000; // 1 minute
|
||||||
|
|
||||||
|
// ---- DOM Elements ----
|
||||||
|
const dom = {
|
||||||
|
clock: document.getElementById('clock'),
|
||||||
|
serverCountText: document.getElementById('serverCountText'),
|
||||||
|
sourceCount: document.getElementById('sourceCount'),
|
||||||
|
totalServers: document.getElementById('totalServers'),
|
||||||
|
cpuPercent: document.getElementById('cpuPercent'),
|
||||||
|
cpuDetail: document.getElementById('cpuDetail'),
|
||||||
|
memPercent: document.getElementById('memPercent'),
|
||||||
|
memDetail: document.getElementById('memDetail'),
|
||||||
|
diskPercent: document.getElementById('diskPercent'),
|
||||||
|
diskDetail: document.getElementById('diskDetail'),
|
||||||
|
totalBandwidth: document.getElementById('totalBandwidth'),
|
||||||
|
bandwidthDetail: document.getElementById('bandwidthDetail'),
|
||||||
|
traffic24hRx: document.getElementById('traffic24hRx'),
|
||||||
|
traffic24hTx: document.getElementById('traffic24hTx'),
|
||||||
|
traffic24hTotal: document.getElementById('traffic24hTotal'),
|
||||||
|
networkCanvas: document.getElementById('networkCanvas'),
|
||||||
|
gaugeCpuFill: document.getElementById('gaugeCpuFill'),
|
||||||
|
gaugeRamFill: document.getElementById('gaugeRamFill'),
|
||||||
|
gaugeDiskFill: document.getElementById('gaugeDiskFill'),
|
||||||
|
gaugeCpuValue: document.getElementById('gaugeCpuValue'),
|
||||||
|
gaugeRamValue: document.getElementById('gaugeRamValue'),
|
||||||
|
gaugeDiskValue: document.getElementById('gaugeDiskValue'),
|
||||||
|
serverTableBody: document.getElementById('serverTableBody'),
|
||||||
|
btnSettings: document.getElementById('btnSettings'),
|
||||||
|
settingsModal: document.getElementById('settingsModal'),
|
||||||
|
modalClose: document.getElementById('modalClose'),
|
||||||
|
sourceName: document.getElementById('sourceName'),
|
||||||
|
sourceUrl: document.getElementById('sourceUrl'),
|
||||||
|
sourceDesc: document.getElementById('sourceDesc'),
|
||||||
|
btnTest: document.getElementById('btnTest'),
|
||||||
|
btnAdd: document.getElementById('btnAdd'),
|
||||||
|
formMessage: document.getElementById('formMessage'),
|
||||||
|
sourceItems: document.getElementById('sourceItems')
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- State ----
|
||||||
|
let previousMetrics = null;
|
||||||
|
let networkChart = null;
|
||||||
|
|
||||||
|
// ---- Initialize ----
|
||||||
|
function init() {
|
||||||
|
// Add SVG gradient definitions for gauges
|
||||||
|
addGaugeSvgDefs();
|
||||||
|
|
||||||
|
// Clock
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
|
||||||
|
// Network chart
|
||||||
|
networkChart = new AreaChart(dom.networkCanvas);
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
dom.btnSettings.addEventListener('click', openSettings);
|
||||||
|
dom.modalClose.addEventListener('click', closeSettings);
|
||||||
|
dom.settingsModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === dom.settingsModal) closeSettings();
|
||||||
|
});
|
||||||
|
dom.btnTest.addEventListener('click', testConnection);
|
||||||
|
dom.btnAdd.addEventListener('click', addSource);
|
||||||
|
|
||||||
|
// Keyboard shortcut
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start data fetching
|
||||||
|
fetchMetrics();
|
||||||
|
fetchNetworkHistory();
|
||||||
|
setInterval(fetchMetrics, REFRESH_INTERVAL);
|
||||||
|
setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Add SVG Gradient Defs ----
|
||||||
|
function addGaugeSvgDefs() {
|
||||||
|
const svgs = document.querySelectorAll('.gauge svg');
|
||||||
|
const gradients = [
|
||||||
|
{ id: 'gaugeCpuGrad', colors: ['#6366f1', '#818cf8'] },
|
||||||
|
{ id: 'gaugeRamGrad', colors: ['#06b6d4', '#22d3ee'] },
|
||||||
|
{ id: 'gaugeDiskGrad', colors: ['#f59e0b', '#fbbf24'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
svgs.forEach((svg, i) => {
|
||||||
|
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||||
|
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
|
||||||
|
grad.setAttribute('id', gradients[i].id);
|
||||||
|
grad.setAttribute('x1', '0%');
|
||||||
|
grad.setAttribute('y1', '0%');
|
||||||
|
grad.setAttribute('x2', '100%');
|
||||||
|
grad.setAttribute('y2', '100%');
|
||||||
|
|
||||||
|
gradients[i].colors.forEach((color, ci) => {
|
||||||
|
const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
||||||
|
stop.setAttribute('offset', ci === 0 ? '0%' : '100%');
|
||||||
|
stop.setAttribute('stop-color', color);
|
||||||
|
grad.appendChild(stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
defs.appendChild(grad);
|
||||||
|
svg.insertBefore(defs, svg.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Clock ----
|
||||||
|
function updateClock() {
|
||||||
|
dom.clock.textContent = formatClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fetch Metrics ----
|
||||||
|
async function fetchMetrics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/metrics/overview');
|
||||||
|
const data = await response.json();
|
||||||
|
updateDashboard(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching metrics:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Update Dashboard ----
|
||||||
|
function updateDashboard(data) {
|
||||||
|
// Server count
|
||||||
|
dom.totalServers.textContent = data.totalServers;
|
||||||
|
dom.serverCountText.textContent = `${data.totalServers} 台服务器`;
|
||||||
|
|
||||||
|
// CPU
|
||||||
|
const cpuPct = data.cpu.percent;
|
||||||
|
dom.cpuPercent.textContent = formatPercent(cpuPct);
|
||||||
|
dom.cpuDetail.textContent = `${data.cpu.used.toFixed(1)} / ${data.cpu.total.toFixed(0)} 核心`;
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
const memPct = data.memory.percent;
|
||||||
|
dom.memPercent.textContent = formatPercent(memPct);
|
||||||
|
dom.memDetail.textContent = `${formatBytes(data.memory.used)} / ${formatBytes(data.memory.total)}`;
|
||||||
|
|
||||||
|
// Disk
|
||||||
|
const diskPct = data.disk.percent;
|
||||||
|
dom.diskPercent.textContent = formatPercent(diskPct);
|
||||||
|
dom.diskDetail.textContent = `${formatBytes(data.disk.used)} / ${formatBytes(data.disk.total)}`;
|
||||||
|
|
||||||
|
// Bandwidth
|
||||||
|
dom.totalBandwidth.textContent = formatBandwidth(data.network.totalBandwidth);
|
||||||
|
dom.bandwidthDetail.textContent = `↓ ${formatBandwidth(data.network.rx)} ↑ ${formatBandwidth(data.network.tx)}`;
|
||||||
|
|
||||||
|
// 24h traffic
|
||||||
|
dom.traffic24hRx.textContent = formatBytes(data.traffic24h.rx);
|
||||||
|
dom.traffic24hTx.textContent = formatBytes(data.traffic24h.tx);
|
||||||
|
dom.traffic24hTotal.textContent = formatBytes(data.traffic24h.total);
|
||||||
|
|
||||||
|
// Update gauges
|
||||||
|
updateGauge(dom.gaugeCpuFill, dom.gaugeCpuValue, cpuPct);
|
||||||
|
updateGauge(dom.gaugeRamFill, dom.gaugeRamValue, memPct);
|
||||||
|
updateGauge(dom.gaugeDiskFill, dom.gaugeDiskValue, diskPct);
|
||||||
|
|
||||||
|
// Update server table
|
||||||
|
updateServerTable(data.servers);
|
||||||
|
|
||||||
|
// Flash animation
|
||||||
|
if (previousMetrics) {
|
||||||
|
[dom.cpuPercent, dom.memPercent, dom.diskPercent, dom.totalBandwidth].forEach(el => {
|
||||||
|
el.classList.remove('value-update');
|
||||||
|
void el.offsetWidth; // Force reflow
|
||||||
|
el.classList.add('value-update');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMetrics = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Gauge Update ----
|
||||||
|
const CIRCUMFERENCE = 2 * Math.PI * 52; // r=52
|
||||||
|
|
||||||
|
function updateGauge(fillEl, valueEl, percent) {
|
||||||
|
const clamped = Math.min(100, Math.max(0, percent));
|
||||||
|
const offset = CIRCUMFERENCE - (clamped / 100) * CIRCUMFERENCE;
|
||||||
|
fillEl.style.strokeDashoffset = offset;
|
||||||
|
valueEl.textContent = formatPercent(clamped);
|
||||||
|
|
||||||
|
// Change color based on usage
|
||||||
|
const color = getUsageColor(clamped);
|
||||||
|
// We keep gradient but could override for critical
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Server Table ----
|
||||||
|
function updateServerTable(servers) {
|
||||||
|
if (!servers || servers.length === 0) {
|
||||||
|
dom.serverTableBody.innerHTML = `
|
||||||
|
<tr class="empty-row">
|
||||||
|
<td colspan="8">暂无数据 - 请先配置 Prometheus 数据源</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort servers: online first, then by cpu usage
|
||||||
|
servers.sort((a, b) => {
|
||||||
|
if (a.up !== b.up) return b.up ? 1 : -1;
|
||||||
|
return b.cpuPercent - a.cpuPercent;
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.serverTableBody.innerHTML = servers.map(server => {
|
||||||
|
const memPct = server.memTotal > 0 ? (server.memUsed / server.memTotal * 100) : 0;
|
||||||
|
const diskPct = server.diskTotal > 0 ? (server.diskUsed / server.diskTotal * 100) : 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="status-dot ${server.up ? 'status-dot-online' : 'status-dot-offline'}"></span>
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-primary); font-weight: 500;">${escapeHtml(server.instance)}</td>
|
||||||
|
<td>${escapeHtml(server.source)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="usage-bar">
|
||||||
|
<div class="usage-bar-track">
|
||||||
|
<div class="usage-bar-fill usage-bar-fill-cpu" style="width: ${Math.min(server.cpuPercent, 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
<span>${formatPercent(server.cpuPercent)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="usage-bar">
|
||||||
|
<div class="usage-bar-track">
|
||||||
|
<div class="usage-bar-fill usage-bar-fill-mem" style="width: ${Math.min(memPct, 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
<span>${formatPercent(memPct)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="usage-bar">
|
||||||
|
<div class="usage-bar-track">
|
||||||
|
<div class="usage-bar-fill usage-bar-fill-disk" style="width: ${Math.min(diskPct, 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
<span>${formatPercent(diskPct)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatBandwidth(server.netRx)}</td>
|
||||||
|
<td>${formatBandwidth(server.netTx)}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Network History ----
|
||||||
|
async function fetchNetworkHistory() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/metrics/network-history');
|
||||||
|
const data = await response.json();
|
||||||
|
networkChart.setData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching network history:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings Modal ----
|
||||||
|
function openSettings() {
|
||||||
|
dom.settingsModal.classList.add('active');
|
||||||
|
loadSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
dom.settingsModal.classList.remove('active');
|
||||||
|
hideMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sources');
|
||||||
|
const sources = await response.json();
|
||||||
|
dom.sourceCount.textContent = `${sources.length} 个数据源`;
|
||||||
|
renderSources(sources);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading sources:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSources(sources) {
|
||||||
|
if (sources.length === 0) {
|
||||||
|
dom.sourceItems.innerHTML = '<div class="source-empty">暂无数据源</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.sourceItems.innerHTML = sources.map(source => `
|
||||||
|
<div class="source-item" data-id="${source.id}">
|
||||||
|
<div class="source-item-info">
|
||||||
|
<div class="source-item-name">
|
||||||
|
${escapeHtml(source.name)}
|
||||||
|
<span class="source-status ${source.status === 'online' ? 'source-status-online' : 'source-status-offline'}">
|
||||||
|
${source.status === 'online' ? '在线' : '离线'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="source-item-url">${escapeHtml(source.url)}</div>
|
||||||
|
${source.description ? `<div class="source-item-desc">${escapeHtml(source.description)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="source-item-actions">
|
||||||
|
<button class="btn btn-delete" onclick="deleteSource(${source.id})">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Test Connection ----
|
||||||
|
async function testConnection() {
|
||||||
|
const url = dom.sourceUrl.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
showMessage('请输入 Prometheus URL', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.btnTest.textContent = '测试中...';
|
||||||
|
dom.btnTest.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sources/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
showMessage(`连接成功!Prometheus 版本: ${data.version}`, 'success');
|
||||||
|
} else {
|
||||||
|
showMessage(`连接失败: ${data.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showMessage(`连接失败: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
dom.btnTest.textContent = '测试连接';
|
||||||
|
dom.btnTest.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Add Source ----
|
||||||
|
async function addSource() {
|
||||||
|
const name = dom.sourceName.value.trim();
|
||||||
|
const url = dom.sourceUrl.value.trim();
|
||||||
|
const description = dom.sourceDesc.value.trim();
|
||||||
|
|
||||||
|
if (!name || !url) {
|
||||||
|
showMessage('请填写名称和URL', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.btnAdd.textContent = '添加中...';
|
||||||
|
dom.btnAdd.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sources', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, url, description })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showMessage('数据源添加成功', 'success');
|
||||||
|
dom.sourceName.value = '';
|
||||||
|
dom.sourceUrl.value = '';
|
||||||
|
dom.sourceDesc.value = '';
|
||||||
|
loadSources();
|
||||||
|
// Refresh metrics immediately
|
||||||
|
fetchMetrics();
|
||||||
|
fetchNetworkHistory();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
showMessage(`添加失败: ${err.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showMessage(`添加失败: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
dom.btnAdd.textContent = '添加';
|
||||||
|
dom.btnAdd.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Delete Source ----
|
||||||
|
window.deleteSource = async function (id) {
|
||||||
|
if (!confirm('确定要删除这个数据源吗?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sources/${id}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) {
|
||||||
|
loadSources();
|
||||||
|
fetchMetrics();
|
||||||
|
fetchNetworkHistory();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting source:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Messages ----
|
||||||
|
function showMessage(text, type) {
|
||||||
|
dom.formMessage.textContent = text;
|
||||||
|
dom.formMessage.className = `form-message ${type}`;
|
||||||
|
setTimeout(hideMessage, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMessage() {
|
||||||
|
dom.formMessage.className = 'form-message';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Escape HTML ----
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Load source count on page load ----
|
||||||
|
async function loadSourceCount() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sources');
|
||||||
|
const sources = await response.json();
|
||||||
|
dom.sourceCount.textContent = `${sources.length} 个数据源`;
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Start ----
|
||||||
|
loadSourceCount();
|
||||||
|
init();
|
||||||
|
})();
|
||||||
176
public/js/chart.js
Normal file
176
public/js/chart.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Custom Canvas Chart Renderer
|
||||||
|
* Lightweight, no-dependency chart for the network traffic area chart
|
||||||
|
*/
|
||||||
|
class AreaChart {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.data = { timestamps: [], rx: [], tx: [] };
|
||||||
|
this.animProgress = 0;
|
||||||
|
this.animFrame = null;
|
||||||
|
this.dpr = window.devicePixelRatio || 1;
|
||||||
|
this.padding = { top: 20, right: 16, bottom: 32, left: 56 };
|
||||||
|
|
||||||
|
this._resize = this.resize.bind(this);
|
||||||
|
window.addEventListener('resize', this._resize);
|
||||||
|
this.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const rect = this.canvas.parentElement.getBoundingClientRect();
|
||||||
|
this.width = rect.width;
|
||||||
|
this.height = rect.height;
|
||||||
|
this.canvas.width = this.width * this.dpr;
|
||||||
|
this.canvas.height = this.height * this.dpr;
|
||||||
|
this.canvas.style.width = this.width + 'px';
|
||||||
|
this.canvas.style.height = this.height + 'px';
|
||||||
|
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(data) {
|
||||||
|
this.data = data;
|
||||||
|
this.animProgress = 0;
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
animate() {
|
||||||
|
if (this.animFrame) cancelAnimationFrame(this.animFrame);
|
||||||
|
const start = performance.now();
|
||||||
|
const duration = 800;
|
||||||
|
|
||||||
|
const step = (now) => {
|
||||||
|
const elapsed = now - start;
|
||||||
|
this.animProgress = Math.min(elapsed / duration, 1);
|
||||||
|
// Ease out cubic
|
||||||
|
this.animProgress = 1 - Math.pow(1 - this.animProgress, 3);
|
||||||
|
this.draw();
|
||||||
|
if (this.animProgress < 1) {
|
||||||
|
this.animFrame = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.animFrame = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const w = this.width;
|
||||||
|
const h = this.height;
|
||||||
|
const p = this.padding;
|
||||||
|
const chartW = w - p.left - p.right;
|
||||||
|
const chartH = h - p.top - p.bottom;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const { timestamps, rx, tx } = this.data;
|
||||||
|
if (!timestamps || timestamps.length < 2) {
|
||||||
|
ctx.fillStyle = '#5a6380';
|
||||||
|
ctx.font = '13px Inter, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('等待数据...', w / 2, h / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find max
|
||||||
|
let maxVal = 0;
|
||||||
|
for (let i = 0; i < rx.length; i++) {
|
||||||
|
maxVal = Math.max(maxVal, rx[i] || 0, tx[i] || 0);
|
||||||
|
}
|
||||||
|
maxVal = maxVal * 1.15 || 1;
|
||||||
|
|
||||||
|
const len = timestamps.length;
|
||||||
|
const xStep = chartW / (len - 1);
|
||||||
|
|
||||||
|
// Helper to get point
|
||||||
|
const getX = (i) => p.left + i * xStep;
|
||||||
|
const getY = (val) => p.top + chartH - (val / maxVal) * chartH * this.animProgress;
|
||||||
|
|
||||||
|
// Draw grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(99, 102, 241, 0.06)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const gridLines = 4;
|
||||||
|
for (let i = 0; i <= gridLines; i++) {
|
||||||
|
const y = p.top + (chartH / gridLines) * i;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.left, y);
|
||||||
|
ctx.lineTo(p.left + chartW, y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Y-axis labels
|
||||||
|
const val = maxVal * (1 - i / gridLines);
|
||||||
|
ctx.fillStyle = '#5a6380';
|
||||||
|
ctx.font = '10px "JetBrains Mono", monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(formatBandwidth(val, 1), p.left - 8, y + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-axis labels (every ~4 hours)
|
||||||
|
ctx.fillStyle = '#5a6380';
|
||||||
|
ctx.font = '10px "JetBrains Mono", monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const labelInterval = Math.max(1, Math.floor(len / 6));
|
||||||
|
for (let i = 0; i < len; i += labelInterval) {
|
||||||
|
const x = getX(i);
|
||||||
|
ctx.fillText(formatTime(timestamps[i]), x, h - 8);
|
||||||
|
}
|
||||||
|
// Always show last label
|
||||||
|
ctx.fillText(formatTime(timestamps[len - 1]), getX(len - 1), h - 8);
|
||||||
|
|
||||||
|
// Draw TX area
|
||||||
|
this.drawArea(ctx, tx, getX, getY, chartH, p,
|
||||||
|
'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)',
|
||||||
|
'#6366f1', len);
|
||||||
|
|
||||||
|
// Draw RX area (on top)
|
||||||
|
this.drawArea(ctx, rx, getX, getY, chartH, p,
|
||||||
|
'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)',
|
||||||
|
'#06b6d4', len);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawArea(ctx, values, getX, getY, chartH, p, fillColorTop, fillColorBottom, strokeColor, len) {
|
||||||
|
if (!values || values.length === 0) return;
|
||||||
|
|
||||||
|
// Fill
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(getX(0), getY(values[0] || 0));
|
||||||
|
for (let i = 1; i < len; i++) {
|
||||||
|
const prevX = getX(i - 1);
|
||||||
|
const currX = getX(i);
|
||||||
|
const prevY = getY(values[i - 1] || 0);
|
||||||
|
const currY = getY(values[i] || 0);
|
||||||
|
const midX = (prevX + currX) / 2;
|
||||||
|
ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY);
|
||||||
|
}
|
||||||
|
ctx.lineTo(getX(len - 1), p.top + chartH);
|
||||||
|
ctx.lineTo(getX(0), p.top + chartH);
|
||||||
|
ctx.closePath();
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(0, p.top, 0, p.top + chartH);
|
||||||
|
gradient.addColorStop(0, fillColorTop);
|
||||||
|
gradient.addColorStop(1, fillColorBottom);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Stroke
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(getX(0), getY(values[0] || 0));
|
||||||
|
for (let i = 1; i < len; i++) {
|
||||||
|
const prevX = getX(i - 1);
|
||||||
|
const currX = getX(i);
|
||||||
|
const prevY = getY(values[i - 1] || 0);
|
||||||
|
const currY = getY(values[i] || 0);
|
||||||
|
const midX = (prevX + currX) / 2;
|
||||||
|
ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = strokeColor;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener('resize', this._resize);
|
||||||
|
if (this.animFrame) cancelAnimationFrame(this.animFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
public/js/utils.js
Normal file
102
public/js/utils.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Utility Functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human-readable string
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes, decimals = 2) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k));
|
||||||
|
const value = bytes / Math.pow(k, i);
|
||||||
|
return value.toFixed(decimals) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes per second to human-readable bandwidth
|
||||||
|
*/
|
||||||
|
function formatBandwidth(bytesPerSec, decimals = 2) {
|
||||||
|
if (!bytesPerSec || bytesPerSec === 0) return '0 B/s';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
|
||||||
|
const i = Math.floor(Math.log(Math.abs(bytesPerSec)) / Math.log(k));
|
||||||
|
const value = bytesPerSec / Math.pow(k, i);
|
||||||
|
return value.toFixed(decimals) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format percentage
|
||||||
|
*/
|
||||||
|
function formatPercent(value, decimals = 1) {
|
||||||
|
if (!value || isNaN(value)) return '0%';
|
||||||
|
return value.toFixed(decimals) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp to HH:MM
|
||||||
|
*/
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
const d = new Date(timestamp);
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format full clock
|
||||||
|
*/
|
||||||
|
function formatClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const date = now.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
});
|
||||||
|
const time = now.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
return `${date} ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color based on usage percentage
|
||||||
|
*/
|
||||||
|
function getUsageColor(percent) {
|
||||||
|
if (percent < 50) return '#10b981';
|
||||||
|
if (percent < 75) return '#f59e0b';
|
||||||
|
return '#f43f5e';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth number animation
|
||||||
|
*/
|
||||||
|
function animateValue(element, start, end, duration = 600) {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const diff = end - start;
|
||||||
|
|
||||||
|
function update(currentTime) {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
// Ease out cubic
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const current = start + diff * eased;
|
||||||
|
|
||||||
|
if (element.dataset.format === 'percent') {
|
||||||
|
element.textContent = formatPercent(current);
|
||||||
|
} else if (element.dataset.format === 'bytes') {
|
||||||
|
element.textContent = formatBytes(current);
|
||||||
|
} else if (element.dataset.format === 'bandwidth') {
|
||||||
|
element.textContent = formatBandwidth(current);
|
||||||
|
} else {
|
||||||
|
element.textContent = Math.round(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
14
server/db.js
Normal file
14
server/db.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.MYSQL_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.MYSQL_PORT) || 3306,
|
||||||
|
user: process.env.MYSQL_USER || 'root',
|
||||||
|
password: process.env.MYSQL_PASSWORD || '',
|
||||||
|
database: process.env.MYSQL_DATABASE || 'display_wall',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = pool;
|
||||||
246
server/index.js
Normal file
246
server/index.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('./db');
|
||||||
|
const prometheusService = require('./prometheus-service');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
|
// ==================== Prometheus Source CRUD ====================
|
||||||
|
|
||||||
|
// Get all Prometheus sources
|
||||||
|
app.get('/api/sources', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY created_at DESC');
|
||||||
|
// Test connectivity for each source
|
||||||
|
const sourcesWithStatus = await Promise.all(rows.map(async (source) => {
|
||||||
|
try {
|
||||||
|
const response = await prometheusService.testConnection(source.url);
|
||||||
|
return { ...source, status: 'online', version: response };
|
||||||
|
} catch (e) {
|
||||||
|
return { ...source, status: 'offline', version: null };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
res.json(sourcesWithStatus);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching sources:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch sources' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a new Prometheus source
|
||||||
|
app.post('/api/sources', async (req, res) => {
|
||||||
|
const { name, url, description } = req.body;
|
||||||
|
if (!name || !url) {
|
||||||
|
return res.status(400).json({ error: 'Name and URL are required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [result] = await db.query(
|
||||||
|
'INSERT INTO prometheus_sources (name, url, description) VALUES (?, ?, ?)',
|
||||||
|
[name, url, description || '']
|
||||||
|
);
|
||||||
|
const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding source:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to add source' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a Prometheus source
|
||||||
|
app.put('/api/sources/:id', async (req, res) => {
|
||||||
|
const { name, url, description } = req.body;
|
||||||
|
try {
|
||||||
|
await db.query(
|
||||||
|
'UPDATE prometheus_sources SET name = ?, url = ?, description = ? WHERE id = ?',
|
||||||
|
[name, url, description || '', req.params.id]
|
||||||
|
);
|
||||||
|
const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating source:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update source' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a Prometheus source
|
||||||
|
app.delete('/api/sources/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await db.query('DELETE FROM prometheus_sources WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ message: 'Source deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting source:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete source' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection to a Prometheus source
|
||||||
|
app.post('/api/sources/test', async (req, res) => {
|
||||||
|
const { url } = req.body;
|
||||||
|
try {
|
||||||
|
const version = await prometheusService.testConnection(url);
|
||||||
|
res.json({ status: 'ok', version });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Metrics Aggregation ====================
|
||||||
|
|
||||||
|
// Get all aggregated metrics from all Prometheus sources
|
||||||
|
app.get('/api/metrics/overview', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [sources] = await db.query('SELECT * FROM prometheus_sources');
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
totalServers: 0,
|
||||||
|
cpu: { used: 0, total: 0, percent: 0 },
|
||||||
|
memory: { used: 0, total: 0, percent: 0 },
|
||||||
|
disk: { used: 0, total: 0, percent: 0 },
|
||||||
|
network: { totalBandwidth: 0, rx: 0, tx: 0 },
|
||||||
|
traffic24h: { rx: 0, tx: 0, total: 0 },
|
||||||
|
servers: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMetrics = await Promise.all(sources.map(source =>
|
||||||
|
prometheusService.getOverviewMetrics(source.url, source.name).catch(err => {
|
||||||
|
console.error(`Error fetching metrics from ${source.name}:`, err.message);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const validMetrics = allMetrics.filter(m => m !== null);
|
||||||
|
|
||||||
|
// Aggregate across all sources
|
||||||
|
let totalServers = 0;
|
||||||
|
let cpuUsed = 0, cpuTotal = 0;
|
||||||
|
let memUsed = 0, memTotal = 0;
|
||||||
|
let diskUsed = 0, diskTotal = 0;
|
||||||
|
let netRx = 0, netTx = 0;
|
||||||
|
let traffic24hRx = 0, traffic24hTx = 0;
|
||||||
|
let allServers = [];
|
||||||
|
|
||||||
|
for (const m of validMetrics) {
|
||||||
|
totalServers += m.totalServers;
|
||||||
|
cpuUsed += m.cpu.used;
|
||||||
|
cpuTotal += m.cpu.total;
|
||||||
|
memUsed += m.memory.used;
|
||||||
|
memTotal += m.memory.total;
|
||||||
|
diskUsed += m.disk.used;
|
||||||
|
diskTotal += m.disk.total;
|
||||||
|
netRx += m.network.rx;
|
||||||
|
netTx += m.network.tx;
|
||||||
|
traffic24hRx += m.traffic24h.rx;
|
||||||
|
traffic24hTx += m.traffic24h.tx;
|
||||||
|
allServers = allServers.concat(m.servers);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalServers,
|
||||||
|
cpu: {
|
||||||
|
used: cpuUsed,
|
||||||
|
total: cpuTotal,
|
||||||
|
percent: cpuTotal > 0 ? (cpuUsed / cpuTotal * 100) : 0
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
used: memUsed,
|
||||||
|
total: memTotal,
|
||||||
|
percent: memTotal > 0 ? (memUsed / memTotal * 100) : 0
|
||||||
|
},
|
||||||
|
disk: {
|
||||||
|
used: diskUsed,
|
||||||
|
total: diskTotal,
|
||||||
|
percent: diskTotal > 0 ? (diskUsed / diskTotal * 100) : 0
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
totalBandwidth: netRx + netTx,
|
||||||
|
rx: netRx,
|
||||||
|
tx: netTx
|
||||||
|
},
|
||||||
|
traffic24h: {
|
||||||
|
rx: traffic24hRx,
|
||||||
|
tx: traffic24hTx,
|
||||||
|
total: traffic24hRx + traffic24hTx
|
||||||
|
},
|
||||||
|
servers: allServers
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching overview metrics:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch metrics' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get network traffic history (past 24h in intervals)
|
||||||
|
app.get('/api/metrics/network-history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [sources] = await db.query('SELECT * FROM prometheus_sources');
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return res.json({ timestamps: [], rx: [], tx: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allHistories = await Promise.all(sources.map(source =>
|
||||||
|
prometheusService.getNetworkHistory(source.url).catch(err => {
|
||||||
|
console.error(`Error fetching network history from ${source.name}:`, err.message);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const validHistories = allHistories.filter(h => h !== null);
|
||||||
|
if (validHistories.length === 0) {
|
||||||
|
return res.json({ timestamps: [], rx: [], tx: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all histories by timestamp
|
||||||
|
const merged = prometheusService.mergeNetworkHistories(validHistories);
|
||||||
|
res.json(merged);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching network history:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch network history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get CPU usage history for sparklines
|
||||||
|
app.get('/api/metrics/cpu-history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [sources] = await db.query('SELECT * FROM prometheus_sources');
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return res.json({ timestamps: [], values: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allHistories = await Promise.all(sources.map(source =>
|
||||||
|
prometheusService.getCpuHistory(source.url).catch(err => {
|
||||||
|
console.error(`Error fetching CPU history from ${source.name}:`, err.message);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const validHistories = allHistories.filter(h => h !== null);
|
||||||
|
if (validHistories.length === 0) {
|
||||||
|
return res.json({ timestamps: [], values: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = prometheusService.mergeCpuHistories(validHistories);
|
||||||
|
res.json(merged);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching CPU history:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch CPU history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`\n 🚀 Data Visualization Display Wall`);
|
||||||
|
console.log(` 📊 Server running at http://localhost:${PORT}`);
|
||||||
|
console.log(` ⚙️ Configure Prometheus sources at http://localhost:${PORT}/settings\n`);
|
||||||
|
});
|
||||||
47
server/init-db.js
Normal file
47
server/init-db.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Database Initialization Script
|
||||||
|
* Run: npm run init-db
|
||||||
|
* Creates the required MySQL database and tables.
|
||||||
|
*/
|
||||||
|
require('dotenv').config();
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
async function initDatabase() {
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
host: process.env.MYSQL_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.MYSQL_PORT) || 3306,
|
||||||
|
user: process.env.MYSQL_USER || 'root',
|
||||||
|
password: process.env.MYSQL_PASSWORD || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbName = process.env.MYSQL_DATABASE || 'display_wall';
|
||||||
|
|
||||||
|
console.log('🔧 Initializing database...\n');
|
||||||
|
|
||||||
|
// Create database
|
||||||
|
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||||
|
console.log(` ✅ Database "${dbName}" ready`);
|
||||||
|
|
||||||
|
await connection.query(`USE \`${dbName}\``);
|
||||||
|
|
||||||
|
// Create prometheus_sources table
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS prometheus_sources (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
url VARCHAR(500) NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
console.log(' ✅ Table "prometheus_sources" ready');
|
||||||
|
|
||||||
|
console.log('\n🎉 Database initialization complete!\n');
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
initDatabase().catch(err => {
|
||||||
|
console.error('❌ Database initialization failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
320
server/prometheus-service.js
Normal file
320
server/prometheus-service.js
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const QUERY_TIMEOUT = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an axios instance for a given Prometheus URL
|
||||||
|
*/
|
||||||
|
function createClient(baseUrl) {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: baseUrl.replace(/\/+$/, ''),
|
||||||
|
timeout: QUERY_TIMEOUT
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Prometheus connection
|
||||||
|
*/
|
||||||
|
async function testConnection(url) {
|
||||||
|
const client = createClient(url);
|
||||||
|
const res = await client.get('/api/v1/status/buildinfo');
|
||||||
|
return res.data?.data?.version || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a Prometheus instant query
|
||||||
|
*/
|
||||||
|
async function query(url, expr) {
|
||||||
|
const client = createClient(url);
|
||||||
|
const res = await client.get('/api/v1/query', { params: { query: expr } });
|
||||||
|
if (res.data.status !== 'success') {
|
||||||
|
throw new Error(`Prometheus query failed: ${res.data.error || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return res.data.data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a Prometheus range query
|
||||||
|
*/
|
||||||
|
async function queryRange(url, expr, start, end, step) {
|
||||||
|
const client = createClient(url);
|
||||||
|
const res = await client.get('/api/v1/query_range', {
|
||||||
|
params: { query: expr, start, end, step }
|
||||||
|
});
|
||||||
|
if (res.data.status !== 'success') {
|
||||||
|
throw new Error(`Prometheus range query failed: ${res.data.error || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return res.data.data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overview metrics from a single Prometheus source
|
||||||
|
*/
|
||||||
|
async function getOverviewMetrics(url, sourceName) {
|
||||||
|
// Run all queries in parallel
|
||||||
|
const [
|
||||||
|
cpuResult,
|
||||||
|
cpuCountResult,
|
||||||
|
memTotalResult,
|
||||||
|
memAvailResult,
|
||||||
|
diskTotalResult,
|
||||||
|
diskFreeResult,
|
||||||
|
netRxResult,
|
||||||
|
netTxResult,
|
||||||
|
traffic24hRxResult,
|
||||||
|
traffic24hTxResult,
|
||||||
|
upResult
|
||||||
|
] = await Promise.all([
|
||||||
|
// CPU usage per instance: 1 - avg idle
|
||||||
|
query(url, '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)').catch(() => []),
|
||||||
|
// CPU count per instance
|
||||||
|
query(url, 'count by (instance) (node_cpu_seconds_total{mode="idle"})').catch(() => []),
|
||||||
|
// Memory total per instance
|
||||||
|
query(url, 'node_memory_MemTotal_bytes').catch(() => []),
|
||||||
|
// Memory available per instance
|
||||||
|
query(url, 'node_memory_MemAvailable_bytes').catch(() => []),
|
||||||
|
// Disk total per instance (root filesystem)
|
||||||
|
query(url, 'sum by (instance) (node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"})').catch(() => []),
|
||||||
|
// Disk free per instance (root filesystem)
|
||||||
|
query(url, 'sum by (instance) (node_filesystem_free_bytes{mountpoint="/",fstype!="tmpfs"})').catch(() => []),
|
||||||
|
// Network receive rate (bytes/sec)
|
||||||
|
query(url, 'sum by (instance) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))').catch(() => []),
|
||||||
|
// Network transmit rate (bytes/sec)
|
||||||
|
query(url, 'sum by (instance) (rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))').catch(() => []),
|
||||||
|
// Total traffic received in last 24h
|
||||||
|
query(url, 'sum by (instance) (increase(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
|
||||||
|
// Total traffic transmitted in last 24h
|
||||||
|
query(url, 'sum by (instance) (increase(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []),
|
||||||
|
// Up instances
|
||||||
|
query(url, 'up{job=~".*node.*|.*exporter.*"}').catch(() => [])
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build per-instance data map
|
||||||
|
const instances = new Map();
|
||||||
|
|
||||||
|
const getOrCreate = (instance) => {
|
||||||
|
if (!instances.has(instance)) {
|
||||||
|
instances.set(instance, {
|
||||||
|
instance,
|
||||||
|
source: sourceName,
|
||||||
|
cpuPercent: 0,
|
||||||
|
cpuCores: 0,
|
||||||
|
memTotal: 0,
|
||||||
|
memUsed: 0,
|
||||||
|
diskTotal: 0,
|
||||||
|
diskUsed: 0,
|
||||||
|
netRx: 0,
|
||||||
|
netTx: 0,
|
||||||
|
up: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return instances.get(instance);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse UP status
|
||||||
|
for (const r of upResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.up = parseFloat(r.value[1]) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CPU usage
|
||||||
|
for (const r of cpuResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.cpuPercent = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CPU count
|
||||||
|
for (const r of cpuCountResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.cpuCores = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse memory
|
||||||
|
for (const r of memTotalResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.memTotal = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
for (const r of memAvailResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.memUsed = inst.memTotal - (parseFloat(r.value[1]) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse disk
|
||||||
|
for (const r of diskTotalResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.diskTotal = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
for (const r of diskFreeResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.diskUsed = inst.diskTotal - (parseFloat(r.value[1]) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse network rates
|
||||||
|
for (const r of netRxResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.netRx = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
for (const r of netTxResult) {
|
||||||
|
const inst = getOrCreate(r.metric.instance);
|
||||||
|
inst.netTx = parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate
|
||||||
|
let totalCpuUsed = 0, totalCpuCores = 0;
|
||||||
|
let totalMemUsed = 0, totalMemTotal = 0;
|
||||||
|
let totalDiskUsed = 0, totalDiskTotal = 0;
|
||||||
|
let totalNetRx = 0, totalNetTx = 0;
|
||||||
|
let totalTraffic24hRx = 0, totalTraffic24hTx = 0;
|
||||||
|
|
||||||
|
for (const inst of instances.values()) {
|
||||||
|
totalCpuUsed += (inst.cpuPercent / 100) * inst.cpuCores;
|
||||||
|
totalCpuCores += inst.cpuCores;
|
||||||
|
totalMemUsed += inst.memUsed;
|
||||||
|
totalMemTotal += inst.memTotal;
|
||||||
|
totalDiskUsed += inst.diskUsed;
|
||||||
|
totalDiskTotal += inst.diskTotal;
|
||||||
|
totalNetRx += inst.netRx;
|
||||||
|
totalNetTx += inst.netTx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse 24h traffic
|
||||||
|
for (const r of traffic24hRxResult) {
|
||||||
|
totalTraffic24hRx += parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
for (const r of traffic24hTxResult) {
|
||||||
|
totalTraffic24hTx += parseFloat(r.value[1]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalServers: instances.size,
|
||||||
|
cpu: {
|
||||||
|
used: totalCpuUsed,
|
||||||
|
total: totalCpuCores,
|
||||||
|
percent: totalCpuCores > 0 ? (totalCpuUsed / totalCpuCores * 100) : 0
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
used: totalMemUsed,
|
||||||
|
total: totalMemTotal,
|
||||||
|
percent: totalMemTotal > 0 ? (totalMemUsed / totalMemTotal * 100) : 0
|
||||||
|
},
|
||||||
|
disk: {
|
||||||
|
used: totalDiskUsed,
|
||||||
|
total: totalDiskTotal,
|
||||||
|
percent: totalDiskTotal > 0 ? (totalDiskUsed / totalDiskTotal * 100) : 0
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
rx: totalNetRx,
|
||||||
|
tx: totalNetTx
|
||||||
|
},
|
||||||
|
traffic24h: {
|
||||||
|
rx: totalTraffic24hRx,
|
||||||
|
tx: totalTraffic24hTx
|
||||||
|
},
|
||||||
|
servers: Array.from(instances.values())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get network traffic history (past 24h, 15-min intervals)
|
||||||
|
*/
|
||||||
|
async function getNetworkHistory(url) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const start = now - 86400; // 24h ago
|
||||||
|
const step = 900; // 15 minutes
|
||||||
|
|
||||||
|
const [rxResult, txResult] = await Promise.all([
|
||||||
|
queryRange(url,
|
||||||
|
'sum(rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))',
|
||||||
|
start, now, step
|
||||||
|
).catch(() => []),
|
||||||
|
queryRange(url,
|
||||||
|
'sum(rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))',
|
||||||
|
start, now, step
|
||||||
|
).catch(() => [])
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Extract values - each result[0].values = [[timestamp, value], ...]
|
||||||
|
const rxValues = rxResult.length > 0 ? rxResult[0].values : [];
|
||||||
|
const txValues = txResult.length > 0 ? txResult[0].values : [];
|
||||||
|
|
||||||
|
return { rxValues, txValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge network histories from multiple sources
|
||||||
|
*/
|
||||||
|
function mergeNetworkHistories(histories) {
|
||||||
|
const timestampMap = new Map();
|
||||||
|
|
||||||
|
for (const history of histories) {
|
||||||
|
for (const [ts, val] of history.rxValues) {
|
||||||
|
const existing = timestampMap.get(ts) || { rx: 0, tx: 0 };
|
||||||
|
existing.rx += parseFloat(val) || 0;
|
||||||
|
timestampMap.set(ts, existing);
|
||||||
|
}
|
||||||
|
for (const [ts, val] of history.txValues) {
|
||||||
|
const existing = timestampMap.get(ts) || { rx: 0, tx: 0 };
|
||||||
|
existing.tx += parseFloat(val) || 0;
|
||||||
|
timestampMap.set(ts, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...timestampMap.entries()].sort((a, b) => a[0] - b[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamps: sorted.map(([ts]) => ts * 1000), // ms for JS
|
||||||
|
rx: sorted.map(([, v]) => v.rx),
|
||||||
|
tx: sorted.map(([, v]) => v.tx)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CPU usage history (past 1h, 1-min intervals)
|
||||||
|
*/
|
||||||
|
async function getCpuHistory(url) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const start = now - 3600; // 1h ago
|
||||||
|
const step = 60; // 1 minute
|
||||||
|
|
||||||
|
const result = await queryRange(url,
|
||||||
|
'100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
|
||||||
|
start, now, step
|
||||||
|
).catch(() => []);
|
||||||
|
|
||||||
|
const values = result.length > 0 ? result[0].values : [];
|
||||||
|
return values; // [[timestamp, value], ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge CPU histories from multiple sources (average)
|
||||||
|
*/
|
||||||
|
function mergeCpuHistories(histories) {
|
||||||
|
const timestampMap = new Map();
|
||||||
|
|
||||||
|
for (const history of histories) {
|
||||||
|
for (const [ts, val] of history) {
|
||||||
|
const existing = timestampMap.get(ts) || { sum: 0, count: 0 };
|
||||||
|
existing.sum += parseFloat(val) || 0;
|
||||||
|
existing.count += 1;
|
||||||
|
timestampMap.set(ts, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...timestampMap.entries()].sort((a, b) => a[0] - b[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamps: sorted.map(([ts]) => ts * 1000),
|
||||||
|
values: sorted.map(([, v]) => v.sum / v.count)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
testConnection,
|
||||||
|
query,
|
||||||
|
queryRange,
|
||||||
|
getOverviewMetrics,
|
||||||
|
getNetworkHistory,
|
||||||
|
mergeNetworkHistories,
|
||||||
|
getCpuHistory,
|
||||||
|
mergeCpuHistories
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user