52 Commits

Author SHA1 Message Date
CN-JS-HuiBai
e19a21a3cc 完善serverless部署环境 2026-04-10 14:40:24 +08:00
CN-JS-HuiBai
bfb40f4947 修复安装脚本的潜在问题 2026-04-09 14:02:58 +08:00
CN-JS-HuiBai
9854f478c0 优化数据库自检能力 2026-04-09 13:55:24 +08:00
CN-JS-HuiBai
3963d137de 添加设置项 2026-04-09 13:47:51 +08:00
CN-JS-HuiBai
60d8a3d550 修复安全边界问题 2026-04-09 13:37:47 +08:00
CN-JS-HuiBai
09f20ec81d 2D 地图路由修复:
服务器详情体验增强:
2026-04-09 12:50:40 +08:00
CN-JS-HuiBai
06ddd5a8e1 优化内存显示 2026-04-09 12:47:01 +08:00
CN-JS-HuiBai
a14fcdf158 修复2D地图无法渲染的问题 2026-04-09 12:25:41 +08:00
CN-JS-HuiBai
6aa8ba5fbc 添加95计费出入去大带宽 2026-04-09 12:21:41 +08:00
CN-JS-HuiBai
2eae34bb96 修复无法绘图的故障 2026-04-08 14:38:43 +08:00
CN-JS-HuiBai
05ae5dff2a 修复错误 2026-04-07 20:49:03 +08:00
CN-JS-HuiBai
64fc023f7b 资源本地化 2026-04-07 16:01:29 +08:00
CN-JS-HuiBai
307a26c0db 修复websocket 2026-04-07 12:20:09 +08:00
CN-JS-HuiBai
73401309f2 进一步优化2 2026-04-07 00:32:02 +08:00
CN-JS-HuiBai
f169dd4267 进一步优化 2026-04-07 00:29:46 +08:00
CN-JS-HuiBai
09887b52d0 渲染优化 2026-04-07 00:26:00 +08:00
CN-JS-HuiBai
c94b697319 添加刷新按钮 2026-04-06 23:13:50 +08:00
CN-JS-HuiBai
1bfee2026f 优化 2026-04-06 19:49:32 +08:00
CN-JS-HuiBai
c47e483028 优化动画 2026-04-06 19:47:33 +08:00
CN-JS-HuiBai
864cbc3569 优化线条2 2026-04-06 19:44:57 +08:00
CN-JS-HuiBai
47a795cd73 优化线条 2026-04-06 19:43:17 +08:00
CN-JS-HuiBai
92f97b7e51 优化算法 2026-04-06 19:35:30 +08:00
CN-JS-HuiBai
3bdde47c60 优化分布 2026-04-06 19:32:54 +08:00
CN-JS-HuiBai
1583758f29 支持数据源编辑 2026-04-06 18:46:51 +08:00
CN-JS-HuiBai
0602e37bc9 修复数据库 2026-04-06 18:27:11 +08:00
CN-JS-HuiBai
41bdb38d51 优化渲染逻辑 2026-04-06 18:20:29 +08:00
CN-JS-HuiBai
d958aa8d74 设置标题 2026-04-06 18:05:35 +08:00
CN-JS-HuiBai
2024523b46 修复保存后前端卡死的问题 2026-04-06 18:02:53 +08:00
CN-JS-HuiBai
da722ee07e 2 2026-04-06 17:56:36 +08:00
CN-JS-HuiBai
bc8414df3d 优化 2026-04-06 17:49:31 +08:00
CN-JS-HuiBai
15f4b610af 修复无法保存配置的问题 2026-04-06 17:44:58 +08:00
CN-JS-HuiBai
94ed27199a Dlogo 2026-04-06 17:27:56 +08:00
CN-JS-HuiBai
131c011c5c 优化备案号结构 2026-04-06 17:23:07 +08:00
CN-JS-HuiBai
6d5b8bbb08 添加备案号 2026-04-06 17:05:26 +08:00
CN-JS-HuiBai
b4415f25ac 添加copyright 2026-04-06 16:58:58 +08:00
CN-JS-HuiBai
d469dacc08 进一步优化移动端显示 2026-04-06 16:52:35 +08:00
CN-JS-HuiBai
ee4354b571 支持自动识别目录 2026-04-06 16:45:00 +08:00
CN-JS-HuiBai
50818b54ca 升级数据库 2026-04-06 16:39:19 +08:00
CN-JS-HuiBai
480cdf3f6d 优化升级脚本 2026-04-06 16:34:21 +08:00
CN-JS-HuiBai
832fd0fde1 添加升级脚本 2026-04-06 16:32:24 +08:00
CN-JS-HuiBai
fd0a52368a 优化黑暗模式和白天模式 2026-04-06 16:26:13 +08:00
CN-JS-HuiBai
650cc6f1b5 修复移动布局错误 2026-04-06 16:21:38 +08:00
CN-JS-HuiBai
ff1c53ea40 服务器分布居中 2026-04-06 16:19:23 +08:00
CN-JS-HuiBai
b79655ccdc 优化安卓界面布局 2026-04-06 16:13:31 +08:00
CN-JS-HuiBai
1f12197a91 优化移动端显示 2026-04-06 16:09:00 +08:00
CN-JS-HuiBai
d0455fb032 优化时间点 2026-04-06 16:02:01 +08:00
CN-JS-HuiBai
b2f6f7d2d0 优化 2026-04-06 15:59:37 +08:00
CN-JS-HuiBai
ff439bb831 恢复BUSY SYSTEM 2026-04-06 15:36:11 +08:00
CN-JS-HuiBai
3980d66b49 优化CPU绘图 2026-04-06 15:32:54 +08:00
b16d910051 更新 README.md 2026-04-06 15:17:16 +08:00
CN-JS-HuiBai
7e3a8e12d0 优化ENV 2026-04-06 15:15:56 +08:00
CN-JS-HuiBai
1d728d991e 优化安装脚本逻辑 2026-04-06 15:14:49 +08:00
22 changed files with 3967 additions and 1498 deletions

View File

@@ -1,10 +1,19 @@
# PromdataPanel Environment Configuration
# Note: Database and Cache settings will be automatically configured upon visiting /init.html
# Server Binding
HOST=0.0.0.0 HOST=0.0.0.0
PORT=3051 PORT=3000
# Aggregation interval in milliseconds (default 5s)
REFRESH_INTERVAL=5000 REFRESH_INTERVAL=5000
# Valkey/Redis Cache Configuration # Security
VALKEY_HOST=localhost # Keep remote setup disabled unless you explicitly need to initialize from another host.
VALKEY_PORT=6379 ALLOW_REMOTE_SETUP=false
VALKEY_PASSWORD= COOKIE_SECURE=false
VALKEY_DB=dashboard SESSION_TTL_SECONDS=86400
VALKEY_TTL=30 PASSWORD_ITERATIONS=210000
# Runtime external data providers
ENABLE_EXTERNAL_GEO_LOOKUP=false

155
README.md
View File

@@ -1,100 +1,119 @@
# 数据可视化展示大屏 # PromdataPanel
多源 Prometheus 服务器监控展示大屏支持对接多个 Prometheus 实例,实时展示所有服务器的 CPU、内存、磁盘、网络等关键指标。 多源 Prometheus 服务器监控展示大屏支持对接多个 Prometheus 实例,实时聚合展示所有服务器的 CPU、内存、磁盘、带宽等关键指标,并提供可视化节点分布图
## 功能特性 ## 功能特性
- 🔌 **多数据源管理** - MySQL 存储配置,支持对接多个 Prometheus 实例 - 🔌 **多数据源管理** - 支持对接多个 Prometheus 实例Node_Exporter / BlackboxExporter
- 📊 **NodeExporter 数据查询** - 自动聚合所有 Prometheus 中的 NodeExporter 数据 - 📊 **指标自动聚合** - 自动汇总所有数据源的 NodeExporter 指标,实时计算全网负载
- 🌐 **网络流量统计** - 24 小时网络流量趋势图,总流量统计 - 🌐 **网络流量统计** - 24 小时流量趋势图,实时带宽Rx/Tx求和显示
- **实时带宽监控** - 所有服务器网络带宽求和,实时显示 - 🗺️ **节点分布可视化** - 自动识别服务器地理位置,并在全球地图上展示实时连接状态与延迟
- 💻 **资源使用概览** - CPU、内存、磁盘的总使用率和详细统计 - **毫秒级实时性** - 深度优化查询逻辑,支持 5s 采集频率的实时动态展示
- 🖥️ **服务器列表** - 所有服务器的详细指标一览表 - 📱 **响应式与美学设计** - 现代 UI/UX 体验,支持暗色模式,极致性能优化
## 快速开始 ## 快速安装
### 1. 环境要求 ### 方式一:一键脚本安装 (推荐)
- Node.js >= 16 在 Linux 服务器上,您可以使用以下脚本一键完成下载、环境检测、依赖安装并将其注册为 Systemd 系统服务:
- MySQL >= 5.7
- Valkey >= 7.0 (或 Redis >= 6.0)
### 2. 配置
复制环境变量文件并修改:
```bash ```bash
cp .env.example .env # 下载安装最新版本 (默认 v0.1.0)
VERSION=v0.1.0 curl -sSL https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/raw/branch/main/install.sh | bash
``` ```
编辑 `.env` 文件,配置 MySQL 和 Valkey 连接信息: ### 方式二:手动安装
```env #### 1. 环境要求
# MySQL 配置 - **Node.js** >= 18
MYSQL_HOST=localhost - **MySQL** >= 8.0
MYSQL_PORT=3306 - **Valkey** >= 7.0 (或 Redis >= 6.0)
MYSQL_USER=root
MYSQL_PASSWORD=your_password
MYSQL_DATABASE=display_wall
# Valkey/Redis 缓存配置 (可选) #### 2. 配置与启动
VALKEY_HOST=localhost 1. 克隆代码库:`git clone https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel.git`
VALKEY_PORT=6379 2. 复制配置文件:`cp .env.example .env`
VALKEY_PASSWORD= 3. 安装依赖:`npm install --production`
VALKEY_TTL=30 4. 启动服务:`npm start`
PORT=3000 ### 方式三:更新现有版本
```
### 3. 系统初始化 如果您已经安装了本系统,可以使用随附的 `update.sh` 脚本一键升级到最新代码:
访问 `http://localhost:3000/init.html`,按照引导完成数据库和缓存的初始化。
### 4. 安装依赖并启动
```bash ```bash
npm install # 进入程序目录
npm run dev curl -sSL https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/raw/branch/main/update.sh | bash
``` ```
访问 `http://localhost:3000` 即可看到展示大屏。 #### 3. 系统初始化
首次运行后,访问 `http://your-ip:3000/init.html`,按照引导完成 MySQL 数据库和 Valkey 缓存的连接。
### 5. 配置 Prometheus 数据源 ## 使用指引
点击右上角的 ⚙️ 按钮,添加你的 Prometheus 地址(如 `http://prometheus.example.com:9090`)。 ### 1. 添加 Prometheus 数据源
点击页面右上角的 ⚙️ 按钮进入设置,添加并测试您的 Prometheus HTTP 地址。
### 6. Prometheus 配置参考 (Example) ### 2. Prometheus 采集配置
建议在 `prometheus.yml` 中设置采集周期为 `5s` 以实现平滑的实时动态效果:
在您的 Prometheus 配置文件 `prometheus.yml` 中,建议执行以下配置(`scrape_interval` 建议设为 `5s` 以获取最佳实时展示效果):
```yaml ```yaml
global: global:
scrape_interval: 5s scrape_interval: 5s
scrape_configs:
- job_name: '机器名称'
static_configs:
- targets: ['IP:Port']
```
scrape_configs:
- job_name: 'nodes'
static_configs:
- targets: ['your-server-ip:9100']
```
## 技术栈 ## 技术栈
- **后端**: Node.js + Express - **Runtime**: Node.js
- **数据库**: MySQL (存储配置数据) - **Framework**: Express.js
- **缓存**: Valkey / Redis (用于加速流量计算结果读取) - **Database**: MySQL 8.0+
- **数据源**: Prometheus HTTP API - **Caching**: Valkey / Redis
- **前端**: 原生 HTML/CSS/JavaScript - **Visualization**: ECharts / Canvas
- **图表**: 自定义 Canvas 渲染 - **Frontend**: Vanilla JS / CSS3
## API 接口 ## API 接口文档
| 方法 | 路径 | 说明 | 本项提供了完整的 RESTful API用于数据采集、系统配置和状态监控。
|------|------|------|
| GET | `/api/sources` | 获取所有数据源 | ### 1. 认证接口 (`/api/auth`)
| POST | `/api/sources` | 添加数据源 | - `POST /api/auth/login`: 用户登录
| PUT | `/api/sources/:id` | 更新数据源 | - `POST /api/auth/logout`: 退出登录
| DELETE | `/api/sources/:id` | 删除数据源 | - `POST /api/auth/change-password`: 修改密码 (需登录)
| POST | `/api/sources/test` | 测试数据源连接 | - `GET /api/auth/status`: 获取当前登录状态
| GET | `/api/metrics/overview` | 获取聚合指标概览 |
| GET | `/api/metrics/network-history` | 获取24h网络流量历史 | ### 2. 数据源管理 (`/api/sources`)
| GET | `/api/metrics/cpu-history` | 获取CPU使用率历史 | - `GET /api/sources`: 获取所有 Prometheus 数据源及其状态
- `POST /api/sources`: 添加新数据源 (需登录)
- `PUT /api/sources/:id`: 修改数据源信息 (需登录)
- `DELETE /api/sources/:id`: 删除数据源 (需登录)
- `POST /api/sources/test`: 测试数据源连接性 (需登录)
### 3. 指标数据获取 (`/api/metrics`)
- `GET /api/metrics/overview`: 获取所有服务器的聚合实时指标 (CPU, 内存, 磁盘, 网络)
- `GET /api/metrics/network-history`: 获取全网 24 小时流量历史趋势
- `GET /api/metrics/cpu-history`: 获取全网 CPU 使用率历史记录
- `GET /api/metrics/server-details`: 获取特定服务器的详细实时指标
- `GET /api/metrics/server-history`: 获取特定服务器的历史指标数据
- `GET /api/metrics/latency`: 获取节点间的实时延迟数据
### 4. 系统配置与监控
- `GET /api/settings`: 获取站点全局配置
- `POST /api/settings`: 修改站点全局配置 (需登录)
- `GET /health`: 获取系统健康检查报告 (数据库、缓存、内存等状态)
### 5. 延迟链路管理 (`/api/latency-routes`)
- `GET /api/latency-routes`: 获取配置的所有延迟检测链路
- `POST /api/latency-routes`: 添加延迟检测链路 (需登录)
- `PUT /api/latency-routes/:id`: 修改延迟检测链路 (需登录)
- `DELETE /api/latency-routes/:id`: 删除延迟检测链路 (需登录)
### 6. 实时通信 (WebSocket)
系统支持通过 WebSocket 接收实时推送,默认端口与 HTTP 服务一致:
- **消息类型 `overview`**: 包含聚合指标、服务器在线状态以及地理分布后的延迟链路数据。
## LICENSE
MIT License

