Compare commits
39 Commits
PromdataPa
...
PromdataPa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb40f4947 | ||
|
|
9854f478c0 | ||
|
|
3963d137de | ||
|
|
60d8a3d550 | ||
|
|
09f20ec81d | ||
|
|
06ddd5a8e1 | ||
|
|
a14fcdf158 | ||
|
|
6aa8ba5fbc | ||
|
|
2eae34bb96 | ||
|
|
05ae5dff2a | ||
|
|
64fc023f7b | ||
|
|
307a26c0db | ||
|
|
73401309f2 | ||
|
|
f169dd4267 | ||
|
|
09887b52d0 | ||
|
|
c94b697319 | ||
|
|
1bfee2026f | ||
|
|
c47e483028 | ||
|
|
864cbc3569 | ||
|
|
47a795cd73 | ||
|
|
92f97b7e51 | ||
|
|
3bdde47c60 | ||
|
|
1583758f29 | ||
|
|
0602e37bc9 | ||
|
|
41bdb38d51 | ||
|
|
d958aa8d74 | ||
|
|
2024523b46 | ||
|
|
da722ee07e | ||
|
|
bc8414df3d | ||
|
|
15f4b610af | ||
|
|
94ed27199a | ||
|
|
131c011c5c | ||
|
|
6d5b8bbb08 | ||
|
|
b4415f25ac | ||
|
|
d469dacc08 | ||
|
|
ee4354b571 | ||
|
|
50818b54ca | ||
|
|
480cdf3f6d | ||
|
|
832fd0fde1 |
10
.env.example
10
.env.example
@@ -7,3 +7,13 @@ PORT=3000
|
|||||||
|
|
||||||
# Aggregation interval in milliseconds (default 5s)
|
# Aggregation interval in milliseconds (default 5s)
|
||||||
REFRESH_INTERVAL=5000
|
REFRESH_INTERVAL=5000
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# Keep remote setup disabled unless you explicitly need to initialize from another host.
|
||||||
|
ALLOW_REMOTE_SETUP=false
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
SESSION_TTL_SECONDS=86400
|
||||||
|
PASSWORD_ITERATIONS=210000
|
||||||
|
|
||||||
|
# Runtime external data providers
|
||||||
|
ENABLE_EXTERNAL_GEO_LOOKUP=false
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -35,6 +35,15 @@ VERSION=v0.1.0 curl -sSL https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/r
|
|||||||
3. 安装依赖:`npm install --production`
|
3. 安装依赖:`npm install --production`
|
||||||
4. 启动服务:`npm start`
|
4. 启动服务:`npm start`
|
||||||
|
|
||||||
|
### 方式三:更新现有版本
|
||||||
|
|
||||||
|
如果您已经安装了本系统,可以使用随附的 `update.sh` 脚本一键升级到最新代码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入程序目录
|
||||||
|
curl -sSL https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/raw/branch/main/update.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
#### 3. 系统初始化
|
#### 3. 系统初始化
|
||||||
首次运行后,访问 `http://your-ip:3000/init.html`,按照引导完成 MySQL 数据库和 Valkey 缓存的连接。
|
首次运行后,访问 `http://your-ip:3000/init.html`,按照引导完成 MySQL 数据库和 Valkey 缓存的连接。
|
||||||
|
|
||||||
@@ -65,6 +74,46 @@ scrape_configs:
|
|||||||
- **Visualization**: ECharts / Canvas
|
- **Visualization**: ECharts / Canvas
|
||||||
- **Frontend**: Vanilla JS / CSS3
|
- **Frontend**: Vanilla JS / CSS3
|
||||||
|
|
||||||
|
## API 接口文档
|
||||||
|
|
||||||
|
本项提供了完整的 RESTful API,用于数据采集、系统配置和状态监控。
|
||||||
|
|
||||||
|
### 1. 认证接口 (`/api/auth`)
|
||||||
|
- `POST /api/auth/login`: 用户登录
|
||||||
|
- `POST /api/auth/logout`: 退出登录
|
||||||
|
- `POST /api/auth/change-password`: 修改密码 (需登录)
|
||||||
|
- `GET /api/auth/status`: 获取当前登录状态
|
||||||
|
|
||||||
|
### 2. 数据源管理 (`/api/sources`)
|
||||||
|
- `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
|
## LICENSE
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|||||||
305
install.sh
305
install.sh
@@ -1,155 +1,196 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# PromdataPanel - Multi-Prometheus Monitoring Dashboard Installer
|
set -euo pipefail
|
||||||
# This script handles OS detection, Node.js installation, project setup, and Systemd configuration.
|
|
||||||
|
|
||||||
# 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'
|
||||||
|
|
||||||
# 0. Configuration
|
|
||||||
VERSION=${VERSION:-"v0.1.0"}
|
VERSION=${VERSION:-"v0.1.0"}
|
||||||
DOWNLOAD_URL="https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/archive/${VERSION}.zip"
|
DOWNLOAD_URL="https://git.littlediary.cn/CN-JS-HuiBai/PromdataPanel/archive/${VERSION}.zip"
|
||||||
MIN_NODE_VERSION=18
|
MIN_NODE_VERSION=18
|
||||||
|
SERVICE_NAME="promdatapanel"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
OS_ID=""
|
||||||
|
OS_VER=""
|
||||||
|
PROJECT_DIR=""
|
||||||
|
REAL_USER=""
|
||||||
|
|
||||||
echo -e "${BLUE}================================================${NC}"
|
echo -e "${BLUE}================================================${NC}"
|
||||||
echo -e "${BLUE} PromdataPanel Auto-Installer ${NC}"
|
echo -e "${BLUE} PromdataPanel Auto-Installer ${NC}"
|
||||||
echo -e "${BLUE} Version: ${VERSION} ${NC}"
|
echo -e "${BLUE} Version: ${VERSION} ${NC}"
|
||||||
echo -e "${BLUE}================================================${NC}"
|
echo -e "${BLUE}================================================${NC}"
|
||||||
|
|
||||||
# 1. OS Detection
|
|
||||||
detect_os() {
|
detect_os() {
|
||||||
if [ -f /etc/os-release ]; then
|
if [ -f /etc/os-release ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
. /etc/os-release
|
. /etc/os-release
|
||||||
OS_ID=$ID
|
OS_ID="${ID:-}"
|
||||||
OS_VER=$VERSION_ID
|
OS_VER="${VERSION_ID:-}"
|
||||||
else
|
else
|
||||||
echo -e "${RED}Error: Cannot detect operating system type (/etc/os-release missing).${NC}"
|
echo -e "${RED}Error: Cannot detect operating system type (/etc/os-release missing).${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -z "$OS_ID" ]; then
|
||||||
|
echo -e "${RED}Error: Unable to determine operating system ID.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo -e "Detected OS: ${GREEN}${OS_ID} ${OS_VER}${NC}"
|
echo -e "Detected OS: ${GREEN}${OS_ID} ${OS_VER}${NC}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. Node.js Installation/Verification
|
require_cmd() {
|
||||||
install_node() {
|
local cmd="$1"
|
||||||
echo -e "${BLUE}Verifying Node.js environment...${NC}"
|
local hint="${2:-}"
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
NODE_INSTALLED=false
|
echo -e "${RED}Missing required command: ${cmd}.${NC}"
|
||||||
if command -v node &> /dev/null; then
|
if [ -n "$hint" ]; then
|
||||||
CURRENT_NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
echo -e "${YELLOW}${hint}${NC}"
|
||||||
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
|
||||||
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
|
exit 1
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. Download and Extract (If needed)
|
install_packages() {
|
||||||
if [ ! -f "server/index.js" ]; then
|
case "$OS_ID" in
|
||||||
echo -e "${YELLOW}Project files not found. Starting download...${NC}"
|
ubuntu|debian|raspbian)
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y "$@"
|
||||||
|
;;
|
||||||
|
centos|rhel|almalinux|rocky)
|
||||||
|
sudo yum install -y "$@"
|
||||||
|
;;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if ! command -v curl &> /dev/null; then
|
ensure_tooling() {
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
echo -e "${BLUE}Installing curl...${NC}"
|
echo -e "${BLUE}Installing curl...${NC}"
|
||||||
[ "$OS_ID" = "ubuntu" ] || [ "$OS_ID" = "debian" ] && sudo apt-get install -y curl
|
install_packages curl
|
||||||
[ "$OS_ID" = "centos" ] || [ "$OS_ID" = "rhel" ] && sudo yum install -y curl
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v unzip &> /dev/null; then
|
if ! command -v unzip >/dev/null 2>&1; then
|
||||||
echo -e "${BLUE}Installing unzip...${NC}"
|
echo -e "${BLUE}Installing unzip...${NC}"
|
||||||
[ "$OS_ID" = "ubuntu" ] || [ "$OS_ID" = "debian" ] && sudo apt-get install -y unzip
|
install_packages unzip
|
||||||
[ "$OS_ID" = "centos" ] || [ "$OS_ID" = "rhel" ] && sudo yum install -y unzip
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_nodesource_apt_repo() {
|
||||||
|
sudo install -d -m 0755 /etc/apt/keyrings
|
||||||
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
install_node() {
|
||||||
|
echo -e "${BLUE}Verifying Node.js environment...${NC}"
|
||||||
|
|
||||||
|
local node_installed=false
|
||||||
|
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
|
fi
|
||||||
|
|
||||||
TEMP_ZIP="promdatapanel_${VERSION}.zip"
|
if [ "$node_installed" = true ]; then
|
||||||
echo -e "${BLUE}Downloading ${DOWNLOAD_URL}...${NC}"
|
return
|
||||||
curl -L "$DOWNLOAD_URL" -o "$TEMP_ZIP"
|
fi
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
echo -e "${BLUE}Installing Node.js 20.x...${NC}"
|
||||||
echo -e "${RED}Download failed.${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
|
exit 1
|
||||||
fi
|
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}"
|
echo -e "${BLUE}Extracting files...${NC}"
|
||||||
unzip -q "$TEMP_ZIP"
|
unzip -q "$temp_zip" -d "$temp_dir"
|
||||||
|
|
||||||
EXTRACTED_DIR=$(ls -d */ | grep -E "^PromdataPanel" | head -n 1)
|
local extracted_dir
|
||||||
if [ -d "$EXTRACTED_DIR" ]; then
|
extracted_dir=$(find "$temp_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)
|
||||||
cd "$EXTRACTED_DIR" || exit 1
|
if [ -z "$extracted_dir" ] || [ ! -f "$extracted_dir/server/index.js" ]; then
|
||||||
else
|
echo -e "${RED}Download succeeded, but archive structure is invalid.${NC}"
|
||||||
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
|
|
||||||
REAL_USER=${SUDO_USER:-$USER}
|
|
||||||
else
|
|
||||||
REAL_USER=$USER
|
|
||||||
fi
|
|
||||||
|
|
||||||
detect_os
|
|
||||||
install_node
|
|
||||||
|
|
||||||
PROJECT_DIR=$(pwd)
|
|
||||||
echo -e "Project Directory: ${GREEN}$PROJECT_DIR${NC}"
|
|
||||||
echo -e "Running User: ${GREEN}$REAL_USER${NC}"
|
|
||||||
|
|
||||||
# Check for .env file
|
|
||||||
if [ ! -f ".env" ]; then
|
|
||||||
if [ -f ".env.example" ]; then
|
|
||||||
echo -e "${BLUE}Creating .env from .env.example...${NC}"
|
|
||||||
cp .env.example .env
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 6. Create Systemd Service File
|
cd "$extracted_dir"
|
||||||
SERVICE_FILE="/etc/systemd/system/promdatapanel.service"
|
}
|
||||||
NODE_PATH=$(command -v node)
|
|
||||||
|
|
||||||
echo -e "${BLUE}Creating systemd service at $SERVICE_FILE...${NC}"
|
detect_runtime_user() {
|
||||||
sudo bash -c "cat <<EOF > '$SERVICE_FILE'
|
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=PromdataPanel Monitoring Dashboard
|
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
|
||||||
@@ -157,39 +198,63 @@ 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}
|
||||||
EnvironmentFile=-$PROJECT_DIR/.env
|
EnvironmentFile=-${PROJECT_DIR}/.env
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF"
|
EOF
|
||||||
|
|
||||||
|
echo -e "${BLUE}Creating systemd service at ${SERVICE_FILE}...${NC}"
|
||||||
|
sudo install -m 0644 "$tmp_service" "$SERVICE_FILE"
|
||||||
|
rm -f "$tmp_service"
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_os
|
||||||
|
download_project_if_needed
|
||||||
|
detect_runtime_user
|
||||||
|
install_node
|
||||||
|
|
||||||
|
PROJECT_DIR=$(pwd)
|
||||||
|
echo -e "Project Directory: ${GREEN}${PROJECT_DIR}${NC}"
|
||||||
|
echo -e "Running User: ${GREEN}${REAL_USER}${NC}"
|
||||||
|
|
||||||
|
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
|
||||||
|
echo -e "${BLUE}Creating .env from .env.example...${NC}"
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}Installing NPM dependencies...${NC}"
|
||||||
|
npm install --production
|
||||||
|
|
||||||
|
write_service_file
|
||||||
|
|
||||||
# 7. Reload and Start
|
|
||||||
echo -e "${BLUE}Reloading systemd and restarting service...${NC}"
|
echo -e "${BLUE}Reloading systemd and restarting service...${NC}"
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable promdatapanel
|
sudo systemctl enable "$SERVICE_NAME"
|
||||||
sudo systemctl restart promdatapanel
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
# 8. Check Status
|
|
||||||
echo -e "${BLUE}Checking service status...${NC}"
|
echo -e "${BLUE}Checking service status...${NC}"
|
||||||
sleep 2
|
sleep 2
|
||||||
if sudo systemctl is-active --quiet promdatapanel; then
|
if sudo systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||||
echo -e "${GREEN}SUCCESS: PromdataPanel is now running.${NC}"
|
echo -e "${GREEN}SUCCESS: PromdataPanel is now running.${NC}"
|
||||||
PORT=$(grep "^PORT=" .env | cut -d'=' -f2)
|
PORT=$(grep "^PORT=" .env 2>/dev/null | cut -d'=' -f2 || true)
|
||||||
PORT=${PORT:-3000}
|
PORT=${PORT:-3000}
|
||||||
IP_ADDR=$(hostname -I | awk '{print $1}')
|
IP_ADDR=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
if [ -n "${IP_ADDR:-}" ]; then
|
||||||
echo -e "Dashboard URL: ${YELLOW}http://${IP_ADDR}:${PORT}${NC}"
|
echo -e "Dashboard URL: ${YELLOW}http://${IP_ADDR}:${PORT}${NC}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${RED}FAILED: Service failed to start.${NC}"
|
echo -e "${RED}FAILED: Service failed to start.${NC}"
|
||||||
echo -e "Check logs with: ${BLUE}journalctl -u promdatapanel -xe${NC}"
|
echo -e "Check logs with: ${BLUE}journalctl -u ${SERVICE_NAME} -xe${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${BLUE}================================================${NC}"
|
echo -e "${BLUE}================================================${NC}"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1959,6 +1976,12 @@ input:checked+.slider:before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#logoIconContainer {
|
#logoIconContainer {
|
||||||
|
width: auto;
|
||||||
|
height: 30px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
}
|
}
|
||||||
@@ -2158,6 +2181,7 @@ input:checked+.slider:before {
|
|||||||
width: 96%;
|
width: 96%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
max-height: 90dvh;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2195,16 +2219,19 @@ input:checked+.slider:before {
|
|||||||
.modal-body {
|
.modal-body {
|
||||||
padding: 16px 14px;
|
padding: 16px 14px;
|
||||||
max-height: calc(90vh - 70px);
|
max-height: calc(90vh - 70px);
|
||||||
|
max-height: calc(90dvh - 70px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Server Detail Modal -- */
|
/* -- Server Detail Modal -- */
|
||||||
#serverDetailModal .modal {
|
#serverDetailModal .modal {
|
||||||
width: 98%;
|
width: 98%;
|
||||||
max-height: 92vh;
|
max-height: 92vh;
|
||||||
|
max-height: 92dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
#serverDetailModal .modal-body {
|
#serverDetailModal .modal-body {
|
||||||
max-height: calc(92vh - 60px);
|
max-height: calc(92vh - 60px);
|
||||||
|
max-height: calc(92dvh - 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-metrics-list {
|
.detail-metrics-list {
|
||||||
@@ -2383,11 +2410,13 @@ input:checked+.slider:before {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
height: 100vh !important;
|
height: 100vh !important;
|
||||||
|
height: 100dvh !important;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.globe-card.expanded .globe-body {
|
.globe-card.expanded .globe-body {
|
||||||
height: calc(100vh - 110px) !important;
|
height: calc(100vh - 110px) !important;
|
||||||
|
height: calc(100dvh - 110px) !important;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2482,16 +2511,20 @@ input:checked+.slider:before {
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
height: 100vh !important;
|
height: 100vh !important;
|
||||||
|
height: 100dvh !important;
|
||||||
max-height: 100vh !important;
|
max-height: 100vh !important;
|
||||||
|
max-height: 100dvh !important;
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
#serverDetailModal .modal-body {
|
#serverDetailModal .modal-body {
|
||||||
max-height: calc(100vh - 60px) !important;
|
max-height: calc(100vh - 60px) !important;
|
||||||
|
max-height: calc(100dvh - 60px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metric titles no wrap (as requested) */
|
/* Metric titles no wrap (as requested) */
|
||||||
@@ -2507,12 +2540,16 @@ input:checked+.slider:before {
|
|||||||
.modal {
|
.modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
|
max-height: 100dvh;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
max-height: calc(100vh - 60px);
|
max-height: calc(100vh - 60px);
|
||||||
|
max-height: calc(100dvh - 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Latency route items on small mobile -- */
|
/* -- Latency route items on small mobile -- */
|
||||||
@@ -2675,3 +2712,80 @@ input:checked+.slider:before {
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,8 +500,33 @@
|
|||||||
<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 class="form-group" style="margin-top: 15px;">
|
||||||
|
<label for="requireLoginForServerDetailsInput">服务器详情是否仅登录后可查看</label>
|
||||||
|
<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); width: 100%;">
|
||||||
|
<option value="1">仅登录后可查看</option>
|
||||||
|
<option value="0">允许公开查看</option>
|
||||||
|
</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>
|
||||||
<div class="settings-section" style="margin-top: 25px; border-top: 1px solid var(--border-color); padding-top: 20px;">
|
<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>
|
<h4 style="font-size: 0.85rem; color: var(--accent-indigo); margin-bottom: 15px; text-transform: uppercase; letter-spacing: 0.5px;">界面外观 (Appearance)</h4>
|
||||||
@@ -465,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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
623
public/js/app.js
623
public/js/app.js
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -335,6 +346,7 @@ class MetricChart {
|
|||||||
|
|
||||||
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,6 +477,10 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -541,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
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
1
public/vendor/world.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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...`);
|
|
||||||
await db.query("ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth");
|
|
||||||
console.log(`[Database Integrity] ✅ Column 'p95_type' added.`);
|
|
||||||
}
|
|
||||||
if (!columnNames.includes('blackbox_source_id')) {
|
|
||||||
console.log(`[Database Integrity] ⚠️ Missing column 'blackbox_source_id' in 'site_settings'. Adding it...`);
|
|
||||||
await db.query("ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER p95_type");
|
|
||||||
console.log(`[Database Integrity] ✅ Column 'blackbox_source_id' added.`);
|
|
||||||
}
|
|
||||||
if (!columnNames.includes('latency_source')) {
|
|
||||||
console.log(`[Database Integrity] ⚠️ Missing column 'latency_source' in 'site_settings'. Adding it...`);
|
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
if (!columnNames.includes('latency_target')) {
|
|
||||||
console.log(`[Database Integrity] ⚠️ Missing column 'latency_target' in 'site_settings'. Adding it...`);
|
|
||||||
await db.query("ALTER TABLE site_settings ADD COLUMN latency_target VARCHAR(255) AFTER latency_dest");
|
|
||||||
console.log(`[Database Integrity] ✅ Column 'latency_target' added.`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Database Integrity] ❌ Error checking integrity:', err.message);
|
console.error(`[Database Integrity] ❌ Failed to add column '${columnName}':`, err.message);
|
||||||
|
// Try without AFTER if it exists
|
||||||
|
if (sql.includes('AFTER')) {
|
||||||
|
try {
|
||||||
|
const fallback = sql.split(' AFTER')[0];
|
||||||
|
console.log(`[Database Integrity] 🔄 Retrying column '${columnName}' WITHOUT 'AFTER'...`);
|
||||||
|
await db.query(fallback);
|
||||||
|
console.log(`[Database Integrity] ✅ Column '${columnName}' added via fallback.`);
|
||||||
|
} catch (err2) {
|
||||||
|
console.error(`[Database Integrity] ❌ Fallback also failed:`, err2.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
await addColumn('p95_type', "ALTER TABLE site_settings ADD COLUMN p95_type VARCHAR(20) DEFAULT 'tx' AFTER show_95_bandwidth");
|
||||||
|
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 addColumn('blackbox_source_id', "ALTER TABLE site_settings ADD COLUMN blackbox_source_id INT AFTER p95_type");
|
||||||
|
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) {
|
||||||
|
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
|
||||||
`);
|
`);
|
||||||
|
|||||||
225
server/db-schema-check.js
Normal file
225
server/db-schema-check.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Database schema check
|
||||||
|
* Ensures required tables and columns exist at startup.
|
||||||
|
*/
|
||||||
|
require('dotenv').config();
|
||||||
|
const db = require('./db');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
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 envPath = path.join(__dirname, '..', '.env');
|
||||||
|
if (!fs.existsSync(envPath)) 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;
|
||||||
@@ -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}` : ''}`;
|
||||||
|
|||||||
543
server/index.js
543
server/index.js
@@ -7,9 +7,10 @@ const prometheusService = require('./prometheus-service');
|
|||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
const geoService = require('./geo-service');
|
const geoService = require('./geo-service');
|
||||||
const latencyService = require('./latency-service');
|
const latencyService = require('./latency-service');
|
||||||
const checkAndFixDatabase = require('./db-integrity-check');
|
const checkAndFixDatabase = require('./db-schema-check');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -21,13 +22,271 @@ const fs = require('fs');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
let isDbInitialized = false;
|
let isDbInitialized = false;
|
||||||
const sessions = new Map(); // Simple session store: sessionId -> {userId, username}
|
const sessions = new Map(); // Fallback session store when Valkey is unavailable
|
||||||
|
const requestBuckets = new Map();
|
||||||
|
const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 86400;
|
||||||
|
const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000;
|
||||||
|
const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true';
|
||||||
|
const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true';
|
||||||
|
const RATE_LIMITS = {
|
||||||
|
login: { windowMs: 15 * 60 * 1000, max: 8 },
|
||||||
|
setup: { windowMs: 10 * 60 * 1000, max: 20 }
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeIp(ip) {
|
||||||
|
if (!ip) return '';
|
||||||
|
if (ip.startsWith('::ffff:')) return ip.substring(7);
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateOrLoopbackIp(ip) {
|
||||||
|
const normalized = normalizeIp(ip);
|
||||||
|
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (normalized === '::1' || normalized === '127.0.0.1' || normalized === 'localhost') return true;
|
||||||
|
|
||||||
|
if (net.isIPv4(normalized)) {
|
||||||
|
return (
|
||||||
|
normalized.startsWith('10.') ||
|
||||||
|
normalized.startsWith('127.') ||
|
||||||
|
normalized.startsWith('192.168.') ||
|
||||||
|
/^172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) ||
|
||||||
|
normalized.startsWith('169.254.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (net.isIPv6(normalized)) {
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
return lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80:');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalRequest(req) {
|
||||||
|
const ip = normalizeIp(req.socket.remoteAddress || req.ip || '');
|
||||||
|
return isPrivateOrLoopbackIp(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientIp(req) {
|
||||||
|
return normalizeIp(req.socket?.remoteAddress || req.ip || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRateLimitKey(scope, req, discriminator = '') {
|
||||||
|
return `${scope}:${getClientIp(req)}:${discriminator}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(key, { windowMs, max }) {
|
||||||
|
const now = Date.now();
|
||||||
|
const bucket = requestBuckets.get(key);
|
||||||
|
|
||||||
|
if (!bucket || bucket.resetAt <= now) {
|
||||||
|
requestBuckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { allowed: true, retryAfterMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucket.count >= max) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfterMs: Math.max(0, bucket.resetAt - now)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.count += 1;
|
||||||
|
return { allowed: true, retryAfterMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceRateLimit(req, res, scope, discriminator = '') {
|
||||||
|
const result = checkRateLimit(makeRateLimitKey(scope, req, discriminator), RATE_LIMITS[scope]);
|
||||||
|
if (result.allowed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Retry-After', Math.max(1, Math.ceil(result.retryAfterMs / 1000)));
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Too many requests. Please try again later.'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeJsonForInlineScript(value) {
|
||||||
|
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (char) => {
|
||||||
|
switch (char) {
|
||||||
|
case '<':
|
||||||
|
return '\\u003c';
|
||||||
|
case '>':
|
||||||
|
return '\\u003e';
|
||||||
|
case '&':
|
||||||
|
return '\\u0026';
|
||||||
|
case '\u2028':
|
||||||
|
return '\\u2028';
|
||||||
|
case '\u2029':
|
||||||
|
return '\\u2029';
|
||||||
|
default:
|
||||||
|
return char;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicSiteSettings(settings = {}) {
|
||||||
|
return {
|
||||||
|
page_name: settings.page_name || '',
|
||||||
|
show_page_name: settings.show_page_name !== undefined ? settings.show_page_name : 1,
|
||||||
|
title: settings.title || '',
|
||||||
|
logo_url: settings.logo_url || null,
|
||||||
|
logo_url_dark: settings.logo_url_dark || null,
|
||||||
|
favicon_url: settings.favicon_url || null,
|
||||||
|
default_theme: settings.default_theme || 'dark',
|
||||||
|
show_95_bandwidth: settings.show_95_bandwidth ? 1 : 0,
|
||||||
|
p95_type: settings.p95_type || 'tx',
|
||||||
|
require_login_for_server_details: settings.require_login_for_server_details !== undefined
|
||||||
|
? (settings.require_login_for_server_details ? 1 : 0)
|
||||||
|
: 1,
|
||||||
|
icp_filing: settings.icp_filing || null,
|
||||||
|
ps_filing: settings.ps_filing || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSiteSettingsRow() {
|
||||||
|
const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1');
|
||||||
|
return rows.length > 0 ? rows[0] : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireServerDetailsAccess(req, res, next) {
|
||||||
|
try {
|
||||||
|
const settings = await getSiteSettingsRow();
|
||||||
|
const requiresLogin = settings.require_login_for_server_details !== undefined
|
||||||
|
? !!settings.require_login_for_server_details
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (!requiresLogin) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return requireAuth(req, res, next);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Server details access check failed:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to verify detail access' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookieOptions(req, maxAgeSeconds) {
|
||||||
|
const options = ['Path=/', 'HttpOnly', 'SameSite=Strict'];
|
||||||
|
if (typeof maxAgeSeconds === 'number') {
|
||||||
|
options.push(`Max-Age=${maxAgeSeconds}`);
|
||||||
|
}
|
||||||
|
if (COOKIE_SECURE || req.secure || req.headers['x-forwarded-proto'] === 'https') {
|
||||||
|
options.push('Secure');
|
||||||
|
}
|
||||||
|
return options.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCookie(req, res, sessionId, maxAgeSeconds = SESSION_TTL_SECONDS) {
|
||||||
|
res.setHeader('Set-Cookie', `session_id=${encodeURIComponent(sessionId)}; ${getCookieOptions(req, maxAgeSeconds)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionCookie(req, res) {
|
||||||
|
res.setHeader('Set-Cookie', `session_id=; ${getCookieOptions(req, 0)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPasswordHash(password, salt, iterations = PASSWORD_ITERATIONS) {
|
||||||
|
const hash = crypto.pbkdf2Sync(password, salt, iterations, 64, 'sha512').toString('hex');
|
||||||
|
return `pbkdf2$sha512$${iterations}$${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePasswordHash(storedPassword) {
|
||||||
|
if (typeof storedPassword !== 'string') {
|
||||||
|
return { iterations: 1000, hash: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedPassword.startsWith('pbkdf2$')) {
|
||||||
|
const parts = storedPassword.split('$');
|
||||||
|
if (parts.length === 4 && parts[1] === 'sha512') {
|
||||||
|
return {
|
||||||
|
iterations: parseInt(parts[2], 10) || 1000,
|
||||||
|
hash: parts[3]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { iterations: 1000, hash: storedPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password, user) {
|
||||||
|
const parsed = parsePasswordHash(user.password);
|
||||||
|
const computed = crypto.pbkdf2Sync(password, user.salt, parsed.iterations, 64, 'sha512').toString('hex');
|
||||||
|
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(parsed.hash, 'hex'));
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUpgradePasswordHash(user) {
|
||||||
|
const parsed = parsePasswordHash(user.password);
|
||||||
|
return !String(user.password || '').startsWith('pbkdf2$') || parsed.iterations < PASSWORD_ITERATIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSession(sessionId, sessionData) {
|
||||||
|
sessions.set(sessionId, {
|
||||||
|
...sessionData,
|
||||||
|
expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000
|
||||||
|
});
|
||||||
|
await cache.set(`session:${sessionId}`, sessionData, SESSION_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSession(sessionId) {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
|
const cachedSession = await cache.get(`session:${sessionId}`);
|
||||||
|
if (cachedSession) {
|
||||||
|
sessions.set(sessionId, {
|
||||||
|
...cachedSession,
|
||||||
|
expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000
|
||||||
|
});
|
||||||
|
return cachedSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = sessions.get(sessionId);
|
||||||
|
if (!fallback) return null;
|
||||||
|
if (fallback.expiresAt && fallback.expiresAt <= Date.now()) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fallback.id,
|
||||||
|
username: fallback.username
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function destroySession(sessionId) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
await cache.del(`session:${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSetupAccess(req, res, next) {
|
||||||
|
if (!enforceRateLimit(req, res, 'setup')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ALLOW_REMOTE_SETUP || isLocalRequest(req)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Remote setup is disabled. Use a local request or set ALLOW_REMOTE_SETUP=true.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Middleware: Check Auth
|
// Middleware: Check Auth
|
||||||
function requireAuth(req, res, next) {
|
async function requireAuth(req, res, next) {
|
||||||
const sessionId = getCookie(req, 'session_id');
|
const sessionId = getCookie(req, 'session_id');
|
||||||
if (sessionId && sessions.has(sessionId)) {
|
const session = await getSession(sessionId);
|
||||||
req.user = sessions.get(sessionId);
|
if (session) {
|
||||||
|
req.user = session;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
res.status(401).json({ error: 'Auth required' });
|
res.status(401).json({ error: 'Auth required' });
|
||||||
@@ -78,12 +337,12 @@ app.get('/health', async (req, res) => {
|
|||||||
database: {
|
database: {
|
||||||
name: 'MySQL',
|
name: 'MySQL',
|
||||||
status: dbStatus.status,
|
status: dbStatus.status,
|
||||||
message: dbStatus.error || 'Connected'
|
message: dbStatus.status === 'up' ? 'Connected' : 'Unavailable'
|
||||||
},
|
},
|
||||||
valkey: {
|
valkey: {
|
||||||
name: 'Valkey (Redis)',
|
name: 'Valkey (Redis)',
|
||||||
status: cacheStatus.status,
|
status: cacheStatus.status,
|
||||||
message: cacheStatus.error || 'Connected'
|
message: cacheStatus.status === 'up' ? 'Connected' : 'Unavailable'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -101,17 +360,24 @@ app.get('/health', async (req, res) => {
|
|||||||
// --- Auth API ---
|
// --- Auth API ---
|
||||||
app.post('/api/auth/login', async (req, res) => {
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
|
const loginKey = String(username || '').trim().toLowerCase();
|
||||||
|
if (!enforceRateLimit(req, res, 'login', loginKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
|
const [rows] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
|
||||||
if (rows.length === 0) return res.status(401).json({ error: 'Invalid credentials' });
|
if (rows.length === 0) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
const hash = crypto.pbkdf2Sync(password, user.salt, 1000, 64, 'sha512').toString('hex');
|
|
||||||
|
|
||||||
if (hash === user.password) {
|
if (verifyPassword(password, user)) {
|
||||||
|
if (shouldUpgradePasswordHash(user)) {
|
||||||
|
await db.query('UPDATE users SET password = ? WHERE id = ?', [createPasswordHash(password, user.salt), user.id]);
|
||||||
|
}
|
||||||
|
|
||||||
const sessionId = crypto.randomBytes(32).toString('hex');
|
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||||
sessions.set(sessionId, { id: user.id, username: user.username });
|
await persistSession(sessionId, { id: user.id, username: user.username });
|
||||||
res.setHeader('Set-Cookie', `session_id=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
|
setSessionCookie(req, res, sessionId);
|
||||||
res.json({ success: true, username: user.username });
|
res.json({ success: true, username: user.username });
|
||||||
} else {
|
} else {
|
||||||
res.status(401).json({ error: 'Invalid credentials' });
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
@@ -123,8 +389,8 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/auth/logout', (req, res) => {
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
const sessionId = getCookie(req, 'session_id');
|
const sessionId = getCookie(req, 'session_id');
|
||||||
if (sessionId) sessions.delete(sessionId);
|
destroySession(sessionId).catch(() => {});
|
||||||
res.setHeader('Set-Cookie', 'session_id=; Path=/; HttpOnly; Max-Age=0');
|
clearSessionCookie(req, res);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,14 +405,14 @@ app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
|||||||
if (rows.length === 0) return res.status(404).json({ error: '用户不存在' });
|
if (rows.length === 0) return res.status(404).json({ error: '用户不存在' });
|
||||||
|
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
const oldHash = crypto.pbkdf2Sync(oldPassword, user.salt, 1000, 64, 'sha512').toString('hex');
|
const passwordMatches = verifyPassword(oldPassword, user);
|
||||||
|
|
||||||
if (oldHash !== user.password) {
|
if (!passwordMatches) {
|
||||||
return res.status(401).json({ error: '旧密码输入错误' });
|
return res.status(401).json({ error: '旧密码输入错误' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSalt = crypto.randomBytes(16).toString('hex');
|
const newSalt = crypto.randomBytes(16).toString('hex');
|
||||||
const newHash = crypto.pbkdf2Sync(newPassword, newSalt, 1000, 64, 'sha512').toString('hex');
|
const newHash = createPasswordHash(newPassword, newSalt);
|
||||||
|
|
||||||
await db.query('UPDATE users SET password = ?, salt = ? WHERE id = ?', [newHash, newSalt, user.id]);
|
await db.query('UPDATE users SET password = ?, salt = ? WHERE id = ?', [newHash, newSalt, user.id]);
|
||||||
res.json({ success: true, message: '密码修改成功' });
|
res.json({ success: true, message: '密码修改成功' });
|
||||||
@@ -156,17 +422,18 @@ app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/auth/status', (req, res) => {
|
app.get('/api/auth/status', async (req, res) => {
|
||||||
const sessionId = getCookie(req, 'session_id');
|
const sessionId = getCookie(req, 'session_id');
|
||||||
if (sessionId && sessions.has(sessionId)) {
|
const session = await getSession(sessionId);
|
||||||
res.json({ authenticated: true, username: sessions.get(sessionId).username });
|
if (session) {
|
||||||
|
res.json({ authenticated: true, username: session.username });
|
||||||
} else {
|
} else {
|
||||||
res.json({ authenticated: false });
|
res.json({ authenticated: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup API Routes
|
// Setup API Routes
|
||||||
app.post('/api/setup/test', async (req, res) => {
|
app.post('/api/setup/test', ensureSetupAccess, async (req, res) => {
|
||||||
const { host, port, user, password } = req.body;
|
const { host, port, user, password } = req.body;
|
||||||
try {
|
try {
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
@@ -184,7 +451,7 @@ app.post('/api/setup/test', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/setup/test-valkey', async (req, res) => {
|
app.post('/api/setup/test-valkey', ensureSetupAccess, async (req, res) => {
|
||||||
const { host, port, password } = req.body;
|
const { host, port, password } = req.body;
|
||||||
try {
|
try {
|
||||||
const Redis = require('ioredis');
|
const Redis = require('ioredis');
|
||||||
@@ -205,9 +472,13 @@ app.post('/api/setup/test-valkey', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/setup/init', async (req, res) => {
|
app.post('/api/setup/init', ensureSetupAccess, async (req, res) => {
|
||||||
const { host, port, user, password, database, vHost, vPort, vPassword } = req.body;
|
const { host, port, user, password, database, vHost, vPort, vPassword } = req.body;
|
||||||
try {
|
try {
|
||||||
|
if (isDbInitialized) {
|
||||||
|
return res.status(409).json({ success: false, error: 'System is already initialized' });
|
||||||
|
}
|
||||||
|
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
const connection = await mysql.createConnection({
|
const connection = await mysql.createConnection({
|
||||||
host: host || 'localhost',
|
host: host || 'localhost',
|
||||||
@@ -262,13 +533,18 @@ app.post('/api/setup/init', async (req, res) => {
|
|||||||
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
|
page_name VARCHAR(255) DEFAULT '数据可视化展示大屏',
|
||||||
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
|
||||||
`);
|
`);
|
||||||
@@ -277,6 +553,35 @@ app.post('/api/setup/init', async (req, res) => {
|
|||||||
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0, 'tx')
|
VALUES (1, '数据可视化展示大屏', '数据可视化展示大屏', 'dark', 0, 'tx')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Ensure the first-run schema matches the runtime expectations without requiring a restart migration.
|
||||||
|
await connection.query("ALTER TABLE prometheus_sources ADD COLUMN IF NOT EXISTS is_server_source TINYINT(1) DEFAULT 1 AFTER description");
|
||||||
|
await connection.query("ALTER TABLE prometheus_sources ADD COLUMN IF NOT EXISTS type VARCHAR(50) DEFAULT 'prometheus' AFTER is_server_source");
|
||||||
|
await connection.query("ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS show_page_name TINYINT(1) DEFAULT 1 AFTER page_name");
|
||||||
|
await connection.query("ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS require_login_for_server_details TINYINT(1) DEFAULT 1 AFTER p95_type");
|
||||||
|
await connection.query(`
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
await connection.query(`
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
|
||||||
await connection.end();
|
await connection.end();
|
||||||
|
|
||||||
// Save to .env
|
// Save to .env
|
||||||
@@ -291,6 +596,11 @@ VALKEY_PASSWORD=${vPassword || ''}
|
|||||||
PORT=${process.env.PORT || 3000}
|
PORT=${process.env.PORT || 3000}
|
||||||
HOST=${process.env.HOST || '0.0.0.0'}
|
HOST=${process.env.HOST || '0.0.0.0'}
|
||||||
REFRESH_INTERVAL=${process.env.REFRESH_INTERVAL || 5000}
|
REFRESH_INTERVAL=${process.env.REFRESH_INTERVAL || 5000}
|
||||||
|
ALLOW_REMOTE_SETUP=${process.env.ALLOW_REMOTE_SETUP || 'false'}
|
||||||
|
COOKIE_SECURE=${process.env.COOKIE_SECURE || 'false'}
|
||||||
|
SESSION_TTL_SECONDS=${process.env.SESSION_TTL_SECONDS || SESSION_TTL_SECONDS}
|
||||||
|
PASSWORD_ITERATIONS=${process.env.PASSWORD_ITERATIONS || PASSWORD_ITERATIONS}
|
||||||
|
ENABLE_EXTERNAL_GEO_LOOKUP=${process.env.ENABLE_EXTERNAL_GEO_LOOKUP || 'false'}
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
|
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
|
||||||
|
|
||||||
@@ -317,7 +627,7 @@ REFRESH_INTERVAL=${process.env.REFRESH_INTERVAL || 5000}
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Setup Status Check
|
// Setup Status Check
|
||||||
app.get('/api/setup/status', async (req, res) => {
|
app.get('/api/setup/status', ensureSetupAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!isDbInitialized) {
|
if (!isDbInitialized) {
|
||||||
return res.json({ initialized: false, step: 'db' });
|
return res.json({ initialized: false, step: 'db' });
|
||||||
@@ -334,7 +644,7 @@ app.get('/api/setup/status', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create First Admin
|
// Create First Admin
|
||||||
app.post('/api/setup/admin', async (req, res) => {
|
app.post('/api/setup/admin', ensureSetupAccess, async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
if (!username || !password) return res.status(400).json({ error: 'Username and password are required' });
|
if (!username || !password) return res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
|
||||||
@@ -343,7 +653,7 @@ app.post('/api/setup/admin', async (req, res) => {
|
|||||||
if (rows[0].count > 0) return res.status(403).json({ error: 'Admin already exists' });
|
if (rows[0].count > 0) return res.status(403).json({ error: 'Admin already exists' });
|
||||||
|
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
const hash = createPasswordHash(password, salt);
|
||||||
|
|
||||||
await db.query('INSERT INTO users (username, password, salt) VALUES (?, ?, ?)', [username, hash, salt]);
|
await db.query('INSERT INTO users (username, password, salt) VALUES (?, ?, ?)', [username, hash, salt]);
|
||||||
const [userRows] = await db.query('SELECT id, username FROM users WHERE username = ?', [username]);
|
const [userRows] = await db.query('SELECT id, username FROM users WHERE username = ?', [username]);
|
||||||
@@ -351,8 +661,8 @@ app.post('/api/setup/admin', async (req, res) => {
|
|||||||
|
|
||||||
// Auto-login after creation so the next setup steps (like adding Prometheus) work without 401
|
// Auto-login after creation so the next setup steps (like adding Prometheus) work without 401
|
||||||
const sessionId = crypto.randomBytes(32).toString('hex');
|
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||||
sessions.set(sessionId, { id: user.id, username: user.username });
|
await persistSession(sessionId, { id: user.id, username: user.username });
|
||||||
res.setHeader('Set-Cookie', `session_id=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
|
setSessionCookie(req, res, sessionId);
|
||||||
|
|
||||||
res.json({ success: true, message: 'Admin account created and logged in' });
|
res.json({ success: true, message: 'Admin account created and logged in' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -406,13 +716,18 @@ const serveIndex = async (req, res) => {
|
|||||||
// Fetch settings
|
// Fetch settings
|
||||||
let settings = {
|
let settings = {
|
||||||
page_name: '数据可视化展示大屏',
|
page_name: '数据可视化展示大屏',
|
||||||
|
show_page_name: 1,
|
||||||
title: '数据可视化展示大屏',
|
title: '数据可视化展示大屏',
|
||||||
logo_url: null,
|
logo_url: null,
|
||||||
|
logo_url_dark: null,
|
||||||
|
favicon_url: null,
|
||||||
default_theme: 'dark',
|
default_theme: 'dark',
|
||||||
blackbox_source_id: null,
|
blackbox_source_id: null,
|
||||||
latency_source: null,
|
latency_source: null,
|
||||||
latency_dest: null,
|
latency_dest: null,
|
||||||
latency_target: null
|
latency_target: null,
|
||||||
|
icp_filing: null,
|
||||||
|
ps_filing: null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDbInitialized) {
|
if (isDbInitialized) {
|
||||||
@@ -425,7 +740,7 @@ const serveIndex = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inject settings
|
// Inject settings
|
||||||
const settingsJson = JSON.stringify(settings);
|
const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings));
|
||||||
const injection = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`;
|
const injection = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`;
|
||||||
|
|
||||||
// Replace <head> with <head> + injection
|
// Replace <head> with <head> + injection
|
||||||
@@ -446,7 +761,7 @@ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false }));
|
|||||||
// ==================== Prometheus Source CRUD ====================
|
// ==================== Prometheus Source CRUD ====================
|
||||||
|
|
||||||
// Get all Prometheus sources
|
// Get all Prometheus sources
|
||||||
app.get('/api/sources', async (req, res) => {
|
app.get('/api/sources', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY is_server_source DESC, created_at DESC');
|
const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY is_server_source DESC, created_at DESC');
|
||||||
// Test connectivity for each source
|
// Test connectivity for each source
|
||||||
@@ -498,7 +813,7 @@ app.post('/api/sources', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Update a Prometheus source
|
// Update a Prometheus source
|
||||||
app.put('/api/sources/:id', requireAuth, async (req, res) => {
|
app.put('/api/sources/:id', requireAuth, async (req, res) => {
|
||||||
let { name, url, description, is_server_source } = req.body;
|
let { name, url, description, is_server_source, type } = req.body;
|
||||||
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
|
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
|
||||||
try {
|
try {
|
||||||
await db.query(
|
await db.query(
|
||||||
@@ -530,7 +845,7 @@ app.delete('/api/sources/:id', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test connection to a Prometheus source
|
// Test connection to a Prometheus source
|
||||||
app.post('/api/sources/test', async (req, res) => {
|
app.post('/api/sources/test', requireAuth, async (req, res) => {
|
||||||
let { url, type } = req.body;
|
let { url, type } = req.body;
|
||||||
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
|
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
|
||||||
try {
|
try {
|
||||||
@@ -554,17 +869,26 @@ app.post('/api/sources/test', async (req, res) => {
|
|||||||
app.get('/api/settings', async (req, res) => {
|
app.get('/api/settings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1');
|
const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1');
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.json(getPublicSiteSettings());
|
||||||
|
}
|
||||||
|
return res.json(getPublicSiteSettings(rows[0]));
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
page_name: '数据可视化展示大屏',
|
page_name: '数据可视化展示大屏',
|
||||||
|
show_page_name: 1,
|
||||||
title: '数据可视化展示大屏',
|
title: '数据可视化展示大屏',
|
||||||
logo_url: null,
|
logo_url: null,
|
||||||
|
logo_url_dark: null,
|
||||||
|
favicon_url: null,
|
||||||
show_95_bandwidth: 0,
|
show_95_bandwidth: 0,
|
||||||
p95_type: 'tx',
|
p95_type: 'tx',
|
||||||
blackbox_source_id: null,
|
blackbox_source_id: null,
|
||||||
latency_source: null,
|
latency_source: null,
|
||||||
latency_dest: null,
|
latency_dest: null,
|
||||||
latency_target: null
|
latency_target: null,
|
||||||
|
icp_filing: null,
|
||||||
|
ps_filing: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
@@ -576,29 +900,73 @@ app.get('/api/settings', async (req, res) => {
|
|||||||
|
|
||||||
// Update site settings
|
// Update site settings
|
||||||
app.post('/api/settings', requireAuth, async (req, res) => {
|
app.post('/api/settings', requireAuth, async (req, res) => {
|
||||||
const { page_name, title, logo_url, default_theme, show_95_bandwidth, p95_type, blackbox_source_id, latency_source, latency_dest, latency_target } = req.body;
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Fetch current settings first to preserve fields not sent by the UI
|
||||||
|
const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1');
|
||||||
|
let current = rows.length > 0 ? rows[0] : {};
|
||||||
|
|
||||||
|
// 2. Destructure fields from body
|
||||||
|
const {
|
||||||
|
page_name, show_page_name, title, logo_url, logo_url_dark, favicon_url,
|
||||||
|
default_theme, show_95_bandwidth, p95_type, require_login_for_server_details,
|
||||||
|
icp_filing, ps_filing
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 3. Prepare parameters, prioritizing body but falling back to current
|
||||||
|
const settings = {
|
||||||
|
page_name: page_name !== undefined ? page_name : (current.page_name || '数据可视化展示大屏'),
|
||||||
|
show_page_name: show_page_name !== undefined ? (show_page_name ? 1 : 0) : (current.show_page_name !== undefined ? current.show_page_name : 1),
|
||||||
|
title: title !== undefined ? title : (current.title || '数据可视化展示大屏'),
|
||||||
|
logo_url: logo_url !== undefined ? logo_url : (current.logo_url || null),
|
||||||
|
logo_url_dark: logo_url_dark !== undefined ? logo_url_dark : (current.logo_url_dark || null),
|
||||||
|
favicon_url: favicon_url !== undefined ? favicon_url : (current.favicon_url || null),
|
||||||
|
default_theme: default_theme !== undefined ? default_theme : (current.default_theme || 'dark'),
|
||||||
|
show_95_bandwidth: show_95_bandwidth !== undefined ? (show_95_bandwidth ? 1 : 0) : (current.show_95_bandwidth || 0),
|
||||||
|
p95_type: p95_type !== undefined ? p95_type : (current.p95_type || 'tx'),
|
||||||
|
require_login_for_server_details: require_login_for_server_details !== undefined
|
||||||
|
? (require_login_for_server_details ? 1 : 0)
|
||||||
|
: (current.require_login_for_server_details !== undefined ? current.require_login_for_server_details : 1),
|
||||||
|
blackbox_source_id: current.blackbox_source_id || null, // UI doesn't send this
|
||||||
|
latency_source: current.latency_source || null, // UI doesn't send this
|
||||||
|
latency_dest: current.latency_dest || null, // UI doesn't send this
|
||||||
|
latency_target: current.latency_target || null, // UI doesn't send this
|
||||||
|
icp_filing: icp_filing !== undefined ? icp_filing : (current.icp_filing || null),
|
||||||
|
ps_filing: ps_filing !== undefined ? ps_filing : (current.ps_filing || null)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Update database
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO site_settings (id, page_name, title, logo_url, default_theme, show_95_bandwidth, p95_type, blackbox_source_id, latency_source, latency_dest, latency_target)
|
`INSERT INTO site_settings (
|
||||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
id, page_name, show_page_name, title, logo_url, logo_url_dark, favicon_url,
|
||||||
|
default_theme, show_95_bandwidth, p95_type, require_login_for_server_details,
|
||||||
|
blackbox_source_id, latency_source, latency_dest, latency_target,
|
||||||
|
icp_filing, ps_filing
|
||||||
|
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
page_name = VALUES(page_name),
|
page_name = VALUES(page_name),
|
||||||
|
show_page_name = VALUES(show_page_name),
|
||||||
title = VALUES(title),
|
title = VALUES(title),
|
||||||
logo_url = VALUES(logo_url),
|
logo_url = VALUES(logo_url),
|
||||||
|
logo_url_dark = VALUES(logo_url_dark),
|
||||||
|
favicon_url = VALUES(favicon_url),
|
||||||
default_theme = VALUES(default_theme),
|
default_theme = VALUES(default_theme),
|
||||||
show_95_bandwidth = VALUES(show_95_bandwidth),
|
show_95_bandwidth = VALUES(show_95_bandwidth),
|
||||||
p95_type = VALUES(p95_type),
|
p95_type = VALUES(p95_type),
|
||||||
|
require_login_for_server_details = VALUES(require_login_for_server_details),
|
||||||
blackbox_source_id = VALUES(blackbox_source_id),
|
blackbox_source_id = VALUES(blackbox_source_id),
|
||||||
latency_source = VALUES(latency_source),
|
latency_source = VALUES(latency_source),
|
||||||
latency_dest = VALUES(latency_dest),
|
latency_dest = VALUES(latency_dest),
|
||||||
latency_target = VALUES(latency_target)`,
|
latency_target = VALUES(latency_target),
|
||||||
|
icp_filing = VALUES(icp_filing),
|
||||||
|
ps_filing = VALUES(ps_filing)`,
|
||||||
[
|
[
|
||||||
page_name, title, logo_url, default_theme,
|
settings.page_name, settings.show_page_name, settings.title, settings.logo_url, settings.logo_url_dark, settings.favicon_url,
|
||||||
show_95_bandwidth ? 1 : 0, p95_type || 'tx',
|
settings.default_theme, settings.show_95_bandwidth, settings.p95_type, settings.require_login_for_server_details,
|
||||||
blackbox_source_id || null, latency_source || null,
|
settings.blackbox_source_id, settings.latency_source, settings.latency_dest, settings.latency_target,
|
||||||
latency_dest || null, latency_target || null
|
settings.icp_filing, settings.ps_filing
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating settings:', err);
|
console.error('Error updating settings:', err);
|
||||||
@@ -609,7 +977,7 @@ app.post('/api/settings', requireAuth, async (req, res) => {
|
|||||||
// ==================== Metrics Aggregation ====================
|
// ==================== Metrics Aggregation ====================
|
||||||
|
|
||||||
// Reusable function to get overview metrics
|
// Reusable function to get overview metrics
|
||||||
async function getOverview() {
|
async function getOverview(force = false) {
|
||||||
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"');
|
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"');
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -626,8 +994,12 @@ async function getOverview() {
|
|||||||
|
|
||||||
const allMetrics = await Promise.all(sources.map(async (source) => {
|
const allMetrics = await Promise.all(sources.map(async (source) => {
|
||||||
const cacheKey = `source_metrics:${source.url}:${source.name}`;
|
const cacheKey = `source_metrics:${source.url}:${source.name}`;
|
||||||
|
if (force) {
|
||||||
|
await cache.del(cacheKey);
|
||||||
|
} else {
|
||||||
const cached = await cache.get(cacheKey);
|
const cached = await cache.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metrics = await prometheusService.getOverviewMetrics(source.url, source.name);
|
const metrics = await prometheusService.getOverviewMetrics(source.url, source.name);
|
||||||
@@ -736,7 +1108,8 @@ async function getOverview() {
|
|||||||
// Get all aggregated metrics from all Prometheus sources
|
// Get all aggregated metrics from all Prometheus sources
|
||||||
app.get('/api/metrics/overview', async (req, res) => {
|
app.get('/api/metrics/overview', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const overview = await getOverview();
|
const force = req.query.force === 'true';
|
||||||
|
const overview = await getOverview(force);
|
||||||
res.json(overview);
|
res.json(overview);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching overview metrics:', err);
|
console.error('Error fetching overview metrics:', err);
|
||||||
@@ -747,9 +1120,15 @@ app.get('/api/metrics/overview', async (req, res) => {
|
|||||||
// Get network traffic history (past 24h) from Prometheus
|
// Get network traffic history (past 24h) from Prometheus
|
||||||
app.get('/api/metrics/network-history', async (req, res) => {
|
app.get('/api/metrics/network-history', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const force = req.query.force === 'true';
|
||||||
const cacheKey = 'network_history_all';
|
const cacheKey = 'network_history_all';
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
await cache.del(cacheKey);
|
||||||
|
} else {
|
||||||
const cached = await cache.get(cacheKey);
|
const cached = await cache.get(cacheKey);
|
||||||
if (cached) return res.json(cached);
|
if (cached) return res.json(cached);
|
||||||
|
}
|
||||||
|
|
||||||
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"');
|
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"');
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
@@ -806,7 +1185,7 @@ app.get('/api/metrics/cpu-history', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get detailed metrics for a specific server
|
// Get detailed metrics for a specific server
|
||||||
app.get('/api/metrics/server-details', async (req, res) => {
|
app.get('/api/metrics/server-details', requireServerDetailsAccess, async (req, res) => {
|
||||||
const { instance, job, source } = req.query;
|
const { instance, job, source } = req.query;
|
||||||
|
|
||||||
if (!instance || !job || !source) {
|
if (!instance || !job || !source) {
|
||||||
@@ -831,25 +1210,30 @@ app.get('/api/metrics/server-details', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get historical metrics for a specific server
|
// Get historical metrics for a specific server
|
||||||
app.get('/api/metrics/server-history', async (req, res) => {
|
app.get('/api/metrics/server-history', requireServerDetailsAccess, async (req, res) => {
|
||||||
const { instance, job, source, metric, range, start, end } = req.query;
|
const { instance, job, source, metric, range, start, end } = req.query;
|
||||||
|
|
||||||
if (!instance || !job || !source || !metric) {
|
if (!instance || !job || !source || !metric) {
|
||||||
return res.status(400).json({ error: 'instance, job, source, and metric are required' });
|
return res.status(400).json({ error: 'instance, job, source, and metric are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT url FROM prometheus_sources WHERE name = ?', [source]);
|
const [rows] = await db.query('SELECT url FROM prometheus_sources WHERE name = ?', [source]);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Source not found' });
|
if (rows.length === 0) return res.status(404).json({ error: 'Source not found' });
|
||||||
const sourceUrl = rows[0].url;
|
const sourceUrl = rows[0].url;
|
||||||
const data = await prometheusService.getServerHistory(sourceUrl, instance, job, metric, range, start, end);
|
|
||||||
|
// Fetch p95Type from settings for networkTrend stats
|
||||||
|
let p95Type = 'tx';
|
||||||
|
try {
|
||||||
|
const [settingsRows] = await db.query('SELECT p95_type FROM site_settings WHERE id = 1');
|
||||||
|
if (settingsRows.length > 0) p95Type = settingsRows[0].p95_type;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
const data = await prometheusService.getServerHistory(sourceUrl, instance, job, metric, range, start, end, p95Type);
|
const data = await prometheusService.getServerHistory(sourceUrl, instance, job, metric, range, start, end, p95Type);
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
// SPA fallback
|
// SPA fallback
|
||||||
app.get('*', (req, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
@@ -859,7 +1243,7 @@ app.get('*', (req, res, next) => {
|
|||||||
|
|
||||||
|
|
||||||
// ==================== Latency Routes CRUD ====================
|
// ==================== Latency Routes CRUD ====================
|
||||||
app.get('/api/latency-routes', async (req, res) => {
|
|
||||||
app.get('/api/latency-routes', requireAuth, async (req, res) => {
|
app.get('/api/latency-routes', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
@@ -947,6 +1331,7 @@ app.get('/api/metrics/latency', async (req, res) => {
|
|||||||
// ==================== WebSocket Server ====================
|
// ==================== WebSocket Server ====================
|
||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocket.Server({ server });
|
||||||
let isBroadcastRunning = false;
|
let isBroadcastRunning = false;
|
||||||
|
|
||||||
function broadcast(data) {
|
function broadcast(data) {
|
||||||
@@ -959,23 +1344,67 @@ function broadcast(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast loop
|
// Broadcast loop
|
||||||
|
async function broadcastMetrics() {
|
||||||
|
if (isBroadcastRunning) return;
|
||||||
isBroadcastRunning = true;
|
isBroadcastRunning = true;
|
||||||
try {
|
try {
|
||||||
broadcast({ type: 'overview', data: overview });
|
const overview = await getOverview();
|
||||||
|
|
||||||
|
// Also include latencies in the broadcast to make map lines real-time
|
||||||
|
const [routes] = await db.query(`
|
||||||
|
SELECT r.*, s.url, s.type as source_type
|
||||||
|
FROM latency_routes r
|
||||||
|
JOIN prometheus_sources s ON r.source_id = s.id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const latencyResults = await Promise.all(routes.map(async (route) => {
|
||||||
|
let latency = await cache.get(`latency:route:${route.id}`);
|
||||||
|
if (latency === null && route.source_type === 'prometheus') {
|
||||||
|
latency = await prometheusService.getLatency(route.url, route.latency_target);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: route.id,
|
||||||
|
source: route.latency_source,
|
||||||
|
dest: route.latency_dest,
|
||||||
|
latency: latency
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
broadcast({
|
||||||
|
type: 'overview',
|
||||||
|
data: {
|
||||||
|
...overview,
|
||||||
|
latencies: latencyResults
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// console.error('WS Broadcast error:', err.message);
|
||||||
|
} finally {
|
||||||
isBroadcastRunning = false;
|
isBroadcastRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Start services
|
|
||||||
checkAndFixDatabase();
|
// Start server and services
|
||||||
latencyService.start();
|
async function start() {
|
||||||
|
try {
|
||||||
|
console.log('🔧 Initializing services...');
|
||||||
|
// Ensure DB is ready before starting anything else
|
||||||
await checkAndFixDatabase();
|
await checkAndFixDatabase();
|
||||||
const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000;
|
|
||||||
setInterval(broadcastMetrics, REFRESH_INT);
|
// Start services
|
||||||
latencyService.start();
|
latencyService.start();
|
||||||
server.listen(PORT, HOST, () => {
|
|
||||||
|
const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000;
|
||||||
|
setInterval(broadcastMetrics, REFRESH_INT);
|
||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`\n 🚀 Data Visualization Display Wall (WebSocket Enabled)`);
|
console.log(`\n 🚀 Data Visualization Display Wall (WebSocket Enabled)`);
|
||||||
console.log(` 📊 Server running at http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
|
console.log(` 📊 Server running at http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
|
||||||
});
|
console.log(` ⚙️ Configure Prometheus sources at http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}/settings\n`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Server failed to start:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -558,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]))`,
|
||||||
@@ -626,7 +628,7 @@ 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);
|
||||||
|
|
||||||
@@ -681,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
187
update.sh
Normal 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}"
|
||||||
Reference in New Issue
Block a user