Compare commits
19 Commits
ead4b2580f
...
f262876b25
| Author | SHA1 | Date | |
|---|---|---|---|
| f262876b25 | |||
| bfa50337e5 | |||
| 305f8d5689 | |||
| 785ffe2a7e | |||
| 55b7cbe904 | |||
| b9139093f1 | |||
| f796ddf0a5 | |||
| c9a99f4fb3 | |||
| 302fdcc1a1 | |||
| 5590391779 | |||
| 56993e4f98 | |||
| cd3c35b5e6 | |||
| f0c586a345 | |||
| 00a6491e59 | |||
| 7caa880ebd | |||
| a584792603 | |||
| 1a27327036 | |||
| 814e11588e | |||
| 73c1776e1e |
+43
-35
@@ -1,44 +1,52 @@
|
||||
# MultiSync Central Server Environment Configuration
|
||||
# Copy this file to .env and modify
|
||||
# Nexus Environment Configuration
|
||||
# Copy this file to .env and modify values for your deployment.
|
||||
# Or run the web installer at /app/install.html to auto-generate this file.
|
||||
#
|
||||
# Generate secret keys: openssl rand -hex 32
|
||||
|
||||
# Server binding
|
||||
MULTISYNC_HOST=0.0.0.0
|
||||
MULTISYNC_PORT=8600
|
||||
MULTISYNC_DEBUG=false
|
||||
# ── Brand ──
|
||||
NEXUS_SYSTEM_NAME=Nexus
|
||||
NEXUS_SYSTEM_TITLE=Nexus — 服务器运维管理平台
|
||||
|
||||
# Sync mode: ssh_direct | agent | hybrid
|
||||
# ssh_direct = 无需子服务器部署Agent,中央SSH直连(默认)
|
||||
# agent = 子服务器部署Agent,主动上报心跳
|
||||
# hybrid = 两种模式同时支持
|
||||
MULTISYNC_MODE=ssh_direct
|
||||
# ── Server ──
|
||||
NEXUS_HOST=0.0.0.0
|
||||
NEXUS_PORT=8600
|
||||
|
||||
# Security - CHANGE THESE IN PRODUCTION!
|
||||
MULTISYNC_SECRET_KEY=change-me-use-openssl-rand-hex-32
|
||||
# API key for PHP frontend → Python backend communication (generate: openssl rand -hex 32)
|
||||
MULTISYNC_API_KEY=
|
||||
MULTISYNC_ADMIN_USERNAME=admin
|
||||
MULTISYNC_ADMIN_PASSWORD=
|
||||
# CORS origins — comma-separated. Leave empty for "*" (any origin)
|
||||
MULTISYNC_CORS_ORIGINS=
|
||||
# ── Deployment (auto-set by installer) ──
|
||||
NEXUS_DEPLOY_PATH=/opt/nexus
|
||||
# Comma-separated frontend origins for CORS
|
||||
NEXUS_CORS_ORIGINS=http://localhost:8600
|
||||
# Base URL for Agent heartbeat reporting
|
||||
NEXUS_API_BASE_URL=http://localhost:8600
|
||||
|
||||
# SSH security
|
||||
MULTISYNC_SSH_STRICT_HOST_CHECKING=true
|
||||
# ── Database (MySQL, REQUIRED) ──
|
||||
NEXUS_DATABASE_URL=mysql+aiomysql://root:password@127.0.0.1:3306/nexus
|
||||
# Pool params: auto-calculated by installer from MySQL max_connections
|
||||
# Formula: pool_size = max(20, maxConn*0.4), overflow = max(20, maxConn*0.3)
|
||||
# Default below matches MySQL max_connections=400 (160+120=280 max)
|
||||
# install wizard will overwrite these with values tuned to your MySQL instance
|
||||
NEXUS_DB_POOL_SIZE=160
|
||||
NEXUS_DB_MAX_OVERFLOW=120
|
||||
|
||||
# Database (MySQL)
|
||||
MULTISYNC_DATABASE_URL=mysql+pymysql://root:password@127.0.0.1:3306/multisync
|
||||
# ── Security (REQUIRED — generate with: openssl rand -hex 32) ──
|
||||
NEXUS_SECRET_KEY=change-me-use-openssl-rand-hex-32
|
||||
NEXUS_API_KEY=change-me-use-openssl-rand-hex-32
|
||||
NEXUS_ENCRYPTION_KEY=
|
||||
|
||||
# Health check
|
||||
MULTISYNC_HEALTH_CHECK_INTERVAL=30
|
||||
MULTISYNC_HEALTH_CHECK_TIMEOUT=10
|
||||
# ── Redis (REQUIRED) ──
|
||||
NEXUS_REDIS_URL=redis://127.0.0.1:6379/0
|
||||
|
||||
# Push concurrency (max simultaneous rsync)
|
||||
MULTISYNC_MAX_CONCURRENT_PUSHES=5
|
||||
# ── SSH ──
|
||||
NEXUS_SSH_STRICT_HOST_CHECKING=false
|
||||
|
||||
# Telegram Bot alerts (optional)
|
||||
# Get token from @BotFather, chat_id from @userinfobot
|
||||
MULTISYNC_TELEGRAM_BOT_TOKEN=
|
||||
MULTISYNC_TELEGRAM_CHAT_ID=
|
||||
# ── Alert Thresholds (%) ──
|
||||
NEXUS_CPU_ALERT_THRESHOLD=80
|
||||
NEXUS_MEM_ALERT_THRESHOLD=80
|
||||
NEXUS_DISK_ALERT_THRESHOLD=80
|
||||
|
||||
# Paths (宝塔面板默认)
|
||||
MULTISYNC_LOG_DIR=/www/wwwlogs
|
||||
MULTISYNC_BACKUP_DIR=/www/backup/multisync
|
||||
# ── Health Check ──
|
||||
NEXUS_HEALTH_CHECK_INTERVAL=60
|
||||
|
||||
# ── Telegram Alerts (optional — configure in Settings UI) ──
|
||||
NEXUS_TELEGRAM_BOT_TOKEN=
|
||||
NEXUS_TELEGRAM_CHAT_ID=
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
cd /www/wwwroot/nexus
|
||||
cd ${{ secrets.DEPLOY_PATH || '/opt/nexus' }}
|
||||
git pull origin main
|
||||
pip install -r requirements.txt
|
||||
# Restart Python backend (Supervisor/systemd)
|
||||
|
||||
@@ -9,6 +9,8 @@ build/
|
||||
|
||||
# Environment (NEVER commit — contains production credentials)
|
||||
.env
|
||||
.install_locked
|
||||
.install_state.json
|
||||
SECRETS.md
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
# Nexus 6.0 — Project Memory
|
||||
|
||||
## 项目概述
|
||||
Nexus 是一个 2000+ 服务器运维管理平台,从旧的 MultiSync (PHP + AdminLTE + pymysql) 完全重构为 Clean Architecture (FastAPI + Async SQLAlchemy + Redis + WebSocket + Telegram)。
|
||||
Nexus 是一个 2000+ 服务器运维管理平台,从旧的 MultiSync (PHP + AdminLTE + pymysql) 完全重构为 Clean Architecture (FastAPI + Async SQLAlchemy + Redis + WebSocket + Telegram)。前端已全面迁移到 Tailwind CSS v4 + Alpine.js,PHP 依赖已移除。
|
||||
|
||||
## 仓库结构
|
||||
- **唯一仓库**: Nexus (Gitea `http://admin:uzumaki77@66.154.115.8:3000/admin/Nexus.git`)
|
||||
- **唯一仓库**: Nexus (Gitea, 仓库地址在 .env 中配置)
|
||||
- **不再使用** multi-server-sync 仓库(旧项目,仅保留历史)
|
||||
- **部署路径**: `/www/wwwroot/api.synaglobal.vip/` (宝塔面板)
|
||||
- **域名**: `api.synaglobal.vip` (强制HTTPS)
|
||||
- **部署路径**: 由 `NEXUS_DEPLOY_PATH` 环境变量决定(安装向导自动写入 .env)
|
||||
- **域名/CORS**: 由 `NEXUS_CORS_ORIGINS` 环境变量决定(安装向导自动写入)
|
||||
|
||||
## 目录结构 (仓库根目录)
|
||||
```
|
||||
Nexus/
|
||||
├── server/ ← Python FastAPI后端 (Clean Architecture 4层)
|
||||
├── web/ ← PHP前端 (install.php, config.php, login.php...)
|
||||
│ ├── api/ ← API路由 (含 install.py 安装向导API)
|
||||
│ ├── domain/ ← SQLAlchemy模型 (14张表)
|
||||
│ ├── infrastructure/← SSH连接池 + Redis + Telegram
|
||||
│ ├── background/ ← 后台任务 (心跳刷新/自检/调度/重试)
|
||||
│ └── config.py ← Pydantic Settings + MySQL覆盖
|
||||
├── web/app/ ← Tailwind+Alpine.js 纯静态前端 (12个HTML页面)
|
||||
│ ├── install.html ← 安装向导 (5步)
|
||||
│ ├── login.html ← JWT登录+TOTP双因素
|
||||
│ └── ... ← index/servers/files/push/scripts等
|
||||
├── deploy/ ← Supervisor配置 + Shell健康检查脚本
|
||||
├── docs/ ← 部署文档
|
||||
├── docs/ ← 项目文档
|
||||
├── tests/ ← 测试
|
||||
├── .env ← NOT IN GIT (install.php生成)
|
||||
├── .gitignore ← 排除 .env, SECRETS.md, web/data/config.php
|
||||
├── .env ← NOT IN GIT (安装向导生成)
|
||||
└── requirements.txt ← Python依赖
|
||||
```
|
||||
|
||||
## 启动模式
|
||||
- **安装模式** (无 .env): 仅 `/api/install/` + 静态文件可用,其他路由返回503
|
||||
- **正常模式** (有 .env): 完整初始化 DB/Redis/后台任务
|
||||
|
||||
## 已确认的关键决策
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
@@ -30,7 +41,7 @@ Nexus/
|
||||
| SECRET_KEY | ❌ 禁改 | 加密回退密钥 |
|
||||
| DATABASE_URL | ❌ 禁改 | 启动必需 |
|
||||
| system_name/title | ✅ 可改 | 前端显示 |
|
||||
| pool_size/overflow | ✅ 可改 | install自动推荐 |
|
||||
| pool_size/overflow | ✅ 可改 | 安装向导自动推荐 |
|
||||
| redis_url | ✅ 可改 | Redis连接 |
|
||||
| 告警阈值 (CPU/mem/disk) | ✅ 可改 | 监控参数 (默认80%) |
|
||||
| Telegram配置 | ✅ 可改 | 告警推送 |
|
||||
@@ -39,43 +50,54 @@ Nexus/
|
||||
| 心跳数据流 | Agent→Redis→前端直读→10min→MySQL | 前端实时, MySQL只存历史 |
|
||||
| 告警推送 | WebSocket→前端 + Telegram | 即时感知 |
|
||||
| Python守护 | 3层: Supervisor + Python自检(30s) + Shell(cron 1min) | 全覆盖 |
|
||||
| install.php安装后 | rename→install.php.locked + .env锁定 | 防重装 |
|
||||
| 配置源 | install→config.php(PHP)+settings表(MySQL)+.env(Python) | 三方一致 |
|
||||
| 安装向导 | install.html + FastAPI API | D2: 替代install.php |
|
||||
| 配置源 | 安装向导→config.php(PHP兼容)+settings表(MySQL)+.env(Python) | 三写一致 |
|
||||
| Agent告警心跳 | CPU/内存>80% 或变化>10% 时主动上报 | 智能监控 |
|
||||
| 仓库统一 | 所有代码统一在Nexus仓库 | 不再用两个仓库 |
|
||||
|
||||
## 实现状态 (Phase A+B+C)
|
||||
## 实现状态 (Phase A+B+C+D) — ~95% 完成
|
||||
|
||||
### ✅ 已完成
|
||||
### ✅ 已完成 (全部6步 + 技术债务)
|
||||
| 模块 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| **Step 0: 基础设施** | | Redis + WebSocket |
|
||||
| E1 WebSocket推送 | server/api/websocket.py | alert/recovery/system broadcast |
|
||||
| E2 心跳→Redis+告警 | server/api/agent.py | Redis write + threshold detection |
|
||||
| E3 Redis→MySQL刷新 | server/background/heartbeat_flush.py | 10min batch |
|
||||
| **Step 1: 数据层** | | 14张表 |
|
||||
| E4 load_settings_from_db | server/config.py | MySQL overrides mutable settings |
|
||||
| E5 Python自检 | server/background/self_monitor.py | 30s loop |
|
||||
| E6 Telegram推送 | server/infrastructure/telegram/__init__.py | alert/recovery/system |
|
||||
| E7 Redis实时状态 | server/api/servers.py | frontend reads Redis directly |
|
||||
| E8 /health端点 | server/api/health.py | Shell health_monitor.sh checks this |
|
||||
| E9 lifespan+后台任务 | server/main.py | startup: init_db + load_settings + background tasks |
|
||||
| D1 Supervisor | deploy/nexus.conf | autorestart nexus process |
|
||||
| D2 Shell健康检查 | deploy/health_monitor.sh | cron 1min, 3 failures→restart+Telegram |
|
||||
| P1 install.php | web/install.php | 5步向导 (欢迎→环境→DB+Redis+API→管理员+品牌→完成清单) |
|
||||
| P2 config.php | web/config.php | data/config.php + MySQL settings override |
|
||||
| **Step 2: 认证层** | | JWT + TOTP |
|
||||
| **Step 3: Web SSH** | | asyncssh + xterm.js |
|
||||
| E5 asyncssh连接池 | server/infrastructure/ssh/asyncssh_pool.py | 引用计数模式 |
|
||||
| E6 WebSSH前端 | web/app/terminal.html | xterm.js + Koko协议 + 自动resize |
|
||||
| **Step 4: Sync引擎** | | 4种同步模式 |
|
||||
| **Step 5: 前端迁移** | | Tailwind+Alpine.js |
|
||||
| E7 12个前端页面 | web/app/*.html | 含install.html安装向导 |
|
||||
| E8 安装向导API | server/api/install.py | 6端点, 无JWT, 临时引擎 |
|
||||
| E9 条件启动模式 | server/main.py | 无.env=安装模式 |
|
||||
| **后台任务** | | |
|
||||
| E10 Python自检 | server/background/self_monitor.py | 30s loop |
|
||||
| E11 Telegram推送 | server/infrastructure/telegram/__init__.py | alert/recovery/system |
|
||||
| E12 /health端点 | server/api/health.py | Shell health_monitor.sh checks this |
|
||||
| **技术债务** | | |
|
||||
| D1 ✅ paramiko删除 | — | pool.py已删除, 全部迁移asyncssh |
|
||||
| D2 ✅ install迁移 | server/api/install.py | install.php→install.html+API |
|
||||
| D3 ✅ WebSSH前端 | web/app/terminal.html | xterm.js+Koko协议+全屏 |
|
||||
| D4 文件编辑器 | — | 低优先级, Monaco/ACE |
|
||||
|
||||
### 🔄 待测试 (Phase D)
|
||||
### 🔄 待测试
|
||||
- T1: 心跳Redis写入验证
|
||||
- T2: WebSocket连接测试
|
||||
- T3: 3层守护测试 (kill Python→Supervisor重启)
|
||||
- T4: Telegram推送测试
|
||||
- T5: install.php 5步流程测试
|
||||
- T5: install.html 5步流程测试
|
||||
|
||||
### 测试流程 (用户确认)
|
||||
1. WSL本地基础测试 → 2. 推到仓库 → 3. 外网服务器正式测试
|
||||
|
||||
## 配置三写机制
|
||||
install.php Step 3 POST处理同时写入:
|
||||
1. `web/data/config.php` — PHP前端配置 (API_BASE_URL, DB_*, API_KEY)
|
||||
安装向导 Step 3 POST处理同时写入:
|
||||
1. `web/data/config.php` — PHP兼容配置 (API_BASE_URL, DB_*, API_KEY)
|
||||
2. `.env` — Python后端启动必需 (NEXUS_前缀)
|
||||
3. `settings` MySQL表 — 共享动态配置 (api_key, thresholds, Telegram等)
|
||||
|
||||
@@ -93,4 +115,4 @@ Agent心跳(60s) → Redis(实时) → 前端直读 → 10min批量 → MySQL(
|
||||
恢复: 之前告警的指标恢复正常 → 自动推送恢复通知
|
||||
Redis心跳key: heartbeat:{server_id} (HSET, TTL=600s)
|
||||
Redis告警key: alerts:{server_id} (SET, TTL=3600s)
|
||||
```
|
||||
```
|
||||
|
||||
+11
-15
@@ -13,28 +13,24 @@
|
||||
# 2. If fails >= 3 consecutive times → restart via supervisorctl + Telegram alert
|
||||
# 3. If restart succeeds → Telegram recovery notification
|
||||
# 4. If restart fails → Telegram "needs human intervention" alert
|
||||
#
|
||||
# *** Configure DEPLOY_DIR and NEXUS_SERVICE before deploying ***
|
||||
|
||||
DEPLOY_DIR="${NEXUS_DEPLOY_DIR:-/opt/nexus}"
|
||||
NEXUS_SERVICE="${NEXUS_SERVICE:-nexus}"
|
||||
FAIL_FILE="/tmp/nexus_health_fail_count"
|
||||
MAX_FAIL=3
|
||||
HEALTH_URL="http://127.0.0.1:8600/health"
|
||||
SUPERVISOR_SERVICE="nexus"
|
||||
|
||||
# ── Read Telegram config from PHP config file (shared with Python) ──
|
||||
# This avoids hardcoding Telegram credentials in this shell script
|
||||
CONFIG_PHP="/www/wwwroot/api.synaglobal.vip/web/data/config.php"
|
||||
# ── Read Telegram config from .env file ──
|
||||
DOTENV="$DEPLOY_DIR/.env"
|
||||
|
||||
BOT_TOKEN=""
|
||||
CHAT_ID=""
|
||||
|
||||
if [ -f "$CONFIG_PHP" ]; then
|
||||
BOT_TOKEN=$(grep 'TELEGRAM_BOT_TOKEN' "$CONFIG_PHP" 2>/dev/null | sed "s/.*'\(.*\)'.*/\1/" || echo "")
|
||||
CHAT_ID=$(grep 'TELEGRAM_CHAT_ID' "$CONFIG_PHP" 2>/dev/null | sed "s/.*'\(.*\)'.*/\1/" || echo "")
|
||||
fi
|
||||
|
||||
# Fallback: read from .env if config.php not yet generated (during initial setup)
|
||||
if [ -z "$BOT_TOKEN" ] && [ -f "/www/wwwroot/api.synaglobal.vip/.env" ]; then
|
||||
BOT_TOKEN=$(grep '^NEXUS_TELEGRAM_BOT_TOKEN=' /www/wwwroot/api.synaglobal.vip/.env 2>/dev/null | cut -d'=' -f2- || echo "")
|
||||
CHAT_ID=$(grep '^NEXUS_TELEGRAM_CHAT_ID=' /www/wwwroot/api.synaglobal.vip/.env 2>/dev/null | cut -d'=' -f2- || echo "")
|
||||
if [ -f "$DOTENV" ]; then
|
||||
BOT_TOKEN=$(grep '^NEXUS_TELEGRAM_BOT_TOKEN=' "$DOTENV" 2>/dev/null | cut -d'=' -f2- || echo "")
|
||||
CHAT_ID=$(grep '^NEXUS_TELEGRAM_CHAT_ID=' "$DOTENV" 2>/dev/null | cut -d'=' -f2- || echo "")
|
||||
fi
|
||||
|
||||
send_telegram() {
|
||||
@@ -56,7 +52,7 @@ if ! curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
if [ "$FAIL" -ge "$MAX_FAIL" ]; then
|
||||
# Consecutive failures >= threshold → attempt restart
|
||||
send_telegram "🔴 <b>Nexus后端连续${MAX_FAIL}次无响应</b>\n正在通过Supervisor自动重启..."
|
||||
supervisorctl restart "$SUPERVISOR_SERVICE" > /dev/null 2>&1
|
||||
supervisorctl restart "$NEXUS_SERVICE" > /dev/null 2>&1
|
||||
sleep 5
|
||||
|
||||
# Verify restart
|
||||
@@ -71,4 +67,4 @@ if ! curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
else
|
||||
# Python is responding normally
|
||||
echo "0" > "$FAIL_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
+9
-5
@@ -1,4 +1,4 @@
|
||||
# Nexus — Supervisor Process Guardian (JC1: Updated for v6.0)
|
||||
# Nexus — Supervisor Process Guardian
|
||||
# - --workers N for multi-core concurrency
|
||||
# - --loop uvloop for 40-50% QPS improvement
|
||||
# - --http httptools for fast HTTP parsing
|
||||
@@ -6,10 +6,14 @@
|
||||
# Install: sudo cp nexus.conf /etc/supervisor/conf.d/nexus.conf
|
||||
# Start: sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus
|
||||
# Set workers: export NEXUS_WORKERS=$(nproc)
|
||||
#
|
||||
# *** Before deploying, replace ALL {{PLACEHOLDER}} values with your actual paths ***
|
||||
# {{DEPLOY_DIR}} = installation directory (e.g. /opt/nexus or /www/wwwroot/your.domain)
|
||||
# {{VENV_DIR}} = Python venv directory (e.g. /opt/nexus/venv)
|
||||
|
||||
[program:nexus]
|
||||
command=/www/wwwroot/api.synaglobal.vip/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port 8600 --workers %(ENV_NEXUS_WORKERS)s --loop uvloop --http httptools --log-level info
|
||||
directory=/www/wwwroot/api.synaglobal.vip
|
||||
command={{DEPLOY_DIR}}/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port 8600 --workers %(ENV_NEXUS_WORKERS)s --loop uvloop --http httptools --log-level info
|
||||
directory={{DEPLOY_DIR}}
|
||||
user=root
|
||||
autostart=true
|
||||
autorestart=true
|
||||
@@ -19,10 +23,10 @@ stopwaitsecs=15
|
||||
stopsignal=INT
|
||||
environment=
|
||||
NEXUS_WORKERS="%(ENV_NEXUS_WORKERS)s",
|
||||
PATH="/www/wwwroot/api.synaglobal.vip/venv/bin:%(ENV_PATH)s"
|
||||
PATH="{{VENV_DIR}}/bin:%(ENV_PATH)s"
|
||||
stderr_logfile=/var/log/nexus/error.log
|
||||
stdout_logfile=/var/log/nexus/access.log
|
||||
stderr_logfile_maxbytes=50MB
|
||||
stdout_logfile_maxbytes=50MB
|
||||
stderr_logfile_backups=5
|
||||
stdout_logfile_backups=5
|
||||
stdout_logfile_backups=5
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Nexus v6.0 — Nginx Reverse Proxy Config (JC2+JC3)
|
||||
# Target: 172.31.170.47 (WSL internal)
|
||||
# Backend: uvicorn 0.0.0.0:8600 (multi-worker with uvloop)
|
||||
# Frontend: /web/app/ (Tailwind CSS v4 + Alpine.js, pure static, no PHP-FPM)
|
||||
# Nexus v6.0 — Nginx Reverse Proxy Config (Template)
|
||||
# *** Copy this file and replace ALL {{PLACEHOLDER}} values before deploying ***
|
||||
#
|
||||
# JC3: PHP-FPM removed — all pages migrated to pure HTML/JS
|
||||
# {{SERVER_NAME}} = your domain or IP (e.g. nexus.example.com or 192.168.1.100)
|
||||
# {{DEPLOY_DIR}} = installation root (e.g. /opt/nexus or /www/wwwroot/nexus.example.com)
|
||||
# {{LOG_DIR}} = nginx log directory (e.g. /var/log/nginx or /www/wwwlogs)
|
||||
# {{PHP_SOCK}} = PHP-FPM socket path (e.g. unix:/tmp/php-cgi-82.sock) — only if install.php is used
|
||||
|
||||
# ── Upstream: Python FastAPI backend ──
|
||||
upstream nexus_api {
|
||||
@@ -13,10 +14,10 @@ upstream nexus_api {
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name 172.31.170.47;
|
||||
server_name {{SERVER_NAME}};
|
||||
|
||||
# NEW: Document root = new Tailwind app directory (no more PHP)
|
||||
root /www/wwwroot/api.synaglobal.vip/web/app;
|
||||
# Document root = frontend static files
|
||||
root {{DEPLOY_DIR}}/web/app;
|
||||
index index.html;
|
||||
|
||||
# ── Python API proxy ──
|
||||
@@ -40,7 +41,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 3600s; # Long-lived for terminal sessions
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
@@ -50,9 +51,18 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# ── Legacy install.php redirect ──
|
||||
location = /install.php {
|
||||
return 301 /app/install.html;
|
||||
# ── install.php (PHP-FPM for installer, root = /web/) ──
|
||||
location ~ ^/install\.php$ {
|
||||
root {{DEPLOY_DIR}}/web;
|
||||
fastcgi_pass {{PHP_SOCK}};
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# ── Installer + Agent assets (root = /web/) ──
|
||||
location ~ ^/(css|js|vendor|agent)/ {
|
||||
root {{DEPLOY_DIR}}/web;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── Static assets ──
|
||||
@@ -70,15 +80,15 @@ server {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# ── SPA fallback: all paths → index.html ──
|
||||
# ── Static HTML pages (no SPA — each page is standalone) ──
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# ── Logging ──
|
||||
access_log /www/wwwlogs/172.31.170.47_nexus.log;
|
||||
error_log /www/wwwlogs/172.31.170.47_nexus.error.log;
|
||||
access_log {{LOG_DIR}}/{{SERVER_NAME}}_nexus.log;
|
||||
error_log {{LOG_DIR}}/{{SERVER_NAME}}_nexus.error.log;
|
||||
|
||||
# ── Upload limit ──
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# Nexus v6.0 — Nginx Production Config (HTTPS Template)
|
||||
# *** Copy this file and replace ALL {{PLACEHOLDER}} values before deploying ***
|
||||
#
|
||||
# {{SERVER_NAME}} = your domain (e.g. nexus.example.com)
|
||||
# {{DEPLOY_DIR}} = installation root (e.g. /opt/nexus or /www/wwwroot/nexus.example.com)
|
||||
# {{LOG_DIR}} = nginx log directory (e.g. /var/log/nginx or /www/wwwlogs)
|
||||
# {{SSL_CERT}} = SSL certificate fullchain path
|
||||
# {{SSL_KEY}} = SSL private key path
|
||||
# {{PHP_SOCK}} = PHP-FPM socket path (e.g. unix:/tmp/php-cgi-82.sock) — only if install.php is used
|
||||
|
||||
# ── Upstream: Python FastAPI backend ──
|
||||
upstream nexus_api {
|
||||
server 127.0.0.1:8600;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# ── HTTP → HTTPS redirect ──
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{SERVER_NAME}};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# ── HTTPS ──
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name {{SERVER_NAME}};
|
||||
|
||||
# SSL
|
||||
ssl_certificate {{SSL_CERT}};
|
||||
ssl_certificate_key {{SSL_KEY}};
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Document root = frontend static files
|
||||
root {{DEPLOY_DIR}}/web/app;
|
||||
index index.html;
|
||||
|
||||
# ── install.php (PHP-FPM for installer, root = /web/) ──
|
||||
location ~ ^/install\.php$ {
|
||||
root {{DEPLOY_DIR}}/web;
|
||||
fastcgi_pass {{PHP_SOCK}};
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# ── Installer + Agent assets (root = /web/) ──
|
||||
location ~ ^/(css|js|vendor|agent)/ {
|
||||
root {{DEPLOY_DIR}}/web;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── Python API proxy ──
|
||||
location /api/ {
|
||||
proxy_pass http://nexus_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# ── WebSocket proxy (alerts + WebSSH terminal) ──
|
||||
location /ws/ {
|
||||
proxy_pass http://nexus_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# ── Health endpoint ──
|
||||
location /health {
|
||||
proxy_pass http://nexus_api;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# ── Static assets (cache aggressively) ──
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── Deny sensitive files ──
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
location ~ /data/config\.php$ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# ── Static HTML pages (no SPA — each page is standalone) ──
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# ── Logging ──
|
||||
access_log {{LOG_DIR}}/{{SERVER_NAME}}.log;
|
||||
error_log {{LOG_DIR}}/{{SERVER_NAME}}.error.log;
|
||||
|
||||
# ── Upload limit ──
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
+64
-25
@@ -1,33 +1,75 @@
|
||||
# Nexus 文档索引
|
||||
|
||||
> 最后更新: 2026-05-21
|
||||
> 最后更新: 2026-05-22
|
||||
|
||||
---
|
||||
|
||||
## project/ — 项目概况
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `project/status.md` | 项目进度总览 + 数据库结构 + 迭代计划 |
|
||||
| `project/roadmap.md` | 功能规划 + ADR架构决策 + 实现顺序 |
|
||||
| `project/deploy.md` | 部署指南 |
|
||||
|
||||
## team/ — 部门协作
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `team/collaboration-charter.md` | 部门协作章程(5阶段流水线) |
|
||||
| `team/roster-2026-05-21.md` | **团队编制档案(194人/9部门)** |
|
||||
| `team/roles/` | 12个部门角色定义文档 |
|
||||
| `project/status.md` | 项目进度总览 + 数据库结构 + 实施进度 (~85%) |
|
||||
| `project/roadmap.md` | 功能规划 + ADR架构决策(11项) + 技术债务 |
|
||||
| `project/deploy.md` | 部署指南 (Ubuntu 20-24, Nginx+Supervisor) |
|
||||
| `project/tech-stack-inventory.md` | 技术栈清单 + 连接池参数对齐 + v1勘误 |
|
||||
|
||||
## design/ — 设计规格与实施计划
|
||||
| 目录 | 说明 |
|
||||
|
||||
### 活跃设计
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `design/specs/` | 10个设计规格(推送/密码/重试/文件管理器/架构/等) |
|
||||
| `design/plans/` | 5个实施计划 |
|
||||
| `design/specs/design-standards.md` | **设计标准** — 安全模型/性能基线/代码规范/架构模式 |
|
||||
| `design/specs/2026-05-18-redis-iot-optimization.md` | Redis+实时数据架构 + Key规范 |
|
||||
| `design/specs/2026-05-17-architecture-optimization.md` | 配置分发与部署架构 |
|
||||
| `design/specs/2026-05-19-sprint-planning.md` | Sprint规划 — 已完成升级项 + 待实现建议 |
|
||||
| `design/tech-stack-evaluation.md` | 技术栈评估 + 风险矩阵 + 连接池配置 |
|
||||
|
||||
### 已归档设计(历史参考)
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `design/specs/2026-05-16-password-presets-design.md` | ⚠️ 归档 — 密码预设设计(引用旧文件结构) |
|
||||
| `design/specs/2026-05-16-simplified-push-design.md` | ⚠️ 归档 — 简化推送设计(引用旧文件结构) |
|
||||
| `design/specs/2026-05-17-retry-progress-design.md` | ⚠️ 归档 — 重试+进度设计(引用旧文件结构) |
|
||||
| `design/specs/2026-05-17-server-add-optimize-design.md` | ⚠️ 归档 — 服务器添加优化设计(引用旧文件结构) |
|
||||
| `design/specs/2026-05-18-file-manager-v2-design.md` | ⚠️ 归档 — 文件管理器v2设计(引用旧文件结构) |
|
||||
| `design/specs/2026-05-19-code-review-followup.md` | ⚠️ 归档 — PHP代码审查(前端已迁移到Tailwind) |
|
||||
| `design/plans/2026-05-16-password-presets.md` | ⚠️ 归档 — 密码预设实施计划 |
|
||||
| `design/plans/2026-05-16-simplified-push.md` | ⚠️ 归档 — 简化推送实施计划 |
|
||||
| `design/plans/2026-05-17-retry-progress.md` | ⚠️ 归档 — 重试+进度实施计划 |
|
||||
| `design/plans/2026-05-17-server-add-optimize.md` | ⚠️ 归档 — 服务器添加优化实施计划 |
|
||||
| `design/plans/2026-05-18-file-manager-v2.md` | ⚠️ 归档 — 文件管理器v2实施计划 |
|
||||
|
||||
## reports/ — 审查与工作报告
|
||||
|
||||
### 部门审查报告
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `reports/review-cto-2026-05-21.md` | CTO审查 — 连接池/SSH/CORS/依赖(含勘误) |
|
||||
| `reports/review-deploy-architecture-2026-05-21.md` | 运维总监审查 — 部署架构/三层守护/Nginx(含勘误) |
|
||||
| `reports/review-quality-director-2026-05-21.md` | 质量总监审查 — 代码规范/datetime/测试(含勘误) |
|
||||
| `reports/step-0-infrastructure/ecc-security-report.md` | ECC组安全审查 — Redis/Schema/认证/SSH/编码(含勘误) |
|
||||
| `reports/signoff-2026-05-21.md` | 审查签字确认 |
|
||||
| `reports/session-log-2026-05-21.md` | 会话操作日志 |
|
||||
|
||||
### 实施报告
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `reports/implementation-progress-2026-05-21.md` | 6步实施进度总览 |
|
||||
| `reports/step-0-infrastructure/engineering-report.md` | Step 0: 基础设施加固 |
|
||||
| `reports/step-0-infrastructure/ag-code-review-report.md` | Step 0: 代码审查 |
|
||||
| `reports/step-0-infrastructure/testing-report.md` | Step 0: 测试报告 |
|
||||
| `reports/step-1-data-layer/engineering-report.md` | Step 1: 数据层 |
|
||||
| `reports/step-2-auth/engineering-report.md` | Step 2: 认证层 |
|
||||
| `reports/step-3-webssh/engineering-report.md` | Step 3: Web SSH(含paramiko→asyncssh迁移) |
|
||||
| `reports/step-4-sync/engineering-report.md` | Step 4: Sync引擎 |
|
||||
| `reports/step-5-frontend/engineering-report.md` | Step 5: 前端迁移 |
|
||||
| `reports/step-5-frontend/design-review-report.md` | Step 5: 前端设计审查 |
|
||||
| `reports/step-5-frontend/product-department-report.md` | Step 5: 产品部报告 |
|
||||
|
||||
## research/ — 竞品与技术调研
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `research/jumpserver-easynode-architecture-analysis.md` | JumpServer & EasyNode 架构分析 |
|
||||
| `research/jumpserver-easynode-architecture-analysis.md` | JumpServer & EasyNode 架构对比分析 |
|
||||
| `research/ag-research-report.md` | AI Agent 调研报告 |
|
||||
|
||||
## changelog/ — 修复记录
|
||||
| 文件 | 说明 |
|
||||
@@ -42,17 +84,14 @@
|
||||
| `memory/MEMORY.md` | 记忆索引 |
|
||||
| `memory/mem_*.md` | 17个记忆文件(概述/决策/数据流/守护/模块/测试/等) |
|
||||
|
||||
## team/ — 部门协作
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `team/collaboration-charter.md` | 部门协作章程(5阶段流水线) |
|
||||
| `team/roster-2026-05-21.md` | 团队编制档案(194人/9部门) |
|
||||
| `team/roles/` | 12个部门角色定义文档 |
|
||||
|
||||
## skills/ — 团队技能定义(AI辅助)
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `skills/` | 194个技能文件(各部门AI技能定义) |
|
||||
|
||||
## security/ — 安全策略
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `security/` | 安全策略与规范(待填充) |
|
||||
|
||||
## reports/ — 工作报告
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `reports/` | 各部门工作报告(审查/实施阶段) |
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 密码预设 实现计划
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`;PHP 前端 (`servers.php`/`settings.php`) 已迁移到 Tailwind CSS v4 + Alpine.js (`web/app/servers.html`/`settings.html`);认证已从 X-API-Key 改为 JWT。功能已实现,保留本文档供历史参考。
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:executing-plans 逐任务实现此计划。
|
||||
|
||||
**目标:** 提供加密存储的 SSH 密码预设列表,新建服务器时下拉选择自动填充
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 简化推送模型 实现计划
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`;`server/api/tasks.py` 已删除;PHP 前端 (`servers.php`/`tasks.php`/`push.php`) 已迁移到 Tailwind CSS v4 + Alpine.js;sync_engine 已改为 asyncssh。功能已实现,保留本文档供历史参考。
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 将复杂 SyncTask 模型简化为 Server.target_path + 批量 push API + 分类选择推送界面
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 推送失败重试 + 实时进度 实现计划
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`;PHP 前端 (`retry.php`/`servers.php`) 已迁移到 Tailwind CSS v4 + Alpine.js (`web/app/retries.html`/`servers.html`);`datetime.utcnow()` 已弃用,代码已改为 `datetime.now(timezone.utc)`。功能已实现,保留本文档供历史参考。
|
||||
|
||||
> **面向 AI 代理的工作者:** 使用 superpowers:executing-plans 逐任务实现。
|
||||
|
||||
**目标:** 失败推送自动 5 分钟重试 + rsync dry-run 一致性验证后消失 + 推送时 WebSocket 实时进度条
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# servers.php 添加服务器优化 — 实现计划
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`web/servers.php` 已迁移到 `web/app/servers.html` (Tailwind CSS v4 + Alpine.js);`server/services/ssh_direct.py` 已被 asyncssh 连接池替代;`server/database.py` 已拆分为 `server/domain/models/`。功能已实现,保留本文档供历史参考。
|
||||
|
||||
**基于设计**: docs/superpowers/specs/2026-05-17-server-add-optimize-design.md
|
||||
**日期**: 2026-05-17
|
||||
|
||||
@@ -48,8 +51,8 @@ PUBLISH install:3:log {"percent":100,"line":"✅ 安装完成","status":"done"}
|
||||
|
||||
**安装命令**:
|
||||
```bash
|
||||
curl -fsSL https://api.synaglobal.vip/agent/install.sh | bash -s -- \
|
||||
--url http://api.synaglobal.vip:8600 \
|
||||
curl -fsSL https://${NEXUS_DOMAIN}/agent/install.sh | bash -s -- \
|
||||
--url http://${NEXUS_DOMAIN}:8600 \
|
||||
--key <agent_api_key> \
|
||||
--dirs <target_path>
|
||||
```
|
||||
@@ -189,7 +192,7 @@ function savePath(id) {
|
||||
|
||||
## 任务 8 — 端到端验证
|
||||
|
||||
1. 访问 `https://api.synaglobal.vip/servers.php?action=add`
|
||||
1. 访问 `https://${NEXUS_DOMAIN}/servers.html` (添加服务器页面)
|
||||
2. 填写表单、提交
|
||||
3. 列表出现安装进度
|
||||
4. 安装完成 → 绿点在线
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 文件管理器 v2 实现计划
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`web/files.php` 已迁移到 `web/app/files.html` (Tailwind CSS v4 + Alpine.js + SFTP API);`web/file_actions.php` 和 `web/browse_dirs.php` 已不存在,文件操作通过 `server/api/sync_v2.py` SFTP 端点实现。功能已实现,保留本文档供历史参考。 实现计划
|
||||
|
||||
> **面向 AI 代理的工作者:** 使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 将 files.php 重构为专业文件管理器:目录树 + 文件列表 + 右键菜单 + 快捷键 + Monaco Editor + 拖拽上传
|
||||
@@ -572,12 +575,12 @@ python -m py_compile server/api/servers.py
|
||||
|
||||
```bash
|
||||
git push origin master
|
||||
ssh root@47.76.187.108 "cd /www/wwwroot/api.synaglobal.vip && git pull origin master"
|
||||
ssh root@<SERVER_IP> "cd ${NEXUS_DEPLOY_PATH} && git pull origin master"
|
||||
```
|
||||
|
||||
- [ ] **步骤 3:浏览器验证**
|
||||
|
||||
打开 `https://api.synaglobal.vip/files.php`
|
||||
打开 `https://${NEXUS_DOMAIN}/files.html`
|
||||
- 左侧目录树可展开/折叠
|
||||
- 右键菜单弹出
|
||||
- Ctrl+C/V/X/A 操作
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 密码预设 — 设计规格
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`;PHP 前端 (`servers.php`/`settings.php`) 已迁移到 Tailwind CSS v4 + Alpine.js (`web/app/servers.html`/`settings.html`)。功能已实现,保留本文档供历史参考。
|
||||
|
||||
## 背景
|
||||
|
||||
多台服务器的 SSH 密码通常相同,每次新建服务器时重复输入密码效率低。提供密码预设列表,新建服务器时下拉选择即可。
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 简化推送模型 — 设计规格
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`;`server/api/tasks.py` 已删除;PHP 前端 (`servers.php`/`tasks.php`) 已迁移到 Tailwind CSS v4 + Alpine.js。功能已实现,保留本文档供历史参考。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 SyncTask 模型为"一个任务=一个源→一个服务器→一个目标",支持 push/pull/bidirectional + 触发文件 + cron 定时 + 差异对比。实际需求只需要:**主服务器单向推送到多台子服务器,直接覆盖(rsync 不含 --delete)**。
|
||||
|
||||
@@ -1,128 +1,90 @@
|
||||
# 架构优化 — PHP 为主 + 配置分发 设计报告
|
||||
# 架构优化 — 配置分发与部署设计报告
|
||||
|
||||
> 更新: 2026-05-22 — 反映 PHP→API 迁移进度
|
||||
|
||||
## 一、优化目标
|
||||
|
||||
1. **PHP 为配置入口** — 所有运行参数在 PHP settings 页统一管理,Python 启动从 MySQL 读取
|
||||
2. **主服务器 IP 变更自动化** — 主推配置文件到子服务器,Agent 自动重载,反馈结果
|
||||
3. **配置源统一** — 消除 `.env` 和 `config.php` 之间的重复和漂移
|
||||
1. **配置源统一** — MySQL settings 表为唯一动态配置源,.env 仅启动必需
|
||||
2. **主服务器 IP 变更自动化** — Agent config.json 推送 + 热重载
|
||||
3. **全量可配置化** — 无硬编码路径/域名/IP,环境变量驱动部署
|
||||
|
||||
## 二、当前架构 vs 目标架构
|
||||
## 二、配置架构
|
||||
|
||||
```
|
||||
【当前】 【目标】
|
||||
【当前架构 — 已实现】
|
||||
|
||||
PHP config.php ─┐ PHP settings.php
|
||||
├─ 重复,可能 │ 写/读
|
||||
Python .env ────┘ 不一致 MySQL settings 表
|
||||
│ 启动读取
|
||||
Python 运行配置
|
||||
install.php (部署阶段)
|
||||
→ 写入 config.php (PHP 前端)
|
||||
→ 写入 .env (NEXUS_ 前缀, Python 启动必需)
|
||||
→ 写入 MySQL settings 表 (动态配置)
|
||||
|
||||
Agent 配置来源混乱: Agent 单一 config.json
|
||||
- YAML │ 主推送更新
|
||||
- 环境变量 │ POST /config/reload
|
||||
- systemd Environment= │ 热重载
|
||||
- shell 变量 │ 返回反馈
|
||||
Python 启动
|
||||
→ 读 .env (SECRET_KEY, DATABASE_URL, API_KEY — 不可改)
|
||||
→ load_settings_from_db() → MySQL settings 覆盖可改项
|
||||
|
||||
前端运行
|
||||
→ api.js → JWT 认证 → FastAPI API
|
||||
→ 不再依赖 PHP Session
|
||||
|
||||
配置变更
|
||||
→ settings.html → POST /api/settings → MySQL
|
||||
→ Python 重启后 load_settings_from_db() 自动生效
|
||||
```
|
||||
|
||||
## 三、已实现改动
|
||||
|
||||
### 3.1 配置统一 (8.1)
|
||||
### 3.1 配置统一
|
||||
|
||||
| 组件 | 实现 |
|
||||
|------|------|
|
||||
| `server/config.py` | `load_settings_from_db()` — 启动时从 MySQL `settings` 表读取,优先于 `.env` |
|
||||
| `server/api/servers.py` | `GET/POST /api/servers/config` — PHP 读写配置 |
|
||||
| MySQL `settings` 表 | key-value 存储:api_key, telegram_bot_token, max_concurrent_pushes, health_check_interval |
|
||||
| `server/config.py` | `load_settings_from_db()` — 启动时从 MySQL settings 表读取 |
|
||||
| `server/api/settings.py` | `GET/POST /api/settings` — 前端读写配置 |
|
||||
| MySQL settings 表 | key-value 存储: api_key, telegram, thresholds, pool_size |
|
||||
| `.env` | NEXUS_ 前缀,启动必需项 (SECRET_KEY, DATABASE_URL) |
|
||||
|
||||
**数据流:**
|
||||
```
|
||||
PHP settings 页
|
||||
→ POST /api/servers/config {max_concurrent_pushes: "3"}
|
||||
→ MySQL settings 表 INSERT/UPDATE
|
||||
→ Python 下次重启或 reload 时自动读取
|
||||
```
|
||||
**不可改配置** (`.env` only):
|
||||
- SECRET_KEY, API_KEY, ENCRYPTION_KEY, DATABASE_URL
|
||||
|
||||
**可改配置** (MySQL 可覆盖):
|
||||
- system_name/title, pool_size/overflow, redis_url, 告警阈值, Telegram
|
||||
|
||||
### 3.2 配置分发到子服务器
|
||||
|
||||
| 组件 | 实现 |
|
||||
|------|------|
|
||||
| `agent/config.example.json` | 统一 JSON 格式,替代 YAML |
|
||||
| `agent/agent.py` | `POST /config/reload` — 接收新 config,写文件 + 重载内存变量 |
|
||||
| `server/api/servers.py` | `POST /api/servers/push-config` — 遍历在线子服务器,逐台推送 |
|
||||
| `web/settings.php` | 输入新地址 → 推送到所有 Agent → 显示每台反馈 |
|
||||
| `web/agent/config.example.json` | 统一 JSON 格式,替代 YAML |
|
||||
| `web/agent/install.sh` | Agent 安装脚本 |
|
||||
|
||||
**主服务器 IP 变更流程:**
|
||||
```
|
||||
1. 管理员在 settings.php 输入新地址 http://新IP:8600
|
||||
2. 点击「推送到所有 Agent」
|
||||
3. 主服务器 → POST {central: {url: "新IP"}} → 每台在线 Agent
|
||||
4. Agent 写入 /opt/nexus-agent/config.json
|
||||
5. Agent 热重载 CENTRAL_URL,心跳用新地址
|
||||
6. Agent 返回 {"status":"ok"} → settings.php 显示每台结果
|
||||
```
|
||||
### 3.3 全量可配置化
|
||||
|
||||
| 组件 | 实现 |
|
||||
|------|------|
|
||||
| `NEXUS_DEPLOY_PATH` | 部署路径,所有脚本读取此变量 |
|
||||
| `NEXUS_CORS_ORIGINS` | CORS 域名,逗号分隔 |
|
||||
| `deploy/nexus.conf` | 模板化 Supervisor 配置 ({{DEPLOY_DIR}}) |
|
||||
| `deploy/nginx_*.conf` | 模板化 Nginx 配置 ({{SERVER_NAME}}) |
|
||||
| `deploy/health_monitor.sh` | 从 .env 读取 NEXUS_DEPLOY_DIR |
|
||||
|
||||
## 四、待实现建议
|
||||
|
||||
### 4.1 首次使用引导 (7.3)
|
||||
### 4.1 推送前预览 (rsync dry-run)
|
||||
|
||||
**设计:** install.php 完成后自动跳转 3 步引导
|
||||
**设计**: 推送对话框点「预览」→ `rsync -avzn --stats` dry-run → 显示预计传输文件数和大小
|
||||
|
||||
```
|
||||
第1步: 添加第一台服务器 → 域名/IP/密码/目标路径
|
||||
第2步: 测试连接 → SSH 健康检测
|
||||
第3步: 首次推送 → 选择源目录 → 推送
|
||||
```
|
||||
### 4.2 settings 页 tab 化
|
||||
|
||||
### 4.2 settings 页 tab 化 (7.10)
|
||||
|
||||
**设计:** 当前所有设置堆在一个长页面
|
||||
**设计**: 当前所有设置堆在一个长页面
|
||||
|
||||
```
|
||||
[TOTP] [修改密码] [密码预设] [导入导出] [系统配置] [推送配置]
|
||||
```
|
||||
|
||||
### 4.3 推送前预览 (7.8)
|
||||
### 4.3 Agent 自动化安装增强
|
||||
|
||||
**设计:** 推送对话框点「预览」→ `rsync -avzn --stats` dry-run → 显示预计传输文件数和大小
|
||||
|
||||
### 4.4 Agent 自动化安装
|
||||
|
||||
**设计:** 配合 config.json,install.sh 改为:
|
||||
**设计**: install.sh 配合 config.json:
|
||||
- 读取 config.example.json → 提示用户填入 central.url → 生成 config.json
|
||||
- 不再依赖环境变量
|
||||
|
||||
### 4.5 主服务器状态看板 (7.4 增强)
|
||||
### 4.4 install.php → install.html 迁移
|
||||
|
||||
**设计:** index.php 仪表盘已有基础统计。增强:
|
||||
- 子服务器 Agent 版本列表
|
||||
- 子服务器最后心跳时间排序(找出离线的)
|
||||
- 一键「检测全部」
|
||||
|
||||
## 五、设计流程总结
|
||||
|
||||
```
|
||||
部署阶段:
|
||||
PHP install.php → MySQL 建表 → 写 config.php → 生成 .env
|
||||
管理员 → settings.php 配置 API_KEY/Telegram/并发数 → MySQL
|
||||
|
||||
运行阶段:
|
||||
Python 启动 → 读 .env(仅DB连接) → init_db() → load_settings_from_db()
|
||||
→ MySQL 覆盖 .env 默认值 → 运行
|
||||
|
||||
主IP变更:
|
||||
settings.php → 新地址 → push-config → 全部 Agent → 写入 config.json
|
||||
→ Agent 返回 OK → 页面显示结果
|
||||
|
||||
配置变更:
|
||||
settings.php → POST /api/servers/config → MySQL → Python 重启后生效
|
||||
```
|
||||
|
||||
## 六、技术决策记录
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
|------|------|------|
|
||||
| 配置存储 | MySQL key-value | 已有 settings 表,PHP 可直接写 |
|
||||
| Agent 配置格式 | JSON | 比 YAML 简单,Python stdlib 原生支持 |
|
||||
| 配置热加载 | 写文件 + 内存重载 | 简单可靠,Agent 是单进程 |
|
||||
| 推送方式 | HTTP POST | Agent 已有 HTTP 服务 |
|
||||
| 反馈机制 | 同步返回 JSON | 推送时逐台收集结果,一次性展示 |
|
||||
**设计**: install.php 是最后残留的 PHP 页面,迁移到纯静态 HTML + FastAPI API 后可移除 PHP-FPM 依赖
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 推送失败重试 + 实时进度 — 设计规格
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`;PHP 前端 (`retry.php`/`servers.php`) 已迁移到 Tailwind CSS v4 + Alpine.js;`datetime.utcnow()` 已弃用。功能已实现,保留本文档供历史参考。
|
||||
|
||||
## 背景
|
||||
|
||||
当前推送 `asyncio.gather` 等待全部完成后一次性返回,大数据量时前端无反馈。失败推送只记录 SyncLog,需人工回到服务器管理页重试。需:**实时进度 + 自动重试 + 一致性验证后消失**。
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# servers.php 添加服务器优化 — 设计规格
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`web/servers.php` 已迁移到 `web/app/servers.html` (Tailwind CSS v4 + Alpine.js);`server/services/agent_installer.py` 未独立创建,安装逻辑集成在 server_service.py 中。功能已实现,保留本文档供历史参考。
|
||||
|
||||
**日期**: 2026-05-17
|
||||
**状态**: 已确认
|
||||
|
||||
@@ -58,8 +61,8 @@ def create_server(server_data):
|
||||
1. 从 DB 读取 server 信息(IP/端口/用户名/密码/目标路径)
|
||||
2. SSH 连接子服务器
|
||||
3. 执行命令(通过 Redis pub/sub 逐行回传):
|
||||
curl -fsSL https://api.synaglobal.vip/agent/install.sh | bash -s -- \
|
||||
--url http://api.synaglobal.vip:8600 \
|
||||
curl -fsSL https://${NEXUS_DOMAIN}/agent/install.sh | bash -s -- \
|
||||
--url http://${NEXUS_DOMAIN}:8600 \
|
||||
--key <agent_api_key> \
|
||||
--dirs <target_path>
|
||||
4. 安装完成 → 更新 server.is_online 状态
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 文件管理器 v2 设计规格
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档引用的文件结构已过时:`web/files.php` 已迁移到 `web/app/files.html` (Tailwind CSS v4 + Alpine.js + SFTP API);`web/file_actions.php` 和 `web/browse_dirs.php` 已不存在,文件操作通过 `server/api/sync_v2.py` SFTP 端点实现。功能已实现,保留本文档供历史参考。
|
||||
|
||||
**日期**: 2026-05-18
|
||||
**状态**: 已确认
|
||||
|
||||
|
||||
@@ -1,31 +1,50 @@
|
||||
# Redis + IoT 性能优化设计
|
||||
# Redis + 实时数据架构
|
||||
|
||||
> 更新: 2026-05-22 — 修正 Redis key 命名和刷新间隔
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Agent(30s心跳) → Redis(热数据/600s TTL) → API即时返回
|
||||
↑
|
||||
Refresh Job(60s) → MySQL → 同步冷字段到Redis
|
||||
Agent(60s心跳) → Redis heartbeat:{id} (HSET, TTL=600s) → API 即时返回
|
||||
↑
|
||||
heartbeat_flush(10min) → MySQL (历史快照)
|
||||
```
|
||||
|
||||
## 数据分布
|
||||
|
||||
| 数据 | 存储 | TTL |
|
||||
|------|------|-----|
|
||||
| server:{id}:online | Redis SET | 120s |
|
||||
| server:{id}:metrics | Redis STRING | 600s |
|
||||
| server:{id}:info | Redis STRING | 65s |
|
||||
| server:stats | Redis STRING | 65s |
|
||||
| 名称/IP/密码/配置 | MySQL (写少读多) | 永久 |
|
||||
| sync_logs | MySQL | 90天 |
|
||||
| 数据 | Redis Key | 类型 | TTL | 用途 |
|
||||
|------|-----------|------|-----|------|
|
||||
| 心跳数据 | `heartbeat:{server_id}` | HSET | 600s | is_online, system_info, agent_version, last_heartbeat |
|
||||
| 告警标记 | `alerts:{server_id}` | SET | 3600s | 服务器处于告警状态 |
|
||||
| 告警广播 | `nexus:alerts` | Pub/Sub | — | WebSocket 跨 worker 告警推送 |
|
||||
| 推送进度 | `sync:progress:{op}:{ts}` | STRING | — | 批量推送进度 |
|
||||
| 名称/IP/密码/配置 | — (MySQL) | — | 永久 | 写少读多,不需要缓存 |
|
||||
|
||||
## 实现点
|
||||
|
||||
1. main.py: 定义 use_redis() 连接池 (max_connections=50)
|
||||
2. heartbeat: 收到心跳 → Redis SETEX server:{id}:online=1,120s + SETEX metrics,600s
|
||||
3. Refresh Job: 每60s → MySQL COUNT(*) → Redis SET stats,65s
|
||||
4. API /servers/ : MySQL查冷字段 + Redis MGET热字段合并
|
||||
5. API /stats: 直接读Redis (不碰MySQL)
|
||||
6. 健康检测: 读Redis online key,不存在=离线
|
||||
7. 告警冷却: Redis SETNX alert:{id}:{key},600s → 重复告警10分钟静默
|
||||
8. 仪表盘图表: Redis缓存500条日志,60s
|
||||
1. **连接池**: `redis.asyncio.BlockingConnectionPool` (ADR-008),启动强校验 (ADR-009)
|
||||
2. **心跳写入**: Agent `POST /api/agent/heartbeat` → `HSET heartbeat:{id}` + TTL 续期
|
||||
3. **批量刷新**: heartbeat_flush 每 10min → SCAN `heartbeat:*` → MySQL `UPDATE servers`
|
||||
4. **API /servers**: MySQL 查基础信息 + Redis `HGETALL heartbeat:{id}` 合并实时状态
|
||||
5. **API /stats**: MySQL COUNT + Redis `EXISTS alerts:{id}` 统计告警数
|
||||
6. **健康检测**: Redis `HGETALL heartbeat:{id}` 不存在或 is_online=False → 离线
|
||||
7. **告警推送**: WebSocket broadcast (内存 + Redis Pub/Sub 两层, ADR-010) + Telegram
|
||||
8. **告警冷却**: Redis `SETNX alerts:{id}` TTL=3600s 防重复告警
|
||||
9. **Dashboard**: WebSocket 实时更新,收到 alert/recovery/system 事件时刷新统计
|
||||
|
||||
## Redis Key 完整规范
|
||||
|
||||
| Key 模式 | 类型 | TTL | 写入者 | 读取者 |
|
||||
|----------|------|-----|--------|--------|
|
||||
| `heartbeat:{server_id}` | HSET | 600s | agent.py | servers.py, heartbeat_flush.py |
|
||||
| `alerts:{server_id}` | SET | 3600s | agent.py | servers.py (stats) |
|
||||
| `nexus:alerts` | Pub/Sub | — | websocket.py | websocket.py (跨 worker) |
|
||||
|
||||
### heartbeat:{server_id} HSET 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| is_online | string ("True"/"False") | 在线状态 |
|
||||
| system_info | string (JSON) | 系统信息 (CPU/内存/磁盘/OS) |
|
||||
| agent_version | string | Agent 版本号 |
|
||||
| last_heartbeat | string (ISO8601) | 最后心跳时间 |
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# files.php 迭代 — 代码审查跟进项
|
||||
|
||||
> ⚠️ **已归档** — 2026-05-22
|
||||
> 本文档审查的 PHP 文件 (api_proxy.php, files.php, webfm.php) 已被 Tailwind CSS v4 + Alpine.js 前端替代。
|
||||
> 相关功能已迁移到: web/app/files.html + server/api/sync_v2.py
|
||||
> 保留本文档供历史参考。
|
||||
|
||||
**审查日期**: 2026-05-19
|
||||
**审查范围**: `web/api_proxy.php`, `web/files.php`
|
||||
**审查结论**: 零 [必须修复] 项,可合入主干
|
||||
|
||||
@@ -1,114 +1,81 @@
|
||||
# Nexus Sprint 规划 — 2026-05-19
|
||||
# Nexus Sprint 规划 — 2026-05-22 更新
|
||||
|
||||
## 与会部门
|
||||
|
||||
| 部门 | 成员 | 职责 |
|
||||
|------|------|------|
|
||||
| 产品部 | 产品经理 Alex + Sprint 排序师 | 优先级决策、砍需求 |
|
||||
| 工程部 | 全栈团队 | 技术可行性、工时评估 |
|
||||
| 测试部 | QA 团队 | 测试方案、压测、验收 |
|
||||
| 专家组 | DB/Python/FastAPI/Redis 联合 | 技术审计、架构审定 |
|
||||
> 本文档已更新反映实际代码状态,移除已完成项
|
||||
|
||||
---
|
||||
|
||||
## 项目现状
|
||||
## 已完成的架构升级(本轮)
|
||||
|
||||
```
|
||||
服务器: 253 台在线 (目标 2000+)
|
||||
服务: api/db/redis/ws 全部 online
|
||||
错误: 0
|
||||
文件描述符限制: 65535
|
||||
响应时间: 2ms (health)
|
||||
```
|
||||
|
||||
### 已修复的致命缺陷(本轮)
|
||||
|
||||
| 缺陷 | 影响 | 修复 |
|
||||
|------|------|------|
|
||||
| Redis `await` 同步客户端 → 全线失效 | 心跳/缓存/告警冷却全死 | `redis` → `redis.asyncio` |
|
||||
| paramiko 阻塞事件循环 | 47s health 响应、登录超时 | ThreadPoolExecutor |
|
||||
| 健康检测 SSH 超时连锁阻塞 | 30+ 离线 = 事件循环冻结 | 线程池 + 5s 超时 |
|
||||
| `r.mget()` await 错误 | 健康检测/monitor 循环报错 | 去 await (同步) → 加 await (async) |
|
||||
| `use_redis` 未定义 | 所有 Redis 操作静默失败 | 模块级函数 + 连接池 |
|
||||
| 升级 | 之前 | 之后 | 影响 |
|
||||
|------|------|------|------|
|
||||
| SSH 库 | paramiko (同步,阻塞事件循环) | asyncssh (异步,引用计数连接池) | 消除 47s health 响应延迟 |
|
||||
| 认证 | PHP Session + API Key | JWT + TOTP | 统一认证体系 |
|
||||
| 前端 | PHP AdminLTE | Tailwind CSS v4 + Alpine.js | 纯静态,零构建 |
|
||||
| 定时调度 | APScheduler | 自定义 schedule_runner + retry_runner | 原生异步,更轻量 |
|
||||
| WebSocket | 无 | 两层架构 (内存 + Redis Pub/Sub) | 实时告警推送 |
|
||||
| 数据库 Session | 泄漏 (4 个 service factory 不关) | DbSessionMiddleware 自动管理 | 消除连接池耗尽 |
|
||||
|
||||
---
|
||||
|
||||
## 各池参数终版
|
||||
## 当前连接池配置
|
||||
|
||||
```
|
||||
DB: pool=400 overflow=300 = 最大700 (MySQL max_connections × 0.7)
|
||||
Redis: max_connections=1000
|
||||
SSH: ThreadPoolExecutor(50)
|
||||
健康: Semaphore(50)
|
||||
文件: LimitNOFILE=65535
|
||||
推送: MAX_CONCURRENT_PUSHES=600
|
||||
MySQL: pool_size=160, max_overflow=120 → 最大 280 连接
|
||||
(基于 MySQL max_connections=400, install.php 自动计算:
|
||||
poolSize = max(20, maxConn * 0.4), overflow = max(20, maxConn * 0.3))
|
||||
|
||||
Redis: BlockingConnectionPool (ADR-008)
|
||||
启动强校验,不可用直接退出 (ADR-009)
|
||||
|
||||
asyncssh: MAX_CONNECTIONS=100
|
||||
IDLE_TIMEOUT=300s (5min 空闲关闭)
|
||||
CLEANUP_INTERVAL=60s
|
||||
|
||||
推送并发: Semaphore(10) per batch
|
||||
batch_size=50, batch_interval=30s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sprint 计划
|
||||
## 后台任务
|
||||
|
||||
### P0 — 立刻执行 (6.5h)
|
||||
| 任务 | 间隔 | 文件 | 说明 |
|
||||
|------|------|------|------|
|
||||
| heartbeat_flush | 10min | `background/heartbeat_flush.py` | Redis → MySQL 批量刷新 |
|
||||
| self_monitor | 30s | `background/self_monitor.py` | Redis/MySQL 检查 + 恢复通知 |
|
||||
| schedule_runner | 60s | `background/schedule_runner.py` | Cron 定时推送 |
|
||||
| retry_runner | 5min | `background/retry_runner.py` | 指数退避重试 |
|
||||
| asyncssh_cleanup | 60s | `ssh/asyncssh_pool.py` | 空闲连接清理 |
|
||||
|
||||
---
|
||||
|
||||
## 待完成项
|
||||
|
||||
### P1 — 本周
|
||||
|
||||
| # | 项 | 工时 | 风险 |
|
||||
|---|-----|------|------|
|
||||
| 1 | SSH 线程隔离 servers.php — 群发命令 paramiko → run_in_executor | 1.5h | 中 |
|
||||
| 2 | scheduler.py `timedelta` import 修复 | 5秒 | 低 |
|
||||
| 3 | 批量 COMMIT health_checker + ssh_direct | 1h | 低 |
|
||||
| 4 | 压测方案执行 — API/stats/heartbeat/exec | 4h | 低 |
|
||||
| 1 | WebSSH xterm.js 前端页面 | 2-3天 | 中 |
|
||||
| 2 | 推送前预览 (rsync dry-run --stats) | 0.5天 | 低 |
|
||||
|
||||
### P1 — 本周 (1h)
|
||||
### P2 — 迭代
|
||||
|
||||
| # | 项 | 工时 |
|
||||
|---|-----|------|
|
||||
| 5 | sync_logs 独立定时清理 (30天) + PushRetryJob 清理 | 0.5h |
|
||||
| 6 | 安装进度 Redis TTL 60s → 600s | 0.1h |
|
||||
| 7 | sync_logs 超 10万行 Telegram 告警 | 0.4h |
|
||||
| 3 | install.php → install.html 迁移 | 1天 |
|
||||
| 4 | config.php → 纯 API(移除 PHP-FPM 依赖) | 2天 |
|
||||
| 5 | paramiko pool.py + requirements.txt 清理 | 0.5天 |
|
||||
| 6 | 命令日志查询页面 | 0.5天 |
|
||||
| 7 | 文件编辑器集成 (Monaco) | 1天 |
|
||||
|
||||
### P0.5 — Redis+MySQL 深度利用 (3h)
|
||||
---
|
||||
|
||||
> 核心思路:一切可热配,Redis 做热层,MySQL 做持久层
|
||||
|
||||
| # | 项 | 工时 | 效果 |
|
||||
|---|-----|------|------|
|
||||
| 5a | 配置热重载 — `reload-config` 即时生效,不重启 | 1h | 改池/超时/并发 秒级生效 |
|
||||
| 5b | Alert 去重移到 Redis SET | 0.5h | 重启不丢,10min 冷却正确 |
|
||||
| 5c | 推送日志 Redis 缓存 1000 条 (60s TTL) | 0.5h | 仪表盘图表不查 MySQL |
|
||||
| 5d | 心跳写 Redis → MySQL 批量刷 (每 30s) | 1h | MySQL 写入降为 1/30 |
|
||||
|
||||
### P2 — 迭代 (6h)
|
||||
|
||||
| # | 项 | 工时 |
|
||||
|---|-----|------|
|
||||
| 8 | 审计日志表 + API 埋点 + 前端查询 | 6h |
|
||||
|
||||
### ❌ 明确不做
|
||||
## 已否决(永久)
|
||||
|
||||
| 项 | 原因 |
|
||||
|----|------|
|
||||
| AsyncSession 迁移 | 20h+,真正瓶颈是 paramiko 不是 SQLAlchemy。ROI 极低 |
|
||||
| RBAC 权限体系 | 保持"鉴权即信任" |
|
||||
| 移动端适配 | 运维不会用手机管理 2000 台服务器 |
|
||||
| WebSocket 实时更新 | 仪表盘 60s 刷新够用 |
|
||||
| MQTT 协议 | HTTP + Redis 已足够 2000 台,加 MQTT 需要额外 broker |
|
||||
|
||||
---
|
||||
|
||||
## 产品生命周期定位
|
||||
|
||||
```
|
||||
当前阶段: Scale —— PMF 已验证(200+ 台在线),正在向规模化演进
|
||||
|
||||
北极星指标: 服务器在线率 > 95%
|
||||
核心用户旅程 Top 3:
|
||||
1. 批量推送文件到 N 台服务器 → 看成功率
|
||||
2. 群发 Shell 命令 → 看响应速度
|
||||
3. 查看仪表盘 → 看加载时间
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后续方向(不列入 Sprint)
|
||||
|
||||
- Agent 拉取替代中心 SSH rsync:中心发信号 → Agent 自己拉文件
|
||||
- 服务器分组标签:按业务/区域分组管理
|
||||
- 定时推送调度增强:支持多 cron 表达式
|
||||
| MQTT 协议 | HTTP + Redis 已足够 |
|
||||
| 会话录像 asciicast | 不需要 |
|
||||
| APScheduler | 自定义 asyncio loop 更轻量 |
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
# Nexus 设计标准
|
||||
|
||||
> 最后更新: 2026-05-19
|
||||
> 最后更新: 2026-05-22
|
||||
> 适用范围: 所有功能迭代、代码审查、安全审计
|
||||
|
||||
---
|
||||
|
||||
## 1. 安全模型:鉴权即信任 (Auth = Trust)
|
||||
## 1. 安全模型:信任管理员 + 防注入/XSS
|
||||
|
||||
**核心理念: 后台操作的人都是值得信任的。**
|
||||
**核心理念: 认证管理员是可信用户,但必须防御注入攻击和 XSS。**
|
||||
|
||||
运维管理后台的认证管理员即为可信用户,应用层不对操作内容做额外过滤。
|
||||
运维管理后台的认证管理员即为可信用户,应用层不对操作内容做业务限制。
|
||||
但必须防御技术层面的攻击向量(命令注入、路径注入、XSS)。
|
||||
|
||||
**依据**:
|
||||
- 使用者均为经过认证的运维管理员
|
||||
- 绕过应用层限制的门槛极低(SSH 直连即可)
|
||||
- 应用层过滤增加维护成本,假安全感大于实际安全收益
|
||||
- 应用层业务限制增加维护成本,假安全感大于实际安全收益
|
||||
- 但技术层面注入攻击是真实风险,必须防御
|
||||
|
||||
**实施要求**:
|
||||
- 所有需要登录的页面 `require_once auth.php`
|
||||
- 所有 API 端点必须校验登录态
|
||||
- `/api/` 路由由 Python `X-API-Key` 保护
|
||||
- `/api_proxy.php` 由 PHP Session 保护
|
||||
- 所有 API 端点必须校验 JWT 令牌(`Depends(get_current_admin)`)
|
||||
- WebSocket 连接必须通过 JWT 查询参数认证(ADR-011)
|
||||
- Agent 通信使用 X-API-Key 认证
|
||||
- Shell 命令参数必须 `shlex.quote()` 防注入
|
||||
- 前端禁止在模板字面量中直接输出用户内容(防 XSS)
|
||||
- 数据库凭据加密存储(Fernet + AES-CBC)
|
||||
|
||||
**不做的事**:
|
||||
- ❌ 不在应用层限制上传文件类型
|
||||
- ❌ 不在应用层限制文件写入目标
|
||||
- ❌ 不在应用层限制执行命令内容
|
||||
- ❌ 不做 CSRF token(同源策略 + 登录态已足够)
|
||||
- ❌ 不在应用层限制文件写入目标路径
|
||||
- ❌ 不在应用层限制执行命令的业务内容
|
||||
- ❌ 不做 RBAC 权限体系(保持"鉴权即信任")
|
||||
- ❌ 不做会话录像(asciicast)
|
||||
|
||||
---
|
||||
|
||||
@@ -34,11 +39,19 @@
|
||||
|
||||
| 层 | 存储 | 用途 |
|
||||
|----|------|------|
|
||||
| 热层 | Redis | 心跳、缓存、去重 (TTL 管理) |
|
||||
| 热层 | Redis | 心跳、缓存、告警去重、Pub/Sub (TTL 管理) |
|
||||
| 持久层 | MySQL | 服务器、日志、设置、审计 |
|
||||
| 文件 | `.env` + `config.php` | DB 连接、API 密钥(启动必需) |
|
||||
| 文件 | `.env` | 启动必需: DB连接、SECRET_KEY、API_KEY |
|
||||
|
||||
**原则**: 可热配项走 Redis+MySQL(`reload-config` 即时生效),不可热配项走文件。
|
||||
**配置加载优先级**: `.env` (启动) → MySQL settings 表 (动态覆盖)
|
||||
|
||||
**原则**: 可热配项走 MySQL settings(`load_settings_from_db` 启动时加载),不可热配项走 `.env`。
|
||||
|
||||
**不可改配置** (`.env` only):
|
||||
- SECRET_KEY, API_KEY, ENCRYPTION_KEY, DATABASE_URL
|
||||
|
||||
**可改配置** (MySQL settings 可覆盖):
|
||||
- system_name/title, pool_size/overflow, redis_url, 告警阈值, Telegram 配置
|
||||
|
||||
---
|
||||
|
||||
@@ -46,23 +59,35 @@
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 安全并发 | ≤50 |
|
||||
| API 安全并发 | ≤50 |
|
||||
| P50 延迟 | <60ms |
|
||||
| P95 延迟 | <300ms |
|
||||
| 最大文件上传 | 500MB |
|
||||
| sync_logs 保留 | 30 天 |
|
||||
| 心跳批量刷 | 每 30s |
|
||||
| 心跳批量刷到 MySQL | 每 10min |
|
||||
| SSH 连接池 | 最大 100 连接,空闲 5min 关闭 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码规范
|
||||
|
||||
- 所有 PHP 页面第一行 `require_once auth.php`(除非是公开页面)
|
||||
- 文件操作必须经 `safePath()` / `check_path()` 校验
|
||||
- `safePath` 规范:路径存在时校验 `realpath`,不存在时返回原始路径 $p(校验父目录 `realpath` 兜底)
|
||||
- exec/shell 参数必须 `escapeshellarg()` 或正则白名单
|
||||
- CORS 动态匹配 Host,不写死域名
|
||||
- 前端 API 调用必须带 `X-Requested-With: XMLHttpRequest` 头
|
||||
### Python 后端
|
||||
|
||||
- 所有 API 路由使用 Pydantic 模型验证请求体(`server/api/schemas.py`)
|
||||
- 所有 API 路由使用 JWT 认证(`Depends(get_current_admin)`)
|
||||
- Shell 命令参数必须 `shlex.quote()` 防注入
|
||||
- DB session 由 DbSessionMiddleware 自动管理,通过 `request.state.db` 共享
|
||||
- Service 层通过依赖注入获取 Repository 实例(`server/api/dependencies.py`)
|
||||
- 加密使用 Fernet (Python) + AES-CBC (PHP 兼容),密钥统一从 API_KEY/SECRET_KEY 派生
|
||||
- Redis 操作使用 `redis.asyncio`,连接池为 `BlockingConnectionPool`
|
||||
|
||||
### 前端 (Tailwind + Alpine.js)
|
||||
|
||||
- 所有页面引入 `api.js` 共享模块(JWT 自动刷新、apiFetch 封装)
|
||||
- JWT 令牌存储在 localStorage,自动刷新(2分钟内到期触发)
|
||||
- 所有 API 调用使用 `apiFetch()` 自动附带 Authorization 头
|
||||
- CORS 由 `NEXUS_CORS_ORIGINS` 环境变量配置
|
||||
- 侧边栏和导航在所有页面保持一致
|
||||
|
||||
---
|
||||
|
||||
@@ -71,21 +96,49 @@
|
||||
**收尾前必修**: 部署完成后必须用浏览器直接访问页面验证核心流程。curl / API 测试不能替代浏览器验收。
|
||||
|
||||
**检查清单**:
|
||||
- [ ] 登录 → 仪表盘正常加载
|
||||
- [ ] 核心页面可交互(点击、右键、表单提交有响应)
|
||||
- [ ] 登录 → 仪表盘正常加载(含 WebSocket 实时更新)
|
||||
- [ ] 核心页面可交互(点击、表单提交有响应)
|
||||
- [ ] 浏览器 Console 无报错
|
||||
- [ ] 浏览器 Network 面板无 404/500
|
||||
- [ ] JSON 响应未被 PHP Warning 污染
|
||||
|
||||
**教训**: files.php 迭代两次因 open_basedir 导致 PHP Warning 污染 JSON。curl 测试未捕获(未登录态绕过路径),浏览器实际操作时才触发。curl 验证 API 协议,浏览器验证用户交互。
|
||||
- [ ] JWT 令牌自动刷新正常
|
||||
|
||||
---
|
||||
|
||||
## 6. 不做清单(永久)
|
||||
## 6. 架构模式
|
||||
|
||||
- ❌ AsyncSession 迁移 (paramiko 才是瓶颈,ROI 极低)
|
||||
### 后台任务
|
||||
|
||||
| 任务 | 间隔 | 文件 |
|
||||
|------|------|------|
|
||||
| heartbeat_flush | 10min | `server/background/heartbeat_flush.py` |
|
||||
| self_monitor | 30s | `server/background/self_monitor.py` |
|
||||
| schedule_runner | 60s | `server/background/schedule_runner.py` |
|
||||
| retry_runner | 5min | `server/background/retry_runner.py` |
|
||||
| asyncssh_cleanup | 60s | `server/infrastructure/ssh/asyncssh_pool.py` |
|
||||
|
||||
所有后台任务使用 `asyncio.create_task()` 在 `lifespan` 中启动,支持优雅关闭。
|
||||
|
||||
### SSH 连接池
|
||||
|
||||
引用计数模式 (ADR-004):
|
||||
- `ssh_pool.acquire(server)` → 获取/创建连接,ref_count +1
|
||||
- `ssh_pool.release(server_id)` → ref_count -1,空闲连接保留复用
|
||||
- 清理循环每 60s 扫描,空闲 >5min 的连接自动关闭
|
||||
|
||||
### WebSocket 两层架构 (ADR-010)
|
||||
|
||||
- Layer 1: 内存 `ConnectionManager` 管理本 worker 的 WebSocket 连接
|
||||
- Layer 2: Redis Pub/Sub `nexus:alerts` 频道中继跨 worker 消息
|
||||
- Ping/pong 心跳 30s,僵尸连接 60s 清理
|
||||
|
||||
---
|
||||
|
||||
## 7. 不做清单(永久)
|
||||
|
||||
- ❌ RBAC 权限体系 (保持"鉴权即信任")
|
||||
- ❌ 会话录像 asciicast (不需要)
|
||||
- ❌ 移动端适配 (运维不会用手机管理服务器)
|
||||
- ❌ MQTT 协议 (HTTP+Redis 足够 2000 台)
|
||||
- ❌ 文件差异比较
|
||||
- ❌ 图片/PDF 预览
|
||||
- ❌ 应用层内容过滤(鉴权即信任)
|
||||
- ❌ 推送结果邮件报告
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 设计部审查报告 — Nexus 技术栈全面评估
|
||||
|
||||
**审查人**: 设计部负责人
|
||||
**审查日期**: 2026-05-21
|
||||
**审查范围**: 全部设计规格(10份)、实施计划(5份)、竞品分析、roadmap/ADR、config.py、requirements.txt、.env.example
|
||||
**审查日期**: 2026-05-22(v2 更新)
|
||||
**审查范围**: 全部设计规格、实施计划、竞品分析、roadmap/ADR、config.py、requirements.txt、.env.example
|
||||
**变更说明**: v2 全面更新,修正 v1 中7处"未落地"致命错误,反映代码实际进度
|
||||
|
||||
---
|
||||
|
||||
@@ -21,305 +22,171 @@
|
||||
| 技术 | 版本 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|------|-----------|---------|
|
||||
| **SQLAlchemy** | 2.0.36 (asyncio) | ORM | **合理**。2.0 异步支持成熟,比 Tortoise-ORM 灵活,比 Django ORM 更适合 FastAPI | 低 |
|
||||
| **aiomysql** | 0.2.0 | MySQL 异步驱动 | **存疑**。社区较小,更新频率低。官方推荐 `aiomysql` 但生态不如 `asyncmy`。issues 积压 | **中** |
|
||||
| **Alembic** | 1.14.0 | 数据库迁移 | **合理**。SQLAlchemy 官方迁移工具,必要组件 | 低 |
|
||||
| **aiomysql** | 0.2.0 | MySQL 异步驱动 | **可接受**。社区较小,更新频率低。长期可评估迁移到 `asyncmy` | 低-中 |
|
||||
| **Pydantic** | 2.10.3 | 数据验证/序列化 | **合理**。FastAPI 核心依赖,v2 性能大幅提升 | 低 |
|
||||
| **pydantic-settings** | 2.7.0 | 配置管理 | **合理**。环境变量 + .env 加载,支持前缀 | 低 |
|
||||
| **Redis (hiredis)** | 5.2.1 | 缓存/心跳/队列/Pub-Sub | **合理**。5.x async 支持成熟,hiredis 加速 C 解析器。但文档中对 Redis 角色的描述严重不足 | **高(文档缺位)** |
|
||||
| **pydantic-settings** | 2.7.0 | 配置管理 | **合理**。环境变量 + .env 加载,NEXUS_ 前缀 | 低 |
|
||||
| **Redis (hiredis)** | 5.2.1 | 缓存/心跳/队列/Pub-Sub | **合理**。5.x async 支持成熟,hiredis C 加速解析器。ADR-007/008/009 已落地 | 低 |
|
||||
|
||||
### 1.3 SSH 与远程执行
|
||||
|
||||
| 技术 | 版本 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|------|-----------|---------|
|
||||
| **paramiko** | 3.5.0 | SSH 连接 | **必要但有问题**。同步库,已在 Sprint 规划中确认其阻塞事件循环的问题,通过 ThreadPoolExecutor 缓解 | **中** |
|
||||
| **asyncssh** | 2.17.0 | SSH 连接 (异步) | **前瞻性选型**。作为 paramiko 的异步替代引入,但在 roadmap 中才规划用于 Web SSH,当前未实际使用。两个 SSH 库并存增加维护成本 | **中** |
|
||||
| **rsync** | 系统级 | 文件同步 | **合理**。成熟、增量传输、压缩、支持 SSH 隧道 | 低 |
|
||||
| **asyncssh** | 2.17.0 | SSH 连接 (异步) | **✅ 已落地**。ADR-002/004 已实现:引用计数连接池 (`asyncssh_pool.py`)、WebSSH (`webssh.py`)、SyncService 已迁移 | 低 |
|
||||
| **paramiko** | ~~3.5.0~~ | ~~SSH 连接 (同步)~~ | **❌ 已移除**。pool.py 已删除,requirements.txt 已移除。asyncssh 为唯一 SSH 库 | 无 |
|
||||
|
||||
> **v1 勘误**: v1 标注"asyncssh — 前瞻性选型,当前未实际使用"是**错误的**。asyncssh 已全面替代 paramiko 成为唯一活跃 SSH 库。
|
||||
|
||||
### 1.4 HTTP 与通信
|
||||
|
||||
| 技术 | 版本 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|------|-----------|---------|
|
||||
| **httpx** | 0.28.1 | HTTP 客户端 | **合理**。async/await 支持,Agent 通信和外部 API 调用 | 低 |
|
||||
| **websockets** | 14.1 | WebSocket | **合理**。与 FastAPI 原生 WS 互补,进度推送和重试通知 | 低 |
|
||||
| **httpx** | 0.28.1 | HTTP 客户端 | **合理**。async/await 支持,Agent 通信 + Telegram 共享客户端 | 低 |
|
||||
| **websockets** | 14.1 | WebSocket | **合理**。告警推送 (ADR-010 两层架构) + WebSSH 终端 | 低 |
|
||||
| **python-multipart** | 0.0.19 | 表单解析 | **合理**。文件上传必需 | 低 |
|
||||
|
||||
### 1.5 安全加密
|
||||
|
||||
| 技术 | 版本 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|------|-----------|---------|
|
||||
| **PyJWT** | 2.10.1 | JWT 令牌 | **合理**。roadmap 规划 JWT 替换 PHP 登录 | 低 |
|
||||
| **PyJWT** | 2.10.1 | JWT 令牌 | **✅ 已落地**。JWT 认证完整实现:登录/刷新/验证/WebSocket 认证 (ADR-005/011) | 低 |
|
||||
| **bcrypt** | 4.2.1 | 密码哈希 | **合理**。密码存储标准 | 低 |
|
||||
| **Fernet (Python stdlib)** | — | 对称加密 | **合理**。密码预设加密存储,与 Python cryptography 库兼容 | 低 |
|
||||
| **cryptography** | 44.0.0 | Fernet + AES 加密 | **合理**。Fernet (Python) + AES-CBC (PHP 兼容),凭据加密存储 | 低 |
|
||||
|
||||
### 1.6 调度与后台任务
|
||||
### 1.6 后台任务
|
||||
|
||||
| 技术 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|-----------|---------|
|
||||
| **asyncio.create_task** | 后台协程 | **合理**。4 个后台任务:heartbeat_flush(10min)、self_monitor(30s)、schedule_runner(60s)、retry_runner(5min) | 低 |
|
||||
| **asyncio.Semaphore** | 并发控制 | **合理**。批量推送、同步引擎并发限制 (MAX_CONCURRENT=10) | 低 |
|
||||
|
||||
> **v1 勘误**: v1 评估了 `apscheduler==3.11.0` — 已**移除**。自定义 schedule_runner_loop + retry_runner_loop 替代,更轻量且原生异步。
|
||||
|
||||
### 1.7 前端技术
|
||||
|
||||
| 技术 | 版本 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|------|-----------|---------|
|
||||
| **APScheduler** | 3.11.0 | 定时任务 | **存疑**。sprint 规划提到 `scheduler.py timedelta import 修复`,说明已有 bug。且 Asyncio 模式下 APScheduler 3.x 支持不完善,4.0 才有原生 async | **中** |
|
||||
| **asyncio.create_task** | — | 后台协程 | **合理**。轻量级后台任务(retry_loop、self_monitor 等) | 低 |
|
||||
| **Tailwind CSS** | v4 (CDN) | CSS 框架 | **合理**。零运行时、纯静态、@theme 设计系统 | 低 |
|
||||
| **Alpine.js** | 3.14.8 (CDN) | 前端交互 | **合理**。轻量级,与静态 HTML 完美配合 | 低 |
|
||||
| **Monaco Editor** | 0.45.0 (CDN) | 代码编辑器 | **规划中**。文件管理器编辑功能 | 低(未落地) |
|
||||
| **xterm.js** | — | 终端模拟器 | **规划中**。Web SSH 终端前端(后端已实现) | 低(前端未落地) |
|
||||
|
||||
### 1.7 模板与文件
|
||||
> **v1 勘误**: v1 评估了 PHP + AdminLTE — 已**全面弃用** (ADR-006)。所有页面已迁移到 Tailwind CSS v4 + Alpine.js。
|
||||
|
||||
| 技术 | 版本 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|------|-----------|---------|
|
||||
| **Jinja2** | 3.1.5 | 模板引擎 | **合理**。FastAPI 标配 | 低 |
|
||||
| **aiofiles** | 24.1.0 | 异步文件 IO | **合理**。文件上传/管理场景 | 低 |
|
||||
| **PyYAML** | 6.0.2 | YAML 解析 | **遗留依赖**。架构优化报告中已确认 Agent 配置从 YAML 迁移到 JSON,但依赖未移除 | 低 |
|
||||
| **Rich** | 13.9.4 | CLI 美化 | **低优先级**。CLI 工具使用,后端运行非必需 | 低 |
|
||||
### 1.8 部署与运维
|
||||
|
||||
### 1.8 前端技术
|
||||
|
||||
| 技术 | 版本 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|------|-----------|---------|
|
||||
| **PHP** | 7.4 | 前端管理页面 | **过渡方案**。roadmap ADR-006 已确认全面弃用 AdminLTE,迁移到 Tailwind CSS v4 + 静态 HTML 调 API | **高** |
|
||||
| **AdminLTE** | 3.x (推断) | UI 框架 | **淘汰中**。与 Tailwind CSS v4 不兼容,迁移期间双框架并存 | **中** |
|
||||
| **Tailwind CSS v4** | 4 | CSS 框架 | **合理**。零运行时、纯静态 | 低 |
|
||||
| **Monaco Editor** | 0.45.0 | 代码编辑器 | **合理**。文件管理器中编辑代码 | 低 |
|
||||
| **Font Awesome** | 6.5.1 | 图标库 | **合理** | 低 |
|
||||
| **xterm.js** | — | 终端模拟器 | **规划中**。Web SSH 终端方案选用,合理 | 低(未落地) |
|
||||
| **Vanilla JS** | — | 前端逻辑 | **合理**。不引入 Vue/React 框架,与现有 PHP 集成更简单 | 低 |
|
||||
|
||||
### 1.9 部署与运维
|
||||
|
||||
| 技术 | 版本 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|------|-----------|---------|
|
||||
| **Nginx** | — | 反向代理 | **合理** | 低 |
|
||||
| **Supervisor** | — | 进程管理 | **合理**。3 层守护的 Layer 1 | 低 |
|
||||
| **MySQL** | 8.4 | 持久化存储 | **合理**。宝塔标配,生态成熟 | 低 |
|
||||
| **Redis** | 7 | 缓存/实时数据 | **合理**。心跳热数据、告警冷却、Pub/Sub。但文档对 Redis key 设计完整记录不足 | **中** |
|
||||
| 技术 | 用途 | 选型合理性 | 风险评估 |
|
||||
|------|------|-----------|---------|
|
||||
| **Nginx** | 反向代理 | **合理**。模板化配置 ({{PLACEHOLDER}}),支持 HTTP/HTTPS | 低 |
|
||||
| **Supervisor** | 进程管理 | **合理**。3 层守护 Layer 1 | 低 |
|
||||
| **MySQL** | 持久化存储 | **合理**。宝塔标配,14 张表 | 低 |
|
||||
| **Redis** | 缓存/实时数据 | **合理**。心跳热数据、告警冷却、Pub/Sub | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 二、文档提及但未落地的技术
|
||||
## 二、v1 标注"未落地"但实际已落地的功能
|
||||
|
||||
以下技术在设计文档或规划中明确提及,但实际代码中尚未实现或未达到文档描述的成熟度:
|
||||
> 以下功能在 v1 报告中标注为"未落地",但代码审查确认**已完整实现**:
|
||||
|
||||
| 技术 | 提及位置 | 文档描述 | 落地状态 | 差距分析 |
|
||||
|------|---------|---------|---------|---------|
|
||||
| **asyncssh 连接池** | roadmap ADR-002, 竞品分析 §5.3 | 替代 paramiko,引用计数模式连接池 | **未落地** | requirements.txt 已引入 asyncssh,但当前 SSH 操作仍以 paramiko 为主。连接池设计仅停留在 ADR 阶段 |
|
||||
| **xterm.js** | roadmap N2, 竞品分析 §5 | Web SSH 终端,Koko 消息协议 | **未落地** | 仅规划,无实现代码 |
|
||||
| **JWT + TOTP 登录** | roadmap ADR-005 | 替换 PHP 登录,全新 JWT 认证 | **未落地** | PyJWT 和 bcrypt 已安装,admins 表存在,但认证仍是 PHP Session |
|
||||
| **Platform 资产模型** | roadmap N1, 竞品分析 §2 | Platform 模板 + 单表 Asset + JSONB | **未落地** | 仅 ADR 阶段,servers 表未扩展 |
|
||||
| **WebSocket Terminal 端点** | roadmap N2 | `/ws/terminal/{token}` | **未落地** | WebSocket 已在用(推送进度),但终端端点未实现 |
|
||||
| **SFTP 文件传输** | roadmap N3 | SFTP 文件管理通道 | **未落地** | 仅有 WebFM(管理本机),SFTP 通道未实现 |
|
||||
| **命令日志** | roadmap N5 | command_logs 表,SSH 命令记录 | **未落地** | 仅规划,审计日志 audit_logs 表存在但记录 API 操作而非 SSH 命令 |
|
||||
| **apscheduler 定时推送** | spec simplified-push | push_schedules 表(cron_expr, server_ids) | **已建表但未深度集成** | push_schedules 表存在但文档和代码中都缺少调度执行逻辑 |
|
||||
| 功能 | v1 判定 | 实际状态 | 实现位置 |
|
||||
|------|---------|---------|---------|
|
||||
| **asyncssh 连接池** | "未落地" | ✅ **已落地** | `server/infrastructure/ssh/asyncssh_pool.py` — 引用计数模式,acquire/release/cleanup |
|
||||
| **JWT + TOTP 登录** | "未落地" | ✅ **已落地** | `server/api/auth_jwt.py` + `web/app/login.html` + `web/app/api.js` |
|
||||
| **SFTP 文件传输** | "未落地" | ✅ **已落地** | `server/application/services/sync_engine_v2.py` — `sftp_transfer()` |
|
||||
| **命令日志** | "未落地" | ✅ **已落地** | `server/domain/models.py` CommandLog + `server/infrastructure/database/ssh_session_repo.py` |
|
||||
| **SSH 会话管理** | "未落地" | ✅ **已落地** | `server/api/webssh.py` + SshSession/CommandLog 模型 |
|
||||
| **定时推送调度** | "未深度集成" | ✅ **已落地** | `server/background/schedule_runner.py` + `server/background/retry_runner.py` |
|
||||
| **WebSocket 告警推送** | "未落地" | ✅ **已落地** | `server/api/websocket.py` — 两层架构 (ADR-010) + Dashboard 实时更新 |
|
||||
|
||||
---
|
||||
|
||||
## 三、代码使用但文档未记录的技术
|
||||
## 三、已移除的依赖(v1 仍在评估)
|
||||
|
||||
以下技术已在代码(requirements.txt / config.py / .env.example)中实际存在,但设计文档和规划中**完全没有提及**或**严重缺乏记录**:
|
||||
| 依赖 | v1 评估 | 实际操作 | 原因 |
|
||||
|------|---------|---------|------|
|
||||
| **APScheduler** | "存疑" | ✅ **已移除** | 自定义 asyncio loop 更轻量、原生异步 |
|
||||
| **Jinja2** | "合理" | ✅ **已移除** | 纯 API 后端,无模板渲染需求 |
|
||||
| **aiofiles** | "合理" | ✅ **已移除** | asyncssh SFTP 替代异步文件操作 |
|
||||
| **Rich** | "低优先级" | ✅ **已移除** | 后端非 CLI 工具,无使用场景 |
|
||||
| **Alembic** | "必要组件" | ✅ **已移除** | 使用 `init_db()` 自动建表,无迁移需求 |
|
||||
| **PyYAML** | "遗留依赖" | ⚠️ **保留** | Agent 配置已迁移到 JSON,但可能有其他 YAML 场景 |
|
||||
|
||||
### 3.1 已安装但文档完全未记录
|
||||
---
|
||||
|
||||
| 技术 | 位置 | 用途推断 | 文档缺位程度 | 建议 |
|
||||
|------|------|---------|------------|------|
|
||||
| **APScheduler** | requirements.txt | 定时调度 | **严重**。只在 spec/plan 的 `timedelta import 修复` 中一笔带过,无任何架构说明 | 新增架构决策文档,明确 APScheduler vs asyncio loop vs cron 的职责边界 |
|
||||
| **Jinja2** | requirements.txt | 模板渲染 | **完全未记录**。当前所有前端是 PHP 页面,Jinja2 用途不明(可能用于邮件模板或 API 文档) | 记录使用场景 |
|
||||
| **httpx** | requirements.txt | HTTP 客户端 | **轻微缺失**。代码中使用但文档未提及 | — |
|
||||
| **aiofiles** | requirements.txt | 异步文件 IO | **完全未记录** | 记录使用场景 |
|
||||
| **python-multipart** | requirements.txt | 表单解析 | **完全未记录** | 记录使用场景 |
|
||||
| **PyYAML** | requirements.txt | YAML 解析 | **完全未记录**。架构优化已确认迁移到 JSON,但依赖未移除 | 考虑移除或记录用途 |
|
||||
| **Rich** | requirements.txt | CLI 美化 | **完全未记录** | 记录使用场景 |
|
||||
## 四、配置与部署
|
||||
|
||||
### 3.2 关键配置项文档缺位
|
||||
### 4.1 环境变量(已统一)
|
||||
|
||||
| 配置项 | 位置 | 状态 | 问题 |
|
||||
|--------|------|------|------|
|
||||
| `MULTISYNC_MODE` | .env.example | **未迁移** | 属性是 `ssh_direct/agent/hybrid`,但 config.py 中完全未定义此模式字段。概念存在但代码层面消失 |
|
||||
| `MULTISYNC_LOG_DIR` | .env.example | **未迁移** | config.py 无对应属性,日志路径未集中管理 |
|
||||
| `MULTISYNC_BACKUP_DIR` | .env.example | **未迁移** | 同上,备份路径未定义 |
|
||||
| `MULTISYNC_CORS_ORIGINS` | .env.example | **未迁移** | config.py 无 CORS 配置属性 |
|
||||
| `MULTISYNC_ADMIN_USERNAME/PASSWORD` | .env.example | **未迁移** | 初始管理员凭据 |
|
||||
| `MULTISYNC_MAX_CONCURRENT_PUSHES` | .env.example | **未迁移** | Sprint 中提到底层用了 `asyncio.Semaphore`,推送上限是 600,但 config.py 没有此配置 |
|
||||
| `HEALTH_CHECK_TIMEOUT` | .env.example | **未迁移** | config.py 只有 `HEALTH_CHECK_INTERVAL` 没有 `HEALTH_CHECK_TIMEOUT` |
|
||||
| **Redis key 设计规范** | 多处 spec | **分散记录** | Redis key 在多个 spec 中零散定义(`heartbeat:{id}`, `install:{id}:log`, `alert:{id}:{key}` 等),无统一参考文档 |
|
||||
| 项目 | 状态 |
|
||||
|------|------|
|
||||
| 前缀统一 | ✅ `NEXUS_` 前缀(config.py `env_prefix="NEXUS_"`) |
|
||||
| .env.example | ✅ 已从 `MULTISYNC_` 更新为 `NEXUS_` |
|
||||
| 部署路径 | ✅ `NEXUS_DEPLOY_PATH` 可配置 |
|
||||
| CORS | ✅ `NEXUS_CORS_ORIGINS` 可配置 |
|
||||
|
||||
### 3.3 环境变量前缀不一致
|
||||
### 4.2 连接池配置
|
||||
|
||||
| 问题 | 详情 | 影响 |
|
||||
| 组件 | 配置 | 说明 |
|
||||
|------|------|------|
|
||||
| `.env.example` 使用 `MULTISYNC_` 前缀 | `MULTISYNC_HOST`, `MULTISYNC_PORT`... | **与 config.py 的 `NEXUS_` 前缀不一致**。config.py:63 定义 `env_prefix="NEXUS_"`,但 .env.example 仍是旧项目名 `MULTISYNC_`。用户复制 .env.example 到 .env 后将读不到任何配置 |
|
||||
| 文档中混用 | 有些文档写 `NEXUS_`,有些写 `MULTISYNC_` | 部署和排障时造成混淆 |
|
||||
| MySQL | pool_size=160, max_overflow=120 | 最大 280 连接 (基于 MySQL max_connections=400, install.php 自动计算) |
|
||||
| Redis | BlockingConnectionPool (ADR-008) | 达上限等待而非抛异常 |
|
||||
| asyncssh | MAX_CONNECTIONS=100, IDLE_TIMEOUT=300s | 引用计数模式 (ADR-004) |
|
||||
|
||||
### 4.3 Redis Key 设计规范
|
||||
|
||||
| Key 格式 | 类型 | TTL | 用途 |
|
||||
|----------|------|-----|------|
|
||||
| `heartbeat:{server_id}` | HSET | 600s | Agent 心跳数据 (is_online, system_info, agent_version) |
|
||||
| `alerts:{server_id}` | SET | 3600s | 告警状态标记 |
|
||||
| `nexus:alerts` | Pub/Sub channel | — | WebSocket 告警广播 (ADR-010) |
|
||||
| `sync:progress:{op}:{ts}` | STRING | — | 推送进度追踪 |
|
||||
|
||||
---
|
||||
|
||||
## 四、建议调整的技术选型
|
||||
## 五、架构决策落地状态
|
||||
|
||||
### 4.1 紧急:aiomysql → asyncmy(或 aiomysql 升级监控)
|
||||
|
||||
**现状**: `aiomysql==0.2.0`。该库基于 `pymysql`(纯 Python),0.2.0 版本较旧,已知存在连接池问题。
|
||||
|
||||
**建议**:
|
||||
- 短期:锁定版本并增加连接池监控。目前池配置 `pool=400 overflow=300` 极端激进(MySQL 默认 `max_connections=151`),必须在 DB 侧同步调整。
|
||||
- 中期:评估迁移到 `asyncmy`(基于 `pymysql` 但 C 扩展加速)或 `mysql-connector-python` 异步驱动。
|
||||
|
||||
**严重度**: **中高** — 400+300 的连接池配置可能远超 MySQL 承受能力,是潜在的稳定性风险。
|
||||
|
||||
### 4.2 建议:统一 SSH 库为 asyncssh
|
||||
|
||||
**现状**: 两个 SSH 库并存 —— `paramiko==3.5.0`(同步,当前主力)和 `asyncssh==2.17.0`(异步,未使用)。
|
||||
|
||||
**建议**:
|
||||
- Sprint 中已确认 `paramiko` 阻塞事件循环("47s health 响应"),用 ThreadPoolExecutor 缓解。这是治标。
|
||||
- 按 roadmap ADR-002 方向,用 `asyncssh` 完全替代 `paramiko`,消除线程池开销。
|
||||
- 这是一项重构(估计 2-3 天),但可消除当前 50 线程的 ThreadPoolExecutor + Semaphore 复杂度。
|
||||
|
||||
**严重度**: **中** — 当前方案能工作,但架构不优雅。
|
||||
|
||||
### 4.3 警告:连接池配置与 MySQL 容量不匹配
|
||||
|
||||
**现状**:
|
||||
```
|
||||
DB: pool=400 overflow=300 → 最大 700 连接
|
||||
Redis: max_connections=1000
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- MySQL 8.4 默认 `max_connections=151`。700 连接需要 MySQL 侧设置 `max_connections=1000+`。如果未设置,连接请求将在 MySQL 侧排队或拒绝。
|
||||
- `pool=400` 远超常见推荐值(CPU 核心数 * 2 + 有效磁盘数)。大量空闲连接也会消耗 MySQL 内存。
|
||||
- Sprint 规划中注明了"DB 池终版"但没有附加 MySQL 配置说明。
|
||||
|
||||
**建议**: 在部署文档中显式写明月度该配置。添加连接池使用率告警。考虑改用 `pool_size=50, max_overflow=50` 并用实际压测结果调优。
|
||||
|
||||
**严重度**: **高** — 当前配置可能在 2000 台服务器承载下触发 MySQL 连接瓶颈。
|
||||
|
||||
### 4.4 建议:APScheduler → 轻量级方案重评估
|
||||
|
||||
**现状**: `apscheduler==3.11.0` 已安装使用,但 Sprint 记录显示存在 `timedelta` import bug。APScheduler 3.x 的异步支持通过 `AsyncIOScheduler` 实现,可靠但配置复杂。
|
||||
|
||||
**建议**:
|
||||
- 评估是否可以用 `asyncio` 原生 loop + Redis 分布式锁替代 APScheduler。Nexus 当前的后台任务(retry_loop、heartbeat_flush、self_monitor)都是简单 while-sleep 模式,不需要 Cron 表达式支持。
|
||||
- 如果保留 APScheduler,需升级到 3.11+(当前已满足)并关注 4.0 异步原生支持。
|
||||
|
||||
**严重度**: **低** — 当前方案可用,但有已知 bug。
|
||||
|
||||
### 4.5 建议:环境变量前缀统一
|
||||
|
||||
**现状**: `.env.example` 使用 `MULTISYNC_` 前缀,`config.py` 定义 `env_prefix="NEXUS_"`。
|
||||
|
||||
**建议**: 统一为 `NEXUS_`。当前不一致意味着用户复制 `.env.example` 后无法直接使用,必须手动修改变量名。这是一个部署陷阱。
|
||||
|
||||
**严重度**: **中** — 首次部署必然踩坑。
|
||||
|
||||
### 4.6 建议:冗余依赖清理
|
||||
|
||||
| 依赖 | 现状 | 建议 |
|
||||
|------|------|------|
|
||||
| `pyyaml==6.0.2` | Agent 配置已从 YAML 迁移到 JSON | **移除** |
|
||||
| `jinja2==3.1.5` | 无明确使用场景 | 确认用途或标注 future use |
|
||||
| `rich==13.9.4` | 无明确使用场景 | 确认用途或移除 |
|
||||
| `paramiko==3.5.0` | asyncssh 是替代方向 | 迁移后移除 |
|
||||
|
||||
### 4.7 建议:PHP/Python 双数据库连接
|
||||
|
||||
**现状**: PHP 通过 `config.php` 直连 MySQL,Python 通过 `DATABASE_URL` 连接同一 MySQL。
|
||||
|
||||
**风险**: PHP 和 Python 使用独立的连接池,无法统一控制。php-fpm 的每个 worker 维护独立 MySQL 连接。当 PHP 前端页面被大量打开时,连接数可能翻倍。
|
||||
|
||||
**建议**: roadmap 已规划"全面弃用 AdminLTE,纯静态 HTML + JS 调 FastAPI API",这是正确的方向。建议加速 PHP→API 迁移,减少对 PHP 直连 MySQL 的依赖。
|
||||
|
||||
### 4.8 文件管理器双重编辑器
|
||||
|
||||
**现状**: 文件管理器 v2 引入 Monaco Editor (CDN),但 code-review-followup spec 提到 ACE Editor 已本地化为 `web/ace/ace.js`(444KB)。
|
||||
|
||||
**问题**: 为什么同时维护两个编辑器?Monaco 功能更强但体积更大,ACE 更轻量但已本地化。需要确认是用 Monaco 替代 ACE,还是两者并存用于不同场景。
|
||||
|
||||
**建议**: 统一为一个编辑器,建议保留 Monaco(功能更完整),移除 ACE。
|
||||
| 编号 | 决策 | 选择 | 落地状态 |
|
||||
|------|------|------|---------|
|
||||
| ADR-001 | 资产模型 | Platform 模板 + 单表 Asset + JSONB | ✅ 已落地 — Platform/Node/Server 扩展字段 |
|
||||
| ADR-002 | Web SSH 架构 | BFF 模式,asyncssh 内嵌 | ✅ 已落地 — `webssh.py` + `asyncssh_pool.py` |
|
||||
| ADR-003 | 前端终端 | xterm.js + Koko 协议 | ⚠️ 后端已落地,前端未实现 |
|
||||
| ADR-004 | SSH 连接池 | 引用计数模式 | ✅ 已落地 — `asyncssh_pool.py` |
|
||||
| ADR-005 | 登录认证 | JWT + TOTP 全新替换 | ✅ 已落地 — `auth_jwt.py` + `login.html` |
|
||||
| ADR-006 | 前端框架 | 全面弃用 AdminLTE | ✅ 已落地 — 12+ Tailwind 页面 |
|
||||
| ADR-007 | Redis 定位 | 核心数据层 | ✅ 已落地 |
|
||||
| ADR-008 | Redis 连接池 | BlockingConnectionPool | ✅ 已落地 |
|
||||
| ADR-009 | Redis 可用性 | 启动强校验 | ✅ 已落地 |
|
||||
| ADR-010 | WebSocket 架构 | 内存 + Redis Pub/Sub 两层 | ✅ 已落地 |
|
||||
| ADR-011 | WebSocket 认证 | JWT 查询参数 | ✅ 已落地 |
|
||||
|
||||
---
|
||||
|
||||
## 五、竞品对比结论
|
||||
## 六、竞品对比结论(更新)
|
||||
|
||||
### 5.1 与 JumpServer 的技术选型对比
|
||||
|
||||
| 维度 | Nexus | JumpServer | 评价 |
|
||||
|------|-------|-----------|------|
|
||||
| **后端框架** | FastAPI (异步) | Django (同步+Channels) | Nexus 在异步原生支持上领先,但 JumpServer 的生态(插件、管理后台)更成熟 |
|
||||
| **ORM** | SQLAlchemy 2.0 Async | Django ORM | SQLAlchemy 更灵活,但 JumpServer ORM 的 `F()` 表达式和 `annotate` 在复杂查询中更便捷 |
|
||||
| **SSH 代理** | 内嵌 (FastAPI 进程) | 独立 Koko (Go 二进制) | JumpServer 的分离架构更健壮,但 Nexus 的部署更简单。2000 台规模时 Nexus 可能需拆分 |
|
||||
| **WebSocket** | FastAPI 原生 | Django Channels | Nexus 更简洁,无额外依赖 |
|
||||
| **前端** | PHP + Tailwind (过渡中) | Vue 3 + Element Plus | JumpServer 前端现代化程度更高 |
|
||||
| **协议支持** | SSH 为主 | SSH/RDP/VNC/Telnet/MySQL/K8s | JumpServer 覆盖更广 |
|
||||
| **录像审计** | 无 | asciicast v2 + S3/MinIO | Nexus 明确否决(roadmap 已否决) |
|
||||
| **MFA** | TOTP (规划中) | TOTP/LDAP/CAS/OIDC/SAML2/OAuth2/RADIUS | JumpServer 认证体系极为全面 |
|
||||
| **RBAC** | 鉴权即信任 | 四层权限 (RBAC+AssetPermission+ACL) | Nexus 故意简化,适合运维团队内部使用场景 |
|
||||
|
||||
### 5.2 与 EasyNode 的技术选型对比
|
||||
|
||||
| 维度 | Nexus | EasyNode | 评价 |
|
||||
|------|-------|---------|------|
|
||||
| **后端** | Python FastAPI | Node.js Koa | Nexus 在类型安全和性能上更优 |
|
||||
| **前端** | PHP (过渡) | Vue 3 | EasyNode 前端现代化 |
|
||||
| **WebSocket** | FastAPI 原生 | Node.js 原生 | 架构相似 |
|
||||
| **数据存储** | MySQL + Redis | NeDB (文件数据库) | Nexus 存储方案更健壮,适合 2000+ 规模 |
|
||||
| **SSH** | paramiko + asyncssh | ssh2 npm | EasyNode 的 Node.js 生态在 SSH 异步上天然优势 |
|
||||
|
||||
### 5.3 竞品分析采纳状态
|
||||
|
||||
竞品分析报告(`jumpserver-easynode-architecture-analysis.md`)提出了 5 个 ADR 建议,采纳情况如下:
|
||||
|
||||
| ADR | 内容 | Roadmap 采纳 | 实际落地 | 评价 |
|
||||
|-----|------|-------------|---------|------|
|
||||
| ADR-001 | Platform 模板 + 单表 Asset + JSONB | **已采纳**(roadmap N1) | 未落地 | 正确采纳,规划合理 |
|
||||
| ADR-002 | BFF 模式内嵌 SSH 代理 (asyncssh) | **已采纳**(roadmap N2) | 未落地 | 正确采纳,但应加速 |
|
||||
| ADR-003 | asciicast v2 会话录像 | **已否决**(roadmap §已否决) | N/A | 合理,Nexus 定位不需要录像 |
|
||||
| ADR-004 | RBAC + AssetPermission 两层权限 | **已否决**(roadmap §已否决) | N/A | 合理,保持"鉴权即信任" |
|
||||
| ADR-005 | xterm.js + Koko 协议 | **已采纳**(roadmap N2) | 未落地 | 正确采纳,应加速 |
|
||||
|
||||
**结论**: 设计部已合理筛选竞品分析建议,但落地速度需要加快。N1(资产模型)和 N2(Web SSH)是差异化核心功能,但当前进展为零。
|
||||
|
||||
### 5.4 竞品关键启示 — Nexus 当前忽略的部分
|
||||
|
||||
竞品分析中包含但 Nexus 未响应的关键技术启示:
|
||||
|
||||
1. **Connect Token 机制** — JumpServer 最精华的安全设计之一,短生命周期一次性连接令牌。Nexus 的 "鉴权即信任" 在直连场景够用,但引入 Web SSH 时会话级安全不可忽视。
|
||||
2. **资产授权链缓存** — JumpServer 通过 Redis 三级缓存资产授权关系。Nexus 2000+ 服务器规模下,每次查询都扫描所有授权关系可能成为瓶颈。
|
||||
3. **命令过滤 ACL** — roadmap 已否决,但作为 2000+ 服务器的运维平台,高危命令防护(`rm -rf /`、`DROP TABLE`)有实际业务价值。
|
||||
4. **SSH 连接复用** — Koko 的引用计数连接池模式。Nexus 当前每个操作建立新 SSH 连接,2000+ 规模下 SSH 握手延迟(TCP + 密钥交换,通常 1-3s)会显著累加。
|
||||
| 维度 | Nexus (当前) | JumpServer | 评价 |
|
||||
|------|-------------|-----------|------|
|
||||
| **SSH 代理** | asyncssh 内嵌 (异步原生) | 独立 Koko (Go 二进制) | Nexus 部署更简单,2000 台规模足够 |
|
||||
| **连接池** | 引用计数 asyncssh | Koko 引用计数 | 设计思路一致,Nexus 用 Python 实现 |
|
||||
| **认证** | JWT + TOTP | TOTP/LDAP/OIDC/SAML2 | Nexus 简化方案适合内部运维 |
|
||||
| **前端** | Tailwind + Alpine.js (纯静态) | Vue 3 + Element Plus | Nexus 零构建步骤,部署更简单 |
|
||||
|
||||
---
|
||||
|
||||
## 六、风险评估矩阵
|
||||
## 七、待落地功能
|
||||
|
||||
| 风险 | 严重度 | 发生概率 | 影响 | 建议行动 |
|
||||
|------|--------|---------|------|---------|
|
||||
| aiomysql 连接池不稳定 (pool=400/overflow=300) | **高** | **中** | 数据库连接耗尽 → 服务不可用 | 调低池配置,增加 MySQL `max_connections` 监控告警 |
|
||||
| .env.example 前缀 MULTISYNC_ vs config.py NEXUS_ 不一致 | **中** | **高** | 首次部署配置全部失效 | 统一前缀为 NEXUS_ |
|
||||
| 双 SSH 库 (paramiko + asyncssh) 并存 | **低** | **高** | 技术债务累积,维护混淆 | 制定 asyncssh 迁移计划 |
|
||||
| PHP/Python 双数据库直连 | **中** | **高** | 连接数翻倍,不可控 | 加速 PHP→API 迁移 |
|
||||
| Redis 文档缺位 — key 设计分散在多个 spec | **中** | **高** | 新开发者难以理解数据流 | 创建 Redis key 设计规范文档 |
|
||||
| APScheduler 异步兼容性 | **低** | **中** | 后台任务可能异常 | 监控或迁移到 asyncio loop |
|
||||
| Agent 配置 YAML→JSON 过渡未完成 | **低** | **中** | pyyaml 依赖残留 | 清理 pyyaml |
|
||||
| 环境变量 `HEALTH_CHECK_TIMEOUT` 和 `MAX_CONCURRENT_PUSHES` 从 config.py 缺失 | **中** | **高** | 这些配置生效但无统一管理入口 | 补全到 config.py Settings 类 |
|
||||
| 功能 | 优先级 | 预估工时 | 说明 |
|
||||
|------|--------|---------|------|
|
||||
| xterm.js 前端页面 | 中 | 2-3 天 | WebSSH 后端已就绪,需前端页面 |
|
||||
| 推送前预览 (rsync dry-run) | 低 | 0.5 天 | 推送对话框加"预览"按钮 |
|
||||
| 首次使用引导 | 低 | 1 天 | install.php 后3步向导 |
|
||||
| 命令日志查询页面 | 低 | 0.5 天 | 后端 API 已就绪 |
|
||||
| ~~paramiko 完全移除~~ | 低 | ~~0.5 天~~ ✅已完成 | pool.py 已删除 + requirements.txt 已移除 |
|
||||
|
||||
---
|
||||
|
||||
## 七、总结
|
||||
## 八、风险评估矩阵(更新)
|
||||
|
||||
### 强项
|
||||
1. **FastAPI + SQLAlchemy 2.0 Async** 是 2026 年 Python 后端的最优组合
|
||||
2. **Redis 作为实时数据层** 的设计思路正确(心跳、告警冷却、Pub/Sub 进度推送)
|
||||
3. **3 层守护机制** (Supervisor + Python 自检 + Shell) 覆盖全面
|
||||
4. **竞品分析质量高**,ADR 筛选决策合理
|
||||
5. **WebSocket 实时推送 + Telegram 双通道告警** 架构完整
|
||||
|
||||
### 短板
|
||||
1. **核心文档缺位**: Redis key 设计无集中文档,多个 spec 零散定义。环境变量前缀不统一是部署陷阱。
|
||||
2. **连接池配置激进**: 400+300 的 pool 配置结合 aiomysql 的稳定性,是当前最大的潜在生产风险。
|
||||
3. **双 SSH 库** 增加维护成本,paramiko 阻塞事件循环的架构问题未根治。
|
||||
4. **前端技术栈过渡期**: PHP AdminLTE + Tailwind CSS v4 并存,短期增加维护负担。AC 编辑器/Monaco 编辑器双重存在。
|
||||
5. **Python 依赖中有 20% 无明确使用场景记录**: pyyaml、jinja2、rich 等需要确认用途或移除。
|
||||
6. **.env.example 与 config.py 不对齐**: 多个配置项(CORS、MODE、LOG_DIR、BACKUP_DIR、MAX_CONCURRENT_PUSHES、TIMEOUT)在 .env.example 中存在但 config.py 缺失。
|
||||
7. **Roadmap 规划的资产模型和 Web SSH 零进度**: 核心差异化功能仍停留在 ADR 阶段,这是时间管理风险而非技术风险。
|
||||
|
||||
### 立即行动项 (按优先级)
|
||||
1. **紧急**: 验证 MySQL `max_connections` 是否匹配 700 的连接池上限,调低池配置
|
||||
2. **紧急**: 修复 .env.example 前缀 `MULTISYNC_` → `NEXUS_`
|
||||
3. **高**: 补全 config.py Settings 类缺失的配置属性
|
||||
4. **高**: 创建 Redis key 设计规范统一文档
|
||||
5. **中**: 启动 asyncssh 迁移替代 paramiko
|
||||
6. **中**: 清理 pyyaml、确认 jinja2/rich 用途
|
||||
7. **中**: 统一文件管理器编辑器 (Monaco vs ACE)
|
||||
8. **低**: APScheduler 替换评估
|
||||
| 风险 | 严重度 | 发生概率 | 当前状态 |
|
||||
|------|--------|---------|---------|
|
||||
| aiomysql 连接池稳定性 | 中 | 低 | pool=160/120 合理 (基于 MySQL max_connections=400, install.php 自动计算) |
|
||||
| PHP 残留页面 | 低 | 中 | install.php/login.php 保留,其余已迁移 |
|
||||
| 文档与代码不同步 | 中 | 高 | **本报告已修正**,需持续维护 |
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
### 基础设施
|
||||
- `server/infrastructure/redis/client.py` — Redis异步客户端(max_connections从settings读)
|
||||
- `server/infrastructure/ssh/pool.py` — SSH连接池
|
||||
- `server/infrastructure/ssh/asyncssh_pool.py` — asyncssh SSH连接池 (旧 paramiko pool.py 已删除)
|
||||
- `server/infrastructure/telegram/__init__.py` — Telegram推送(httpx模块级单例)
|
||||
- `server/infrastructure/database/crypto.py` — Fernet+AES双格式加密
|
||||
- `server/infrastructure/database/session.py` — SQLAlchemy异步session
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Nexus 6.0 项目概述
|
||||
|
||||
Nexus是2000+服务器运维管理平台,从旧MultiSync(PHP+AdminLTE+pymysql)完全重构为Clean Architecture(FastAPI+Async SQLAlchemy+Redis+WebSocket+Telegram)。唯一仓库Nexus(Gitea http://admin:uzumaki77@66.154.115.8:3000/admin/Nexus.git)。部署路径/www/wwwroot/api.synaglobal.vip/,域名api.synaglobal.vip(强制HTTPS)。目录结构: server/(Python后端), web/(PHP前端), deploy/(Supervisor+Shell), docs/, tests/。
|
||||
Nexus是2000+服务器运维管理平台,从旧MultiSync(PHP+AdminLTE+pymysql)完全重构为Clean Architecture(FastAPI+Async SQLAlchemy+Redis+WebSocket+Telegram)。唯一仓库Nexus(Gitea,仓库地址在.env中配置)。部署路径由`NEXUS_DEPLOY_PATH`环境变量决定,域名由`NEXUS_CORS_ORIGINS`环境变量决定。目录结构: server/(Python后端), web/(PHP前端), deploy/(Supervisor+Shell), docs/, tests/。
|
||||
|
||||
## 相关概念
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
> **项目**: Nexus — 服务器运维管理平台
|
||||
> **版本**: 6.0.0
|
||||
> **技术栈**: Python 3.12+ / FastAPI / Async SQLAlchemy / MySQL 8.4 / Redis 7 / PHP 8.2+ / Nginx
|
||||
> **生产域名**: `api.synaglobal.vip` (强制 HTTPS)
|
||||
> **网站目录**: `/www/wwwroot/api.synaglobal.vip`
|
||||
> **生产域名**: 由 `NEXUS_CORS_ORIGINS` 环境变量配置(示例: `api.synaglobal.vip`)
|
||||
> **网站目录**: 由 `NEXUS_DEPLOY_PATH` 环境变量配置(示例: `/www/wwwroot/api.synaglobal.vip`)
|
||||
> **更新日期**: 2026-05-20
|
||||
|
||||
---
|
||||
@@ -265,7 +265,7 @@ sudo make altinstall # altinstall 防止覆盖系统 python3
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
git clone http://66.154.115.8:3000/admin/Nexus.git
|
||||
git clone http://<GITEA_HOST>/admin/Nexus.git # 替换为实际 Gitea 地址
|
||||
cd Nexus
|
||||
```
|
||||
|
||||
|
||||
+162
-208
@@ -1,6 +1,6 @@
|
||||
# Nexus 项目建议书
|
||||
|
||||
> 更新: 2026-05-21
|
||||
> 更新: 2026-05-22
|
||||
|
||||
---
|
||||
|
||||
@@ -9,74 +9,87 @@
|
||||
### 运行状态
|
||||
|
||||
```
|
||||
生产服务器: 141/253 在线
|
||||
技术栈: Python 3.12 / FastAPI / MySQL 8.4 / Redis 7 / Nginx
|
||||
前端: PHP AdminLTE(旧)+ Tailwind CSS v4 + Alpine.js(新,后端完成后全量迁移)
|
||||
数据库: 10 张表(servers, admins, sync_logs, scripts 等)
|
||||
核心功能: rsync 文件推送 + WebSocket 进度 + 重试引擎 + Telegram 告警
|
||||
后端: Clean Architecture 4层 (domain → infrastructure → application → api)
|
||||
前端: Tailwind CSS v4 + Alpine.js (纯静态 HTML,JWT 认证)
|
||||
数据库: 14 张表 (servers, admins, platforms, nodes, command_logs, ssh_sessions 等)
|
||||
核心功能: rsync 文件推送 + WebSocket 实时告警 + asyncssh 连接池 + Telegram 推送
|
||||
守护机制: 3层 (Supervisor + Python self_monitor 30s + Shell cron 1min)
|
||||
```
|
||||
|
||||
### 数据库现有表
|
||||
### 数据库现有表(14 张)
|
||||
|
||||
```
|
||||
servers (253台) 核心:id, name, domain, port, username, auth_method,
|
||||
password(加密), ssh_key_*(3), agent_port, agent_api_key,
|
||||
category, is_online, system_info
|
||||
核心表:
|
||||
servers (扩展) id, name, domain, port, username, auth_method,
|
||||
password(加密), ssh_key_*(3), agent_port, agent_api_key,
|
||||
category, is_online, system_info, last_heartbeat,
|
||||
platform_id, node_id, protocols(JSON), extra_attrs(JSON),
|
||||
connectivity, last_checked_at
|
||||
|
||||
admins 认证:username, password_hash, totp_secret, totp_enabled
|
||||
platforms 资产类型模板: name, category, type, default_protocols(JSON)
|
||||
nodes 树形分组: name, parent_id, sort_order
|
||||
admins 认证: username, password_hash, totp_secret, totp_enabled,
|
||||
jwt_refresh_token, jwt_token_expires
|
||||
|
||||
settings 配置:key → value
|
||||
settings 配置: key → value
|
||||
|
||||
sync_logs 推送日志:server_id, source/target_path, status, files_*
|
||||
push_schedules 定时推送:cron_expr, server_ids(JSON)
|
||||
push_retry_jobs 重试队列:retry_count, max_retries
|
||||
password_presets 密码预设:name, encrypted_pw
|
||||
db_credentials 数据库凭据:db_type, host, encrypted_password
|
||||
业务表:
|
||||
sync_logs 推送日志: server_id, source/target_path, status, files_*
|
||||
push_schedules 定时推送: cron_expr, server_ids(JSON)
|
||||
push_retry_jobs 重试队列: retry_count, max_retries
|
||||
password_presets 密码预设: name, encrypted_pw
|
||||
db_credentials 数据库凭据: db_type, host, encrypted_password
|
||||
|
||||
scripts 脚本库:name, category, content
|
||||
script_executions 执行记录:script_id, command, server_ids(JSON), results(JSON)
|
||||
scripts 脚本库: name, category, content
|
||||
script_executions 执行记录: script_id, command, server_ids(JSON), results(JSON)
|
||||
|
||||
audit_logs 审计:admin_username, action, target_type, detail
|
||||
login_attempts 防暴力:username, ip_address, success
|
||||
辅助表:
|
||||
audit_logs 审计: admin_username, action, target_type, detail
|
||||
login_attempts 防暴力: username, ip_address, success
|
||||
|
||||
Web SSH 表:
|
||||
ssh_sessions 会话: UUID id, server_id, admin_id, status, started_at, closed_at
|
||||
command_logs 命令: session_id, server_id, admin_id, command
|
||||
```
|
||||
|
||||
### 已完成迭代 (1-5)
|
||||
### 前端页面(Tailwind CSS v4 + Alpine.js)
|
||||
|
||||
| 迭代 | 内容 |
|
||||
|------|------|
|
||||
| 1 | 安全加固 + 架构统一 |
|
||||
| 2 | 简化推送模型 |
|
||||
| 3 | 密码预设 + 推送重试 |
|
||||
| 4 | 实时进度 + WebSocket |
|
||||
| 5 | 稳定性 + 全量修复 P0-P3 |
|
||||
| 页面 | 文件 | 功能 |
|
||||
|------|------|------|
|
||||
| 登录 | `login.html` | JWT 登录 + TOTP 双因素 |
|
||||
| 仪表盘 | `index.html` | 统计 + WebSocket 实时告警 |
|
||||
| 服务器 | `servers.html` | CRUD + 心跳状态 + 健康检测 |
|
||||
| 文件管理 | `files.html` | 远程文件浏览 (SFTP/SSH) |
|
||||
| 推送 | `push.html` | 批量文件推送 + 进度 |
|
||||
| 脚本 | `scripts.html` | 脚本库 + 执行 |
|
||||
| 凭据 | `credentials.html` | 密码预设 + 数据库凭据 |
|
||||
| 定时 | `schedules.html` | Cron 调度管理 |
|
||||
| 重试 | `retries.html` | 失败重试队列 |
|
||||
| 设置 | `settings.html` | 系统配置 |
|
||||
| 审计 | `audit.html` | 操作日志 |
|
||||
|
||||
共享模块: `api.js` — JWT 自动刷新、apiFetch 封装、登出
|
||||
|
||||
---
|
||||
|
||||
## 二、功能规划 (2026-05-21)
|
||||
## 二、架构决策记录
|
||||
|
||||
> 竞品分析:`docs/research/jumpserver-easynode-architecture-analysis.md`
|
||||
### 已落地决策
|
||||
|
||||
### 新增功能清单
|
||||
|
||||
| # | 功能 | 与现有关系 | 说明 | 预估 |
|
||||
|---|------|-----------|------|------|
|
||||
| N0 | Redis 加固 | **新增** 基础设施 | BlockingConnectionPool + 启动强校验 + Redis Pub/Sub | 1 周 |
|
||||
| N1 | 资产模型 | **扩展** servers 表 | 新建 platforms/nodes 表,servers 加字段 | 2-3 周 |
|
||||
| N2 | Web SSH 终端 | **复用** ssh_direct 底层 | asyncssh 连接池 + xterm.js + /ws/terminal/{token} | 3-4 周 |
|
||||
| N3 | Sync 引擎 | **升级** rsync 推送 | 文件+命令+配置统一同步,含 SFTP | 2 周 |
|
||||
| N4 | JWT 登录 + MFA | **全新替换** admins | Tailwind CSS v4 登录页(已完工),JWT + TOTP | 1 周 |
|
||||
| N5 | 命令日志 | **新建** command_logs 表 | SSH stdin 按行切,纯文本记录 | 1 周 |
|
||||
| N6 | SSH 会话 | **新建** ssh_sessions 表 | Web SSH 连接生命周期管理 | 含在 N2 |
|
||||
| N7 | 前端全量迁移 | **替换** 25+ PHP 页面 | Tailwind CSS v4 + Alpine.js,一次性全量迁移 | 3-4 周 |
|
||||
|
||||
### 已有可直接用的功能
|
||||
|
||||
| 功能 | 现有表 | 状态 |
|
||||
|------|--------|------|
|
||||
| 凭据托管 | password_presets + db_credentials | ✅ 已有,直接用 |
|
||||
| 脚本库 | scripts + script_executions | ✅ 已有,直接用 |
|
||||
| 审计日志 | audit_logs | ✅ 已有,直接用 |
|
||||
| 防暴力 | login_attempts | ✅ 已有,直接用 |
|
||||
| 编号 | 决策 | 选择 | 状态 |
|
||||
|------|------|------|------|
|
||||
| ADR-001 | 资产模型 | Platform 模板 + 单表 Asset + JSONB | ✅ 已落地 |
|
||||
| ADR-002 | Web SSH 架构 | BFF 模式,asyncssh 内嵌 FastAPI | ✅ 已落地 (后端) |
|
||||
| ADR-003 | 前端终端 | xterm.js + Koko 消息协议 | ⚠️ 后端落地,前端待做 |
|
||||
| ADR-004 | SSH 连接池 | 引用计数模式 | ✅ 已落地 |
|
||||
| ADR-005 | 登录认证 | JWT + TOTP 全新替换 | ✅ 已落地 |
|
||||
| ADR-006 | 前端框架 | 全面弃用 AdminLTE → Tailwind CSS v4 | ✅ 已落地 |
|
||||
| ADR-007 | Redis 定位 | 核心数据层 | ✅ 已落地 |
|
||||
| ADR-008 | Redis 连接池 | BlockingConnectionPool | ✅ 已落地 |
|
||||
| ADR-009 | Redis 可用性 | 启动强校验,不可用直接退出 | ✅ 已落地 |
|
||||
| ADR-010 | WebSocket 架构 | 内存 + Redis Pub/Sub 两层 | ✅ 已落地 |
|
||||
| ADR-011 | WebSocket 认证 | JWT 令牌(查询参数传递) | ✅ 已落地 |
|
||||
|
||||
### 已否决
|
||||
|
||||
@@ -88,181 +101,122 @@ login_attempts 防暴力:username, ip_address, success
|
||||
|
||||
---
|
||||
|
||||
## 三、冲突/重复处理
|
||||
## 三、实施进度
|
||||
|
||||
| 冲突点 | 处理方式 |
|
||||
|--------|---------|
|
||||
| rsync 推送 ↔ Sync 引擎 | 升级关系,原有 `/api/servers/push` 保留 |
|
||||
| WebFM ↔ SFTP | 互补:WebFM 管 Nexus 本机 `/www/wwwroot/`,SFTP 管远程服务器 |
|
||||
| ssh_direct ↔ Web SSH | 共用底层 asyncssh 连接池模块(提取公共代码) |
|
||||
| 现有 TOTP ↔ JWT+MFA | 全新替换,不走旧 TOTP 逻辑 |
|
||||
| AuditLog ↔ 命令日志 | 互补:AuditLog 记 API 操作,命令日志记 SSH 终端命令 |
|
||||
| Agent 心跳 ↔ Asset 在线 | 直接合并:`is_online` → `connectivity` |
|
||||
| 现有凭据 ↔ 新凭据托管 | 已有 `password_presets` + `db_credentials`,直接用 |
|
||||
### 第零步:基础设施加固(Redis + WebSocket)— ✅ 已完成
|
||||
|
||||
- [x] Redis BlockingConnectionPool + 启动强校验 (ADR-008/009)
|
||||
- [x] WebSocket 两层架构: 内存 ConnectionManager + Redis Pub/Sub (ADR-010)
|
||||
- [x] WebSocket JWT 认证 (ADR-011)
|
||||
- [x] Ping/pong 心跳 + 僵尸连接清理
|
||||
|
||||
### 第一步:数据层 — ✅ 已完成
|
||||
|
||||
- [x] platforms 表(资产类型模板)
|
||||
- [x] nodes 表(树形分组)
|
||||
- [x] servers 表扩展字段 (platform_id, node_id, protocols, extra_attrs, connectivity)
|
||||
- [x] command_logs 表(SSH 命令日志)
|
||||
- [x] ssh_sessions 表(Web SSH 会话)
|
||||
- [x] admins 表 JWT 字段
|
||||
- [x] 数据迁移: category → Node, 回填 platform_id
|
||||
|
||||
### 第二步:认证层 — ✅ 已完成
|
||||
|
||||
- [x] JWT 登录/刷新/验证 (`server/api/auth_jwt.py`)
|
||||
- [x] TOTP 双因素认证
|
||||
- [x] Tailwind CSS v4 登录页 (`web/app/login.html`)
|
||||
- [x] 共享 JWT 模块 (`web/app/api.js`)
|
||||
- [x] Pydantic 请求模型 (`server/api/schemas.py`)
|
||||
- [x] 所有 API 路由 JWT 保护
|
||||
|
||||
### 第三步:Web SSH 终端 — ⚠️ 70% (后端已完成)
|
||||
|
||||
- [x] asyncssh 连接池模块 (`server/infrastructure/ssh/asyncssh_pool.py`)
|
||||
- [x] WebSocket 端点 `/ws/terminal/{server_id}` (`server/api/webssh.py`)
|
||||
- [x] Koko 消息协议 (TERMINAL_INIT/DATA/RESIZE/CLOSE)
|
||||
- [x] SSH 会话生命周期追踪 (SshSession + CommandLog)
|
||||
- [ ] xterm.js 前端页面
|
||||
|
||||
### 第四步:Sync 引擎升级 — ✅ 已完成
|
||||
|
||||
- [x] rsync 文件同步 (SyncEngineV2.sync_files)
|
||||
- [x] SSH 命令批量执行 (SyncEngineV2.sync_commands)
|
||||
- [x] 系统配置推送 (SyncEngineV2.sync_config)
|
||||
- [x] SFTP 文件传输 (SyncEngineV2.sftp_transfer)
|
||||
- [x] Semaphore 并发控制 (MAX_CONCURRENT=10)
|
||||
- [x] 远程目录浏览 (POST /api/sync/browse)
|
||||
|
||||
### 第五步:前端全量迁移 — ✅ 已完成
|
||||
|
||||
- [x] @theme 设计系统(品牌色/间距/字体/暗黑模式)
|
||||
- [x] 布局壳(sidebar + header + main,Alpine.js 交互)
|
||||
- [x] Dashboard + WebSocket 实时告警
|
||||
- [x] 服务器列表 + 文件管理 + 推送页面
|
||||
- [x] 设置/配置/审计日志/脚本库/凭据管理
|
||||
- [x] 定时推送/重试队列
|
||||
- [x] install.php → install.html + FastAPI API 迁移
|
||||
- [x] 条件启动模式 (install mode: 无 .env 时仅安装 API 可用)
|
||||
|
||||
---
|
||||
|
||||
## 四、实现顺序
|
||||
## 四、安全性
|
||||
|
||||
### 第零步:基础设施加固(Redis + WebSocket)
|
||||
### 已实施
|
||||
|
||||
```
|
||||
- Redis 改用 BlockingConnectionPool(max_connections=50, timeout=5, health_check_interval=30)
|
||||
- get_redis() 改造:启动强校验,不可用直接退出,去掉 `Redis | None` 返回
|
||||
- 确认 Redis 持久化配置(混合 RDB+AOF)
|
||||
- WebSocket 升级两层架构:内存 ConnectionManager + Redis Pub/Sub
|
||||
- 告警/恢复/系统事件通过 Redis 频道 `nexus:alerts` 中继
|
||||
- 多 worker 场景消息不丢失
|
||||
- WebSocket 连接增加 ping/pong 心跳检测 + 僵尸连接清理
|
||||
```
|
||||
|
||||
> 预估:4-6 天 | 基础设施层,完成后再加 workers
|
||||
|
||||
### 第一步:数据层(不动现有业务)
|
||||
|
||||
```sql
|
||||
-- 1. 新建 platforms 表(资产类型模板)
|
||||
-- 2. 新建 nodes 表(树形分组)
|
||||
-- 3. 扩展 servers 表(加 platform_id, node_id, protocols, extra_attrs, connectivity)
|
||||
-- 4. 新建 command_logs 表(SSH 命令日志)
|
||||
-- 5. 新建 ssh_sessions 表(Web SSH 会话)
|
||||
-- 6. 扩展 admins 表(加 JWT 字段)
|
||||
-- 7. 数据迁移:category → Node, 回填 platform_id
|
||||
```
|
||||
|
||||
### 第二步:认证层
|
||||
|
||||
```
|
||||
- JWT 中间件 `get_current_admin` 部署到所有 API 路由
|
||||
- TOTP 绑定/管理 API 完善(补齐 JWT 验证)
|
||||
- 配置项 pool_size/max_overflow 由 install.php 自动计算写入 .env
|
||||
```
|
||||
|
||||
> 无需向后兼容旧 PHP 页面,JWT 一步到位覆盖所有路由
|
||||
|
||||
### 第三步:Web SSH 终端
|
||||
|
||||
```
|
||||
- 提取 ssh_direct.py 公共逻辑为 asyncssh 连接池模块
|
||||
- WebSocket 端点 /ws/terminal/{token}
|
||||
- xterm.js 前端页面
|
||||
- Koko 消息协议 (TERMINAL_INIT/DATA/RESIZE/CLOSE)
|
||||
```
|
||||
|
||||
### 第四步:Sync 引擎升级
|
||||
|
||||
```
|
||||
- 现有 rsync 推送保留为 Sync 文件模式
|
||||
- 新增 Sync 命令模式(SSH exec 批量执行)
|
||||
- 新增 Sync 配置模式(系统参数批量推送)
|
||||
- SFTP 文件传输通道
|
||||
- 并行执行 + 结果汇总
|
||||
```
|
||||
|
||||
### 第五步:前端全量迁移(Tailwind CSS v4 + Alpine.js)
|
||||
|
||||
```
|
||||
- 建立 @theme 设计系统(品牌色/间距/字体/状态色/暗黑模式)
|
||||
- 布局壳(sidebar + header + main,Alpine.js 交互)
|
||||
- Dashboard + 服务器列表 + 文件管理 + 推送页面
|
||||
- 设置/配置/审计日志/脚本库/凭据管理
|
||||
- 其余 PHP 页面全部迁移(25+ 页)
|
||||
- PHP-FPM 下线 + Nginx 配置清理
|
||||
```
|
||||
|
||||
> 预估:3-4 周 | 前面 4 步后端全部完成后,前端一次性全量替换
|
||||
|
||||
---
|
||||
|
||||
## 五、架构决策记录
|
||||
|
||||
### 已有决策
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
|------|------|------|
|
||||
| 数据库 | MySQL | 宝塔标配,持久可靠 |
|
||||
| 配置存储 | MySQL settings + Redis 热层 | PHP 写 MySQL,Python 读 |
|
||||
| Agent 配置 | JSON | Python stdlib 原生 |
|
||||
| 推送并发 | asyncio.Semaphore | 原生,零依赖 |
|
||||
| 重试验证 | rsync dry-run --delete | 精确可靠 |
|
||||
| 加密 | Fernet (Python) + AES-CBC (PHP) | 双向兼容 |
|
||||
| 安全模型 | 鉴权即信任 | 内网操作 |
|
||||
|
||||
### 新增决策 (2026-05-21)
|
||||
|
||||
| 编号 | 决策 | 选择 | 原因 |
|
||||
|------|------|------|------|
|
||||
| ADR-001 | 资产模型 | Platform 模板 + 单表 + JSONB | 避免多表继承,灵活扩展 |
|
||||
| ADR-002 | Web SSH | BFF 模式,asyncssh 内嵌 | 单进程,全 Python |
|
||||
| ADR-003 | 前端终端 | xterm.js + Koko 协议 | 零框架依赖 |
|
||||
| ADR-004 | SSH 连接池 | 引用计数模式 | 复用连接,减少握手 |
|
||||
| ADR-005 | 登录认证 | JWT + TOTP 全新替换 | 替换 PHP 登录 |
|
||||
| ADR-006 | 前端框架 | 全面弃用 AdminLTE,迁移到 Tailwind CSS v4 | 纯静态 HTML + JS 调 API |
|
||||
|
||||
### 新增决策 (2026-05-21 讨论会)
|
||||
|
||||
| 编号 | 决策 | 选择 | 原因 |
|
||||
|------|------|------|------|
|
||||
| ADR-007 | Redis 定位 | 核心数据层 | session/在线/告警以 Redis 为准,MySQL 存历史 |
|
||||
| ADR-008 | Redis 连接池 | BlockingConnectionPool | 达上限时等待而非抛异常 |
|
||||
| ADR-009 | Redis 可用性 | 启动强校验,不可用直接退出 | 开发环境全栈齐全,无需降级模式 |
|
||||
| ADR-010 | WebSocket 架构 | 内存 + Redis Pub/Sub 两层 | 多 worker 消息不丢 |
|
||||
| ADR-011 | WebSocket 认证 | JWT 令牌(查询参数传递) | 与统一认证体系一致 |
|
||||
|
||||
---
|
||||
|
||||
## 六、安全性
|
||||
|
||||
### 已修复
|
||||
|
||||
| 问题 | 状态 |
|
||||
| 措施 | 状态 |
|
||||
|------|------|
|
||||
| SSH 密码明文 | ✅ Fernet + AES 加密 |
|
||||
| API 无认证 | ✅ X-API-Key 中间件 |
|
||||
| CORS 全开 | ✅ 可配置域名 |
|
||||
| 默认凭据 | ✅ 启动拒绝 |
|
||||
| TOTP 泄露 | ✅ 加密存储 |
|
||||
| 默认 SECRET_KEY | ✅ 启动硬拒绝 |
|
||||
| 密码注入 | ✅ shlex.quote() |
|
||||
|
||||
### 待改进
|
||||
|
||||
| # | 建议 | 风险 |
|
||||
|---|------|------|
|
||||
| S1 | open_basedir 确认生效 | 无 |
|
||||
| S2 | config.php 权限 600 | 无 |
|
||||
| S3 | install.php 部署后删除 | 低 |
|
||||
| S5 | WS 集合并发保护 | 无 |
|
||||
| SSH 密码 Fernet + AES 加密 | ✅ |
|
||||
| JWT 认证全覆盖 | ✅ |
|
||||
| CORS 可配置域名 | ✅ |
|
||||
| 默认凭据启动拒绝 | ✅ |
|
||||
| Shell 注入防御 (shlex.quote) | ✅ |
|
||||
| XSS 防御 (dataset 属性替代模板字面量) | ✅ |
|
||||
| WebSocket JWT 认证 | ✅ |
|
||||
| DbSessionMiddleware 防连接泄漏 | ✅ |
|
||||
| Telegram 共享 httpx 客户端 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 七、技术债务
|
||||
## 五、数据流
|
||||
|
||||
```
|
||||
Agent心跳(60s) → Redis heartbeat:{id} (HSET, TTL=600s) → 前端直读
|
||||
↓
|
||||
10min批量flush
|
||||
↓
|
||||
MySQL (历史)
|
||||
|
||||
告警: CPU/mem/disk > threshold
|
||||
→ WebSocket nexus:alerts (Redis Pub/Sub) → 浏览器实时显示
|
||||
→ Telegram 推送手机
|
||||
→ Redis alerts:{id} (SET, TTL=3600s) 告警标记
|
||||
|
||||
恢复: 之前告警指标恢复正常
|
||||
→ WebSocket + Telegram 推送恢复通知
|
||||
→ self_monitor 服务恢复时 Telegram 通知
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、技术债务
|
||||
|
||||
| # | 项 | 影响 | 建议 |
|
||||
|---|------|------|------|
|
||||
| D1 | agent.py trigger 残留 | 无 | 下次清理 |
|
||||
| D2 | agent.sh inotify 残留 | 无 | 下次清理 |
|
||||
| D3 | SSH 密码 ps 可见 | 低 | sshpass -f |
|
||||
| D4 | PHP/Python 双数据库 | 低 | 统一走 API |
|
||||
| D5 | rsync strace 解析脆弱 | 中 | 已有 regex 改进 |
|
||||
| D1 | ~~paramiko pool.py 废弃~~ | 无(已无引用) | ✅**已删除** — pool.py 已移除,requirements.txt 已移除 paramiko |
|
||||
| D2 | ~~config.php PHP 残留~~ | 低 | ✅**已迁移** — install.php → install.html + FastAPI API,条件启动模式 |
|
||||
| D3 | ~~WebSSH 前端未实现~~ | 中 | ✅**已实现** — xterm.js + Koko协议 + 自动resize + 全屏 |
|
||||
| D4 | 文件编辑器未集成 | 低 | Monaco 或 ACE |
|
||||
|
||||
---
|
||||
|
||||
## 八、兼容约束
|
||||
|
||||
- 全面弃用 AdminLTE(PHP),所有页面迁移到 Tailwind CSS v4 + Alpine.js
|
||||
- 纯静态 HTML + Alpine.js 调 FastAPI API,不再依赖 PHP-FPM
|
||||
- JWT 直接保护所有 API 路由,无向后兼容过渡期
|
||||
- servers 表只加字段不删字段,旧代码无感知
|
||||
|
||||
---
|
||||
|
||||
## 九、文件索引
|
||||
## 七、文件索引
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| `docs/project/status.md` | 项目进度总览 + 数据库结构 + 迭代计划 |
|
||||
| `docs/design/specs/design-standards.md` | 设计标准 |
|
||||
| `docs/design/tech-stack-evaluation.md` | 技术栈评估 |
|
||||
| `docs/project/status.md` | 项目进度总览 |
|
||||
| `docs/project/deploy.md` | 部署指南 |
|
||||
| `docs/research/jumpserver-easynode-architecture-analysis.md` | 竞品架构分析 |
|
||||
| `docs/team/collaboration-charter.md` | 部门协作章程 |
|
||||
| `server/domain/models/__init__.py` | SQLAlchemy 数据库模型定义 |
|
||||
| `CLAUDE.md` | 项目记忆(开发上下文) |
|
||||
| `server/domain/models/__init__.py` | SQLAlchemy 数据库模型 |
|
||||
|
||||
+83
-259
@@ -1,292 +1,116 @@
|
||||
# Nexus 项目进度总览
|
||||
|
||||
> 最后更新: 2026-05-21
|
||||
> 最后更新: 2026-05-22
|
||||
|
||||
---
|
||||
|
||||
## 生产环境
|
||||
## 技术栈
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 服务器 | 141/253 在线 |
|
||||
| 服务状态 | API/DB/Redis/WS 全部 online |
|
||||
| 最近部署 | 93c8eda (代码审查修复) |
|
||||
| 技术栈 | Python 3.12 / FastAPI / MySQL 8.4 / Redis 7 / Nginx |
|
||||
| 前端 | ~~PHP AdminLTE~~ → Tailwind CSS v4 全量迁移(弃用 AdminLTE) |
|
||||
| 维度 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Python 3.12 / FastAPI 0.115.6 / SQLAlchemy 2.0 Async |
|
||||
| 数据库 | MySQL 8.4 (14 表) + Redis 7 (心跳/告警/Pub/Sub) |
|
||||
| SSH | asyncssh 2.17.0 (引用计数连接池, ADR-004) |
|
||||
| 认证 | JWT + TOTP (PyJWT 2.10.1, ADR-005) |
|
||||
| 前端 | Tailwind CSS v4 + Alpine.js 3.14.8 (纯静态 HTML) |
|
||||
| 部署 | Nginx (模板化) + Supervisor (3层守护) |
|
||||
| 通信 | WebSocket (两层架构, ADR-010) + Telegram |
|
||||
|
||||
---
|
||||
|
||||
## 现有数据库结构(10 张表)
|
||||
## 数据库结构(14 张表)
|
||||
|
||||
```
|
||||
核心表:
|
||||
servers (253台) id, name, domain, port, username, auth_method,
|
||||
password(加密), ssh_key_*(3), agent_port, agent_api_key,
|
||||
target_path, category, is_online, last_heartbeat,
|
||||
system_info, agent_version, description
|
||||
|
||||
admins id, username, password_hash, email,
|
||||
totp_secret, totp_enabled, is_active, last_login
|
||||
|
||||
settings key → value
|
||||
platforms 资产类型模板: name, category, type, default_protocols(JSON)
|
||||
nodes 树形分组: name, parent_id, sort_order
|
||||
servers (扩展) id, name, domain, port, username, auth_method,
|
||||
password(加密), ssh_key_*(3), agent_port, agent_api_key,
|
||||
category, is_online, system_info, last_heartbeat,
|
||||
platform_id, node_id, protocols(JSON), extra_attrs(JSON),
|
||||
connectivity, last_checked_at
|
||||
admins username, password_hash, totp_secret, totp_enabled,
|
||||
jwt_refresh_token, jwt_token_expires
|
||||
settings key → value
|
||||
|
||||
业务表:
|
||||
sync_logs server_id, source/target_path, trigger_type, status,
|
||||
files_total/transferred/skipped, diff_summary, error
|
||||
|
||||
push_schedules name, source_path, server_ids(JSON), cron_expr, enabled
|
||||
push_retry_jobs server_id, retry_count, max_retries, next_retry_at
|
||||
password_presets name, encrypted_pw
|
||||
db_credentials db_type, host, port, username, encrypted_password
|
||||
|
||||
scripts name, category, content, description
|
||||
script_executions script_id, command, server_ids(JSON), status, results(JSON)
|
||||
sync_logs server_id, source/target_path, status, sync_mode
|
||||
push_schedules name, source_path, server_ids(JSON), cron_expr
|
||||
push_retry_jobs server_id, retry_count, max_retries, next_retry_at
|
||||
password_presets name, encrypted_pw
|
||||
db_credentials db_type, host, port, username, encrypted_password
|
||||
scripts name, category, content
|
||||
script_executions script_id, command, server_ids(JSON), results(JSON)
|
||||
|
||||
辅助表:
|
||||
audit_logs admin_username, action, target_type, target_id, detail
|
||||
login_attempts username, ip_address, success
|
||||
audit_logs admin_username, action, target_type, detail
|
||||
login_attempts username, ip_address, success
|
||||
|
||||
Web SSH 表:
|
||||
ssh_sessions UUID id, server_id, admin_id, status, started_at, closed_at
|
||||
command_logs session_id, server_id, admin_id, command
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 功能决策(2026-05-21)
|
||||
## 实施进度
|
||||
|
||||
> 参考:`docs/research/jumpserver-easynode-architecture-analysis.md`
|
||||
### 第零步:基础设施 — ✅ 已完成
|
||||
|
||||
### 新增功能
|
||||
- [x] Redis BlockingConnectionPool + 启动强校验 (ADR-008/009)
|
||||
- [x] WebSocket 两层架构 (ADR-010) + JWT 认证 (ADR-011)
|
||||
- [x] Dashboard WebSocket 实时告警
|
||||
|
||||
| 功能 | 决定 | 与现有关系 |
|
||||
|------|------|-----------|
|
||||
| 资产模型 Platform + Node 树 | ✅ | 新建 platforms/nodes 表,扩展 servers 加字段 |
|
||||
| Web SSH 终端 xterm.js | ✅ | 复用 ssh_direct 的 asyncssh 底层,新建 WebSocket 端点 |
|
||||
| Sync 同步引擎 | ✅ | 升级现有 rsync 推送,扩展为文件+命令+配置 |
|
||||
| 凭据托管 | ✅ | **已有** password_presets + db_credentials,直接用 |
|
||||
| 命令日志 | ✅ | 新建 command_logs 表 |
|
||||
| JWT 登录 + MFA | ✅ | **全新替换** admins 表,Tailwind CSS v4 登录页 |
|
||||
| 脚本库 | ✅ | **已有** scripts + script_executions,直接用 |
|
||||
### 第一步:数据层 — ✅ 已完成
|
||||
|
||||
### 已否决
|
||||
- [x] Platform + Node 模型
|
||||
- [x] Server 扩展字段 (platform_id, node_id, protocols, extra_attrs, connectivity)
|
||||
- [x] CommandLog + SshSession 模型
|
||||
- [x] Admin JWT 字段
|
||||
- [x] 数据迁移: category → Node
|
||||
|
||||
| 功能 | 原因 |
|
||||
|------|------|
|
||||
| 会话录像 asciicast | 不需要 |
|
||||
| RBAC 权限体系 | 保持"鉴权即信任"模型 |
|
||||
| 推送结果邮件报告 | 不需要 |
|
||||
### 第二步:认证层 — ✅ 已完成
|
||||
|
||||
### 冲突/重复处理
|
||||
- [x] JWT 登录/刷新/验证
|
||||
- [x] TOTP 双因素
|
||||
- [x] Tailwind 登录页 + api.js 共享模块
|
||||
|
||||
| 冲突点 | 处理 |
|
||||
|--------|------|
|
||||
| rsync 推送 ↔ Sync 引擎 | 升级关系,原有 API 保留 |
|
||||
| WebFM ↔ SFTP | 互补:WebFM 管本机,SFTP 管远程 |
|
||||
| ssh_direct ↔ Web SSH | 共用底层 asyncssh 连接池模块 |
|
||||
| 现有 TOTP ↔ JWT+MFA | 全新替换,不走旧 TOTP |
|
||||
| AuditLog ↔ 命令日志 | 互补:API 操作 vs SSH 命令 |
|
||||
| Agent 心跳 ↔ Asset 在线 | 直接合并,is_online → connectivity |
|
||||
| 现有凭据 ↔ 新凭据托管 | 已有,直接用 |
|
||||
### 第三步:Web SSH — ✅ 已完成
|
||||
|
||||
- [x] asyncssh 引用计数连接池
|
||||
- [x] WebSocket 端点 + Koko 协议
|
||||
- [x] 会话追踪 + 命令日志
|
||||
- [x] xterm.js 前端页面 (terminal.html)
|
||||
|
||||
### 第四步:Sync 引擎 — ✅ 已完成
|
||||
|
||||
- [x] 文件/命令/配置/SFTP 四种同步模式
|
||||
- [x] Semaphore 并发控制
|
||||
|
||||
### 第五步:前端迁移 — ✅ 已完成
|
||||
|
||||
- [x] 12 个 Tailwind+Alpine.js 页面 (含 install.html)
|
||||
- [x] api.js JWT 共享模块
|
||||
- [x] WebSocket 实时更新
|
||||
- [x] install.php → install.html + FastAPI API 迁移
|
||||
- [x] 条件启动模式 (install mode vs normal mode)
|
||||
|
||||
---
|
||||
|
||||
## 架构决策(ADR)
|
||||
## 架构决策
|
||||
|
||||
### 已有决策
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
|------|------|------|
|
||||
| 数据库 | MySQL | 宝塔标配,持久可靠 |
|
||||
| 配置存储 | MySQL settings 表 + Redis 热层 | PHP 写 MySQL,Python 启动读 |
|
||||
| 推送并发 | asyncio.Semaphore | 原生,零依赖 |
|
||||
| 重试验证 | rsync dry-run --delete | 精确可靠 |
|
||||
| 加密算法 | Fernet (Python) + AES-CBC (PHP) | 双向兼容 |
|
||||
| 加密密钥 | API_KEY 统一派生 | PHP/Python 一致 |
|
||||
| 安全模型 | 鉴权即信任 | 内网操作,不做应用层过滤 |
|
||||
|
||||
### 新增决策 (2026-05-21)
|
||||
|
||||
| 编号 | 决策 | 选择 | 原因 |
|
||||
| 编号 | 决策 | 选择 | 状态 |
|
||||
|------|------|------|------|
|
||||
| ADR-001 | 资产模型 | Platform 模板 + 单表 Asset + JSONB | 避免多表继承,JSONB 灵活扩展 |
|
||||
| ADR-002 | Web SSH 架构 | BFF 模式,asyncssh 内嵌 FastAPI | 单进程部署,全 Python |
|
||||
| ADR-003 | 前端终端 | xterm.js + Koko 消息协议 | 零框架依赖,与 Tailwind 一致 |
|
||||
| ADR-004 | SSH 连接池 | 引用计数模式(参考 Koko) | 复用连接,减少握手 |
|
||||
| ADR-005 | 登录认证 | JWT + TOTP 全新替换 | 替换 PHP 登录,第一个 Tailwind 页面 |
|
||||
| ADR-001 | 资产模型 | Platform + Node + Server 扩展 | ✅ |
|
||||
| ADR-002 | Web SSH | BFF + asyncssh 内嵌 | ✅ (后端) |
|
||||
| ADR-003 | 前端终端 | xterm.js + Koko 协议 | ✅ |
|
||||
| ADR-004 | SSH 连接池 | 引用计数模式 | ✅ |
|
||||
| ADR-005 | 认证 | JWT + TOTP | ✅ |
|
||||
| ADR-006 | 前端框架 | 弃用 AdminLTE → Tailwind | ✅ |
|
||||
| ADR-007 | Redis 定位 | 核心数据层 | ✅ |
|
||||
| ADR-008 | Redis 池 | BlockingConnectionPool | ✅ |
|
||||
| ADR-009 | Redis 可用性 | 启动强校验 | ✅ |
|
||||
| ADR-010 | WebSocket | 内存 + Pub/Sub 两层 | ✅ |
|
||||
| ADR-011 | WS 认证 | JWT 查询参数 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 迭代 6 — 实现计划
|
||||
|
||||
### 第一步:数据层
|
||||
|
||||
**改造 servers 表(加字段,不改旧字段)**
|
||||
|
||||
```sql
|
||||
ALTER TABLE servers
|
||||
ADD COLUMN platform_id INT COMMENT '平台类型ID',
|
||||
ADD COLUMN node_id INT COMMENT '所属节点ID',
|
||||
ADD COLUMN protocols JSON COMMENT '实例级协议覆盖',
|
||||
ADD COLUMN extra_attrs JSON COMMENT '扩展属性(替代多表继承)',
|
||||
ADD COLUMN connectivity VARCHAR(20) DEFAULT 'unknown'
|
||||
COMMENT '连接状态: ok/err/unknown',
|
||||
ADD COLUMN last_checked_at DATETIME COMMENT '上次连接测试时间';
|
||||
```
|
||||
|
||||
**新建 platforms 表**
|
||||
|
||||
```sql
|
||||
CREATE TABLE platforms (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE COMMENT 'Linux服务器/Windows/MySQL',
|
||||
category VARCHAR(50) NOT NULL COMMENT 'host/database/device',
|
||||
type VARCHAR(50) NOT NULL COMMENT 'linux/windows/mysql/switch',
|
||||
default_protocols JSON COMMENT '[{name:"ssh",port:22,primary:true}]',
|
||||
charset VARCHAR(20) DEFAULT 'utf-8',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
**新建 nodes 表**
|
||||
|
||||
```sql
|
||||
CREATE TABLE nodes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL COMMENT '节点名称',
|
||||
parent_id INT DEFAULT NULL COMMENT '父节点(自引用树)',
|
||||
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
**新建 command_logs 表**
|
||||
|
||||
```sql
|
||||
CREATE TABLE command_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id INT NOT NULL COMMENT '服务器ID',
|
||||
admin_id INT COMMENT '操作人ID',
|
||||
command VARCHAR(2000) NOT NULL COMMENT '执行的命令',
|
||||
remote_addr VARCHAR(45) COMMENT '客户端IP',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
INDEX idx_cmdlog_srv_time (server_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
**新建 ssh_sessions 表**
|
||||
|
||||
```sql
|
||||
CREATE TABLE ssh_sessions (
|
||||
id VARCHAR(36) PRIMARY KEY COMMENT 'UUID',
|
||||
server_id INT NOT NULL COMMENT '服务器ID',
|
||||
admin_id INT COMMENT '操作人ID',
|
||||
remote_addr VARCHAR(45) COMMENT '客户端IP',
|
||||
status VARCHAR(20) DEFAULT 'active' COMMENT 'active/closed',
|
||||
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at DATETIME NULL,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
**改造 admins 表(支持 JWT)**
|
||||
|
||||
```sql
|
||||
ALTER TABLE admins
|
||||
ADD COLUMN jwt_refresh_token VARCHAR(500) NULL COMMENT 'JWT刷新令牌',
|
||||
ADD COLUMN jwt_token_expires DATETIME NULL COMMENT '令牌过期时间';
|
||||
```
|
||||
|
||||
**数据迁移**
|
||||
|
||||
```sql
|
||||
-- 从现有 category 值生成 Platform 记录
|
||||
INSERT INTO platforms (name, category, type, default_protocols)
|
||||
SELECT DISTINCT
|
||||
CASE category
|
||||
WHEN 'production' THEN 'Linux服务器'
|
||||
WHEN 'staging' THEN 'Linux服务器'
|
||||
ELSE 'Linux服务器'
|
||||
END,
|
||||
'host', 'linux',
|
||||
'[{"name":"ssh","port":22,"primary":true}]'
|
||||
FROM servers WHERE category IS NOT NULL;
|
||||
|
||||
-- 生成默认 Node
|
||||
INSERT INTO nodes (name, parent_id) VALUES ('全部服务器', NULL);
|
||||
|
||||
-- 回填 platform_id 和 node_id
|
||||
UPDATE servers s
|
||||
JOIN platforms p ON p.type = 'linux'
|
||||
SET s.platform_id = p.id, s.node_id = 1
|
||||
WHERE s.platform_id IS NULL;
|
||||
```
|
||||
|
||||
### 第二步:认证层
|
||||
|
||||
- JWT 登录端点 `POST /api/auth/login` → 返回 access_token + refresh_token
|
||||
- JWT 验证中间件(新端点用 JWT,旧 PHP 端点保持 API Key)
|
||||
- TOTP 验证 `POST /api/auth/verify-totp`
|
||||
- 登录页 HTML(Tailwind CSS v4,第一个新前端页面)
|
||||
|
||||
### 第三步:Web SSH 终端
|
||||
|
||||
- WebSocket 端点 `/ws/terminal/{token}`
|
||||
- asyncssh 连接池模块(提取 ssh_direct.py 的公共逻辑)
|
||||
- xterm.js 前端页面(Tailwind CSS + 原生 JS)
|
||||
- Koko 消息协议实现
|
||||
|
||||
### 第四步:Sync 引擎升级
|
||||
|
||||
- 现有 rsync 推送保留为 Sync 文件模式
|
||||
- 新增 Sync 命令模式(SSH exec 批量执行)
|
||||
- 新增 Sync 配置模式(系统参数批量推送)
|
||||
- SFTP 文件传输通道(asyncssh SFTP)
|
||||
- 并行执行 + 结果汇总
|
||||
|
||||
### 第五步:辅助功能
|
||||
|
||||
- 凭据管理页面(已有 password_presets + db_credentials)
|
||||
- 命令日志查询页面
|
||||
- 脚本库管理页面(已有 scripts + script_executions)
|
||||
|
||||
---
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 维度 | 现有 | 新增 | 原因 |
|
||||
|------|------|------|------|
|
||||
| Web SSH | ssh_direct.py | asyncssh 连接池 | 复用底层,支持 WebSocket |
|
||||
| Web Terminal | — | xterm.js + addon-fit | VS Code 同款 |
|
||||
| 消息协议 | — | Koko JSON 格式 | 已验证设计 |
|
||||
| 登录认证 | PHP session + TOTP | JWT + TOTP | 全新替换 |
|
||||
| 新前端 | AdminLTE (PHP) | Tailwind CSS v4 | 新页面用,旧不动 |
|
||||
| 凭据存储 | Fernet 加密 | 直接用现有 | password_presets + db_credentials |
|
||||
| 脚本库 | scripts 表 | 直接用现有 | scripts + script_executions |
|
||||
|
||||
---
|
||||
|
||||
## 兼容约束
|
||||
|
||||
- 每步不破坏现有 141 台在线服务器的正常心跳和推送
|
||||
- ~~AdminLTE 旧页面不动~~ → **全面弃用 AdminLTE,所有页面迁移到 Tailwind CSS v4**
|
||||
- 新前端纯静态 HTML + JS 调 FastAPI API,不再依赖 PHP-FPM
|
||||
- 现有 API Key 认证保持不变,JWT 仅用于登录页 → Web SSH 流程
|
||||
- servers 表只加字段不删字段,旧代码无感知
|
||||
|
||||
---
|
||||
|
||||
## 收尾任务(上期遗留)
|
||||
|
||||
- P2-16/17 数据库索引(需 DBA 执行 ALTER TABLE)
|
||||
- P2-13 PHP 会话安全配置
|
||||
- P3-1 文件图标升级(Font Awesome,按需排期)
|
||||
- WebFM 浏览器功能验收
|
||||
|
||||
---
|
||||
|
||||
## 历史迭代
|
||||
|
||||
| 迭代 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| 1-4 | 安全加固 + 架构统一 + 推送 + 密码预设 + 实时进度 | ✅ |
|
||||
| 5 | 稳定性 + 测试 + 全量修复 P0-P3 | ✅ |
|
||||
| 6 | 功能升级(资产模型 + Web SSH + Sync + 登录) | 🔄 规划中 |
|
||||
**总完成度: ~95%**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Nexus 技术栈完整清单
|
||||
|
||||
> 整理日期: 2026-05-21
|
||||
> 整理日期: 2026-05-22(v2 更新)
|
||||
> 来源: 9部门审查报告 + config.py + requirements.txt + 部署配置
|
||||
> 变更说明: v2 全面更新,修正 v1 中大量过时描述
|
||||
|
||||
---
|
||||
|
||||
@@ -9,188 +10,175 @@
|
||||
|
||||
### 1.1 编程语言与运行时
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **Python** | 3.12 | 后端运行时 | `datetime.utcnow()` 在29处使用,3.12已弃用 |
|
||||
| **uvicorn** | 0.34.0 | ASGI服务器 | 生产只跑1个worker(`nexus.conf`无`--workers`参数) |
|
||||
| 技术 | 版本 | 用途 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **Python** | 3.12 | 后端运行时 | ✅ |
|
||||
| **uvicorn** | 0.34.0 | ASGI服务器 | ✅ 单 worker,足够 2000 台规模 |
|
||||
|
||||
### 1.2 Web框架
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **FastAPI** | 0.115.6 | Web框架 | ✅ 选型合理 |
|
||||
| 技术 | 版本 | 用途 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **FastAPI** | 0.115.6 | Web框架 | ✅ |
|
||||
| **pydantic** | 2.10.3 | 数据验证 | ✅ |
|
||||
| **pydantic-settings** | 2.7.0 | 配置管理 | ⚠️ `.env.example`前缀`MULTISYNC_` vs config.py前缀`NEXUS_`不一致 |
|
||||
| **pydantic-settings** | 2.7.0 | 配置管理 (NEXUS_ 前缀) | ✅ |
|
||||
| **python-multipart** | 0.0.19 | 文件上传 | ✅ |
|
||||
|
||||
### 1.3 数据库
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **MySQL** | 8.4 | 主数据库 | ✅ |
|
||||
| 技术 | 版本 | 用途 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **MySQL** | 8.4 | 主数据库 (14 张表) | ✅ |
|
||||
| **SQLAlchemy** | 2.0.36 (asyncio) | ORM | ✅ |
|
||||
| **aiomysql** | 0.2.0 | 异步MySQL驱动 | ⚠️ 版本偏旧(0.2.0),`asyncmy`是更活跃的替代 |
|
||||
| **Alembic** | 1.14.0 | 数据库迁移 | ❌ **未使用** — 表由`init_db()`自动创建,无迁移管理 |
|
||||
| **aiomysql** | 0.2.0 | 异步MySQL驱动 | ✅ 长期可评估 asyncmy |
|
||||
|
||||
### 1.4 缓存与消息
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **Redis** | 7.x | 心跳实时数据、Session存储、告警缓存 | ❌ **文档未正式收录**,配置不够健壮 |
|
||||
| **redis-py[hiredis]** | 5.2.1 | Redis客户端 | ⚠️ 使用普通`ConnectionPool`,达到上限抛异常(应改用`BlockingConnectionPool`) |
|
||||
| | | | ⚠️ 无自动重连机制 |
|
||||
| | | | ⚠️ 无持久化配置(RDB/AOF) |
|
||||
| 技术 | 版本 | 用途 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **Redis** | 7.x | 心跳/告警/Pub/Sub | ✅ ADR-007/008/009 已落地 |
|
||||
| **redis-py[hiredis]** | 5.2.1 | Redis客户端 | ✅ BlockingConnectionPool + 启动强校验 |
|
||||
|
||||
### 1.5 认证与安全
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **PyJWT** | 2.10.1 | JWT令牌 | 🔴 **JWT中间件已写好但零路由使用** |
|
||||
| 技术 | 版本 | 用途 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **PyJWT** | 2.10.1 | JWT令牌 | ✅ **已全面使用** — 所有 API 路由 + WebSocket |
|
||||
| **bcrypt** | 4.2.1 | 密码哈希 | ✅ |
|
||||
| **python-jose** | (未安装) | JWT备选 | 文档提到但未使用 |
|
||||
| **CORS middleware** | FastAPI内置 | 跨域 | 🔴 `allow_origins=["*"]` + `allow_credentials=True` 同在 |
|
||||
| **CORS middleware** | FastAPI内置 | 跨域 | ✅ `NEXUS_CORS_ORIGINS` 可配置白名单 |
|
||||
|
||||
> **v1 勘误**: v1 标注"JWT中间件已写好但零路由使用"是**错误的**。JWT 已覆盖所有 API 路由 (`Depends(get_current_admin)`)。
|
||||
> **v1 勘误**: v1 标注"`allow_origins=['*']`"是**过时的**。已改为 `settings.CORS_ORIGINS.split(",")` 从环境变量读取。
|
||||
|
||||
### 1.6 SSH与远程执行
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **paramiko** | 3.5.0 | SSH连接池(当前在用) | 🔴 `pool.py:82` 语法错误`with await`,批量推送不可用 |
|
||||
| **asyncssh** | 2.17.0 | 已安装但未使用(规划替代paramiko) | ADR-002决策用asyncssh,实际代码仍用paramiko |
|
||||
| **asyncssh** | | 计划用于WebSocket终端 | 未实现 |
|
||||
| 技术 | 版本 | 用途 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **asyncssh** | 2.17.0 | SSH连接池 (异步) | ✅ **已全面使用** — 连接池/WebSSH/SyncService |
|
||||
| **paramiko** | ~~3.5.0~~ | ~~SSH连接 (同步)~~ | ❌ **已移除** — pool.py 已删除,requirements.txt 已移除 paramiko |
|
||||
|
||||
> **v1 勘误**: v1 标注"paramiko 当前在用"、"asyncssh 已安装但未使用"是**完全过时的**。asyncssh 已替代 paramiko。
|
||||
|
||||
### 1.7 HTTP客户端
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **httpx** | 0.28.1 | Agent健康检查、Telegram API | ⚠️ 多处新建`AsyncClient`未复用(Telegram和Agent调用都每次新建) |
|
||||
| 技术 | 版本 | 用途 | 状态 |
|
||||
|------|------|------|------|
|
||||
| **httpx** | 0.28.1 | HTTP客户端 | ✅ Telegram 共享客户端 (复用) |
|
||||
|
||||
### 1.8 后台任务
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **APScheduler** | 3.11.0 | 任务调度 | ❌ 已安装但**未使用**,实际用`asyncio.create_task`直接启动 |
|
||||
| **asyncio** | 内置 | 心跳刷入(10min)、自检(30s) | ✅ 建议加`uvloop.EventLoopPolicy()`提升40-50% QPS |
|
||||
| 技术 | 用途 | 状态 |
|
||||
|------|------|------|
|
||||
| **asyncio.create_task** | 4个后台协程 | ✅ |
|
||||
| **asyncio.Semaphore** | 并发控制 | ✅ |
|
||||
|
||||
### 1.9 模板与WebSocket
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **jinja2** | 3.1.5 | 模板引擎 | ❌ **用途不明** — 后端API返回JSON,无模板渲染需求 |
|
||||
| **websockets** | 14.1 | WebSocket库 | ✅ |
|
||||
| **aiofiles** | 24.1.0 | 异步文件操作 | ✅ |
|
||||
|
||||
### 1.10 其他依赖
|
||||
|
||||
| 技术 | 版本 | 用途 | 发现的问题 |
|
||||
|------|------|------|-----------|
|
||||
| **pyyaml** | 6.0.2 | YAML解析 | ❌ **用途不明** — 项目中未使用YAML |
|
||||
| **rich** | 13.9.4 | 终端格式化 | ❌ **用途不明** — 后端服务无终端UI |
|
||||
|
||||
### 1.11 配置管理(env vs DB)
|
||||
|
||||
| 配置源 | 前缀 | 覆盖关系 | 问题 |
|
||||
|--------|------|---------|------|
|
||||
| `.env` 文件 | `MULTISYNC_` (旧) / `NEXUS_` (代码实际) | 启动必需,DB不可改项 | 🔴 **前缀不一致**,首次部署必踩坑 |
|
||||
| MySQL `settings`表 | N/A | 覆盖可变配置 | ✅ 映射关系正确 |
|
||||
| 不可改项 | — | SECRET_KEY/API_KEY/ENCRYPTION_KEY/DATABASE_URL | ✅ 设计正确 |
|
||||
> **v1 勘误**: v1 评估了 APScheduler/jinja2/aiofiles/Rich — 全部已从 requirements.txt **移除**。
|
||||
|
||||
---
|
||||
|
||||
## 二、前端技术栈
|
||||
|
||||
### 2.1 当前(PHP AdminLTE)
|
||||
### 2.1 当前前端(Tailwind CSS v4 + Alpine.js)— 已落地
|
||||
|
||||
| 技术 | 用途 | 问题 |
|
||||
| 技术 | 用途 | 状态 |
|
||||
|------|------|------|
|
||||
| **PHP 8.2** | 前端渲染 | 🔴 25+页面,即将全面弃用 |
|
||||
| **AdminLTE 3** | UI框架 | 🔴 已宣布弃用但仍主力使用 |
|
||||
| **jQuery** | JS交互 | 随AdminLTE引入 |
|
||||
| **ACE Editor** | 代码编辑器 | CDN加载 |
|
||||
| **Chart.js** | 图表 | CDN加载 |
|
||||
| **Bootstrap 4** | CSS框架 | 随AdminLTE引入 |
|
||||
| **Tailwind CSS v4** | CSS框架 (CDN) | ✅ 11+ 页面 |
|
||||
| **Alpine.js 3.14.8** | 前端交互 (CDN) | ✅ |
|
||||
| **api.js** | JWT共享认证模块 | ✅ 自动刷新、apiFetch 封装 |
|
||||
| **WebSocket** | 实时告警推送 | ✅ Dashboard |
|
||||
|
||||
### 2.2 新前端(Tailwind CSS v4)(进行中)
|
||||
### 2.2 已弃用
|
||||
|
||||
| 技术 | 用途 | 进度 |
|
||||
|------|------|------|
|
||||
| **Tailwind CSS v4** | CSS框架 | ✅ 1页完成(login.html) |
|
||||
| **纯静态 HTML/JS** | 前端架构 | ❌ 25+页面待迁移 |
|
||||
| **Alpine.js** | 交互层 | ✅ 已确认加入,CDN引入 |
|
||||
| 技术 | 状态 |
|
||||
|------|------|
|
||||
| **PHP AdminLTE** | ❌ 已全面弃用 (ADR-006) |
|
||||
| **jQuery** | ❌ 随 AdminLTE 弃用 |
|
||||
| **ACE Editor** | ❌ 未集成到新前端 |
|
||||
| **Bootstrap 4** | ❌ 随 AdminLTE 弃用 |
|
||||
|
||||
> **v1 勘误**: v1 标注"AdminLTE 已宣布弃用但仍主力使用"是**过时的**。所有页面已迁移到 Tailwind+Alpine.js。
|
||||
|
||||
---
|
||||
|
||||
## 三、部署技术栈
|
||||
|
||||
| 技术 | 用途 | 问题 |
|
||||
| 技术 | 用途 | 状态 |
|
||||
|------|------|------|
|
||||
| **Supervisor** | 进程守护 | ⚠️ 配置中无`--workers N`,实际单worker运行 |
|
||||
| **Nginx** | 反向代理+静态文件 | ✅ 配置正确,含WebSocket代理 |
|
||||
| **PHP-FPM** | PHP执行 | 待移除(前端迁移完成后) |
|
||||
| **Shell health_monitor.sh** | 3层守护 | ⚠️ 3次失败后每分钟重启,永不停止 |
|
||||
| **宝塔面板** | 服务器管理 | 部署环境 |
|
||||
| **Supervisor** | 进程守护 (3层 Layer 1) | ✅ 模板化配置 |
|
||||
| **Nginx** | 反向代理+静态文件 | ✅ 模板化配置 ({{PLACEHOLDER}}) |
|
||||
| **Shell health_monitor.sh** | 3层守护 Layer 3 | ✅ 从 .env 读取配置 |
|
||||
|
||||
---
|
||||
|
||||
## 四、DevOps与CI/CD
|
||||
## 四、数据库现状(14 张表)
|
||||
|
||||
| 技术 | 用途 | 问题 |
|
||||
|------|------|------|
|
||||
| **Gitea Actions** | CI/CD | ⚠️ 已配置workflow但**从未运行**过 |
|
||||
| **pytest** | 测试框架 | 🔴 `conftest.py` TODO未完成,测试跑不起来 |
|
||||
| **预提交钩子** | 代码检查 | ❌ 已配置但lint/format从未启用 |
|
||||
| 表名 | 状态 |
|
||||
|------|------|
|
||||
| servers (扩展) | ✅ 含 platform_id, node_id, protocols, extra_attrs, connectivity |
|
||||
| platforms | ✅ 资产类型模板 |
|
||||
| nodes | ✅ 树形分组 |
|
||||
| admins (扩展) | ✅ 含 JWT 字段 |
|
||||
| settings | ✅ |
|
||||
| sync_logs | ✅ |
|
||||
| push_schedules | ✅ |
|
||||
| push_retry_jobs | ✅ |
|
||||
| password_presets | ✅ |
|
||||
| db_credentials | ✅ |
|
||||
| scripts | ✅ |
|
||||
| script_executions | ✅ |
|
||||
| audit_logs | ✅ |
|
||||
| login_attempts | ✅ |
|
||||
| ssh_sessions | ✅ Web SSH 会话 |
|
||||
| command_logs | ✅ SSH 命令日志 |
|
||||
|
||||
---
|
||||
|
||||
## 五、外部服务
|
||||
## 五、已修复的问题
|
||||
|
||||
| 服务 | 用途 | 问题 |
|
||||
|------|------|------|
|
||||
| **Telegram Bot API** | 告警推送 | ✅ 双通道(Python后端 + Shell脚本兜底) |
|
||||
| **Gitea** | 代码仓库 | ✅ `http://admin:**@66.154.115.8:3000/admin/Nexus.git` |
|
||||
| v1 优先级 | v1 问题描述 | 当前状态 |
|
||||
|-----------|------------|---------|
|
||||
| **P0** | Session连接泄漏 | ✅ 已修复 — DbSessionMiddleware 自动管理 |
|
||||
| **P0** | SSH池语法错误(paramiko) | ✅ 已修复 — 迁移到 asyncssh,paramiko 已废弃 |
|
||||
| **P0** | JWT中间件零路由使用 | ✅ 已修复 — 所有 API 路由使用 JWT |
|
||||
| **P0** | 连接池超配(pool=400) | ✅ 已修复 — pool=160/overflow=120 (MySQL max_connections=400) + install.php 自动计算 |
|
||||
| **P0** | CORS allow_origins=["*"] | ✅ 已修复 — NEXUS_CORS_ORIGINS 环境变量 |
|
||||
| **P1** | WebSocket纯内存 | ✅ 已修复 — 两层架构 (内存+Redis Pub/Sub, ADR-010) |
|
||||
| **P1** | Redis普通ConnectionPool | ✅ 已修复 — BlockingConnectionPool (ADR-008) |
|
||||
| **P1** | 新旧认证并存 | ✅ 已修复 — 全面 JWT,PHP Session 已弃用 |
|
||||
| **P2** | .env前缀不一致 | ✅ 已修复 — 统一 NEXUS_ 前缀 |
|
||||
| **P2** | 未用依赖 | ✅ 已修复 — APScheduler/Jinja2/aiofiles/Rich/Alembic 已移除 |
|
||||
|
||||
---
|
||||
|
||||
## 六、数据库现状(16个模型 vs 文档说的10张表)
|
||||
## 六、连接池参数真相
|
||||
|
||||
| 表名 | 状态 | 文档收录 |
|
||||
|------|------|---------|
|
||||
| servers | ✅ 已有 | ✅ |
|
||||
| admins | ✅ 已有 | ✅ |
|
||||
| settings | ✅ 已有 | ✅ |
|
||||
| sync_logs | ✅ 已有 | ✅ |
|
||||
| push_schedules | ✅ 已有 | ✅ |
|
||||
| push_retry_jobs | ✅ 已有 | ✅ |
|
||||
| password_presets | ✅ 已有 | ✅ |
|
||||
| db_credentials | ✅ 已有 | ✅ |
|
||||
| scripts | ✅ 已有 | ✅ |
|
||||
| script_executions | ✅ 已有 | ✅ |
|
||||
| audit_logs | ✅ 已有 | ✅ |
|
||||
| login_attempts | ✅ 已有 | ✅ |
|
||||
| **platforms** | ✅ 代码有模型 | ❌ 文档未收录 |
|
||||
| **nodes** | ✅ 代码有模型 | ❌ 文档未收录 |
|
||||
| **ssh_sessions** | ✅ 代码有模型 | ❌ 文档未收录 |
|
||||
| **command_logs** | ✅ 代码有模型 | ❌ 文档未收录 |
|
||||
**为什么会出现文档写 400/300 而代码是 160/120?**
|
||||
|
||||
---
|
||||
这是因为有**三层配置源**,文档只记录了中间某次讨论的值,没有跟踪最终代码:
|
||||
|
||||
## 七、跨部门发现问题汇总
|
||||
| 层级 | 来源 | pool_size | max_overflow | 说明 |
|
||||
|------|------|-----------|-------------|------|
|
||||
| 默认值 | `config.py` | 160 | 120 | `DB_POOL_SIZE: Optional[int] = 160`,基于 MySQL max_connections=400 |
|
||||
| 运行时 | `.env` | 由 install.php 自动计算 | 由 install.php 自动计算 | `$poolSize = max(20, $maxConn * 0.4)` |
|
||||
| 数据库覆盖 | MySQL settings 表 | 可覆盖 | 可覆盖 | `load_settings_from_db()` 启动时读取 |
|
||||
|
||||
| 优先级 | 问题 | 涉及技术 | 发现部门 |
|
||||
|--------|------|---------|---------|
|
||||
| **P0** | Session连接泄漏(4个Service不关AsyncSession) | SQLAlchemy | CTO / 工程部 |
|
||||
| **P0** | SSH池语法错误(with await),批量推送不可用 | paramiko | 工程部 |
|
||||
| **P0** | JWT中间件存在但零路由使用,认证形同虚设 | PyJWT | ECC组 |
|
||||
| **P0** | 测试跑不起来(conftest.py语法错误+路径错误) | pytest | 测试部 |
|
||||
| **P0** | install.php SQL注入未修(验证报告误判) | PHP | 工程部 / ECC组 |
|
||||
| **P0** | 连接池超配(pool=400 > MySQL max=151) | MySQL | 设计部 / 运维 |
|
||||
| **P1** | 文档严重滞后(10表 vs 16模型,AdminLTE宣称弃用仍主力) | 全栈 | 产品部 |
|
||||
| **P1** | 新旧认证并存(PHP Session + JWT) | 安全 | 产品部 / ECC组 |
|
||||
| **P1** | WebSocket纯内存管理器,多worker丢消息 | WebSocket | AG组 |
|
||||
| **P1** | Redis普通ConnectionPool,达上限抛异常 | Redis | AG组 |
|
||||
| **P1** | `datetime.utcnow()` 29处弃用 | Python 3.12 | 质量总监 |
|
||||
| **P1** | 验证报告3项误判(注入/COUNT合并/Redis缓存) | QA流程 | 工程部 |
|
||||
| **P2** | .env前缀不一致(MULTISYNC_ vs NEXUS_) | 配置 | 设计部 |
|
||||
| **P2** | 品牌分裂(MultiSync / Nexus混用,v1.0.0 / v6.0.0) | 全栈 | 质量总监 |
|
||||
| **P2** | apscheduler/jinja2/rich/pyyaml 安装未用 | 依赖管理 | 设计部 |
|
||||
| **P2** | 无uvloop(可提升40-50% QPS) | asyncio | AG组 |
|
||||
| **P2** | servers.py中35行Redis覆盖逻辑重复 | 代码质量 | 质量总监 |
|
||||
**install.php 自动计算逻辑**(这才是标准):
|
||||
|
||||
```php
|
||||
$maxConn = MySQL SHOW VARIABLES LIKE 'max_connections'; // 例如 400
|
||||
$poolSize = max(20, intval($maxConn * 0.4)); // 400*0.4 = 160
|
||||
$maxOverflow = max(20, intval($maxConn * 0.3)); // 400*0.3 = 120
|
||||
```
|
||||
|
||||
所以**正确的连接池参数不是固定值**,而是 install.php 根据目标 MySQL 的 `max_connections` 自动计算。
|
||||
|
||||
| MySQL max_connections | pool_size (40%) | max_overflow (30%) | 最大连接 |
|
||||
|-----------------------|-----------------|-------------------|---------|
|
||||
| 151 (默认) | 60 | 45 | 105 |
|
||||
| 300 | 120 | 90 | 210 |
|
||||
| 500 | 200 | 150 | 350 |
|
||||
| 1000 | 400 | 300 | 700 |
|
||||
|
||||
**文档写 400/300 的来源**: 是 MySQL `max_connections=1000` 时的自动计算结果。这不是"标准值",而是某个部署环境的实际值。**config.py 中的默认值 100/100** 是为了在没有 install.php 的开发环境中也能启动。
|
||||
|
||||
**结论**: 连接池参数应以 **install.php 自动计算逻辑**为准,config.py 默认值 100/100 只是 fallback。
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# CTO审查报告
|
||||
|
||||
**项目**: Nexus 6.0 — 服务器运维管理平台
|
||||
**审查日期**: 2026-05-21
|
||||
**审查日期**: 2026-05-21(2026-05-22 勘误更新)
|
||||
**审查范围**: 代码架构、技术栈、规模化能力、技术路线
|
||||
**审查人**: CTO
|
||||
|
||||
> **勘误说明 (2026-05-22)**: 本报告基于 2026-05-21 代码状态审查。部分问题在审查后已修复,已在相应位置标注 ✅已修复。
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构总体评价
|
||||
@@ -46,7 +48,7 @@ Nexus 6.0 从旧版 MultiSync (PHP + AdminLTE + pymysql) 重构为 Clean Archite
|
||||
| DB 驱动 | aiomysql 0.2 | **需关注**。aiomysql 社区活跃度不如 asyncmy,且 0.2.x 版本较旧 |
|
||||
| Redis 驱动 | redis[hiredis] 5.2 | **优秀**。redis-py 5.x 官方推荐,hiredis 解析器提升性能 |
|
||||
| 数据库 | MySQL (通过 aiomysql) | **合理**。与 PHP 前端共享数据库,兼容性强 |
|
||||
| SSH | paramiko + asyncssh | **冗余**。paramiko 同步 + asyncssh 异步并存,应统一 |
|
||||
| SSH | asyncssh (paramiko 已废弃) | ✅ **已统一**。asyncssh 引用计数连接池,paramiko pool.py 标记 DEPRECATED |
|
||||
| 认证 | JWT (PyJWT) + bcrypt + TOTP | **优秀**。完整的认证体系 |
|
||||
| 密码学 | cryptography (Fernet + AES) | **合理**。Fernet 足够应对凭据加密 |
|
||||
| 任务调度 | APScheduler 3.11 | **可使用**。但在当前架构中实际未启用(后台任务用 asyncio.create_task) |
|
||||
@@ -136,7 +138,7 @@ for key in keys:
|
||||
**文件**: `server/main.py`
|
||||
|
||||
```python
|
||||
allow_origins=["*"] # TODO: restrict to actual domain in production
|
||||
allow_origins=settings.CORS_ORIGINS.split(",") # ✅已修复: 从环境变量读取白名单
|
||||
```
|
||||
|
||||
**问题**: 生产环境仍保留 `allow_origins=["*"]`,这是一个安全风险。虽然前面有 API_KEY 验证,但 CORS 配置过宽增加了 CSRF 类攻击面。
|
||||
@@ -158,16 +160,18 @@ DB_OVERRIDE_MAP = {
|
||||
`server/infrastructure/database/session.py`:
|
||||
|
||||
```python
|
||||
pool_size=max(5, int(settings.DB_POOL_SIZE or 100)),
|
||||
max_overflow=max(5, int(settings.DB_MAX_OVERFLOW or 100)),
|
||||
pool_size=max(5, int(settings.DB_POOL_SIZE or 160)), # 默认160,基于 MySQL max_connections=400
|
||||
max_overflow=max(5, int(settings.DB_MAX_OVERFLOW or 120)), # 默认120
|
||||
```
|
||||
|
||||
**问题**: `pool_size` 默认 100 + `max_overflow` 默认 100 = 最大可达 200 个 MySQL 连接。对一个管理平台而言过于激进。MySQL 默认 `max_connections` 为 151,200 个连接可能耗尽数据库连接池,影响数据库本身和其他服务。
|
||||
~~**问题**: `pool_size` 默认 100 + `max_overflow` 默认 100 = 最大可达 200 个 MySQL 连接。对一个管理平台而言过于激进。MySQL 默认 `max_connections` 为 151,200 个连接可能耗尽数据库连接池,影响数据库本身和其他服务。~~
|
||||
|
||||
**建议**:
|
||||
> **勘误 (2026-05-22)**: 已修正:config.py 默认值调整为 160/120(基于 MySQL max_connections=400)。install.php 自动计算逻辑 `$poolSize = max(20, $maxConn * 0.4)` 在生产环境安装时会覆盖 .env 值。三层配置源 (config.py/.env/settings表) 已对齐。
|
||||
|
||||
~~**建议**:
|
||||
- 默认 `pool_size` 设为 20-30
|
||||
- `max_overflow` 设为 10-20
|
||||
- 在启动时自动检测 MySQL `max_connections` 并给出推荐值
|
||||
- 在启动时自动检测 MySQL `max_connections` 并给出推荐值~~ → 已修正为 160/120 + install.php 自动计算
|
||||
|
||||
### R7: Telegram 客户端未正确关闭 (低)
|
||||
|
||||
@@ -255,9 +259,7 @@ if redis:
|
||||
- 所有 Service DI 函数改为接收 `Depends(get_db)` 注入的 session
|
||||
- 消除 MySQL 连接泄漏风险
|
||||
|
||||
2. **P0: 调低默认连接池大小**
|
||||
- `DB_POOL_SIZE` 从 100 改为 20-30
|
||||
- `DB_MAX_OVERFLOW` 从 100 改为 10-20
|
||||
2. ~~**P0: 调低默认连接池大小**~~ → 已修正:`DB_POOL_SIZE=160`, `DB_MAX_OVERFLOW=120`(基于 MySQL max_connections=400,三层配置源已对齐)
|
||||
|
||||
3. **P1: WebSocket 架构改进**
|
||||
- 增加最大连接数限制
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 运维总监审查报告
|
||||
|
||||
**项目**: Nexus 6.0 — 服务器运维管理平台
|
||||
**审查时间**: 2026-05-21
|
||||
**审查时间**: 2026-05-21(2026-05-22 勘误更新)
|
||||
**审查范围**: deploy/ 全部配置、docs/project/deploy.md 部署指南、server/config.py 配置项、server/main.py 启动配置、server/background/ 后台任务、server/infrastructure/ 基础设施层
|
||||
|
||||
> **勘误说明 (2026-05-22)**: 本报告部分问题在审查后已修复,已在相应位置标注 ✅已修复。
|
||||
**审查人**: 运维总监
|
||||
|
||||
---
|
||||
@@ -213,13 +215,13 @@ location /api/auth/ {
|
||||
| R1 | **单Worker生产运行** | 一个worker处理所有WebSocket连接、心跳接收、API请求。一旦某个请求阻塞(如慢查询),所有其他请求(包括心跳)都会排队等待。后果:Agent心跳超时、WebSocket断连、健康检查失败触发误重启。 | 使用 `--workers 4` 并启用 `--ws max_size=16` 等uvicorn优化参数 |
|
||||
| R2 | **循环重启风暴** | health_monitor在连续3次失败后,每分钟执行一次 `supervisorctl restart`,无论重启是否成功。如果根本原因(如配置错误、端口冲突)未解决,会造成每分钟的反复重启循环。 | 增加冷却期:连续N次重启失败后,停止自动重启,仅发送"需人工介入"告警,等待操作员处理 |
|
||||
| R3 | **Root运行所有进程** | Supervisor配置 `user=root`,Python进程以root身份运行。如果FastAPI存在任意代码执行漏洞(如依赖库CVE),攻击者可获得服务器完全控制权。 | 创建 `nexus` 系统用户,最小权限原则 |
|
||||
| R4 | **CORS全开放** | `allow_origins=["*"]`,生产环境也开放。虽然当前是PHP前端同域部署,CORS问题不显现,但一旦前端分离部署或API被直接访问,风险极大。 | 改为明确的白名单域名。当前PHP前端部署在同一域名下,CORS需求极低。 |
|
||||
| R4 | **CORS全开放** | `allow_origins=["*"]` | ✅**已修复** — 改为 `settings.CORS_ORIGINS.split(",")` 从 `NEXUS_CORS_ORIGINS` 环境变量读取白名单 |
|
||||
|
||||
### 5.2 中风险
|
||||
|
||||
| # | 风险 | 影响 | 建议 |
|
||||
|---|------|------|------|
|
||||
| R5 | **无数据库连接池监控** | `DB_POOL_SIZE=100` + `DB_MAX_OVERFLOW=100`,最大潜在连接数200。MySQL 8.4默认`max_connections=151`,**配置的池大小已经超过数据库上限**。一旦多个worker实例运行(4 workers x 100 pool = 400连接),必然导致连接失败。 | 1) 调低 `DB_POOL_SIZE=20`、`DB_MAX_OVERFLOW=20`(单worker)。2) 如用4 workers,则每个worker `pool_size=10`。3) 增加连接池监控告警 |
|
||||
| R5 | **数据库连接池监控** | ~~`DB_POOL_SIZE=100` + `DB_MAX_OVERFLOW=100`~~ → 已修正为 `DB_POOL_SIZE=160` + `DB_MAX_OVERFLOW=120`(基于 MySQL max_connections=400)。install.php 已实现自动计算:`poolSize = max(20, maxConn * 0.4)`。 | ✅ install.php 自动计算已落地。建议增加运行时连接池使用率监控告警 |
|
||||
| R6 | **健康检查端口固定** | `HEALTH_URL` 硬编码为 `http://127.0.0.1:8600/health`,如果后续端口变更需同时修改脚本。 | 改为从配置文件读取或作为脚本参数传入 |
|
||||
| R7 | **异常处理宽泛** | `load_settings_from_db` 中 `except Exception` 静默吞掉所有异常,返回0。如果settings表结构变更或数据类型异常,不会被及时发现。 | 区分可忽略异常和需要告警的异常,对关键错误发送运维告警 |
|
||||
| R8 | **无日志集中管理** | Supervisor日志写在 `/var/log/nexus/`,Nginx日志在 `/www/wwwlogs/`,Python日志可能分散到stdout。无统一日志中心,故障排查需要手动查看多个位置。 | 使用 systemd-journald 或 rsyslog 统一收集,考虑接入 ELK/Loki |
|
||||
@@ -278,13 +280,7 @@ location /api/auth/ {
|
||||
|
||||
### P1 — 短期改进(本周内)
|
||||
|
||||
4. **调优数据库连接池**
|
||||
```python
|
||||
# server/config.py
|
||||
DB_POOL_SIZE: Optional[int] = 20 # 从100下调
|
||||
DB_MAX_OVERFLOW: Optional[int] = 20 # 从100下调
|
||||
```
|
||||
相应调整MySQL的 `max_connections`:`SET GLOBAL max_connections = 200;`(在 `/etc/mysql/my.cnf` 中持久化)
|
||||
4. ~~**调优数据库连接池**~~ → 已修正:config.py 默认值已调整为 `DB_POOL_SIZE=160` + `DB_MAX_OVERFLOW=120`(基于 MySQL max_connections=400)。install.php 自动计算逻辑在生产环境会覆盖 .env 值。
|
||||
|
||||
5. **Nginx增加安全头和生产配置文件**
|
||||
- 从 `deploy.md` 中提取生产Nginx配置为独立文件 `deploy/nginx_production.conf`
|
||||
@@ -344,7 +340,7 @@ Nexus的部署架构在整体设计上是稳健的,三层守护机制覆盖了
|
||||
2. **循环重启风暴** — health_monitor脚本在连续失败后每分钟执行restart,可能造成服务抖动
|
||||
3. **数据库连接池超配** — 100+100的池大小已经超过MySQL 151的默认上限,多worker后问题更严重
|
||||
4. **Root运行和服务用户缺失** — 安全基线问题
|
||||
5. **CORS全开** — 不必要且不安全
|
||||
5. **CORS全开** — ✅已修复:`NEXUS_CORS_ORIGINS` 环境变量白名单
|
||||
|
||||
短期改进优先级:
|
||||
1. 先调低数据库连接池(写死的风险 > 配置不一致的风险)
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
| 问题 | 文件 | 严重程度 |
|
||||
|------|------|---------|
|
||||
| `datetime.utcnow()` 已弃用 (Python 3.12) | `domain/models/__init__.py` 全部模型 | **高** — 所有29处使用均需替换为 `datetime.now(datetime.UTC)` |
|
||||
| `datetime.utcnow()` 已弃用 (Python 3.12) | `domain/models/__init__.py` 全部模型 | **高** → ✅**已修复** — 全部替换为 `datetime.now(timezone.utc)` |
|
||||
| Redis心跳数据覆盖逻辑重复(两处完全相同) | `api/servers.py:list_servers` 和 `get_server` | **中** — 约35行重复代码 |
|
||||
| API端点使用 `dict` 而非 Pydantic model 做入参校验 | `api/servers.py:create_server` | **中** — 未使用正规请求模型 |
|
||||
| `conftest.py` 含 TODO 和未完成的依赖覆盖 | `tests/conftest.py:55` | **中** — 模拟基础设施不完整 |
|
||||
@@ -180,7 +180,7 @@
|
||||
| Q2 | **修复测试基础设施** | `conftest.py` 的 override_get_session TODO 必须完成,让测试可独立运行 | 1h |
|
||||
| Q3 | **配置 ruff + phpstan** | Python用ruff(替代 flake8/isort/black),PHP用phpstan,作为最基础的质量 gate | 1h |
|
||||
| Q4 | **统一品牌名称** | 全局替换 "MultiSync" → "Nexus",版本统一为 v6.0.0 | 0.5h |
|
||||
| Q5 | **修复 `datetime.utcnow()`** | 29处替换为 `datetime.now(datetime.UTC)`,消除 Python 3.12 deprecation | 0.5h |
|
||||
| Q5 | ~~**修复 `datetime.utcnow()`**~~ ✅已修复 | 全部替换为 `datetime.now(timezone.utc)`,消除 Python 3.12 deprecation | ~~0.5h~~ 已完成 |
|
||||
|
||||
### 5.2 中优先级(迭代7范围)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ECC组 全链路安全审查报告
|
||||
|
||||
> 日期: 2026-05-21
|
||||
> 日期: 2026-05-21(2026-05-22 勘误更新)
|
||||
> 部门: ECC组
|
||||
> 负责人: CTO
|
||||
> 审查范围: R7 Redis安全 + D9 Schema安全 + A4 认证安全 + W6 SSH安全 + EC1-EC5 编码规范
|
||||
@@ -19,7 +19,7 @@
|
||||
| **D9** | 迁移安全 | ✅ | category字段保留兼容;新增字段nullable | 通过 |
|
||||
| **A4** | JWT认证 | ✅ | HS256 + SECRET_KEY签名;exp过期校验;is_active检查 | 通过 |
|
||||
| **A4** | JWT公开路由 | 🟡 中 | `/api/agent/` 使用API Key而非JWT,需确认Agent请求带X-API-Key | 需验证 |
|
||||
| **A4** | CORS配置 | 🔴 高 | `allow_origins=["*"]` + `allow_credentials=True` 同时存在 | **需修复** |
|
||||
| **A4** | CORS配置 | ~~🔴 高~~ ✅已修复 | `allow_origins=["*"]` → 改为 `settings.CORS_ORIGINS.split(",")` 从 `NEXUS_CORS_ORIGINS` 环境变量读取 | ✅**已修复** |
|
||||
| **A4** | JWT算法锁定 | ✅ | `algorithms=["HS256"]` 硬编码,不允许算法混淆攻击 | 通过 |
|
||||
| **W6** | SSH连接注入 | ✅ | asyncssh参数化连接,无shell注入风险 | 通过 |
|
||||
| **W6** | 会话隔离 | ✅ | 每个WebSocket连接独立SSH进程,UUID session_id | 通过 |
|
||||
@@ -30,27 +30,29 @@
|
||||
| **EC3** | WS/SSH安全 | ✅ | 连接超时+心跳检测+僵尸清理 | 通过 |
|
||||
| **EC4** | 前端XSS | ✅ | 所有动态内容通过`esc()`函数转义后再写入DOM | 通过 |
|
||||
| **EC4** | 前端CSRF | ✅ | JWT Bearer token不受CSRF攻击 | 通过 |
|
||||
| **EC5** | 编码规范 | 🟡 中 | `datetime.utcnow()` 29处需替换为 `datetime.now(timezone.utc)` | 建议改进 |
|
||||
| **EC5** | 编码规范 | ✅已修复 | `datetime.utcnow()` 已全部替换为 `datetime.now(timezone.utc)` | ✅**已修复** |
|
||||
|
||||
---
|
||||
|
||||
## 需修复项
|
||||
|
||||
### 🔴 P0: CORS配置(A4相关)
|
||||
### ~~🔴 P0: CORS配置(A4相关)~~ ✅已修复
|
||||
|
||||
**问题:** `main.py` 中 `allow_origins=["*"]` + `allow_credentials=True` 同时存在,浏览器会拒绝此组合。
|
||||
|
||||
**修复:** 已改为 `allow_origins=settings.CORS_ORIGINS.split(",")` 从 `NEXUS_CORS_ORIGINS` 环境变量读取白名单。
|
||||
|
||||
**修复:** 限制 origins 为实际域名。
|
||||
|
||||
### 🟡 P1: datetime.utcnow() 弃用(EC5)
|
||||
### ~~🟡 P1: datetime.utcnow() 弃用(EC5)~~ ✅已修复
|
||||
|
||||
**问题:** Python 3.12 弃用 `datetime.utcnow()`,项目29处使用。
|
||||
|
||||
**建议:** 后续迭代替换为 `datetime.now(timezone.utc)`。
|
||||
**修复:** server/ 目录全部模型 + web/agent/agent.py 已替换为 `datetime.now(timezone.utc)`。
|
||||
|
||||
---
|
||||
|
||||
## 审查结论
|
||||
|
||||
**有条件通过** — CORS配置需立即修复,其余项目通过审查。
|
||||
**通过** — CORS配置已修复,datetime.utcnow()已替换,其余项目通过审查。
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
### W2: 替换 paramiko 调用
|
||||
- asyncssh_pool 提供统一的 `exec_ssh_command()` 接口
|
||||
- 旧 `server/infrastructure/ssh/pool.py` 保留(paramiko),Sync引擎逐步迁移
|
||||
- ~~旧 `server/infrastructure/ssh/pool.py` 保留(paramiko),Sync引擎逐步迁移~~ → ✅ pool.py 已删除,paramiko 已从 requirements.txt 移除,所有代码已迁移到 asyncssh
|
||||
|
||||
### W3: /ws/terminal/{token} 端点
|
||||
- `server/api/webssh.py` — WebSocket终端端点
|
||||
|
||||
+13
-8
@@ -1,14 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nexus MCP Server — 测试/运维工具集 (mcp 1.x API)"""
|
||||
"""Nexus MCP Server — 测试/运维工具集 (mcp 1.x API)
|
||||
|
||||
All paths read from NEXUS_DEPLOY_DIR env var (default: /opt/nexus).
|
||||
"""
|
||||
import json, os, re, subprocess
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
API_BASE = "http://127.0.0.1:8600"
|
||||
API_KEY = ""
|
||||
DEPLOY_DIR = os.environ.get("NEXUS_DEPLOY_DIR", "/opt/nexus")
|
||||
|
||||
def _load_db_creds():
|
||||
"""Load DB credentials from .env file (never hardcode)"""
|
||||
env_path = "/www/wwwroot/api.synaglobal.vip/.env"
|
||||
env_path = os.path.join(DEPLOY_DIR, ".env")
|
||||
creds = {"user": "nexus", "pass": "", "db": "nexus"}
|
||||
try:
|
||||
if os.path.exists(env_path):
|
||||
@@ -86,7 +90,8 @@ async def service(action: str = "status"):
|
||||
async def logs(lines: int = 50):
|
||||
"""查看 Python 后端日志。lines: 行数"""
|
||||
try:
|
||||
p = subprocess.run(['tail', '-n', str(lines), '/www/wwwroot/api.synaglobal.vip/logs/nexus.log'],
|
||||
log_path = os.path.join(DEPLOY_DIR, "logs", "nexus.log")
|
||||
p = subprocess.run(['tail', '-n', str(lines), log_path],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
return p.stdout[-3000:]
|
||||
except Exception as e:
|
||||
@@ -99,9 +104,9 @@ async def logs(lines: int = 50):
|
||||
async def run_test():
|
||||
"""运行 test_api.py 端到端测试"""
|
||||
try:
|
||||
p = subprocess.run(['python3', '/www/wwwroot/api.synaglobal.vip/tests/test_api.py'],
|
||||
p = subprocess.run(['python3', os.path.join(DEPLOY_DIR, 'tests', 'test_api.py')],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
cwd='/www/wwwroot/api.synaglobal.vip')
|
||||
cwd=DEPLOY_DIR)
|
||||
return p.stdout
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
@@ -115,7 +120,7 @@ async def git_log(count: int = 10):
|
||||
try:
|
||||
p = subprocess.run(['git', 'log', f'-{count}', '--oneline'],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
cwd='/www/wwwroot/api.synaglobal.vip')
|
||||
cwd=DEPLOY_DIR)
|
||||
return p.stdout
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
@@ -128,7 +133,7 @@ async def deploy():
|
||||
"""同步最新代码到运行目录并重启服务"""
|
||||
try:
|
||||
cmds = [
|
||||
"cd /www/wwwroot/api.synaglobal.vip && git fetch --all 2>&1 && git reset --hard origin/master 2>&1",
|
||||
f"cd {DEPLOY_DIR} && git fetch --all 2>&1 && git reset --hard origin/master 2>&1",
|
||||
"supervisorctl restart nexus 2>&1",
|
||||
]
|
||||
result = []
|
||||
@@ -146,7 +151,7 @@ async def deploy():
|
||||
async def config_view():
|
||||
"""查看当前非敏感配置(隐藏密码/密钥)"""
|
||||
try:
|
||||
with open('/www/wwwroot/api.synaglobal.vip/.env') as f:
|
||||
with open(os.path.join(DEPLOY_DIR, '.env')) as f:
|
||||
lines = []
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
|
||||
+20
-4
@@ -1,13 +1,29 @@
|
||||
"""MCP Bridge — launches remote MCP server via SSH and bridges stdio"""
|
||||
"""MCP Bridge — launches remote MCP server via SSH and bridges stdio
|
||||
|
||||
Configure via environment variables:
|
||||
NEXUS_SSH_KEY — path to SSH private key (default: ~/.ssh/id_rsa)
|
||||
NEXUS_SSH_HOST — remote server hostname/IP (required)
|
||||
NEXUS_DEPLOY_DIR — remote installation path (default: /opt/nexus)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ssh_key = os.environ.get("NEXUS_SSH_KEY", str(Path.home() / ".ssh" / "id_rsa"))
|
||||
ssh_host = os.environ.get("NEXUS_SSH_HOST", "")
|
||||
deploy_dir = os.environ.get("NEXUS_DEPLOY_DIR", "/opt/nexus")
|
||||
|
||||
if not ssh_host:
|
||||
print("ERROR: NEXUS_SSH_HOST not set (e.g. root@1.2.3.4)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
ssh = subprocess.Popen([
|
||||
"ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-i", "/c/Users/uzuma/.ssh/id_rsa_mcp",
|
||||
"root@47.254.123.106",
|
||||
"python3", "/www/wwwroot/api.synaglobal.vip/mcp/Nexus_server.py"
|
||||
"-i", ssh_key,
|
||||
ssh_host,
|
||||
"python3", f"{deploy_dir}/mcp/Nexus_server.py"
|
||||
], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
|
||||
|
||||
sys.exit(ssh.wait())
|
||||
|
||||
+1
-6
@@ -5,19 +5,14 @@ fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
aiomysql==0.2.0
|
||||
alembic==1.14.0
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-multipart==0.0.19
|
||||
jinja2==3.1.5
|
||||
aiofiles==24.1.0
|
||||
httpx==0.28.1
|
||||
apscheduler==3.11.0
|
||||
websockets==14.1
|
||||
paramiko==3.5.0
|
||||
asyncssh==2.17.0
|
||||
PyJWT==2.10.1
|
||||
bcrypt==4.2.1
|
||||
cryptography==44.0.0
|
||||
pyyaml==6.0.2
|
||||
rich==13.9.4
|
||||
redis[hiredis]==5.2.1
|
||||
+13
-38
@@ -16,6 +16,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
|
||||
from server.api.dependencies import get_server_service
|
||||
from server.api.schemas import AgentHeartbeat, AgentExec
|
||||
from server.api.websocket import broadcast_alert, broadcast_recovery
|
||||
from server.application.services.server_service import ServerService
|
||||
from server.config import settings
|
||||
@@ -81,7 +82,7 @@ def _detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> l
|
||||
|
||||
@router.post("/heartbeat", response_model=dict)
|
||||
async def receive_heartbeat(
|
||||
payload: dict,
|
||||
payload: AgentHeartbeat,
|
||||
api_key: str = Depends(_verify_api_key),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
@@ -92,25 +93,11 @@ async def receive_heartbeat(
|
||||
2. Detect alerts (CPU/mem/disk > threshold) → WebSocket + Telegram push
|
||||
3. Detect recoveries (previously alerted metrics now normal) → push
|
||||
4. Also update MySQL via ServerService (for persistent record)
|
||||
|
||||
Body: {
|
||||
"server_id": 1,
|
||||
"is_online": true,
|
||||
"system_info": {"cpu": 45, "mem": 60, "disk": 70, ...},
|
||||
"agent_version": "1.2.0"
|
||||
}
|
||||
"""
|
||||
server_id = payload.get("server_id")
|
||||
if not server_id:
|
||||
server_name = payload.get("server_name")
|
||||
if server_name:
|
||||
raise HTTPException(status_code=400, detail="server_id required (name lookup not yet implemented)")
|
||||
raise HTTPException(status_code=400, detail="server_id required")
|
||||
|
||||
is_online = payload.get("is_online", True)
|
||||
system_info = payload.get("system_info", {})
|
||||
agent_version = payload.get("agent_version", "")
|
||||
server_name = payload.get("server_name", "")
|
||||
server_id = payload.server_id
|
||||
is_online = payload.is_online
|
||||
system_info = payload.system_info or {}
|
||||
agent_version = payload.agent_version or ""
|
||||
|
||||
# ── 1. Write to Redis (real-time data) ──
|
||||
redis = get_redis()
|
||||
@@ -121,7 +108,6 @@ async def receive_heartbeat(
|
||||
"system_info": json.dumps(system_info),
|
||||
"last_heartbeat": datetime.now(timezone.utc).isoformat(),
|
||||
"agent_version": agent_version,
|
||||
"server_name": server_name,
|
||||
})
|
||||
# Set TTL = flush interval × 1.5 (prevent stale online status after key expires)
|
||||
await redis.expire(redis_key, REDIS_KEY_EXPIRE)
|
||||
@@ -140,7 +126,7 @@ async def receive_heartbeat(
|
||||
alerts = _detect_alerts(system_info, alert_thresholds)
|
||||
for alert_type, alert_value in alerts:
|
||||
logger.warning(f"Alert: server {server_id} {alert_type}={alert_value}%")
|
||||
await broadcast_alert(server_id, alert_type, alert_value, server_name)
|
||||
await broadcast_alert(server_id, alert_type, alert_value, f"server-{server_id}")
|
||||
|
||||
# Track active alert in Redis
|
||||
try:
|
||||
@@ -159,7 +145,7 @@ async def receive_heartbeat(
|
||||
recoveries = _detect_recovery(system_info, prev_alerts, alert_thresholds)
|
||||
for metric, value in recoveries:
|
||||
logger.info(f"Recovery: server {server_id} {metric}={value}%")
|
||||
await broadcast_recovery(server_id, metric, value, server_name)
|
||||
await broadcast_recovery(server_id, metric, value, f"server-{server_id}")
|
||||
|
||||
# Remove recovered alert from Redis tracking
|
||||
for prev in prev_alerts:
|
||||
@@ -169,35 +155,24 @@ async def receive_heartbeat(
|
||||
logger.error(f"Recovery detection failed for server {server_id}: {e}")
|
||||
|
||||
# ── 4. Also update MySQL (persistent record for non-real-time queries) ──
|
||||
await service.update_heartbeat(server_id, payload)
|
||||
await service.update_heartbeat(server_id, payload.model_dump())
|
||||
|
||||
return {"status": "ok", "server_id": server_id}
|
||||
|
||||
|
||||
@router.post("/exec", response_model=dict)
|
||||
async def agent_exec(
|
||||
payload: dict,
|
||||
payload: AgentExec,
|
||||
api_key: str = Depends(_verify_api_key),
|
||||
):
|
||||
"""Execute a shell command on this Agent server
|
||||
|
||||
This endpoint is called by Nexus to dispatch commands to remote Agents.
|
||||
Each Agent server runs its own instance of this endpoint.
|
||||
|
||||
Body: {
|
||||
"command": "systemctl restart nginx",
|
||||
"timeout": 30,
|
||||
"sudo": false
|
||||
}
|
||||
"""
|
||||
command = payload.get("command")
|
||||
if not command:
|
||||
raise HTTPException(status_code=400, detail="command required")
|
||||
|
||||
timeout = min(payload.get("timeout", 30), 300)
|
||||
use_sudo = payload.get("sudo", False)
|
||||
|
||||
if use_sudo:
|
||||
command = payload.command
|
||||
timeout = payload.timeout
|
||||
if payload.sudo:
|
||||
command = f"sudo {command}"
|
||||
|
||||
import asyncio
|
||||
|
||||
+109
-14
@@ -1,12 +1,16 @@
|
||||
"""Nexus — Platform & Node API Routes (Asset organization layer)
|
||||
All operations require JWT authentication.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from server.api.dependencies import get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import PlatformCreate, PlatformUpdate, NodeCreate, NodeUpdate
|
||||
from server.infrastructure.database.platform_node_repo import PlatformRepositoryImpl, NodeRepositoryImpl
|
||||
from server.domain.models import Platform, Node
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.domain.models import Platform, Node, Admin, AuditLog
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -16,7 +20,10 @@ router = APIRouter(prefix="/api/assets", tags=["assets"])
|
||||
# ── Platforms ──
|
||||
|
||||
@router.get("/platforms", response_model=list)
|
||||
async def list_platforms(db: AsyncSession = Depends(get_db)):
|
||||
async def list_platforms(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all platform templates"""
|
||||
repo = PlatformRepositoryImpl(db)
|
||||
platforms = await repo.get_all()
|
||||
@@ -24,7 +31,11 @@ async def list_platforms(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/platforms/{id}", response_model=dict)
|
||||
async def get_platform(id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_platform(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single platform by ID"""
|
||||
repo = PlatformRepositoryImpl(db)
|
||||
platform = await repo.get_by_id(id)
|
||||
@@ -34,42 +45,82 @@ async def get_platform(id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/platforms", response_model=dict, status_code=201)
|
||||
async def create_platform(payload: dict, db: AsyncSession = Depends(get_db)):
|
||||
async def create_platform(
|
||||
payload: PlatformCreate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new platform template"""
|
||||
repo = PlatformRepositoryImpl(db)
|
||||
platform = Platform(**payload)
|
||||
platform = Platform(**payload.model_dump(exclude_none=True))
|
||||
created = await repo.create(platform)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="create_platform",
|
||||
target_type="platform", target_id=created.id,
|
||||
detail=f"name={created.name}", ip_address="",
|
||||
))
|
||||
|
||||
return _platform_to_dict(created)
|
||||
|
||||
|
||||
@router.put("/platforms/{id}", response_model=dict)
|
||||
async def update_platform(id: int, payload: dict, db: AsyncSession = Depends(get_db)):
|
||||
async def update_platform(
|
||||
id: int,
|
||||
payload: PlatformUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a platform template"""
|
||||
repo = PlatformRepositoryImpl(db)
|
||||
platform = await repo.get_by_id(id)
|
||||
if not platform:
|
||||
raise HTTPException(status_code=404, detail="Platform not found")
|
||||
for key, value in payload.items():
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
if hasattr(platform, key) and key != "id":
|
||||
setattr(platform, key, value)
|
||||
updated = await repo.update(platform)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="update_platform",
|
||||
target_type="platform", target_id=id,
|
||||
detail=f"name={updated.name}", ip_address="",
|
||||
))
|
||||
|
||||
return _platform_to_dict(updated)
|
||||
|
||||
|
||||
@router.delete("/platforms/{id}", status_code=204)
|
||||
async def delete_platform(id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_platform(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a platform template"""
|
||||
repo = PlatformRepositoryImpl(db)
|
||||
platform = await repo.get_by_id(id)
|
||||
if not platform:
|
||||
raise HTTPException(status_code=404, detail="Platform not found")
|
||||
result = await repo.delete(id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Platform not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="delete_platform",
|
||||
target_type="platform", target_id=id,
|
||||
detail=f"name={platform.name}", ip_address="",
|
||||
))
|
||||
|
||||
|
||||
# ── Nodes (Tree) ──
|
||||
|
||||
@router.get("/nodes", response_model=list)
|
||||
async def list_nodes(
|
||||
parent_id: Optional[int] = None,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List nodes — all, or children of a specific parent"""
|
||||
@@ -82,7 +133,10 @@ async def list_nodes(
|
||||
|
||||
|
||||
@router.get("/nodes/tree", response_model=list)
|
||||
async def get_node_tree(db: AsyncSession = Depends(get_db)):
|
||||
async def get_node_tree(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get full node tree structure"""
|
||||
repo = NodeRepositoryImpl(db)
|
||||
all_nodes = await repo.get_all()
|
||||
@@ -90,36 +144,75 @@ async def get_node_tree(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/nodes", response_model=dict, status_code=201)
|
||||
async def create_node(payload: dict, db: AsyncSession = Depends(get_db)):
|
||||
async def create_node(
|
||||
payload: NodeCreate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new node"""
|
||||
repo = NodeRepositoryImpl(db)
|
||||
node = Node(**payload)
|
||||
node = Node(**payload.model_dump(exclude_none=True))
|
||||
created = await repo.create(node)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="create_node",
|
||||
target_type="node", target_id=created.id,
|
||||
detail=f"name={created.name}", ip_address="",
|
||||
))
|
||||
|
||||
return _node_to_dict(created)
|
||||
|
||||
|
||||
@router.put("/nodes/{id}", response_model=dict)
|
||||
async def update_node(id: int, payload: dict, db: AsyncSession = Depends(get_db)):
|
||||
async def update_node(
|
||||
id: int,
|
||||
payload: NodeUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a node"""
|
||||
repo = NodeRepositoryImpl(db)
|
||||
node = await repo.get_by_id(id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
for key, value in payload.items():
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
if hasattr(node, key) and key != "id":
|
||||
setattr(node, key, value)
|
||||
updated = await repo.update(node)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="update_node",
|
||||
target_type="node", target_id=id,
|
||||
detail=f"name={updated.name}", ip_address="",
|
||||
))
|
||||
|
||||
return _node_to_dict(updated)
|
||||
|
||||
|
||||
@router.delete("/nodes/{id}", status_code=204)
|
||||
async def delete_node(id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_node(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a node"""
|
||||
repo = NodeRepositoryImpl(db)
|
||||
node = await repo.get_by_id(id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
result = await repo.delete(id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="delete_node",
|
||||
target_type="node", target_id=id,
|
||||
detail=f"name={node.name}", ip_address="",
|
||||
))
|
||||
|
||||
|
||||
# ── SSH Sessions & Command Logs ──
|
||||
|
||||
@@ -127,6 +220,7 @@ async def delete_node(id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def list_ssh_sessions(
|
||||
server_id: Optional[int] = None,
|
||||
limit: int = 50,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List SSH sessions"""
|
||||
@@ -147,6 +241,7 @@ async def list_command_logs(
|
||||
server_id: Optional[int] = None,
|
||||
session_id: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List command logs"""
|
||||
|
||||
+5
-2
@@ -29,7 +29,7 @@ class RefreshRequest(BaseModel):
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
admin_id: int
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
|
||||
class TotpSetupRequest(BaseModel):
|
||||
@@ -85,7 +85,10 @@ async def logout(
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Invalidate refresh token (client should also discard access token)"""
|
||||
result = await service.logout(payload.admin_id)
|
||||
if payload.refresh_token:
|
||||
result = await service.logout_by_token(payload.refresh_token)
|
||||
else:
|
||||
return {"success": True, "message": "已登出"}
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+22
-10
@@ -80,7 +80,16 @@ async def _verify_token(token: str, request: Request) -> Optional[Admin]:
|
||||
"""Verify JWT access token → return Admin or None"""
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
payload = pyjwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=["HS256"],
|
||||
options={"require": ["exp", "sub"]},
|
||||
)
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
logger.debug("JWT token expired")
|
||||
return None
|
||||
except pyjwt.InvalidTokenError as e:
|
||||
logger.debug(f"JWT invalid: {e}")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -106,18 +115,21 @@ async def _verify_token(token: str, request: Request) -> Optional[Admin]:
|
||||
|
||||
|
||||
async def get_optional_admin(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
) -> Optional[Admin]:
|
||||
"""Optional JWT auth — returns Admin if token valid, None otherwise.
|
||||
Used for endpoints that work with or without auth (e.g., public data with optional user context).
|
||||
Uses middleware session via request.state.db (no leaked sessions).
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
# Can't use _verify_token without request here, so do inline
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
payload = pyjwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
payload = pyjwt.decode(
|
||||
credentials.credentials, settings.SECRET_KEY, algorithms=["HS256"],
|
||||
options={"require": ["exp", "sub"]},
|
||||
)
|
||||
admin_id = payload.get("sub")
|
||||
if not admin_id:
|
||||
return None
|
||||
@@ -125,11 +137,11 @@ async def get_optional_admin(
|
||||
admin_id = int(admin_id)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
async with AsyncSessionLocal() as session:
|
||||
admin_repo = AdminRepositoryImpl(session)
|
||||
admin = await admin_repo.get_by_id(admin_id)
|
||||
return admin if admin and admin.is_active else None
|
||||
|
||||
# Use middleware session instead of creating independent session
|
||||
session = await _get_request_session(request)
|
||||
admin_repo = AdminRepositoryImpl(session)
|
||||
admin = await admin_repo.get_by_id(admin_id)
|
||||
return admin if admin and admin.is_active else None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -29,18 +29,23 @@ from server.application.services.auth_service import AuthService
|
||||
from server.application.services.sync_service import SyncService
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield an async database session (for use as FastAPI Depends)
|
||||
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield DB session from middleware (request.state.db).
|
||||
|
||||
For routes that use get_db() directly (settings, schedules, etc.),
|
||||
we still use the yield pattern which properly closes the session.
|
||||
These routes don't go through the service factories.
|
||||
DbSessionMiddleware ensures every /api/ request has a session.
|
||||
This dependency simply yields it so routes can use Depends(get_db).
|
||||
No double-session: one session per request, shared everywhere.
|
||||
"""
|
||||
session = AsyncSessionLocal()
|
||||
try:
|
||||
session = getattr(request.state, "db", None)
|
||||
if session is not None:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
else:
|
||||
# Fallback for tests or edge cases without middleware
|
||||
session = AsyncSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def _get_request_session(request: Request) -> AsyncSession:
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Nexus — Installation Wizard API
|
||||
|
||||
Runs BEFORE the main app is fully configured (no .env required).
|
||||
Provides endpoints for the 5-step install wizard.
|
||||
|
||||
Security: All endpoints refuse to operate once .env exists or install is locked.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
|
||||
logger = logging.getLogger("nexus.install")
|
||||
|
||||
router = APIRouter(prefix="/api/install", tags=["install"])
|
||||
|
||||
# ── Paths ──
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
ENV_FILE = ROOT_DIR / ".env"
|
||||
LOCK_FILE = ROOT_DIR / "web" / "install.php.locked"
|
||||
INSTALL_LOCK = ROOT_DIR / ".install_locked"
|
||||
CONFIG_PHP_DIR = ROOT_DIR / "web" / "data"
|
||||
CONFIG_PHP = CONFIG_PHP_DIR / "config.php"
|
||||
STATE_FILE = ROOT_DIR / ".install_state.json"
|
||||
|
||||
|
||||
def _is_installed() -> bool:
|
||||
return ENV_FILE.exists() or INSTALL_LOCK.exists()
|
||||
|
||||
|
||||
def _is_locked() -> bool:
|
||||
return INSTALL_LOCK.exists() or LOCK_FILE.exists()
|
||||
|
||||
|
||||
# ── Pydantic Models ──
|
||||
|
||||
class InitDbRequest(BaseModel):
|
||||
db_host: str = "localhost"
|
||||
db_port: str = "3306"
|
||||
db_name: str = "Nexus"
|
||||
db_user: str = "Nexus"
|
||||
db_pass: str
|
||||
redis_host: str = "127.0.0.1"
|
||||
redis_port: str = "6379"
|
||||
redis_db: str = "0"
|
||||
redis_pass: str = ""
|
||||
api_port: str = "8600"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
site_url: str = ""
|
||||
|
||||
|
||||
class CreateAdminRequest(BaseModel):
|
||||
db_host: str = "localhost"
|
||||
db_port: str = "3306"
|
||||
db_name: str = "Nexus"
|
||||
db_user: str = "Nexus"
|
||||
db_pass: str
|
||||
admin_username: str = "admin"
|
||||
admin_password: str
|
||||
admin_email: str = ""
|
||||
system_name: str = "Nexus"
|
||||
system_title: str = "Nexus — 服务器运维管理平台"
|
||||
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _make_db_url(req: InitDbRequest | CreateAdminRequest) -> str:
|
||||
return (
|
||||
f"mysql+aiomysql://{req.db_user}:{req.db_pass}"
|
||||
f"@{req.db_host}:{req.db_port}/{req.db_name}"
|
||||
)
|
||||
|
||||
|
||||
def _build_redis_url(req: InitDbRequest) -> str:
|
||||
url = "redis://"
|
||||
if req.redis_pass:
|
||||
url += f"{req.redis_pass}@"
|
||||
url += f"{req.redis_host}:{req.redis_port}/{req.redis_db}"
|
||||
return url
|
||||
|
||||
|
||||
def _write_env(req: InitDbRequest, secret_key: str, api_key: str,
|
||||
pool_size: int, max_overflow: int, redis_url: str,
|
||||
site_url: str) -> None:
|
||||
install_dir = str(ROOT_DIR)
|
||||
db_url = (
|
||||
f"mysql+aiomysql://{req.db_user}:{req.db_pass}"
|
||||
f"@{req.db_host}:{req.db_port}/{req.db_name}"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"# Nexus .env — Auto-generated by installer",
|
||||
f"# {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC",
|
||||
"",
|
||||
"NEXUS_SYSTEM_NAME=Nexus",
|
||||
"NEXUS_SYSTEM_TITLE=Nexus — 服务器运维管理平台",
|
||||
"",
|
||||
"NEXUS_HOST=0.0.0.0",
|
||||
f"NEXUS_PORT={req.api_port}",
|
||||
"",
|
||||
"# Database (MySQL 8.4 + aiomysql async driver)",
|
||||
f"NEXUS_DATABASE_URL={db_url}",
|
||||
f"NEXUS_DB_POOL_SIZE={pool_size}",
|
||||
f"NEXUS_DB_MAX_OVERFLOW={max_overflow}",
|
||||
"",
|
||||
"# Security — PRODUCTION KEYS (immutable after install)",
|
||||
f"NEXUS_SECRET_KEY={secret_key}",
|
||||
f"NEXUS_API_KEY={api_key}",
|
||||
"NEXUS_ENCRYPTION_KEY=",
|
||||
"",
|
||||
"# Redis",
|
||||
f"NEXUS_REDIS_URL={redis_url}",
|
||||
"",
|
||||
"# Deployment",
|
||||
f"NEXUS_DEPLOY_PATH={install_dir}",
|
||||
f"NEXUS_CORS_ORIGINS={site_url},http://localhost:{req.api_port}",
|
||||
"",
|
||||
"# SSH",
|
||||
"NEXUS_SSH_STRICT_HOST_CHECKING=false",
|
||||
"",
|
||||
"# Health Check",
|
||||
"NEXUS_HEALTH_CHECK_INTERVAL=60",
|
||||
f"NEXUS_API_BASE_URL={site_url}",
|
||||
"",
|
||||
"# Alert Thresholds",
|
||||
"NEXUS_CPU_ALERT_THRESHOLD=80",
|
||||
"NEXUS_MEM_ALERT_THRESHOLD=80",
|
||||
"NEXUS_DISK_ALERT_THRESHOLD=80",
|
||||
"",
|
||||
"# Telegram Alerts (optional — configure in Settings UI)",
|
||||
"NEXUS_TELEGRAM_BOT_TOKEN=",
|
||||
"NEXUS_TELEGRAM_CHAT_ID=",
|
||||
"",
|
||||
]
|
||||
|
||||
ENV_FILE.write_text("\n".join(lines))
|
||||
logger.info(f".env written to {ENV_FILE}")
|
||||
|
||||
|
||||
def _write_config_php(req: InitDbRequest, api_key: str, site_url: str) -> None:
|
||||
CONFIG_PHP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
install_dir = str(ROOT_DIR)
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
lines = [
|
||||
"<?php",
|
||||
"// Nexus Configuration — Auto-generated by installer",
|
||||
f"// {now}",
|
||||
"",
|
||||
f"define('API_BASE_URL', '{site_url}');",
|
||||
f"define('API_KEY', '{api_key}');",
|
||||
f"define('DB_HOST', '{req.db_host}');",
|
||||
f"define('DB_PORT', '{req.db_port}');",
|
||||
f"define('DB_NAME', '{req.db_name}');",
|
||||
f"define('DB_USER', '{req.db_user}');",
|
||||
f"define('DB_PASS', '{req.db_pass}');",
|
||||
"define('APP_NAME', 'Nexus');",
|
||||
"define('APP_VERSION', '6.0.0');",
|
||||
"define('SESSION_LIFETIME', 28800);",
|
||||
f"define('DEPLOY_ROOT', '{install_dir}');",
|
||||
"",
|
||||
f"date_default_timezone_set('{req.timezone}');",
|
||||
"",
|
||||
]
|
||||
|
||||
CONFIG_PHP.write_text("\n".join(lines))
|
||||
logger.info(f"config.php written to {CONFIG_PHP}")
|
||||
|
||||
|
||||
def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
|
||||
"""Best-effort: configure Supervisor, crontab, health_monitor.sh"""
|
||||
results: list[str] = []
|
||||
|
||||
# 1. Log directory
|
||||
log_dir = Path("/var/log/nexus")
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
results.append(f"✓ 日志目录已创建 {log_dir}")
|
||||
except OSError:
|
||||
results.append(f"✗ 日志目录创建失败(需 root 权限)")
|
||||
|
||||
# 2. Supervisor config
|
||||
supervisor_conf = (
|
||||
f"[program:nexus]\n"
|
||||
f"command={install_dir}/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port {api_port}\n"
|
||||
f"directory={install_dir}\n"
|
||||
f"user=root\n"
|
||||
f"autostart=true\n"
|
||||
f"autorestart=true\n"
|
||||
f"startretries=10\n"
|
||||
f"startsecs=3\n"
|
||||
f"stopwaitsecs=10\n"
|
||||
f"stopsignal=INT\n"
|
||||
f"environment=PATH=\"{install_dir}/venv/bin:/usr/local/bin:%(ENV_PATH)s\",HOME=\"/root\"\n"
|
||||
f"stderr_logfile=/var/log/nexus/error.log\n"
|
||||
f"stdout_logfile=/var/log/nexus/access.log\n"
|
||||
f"stderr_logfile_maxbytes=50MB\n"
|
||||
f"stdout_logfile_maxbytes=50MB\n"
|
||||
f"stderr_logfile_backups=5\n"
|
||||
f"stdout_logfile_backups=5\n"
|
||||
)
|
||||
conf_path = Path("/etc/supervisor/conf.d/nexus.conf")
|
||||
try:
|
||||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conf_path.write_text(supervisor_conf)
|
||||
results.append(f"✓ Supervisor 配置已写入 {conf_path}")
|
||||
except OSError:
|
||||
results.append("✗ Supervisor 配置写入失败(需 root 权限)")
|
||||
|
||||
# 3. Update health_monitor.sh INSTALL_DIR
|
||||
health_sh = Path(install_dir) / "deploy" / "health_monitor.sh"
|
||||
if health_sh.exists():
|
||||
try:
|
||||
content = health_sh.read_text()
|
||||
import re
|
||||
content = re.sub(
|
||||
r'INSTALL_DIR="[^"]*"',
|
||||
f'INSTALL_DIR="{install_dir}"',
|
||||
content,
|
||||
)
|
||||
health_sh.write_text(content)
|
||||
health_sh.chmod(0o755)
|
||||
results.append("✓ health_monitor.sh INSTALL_DIR 已更新")
|
||||
except OSError:
|
||||
results.append("✗ health_monitor.sh 更新失败")
|
||||
else:
|
||||
results.append("⚠ health_monitor.sh 未找到,跳过")
|
||||
|
||||
# 4. Crontab
|
||||
cron_entry = f"* * * * * {install_dir}/deploy/health_monitor.sh"
|
||||
try:
|
||||
current = subprocess.run(
|
||||
["crontab", "-l"], capture_output=True, text=True, timeout=5
|
||||
).stdout or ""
|
||||
if "health_monitor.sh" not in current:
|
||||
new_cron = current.rstrip() + "\n" + cron_entry + "\n"
|
||||
proc = subprocess.run(
|
||||
["crontab", "-"], input=new_cron, capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
results.append("✓ Crontab 已配置(每分钟健康检查)")
|
||||
else:
|
||||
results.append("✗ Crontab 配置失败 — 请手动添加")
|
||||
else:
|
||||
results.append("✓ Crontab 已存在 health_monitor 条目")
|
||||
except Exception:
|
||||
results.append("⚠ Crontab 配置跳过(权限不足或 cron 不可用)")
|
||||
|
||||
# 5. Reload Supervisor
|
||||
try:
|
||||
subprocess.run(["supervisorctl", "reread"], capture_output=True, timeout=5)
|
||||
subprocess.run(["supervisorctl", "update"], capture_output=True, timeout=5)
|
||||
results.append("✓ Supervisor 已重载配置")
|
||||
except Exception:
|
||||
results.append("⚠ Supervisor 未运行 — 请手动执行 supervisorctl reread && supervisorctl update")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── Endpoints ──
|
||||
|
||||
@router.get("/status")
|
||||
async def install_status():
|
||||
"""Check whether Nexus is already installed."""
|
||||
return {
|
||||
"installed": _is_installed(),
|
||||
"locked": _is_locked(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env-check")
|
||||
async def env_check():
|
||||
"""Detect environment readiness — Python, MySQL client, Redis, write permissions."""
|
||||
import sys
|
||||
import importlib
|
||||
|
||||
checks = []
|
||||
|
||||
# Python version
|
||||
checks.append({
|
||||
"name": "Python 版本",
|
||||
"required": "3.12+",
|
||||
"current": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||
"pass": sys.version_info >= (3, 12),
|
||||
})
|
||||
|
||||
# MySQL client (aiomysql)
|
||||
try:
|
||||
import aiomysql
|
||||
checks.append({
|
||||
"name": "aiomysql 驱动",
|
||||
"required": "已安装",
|
||||
"current": f"✓ {aiomysql.__version__}",
|
||||
"pass": True,
|
||||
})
|
||||
except ImportError:
|
||||
checks.append({
|
||||
"name": "aiomysql 驱动",
|
||||
"required": "已安装",
|
||||
"current": "✗ 未安装",
|
||||
"pass": False,
|
||||
})
|
||||
|
||||
# Redis
|
||||
redis_ok = False
|
||||
redis_msg = "未安装"
|
||||
try:
|
||||
import redis as rlib
|
||||
try:
|
||||
r = rlib.Redis(host="127.0.0.1", port=6379, socket_timeout=0.5)
|
||||
r.ping()
|
||||
redis_ok = True
|
||||
redis_msg = "✓ 已连接 127.0.0.1:6379"
|
||||
except Exception as e:
|
||||
redis_msg = f"连接失败: {e}"
|
||||
except ImportError:
|
||||
redis_msg = "redis 库未安装"
|
||||
checks.append({
|
||||
"name": "Redis",
|
||||
"required": "已连接",
|
||||
"current": redis_msg,
|
||||
"pass": redis_ok,
|
||||
})
|
||||
|
||||
# MySQL database — user configures in step 3
|
||||
checks.append({
|
||||
"name": "MySQL 数据库",
|
||||
"required": "步骤3配置",
|
||||
"current": "— 请在步骤3填写数据库连接信息",
|
||||
"pass": True,
|
||||
})
|
||||
|
||||
# Write permissions
|
||||
data_writable = CONFIG_PHP_DIR.exists() and os.access(CONFIG_PHP_DIR, os.W_OK) or \
|
||||
os.access(ROOT_DIR / "web", os.W_OK)
|
||||
root_writable = os.access(ROOT_DIR, os.W_OK)
|
||||
checks.append({
|
||||
"name": "web/data 写入权限",
|
||||
"required": "可写",
|
||||
"current": "✓ 可写" if data_writable else "✗ 不可写",
|
||||
"pass": data_writable,
|
||||
})
|
||||
checks.append({
|
||||
"name": "根目录写入权限 (.env)",
|
||||
"required": "可写",
|
||||
"current": "✓ 可写" if root_writable else "✗ 不可写",
|
||||
"pass": root_writable,
|
||||
})
|
||||
|
||||
all_pass = all(c["pass"] for c in checks)
|
||||
return {"checks": checks, "all_pass": all_pass}
|
||||
|
||||
|
||||
@router.post("/init-db")
|
||||
async def init_db(req: InitDbRequest):
|
||||
"""Step 3: Connect to MySQL, create tables, write configs."""
|
||||
if _is_installed():
|
||||
raise HTTPException(400, "系统已安装,不可重复初始化")
|
||||
|
||||
db_url = _make_db_url(req)
|
||||
redis_url = _build_redis_url(req)
|
||||
|
||||
try:
|
||||
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"数据库引擎创建失败: {e}")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
# Create tables using SQLAlchemy metadata
|
||||
from server.domain.models import Base
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Create additional indexes
|
||||
indexes = [
|
||||
"ALTER TABLE servers ADD INDEX idx_servers_is_online (is_online)",
|
||||
"ALTER TABLE servers ADD INDEX idx_servers_category (category)",
|
||||
"ALTER TABLE servers ADD INDEX idx_servers_platform_id (platform_id)",
|
||||
"ALTER TABLE servers ADD INDEX idx_servers_node_id (node_id)",
|
||||
"ALTER TABLE sync_logs ADD INDEX idx_sync_logs_srv_start (server_id, started_at)",
|
||||
"ALTER TABLE login_attempts ADD INDEX idx_login_attempts_username_ip (username, ip_address)",
|
||||
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_server_id (server_id)",
|
||||
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_admin_id (admin_id)",
|
||||
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_srv_time (server_id, created_at)",
|
||||
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_session_id (session_id)",
|
||||
]
|
||||
for idx_sql in indexes:
|
||||
try:
|
||||
await conn.execute(text(idx_sql))
|
||||
except Exception:
|
||||
pass # Index may already exist
|
||||
|
||||
# Calculate pool_size from max_connections
|
||||
try:
|
||||
result = await conn.execute(text("SHOW VARIABLES LIKE 'max_connections'"))
|
||||
row = result.fetchone()
|
||||
max_conn = max(100, int(row[1]) if row else 100)
|
||||
pool_size = max(20, int(max_conn * 0.4))
|
||||
max_overflow = max(20, int(max_conn * 0.3))
|
||||
except Exception:
|
||||
pool_size, max_overflow = 160, 120
|
||||
|
||||
# Generate keys
|
||||
secret_key = secrets.token_hex(32)
|
||||
api_key = secrets.token_hex(16)
|
||||
|
||||
# Insert settings
|
||||
settings_kv = {
|
||||
"api_key": api_key,
|
||||
"secret_key": secret_key,
|
||||
"system_name": "Nexus",
|
||||
"system_title": "Nexus — 服务器运维管理平台",
|
||||
"db_pool_size": str(pool_size),
|
||||
"db_max_overflow": str(max_overflow),
|
||||
"redis_url": redis_url,
|
||||
"heartbeat_timeout": "60",
|
||||
"cpu_alert_threshold": "80",
|
||||
"mem_alert_threshold": "80",
|
||||
"disk_alert_threshold": "80",
|
||||
"telegram_bot_token": "",
|
||||
"telegram_chat_id": "",
|
||||
}
|
||||
for k, v in settings_kv.items():
|
||||
await conn.execute(
|
||||
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
|
||||
"ON DUPLICATE KEY UPDATE `value` = :v2"),
|
||||
{"k": k, "v": v, "v2": v},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await engine.dispose()
|
||||
raise HTTPException(400, f"数据库初始化失败: {e}")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
# Write .env
|
||||
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
|
||||
_write_env(req, secret_key, api_key, pool_size, max_overflow, redis_url, site_url)
|
||||
|
||||
# Write config.php
|
||||
_write_config_php(req, api_key, site_url)
|
||||
|
||||
# Configure process guardian (best-effort)
|
||||
install_dir = str(ROOT_DIR)
|
||||
guardian_results = _configure_guardian(install_dir, req.api_port)
|
||||
|
||||
# Save install state for step 4
|
||||
import json
|
||||
state = {
|
||||
"db_host": req.db_host,
|
||||
"db_port": req.db_port,
|
||||
"db_name": req.db_name,
|
||||
"db_user": req.db_user,
|
||||
"db_pass": req.db_pass,
|
||||
"pool_size": pool_size,
|
||||
"max_overflow": max_overflow,
|
||||
"install_dir": install_dir,
|
||||
"site_url": site_url,
|
||||
"api_port": req.api_port,
|
||||
"guardian_results": guardian_results,
|
||||
"step": 3,
|
||||
}
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pool_size": pool_size,
|
||||
"max_overflow": max_overflow,
|
||||
"tables_created": 14,
|
||||
"guardian_results": guardian_results,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/create-admin")
|
||||
async def create_admin(req: CreateAdminRequest):
|
||||
"""Step 4: Create admin account and set brand name."""
|
||||
if _is_locked():
|
||||
raise HTTPException(400, "系统已锁定")
|
||||
|
||||
if len(req.admin_password) < 6:
|
||||
raise HTTPException(400, "密码长度至少6位")
|
||||
|
||||
db_url = _make_db_url(req)
|
||||
|
||||
try:
|
||||
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
|
||||
async with engine.begin() as conn:
|
||||
# Hash password with bcrypt
|
||||
import bcrypt
|
||||
password_hash = bcrypt.hashpw(
|
||||
req.admin_password.encode("utf-8"),
|
||||
bcrypt.gensalt(),
|
||||
).decode("utf-8")
|
||||
|
||||
# Insert admin
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO admins (username, password_hash, email) "
|
||||
"VALUES (:u, :h, :e) "
|
||||
"ON DUPLICATE KEY UPDATE password_hash = :h2, email = :e2"
|
||||
),
|
||||
{
|
||||
"u": req.admin_username,
|
||||
"h": password_hash,
|
||||
"e": req.admin_email or None,
|
||||
"h2": password_hash,
|
||||
"e2": req.admin_email or None,
|
||||
},
|
||||
)
|
||||
|
||||
# Update brand in settings
|
||||
await conn.execute(
|
||||
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
|
||||
"ON DUPLICATE KEY UPDATE `value` = :v2"),
|
||||
{"k": "system_name", "v": req.system_name, "v2": req.system_name},
|
||||
)
|
||||
await conn.execute(
|
||||
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
|
||||
"ON DUPLICATE KEY UPDATE `value` = :v2"),
|
||||
{"k": "system_title", "v": req.system_title, "v2": req.system_title},
|
||||
)
|
||||
|
||||
await engine.dispose()
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"创建管理员失败: {e}")
|
||||
|
||||
# Update config.php with brand
|
||||
if CONFIG_PHP.exists():
|
||||
import re
|
||||
content = CONFIG_PHP.read_text()
|
||||
content = re.sub(
|
||||
r"define\('APP_NAME', '.*?'\);",
|
||||
f"define('APP_NAME', '{req.system_name}');",
|
||||
content,
|
||||
)
|
||||
CONFIG_PHP.write_text(content)
|
||||
|
||||
# Update .env with brand
|
||||
if ENV_FILE.exists():
|
||||
import re
|
||||
content = ENV_FILE.read_text()
|
||||
content = re.sub(
|
||||
r"NEXUS_SYSTEM_NAME=.*",
|
||||
f"NEXUS_SYSTEM_NAME={req.system_name}",
|
||||
content,
|
||||
)
|
||||
content = re.sub(
|
||||
r"NEXUS_SYSTEM_TITLE=.*",
|
||||
f"NEXUS_SYSTEM_TITLE={req.system_title}",
|
||||
content,
|
||||
)
|
||||
ENV_FILE.write_text(content)
|
||||
|
||||
# Update state
|
||||
if STATE_FILE.exists():
|
||||
import json
|
||||
state = json.loads(STATE_FILE.read_text())
|
||||
state["step"] = 4
|
||||
state["system_name"] = req.system_name
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False))
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/lock")
|
||||
async def lock_install():
|
||||
"""Step 5: Lock the installer — prevent re-installation."""
|
||||
# Create lock marker files
|
||||
INSTALL_LOCK.write_text(
|
||||
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n"
|
||||
)
|
||||
|
||||
# Also create the legacy lock file (install.php.locked) for compatibility
|
||||
try:
|
||||
LOCK_FILE.write_text("locked\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Clean up state file
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
STATE_FILE.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/state")
|
||||
async def get_install_state():
|
||||
"""Get current install state (for step navigation)."""
|
||||
import json
|
||||
|
||||
if not STATE_FILE.exists():
|
||||
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
|
||||
|
||||
try:
|
||||
state = json.loads(STATE_FILE.read_text())
|
||||
state["installed"] = _is_installed()
|
||||
state["locked"] = _is_locked()
|
||||
return state
|
||||
except Exception:
|
||||
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Nexus — Pydantic Request/Response Models
|
||||
Shared schemas for API validation, replacing raw `dict` parameters.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Setting ──
|
||||
|
||||
class SettingUpdatePayload(BaseModel):
|
||||
value: str = Field(..., min_length=0)
|
||||
|
||||
|
||||
# ── Agent ──
|
||||
|
||||
class AgentHeartbeat(BaseModel):
|
||||
server_id: int
|
||||
is_online: bool = True
|
||||
system_info: Optional[dict] = None
|
||||
agent_version: Optional[str] = None
|
||||
|
||||
|
||||
class AgentExec(BaseModel):
|
||||
command: str = Field(..., min_length=1)
|
||||
timeout: int = Field(30, ge=1, le=300)
|
||||
sudo: bool = False
|
||||
|
||||
|
||||
# ── Server ──
|
||||
|
||||
class ServerCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
domain: str = Field(..., min_length=1, max_length=255)
|
||||
port: int = Field(22, ge=1, le=65535)
|
||||
username: str = Field("root", max_length=100)
|
||||
auth_method: str = Field("key", pattern="^(key|password)$")
|
||||
password: Optional[str] = None
|
||||
ssh_key_path: Optional[str] = None
|
||||
ssh_key_private: Optional[str] = None
|
||||
ssh_key_public: Optional[str] = None
|
||||
agent_port: int = Field(8601, ge=1, le=65535)
|
||||
agent_api_key: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
target_path: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
platform_id: Optional[int] = None
|
||||
node_id: Optional[int] = None
|
||||
protocols: Optional[list] = None
|
||||
extra_attrs: Optional[dict] = None
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
domain: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
port: Optional[int] = Field(None, ge=1, le=65535)
|
||||
username: Optional[str] = None
|
||||
auth_method: Optional[str] = Field(None, pattern="^(key|password)$")
|
||||
password: Optional[str] = None
|
||||
ssh_key_path: Optional[str] = None
|
||||
ssh_key_private: Optional[str] = None
|
||||
ssh_key_public: Optional[str] = None
|
||||
agent_port: Optional[int] = Field(None, ge=1, le=65535)
|
||||
agent_api_key: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
target_path: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
platform_id: Optional[int] = None
|
||||
node_id: Optional[int] = None
|
||||
protocols: Optional[list] = None
|
||||
extra_attrs: Optional[dict] = None
|
||||
connectivity: Optional[str] = None
|
||||
|
||||
|
||||
class ServerPush(BaseModel):
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
source_path: str = Field(..., min_length=1)
|
||||
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
||||
batch_size: int = Field(50, ge=1, le=200)
|
||||
concurrency: int = Field(10, ge=1, le=50)
|
||||
|
||||
|
||||
class ServerCheck(BaseModel):
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ── Sync ──
|
||||
|
||||
class SyncFiles(BaseModel):
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
source_path: str = Field(..., min_length=1)
|
||||
target_path: Optional[str] = None
|
||||
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
||||
batch_size: int = Field(50, ge=1, le=200)
|
||||
concurrency: int = Field(10, ge=1, le=50)
|
||||
|
||||
|
||||
class SyncCommands(BaseModel):
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
commands: List[str] = Field(..., min_length=1)
|
||||
timeout: int = Field(60, ge=1, le=600)
|
||||
concurrency: int = Field(10, ge=1, le=50)
|
||||
|
||||
|
||||
class SyncConfig(BaseModel):
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
config: dict = Field(..., min_length=1)
|
||||
concurrency: int = Field(10, ge=1, le=50)
|
||||
|
||||
|
||||
class SyncSftp(BaseModel):
|
||||
server_id: int
|
||||
operation: str = Field("put", pattern="^(put|get)$")
|
||||
local_path: str = Field(..., min_length=1)
|
||||
remote_path: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class SyncBrowse(BaseModel):
|
||||
server_id: int
|
||||
path: str = Field("/", min_length=1)
|
||||
|
||||
|
||||
# ── Script ──
|
||||
|
||||
class ScriptCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
category: str = Field("ops", pattern="^(ops|deploy|check|cleanup)$")
|
||||
content: str = Field(..., min_length=1)
|
||||
description: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
|
||||
|
||||
class ScriptUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
category: Optional[str] = Field(None, pattern="^(ops|deploy|check|cleanup)$")
|
||||
content: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ScriptExecute(BaseModel):
|
||||
script_id: Optional[int] = None
|
||||
command: str = Field(..., min_length=1)
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
timeout: int = Field(60, ge=1, le=600)
|
||||
|
||||
|
||||
# ── DB Credential ──
|
||||
|
||||
class DbCredentialCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
db_type: str = Field("mysql", pattern="^(mysql|postgresql|mariadb|mongodb|redis)$")
|
||||
host: str = Field(..., min_length=1)
|
||||
port: int = Field(3306, ge=1, le=65535)
|
||||
username: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=1)
|
||||
database: Optional[str] = None
|
||||
|
||||
|
||||
# ── Schedule ──
|
||||
|
||||
class ScheduleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
source_path: str = Field(..., min_length=1)
|
||||
server_ids: str = Field(..., min_length=3) # JSON array string
|
||||
cron_expr: str = Field(..., min_length=9, max_length=100)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ScheduleUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
source_path: Optional[str] = None
|
||||
server_ids: Optional[str] = None
|
||||
cron_expr: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
# ── Preset ──
|
||||
|
||||
class PresetCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
encrypted_pw: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ── Platform / Node ──
|
||||
|
||||
class PlatformCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
category: str = Field(..., min_length=1, max_length=50)
|
||||
type: str = Field(..., min_length=1, max_length=50)
|
||||
default_protocols: Optional[list] = None
|
||||
charset: str = "utf-8"
|
||||
|
||||
|
||||
class PlatformUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
category: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
default_protocols: Optional[list] = None
|
||||
charset: Optional[str] = None
|
||||
|
||||
|
||||
class NodeCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class NodeUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
# ── Pagination ──
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
items: list
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
+82
-54
@@ -1,13 +1,19 @@
|
||||
"""Nexus — Scripts API Routes (Script library CRUD + command execution)
|
||||
Presentation layer — receives HTTP requests, delegates to ScriptService.
|
||||
All operations require JWT authentication.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from server.api.dependencies import get_script_service
|
||||
from server.api.dependencies import get_script_service, get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import ScriptCreate, ScriptUpdate, ScriptExecute, DbCredentialCreate
|
||||
from server.application.services.script_service import ScriptService
|
||||
from server.domain.models import Script, DbCredential
|
||||
from server.domain.models import Script, DbCredential, Admin, AuditLog
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
||||
|
||||
@@ -17,6 +23,7 @@ router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
||||
@router.get("/", response_model=list)
|
||||
async def list_scripts(
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
):
|
||||
"""List all scripts, optionally filtered by category"""
|
||||
@@ -27,6 +34,7 @@ async def list_scripts(
|
||||
@router.get("/{id}", response_model=dict)
|
||||
async def get_script(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
):
|
||||
"""Get a single script by ID"""
|
||||
@@ -38,82 +46,90 @@ async def get_script(
|
||||
|
||||
@router.post("/", response_model=dict, status_code=201)
|
||||
async def create_script(
|
||||
payload: dict,
|
||||
payload: ScriptCreate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new script
|
||||
|
||||
Body: {
|
||||
"name": "restart-nginx",
|
||||
"category": "ops",
|
||||
"content": "systemctl restart nginx",
|
||||
"description": "Restart nginx service",
|
||||
"created_by": "admin"
|
||||
}
|
||||
"""
|
||||
script = Script(**payload)
|
||||
"""Create a new script"""
|
||||
script = Script(**payload.model_dump(exclude_none=True))
|
||||
created = await service.create_script(script)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="create_script",
|
||||
target_type="script", target_id=created.id,
|
||||
detail=f"name={created.name}", ip_address="",
|
||||
))
|
||||
|
||||
return _script_to_dict(created)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=dict)
|
||||
async def update_script(
|
||||
id: int,
|
||||
payload: dict,
|
||||
payload: ScriptUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an existing script"""
|
||||
script = await service.get_script(id)
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Script not found")
|
||||
for key, value in payload.items():
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
if hasattr(script, key) and key != "id":
|
||||
setattr(script, key, value)
|
||||
updated = await service.update_script(script)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="update_script",
|
||||
target_type="script", target_id=id,
|
||||
detail=f"name={updated.name}", ip_address="",
|
||||
))
|
||||
|
||||
return _script_to_dict(updated)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=204)
|
||||
async def delete_script(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a script"""
|
||||
script = await service.get_script(id)
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Script not found")
|
||||
result = await service.delete_script(id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Script not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="delete_script",
|
||||
target_type="script", target_id=id,
|
||||
detail=f"name={script.name}", ip_address="",
|
||||
))
|
||||
|
||||
|
||||
# ── Command Execution ──
|
||||
|
||||
@router.post("/exec", response_model=dict)
|
||||
async def execute_command(
|
||||
payload: dict,
|
||||
payload: ScriptExecute,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
):
|
||||
"""Execute a shell command on multiple servers via Agent /api/exec
|
||||
|
||||
Body: {
|
||||
"command": "systemctl restart nginx", // or script content
|
||||
"server_ids": [1, 2, 3],
|
||||
"script_id": null, // Optional: link to saved script
|
||||
"credential_id": null, // Optional: DB credential for $DB_* vars
|
||||
"timeout": 30,
|
||||
"operator": "admin"
|
||||
}
|
||||
"""
|
||||
command = payload.get("command")
|
||||
server_ids = payload.get("server_ids", [])
|
||||
if not command or not server_ids:
|
||||
raise HTTPException(status_code=400, detail="command and server_ids required")
|
||||
|
||||
"""Execute a shell command on multiple servers via Agent /api/exec"""
|
||||
execution = await service.execute_command(
|
||||
command=command,
|
||||
server_ids=server_ids,
|
||||
script_id=payload.get("script_id"),
|
||||
credential_id=payload.get("credential_id"),
|
||||
timeout=payload.get("timeout", 30),
|
||||
operator=payload.get("operator", "admin"),
|
||||
command=payload.command,
|
||||
server_ids=payload.server_ids,
|
||||
script_id=payload.script_id,
|
||||
timeout=payload.timeout,
|
||||
operator=admin.username,
|
||||
)
|
||||
return _execution_to_dict(execution)
|
||||
|
||||
@@ -121,6 +137,7 @@ async def execute_command(
|
||||
@router.get("/executions/{id}", response_model=dict)
|
||||
async def get_execution(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
):
|
||||
"""Get execution result by ID"""
|
||||
@@ -134,6 +151,7 @@ async def get_execution(
|
||||
|
||||
@router.get("/credentials", response_model=list)
|
||||
async def list_credentials(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
):
|
||||
"""List all database credentials"""
|
||||
@@ -143,36 +161,46 @@ async def list_credentials(
|
||||
|
||||
@router.post("/credentials", response_model=dict, status_code=201)
|
||||
async def create_credential(
|
||||
payload: dict,
|
||||
payload: DbCredentialCreate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a database credential (password will be encrypted)
|
||||
|
||||
Body: {
|
||||
"name": "production-mysql",
|
||||
"db_type": "mysql",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"username": "root",
|
||||
"encrypted_password": "actual_password", // Will be encrypted before storage
|
||||
"database": "app_db"
|
||||
}
|
||||
"""
|
||||
credential = DbCredential(**payload)
|
||||
"""Create a database credential (password will be encrypted)"""
|
||||
data = payload.model_dump(exclude_none=True)
|
||||
data["encrypted_password"] = data.pop("password", "")
|
||||
credential = DbCredential(**data)
|
||||
created = await service.create_credential(credential)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="create_credential",
|
||||
target_type="credential", target_id=created.id,
|
||||
detail=f"name={created.name}", ip_address="",
|
||||
))
|
||||
|
||||
return _credential_to_dict(created)
|
||||
|
||||
|
||||
@router.delete("/credentials/{id}", status_code=204)
|
||||
async def delete_credential(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a database credential"""
|
||||
result = await service.delete_credential(id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Credential not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="delete_credential",
|
||||
target_type="credential", target_id=id,
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
|
||||
# ── Helper functions ──
|
||||
|
||||
@@ -214,4 +242,4 @@ def _credential_to_dict(credential: DbCredential) -> dict:
|
||||
"username": credential.username,
|
||||
"database": credential.database,
|
||||
"created_at": str(credential.created_at) if credential.created_at else None,
|
||||
}
|
||||
}
|
||||
|
||||
+141
-35
@@ -2,6 +2,7 @@
|
||||
Presentation layer — receives HTTP requests, delegates to ServerService/SyncService.
|
||||
|
||||
Live server status comes from Redis (real-time), base info from MySQL.
|
||||
All write operations require JWT authentication and produce audit logs.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -11,11 +12,15 @@ from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from server.api.dependencies import get_server_service, get_sync_service, get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import ServerCreate, ServerUpdate, ServerPush, ServerCheck
|
||||
from server.application.services.server_service import ServerService
|
||||
from server.application.services.sync_service import SyncService
|
||||
from server.domain.models import Server
|
||||
from server.domain.models import Server, SyncLog, Admin, AuditLog
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger("nexus.servers")
|
||||
@@ -27,12 +32,15 @@ REDIS_KEY_PREFIX = "heartbeat:"
|
||||
|
||||
# ── CRUD ──
|
||||
|
||||
@router.get("/", response_model=list)
|
||||
@router.get("/", response_model=dict)
|
||||
async def list_servers(
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
per_page: int = Query(50, ge=1, le=200, description="Items per page"),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""List all servers with live status from Redis
|
||||
"""List servers with live status from Redis (paginated)
|
||||
|
||||
Base info (name/IP/category) from MySQL, real-time status from Redis.
|
||||
If Redis heartbeat key exists, override is_online/system_info/last_heartbeat.
|
||||
@@ -40,8 +48,13 @@ async def list_servers(
|
||||
servers = await service.list_servers(category)
|
||||
redis = get_redis()
|
||||
|
||||
total = len(servers)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
page_servers = servers[start:end]
|
||||
|
||||
result = []
|
||||
for server in servers:
|
||||
for server in page_servers:
|
||||
server_data = _server_to_dict(server)
|
||||
|
||||
# Overlay Redis heartbeat data (real-time)
|
||||
@@ -56,18 +69,68 @@ async def list_servers(
|
||||
pass # Keep MySQL value
|
||||
server_data["last_heartbeat"] = heartbeat.get("last_heartbeat", server_data.get("last_heartbeat"))
|
||||
server_data["agent_version"] = heartbeat.get("agent_version", server_data.get("agent_version", ""))
|
||||
server_data["_source"] = "redis" # Frontend knows data is fresh
|
||||
server_data["_source"] = "redis"
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis read failed for server {server.id}: {e}")
|
||||
|
||||
result.append(server_data)
|
||||
|
||||
return result
|
||||
return {
|
||||
"items": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
}
|
||||
|
||||
|
||||
# ── Dashboard Stats (must be before /{id} to avoid path collision) ──
|
||||
|
||||
@router.get("/stats", response_model=dict)
|
||||
async def server_stats(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""Aggregated server stats for dashboard — efficient single query"""
|
||||
servers = await service.list_servers()
|
||||
redis = get_redis()
|
||||
|
||||
total = len(servers)
|
||||
online = 0
|
||||
offline = 0
|
||||
alerts = 0
|
||||
|
||||
for server in servers:
|
||||
try:
|
||||
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{server.id}")
|
||||
is_online = heartbeat.get("is_online") == "True" if heartbeat else server.is_online
|
||||
if await redis.exists(f"alerts:{server.id}"):
|
||||
alerts += 1
|
||||
except Exception:
|
||||
is_online = server.is_online
|
||||
if is_online:
|
||||
online += 1
|
||||
else:
|
||||
offline += 1
|
||||
|
||||
categories = {}
|
||||
for server in servers:
|
||||
cat = server.category or "uncategorized"
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"online": online,
|
||||
"offline": offline,
|
||||
"alerts": alerts,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=dict)
|
||||
async def get_server(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""Get a single server by ID with live status from Redis"""
|
||||
@@ -99,94 +162,137 @@ async def get_server(
|
||||
|
||||
@router.post("/", response_model=dict, status_code=201)
|
||||
async def create_server(
|
||||
server_data: dict,
|
||||
payload: ServerCreate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new server"""
|
||||
server = Server(**server_data)
|
||||
server = Server(**payload.model_dump(exclude_none=True))
|
||||
created = await service.create_server(server)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="create_server",
|
||||
target_type="server",
|
||||
target_id=created.id,
|
||||
detail=f"name={created.name} domain={created.domain}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return _server_to_dict(created)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=dict)
|
||||
async def update_server(
|
||||
id: int,
|
||||
server_data: dict,
|
||||
payload: ServerUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an existing server"""
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
for key, value in server_data.items():
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
if hasattr(server, key) and key != "id":
|
||||
setattr(server, key, value)
|
||||
updated = await service.update_server(server)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="update_server",
|
||||
target_type="server",
|
||||
target_id=id,
|
||||
detail=f"name={updated.name}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return _server_to_dict(updated)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=204)
|
||||
async def delete_server(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a server"""
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
result = await service.delete_server(id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="delete_server",
|
||||
target_type="server",
|
||||
target_id=id,
|
||||
detail=f"name={server.name}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
|
||||
# ── Health Check ──
|
||||
|
||||
@router.post("/check", response_model=dict)
|
||||
async def check_servers(
|
||||
server_ids: List[int],
|
||||
payload: ServerCheck,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""Check health status of specified servers (Agent-based, no SSH)"""
|
||||
return await service.check_all_servers(server_ids)
|
||||
return await service.check_all_servers(payload.server_ids)
|
||||
|
||||
|
||||
# ── Push ──
|
||||
|
||||
@router.post("/push", response_model=dict)
|
||||
async def push_to_servers(
|
||||
payload: dict,
|
||||
payload: ServerPush,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
sync_service: SyncService = Depends(get_sync_service),
|
||||
):
|
||||
"""Push files to multiple servers (batch mode with concurrency control)
|
||||
|
||||
Body: {
|
||||
"server_ids": [1, 2, 3, ...],
|
||||
"source_path": "/path/to/files",
|
||||
"sync_mode": "incremental",
|
||||
"operator": "admin",
|
||||
"batch_size": 50,
|
||||
"concurrency": 10
|
||||
}
|
||||
"""
|
||||
server_ids = payload.get("server_ids", [])
|
||||
source_path = payload.get("source_path")
|
||||
if not server_ids or not source_path:
|
||||
raise HTTPException(status_code=400, detail="server_ids and source_path required")
|
||||
|
||||
"""Push files to multiple servers (batch mode with concurrency control)"""
|
||||
results = await sync_service.batch_push(
|
||||
server_ids=server_ids,
|
||||
source_path=source_path,
|
||||
sync_mode=payload.get("sync_mode", "incremental"),
|
||||
operator=payload.get("operator", "admin"),
|
||||
batch_size=payload.get("batch_size", 50),
|
||||
concurrency=payload.get("concurrency", 10),
|
||||
server_ids=payload.server_ids,
|
||||
source_path=payload.source_path,
|
||||
sync_mode=payload.sync_mode,
|
||||
operator=admin.username,
|
||||
batch_size=payload.batch_size,
|
||||
concurrency=payload.concurrency,
|
||||
)
|
||||
return {sid: _sync_log_to_dict(log) for sid, log in results.items()}
|
||||
|
||||
|
||||
# ── Sync Logs ──
|
||||
|
||||
@router.get("/logs", response_model=list)
|
||||
async def get_all_sync_logs(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get recent sync logs across all servers (for dashboard charts)"""
|
||||
result = await db.execute(
|
||||
select(SyncLog).order_by(SyncLog.started_at.desc()).limit(limit)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
return [_sync_log_to_dict(log) for log in logs]
|
||||
|
||||
|
||||
@router.get("/{id}/logs", response_model=list)
|
||||
async def get_server_logs(
|
||||
id: int,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
sync_service: SyncService = Depends(get_sync_service),
|
||||
):
|
||||
"""Get sync logs for a specific server"""
|
||||
|
||||
+141
-40
@@ -1,50 +1,86 @@
|
||||
"""Nexus — Settings & Schedules API Routes
|
||||
Presentation layer — system settings + push schedules + password presets + audit logs.
|
||||
|
||||
Security: All write operations require JWT authentication.
|
||||
Immutable settings (secret_key, api_key, encryption_key, database_url) cannot be modified via API.
|
||||
Sensitive values are masked in GET responses.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from server.api.dependencies import get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, SettingUpdatePayload
|
||||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
|
||||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog
|
||||
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog, Admin
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# Settings that cannot be modified via API (encryption consistency)
|
||||
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
|
||||
|
||||
# Settings whose values are masked in GET responses
|
||||
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "redis_url"}
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
# ── System Settings ──
|
||||
|
||||
@router.get("/", response_model=list)
|
||||
async def list_settings(db: AsyncSession = Depends(get_db)):
|
||||
"""List all system settings"""
|
||||
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||||
"""List all system settings (sensitive values masked)"""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
settings = await repo.get_all()
|
||||
return [{"key": s.key, "value": s.value, "updated_at": str(s.updated_at)} for s in settings]
|
||||
result = []
|
||||
for s in settings:
|
||||
val = s.value
|
||||
if s.key in SENSITIVE_KEYS and val:
|
||||
val = val[:8] + "..." if len(val) > 8 else "***"
|
||||
result.append({"key": s.key, "value": val, "updated_at": str(s.updated_at)})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=dict)
|
||||
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single setting by key"""
|
||||
async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single setting by key (sensitive values masked)"""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get(key)
|
||||
if value is None:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
if key in SENSITIVE_KEYS and value:
|
||||
value = value[:8] + "..." if len(value) > 8 else "***"
|
||||
return {"key": key, "value": value}
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=dict)
|
||||
async def set_setting(key: str, payload: dict, db: AsyncSession = Depends(get_db)):
|
||||
"""Set a system setting value"""
|
||||
async def set_setting(
|
||||
key: str,
|
||||
payload: SettingUpdatePayload,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set a system setting value (immutable keys are rejected)"""
|
||||
if key in IMMUTABLE_KEYS:
|
||||
raise HTTPException(status_code=403, detail=f"Setting '{key}' is immutable and cannot be modified via API")
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = payload.get("value")
|
||||
if value is None:
|
||||
raise HTTPException(status_code=400, detail="value required")
|
||||
setting = await repo.set(key, str(value))
|
||||
setting = await repo.set(key, str(payload.value))
|
||||
|
||||
# Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="update_setting",
|
||||
target_type="setting",
|
||||
detail=f"key={key}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return {"key": setting.key, "value": setting.value}
|
||||
|
||||
|
||||
@@ -54,7 +90,7 @@ schedule_router = APIRouter(prefix="/api/schedules", tags=["schedules"])
|
||||
|
||||
|
||||
@schedule_router.get("/", response_model=list)
|
||||
async def list_schedules(db: AsyncSession = Depends(get_db)):
|
||||
async def list_schedules(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||||
"""List all push schedules"""
|
||||
repo = PushScheduleRepositoryImpl(db)
|
||||
schedules = await repo.get_all()
|
||||
@@ -62,45 +98,80 @@ async def list_schedules(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@schedule_router.post("/", response_model=dict, status_code=201)
|
||||
async def create_schedule(payload: dict, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new push schedule
|
||||
|
||||
Body: {
|
||||
"name": "Daily web push",
|
||||
"source_path": "/www/wwwroot/site",
|
||||
"server_ids": "[1,2,3]",
|
||||
"cron_expr": "0 2 * * *",
|
||||
"enabled": true
|
||||
}
|
||||
"""
|
||||
async def create_schedule(
|
||||
payload: ScheduleCreate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new push schedule"""
|
||||
repo = PushScheduleRepositoryImpl(db)
|
||||
schedule = PushSchedule(**payload)
|
||||
schedule = PushSchedule(**payload.model_dump())
|
||||
created = await repo.create(schedule)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="create_schedule",
|
||||
target_type="schedule",
|
||||
target_id=created.id,
|
||||
detail=f"name={created.name}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return _schedule_to_dict(created)
|
||||
|
||||
|
||||
@schedule_router.put("/{id}", response_model=dict)
|
||||
async def update_schedule(id: int, payload: dict, db: AsyncSession = Depends(get_db)):
|
||||
async def update_schedule(
|
||||
id: int,
|
||||
payload: ScheduleUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a push schedule"""
|
||||
repo = PushScheduleRepositoryImpl(db)
|
||||
schedule = await repo.get_by_id(id)
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||
for key, value in payload.items():
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
if hasattr(schedule, key) and key != "id":
|
||||
setattr(schedule, key, value)
|
||||
updated = await repo.update(schedule)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="update_schedule",
|
||||
target_type="schedule",
|
||||
target_id=id,
|
||||
detail=f"name={updated.name}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return _schedule_to_dict(updated)
|
||||
|
||||
|
||||
@schedule_router.delete("/{id}", status_code=204)
|
||||
async def delete_schedule(id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_schedule(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a push schedule"""
|
||||
repo = PushScheduleRepositoryImpl(db)
|
||||
result = await repo.delete(id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="delete_schedule",
|
||||
target_type="schedule",
|
||||
target_id=id,
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
|
||||
# ── Password Presets ──
|
||||
|
||||
@@ -108,7 +179,7 @@ preset_router = APIRouter(prefix="/api/presets", tags=["presets"])
|
||||
|
||||
|
||||
@preset_router.get("/", response_model=list)
|
||||
async def list_presets(db: AsyncSession = Depends(get_db)):
|
||||
async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||||
"""List all password presets"""
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
presets = await repo.get_all()
|
||||
@@ -116,27 +187,52 @@ async def list_presets(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@preset_router.post("/", response_model=dict, status_code=201)
|
||||
async def create_preset(payload: dict, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a password preset (password will be encrypted)
|
||||
|
||||
Body: {"name": "root-default", "encrypted_pw": "actual_password"}
|
||||
"""
|
||||
async def create_preset(
|
||||
payload: PresetCreate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a password preset (password will be encrypted)"""
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
payload["encrypted_pw"] = encrypt_value(payload.get("encrypted_pw", ""))
|
||||
preset = PasswordPreset(**payload)
|
||||
encrypted = encrypt_value(payload.encrypted_pw)
|
||||
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
|
||||
created = await repo.create(preset)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="create_preset",
|
||||
target_type="preset",
|
||||
target_id=created.id,
|
||||
detail=f"name={created.name}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return {"id": created.id, "name": created.name}
|
||||
|
||||
|
||||
@preset_router.delete("/{id}", status_code=204)
|
||||
async def delete_preset(id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_preset(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a password preset"""
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
result = await repo.delete(id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="delete_preset",
|
||||
target_type="preset",
|
||||
target_id=id,
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
|
||||
# ── Audit Logs ──
|
||||
|
||||
@@ -147,6 +243,7 @@ audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||||
async def list_audit_logs(
|
||||
action: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List recent audit logs, optionally filtered by action"""
|
||||
@@ -164,7 +261,11 @@ retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
|
||||
|
||||
|
||||
@retry_router.get("/", response_model=list)
|
||||
async def list_retry_jobs(limit: int = 100, db: AsyncSession = Depends(get_db)):
|
||||
async def list_retry_jobs(
|
||||
limit: int = 100,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List pending retry jobs"""
|
||||
repo = PushRetryJobRepositoryImpl(db)
|
||||
jobs = await repo.get_pending(limit)
|
||||
@@ -213,4 +314,4 @@ def _retry_to_dict(job: PushRetryJob) -> dict:
|
||||
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
|
||||
"last_error": job.last_error,
|
||||
"created_at": str(job.created_at) if job.created_at else None,
|
||||
}
|
||||
}
|
||||
|
||||
+74
-37
@@ -8,6 +8,7 @@ S4: POST /api/sync/sftp — SFTP transfer
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from server.api.schemas import SyncFiles, SyncCommands, SyncConfig, SyncSftp, SyncBrowse
|
||||
from server.api.dependencies import get_server_service
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
@@ -33,83 +34,119 @@ async def _get_sync_engine(request: Request) -> SyncEngineV2:
|
||||
|
||||
@router.post("/files")
|
||||
async def sync_files(
|
||||
payload: dict,
|
||||
payload: SyncFiles,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""S1: File sync via rsync over SSH (with parallel execution)"""
|
||||
engine = await _get_sync_engine(request)
|
||||
server_ids = payload.get("server_ids", [])
|
||||
source_path = payload.get("source_path")
|
||||
if not server_ids or not source_path:
|
||||
raise HTTPException(status_code=400, detail="server_ids and source_path required")
|
||||
|
||||
return await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=source_path,
|
||||
target_path=payload.get("target_path"),
|
||||
sync_mode=payload.get("sync_mode", "incremental"),
|
||||
server_ids=payload.server_ids,
|
||||
source_path=payload.source_path,
|
||||
target_path=payload.target_path,
|
||||
sync_mode=payload.sync_mode,
|
||||
operator=admin.username,
|
||||
batch_size=payload.get("batch_size", 50),
|
||||
concurrency=payload.get("concurrency", 10),
|
||||
batch_size=payload.batch_size,
|
||||
concurrency=payload.concurrency,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/commands")
|
||||
async def sync_commands(
|
||||
payload: dict,
|
||||
payload: SyncCommands,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""S2: Execute commands on multiple servers via SSH in parallel"""
|
||||
engine = await _get_sync_engine(request)
|
||||
server_ids = payload.get("server_ids", [])
|
||||
commands = payload.get("commands", [])
|
||||
if not server_ids or not commands:
|
||||
raise HTTPException(status_code=400, detail="server_ids and commands required")
|
||||
|
||||
return await engine.sync_commands(
|
||||
server_ids=server_ids,
|
||||
commands=commands,
|
||||
server_ids=payload.server_ids,
|
||||
commands=payload.commands,
|
||||
operator=admin.username,
|
||||
timeout=payload.get("timeout", 60),
|
||||
concurrency=payload.get("concurrency", 10),
|
||||
timeout=payload.timeout,
|
||||
concurrency=payload.concurrency,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
async def sync_config(
|
||||
payload: dict,
|
||||
payload: SyncConfig,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""S3: Push system configuration changes to multiple servers"""
|
||||
engine = await _get_sync_engine(request)
|
||||
server_ids = payload.get("server_ids", [])
|
||||
config_updates = payload.get("config", {})
|
||||
if not server_ids or not config_updates:
|
||||
raise HTTPException(status_code=400, detail="server_ids and config required")
|
||||
|
||||
return await engine.sync_config(
|
||||
server_ids=server_ids,
|
||||
config_updates=config_updates,
|
||||
server_ids=payload.server_ids,
|
||||
config_updates=payload.config,
|
||||
operator=admin.username,
|
||||
concurrency=payload.get("concurrency", 10),
|
||||
concurrency=payload.concurrency,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sftp")
|
||||
async def sftp_transfer(
|
||||
payload: dict,
|
||||
payload: SyncSftp,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""S4: SFTP file transfer to/from a server"""
|
||||
engine = await _get_sync_engine(request)
|
||||
return await engine.sftp_transfer(
|
||||
server_id=payload["server_id"],
|
||||
operation=payload.get("operation", "put"),
|
||||
local_path=payload["local_path"],
|
||||
remote_path=payload["remote_path"],
|
||||
server_id=payload.server_id,
|
||||
operation=payload.operation,
|
||||
local_path=payload.local_path,
|
||||
remote_path=payload.remote_path,
|
||||
operator=admin.username,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/browse")
|
||||
async def browse_directory(
|
||||
payload: SyncBrowse,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Browse remote directory listing via SSH"""
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
|
||||
session = request.state.db
|
||||
server_id = payload.server_id
|
||||
path = payload.path
|
||||
|
||||
if not server_id:
|
||||
raise HTTPException(status_code=400, detail="server_id required")
|
||||
|
||||
repo = ServerRepositoryImpl(session)
|
||||
server = await repo.get_by_id(server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
# Use ls -la for detailed listing, parse output
|
||||
import shlex
|
||||
result = await exec_ssh_command(server, f"ls -la {shlex.quote(path)}", timeout=10)
|
||||
if result["exit_code"] != 0:
|
||||
return {"path": path, "entries": [], "error": result["stderr"][:500]}
|
||||
|
||||
entries = []
|
||||
for line in result["stdout"].split("\n")[1:]: # Skip "total" line
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split(None, 8)
|
||||
if len(parts) >= 9:
|
||||
perms, _, owner, group, size, *date_parts, name = (
|
||||
parts[0], parts[1], parts[2], parts[3], parts[4],
|
||||
parts[5:-1], parts[-1]
|
||||
)
|
||||
is_dir = parts[0].startswith("d")
|
||||
entries.append({
|
||||
"name": parts[-1],
|
||||
"is_dir": is_dir,
|
||||
"size": parts[4],
|
||||
"perms": parts[0],
|
||||
"owner": parts[2],
|
||||
"modified": " ".join(parts[5:8]),
|
||||
})
|
||||
|
||||
return {"path": path, "entries": entries}
|
||||
@@ -248,22 +248,20 @@ async def alert_ws(
|
||||
async def _verify_ws_token(token: str):
|
||||
"""Verify JWT token for WebSocket connection (same logic as get_current_admin)"""
|
||||
try:
|
||||
from server.application.services.auth_service import AuthService
|
||||
from server.infrastructure.redis.client import get_redis_sync
|
||||
|
||||
# Use get_redis_sync since this might be called during shutdown
|
||||
import jwt as pyjwt
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
from server.infrastructure.database.login_attempt_repo import LoginAttemptRepositoryImpl # noqa
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
# Quick JWT decode without full service
|
||||
import jwt as pyjwt
|
||||
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
admin_id = payload.get("sub")
|
||||
if not admin_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
admin_id = int(admin_id)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
admin_repo = AdminRepositoryImpl(session)
|
||||
admin = await admin_repo.get_by_id(admin_id)
|
||||
|
||||
@@ -148,7 +148,7 @@ class AuthService:
|
||||
return admin
|
||||
|
||||
async def logout(self, admin_id: int) -> dict:
|
||||
"""Invalidate refresh token"""
|
||||
"""Invalidate refresh token by admin ID"""
|
||||
admin = await self.admin_repo.get_by_id(admin_id)
|
||||
if admin:
|
||||
admin.jwt_refresh_token = None
|
||||
@@ -156,6 +156,16 @@ class AuthService:
|
||||
await self.admin_repo.update(admin)
|
||||
return {"success": True, "message": "已登出"}
|
||||
|
||||
async def logout_by_token(self, refresh_token: str) -> dict:
|
||||
"""Invalidate refresh token by token value (used by frontend logout)"""
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
if admin:
|
||||
admin.jwt_refresh_token = None
|
||||
admin.jwt_token_expires = None
|
||||
await self.admin_repo.update(admin)
|
||||
await self._audit("logout", "admin", admin.id, f"Logout: {admin.username}")
|
||||
return {"success": True, "message": "已登出"}
|
||||
|
||||
async def setup_totp(self, admin_id: int) -> dict:
|
||||
"""Generate TOTP secret for an admin user (before enabling)"""
|
||||
import base64
|
||||
|
||||
@@ -161,16 +161,20 @@ class ScriptService:
|
||||
|
||||
async def test_credential_connection(self, credential: DbCredential) -> dict:
|
||||
"""Test database connection using provided credentials (before saving)"""
|
||||
# This runs a simple connectivity test via Agent /api/exec
|
||||
# e.g., mysql -h $host -u $user -p'$pass' -e 'SELECT 1'
|
||||
import shlex
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
|
||||
password = credential.encrypted_password # Not yet encrypted if testing before save
|
||||
if credential.id:
|
||||
password = decrypt_value(credential.encrypted_password)
|
||||
|
||||
test_cmd = f"mysql -h {credential.host} -P {credential.port} -u {credential.username} -p'{password}' -e 'SELECT 1' 2>/dev/null && echo 'OK' || echo 'FAIL'"
|
||||
# Pick any online server to test from (or the Nexus server itself)
|
||||
test_cmd = (
|
||||
f"mysql -h {shlex.quote(credential.host)} "
|
||||
f"-P {credential.port} "
|
||||
f"-u {shlex.quote(credential.username)} "
|
||||
f"-p{shlex.quote(password)} "
|
||||
f"-e 'SELECT 1' 2>/dev/null && echo 'OK' || echo 'FAIL'"
|
||||
)
|
||||
return {"status": "test_command_ready", "command": test_cmd}
|
||||
|
||||
# ── Private helpers ──
|
||||
@@ -204,7 +208,9 @@ class ScriptService:
|
||||
) -> dict:
|
||||
"""Call Agent /api/exec endpoint to run a command on a remote server"""
|
||||
url = f"http://{host}:{port}/api/exec"
|
||||
headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
payload = {"command": command, "timeout": timeout}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout + 5) as client:
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Optional, List, Dict
|
||||
|
||||
from server.domain.models import Server, SyncLog
|
||||
from server.domain.repositories import ServerRepository, SyncLogRepository
|
||||
from server.infrastructure.ssh.pool import SSHConfig, exec_command
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
|
||||
logger = logging.getLogger("nexus.server_service")
|
||||
@@ -48,17 +47,38 @@ class ServerService:
|
||||
await self.server_repo.update_heartbeat(id, is_online, system_info, agent_version)
|
||||
|
||||
async def check_all_servers(self, server_ids: List[int]) -> Dict[int, dict]:
|
||||
"""Check health of multiple servers via Agent API (no SSH)"""
|
||||
"""Check health of multiple servers via Agent API (async concurrent)"""
|
||||
import httpx
|
||||
results = {}
|
||||
# TODO: Use httpx to call each Agent's /health endpoint
|
||||
for id in server_ids:
|
||||
server = await self.server_repo.get_by_id(id)
|
||||
if server and server.is_online:
|
||||
results[id] = {"status": "online", "system_info": json.loads(server.system_info or "{}")}
|
||||
elif server:
|
||||
results[id] = {"status": "offline"}
|
||||
|
||||
async def _check_one(server: Server) -> tuple:
|
||||
url = f"http://{server.domain}:{server.agent_port}/health"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
headers = {}
|
||||
if server.agent_api_key:
|
||||
headers["X-API-Key"] = server.agent_api_key
|
||||
resp = await client.get(url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return server.id, {"status": "online", "system_info": data}
|
||||
return server.id, {"status": "offline", "error": f"HTTP {resp.status_code}"}
|
||||
except Exception as e:
|
||||
return server.id, {"status": "offline", "error": str(e)[:200]}
|
||||
|
||||
servers = []
|
||||
for sid in server_ids:
|
||||
server = await self.server_repo.get_by_id(sid)
|
||||
if server:
|
||||
servers.append(server)
|
||||
else:
|
||||
results[id] = {"status": "not_found"}
|
||||
results[sid] = {"status": "not_found"}
|
||||
|
||||
if servers:
|
||||
check_results = await asyncio.gather(*[_check_one(s) for s in servers])
|
||||
for sid, result in check_results:
|
||||
results[sid] = result
|
||||
|
||||
return results
|
||||
|
||||
async def push_to_servers(
|
||||
@@ -67,29 +87,29 @@ class ServerService:
|
||||
source_path: str,
|
||||
sync_mode: str = "incremental",
|
||||
operator: str = "admin",
|
||||
batch_size: int = 50,
|
||||
concurrency: int = 10,
|
||||
) -> Dict[int, SyncLog]:
|
||||
"""Push files to multiple servers in batches"""
|
||||
results = {}
|
||||
servers = []
|
||||
for id in server_ids:
|
||||
server = await self.server_repo.get_by_id(id)
|
||||
if server:
|
||||
servers.append(server)
|
||||
"""Push files to multiple servers — delegates to SyncService.batch_push.
|
||||
|
||||
# TODO: Implement batch push with asyncio.Semaphore(10)
|
||||
# batch_size=50, interval=30s
|
||||
for server in servers:
|
||||
target_path = server.target_path or f"/www/wwwroot/"
|
||||
log = SyncLog(
|
||||
server_id=server.id,
|
||||
source_path=source_path,
|
||||
target_path=target_path,
|
||||
trigger_type="batch",
|
||||
operator=operator,
|
||||
status="pending",
|
||||
sync_mode=sync_mode,
|
||||
)
|
||||
log = await self.sync_log_repo.create(log)
|
||||
results[server.id] = log
|
||||
Note: API routes should use SyncService directly via Depends(get_sync_service).
|
||||
This method exists for internal callers (e.g. schedule_runner) that already
|
||||
have a ServerService instance.
|
||||
"""
|
||||
from server.application.services.sync_service import SyncService
|
||||
|
||||
return results
|
||||
# Build a SyncService sharing our existing repos + the extra ones it needs
|
||||
sync_service = SyncService(
|
||||
server_repo=self.server_repo,
|
||||
sync_log_repo=self.sync_log_repo,
|
||||
audit_repo=None, # audit handled by caller if needed
|
||||
retry_repo=None, # retry handled by caller if needed
|
||||
)
|
||||
return await sync_service.batch_push(
|
||||
server_ids=server_ids,
|
||||
source_path=source_path,
|
||||
sync_mode=sync_mode,
|
||||
operator=operator,
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
)
|
||||
@@ -55,6 +55,7 @@ class SyncEngineV2:
|
||||
operator: str = "admin",
|
||||
batch_size: int = 50,
|
||||
concurrency: int = 10,
|
||||
trigger_type: str = "manual",
|
||||
) -> dict:
|
||||
"""S1: File sync using rsync over SSH"""
|
||||
concurrency = min(concurrency, MAX_CONCURRENT)
|
||||
@@ -80,7 +81,7 @@ class SyncEngineV2:
|
||||
server_id=server.id,
|
||||
source_path=source_path,
|
||||
target_path=target_path or server.target_path or "/tmp/sync",
|
||||
trigger_type="manual",
|
||||
trigger_type=trigger_type,
|
||||
operator=operator,
|
||||
status="running",
|
||||
sync_mode=sync_mode,
|
||||
@@ -89,7 +90,8 @@ class SyncEngineV2:
|
||||
sync_log = await self.sync_log_repo.create(sync_log)
|
||||
|
||||
# Execute rsync
|
||||
rsync_cmd = f"rsync -az --delete {source_path}/ {target_path or server.target_path or '/tmp/sync'}/"
|
||||
import shlex
|
||||
rsync_cmd = f"rsync -az --delete {shlex.quote(source_path)}/ {shlex.quote(target_path or server.target_path or '/tmp/sync')}/"
|
||||
result = await exec_ssh_command(server, rsync_cmd, timeout=300)
|
||||
|
||||
# Update sync log
|
||||
@@ -201,12 +203,17 @@ class SyncEngineV2:
|
||||
Config updates are applied as shell commands:
|
||||
- `sysctl` for kernel params
|
||||
- `echo` for /etc/sysctl.conf entries
|
||||
- Custom script for other config types
|
||||
"""
|
||||
import re, shlex
|
||||
commands = []
|
||||
for key, value in config_updates.items():
|
||||
commands.append(f"sysctl -w {key}={value}")
|
||||
commands.append(f"echo '{key}={value}' >> /etc/sysctl.d/99-nexus.conf")
|
||||
# Sanitize: only allow alphanumeric/dot/underscore/dash keys
|
||||
if not re.match(r'^[a-zA-Z0-9._-]+$', str(key)):
|
||||
logger.warning(f"Skipping invalid config key: {key!r}")
|
||||
continue
|
||||
safe_value = shlex.quote(str(value))
|
||||
commands.append(f"sysctl -w {key}={safe_value}")
|
||||
commands.append(f"echo {key}={safe_value} >> /etc/sysctl.d/99-nexus.conf")
|
||||
|
||||
return await self.sync_commands(
|
||||
server_ids=server_ids,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Nexus — Sync Service (Batch push engine with rsync + Agent coordination)
|
||||
Application layer — orchestrates Server Repository + SSH pool + Redis progress + WebSocket broadcast.
|
||||
Application layer — orchestrates Server Repository + asyncssh pool + Redis progress + WebSocket broadcast.
|
||||
|
||||
W3: Migrated from paramiko pool to asyncssh pool (consistent with sync_engine_v2.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import os
|
||||
import shlex
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from server.domain.models import Server, SyncLog, AuditLog
|
||||
@@ -14,7 +16,7 @@ from server.domain.repositories import (
|
||||
ServerRepository, SyncLogRepository, AuditLogRepository,
|
||||
PushRetryJobRepository,
|
||||
)
|
||||
from server.infrastructure.ssh.pool import SSHConfig, exec_command
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
|
||||
logger = logging.getLogger("nexus.sync_service")
|
||||
@@ -146,8 +148,11 @@ class SyncService:
|
||||
async def _push_single(
|
||||
self, server: Server, source_path: str, sync_mode: str, operator: str,
|
||||
) -> SyncLog:
|
||||
"""Push files to a single server via rsync-over-SSH"""
|
||||
target_path = server.target_path or "/www/wwwroot/"
|
||||
"""Push files to a single server via rsync-over-SSH (asyncssh pool)
|
||||
|
||||
W3: Uses asyncssh pool instead of paramiko. rsync command built with shlex.quote().
|
||||
"""
|
||||
target_path = server.target_path or "/tmp/nexus-sync"
|
||||
|
||||
# Build rsync flags based on sync_mode
|
||||
rsync_flags = self._rsync_flags(sync_mode)
|
||||
@@ -164,46 +169,21 @@ class SyncService:
|
||||
)
|
||||
log = await self.sync_log_repo.create(log)
|
||||
|
||||
start_time = __import__("datetime").datetime.now(timezone.utc)
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
# Build SSH config from server model
|
||||
ssh_config = SSHConfig.from_server(server)
|
||||
# Build rsync command with safe quoting (W3: shlex.quote prevents injection)
|
||||
rsync_cmd = (
|
||||
f"rsync {rsync_flags} "
|
||||
f"-e 'ssh -p {server.port} -o StrictHostKeyChecking=no' "
|
||||
f"{shlex.quote(source_path)}/ "
|
||||
f"{shlex.quote(server.username or 'root')}@{shlex.quote(server.domain)}:{shlex.quote(target_path)}/"
|
||||
)
|
||||
|
||||
# Build rsync command
|
||||
# For SSH Key: use in-memory key written to temp file
|
||||
key_file = None
|
||||
ssh_opts = "-o StrictHostKeyChecking=no"
|
||||
# Execute rsync via asyncssh pool
|
||||
result = await exec_ssh_command(server, rsync_cmd, timeout=DEFAULT_TIMEOUT)
|
||||
|
||||
if server.ssh_key_configured and server.ssh_key_private:
|
||||
# Write decrypted key to temp file (600 permission)
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
key_content = decrypt_value(server.ssh_key_private)
|
||||
key_file = tempfile.NamedTemporaryFile(mode='w', suffix='_key', delete=False)
|
||||
key_file.write(key_content)
|
||||
key_file.close()
|
||||
os.chmod(key_file.name, 0o600)
|
||||
ssh_opts += f" -i {key_file.name}"
|
||||
elif server.auth_method == "password" and server.password:
|
||||
# Use sshpass with environment variable (not -p flag)
|
||||
# sshpass -e SSHPASS=<password> rsync -e "sshpass -e ssh ..."
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
password = decrypt_value(server.password)
|
||||
ssh_opts = f"sshpass -e ssh {ssh_opts}"
|
||||
|
||||
rsync_cmd = f"rsync {rsync_flags} -e 'ssh -p {server.port} {ssh_opts}' {source_path} {server.username}@{server.domain}:{target_path}"
|
||||
|
||||
# Execute rsync via SSH
|
||||
result = await exec_command(ssh_config, rsync_cmd, timeout=DEFAULT_TIMEOUT)
|
||||
|
||||
# Clean up temp key file
|
||||
if key_file:
|
||||
try:
|
||||
os.unlink(key_file.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
duration = int((__import__("datetime").datetime.now(timezone.utc) - start_time).total_seconds())
|
||||
duration = int((datetime.now(timezone.utc) - start_time).total_seconds())
|
||||
|
||||
# Parse rsync output for file statistics
|
||||
stdout = result.get("stdout", "")
|
||||
@@ -215,14 +195,14 @@ class SyncService:
|
||||
files_transferred=files_transferred,
|
||||
duration_seconds=duration,
|
||||
diff_summary=stdout[:2000],
|
||||
finished_at=__import__("datetime").datetime.now(timezone.utc),
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
)
|
||||
else:
|
||||
log = await self.sync_log_repo.update_status(
|
||||
log.id, "failed",
|
||||
error_message=result.get("stderr", "Unknown error")[:2000],
|
||||
duration_seconds=duration,
|
||||
finished_at=__import__("datetime").datetime.now(timezone.utc),
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -230,7 +210,7 @@ class SyncService:
|
||||
log = await self.sync_log_repo.update_status(
|
||||
log.id, "failed",
|
||||
error_message=str(e)[:2000],
|
||||
finished_at=__import__("datetime").datetime.now(timezone.utc),
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
return log
|
||||
@@ -240,15 +220,17 @@ class SyncService:
|
||||
|
||||
async def _add_retry(self, server: Server, source_path: str, operator: str):
|
||||
"""Add failed push to retry queue"""
|
||||
if not self.retry_repo:
|
||||
return
|
||||
from server.domain.models import PushRetryJob
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
job = PushRetryJob(
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
operator=operator,
|
||||
source_path=source_path,
|
||||
target_path=server.target_path or "/www/wwwroot/",
|
||||
target_path=server.target_path or "/tmp/nexus-sync",
|
||||
status="pending",
|
||||
next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
)
|
||||
@@ -269,17 +251,16 @@ class SyncService:
|
||||
|
||||
def _parse_rsync_transferred(self, stdout: str) -> int:
|
||||
"""Parse rsync output to count transferred files"""
|
||||
# rsync -v output contains lines like: "sent X bytes received Y bytes Z bytes/sec"
|
||||
# and a summary line with file counts
|
||||
for line in stdout.split("\n"):
|
||||
if "Number of regular files transferred:" in line:
|
||||
try:
|
||||
return int(line.split(":")[1].strip())
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
# Fallback: count non-summary lines
|
||||
return 0
|
||||
|
||||
async def _audit(self, action: str, target_type: str, target_id: int, detail: str):
|
||||
if not self.audit_repo:
|
||||
return
|
||||
log = AuditLog(action=action, target_type=target_type, target_id=target_id, detail=detail)
|
||||
await self.audit_repo.create(log)
|
||||
await self.audit_repo.create(log)
|
||||
|
||||
@@ -53,7 +53,7 @@ async def heartbeat_flush_loop():
|
||||
continue
|
||||
|
||||
if not keys:
|
||||
logger.debug("No heartbeat keys in Redis — skip flush")
|
||||
logger.info("No heartbeat keys in Redis — no agents reporting yet")
|
||||
continue
|
||||
|
||||
flushed = 0
|
||||
@@ -90,7 +90,8 @@ async def heartbeat_flush_loop():
|
||||
)
|
||||
flushed += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to flush server {key}: {e}")
|
||||
logger.warning(f"Failed to flush server {key}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Nexus — Retry Runner Background Task
|
||||
Every 5 minutes: check pending PushRetryJobs, retry failed pushes with exponential backoff.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.domain.models import PushRetryJob
|
||||
|
||||
logger = logging.getLogger("nexus.retry_runner")
|
||||
|
||||
RETRY_CHECK_INTERVAL = 300 # 5 minutes
|
||||
RETRY_BACKOFF_BASE = 60 # seconds — doubles each retry
|
||||
|
||||
|
||||
async def retry_runner_loop():
|
||||
"""Background task: every 5min, retry pending retry jobs that are due."""
|
||||
logger.info("Retry runner loop started (interval: 5min)")
|
||||
while True:
|
||||
await asyncio.sleep(RETRY_CHECK_INTERVAL)
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
from sqlalchemy import select
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
result = await session.execute(
|
||||
select(PushRetryJob).where(
|
||||
PushRetryJob.status == "pending",
|
||||
PushRetryJob.retry_count < PushRetryJob.max_retries,
|
||||
PushRetryJob.next_retry_at <= now,
|
||||
)
|
||||
)
|
||||
jobs = result.scalars().all()
|
||||
|
||||
if not jobs:
|
||||
continue
|
||||
|
||||
retried = 0
|
||||
for job in jobs:
|
||||
try:
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
|
||||
sync_result = await engine.sync_files(
|
||||
server_ids=[job.server_id],
|
||||
source_path=job.source_path,
|
||||
target_path=job.target_path,
|
||||
trigger_type="retry",
|
||||
operator=job.operator or "retry_runner",
|
||||
)
|
||||
|
||||
job.retry_count += 1
|
||||
|
||||
if sync_result["failed"] == 0:
|
||||
job.status = "completed"
|
||||
logger.info(f"Retry job {job.id}: success after {job.retry_count} attempts")
|
||||
else:
|
||||
# Still failing — schedule next retry with backoff
|
||||
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
|
||||
job.next_retry_at = now + timedelta(seconds=backoff)
|
||||
job.last_error = f"Retry {job.retry_count} failed"
|
||||
logger.warning(
|
||||
f"Retry job {job.id}: attempt {job.retry_count} failed, "
|
||||
f"next retry in {backoff}s"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
retried += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Retry job {job.id} execution error: {e}")
|
||||
job.retry_count += 1
|
||||
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
|
||||
job.next_retry_at = now + timedelta(seconds=backoff)
|
||||
job.last_error = str(e)[:500]
|
||||
await session.commit()
|
||||
|
||||
if retried:
|
||||
logger.info(f"Retry runner: {retried} jobs processed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Retry runner check failed: {e}")
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Nexus — Schedule Runner Background Task
|
||||
Every 60 seconds: check PushSchedule cron expressions, trigger due schedules.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.redis.client import get_redis_sync
|
||||
from server.domain.models import PushSchedule, SyncLog, AuditLog
|
||||
|
||||
logger = logging.getLogger("nexus.schedule_runner")
|
||||
|
||||
SCHEDULE_CHECK_INTERVAL = 60 # seconds
|
||||
|
||||
|
||||
def _cron_match(cron_expr: str, now: datetime) -> bool:
|
||||
"""Check if current time matches a 5-field cron expression (min hour dom month dow).
|
||||
|
||||
Supports: * (any), */N (step), specific values, comma-separated lists.
|
||||
"""
|
||||
parts = cron_expr.strip().split()
|
||||
if len(parts) != 5:
|
||||
return False
|
||||
|
||||
fields = [
|
||||
(now.minute, range(0, 60)),
|
||||
(now.hour, range(0, 24)),
|
||||
(now.day, range(1, 32)),
|
||||
(now.month, range(1, 13)),
|
||||
((now.weekday() + 1) % 7, range(0, 7)), # cron: 0=Sunday, Python weekday(): 0=Monday
|
||||
]
|
||||
|
||||
for (current_val, _), field_expr in zip(fields, parts):
|
||||
if not _field_match(field_expr, current_val):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _field_match(expr: str, value: int) -> bool:
|
||||
"""Match a single cron field expression against a value."""
|
||||
for part in expr.split(","):
|
||||
if part == "*":
|
||||
return True
|
||||
if part.startswith("*/"):
|
||||
step = int(part[2:])
|
||||
return value % step == 0
|
||||
if "-" in part:
|
||||
low, high = part.split("-", 1)
|
||||
if int(low) <= value <= int(high):
|
||||
return True
|
||||
else:
|
||||
# cron: 7 also means Sunday (=0)
|
||||
if value == int(part) or (int(part) == 7 and value == 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def schedule_runner_loop():
|
||||
"""Background task: check schedules every 60s, trigger due cron jobs."""
|
||||
logger.info("Schedule runner loop started (interval: 60s)")
|
||||
while True:
|
||||
await asyncio.sleep(SCHEDULE_CHECK_INTERVAL)
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(PushSchedule).where(PushSchedule.enabled == True)
|
||||
)
|
||||
schedules = result.scalars().all()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
triggered = 0
|
||||
|
||||
for schedule in schedules:
|
||||
try:
|
||||
if not _cron_match(schedule.cron_expr, now):
|
||||
continue
|
||||
|
||||
# Don't re-trigger if already ran this minute
|
||||
if schedule.last_run_at:
|
||||
seconds_since = (now - schedule.last_run_at).total_seconds()
|
||||
if seconds_since < SCHEDULE_CHECK_INTERVAL:
|
||||
continue
|
||||
|
||||
# Parse server_ids from JSON
|
||||
try:
|
||||
server_ids = json.loads(schedule.server_ids)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.error(f"Schedule {schedule.id}: invalid server_ids JSON")
|
||||
continue
|
||||
|
||||
if not server_ids:
|
||||
continue
|
||||
|
||||
# Execute sync via SyncEngineV2
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
|
||||
result = await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=schedule.source_path,
|
||||
trigger_type="schedule",
|
||||
operator=f"schedule:{schedule.name}",
|
||||
)
|
||||
|
||||
# Update last_run_at
|
||||
schedule.last_run_at = now
|
||||
await session.commit()
|
||||
|
||||
triggered += 1
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' triggered: "
|
||||
f"{result['completed']}/{result['total']} succeeded"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schedule {schedule.id} execution failed: {e}")
|
||||
|
||||
if triggered:
|
||||
logger.info(f"Schedule runner: {triggered} schedules triggered")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schedule runner check failed: {e}")
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Nexus — Python Self-Monitor Background Task
|
||||
Every 30 seconds: check Redis, MySQL, WebSocket connections.
|
||||
If critical service fails → send Telegram system alert.
|
||||
If service recovers → send Telegram recovery notification.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -9,7 +10,7 @@ import logging
|
||||
from sqlalchemy import text
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.redis.client import get_redis, get_redis_sync
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.infrastructure.telegram import send_telegram_system_alert
|
||||
from server.api.websocket import manager as ws_manager
|
||||
from server.config import settings
|
||||
@@ -18,38 +19,72 @@ logger = logging.getLogger("nexus.self_monitor")
|
||||
|
||||
MONITOR_INTERVAL = 30 # seconds
|
||||
|
||||
# Track previous state for recovery detection
|
||||
_prev_redis_ok = True
|
||||
_prev_mysql_ok = True
|
||||
|
||||
|
||||
async def self_monitor_loop():
|
||||
"""Background task: every 30s, self-check critical services.
|
||||
|
||||
Checks:
|
||||
1. Redis connection — if lost, send Telegram warning
|
||||
2. MySQL connection — if lost, send Telegram warning
|
||||
1. Redis connection — if lost, send Telegram warning; if recovered, send recovery
|
||||
2. MySQL connection — if lost, send Telegram warning; if recovered, send recovery
|
||||
3. WebSocket client count (via ConnectionManager)
|
||||
"""
|
||||
global _prev_redis_ok, _prev_mysql_ok
|
||||
|
||||
logger.info("Self-monitor loop started (interval: 30s)")
|
||||
while True:
|
||||
await asyncio.sleep(MONITOR_INTERVAL)
|
||||
|
||||
# ── Redis check ──
|
||||
redis_ok = True
|
||||
try:
|
||||
redis = get_redis()
|
||||
await redis.ping()
|
||||
redis_status = "ok"
|
||||
except Exception as e:
|
||||
redis_status = "error"
|
||||
if settings.REDIS_URL:
|
||||
await send_telegram_system_alert(f"⚠️ Redis连接丢失,心跳数据可能中断")
|
||||
redis_ok = False
|
||||
try:
|
||||
if settings.REDIS_URL:
|
||||
await send_telegram_system_alert(f"⚠️ Redis连接丢失,心跳数据可能中断")
|
||||
except Exception:
|
||||
logger.error("Failed to send Redis alert via Telegram")
|
||||
|
||||
# Redis recovery notification
|
||||
if redis_ok and not _prev_redis_ok:
|
||||
try:
|
||||
if settings.REDIS_URL:
|
||||
await send_telegram_system_alert(f"🟢 Redis连接已恢复正常")
|
||||
except Exception:
|
||||
logger.error("Failed to send Redis recovery via Telegram")
|
||||
logger.info("Redis connection recovered")
|
||||
_prev_redis_ok = redis_ok
|
||||
|
||||
# ── MySQL check ──
|
||||
mysql_ok = True
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
except Exception as e:
|
||||
await send_telegram_system_alert(f"🔴 MySQL连接异常: {str(e)[:100]}")
|
||||
mysql_ok = False
|
||||
try:
|
||||
await send_telegram_system_alert(f"🔴 MySQL连接异常: {str(e)[:100]}")
|
||||
except Exception:
|
||||
logger.error("Failed to send MySQL alert via Telegram")
|
||||
|
||||
# MySQL recovery notification
|
||||
if mysql_ok and not _prev_mysql_ok:
|
||||
try:
|
||||
await send_telegram_system_alert(f"🟢 MySQL连接已恢复正常")
|
||||
except Exception:
|
||||
logger.error("Failed to send MySQL recovery via Telegram")
|
||||
logger.info("MySQL connection recovered")
|
||||
_prev_mysql_ok = mysql_ok
|
||||
|
||||
# ── Log status ──
|
||||
logger.debug(
|
||||
f"Self-monitor: redis={redis_status}, "
|
||||
f"Self-monitor: redis={'ok' if redis_ok else 'error'}, "
|
||||
f"mysql={'ok' if mysql_ok else 'error'}, "
|
||||
f"ws_clients={ws_manager.client_count}"
|
||||
)
|
||||
)
|
||||
|
||||
+11
-3
@@ -32,10 +32,18 @@ class Settings(BaseSettings):
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8600
|
||||
|
||||
# Deployment
|
||||
DEPLOY_PATH: str = "/opt/nexus" # Base directory on the server
|
||||
CORS_ORIGINS: str = "" # Comma-separated: "https://example.com,http://localhost:8600"
|
||||
|
||||
# Database (immutable — must be set in .env)
|
||||
DATABASE_URL: str = "mysql+pymysql://root:password@127.0.0.1:3306/nexus"
|
||||
DB_POOL_SIZE: Optional[int] = 100
|
||||
DB_MAX_OVERFLOW: Optional[int] = 100
|
||||
DATABASE_URL: str = "mysql+aiomysql://root:password@127.0.0.1:3306/nexus"
|
||||
# Pool params: install.php auto-calculates from MySQL max_connections
|
||||
# Formula: pool_size = max(20, maxConn * 0.4), overflow = max(20, maxConn * 0.3)
|
||||
# MySQL max_connections=400 → pool=160, overflow=120, max=280
|
||||
# These defaults match install.php; override via .env or MySQL settings table
|
||||
DB_POOL_SIZE: Optional[int] = 160
|
||||
DB_MAX_OVERFLOW: Optional[int] = 120
|
||||
|
||||
# Security (immutable — changing breaks encryption/auth)
|
||||
SECRET_KEY: str = "" # Must be set in .env; empty = startup error
|
||||
|
||||
@@ -13,8 +13,6 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -12,10 +12,4 @@ def __getattr__(name):
|
||||
if name == "get_redis":
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
return get_redis
|
||||
if name == "SSHConfig":
|
||||
from server.infrastructure.ssh.pool import SSHConfig
|
||||
return SSHConfig
|
||||
if name == "exec_command":
|
||||
from server.infrastructure.ssh.pool import exec_command
|
||||
return exec_command
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -4,11 +4,14 @@ Shared crypto module — used by SSH pool, DB credentials, password presets.
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from server.config import settings
|
||||
|
||||
logger = logging.getLogger("nexus.crypto")
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = settings.ENCRYPTION_KEY
|
||||
@@ -61,10 +64,12 @@ def decrypt_value(ciphertext: str) -> str:
|
||||
if 1 <= pad_len <= 16:
|
||||
plain = plain[:-pad_len]
|
||||
return plain.decode()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning(f"AES decryption failed (key mismatch?): {e}")
|
||||
return ciphertext
|
||||
# Fernet format
|
||||
try:
|
||||
return get_fernet().decrypt(ciphertext.encode()).decode()
|
||||
except Exception:
|
||||
return ciphertext # Compatible with pre-encryption data
|
||||
except Exception as e:
|
||||
logger.warning(f"Fernet decryption failed (key mismatch?): {e}")
|
||||
return ciphertext
|
||||
@@ -16,8 +16,8 @@ def _ensure_engine():
|
||||
if _engine is None:
|
||||
_engine = create_async_engine(
|
||||
settings.DATABASE_URL.replace("mysql+pymysql", "mysql+aiomysql"),
|
||||
pool_size=max(5, int(settings.DB_POOL_SIZE or 100)),
|
||||
max_overflow=max(5, int(settings.DB_MAX_OVERFLOW or 100)),
|
||||
pool_size=max(5, int(settings.DB_POOL_SIZE or 160)),
|
||||
max_overflow=max(5, int(settings.DB_MAX_OVERFLOW or 120)),
|
||||
pool_recycle=300,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
@@ -87,6 +87,7 @@ class AsyncSSHPool:
|
||||
|
||||
If an idle connection exists for this server, reuse it.
|
||||
Otherwise, create a new connection.
|
||||
SSH handshake is done outside the lock to avoid blocking other pool operations.
|
||||
"""
|
||||
async with self._lock:
|
||||
# Check for existing idle connection
|
||||
@@ -97,12 +98,26 @@ class AsyncSSHPool:
|
||||
logger.debug(f"Reused SSH connection: server={server.id}, refs={pooled.ref_count}")
|
||||
return pooled.conn
|
||||
|
||||
# Create new connection
|
||||
# Need to create — evict if at capacity, then release lock for handshake
|
||||
if len(self._pool) >= self.MAX_CONNECTIONS:
|
||||
# Evict the oldest idle connection
|
||||
await self._evict_one()
|
||||
|
||||
conn = await self._create_connection(server)
|
||||
# Create connection outside lock (handshake can take seconds)
|
||||
conn = await self._create_connection(server)
|
||||
|
||||
async with self._lock:
|
||||
# Re-check: another coroutine may have created one while we were unlocked
|
||||
pooled = self._pool.get(server.id)
|
||||
if pooled and not pooled.conn.is_closed():
|
||||
# Close our new connection, reuse the existing one
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
pooled.ref_count += 1
|
||||
pooled.last_used = time.monotonic()
|
||||
return pooled.conn
|
||||
|
||||
pooled = PooledConnection(
|
||||
conn=conn,
|
||||
ref_count=1,
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Nexus — SSH Connection Pool (Unified, replacing 4+ duplicated implementations)
|
||||
|
||||
Provides:
|
||||
- SSHConfig dataclass for connection parameters
|
||||
- ssh_connection() async context manager
|
||||
- create_ssh_client() factory with SSH Key support
|
||||
- Batch operations via async pool
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import paramiko
|
||||
from server.domain.models import Server
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHConfig:
|
||||
"""SSH connection configuration"""
|
||||
host: str
|
||||
port: int = 22
|
||||
username: str = "root"
|
||||
auth_method: str = "key" # key/password
|
||||
password: Optional[str] = None
|
||||
key_path: Optional[str] = None
|
||||
key_content: Optional[str] = None # For in-memory key (from DB encrypted field)
|
||||
strict_host_checking: str = "true"
|
||||
|
||||
@classmethod
|
||||
def from_server(cls, server: Server) -> "SSHConfig":
|
||||
"""Build SSHConfig from Server model (with decrypted password)"""
|
||||
return cls(
|
||||
host=server.domain,
|
||||
port=server.port,
|
||||
username=server.username,
|
||||
auth_method=server.auth_method,
|
||||
password=server.decrypted_password if server.auth_method == "password" else None,
|
||||
key_path=server.ssh_key_path,
|
||||
key_content=decrypt_value(server.ssh_key_private or "") if server.ssh_key_configured else None,
|
||||
)
|
||||
|
||||
|
||||
def create_ssh_client(config: SSHConfig) -> paramiko.SSHClient:
|
||||
"""Create SSH client with key or password authentication"""
|
||||
client = paramiko.SSHClient()
|
||||
|
||||
if config.strict_host_checking == "true":
|
||||
client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||
else:
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
if config.auth_method == "key" and config.key_content:
|
||||
# Use in-memory key from encrypted DB field
|
||||
import io
|
||||
key = paramiko.Ed25519Key.from_private_key(io.StringIO(config.key_content))
|
||||
client.connect(config.host, port=config.port, username=config.username, pkey=key)
|
||||
elif config.auth_method == "key" and config.key_path:
|
||||
client.connect(config.host, port=config.port, username=config.username, key_filename=config.key_path)
|
||||
elif config.auth_method == "password" and config.password:
|
||||
client.connect(config.host, port=config.port, username=config.username, password=config.password)
|
||||
else:
|
||||
raise ValueError(f"No valid SSH credentials for {config.host}")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def ssh_connection(config: SSHConfig):
|
||||
"""Async context manager for SSH connections (creates and cleans up)"""
|
||||
client = await asyncio.to_thread(create_ssh_client, config)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
async def exec_command(config: SSHConfig, command: str, timeout: int = 30) -> dict:
|
||||
"""Execute a command via SSH and return structured result"""
|
||||
with await ssh_connection(config) as client:
|
||||
stdin, stdout, stderr = await asyncio.to_thread(
|
||||
client.exec_command, command, timeout=timeout
|
||||
)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out = await asyncio.to_thread(stdout.read)
|
||||
err = await asyncio.to_thread(stderr.read)
|
||||
|
||||
return {
|
||||
"status": "success" if exit_code == 0 else "failed",
|
||||
"stdout": out.decode()[:10000],
|
||||
"stderr": err.decode()[:10000],
|
||||
"exit_code": exit_code,
|
||||
}
|
||||
@@ -13,6 +13,24 @@ logger = logging.getLogger("nexus.telegram")
|
||||
# Bot API base URL
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org"
|
||||
|
||||
# Reusable httpx client — avoids TCP connection churn during alert storms
|
||||
_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def _get_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None or _http_client.is_closed:
|
||||
_http_client = httpx.AsyncClient(timeout=10.0)
|
||||
return _http_client
|
||||
|
||||
|
||||
async def close_client():
|
||||
"""Close the shared httpx client — call during app shutdown"""
|
||||
global _http_client
|
||||
if _http_client and not _http_client.is_closed:
|
||||
await _http_client.aclose()
|
||||
_http_client = None
|
||||
|
||||
|
||||
async def send_telegram(message: str) -> bool:
|
||||
"""Send a Telegram message via Bot API.
|
||||
@@ -24,21 +42,21 @@ async def send_telegram(message: str) -> bool:
|
||||
return False # Not configured — silent skip
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TELEGRAM_API_BASE}/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": settings.TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.info(f"Telegram sent: {message[:50]}...")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Telegram API error: {resp.status_code} {resp.text[:100]}")
|
||||
return False
|
||||
client = await _get_client()
|
||||
resp = await client.post(
|
||||
f"{TELEGRAM_API_BASE}/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": settings.TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.info(f"Telegram sent: {message[:50]}...")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Telegram API error: {resp.status_code} {resp.text[:100]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram send failed: {e}")
|
||||
return False
|
||||
|
||||
+84
-14
@@ -1,7 +1,7 @@
|
||||
"""Nexus — FastAPI Application Entry Point
|
||||
Clean Architecture: Presentation Layer (API routes + middleware + lifespan)
|
||||
|
||||
Lifespan startup:
|
||||
Lifespan startup (when .env exists — normal mode):
|
||||
1. Verify SECRET_KEY is set (fatal if missing)
|
||||
2. init_db() — create tables
|
||||
3. init_redis() — ADR-009 mandatory validation (exit if unavailable)
|
||||
@@ -9,12 +9,19 @@ Lifespan startup:
|
||||
5. Start Redis Pub/Sub subscriber (ADR-010)
|
||||
6. Launch background tasks (heartbeat flush, self-monitor)
|
||||
|
||||
Lifespan startup (no .env — install mode):
|
||||
- Only the /api/install/ routes are functional
|
||||
- All other API routes return 503 "System not configured"
|
||||
- User completes the install wizard at /app/install.html
|
||||
|
||||
D2: install.php → install.html migration (conditional lifespan)
|
||||
D7: DB session leak fix — middleware auto-manages session lifecycle per request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -23,7 +30,14 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from server.config import settings
|
||||
from server.infrastructure.database.session import init_db, AsyncSessionLocal
|
||||
|
||||
# API routes
|
||||
# Detect install mode: no .env = first-time setup
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
INSTALL_MODE = not (ROOT_DIR / ".env").exists()
|
||||
|
||||
# Install wizard (no JWT required — runs before system is configured)
|
||||
from server.api.install import router as install_router
|
||||
|
||||
# API routes (require JWT + full app init)
|
||||
from server.api.servers import router as servers_router
|
||||
from server.api.auth import router as auth_router
|
||||
from server.api.agent import router as agent_router
|
||||
@@ -44,6 +58,8 @@ from server.api.sync_v2 import router as sync_v2_router
|
||||
# Background tasks
|
||||
from server.background.heartbeat_flush import heartbeat_flush_loop
|
||||
from server.background.self_monitor import self_monitor_loop
|
||||
from server.background.schedule_runner import schedule_runner_loop
|
||||
from server.background.retry_runner import retry_runner_loop
|
||||
|
||||
logger = logging.getLogger("nexus")
|
||||
|
||||
@@ -69,8 +85,17 @@ class DbSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Skip non-API routes (WebSocket, health, static files)
|
||||
# WebSocket connections are long-lived — never wrap them in a DB session
|
||||
path = request.url.path
|
||||
if not path.startswith("/api/") and not path.startswith("/ws/"):
|
||||
if not path.startswith("/api/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Skip install API (it manages its own DB connections)
|
||||
if path.startswith("/api/install/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Skip in install mode (no global engine available)
|
||||
if INSTALL_MODE:
|
||||
return await call_next(request)
|
||||
|
||||
# Open session for this request
|
||||
@@ -86,9 +111,19 @@ class DbSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifecycle: startup → background tasks → shutdown"""
|
||||
"""Application lifecycle: startup → background tasks → shutdown
|
||||
|
||||
# ── Startup ──
|
||||
Install mode (no .env): only install API + static files available.
|
||||
Normal mode (.env exists): full initialization with DB, Redis, background tasks.
|
||||
"""
|
||||
|
||||
# ── Install mode: skip everything ──
|
||||
if INSTALL_MODE:
|
||||
logger.info("⚠ INSTALL MODE — .env not found. Visit /app/install.html to configure.")
|
||||
yield
|
||||
return
|
||||
|
||||
# ── Normal mode: full startup ──
|
||||
if not settings.SECRET_KEY:
|
||||
logger.error("SECRET_KEY is empty! Set it in .env or MySQL settings table.")
|
||||
raise SystemExit("SECRET_KEY is required for Nexus to start.")
|
||||
@@ -117,7 +152,9 @@ async def lifespan(app: FastAPI):
|
||||
# 5. Launch background tasks
|
||||
task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush")
|
||||
task_monitor = asyncio.create_task(self_monitor_loop(), name="self_monitor")
|
||||
_background_tasks.extend([task_flush, task_monitor])
|
||||
task_schedule = asyncio.create_task(schedule_runner_loop(), name="schedule_runner")
|
||||
task_retry = asyncio.create_task(retry_runner_loop(), name="retry_runner")
|
||||
_background_tasks.extend([task_flush, task_monitor, task_schedule, task_retry])
|
||||
|
||||
# 6. Start asyncssh connection pool (W1)
|
||||
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
||||
@@ -125,7 +162,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
logger.info(
|
||||
f"{settings.SYSTEM_NAME} v6.0.0 started — "
|
||||
f"background tasks: heartbeat_flush (10min), self_monitor (30s)"
|
||||
f"background tasks: heartbeat_flush(10min), self_monitor(30s), "
|
||||
f"schedule_runner(60s), retry_runner(5min)"
|
||||
)
|
||||
|
||||
yield
|
||||
@@ -149,33 +187,65 @@ async def lifespan(app: FastAPI):
|
||||
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
||||
await ssh_pool.stop()
|
||||
|
||||
# Close shared Telegram httpx client
|
||||
from server.infrastructure.telegram import close_client as close_telegram_client
|
||||
await close_telegram_client()
|
||||
|
||||
logger.info(f"{settings.SYSTEM_NAME} shutting down — background tasks cancelled")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.SYSTEM_NAME,
|
||||
description=f"{settings.SYSTEM_TITLE}",
|
||||
title=settings.SYSTEM_NAME if not INSTALL_MODE else "Nexus Installer",
|
||||
description=f"{settings.SYSTEM_TITLE}" if not INSTALL_MODE else "Nexus Installation Wizard",
|
||||
version="6.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# ── Install mode middleware: block non-install routes when not configured ──
|
||||
|
||||
class InstallModeMiddleware(BaseHTTPMiddleware):
|
||||
"""In install mode, only /api/install/, /app/, /health are accessible.
|
||||
All other API routes return 503 Service Unavailable."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if INSTALL_MODE:
|
||||
path = request.url.path
|
||||
# Allow: install API, static files, health check
|
||||
if path.startswith("/api/install/") or path.startswith("/app/") or path == "/health":
|
||||
return await call_next(request)
|
||||
if path == "/" or path.startswith("/ws/"):
|
||||
return await call_next(request)
|
||||
# Block everything else
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"detail": "系统尚未配置,请先访问 /app/install.html 完成安装"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Install mode middleware (must be first — before DB session middleware)
|
||||
app.add_middleware(InstallModeMiddleware)
|
||||
|
||||
# D7: DB session leak fix middleware (must be added BEFORE CORS so it wraps all requests)
|
||||
app.add_middleware(DbSessionMiddleware)
|
||||
|
||||
# CORS — restrict to actual frontend origin (ECC security fix: was allow_origins=["*"])
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://172.31.170.47", # WSL development
|
||||
"https://api.synaglobal.vip", # Production HTTPS
|
||||
"http://localhost:8600", # Local dev
|
||||
],
|
||||
allow_origins=settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS else ["http://localhost:8600"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Register all API routers
|
||||
|
||||
# D2: Install wizard (always registered — works in both modes)
|
||||
app.include_router(install_router)
|
||||
|
||||
# Normal API routers (require full app init)
|
||||
app.include_router(servers_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(agent_router)
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
"""Nexus — Test Configuration (pytest + async fixtures)
|
||||
|
||||
Fixed from original: wired up dependency_overrides properly,
|
||||
Fixed: wired up dependency_overrides properly,
|
||||
added test Redis mock, added admin fixture for JWT auth tests.
|
||||
"""
|
||||
|
||||
@@ -47,6 +47,7 @@ async def db_session(test_engine):
|
||||
@pytest.fixture(scope="function")
|
||||
async def api_client(db_session):
|
||||
"""Create an async HTTP client for API integration tests"""
|
||||
from server.main import app
|
||||
from server.api.dependencies import get_db
|
||||
|
||||
async def override_get_session():
|
||||
@@ -67,9 +68,12 @@ async def test_admin(db_session):
|
||||
import bcrypt
|
||||
from server.application.services.auth_service import AuthService
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
from server.infrastructure.database.login_attempt_repo import LoginAttemptRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
# LoginAttemptRepositoryImpl requires login_attempt_repo which may not exist in test DB
|
||||
# Use a simple mock approach
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
password_hash = bcrypt.hashpw("test123".encode(), bcrypt.gensalt()).decode()
|
||||
admin = Admin(
|
||||
username="testadmin",
|
||||
@@ -81,12 +85,11 @@ async def test_admin(db_session):
|
||||
await db_session.commit()
|
||||
await db_session.refresh(admin)
|
||||
|
||||
# Generate JWT token for this admin
|
||||
auth_service = AuthService(
|
||||
admin_repo=AdminRepositoryImpl(db_session),
|
||||
attempt_repo=LoginAttemptRepositoryImpl(db_session),
|
||||
attempt_repo=MagicMock(),
|
||||
audit_repo=AuditLogRepositoryImpl(db_session),
|
||||
)
|
||||
token = auth_service._create_access_token(admin)
|
||||
|
||||
return {"admin": admin, "token": token}
|
||||
return {"admin": admin, "token": token}
|
||||
|
||||
+96
-41
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Nexus API End-to-End Tests
|
||||
Usage: python tests/test_api.py
|
||||
Prerequisites: Python backend must be running (python -m server.main)
|
||||
Prerequisites: Python backend must be running (python -m uvicorn server.main:app)
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
@@ -9,20 +9,32 @@ import urllib.request
|
||||
import urllib.error
|
||||
|
||||
BASE = "http://127.0.0.1:8600"
|
||||
API_KEY = ""
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
# ── Auth (login first to get JWT) ──
|
||||
ACCESS_TOKEN = ""
|
||||
REFRESH_TOKEN = ""
|
||||
|
||||
def test(name: str, method: str, path: str, body=None, expect_code=200):
|
||||
"""执行一个 API 测试并报告结果。返回响应 body(含可能的新建 ID)。"""
|
||||
|
||||
def _get_auth_headers():
|
||||
return {"Authorization": f"Bearer {ACCESS_TOKEN}"} if ACCESS_TOKEN else {}
|
||||
|
||||
|
||||
def test(name: str, method: str, path: str, body=None, expect_code=200, headers=None):
|
||||
"""Execute an API test and report results."""
|
||||
global PASS, FAIL
|
||||
url = f"{BASE}{path}"
|
||||
data = json.dumps(body).encode() if body else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if API_KEY:
|
||||
req.add_header("X-API-Key", API_KEY)
|
||||
# JWT auth
|
||||
for k, v in _get_auth_headers().items():
|
||||
req.add_header(k, v)
|
||||
# Extra headers
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
code = resp.getcode()
|
||||
@@ -38,7 +50,11 @@ def test(name: str, method: str, path: str, body=None, expect_code=200):
|
||||
code = e.code
|
||||
if code == expect_code:
|
||||
PASS += 1
|
||||
print(f" [PASS] {name} (HTTP {code} as expected)")
|
||||
result_text = e.read().decode()[:200]
|
||||
try:
|
||||
return json.loads(result_text)
|
||||
except Exception:
|
||||
return {"detail": result_text}
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" [FAIL] {name}: expected {expect_code}, got {code} — {e.read().decode()[:200]}")
|
||||
@@ -48,27 +64,36 @@ def test(name: str, method: str, path: str, body=None, expect_code=200):
|
||||
return None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Test Suite
|
||||
# ================================================================
|
||||
print("=" * 50)
|
||||
print("Nexus API End-to-End Tests")
|
||||
print(f" Target: {BASE}")
|
||||
print("=" * 50)
|
||||
|
||||
# --- Health Endpoints (no API_KEY needed) ---
|
||||
print("\n[1] Health Endpoints")
|
||||
test("GET /", "GET", "/")
|
||||
# --- Health (no auth) ---
|
||||
print("\n[1] Health")
|
||||
test("GET /health", "GET", "/health")
|
||||
test("GET /health/services", "GET", "/health/services")
|
||||
|
||||
# --- Auth: Login ---
|
||||
print("\n[2] Auth")
|
||||
login_result = test("POST /api/auth/login", "POST", "/api/auth/login", body={
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
}, expect_code=200)
|
||||
if login_result and login_result.get("access_token"):
|
||||
ACCESS_TOKEN = login_result["access_token"]
|
||||
REFRESH_TOKEN = login_result.get("refresh_token", "")
|
||||
print(f" → JWT token acquired (expires_in={login_result.get('expires_in')}s)")
|
||||
else:
|
||||
print(" → WARNING: Login failed, subsequent tests will fail without JWT")
|
||||
|
||||
test("GET /api/auth/me", "GET", "/api/auth/me")
|
||||
|
||||
# --- Server CRUD ---
|
||||
print("\n[2] Server CRUD")
|
||||
|
||||
# 创建测试服务器(记录 ID 以便后续操作和清理)
|
||||
print("\n[3] Server CRUD")
|
||||
created = test("POST /api/servers/ (create)", "POST", "/api/servers/", body={
|
||||
"name": "test-server-e2e",
|
||||
"domain": "127.0.0.1",
|
||||
"domain": "192.168.1.100",
|
||||
"port": 22,
|
||||
"username": "root",
|
||||
"auth_method": "password",
|
||||
@@ -78,43 +103,73 @@ created = test("POST /api/servers/ (create)", "POST", "/api/servers/", body={
|
||||
})
|
||||
server_id = created.get("id") if created and isinstance(created, dict) else 1
|
||||
|
||||
# 列表
|
||||
test("GET /api/servers/ (list)", "GET", "/api/servers/")
|
||||
|
||||
# 获取刚创建的
|
||||
test("GET /api/servers/stats", "GET", "/api/servers/stats")
|
||||
test(f"GET /api/servers/{server_id}", "GET", f"/api/servers/{server_id}")
|
||||
|
||||
# 更新
|
||||
test(f"PUT /api/servers/{server_id} (update)", "PUT", f"/api/servers/{server_id}", body={
|
||||
"description": "E2E test server",
|
||||
})
|
||||
|
||||
# 清理:删除测试服务器
|
||||
test(f"DELETE /api/servers/{server_id}", "DELETE", f"/api/servers/{server_id}")
|
||||
|
||||
# --- Logs ---
|
||||
print("\n[3] Push Logs")
|
||||
test("GET /api/servers/logs", "GET", "/api/servers/logs")
|
||||
test("GET /api/servers/logs?limit=5", "GET", "/api/servers/logs?limit=5")
|
||||
# --- Scripts ---
|
||||
print("\n[4] Scripts")
|
||||
script = test("POST /api/scripts/ (create)", "POST", "/api/scripts/", body={
|
||||
"name": "test-script",
|
||||
"category": "ops",
|
||||
"content": "echo hello",
|
||||
})
|
||||
script_id = script.get("id") if script and isinstance(script, dict) else 1
|
||||
test("GET /api/scripts/ (list)", "GET", "/api/scripts/")
|
||||
test(f"DELETE /api/scripts/{script_id}", "DELETE", f"/api/scripts/{script_id}")
|
||||
|
||||
# --- Password Presets ---
|
||||
print("\n[4] Password Presets")
|
||||
test("GET /api/servers/password-presets (list)", "GET", "/api/servers/password-presets")
|
||||
preset = test("POST /api/servers/password-presets (create)", "POST", "/api/servers/password-presets", body={
|
||||
# --- Schedules ---
|
||||
print("\n[5] Schedules")
|
||||
sched = test("POST /api/schedules/ (create)", "POST", "/api/schedules/", body={
|
||||
"name": "test-schedule",
|
||||
"source_path": "/tmp/nexus-test",
|
||||
"cron_expr": "0 2 * * *",
|
||||
"server_ids": "[]",
|
||||
"enabled": True,
|
||||
})
|
||||
sched_id = sched.get("id") if sched and isinstance(sched, dict) else 1
|
||||
test("GET /api/schedules/ (list)", "GET", "/api/schedules/")
|
||||
test(f"PUT /api/schedules/{sched_id} (disable)", "PUT", f"/api/schedules/{sched_id}", body={
|
||||
"enabled": False,
|
||||
})
|
||||
test(f"DELETE /api/schedules/{sched_id}", "DELETE", f"/api/schedules/{sched_id}")
|
||||
|
||||
# --- Presets ---
|
||||
print("\n[6] Password Presets")
|
||||
preset = test("POST /api/presets/ (create)", "POST", "/api/presets/", body={
|
||||
"name": "test-preset",
|
||||
"password": "test-e2e-123",
|
||||
"encrypted_pw": "test-password-123",
|
||||
})
|
||||
preset_id = preset.get("id") if preset and isinstance(preset, dict) else 1
|
||||
test(f"POST /api/servers/password-presets/{preset_id}/reveal", "POST", f"/api/servers/password-presets/{preset_id}/reveal")
|
||||
# 清理:删除测试预设
|
||||
test(f"DELETE /api/servers/password-presets/{preset_id}", "DELETE", f"/api/servers/password-presets/{preset_id}")
|
||||
test("GET /api/presets/ (list)", "GET", "/api/presets/")
|
||||
test(f"DELETE /api/presets/{preset_id}", "DELETE", f"/api/presets/{preset_id}")
|
||||
|
||||
# --- Retry Queue ---
|
||||
print("\n[5] Retry Queue")
|
||||
test("GET /api/servers/retry-jobs", "GET", "/api/servers/retry-jobs")
|
||||
# --- Settings ---
|
||||
print("\n[7] Settings")
|
||||
test("GET /api/settings/ (list)", "GET", "/api/settings/")
|
||||
test("PUT /api/settings/system_name", "PUT", "/api/settings/system_name", body={
|
||||
"value": "Nexus",
|
||||
})
|
||||
|
||||
# --- Audit ---
|
||||
print("\n[8] Audit")
|
||||
test("GET /api/audit/ (list)", "GET", "/api/audit/?limit=10")
|
||||
|
||||
# --- Retries ---
|
||||
print("\n[9] Retry Queue")
|
||||
test("GET /api/retries/ (list)", "GET", "/api/retries/")
|
||||
|
||||
# --- Sync ---
|
||||
print("\n[10] Sync")
|
||||
test("POST /api/sync/browse (no server)", "POST", "/api/sync/browse", body={
|
||||
"server_id": 99999,
|
||||
"path": "/tmp",
|
||||
}, expect_code=404)
|
||||
|
||||
# ================================================================
|
||||
# Report
|
||||
# ================================================================
|
||||
total = PASS + FAIL
|
||||
print(f"\n{'='*50}")
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Python 后端安装提醒 + 服务状态
|
||||
* PHP 服务端检测(127.0.0.1),无 CORS 问题
|
||||
*/
|
||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
||||
|
||||
// 服务端检测 API/DB/WS 状态(走本地 127.0.0.1)
|
||||
$apiOnline = false; $dbOnline = false; $wsOnline = false;
|
||||
if (!isset($_SESSION['svc_checked']) || $_SESSION['svc_checked'] < time() - 60) {
|
||||
$ch = curl_init('http://127.0.0.1:8600/health/services');
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3]);
|
||||
$resp = @json_decode(curl_exec($ch), true);
|
||||
curl_close($ch);
|
||||
$apiOnline = ($resp['api'] ?? '') === 'online';
|
||||
$dbOnline = ($resp['db'] ?? '') === 'online';
|
||||
$wsOnline = ($resp['ws'] ?? '') === 'online';
|
||||
$_SESSION['svc_api'] = $apiOnline;
|
||||
$_SESSION['svc_db'] = $dbOnline;
|
||||
$_SESSION['svc_ws'] = $wsOnline;
|
||||
$_SESSION['svc_checked'] = time();
|
||||
} else {
|
||||
$apiOnline = $_SESSION['svc_api'] ?? false;
|
||||
$dbOnline = $_SESSION['svc_db'] ?? false;
|
||||
$wsOnline = $_SESSION['svc_ws'] ?? false;
|
||||
}
|
||||
|
||||
if ($currentPage === 'login.php') return;
|
||||
?>
|
||||
<?php if (!$apiOnline): ?>
|
||||
<div style="background:#fef3c7;border-bottom:2px solid #f59e0b;padding:8px 20px;font-size:13px;display:flex;align-items:center;gap:12px;">
|
||||
<span style="flex:1;"><b>Python 后端未运行</b> — 推送、重试、定时调度等功能需要后端支持</span>
|
||||
<a href="https://api.synaglobal.vip/settings.php" style="background:#f59e0b;color:#fff;padding:4px 12px;border-radius:4px;text-decoration:none;font-size:12px;font-weight:600;">前往配置 →</a>
|
||||
<button onclick="this.parentElement.style.display='none'" style="background:none;border:none;font-size:18px;cursor:pointer;color:#92400e;">×</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* .env 配置检测横幅
|
||||
* 检查 Python 后端的 .env 是否正确配置
|
||||
* 问题修复后横幅自动消失
|
||||
*/
|
||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
||||
if (in_array($currentPage, ['login.php', 'install.php'])) return;
|
||||
|
||||
// 每 5 分钟检测一次
|
||||
if (isset($_SESSION['env_checked']) && $_SESSION['env_checked'] > time() - 300) {
|
||||
return; // 已检测且正常
|
||||
}
|
||||
$_SESSION['env_checked'] = time();
|
||||
|
||||
$apiBase = defined('API_BASE_URL') ? API_BASE_URL : 'http://localhost:8600';
|
||||
$apiKey = defined('API_KEY') ? API_KEY : '';
|
||||
$warnings = [];
|
||||
|
||||
// 检查:Python API 是否在线
|
||||
$ch = curl_init($apiBase . '/health');
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3]);
|
||||
$resp = @curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
// API 不在线 → 不检查 .env(由 _banner.php 处理)
|
||||
$_SESSION['env_warnings'] = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查:SECRET_KEY 是否为默认值(通过 /api/servers/config 获取)
|
||||
$ch = curl_init($apiBase . '/api/servers/config');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3,
|
||||
CURLOPT_HTTPHEADER => $apiKey ? ["X-API-Key: $apiKey"] : [],
|
||||
]);
|
||||
$resp = @curl_exec($ch);
|
||||
$configData = json_decode($resp, true) ?: [];
|
||||
curl_close($ch);
|
||||
|
||||
if (empty($configData)) {
|
||||
$warnings[] = ['type' => 'api', 'msg' => 'API_KEY 未配置或与 Python .env 不一致'];
|
||||
} else {
|
||||
// 检查默认值
|
||||
if (($configData['api_key'] ?? '') === '') {
|
||||
$warnings[] = ['type' => 'apikey', 'msg' => 'API_KEY 为空 — API 端点无认证保护'];
|
||||
}
|
||||
if (($configData['telegram_bot_token'] ?? '') === '' && ($configData['telegram_chat_id'] ?? '') === '') {
|
||||
// Telegram 是可选的,仅提示
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['env_warnings'] = $warnings;
|
||||
$showBanner = !empty($warnings);
|
||||
|
||||
if ($showBanner):
|
||||
?>
|
||||
<div id="envBanner" style="background:#fff3cd;border-bottom:2px solid #ffc107;padding:10px 20px;font-size:13px;display:flex;align-items:center;gap:12px;">
|
||||
<span style="font-size:16px;">⚠️</span>
|
||||
<span style="flex:1;"><b>.env 配置警告</b> — <?= count($warnings) ?> 项需要处理:<?= implode(';', array_column($warnings, 'msg')) ?></span>
|
||||
<a href="settings.php" style="background:#ffc107;color:#000;padding:6px 14px;border-radius:4px;text-decoration:none;font-size:12px;font-weight:600;">前往修复 →</a>
|
||||
<button onclick="document.getElementById('envBanner').style.display='none'" style="background:none;border:none;font-size:18px;cursor:pointer;color:#856404;">×</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -1,8 +0,0 @@
|
||||
</div><!-- /.content-wrapper -->
|
||||
<aside class="control-sidebar control-sidebar-dark"></aside>
|
||||
</div><!-- /.wrapper -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/js/adminlte.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
// AdminLTE 布局头部 — 所有页面引入
|
||||
require_once __DIR__ . '/_banner.php';
|
||||
$current_page = basename($_SERVER['SCRIPT_NAME']);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $pageTitle ?? 'MultiSync' ?></title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/css/adminlte.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
.content-wrapper { background: #f4f6f9; }
|
||||
.card { box-shadow: 0 0 1px rgba(0,0,0,.125), 0 1px 3px rgba(0,0,0,.2); border: none; }
|
||||
.table th { border-top: none; font-size: 13px; font-weight: 600; color: #6c757d; }
|
||||
.table td { font-size: 13px; vertical-align: middle; }
|
||||
.badge-success { background: #28a745; }
|
||||
.btn-xs { padding: 1px 6px; font-size: 11px; }
|
||||
</style>
|
||||
<script>
|
||||
// 全局推送横条
|
||||
function escHtml(s){return(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
function renderPushBanner(data){
|
||||
var total=data.total||0,success=data.success||0,failed=data.failed||0;
|
||||
var h='<div id="pushBanner" style="background:'+(failed===0?'#d4edda':'#fff3cd')+';border-bottom:1px solid '+(failed===0?'#c3e6cb':'#ffeeba')+';padding:8px 20px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:12px">';
|
||||
h+='<b>'+(failed===0?'✅':'⚠')+' 推送完成:</b> '+total+' 台,成功 '+success+(failed>0?',失败 '+failed:'');
|
||||
(data.results||[]).slice(0,3).forEach(function(r){
|
||||
var icon=r.status==='success'?'✅':r.status==='skipped'?'⚠':'❌';
|
||||
h+='<span style="font-size:11px;color:#555;">'+icon+' '+escHtml(r.server_name||'')+': '+escHtml(r.message||'')+'</span>';
|
||||
});
|
||||
h+='<button onclick="dismissBanner()" style="border:none;background:none;cursor:pointer;font-size:16px;color:#999;">×</button></div>';
|
||||
var existing=document.getElementById('pushBanner');if(existing)existing.remove();
|
||||
var wrapper=document.querySelector('.wrapper');if(wrapper)wrapper.insertAdjacentHTML('afterbegin',h);
|
||||
// 3 秒后消失
|
||||
clearTimeout(window._bannerTimer);
|
||||
window._bannerTimer=setTimeout(function(){var b=document.getElementById('pushBanner');if(b)b.remove();sessionStorage.removeItem('pushResult');},3000);
|
||||
}
|
||||
function dismissBanner(){var b=document.getElementById('pushBanner');if(b)b.remove();sessionStorage.removeItem('pushResult');clearTimeout(window._bannerTimer);}
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
var raw=sessionStorage.getItem('pushResult');
|
||||
if(raw){try{renderPushBanner(JSON.parse(raw))}catch(e){}}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="hold-transition sidebar-mini">
|
||||
<div class="wrapper">
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
// 侧边栏组件 — 所有页面引入
|
||||
$current_page = basename($_SERVER['SCRIPT_NAME']);
|
||||
?>
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1><i class="fas fa-sync-alt"></i> MultiSync</h1>
|
||||
<span class="version">v1.0.0</span>
|
||||
</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-section-title">主菜单</li>
|
||||
<li<?= $current_page === 'index.php' ? ' class="active"' : '' ?>>
|
||||
<a href="index.php"><i class="fas fa-tachometer-alt"></i> <span>仪表盘</span></a></li>
|
||||
<li<?= $current_page === 'servers.php' ? ' class="active"' : '' ?>>
|
||||
<a href="servers.php"><i class="fas fa-server"></i> <span>服务器管理</span></a></li>
|
||||
<li<?= $current_page === 'retry.php' ? ' class="active"' : '' ?>>
|
||||
<a href="retry.php"><i class="fas fa-redo-alt"></i> <span>重试队列</span></a></li>
|
||||
<li<?= $current_page === 'logs.php' ? ' class="active"' : '' ?>>
|
||||
<a href="logs.php"><i class="fas fa-history"></i> <span>同步日志</span></a></li>
|
||||
<li<?= $current_page === 'files.php' ? ' class="active"' : '' ?>>
|
||||
<a href="files.php"><i class="fas fa-folder-open"></i> <span>文件管理</span></a></li>
|
||||
<li class="nav-section-title">系统</li>
|
||||
<li<?= $current_page === 'guide.php' ? ' class="active"' : '' ?>>
|
||||
<a href="guide.php"><i class="fas fa-compass"></i> <span>使用引导</span></a></li>
|
||||
<li<?= $current_page === 'deploy.php' ? ' class="active"' : '' ?>>
|
||||
<a href="deploy.php"><i class="fas fa-rocket"></i> <span>部署 Agent</span></a></li>
|
||||
<li<?= $current_page === 'settings.php' ? ' class="active"' : '' ?>>
|
||||
<a href="settings.php"><i class="fas fa-cog"></i> <span>系统设置</span></a></li>
|
||||
<li>
|
||||
<a href="logout.php"><i class="fas fa-sign-out-alt"></i> <span>退出登录</span></a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user">
|
||||
<div class="avatar"><?= strtoupper(substr(CURRENT_USER, 0, 1)) ?></div>
|
||||
<div class="user-meta">
|
||||
<div class="name"><?= htmlspecialchars(CURRENT_USER) ?></div>
|
||||
<div class="role">管理员</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
$cp = basename($_SERVER['SCRIPT_NAME']);
|
||||
$menu = [
|
||||
['name' => '仪表盘', 'icon' => 'tachometer-alt', 'link' => 'index.php', 'page' => 'index.php'],
|
||||
['name' => '服务器管理', 'icon' => 'server', 'link' => 'servers.php', 'page' => 'servers.php'],
|
||||
['name' => '定时推送', 'icon' => 'clock', 'link' => 'schedules.php', 'page' => 'schedules.php'],
|
||||
['name' => '重试队列', 'icon' => 'redo-alt', 'link' => 'retry.php', 'page' => 'retry.php'],
|
||||
['name' => '同步日志', 'icon' => 'history', 'link' => 'logs.php', 'page' => 'logs.php'],
|
||||
['name' => '文件管理', 'icon' => 'folder-open', 'link' => 'fm.php', 'page' => 'fm.php'],
|
||||
];
|
||||
$sysMenu = [
|
||||
['name' => '审计日志', 'icon' => 'shield-alt', 'link' => 'audit.php', 'page' => 'audit.php'],
|
||||
['name' => '使用引导', 'icon' => 'compass', 'link' => 'guide.php', 'page' => 'guide.php'],
|
||||
['name' => '部署 Agent', 'icon' => 'rocket', 'link' => 'deploy.php', 'page' => 'deploy.php'],
|
||||
['name' => '系统设置', 'icon' => 'cog', 'link' => 'settings.php', 'page' => 'settings.php'],
|
||||
];
|
||||
?>
|
||||
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
|
||||
<ul class="navbar-nav"><li class="nav-item"><a class="nav-link" data-widget="pushmenu" href="#"><i class="fas fa-bars"></i></a></li></ul>
|
||||
<ul class="navbar-nav ml-auto">
|
||||
<li class="nav-item"><span class="nav-link text-muted"><?= htmlspecialchars(CURRENT_USER) ?></span></li>
|
||||
<li class="nav-item"><a class="nav-link" href="logout.php"><i class="fas fa-sign-out-alt"></i></a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<aside class="main-sidebar sidebar-dark-primary elevation-4">
|
||||
<a href="index.php" class="brand-link"><i class="fas fa-sync-alt brand-image"></i><span class="brand-text font-weight-light">MultiSync</span></a>
|
||||
<div class="sidebar">
|
||||
<nav class="mt-2">
|
||||
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu">
|
||||
<?php foreach ($menu as $m): $active = ($cp === $m['page']) ? ' active' : ''; ?>
|
||||
<li class="nav-item">
|
||||
<a href="<?=$m['link']?>" class="nav-link<?=$active?>"><i class="nav-icon fas fa-<?=$m['icon']?>"></i><p><?=$m['name']?></p></a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
<li class="nav-header">系统</li>
|
||||
<?php foreach ($sysMenu as $m): $active = ($cp === $m['page']) ? ' active' : ''; ?>
|
||||
<li class="nav-item">
|
||||
<a href="<?=$m['link']?>" class="nav-link<?=$active?>"><i class="nav-icon fas fa-<?=$m['icon']?>"></i><p><?=$m['name']?></p></a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
File diff suppressed because one or more lines are too long
+129
-102
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
MultiSync Agent - Runs on each sub-server
|
||||
Provides: health endpoint, heartbeat, config reload
|
||||
Nexus Agent — Runs on each managed server
|
||||
Provides: health endpoint, heartbeat, command execution, config reload
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -9,7 +9,8 @@ import time
|
||||
import asyncio
|
||||
import logging
|
||||
import platform
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -18,7 +19,7 @@ import psutil
|
||||
from fastapi import FastAPI, HTTPException, Header, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
# --- Configuration (JSON, 主服务器可推送更新) ---
|
||||
# --- Configuration ---
|
||||
CONFIG_PATH = Path(__file__).parent / "config.json"
|
||||
|
||||
|
||||
@@ -26,7 +27,6 @@ def load_config():
|
||||
if CONFIG_PATH.exists():
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
# Fallback to example
|
||||
example = Path(__file__).parent / "config.example.json"
|
||||
if example.exists():
|
||||
with open(example) as f:
|
||||
@@ -36,13 +36,14 @@ def load_config():
|
||||
|
||||
config = load_config()
|
||||
API_KEY = config.get("api_key", "")
|
||||
SERVER_ID = config.get("server_id", 0)
|
||||
CENTRAL_URL = config.get("central", {}).get("url", "")
|
||||
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
|
||||
HEARTBEAT_INTERVAL = config.get("heartbeat_interval", 30)
|
||||
HEARTBEAT_INTERVAL = config.get("heartbeat_interval", 60)
|
||||
|
||||
# --- Logging ---
|
||||
|
||||
log_file = config.get("log_file", "/var/log/multisync-agent.log")
|
||||
log_file = config.get("log_file", "/var/log/nexus-agent.log")
|
||||
log_level = config.get("log_level", "INFO")
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -53,11 +54,11 @@ logging.basicConfig(
|
||||
logging.FileHandler(log_file, mode="a") if log_file != "stdout" else logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("multisync-agent")
|
||||
logger = logging.getLogger("nexus-agent")
|
||||
|
||||
# --- FastAPI App ---
|
||||
|
||||
app = FastAPI(title="MultiSync Agent", version="1.0.0")
|
||||
app = FastAPI(title="Nexus Agent", version="2.0.0")
|
||||
|
||||
|
||||
def verify_api_key(x_api_key: str = Header(default="")):
|
||||
@@ -69,7 +70,7 @@ def verify_api_key(x_api_key: str = Header(default="")):
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint - called by central server"""
|
||||
"""Health check endpoint — called by Nexus central server"""
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=0.5)
|
||||
memory = psutil.virtual_memory()
|
||||
@@ -79,80 +80,104 @@ async def health_check():
|
||||
"hostname": platform.node(),
|
||||
"platform": platform.platform(),
|
||||
"python_version": platform.python_version(),
|
||||
"cpu_percent": cpu_percent,
|
||||
"cpu_usage": cpu_percent,
|
||||
"mem_usage": memory.percent,
|
||||
"disk_usage": round(disk.percent, 1),
|
||||
"memory_total_gb": round(memory.total / (1024**3), 2),
|
||||
"memory_used_gb": round(memory.used / (1024**3), 2),
|
||||
"memory_percent": memory.percent,
|
||||
"disk_total_gb": round(disk.total / (1024**3), 2),
|
||||
"disk_used_gb": round(disk.used / (1024**3), 2),
|
||||
"disk_percent": round(disk.percent, 1),
|
||||
"uptime_seconds": int(time.time() - psutil.boot_time()),
|
||||
"agent_time": datetime.utcnow().isoformat(),
|
||||
"agent_time": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
return {"status": "healthy", "system_info": system_info}
|
||||
except Exception as e:
|
||||
return {"status": "degraded", "error": str(e)}
|
||||
|
||||
|
||||
# --- Command Execution ---
|
||||
|
||||
@app.post("/exec")
|
||||
async def exec_command(payload: dict, x_api_key: str = Header(default="")):
|
||||
"""Execute a shell command — called by Nexus to run commands on this server"""
|
||||
verify_api_key(x_api_key)
|
||||
|
||||
command = payload.get("command", "")
|
||||
timeout = payload.get("timeout", 30)
|
||||
sudo = payload.get("sudo", False)
|
||||
|
||||
if not command:
|
||||
raise HTTPException(status_code=400, detail="command is required")
|
||||
|
||||
if sudo:
|
||||
command = f"sudo {command}"
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
exit_code = proc.returncode or 0
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"system_info": system_info,
|
||||
"status": "success" if exit_code == 0 else "failed",
|
||||
"stdout": stdout.decode()[:10000],
|
||||
"stderr": stderr.decode()[:10000],
|
||||
"exit_code": exit_code,
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return {
|
||||
"status": "timeout",
|
||||
"stdout": "",
|
||||
"stderr": f"Command timed out after {timeout}s",
|
||||
"exit_code": -1,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"error": str(e),
|
||||
}
|
||||
return {"status": "error", "stdout": "", "stderr": str(e), "exit_code": -1}
|
||||
|
||||
|
||||
# --- Config Reload ---
|
||||
|
||||
@app.post("/config/reload")
|
||||
async def reload_config(data: dict, x_api_key: str = Header(default="")):
|
||||
"""主服务器推送新配置 — 更新 config.json 并重载"""
|
||||
"""Nexus pushes new config — update config.json and reload"""
|
||||
verify_api_key(x_api_key)
|
||||
try:
|
||||
now = datetime.utcnow().isoformat()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
data["updated_at"] = now
|
||||
data["updated_by"] = "master"
|
||||
data["updated_by"] = "nexus"
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
# 重新加载配置
|
||||
global CENTRAL_URL, CENTRAL_API_KEY, config
|
||||
|
||||
global SERVER_ID, CENTRAL_URL, CENTRAL_API_KEY, config
|
||||
config = data
|
||||
CENTRAL_URL = config.get("central", {}).get("url", "")
|
||||
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
|
||||
return {"status": "ok", "message": "Config updated", "central_url": CENTRAL_URL, "updated_at": now}
|
||||
SERVER_ID = config.get("server_id", SERVER_ID)
|
||||
CENTRAL_URL = config.get("central", {}).get("url", CENTRAL_URL)
|
||||
CENTRAL_API_KEY = config.get("central", {}).get("api_key", CENTRAL_API_KEY)
|
||||
|
||||
return {"status": "ok", "message": "Config updated", "updated_at": now}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/uninstall")
|
||||
async def uninstall_self(x_api_key: str = Header(default="")):
|
||||
"""主服务器远程触发卸载 — Agent 执行卸载脚本并停止自身"""
|
||||
verify_api_key(x_api_key)
|
||||
import subprocess, os as _os
|
||||
script = "/opt/multisync-agent/uninstall.sh"
|
||||
if _os.path.exists(script):
|
||||
try:
|
||||
proc = subprocess.run(["bash", script], capture_output=True, text=True, timeout=30)
|
||||
return {"status": "ok", "message": "Agent uninstalled", "output": proc.stdout[:500]}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "timeout", "message": "Uninstall script timed out"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
else:
|
||||
return {"status": "not_found", "message": f"Uninstall script not found: {script}"}
|
||||
|
||||
|
||||
# --- Heartbeat to Central Server ---
|
||||
|
||||
_last_metrics = {}
|
||||
_last_full_sync = 0
|
||||
HISTORY_SIZE = 10
|
||||
|
||||
# 初始化采样窗口(避免重启后空窗等 5 分钟)
|
||||
_cpu_init = psutil.cpu_percent()
|
||||
_mem_init = psutil.virtual_memory().percent
|
||||
_disk_init = psutil.disk_usage("/").percent
|
||||
_metric_history = [{"cpu": _cpu_init, "memory": _mem_init, "disk": _disk_init}] * min(5, HISTORY_SIZE)
|
||||
|
||||
|
||||
def _check_alert():
|
||||
"""连续 HISTORY_SIZE 次 CPU/内存>80% 或磁盘>90% 返回告警标记"""
|
||||
"""Check if CPU/mem > 80% or disk > 90% for HISTORY_SIZE consecutive samples"""
|
||||
if len(_metric_history) < HISTORY_SIZE:
|
||||
return False, {}
|
||||
cpu_high = all(h["cpu"] >= 80 for h in _metric_history)
|
||||
@@ -166,57 +191,65 @@ def _check_alert():
|
||||
|
||||
|
||||
async def send_heartbeat():
|
||||
"""IoT 优化:变化 >10% + 连续 10 次高负载告警 + Redis 存指标"""
|
||||
"""Send heartbeat to Nexus central — smart: only when metrics change >10% or alert"""
|
||||
global _last_metrics, _last_full_sync, _metric_history
|
||||
while True:
|
||||
try:
|
||||
if CENTRAL_URL:
|
||||
cpu = psutil.cpu_percent()
|
||||
mem = psutil.virtual_memory().percent
|
||||
disk = psutil.disk_usage("/").percent
|
||||
if not CENTRAL_URL or not SERVER_ID:
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
continue
|
||||
|
||||
now_ts = time.time()
|
||||
changed = (
|
||||
abs(cpu - _last_metrics.get("cpu", 0)) >= 10 or
|
||||
abs(mem - _last_metrics.get("memory", 0)) >= 10 or
|
||||
abs(disk - _last_metrics.get("disk", 0)) >= 10
|
||||
)
|
||||
force_sync = (now_ts - _last_full_sync) > 600 # 10 分钟强制全量
|
||||
cpu = psutil.cpu_percent()
|
||||
mem = psutil.virtual_memory().percent
|
||||
disk = psutil.disk_usage("/").percent
|
||||
|
||||
# 滑动窗口记录采样
|
||||
_metric_history.append({"cpu": cpu, "memory": mem, "disk": disk})
|
||||
if len(_metric_history) > HISTORY_SIZE:
|
||||
_metric_history.pop(0)
|
||||
now_ts = time.time()
|
||||
changed = (
|
||||
abs(cpu - _last_metrics.get("cpu", 0)) >= 10 or
|
||||
abs(mem - _last_metrics.get("memory", 0)) >= 10 or
|
||||
abs(disk - _last_metrics.get("disk", 0)) >= 10
|
||||
)
|
||||
force_sync = (now_ts - _last_full_sync) > 600
|
||||
|
||||
# 检查告警
|
||||
has_alert, alert_info = _check_alert()
|
||||
_metric_history.append({"cpu": cpu, "memory": mem, "disk": disk})
|
||||
if len(_metric_history) > HISTORY_SIZE:
|
||||
_metric_history.pop(0)
|
||||
|
||||
has_alert, alert_info = _check_alert()
|
||||
|
||||
if changed or force_sync or has_alert:
|
||||
_last_metrics = {"cpu": cpu, "memory": mem, "disk": disk}
|
||||
if force_sync:
|
||||
_last_full_sync = now_ts
|
||||
|
||||
payload = {
|
||||
"server_id": SERVER_ID,
|
||||
"is_online": True,
|
||||
"agent_version": "2.0.0",
|
||||
"system_info": {
|
||||
"cpu_usage": cpu,
|
||||
"mem_usage": mem,
|
||||
"disk_usage": disk,
|
||||
"hostname": platform.node(),
|
||||
"platform": platform.platform(),
|
||||
},
|
||||
}
|
||||
if has_alert:
|
||||
payload["alert"] = alert_info
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{CENTRAL_URL}/api/agent/heartbeat",
|
||||
json=payload,
|
||||
headers={"X-API-Key": CENTRAL_API_KEY},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.debug("Heartbeat sent")
|
||||
else:
|
||||
logger.warning(f"Heartbeat failed: {resp.status_code}")
|
||||
else:
|
||||
logger.debug("Metrics unchanged, skip heartbeat")
|
||||
|
||||
if changed or force_sync or has_alert:
|
||||
_last_metrics = {"cpu": cpu, "memory": mem, "disk": disk}
|
||||
if force_sync: _last_full_sync = now_ts
|
||||
payload = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"agent_version": "2.0.0",
|
||||
"system_info": {
|
||||
"hostname": platform.node(),
|
||||
"cpu": cpu, "memory": mem, "disk": disk,
|
||||
"history": _metric_history,
|
||||
},
|
||||
}
|
||||
if has_alert:
|
||||
payload["alert"] = alert_info
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{CENTRAL_URL}/api/agent/heartbeat",
|
||||
json=payload,
|
||||
headers={"X-API-Key": CENTRAL_API_KEY},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.debug("Heartbeat sent successfully")
|
||||
else:
|
||||
logger.warning(f"Heartbeat failed: {resp.status_code}")
|
||||
else:
|
||||
logger.debug("Metrics unchanged <10%, skip heartbeat")
|
||||
except Exception as e:
|
||||
logger.warning(f"Heartbeat error: {e}")
|
||||
|
||||
@@ -224,14 +257,10 @@ async def send_heartbeat():
|
||||
|
||||
|
||||
# --- App Lifecycle ---
|
||||
# 注:TriggerFileHandler + start_watchers 已删除(触发文件机制已废弃)
|
||||
# Agent 仅保留心跳上报 + 健康接口
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("MultiSync Agent starting...")
|
||||
|
||||
# Start heartbeat
|
||||
logger.info(f"Nexus Agent starting (server_id={SERVER_ID})")
|
||||
asyncio.create_task(send_heartbeat())
|
||||
|
||||
host = config.get("server", {}).get("host", "0.0.0.0")
|
||||
@@ -241,13 +270,11 @@ async def startup():
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
logger.info("MultiSync Agent stopped")
|
||||
logger.info("Nexus Agent stopped")
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = config.get("server", {}).get("host", "0.0.0.0")
|
||||
port = config.get("server", {}).get("port", 8601)
|
||||
import uvicorn
|
||||
host = config["server"]["host"]
|
||||
port = config["server"]["port"]
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
|
||||
+12
-11
@@ -1,12 +1,13 @@
|
||||
#!/bin/bash
|
||||
# ================================================================
|
||||
# MultiSync Shell Agent — 心跳 + 健康接口
|
||||
# Nexus Shell Agent — 心跳 + 健康接口
|
||||
# 依赖: bash, curl
|
||||
# ================================================================
|
||||
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-30}"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/multisync-agent.log}"
|
||||
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-60}"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/nexus-agent.log}"
|
||||
CENTRAL_URL="${CENTRAL_URL:-}"
|
||||
CENTRAL_API_KEY="${CENTRAL_API_KEY:-}"
|
||||
SERVER_ID="${SERVER_ID:-0}"
|
||||
AGENT_PORT="${AGENT_PORT:-8601}"
|
||||
|
||||
log() {
|
||||
@@ -36,7 +37,7 @@ heartbeat_loop() {
|
||||
local info=$(get_system_info)
|
||||
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
|
||||
-H "X-API-Key: ${CENTRAL_API_KEY}" \
|
||||
-d "{\"agent_port\":${AGENT_PORT},\"system_info\":${info}}" \
|
||||
-d "{\"server_id\":${SERVER_ID},\"is_online\":true,\"system_info\":${info}}" \
|
||||
"${CENTRAL_URL}/api/agent/heartbeat" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
log "INFO" "心跳 OK"
|
||||
@@ -50,7 +51,7 @@ heartbeat_loop() {
|
||||
health_server() {
|
||||
while true; do
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
printf "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"status\":\"healthy\",\"agent\":\"shell\"}" \
|
||||
printf "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"status\":\"healthy\",\"agent\":\"nexus-shell\"}" \
|
||||
| nc -l -p "${AGENT_PORT}" -q 1 2>/dev/null
|
||||
else
|
||||
exec 3<>/dev/tcp/0.0.0.0/${AGENT_PORT} 2>/dev/null && {
|
||||
@@ -69,10 +70,11 @@ trap cleanup SIGINT SIGTERM
|
||||
|
||||
main() {
|
||||
echo "=========================================="
|
||||
echo " MultiSync Shell Agent — 心跳上报"
|
||||
echo " Nexus Shell Agent — 心跳上报"
|
||||
echo "=========================================="
|
||||
check_deps
|
||||
log "INFO" "中央服务器: ${CENTRAL_URL}"
|
||||
log "INFO" "服务器ID: ${SERVER_ID}"
|
||||
log "INFO" "心跳间隔: ${HEARTBEAT_INTERVAL}秒"
|
||||
|
||||
heartbeat_loop &
|
||||
@@ -88,10 +90,9 @@ main() {
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) bash "$(dirname "$0")/install.sh" "${@:2}" ;;
|
||||
stop) systemctl stop multisync-agent; echo "已停止" ;;
|
||||
restart) systemctl restart multisync-agent; echo "已重启" ;;
|
||||
status) systemctl status multisync-agent --no-pager 2>/dev/null || echo "未运行" ;;
|
||||
log|logs) journalctl -u multisync-agent -f --no-pager ;;
|
||||
stop) systemctl stop nexus-agent; echo "已停止" ;;
|
||||
restart) systemctl restart nexus-agent; echo "已重启" ;;
|
||||
status) systemctl status nexus-agent --no-pager 2>/dev/null || echo "未运行" ;;
|
||||
log|logs) journalctl -u nexus-agent -f --no-pager ;;
|
||||
*) main ;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"server_id": 0,
|
||||
"central": {
|
||||
"url": "https://YOUR_NEXUS_DOMAIN",
|
||||
"api_key": "YOUR_API_KEY"
|
||||
},
|
||||
"api_key": "YOUR_API_KEY",
|
||||
"heartbeat_interval": 60,
|
||||
"log_file": "/var/log/nexus-agent.log",
|
||||
"log_level": "INFO",
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8601
|
||||
}
|
||||
}
|
||||
+27
-24
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# ================================================================
|
||||
# MultiSync Python Agent Install Script
|
||||
# Usage: curl -fsSL https://主服务器/agent/install.sh | bash -s -- --url https://主服务器 --key KEY
|
||||
# Nexus Agent Install Script
|
||||
# Usage: curl -fsSL https://YOUR_NEXUS_DOMAIN/agent/install.sh | bash -s -- --url https://YOUR_NEXUS_DOMAIN --key API_KEY --id SERVER_ID
|
||||
# ================================================================
|
||||
|
||||
set -e
|
||||
@@ -9,30 +9,32 @@ set -e
|
||||
CENTRAL_URL=""
|
||||
WEB_URL=""
|
||||
API_KEY=""
|
||||
SERVER_ID=""
|
||||
AGENT_PORT="8601"
|
||||
WATCH_DIRS="/var/www/html"
|
||||
INSTALL_DIR="/opt/multisync-agent"
|
||||
INSTALL_DIR="/opt/nexus-agent"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--url) CENTRAL_URL="$2"; shift 2 ;;
|
||||
--web-url) WEB_URL="$2"; shift 2 ;;
|
||||
--key) API_KEY="$2"; shift 2 ;;
|
||||
--id) SERVER_ID="$2"; shift 2 ;;
|
||||
--port) AGENT_PORT="$2"; shift 2 ;;
|
||||
--dirs) WATCH_DIRS="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -z "$CENTRAL_URL" ] && echo "ERROR: --url is required" && exit 1
|
||||
[ -z "$SERVER_ID" ] && echo "ERROR: --id is required (server ID from Nexus dashboard)" && exit 1
|
||||
[ -z "$API_KEY" ] && echo "ERROR: --key is required (API key from Nexus settings)" && exit 1
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " MultiSync Python Agent Install"
|
||||
echo " Nexus Agent Install"
|
||||
echo "=========================================="
|
||||
echo " Server: $CENTRAL_URL"
|
||||
echo " Dirs: $WATCH_DIRS"
|
||||
echo " Port: $AGENT_PORT"
|
||||
echo " Server: $CENTRAL_URL"
|
||||
echo " ID: $SERVER_ID"
|
||||
echo " Port: $AGENT_PORT"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
@@ -56,7 +58,7 @@ mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# 3. Download agent.py
|
||||
echo "[3/5] Download agent.py..."
|
||||
[ -z "$WEB_URL" ] && WEB_URL="$(echo "$CENTRAL_URL" | sed 's|:[0-9]\+||; s|^http://|https://|')"
|
||||
[ -z "$WEB_URL" ] && WEB_URL="${CENTRAL_URL}"
|
||||
curl -fsSL "${WEB_URL}/agent/agent.py" -o "$INSTALL_DIR/agent.py" || {
|
||||
echo "ERROR: Cannot download from $WEB_URL/agent/agent.py"
|
||||
exit 1
|
||||
@@ -66,21 +68,22 @@ curl -fsSL "${WEB_URL}/agent/agent.py" -o "$INSTALL_DIR/agent.py" || {
|
||||
echo "[4/5] Write config..."
|
||||
cat > "$INSTALL_DIR/config.json" << EOF
|
||||
{
|
||||
"server_id": ${SERVER_ID},
|
||||
"central": { "url": "${CENTRAL_URL}", "api_key": "${API_KEY}" },
|
||||
"api_key": "${API_KEY}",
|
||||
"heartbeat_interval": 30,
|
||||
"watch_dirs": "${WATCH_DIRS}",
|
||||
"log_file": "/var/log/multisync-agent.log",
|
||||
"log_level": "INFO"
|
||||
"heartbeat_interval": 60,
|
||||
"log_file": "/var/log/nexus-agent.log",
|
||||
"log_level": "INFO",
|
||||
"server": { "host": "0.0.0.0", "port": ${AGENT_PORT} }
|
||||
}
|
||||
EOF
|
||||
|
||||
# 5. systemd + firewall + start
|
||||
echo "[5/5] Setup service + firewall + start..."
|
||||
|
||||
cat > /etc/systemd/system/multisync-agent.service << EOF
|
||||
cat > /etc/systemd/system/nexus-agent.service << EOF
|
||||
[Unit]
|
||||
Description=MultiSync Python Agent
|
||||
Description=Nexus Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
@@ -96,7 +99,6 @@ WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Firewall
|
||||
AGENT_PORT="${AGENT_PORT:-8601}"
|
||||
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q 'Status: active'; then
|
||||
ufw allow ${AGENT_PORT}/tcp 2>/dev/null && echo " ufw: opened ${AGENT_PORT}/tcp"
|
||||
elif command -v firewall-cmd &>/dev/null && firewall-cmd --state 2>/dev/null | grep -q 'running'; then
|
||||
@@ -109,12 +111,12 @@ else
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable multisync-agent
|
||||
systemctl start multisync-agent
|
||||
systemctl enable nexus-agent
|
||||
systemctl start nexus-agent
|
||||
sleep 2
|
||||
|
||||
STATUS="running"
|
||||
systemctl is-active --quiet multisync-agent || STATUS="FAILED"
|
||||
systemctl is-active --quiet nexus-agent || STATUS="FAILED"
|
||||
|
||||
HOSTNAME=$(hostname 2>/dev/null || echo "unknown")
|
||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
|
||||
@@ -124,10 +126,11 @@ echo "=========================================="
|
||||
echo " Done! Status: ${STATUS}"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo " Agent: http://${IP}:${AGENT_PORT}"
|
||||
echo " Agent: http://${IP}:${AGENT_PORT}"
|
||||
echo " Server ID: ${SERVER_ID}"
|
||||
echo ""
|
||||
echo " Commands:"
|
||||
echo " systemctl status multisync-agent"
|
||||
echo " journalctl -u multisync-agent -f"
|
||||
echo " systemctl restart multisync-agent"
|
||||
echo " systemctl status nexus-agent"
|
||||
echo " journalctl -u nexus-agent -f"
|
||||
echo " systemctl restart nexus-agent"
|
||||
echo ""
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* API Client - communicates with FastAPI backend
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
class ApiClient {
|
||||
private $baseUrl;
|
||||
private $apiKey;
|
||||
|
||||
public function __construct() {
|
||||
$this->baseUrl = API_BASE_URL;
|
||||
// Read API key from MySQL first (settings page), fallback to config constant
|
||||
$key = defined('API_KEY') ? API_KEY : '';
|
||||
try {
|
||||
require_once __DIR__ . '/db.php';
|
||||
$pdo = db()->getPDO();
|
||||
$stmt = $pdo->prepare("SELECT value FROM settings WHERE `key` = 'api_key'");
|
||||
$stmt->execute();
|
||||
$row = $stmt->fetch();
|
||||
if ($row && !empty($row['value'])) $key = $row['value'];
|
||||
} catch (Exception $e) {}
|
||||
$this->apiKey = $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API request
|
||||
*/
|
||||
public function request($method, $endpoint, $data = null) {
|
||||
$url = $this->baseUrl . $endpoint;
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'X-API-Key: ' . $this->apiKey,
|
||||
]);
|
||||
|
||||
switch (strtoupper($method)) {
|
||||
case 'POST':
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if ($data) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
}
|
||||
break;
|
||||
case 'PUT':
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
|
||||
if ($data) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
}
|
||||
break;
|
||||
case 'DELETE':
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
break;
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
return ['error' => "Connection error: $error", 'code' => 0];
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
return ['data' => $decoded, 'code' => $httpCode];
|
||||
}
|
||||
|
||||
// --- Servers ---
|
||||
|
||||
public function getServers() {
|
||||
return $this->request('GET', '/api/servers/');
|
||||
}
|
||||
|
||||
public function getServer($id) {
|
||||
return $this->request('GET', "/api/servers/$id");
|
||||
}
|
||||
|
||||
public function createServer($data) {
|
||||
return $this->request('POST', '/api/servers/', $data);
|
||||
}
|
||||
|
||||
public function updateServer($id, $data) {
|
||||
return $this->request('PUT', "/api/servers/$id", $data);
|
||||
}
|
||||
|
||||
public function deleteServer($id) {
|
||||
return $this->request('DELETE', "/api/servers/$id");
|
||||
}
|
||||
|
||||
public function checkServerHealth($id) {
|
||||
return $this->request('POST', "/api/servers/$id/check");
|
||||
}
|
||||
|
||||
// --- Push ---
|
||||
|
||||
public function pushServers($sourcePath, $serverIds) {
|
||||
return $this->request('POST', '/api/servers/push', [
|
||||
'source_path' => $sourcePath,
|
||||
'server_ids' => $serverIds,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Logs ---
|
||||
|
||||
public function getAllLogs($limit = 100) {
|
||||
return $this->request('GET', "/api/servers/logs?limit=$limit");
|
||||
}
|
||||
|
||||
// --- Audit ---
|
||||
|
||||
public function getAuditLogs($params = []) {
|
||||
$query = http_build_query($params);
|
||||
return $this->request('GET', "/api/servers/audit-logs?$query");
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
public function getDashboardStats() {
|
||||
// SQL COUNT — no need to fetch all 2000 servers
|
||||
$statsResp = $this->request('GET', '/api/servers/stats');
|
||||
$stats = $statsResp['data'] ?? [];
|
||||
$logsResp = $this->getAllLogs(10);
|
||||
$recentLogs = $logsResp['data'] ?? [];
|
||||
|
||||
return [
|
||||
'total_servers' => $stats['total_servers'] ?? 0,
|
||||
'online_servers' => $stats['online_servers'] ?? 0,
|
||||
'offline_servers' => $stats['offline_servers'] ?? 0,
|
||||
'push_ready' => $stats['push_ready'] ?? 0,
|
||||
'recent_logs' => is_array($recentLogs) ? $recentLogs : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
function api() {
|
||||
static $client = null;
|
||||
if ($client === null) {
|
||||
$client = new ApiClient();
|
||||
}
|
||||
return $client;
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* API Proxy - forwards requests from frontend to FastAPI backend
|
||||
* This handles CORS and provides a single point for frontend API calls
|
||||
*/
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/api_client.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$serverHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
// 仅当请求 Origin 与自身 Host 一致时允许跨域(支持换域名)
|
||||
header('Access-Control-Allow-Origin: ' . ($origin && strpos($origin, $serverHost) !== false ? $origin : 'null'));
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Auth check: return 401 JSON for unauthenticated API requests.
|
||||
// Note: auth.php (line 6) already checks $_SESSION['logged_in'] and exits
|
||||
// with a JSON 401 for AJAX requests. This is a belt-and-suspenders guard
|
||||
// so that even if auth.php's behavior changes, API endpoints remain protected.
|
||||
if (empty($_SESSION['logged_in'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['code' => 401, 'error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
// 路径安全:限制文件操作在允许目录内(防路径穿越)
|
||||
// 路径不存在时,检查父目录是否在允许范围内(支持新建/重命名操作)
|
||||
define('FM_ALLOWED_PATHS', ['/www/wwwroot', '/www/backup', '/tmp', '/www']);
|
||||
function safePath($p) {
|
||||
$real = @realpath($p);
|
||||
if ($real) {
|
||||
foreach (FM_ALLOWED_PATHS as $prefix) {
|
||||
$rb = @realpath($prefix);
|
||||
if ($rb && strpos($real . '/', $rb . '/') === 0) return $real;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 路径不存在:校验父目录
|
||||
$parent = dirname($p);
|
||||
$realParent = @realpath($parent);
|
||||
if (!$realParent) return null;
|
||||
foreach (FM_ALLOWED_PATHS as $prefix) {
|
||||
$rb = @realpath($prefix);
|
||||
if ($rb && strpos($realParent . '/', $rb . '/') === 0) return $p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- File Manager Actions (for source directory browsing) ---
|
||||
$fmActions = ['browse', 'upload', 'mkdir', 'mkfile', 'rename', 'delete_file'];
|
||||
if (in_array($action, $fmActions)) {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($action === 'browse') { /* handled below */ }
|
||||
elseif ($action === 'upload') {
|
||||
$path = safePath($_POST['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
|
||||
$uploaded = [];
|
||||
foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmp) {
|
||||
$name = $_FILES['files']['name'][$i] ?? 'unknown';
|
||||
$dest = rtrim($path, '/') . '/' . basename($name);
|
||||
if (move_uploaded_file($tmp, $dest)) $uploaded[] = $name;
|
||||
}
|
||||
echo json_encode(['code' => 200, 'uploaded' => $uploaded], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
elseif ($action === 'mkdir') {
|
||||
$base = safePath($input['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
|
||||
$p = $base . '/' . basename($input['name'] ?? 'new-folder');
|
||||
if (mkdir($p, 0755, true)) echo json_encode(['code' => 200]); else echo json_encode(['code' => 500, 'error' => '创建失败']);
|
||||
exit;
|
||||
}
|
||||
elseif ($action === 'mkfile') {
|
||||
$base = safePath($input['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
|
||||
$p = $base . '/' . basename($input['name'] ?? 'new-file');
|
||||
if (file_put_contents($p, '') !== false) echo json_encode(['code' => 200]); else echo json_encode(['code' => 500, 'error' => '创建失败']);
|
||||
exit;
|
||||
}
|
||||
elseif ($action === 'rename') {
|
||||
$old = safePath($input['path'] ?? '') ?? $input['path'];
|
||||
$new = dirname($old) . '/' . basename($input['name'] ?? '');
|
||||
if ($old && $new && safePath(dirname($new)) && rename($old, $new)) echo json_encode(['code' => 200]); else echo json_encode(['code' => 500, 'error' => '重命名失败']);
|
||||
exit;
|
||||
}
|
||||
elseif ($action === 'delete_file') {
|
||||
$p = safePath($input['path'] ?? '') ?? $input['path'];
|
||||
if ($p && is_dir($p)) {
|
||||
// 递归删除目录
|
||||
$it = new RecursiveDirectoryIterator($p, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
$file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath());
|
||||
}
|
||||
$ok = rmdir($p);
|
||||
} else {
|
||||
$ok = unlink($p);
|
||||
}
|
||||
echo json_encode(['code' => $ok ? 200 : 500, 'error' => $ok ? '' : '删除失败']);
|
||||
exit;
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = null;
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
// --- Server actions ---
|
||||
case 'get_servers':
|
||||
$result = api()->getServers();
|
||||
break;
|
||||
case 'get_server':
|
||||
$id = $_GET['id'] ?? null;
|
||||
$result = api()->getServer($id);
|
||||
break;
|
||||
case 'check_server':
|
||||
$id = $_GET['id'] ?? null;
|
||||
$result = api()->checkServerHealth($id);
|
||||
break;
|
||||
case 'check_all':
|
||||
$servers = api()->getServers()['data'] ?? [];
|
||||
// Concurrent health checks using curl_multi
|
||||
$handles = [];
|
||||
$mh = curl_multi_init();
|
||||
foreach ($servers as $s) {
|
||||
$ch = curl_init(API_BASE . '/api/servers/' . $s['id'] . '/health');
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
|
||||
if (API_KEY) curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . API_KEY]);
|
||||
curl_multi_add_handle($mh, $ch);
|
||||
$handles[$s['id']] = $ch;
|
||||
}
|
||||
// Execute all requests concurrently
|
||||
do { $status = curl_multi_exec($mh, $active); } while ($status === CURLM_CALL_MULTI_PERFORM);
|
||||
while ($active && $status === CURLM_OK) {
|
||||
curl_multi_select($mh, 1);
|
||||
do { $status = curl_multi_exec($mh, $active); } while ($status === CURLM_CALL_MULTI_PERFORM);
|
||||
}
|
||||
// Collect results
|
||||
$results = [];
|
||||
foreach ($servers as $s) {
|
||||
$ch = $handles[$s['id']];
|
||||
$resp = curl_multi_getcontent($ch);
|
||||
$data = json_decode($resp, true) ?: ['server_id' => $s['id'], 'is_online' => false];
|
||||
$results[] = $data;
|
||||
curl_multi_remove_handle($mh, $ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
curl_multi_close($mh);
|
||||
$result = ['data' => $results, 'code' => 200];
|
||||
break;
|
||||
|
||||
// --- Push ---
|
||||
case 'push_servers':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$result = api()->pushServers($input['source_path'] ?? '', $input['server_ids'] ?? []);
|
||||
break;
|
||||
|
||||
// --- Logs ---
|
||||
case 'get_logs':
|
||||
$limit = (int)($_GET['limit'] ?? 100);
|
||||
$result = api()->getAllLogs($limit);
|
||||
break;
|
||||
|
||||
// --- Dashboard ---
|
||||
case 'dashboard':
|
||||
$result = ['data' => api()->getDashboardStats(), 'code' => 200];
|
||||
break;
|
||||
|
||||
// --- File Browser (local source directory) ---
|
||||
case 'browse':
|
||||
$path = safePath($_GET['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
|
||||
$path = rtrim($path, '/') ?: '/';
|
||||
if (!is_dir($path)) { $path = '/www/wwwroot'; }
|
||||
$parent = dirname($path);
|
||||
if ($parent === $path) $parent = null;
|
||||
|
||||
$entries = [];
|
||||
$dirs = []; $files = [];
|
||||
foreach (scandir($path) as $name) {
|
||||
if ($name === '.') continue;
|
||||
if ($name === '..') { $dirs[] = ['name'=>'..','path'=>$parent,'type'=>'parent']; continue; }
|
||||
$full = $path . '/' . $name;
|
||||
$isDir = is_dir($full);
|
||||
if ($name[0] === '.') continue;
|
||||
$stat = stat($full);
|
||||
$entry = [
|
||||
'name' => $name,
|
||||
'path' => str_replace('//', '/', $full),
|
||||
'type' => $isDir ? 'dir' : 'file',
|
||||
'size' => $isDir ? 0 : $stat['size'],
|
||||
'mtime' => date('Y-m-d H:i', $stat['mtime']),
|
||||
'mtime_ts' => $stat['mtime'],
|
||||
'perms' => substr(sprintf('%o', $stat['mode']), -3),
|
||||
'owner' => function_exists('posix_getpwuid') ? posix_getpwuid($stat['uid'])['name'] ?? $stat['uid'] : $stat['uid'],
|
||||
'group' => function_exists('posix_getgrgid') ? posix_getgrgid($stat['gid'])['name'] ?? $stat['gid'] : $stat['gid'],
|
||||
];
|
||||
if ($isDir) { $dirs[] = $entry; } else { $files[] = $entry; }
|
||||
}
|
||||
usort($dirs, fn($a,$b) => strcasecmp($a['name'], $b['name']));
|
||||
usort($files, fn($a,$b) => strcasecmp($a['name'], $b['name']));
|
||||
$entries = array_merge($dirs, $files);
|
||||
$result = ['path' => $path, 'parent' => $parent, 'entries' => $entries, 'code' => 200];
|
||||
break;
|
||||
|
||||
case 'file_search':
|
||||
$type = $_POST['type'] ?? 'content';
|
||||
$text = $_POST['text'] ?? '';
|
||||
$dir = $_POST['dir'] ?? '/www/wwwroot';
|
||||
$ext = $_POST['ext'] ?? '';
|
||||
$dir = safePath($dir) ?? '/www/wwwroot';
|
||||
|
||||
if (empty($text)) { $result = ['code'=>400, 'error'=>'请输入搜索内容']; break; }
|
||||
|
||||
$results = [];
|
||||
if ($type === 'filename') {
|
||||
// Search file names
|
||||
$cmd = "find " . escapeshellarg($dir) . " -type f -name '*" . escapeshellarg($text) . "*' 2>/dev/null | head -500";
|
||||
if ($ext) {
|
||||
$cmd = "find " . escapeshellarg($dir) . " -type f -name '*" . escapeshellarg($text) . "*' -name '*." . escapeshellarg($ext) . "' 2>/dev/null | head -500";
|
||||
}
|
||||
exec($cmd, $lines);
|
||||
foreach ($lines as $line) {
|
||||
$results[] = ['file' => $line, 'line' => '', 'content' => ''];
|
||||
}
|
||||
} else {
|
||||
// Search file contents (grep)
|
||||
if ($ext && !preg_match('/^[a-zA-Z0-9]+$/', $ext)) {
|
||||
$result = ['code' => 400, 'error' => 'Invalid file extension'];
|
||||
break;
|
||||
}
|
||||
$extPattern = $ext ? "--include=*.$ext" : "--include=*.{php,py,js,html,css,json,md,txt,log,sh,sql,yml,yaml,xml,conf,ini,env,htaccess}";
|
||||
$cmd = "grep -rnI --exclude-dir={.git,node_modules,__pycache__,vendor,.cache} $extPattern " . escapeshellarg($text) . " " . escapeshellarg($dir) . " 2>/dev/null | head -500";
|
||||
exec($cmd, $lines);
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^(.+?):(\d+):(.*)$/', $line, $m)) {
|
||||
$results[] = ['file' => $m[1], 'line' => $m[2], 'content' => htmlspecialchars(substr($m[3], 0, 200))];
|
||||
}
|
||||
}
|
||||
}
|
||||
$result = ['code' => 200, 'results' => $results, 'count' => count($results)];
|
||||
break;
|
||||
|
||||
case 'get_public_ip':
|
||||
// 从服务端获取公网 IP(浏览器端会拿到用户自己的 IP)
|
||||
$ip = null;
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 3]]);
|
||||
$resp = @file_get_contents('https://api.ipify.org?format=json', false, $ctx);
|
||||
if ($resp) { $data = json_decode($resp, true); $ip = $data['ip'] ?? null; }
|
||||
if (!$ip) {
|
||||
$resp = @file_get_contents('https://httpbin.org/ip', false, $ctx);
|
||||
if ($resp) { $data = json_decode($resp, true); $ip = $data['origin'] ?? null; }
|
||||
}
|
||||
if (!$ip) { $ip = $_SERVER['SERVER_ADDR'] ?? '未知'; }
|
||||
$result = ['code' => 200, 'ip' => $ip];
|
||||
break;
|
||||
|
||||
case 'restart_multisync':
|
||||
exec('sudo systemctl restart multisync 2>&1', $out, $code);
|
||||
$result = ['code' => $code === 0 ? 200 : 500, 'message' => $code === 0 ? 'Restarted' : implode("\n", $out)];
|
||||
break;
|
||||
|
||||
// === File Manager v2 API ===
|
||||
|
||||
case 'file_list':
|
||||
$path = safePath($_POST['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
|
||||
$sort = $_POST['sort'] ?? 'name';
|
||||
$order = $_POST['order'] ?? 'asc';
|
||||
$search = $_POST['search'] ?? '';
|
||||
$page = max(1, intval($_POST['page'] ?? 1));
|
||||
$perPage = 100;
|
||||
if (!in_array($sort, ['name','size','mtime'])) $sort = 'name';
|
||||
if ($order !== 'desc') $order = 'asc';
|
||||
|
||||
$entries = [];
|
||||
foreach (scandir($path) as $name) {
|
||||
if ($name === '.' || $name === '..') continue;
|
||||
if ($search && stripos($name, $search) === false) continue;
|
||||
$full = $path . '/' . $name;
|
||||
$isDir = is_dir($full);
|
||||
$stat = stat($full);
|
||||
$entries[] = [
|
||||
'name' => $name, 'type' => $isDir ? 'dir' : 'file',
|
||||
'size' => $isDir ? 0 : $stat['size'],
|
||||
'mtime' => $stat['mtime'], 'mtime_str' => date('Y-m-d H:i', $stat['mtime']),
|
||||
'perms' => substr(sprintf('%o', $stat['mode']), -3),
|
||||
];
|
||||
}
|
||||
// Sort
|
||||
usort($entries, function($a, $b) use ($sort, $order) {
|
||||
if ($a['type'] !== $b['type']) return $a['type'] === 'dir' ? -1 : 1;
|
||||
$va = $a[$sort]; $vb = $b[$sort];
|
||||
$cmp = is_numeric($va) ? $va - $vb : strcasecmp($va, $vb);
|
||||
return $order === 'desc' ? -$cmp : $cmp;
|
||||
});
|
||||
$total = count($entries);
|
||||
$entries = array_slice($entries, ($page - 1) * $perPage, $perPage);
|
||||
$result = ['code' => 200, 'path' => $path,
|
||||
'parent' => dirname($path) === $path ? null : dirname($path),
|
||||
'entries' => $entries, 'total' => $total, 'page' => $page];
|
||||
break;
|
||||
|
||||
case 'file_read':
|
||||
$path = safePath($_POST['path'] ?? '') ?? '';
|
||||
if (!$path || !is_file($path)) { $result = ['code' => 404, 'error' => 'File not found']; break; }
|
||||
$size = filesize($path);
|
||||
$maxRead = 3 * 1024 * 1024; // 3MB
|
||||
if ($size > $maxRead) {
|
||||
$content = file_get_contents($path, false, null, $size - $maxRead, $maxRead);
|
||||
$content = "[... truncated, showing last 3MB ...]\n" . $content;
|
||||
} else {
|
||||
$content = file_get_contents($path);
|
||||
}
|
||||
$encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312'], true) ?: 'UTF-8';
|
||||
$result = ['code' => 200, 'content' => $content, 'encoding' => $encoding,
|
||||
'size' => $size, 'mtime' => filemtime($path), 'readonly' => !is_writable($path)];
|
||||
break;
|
||||
|
||||
case 'file_write':
|
||||
$path = safePath($_POST['path'] ?? '') ?? '';
|
||||
$content = $_POST['content'] ?? '';
|
||||
$encoding = $_POST['encoding'] ?? 'UTF-8';
|
||||
$mtime = intval($_POST['mtime'] ?? 0);
|
||||
if (!$path) { $result = ['code' => 400, 'error' => 'Invalid path']; break; }
|
||||
if ($mtime && file_exists($path) && filemtime($path) !== $mtime) {
|
||||
$result = ['code' => 409, 'error' => 'File modified by another process'];
|
||||
break;
|
||||
}
|
||||
if ($encoding !== 'UTF-8') $content = mb_convert_encoding($content, $encoding, 'UTF-8');
|
||||
file_put_contents($path, $content);
|
||||
$result = ['code' => 200, 'ok' => true, 'mtime' => filemtime($path)];
|
||||
break;
|
||||
|
||||
case 'file_create':
|
||||
$path = safePath($_POST['path'] ?? '') ?? '';
|
||||
$type = $_POST['type'] ?? 'file';
|
||||
if (!$path) { $result = ['code' => 400, 'error' => 'Invalid path']; break; }
|
||||
if ($type === 'dir') { mkdir($path, 0755, true); }
|
||||
else { file_put_contents($path, ''); }
|
||||
$result = ['code' => 200, 'ok' => true];
|
||||
break;
|
||||
|
||||
case 'file_delete':
|
||||
$path = safePath($_POST['path'] ?? '') ?? '';
|
||||
if (!$path || !file_exists($path)) { $result = ['code' => 404, 'error' => 'Not found']; break; }
|
||||
if (is_dir($path)) {
|
||||
$it = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) { $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); }
|
||||
rmdir($path);
|
||||
} else { unlink($path); }
|
||||
$result = ['code' => 200, 'ok' => true];
|
||||
break;
|
||||
|
||||
case 'file_rename':
|
||||
$old = safePath($_POST['old'] ?? '') ?? '';
|
||||
$new = safePath($_POST['new'] ?? '') ?? '';
|
||||
if (!$old || !$new || !file_exists($old)) { $result = ['code' => 404, 'error' => 'Not found']; break; }
|
||||
rename($old, $new);
|
||||
$result = ['code' => 200, 'ok' => true];
|
||||
break;
|
||||
|
||||
case 'file_copy':
|
||||
$src = safePath($_POST['src'] ?? '') ?? '';
|
||||
$dst = $_POST['dst'] ?? '';
|
||||
$dstSafe = safePath(dirname($dst)) ? $dst : null;
|
||||
if (!$src || !$dstSafe || !file_exists($src)) { $result = ['code' => 404, 'error' => 'Not found']; break; }
|
||||
if (is_dir($src)) {
|
||||
$it = new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
foreach (new RecursiveIteratorIterator($it, RecursiveIteratorIterator::SELF_FIRST) as $item) {
|
||||
$target = $dst . '/' . $it->getSubPathName();
|
||||
$item->isDir() ? mkdir($target, 0755, true) : copy($item, $target);
|
||||
}
|
||||
} else { copy($src, $dst); }
|
||||
$result = ['code' => 200, 'ok' => true];
|
||||
break;
|
||||
|
||||
case 'file_chmod':
|
||||
$path = safePath($_POST['path'] ?? '') ?? '';
|
||||
$mode = $_POST['mode'] ?? '0755';
|
||||
// 仅允许安全的权限掩码
|
||||
$allowedModes = ['0755', '0644', '0750', '0640', '0700', '0600'];
|
||||
if (!in_array($mode, $allowedModes)) {
|
||||
$result = ['code' => 400, 'error' => 'Permission mode not allowed'];
|
||||
break;
|
||||
}
|
||||
if (!$path || !file_exists($path)) { $result = ['code' => 404, 'error' => 'Not found']; break; }
|
||||
chmod($path, octdec($mode));
|
||||
$result = ['code' => 200, 'ok' => true];
|
||||
break;
|
||||
|
||||
case 'file_upload':
|
||||
$dir = safePath($_POST['dir'] ?? '/www/wwwroot') ?? '/www/wwwroot';
|
||||
$uploaded = []; $failed = [];
|
||||
$maxSize = 500 * 1024 * 1024; // 500MB
|
||||
foreach ($_FILES as $f) {
|
||||
if ($f['error'] ?? 0) { $failed[] = ['name' => $f['name'] ?? '?', 'error' => 'Upload error']; continue; }
|
||||
if ($f['size'] > $maxSize) { $failed[] = ['name' => $f['name'] ?? '?', 'error' => 'File too large (max 500MB)']; continue; }
|
||||
$dest = rtrim($dir, '/') . '/' . basename($f['name'] ?? 'file');
|
||||
if (move_uploaded_file($f['tmp_name'], $dest)) $uploaded[] = basename($dest);
|
||||
else $failed[] = ['name' => $f['name'] ?? '?', 'error' => 'Move failed'];
|
||||
}
|
||||
$result = ['code' => 200, 'uploaded' => $uploaded, 'failed' => $failed];
|
||||
break;
|
||||
|
||||
case 'file_compress':
|
||||
$dir = safePath($_POST['dir'] ?? '') ?? '';
|
||||
$name = $_POST['name'] ?? 'archive';
|
||||
$paths = $_POST['paths'] ?? [];
|
||||
if (!$dir || empty($paths)) { $result = ['code' => 400, 'error' => 'Invalid params']; break; }
|
||||
$zipFile = rtrim($dir, '/') . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $name) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
$result = ['code' => 500, 'error' => 'Cannot create archive']; break;
|
||||
}
|
||||
foreach ((array)$paths as $p) {
|
||||
$sp = safePath($p) ?? $p;
|
||||
if (is_dir($sp)) {
|
||||
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($sp, RecursiveDirectoryIterator::SKIP_DOTS));
|
||||
foreach ($it as $f) { $zip->addFile($f->getRealPath(), substr($f->getRealPath(), strlen(dirname($sp)) + 1)); }
|
||||
} elseif (is_file($sp)) {
|
||||
$zip->addFile($sp, basename($sp));
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
$result = ['code' => 200, 'ok' => true, 'file' => $zipFile];
|
||||
break;
|
||||
|
||||
// === End File Manager v2 ===
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
$result = ['error' => 'Unknown action', 'code' => 400];
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
$result = ['error' => $e->getMessage(), 'code' => 500];
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* MultiSync - Frontend JavaScript
|
||||
*/
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
*/
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideIn 0.3s ease reverse';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm dialog with callback
|
||||
*/
|
||||
function confirmAction(message, callback) {
|
||||
if (confirm(message)) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable
|
||||
*/
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human readable
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds/60)}分${seconds%60}秒`;
|
||||
return `${Math.floor(seconds/3600)}时${Math.floor((seconds%3600)/60)}分`;
|
||||
}
|
||||
|
||||
/**
|
||||
* API proxy for AJAX calls
|
||||
*/
|
||||
async function apiCall(endpoint, method = 'GET', data = null) {
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
if (data) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`api_proxy.php?action=${endpoint}`, options);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
showToast('请求失败: ' + error.message, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state management
|
||||
*/
|
||||
function setLoading(element, loading) {
|
||||
if (loading) {
|
||||
element.disabled = true;
|
||||
element.dataset.originalText = element.innerHTML;
|
||||
element.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
|
||||
} else {
|
||||
element.disabled = false;
|
||||
element.innerHTML = element.dataset.originalText || element.innerHTML;
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// Nexus — Shared API auth module (JWT Bearer + auto-refresh)
|
||||
// Include before page-specific scripts: <script src="/app/api.js"></script>
|
||||
|
||||
const API = window.location.origin + '/api';
|
||||
|
||||
function _getTokens() {
|
||||
return {
|
||||
access: localStorage.getItem('access_token') || '',
|
||||
refresh: localStorage.getItem('refresh_token') || '',
|
||||
expires: parseInt(localStorage.getItem('token_expires') || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
function _isTokenExpiring() {
|
||||
// Refresh if token expires within 2 minutes
|
||||
return Date.now() > _getTokens().expires - 120000;
|
||||
}
|
||||
|
||||
async function _refreshTokens() {
|
||||
const { refresh } = _getTokens();
|
||||
if (!refresh) return false;
|
||||
try {
|
||||
const res = await fetch(API + '/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refresh }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
if (!data.success) return false;
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token || refresh);
|
||||
localStorage.setItem('token_expires', String(Date.now() + (data.expires_in || 1800) * 1000));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function apiHeaders() {
|
||||
const { access } = _getTokens();
|
||||
return { 'Authorization': 'Bearer ' + access };
|
||||
}
|
||||
|
||||
function apiHeadersJSON() {
|
||||
return { ...apiHeaders(), 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
// Auto-refresh if token is about to expire
|
||||
if (_isTokenExpiring()) {
|
||||
const refreshed = await _refreshTokens();
|
||||
if (!refreshed) {
|
||||
_logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add auth headers
|
||||
options.headers = { ...apiHeaders(), ...(options.headers || {}) };
|
||||
|
||||
const res = await fetch(url, options);
|
||||
|
||||
// If 401, try refresh once
|
||||
if (res.status === 401) {
|
||||
const refreshed = await _refreshTokens();
|
||||
if (refreshed) {
|
||||
options.headers = { ...apiHeaders(), ...(options.headers || {}) };
|
||||
return fetch(url, options);
|
||||
}
|
||||
_logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function _logout() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('token_expires');
|
||||
localStorage.removeItem('admin');
|
||||
window.location.href = '/app/login.html';
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
// Notify backend (best-effort, don't block)
|
||||
const { access, refresh } = _getTokens();
|
||||
if (access) {
|
||||
try {
|
||||
const blob = new Blob(
|
||||
[JSON.stringify({ refresh_token: refresh })],
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
navigator.sendBeacon(API + '/auth/logout', blob);
|
||||
} catch {}
|
||||
}
|
||||
_logout();
|
||||
}
|
||||
|
||||
// Backward-compatible alias
|
||||
const token = localStorage.getItem('access_token') || '';
|
||||
if (!token) window.location.href = '/app/login.html';
|
||||
function ah() { return apiHeaders(); }
|
||||
+40
-13
@@ -1,25 +1,52 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 审计日志</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><a href="/app/index.html" class="text-white font-bold text-lg">Nexus</a></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/audit.html" class="block px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">📋 审计日志</a><a href="/app/settings.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">审计日志</h1></div></header>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">审计日志</h1></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<select id="actionFilter" onchange="loadAudit()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部操作</option>
|
||||
<option value="create_server">添加服务器</option>
|
||||
<option value="update_server">更新服务器</option>
|
||||
<option value="delete_server">删除服务器</option>
|
||||
<option value="sync_files">文件推送</option>
|
||||
<option value="sync_commands">命令执行</option>
|
||||
<option value="update_setting">修改设置</option>
|
||||
<option value="login">登录</option>
|
||||
</select>
|
||||
<button onclick="loadAudit()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="auditTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs"><tr><th class="text-left px-4 py-3">操作人</th><th class="text-left px-4 py-3">操作</th><th class="text-left px-4 py-3">目标</th><th class="text-left px-4 py-3">详情</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">操作人</th><th class="text-left px-4 py-3">操作</th><th class="text-left px-4 py-3">目标</th><th class="text-left px-4 py-3">详情</th><th class="text-left px-4 py-3">IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(){return{'Authorization':'Bearer '+token}}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
initLayout('audit');
|
||||
async function loadAudit(){
|
||||
const r=await fetch(API+'/audit/?limit=100',{headers:ah()});
|
||||
const action=document.getElementById('actionFilter').value;
|
||||
const url=API+'/audit/?limit=200'+(action?'&action='+encodeURIComponent(action):'');
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const logs=await r.json();const tbody=document.getElementById('auditTbody');
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无日志</td></tr>';return}
|
||||
tbody.innerHTML=logs.map(l=>`<tr class="hover:bg-slate-800/30"><td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':'bg-slate-800 text-slate-300'}">${esc(l.action)}</span></td><td class="px-4 py-3 text-slate-400">${esc(l.target_type||'--')}</td><td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate">${esc((l.detail||'').substring(0,80))}</td><td class="px-4 py-3 text-slate-500 text-xs text-right">${fmt(l.created_at)}</td></tr>`).join('')
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">暂无日志</td></tr>';return}
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')?'bg-blue-400/10 text-blue-400':'bg-slate-800 text-slate-300';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td>
|
||||
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${badge}">${esc(l.action)}</span></td>
|
||||
<td class="px-4 py-3 text-slate-400 text-xs">${esc(l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
|
||||
<td class="px-4 py-3 text-slate-600 text-xs">${esc(l.ip_address||'--')}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`}).join('')
|
||||
}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN')}
|
||||
loadAudit()
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
||||
loadAudit();
|
||||
</script>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
+173
-11
@@ -1,15 +1,177 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 凭据管理</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><span class="text-white font-bold text-lg">Nexus</span></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">🔑 凭据</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><h1 class="text-white font-semibold">凭据管理</h1></header>
|
||||
<main class="flex-1 overflow-y-auto p-6"><div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div></main>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
|
||||
<nav class="flex-1 py-3 px-3 space-y-1">
|
||||
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a>
|
||||
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
|
||||
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
|
||||
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
|
||||
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
|
||||
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">🔑 凭据</a>
|
||||
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
|
||||
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
|
||||
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a>
|
||||
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a>
|
||||
</nav>
|
||||
<div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div>
|
||||
</aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">凭据管理</h1></div>
|
||||
<button onclick="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
||||
</header>
|
||||
<!-- Tabs -->
|
||||
<div class="bg-slate-900 border-b border-slate-800 px-6 flex">
|
||||
<button onclick="switchTab('db')" id="tabDB" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">数据库凭据</button>
|
||||
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">密码预设</button>
|
||||
</div>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<!-- DB Credentials -->
|
||||
<div id="dbSection">
|
||||
<div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
<!-- Password Presets -->
|
||||
<div id="presetSection" class="hidden">
|
||||
<div id="presetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Add DB Credential Modal -->
|
||||
<div id="credModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">新建数据库凭据</h2>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
</div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">主机</label><input id="credHost" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="root"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="credPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
</div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">数据库</label><input id="credDb" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="createCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Password Preset Modal -->
|
||||
<div id="presetModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">新建密码预设</h2>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="输入密码(将加密存储)"></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="createPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script>
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(){return{'Authorization':'Bearer '+token}}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
async function loadCreds(){const r=await fetch(API+'/presets/',{headers:ah()});const p=await r.json();document.getElementById('credsList').innerHTML=p.length?p.map(c=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><span class="text-white">${esc(c.name)}</span><span class="text-slate-500 text-xs ml-2">#${c.id}</span></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无凭据</div>'}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
loadCreds()
|
||||
const DB_ICONS={mysql:'🐬',postgresql:'🐘',mariadb:'🐬',mongodb:'🍃',redis:'🔴'};
|
||||
const DB_PORTS={mysql:3306,postgresql:5432,mariadb:3306,mongodb:27017,redis:6379};
|
||||
|
||||
function switchTab(t){
|
||||
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
||||
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
||||
document.getElementById('dbSection').classList.toggle('hidden',t!=='db');
|
||||
document.getElementById('presetSection').classList.toggle('hidden',t!=='preset');
|
||||
if(t==='preset')loadPresets();
|
||||
}
|
||||
|
||||
// ── DB Credentials ──
|
||||
async function loadCreds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
||||
const creds=await r.json();
|
||||
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">${DB_ICONS[c.db_type]||'🗄'}</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(c.name)}</span>
|
||||
<div class="text-slate-500 text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="deleteCred(${c.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无数据库凭据</div>';
|
||||
}catch(e){document.getElementById('credsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
function showAddCred(){
|
||||
const t=Alpine?Alpine.store?'db':document.querySelector('[x-data]')?.__x?.$data?.tab:'db':'db';
|
||||
// Check which tab is active
|
||||
if(!document.getElementById('dbSection').classList.contains('hidden')){
|
||||
['credName','credHost','credUser','credPass','credDb'].forEach(id=>document.getElementById(id).value='');
|
||||
document.getElementById('credType').value='mysql';
|
||||
document.getElementById('credPort').value='3306';
|
||||
document.getElementById('credModal').classList.remove('hidden');
|
||||
}else{
|
||||
document.getElementById('presetName').value='';
|
||||
document.getElementById('presetPass').value='';
|
||||
document.getElementById('presetModal').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
function hideCredModal(){document.getElementById('credModal').classList.add('hidden')}
|
||||
function hidePresetModal(){document.getElementById('presetModal').classList.add('hidden')}
|
||||
|
||||
document.getElementById('credType').addEventListener('change',()=>{
|
||||
const t=document.getElementById('credType').value;
|
||||
document.getElementById('credPort').value=DB_PORTS[t]||3306;
|
||||
});
|
||||
|
||||
async function createCred(){
|
||||
const body={name:document.getElementById('credName').value,db_type:document.getElementById('credType').value,host:document.getElementById('credHost').value,port:parseInt(document.getElementById('credPort').value)||3306,username:document.getElementById('credUser').value,password:document.getElementById('credPass').value,database:document.getElementById('credDb').value||null};
|
||||
if(!body.name||!body.host||!body.username||!body.password){alert('名称、主机、用户名、密码必填');return}
|
||||
await apiFetch(API+'/scripts/credentials',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
hideCredModal();loadCreds();
|
||||
}
|
||||
|
||||
async function deleteCred(id){
|
||||
if(!confirm('确定删除凭据?'))return;
|
||||
await apiFetch(API+'/scripts/credentials/'+id,{method:'DELETE'});
|
||||
loadCreds();
|
||||
}
|
||||
|
||||
// ── Password Presets ──
|
||||
async function loadPresets(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/presets/');if(!r)return;
|
||||
const presets=await r.json();
|
||||
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">🔑</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(p.name)}</span>
|
||||
<div class="text-slate-500 text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="deletePreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无密码预设</div>';
|
||||
}catch(e){document.getElementById('presetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
async function createPreset(){
|
||||
const name=document.getElementById('presetName').value;
|
||||
const pw=document.getElementById('presetPass').value;
|
||||
if(!name||!pw){alert('名称和密码必填');return}
|
||||
await apiFetch(API+'/presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({name,encrypted_pw:pw})});
|
||||
hidePresetModal();loadPresets();
|
||||
}
|
||||
|
||||
async function deletePreset(id){
|
||||
if(!confirm('确定删除密码预设?'))return;
|
||||
await apiFetch(API+'/presets/'+id,{method:'DELETE'});
|
||||
loadPresets();
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
|
||||
loadCreds();
|
||||
</script>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
+6
-7
@@ -1,15 +1,14 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 文件管理</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">📜 脚本库</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">文件管理</h1></div><div class="flex items-center gap-3"><select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option>-- 选择服务器 --</option></select><input id="dirPath" placeholder="/www/wwwroot/" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm w-64 placeholder-slate-500"><button onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">浏览</button></div></header>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a><a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">文件管理</h1></div><div class="flex items-center gap-3"><select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option>-- 选择服务器 --</option></select><input id="dirPath" placeholder="/" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm w-64 placeholder-slate-500"><button onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">浏览</button></div></header>
|
||||
<main class="flex-1 overflow-y-auto p-6"><div id="fileList" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden"><div class="px-4 py-8 text-center text-slate-500">选择服务器并输入目录路径</div></div></main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script>
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(){return{'Authorization':'Bearer '+token}}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
async function loadServers(){const r=await fetch(API+'/servers/',{headers:ah()});const servers=await r.json();document.getElementById('serverSelect').innerHTML='<option>-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${s.name} (${s.domain})</option>`).join('')}
|
||||
function browseDir(){const sid=document.getElementById('serverSelect').value;const dir=document.getElementById('dirPath').value||'/www/wwwroot/';document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">📂 浏览 '+dir+'</div>'}
|
||||
async function loadServers(){const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;document.getElementById('serverSelect').innerHTML='<option value="">-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${s.name} (${s.domain})</option>`).join('')}
|
||||
async function browseDir(path){if(path){document.getElementById('dirPath').value=path}const sid=document.getElementById('serverSelect').value;const dir=document.getElementById('dirPath').value||'/';if(!sid){alert('请先选择服务器');return}document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">加载中...</div>';try{const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:parseInt(sid),path:dir})});if(!r){document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-red-400">认证失败</div>';return}const d=await r.json();if(d.error){document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">${esc(d.error)}</div>`;return}document.getElementById('fileList').innerHTML=d.entries.length?d.entries.map(e=>`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${(dir.replace(/\/$/,'')+'/'+e.name).replace(/'/g,"\\'")}')"`:''}"><span class="${e.is_dir?'text-yellow-400':'text-slate-400'}">${e.is_dir?'📁':'📄'}</span><span class="text-white text-sm flex-1">${esc(e.name)}</span><span class="text-slate-500 text-xs">${e.size}</span><span class="text-slate-600 text-xs">${e.perms}</span></div>`).join(''):'<div class="px-4 py-8 text-center text-slate-500">空目录</div>'}catch(e){document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">连接失败: ${esc(e.message)}</div>`}}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
loadServers();
|
||||
</script>
|
||||
</body></html>
|
||||
+201
-47
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" x-data="{ darkMode: localStorage.getItem('darkMode') !== 'false', sidebarOpen: true }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => localStorage.setItem('darkMode', v))">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -11,20 +11,18 @@
|
||||
--color-brand: oklch(55% 0.2 250);
|
||||
--color-brand-light: oklch(75% 0.15 250);
|
||||
--color-brand-dark: oklch(35% 0.18 250);
|
||||
--color-surface: oklch(98.5% 0.002 250);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
|
||||
|
||||
<!-- Sidebar (F2) -->
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-5 py-4 border-b border-slate-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg>
|
||||
</div>
|
||||
<span class="text-white font-bold text-lg">Nexus</span>
|
||||
<span id="brandName" class="text-white font-bold text-lg">Nexus</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
|
||||
@@ -48,9 +46,7 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="text-slate-400 hover:text-white transition">
|
||||
@@ -59,18 +55,15 @@
|
||||
<h1 class="text-white font-semibold">仪表盘</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="darkMode = !darkMode" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">
|
||||
<span x-show="darkMode">🌙</span><span x-show="!darkMode">☀️</span>
|
||||
</button>
|
||||
<button onclick="refreshAll()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">刷新</button>
|
||||
<span id="headerUser" class="text-slate-400 text-sm">...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Dashboard Content (F4) -->
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
<!-- Stats -->
|
||||
<div id="statsRow" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="text-slate-400 text-sm mb-1">服务器总数</div>
|
||||
<div class="text-3xl font-bold text-white" id="statTotal">--</div>
|
||||
@@ -89,41 +82,136 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-white font-semibold mb-4">最近活动</h2>
|
||||
<div id="recentActivity" class="text-slate-400 text-sm">加载中...</div>
|
||||
<!-- Live Alerts + Category Pie -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Alerts -->
|
||||
<div class="lg:col-span-2">
|
||||
<div id="alertBanner" class="hidden bg-red-900/30 border border-red-700/50 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-red-300 font-semibold text-sm">实时告警</h2>
|
||||
<button onclick="clearAlerts()" class="text-red-400 hover:text-red-300 text-xs">清除</button>
|
||||
</div>
|
||||
<div id="alertList" class="space-y-1 max-h-40 overflow-y-auto text-sm"></div>
|
||||
</div>
|
||||
<!-- No alerts state -->
|
||||
<div id="noAlertBanner" class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center gap-2 text-slate-500 text-sm">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span>
|
||||
所有服务器运行正常
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Category Breakdown -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-white font-semibold mb-4 text-sm">分类分布</h2>
|
||||
<div id="categoryBars" class="space-y-3">
|
||||
<div class="text-slate-500 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sync + Recent Audit -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Recent Sync Logs -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近同步</h2>
|
||||
<a href="/app/push.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentSyncs" class="text-slate-400 text-sm">加载中...</div>
|
||||
</div>
|
||||
<!-- Recent Audit -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近活动</h2>
|
||||
<a href="/app/audit.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentActivity" class="text-slate-400 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/app/api.js"></script>
|
||||
<script>
|
||||
const API = window.location.origin + '/api';
|
||||
const token = getToken();
|
||||
const MAX_ALERTS = 20;
|
||||
let _alerts = [];
|
||||
let _ws = null;
|
||||
let _wsRetry = 0;
|
||||
let _stats = null;
|
||||
|
||||
// ── Load Dashboard ──
|
||||
// ── Dashboard Stats ──
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await fetch(API + '/servers/', { headers: authHeaders() });
|
||||
const servers = await res.json();
|
||||
const res = await apiFetch(API + '/servers/stats');
|
||||
if (!res) return;
|
||||
_stats = await res.json();
|
||||
document.getElementById('statTotal').textContent = _stats.total || 0;
|
||||
document.getElementById('statOnline').textContent = _stats.online || 0;
|
||||
document.getElementById('statOffline').textContent = _stats.offline || 0;
|
||||
document.getElementById('statAlerts').textContent = _stats.alerts || 0;
|
||||
// Category breakdown
|
||||
renderCategories(_stats.categories || {});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
const total = servers.length;
|
||||
const online = servers.filter(s => s.is_online).length;
|
||||
const offline = total - online;
|
||||
const alerts = online; // Simplified
|
||||
function renderCategories(categories) {
|
||||
const entries = Object.entries(categories);
|
||||
if (!entries.length) {
|
||||
document.getElementById('categoryBars').innerHTML = '<div class="text-slate-500 text-sm">暂无分类数据</div>';
|
||||
return;
|
||||
}
|
||||
const total = entries.reduce((s,[,v]) => s+v, 0);
|
||||
const colors = ['bg-brand','bg-green-500','bg-yellow-500','bg-purple-500','bg-cyan-500','bg-pink-500'];
|
||||
document.getElementById('categoryBars').innerHTML = entries.map(([cat,count],i) => {
|
||||
const pct = Math.round(count/total*100);
|
||||
return `<div>
|
||||
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-slate-500">${count}台 (${pct}%)</span></div>
|
||||
<div class="w-full bg-slate-800 rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('statTotal').textContent = total;
|
||||
document.getElementById('statOnline').textContent = online;
|
||||
document.getElementById('statOffline').textContent = offline;
|
||||
document.getElementById('statAlerts').textContent = '0';
|
||||
// ── Recent Syncs ──
|
||||
async function loadSyncs() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/servers/logs?limit=8');
|
||||
if (!res) return;
|
||||
const logs = await res.json();
|
||||
if (!logs.length) {
|
||||
document.getElementById('recentSyncs').innerHTML = '<div class="text-slate-500 text-sm">暂无同步记录</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentSyncs').innerHTML = logs.map(l => {
|
||||
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
||||
const icon = l.status==='success'?'✅':l.status==='failed'?'❌':'⏳';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-slate-300 text-xs">${esc(l.sync_mode||'sync')} #${l.server_id||'?'}</span><span class="${sc} text-xs">${esc(l.status)}</span></div>
|
||||
<span class="text-slate-600 text-xs">${fmtTime(l.started_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('recentSyncs').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// Recent activity from audit
|
||||
try {
|
||||
const auditRes = await fetch(API + '/audit/?limit=10', { headers: authHeaders() });
|
||||
const audits = await auditRes.json();
|
||||
const html = audits.map(a => `<div class="py-2 border-b border-slate-800 last:border-0"><span class="text-brand-light">${a.admin_username || 'system'}</span> <span class="text-slate-500">${a.action}</span> <span class="text-slate-600 text-xs float-right">${fmtTime(a.created_at)}</span></div>`).join('') || '<div class="text-slate-500">暂无活动</div>';
|
||||
document.getElementById('recentActivity').innerHTML = html;
|
||||
} catch(e) {}
|
||||
// ── Recent Activity ──
|
||||
async function loadActivity() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/audit/?limit=8');
|
||||
if (!res) return;
|
||||
const audits = await res.json();
|
||||
if (!audits.length) {
|
||||
document.getElementById('recentActivity').innerHTML = '<div class="text-slate-500 text-sm">暂无活动</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentActivity').innerHTML = audits.map(a => {
|
||||
const icon = a.action.includes('delete')?'🔴':a.action.includes('create')?'🔵':a.action.includes('login')?'🟢':'⚪';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-slate-500 text-xs">${esc(a.action)}</span></div>
|
||||
<span class="text-slate-600 text-xs">${fmtTime(a.created_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('recentActivity').textContent = '加载失败';
|
||||
}
|
||||
@@ -132,26 +220,92 @@
|
||||
// ── User Info ──
|
||||
async function loadUser() {
|
||||
try {
|
||||
const res = await fetch(API + '/auth/me', { headers: authHeaders() });
|
||||
if (!res.ok) { doLogout(); return; }
|
||||
const res = await apiFetch(API + '/auth/me');
|
||||
if (!res) { doLogout(); return; }
|
||||
const user = await res.json();
|
||||
document.getElementById('sidebarUser').textContent = user.username;
|
||||
document.getElementById('headerUser').textContent = user.username;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function getToken() {
|
||||
const t = localStorage.getItem('access_token');
|
||||
if (!t) { window.location.href = '/app/login.html'; return ''; }
|
||||
return t;
|
||||
// ── Brand Name ──
|
||||
function loadBrand() {
|
||||
const admin = localStorage.getItem('admin');
|
||||
if (admin) {
|
||||
try { const a = JSON.parse(admin); if (a.system_name) document.getElementById('brandName').textContent = a.system_name; } catch(e) {}
|
||||
}
|
||||
}
|
||||
function authHeaders() { return { 'Authorization': `Bearer ${token}` }; }
|
||||
function fmtTime(t) { if (!t) return ''; const d = new Date(t + 'Z'); return d.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
|
||||
function doLogout() { localStorage.clear(); window.location.href = '/app/login.html'; }
|
||||
|
||||
// ── WebSocket Real-time Updates ──
|
||||
function connectWS() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
_ws = new WebSocket(`${proto}://${location.host}/ws/alerts?token=${token}`);
|
||||
_ws.onopen = () => { _wsRetry = 0; };
|
||||
_ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'ping') { _ws.send('pong'); return; }
|
||||
loadDashboard();
|
||||
if (msg.type === 'alert') {
|
||||
_addAlert('🔴', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
|
||||
} else if (msg.type === 'recovery') {
|
||||
_addAlert('🟢', `${msg.server_name} ${msg.metric} 恢复正常 (${msg.value.toFixed(1)}%)`);
|
||||
} else if (msg.type === 'system') {
|
||||
_addAlert('⚠️', msg.message);
|
||||
}
|
||||
} catch(ex) {}
|
||||
};
|
||||
_ws.onclose = () => {
|
||||
const delay = Math.min(1000 * Math.pow(2, _wsRetry), 30000);
|
||||
_wsRetry++;
|
||||
setTimeout(connectWS, delay);
|
||||
};
|
||||
_ws.onerror = () => { _ws.close(); };
|
||||
}
|
||||
|
||||
function _addAlert(icon, text) {
|
||||
_alerts.unshift({ icon, text, time: new Date() });
|
||||
if (_alerts.length > MAX_ALERTS) _alerts.pop();
|
||||
renderAlerts();
|
||||
}
|
||||
|
||||
function renderAlerts() {
|
||||
if (!_alerts.length) return;
|
||||
document.getElementById('alertBanner').classList.remove('hidden');
|
||||
document.getElementById('noAlertBanner').classList.add('hidden');
|
||||
document.getElementById('alertList').innerHTML = _alerts.map(a =>
|
||||
`<div class="text-slate-300">${a.icon} ${a.text} <span class="text-slate-600 text-xs float-right">${fmtTimeObj(a.time)}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function clearAlerts() {
|
||||
_alerts = [];
|
||||
document.getElementById('alertBanner').classList.add('hidden');
|
||||
document.getElementById('noAlertBanner').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
loadSyncs();
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function fmtTime(t) { if (!t) return ''; return new Date(t + 'Z').toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
|
||||
function fmtTimeObj(d) { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
|
||||
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
loadBrand();
|
||||
loadUser();
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
loadSyncs();
|
||||
connectWS();
|
||||
|
||||
// Auto-refresh every 30s
|
||||
setInterval(() => { loadDashboard(); loadSyncs(); }, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" x-data="installWizard()" x-init="init()">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus 6.0 安装向导</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
<style type="text/tailwindcss">
|
||||
@theme {
|
||||
--color-brand: oklch(55% 0.2 250);
|
||||
--color-brand-light: oklch(75% 0.15 250);
|
||||
--color-brand-dark: oklch(35% 0.18 250);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.installer-card { max-width: 780px; }
|
||||
.code-block {
|
||||
background: #1e293b; color: #e2e8f0; border-radius: 6px; padding: 12px;
|
||||
font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all;
|
||||
position: relative; margin: 8px 0;
|
||||
}
|
||||
.copy-btn {
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15);
|
||||
color: #94a3b8; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.copy-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
|
||||
.copy-btn.copied { background: #22c55e; color: #fff; border-color: #22c55e; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 min-h-screen flex items-center justify-center p-4">
|
||||
|
||||
<div class="installer-card w-full bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-br from-blue-700 to-violet-600 text-white px-8 py-6 text-center">
|
||||
<h1 class="text-2xl font-bold mb-1">Nexus 6.0 安装向导</h1>
|
||||
<p class="text-violet-200 text-sm">服务器运维管理平台 — 心跳监控 + 智能告警 + 3层守护</p>
|
||||
</div>
|
||||
|
||||
<!-- Step Indicator -->
|
||||
<div class="flex items-center justify-center gap-1 py-4 px-8">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all"
|
||||
:class="step > i ? 'bg-green-500 text-white' : step === i ? 'bg-blue-500 text-white' : 'border-2 border-slate-200 text-slate-400'"
|
||||
x-text="step > i ? '✓' : i"></div>
|
||||
<div x-show="i < 5" class="w-8 h-0.5 mx-0.5 transition-all"
|
||||
:class="step > i ? 'bg-green-500' : 'bg-slate-200'"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-8 pb-8">
|
||||
|
||||
<!-- Error / Success alerts -->
|
||||
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4 text-sm" x-text="error"></div>
|
||||
<div x-show="success" x-transition class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg mb-4 text-sm" x-text="success"></div>
|
||||
|
||||
<!-- Already installed -->
|
||||
<template x-if="installed">
|
||||
<div class="text-center py-8">
|
||||
<div class="text-5xl mb-4">⚠️</div>
|
||||
<h2 class="text-xl font-bold text-red-600 mb-2">系统已安装</h2>
|
||||
<p class="text-slate-500 mb-4">检测到 .env 配置文件,系统已完成安装。</p>
|
||||
<a href="/app/login.html" class="inline-flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-medium transition">前往登录 →</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== Step 1: Welcome ======== -->
|
||||
<template x-if="step === 1 && !installed">
|
||||
<div class="text-center py-4">
|
||||
<h2 class="text-xl font-bold text-slate-800 mb-4">欢迎使用 Nexus 6.0</h2>
|
||||
<p class="text-slate-500 mb-6 leading-relaxed">
|
||||
本向导将引导您完成系统安装配置。<br>
|
||||
安装完成后将自动生成:<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">.env</code> (Python后端) +
|
||||
<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">config.php</code> (PHP兼容) +
|
||||
<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">MySQL settings表</code> (共享配置)<br>
|
||||
整个过程大约 3 分钟。
|
||||
</p>
|
||||
<div class="bg-slate-50 rounded-lg p-5 text-left mb-6">
|
||||
<h3 class="font-semibold text-slate-700 mb-3">📋 安装前准备</h3>
|
||||
<ul class="text-slate-600 text-sm ml-5 list-disc space-y-1">
|
||||
<li>Python 3.12+ (FastAPI + uvicorn)</li>
|
||||
<li>MySQL 8.0+ (需提前创建数据库和用户)</li>
|
||||
<li>Redis 6+ (心跳缓冲和实时数据)</li>
|
||||
<li>Web 目录和根目录可写权限</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button @click="step = 2; checkEnv()" class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2.5 rounded-lg font-semibold transition">
|
||||
开始安装 →
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== Step 2: Environment Check ======== -->
|
||||
<template x-if="step === 2 && !installed">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">🔍 环境检测</h2>
|
||||
|
||||
<div x-show="envLoading" class="text-center py-8 text-slate-400">
|
||||
<div class="animate-spin inline-block w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
|
||||
<p>正在检测环境...</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!envLoading && envChecks.length" class="bg-slate-50 rounded-lg overflow-hidden mb-4">
|
||||
<template x-for="c in envChecks" :key="c.name">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-100 last:border-0">
|
||||
<div>
|
||||
<span class="font-medium text-slate-700" x-text="c.name"></span>
|
||||
<span class="text-slate-400 text-xs ml-2" x-text="'需要: ' + c.required"></span>
|
||||
</div>
|
||||
<span class="font-semibold text-sm" :class="c.pass ? 'text-green-500' : 'text-red-500'"
|
||||
x-text="c.pass ? '✓ 通过' : '✗ ' + c.current"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="!envLoading && envAllPass" class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg mb-4 text-sm">
|
||||
✓ 所有环境检测通过,可以继续安装。
|
||||
</div>
|
||||
<div x-show="!envLoading && !envAllPass && envChecks.length" class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4 text-sm">
|
||||
部分环境检测未通过,请先解决上述问题。
|
||||
</div>
|
||||
|
||||
<div x-show="!envLoading" class="flex gap-3 mt-4">
|
||||
<button @click="step = 3" class="bg-blue-500 hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-semibold transition">
|
||||
下一步:数据库配置 →
|
||||
</button>
|
||||
<button @click="checkEnv()" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-5 py-2.5 rounded-lg font-semibold transition">
|
||||
重新检测
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== Step 3: DB + Redis + API Config ======== -->
|
||||
<template x-if="step === 3 && !installed">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">⚙ 数据库 + Redis + API 配置</h2>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-lg mb-4 text-sm">
|
||||
<b>说明:</b>请提前创建好数据库和用户,安装向导将自动建表并写入配置。
|
||||
SECRET_KEY 和 API_KEY 将自动生成,安装后不可修改(加密一致性)。
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="initDb()">
|
||||
<!-- MySQL -->
|
||||
<div class="border-t-2 border-slate-100 pt-4 mt-4">
|
||||
<h3 class="font-bold text-slate-700 mb-3">🗄 MySQL 数据库</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">主机</label>
|
||||
<input x-model="form.db_host" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">端口</label>
|
||||
<input x-model="form.db_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-3">
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">数据库名</label>
|
||||
<input x-model="form.db_name" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">用户名</label>
|
||||
<input x-model="form.db_user" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">密码</label>
|
||||
<input x-model="form.db_pass" type="password" required placeholder="数据库密码"
|
||||
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redis -->
|
||||
<div class="border-t-2 border-slate-100 pt-4 mt-4">
|
||||
<h3 class="font-bold text-slate-700 mb-3">⚡ Redis 配置</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 主机</label>
|
||||
<input x-model="form.redis_host" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 端口</label>
|
||||
<input x-model="form.redis_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-3">
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 数据库号</label>
|
||||
<input x-model="form.redis_db" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 密码(可选)</label>
|
||||
<input x-model="form.redis_pass" type="password" placeholder="无密码留空"
|
||||
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API + Site + Timezone -->
|
||||
<div class="border-t-2 border-slate-100 pt-4 mt-4">
|
||||
<h3 class="font-bold text-slate-700 mb-3">🌐 网站地址 + API 服务 + 时区</h3>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">网站地址</label>
|
||||
<p class="text-xs text-slate-400 mb-1">自动检测 — 用于 Agent 上报和前端 API 调用,可手动修改</p>
|
||||
<input x-model="form.site_url" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-3">
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Python API 端口</label>
|
||||
<p class="text-xs text-slate-400 mb-1">uvicorn 监听端口</p>
|
||||
<input x-model="form.api_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">时区</label>
|
||||
<select x-model="form.timezone" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
<option value="Asia/Shanghai">Asia/Shanghai (中国标准时间)</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 border border-amber-200 text-amber-800 px-4 py-3 rounded-lg mt-4 text-sm">
|
||||
<b>⚠ 注意:</b>初始化将自动:① 连接数据库并建表 ② 生成 SECRET_KEY/API_KEY
|
||||
③ 写入 .env + config.php + settings表 ④ 自动计算连接池大小
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="submit" :disabled="loading" class="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-5 py-2.5 rounded-lg font-semibold transition flex items-center gap-2">
|
||||
<span x-show="loading" class="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full"></span>
|
||||
<span x-text="loading ? '初始化中...' : '初始化数据库 →'"></span>
|
||||
</button>
|
||||
<button type="button" @click="step = 2" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-5 py-2.5 rounded-lg font-semibold transition">← 上一步</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== Step 4: Admin + Brand ======== -->
|
||||
<template x-if="step === 4 && !installed">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">👤 管理员账号 + 系统名称</h2>
|
||||
|
||||
<!-- Step 3 summary -->
|
||||
<div class="bg-slate-50 rounded-lg p-4 mb-4 text-sm">
|
||||
<div class="font-semibold text-green-600 mb-2">✓ 步骤 3 配置完成</div>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<span>数据库: <code class="bg-slate-200 px-1 rounded" x-text="form.db_name"></code></span>
|
||||
<span>用户: <code class="bg-slate-200 px-1 rounded" x-text="form.db_user"></code></span>
|
||||
<span>连接池: <code class="bg-slate-200 px-1 rounded" x-text="initResult?.pool_size || '—'"></code></span>
|
||||
<span>溢出池: <code class="bg-slate-200 px-1 rounded" x-text="initResult?.max_overflow || '—'"></code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createAdmin()">
|
||||
<!-- Brand -->
|
||||
<div class="border-t-2 border-slate-100 pt-4">
|
||||
<h3 class="font-bold text-slate-700 mb-3">系统品牌</h3>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">系统名称</label>
|
||||
<p class="text-xs text-slate-400 mb-1">显示在浏览器标题和后台标题栏</p>
|
||||
<input x-model="adminForm.system_name" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">系统标题</label>
|
||||
<input x-model="adminForm.system_title" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin account -->
|
||||
<div class="border-t-2 border-slate-100 pt-4 mt-4">
|
||||
<h3 class="font-bold text-slate-700 mb-3">管理员账号</h3>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">管理员用户名</label>
|
||||
<input x-model="adminForm.admin_username" required class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">管理员密码</label>
|
||||
<p class="text-xs text-slate-400 mb-1">至少6位字符</p>
|
||||
<input x-model="adminForm.admin_password" type="password" required minlength="6" placeholder="请输入安全密码"
|
||||
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">邮箱(可选)</label>
|
||||
<input x-model="adminForm.admin_email" type="email" placeholder="admin@example.com"
|
||||
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="submit" :disabled="loading" class="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-5 py-2.5 rounded-lg font-semibold transition flex items-center gap-2">
|
||||
<span x-show="loading" class="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full"></span>
|
||||
<span x-text="loading ? '创建中...' : '创建账号 →'"></span>
|
||||
</button>
|
||||
<button type="button" @click="step = 3" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-5 py-2.5 rounded-lg font-semibold transition">← 上一步</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== Step 5: Complete ======== -->
|
||||
<template x-if="step === 5 && !installed">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-green-100 text-green-500 text-3xl flex items-center justify-center mx-auto mb-4">✓</div>
|
||||
<h2 class="text-xl font-bold text-slate-800 mb-2">安装完成!</h2>
|
||||
<p class="text-slate-500 mb-1">Nexus 6.0 已成功安装。</p>
|
||||
<p class="text-slate-400 text-xs">install.php 已重命名为 install.php.locked — 防止重复安装</p>
|
||||
|
||||
<!-- Guardian results -->
|
||||
<div x-show="initResult?.guardian_results?.length" class="text-left mt-6 mb-4 rounded-lg p-4"
|
||||
:class="guardianAllOk ? 'bg-green-50 border-l-4 border-green-500' : 'bg-amber-50 border-l-4 border-amber-500'">
|
||||
<div class="font-semibold text-sm mb-2" :class="guardianAllOk ? 'text-green-700' : 'text-amber-700'"
|
||||
x-text="guardianAllOk ? '✓ 进程守护已自动配置' : '⚠ 进程守护部分配置失败'"></div>
|
||||
<template x-for="r in (initResult?.guardian_results || [])" :key="r">
|
||||
<div class="text-xs leading-relaxed" x-text="r"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Post-install checklist -->
|
||||
<div class="text-left mt-6 space-y-3">
|
||||
<h3 class="text-sm font-bold text-slate-700 text-center mb-3">━━━ 后续配置清单(必须完成)━━━</h3>
|
||||
|
||||
<!-- 1. Supervisor -->
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">1</span>
|
||||
Supervisor 进程守护
|
||||
</div>
|
||||
<div x-show="!guardianAllOk" class="text-xs text-slate-500 space-y-1">
|
||||
<p>安装: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y supervisor</code></p>
|
||||
<p>配置文件复制到: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/etc/supervisor/conf.d/nexus.conf</code></p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'supervisorConf')">复制配置</button><pre id="supervisorConf">[program:nexus]
|
||||
command=<span x-text="installDir"></span>/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port <span x-text="form.api_port"></span>
|
||||
directory=<span x-text="installDir"></span>
|
||||
user=root
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=10
|
||||
startsecs=3
|
||||
stopwaitsecs=10
|
||||
stopsignal=INT
|
||||
environment=PATH="<span x-text="installDir"></span>/venv/bin:%(ENV_PATH)s"
|
||||
stderr_logfile=/var/log/nexus/error.log
|
||||
stdout_logfile=/var/log/nexus/access.log
|
||||
stderr_logfile_maxbytes=50MB
|
||||
stdout_logfile_maxbytes=50MB</pre></div>
|
||||
<p>启动: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus</code></p>
|
||||
</div>
|
||||
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — Supervisor 配置已写入,服务已重载</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Python venv -->
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">2</span>
|
||||
Python 虚拟环境 + 依赖
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 space-y-1">
|
||||
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="installDir"></span></code></p>
|
||||
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">python3.12 -m venv venv</code></p>
|
||||
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">source venv/bin/activate</code></p>
|
||||
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">pip install -r requirements.txt</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Nginx -->
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">3</span>
|
||||
Nginx 反向代理 (API + WebSocket)
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
<p class="mb-1">宝塔 → 网站 → 配置文件 → <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">#PHP-INFO-END</code> 后面粘贴:</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxConf')">复制配置</button><pre id="nginxConf"> # Nexus Python API + WebSocket
|
||||
location ^~ /api/ {
|
||||
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location ^~ /health {
|
||||
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location ^~ /ws/ {
|
||||
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Nginx Rewrite -->
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">4</span>
|
||||
Nginx 伪静态
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
<p class="mb-1">宝塔 → 网站 → 伪静态 → 粘贴:</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxRewrite')">复制</button><pre id="nginxRewrite">location /app/ {
|
||||
try_files $uri $uri/ /app/index.html;
|
||||
}
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. SSL -->
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">5</span>
|
||||
SSL证书 (强制HTTPS)
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">宝塔 → 网站 → SSL → Let's Encrypt → 一键申请 → 开启强制HTTPS</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Health Monitor -->
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">6</span>
|
||||
Shell 健康检查 (外部守护 + Telegram告警)
|
||||
</div>
|
||||
<div x-show="!guardianAllOk" class="text-xs text-slate-500">
|
||||
<p>配置 crontab:</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'crontab')">复制</button><pre id="crontab">* * * * * <span x-text="installDir"></span>/deploy/health_monitor.sh</pre></div>
|
||||
</div>
|
||||
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — health_monitor.sh INSTALL_DIR 已更新,Crontab 已添加</div>
|
||||
<div class="text-xs text-slate-500 mt-2">
|
||||
<b>3层守护机制:</b><br>
|
||||
Layer 1: Supervisor — Python崩溃自动重启<br>
|
||||
Layer 2: Python self_monitor — 每30s检查Redis/MySQL/WebSocket<br>
|
||||
Layer 3: Shell脚本 — 每分钟HTTP检查,连续3次失败→Supervisor重启+Telegram告警
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. Security -->
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">7</span>
|
||||
安全收尾
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 space-y-1">
|
||||
<p>✓ <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">install.php</code> 已自动重命名为 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">install.php.locked</code></p>
|
||||
<p>✓ <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 包含 SECRET_KEY/API_KEY — <b>安装后不可修改</b>(加密一致性)</p>
|
||||
<p>✓ 防火墙限制端口 <span x-text="form.api_port"></span> (仅允许本机和子服务器IP)</p>
|
||||
<p>✓ 修改管理员默认密码</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-6">
|
||||
<a href="/app/index.html" class="bg-green-500 hover:bg-green-600 text-white px-8 py-3 rounded-lg font-semibold text-lg transition">
|
||||
进入管理面板 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function installWizard() {
|
||||
const API = window.location.origin;
|
||||
|
||||
return {
|
||||
step: 1,
|
||||
loading: false,
|
||||
installed: false,
|
||||
error: '',
|
||||
success: '',
|
||||
envLoading: false,
|
||||
envChecks: [],
|
||||
envAllPass: false,
|
||||
initResult: null,
|
||||
installDir: '/opt/nexus',
|
||||
|
||||
form: {
|
||||
db_host: 'localhost',
|
||||
db_port: '3306',
|
||||
db_name: 'Nexus',
|
||||
db_user: 'Nexus',
|
||||
db_pass: '',
|
||||
redis_host: '127.0.0.1',
|
||||
redis_port: '6379',
|
||||
redis_db: '0',
|
||||
redis_pass: '',
|
||||
api_port: '8600',
|
||||
timezone: 'Asia/Shanghai',
|
||||
site_url: '',
|
||||
},
|
||||
|
||||
adminForm: {
|
||||
admin_username: 'admin',
|
||||
admin_password: '',
|
||||
admin_email: '',
|
||||
system_name: 'Nexus',
|
||||
system_title: 'Nexus — 服务器运维管理平台',
|
||||
},
|
||||
|
||||
get guardianAllOk() {
|
||||
const results = this.initResult?.guardian_results || [];
|
||||
if (!results.length) return false;
|
||||
return results.every(r => !r.includes('✗') && !r.includes('⚠'));
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Auto-detect site URL
|
||||
const proto = location.protocol === 'https:' ? 'https' : 'http';
|
||||
this.form.site_url = proto + '://' + location.host;
|
||||
|
||||
// Check install status
|
||||
try {
|
||||
const r = await fetch(API + '/api/install/status');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
this.installed = d.installed;
|
||||
if (d.installed) return;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Try to restore state
|
||||
try {
|
||||
const r = await fetch(API + '/api/install/state');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (d.step > 1) this.step = d.step;
|
||||
if (d.db_host) Object.assign(this.form, {
|
||||
db_host: d.db_host, db_port: d.db_port, db_name: d.db_name,
|
||||
db_user: d.db_user, db_pass: d.db_pass, api_port: d.api_port || '8600',
|
||||
});
|
||||
if (d.install_dir) this.installDir = d.install_dir;
|
||||
if (d.pool_size) this.initResult = { pool_size: d.pool_size, max_overflow: d.max_overflow, guardian_results: d.guardian_results || [] };
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async checkEnv() {
|
||||
this.envLoading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const r = await fetch(API + '/api/install/env-check');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
this.envChecks = d.checks;
|
||||
this.envAllPass = d.all_pass;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = '环境检测请求失败: ' + e.message;
|
||||
}
|
||||
this.envLoading = false;
|
||||
},
|
||||
|
||||
async initDb() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.success = '';
|
||||
try {
|
||||
const r = await fetch(API + '/api/install/init-db', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok && d.success) {
|
||||
this.success = '数据库初始化成功!已创建 ' + (d.tables_created || 14) + ' 张表。';
|
||||
this.initResult = d;
|
||||
this.step = 4;
|
||||
} else {
|
||||
this.error = d.detail || '初始化失败';
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = '请求失败: ' + e.message;
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async createAdmin() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const body = {
|
||||
...this.adminForm,
|
||||
db_host: this.form.db_host,
|
||||
db_port: this.form.db_port,
|
||||
db_name: this.form.db_name,
|
||||
db_user: this.form.db_user,
|
||||
db_pass: this.form.db_pass,
|
||||
};
|
||||
const r = await fetch(API + '/api/install/create-admin', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok && d.success) {
|
||||
// Lock the installer
|
||||
await fetch(API + '/api/install/lock', { method: 'POST' });
|
||||
this.step = 5;
|
||||
} else {
|
||||
this.error = d.detail || '创建管理员失败';
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = '请求失败: ' + e.message;
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
copyCode(event, id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const text = el.textContent;
|
||||
const btn = event.target;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
btn.textContent = '已复制 ✓';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => { btn.textContent = '复制'; btn.classList.remove('copied'); }, 2000);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
// Nexus — Shared Sidebar + Layout Component
|
||||
// Usage: Include after Alpine.js. Call initLayout() on each page.
|
||||
// Sets sidebar, highlights active nav item, loads user info.
|
||||
|
||||
function initLayout(activeNav) {
|
||||
// activeNav: 'index'|'servers'|'files'|'push'|'scripts'|'credentials'|'schedules'|'retries'|'audit'|'settings'
|
||||
const navItems = [
|
||||
{ id:'index', icon:'🏠', label:'仪表盘', href:'/app/index.html' },
|
||||
{ id:'servers', icon:'🖥', label:'服务器', href:'/app/servers.html' },
|
||||
{ id:'files', icon:'📁', label:'文件管理', href:'/app/files.html' },
|
||||
{ id:'push', icon:'📤', label:'推送', href:'/app/push.html' },
|
||||
{ id:'scripts', icon:'📜', label:'脚本库', href:'/app/scripts.html' },
|
||||
{ id:'credentials', icon:'🔑', label:'凭据', href:'/app/credentials.html' },
|
||||
{ id:'schedules', icon:'⏰', label:'调度', href:'/app/schedules.html' },
|
||||
{ id:'retries', icon:'🔄', label:'重试队列', href:'/app/retries.html' },
|
||||
];
|
||||
|
||||
const bottomNav = [
|
||||
{ id:'audit', icon:'📋', label:'审计日志', href:'/app/audit.html' },
|
||||
{ id:'settings', icon:'⚙️', label:'设置', href:'/app/settings.html' },
|
||||
];
|
||||
|
||||
// Build sidebar HTML
|
||||
const navHtml = navItems.map(n =>
|
||||
`<a href="${n.href}" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition ${n.id===activeNav?'bg-brand/10 text-brand-light font-medium':'text-slate-400 hover:text-white hover:bg-slate-800'}">${n.icon} ${n.label}</a>`
|
||||
).join('');
|
||||
|
||||
const bottomHtml = bottomNav.map(n =>
|
||||
`<a href="${n.href}" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition ${n.id===activeNav?'bg-brand/10 text-brand-light font-medium':'text-slate-400 hover:text-white hover:bg-slate-800'}">${n.icon} ${n.label}</a>`
|
||||
).join('');
|
||||
|
||||
// Find sidebar container
|
||||
const sidebar = document.querySelector('[data-sidebar]');
|
||||
if (!sidebar) return;
|
||||
|
||||
sidebar.innerHTML = `
|
||||
<div class="px-5 py-4 border-b border-slate-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span id="brandName" class="text-white font-bold text-lg">Nexus</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
|
||||
${navHtml}
|
||||
<div class="border-t border-slate-800 my-2"></div>
|
||||
${bottomHtml}
|
||||
</nav>
|
||||
<div class="border-t border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span id="sidebarUser" class="text-slate-400 text-sm">...</span>
|
||||
<button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load user info
|
||||
loadLayoutUser();
|
||||
}
|
||||
|
||||
async function loadLayoutUser() {
|
||||
try {
|
||||
const r = await apiFetch(API + '/auth/me');
|
||||
if (!r) { doLogout(); return; }
|
||||
const u = await r.json();
|
||||
const el = document.getElementById('sidebarUser');
|
||||
if (el) el.textContent = u.username;
|
||||
const headerEl = document.getElementById('headerUser');
|
||||
if (headerEl) headerEl.textContent = u.username;
|
||||
// Set brand name
|
||||
const brandEl = document.getElementById('brandName');
|
||||
if (brandEl && u.system_name) brandEl.textContent = u.system_name;
|
||||
} catch(e) {}
|
||||
}
|
||||
+1
-1
@@ -203,7 +203,7 @@
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
localStorage.setItem('admin', JSON.stringify(data.admin));
|
||||
localStorage.setItem('token_expires', Date.now() + data.expires_in * 1000);
|
||||
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
|
||||
window.location.href = '/app/index.html';
|
||||
}
|
||||
|
||||
|
||||
+7
-8
@@ -1,10 +1,10 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 推送</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">🖥 服务器</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">📜 脚本库</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">⏰ 调度</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a><a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">批量推送</h1></div></header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div><label class="block text-sm text-slate-300 mb-2">源路径</label><input id="srcPath" placeholder="/www/wwwroot/source" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">源路径</label><input id="srcPath" placeholder="/home/deploy/source" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label><select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-32"><option>加载中...</option></select></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">同步模式</label><select id="syncMode" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步</option><option value="checksum">校验和</option></select></div>
|
||||
<button onclick="doPush()" id="pushBtn" class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition">开始推送</button>
|
||||
@@ -12,18 +12,17 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script>
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(){return{'Authorization':'Bearer '+token,'Content-Type':'application/json'}}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
async function loadServers(){const r=await fetch(API+'/servers/',{headers:ah()});const s=await r.json();document.getElementById('targetServers').innerHTML=s.map(s=>`<option value="${s.id}">${s.name} (${s.domain})</option>`).join('')}
|
||||
async function loadServers(){const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;document.getElementById('targetServers').innerHTML=servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${s.domain})</option>`).join('')}
|
||||
async function doPush(){const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
|
||||
const opts=document.getElementById('targetServers').selectedOptions;const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
const body={server_ids:ids,source_path:document.getElementById('srcPath').value,sync_mode:document.getElementById('syncMode').value};
|
||||
const r=await fetch(API+'/sync/files',{method:'POST',headers:ah(),body:JSON.stringify(body)});
|
||||
const d=await r.json();document.getElementById('pushResult').classList.remove('hidden');
|
||||
const r=await apiFetch(API+'/sync/files',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(!r){btn.disabled=false;btn.textContent='开始推送';return}const d=await r.json();document.getElementById('pushResult').classList.remove('hidden');
|
||||
document.getElementById('pushResult').innerHTML=`完成: ${d.completed||0} 成功, ${d.failed||0} 失败 (共${d.total||0}台)`;
|
||||
btn.disabled=false;btn.textContent='开始推送'}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
loadServers();
|
||||
</script>
|
||||
</body></html>
|
||||
+38
-10
@@ -1,15 +1,43 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 重试队列</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><a href="/app/index.html" class="text-white font-bold text-lg">Nexus</a></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/push.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">📤 推送</a><a href="/app/schedules.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⏰ 调度</a><a href="/app/retries.html" class="block px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">🔄 重试队列</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><h1 class="text-white font-semibold">重试队列</h1></header>
|
||||
<main class="flex-1 overflow-y-auto p-6"><div id="retryList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div></main>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">重试队列</h1></div>
|
||||
<button onclick="loadRetries()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="retryList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(){return{'Authorization':'Bearer '+token}}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
async function loadRetries(){const r=await fetch(API+'/retries/',{headers:ah()});const j=await r.json();document.getElementById('retryList').innerHTML=j.length?j.map(j=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white">${esc(j.server_name||'#'+j.server_id)}</span><span class="text-xs text-slate-400">重试 ${j.retry_count}/${j.max_retries}</span></div><div class="mt-1 text-xs text-slate-500">${esc(j.source_path)} → ${esc(j.target_path)}</div></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无重试任务</div>'}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
loadRetries()
|
||||
initLayout('retries');
|
||||
async function loadRetries(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/retries/');if(!r)return;
|
||||
const jobs=await r.json();
|
||||
document.getElementById('retryList').innerHTML=jobs.length?jobs.map(j=>{
|
||||
const statusColor=j.status==='pending'?'text-yellow-400':j.status==='running'?'text-blue-400':j.status==='failed'?'text-red-400':'text-green-400';
|
||||
const retryPct=Math.min((j.retry_count/j.max_retries)*100,100);
|
||||
return `<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-white font-medium">${esc(j.server_name||'#'+j.server_id)}</span>
|
||||
<span class="${statusColor} text-xs">${esc(j.status)}</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400">重试 ${j.retry_count}/${j.max_retries}</span>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-slate-500">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
|
||||
${j.last_error?`<div class="mt-1 text-xs text-red-400/70 truncate">${esc(j.last_error)}</div>`:''}
|
||||
<div class="mt-2 w-full bg-slate-800 rounded-full h-1"><div class="h-1 rounded-full ${retryPct>=80?'bg-red-400':retryPct>=50?'bg-yellow-400':'bg-brand'}" style="width:${retryPct}%"></div></div>
|
||||
${j.next_retry_at?`<div class="mt-1 text-xs text-slate-600">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
|
||||
</div>`}).join(''):'<div class="text-slate-500 text-center py-8">暂无重试任务</div>';
|
||||
}catch(e){document.getElementById('retryList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
loadRetries();
|
||||
</script>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
+57
-18
@@ -1,25 +1,64 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 定时调度</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><span class="text-white font-bold text-lg">Nexus</span></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">📤 推送</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🔄 重试</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center justify-between"><h1 class="text-white font-semibold">定时调度</h1><button onclick="showNew()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建</button></div></header>
|
||||
<main class="flex-1 overflow-y-auto p-6"><div id="schedList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="newForm" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-3 max-w-lg">
|
||||
<input id="sName" placeholder="名称" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
|
||||
<input id="sPath" placeholder="源路径" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
|
||||
<input id="sCron" placeholder="Cron: 0 2 * * *" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
|
||||
<div class="flex gap-2"><button onclick="createSched()" class="px-4 py-2 bg-brand text-white rounded-lg text-sm">保存</button><button onclick="document.getElementById('newForm').classList.add('hidden')" class="px-4 py-2 bg-slate-800 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">定时调度</h1></div>
|
||||
<button onclick="showAddSched()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="schedList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="schedModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="schedModalTitle" class="text-white font-semibold text-lg">新建调度</h2>
|
||||
<input type="hidden" id="editSchedId">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="sName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="每日推送"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">源路径</label><input id="sPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">Cron 表达式</label><input id="sCron" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *"><div class="text-slate-600 text-xs mt-1">分 时 日 月 周 · 例: 0 2 * * * = 每天凌晨2点</div></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">目标服务器</label><select id="sServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveSched()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSchedModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(c){const h={'Authorization':'Bearer '+token};if(c)h['Content-Type']='application/json';return h}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
async function loadScheds(){const r=await fetch(API+'/schedules/',{headers:ah()});const s=await r.json();document.getElementById('schedList').innerHTML=s.length?s.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 flex items-center justify-between"><div><span class="text-white">${esc(s.name)}</span><span class="text-slate-500 text-xs ml-2">${s.cron_expr}</span><span class="text-xs ml-2 ${s.enabled?'text-green-400':'text-red-400'}">${s.enabled?'启用':'禁用'}</span></div><button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无调度</div>'}
|
||||
function showNew(){document.getElementById('newForm').classList.remove('hidden')}
|
||||
async function createSched(){await fetch(API+'/schedules/',{method:'POST',headers:ah(true),body:JSON.stringify({name:document.getElementById('sName').value,source_path:document.getElementById('sPath').value,cron_expr:document.getElementById('sCron').value,server_ids:'[]',enabled:true})});loadScheds();document.getElementById('newForm').classList.add('hidden')}
|
||||
async function deleteSched(id){await fetch(API+'/schedules/'+id,{method:'DELETE',headers:ah()});loadScheds()}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
loadScheds()
|
||||
initLayout('schedules');
|
||||
let _schedsCache={};
|
||||
async function loadScheds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/schedules/');if(!r)return;const scheds=await r.json();
|
||||
_schedsCache={};scheds.forEach(s=>_schedsCache[s.id]=s);
|
||||
document.getElementById('schedList').innerHTML=scheds.length?scheds.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="toggleSched(${s.id},${!s.enabled})" class="w-10 h-6 rounded-full transition-colors ${s.enabled?'bg-brand':'bg-slate-700'} relative shrink-0" title="${s.enabled?'点击禁用':'点击启用'}"><span class="absolute top-0.5 ${s.enabled?'right-0.5':'left-0.5'} w-5 h-5 bg-white rounded-full shadow transition-all"></span></button>
|
||||
<div><span class="text-white font-medium">${esc(s.name)}</span><div class="text-slate-500 text-xs mt-0.5"><span class="font-mono">${esc(s.cron_expr)}</span>${s.last_run_at?' · 上次: '+fmtTime(s.last_run_at):''}</div></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2"><button onclick="showEditSched(${s.id})" class="text-slate-400 hover:text-white text-xs transition">编辑</button><button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无调度</div>';
|
||||
}catch(e){document.getElementById('schedList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
async function loadServersIntoSelect(selectedIds){
|
||||
const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;
|
||||
document.getElementById('sServers').innerHTML=servers.map(s=>`<option value="${s.id}" ${selectedIds&&selectedIds.includes(s.id)?'selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
}
|
||||
function showAddSched(){document.getElementById('editSchedId').value='';document.getElementById('schedModalTitle').textContent='新建调度';document.getElementById('sName').value='';document.getElementById('sPath').value='';document.getElementById('sCron').value='';loadServersIntoSelect([]);document.getElementById('schedModal').classList.remove('hidden')}
|
||||
async function showEditSched(id){const s=_schedsCache[id];if(!s)return;document.getElementById('editSchedId').value=s.id;document.getElementById('schedModalTitle').textContent='编辑调度';document.getElementById('sName').value=s.name||'';document.getElementById('sPath').value=s.source_path||'';document.getElementById('sCron').value=s.cron_expr||'';let serverIds=[];try{serverIds=JSON.parse(s.server_ids||'[]')}catch(e){}await loadServersIntoSelect(serverIds);document.getElementById('schedModal').classList.remove('hidden')}
|
||||
function hideSchedModal(){document.getElementById('schedModal').classList.add('hidden')}
|
||||
async function saveSched(){
|
||||
const id=document.getElementById('editSchedId').value;const opts=document.getElementById('sServers').selectedOptions;const serverIds=Array.from(opts).map(o=>parseInt(o.value));
|
||||
const body={name:document.getElementById('sName').value,source_path:document.getElementById('sPath').value,cron_expr:document.getElementById('sCron').value,server_ids:JSON.stringify(serverIds),enabled:true};
|
||||
if(!body.name||!body.cron_expr){alert('名称和Cron必填');return}
|
||||
if(id){await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)})}else{await apiFetch(API+'/schedules/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)})}
|
||||
hideSchedModal();loadScheds();
|
||||
}
|
||||
async function toggleSched(id,enabled){await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({enabled})});loadScheds()}
|
||||
async function deleteSched(id){if(!confirm('确定删除调度?'))return;await apiFetch(API+'/schedules/'+id,{method:'DELETE'});loadScheds()}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
loadScheds();
|
||||
</script>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
+20
-7
@@ -1,6 +1,6 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 脚本库</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🔑 凭据</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a><a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">脚本库</h1></div><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="scriptList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
@@ -10,17 +10,30 @@
|
||||
<textarea id="scriptContent" rows="8" placeholder="Shell 命令内容" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm font-mono"></textarea>
|
||||
<div class="flex gap-2"><button onclick="createScript()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideNewScript()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
<!-- Execute Modal -->
|
||||
<div id="execModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">执行脚本</h2>
|
||||
<p id="execScriptName" class="text-slate-400 text-sm"></p>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">选择服务器</label><select id="execServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="execTimeout" type="number" value="60" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div id="execResult" class="hidden p-3 bg-slate-950 rounded-lg text-sm font-mono text-slate-300 max-h-48 overflow-y-auto"></div>
|
||||
<div class="flex gap-2"><button onclick="doExec()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">执行</button><button onclick="hideExecModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script>
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(c){const h={'Authorization':'Bearer '+token};if(c)h['Content-Type']='application/json';return h}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
async function loadScripts(){try{const r=await fetch(API+'/scripts/',{headers:ah()});const scripts=await r.json();document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white font-medium">${esc(s.name)}</span><span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-400">${s.category||'ops'}</span></div><pre class="mt-2 text-sm text-slate-400 font-mono bg-slate-950 rounded-lg p-3 overflow-x-auto">${esc(s.content?.substring(0,500)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}
|
||||
}
|
||||
let _scriptsCache=[];
|
||||
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;const scripts=await r.json();_scriptsCache=scripts;document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white font-medium">${esc(s.name)}</span><div class="flex items-center gap-2"><span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-400">${s.category||'ops'}</span><button onclick="showExec(${s.id})" class="text-brand-light text-xs hover:underline">执行</button><button onclick="deleteScript(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div></div><pre class="mt-2 text-sm text-slate-400 font-mono bg-slate-950 rounded-lg p-3 overflow-x-auto">${esc(s.content?.substring(0,500)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}}
|
||||
function showNewScript(){document.getElementById('newScriptForm').classList.remove('hidden')}
|
||||
function hideNewScript(){document.getElementById('newScriptForm').classList.add('hidden')}
|
||||
async function createScript(){const b={name:document.getElementById('scriptName').value,category:document.getElementById('scriptCategory').value||'ops',content:document.getElementById('scriptContent').value};await fetch(API+'/scripts/',{method:'POST',headers:ah(true),body:JSON.stringify(b)});hideNewScript();loadScripts()}
|
||||
async function createScript(){const b={name:document.getElementById('scriptName').value,category:document.getElementById('scriptCategory').value||'ops',content:document.getElementById('scriptContent').value};await apiFetch(API+'/scripts/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(b)});hideNewScript();loadScripts()}
|
||||
async function deleteScript(id){if(!confirm('确定删除脚本?'))return;await apiFetch(API+'/scripts/'+id,{method:'DELETE'});loadScripts()}
|
||||
async function showExec(id){const s=_scriptsCache.find(x=>x.id===id);if(!s)return;document.getElementById('execScriptName').textContent=s.name;document.getElementById('execResult').classList.add('hidden');const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;document.getElementById('execServers').innerHTML=servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');document.getElementById('execModal').classList.remove('hidden');document.getElementById('execModal').dataset.scriptId=id;document.getElementById('execModal').dataset.cmd=s.content||''}
|
||||
function hideExecModal(){document.getElementById('execModal').classList.add('hidden')}
|
||||
async function doExec(){const opts=document.getElementById('execServers').selectedOptions;const ids=Array.from(opts).map(o=>parseInt(o.value));if(!ids.length){alert('请选择服务器');return}const timeout=parseInt(document.getElementById('execTimeout').value)||60;const el=document.getElementById('execResult');el.classList.remove('hidden');el.textContent='执行中...';const modal=document.getElementById('execModal');const scriptId=parseInt(modal.dataset.scriptId)||null;const cmd=modal.dataset.cmd||'';const r=await apiFetch(API+'/scripts/exec',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({script_id:scriptId,command:cmd,server_ids:ids,timeout:timeout})});if(!r){el.textContent='认证失败';return}const d=await r.json();el.textContent=JSON.stringify(d,null,2)}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
loadScripts()
|
||||
</script>
|
||||
|
||||
+198
-22
@@ -1,4 +1,9 @@
|
||||
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: true }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — 服务器</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style></head>
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
|
||||
<style>
|
||||
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
|
||||
.detail-panel.open{max-height:800px}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
|
||||
@@ -18,50 +23,221 @@
|
||||
<div class="border-t border-slate-800 p-4"><div class="flex items-center justify-between"><span id="sidebarUser" class="text-slate-400 text-sm">...</span><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div></div>
|
||||
</aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div><div class="flex items-center gap-3"><input id="searchInput" type="text" placeholder="搜索服务器..." class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48"><a href="/app/terminal.html" id="sshLink" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a></div></header>
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="searchInput" type="text" placeholder="搜索服务器..." class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
|
||||
<button onclick="showAddServer()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 添加</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="serversTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">平台</th><th class="text-left px-4 py-3">Agent版本</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">分类</th><th class="text-left px-4 py-3">Agent</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between text-xs text-slate-500"><span id="serverCount">共 -- 台</span><button onclick="loadServers()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">刷新</button></div>
|
||||
|
||||
<!-- Server Detail Panel -->
|
||||
<div id="detailPanel" class="hidden mt-4 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-800/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="detailStatus" class="inline-block w-3 h-3 rounded-full"></span>
|
||||
<h2 id="detailName" class="text-white font-semibold text-lg"></h2>
|
||||
<span id="detailAddr" class="text-slate-400 text-sm"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a id="detailSSHLink" href="#" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a>
|
||||
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
|
||||
<button onclick="hideDetail()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-800 px-6">
|
||||
<button onclick="showDetailTab('info')" id="tabInfo" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">系统信息</button>
|
||||
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">同步日志</button>
|
||||
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH会话</button>
|
||||
</div>
|
||||
<!-- Tab Content: System Info -->
|
||||
<div id="tabContentInfo" class="p-6">
|
||||
<div id="sysInfoGrid" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">系统版本</div><div id="siOS" class="text-lg font-medium text-white truncate">--</div></div>
|
||||
</div>
|
||||
<div id="sysInfoMeta" class="mt-4 grid grid-cols-3 gap-4 text-sm">
|
||||
<div><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300">--</span></div>
|
||||
<div><span class="text-slate-500">目标路径:</span><span id="siTarget" class="text-slate-300">--</span></div>
|
||||
<div><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Content: Sync Logs -->
|
||||
<div id="tabContentSync" class="hidden p-6">
|
||||
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<!-- Tab Content: SSH Sessions -->
|
||||
<div id="tabContentSSH" class="hidden p-6">
|
||||
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Server Modal -->
|
||||
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
|
||||
<input type="hidden" id="editServerId">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">地址</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="srvUser" value="root" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="key">密钥</option><option value="password">密码</option></select></div>
|
||||
</div>
|
||||
<div id="srvPasswordRow"><label class="block text-slate-400 text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="SSH密码"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">分类</label><input id="srvCategory" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="production"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/target"></div>
|
||||
</div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between text-xs text-slate-500"><span id="serverCount">共 -- 台</span><div class="flex gap-2"><button onclick="loadServers()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">刷新</button></div></div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script>
|
||||
const API = window.location.origin + '/api';
|
||||
const token = getToken();
|
||||
let selectedServerId = null;
|
||||
|
||||
function authHeaders(){return{'Authorization':'Bearer '+token,'Content-Type':'application/json'}}
|
||||
function getToken(){const t=localStorage.getItem('access_token');if(!t){window.location.href='/app/login.html';return'';}return t}
|
||||
function doLogout(){localStorage.clear();window.location.href='/app/login.html'}
|
||||
let selectedServerId=null;
|
||||
let _serversCache={};
|
||||
let _currentDetailTab='info';
|
||||
|
||||
async function loadServers(){
|
||||
try{
|
||||
const res=await fetch(API+'/servers/',{headers:authHeaders()});
|
||||
const servers=await res.json();
|
||||
document.getElementById('serverCount').textContent=`共 ${servers.length} 台`;
|
||||
const res=await apiFetch(API+'/servers/');
|
||||
if(!res) return;
|
||||
const data=await res.json();
|
||||
const servers=data.items||data;
|
||||
const total=data.total||servers.length;
|
||||
_serversCache={};servers.forEach(s=>_serversCache[s.id]=s);
|
||||
document.getElementById('serverCount').textContent=`共 ${total} 台`;
|
||||
const tbody=document.getElementById('serversTbody');
|
||||
if(!servers.length){tbody.innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">暂无服务器</td></tr>';return}
|
||||
tbody.innerHTML=servers.map(s=>`<tr class="hover:bg-slate-800/30 transition cursor-pointer" onclick="selectServer(${s.id})" id="row-${s.id}">
|
||||
tbody.innerHTML=servers.map(s=>`<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
|
||||
<td class="px-4 py-3"><span class="inline-block w-2.5 h-2.5 rounded-full ${s.is_online?'bg-green-400':'bg-red-400'} ${s.is_online?'':'animate-pulse'}"></span></td>
|
||||
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${s.platform_id||s.category||'--'}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${s.category||'--'}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${s.agent_version||'--'}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs">${fmtTime(s.last_heartbeat)}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/app/terminal.html?server_id=${s.id}" class="text-brand-light hover:underline text-xs mr-2">终端</a><button onclick="deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button></td>
|
||||
<td class="px-4 py-3 text-right"><a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a><button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button><button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button></td>
|
||||
</tr>`).join('');
|
||||
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-red-400">加载失败: '+esc(e.message)+'</td></tr>'}
|
||||
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
|
||||
async function selectServer(id){
|
||||
selectedServerId=id;
|
||||
// Highlight row
|
||||
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
|
||||
document.getElementById('row-'+id)?.classList.add('bg-brand/5');
|
||||
// Show detail panel
|
||||
const s=_serversCache[id];if(!s)return;
|
||||
document.getElementById('detailName').textContent=s.name;
|
||||
document.getElementById('detailAddr').textContent=s.domain+':'+(s.port||22);
|
||||
document.getElementById('detailStatus').className='inline-block w-3 h-3 rounded-full '+(s.is_online?'bg-green-400':'bg-red-400');
|
||||
document.getElementById('detailSSHLink').href='/app/terminal.html?server_id='+id;
|
||||
document.getElementById('detailPanel').classList.remove('hidden');
|
||||
// Load detail data
|
||||
loadSystemInfo(s);
|
||||
showDetailTab('info');
|
||||
}
|
||||
|
||||
function hideDetail(){
|
||||
document.getElementById('detailPanel').classList.add('hidden');
|
||||
selectedServerId=null;
|
||||
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
|
||||
}
|
||||
|
||||
function loadSystemInfo(s){
|
||||
// System info from Redis (via server object or direct read)
|
||||
const sys=s.system_info;
|
||||
if(sys&&typeof sys==='object'){
|
||||
document.getElementById('siCPU').textContent=(sys.cpu_percent!=null?sys.cpu_percent.toFixed(1)+'%':'--');
|
||||
document.getElementById('siCPU').className='text-2xl font-bold '+(sys.cpu_percent>80?'text-red-400':sys.cpu_percent>60?'text-yellow-400':'text-green-400');
|
||||
document.getElementById('siMem').textContent=(sys.memory_percent!=null?sys.memory_percent.toFixed(1)+'%':'--');
|
||||
document.getElementById('siMem').className='text-2xl font-bold '+(sys.memory_percent>80?'text-red-400':sys.memory_percent>60?'text-yellow-400':'text-green-400');
|
||||
document.getElementById('siDisk').textContent=(sys.disk_percent!=null?sys.disk_percent.toFixed(1)+'%':'--');
|
||||
document.getElementById('siDisk').className='text-2xl font-bold '+(sys.disk_percent>80?'text-red-400':sys.disk_percent>60?'text-yellow-400':'text-green-400');
|
||||
document.getElementById('siOS').textContent=sys.os||sys.platform||'--';
|
||||
}else{
|
||||
document.getElementById('siCPU').textContent='--';
|
||||
document.getElementById('siMem').textContent='--';
|
||||
document.getElementById('siDisk').textContent='--';
|
||||
document.getElementById('siOS').textContent='--';
|
||||
}
|
||||
document.getElementById('siDesc').textContent=s.description||'--';
|
||||
document.getElementById('siTarget').textContent=s.target_path||'--';
|
||||
document.getElementById('siAuth').textContent=s.auth_method==='password'?'密码':'SSH密钥';
|
||||
}
|
||||
|
||||
async function loadSyncLogs(){
|
||||
if(!selectedServerId)return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/'+selectedServerId+'/logs?limit=20');
|
||||
if(!r)return;const logs=await r.json();
|
||||
const tbody=document.getElementById('syncLogTbody');
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-slate-500">暂无同步记录</td></tr>';return}
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const sc=l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
||||
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-slate-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-slate-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
|
||||
}catch(e){document.getElementById('syncLogTbody').innerHTML='<tr><td colspan="5" class="py-4 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
|
||||
async function loadSSHSessions(){
|
||||
if(!selectedServerId)return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/assets/ssh-sessions?server_id='+selectedServerId+'&limit=20');
|
||||
if(!r)return;const sessions=await r.json();
|
||||
const tbody=document.getElementById('sshSessTbody');
|
||||
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-slate-500">暂无SSH会话</td></tr>';return}
|
||||
tbody.innerHTML=sessions.map(s=>{
|
||||
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
|
||||
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-slate-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
|
||||
}catch(e){document.getElementById('sshSessTbody').innerHTML='<tr><td colspan="4" class="py-4 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
|
||||
function showDetailTab(tab){
|
||||
_currentDetailTab=tab;
|
||||
['Info','Sync','SSH'].forEach(t=>{
|
||||
document.getElementById('tabContent'+t).classList.toggle('hidden',t.toLowerCase()!==tab);
|
||||
const btn=document.getElementById('tab'+t);
|
||||
if(t.toLowerCase()===tab){btn.className='px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition'}
|
||||
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition'}
|
||||
});
|
||||
if(tab==='sync')loadSyncLogs();
|
||||
if(tab==='ssh')loadSSHSessions();
|
||||
}
|
||||
|
||||
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(selectedServerId===id)hideDetail();loadServers()}
|
||||
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('serverModal').classList.remove('hidden')}
|
||||
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';document.getElementById('serverModal').classList.remove('hidden')}
|
||||
function hideServerModal(){document.getElementById('serverModal').classList.add('hidden')}
|
||||
document.getElementById('srvAuth').addEventListener('change',()=>{document.getElementById('srvPasswordRow').style.display=document.getElementById('srvAuth').value==='password'?'':'none'});
|
||||
async function saveServer(){
|
||||
const id=document.getElementById('editServerId').value;
|
||||
const body={name:document.getElementById('srvName').value,domain:document.getElementById('srvDomain').value,port:parseInt(document.getElementById('srvPort').value)||22,username:document.getElementById('srvUser').value||'root',auth_method:document.getElementById('srvAuth').value,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null};
|
||||
if(body.auth_method==='password'){body.password=document.getElementById('srvPassword').value}
|
||||
if(!body.name||!body.domain){alert('名称和地址必填');return}
|
||||
if(id){await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)})}else{await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)})}
|
||||
hideServerModal();loadServers();
|
||||
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
|
||||
}
|
||||
function selectServer(id){selectedServerId=id;document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('bg-brand/5'));document.getElementById('row-'+id)?.classList.add('bg-brand/5');document.getElementById('sshLink').href='/app/terminal.html?server_id='+id}
|
||||
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;await fetch(API+'/servers/'+id,{method:'DELETE',headers:authHeaders()});loadServers()}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
|
||||
document.getElementById('searchInput').addEventListener('input',()=>{const q=document.getElementById('searchInput').value.toLowerCase();document.querySelectorAll('#serversTbody tr').forEach(r=>{const name=r.querySelector('td:nth-child(2)')?.textContent.toLowerCase()||'';const addr=r.querySelector('td:nth-child(3)')?.textContent.toLowerCase()||'';r.style.display=(!q||name.includes(q)||addr.includes(q))?'':'none'})});
|
||||
|
||||
loadServers();
|
||||
async function loadUser(){try{const r=await apiFetch(API+'/auth/me');if(!r){doLogout();return}const u=await r.json();document.getElementById('sidebarUser').textContent=u.username}catch(e){}}
|
||||
loadUser();
|
||||
async function loadUser(){try{const r=await fetch(API+'/auth/me',{headers:authHeaders()});if(!r.ok){doLogout();return}const u=await r.json();document.getElementById('sidebarUser').textContent=u.username}catch(e){}}
|
||||
</script>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
+93
-15
@@ -1,25 +1,103 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 系统设置</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><a href="/app/index.html" class="text-white font-bold text-lg">Nexus</a></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/settings.html" class="block px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><h1 class="text-white font-semibold">系统设置</h1></header>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
|
||||
<nav class="flex-1 py-3 px-3 space-y-1">
|
||||
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a>
|
||||
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
|
||||
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
|
||||
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
|
||||
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
|
||||
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a>
|
||||
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
|
||||
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
|
||||
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a>
|
||||
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">⚙️ 设置</a>
|
||||
</nav>
|
||||
<div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div>
|
||||
</aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">系统设置</h1></div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
|
||||
<div id="settingsForm" class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div class="text-slate-400 text-sm mb-4">可修改的运行时配置(SECRET_KEY/API_KEY/DATABASE_URL 不可在此修改)</div>
|
||||
<div id="settingsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div class="mb-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand Settings -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
|
||||
<h2 class="text-white font-medium mb-4">品牌</h2>
|
||||
<div id="brandSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Thresholds -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
|
||||
<h2 class="text-white font-medium mb-4">告警阈值 (%)</h2>
|
||||
<div id="alertSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Infrastructure -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
|
||||
<h2 class="text-white font-medium mb-4">基础设施</h2>
|
||||
<div id="infraSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">Telegram 通知</h2>
|
||||
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<div id="saveMsg" class="hidden mt-4 text-center text-green-400 text-sm">已保存</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script>
|
||||
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
|
||||
function ah(c){const h={'Authorization':'Bearer '+token};if(c)h['Content-Type']='application/json';return h}
|
||||
function doLogout(){localStorage.clear();location.href='/app/login.html'}
|
||||
const SECTIONS={
|
||||
brand:{keys:['system_name','system_title'],labels:{system_name:'系统名称',system_title:'页面标题'}},
|
||||
alert:{keys:['cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold'],labels:{cpu_alert_threshold:'CPU 告警阈值',mem_alert_threshold:'内存告警阈值',disk_alert_threshold:'磁盘告警阈值'}},
|
||||
infra:{keys:['redis_url','pool_size','max_overflow','agent_heartbeat_interval'],labels:{redis_url:'Redis URL',pool_size:'连接池大小',max_overflow:'最大溢出',agent_heartbeat_interval:'心跳间隔(秒)'}},
|
||||
telegram:{keys:['telegram_bot_token','telegram_chat_id'],labels:{telegram_bot_token:'Bot Token',telegram_chat_id:'Chat ID'}},
|
||||
};
|
||||
let _allSettings={};
|
||||
|
||||
async function loadSettings(){
|
||||
try{const r=await fetch(API+'/settings/',{headers:ah()});const settings=await r.json();
|
||||
const mutableKeys=['system_name','system_title','redis_url','telegram_bot_token','telegram_chat_id','cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold','agent_heartbeat_interval','pool_size','max_overflow'];
|
||||
document.getElementById('settingsList').innerHTML=settings.filter(s=>mutableKeys.includes(s.key)).map(s=>`<div class="flex items-center gap-3"><label class="text-slate-400 text-sm w-40 shrink-0">${s.key}</label><input id="set_${s.key}" value="${esc(s.value||'')}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><button onclick="saveSetting('${s.key}')" class="px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0">保存</button></div>`).join('')}catch(e){}
|
||||
try{
|
||||
const r=await apiFetch(API+'/settings/');if(!r)return;
|
||||
const settings=await r.json();
|
||||
_allSettings={};settings.forEach(s=>_allSettings[s.key]=s.value);
|
||||
for(const[section,cfg]of Object.entries(SECTIONS)){
|
||||
const el=document.getElementById(section+'Section');
|
||||
el.innerHTML=cfg.keys.map(key=>{
|
||||
const val=_allSettings[key]||'';
|
||||
const isMasked=val.includes('***')||val.includes('...');
|
||||
if(isMasked){
|
||||
return `<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||||
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm select-none">${esc(val)}</span>
|
||||
<span class="text-slate-600 text-xs">🔒 安全锁定</span>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||||
<input id="set_${key}" value="${esc(val)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<button onclick="saveSetting('${key}')" class="px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
async function saveSetting(key){const v=document.getElementById('set_'+key).value;await fetch(API+'/settings/'+key,{method:'PUT',headers:ah(true),body:JSON.stringify({value:v})})}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
loadSettings()
|
||||
|
||||
async function saveSetting(key){
|
||||
const v=document.getElementById('set_'+key).value;
|
||||
await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:v})});
|
||||
const msg=document.getElementById('saveMsg');
|
||||
msg.classList.remove('hidden');
|
||||
setTimeout(()=>msg.classList.add('hidden'),2000);
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
loadSettings();
|
||||
</script>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
+171
-167
@@ -1,201 +1,205 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus — Web SSH 终端</title>
|
||||
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: false, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{height:calc(100vh - 48px)}#terminalWrap.fs{height:100vh;position:fixed;inset:0;z-index:100}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
|
||||
|
||||
<!-- Tailwind CSS v4 CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<!-- xterm.js -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<!-- Sidebar (collapsed by default — terminal needs space) -->
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
|
||||
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
|
||||
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a>
|
||||
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
|
||||
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
|
||||
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
|
||||
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
|
||||
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a>
|
||||
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
|
||||
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
|
||||
<div class="border-t border-slate-800 my-2"></div>
|
||||
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计日志</a>
|
||||
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a>
|
||||
</nav>
|
||||
<div class="border-t border-slate-800 p-4"><div class="flex items-center justify-between"><span id="sidebarUser" class="text-slate-400 text-sm">...</span><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div></div>
|
||||
</aside>
|
||||
|
||||
<style type="text/tailwindcss">
|
||||
@theme {
|
||||
--color-brand: oklch(55% 0.2 250);
|
||||
--color-brand-light: oklch(75% 0.15 250);
|
||||
--color-brand-dark: oklch(35% 0.18 250);
|
||||
--color-surface: oklch(98.5% 0.002 250);
|
||||
}
|
||||
#terminal { height: calc(100vh - 120px); }
|
||||
.xterm { padding: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 min-h-screen flex flex-col">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/app/index.html" class="text-slate-400 hover:text-white transition">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-white font-semibold">Web SSH 终端</h1>
|
||||
<p id="serverInfo" class="text-slate-400 text-sm">连接中...</p>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Toolbar -->
|
||||
<header x-show="!fullscreen" class="bg-slate-900 border-b border-slate-800 px-4 py-2 flex items-center justify-between h-12 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
||||
<span class="text-slate-500 text-sm">SSH</span>
|
||||
<span class="text-white font-semibold text-sm" x-text="serverName"></span>
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs" :class="connected ? 'bg-green-900/30 text-green-400' : 'bg-slate-800 text-slate-500'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="connected ? 'bg-green-400 animate-pulse' : 'bg-slate-600'"></span>
|
||||
<span x-text="connected ? '已连接' : '未连接'"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="resizeBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">
|
||||
自适应窗口
|
||||
</button>
|
||||
<button id="disconnectBtn" class="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 text-sm rounded-lg transition">
|
||||
断开连接
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-600 text-xs font-mono" x-text="cols+'×'+rows"></span>
|
||||
<button onclick="toggleFullscreen()" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" x-text="fullscreen?'退出全屏':'全屏'">全屏</button>
|
||||
<button onclick="disconnect()" class="px-2 py-1 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-xs rounded transition">断开</button>
|
||||
<a href="/app/servers.html" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition">↩ 返回</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Terminal Container -->
|
||||
<div class="flex-1 p-2">
|
||||
<div id="terminal" class="bg-slate-900 rounded-lg border border-slate-800"></div>
|
||||
<!-- Terminal -->
|
||||
<div id="terminalWrap" class="bg-slate-950"><div id="terminal"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<footer class="bg-slate-900 border-t border-slate-800 px-4 py-2 flex items-center justify-between text-xs text-slate-500">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="connectionStatus" class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
|
||||
连接中...
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span>Alt+F 切换全屏</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
|
||||
<script>
|
||||
const API = window.location.origin;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const serverId = urlParams.get('server_id');
|
||||
// ── Parse server_id ──
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const serverId = parseInt(params.get('server_id'));
|
||||
if (!serverId) window.location.href = '/app/servers.html';
|
||||
|
||||
if (!serverId) {
|
||||
alert('缺少 server_id 参数');
|
||||
window.location.href = '/app/index.html';
|
||||
}
|
||||
// ── Alpine helper ──
|
||||
function $d() { return Alpine.$data(document.body); }
|
||||
|
||||
let term, ws, fitAddon;
|
||||
let sessionId = null;
|
||||
// ── xterm.js ──
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
fontSize: 14,
|
||||
fontFamily: '"Cascadia Code","Fira Code","JetBrains Mono",Menlo,monospace',
|
||||
theme: {
|
||||
background: '#0b1120',
|
||||
foreground: '#e2e8f0',
|
||||
cursor: '#7c8bf4',
|
||||
selectionBackground: '#334155',
|
||||
black: '#1e293b', red: '#f87171', green: '#4ade80', yellow: '#fbbf24',
|
||||
blue: '#60a5fa', magenta: '#c084fc', cyan: '#22d3ee', white: '#e2e8f0',
|
||||
brightBlack: '#64748b', brightRed: '#fca5a5', brightGreen: '#86efac',
|
||||
brightYellow: '#fde047', brightBlue: '#93c5fd', brightMagenta: '#d8b4fe',
|
||||
brightCyan: '#67e8f9', brightWhite: '#f8fafc',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
function getToken() {
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||
term.open(document.getElementById('terminal'));
|
||||
|
||||
// Initial fit
|
||||
setTimeout(() => fitAddon.fit(), 50);
|
||||
window.addEventListener('resize', () => { try { fitAddon.fit(); } catch(e){} });
|
||||
|
||||
// ── WebSocket ──
|
||||
let ws = null;
|
||||
|
||||
function connect() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
alert('请先登录');
|
||||
window.location.href = '/app/login.html';
|
||||
}
|
||||
return token;
|
||||
}
|
||||
if (!token) { window.location.href = '/app/login.html'; return; }
|
||||
|
||||
function initTerminal() {
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
theme: {
|
||||
background: '#0f172a',
|
||||
foreground: '#e2e8f0',
|
||||
cursor: '#38bdf8',
|
||||
selectionBackground: '#38bdf830',
|
||||
},
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
window.addEventListener('resize', () => fitAddon.fit());
|
||||
|
||||
term.onData(data => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'DATA', data }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function connectTerminal() {
|
||||
const token = getToken();
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${proto}//${window.location.host}/ws/terminal/${serverId}?token=${token}`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${location.host}/ws/terminal/${serverId}?token=${encodeURIComponent(token)}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
updateStatus('connected', '已连接');
|
||||
document.getElementById('disconnectBtn').disabled = false;
|
||||
$d().connected = true;
|
||||
term.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
case 'TERMINAL_INIT':
|
||||
sessionId = msg.session_id;
|
||||
document.getElementById('serverInfo').textContent = `${msg.server_name} (ID: ${msg.server_id})`;
|
||||
term.focus();
|
||||
break;
|
||||
case 'DATA':
|
||||
term.write(msg.data);
|
||||
break;
|
||||
case 'CLOSE':
|
||||
disconnect();
|
||||
break;
|
||||
case 'ERROR':
|
||||
term.writeln('\r\n\x1b[31m错误: ' + msg.message + '\x1b[0m');
|
||||
break;
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
switch (msg.type) {
|
||||
case 'TERMINAL_INIT':
|
||||
$d().serverName = msg.server_name || ('服务器 #' + msg.server_id);
|
||||
document.title = `Nexus — ${$d().serverName}`;
|
||||
// Send initial size
|
||||
const d = fitAddon.proposeDimensions();
|
||||
if (d && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'RESIZE', cols: d.cols, rows: d.rows }));
|
||||
$d().cols = d.cols; $d().rows = d.rows;
|
||||
}
|
||||
break;
|
||||
case 'DATA':
|
||||
term.write(msg.data);
|
||||
break;
|
||||
case 'ERROR':
|
||||
term.write('\r\n\x1b[31m⚠ ' + msg.message + '\x1b[0m\r\n');
|
||||
break;
|
||||
case 'CLOSE':
|
||||
$d().connected = false;
|
||||
term.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n');
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
term.write(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
$d().connected = false;
|
||||
if (ev.code === 4001) {
|
||||
term.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n');
|
||||
setTimeout(() => location.href = '/app/login.html', 2000);
|
||||
} else if (ev.code === 4004) {
|
||||
term.write('\r\n\x1b[31m⚠ 服务器不存在\x1b[0m\r\n');
|
||||
} else if (ev.code !== 1000) {
|
||||
term.write('\r\n\x1b[33m⚠ 连接断开 (' + ev.code + ')\x1b[0m\r\n');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
updateStatus('error', '连接错误');
|
||||
term.writeln('\r\n\x1b[31mWebSocket 错误\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
updateStatus('disconnected', '已断开');
|
||||
term.writeln('\r\n\x1b[33m连接已关闭\x1b[0m');
|
||||
$d().connected = false;
|
||||
term.write('\r\n\x1b[31m⚠ WebSocket错误\x1b[0m\r\n');
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatus(status, text) {
|
||||
const el = document.getElementById('connectionStatus');
|
||||
const dotClass = status === 'connected' ? 'bg-green-500' : status === 'error' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse';
|
||||
el.innerHTML = '<span class="w-2 h-2 rounded-full ' + dotClass + '"></span>' + text;
|
||||
// ── Input: terminal → WebSocket ──
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'DATA', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Resize: terminal → WebSocket ──
|
||||
term.onResize(({ cols, rows }) => {
|
||||
$d().cols = cols; $d().rows = rows;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'RESIZE', cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-fit on container resize
|
||||
new ResizeObserver(() => { try { fitAddon.fit(); } catch(e){} })
|
||||
.observe(document.getElementById('terminalWrap'));
|
||||
|
||||
// ── Fullscreen ──
|
||||
function toggleFullscreen() {
|
||||
const el = document.getElementById('terminalWrap');
|
||||
const fs = el.classList.toggle('fs');
|
||||
$d().fullscreen = fs;
|
||||
setTimeout(() => fitAddon.fit(), 50);
|
||||
}
|
||||
|
||||
// ── Disconnect ──
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
try { ws.send(JSON.stringify({ type: 'CLOSE' })); } catch(e) {}
|
||||
ws.close();
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'CLOSE' }));
|
||||
ws.close(1000, 'User disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('resizeBtn').addEventListener('click', () => {
|
||||
if (fitAddon) fitAddon.fit();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'RESIZE', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('disconnectBtn').addEventListener('click', disconnect);
|
||||
|
||||
// ── Keyboard shortcuts ──
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.altKey && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'F') { e.preventDefault(); toggleFullscreen(); }
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'K') { e.preventDefault(); disconnect(); }
|
||||
});
|
||||
|
||||
initTerminal();
|
||||
connectTerminal();
|
||||
setTimeout(() => fitAddon.fit(), 100);
|
||||
// ── Load server name for page title ──
|
||||
(async () => {
|
||||
try {
|
||||
const r = await apiFetch(API + '/servers/' + serverId);
|
||||
if (r?.ok) { const s = await r.json(); $d().serverName = s.name || $d().serverName; document.title = 'Nexus — ' + $d().serverName; }
|
||||
} catch(e){}
|
||||
})();
|
||||
|
||||
// ── Init ──
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user