12 Commits

Author SHA1 Message Date
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
10 changed files with 902 additions and 303 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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}"

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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 = [

View File

@@ -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