Compare commits
12 Commits
v0.1.0
...
PromdataPa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd0a52368a | ||
|
|
650cc6f1b5 | ||
|
|
ff1c53ea40 | ||
|
|
b79655ccdc | ||
|
|
1f12197a91 | ||
|
|
d0455fb032 | ||
|
|
b2f6f7d2d0 | ||
|
|
ff439bb831 | ||
|
|
3980d66b49 | ||
| b16d910051 | |||
|
|
7e3a8e12d0 | ||
|
|
1d728d991e |
17
.env.example
17
.env.example
@@ -1,10 +1,9 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=3051
|
||||
REFRESH_INTERVAL=5000
|
||||
# PromdataPanel Environment Configuration
|
||||
# Note: Database and Cache settings will be automatically configured upon visiting /init.html
|
||||
|
||||
# Valkey/Redis Cache Configuration
|
||||
VALKEY_HOST=localhost
|
||||
VALKEY_PORT=6379
|
||||
VALKEY_PASSWORD=
|
||||
VALKEY_DB=dashboard
|
||||
VALKEY_TTL=30
|
||||
# Server Binding
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
|
||||
# Aggregation interval in milliseconds (default 5s)
|
||||
REFRESH_INTERVAL=5000
|
||||
|
||||
116
README.md
116
README.md
@@ -1,100 +1,70 @@
|
||||
# 数据可视化展示大屏
|
||||
# PromdataPanel
|
||||
|
||||
多源 Prometheus 服务器监控展示大屏,支持对接多个 Prometheus 实例,实时展示所有服务器的 CPU、内存、磁盘、网络等关键指标。
|
||||
多源 Prometheus 服务器监控展示大屏。支持对接多个 Prometheus 实例,实时聚合展示所有服务器的 CPU、内存、磁盘、带宽等关键指标,并提供可视化节点分布图。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🔌 **多数据源管理** - MySQL 存储配置,支持对接多个 Prometheus 实例
|
||||
- 📊 **NodeExporter 数据查询** - 自动聚合所有 Prometheus 中的 NodeExporter 数据
|
||||
- 🌐 **网络流量统计** - 24 小时网络流量趋势图,总流量统计
|
||||
- ⚡ **实时带宽监控** - 所有服务器网络带宽求和,实时显示
|
||||
- 💻 **资源使用概览** - CPU、内存、磁盘的总使用率和详细统计
|
||||
- 🖥️ **服务器列表** - 所有服务器的详细指标一览表
|
||||
- 🔌 **多数据源管理** - 支持对接多个 Prometheus 实例(Node_Exporter / BlackboxExporter)
|
||||
- 📊 **指标自动聚合** - 自动汇总所有数据源的 NodeExporter 指标,实时计算全网负载
|
||||
- 🌐 **网络流量统计** - 24 小时流量趋势图,实时带宽(Rx/Tx)求和显示
|
||||
- 🗺️ **节点分布可视化** - 自动识别服务器地理位置,并在全球地图上展示实时连接状态与延迟
|
||||
- ⚡ **毫秒级实时性** - 深度优化查询逻辑,支持 5s 采集频率的实时动态展示
|
||||
- 📱 **响应式与美学设计** - 现代 UI/UX 体验,支持暗色模式,极致性能优化
|
||||
|
||||
## 快速开始
|
||||
## 快速安装
|
||||
|
||||
### 1. 环境要求
|
||||
### 方式一:一键脚本安装 (推荐)
|
||||
|
||||
- Node.js >= 16
|
||||
- MySQL >= 5.7
|
||||
- Valkey >= 7.0 (或 Redis >= 6.0)
|
||||
|
||||
### 2. 配置
|
||||
|
||||
复制环境变量文件并修改:
|
||||
在 Linux 服务器上,您可以使用以下脚本一键完成下载、环境检测、依赖安装并将其注册为 Systemd 系统服务:
|
||||
|
||||
```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
|
||||
# MySQL 配置
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=your_password
|
||||
MYSQL_DATABASE=display_wall
|
||||
#### 1. 环境要求
|
||||
- **Node.js** >= 18
|
||||
- **MySQL** >= 8.0
|
||||
- **Valkey** >= 7.0 (或 Redis >= 6.0)
|
||||
|
||||
# Valkey/Redis 缓存配置 (可选)
|
||||
VALKEY_HOST=localhost
|
||||
VALKEY_PORT=6379
|
||||
VALKEY_PASSWORD=
|
||||
VALKEY_TTL=30
|
||||
#### 2. 配置与启动
|
||||
1. 克隆代码库:`git clone https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel.git`
|
||||
2. 复制配置文件:`cp .env.example .env`
|
||||
3. 安装依赖:`npm install --production`
|
||||
4. 启动服务:`npm start`
|
||||
|
||||
PORT=3000
|
||||
```
|
||||
#### 3. 系统初始化
|
||||
首次运行后,访问 `http://your-ip:3000/init.html`,按照引导完成 MySQL 数据库和 Valkey 缓存的连接。
|
||||
|
||||
### 3. 系统初始化
|
||||
## 使用指引
|
||||
|
||||
访问 `http://localhost:3000/init.html`,按照引导完成数据库和缓存的初始化。
|
||||
### 1. 添加 Prometheus 数据源
|
||||
点击页面右上角的 ⚙️ 按钮进入设置,添加并测试您的 Prometheus HTTP 地址。
|
||||
|
||||
### 4. 安装依赖并启动
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 `http://localhost:3000` 即可看到展示大屏。
|
||||
|
||||
### 5. 配置 Prometheus 数据源
|
||||
|
||||
点击右上角的 ⚙️ 按钮,添加你的 Prometheus 地址(如 `http://prometheus.example.com:9090`)。
|
||||
|
||||
### 6. Prometheus 配置参考 (Example)
|
||||
|
||||
在您的 Prometheus 配置文件 `prometheus.yml` 中,建议执行以下配置(`scrape_interval` 建议设为 `5s` 以获取最佳实时展示效果):
|
||||
### 2. Prometheus 采集配置
|
||||
建议在 `prometheus.yml` 中设置采集周期为 `5s` 以实现平滑的实时动态效果:
|
||||
|
||||
```yaml
|
||||
global:
|
||||
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
|
||||
- **数据库**: MySQL (存储配置数据)
|
||||
- **缓存**: Valkey / Redis (用于加速流量计算结果读取)
|
||||
- **数据源**: Prometheus HTTP API
|
||||
- **前端**: 原生 HTML/CSS/JavaScript
|
||||
- **图表**: 自定义 Canvas 渲染
|
||||
- **Runtime**: Node.js
|
||||
- **Framework**: Express.js
|
||||
- **Database**: MySQL 8.0+
|
||||
- **Caching**: Valkey / Redis
|
||||
- **Visualization**: ECharts / Canvas
|
||||
- **Frontend**: Vanilla JS / CSS3
|
||||
|
||||
## API 接口
|
||||
## LICENSE
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/sources` | 获取所有数据源 |
|
||||
| POST | `/api/sources` | 添加数据源 |
|
||||
| PUT | `/api/sources/:id` | 更新数据源 |
|
||||
| DELETE | `/api/sources/:id` | 删除数据源 |
|
||||
| POST | `/api/sources/test` | 测试数据源连接 |
|
||||
| GET | `/api/metrics/overview` | 获取聚合指标概览 |
|
||||
| GET | `/api/metrics/network-history` | 获取24h网络流量历史 |
|
||||
| GET | `/api/metrics/cpu-history` | 获取CPU使用率历史 |
|
||||
MIT License
|
||||
|
||||
289
install.sh
289
install.sh
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Data Visualization Display Wall - Systemd Installer
|
||||
# Requirements: Node.js, NPM, Systemd (Linux)
|
||||
# PromdataPanel - Multi-Prometheus Monitoring Dashboard Installer
|
||||
# This script handles OS detection, Node.js installation, project setup, and Systemd configuration.
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
@@ -10,69 +10,148 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}=== Data Visualization Display Wall Installer ===${NC}"
|
||||
# 0. Configuration
|
||||
VERSION=${VERSION:-"v0.1.0"}
|
||||
DOWNLOAD_URL="https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/archive/${VERSION}.zip"
|
||||
MIN_NODE_VERSION=18
|
||||
|
||||
# 1. Permission check (no longer mandatory)
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo -e "${BLUE} PromdataPanel Auto-Installer ${NC}"
|
||||
echo -e "${BLUE} Version: ${VERSION} ${NC}"
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
|
||||
# 1. OS Detection
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /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
|
||||
echo -e "Detected OS: ${GREEN}${OS_ID} ${OS_VER}${NC}"
|
||||
}
|
||||
|
||||
# 2. Node.js Installation/Verification
|
||||
install_node() {
|
||||
echo -e "${BLUE}Verifying Node.js environment...${NC}"
|
||||
|
||||
NODE_INSTALLED=false
|
||||
if command -v node &> /dev/null; then
|
||||
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 v$(node -v) is already installed.${NC}"
|
||||
NODE_INSTALLED=true
|
||||
else
|
||||
echo -e "${YELLOW}Existing Node.js version (v$(node -v)) is too old (Requires >= $MIN_NODE_VERSION).${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$NODE_INSTALLED" = false ]; then
|
||||
echo -e "${BLUE}Installing Node.js 20.x...${NC}"
|
||||
case "$OS_ID" in
|
||||
ubuntu|debian|raspbian)
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ca-certificates curl gnupg
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
;;
|
||||
centos|rhel|almalinux|rocky)
|
||||
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
|
||||
sudo yum install -y nodejs
|
||||
;;
|
||||
fedora)
|
||||
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
|
||||
sudo dnf install -y nodejs
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS for automatic Node.js installation: $OS_ID${NC}"
|
||||
echo -e "Please install Node.js >= 18 manually.${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# 3. Download and Extract (If needed)
|
||||
if [ ! -f "server/index.js" ]; then
|
||||
echo -e "${YELLOW}Project files not found. Starting download...${NC}"
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
echo -e "${BLUE}Installing curl...${NC}"
|
||||
[ "$OS_ID" = "ubuntu" ] || [ "$OS_ID" = "debian" ] && sudo apt-get install -y curl
|
||||
[ "$OS_ID" = "centos" ] || [ "$OS_ID" = "rhel" ] && sudo yum install -y curl
|
||||
fi
|
||||
|
||||
if ! command -v unzip &> /dev/null; then
|
||||
echo -e "${BLUE}Installing unzip...${NC}"
|
||||
[ "$OS_ID" = "ubuntu" ] || [ "$OS_ID" = "debian" ] && sudo apt-get install -y unzip
|
||||
[ "$OS_ID" = "centos" ] || [ "$OS_ID" = "rhel" ] && sudo yum install -y unzip
|
||||
fi
|
||||
|
||||
TEMP_ZIP="promdatapanel_${VERSION}.zip"
|
||||
echo -e "${BLUE}Downloading ${DOWNLOAD_URL}...${NC}"
|
||||
curl -L "$DOWNLOAD_URL" -o "$TEMP_ZIP"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}Download failed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Extracting files...${NC}"
|
||||
unzip -q "$TEMP_ZIP"
|
||||
|
||||
EXTRACTED_DIR=$(ls -d */ | grep -E "^PromdataPanel" | head -n 1)
|
||||
if [ -d "$EXTRACTED_DIR" ]; then
|
||||
cd "$EXTRACTED_DIR" || exit 1
|
||||
else
|
||||
EXTRACTED_DIR=$(ls -d */ | head -n 1)
|
||||
[ -d "$EXTRACTED_DIR" ] && cd "$EXTRACTED_DIR" || exit 1
|
||||
fi
|
||||
rm "../$TEMP_ZIP" 2>/dev/null || rm "$TEMP_ZIP" 2>/dev/null
|
||||
fi
|
||||
|
||||
# 4. Initialize Setup
|
||||
# Permission check
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
# If run as sudo, get the real user that called it
|
||||
REAL_USER=${SUDO_USER:-$USER}
|
||||
else
|
||||
REAL_USER=$USER
|
||||
fi
|
||||
|
||||
# 2. Get current directory and user
|
||||
PROJECT_DIR=$(pwd)
|
||||
USER_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
|
||||
detect_os
|
||||
install_node
|
||||
|
||||
PROJECT_DIR=$(pwd)
|
||||
echo -e "Project Directory: ${GREEN}$PROJECT_DIR${NC}"
|
||||
echo -e "Running User: ${GREEN}$REAL_USER${NC}"
|
||||
|
||||
# 3. Check for mandatory files
|
||||
if [ ! -f "server/index.js" ]; then
|
||||
echo -e "${RED}Error: server/index.js not found. Please run this script from the project root.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Check for dependencies
|
||||
echo -e "${BLUE}Checking dependencies...${NC}"
|
||||
check_dep() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
echo -e "${RED}$1 is not installed. Please install $1 first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
check_dep node
|
||||
check_dep npm
|
||||
|
||||
# 5. Check for .env file
|
||||
# Check for .env file
|
||||
if [ ! -f ".env" ]; then
|
||||
echo -e "${YELLOW}Warning: .env file not found.${NC}"
|
||||
if [ -f ".env.example" ]; then
|
||||
echo -e "Creating .env from .env.example..."
|
||||
echo -e "${BLUE}Creating .env from .env.example...${NC}"
|
||||
cp .env.example .env
|
||||
echo -e "${GREEN}Created .env file. Please ensure values are correct.${NC}"
|
||||
else
|
||||
echo -e "${RED}Error: .env.example not found. Configuration missing.${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 6. Install NPM dependencies
|
||||
echo -e "${BLUE}Installing dependencies...${NC}"
|
||||
npm install
|
||||
# 5. Install Dependencies
|
||||
echo -e "${BLUE}Installing NPM dependencies...${NC}"
|
||||
npm install --production
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}NPM install failed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 7. Create Systemd Service File
|
||||
# 6. Create Systemd Service File
|
||||
SERVICE_FILE="/etc/systemd/system/promdatapanel.service"
|
||||
NODE_PATH=$(command -v node)
|
||||
|
||||
echo -e "${BLUE}Creating systemd service at $SERVICE_FILE... (May require password)${NC}"
|
||||
echo -e "${BLUE}Creating systemd service at $SERVICE_FILE...${NC}"
|
||||
sudo bash -c "cat <<EOF > '$SERVICE_FILE'
|
||||
[Unit]
|
||||
Description=Data Visualization Display Wall
|
||||
Description=PromdataPanel Monitoring Dashboard
|
||||
After=network.target mysql.service redis-server.service valkey-server.service
|
||||
Wants=mysql.service
|
||||
|
||||
@@ -86,153 +165,33 @@ RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=promdatapanel
|
||||
# Pass environment via .env file injection
|
||||
EnvironmentFile=-$PROJECT_DIR/.env
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
# Security Hardening
|
||||
CapabilityBoundingSet=
|
||||
NoNewPrivileges=true
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF"
|
||||
|
||||
# 8. Reload Systemd and Start
|
||||
echo -e "${BLUE}Reloading systemd and restarting service... (May require password)${NC}"
|
||||
# 7. Reload and Start
|
||||
echo -e "${BLUE}Reloading systemd and restarting service...${NC}"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable promdatapanel
|
||||
sudo systemctl restart promdatapanel
|
||||
|
||||
# 9. Check Status
|
||||
# 8. Check Status
|
||||
echo -e "${BLUE}Checking service status...${NC}"
|
||||
sleep 2
|
||||
if sudo systemctl is-active --quiet promdatapanel; then
|
||||
echo -e "${GREEN}SUCCESS: Service is now running.${NC}"
|
||||
echo -e "${GREEN}SUCCESS: PromdataPanel is now running.${NC}"
|
||||
PORT=$(grep "^PORT=" .env | cut -d'=' -f2)
|
||||
PORT=${PORT:-3000}
|
||||
echo -e "Dashboard URL: ${YELLOW}http://localhost:${PORT}${NC}"
|
||||
echo -e "View logs: ${BLUE}journalctl -u promdatapanel -f${NC}"
|
||||
IP_ADDR=$(hostname -I | awk '{print $1}')
|
||||
echo -e "Dashboard URL: ${YELLOW}http://${IP_ADDR}:${PORT}${NC}"
|
||||
else
|
||||
echo -e "${RED}FAILED: Service failed to start.${NC}"
|
||||
echo -e "Check logs with: ${BLUE}journalctl -u promdatapanel -xe${NC}"
|
||||
fi
|
||||
|
||||
# 10. Reverse Proxy Configuration
|
||||
echo -ne "${YELLOW}Do you want to configure a reverse proxy (Nginx/Caddy)? (y/n): ${NC}"
|
||||
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
|
||||
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 -ne "Enable HTTPS (SSL)? (y/n): "
|
||||
read -r ENABLE_HTTPS
|
||||
|
||||
if [ "$PROXY_TYPE" == "1" ]; then
|
||||
# Caddy Config
|
||||
CADDY_FILE="Caddyfile"
|
||||
echo -e "${BLUE}Generating Caddyfile...${NC}"
|
||||
|
||||
if [[ "$ENABLE_HTTPS" =~ ^[Yy]$ ]]; then
|
||||
cat <<EOF > "$CADDY_FILE"
|
||||
$DOMAIN {
|
||||
reverse_proxy localhost:$PORT
|
||||
}
|
||||
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
|
||||
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo -e "${GREEN}Setup completed successfully!${NC}"
|
||||
echo -e "${GREEN}Installation completed!${NC}"
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "data-visualization-display-wall",
|
||||
"name": "promdatapanel",
|
||||
"version": "1.0.0",
|
||||
"description": "Data Visualization Display Wall - Multi-Prometheus Monitoring Dashboard",
|
||||
"main": "server/index.js",
|
||||
|
||||
@@ -1940,47 +1940,674 @@ input:checked+.slider:before {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* -- Header -- */
|
||||
.header {
|
||||
padding: 0 16px;
|
||||
padding: 0 12px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding: 16px;
|
||||
.logo {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#logoIconContainer {
|
||||
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 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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 {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
/* -- Server Detail Modal -- */
|
||||
#serverDetailModal .modal {
|
||||
width: 98%;
|
||||
max-height: 92vh;
|
||||
}
|
||||
|
||||
#serverDetailModal .modal-body {
|
||||
max-height: calc(92vh - 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 {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
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;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.globe-card.expanded .globe-body {
|
||||
height: calc(100vh - 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) {
|
||||
.stat-cards {
|
||||
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;
|
||||
max-height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
#serverDetailModal .modal-body {
|
||||
max-height: calc(100vh - 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;
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
max-height: calc(100vh - 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -437,13 +437,18 @@
|
||||
<label for="logoUrlInput">Logo URL (图片链接,为空则显示默认图标)</label>
|
||||
<input type="url" id="logoUrlInput" placeholder="https://example.com/logo.png">
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 15px;">
|
||||
<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);">
|
||||
<option value="dark">默认夜间模式</option>
|
||||
<option value="light">默认白天模式</option>
|
||||
</select>
|
||||
<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 class="form-group" style="margin-top: 15px;">
|
||||
<label for="show95BandwidthInput">24h趋势图默认显示 95计费线</label>
|
||||
@@ -479,7 +484,7 @@
|
||||
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-group" style="flex: 1.5;">
|
||||
<label>数据源 (Blackbox)</label>
|
||||
<label>探测用服务器</label>
|
||||
<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);">
|
||||
<option value="">-- 选择数据源 --</option>
|
||||
|
||||
@@ -70,6 +70,33 @@
|
||||
justify-content: center;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -183,6 +183,17 @@
|
||||
|
||||
// Auth & Theme listeners
|
||||
dom.themeToggle.addEventListener('change', toggleTheme);
|
||||
|
||||
// System Theme Listener (Real-time)
|
||||
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: light)');
|
||||
if (systemThemeMedia.addEventListener) {
|
||||
systemThemeMedia.addEventListener('change', () => {
|
||||
const savedTheme = localStorage.getItem('theme') || (window.SITE_SETTINGS && window.SITE_SETTINGS.default_theme) || 'dark';
|
||||
if (savedTheme === 'auto') {
|
||||
applyTheme('auto');
|
||||
}
|
||||
});
|
||||
}
|
||||
dom.btnLogin.addEventListener('click', openLoginModal);
|
||||
dom.closeLoginModal.addEventListener('click', closeLoginModal);
|
||||
dom.loginForm.addEventListener('submit', handleLogin);
|
||||
@@ -1705,6 +1716,16 @@
|
||||
networkChart.draw();
|
||||
}
|
||||
}
|
||||
|
||||
// Default Theme
|
||||
if (settings.default_theme) {
|
||||
if (dom.defaultThemeInput) dom.defaultThemeInput.value = settings.default_theme;
|
||||
// If setting is 'auto', we also sync immediately
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (!savedTheme || savedTheme === 'auto') {
|
||||
applyTheme(settings.default_theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSiteSettings() {
|
||||
@@ -1723,6 +1744,11 @@
|
||||
p95_type: dom.p95TypeSelect.value
|
||||
};
|
||||
|
||||
// If user sets default to auto, we should clear their manual override or set it to auto
|
||||
if (settings.default_theme === 'auto') {
|
||||
localStorage.setItem('theme', 'auto');
|
||||
}
|
||||
|
||||
dom.btnSaveSiteSettings.disabled = true;
|
||||
dom.btnSaveSiteSettings.textContent = '保存中...';
|
||||
|
||||
@@ -1974,10 +2000,12 @@
|
||||
|
||||
if (dom.routeSourceSelect) {
|
||||
const currentVal = dom.routeSourceSelect.value;
|
||||
const blackboxSources = sources.filter(s => s.type === 'blackbox');
|
||||
dom.routeSourceSelect.innerHTML = '<option value="">-- 选择数据源 --</option>' +
|
||||
sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
||||
blackboxSources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
||||
dom.routeSourceSelect.value = currentVal;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
|
||||
@@ -330,7 +330,7 @@ class MetricChart {
|
||||
this.data = { timestamps: [], values: [], series: null };
|
||||
this.unit = unit; // '%', 'B/s', etc.
|
||||
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.prevMaxVal = 0;
|
||||
@@ -462,6 +462,20 @@ class MetricChart {
|
||||
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) {
|
||||
// Draw Stacked Area
|
||||
const modes = [
|
||||
|
||||
@@ -549,14 +549,8 @@ async function getServerDetails(baseUrl, instance, job) {
|
||||
|
||||
// Queries based on the requested dashboard structure
|
||||
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`,
|
||||
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`,
|
||||
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])))`,
|
||||
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`,
|
||||
@@ -636,42 +630,18 @@ async function getServerHistory(baseUrl, instance, job, metric, range = '1h', st
|
||||
const url = normalizeUrl(baseUrl);
|
||||
const node = resolveToken(instance);
|
||||
|
||||
// Custom multi-metric handler for CPU Busy
|
||||
// CPU Busy history: 100 - idle
|
||||
if (metric === 'cpuBusy') {
|
||||
const modes = {
|
||||
system: 'system',
|
||||
user: 'user',
|
||||
iowait: 'iowait',
|
||||
irq: 'irq|softirq',
|
||||
other: 'nice|steal|guest|guest_nice',
|
||||
idle: 'idle'
|
||||
};
|
||||
|
||||
const expr = `100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle", instance="${node}"}[1m])))`;
|
||||
const rangeObj = parseRange(range, start, end);
|
||||
const timestamps = [];
|
||||
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));
|
||||
const result = await queryRange(url, expr, rangeObj.queryStart, rangeObj.queryEnd, rangeObj.step);
|
||||
|
||||
results.forEach(r => {
|
||||
r.values.forEach(v => series[r.name].push(parseFloat(v[1])));
|
||||
});
|
||||
|
||||
// Pre-calculate busy percentage: 100 - idle
|
||||
const idleValues = series.idle || [];
|
||||
const busyValues = idleValues.map(idleVal => Math.max(0, 100 - idleVal));
|
||||
|
||||
return { timestamps, series, values: busyValues };
|
||||
if (!result || result.length === 0) return { timestamps: [], values: [] };
|
||||
|
||||
return {
|
||||
timestamps: result[0].values.map(v => v[0] * 1000),
|
||||
values: result[0].values.map(v => parseFloat(v[1]))
|
||||
};
|
||||
}
|
||||
|
||||
// Map metric keys to Prometheus expressions
|
||||
|
||||
Reference in New Issue
Block a user