3
api/index.js Normal file
View File

@@ -0,0 +1,3 @@
const { app } = require('../server/index');
module.exports = app;

View File

@@ -1,238 +1,262 @@
#!/bin/bash #!/bin/bash
# Data Visualization Display Wall - Systemd Installer set -euo pipefail
# Requirements: Node.js, NPM, Systemd (Linux)
# Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m'
echo -e "${BLUE}=== Data Visualization Display Wall Installer ===${NC}" VERSION=${VERSION:-"v0.1.0"}
DOWNLOAD_URL="https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/archive/${VERSION}.zip"
MIN_NODE_VERSION=18
SERVICE_NAME="promdatapanel"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
# 1. Permission check (no longer mandatory) OS_ID=""
if [ "$EUID" -eq 0 ]; then OS_VER=""
# If run as sudo, get the real user that called it PROJECT_DIR=""
REAL_USER=${SUDO_USER:-$USER} REAL_USER=""
else
REAL_USER=$USER
fi
# 2. Get current directory and user echo -e "${BLUE}================================================${NC}"
PROJECT_DIR=$(pwd) echo -e "${BLUE} PromdataPanel Auto-Installer ${NC}"
USER_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6) echo -e "${BLUE} Version: ${VERSION} ${NC}"
echo -e "${BLUE}================================================${NC}"
echo -e "Project Directory: ${GREEN}$PROJECT_DIR${NC}" detect_os() {
echo -e "Running User: ${GREEN}$REAL_USER${NC}" if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"
OS_VER="${VERSION_ID:-}"
else
echo -e "${RED}Error: Cannot detect operating system type (/etc/os-release missing).${NC}"
exit 1
fi
# 3. Check for mandatory files if [ -z "$OS_ID" ]; then
if [ ! -f "server/index.js" ]; then echo -e "${RED}Error: Unable to determine operating system ID.${NC}"
echo -e "${RED}Error: server/index.js not found. Please run this script from the project root.${NC}" exit 1
exit 1 fi
fi
# 4. Check for dependencies echo -e "Detected OS: ${GREEN}${OS_ID} ${OS_VER}${NC}"
echo -e "${BLUE}Checking dependencies...${NC}" }
check_dep() {
if ! command -v "$1" &> /dev/null; then require_cmd() {
echo -e "${RED}$1 is not installed. Please install $1 first.${NC}" local cmd="$1"
local hint="${2:-}"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo -e "${RED}Missing required command: ${cmd}.${NC}"
if [ -n "$hint" ]; then
echo -e "${YELLOW}${hint}${NC}"
fi
exit 1 exit 1
fi fi
} }
check_dep node
check_dep npm
# 5. Check for .env file install_packages() {
if [ ! -f ".env" ]; then case "$OS_ID" in
echo -e "${YELLOW}Warning: .env file not found.${NC}" ubuntu|debian|raspbian)
if [ -f ".env.example" ]; then sudo apt-get update
echo -e "Creating .env from .env.example..." sudo apt-get install -y "$@"
cp .env.example .env ;;
echo -e "${GREEN}Created .env file. Please ensure values are correct.${NC}" centos|rhel|almalinux|rocky)
else sudo yum install -y "$@"
echo -e "${RED}Error: .env.example not found. Configuration missing.${NC}" ;;
fedora)
sudo dnf install -y "$@"
;;
*)
echo -e "${RED}Unsupported OS for automatic package installation: ${OS_ID}${NC}"
echo -e "${YELLOW}Please install the following packages manually: $*${NC}"
exit 1
;;
esac
}
ensure_tooling() {
if ! command -v curl >/dev/null 2>&1; then
echo -e "${BLUE}Installing curl...${NC}"
install_packages curl
fi fi
fi
# 6. Install NPM dependencies if ! command -v unzip >/dev/null 2>&1; then
echo -e "${BLUE}Installing dependencies...${NC}" echo -e "${BLUE}Installing unzip...${NC}"
npm install install_packages unzip
fi
}
if [ $? -ne 0 ]; then configure_nodesource_apt_repo() {
echo -e "${RED}NPM install failed.${NC}" sudo install -d -m 0755 /etc/apt/keyrings
exit 1 curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
fi echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list >/dev/null
}
# 7. Create Systemd Service File install_node() {
SERVICE_FILE="/etc/systemd/system/promdatapanel.service" echo -e "${BLUE}Verifying Node.js environment...${NC}"
NODE_PATH=$(command -v node)
echo -e "${BLUE}Creating systemd service at $SERVICE_FILE... (May require password)${NC}" local node_installed=false
sudo bash -c "cat <<EOF > '$SERVICE_FILE' if command -v node >/dev/null 2>&1; then
local current_node_ver
current_node_ver=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$current_node_ver" -ge "$MIN_NODE_VERSION" ]; then
echo -e "${GREEN}Node.js $(node -v) is already installed.${NC}"
node_installed=true
else
echo -e "${YELLOW}Existing Node.js $(node -v) is too old (requires >= ${MIN_NODE_VERSION}).${NC}"
fi
fi
if [ "$node_installed" = true ]; then
return
fi
echo -e "${BLUE}Installing Node.js 20.x...${NC}"
case "$OS_ID" in
ubuntu|debian|raspbian)
install_packages ca-certificates curl gnupg
configure_nodesource_apt_repo
sudo apt-get update
sudo apt-get install -y nodejs
;;
centos|rhel|almalinux|rocky)
install_packages nodejs
;;
fedora)
install_packages nodejs
;;
*)
echo -e "${RED}Unsupported OS for automatic Node.js installation: ${OS_ID}${NC}"
echo -e "${YELLOW}Please install Node.js >= ${MIN_NODE_VERSION} manually.${NC}"
exit 1
;;
esac
require_cmd node "Please install Node.js >= ${MIN_NODE_VERSION} manually and rerun the installer."
local installed_major
installed_major=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$installed_major" -lt "$MIN_NODE_VERSION" ]; then
echo -e "${RED}Installed Node.js $(node -v) is still below the required version.${NC}"
echo -e "${YELLOW}Please upgrade Node.js manually to >= ${MIN_NODE_VERSION}.${NC}"
exit 1
fi
}
download_project_if_needed() {
if [ -f "server/index.js" ]; then
return
fi
echo -e "${YELLOW}Project files not found. Starting download...${NC}"
ensure_tooling
local temp_dir
temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/promdatapanel-install-XXXXXX")
local temp_zip="${temp_dir}/promdatapanel_${VERSION}.zip"
echo -e "${BLUE}Downloading ${DOWNLOAD_URL}...${NC}"
curl -fL "$DOWNLOAD_URL" -o "$temp_zip"
echo -e "${BLUE}Extracting files...${NC}"
unzip -q "$temp_zip" -d "$temp_dir"
local extracted_dir
extracted_dir=$(find "$temp_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)
if [ -z "$extracted_dir" ] || [ ! -f "$extracted_dir/server/index.js" ]; then
echo -e "${RED}Download succeeded, but archive structure is invalid.${NC}"
exit 1
fi
cd "$extracted_dir"
}
detect_runtime_user() {
if [ "$EUID" -eq 0 ]; then
REAL_USER="${SUDO_USER:-${USER:-root}}"
else
REAL_USER="${USER}"
fi
}
write_service_file() {
local node_path
node_path=$(command -v node)
if [ -z "$node_path" ]; then
echo -e "${RED}Unable to locate node executable after installation.${NC}"
exit 1
fi
local tmp_service
tmp_service=$(mktemp "${TMPDIR:-/tmp}/${SERVICE_NAME}.service.XXXXXX")
cat > "$tmp_service" <<EOF
[Unit] [Unit]
Description=Data Visualization Display Wall Description=PromdataPanel Monitoring Dashboard
After=network.target mysql.service redis-server.service valkey-server.service After=network.target mysql.service redis-server.service valkey-server.service
Wants=mysql.service Wants=mysql.service
[Service] [Service]
Type=simple Type=simple
User=$REAL_USER User=${REAL_USER}
WorkingDirectory=$PROJECT_DIR WorkingDirectory=${PROJECT_DIR}
ExecStart=$NODE_PATH server/index.js ExecStart=${node_path} server/index.js
Restart=always Restart=always
RestartSec=10 RestartSec=10
StandardOutput=syslog StandardOutput=journal
StandardError=syslog StandardError=journal
SyslogIdentifier=promdatapanel SyslogIdentifier=${SERVICE_NAME}
# Pass environment via .env file injection EnvironmentFile=-${PROJECT_DIR}/.env
EnvironmentFile=-$PROJECT_DIR/.env
Environment=NODE_ENV=production Environment=NODE_ENV=production
# Security Hardening
CapabilityBoundingSet=
NoNewPrivileges=true
LimitNOFILE=65535
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF" EOF
# 8. Reload Systemd and Start echo -e "${BLUE}Creating systemd service at ${SERVICE_FILE}...${NC}"
echo -e "${BLUE}Reloading systemd and restarting service... (May require password)${NC}" sudo install -m 0644 "$tmp_service" "$SERVICE_FILE"
sudo systemctl daemon-reload rm -f "$tmp_service"
sudo systemctl enable promdatapanel }
sudo systemctl restart promdatapanel
# 9. Check Status detect_os
echo -e "${BLUE}Checking service status...${NC}" download_project_if_needed
sleep 2 detect_runtime_user
if sudo systemctl is-active --quiet promdatapanel; then install_node
echo -e "${GREEN}SUCCESS: Service is now running.${NC}"
PORT=$(grep "^PORT=" .env | cut -d'=' -f2) PROJECT_DIR=$(pwd)
PORT=${PORT:-3000} echo -e "Project Directory: ${GREEN}${PROJECT_DIR}${NC}"
echo -e "Dashboard URL: ${YELLOW}http://localhost:${PORT}${NC}" echo -e "Running User: ${GREEN}${REAL_USER}${NC}"
echo -e "View logs: ${BLUE}journalctl -u promdatapanel -f${NC}"
else if [ ! -f ".env" ] && [ -f ".env.example" ]; then
echo -e "${RED}FAILED: Service failed to start.${NC}" echo -e "${BLUE}Creating .env from .env.example...${NC}"
echo -e "Check logs with: ${BLUE}journalctl -u promdatapanel -xe${NC}" cp .env.example .env
fi fi
# 10. Reverse Proxy Configuration echo -e "${BLUE}Installing NPM dependencies...${NC}"
echo -ne "${YELLOW}Do you want to configure a reverse proxy (Nginx/Caddy)? (y/n): ${NC}" npm install --production
read -r CONF_PROXY
if [[ "$CONF_PROXY" =~ ^[Yy]$ ]]; then
echo -e "${BLUE}=== Reverse Proxy Configuration ===${NC}"
# Get Domain
echo -ne "Enter your domain name (e.g., monitor.example.com): "
read -r DOMAIN
if [ -z "$DOMAIN" ]; then
echo -e "${RED}Error: Domain cannot be empty. Skipping proxy configuration.${NC}"
else
# Get Port from .env
PORT=$(grep "^PORT=" .env | cut -d'=' -f2)
PORT=${PORT:-3000}
# Choose Proxy write_service_file
echo -e "Select Proxy Type:"
echo -e " 1) Caddy (Automatic SSL, easy to use)"
echo -e " 2) Nginx (Advanced, manual SSL)"
echo -ne "Choose (1/2): "
read -r PROXY_TYPE
# Enable HTTPS? echo -e "${BLUE}Reloading systemd and restarting service...${NC}"
echo -ne "Enable HTTPS (SSL)? (y/n): " sudo systemctl daemon-reload
read -r ENABLE_HTTPS sudo systemctl enable "$SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME"
if [ "$PROXY_TYPE" == "1" ]; then echo -e "${BLUE}Checking service status...${NC}"
# Caddy Config sleep 2
CADDY_FILE="Caddyfile" if sudo systemctl is-active --quiet "$SERVICE_NAME"; then
echo -e "${BLUE}Generating Caddyfile...${NC}" echo -e "${GREEN}SUCCESS: PromdataPanel is now running.${NC}"
PORT=$(grep "^PORT=" .env 2>/dev/null | cut -d'=' -f2 || true)
if [[ "$ENABLE_HTTPS" =~ ^[Yy]$ ]]; then PORT=${PORT:-3000}
cat <<EOF > "$CADDY_FILE" IP_ADDR=$(hostname -I 2>/dev/null | awk '{print $1}')
$DOMAIN { if [ -n "${IP_ADDR:-}" ]; then
reverse_proxy localhost:$PORT echo -e "Dashboard URL: ${YELLOW}http://${IP_ADDR}:${PORT}${NC}"
}
EOF
else
cat <<EOF > "$CADDY_FILE"
http://$DOMAIN {
reverse_proxy localhost:$PORT
}
EOF
fi
echo -e "${GREEN}Caddyfile generated at $PROJECT_DIR/$CADDY_FILE${NC}"
echo -e "${YELLOW}Tip: Ensure Caddy is installed and pointing to this file.${NC}"
elif [ "$PROXY_TYPE" == "2" ]; then
# Nginx Config
echo -ne "Enter Nginx configuration export path (default: ./${DOMAIN}.conf): "
read -r NGINX_PATH
NGINX_PATH=${NGINX_PATH:-"./${DOMAIN}.conf"}
echo -e "${BLUE}Generating Nginx configuration...${NC}"
if [[ "$ENABLE_HTTPS" =~ ^[Yy]$ ]]; then
echo -ne "Enter SSL Certificate Path: "
read -r SSL_CERT
echo -ne "Enter SSL Key Path: "
read -r SSL_KEY
cat <<EOF > "$NGINX_PATH"
server {
listen 80;
server_name $DOMAIN;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl http2;
server_name $DOMAIN;
ssl_certificate $SSL_CERT;
ssl_certificate_key $SSL_KEY;
location / {
proxy_pass http://localhost:$PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
else
cat <<EOF > "$NGINX_PATH"
server {
listen 80;
server_name $DOMAIN;
location / {
proxy_pass http://localhost:$PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
fi
echo -e "${GREEN}Nginx config generated at $NGINX_PATH${NC}"
echo -e "${YELLOW}Tip: You can symlink this to /etc/nginx/sites-enabled/ to activate.${NC}"
else
echo -e "${YELLOW}Unknown proxy type selected. Skipping.${NC}"
fi
fi fi
else
echo -e "${RED}FAILED: Service failed to start.${NC}"
echo -e "Check logs with: ${BLUE}journalctl -u ${SERVICE_NAME} -xe${NC}"
fi fi
echo -e "${BLUE}================================================${NC}" echo -e "${BLUE}================================================${NC}"
echo -e "${GREEN}Setup completed successfully!${NC}" echo -e "${GREEN}Installation completed!${NC}"
echo -e "${BLUE}================================================${NC}"

View File

@@ -1,5 +1,5 @@
{ {
"name": "data-visualization-display-wall", "name": "promdatapanel",
"version": "1.0.0", "version": "1.0.0",
"description": "Data Visualization Display Wall - Multi-Prometheus Monitoring Dashboard", "description": "Data Visualization Display Wall - Multi-Prometheus Monitoring Dashboard",
"main": "server/index.js", "main": "server/index.js",

View File

@@ -51,8 +51,8 @@
--radius-xl: 20px; --radius-xl: 20px;
/* Typography */ /* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; --font-mono: 'Cascadia Mono', 'Consolas', 'Liberation Mono', monospace;
} }
:root.light-theme { :root.light-theme {
@@ -130,6 +130,7 @@ body {
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden; overflow-x: hidden;
position: relative; position: relative;
} }
@@ -149,29 +150,29 @@ body {
.bg-glow { .bg-glow {
position: fixed; position: fixed;
border-radius: 50%; border-radius: 50%;
filter: blur(120px); filter: blur(60px); /* Reduced from 120px to save GPU fill rate */
opacity: 0.4; opacity: 0.3;
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
animation: glowFloat 20s ease-in-out infinite; animation: glowFloat 30s ease-in-out infinite; /* Slower = lighter */
will-change: transform, opacity; will-change: transform;
} }
.bg-glow-1 { .bg-glow-1 {
width: 600px; width: 400px;
height: 600px; height: 400px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.15), transparent 70%); background: radial-gradient(circle, rgba(99, 102, 241, 0.1), transparent 70%);
top: -200px; top: -100px;
left: -100px; left: -50px;
animation-delay: 0s; animation-delay: 0s;
} }
.bg-glow-2 { .bg-glow-2 {
width: 500px; width: 300px;
height: 500px; height: 300px;
background: radial-gradient(circle, rgba(6, 182, 212, 0.12), transparent 70%); background: radial-gradient(circle, rgba(6, 182, 212, 0.08), transparent 70%);
bottom: -150px; bottom: -100px;
right: -100px; right: -50px;
animation-delay: -7s; animation-delay: -7s;
} }
@@ -197,6 +198,7 @@ body {
position: relative; position: relative;
z-index: 1; z-index: 1;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
} }
/* ---- Header ---- */ /* ---- Header ---- */
@@ -209,27 +211,29 @@ body {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 28px; padding: 0 28px;
background: rgba(10, 14, 26, 0.85); background: rgba(10, 14, 26, 0.95);
backdrop-filter: blur(20px) saturate(180%); backdrop-filter: blur(8px); /* Reduced from 20px */
-webkit-backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: 20px;
} }
.logo { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
height: 40px;
} }
.logo-icon { .logo-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
flex-shrink: 0;
} }
.logo-text { .logo-text {
@@ -441,8 +445,8 @@ input:checked+.slider:before {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
backdrop-filter: blur(12px); backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(4px);
transition: all 0.3s ease; transition: all 0.3s ease;
overflow: hidden; overflow: hidden;
} }
@@ -609,8 +613,8 @@ input:checked+.slider:before {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
backdrop-filter: blur(12px); backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(4px);
overflow: hidden; overflow: hidden;
transition: border-color 0.3s ease, background 0.3s ease, box-shadow 0.3s ease; transition: border-color 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
} }
@@ -1451,6 +1455,9 @@ input:checked+.slider:before {
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
width: 100%;
height: 100vh;
height: 100dvh;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1471,6 +1478,7 @@ input:checked+.slider:before {
width: 90%; width: 90%;
max-width: 720px; max-width: 720px;
max-height: 80vh; max-height: 80vh;
max-height: 80dvh;
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
@@ -1551,16 +1559,19 @@ input:checked+.slider:before {
#logoIconContainer { #logoIconContainer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
width: 32px; height: 36px;
height: 32px; width: auto;
max-width: 250px;
flex-shrink: 0;
} }
.logo-icon-img { .logo-icon-img {
width: 100%;
height: 100%; height: 100%;
width: auto;
object-fit: contain; object-fit: contain;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
max-width: 100%;
} }
.modal-close { .modal-close {
@@ -1587,6 +1598,7 @@ input:checked+.slider:before {
padding: 24px; padding: 24px;
overflow-y: auto; overflow-y: auto;
max-height: calc(80vh - 80px); max-height: calc(80vh - 80px);
max-height: calc(80dvh - 80px);
} }
/* ---- Add Source Form ---- */ /* ---- Add Source Form ---- */
@@ -1861,6 +1873,11 @@ input:checked+.slider:before {
} }
/* ---- Animations ---- */ /* ---- Animations ---- */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes fadeInUp { @keyframes fadeInUp {
from { from {
opacity: 0; opacity: 0;
@@ -1940,47 +1957,694 @@ input:checked+.slider:before {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
/* -- Header -- */
.header { .header {
padding: 0 16px; padding: 0 12px;
height: 52px;
} }
.header-meta { .header-meta {
display: none; display: none;
} }
.dashboard { .logo {
padding: 16px; gap: 8px;
} }
.logo-text {
font-size: 0.92rem;
}
#logoIconContainer {
width: auto;
height: 30px;
max-width: 180px;
}
.logo-icon {
width: 26px;
height: 26px;
}
.header-right {
gap: 10px;
}
.theme-switch {
width: 44px;
height: 24px;
}
.slider:before {
height: 16px;
width: 16px;
}
input:checked+.slider:before {
transform: translateX(18px);
}
.btn-settings {
width: 34px;
height: 34px;
}
.btn-settings svg {
width: 16px;
height: 16px;
}
/* -- Dashboard -- */
.dashboard {
padding: 14px 12px 32px;
}
/* -- Stat Cards -- */
.stat-cards { .stat-cards {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 16px;
} }
.stat-card { .stat-card {
padding: 14px 16px; padding: 14px 14px;
gap: 12px;
}
.stat-card-icon {
width: 38px;
height: 38px;
}
.stat-card-icon svg {
width: 20px;
height: 20px;
} }
.stat-card-value { .stat-card-value {
font-size: 1.2rem; font-size: 1.15rem;
}
.stat-card-value-group .stat-card-value {
font-size: 1rem;
}
.stat-card-label {
font-size: 0.68rem;
}
.stat-card-sub {
font-size: 0.65rem;
}
/* -- Charts Section -- */
.charts-section {
gap: 12px;
margin-bottom: 16px;
}
.chart-card-header {
padding: 14px 14px 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
#networkChart .chart-card-header {
flex-direction: column;
align-items: flex-start;
}
.chart-title {
font-size: 0.82rem;
}
.chart-legend {
gap: 12px;
font-size: 0.72rem;
}
.chart-body {
padding: 8px 10px;
height: 220px;
} }
.chart-footer { .chart-footer {
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 12px 20px;
padding: 12px 14px;
justify-content: flex-start;
} }
.traffic-label {
font-size: 0.62rem;
}
.traffic-value {
font-size: 0.78rem;
}
/* -- Globe Card -- */
.globe-body {
height: 240px;
}
/* -- Server Table Section -- */
.chart-header-right {
gap: 8px;
flex-wrap: wrap;
}
#serverSearchFilter {
width: 160px;
padding: 6px 10px 6px 32px;
font-size: 0.8rem;
}
#serverSearchFilter:focus {
width: 200px;
}
.search-box .search-icon {
left: 10px;
width: 14px;
height: 14px;
}
.source-select {
font-size: 0.78rem;
padding: 5px 8px;
}
.server-table-wrap {
padding: 10px 8px 4px;
}
.server-table {
min-width: 640px;
}
.server-table th {
padding: 8px 10px;
font-size: 0.65rem;
}
.server-table td {
padding: 8px 10px;
font-size: 0.72rem;
}
.usage-bar-track {
width: 40px;
}
/* -- Pagination -- */
.pagination-footer {
flex-direction: column;
gap: 10px;
padding: 10px 14px;
align-items: flex-start;
}
.pagination-controls {
flex-wrap: wrap;
gap: 3px;
}
.page-btn {
padding: 3px 8px;
font-size: 0.75rem;
}
/* -- Modal -- */
.modal {
width: 96%;
max-width: none;
max-height: 90vh;
max-height: 90dvh;
border-radius: var(--radius-md);
}
.modal-header {
padding: 14px 16px;
flex-wrap: wrap;
gap: 8px;
}
.modal-tabs {
gap: 4px;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
flex: 1;
min-width: 0;
}
.modal-tabs::-webkit-scrollbar {
display: none;
}
.modal-tab {
font-size: 0.8rem;
padding: 6px 8px;
white-space: nowrap;
flex-shrink: 0;
}
.modal-tab.active::after {
bottom: -15px;
}
.modal-body {
padding: 16px 14px;
max-height: calc(90vh - 70px);
max-height: calc(90dvh - 70px);
}
/* -- Server Detail Modal -- */
#serverDetailModal .modal {
width: 98%;
max-height: 92vh;
max-height: 92dvh;
}
#serverDetailModal .modal-body {
max-height: calc(92vh - 60px);
max-height: calc(92dvh - 60px);
}
.detail-metrics-list {
padding: 6px;
}
.metric-item-header {
padding: 10px 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: nowrap; /* Force single line as requested */
}
.metric-label-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
min-width: 0;
}
.metric-label {
font-size: 0.82rem;
white-space: nowrap; /* Don't wrap */
overflow: hidden;
text-overflow: ellipsis;
}
.metric-value {
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
.chart-controls {
flex-wrap: wrap;
justify-content: flex-start;
gap: 6px;
padding: 8px 10px;
}
.time-range-group {
flex-wrap: wrap;
gap: 6px;
}
.time-range-btn {
padding: 3px 8px;
font-size: 0.7rem;
}
.custom-range-input {
width: 70px;
font-size: 0.7rem;
}
.absolute-range-selector {
flex-wrap: wrap;
gap: 4px;
padding: 4px 6px;
}
.time-input {
font-size: 0.68rem;
}
.detail-chart-wrapper {
height: 180px;
padding: 0 6px 6px;
}
.metric-item.active .metric-item-content {
max-height: 300px;
}
.detail-info-grid {
grid-template-columns: repeat(2, 1fr);
}
.info-label {
font-size: 0.68rem;
}
.info-value {
font-size: 0.85rem;
}
/* -- Partition details -- */
.partition-info {
flex-direction: column;
align-items: flex-start;
gap: 2px;
font-size: 0.75rem;
}
/* -- Forms -- */
.form-row { .form-row {
flex-direction: column; flex-direction: column;
} }
.form-actions { .form-actions {
flex-direction: row; flex-direction: row;
width: 100%;
}
.form-actions .btn {
flex: 1;
text-align: center;
}
.form-group input {
font-size: 0.82rem;
padding: 9px 12px;
}
.add-source-form h3,
.source-list h3 {
font-size: 0.78rem;
}
.btn {
padding: 9px 14px;
font-size: 0.78rem;
}
/* -- Login Button -- */
.btn-login {
padding: 6px 12px;
font-size: 0.78rem;
}
/* -- Source Items -- */
.source-item {
padding: 10px 12px;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.source-item-actions {
align-self: flex-end;
}
.source-item-name {
font-size: 0.82rem;
flex-wrap: wrap;
gap: 6px;
}
/* -- User section -- */
#userSection {
display: flex;
align-items: center;
}
#userSection .user-info {
display: flex;
align-items: center;
gap: 6px;
}
#userSection .username {
display: none;
}
#userSection .btn-logout {
padding: 5px 10px;
font-size: 0.72rem;
}
/* -- Globe expansion on mobile -- */
.globe-card.expanded {
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
height: 100dvh !important;
border-radius: 0;
}
.globe-card.expanded .globe-body {
height: calc(100vh - 110px) !important;
height: calc(100dvh - 110px) !important;
min-height: 200px;
}
/* Touch optimization */
.server-table tbody tr {
cursor: pointer;
-webkit-tap-highlight-color: rgba(99, 102, 241, 0.1);
}
.btn,
.btn-icon,
.btn-icon-sm,
.btn-settings,
.page-btn,
.modal-tab,
.time-range-btn {
touch-action: manipulation;
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.stat-cards { .stat-cards {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px;
}
.stat-card {
flex-direction: row;
padding: 12px 14px;
}
.logo-text {
font-size: 0.82rem;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chart-legend {
gap: 8px;
font-size: 0.68rem;
width: 100%;
justify-content: flex-start;
}
.chart-body {
height: 180px;
}
.globe-body {
height: 200px;
}
#serverSearchFilter {
width: 120px;
font-size: 0.75rem;
}
#serverSearchFilter:focus {
width: 160px;
}
.detail-info-grid {
grid-template-columns: 1fr 1fr;
}
.info-item {
padding: 10px;
}
/* -- Metric content on very small screens -- */
.metric-item.active .metric-item-content {
max-height: 380px;
}
.detail-chart-wrapper {
height: 160px;
}
.source-select {
display: none !important;
}
.server-table th,
.server-table td {
white-space: nowrap;
}
/* Server Detail Modal Full Screen for better fit on phone */
#serverDetailModal .modal {
width: 100% !important;
max-width: 100% !important;
height: 100vh !important;
height: 100dvh !important;
max-height: 100vh !important;
max-height: 100dvh !important;
border-radius: 0 !important;
margin: 0 !important;
top: 0 !important;
left: 0 !important;
transform: none !important;
padding-bottom: env(safe-area-inset-bottom);
}
#serverDetailModal .modal-body {
max-height: calc(100vh - 60px) !important;
max-height: calc(100dvh - 60px) !important;
}
/* Metric titles no wrap (as requested) */
.metric-item-header, .metric-label-group {
flex-wrap: nowrap !important;
}
.metric-label {
white-space: nowrap !important;
}
/* -- Modal full width -- */
.modal {
width: 100%;
max-height: 100vh;
max-height: 100dvh;
border-radius: 0;
height: 100vh;
height: 100dvh;
padding-bottom: env(safe-area-inset-bottom);
}
.modal-body {
max-height: calc(100vh - 60px);
max-height: calc(100dvh - 60px);
}
/* -- Latency route items on small mobile -- */
.latency-route-item {
flex-direction: column !important;
align-items: flex-start !important;
gap: 10px !important;
}
.route-actions {
align-self: flex-end !important;
}
/* -- Chart card header vertical layout -- */
.chart-card-header {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.chart-header-right {
width: auto;
flex: 1;
justify-content: flex-end;
}
/* -- Traffic footer compact -- */
.chart-footer {
justify-content: center;
text-align: center;
}
.traffic-stat {
align-items: center;
}
}
@media (max-width: 360px) {
.header {
padding: 0 8px;
}
.dashboard {
padding: 10px 8px 24px;
}
.stat-card-icon {
width: 32px;
height: 32px;
}
.stat-card-icon svg {
width: 18px;
height: 18px;
}
.stat-card-value {
font-size: 1rem;
}
.logo-text {
max-width: 120px;
font-size: 0.78rem;
}
.chart-card-header {
padding: 10px 10px 0;
}
.chart-footer {
padding: 10px;
gap: 8px 14px;
}
.chart-legend {
flex-wrap: wrap;
}
#serverSearchFilter {
width: 100%;
min-width: 80px;
max-width: 100px;
}
#serverSearchFilter:focus {
max-width: 130px;
}
.source-select {
max-width: 80px;
}
.detail-info-grid {
grid-template-columns: 1fr;
} }
} }
@@ -2047,4 +2711,81 @@ input:checked+.slider:before {
color: var(--accent-indigo); color: var(--accent-indigo);
background: rgba(99, 102, 241, 0.1); background: rgba(99, 102, 241, 0.1);
border-color: var(--accent-indigo); border-color: var(--accent-indigo);
} }
/* ---- Footer ---- */
.site-footer {
margin-top: 40px;
padding: 30px 28px;
border-top: 1px solid var(--border-color);
background: rgba(10, 14, 26, 0.4);
backdrop-filter: blur(10px);
position: relative;
z-index: 10;
}
:root.light-theme .site-footer {
background: rgba(255, 255, 255, 0.4);
}
.footer-content {
max-width: 1600px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.footer-content.only-copyright {
justify-content: center;
}
.copyright {
font-size: 0.88rem;
color: var(--text-muted);
font-weight: 500;
}
.filings {
display: flex;
align-items: center;
gap: 20px;
}
.filings a {
font-size: 0.82rem;
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s;
display: flex;
align-items: center;
}
.filings a:hover {
color: var(--accent-indigo);
}
@media (max-width: 768px) {
.site-footer {
padding: 24px 16px;
margin-top: 20px;
}
.footer-content {
flex-direction: column;
text-align: center;
gap: 12px;
}
.filings {
flex-direction: column;
gap: 8px;
width: 100%;
}
.filings a {
justify-content: center;
}
}

View File

@@ -5,19 +5,21 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="LDNET-GA"> <meta name="description" content="LDNET-GA">
<title>LDNET-GA</title> <title></title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="icon" id="siteFavicon" href="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
<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"> <link rel="stylesheet" href="/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> <script src="/vendor/echarts.min.js"></script>
<script> <script>
// Prevent theme flicker // Prevent theme flicker
(function () { (function () {
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
const settings = window.SITE_SETTINGS || {}; const settings = window.SITE_SETTINGS || {};
const sanitizeAssetUrl = (url) => {
if (!url || typeof url !== 'string') return null;
const trimmed = url.trim();
if (!trimmed) return null;
return /^(https?:|data:image\/|\/)/i.test(trimmed) ? trimmed : null;
};
const defaultTheme = settings.default_theme || 'dark'; const defaultTheme = settings.default_theme || 'dark';
let theme = savedTheme || defaultTheme; let theme = savedTheme || defaultTheme;
@@ -29,10 +31,53 @@
document.documentElement.classList.add('light-theme'); document.documentElement.classList.add('light-theme');
} }
// Also apply title if available to prevent flicker // Also apply title and favicon if available to prevent flicker
if (settings.page_name) { if (settings.page_name) {
document.title = settings.page_name; document.title = settings.page_name;
} }
const safeFaviconUrl = sanitizeAssetUrl(settings.favicon_url);
if (safeFaviconUrl) {
const link = document.getElementById('siteFavicon');
if (link) link.href = safeFaviconUrl;
}
// Advanced Anti-Flicker: Wait for header elements to appear
const observer = new MutationObserver(function(mutations, me) {
const logoText = document.getElementById('logoText');
const logoIcon = document.getElementById('logoIconContainer');
const header = document.getElementById('header');
if (logoText || logoIcon) {
// If we found either, apply what we have
if (logoText) {
const displayTitle = settings.title || settings.page_name || '数据可视化展示大屏';
logoText.textContent = displayTitle;
if (settings.show_page_name === 0) logoText.style.display = 'none';
}
if (logoIcon) {
const actualTheme = document.documentElement.classList.contains('light-theme') ? 'light' : 'dark';
const logoToUse = sanitizeAssetUrl((actualTheme === 'dark' && settings.logo_url_dark) ? settings.logo_url_dark : (settings.logo_url || null));
if (logoToUse) {
const img = document.createElement('img');
img.src = logoToUse;
img.alt = 'Logo';
img.className = 'logo-icon-img';
logoIcon.replaceChildren(img);
} else {
// Only if we REALLY have no logo URL, we show the default SVG fallback
// (But since it's already in HTML, we just don't touch it or we show it if we hid it)
const svg = logoIcon.querySelector('svg');
if (svg) svg.style.visibility = 'visible';
}
}
// Once found everything or we are past header, we are done
if (logoText && logoIcon) me.disconnect();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
})(); })();
</script> </script>
</head> </head>
@@ -51,7 +96,7 @@
<div class="header-left"> <div class="header-left">
<div class="logo"> <div class="logo">
<div id="logoIconContainer"> <div id="logoIconContainer">
<svg class="logo-icon" id="logoSvg" viewBox="0 0 32 32" fill="none"> <svg class="logo-icon" id="logoSvg" viewBox="0 0 32 32" fill="none" style="visibility: hidden;">
<rect x="2" y="2" width="28" height="28" rx="8" stroke="url(#logoGrad)" stroke-width="2.5" /> <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" <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" /> stroke-linecap="round" stroke-linejoin="round" fill="none" />
@@ -65,7 +110,7 @@
</defs> </defs>
</svg> </svg>
</div> </div>
<h1 class="logo-text" id="logoText">数据可视化展示大屏</h1> <h1 class="logo-text" id="logoText"></h1>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@@ -203,6 +248,13 @@
</svg> </svg>
网络流量趋势 (24h) 网络流量趋势 (24h)
</h2> </h2>
<div class="chart-header-actions">
<button class="btn-icon" id="btnRefreshNetwork" title="刷新流量趋势">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
</div>
</div> </div>
<div class="chart-legend"> <div class="chart-legend">
<span class="legend-item" id="legendRx" style="cursor: pointer;" title="点击切换 接收 (RX) 显示/隐藏"><span <span class="legend-item" id="legendRx" style="cursor: pointer;" title="点击切换 接收 (RX) 显示/隐藏"><span
@@ -250,7 +302,7 @@
<path <path
d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /> d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg> </svg>
全球服务器分布 全球骨干分布
</h2> </h2>
<div class="chart-header-actions"> <div class="chart-header-actions">
<button class="btn-icon" id="btnExpandGlobe" title="放大显示"> <button class="btn-icon" id="btnExpandGlobe" title="放大显示">
@@ -354,6 +406,20 @@
</section> </section>
</main> </main>
<!-- Footer -->
<footer class="site-footer">
<div class="footer-content">
<div class="copyright">© <span id="copyrightYear"></span> LDNET-GA-Service. All rights reserved.</div>
<div class="filings">
<a href="http://www.beian.gov.cn/portal/registerSystemInfo" target="_blank" id="psFilingDisplay" style="display: none;">
<img src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b1220'/%3E%3Cpath d='M32 10l18 8v12c0 11.6-7.2 21.9-18 26-10.8-4.1-18-14.4-18-26V18l18-8z' fill='%2310b981'/%3E%3Cpath d='M32 18l10 4.6v7.1c0 7-4.1 13.4-10 16.1-5.9-2.7-10-9.1-10-16.1v-7.1L32 18z' fill='%23ecfdf5'/%3E%3Cpath d='M28 31.5l3 3 6-6' fill='none' stroke='%2310b981' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" alt="公安备案" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 2px;">
<span id="psFilingText"></span>
</a>
<a href="https://beian.miit.gov.cn/" target="_blank" id="icpFilingDisplay" style="display: none;"></a>
</div>
</div>
</footer>
<!-- Settings Modal --> <!-- Settings Modal -->
<div class="modal-overlay" id="settingsModal"> <div class="modal-overlay" id="settingsModal">
<div class="modal"> <div class="modal">
@@ -434,16 +500,46 @@
<input type="text" id="siteTitleInput" placeholder="例:数据可视化展示大屏"> <input type="text" id="siteTitleInput" placeholder="例:数据可视化展示大屏">
</div> </div>
<div class="form-group" style="margin-top: 15px;"> <div class="form-group" style="margin-top: 15px;">
<label for="logoUrlInput">Logo URL (图片链接,为空则显示默认图标)</label> <label for="showPageNameInput">是否显示左上角标题</label>
<input type="url" id="logoUrlInput" placeholder="https://example.com/logo.png"> <select id="showPageNameInput"
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
<option value="1">显示 (Show)</option>
<option value="0">隐藏 (Hide)</option>
</select>
</div> </div>
<div class="form-group" style="margin-top: 15px;"> <div class="form-group" style="margin-top: 15px;">
<label for="defaultThemeInput">默认主题</label> <label for="requireLoginForServerDetailsInput">服务器详情是否仅登录后可查看</label>
<select id="defaultThemeInput" <select id="requireLoginForServerDetailsInput"
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary);"> style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
<option value="dark">默认夜间模式</option> <option value="1">仅登录后可查看</option>
<option value="light">默认白天模式</option> <option value="0">允许公开查看</option>
</select> </select>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">开启后,未登录访客仍可看到大屏总览,但点击单台服务器时需要先登录。</p>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="logoUrlInput">Logo URL (白天/默认,支持图片链接)</label>
<input type="url" id="logoUrlInput" placeholder="https://example.com/logo_light.png">
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="logoUrlDarkInput">Logo URL (黑夜模式,可为空则使用默认)</label>
<input type="url" id="logoUrlDarkInput" placeholder="https://example.com/logo_dark.png">
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="faviconUrlInput">Favicon URL (浏览器标签页图标)</label>
<input type="url" id="faviconUrlInput" placeholder="https://example.com/favicon.ico">
</div>
<div class="settings-section" style="margin-top: 25px; border-top: 1px solid var(--border-color); padding-top: 20px;">
<h4 style="font-size: 0.85rem; color: var(--accent-indigo); margin-bottom: 15px; text-transform: uppercase; letter-spacing: 0.5px;">界面外观 (Appearance)</h4>
<div class="form-group">
<label for="defaultThemeInput">色彩主题模式</label>
<select id="defaultThemeInput"
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); width: 100%;">
<option value="auto">跟随系统主题 (Sync with OS)</option>
<option value="dark">强制深色模式 (Always Dark)</option>
<option value="light">强制浅色模式 (Always Light)</option>
</select>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 6px;">选择“跟随系统”后,应用将自动同步您操作系统或浏览器的黑暗/白天模式设置。</p>
</div>
</div> </div>
<div class="form-group" style="margin-top: 15px;"> <div class="form-group" style="margin-top: 15px;">
<label for="show95BandwidthInput">24h趋势图默认显示 95计费线</label> <label for="show95BandwidthInput">24h趋势图默认显示 95计费线</label>
@@ -460,8 +556,17 @@
<option value="tx">仅统计上行 (TX)</option> <option value="tx">仅统计上行 (TX)</option>
<option value="rx">仅统计下行 (RX)</option> <option value="rx">仅统计下行 (RX)</option>
<option value="both">统计上行+下行 (Sum)</option> <option value="both">统计上行+下行 (Sum)</option>
<option value="max">出入取大 (Max)</option>
</select> </select>
</div> </div>
<div class="form-group" style="margin-top: 15px;">
<label for="psFilingInput">公安备案号 (如:京公网安备 11010102000001号)</label>
<input type="text" id="psFilingInput" placeholder="请输入公安备案号">
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="icpFilingInput">ICP 备案号 (如京ICP备12345678号)</label>
<input type="text" id="icpFilingInput" placeholder="请输入 ICP 备案号">
</div>
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;"> <div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end;">
<button class="btn btn-add" id="btnSaveSiteSettings">保存基础设置</button> <button class="btn btn-add" id="btnSaveSiteSettings">保存基础设置</button>
</div> </div>
@@ -479,7 +584,7 @@
style="background: rgba(255,255,255,0.02); padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border-color);"> style="background: rgba(255,255,255,0.02); padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border-color);">
<div class="form-row"> <div class="form-row">
<div class="form-group" style="flex: 1.5;"> <div class="form-group" style="flex: 1.5;">
<label>数据源 (Blackbox)</label> <label>探测用服务器</label>
<select id="routeSourceSelect" <select id="routeSourceSelect"
style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary);"> style="padding: 10px 14px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary);">
<option value="">-- 选择数据源 --</option> <option value="">-- 选择数据源 --</option>
@@ -640,4 +745,4 @@
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -4,9 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统初始化 - 数据可视化展示大屏</title> <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"> <link rel="stylesheet" href="/css/style.css">
<style> <style>
body { body {
@@ -70,6 +67,33 @@
justify-content: center; justify-content: center;
padding: 10px 0; padding: 10px 0;
} }
@media (max-width: 480px) {
body {
align-items: flex-start;
padding: 16px 12px;
}
.init-container {
padding: 24px 18px;
border-radius: 10px;
max-width: 100%;
}
.init-header h2 {
font-size: 18px;
}
.init-header p {
font-size: 12px;
}
.form-row {
flex-direction: column;
}
.actions {
flex-direction: column;
}
.actions .btn {
width: 100%;
}
}
</style> </style>
</head> </head>
<body> <body>

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ class AreaChart {
this.prevMaxVal = 0; this.prevMaxVal = 0;
this.currentMaxVal = 0; this.currentMaxVal = 0;
this.lastDataHash = ''; // Fingerprint for optimization
// Use debounced resize for performance and safety // Use debounced resize for performance and safety
this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this); this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this);
@@ -40,6 +41,14 @@ class AreaChart {
setData(data) { setData(data) {
if (!data || !data.timestamps) return; if (!data || !data.timestamps) return;
// 1. Data Fingerprinting: Skip redundant updates to save GPU/CPU
const fingerprint = data.timestamps.length + '_' +
(data.rx.length > 0 ? data.rx[data.rx.length - 1] : 0) + '_' +
(data.tx.length > 0 ? data.tx[data.tx.length - 1] : 0);
if (fingerprint === this.lastDataHash) return;
this.lastDataHash = fingerprint;
// Store old data for smooth transition before updating this.data // Store old data for smooth transition before updating this.data
// Only clone if there is data to clone; otherwise use empty set // Only clone if there is data to clone; otherwise use empty set
if (this.data && this.data.timestamps && this.data.timestamps.length > 0) { if (this.data && this.data.timestamps && this.data.timestamps.length > 0) {
@@ -55,8 +64,8 @@ class AreaChart {
// Smoothly transition max value context too // Smoothly transition max value context too
this.prevMaxVal = this.currentMaxVal || 0; this.prevMaxVal = this.currentMaxVal || 0;
// Downsample if data is too dense (target ~1500 points for performance) // Downsample if data is too dense (target ~500 points for GPU performance)
const MAX_POINTS = 1500; const MAX_POINTS = 500;
if (data.timestamps.length > MAX_POINTS) { if (data.timestamps.length > MAX_POINTS) {
const skip = Math.ceil(data.timestamps.length / MAX_POINTS); const skip = Math.ceil(data.timestamps.length / MAX_POINTS);
const downsampled = { timestamps: [], rx: [], tx: [] }; const downsampled = { timestamps: [], rx: [], tx: [] };
@@ -84,6 +93,8 @@ class AreaChart {
combined = data.tx.map(t => t || 0); combined = data.tx.map(t => t || 0);
} else if (this.p95Type === 'rx') { } else if (this.p95Type === 'rx') {
combined = data.rx.map(r => r || 0); combined = data.rx.map(r => r || 0);
} else if (this.p95Type === 'max') {
combined = data.tx.map((t, i) => Math.max(t || 0, data.rx[i] || 0));
} else { } else {
combined = data.tx.map((t, i) => (t || 0) + (data.rx[i] || 0)); combined = data.tx.map((t, i) => (t || 0) + (data.rx[i] || 0));
} }
@@ -103,7 +114,7 @@ class AreaChart {
animate() { animate() {
if (this.animFrame) cancelAnimationFrame(this.animFrame); if (this.animFrame) cancelAnimationFrame(this.animFrame);
const start = performance.now(); const start = performance.now();
const duration = 800; const duration = 400; // Shorter animation = less GPU time
const step = (now) => { const step = (now) => {
const elapsed = now - start; const elapsed = now - start;
@@ -268,7 +279,7 @@ class AreaChart {
drawArea(ctx, values, prevValues, getX, getY, chartH, p, fillColorTop, fillColorBottom, strokeColor, len) { drawArea(ctx, values, prevValues, getX, getY, chartH, p, fillColorTop, fillColorBottom, strokeColor, len) {
if (!values || values.length === 0) return; if (!values || values.length === 0) return;
const useSimple = len > 250; const useSimple = len > 80;
const getPVal = (i) => (prevValues && i < prevValues.length) ? prevValues[i] : 0; const getPVal = (i) => (prevValues && i < prevValues.length) ? prevValues[i] : 0;
// Fill // Fill
@@ -330,11 +341,12 @@ class MetricChart {
this.data = { timestamps: [], values: [], series: null }; this.data = { timestamps: [], values: [], series: null };
this.unit = unit; // '%', 'B/s', etc. this.unit = unit; // '%', 'B/s', etc.
this.dpr = window.devicePixelRatio || 1; this.dpr = window.devicePixelRatio || 1;
this.padding = { top: 10, right: 10, bottom: 20, left: 60 }; this.padding = { top: 10, right: 10, bottom: 35, left: 60 };
this.animProgress = 0; this.animProgress = 0;
this.prevMaxVal = 0; this.prevMaxVal = 0;
this.currentMaxVal = 0; this.currentMaxVal = 0;
this.lastDataHash = ''; // Fingerprint for optimization
// Use debounced resize for performance and safety // Use debounced resize for performance and safety
this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this); this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this);
@@ -358,6 +370,15 @@ class MetricChart {
} }
setData(data) { setData(data) {
if (!data || !data.timestamps) return;
// 1. Simple fingerprinting to avoid constant re-animation of same data
const lastVal = data.values && data.values.length > 0 ? data.values[data.values.length - 1] : 0;
const fingerprint = data.timestamps.length + '_' + lastVal + '_' + (data.series ? 's' : 'v');
if (fingerprint === this.lastDataHash) return;
this.lastDataHash = fingerprint;
if (this.data && this.data.values && this.data.values.length > 0) { if (this.data && this.data.values && this.data.values.length > 0) {
this.prevData = JSON.parse(JSON.stringify(this.data)); this.prevData = JSON.parse(JSON.stringify(this.data));
} else { } else {
@@ -388,7 +409,7 @@ class MetricChart {
animate() { animate() {
if (this.animFrame) cancelAnimationFrame(this.animFrame); if (this.animFrame) cancelAnimationFrame(this.animFrame);
const start = performance.now(); const start = performance.now();
const duration = 500; const duration = 300; // Snappier and lighter on GPU
const step = (now) => { const step = (now) => {
const elapsed = now - start; const elapsed = now - start;
this.animProgress = Math.min(elapsed / duration, 1); this.animProgress = Math.min(elapsed / duration, 1);
@@ -456,12 +477,30 @@ class MetricChart {
} else { } else {
label = v.toFixed(0) + this.unit; label = v.toFixed(0) + this.unit;
} }
} else if (this.unit === '%' && this.totalValue) {
// 当提供了总量时,将百分比转换为实际数值显示(例如内存显示 2GB 而非 25%
const absVal = v * (this.totalValue / 100);
label = window.formatBytes ? window.formatBytes(absVal) : absVal.toFixed(0);
} else { } else {
label = (v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(v < 10 && v > 0 ? 1 : 0)) + this.unit; label = (v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(v < 10 && v > 0 ? 1 : 0)) + this.unit;
} }
ctx.fillText(label, p.left - 8, y + 3); ctx.fillText(label, p.left - 8, y + 3);
} }
// X-axis Timeline
ctx.fillStyle = '#5a6380';
ctx.font = '9px "JetBrains Mono", monospace';
ctx.textAlign = 'center';
const labelInterval = Math.max(1, Math.floor(len / 5));
for (let i = 0; i < len; i += labelInterval) {
const x = getX(i);
ctx.fillText(formatTime(timestamps[i]), x, h - 8);
}
// Always show last label if not already shown
if ((len - 1) % labelInterval !== 0) {
ctx.fillText(formatTime(timestamps[len - 1]), getX(len - 1), h - 8);
}
if (series) { if (series) {
// Draw Stacked Area // Draw Stacked Area
const modes = [ const modes = [
@@ -527,7 +566,7 @@ class MetricChart {
}); });
} else { } else {
const useSimple = len > 250; const useSimple = len > 100;
const prevVals = this.prevData ? this.prevData.values : null; const prevVals = this.prevData ? this.prevData.values : null;
const getPVal = (i) => (prevVals && i < prevVals.length) ? prevVals[i] : 0; const getPVal = (i) => (prevVals && i < prevVals.length) ? prevVals[i] : 0;

45
public/vendor/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
public/vendor/world.json vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -57,38 +57,43 @@ async function checkAndFixDatabase() {
// Check for new columns in site_settings // Check for new columns in site_settings
const [columns] = await db.query("SHOW COLUMNS FROM site_settings"); const [columns] = await db.query("SHOW COLUMNS FROM site_settings");
const columnNames = columns.map(c => c.Field); const columnNames = columns.map(c => c.Field);
if (!columnNames.includes('show_95_bandwidth')) { const addColumn = async (columnName, sql) => {
console.log(`[Database Integrity] ⚠️ Missing column 'show_95_bandwidth' in 'site_settings'. Adding it...`); if (!columnNames.includes(columnName)) {
await db.query("ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme"); try {
console.log(`[Database Integrity] ✅ Column 'show_95_bandwidth' added.`); console.log(`[Database Integrity] ⚠️ Missing column '${columnName}' in 'site_settings'. Adding it...`);
} await db.query(sql);
if (!columnNames.includes('p95_type')) { console.log(`[Database Integrity] ✅ Column '${columnName}' added.`);
console.log(`[Database Integrity] ⚠️ Missing column 'p95_type' in 'site_settings'. Adding it...`); } catch (err) {
await db.query("ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth"); console.error(`[Database Integrity] ❌ Failed to add column '${columnName}':`, err.message);
console.log(`[Database Integrity] ✅ Column 'p95_type' added.`); // Try without AFTER if it exists
} if (sql.includes('AFTER')) {
if (!columnNames.includes('blackbox_source_id')) { try {
console.log(`[Database Integrity] ⚠️ Missing column 'blackbox_source_id' in 'site_settings'. Adding it...`); const fallback = sql.split(' AFTER')[0];
await db.query("ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER p95_type"); console.log(`[Database Integrity] 🔄 Retrying column '${columnName}' WITHOUT 'AFTER'...`);
console.log(`[Database Integrity] ✅ Column 'blackbox_source_id' added.`); await db.query(fallback);
} console.log(`[Database Integrity] ✅ Column '${columnName}' added via fallback.`);
if (!columnNames.includes('latency_source')) { } catch (err2) {
console.log(`[Database Integrity] ⚠️ Missing column 'latency_source' in 'site_settings'. Adding it...`); console.error(`[Database Integrity] ❌ Fallback also failed:`, err2.message);
await db.query("ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id"); }
console.log(`[Database Integrity] ✅ Column 'latency_source' added.`); }
} }
if (!columnNames.includes('latency_dest')) { }
console.log(`[Database Integrity] ⚠️ Missing column 'latency_dest' in 'site_settings'. Adding it...`); };
await db.query("ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source");
console.log(`[Database Integrity] ✅ Column 'latency_dest' added.`); await addColumn('show_page_name', "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name");
} await addColumn('show_95_bandwidth', "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme");
if (!columnNames.includes('latency_target')) { await addColumn('p95_type', "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth");
console.log(`[Database Integrity] ⚠️ Missing column 'latency_target' in 'site_settings'. Adding it...`); await addColumn('require_login_for_server_details', "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type");
await db.query("ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest"); await addColumn('blackbox_source_id', "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER p95_type");
console.log(`[Database Integrity] ✅ Column 'latency_target' added.`); await addColumn('latency_source', "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id");
} await addColumn('latency_dest', "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source");
await addColumn('latency_target', "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest");
await addColumn('icp_filing', "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target");
await addColumn('ps_filing', "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing");
await addColumn('logo_url_dark', "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url");
await addColumn('favicon_url', "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark");
} catch (err) { } catch (err) {
console.error('[Database Integrity] ❌ Error checking integrity:', err.message); console.error('[Database Integrity] ❌ Overall site_settings check error:', err.message);
} }
} }
@@ -123,15 +128,21 @@ async function createTable(tableName) {
CREATE TABLE IF NOT EXISTS site_settings ( CREATE TABLE IF NOT EXISTS site_settings (
id INT PRIMARY KEY DEFAULT 1, id INT PRIMARY KEY DEFAULT 1,
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏', page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
show_page_name TINYINT(1) DEFAULT 1,
title VARCHAR(255) DEFAULT '数据可视化展示大屏', title VARCHAR(255) DEFAULT '数据可视化展示大屏',
logo_url TEXT, logo_url TEXT,
logo_url_dark TEXT,
favicon_url TEXT,
default_theme VARCHAR(20) DEFAULT 'dark', default_theme VARCHAR(20) DEFAULT 'dark',
show_95_bandwidth TINYINT(1) DEFAULT 0, show_95_bandwidth TINYINT(1) DEFAULT 0,
p95_type VARCHAR(20) DEFAULT 'tx', p95_type VARCHAR(20) DEFAULT 'tx',
require_login_for_server_details TINYINT(1) DEFAULT 1,
blackbox_source_id INT, blackbox_source_id INT,
latency_source VARCHAR(100), latency_source VARCHAR(100),
latency_dest VARCHAR(100), latency_dest VARCHAR(100),
latency_target VARCHAR(255), latency_target VARCHAR(255),
icp_filing VARCHAR(255),
ps_filing VARCHAR(255),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);

238
server/db-schema-check.js Normal file
View File

@@ -0,0 +1,238 @@
/**
* Database schema check
* Ensures required tables and columns exist at startup.
*/
require('dotenv').config();
const db = require('./db');
const IS_SERVERLESS = [
process.env.SERVERLESS,
process.env.VERCEL,
process.env.AWS_LAMBDA_FUNCTION_NAME,
process.env.NETLIFY,
process.env.FUNCTION_TARGET,
process.env.K_SERVICE
].some(Boolean);
const SCHEMA = {
users: {
createSql: `
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
salt VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
columns: []
},
prometheus_sources: {
createSql: `
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,
is_server_source TINYINT(1) DEFAULT 1,
type VARCHAR(50) DEFAULT 'prometheus',
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
`,
columns: [
{
name: 'is_server_source',
sql: "ALTER TABLE prometheus_sources ADD COLUMN is_server_source TINYINT(1) DEFAULT 1 AFTER description"
},
{
name: 'type',
sql: "ALTER TABLE prometheus_sources ADD COLUMN type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source"
}
]
},
site_settings: {
createSql: `
CREATE TABLE IF NOT EXISTS site_settings (
id INT PRIMARY KEY DEFAULT 1,
page_name VARCHAR(255) DEFAULT 'Data Visualization Display Wall',
show_page_name TINYINT(1) DEFAULT 1,
title VARCHAR(255) DEFAULT 'Data Visualization Display Wall',
logo_url TEXT,
logo_url_dark TEXT,
favicon_url TEXT,
default_theme VARCHAR(20) DEFAULT 'dark',
show_95_bandwidth TINYINT(1) DEFAULT 0,
p95_type VARCHAR(20) DEFAULT 'tx',
require_login_for_server_details TINYINT(1) DEFAULT 1,
blackbox_source_id INT,
latency_source VARCHAR(100),
latency_dest VARCHAR(100),
latency_target VARCHAR(255),
icp_filing VARCHAR(255),
ps_filing VARCHAR(255),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
seedSql: `
INSERT IGNORE INTO site_settings (
id, page_name, show_page_name, title, default_theme, show_95_bandwidth, p95_type, require_login_for_server_details
) VALUES (
1, 'Data Visualization Display Wall', 1, 'Data Visualization Display Wall', 'dark', 0, 'tx', 1
)
`,
columns: [
{
name: 'show_page_name',
sql: "ALTER TABLE site_settings ADD COLUMN show_page_name TINYINT(1) DEFAULT 1 AFTER page_name"
},
{
name: 'logo_url_dark',
sql: "ALTER TABLE site_settings ADD COLUMN logo_url_dark TEXT AFTER logo_url"
},
{
name: 'favicon_url',
sql: "ALTER TABLE site_settings ADD COLUMN favicon_url TEXT AFTER logo_url_dark"
},
{
name: 'show_95_bandwidth',
sql: "ALTER TABLE site_settings ADD COLUMN show_95_bandwidth TINYINT(1) DEFAULT 0 AFTER default_theme"
},
{
name: 'p95_type',
sql: "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth"
},
{
name: 'require_login_for_server_details',
sql: "ALTER TABLE site_settings ADD COLUMN require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type"
},
{
name: 'blackbox_source_id',
sql: "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER require_login_for_server_details"
},
{
name: 'latency_source',
sql: "ALTER TABLE site_settings ADD COLUMN latency_source VARCHAR(100) AFTER blackbox_source_id"
},
{
name: 'latency_dest',
sql: "ALTER TABLE site_settings ADD COLUMN latency_dest VARCHAR(100) AFTER latency_source"
},
{
name: 'latency_target',
sql: "ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest"
},
{
name: 'icp_filing',
sql: "ALTER TABLE site_settings ADD COLUMN icp_filing VARCHAR(255) AFTER latency_target"
},
{
name: 'ps_filing',
sql: "ALTER TABLE site_settings ADD COLUMN ps_filing VARCHAR(255) AFTER icp_filing"
}
]
},
traffic_stats: {
createSql: `
CREATE TABLE IF NOT EXISTS traffic_stats (
id INT AUTO_INCREMENT PRIMARY KEY,
rx_bytes BIGINT UNSIGNED DEFAULT 0,
tx_bytes BIGINT UNSIGNED DEFAULT 0,
rx_bandwidth DOUBLE DEFAULT 0,
tx_bandwidth DOUBLE DEFAULT 0,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
columns: []
},
server_locations: {
createSql: `
CREATE TABLE IF NOT EXISTS server_locations (
id INT AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(255) NOT NULL UNIQUE,
country CHAR(2),
country_name VARCHAR(100),
region VARCHAR(100),
city VARCHAR(100),
latitude DOUBLE,
longitude DOUBLE,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
columns: []
},
latency_routes: {
createSql: `
CREATE TABLE IF NOT EXISTS latency_routes (
id INT AUTO_INCREMENT PRIMARY KEY,
source_id INT NOT NULL,
latency_source VARCHAR(100) NOT NULL,
latency_dest VARCHAR(100) NOT NULL,
latency_target VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
columns: []
}
};
async function addColumnIfMissing(tableName, existingColumns, column) {
if (existingColumns.has(column.name)) {
return;
}
try {
console.log(`[Database Integrity] Missing column '${column.name}' in '${tableName}'. Adding it...`);
await db.query(column.sql);
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}'.`);
} catch (err) {
console.error(`[Database Integrity] Failed to add '${tableName}.${column.name}':`, err.message);
if (column.sql.includes(' AFTER ')) {
try {
const fallbackSql = column.sql.split(' AFTER ')[0];
await db.query(fallbackSql);
console.log(`[Database Integrity] Column '${column.name}' added to '${tableName}' via fallback.`);
} catch (fallbackErr) {
console.error(`[Database Integrity] Fallback failed for '${tableName}.${column.name}':`, fallbackErr.message);
}
}
}
}
async function ensureTable(tableName, tableSchema) {
await db.query(tableSchema.createSql);
const [columns] = await db.query(`SHOW COLUMNS FROM \`${tableName}\``);
const existingColumns = new Set(columns.map((column) => column.Field));
for (const column of tableSchema.columns || []) {
await addColumnIfMissing(tableName, existingColumns, column);
}
if (tableSchema.seedSql) {
await db.query(tableSchema.seedSql);
}
}
async function checkAndFixDatabase() {
const autoSchemaSync = process.env.DB_AUTO_SCHEMA_SYNC
? process.env.DB_AUTO_SCHEMA_SYNC === 'true'
: !IS_SERVERLESS;
const hasDbConfig = Boolean(process.env.MYSQL_HOST && process.env.MYSQL_USER && process.env.MYSQL_DATABASE);
if (!hasDbConfig || !autoSchemaSync) {
return;
}
try {
for (const [tableName, tableSchema] of Object.entries(SCHEMA)) {
await ensureTable(tableName, tableSchema);
}
} catch (err) {
console.error('[Database Integrity] Startup schema check failed:', err.message);
}
}
module.exports = checkAndFixDatabase;

View File

@@ -1,37 +1,70 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
let pool; let pool;
const IS_SERVERLESS = [
process.env.SERVERLESS,
process.env.VERCEL,
process.env.AWS_LAMBDA_FUNCTION_NAME,
process.env.NETLIFY,
process.env.FUNCTION_TARGET,
process.env.K_SERVICE
].some(Boolean);
function initPool() { function getConnectionLimit() {
if (pool) { const parsed = parseInt(process.env.MYSQL_CONNECTION_LIMIT, 10);
pool.end().catch(e => console.error('Error closing pool:', e)); if (!Number.isNaN(parsed) && parsed > 0) {
return parsed;
} }
pool = mysql.createPool({ return IS_SERVERLESS ? 2 : 10;
}
function createPool() {
return mysql.createPool({
host: process.env.MYSQL_HOST || 'localhost', host: process.env.MYSQL_HOST || 'localhost',
port: parseInt(process.env.MYSQL_PORT) || 3306, port: parseInt(process.env.MYSQL_PORT, 10) || 3306,
user: process.env.MYSQL_USER || 'root', user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '', password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'display_wall', database: process.env.MYSQL_DATABASE || 'display_wall',
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: getConnectionLimit(),
queueLimit: 0 queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0
}); });
} }
function getPool() {
if (!pool) {
pool = createPool();
}
return pool;
}
function initPool({ force = false } = {}) {
if (pool && !force) {
return pool;
}
if (pool) {
pool.end().catch(e => console.error('Error closing pool:', e));
}
pool = createPool();
return pool;
}
async function checkHealth() { async function checkHealth() {
try { try {
if (!pool) return { status: 'down', error: 'Database pool not initialized' }; await getPool().query('SELECT 1');
await pool.query('SELECT 1');
return { status: 'up' }; return { status: 'up' };
} catch (err) { } catch (err) {
return { status: 'down', error: err.message }; return { status: 'down', error: err.message };
} }
} }
initPool();
module.exports = { module.exports = {
query: (...args) => pool.query(...args), query: (...args) => getPool().query(...args),
getPool,
initPool, initPool,
checkHealth checkHealth
}; };

View File

@@ -10,6 +10,7 @@ const db = require('./db');
*/ */
const ipInfoToken = process.env.IPINFO_TOKEN; const ipInfoToken = process.env.IPINFO_TOKEN;
const enableExternalGeoLookup = process.env.ENABLE_EXTERNAL_GEO_LOOKUP === 'true';
/** /**
* Normalizes geo data for consistent display * Normalizes geo data for consistent display
@@ -74,6 +75,10 @@ async function getLocation(target) {
} }
// 4. Resolve via ipinfo.io (LAST RESORT) // 4. Resolve via ipinfo.io (LAST RESORT)
if (!enableExternalGeoLookup) {
return null;
}
try { try {
console.log(`[Geo Service] API lookup (ipinfo.io) for: ${cleanIp}`); console.log(`[Geo Service] API lookup (ipinfo.io) for: ${cleanIp}`);
const url = `https://ipinfo.io/${cleanIp}/json${ipInfoToken ? `?token=${ipInfoToken}` : ''}`; const url = `https://ipinfo.io/${cleanIp}/json${ipInfoToken ? `?token=${ipInfoToken}` : ''}`;

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,18 @@ async function initDatabase() {
title VARCHAR(255) DEFAULT '数据可视化展示大屏', title VARCHAR(255) DEFAULT '数据可视化展示大屏',
logo_url TEXT, logo_url TEXT,
default_theme VARCHAR(20) DEFAULT 'dark', default_theme VARCHAR(20) DEFAULT 'dark',
show_page_name TINYINT(1) DEFAULT 1,
logo_url_dark TEXT,
favicon_url TEXT,
show_95_bandwidth TINYINT(1) DEFAULT 0,
p95_type VARCHAR(20) DEFAULT 'tx',
require_login_for_server_details TINYINT(1) DEFAULT 1,
blackbox_source_id INT,
latency_source VARCHAR(100),
latency_dest VARCHAR(100),
latency_target VARCHAR(255),
icp_filing VARCHAR(255),
ps_filing VARCHAR(255),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);

View File

@@ -4,6 +4,115 @@ const db = require('./db');
const POLL_INTERVAL = 10000; // 10 seconds const POLL_INTERVAL = 10000; // 10 seconds
async function resolveBlackboxLatency(route) {
// Blackbox exporter probe URL
// We assume ICMP module for now. If target is a URL, maybe use http_2xx
let module = 'icmp';
const target = route.latency_target;
if (target.startsWith('http://') || target.startsWith('https://')) {
module = 'http_2xx';
}
const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`;
const response = await axios.get(probeUrl, {
timeout: 5000,
responseType: 'text',
validateStatus: false
});
if (typeof response.data !== 'string') {
throw new Error('Response data is not a string');
}
const lines = response.data.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
// 1. Check if the probe was successful
let isProbeSuccess = false;
for (const line of lines) {
if (/^probe_success(\{.*\})?\s+1/.test(line)) {
isProbeSuccess = true;
break;
}
}
// 2. Extract latency from priority metrics
const targetMetrics = [
'probe_icmp_duration_seconds',
'probe_http_duration_seconds',
'probe_duration_seconds'
];
let foundLatency = null;
for (const metricName of targetMetrics) {
let bestLine = null;
// First pass: look for phase="rtt" which is the most accurate "ping"
for (const line of lines) {
if (line.startsWith(metricName) && line.includes('phase="rtt"')) {
bestLine = line;
break;
}
}
// Second pass: if no rtt phase, look for a line without phases (legacy format) or just the first line
if (!bestLine) {
for (const line of lines) {
if (line.startsWith(metricName)) {
// Prefer lines without {} if possible, otherwise take the first one
if (!line.includes('{')) {
bestLine = line;
break;
}
if (!bestLine) bestLine = line;
}
}
}
if (bestLine) {
// Regex to capture the number, including scientific notation
const regex = new RegExp(`^${metricName}(?:\\{[^}]*\\})?\\s+([\\d.eE+-]+)`);
const match = bestLine.match(regex);
if (match) {
const val = parseFloat(match[1]);
if (!isNaN(val)) {
foundLatency = val * 1000; // convert to ms
break;
}
}
}
}
// 3. Final decision
// If it's a success, use found latency. If success=0 or missing, handle carefully.
if (isProbeSuccess && foundLatency !== null) {
return foundLatency;
}
// If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error)
return null;
}
async function resolveLatencyForRoute(route) {
try {
if (route.source_type === 'blackbox' || route.type === 'blackbox') {
const latency = await resolveBlackboxLatency(route);
if (route.id !== undefined) {
await cache.set(`latency:route:${route.id}`, latency, 60);
}
return latency;
}
return null;
} catch (err) {
if (route.id !== undefined) {
await cache.set(`latency:route:${route.id}`, null, 60);
}
return null;
}
}
async function pollLatency() { async function pollLatency() {
try { try {
const [routes] = await db.query(` const [routes] = await db.query(`
@@ -18,99 +127,7 @@ async function pollLatency() {
// Poll each route // Poll each route
await Promise.allSettled(routes.map(async (route) => { await Promise.allSettled(routes.map(async (route) => {
try { try {
// Blackbox exporter probe URL await resolveLatencyForRoute({ ...route, source_type: 'blackbox' });
// We assume ICMP module for now. If target is a URL, maybe use http_2xx
let module = 'icmp';
let target = route.latency_target;
if (target.startsWith('http://') || target.startsWith('https://')) {
module = 'http_2xx';
}
const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`;
const startTime = Date.now();
const response = await axios.get(probeUrl, {
timeout: 5000,
responseType: 'text',
validateStatus: false
});
if (typeof response.data !== 'string') {
throw new Error('Response data is not a string');
}
const lines = response.data.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
// 1. Check if the probe was successful
let isProbeSuccess = false;
for (const line of lines) {
if (/^probe_success(\{.*\})?\s+1/.test(line)) {
isProbeSuccess = true;
break;
}
}
// 2. Extract latency from priority metrics
const targetMetrics = [
'probe_icmp_duration_seconds',
'probe_http_duration_seconds',
'probe_duration_seconds'
];
let foundLatency = null;
for (const metricName of targetMetrics) {
let bestLine = null;
// First pass: look for phase="rtt" which is the most accurate "ping"
for (const line of lines) {
if (line.startsWith(metricName) && line.includes('phase="rtt"')) {
bestLine = line;
break;
}
}
// Second pass: if no rtt phase, look for a line without phases (legacy format) or just the first line
if (!bestLine) {
for (const line of lines) {
if (line.startsWith(metricName)) {
// Prefer lines without {} if possible, otherwise take the first one
if (!line.includes('{')) {
bestLine = line;
break;
}
if (!bestLine) bestLine = line;
}
}
}
if (bestLine) {
// Regex to capture the number, including scientific notation
const regex = new RegExp(`^${metricName}(?:\\{[^}]*\\})?\\s+([\\d.eE+-]+)`);
const match = bestLine.match(regex);
if (match) {
const val = parseFloat(match[1]);
if (!isNaN(val)) {
foundLatency = val * 1000; // convert to ms
break;
}
}
}
}
// 3. Final decision
// If it's a success, use found latency. If success=0 or missing, handle carefully.
let latency;
if (isProbeSuccess && foundLatency !== null) {
latency = foundLatency;
} else {
// If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error)
latency = null;
}
// Save to Valkey
await cache.set(`latency:route:${route.id}`, latency, 60);
} catch (err) { } catch (err) {
await cache.set(`latency:route:${route.id}`, null, 60); await cache.set(`latency:route:${route.id}`, null, 60);
} }
@@ -130,5 +147,7 @@ function start() {
} }
module.exports = { module.exports = {
pollLatency,
resolveLatencyForRoute,
start start
}; };

View File

@@ -7,10 +7,10 @@ const QUERY_TIMEOUT = 10000;
// Reusable agents to handle potential redirect issues and protocol mismatches // Reusable agents to handle potential redirect issues and protocol mismatches
const crypto = require('crypto'); const crypto = require('crypto');
const httpAgent = new http.Agent({ keepAlive: true }); const httpAgent = new http.Agent({ keepAlive: true });
const httpsAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: false }); const httpsAgent = new https.Agent({ keepAlive: true });
const serverIdMap = new Map(); // token -> { instance, job, source } const serverIdMap = new Map(); // token -> { instance, job, source }
const SECRET = process.env.APP_SECRET || 'prom-data-panel-stable-secret-key-123'; const SECRET = process.env.APP_SECRET || crypto.randomBytes(32).toString('hex');
function getServerToken(instance, job, source) { function getServerToken(instance, job, source) {
const hash = crypto.createHmac('sha256', SECRET) const hash = crypto.createHmac('sha256', SECRET)
@@ -549,14 +549,8 @@ async function getServerDetails(baseUrl, instance, job) {
// Queries based on the requested dashboard structure // Queries based on the requested dashboard structure
const queries = { const queries = {
// Split CPU
cpuSystem: `avg(rate(node_cpu_seconds_total{mode="system", instance="${node}"}[1m])) * 100`,
cpuUser: `avg(rate(node_cpu_seconds_total{mode="user", instance="${node}"}[1m])) * 100`,
cpuIowait: `avg(rate(node_cpu_seconds_total{mode="iowait", instance="${node}"}[1m])) * 100`, cpuIowait: `avg(rate(node_cpu_seconds_total{mode="iowait", instance="${node}"}[1m])) * 100`,
cpuIrq: `avg(rate(node_cpu_seconds_total{mode=~"irq|softirq", instance="${node}"}[1m])) * 100`,
cpuOther: `avg(rate(node_cpu_seconds_total{mode=~"nice|steal|guest|guest_nice", instance="${node}"}[1m])) * 100`, cpuOther: `avg(rate(node_cpu_seconds_total{mode=~"nice|steal|guest|guest_nice", instance="${node}"}[1m])) * 100`,
cpuIdle: `avg(rate(node_cpu_seconds_total{mode="idle", instance="${node}"}[1m])) * 100`,
cpuBusy: `100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle", instance="${node}"}[1m])))`, cpuBusy: `100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle", instance="${node}"}[1m])))`,
sysLoad: `node_load1{instance="${node}",job="${job}"} * 100 / count(count(node_cpu_seconds_total{instance="${node}",job="${job}"}) by (cpu))`, sysLoad: `node_load1{instance="${node}",job="${job}"} * 100 / count(count(node_cpu_seconds_total{instance="${node}",job="${job}"}) by (cpu))`,
memUsedPct: `(1 - (node_memory_MemAvailable_bytes{instance="${node}", job="${job}"} / node_memory_MemTotal_bytes{instance="${node}", job="${job}"})) * 100`, memUsedPct: `(1 - (node_memory_MemAvailable_bytes{instance="${node}", job="${job}"} / node_memory_MemTotal_bytes{instance="${node}", job="${job}"})) * 100`,
@@ -564,6 +558,8 @@ async function getServerDetails(baseUrl, instance, job) {
rootFsUsedPct: `100 - ((node_filesystem_avail_bytes{instance="${node}",job="${job}",mountpoint="/",fstype!~"rootfs|tmpfs"} * 100) / node_filesystem_size_bytes{instance="${node}",job="${job}",mountpoint="/",fstype!~"rootfs|tmpfs"})`, rootFsUsedPct: `100 - ((node_filesystem_avail_bytes{instance="${node}",job="${job}",mountpoint="/",fstype!~"rootfs|tmpfs"} * 100) / node_filesystem_size_bytes{instance="${node}",job="${job}",mountpoint="/",fstype!~"rootfs|tmpfs"})`,
cpuCores: `count(count(node_cpu_seconds_total{instance="${node}",job="${job}"}) by (cpu))`, cpuCores: `count(count(node_cpu_seconds_total{instance="${node}",job="${job}"}) by (cpu))`,
memTotal: `node_memory_MemTotal_bytes{instance="${node}",job="${job}"}`, memTotal: `node_memory_MemTotal_bytes{instance="${node}",job="${job}"}`,
swapTotal: `node_memory_SwapTotal_bytes{instance="${node}",job="${job}"}`,
rootFsTotal: `node_filesystem_size_bytes{instance="${node}",job="${job}",mountpoint="/",fstype!~"rootfs|tmpfs"}`,
uptime: `node_time_seconds{instance="${node}",job="${job}"} - node_boot_time_seconds{instance="${node}",job="${job}"}`, uptime: `node_time_seconds{instance="${node}",job="${job}"} - node_boot_time_seconds{instance="${node}",job="${job}"}`,
netRx: `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`, netRx: `sum(rate(node_network_receive_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`, netTx: `sum(rate(node_network_transmit_bytes_total{instance="${node}",job="${job}",device!~'tap.*|veth.*|br.*|docker.*|virbr*|podman.*|lo.*|vmbr.*|fwbr.|ip.*|gre.*|virbr.*|vnet.*'}[1m]))`,
@@ -632,46 +628,22 @@ async function getServerDetails(baseUrl, instance, job) {
/** /**
* Get historical metrics for a specific server (node) * Get historical metrics for a specific server (node)
*/ */
async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null) { async function getServerHistory(baseUrl, instance, job, metric, range = '1h', start = null, end = null, p95Type = 'tx') {
const url = normalizeUrl(baseUrl); const url = normalizeUrl(baseUrl);
const node = resolveToken(instance); const node = resolveToken(instance);
// Custom multi-metric handler for CPU Busy // CPU Busy history: 100 - idle
if (metric === 'cpuBusy') { if (metric === 'cpuBusy') {
const modes = { const expr = `100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle", instance="${node}"}[1m])))`;
system: 'system',
user: 'user',
iowait: 'iowait',
irq: 'irq|softirq',
other: 'nice|steal|guest|guest_nice',
idle: 'idle'
};
const rangeObj = parseRange(range, start, end); const rangeObj = parseRange(range, start, end);
const timestamps = []; const result = await queryRange(url, expr, rangeObj.queryStart, rangeObj.queryEnd, rangeObj.step);
const series = {};
Object.keys(modes).forEach(m => series[m] = []);
const results = await Promise.all(Object.entries(modes).map(async ([name, mode]) => {
const expr = `avg(rate(node_cpu_seconds_total{mode=~"${mode}", instance="${node}"}[1m])) * 100`;
const res = await queryRange(url, expr, rangeObj.queryStart, rangeObj.queryEnd, rangeObj.step);
return { name, values: res.length > 0 ? res[0].values : [] };
}));
if (results[0].values.length === 0) return { timestamps: [], series: {} };
// Use first result for timestamps
results[0].values.forEach(v => timestamps.push(v[0] * 1000));
results.forEach(r => { if (!result || result.length === 0) return { timestamps: [], values: [] };
r.values.forEach(v => series[r.name].push(parseFloat(v[1])));
}); return {
timestamps: result[0].values.map(v => v[0] * 1000),
// Pre-calculate busy percentage: 100 - idle values: result[0].values.map(v => parseFloat(v[1]))
const idleValues = series.idle || []; };
const busyValues = idleValues.map(idleVal => Math.max(0, 100 - idleVal));
return { timestamps, series, values: busyValues };
} }
// Map metric keys to Prometheus expressions // Map metric keys to Prometheus expressions
@@ -711,9 +683,22 @@ async function getServerHistory(baseUrl, instance, job, metric, range = '1h', st
txTotal += (tx[i] || 0) * duration; txTotal += (tx[i] || 0) * duration;
} }
const sortedTx = [...tx].sort((a, b) => a - b); // Calculate P95 based on p95Type
const p95Idx = Math.floor(sortedTx.length * 0.95); let combined = [];
const p95 = sortedTx.length > 0 ? sortedTx[p95Idx] : 0; if (p95Type === 'rx') {
combined = [...rx];
} else if (p95Type === 'both') {
combined = tx.map((t, i) => (t || 0) + (rx[i] || 0));
} else if (p95Type === 'max') {
combined = tx.map((t, i) => Math.max(t || 0, rx[i] || 0));
} else {
// Default to tx
combined = [...tx];
}
const sorted = combined.sort((a, b) => a - b);
const p95Idx = Math.floor(sorted.length * 0.95);
const p95 = sorted.length > 0 ? sorted[p95Idx] : 0;
return { return {
timestamps, timestamps,

187
update.sh Normal file
View File

@@ -0,0 +1,187 @@
#!/bin/bash
set -euo pipefail
SERVICE_NAME="promdatapanel"
DEFAULT_APP_DIR="/opt/promdata-panel"
ZIP_URL="https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/archive/main.zip"
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
APP_DIR=""
TEMP_DIR=""
BACKUP_DIR=""
ROLLBACK_REQUIRED=false
echo -e "${BLUE}=== Starting PromdataPanel Update ===${NC}"
cleanup() {
if [ -n "${TEMP_DIR}" ] && [ -d "${TEMP_DIR}" ]; then
rm -rf "${TEMP_DIR}"
fi
}
rollback() {
if [ "$ROLLBACK_REQUIRED" != true ] || [ -z "${BACKUP_DIR}" ] || [ ! -d "${BACKUP_DIR}" ]; then
return
fi
echo -e "${YELLOW}Update failed. Restoring previous application state...${NC}"
rsync -a --delete --exclude '.env' "${BACKUP_DIR}/" "${APP_DIR}/"
}
trap 'rollback' ERR
trap cleanup EXIT
validate_app_dir() {
local dir="$1"
[ -n "$dir" ] || return 1
[ -d "$dir" ] || return 1
[ -f "$dir/package.json" ] || return 1
[ -f "$dir/server/index.js" ] || return 1
[ -f "$dir/public/index.html" ] || return 1
return 0
}
detect_app_dir() {
local service_dir=""
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q "^${SERVICE_NAME}\.service"; then
echo "Detecting application directory from systemd service..."
service_dir=$(systemctl show -p WorkingDirectory "$SERVICE_NAME" | cut -d= -f2-)
if validate_app_dir "$service_dir"; then
APP_DIR="$service_dir"
return
fi
fi
local current_dir
current_dir=$(pwd)
if validate_app_dir "$current_dir"; then
APP_DIR="$current_dir"
return
fi
if validate_app_dir "$DEFAULT_APP_DIR"; then
APP_DIR="$DEFAULT_APP_DIR"
return
fi
echo -e "${RED}Error: Could not locate a valid PromdataPanel application directory.${NC}"
echo -e "${YELLOW}Expected markers: package.json, server/index.js, public/index.html${NC}"
exit 1
}
ensure_tool() {
local cmd="$1"
if command -v "$cmd" >/dev/null 2>&1; then
return
fi
echo -e "${BLUE}${cmd} is not installed. Attempting to install it...${NC}"
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y "$cmd"
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y "$cmd"
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y "$cmd"
elif command -v apk >/dev/null 2>&1; then
sudo apk add "$cmd"
else
echo -e "${RED}Error: '${cmd}' is not installed and could not be auto-installed.${NC}"
exit 1
fi
}
update_from_git() {
echo -e "${BLUE}Git repository detected. Pulling latest code...${NC}"
if [ -n "$(git status --porcelain)" ]; then
echo -e "${RED}Error: Working tree has local changes. Commit or stash them before updating.${NC}"
exit 1
fi
git pull --ff-only
}
update_from_zip() {
echo -e "${BLUE}No git repository found. Updating via ZIP archive with staging and rollback...${NC}"
ensure_tool curl
ensure_tool unzip
ensure_tool rsync
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/promdatapanel-update-XXXXXX")
BACKUP_DIR="${TEMP_DIR}/backup"
local archive_path="${TEMP_DIR}/latest.zip"
local extracted_folder=""
local staging_dir=""
echo "Downloading latest version (main branch)..."
curl -fL "$ZIP_URL" -o "$archive_path"
echo "Extracting archive..."
unzip -q "$archive_path" -d "$TEMP_DIR"
extracted_folder=$(find "$TEMP_DIR" -mindepth 1 -maxdepth 1 -type d ! -name backup | head -n 1)
if ! validate_app_dir "$extracted_folder"; then
echo -e "${RED}Extraction failed or archive structure is invalid.${NC}"
exit 1
fi
staging_dir="${TEMP_DIR}/staging"
mkdir -p "$staging_dir"
rsync -a --exclude '.git' "$extracted_folder/" "$staging_dir/"
if [ -f "${APP_DIR}/.env" ]; then
cp "${APP_DIR}/.env" "${staging_dir}/.env"
fi
echo "Installing dependencies in staging directory..."
(
cd "$staging_dir"
npm install --production
)
echo "Creating rollback backup..."
rsync -a --delete --exclude '.env' "${APP_DIR}/" "${BACKUP_DIR}/"
echo "Applying staged update..."
ROLLBACK_REQUIRED=true
rsync -a --delete --exclude '.env' "${staging_dir}/" "${APP_DIR}/"
}
restart_service() {
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$SERVICE_NAME"; then
echo -e "${BLUE}Restarting systemd service: ${SERVICE_NAME}...${NC}"
sudo systemctl restart "$SERVICE_NAME"
return
fi
if command -v pm2 >/dev/null 2>&1 && pm2 list | grep -q "$SERVICE_NAME"; then
echo -e "${BLUE}Restarting with PM2...${NC}"
pm2 restart "$SERVICE_NAME"
return
fi
echo -e "${YELLOW}Warning: Could not detect an active systemd service or PM2 process named '${SERVICE_NAME}'.${NC}"
echo -e "${YELLOW}Please restart the application manually.${NC}"
}
detect_app_dir
echo -e "${BLUE}Application directory: ${APP_DIR}${NC}"
cd "$APP_DIR"
if [ -d ".git" ]; then
update_from_git
echo -e "${BLUE}Updating npm dependencies...${NC}"
npm install --production
else
update_from_zip
fi
restart_service
ROLLBACK_REQUIRED=false
echo -e "${GREEN}=== Update successfully finished ===${NC}"

43
vercel.json Normal file
View File

@@ -0,0 +1,43 @@
{
"version": 2,
"functions": {
"api/index.js": {
"runtime": "@vercel/node",
"includeFiles": "public/**"
}
},
"routes": [
{
"src": "/api/(.*)",
"dest": "/api/index.js"
},
{
"src": "/health",
"dest": "/api/index.js"
},
{
"src": "/init.html",
"dest": "/api/index.js"
},
{
"src": "/css/(.*)",
"dest": "/public/css/$1"
},
{
"src": "/js/(.*)",
"dest": "/public/js/$1"
},
{
"src": "/vendor/(.*)",
"dest": "/public/vendor/$1"
},
{
"src": "/(.*\\.(?:ico|png|jpg|jpeg|svg|webp|json|txt|xml))",
"dest": "/public/$1"
},
{
"src": "/(.*)",
"dest": "/api/index.js"
}
]
}