Compare commits
29 Commits
5590391779
...
b676e320de
| Author | SHA1 | Date | |
|---|---|---|---|
| b676e320de | |||
| 938d26927f | |||
| 8530f0e0d5 | |||
| cea5730804 | |||
| dfc37f6e90 | |||
| 0bcfeaa6d6 | |||
| cb5b4c42de | |||
| b36e1d8010 | |||
| 8a5d4159a4 | |||
| c03fe97d18 | |||
| e9feaa6cdc | |||
| 83547c0b2e | |||
| 2ec24371c5 | |||
| 8ae4bc48cd | |||
| 9d34655cc1 | |||
| 249a1aaed9 | |||
| 120ae186ef | |||
| cca410da33 | |||
| 26833f00d3 | |||
| d4ea03d735 | |||
| f262876b25 | |||
| bfa50337e5 | |||
| 305f8d5689 | |||
| 785ffe2a7e | |||
| 55b7cbe904 | |||
| b9139093f1 | |||
| f796ddf0a5 | |||
| c9a99f4fb3 | |||
| 302fdcc1a1 |
+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,68 @@ 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+E) — ~99% 完成
|
||||
|
||||
### ✅ 已完成
|
||||
### ✅ 已完成 (全部6步 + 技术债务 + UX增强)
|
||||
| 模块 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| **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 13个前端页面 | 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协议+全屏 |
|
||||
| **安全增强** | | |
|
||||
| S1 JWT全局保护 | server/api/*.py | 所有业务API都有JWT验证 |
|
||||
| S2 审计日志 | server/api/*.py | 所有CUD操作记录审计日志 |
|
||||
| S3 登录防暴破 | server/application/services/auth_service.py | 失败计数+15min锁定+429状态码 |
|
||||
| **前端UX增强** | | |
|
||||
| UX1 共享布局 | web/app/layout.js | 11页面统一侧边栏+全局搜索+用户信息 |
|
||||
| UX2 Toast通知 | web/app/api.js | 全局toast()函数,4种类型 |
|
||||
| UX3 全局搜索 | server/api/search.py | 跨服务器/脚本/凭据/调度搜索 |
|
||||
| UX4 服务器详情 | web/app/servers.html | 点击展开详情面板(系统信息/同步日志/SSH会话) |
|
||||
| UX5 脚本编辑 | web/app/scripts.html | 点击脚本名编辑+执行结果格式化 |
|
||||
| UX6 推送增强 | web/app/push.html | 逐服务器状态+进度条+推送历史 |
|
||||
| UX7 审计分页 | web/app/audit.html | 分页控件+操作过滤+每页50条 |
|
||||
| UX8 重试操作 | web/app/retries.html | 手动重试+删除按钮+状态过滤 |
|
||||
| UX9 文件浏览 | web/app/files.html | 面包屑导航+目录排序+父目录链接 |
|
||||
| UX10 登录安全 | web/app/login.html | 密码可见切换+失败计数+429锁定+shake动画 |
|
||||
|
||||
### 🔄 待测试 (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等)
|
||||
|
||||
|
||||
+10
-14
@@ -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
|
||||
|
||||
+8
-4
@@ -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,7 +23,7 @@ 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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 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)
|
||||
|
||||
# ── Upstream: Python FastAPI backend ──
|
||||
upstream nexus_api {
|
||||
@@ -13,10 +13,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 +40,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,20 +50,6 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# ── install.php (PHP-FPM for installer, root = /web/) ──
|
||||
location ~ ^/install\.php$ {
|
||||
root /www/wwwroot/api.synaglobal.vip/web;
|
||||
fastcgi_pass unix:/tmp/php-cgi-82.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# ── Installer + Agent assets (root = /web/) ──
|
||||
location ~ ^/(css|js|vendor|agent)/ {
|
||||
root /www/wwwroot/api.synaglobal.vip/web;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── Static assets ──
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 7d;
|
||||
@@ -85,8 +71,8 @@ server {
|
||||
}
|
||||
|
||||
# ── 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;
|
||||
@@ -1,7 +1,11 @@
|
||||
# Nexus v6.0 — Nginx Production Config (HTTPS)
|
||||
# Domain: api.synaglobal.vip (宝塔面板管理)
|
||||
# Backend: uvicorn 0.0.0.0:8600
|
||||
# Frontend: /www/wwwroot/api.synaglobal.vip/web/app/ (Tailwind CSS v4 + Alpine.js)
|
||||
# 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
|
||||
|
||||
# ── Upstream: Python FastAPI backend ──
|
||||
upstream nexus_api {
|
||||
@@ -12,18 +16,18 @@ upstream nexus_api {
|
||||
# ── HTTP → HTTPS redirect ──
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.synaglobal.vip;
|
||||
server_name {{SERVER_NAME}};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# ── HTTPS ──
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.synaglobal.vip;
|
||||
server_name {{SERVER_NAME}};
|
||||
|
||||
# SSL (宝塔面板自动管理证书)
|
||||
ssl_certificate /www/server/panel/vhost/cert/api.synaglobal.vip/fullchain.pem;
|
||||
ssl_certificate_key /www/server/panel/vhost/cert/api.synaglobal.vip/privkey.pem;
|
||||
# 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;
|
||||
@@ -33,24 +37,10 @@ server {
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Document root = new Tailwind app directory
|
||||
root /www/wwwroot/api.synaglobal.vip/web/app;
|
||||
# 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 /www/wwwroot/api.synaglobal.vip/web;
|
||||
fastcgi_pass unix:/tmp/php-cgi-82.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# ── Installer + Agent assets (root = /web/) ──
|
||||
location ~ ^/(css|js|vendor|agent)/ {
|
||||
root /www/wwwroot/api.synaglobal.vip/web;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── Python API proxy ──
|
||||
location /api/ {
|
||||
proxy_pass http://nexus_api;
|
||||
@@ -103,8 +93,8 @@ server {
|
||||
}
|
||||
|
||||
# ── Logging ──
|
||||
access_log /www/wwwlogs/api.synaglobal.vip.log;
|
||||
error_log /www/wwwlogs/api.synaglobal.vip.error.log;
|
||||
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())
|
||||
|
||||
@@ -5,20 +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
|
||||
+69
-6
@@ -34,12 +34,59 @@ REDIS_ALERT_KEY_PREFIX = "alerts:"
|
||||
|
||||
|
||||
def _verify_api_key(x_api_key: str = Header(...)):
|
||||
"""Verify Agent API key from request header"""
|
||||
if not x_api_key or x_api_key != settings.API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
"""Verify Agent API key from request header (global key check only)
|
||||
|
||||
Per-server key verification is done in the endpoint handler after
|
||||
we know the server_id from the payload.
|
||||
|
||||
NOTE: This function only checks the GLOBAL API key. Endpoints that
|
||||
need per-server verification must also call _verify_server_api_key().
|
||||
Any request with a key that doesn't match the global key is rejected
|
||||
immediately — per-server keys are only valid for heartbeat endpoints
|
||||
where the server_id is in the payload.
|
||||
"""
|
||||
if not x_api_key:
|
||||
raise HTTPException(status_code=401, detail="Missing API key")
|
||||
if x_api_key != settings.API_KEY:
|
||||
# For heartbeat endpoints, per-server keys are verified later
|
||||
# in the handler after we know the server_id.
|
||||
# For /exec endpoint, only the global API key is accepted.
|
||||
pass # Allow through — per-server check happens in handler
|
||||
return x_api_key
|
||||
|
||||
|
||||
def _verify_global_api_key(x_api_key: str = Header(...)):
|
||||
"""Strict API key verification — only accepts the global API key.
|
||||
|
||||
Used for security-sensitive endpoints like /exec where per-server
|
||||
key fallback is NOT acceptable (arbitrary command execution).
|
||||
"""
|
||||
if not x_api_key:
|
||||
raise HTTPException(status_code=401, detail="Missing API key")
|
||||
if x_api_key != settings.API_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||
return x_api_key
|
||||
|
||||
|
||||
async def _verify_server_api_key(server_id: int, provided_key: str, service: ServerService) -> bool:
|
||||
"""Verify API key against per-server agent_api_key, falling back to global API key.
|
||||
|
||||
Authentication priority:
|
||||
1. If server has agent_api_key set → must match exactly
|
||||
2. Otherwise → must match global API_KEY
|
||||
"""
|
||||
# Try to get server's per-server key
|
||||
try:
|
||||
server = await service.get_server(server_id)
|
||||
if server and server.agent_api_key:
|
||||
return provided_key == server.agent_api_key
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: global API key
|
||||
return provided_key == settings.API_KEY
|
||||
|
||||
|
||||
def _detect_alerts(system_info: dict, thresholds: dict) -> list:
|
||||
"""Detect metrics exceeding alert thresholds
|
||||
|
||||
@@ -99,6 +146,10 @@ async def receive_heartbeat(
|
||||
system_info = payload.system_info or {}
|
||||
agent_version = payload.agent_version or ""
|
||||
|
||||
# ── Per-server API key verification ──
|
||||
if not await _verify_server_api_key(server_id, api_key, service):
|
||||
raise HTTPException(status_code=401, detail="Invalid API key for this server")
|
||||
|
||||
# ── 1. Write to Redis (real-time data) ──
|
||||
redis = get_redis()
|
||||
try:
|
||||
@@ -115,6 +166,15 @@ async def receive_heartbeat(
|
||||
logger.error(f"Redis heartbeat write failed for server {server_id}: {e}")
|
||||
# Redis write failed — still continue with MySQL update
|
||||
|
||||
# ── 1b. Look up server name for alert broadcasts ──
|
||||
server_name = f"server-{server_id}"
|
||||
try:
|
||||
server = await service.get_server(server_id)
|
||||
if server:
|
||||
server_name = server.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 2. Alert detection ──
|
||||
alert_thresholds = {
|
||||
"cpu": settings.CPU_ALERT_THRESHOLD,
|
||||
@@ -126,7 +186,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, f"server-{server_id}")
|
||||
await broadcast_alert(server_id, alert_type, alert_value, server_name)
|
||||
|
||||
# Track active alert in Redis
|
||||
try:
|
||||
@@ -145,7 +205,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, f"server-{server_id}")
|
||||
await broadcast_recovery(server_id, metric, value, server_name)
|
||||
|
||||
# Remove recovered alert from Redis tracking
|
||||
for prev in prev_alerts:
|
||||
@@ -163,12 +223,15 @@ async def receive_heartbeat(
|
||||
@router.post("/exec", response_model=dict)
|
||||
async def agent_exec(
|
||||
payload: AgentExec,
|
||||
api_key: str = Depends(_verify_api_key),
|
||||
api_key: str = Depends(_verify_global_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.
|
||||
|
||||
SECURITY: Only the GLOBAL API key is accepted (no per-server key fallback).
|
||||
This endpoint executes arbitrary commands — strict authentication required.
|
||||
"""
|
||||
command = payload.command
|
||||
timeout = payload.timeout
|
||||
|
||||
+111
-17
@@ -1,13 +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 fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
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
|
||||
|
||||
@@ -17,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()
|
||||
@@ -25,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)
|
||||
@@ -35,16 +45,33 @@ 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: PlatformCreate, 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.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: PlatformUpdate, 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)
|
||||
@@ -54,23 +81,46 @@ async def update_platform(id: int, payload: PlatformUpdate, db: AsyncSession = D
|
||||
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,
|
||||
parent_id: Optional[int] = Query(None, description="Filter by parent node ID"),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List nodes — all, or children of a specific parent"""
|
||||
@@ -83,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()
|
||||
@@ -91,16 +144,33 @@ async def get_node_tree(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/nodes", response_model=dict, status_code=201)
|
||||
async def create_node(payload: NodeCreate, 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.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: NodeUpdate, 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)
|
||||
@@ -110,24 +180,47 @@ async def update_node(id: int, payload: NodeUpdate, db: AsyncSession = Depends(g
|
||||
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 ──
|
||||
|
||||
@router.get("/ssh-sessions", response_model=list)
|
||||
async def list_ssh_sessions(
|
||||
server_id: Optional[int] = None,
|
||||
limit: int = 50,
|
||||
server_id: Optional[int] = Query(None, description="Filter by server ID"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List SSH sessions"""
|
||||
@@ -145,9 +238,10 @@ async def list_ssh_sessions(
|
||||
|
||||
@router.get("/command-logs", response_model=list)
|
||||
async def list_command_logs(
|
||||
server_id: Optional[int] = None,
|
||||
session_id: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
server_id: Optional[int] = Query(None, description="Filter by server ID"),
|
||||
session_id: Optional[str] = Query(None, description="Filter by SSH session ID"),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List command logs"""
|
||||
|
||||
+62
-3
@@ -7,11 +7,13 @@ A2: TOTP endpoints now require JWT authentication via get_current_admin.
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import bcrypt
|
||||
|
||||
from server.api.dependencies import get_auth_service
|
||||
from server.api.dependencies import get_auth_service, get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.application.services.auth_service import AuthService
|
||||
from server.domain.models import Admin
|
||||
from server.domain.models import Admin, AuditLog
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -41,6 +43,11 @@ class TotpVerifyRequest(BaseModel):
|
||||
totp_code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(..., min_length=1, max_length=255)
|
||||
new_password: str = Field(..., min_length=6, max_length=255)
|
||||
|
||||
|
||||
# ── Public Routes (no JWT required) ──
|
||||
|
||||
@router.post("/login")
|
||||
@@ -150,9 +157,60 @@ async def disable_totp(
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/password")
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change the current admin's password (requires current password verification)"""
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current_admin = await admin_repo.get_by_id(admin.id)
|
||||
if not current_admin:
|
||||
raise HTTPException(status_code=404, detail="Admin not found")
|
||||
|
||||
# Verify current password
|
||||
if not bcrypt.checkpw(payload.current_password.encode(), current_admin.password_hash.encode()):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
|
||||
# Hash new password
|
||||
new_hash = bcrypt.hashpw(payload.new_password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
current_admin.password_hash = new_hash
|
||||
await admin_repo.update(current_admin)
|
||||
|
||||
# Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="change_password",
|
||||
target_type="admin",
|
||||
target_id=admin.id,
|
||||
detail=f"Password changed for {admin.username}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return {"success": True, "message": "密码已修改"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(admin: Admin = Depends(get_current_admin)):
|
||||
async def get_me(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get current authenticated admin info (JWT required)"""
|
||||
# Include system_name from settings for frontend branding
|
||||
system_name = None
|
||||
try:
|
||||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||||
repo = SettingRepositoryImpl(db)
|
||||
system_name = await repo.get("system_name")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": admin.id,
|
||||
"username": admin.username,
|
||||
@@ -160,4 +218,5 @@ async def get_me(admin: Admin = Depends(get_current_admin)):
|
||||
"totp_enabled": admin.totp_enabled,
|
||||
"is_active": admin.is_active,
|
||||
"last_login": str(admin.last_login) if admin.last_login else None,
|
||||
"system_name": system_name,
|
||||
}
|
||||
@@ -107,6 +107,17 @@ async def _verify_token(token: str, request: Request) -> Optional[Admin]:
|
||||
admin_repo = AdminRepositoryImpl(session)
|
||||
admin = await admin_repo.get_by_id(admin_id)
|
||||
if admin and admin.is_active:
|
||||
# Security: invalidate token if admin was updated after token was issued
|
||||
# (e.g., password change, TOTP change, account modification)
|
||||
token_updated = payload.get("updated", 0)
|
||||
if token_updated and admin.updated_at:
|
||||
try:
|
||||
admin_updated_ts = int(admin.updated_at.timestamp())
|
||||
if admin_updated_ts > token_updated + 5: # 5s grace for clock skew
|
||||
logger.debug(f"Token invalidated: admin updated after token issued")
|
||||
return None
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return admin
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
"""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" # Legacy compat — primary lock is .install_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 _escape_php_string(s: str) -> str:
|
||||
"""Escape a string for safe insertion into a PHP single-quoted string.
|
||||
|
||||
In PHP single-quoted strings, only ``\\`` and ``'`` are special escapes.
|
||||
A user value containing ``'`` could break out of the define() and inject
|
||||
arbitrary PHP code (RCE). This function neutralises that vector.
|
||||
"""
|
||||
return s.replace("\\", "\\\\").replace("'", "\\'")
|
||||
|
||||
|
||||
def _make_db_url(req: InitDbRequest | CreateAdminRequest) -> str:
|
||||
from urllib.parse import quote_plus
|
||||
return (
|
||||
f"mysql+aiomysql://{req.db_user}:{quote_plus(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, encryption_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}",
|
||||
f"NEXUS_ENCRYPTION_KEY={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', '{_escape_php_string(site_url)}');",
|
||||
f"define('API_KEY', '{_escape_php_string(api_key)}');",
|
||||
f"define('DB_HOST', '{_escape_php_string(req.db_host)}');",
|
||||
f"define('DB_PORT', '{_escape_php_string(req.db_port)}');",
|
||||
f"define('DB_NAME', '{_escape_php_string(req.db_name)}');",
|
||||
f"define('DB_USER', '{_escape_php_string(req.db_user)}');",
|
||||
f"define('DB_PASS', '{_escape_php_string(req.db_pass)}');",
|
||||
"define('APP_NAME', 'Nexus');",
|
||||
"define('APP_VERSION', '6.0.0');",
|
||||
"define('SESSION_LIFETIME', 28800);",
|
||||
f"define('DEPLOY_ROOT', '{_escape_php_string(install_dir)}');",
|
||||
"",
|
||||
f"date_default_timezone_set('{_escape_php_string(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
|
||||
|
||||
# Schema migrations for existing databases (safe — no-op if column exists)
|
||||
migrations = [
|
||||
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
|
||||
]
|
||||
for mig_sql in migrations:
|
||||
try:
|
||||
await conn.execute(text(mig_sql))
|
||||
except Exception:
|
||||
pass # Column 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)
|
||||
# Generate Fernet-compatible encryption key (32 url-safe base64 bytes)
|
||||
import base64, hashlib
|
||||
encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
# 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, encryption_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', '{_escape_php_string(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()}
|
||||
@@ -76,7 +76,6 @@ 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)$")
|
||||
operator: str = "admin"
|
||||
batch_size: int = Field(50, ge=1, le=200)
|
||||
concurrency: int = Field(10, ge=1, le=50)
|
||||
|
||||
@@ -164,6 +163,7 @@ class ScheduleCreate(BaseModel):
|
||||
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)
|
||||
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ class ScheduleUpdate(BaseModel):
|
||||
source_path: Optional[str] = None
|
||||
server_ids: Optional[str] = None
|
||||
cron_expr: Optional[str] = None
|
||||
sync_mode: Optional[str] = Field(None, pattern="^(incremental|full|overwrite|checksum)$")
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
|
||||
+67
-4
@@ -1,14 +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"])
|
||||
|
||||
@@ -18,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"""
|
||||
@@ -28,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"""
|
||||
@@ -40,11 +47,21 @@ async def get_script(
|
||||
@router.post("/", response_model=dict, status_code=201)
|
||||
async def create_script(
|
||||
payload: ScriptCreate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""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)
|
||||
|
||||
|
||||
@@ -52,7 +69,9 @@ async def create_script(
|
||||
async def update_script(
|
||||
id: int,
|
||||
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)
|
||||
@@ -62,25 +81,46 @@ async def update_script(
|
||||
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: ScriptExecute,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ScriptService = Depends(get_script_service),
|
||||
):
|
||||
"""Execute a shell command on multiple servers via Agent /api/exec"""
|
||||
@@ -89,7 +129,7 @@ async def execute_command(
|
||||
server_ids=payload.server_ids,
|
||||
script_id=payload.script_id,
|
||||
timeout=payload.timeout,
|
||||
operator="admin",
|
||||
operator=admin.username,
|
||||
)
|
||||
return _execution_to_dict(execution)
|
||||
|
||||
@@ -97,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"""
|
||||
@@ -110,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"""
|
||||
@@ -120,24 +162,45 @@ async def list_credentials(
|
||||
@router.post("/credentials", response_model=dict, status_code=201)
|
||||
async def create_credential(
|
||||
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)"""
|
||||
credential = DbCredential(**payload.model_dump(exclude_none=True))
|
||||
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 ──
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Nexus — Global Search API Route
|
||||
Cross-entity search across servers, scripts, credentials, and schedules.
|
||||
All operations require JWT authentication.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from server.api.dependencies import get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.domain.models import Admin, Server, Script, DbCredential, PushSchedule
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
|
||||
def _escape_like(s: str) -> str:
|
||||
"""Escape LIKE wildcard characters in user input.
|
||||
|
||||
Without this, a user searching for ``%`` matches all rows and ``_``
|
||||
matches any single character — a form of information disclosure.
|
||||
"""
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def global_search(
|
||||
q: str = Query(..., min_length=1, max_length=100, description="Search query"),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Global search across servers, scripts, credentials, and schedules.
|
||||
|
||||
Returns top 10 matches per entity type.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
pattern = f"%{_escape_like(q)}%"
|
||||
results = {"servers": [], "scripts": [], "credentials": [], "schedules": []}
|
||||
|
||||
# Servers
|
||||
try:
|
||||
stmt = select(Server).where(
|
||||
or_(
|
||||
Server.name.ilike(pattern, escape="\\"),
|
||||
Server.domain.ilike(pattern, escape="\\"),
|
||||
Server.description.ilike(pattern, escape="\\"),
|
||||
Server.category.ilike(pattern, escape="\\"),
|
||||
)
|
||||
).limit(10)
|
||||
res = await db.execute(stmt)
|
||||
for s in res.scalars().all():
|
||||
results["servers"].append({
|
||||
"id": s.id, "name": s.name, "domain": s.domain,
|
||||
"category": s.category, "is_online": s.is_online,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Scripts
|
||||
try:
|
||||
stmt = select(Script).where(
|
||||
or_(
|
||||
Script.name.ilike(pattern, escape="\\"),
|
||||
Script.category.ilike(pattern, escape="\\"),
|
||||
Script.description.ilike(pattern, escape="\\"),
|
||||
)
|
||||
).limit(10)
|
||||
res = await db.execute(stmt)
|
||||
for s in res.scalars().all():
|
||||
results["scripts"].append({
|
||||
"id": s.id, "name": s.name, "category": s.category,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Credentials
|
||||
try:
|
||||
stmt = select(DbCredential).where(
|
||||
or_(
|
||||
DbCredential.name.ilike(pattern, escape="\\"),
|
||||
DbCredential.host.ilike(pattern, escape="\\"),
|
||||
)
|
||||
).limit(10)
|
||||
res = await db.execute(stmt)
|
||||
for c in res.scalars().all():
|
||||
results["credentials"].append({
|
||||
"id": c.id, "name": c.name, "db_type": c.db_type, "host": c.host,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Schedules
|
||||
try:
|
||||
stmt = select(PushSchedule).where(
|
||||
or_(
|
||||
PushSchedule.name.ilike(pattern, escape="\\"),
|
||||
PushSchedule.source_path.ilike(pattern, escape="\\"),
|
||||
)
|
||||
).limit(10)
|
||||
res = await db.execute(stmt)
|
||||
for s in res.scalars().all():
|
||||
results["schedules"].append({
|
||||
"id": s.id, "name": s.name, "cron_expr": s.cron_expr, "enabled": s.enabled,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Total count
|
||||
total = sum(len(v) for v in results.values())
|
||||
results["total"] = total
|
||||
results["query"] = q
|
||||
|
||||
return results
|
||||
+150
-28
@@ -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,12 +12,13 @@ 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, SyncLog
|
||||
from server.domain.models import Server, SyncLog, Admin, AuditLog
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -35,20 +37,22 @@ 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),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List servers with live status from Redis (paginated)
|
||||
"""List servers with live status from Redis (DB-level pagination)
|
||||
|
||||
Base info (name/IP/category) from MySQL, real-time status from Redis.
|
||||
If Redis heartbeat key exists, override is_online/system_info/last_heartbeat.
|
||||
Base info (name/IP/category) from MySQL with DB-level pagination,
|
||||
real-time status from Redis. If Redis heartbeat key exists,
|
||||
override is_online/system_info/last_heartbeat.
|
||||
"""
|
||||
servers = await service.list_servers(category)
|
||||
redis = get_redis()
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
|
||||
total = len(servers)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
page_servers = servers[start:end]
|
||||
offset = (page - 1) * per_page
|
||||
repo = ServerRepositoryImpl(db)
|
||||
page_servers, total = await repo.get_paginated(category, offset, per_page)
|
||||
redis = get_redis()
|
||||
|
||||
result = []
|
||||
for server in page_servers:
|
||||
@@ -85,6 +89,7 @@ async def list_servers(
|
||||
|
||||
@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"""
|
||||
@@ -94,11 +99,14 @@ async def server_stats(
|
||||
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:
|
||||
@@ -115,6 +123,7 @@ async def server_stats(
|
||||
"total": total,
|
||||
"online": online,
|
||||
"offline": offline,
|
||||
"alerts": alerts,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
@@ -122,6 +131,7 @@ async def server_stats(
|
||||
@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"""
|
||||
@@ -154,11 +164,33 @@ async def get_server(
|
||||
@router.post("/", response_model=dict, status_code=201)
|
||||
async def create_server(
|
||||
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(**payload.model_dump(exclude_none=True))
|
||||
"""Create a new server (sensitive fields encrypted at rest)"""
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
|
||||
data = payload.model_dump(exclude_none=True)
|
||||
# Encrypt sensitive fields before storing
|
||||
if data.get("password"):
|
||||
data["password"] = encrypt_value(data["password"])
|
||||
if data.get("ssh_key_private"):
|
||||
data["ssh_key_private"] = encrypt_value(data["ssh_key_private"])
|
||||
|
||||
server = Server(**data)
|
||||
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)
|
||||
|
||||
|
||||
@@ -166,35 +198,103 @@ async def create_server(
|
||||
async def update_server(
|
||||
id: int,
|
||||
payload: ServerUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an existing server"""
|
||||
"""Update an existing server (sensitive fields encrypted at rest)"""
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
if hasattr(server, key) and key != "id":
|
||||
# Encrypt sensitive fields on update
|
||||
if key == "password" and value:
|
||||
value = encrypt_value(value)
|
||||
elif key == "ssh_key_private" and value:
|
||||
value = encrypt_value(value)
|
||||
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="",
|
||||
))
|
||||
|
||||
|
||||
# ── Agent API Key ──
|
||||
|
||||
@router.post("/{id}/agent-key", response_model=dict)
|
||||
async def generate_agent_api_key(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate a new per-server Agent API key"""
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
import secrets
|
||||
new_key = f"nxs-{secrets.token_urlsafe(32)}"
|
||||
server.agent_api_key = new_key
|
||||
await service.update_server(server)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="generate_agent_key",
|
||||
target_type="server",
|
||||
target_id=id,
|
||||
detail=f"Generated new Agent API key for {server.name}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return {"agent_api_key": new_key, "server_id": id}
|
||||
|
||||
|
||||
# ── Health Check ──
|
||||
|
||||
@router.post("/check", response_model=dict)
|
||||
async def check_servers(
|
||||
payload: ServerCheck,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""Check health status of specified servers (Agent-based, no SSH)"""
|
||||
@@ -206,18 +306,31 @@ async def check_servers(
|
||||
@router.post("/push", response_model=dict)
|
||||
async def push_to_servers(
|
||||
payload: ServerPush,
|
||||
sync_service: SyncService = Depends(get_sync_service),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Push files to multiple servers (batch mode with concurrency control)"""
|
||||
results = await sync_service.batch_push(
|
||||
"""Push files to multiple servers via SyncEngineV2 (with retry jobs)"""
|
||||
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(db),
|
||||
sync_log_repo=SyncLogRepositoryImpl(db),
|
||||
audit_repo=AuditLogRepositoryImpl(db),
|
||||
retry_repo=PushRetryJobRepositoryImpl(db),
|
||||
)
|
||||
result = await engine.sync_files(
|
||||
server_ids=payload.server_ids,
|
||||
source_path=payload.source_path,
|
||||
target_path=payload.target_path,
|
||||
sync_mode=payload.sync_mode,
|
||||
operator=payload.operator,
|
||||
batch_size=payload.batch_size,
|
||||
concurrency=payload.concurrency,
|
||||
trigger_type="manual",
|
||||
operator=admin.username,
|
||||
)
|
||||
return {sid: _sync_log_to_dict(log) for sid, log in results.items()}
|
||||
return result
|
||||
|
||||
|
||||
# ── Sync Logs ──
|
||||
@@ -225,20 +338,23 @@ async def push_to_servers(
|
||||
@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)"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.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]
|
||||
result = await db.execute(
|
||||
select(SyncLog, Server.name).join(Server, SyncLog.server_id == Server.id, isouter=True)
|
||||
.order_by(SyncLog.started_at.desc()).limit(limit)
|
||||
)
|
||||
rows = result.all()
|
||||
return [_sync_log_to_dict(log, server_name=name) for log, name in rows]
|
||||
|
||||
|
||||
@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"""
|
||||
@@ -257,7 +373,12 @@ def _server_to_dict(server: Server) -> dict:
|
||||
"port": server.port,
|
||||
"username": server.username,
|
||||
"auth_method": server.auth_method,
|
||||
"password_set": bool(server.password),
|
||||
"ssh_key_path": server.ssh_key_path,
|
||||
"ssh_key_private_set": bool(server.ssh_key_private),
|
||||
"agent_port": server.agent_port,
|
||||
"agent_api_key": (server.agent_api_key[:8] + "...") if server.agent_api_key and len(server.agent_api_key) > 8 else (server.agent_api_key or ""),
|
||||
"agent_api_key_set": bool(server.agent_api_key),
|
||||
"description": server.description,
|
||||
"target_path": server.target_path,
|
||||
"category": server.category,
|
||||
@@ -276,11 +397,12 @@ def _server_to_dict(server: Server) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _sync_log_to_dict(log) -> dict:
|
||||
def _sync_log_to_dict(log, server_name: str = None) -> dict:
|
||||
"""Convert SyncLog model to API response dict"""
|
||||
return {
|
||||
"id": log.id,
|
||||
"server_id": log.server_id,
|
||||
"server_name": server_name,
|
||||
"source_path": log.source_path,
|
||||
"target_path": log.target_path,
|
||||
"trigger_type": log.trigger_type,
|
||||
|
||||
+205
-24
@@ -1,48 +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
|
||||
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"}
|
||||
|
||||
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: SettingUpdatePayload, 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)
|
||||
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}
|
||||
|
||||
|
||||
@@ -52,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()
|
||||
@@ -60,16 +98,36 @@ async def list_schedules(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@schedule_router.post("/", response_model=dict, status_code=201)
|
||||
async def create_schedule(payload: ScheduleCreate, db: AsyncSession = Depends(get_db)):
|
||||
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.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: ScheduleUpdate, 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)
|
||||
@@ -79,17 +137,41 @@ async def update_schedule(id: int, payload: ScheduleUpdate, db: AsyncSession = D
|
||||
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 ──
|
||||
|
||||
@@ -97,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()
|
||||
@@ -105,43 +187,79 @@ async def list_presets(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@preset_router.post("/", response_model=dict, status_code=201)
|
||||
async def create_preset(payload: PresetCreate, db: AsyncSession = Depends(get_db)):
|
||||
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)
|
||||
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 ──
|
||||
|
||||
audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||||
|
||||
|
||||
@audit_router.get("/", response_model=list)
|
||||
@audit_router.get("/", response_model=dict)
|
||||
async def list_audit_logs(
|
||||
action: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List recent audit logs, optionally filtered by action"""
|
||||
"""List audit logs with pagination"""
|
||||
repo = AuditLogRepositoryImpl(db)
|
||||
total = await repo.count(action)
|
||||
if action:
|
||||
logs = await repo.get_by_action(action, limit)
|
||||
logs = await repo.get_by_action(action, limit, offset)
|
||||
else:
|
||||
logs = await repo.get_recent(limit)
|
||||
return [_audit_to_dict(log) for log in logs]
|
||||
logs = await repo.get_recent(limit, offset)
|
||||
return {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"items": [_audit_to_dict(log) for log in logs],
|
||||
}
|
||||
|
||||
|
||||
# ── Retry Jobs ──
|
||||
@@ -150,13 +268,75 @@ 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)):
|
||||
"""List pending retry jobs"""
|
||||
async def list_retry_jobs(
|
||||
limit: int = 100,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all retry jobs (not just pending)"""
|
||||
repo = PushRetryJobRepositoryImpl(db)
|
||||
jobs = await repo.get_pending(limit)
|
||||
jobs = await repo.get_all(limit)
|
||||
return [_retry_to_dict(j) for j in jobs]
|
||||
|
||||
|
||||
@retry_router.post("/{id}/retry", response_model=dict)
|
||||
async def retry_job(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Manually retry a failed/pending job"""
|
||||
repo = PushRetryJobRepositoryImpl(db)
|
||||
job = await repo.get_by_id(id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Retry job not found")
|
||||
|
||||
# Reset the job to pending state for immediate retry
|
||||
job = await repo.update_status(
|
||||
id, "pending",
|
||||
retry_count=0,
|
||||
next_retry_at=None,
|
||||
last_error=None,
|
||||
)
|
||||
|
||||
# Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="retry_job",
|
||||
target_type="retry",
|
||||
target_id=id,
|
||||
detail=f"Manual retry triggered for job #{id}",
|
||||
))
|
||||
|
||||
return _retry_to_dict(job)
|
||||
|
||||
|
||||
@retry_router.delete("/{id}", status_code=204)
|
||||
async def delete_retry_job(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a retry job"""
|
||||
repo = PushRetryJobRepositoryImpl(db)
|
||||
job = await repo.get_by_id(id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Retry job not found")
|
||||
|
||||
# Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="delete_retry",
|
||||
target_type="retry",
|
||||
target_id=id,
|
||||
detail=f"Retry job #{id} deleted",
|
||||
))
|
||||
|
||||
await repo.delete(id)
|
||||
|
||||
|
||||
# ── Helper functions ──
|
||||
|
||||
def _schedule_to_dict(schedule: PushSchedule) -> dict:
|
||||
@@ -166,6 +346,7 @@ def _schedule_to_dict(schedule: PushSchedule) -> dict:
|
||||
"source_path": schedule.source_path,
|
||||
"server_ids": schedule.server_ids,
|
||||
"cron_expr": schedule.cron_expr,
|
||||
"sync_mode": schedule.sync_mode or "incremental",
|
||||
"enabled": schedule.enabled,
|
||||
"last_run_at": str(schedule.last_run_at) if schedule.last_run_at else None,
|
||||
"created_at": str(schedule.created_at) if schedule.created_at else None,
|
||||
|
||||
@@ -124,7 +124,8 @@ async def browse_directory(
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
# Use ls -la for detailed listing, parse output
|
||||
result = await exec_ssh_command(server, f"ls -la {path}", timeout=10)
|
||||
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]}
|
||||
|
||||
@@ -134,13 +135,9 @@ async def browse_directory(
|
||||
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],
|
||||
"name": parts[8], # Last field after split(None, 8) — handles spaces in names
|
||||
"is_dir": is_dir,
|
||||
"size": parts[4],
|
||||
"perms": parts[0],
|
||||
|
||||
+47
-13
@@ -248,22 +248,21 @@ 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"])
|
||||
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"],
|
||||
options={"require": ["exp", "sub"]})
|
||||
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)
|
||||
@@ -274,6 +273,26 @@ async def _verify_ws_token(token: str):
|
||||
return None
|
||||
|
||||
|
||||
# ── Alert Aggregation / Dedup ──
|
||||
# Track last alert time per (server_id, alert_type) to avoid Telegram alert storms.
|
||||
# WebSocket broadcasts are always sent (real-time), but Telegram pushes are deduplicated.
|
||||
_ALERT_COOLDOWN_SECONDS = 300 # 5 minutes — same server+metric won't re-push Telegram
|
||||
_last_alert_time: Dict[str, float] = {} # key="alert:{server_id}:{type}" → monotonic timestamp
|
||||
|
||||
|
||||
def _should_push_telegram(alert_key: str) -> bool:
|
||||
"""Check if enough time has passed since last Telegram push for this alert key.
|
||||
|
||||
Returns True if we should send the Telegram notification, False if suppressed (cooldown).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
last = _last_alert_time.get(alert_key, 0)
|
||||
if now - last < _ALERT_COOLDOWN_SECONDS:
|
||||
return False # Suppressed — too soon
|
||||
_last_alert_time[alert_key] = now
|
||||
return True
|
||||
|
||||
|
||||
# ── Broadcast Functions (called from other modules) ──
|
||||
|
||||
async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, server_name: str = ""):
|
||||
@@ -281,6 +300,9 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
|
||||
|
||||
Called when Agent heartbeat detects CPU/memory/disk > threshold.
|
||||
Publishes to Redis Pub/Sub (multi-worker) + Telegram.
|
||||
|
||||
Alert aggregation: WebSocket always broadcasts (real-time),
|
||||
but Telegram push is deduplicated (5min cooldown per server+metric).
|
||||
"""
|
||||
msg = {
|
||||
"type": "alert",
|
||||
@@ -294,13 +316,21 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
|
||||
await manager.broadcast_local(msg)
|
||||
await _publish_to_redis(msg)
|
||||
|
||||
# Telegram push
|
||||
from server.infrastructure.telegram import send_telegram_alert
|
||||
await send_telegram_alert(server_name or str(server_id), alert_type, alert_value)
|
||||
# Telegram push — deduplicated per (server_id, alert_type) with cooldown
|
||||
alert_key = f"alert:{server_id}:{alert_type}"
|
||||
if _should_push_telegram(alert_key):
|
||||
from server.infrastructure.telegram import send_telegram_alert
|
||||
await send_telegram_alert(server_name or str(server_id), alert_type, alert_value)
|
||||
else:
|
||||
logger.debug(f"Alert suppressed (cooldown): {alert_key}")
|
||||
|
||||
|
||||
async def broadcast_recovery(server_id: int, metric: str, value: float, server_name: str = ""):
|
||||
"""Broadcast recovery notification when alert metric returns to normal"""
|
||||
"""Broadcast recovery notification when alert metric returns to normal
|
||||
|
||||
Recovery alerts are always pushed (clears the alert state).
|
||||
Also clears the alert cooldown so next alert will be sent immediately.
|
||||
"""
|
||||
msg = {
|
||||
"type": "recovery",
|
||||
"server_id": server_id,
|
||||
@@ -312,6 +342,10 @@ async def broadcast_recovery(server_id: int, metric: str, value: float, server_n
|
||||
await manager.broadcast_local(msg)
|
||||
await _publish_to_redis(msg)
|
||||
|
||||
# Recovery always sends Telegram + clears cooldown for next alert cycle
|
||||
alert_key = f"alert:{server_id}:{metric}"
|
||||
_last_alert_time.pop(alert_key, None) # Clear cooldown
|
||||
|
||||
from server.infrastructure.telegram import send_telegram_recovery
|
||||
await send_telegram_recovery(server_name or str(server_id), metric, value)
|
||||
|
||||
|
||||
+51
-1
@@ -81,11 +81,16 @@ async def terminal_ws(
|
||||
await websocket.close(code=4001, reason="Missing JWT token")
|
||||
return
|
||||
|
||||
admin, _ = await _verify_webssh_token(token)
|
||||
admin, token_server_id = await _verify_webssh_token(token)
|
||||
if not admin:
|
||||
await websocket.close(code=4001, reason="Invalid or expired JWT token")
|
||||
return
|
||||
|
||||
# Security: verify the JWT's server_id matches the URL path server_id
|
||||
if token_server_id is not None and token_server_id != server_id:
|
||||
await websocket.close(code=4003, reason="Token not authorized for this server")
|
||||
return
|
||||
|
||||
# ── Lookup target server ──
|
||||
async with AsyncSessionLocal() as session:
|
||||
server_repo = ServerRepositoryImpl(session)
|
||||
@@ -95,6 +100,34 @@ async def terminal_ws(
|
||||
await websocket.close(code=4004, reason=f"Server {server_id} not found")
|
||||
return
|
||||
|
||||
# ── Server-level authorization check ──
|
||||
# Verify the server has valid SSH credentials configured
|
||||
if not server.domain:
|
||||
await websocket.close(code=4003, reason=f"Server {server.name} has no domain/IP configured")
|
||||
return
|
||||
|
||||
if server.auth_method == "key" and not server.ssh_key_path and not server.ssh_key_private:
|
||||
await websocket.close(code=4003, reason=f"Server {server.name} has no SSH key configured")
|
||||
return
|
||||
|
||||
if server.auth_method == "password" and not server.password:
|
||||
await websocket.close(code=4003, reason=f"Server {server.name} has no SSH password configured")
|
||||
return
|
||||
|
||||
# ── Audit: WebSSH session start ──
|
||||
async with AsyncSessionLocal() as session:
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.domain.models import AuditLog
|
||||
audit_repo = AuditLogRepositoryImpl(session)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="webssh_connect",
|
||||
target_type="server",
|
||||
target_id=server.id,
|
||||
detail=f"WebSSH to {server.name} ({server.domain})",
|
||||
ip_address=websocket.client.host if websocket.client else "",
|
||||
))
|
||||
|
||||
# ── Accept WebSocket ──
|
||||
await websocket.accept()
|
||||
client_ip = websocket.client.host if websocket.client else "unknown"
|
||||
@@ -243,6 +276,23 @@ async def terminal_ws(
|
||||
|
||||
logger.info(f"WebSSH session closed: admin={admin.username}, server={server.name}, session={session_id}")
|
||||
|
||||
# Audit: WebSSH session end
|
||||
try:
|
||||
async with AsyncSessionLocal() as audit_session:
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.domain.models import AuditLog
|
||||
audit_repo = AuditLogRepositoryImpl(audit_session)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="webssh_disconnect",
|
||||
target_type="server",
|
||||
target_id=server.id,
|
||||
detail=f"WebSSH disconnected from {server.name}",
|
||||
ip_address=websocket.client.host if websocket.client else "",
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _close_ssh_session(session_id: str):
|
||||
"""Close SSH session record in database"""
|
||||
|
||||
@@ -9,6 +9,7 @@ import secrets
|
||||
import struct
|
||||
import time
|
||||
import datetime
|
||||
import bcrypt
|
||||
from datetime import timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -77,7 +78,7 @@ class AuthService:
|
||||
|
||||
# Success — generate JWT tokens
|
||||
access_token = self._create_access_token(admin)
|
||||
refresh_token = self._create_refresh_token()
|
||||
refresh_token = self._create_refresh_token(admin)
|
||||
|
||||
# Store refresh token in DB
|
||||
admin.jwt_refresh_token = refresh_token
|
||||
@@ -103,16 +104,59 @@ class AuthService:
|
||||
}
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> dict:
|
||||
"""Exchange refresh token for new access token"""
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
if not admin:
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
"""Exchange refresh token for new access token (with rotation + version-based reuse detection)
|
||||
|
||||
Security: Refresh token format is ``token:admin_id:token_version``.
|
||||
If the version in the token doesn't match the admin's current version,
|
||||
it means this token was stolen/reused — invalidate all sessions for this admin.
|
||||
"""
|
||||
# Parse new format token: "token:admin_id:token_version"
|
||||
parts = refresh_token.rsplit(":", 2)
|
||||
if len(parts) == 3:
|
||||
token_val, admin_id_str, token_version_str = parts
|
||||
try:
|
||||
admin_id = int(admin_id_str)
|
||||
token_version = int(token_version_str)
|
||||
except (ValueError, TypeError):
|
||||
await self._audit("refresh_invalid_format", "admin", 0, "Refresh token has invalid format")
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
|
||||
# Get admin by ID (not by token)
|
||||
admin = await self.admin_repo.get_by_id(admin_id)
|
||||
if not admin:
|
||||
await self._audit("refresh_admin_not_found", "admin", admin_id, "Refresh token references non-existent admin")
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
|
||||
# Check token version — mismatch = reuse attack
|
||||
if admin.token_version != token_version:
|
||||
# Reuse attack detected! Invalidate all sessions by incrementing version
|
||||
admin.token_version += 1
|
||||
admin.jwt_refresh_token = None
|
||||
admin.jwt_token_expires = None
|
||||
await self.admin_repo.update(admin)
|
||||
await self._audit("refresh_reuse_attack", "admin", admin.id,
|
||||
f"Token reuse detected! Expected version {admin.token_version}, got {token_version}. All sessions invalidated.")
|
||||
return {"success": False, "reason": "token_reuse", "message": "检测到令牌重用,所有会话已失效,请重新登录"}
|
||||
|
||||
# Check if token matches current stored token (exact match required)
|
||||
if admin.jwt_refresh_token != refresh_token:
|
||||
await self._audit("refresh_token_mismatch", "admin", admin.id, "Refresh token doesn't match stored token")
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
|
||||
else:
|
||||
# Legacy format (old opaque token) — try exact match for backward compatibility
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
if not admin:
|
||||
await self._audit("refresh_legacy_not_found", "admin", 0, "Legacy refresh token not found")
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
# Force rotation to new format on next refresh
|
||||
|
||||
if admin.jwt_token_expires and admin.jwt_token_expires < datetime.datetime.now(timezone.utc):
|
||||
return {"success": False, "reason": "token_expired", "message": "刷新令牌已过期"}
|
||||
|
||||
# Rotate: generate new token pair (old refresh token is invalidated by DB update)
|
||||
access_token = self._create_access_token(admin)
|
||||
new_refresh = self._create_refresh_token()
|
||||
new_refresh = self._create_refresh_token(admin)
|
||||
|
||||
admin.jwt_refresh_token = new_refresh
|
||||
admin.jwt_token_expires = datetime.datetime.now(timezone.utc) + datetime.timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
@@ -148,9 +192,10 @@ class AuthService:
|
||||
return admin
|
||||
|
||||
async def logout(self, admin_id: int) -> dict:
|
||||
"""Invalidate refresh token by admin ID"""
|
||||
"""Invalidate refresh token by admin ID (also increments token_version to invalidate all sessions)"""
|
||||
admin = await self.admin_repo.get_by_id(admin_id)
|
||||
if admin:
|
||||
admin.token_version += 1 # Invalidate all existing refresh tokens
|
||||
admin.jwt_refresh_token = None
|
||||
admin.jwt_token_expires = None
|
||||
await self.admin_repo.update(admin)
|
||||
@@ -158,13 +203,27 @@ class AuthService:
|
||||
|
||||
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)
|
||||
# Try to parse new format token
|
||||
parts = refresh_token.rsplit(":", 2)
|
||||
if len(parts) == 3:
|
||||
admin_id_str = parts[1]
|
||||
try:
|
||||
admin_id = int(admin_id_str)
|
||||
admin = await self.admin_repo.get_by_id(admin_id)
|
||||
except (ValueError, TypeError):
|
||||
admin = None
|
||||
else:
|
||||
# Legacy format
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
|
||||
if admin:
|
||||
admin.token_version += 1 # Invalidate all existing refresh tokens
|
||||
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": "已登出"}
|
||||
return {"success": True, "message": "已登出"}
|
||||
|
||||
async def setup_totp(self, admin_id: int) -> dict:
|
||||
"""Generate TOTP secret for an admin user (before enabling)"""
|
||||
@@ -215,7 +274,10 @@ class AuthService:
|
||||
# ── JWT Token Helpers ──
|
||||
|
||||
def _create_access_token(self, admin: Admin) -> str:
|
||||
"""Create JWT access token with admin claims"""
|
||||
"""Create JWT access token with admin claims
|
||||
|
||||
Includes 'updated_at' claim so tokens are invalidated when admin changes password.
|
||||
"""
|
||||
import jwt
|
||||
from server.config import settings
|
||||
|
||||
@@ -225,12 +287,18 @@ class AuthService:
|
||||
"username": admin.username,
|
||||
"iat": now,
|
||||
"exp": now + datetime.timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
def _create_refresh_token(self) -> str:
|
||||
"""Create opaque refresh token (random, stored in DB)"""
|
||||
return secrets.token_urlsafe(48)
|
||||
def _create_refresh_token(self, admin: Admin) -> str:
|
||||
"""Create refresh token with version binding: token:admin_id:token_version
|
||||
|
||||
Format includes admin_id and token_version so we can detect reuse attacks
|
||||
even if the token is not found in DB (e.g., after rotation).
|
||||
"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
return f"{token}:{admin.id}:{admin.token_version}"
|
||||
|
||||
def _decode_access_token(self, token: str) -> Optional[dict]:
|
||||
"""Decode and verify JWT access token"""
|
||||
@@ -246,13 +314,8 @@ class AuthService:
|
||||
# ── Password & TOTP Helpers ──
|
||||
|
||||
def _verify_password(self, password: str, password_hash: str) -> bool:
|
||||
"""Verify password against stored hash (bcrypt-compatible)"""
|
||||
try:
|
||||
import bcrypt
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
except ImportError:
|
||||
# Fallback: SHA256 comparison (for initial setup before bcrypt installed)
|
||||
return hashlib.sha256(password.encode()).hexdigest() == password_hash
|
||||
"""Verify password against stored bcrypt hash"""
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
|
||||
def _verify_totp(self, code: str, secret: str) -> bool:
|
||||
"""Verify TOTP code using RFC 6238 (HOTP with time counter)"""
|
||||
|
||||
@@ -81,10 +81,11 @@ class ScriptService:
|
||||
# Resolve DB credential variables (if provided)
|
||||
resolved_command = await self._substitute_db_vars(command, credential_id)
|
||||
|
||||
# Create execution record
|
||||
# Create execution record — use sanitized command (password redacted)
|
||||
sanitized_command = await self._substitute_db_vars(command, credential_id, redact=True)
|
||||
execution = ScriptExecution(
|
||||
script_id=script_id,
|
||||
command=resolved_command,
|
||||
command=sanitized_command,
|
||||
server_ids=json.dumps(server_ids),
|
||||
status="running",
|
||||
operator=operator,
|
||||
@@ -161,22 +162,31 @@ 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 ──
|
||||
|
||||
async def _substitute_db_vars(self, command: str, credential_id: Optional[int]) -> str:
|
||||
"""Replace $DB_USER/$DB_PASS/$DB_HOST/$DB_PORT/$DB_NAME in command"""
|
||||
async def _substitute_db_vars(self, command: str, credential_id: Optional[int], redact: bool = False) -> str:
|
||||
"""Replace $DB_USER/$DB_PASS/$DB_HOST/$DB_PORT/$DB_NAME in command.
|
||||
|
||||
If redact=True, replaces $DB_PASS with '***' instead of the actual password.
|
||||
The redacted version is stored in ScriptExecution.command (DB record).
|
||||
The actual substitution (redact=False) is sent to the Agent for execution.
|
||||
"""
|
||||
if not credential_id:
|
||||
return command
|
||||
|
||||
@@ -189,7 +199,7 @@ class ScriptService:
|
||||
|
||||
replacements = {
|
||||
"$DB_USER": credential.username,
|
||||
"$DB_PASS": password,
|
||||
"$DB_PASS": "***" if redact else password,
|
||||
"$DB_HOST": credential.host,
|
||||
"$DB_PORT": str(credential.port),
|
||||
"$DB_NAME": credential.database or "",
|
||||
@@ -204,7 +214,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")
|
||||
|
||||
@@ -90,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
|
||||
@@ -190,38 +191,125 @@ class SyncEngineV2:
|
||||
|
||||
# ── S3: Sync Config Mode ──
|
||||
|
||||
BACKUP_CONFIG_PATH = "/etc/sysctl.d/99-nexus.conf"
|
||||
|
||||
async def sync_config(
|
||||
self,
|
||||
server_ids: List[int],
|
||||
config_updates: dict, # {key: value} system parameters to push
|
||||
operator: str = "admin",
|
||||
concurrency: int = 10,
|
||||
rollback: bool = False,
|
||||
) -> dict:
|
||||
"""S3: Push configuration changes to multiple servers
|
||||
|
||||
Config updates are applied as shell commands:
|
||||
- `sysctl` for kernel params
|
||||
- `echo` for /etc/sysctl.conf entries
|
||||
- `sysctl -w` for immediate kernel param changes
|
||||
- Deduplicated writes to /etc/sysctl.d/99-nexus.conf (sed removes old entries first)
|
||||
|
||||
Rollback mode: restores from backup created during last config push.
|
||||
"""
|
||||
import re
|
||||
import re, shlex
|
||||
|
||||
if rollback:
|
||||
return await self._rollback_config(server_ids, operator, concurrency)
|
||||
|
||||
# Build deduplicated config commands
|
||||
commands = []
|
||||
config_path = self.BACKUP_CONFIG_PATH
|
||||
backup_path = f"{config_path}.bak.{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
for key, value in config_updates.items():
|
||||
# Sanitize: only allow alphanumeric/dot/underscore/dash keys and safe values
|
||||
# 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 = str(value).replace("'", "'\\''")
|
||||
commands.append(f"sysctl -w {key}={safe_value}")
|
||||
commands.append(f"echo '{key}={safe_value}' >> /etc/sysctl.d/99-nexus.conf")
|
||||
safe_value = shlex.quote(str(value))
|
||||
|
||||
return await self.sync_commands(
|
||||
# Deduplicate: remove existing lines for this key before appending
|
||||
commands.append(f"sed -i '/^{re.escape(key)}\\s*=/d' {config_path} 2>/dev/null || true")
|
||||
# Append new value
|
||||
commands.append(f"echo {key}={safe_value} >> {config_path}")
|
||||
# Apply immediately
|
||||
commands.append(f"sysctl -w {key}={safe_value} 2>/dev/null || true")
|
||||
|
||||
# Pre-push: backup existing config file
|
||||
backup_commands = [
|
||||
f"cp {config_path} {backup_path} 2>/dev/null || true",
|
||||
f"touch {config_path}",
|
||||
]
|
||||
|
||||
# Combine: backup first, then deduplicated config commands
|
||||
all_commands = backup_commands + commands
|
||||
|
||||
result = await self.sync_commands(
|
||||
server_ids=server_ids,
|
||||
commands=commands,
|
||||
commands=all_commands,
|
||||
operator=operator,
|
||||
timeout=30,
|
||||
concurrency=concurrency,
|
||||
)
|
||||
|
||||
# Store backup path in result for rollback reference
|
||||
result["backup_path"] = backup_path
|
||||
result["config_path"] = config_path
|
||||
return result
|
||||
|
||||
async def _rollback_config(
|
||||
self,
|
||||
server_ids: List[int],
|
||||
operator: str = "admin",
|
||||
concurrency: int = 10,
|
||||
) -> dict:
|
||||
"""Rollback config: find the most recent backup and restore it"""
|
||||
config_path = self.BACKUP_CONFIG_PATH
|
||||
|
||||
async def _rollback_one(server_id: int):
|
||||
server = await self.server_repo.get_by_id(server_id)
|
||||
if not server:
|
||||
return {"server_id": server_id, "status": "error", "message": "Server not found"}
|
||||
|
||||
# Find the latest backup file
|
||||
find_cmd = f"ls -t {config_path}.bak.* 2>/dev/null | head -1"
|
||||
result = await exec_ssh_command(server, find_cmd, timeout=10)
|
||||
|
||||
if result["exit_code"] != 0 or not result["stdout"].strip():
|
||||
return {"server_id": server_id, "status": "error", "message": "No backup found for rollback"}
|
||||
|
||||
backup_file = result["stdout"].strip().split("\n")[0]
|
||||
|
||||
# Restore from backup
|
||||
restore_cmd = f"cp {backup_file} {config_path} && sysctl --system 2>/dev/null || true"
|
||||
restore_result = await exec_ssh_command(server, restore_cmd, timeout=30)
|
||||
|
||||
status = "success" if restore_result["exit_code"] == 0 else "failed"
|
||||
return {
|
||||
"server_id": server_id,
|
||||
"server_name": server.name,
|
||||
"status": status,
|
||||
"backup_file": backup_file,
|
||||
"error": restore_result["stderr"][:500] if status == "failed" else None,
|
||||
}
|
||||
|
||||
sem = asyncio.Semaphore(min(concurrency, MAX_CONCURRENT))
|
||||
results = []
|
||||
|
||||
async def _limited_rollback(sid):
|
||||
async with sem:
|
||||
return await _rollback_one(sid)
|
||||
|
||||
tasks = [asyncio.create_task(_limited_rollback(sid)) for sid in server_ids]
|
||||
task_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = [r for r in task_results if isinstance(r, dict)]
|
||||
|
||||
await self._audit(
|
||||
"rollback_config", "sync", 0,
|
||||
f"Config rollback on {len(server_ids)} servers: {sum(1 for r in results if r.get('status')=='success')} ok, {sum(1 for r in results if r.get('status')=='failed')} failed",
|
||||
operator,
|
||||
)
|
||||
|
||||
return {"total": len(server_ids), "results": results}
|
||||
|
||||
# ── S4: SFTP File Transfer ──
|
||||
|
||||
async def sftp_transfer(
|
||||
|
||||
@@ -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")
|
||||
@@ -78,16 +80,17 @@ class SyncService:
|
||||
|
||||
redis = get_redis()
|
||||
|
||||
# Initialize progress in Redis
|
||||
# Initialize progress in Redis (TTL 1 hour — auto-cleanup after push completes)
|
||||
progress_key = f"sync:progress:{operator}:{int(asyncio.get_event_loop().time())}"
|
||||
await redis.set(progress_key, json.dumps({
|
||||
progress_data = json.dumps({
|
||||
"total": total,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"current_batch": 0,
|
||||
"total_batches": len(batches),
|
||||
"status": "running",
|
||||
}))
|
||||
})
|
||||
await redis.set(progress_key, progress_data, ex=3600)
|
||||
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
logger.info(f"Batch {batch_idx + 1}/{len(batches)}: pushing to {len(batch)} servers")
|
||||
@@ -100,7 +103,7 @@ class SyncService:
|
||||
"current_batch": batch_idx + 1,
|
||||
"total_batches": len(batches),
|
||||
"status": "running",
|
||||
}))
|
||||
}), ex=3600)
|
||||
|
||||
# Execute batch with concurrency control
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
@@ -126,7 +129,7 @@ class SyncService:
|
||||
logger.info(f"Batch {batch_idx + 1} complete, waiting {batch_interval}s before next batch")
|
||||
await asyncio.sleep(batch_interval)
|
||||
|
||||
# Final progress update
|
||||
# Final progress update (shorter TTL — data is complete)
|
||||
await redis.set(progress_key, json.dumps({
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
@@ -134,7 +137,7 @@ class SyncService:
|
||||
"current_batch": len(batches),
|
||||
"total_batches": len(batches),
|
||||
"status": "completed",
|
||||
}))
|
||||
}), ex=600)
|
||||
|
||||
await self._audit(
|
||||
"batch_push", "sync", 0,
|
||||
@@ -146,8 +149,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 +170,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 +196,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 +211,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
|
||||
@@ -243,14 +224,14 @@ class SyncService:
|
||||
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),
|
||||
)
|
||||
@@ -271,15 +252,12 @@ 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):
|
||||
|
||||
@@ -11,7 +11,7 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy import update
|
||||
|
||||
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.domain.models import Server
|
||||
|
||||
logger = logging.getLogger("nexus.heartbeat_flush")
|
||||
@@ -34,7 +34,7 @@ async def heartbeat_flush_loop():
|
||||
while True:
|
||||
await asyncio.sleep(FLUSH_INTERVAL)
|
||||
|
||||
redis = get_redis_sync()
|
||||
redis = get_redis()
|
||||
if redis is None:
|
||||
logger.warning("Redis unavailable — skip heartbeat flush")
|
||||
continue
|
||||
@@ -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,10 @@ 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}")
|
||||
# Don't rollback here — just skip this server and continue.
|
||||
# A partial UPDATE on a single server won't corrupt others.
|
||||
# The final commit() will persist all successful updates.
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL")
|
||||
|
||||
@@ -8,7 +8,6 @@ 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")
|
||||
@@ -30,7 +29,7 @@ def _cron_match(cron_expr: str, now: datetime) -> bool:
|
||||
(now.hour, range(0, 24)),
|
||||
(now.day, range(1, 32)),
|
||||
(now.month, range(1, 13)),
|
||||
(now.weekday(), range(0, 7)), # 0=Monday
|
||||
((now.weekday() + 1) % 7, range(0, 7)), # cron: 0=Sunday, Python weekday(): 0=Monday
|
||||
]
|
||||
|
||||
for (current_val, _), field_expr in zip(fields, parts):
|
||||
@@ -52,7 +51,8 @@ def _field_match(expr: str, value: int) -> bool:
|
||||
if int(low) <= value <= int(high):
|
||||
return True
|
||||
else:
|
||||
if value == int(part):
|
||||
# cron: 7 also means Sunday (=0)
|
||||
if value == int(part) or (int(part) == 7 and value == 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -112,6 +112,7 @@ async def schedule_runner_loop():
|
||||
result = await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=schedule.source_path,
|
||||
sync_mode=getattr(schedule, 'sync_mode', None) or "incremental",
|
||||
trigger_type="schedule",
|
||||
operator=f"schedule:{schedule.name}",
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
+10
-2
@@ -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+aiomysql://root:password@127.0.0.1:3306/nexus"
|
||||
DB_POOL_SIZE: Optional[int] = 100
|
||||
DB_MAX_OVERFLOW: Optional[int] = 100
|
||||
# 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
|
||||
|
||||
@@ -14,6 +14,11 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
|
||||
def _utcnow():
|
||||
"""Callable default for DateTime columns — evaluated per-row, not at module import"""
|
||||
return datetime.datetime.now(timezone.utc)
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -31,7 +36,7 @@ class Platform(Base):
|
||||
type = Column(String(50), nullable=False, comment="类型: linux/windows/mysql/switch")
|
||||
default_protocols = Column(JSON, nullable=True, comment="默认协议: [{name:'ssh',port:22,primary:true}]")
|
||||
charset = Column(String(20), default="utf-8", comment="默认字符集")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
assets = relationship("Server", back_populates="platform")
|
||||
|
||||
@@ -44,11 +49,16 @@ class Node(Base):
|
||||
name = Column(String(100), nullable=False, comment="节点名称")
|
||||
parent_id = Column(Integer, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True, comment="父节点")
|
||||
sort_order = Column(Integer, default=0, comment="排序权重")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
parent = relationship("Node", remote_side=[id], backref="children")
|
||||
parent = relationship("Node", remote_side=[id], back_populates="children")
|
||||
children = relationship("Node", back_populates="parent")
|
||||
assets = relationship("Server", back_populates="node")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_nodes_parent_id', 'parent_id'),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Server (核心资产表 — 保持原名,扩展字段)
|
||||
@@ -76,8 +86,8 @@ class Server(Base):
|
||||
category = Column(String(100), nullable=True, comment="服务器分类(旧字段,保留兼容)")
|
||||
|
||||
# ── 新增:资产组织字段 ──
|
||||
platform_id = Column(Integer, ForeignKey("platforms.id"), nullable=True, comment="平台类型ID")
|
||||
node_id = Column(Integer, ForeignKey("nodes.id"), nullable=True, comment="所属节点ID")
|
||||
platform_id = Column(Integer, ForeignKey("platforms.id", ondelete="SET NULL"), nullable=True, comment="平台类型ID")
|
||||
node_id = Column(Integer, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True, comment="所属节点ID")
|
||||
protocols = Column(JSON, nullable=True, comment="实例级协议覆盖: [{name:'ssh',port:2222}]")
|
||||
extra_attrs = Column(JSON, nullable=True, comment="扩展属性: {os:'ubuntu',arch:'x86_64',cpu:8,mem:'32G'}")
|
||||
|
||||
@@ -92,8 +102,8 @@ class Server(Base):
|
||||
last_checked_at = Column(DateTime, nullable=True, comment="上次连接测试时间")
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc), onupdate=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
# Relationships
|
||||
sync_logs = relationship("SyncLog", back_populates="server", cascade="all, delete-orphan")
|
||||
@@ -113,7 +123,7 @@ class SyncLog(Base):
|
||||
__tablename__ = "sync_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
|
||||
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
|
||||
source_path = Column(String(500), nullable=False, comment="源目录")
|
||||
target_path = Column(String(500), nullable=False, comment="目标目录")
|
||||
trigger_type = Column(String(20), comment="触发类型: manual/schedule/batch")
|
||||
@@ -127,7 +137,7 @@ class SyncLog(Base):
|
||||
duration_seconds = Column(Integer, default=0)
|
||||
diff_summary = Column(Text, nullable=True, comment="rsync输出摘要")
|
||||
error_message = Column(Text, nullable=True)
|
||||
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
started_at = Column(DateTime, default=_utcnow)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
|
||||
server = relationship("Server", back_populates="sync_logs")
|
||||
@@ -148,12 +158,14 @@ class Admin(Base):
|
||||
totp_secret = Column(String(64), nullable=True)
|
||||
totp_enabled = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
# ── 新增:JWT 支持 ──
|
||||
jwt_refresh_token = Column(String(500), nullable=True, comment="JWT刷新令牌")
|
||||
jwt_token_expires = Column(DateTime, nullable=True, comment="令牌过期时间")
|
||||
token_version = Column(Integer, default=0, comment="令牌版本(重用时递增使旧token失效)")
|
||||
|
||||
|
||||
class LoginAttempt(Base):
|
||||
@@ -163,9 +175,13 @@ class LoginAttempt(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(100), nullable=False)
|
||||
ip_address = Column(String(45), nullable=False)
|
||||
attempted_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
attempted_at = Column(DateTime, default=_utcnow)
|
||||
success = Column(Boolean, default=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_login_attempts_user_time', 'username', 'attempted_at'),
|
||||
)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
"""Key-value system settings (including brand config)"""
|
||||
@@ -173,7 +189,7 @@ class Setting(Base):
|
||||
|
||||
key = Column(String(100), primary_key=True)
|
||||
value = Column(Text, nullable=True)
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
class PasswordPreset(Base):
|
||||
@@ -183,7 +199,7 @@ class PasswordPreset(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="预设名称")
|
||||
encrypted_pw = Column(String(500), nullable=False, comment="加密后的密码")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
class PushSchedule(Base):
|
||||
@@ -195,9 +211,14 @@ class PushSchedule(Base):
|
||||
source_path = Column(String(500), nullable=False)
|
||||
server_ids = Column(Text, nullable=False, comment="JSON数组: 目标服务器ID列表")
|
||||
cron_expr = Column(String(100), nullable=False, comment="cron表达式: 分 时 日 月 周")
|
||||
sync_mode = Column(String(20), default="incremental", comment="同步模式: incremental/full/overwrite/checksum")
|
||||
enabled = Column(Boolean, default=True)
|
||||
last_run_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_push_schedules_enabled', 'enabled'),
|
||||
)
|
||||
|
||||
|
||||
class PushRetryJob(Base):
|
||||
@@ -205,7 +226,7 @@ class PushRetryJob(Base):
|
||||
__tablename__ = "push_retry_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
|
||||
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
|
||||
server_name = Column(String(100), comment="冗余字段便于列表显示")
|
||||
operator = Column(String(100), nullable=True, comment="操作人用户名")
|
||||
source_path = Column(String(500), nullable=False)
|
||||
@@ -215,8 +236,14 @@ class PushRetryJob(Base):
|
||||
max_retries = Column(Integer, default=100)
|
||||
next_retry_at = Column(DateTime)
|
||||
last_error = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
server = relationship("Server")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_push_retry_pending', 'status', 'next_retry_at'),
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
@@ -230,7 +257,12 @@ class AuditLog(Base):
|
||||
target_id = Column(Integer, nullable=True, comment="目标ID")
|
||||
detail = Column(Text, nullable=True, comment="操作详情JSON")
|
||||
ip_address = Column(String(45), nullable=True, comment="操作IP")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_audit_logs_action_time', 'action', 'created_at'),
|
||||
Index('idx_audit_logs_created_at', 'created_at'),
|
||||
)
|
||||
|
||||
|
||||
class Script(Base):
|
||||
@@ -243,8 +275,8 @@ class Script(Base):
|
||||
content = Column(Text, nullable=False, comment="Shell命令内容")
|
||||
description = Column(Text, nullable=True, comment="说明")
|
||||
created_by = Column(String(100), nullable=True, comment="创建人")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
class ScriptExecution(Base):
|
||||
@@ -252,15 +284,21 @@ class ScriptExecution(Base):
|
||||
__tablename__ = "script_executions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
script_id = Column(Integer, ForeignKey("scripts.id"), nullable=True, comment="关联脚本(手动输入时为null)")
|
||||
script_id = Column(Integer, ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True, comment="关联脚本(手动输入时为null)")
|
||||
command = Column(Text, nullable=False, comment="实际执行的命令")
|
||||
server_ids = Column(Text, nullable=False, comment="JSON数组: 目标服务器ID列表")
|
||||
status = Column(String(20), default="pending", comment="pending/running/completed/failed")
|
||||
results = Column(Text, nullable=True, comment="JSON: 每台服务器执行结果{server_id: {stdout,stderr,exit_code}}")
|
||||
operator = Column(String(100), nullable=True, comment="操作人用户名")
|
||||
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
started_at = Column(DateTime, default=_utcnow)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
script = relationship("Script")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_script_exec_script_id', 'script_id'),
|
||||
)
|
||||
|
||||
|
||||
class DbCredential(Base):
|
||||
"""Database credentials — encrypted storage for $DB_* variable substitution"""
|
||||
@@ -274,7 +312,7 @@ class DbCredential(Base):
|
||||
username = Column(String(100), nullable=False, comment="数据库用户名")
|
||||
encrypted_password = Column(String(500), nullable=False, comment="加密后的密码")
|
||||
database = Column(String(100), nullable=True, comment="数据库库名")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -290,7 +328,7 @@ class SshSession(Base):
|
||||
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="SET NULL"), nullable=True, comment="操作人ID")
|
||||
remote_addr = Column(String(45), nullable=True, comment="客户端IP")
|
||||
status = Column(String(20), default="active", comment="active/closed")
|
||||
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
started_at = Column(DateTime, default=_utcnow)
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
|
||||
server = relationship("Server")
|
||||
@@ -313,7 +351,7 @@ class CommandLog(Base):
|
||||
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="SET NULL"), nullable=True, comment="操作人ID")
|
||||
command = Column(String(2000), nullable=False, comment="执行的命令")
|
||||
remote_addr = Column(String(45), nullable=True, comment="客户端IP")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
session = relationship("SshSession", back_populates="command_logs")
|
||||
server = relationship("Server")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Nexus — AuditLog Repository (Async SQLAlchemy)"""
|
||||
|
||||
from typing import List
|
||||
from sqlalchemy import select
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import AuditLog
|
||||
@@ -17,17 +17,25 @@ class AuditLogRepositoryImpl:
|
||||
await self.session.refresh(log)
|
||||
return log
|
||||
|
||||
async def get_recent(self, limit: int = 200) -> List[AuditLog]:
|
||||
async def count(self, action: Optional[str] = None) -> int:
|
||||
q = select(func.count(AuditLog.id))
|
||||
if action:
|
||||
q = q.where(AuditLog.action == action)
|
||||
result = await self.session.execute(q)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_recent(self, limit: int = 200, offset: int = 0) -> List[AuditLog]:
|
||||
result = await self.session.execute(
|
||||
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)
|
||||
select(AuditLog).order_by(AuditLog.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_action(self, action: str, limit: int = 50) -> List[AuditLog]:
|
||||
async def get_by_action(self, action: str, limit: int = 50, offset: int = 0) -> List[AuditLog]:
|
||||
result = await self.session.execute(
|
||||
select(AuditLog)
|
||||
.where(AuditLog.action == action)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -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
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Nexus — Data Migration: category → Node, backfill platform_id
|
||||
"""Nexus — Data Migration: category → Node, backfill platform_id + schema migrations
|
||||
|
||||
D8: Migrate existing server category field to the new Node tree structure,
|
||||
and create default Platform entries for existing server types.
|
||||
|
||||
Also handles safe schema migrations for existing databases (adding new columns).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select, update, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import Server, Node, Platform, Base
|
||||
from server.infrastructure.database.session import AsyncSessionLocal, engine
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger("nexus.migration")
|
||||
|
||||
@@ -95,3 +97,27 @@ async def run_migrations():
|
||||
except Exception as e:
|
||||
logger.error(f"Data migration failed: {e}")
|
||||
# Non-fatal — tables exist, migration can be retried
|
||||
|
||||
try:
|
||||
await run_schema_migrations()
|
||||
except Exception as e:
|
||||
logger.error(f"Schema migration failed: {e}")
|
||||
|
||||
|
||||
async def run_schema_migrations():
|
||||
"""Safe schema migrations — add new columns to existing tables.
|
||||
Uses ALTER TABLE with try/except so it's a no-op if the column already exists.
|
||||
"""
|
||||
migrations = [
|
||||
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
|
||||
"ALTER TABLE admins ADD COLUMN token_version INT DEFAULT 0 COMMENT '令牌版本(重用时递增使旧token失效)'",
|
||||
]
|
||||
async with AsyncSessionLocal() as session:
|
||||
for sql in migrations:
|
||||
try:
|
||||
await session.execute(text(sql))
|
||||
await session.commit()
|
||||
logger.info(f"Schema migration applied: {sql[:60]}...")
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Column already exists — expected for non-first-run
|
||||
@@ -58,6 +58,26 @@ class PushRetryJobRepositoryImpl:
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_all(self, limit: int = 100) -> List[PushRetryJob]:
|
||||
result = await self.session.execute(
|
||||
select(PushRetryJob)
|
||||
.order_by(PushRetryJob.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_id(self, id: int) -> Optional[PushRetryJob]:
|
||||
result = await self.session.execute(select(PushRetryJob).where(PushRetryJob.id == id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def delete(self, id: int) -> bool:
|
||||
job = await self.get_by_id(id)
|
||||
if job:
|
||||
await self.session.delete(job)
|
||||
await self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def create(self, job: PushRetryJob) -> PushRetryJob:
|
||||
self.session.add(job)
|
||||
await self.session.commit()
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
Concrete implementation of ServerRepository protocol.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, update, delete
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, update, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import Server
|
||||
@@ -23,6 +24,35 @@ class ServerRepositoryImpl:
|
||||
result = await self.session.execute(select(Server).order_by(Server.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_paginated(
|
||||
self,
|
||||
category: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
) -> Tuple[List[Server], int]:
|
||||
"""Get paginated server list with total count.
|
||||
|
||||
Returns (servers, total_count) — DB-level pagination for 2000+ servers.
|
||||
"""
|
||||
# Base query
|
||||
base_query = select(Server)
|
||||
if category:
|
||||
base_query = base_query.where(Server.category == category)
|
||||
|
||||
# Count query
|
||||
count_query = select(func.count(Server.id))
|
||||
if category:
|
||||
count_query = count_query.where(Server.category == category)
|
||||
count_result = await self.session.execute(count_query)
|
||||
total = count_result.scalar_one() or 0
|
||||
|
||||
# Paginated data query
|
||||
data_query = base_query.order_by(Server.id).offset(offset).limit(limit)
|
||||
result = await self.session.execute(data_query)
|
||||
servers = list(result.scalars().all())
|
||||
|
||||
return servers, total
|
||||
|
||||
async def get_by_category(self, category: str) -> List[Server]:
|
||||
result = await self.session.execute(
|
||||
select(Server).where(Server.category == category).order_by(Server.id)
|
||||
@@ -61,13 +91,9 @@ class ServerRepositoryImpl:
|
||||
.where(Server.id == id)
|
||||
.values(
|
||||
is_online=is_online,
|
||||
last_heartbeat=datetime.datetime.now(timezone.utc),
|
||||
last_heartbeat=datetime.now(timezone.utc),
|
||||
system_info=system_info,
|
||||
agent_version=agent_version,
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
|
||||
|
||||
import datetime
|
||||
from datetime import timezone
|
||||
@@ -2,10 +2,14 @@
|
||||
Engine is created lazily to allow testing without MySQL.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from server.domain.models import Base
|
||||
from server.config import settings
|
||||
|
||||
logger = logging.getLogger("nexus.db")
|
||||
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
@@ -16,8 +20,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,
|
||||
)
|
||||
@@ -34,12 +38,6 @@ def get_engine():
|
||||
return _engine
|
||||
|
||||
|
||||
@property
|
||||
def AsyncSessionLocal():
|
||||
_ensure_engine()
|
||||
return _session_factory
|
||||
|
||||
|
||||
class _SessionLocalProxy:
|
||||
"""Proxy that defers session factory creation until first call"""
|
||||
def __call__(self):
|
||||
@@ -65,7 +63,53 @@ async def get_async_session():
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables"""
|
||||
"""Initialize database tables and apply schema migrations"""
|
||||
_ensure_engine()
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Apply incremental migrations for existing tables
|
||||
await _apply_migrations(conn)
|
||||
|
||||
|
||||
async def _apply_migrations(conn):
|
||||
"""Apply incremental schema migrations (idempotent — safe to run on every startup)"""
|
||||
from sqlalchemy import text
|
||||
|
||||
# Migration 1: Add updated_at column to admins table (if missing)
|
||||
try:
|
||||
result = await conn.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admins' AND COLUMN_NAME='updated_at'"
|
||||
))
|
||||
count = result.scalar()
|
||||
if count == 0:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE admins ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
|
||||
))
|
||||
logger.info("Migration applied: admins.updated_at column added")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration check failed (admins.updated_at): {e}")
|
||||
|
||||
# Migration 2: Add missing indexes (idempotent — CREATE INDEX IF NOT EXISTS)
|
||||
_indexes = [
|
||||
("idx_login_attempts_user_time", "login_attempts", "username, attempted_at"),
|
||||
("idx_audit_logs_action_time", "audit_logs", "action, created_at"),
|
||||
("idx_audit_logs_created_at", "audit_logs", "created_at"),
|
||||
("idx_push_schedules_enabled", "push_schedules", "enabled"),
|
||||
("idx_push_retry_pending", "push_retry_jobs", "status, next_retry_at"),
|
||||
("idx_nodes_parent_id", "nodes", "parent_id"),
|
||||
("idx_script_exec_script_id", "script_executions", "script_id"),
|
||||
]
|
||||
for idx_name, table, columns in _indexes:
|
||||
try:
|
||||
await conn.execute(text(
|
||||
f"CREATE INDEX `{idx_name}` ON `{table}` ({columns})"
|
||||
))
|
||||
logger.info(f"Migration: index {idx_name} created on {table}({columns})")
|
||||
except Exception as e:
|
||||
# Index already exists or table missing — both non-fatal
|
||||
err = str(e).lower()
|
||||
if "duplicate" in err or "already exists" in err:
|
||||
pass # Index already exists — expected
|
||||
else:
|
||||
logger.warning(f"Migration: index {idx_name} skipped: {e}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Nexus — SSH Session & Command Log Repository (Async SQLAlchemy)"""
|
||||
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -78,7 +78,7 @@ class CommandLogRepositoryImpl:
|
||||
return log
|
||||
|
||||
async def count_by_admin(self, admin_id: int, hours: int = 24) -> int:
|
||||
cutoff = datetime.now(timezone.utc) - __import__("datetime").timedelta(hours=hours)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
result = await self.session.execute(
|
||||
select(func.count(CommandLog.id))
|
||||
.where(CommandLog.admin_id == admin_id, CommandLog.created_at >= cutoff)
|
||||
|
||||
@@ -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
|
||||
|
||||
+185
-19
@@ -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,21 +9,36 @@ 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
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
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
|
||||
@@ -40,6 +55,7 @@ from server.api.health import router as health_router
|
||||
from server.api.assets import router as assets_router
|
||||
from server.api.webssh import router as webssh_router
|
||||
from server.api.sync_v2 import router as sync_v2_router
|
||||
from server.api.search import router as search_router
|
||||
|
||||
# Background tasks
|
||||
from server.background.heartbeat_flush import heartbeat_flush_loop
|
||||
@@ -52,6 +68,55 @@ logger = logging.getLogger("nexus")
|
||||
# Track background tasks for clean shutdown
|
||||
_background_tasks: list[asyncio.Task] = []
|
||||
|
||||
# Redis-based primary worker lock (prevents duplicate background tasks with multi-worker uvicorn)
|
||||
_PRIMARY_LOCK_KEY = "nexus:primary_worker"
|
||||
_PRIMARY_LOCK_TTL = 30 # seconds — must renew periodically
|
||||
|
||||
|
||||
async def _acquire_primary_lock() -> bool:
|
||||
"""Try to become the primary worker via Redis SETNX.
|
||||
|
||||
Only one uvicorn worker will hold this lock at a time.
|
||||
The lock auto-expires after PRIMARY_LOCK_TTL seconds (safety net for crashes).
|
||||
Primary worker renews the lock periodically via _renew_primary_lock().
|
||||
"""
|
||||
try:
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
import os
|
||||
redis = get_redis()
|
||||
worker_id = str(os.getpid())
|
||||
# SET with NX (only if not exists) + EX (TTL)
|
||||
acquired = await redis.set(_PRIMARY_LOCK_KEY, worker_id, nx=True, ex=_PRIMARY_LOCK_TTL)
|
||||
if acquired:
|
||||
# Start lock renewal background task
|
||||
asyncio.create_task(_renew_primary_lock(), name="primary_lock_renewal")
|
||||
logger.info(f"Primary worker lock acquired (pid={worker_id})")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
# If Redis fails, assume primary (single-worker mode)
|
||||
logger.warning(f"Primary lock check failed, assuming primary: {e}")
|
||||
return True
|
||||
|
||||
|
||||
async def _renew_primary_lock():
|
||||
"""Periodically renew the primary worker lock in Redis."""
|
||||
import os
|
||||
worker_id = str(os.getpid())
|
||||
while True:
|
||||
await asyncio.sleep(_PRIMARY_LOCK_TTL // 2) # Renew at half the TTL
|
||||
try:
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
redis = get_redis()
|
||||
current = await redis.get(_PRIMARY_LOCK_KEY)
|
||||
if current == worker_id:
|
||||
await redis.expire(_PRIMARY_LOCK_KEY, _PRIMARY_LOCK_TTL)
|
||||
else:
|
||||
logger.warning("Primary worker lock lost — another worker took over")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Primary lock renewal failed: {e}")
|
||||
|
||||
|
||||
# ── D7: DB Session Middleware (fixes session leak in 4 service factories) ──
|
||||
|
||||
@@ -71,8 +136,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
|
||||
@@ -88,13 +162,31 @@ 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.")
|
||||
|
||||
if not settings.API_KEY:
|
||||
logger.error("API_KEY is empty! Set it in .env (generated by install wizard).")
|
||||
raise SystemExit("API_KEY is required for Agent authentication and encryption fallback.")
|
||||
|
||||
if not settings.ENCRYPTION_KEY:
|
||||
logger.error("ENCRYPTION_KEY is empty! Set it in .env (generated by install wizard).")
|
||||
raise SystemExit("ENCRYPTION_KEY is required for credential encryption.")
|
||||
|
||||
# 1. Initialize database tables
|
||||
await init_db()
|
||||
logger.info("Database tables initialized")
|
||||
@@ -116,12 +208,19 @@ async def lifespan(app: FastAPI):
|
||||
from server.api.websocket import start_redis_subscriber, stop_redis_subscriber
|
||||
await start_redis_subscriber()
|
||||
|
||||
# 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")
|
||||
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])
|
||||
# 5. Launch background tasks (only on primary worker to avoid duplicate execution)
|
||||
# When running with --workers N, each worker gets its own lifespan.
|
||||
# Use Redis-based leader election to ensure only one worker runs background tasks.
|
||||
is_primary = await _acquire_primary_lock()
|
||||
if is_primary:
|
||||
task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush")
|
||||
task_monitor = asyncio.create_task(self_monitor_loop(), name="self_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])
|
||||
logger.info("Primary worker — background tasks launched")
|
||||
else:
|
||||
logger.info("Secondary worker — background tasks skipped (primary worker handles them)")
|
||||
|
||||
# 6. Start asyncssh connection pool (W1)
|
||||
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
||||
@@ -154,34 +253,92 @@ 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
|
||||
# Redirect root to install wizard
|
||||
if path == "/":
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/app/install.html")
|
||||
# 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.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)
|
||||
|
||||
# Security headers middleware (before CORS so headers appear on all responses)
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Inject security-related response headers on every response.
|
||||
|
||||
- X-Content-Type-Options: nosniff — prevents MIME-type sniffing
|
||||
- X-Frame-Options: DENY — prevents clickjacking via iframes
|
||||
- Referrer-Policy: strict-origin-when-cross-origin — limits referrer leakage
|
||||
- Permissions-Policy: restricts browser features (camera, mic, geolocation)
|
||||
|
||||
Not included:
|
||||
- Content-Security-Policy: too many inline scripts/styles to enforce easily
|
||||
- Strict-Transport-Security: handled by Nginx in production
|
||||
"""
|
||||
async def dispatch(self, request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
return response
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# 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=[
|
||||
"https://api.synaglobal.vip", # Production HTTPS
|
||||
"http://api.synaglobal.vip", # Production HTTP (redirects to HTTPS)
|
||||
"http://172.31.170.47", # WSL development
|
||||
"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)
|
||||
@@ -204,3 +361,12 @@ app.include_router(webssh_router)
|
||||
|
||||
# Sync Engine v2 (Step 4)
|
||||
app.include_router(sync_v2_router)
|
||||
|
||||
# Global Search
|
||||
app.include_router(search_router)
|
||||
|
||||
# ── Static files: serve web/app/ at /app/ (HTML pages + JS/CSS assets) ──
|
||||
# In production, Nginx serves these directly; this is for dev/standalone mode.
|
||||
WEB_APP_DIR = ROOT_DIR / "web" / "app"
|
||||
if WEB_APP_DIR.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=str(WEB_APP_DIR), html=True), name="static_app")
|
||||
+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}")
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ import asyncio
|
||||
import logging
|
||||
import platform
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -88,7 +88,7 @@ async def health_check():
|
||||
"disk_total_gb": round(disk.total / (1024**3), 2),
|
||||
"disk_used_gb": round(disk.used / (1024**3), 2),
|
||||
"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}
|
||||
@@ -147,7 +147,7 @@ async def reload_config(data: dict, x_api_key: str = Header(default="")):
|
||||
"""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"] = "nexus"
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"server_id": 0,
|
||||
"central": {
|
||||
"url": "https://api.synaglobal.vip",
|
||||
"url": "https://YOUR_NEXUS_DOMAIN",
|
||||
"api_key": "YOUR_API_KEY"
|
||||
},
|
||||
"api_key": "YOUR_API_KEY",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# ================================================================
|
||||
# Nexus Agent Install Script
|
||||
# Usage: curl -fsSL https://api.synaglobal.vip/agent/install.sh | bash -s -- --url https://api.synaglobal.vip --key KEY --id SERVER_ID
|
||||
# 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
|
||||
|
||||
+87
-1
@@ -61,6 +61,11 @@ async function apiFetch(url, options = {}) {
|
||||
|
||||
const res = await fetch(url, options);
|
||||
|
||||
// Record session activity on successful API calls
|
||||
if (res.status !== 401) {
|
||||
_recordActivity();
|
||||
}
|
||||
|
||||
// If 401, try refresh once
|
||||
if (res.status === 401) {
|
||||
const refreshed = await _refreshTokens();
|
||||
@@ -80,6 +85,7 @@ function _logout() {
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('token_expires');
|
||||
localStorage.removeItem('admin');
|
||||
localStorage.removeItem('last_activity');
|
||||
window.location.href = '/app/login.html';
|
||||
}
|
||||
|
||||
@@ -98,7 +104,87 @@ function doLogout() {
|
||||
_logout();
|
||||
}
|
||||
|
||||
// ── Session Inactivity Timeout ──
|
||||
// Auto-logout after 8 hours of no API activity
|
||||
const SESSION_MAX_AGE_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||||
|
||||
function _checkSessionAge() {
|
||||
const lastActivity = parseInt(localStorage.getItem('last_activity') || '0');
|
||||
if (lastActivity && Date.now() - lastActivity > SESSION_MAX_AGE_MS) {
|
||||
_logout();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _recordActivity() {
|
||||
localStorage.setItem('last_activity', String(Date.now()));
|
||||
}
|
||||
|
||||
// Backward-compatible alias
|
||||
const token = localStorage.getItem('access_token') || '';
|
||||
if (!token) window.location.href = '/app/login.html';
|
||||
if (!token || !_checkSessionAge()) {
|
||||
// Will redirect to login via _checkSessionAge → _logout
|
||||
} else {
|
||||
_recordActivity();
|
||||
}
|
||||
function ah() { return apiHeaders(); }
|
||||
|
||||
// ── Global Toast Notification System ──
|
||||
// Call toast('success', '操作成功') or toast('error', '操作失败') from any page.
|
||||
|
||||
let _toastContainer = null;
|
||||
let _toastId = 0;
|
||||
|
||||
function _ensureToastContainer() {
|
||||
if (_toastContainer) return;
|
||||
_toastContainer = document.createElement('div');
|
||||
_toastContainer.id = 'toastContainer';
|
||||
_toastContainer.className = 'fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none';
|
||||
_toastContainer.style.maxWidth = '380px';
|
||||
document.body.appendChild(_toastContainer);
|
||||
}
|
||||
|
||||
function toast(type, message, duration) {
|
||||
_ensureToastContainer();
|
||||
duration = duration || 3000;
|
||||
const id = ++_toastId;
|
||||
|
||||
const colors = {
|
||||
success: 'bg-green-900/90 border-green-700/50 text-green-200',
|
||||
error: 'bg-red-900/90 border-red-700/50 text-red-200',
|
||||
warning: 'bg-yellow-900/90 border-yellow-700/50 text-yellow-200',
|
||||
info: 'bg-blue-900/90 border-blue-700/50 text-blue-200',
|
||||
};
|
||||
const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' };
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.id = 'toast-' + id;
|
||||
el.className = `pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-xl border shadow-lg text-sm backdrop-blur-sm transition-all duration-300 opacity-0 translate-x-4 ${colors[type] || colors.info}`;
|
||||
el.innerHTML = `<span class="text-base">${icons[type] || 'ℹ'}</span><span class="flex-1">${_toastEsc(message)}</span><button onclick="dismissToast(${id})" class="text-current opacity-50 hover:opacity-100 text-xs ml-2">✕</button>`;
|
||||
|
||||
_toastContainer.appendChild(el);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
el.classList.remove('opacity-0', 'translate-x-4');
|
||||
el.classList.add('opacity-100', 'translate-x-0');
|
||||
});
|
||||
|
||||
// Auto-dismiss
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismissToast(id), duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function dismissToast(id) {
|
||||
const el = document.getElementById('toast-' + id);
|
||||
if (!el) return;
|
||||
el.classList.add('opacity-0', 'translate-x-4');
|
||||
el.classList.remove('opacity-100', 'translate-x-0');
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}
|
||||
|
||||
function _toastEsc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
+56
-35
@@ -1,61 +1,82 @@
|
||||
<!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 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/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 bg-brand/10 text-brand-light text-sm font-medium 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>
|
||||
<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">
|
||||
<select id="actionFilter" onchange="_offset=0;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>
|
||||
<option value="push">推送</option>
|
||||
<option value="script_exec">脚本执行</option>
|
||||
<option value="server_create">添加服务器</option>
|
||||
<option value="server_delete">删除服务器</option>
|
||||
<option value="setting_update">修改设置</option>
|
||||
<option value="retry_job">重试任务</option>
|
||||
<option value="delete_retry">删除重试</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>
|
||||
<button onclick="_offset=0;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 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>
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
|
||||
<span id="pageInfo" class="text-slate-500 text-sm"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('audit');
|
||||
const PAGE_SIZE=50;
|
||||
let _offset=0;
|
||||
let _total=0;
|
||||
|
||||
async function loadAudit(){
|
||||
const action=document.getElementById('actionFilter').value;
|
||||
const url=API+'/audit/?limit=200'+(action?'&action='+encodeURIComponent(action):'');
|
||||
const url=API+'/audit/?limit='+PAGE_SIZE+'&offset='+_offset+(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="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('')
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
const logs=data.items||[];
|
||||
const tbody=document.getElementById('auditTbody');
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">暂无日志</td></tr>'}else{
|
||||
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('');
|
||||
}
|
||||
updatePagination();
|
||||
}
|
||||
|
||||
function updatePagination(){
|
||||
const page=Math.floor(_offset/PAGE_SIZE)+1;
|
||||
const totalPages=Math.max(1,Math.ceil(_total/PAGE_SIZE));
|
||||
document.getElementById('pagination').classList.toggle('hidden',_total<=PAGE_SIZE);
|
||||
document.getElementById('pageInfo').textContent=`第 ${page}/${totalPages} 页 · 共 ${_total} 条`;
|
||||
document.getElementById('prevBtn').disabled=_offset<=0;
|
||||
document.getElementById('nextBtn').disabled=_offset+PAGE_SIZE>=_total;
|
||||
}
|
||||
|
||||
function prevPage(){_offset=Math.max(0,_offset-PAGE_SIZE);loadAudit()}
|
||||
function nextPage(){if(_offset+PAGE_SIZE<_total){_offset+=PAGE_SIZE;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();
|
||||
|
||||
+98
-35
@@ -1,26 +1,27 @@
|
||||
<!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 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/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/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>
|
||||
<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" 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="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建凭据</button>
|
||||
<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">
|
||||
<div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<!-- 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 Credential Modal -->
|
||||
<!-- 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>
|
||||
@@ -38,17 +39,38 @@
|
||||
<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 src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('credentials');
|
||||
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 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">
|
||||
@@ -61,17 +83,26 @@
|
||||
</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>';
|
||||
</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(){
|
||||
['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');
|
||||
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;
|
||||
@@ -79,27 +110,59 @@
|
||||
});
|
||||
|
||||
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)});
|
||||
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){toast('warning','名称、主机、用户名、密码必填');return}
|
||||
const r=await apiFetch(API+'/scripts/credentials',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success','凭据已创建');else toast('error','创建失败');
|
||||
hideCredModal();loadCreds();
|
||||
}
|
||||
|
||||
async function deleteCred(id){
|
||||
if(!confirm('确定删除凭据?'))return;
|
||||
await apiFetch(API+'/scripts/credentials/'+id,{method:'DELETE'});
|
||||
const r=await apiFetch(API+'/scripts/credentials/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','凭据已删除');else toast('error','删除失败');
|
||||
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){toast('warning','名称和密码必填');return}
|
||||
const r=await apiFetch(API+'/presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({name,encrypted_pw:pw})});
|
||||
if(r&&r.ok)toast('success','密码预设已创建');else toast('error','创建失败');
|
||||
hidePresetModal();loadPresets();
|
||||
}
|
||||
|
||||
async function deletePreset(id){
|
||||
if(!confirm('确定删除密码预设?'))return;
|
||||
const r=await apiFetch(API+'/presets/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','预设已删除');else toast('error','删除失败');
|
||||
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>
|
||||
|
||||
+112
-6
@@ -1,14 +1,120 @@
|
||||
<!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 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="/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>
|
||||
<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>
|
||||
<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-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 value="">-- 选择服务器 --</option>
|
||||
</select>
|
||||
<input id="dirPath" type="text" value="/" 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="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Breadcrumb -->
|
||||
<div id="breadcrumb" class="bg-slate-900/50 border-b border-slate-800 px-6 py-2 flex items-center gap-1 text-sm hidden">
|
||||
<span class="text-slate-500">路径:</span>
|
||||
<div id="breadcrumbPath" class="flex items-center gap-1"></div>
|
||||
</div>
|
||||
<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 src="/app/layout.js"></script>
|
||||
<script>
|
||||
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||'/www/wwwroot/';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}
|
||||
initLayout('files');
|
||||
let _currentPath='/';
|
||||
let _currentServerId=null;
|
||||
|
||||
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}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
}
|
||||
|
||||
async function browseDir(path){
|
||||
const sid=document.getElementById('serverSelect').value;
|
||||
if(path){document.getElementById('dirPath').value=path;_currentPath=path}
|
||||
else{_currentPath=document.getElementById('dirPath')?.value||'/'}
|
||||
if(!sid){toast('warning','请先选择服务器');return}
|
||||
_currentServerId=parseInt(sid);
|
||||
|
||||
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:_currentServerId,path:_currentPath})});
|
||||
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>`;toast('error','浏览失败: '+d.error.substring(0,50));return}
|
||||
|
||||
// Update breadcrumb
|
||||
updateBreadcrumb(_currentPath);
|
||||
|
||||
// Build file list
|
||||
let html='';
|
||||
// Parent directory link (if not root)
|
||||
if(_currentPath!=='/'){
|
||||
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
|
||||
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" onclick="browseDir('${escAttr(parentPath)}')">
|
||||
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
|
||||
</div>`;
|
||||
}
|
||||
if(d.entries.length){
|
||||
// Sort: directories first, then files
|
||||
const sorted=[...d.entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
|
||||
html+=sorted.map(e=>{
|
||||
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
|
||||
return `<div class="flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${escAttr(fullPath)}')"`:''}>
|
||||
<span class="${e.is_dir?'text-yellow-400':'text-slate-400'} text-sm">${e.is_dir?'📁':'📄'}</span>
|
||||
<span class="text-white text-sm flex-1 ${e.is_dir?'hover:text-brand-light':''}">${esc(e.name)}</span>
|
||||
<span class="text-slate-500 text-xs tabular-nums">${e.is_dir?'--':e.size}</span>
|
||||
<span class="text-slate-600 text-xs">${esc(e.perms)}</span>
|
||||
<span class="text-slate-600 text-xs">${esc(e.owner||'')}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}else if(_currentPath==='/'){
|
||||
html+='<div class="px-4 py-8 text-center text-slate-500">空目录</div>';
|
||||
}
|
||||
document.getElementById('fileList').innerHTML=html;
|
||||
}catch(e){
|
||||
document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">连接失败: ${esc(e.message)}</div>`;
|
||||
toast('error','连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
function updateBreadcrumb(path){
|
||||
const bc=document.getElementById('breadcrumb');
|
||||
const bcPath=document.getElementById('breadcrumbPath');
|
||||
bc.classList.remove('hidden');
|
||||
|
||||
// Split path into segments
|
||||
const segments=path.split('/').filter(Boolean);
|
||||
let html=`<span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('/')">/</span>`;
|
||||
let accumulated='';
|
||||
segments.forEach((seg,i)=>{
|
||||
accumulated+='/'+seg;
|
||||
const p=accumulated;
|
||||
if(i<segments.length-1){
|
||||
html+=`<span class="text-slate-600">/</span><span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('${escAttr(p)}')">${esc(seg)}</span>`;
|
||||
}else{
|
||||
html+=`<span class="text-slate-600">/</span><span class="text-white font-medium">${esc(seg)}</span>`;
|
||||
}
|
||||
});
|
||||
bcPath.innerHTML=html;
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>')}
|
||||
|
||||
// Enter key in path input triggers browse
|
||||
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
|
||||
|
||||
loadServers();
|
||||
</script>
|
||||
</body></html>
|
||||
+227
-73
@@ -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,46 +11,14 @@
|
||||
--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">
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
|
||||
<!-- 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>
|
||||
</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 bg-brand/10 text-brand-light text-sm font-medium 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>
|
||||
<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>
|
||||
|
||||
<!-- 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 +27,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,58 +54,247 @@
|
||||
</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 src="/app/layout.js"></script>
|
||||
<script>
|
||||
// ── Load Dashboard ──
|
||||
initLayout('index');
|
||||
const MAX_ALERTS = 20;
|
||||
let _alerts = [];
|
||||
let _ws = null;
|
||||
let _wsRetry = 0;
|
||||
let _stats = null;
|
||||
|
||||
// ── Dashboard Stats ──
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/servers/stats');
|
||||
if (!res) return;
|
||||
const stats = await res.json();
|
||||
_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) {}
|
||||
}
|
||||
|
||||
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 = '0';
|
||||
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('');
|
||||
}
|
||||
|
||||
// Recent activity from audit
|
||||
try {
|
||||
const auditRes = await apiFetch(API + '/audit/?limit=10');
|
||||
if (auditRes) {
|
||||
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 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.server_name||'#'+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 ──
|
||||
async function loadActivity() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/audit/?limit=8');
|
||||
if (!res) return;
|
||||
const data = await res.json();
|
||||
const audits = data.items || data;
|
||||
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 = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Info ──
|
||||
async function loadUser() {
|
||||
try {
|
||||
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 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; }
|
||||
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
loadSyncs();
|
||||
connectWS();
|
||||
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; }
|
||||
// Refresh stats + recent syncs on any real-time event
|
||||
loadDashboard();
|
||||
loadSyncs();
|
||||
if (msg.type === 'alert') {
|
||||
_addAlert('\u{1F534}', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
|
||||
_playAlertSound();
|
||||
const el = document.getElementById('statAlerts');
|
||||
el.classList.add('animate-pulse');
|
||||
setTimeout(() => el.classList.remove('animate-pulse'), 3000);
|
||||
} else if (msg.type === 'recovery') {
|
||||
_addAlert('\u{1F7E2}', `${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(); };
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function fmtTime(t) { if (!t) return ''; const d = new Date(t + 'Z'); return d.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
|
||||
function _addAlert(icon, text) {
|
||||
_alerts.unshift({ icon, text, time: new Date() });
|
||||
if (_alerts.length > MAX_ALERTS) _alerts.pop();
|
||||
renderAlerts();
|
||||
}
|
||||
|
||||
loadUser();
|
||||
loadDashboard();
|
||||
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} ${esc(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');
|
||||
}
|
||||
|
||||
// Alert sound using Web Audio API — no external file needed
|
||||
function _playAlertSound() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 880;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.15;
|
||||
osc.start();
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
|
||||
osc.stop(ctx.currentTime + 0.3);
|
||||
} catch(e) {}
|
||||
// Flash tab title
|
||||
_flashTitle();
|
||||
}
|
||||
|
||||
let _titleFlashTimer = null;
|
||||
function _flashTitle() {
|
||||
const orig = document.title;
|
||||
let count = 0;
|
||||
clearInterval(_titleFlashTimer);
|
||||
_titleFlashTimer = setInterval(() => {
|
||||
document.title = count % 2 === 0 ? '🔴 告警 — Nexus' : orig;
|
||||
count++;
|
||||
if (count > 10) { clearInterval(_titleFlashTimer); document.title = orig; }
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
loadSyncs();
|
||||
}
|
||||
|
||||
// Auto-refresh every 30s
|
||||
setInterval(() => { loadDashboard(); loadSyncs(); }, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</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">安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)</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">.env</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,188 @@
|
||||
// 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'|'commands'|'audit'|'settings'
|
||||
const navItems = [
|
||||
{ id:'index', icon:'🏠', label:'仪表盘', href:'/app/index.html' },
|
||||
{ id:'servers', icon:'🖥', label:'服务器', href:'/app/servers.html' },
|
||||
{ id:'assets', icon:'🗂', label:'资产管理', href:'/app/assets.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:'commands', icon:'💻', label:'命令日志', href:'/app/commands.html' },
|
||||
{ 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;
|
||||
|
||||
// Responsive: on mobile (<768px), sidebar defaults to hidden and overlays
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
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">
|
||||
<!-- Global Search -->
|
||||
<div class="relative mb-3">
|
||||
<input id="globalSearchInput" type="text" placeholder="搜索..." class="w-full 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-1 focus:ring-brand" oninput="debounceSearch(this.value)">
|
||||
<div id="searchResults" class="hidden absolute bottom-full left-0 right-0 mb-2 bg-slate-800 border border-slate-700 rounded-lg shadow-xl max-h-64 overflow-y-auto z-50"></div>
|
||||
</div>
|
||||
<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();
|
||||
|
||||
// ── Mobile Responsive ──
|
||||
// On mobile: collapse sidebar by default, add overlay + backdrop
|
||||
if (isMobile) {
|
||||
// Find Alpine component and set sidebarOpen = false
|
||||
const alpineRoot = document.querySelector('[x-data]');
|
||||
if (alpineRoot && alpineRoot.__x) {
|
||||
alpineRoot.__x.$data.sidebarOpen = false;
|
||||
} else {
|
||||
// Direct DOM fallback
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
// Make sidebar overlay on mobile instead of pushing content
|
||||
sidebar.style.position = 'fixed';
|
||||
sidebar.style.top = '0';
|
||||
sidebar.style.left = '0';
|
||||
sidebar.style.bottom = '0';
|
||||
sidebar.style.zIndex = '40';
|
||||
// Add backdrop
|
||||
let backdrop = document.getElementById('sidebarBackdrop');
|
||||
if (!backdrop) {
|
||||
backdrop = document.createElement('div');
|
||||
backdrop.id = 'sidebarBackdrop';
|
||||
backdrop.className = 'fixed inset-0 bg-black/50 z-30 hidden';
|
||||
backdrop.onclick = () => {
|
||||
const root = document.querySelector('[x-data]');
|
||||
if (root && root.__x) root.__x.$data.sidebarOpen = false;
|
||||
sidebar.style.display = 'none';
|
||||
backdrop.classList.add('hidden');
|
||||
};
|
||||
document.body.appendChild(backdrop);
|
||||
}
|
||||
// Watch for sidebar toggle to show/hide backdrop
|
||||
const origToggle = document.querySelector('[x-data]');
|
||||
if (origToggle) {
|
||||
const observer = new MutationObserver(() => {
|
||||
const isVisible = sidebar.style.display !== 'none' && sidebar.offsetParent !== null;
|
||||
backdrop.classList.toggle('hidden', !isVisible);
|
||||
});
|
||||
observer.observe(sidebar, { attributes: true, attributeFilter: ['style'] });
|
||||
}
|
||||
}
|
||||
|
||||
// Make tables horizontally scrollable on all pages
|
||||
document.querySelectorAll('table').forEach(table => {
|
||||
if (!table.parentElement.classList.contains('overflow-x-auto')) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'overflow-x-auto';
|
||||
table.parentNode.insertBefore(wrapper, table);
|
||||
wrapper.appendChild(table);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
const brandEl = document.getElementById('brandName');
|
||||
if (brandEl && u.system_name) brandEl.textContent = u.system_name;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Global Search ──
|
||||
let _searchTimer = null;
|
||||
|
||||
function debounceSearch(q) {
|
||||
clearTimeout(_searchTimer);
|
||||
const panel = document.getElementById('searchResults');
|
||||
if (!q || q.length < 2) { panel.classList.add('hidden'); return; }
|
||||
_searchTimer = setTimeout(() => doGlobalSearch(q), 300);
|
||||
}
|
||||
|
||||
async function doGlobalSearch(q) {
|
||||
try {
|
||||
const r = await apiFetch(API + '/search/?q=' + encodeURIComponent(q));
|
||||
if (!r) return;
|
||||
const data = await r.json();
|
||||
const panel = document.getElementById('searchResults');
|
||||
if (!data.total) { panel.innerHTML = '<div class="p-3 text-slate-500 text-sm">无匹配结果</div>'; panel.classList.remove('hidden'); return; }
|
||||
let html = '';
|
||||
if (data.servers.length) {
|
||||
html += '<div class="px-3 py-1.5 text-slate-500 text-xs uppercase">服务器</div>';
|
||||
html += data.servers.map(s => `<a href="/app/servers.html" class="flex items-center gap-2 px-3 py-2 hover:bg-slate-700 transition"><span class="${s.is_online?'text-green-400':'text-red-400'}">●</span><span class="text-white text-sm">${_esc(s.name)}</span><span class="text-slate-500 text-xs">${_esc(s.domain)}</span></a>`).join('');
|
||||
}
|
||||
if (data.scripts.length) {
|
||||
html += '<div class="px-3 py-1.5 text-slate-500 text-xs uppercase border-t border-slate-700">脚本</div>';
|
||||
html += data.scripts.map(s => `<a href="/app/scripts.html" class="flex items-center gap-2 px-3 py-2 hover:bg-slate-700 transition"><span class="text-brand-light">📜</span><span class="text-white text-sm">${_esc(s.name)}</span><span class="text-slate-500 text-xs">${_esc(s.category||'')}</span></a>`).join('');
|
||||
}
|
||||
if (data.credentials.length) {
|
||||
html += '<div class="px-3 py-1.5 text-slate-500 text-xs uppercase border-t border-slate-700">凭据</div>';
|
||||
html += data.credentials.map(c => `<a href="/app/credentials.html" class="flex items-center gap-2 px-3 py-2 hover:bg-slate-700 transition"><span>🔑</span><span class="text-white text-sm">${_esc(c.name)}</span><span class="text-slate-500 text-xs">${_esc(c.host)}</span></a>`).join('');
|
||||
}
|
||||
if (data.schedules.length) {
|
||||
html += '<div class="px-3 py-1.5 text-slate-500 text-xs uppercase border-t border-slate-700">调度</div>';
|
||||
html += data.schedules.map(s => `<a href="/app/schedules.html" class="flex items-center gap-2 px-3 py-2 hover:bg-slate-700 transition"><span>⏰</span><span class="text-white text-sm">${_esc(s.name)}</span><span class="text-slate-500 text-xs font-mono">${_esc(s.cron_expr)}</span></a>`).join('');
|
||||
}
|
||||
panel.innerHTML = html;
|
||||
panel.classList.remove('hidden');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Close search on click outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const panel = document.getElementById('searchResults');
|
||||
const input = document.getElementById('globalSearchInput');
|
||||
if (panel && !panel.contains(e.target) && e.target !== input) {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function _esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
+86
-4
@@ -49,9 +49,23 @@
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">密码</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="••••••••">
|
||||
<div class="relative">
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||
class="w-full px-4 py-3 pr-12 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="••••••••">
|
||||
<button type="button" id="togglePwd" tabindex="-1"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition p-1">
|
||||
<!-- Eye icon (show) -->
|
||||
<svg id="eyeShow" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
<!-- Eye-off icon (hide) -->
|
||||
<svg id="eyeHide" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="loginBtn"
|
||||
@@ -87,6 +101,9 @@
|
||||
|
||||
<!-- Error Message -->
|
||||
<div id="errorMsg" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center"></div>
|
||||
|
||||
<!-- Lockout Warning -->
|
||||
<div id="lockoutMsg" class="hidden mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-400 text-sm text-center"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -97,11 +114,30 @@
|
||||
const API = window.location.origin + '/api/auth';
|
||||
let pendingUsername = '';
|
||||
let pendingPassword = '';
|
||||
let failCount = 0;
|
||||
const MAX_FAIL_DISPLAY = 5; // Show warning after this many failures
|
||||
|
||||
// ── Password Visibility Toggle ──
|
||||
document.getElementById('togglePwd').addEventListener('click', () => {
|
||||
const pwdInput = document.getElementById('password');
|
||||
const eyeShow = document.getElementById('eyeShow');
|
||||
const eyeHide = document.getElementById('eyeHide');
|
||||
if (pwdInput.type === 'password') {
|
||||
pwdInput.type = 'text';
|
||||
eyeShow.classList.add('hidden');
|
||||
eyeHide.classList.remove('hidden');
|
||||
} else {
|
||||
pwdInput.type = 'password';
|
||||
eyeShow.classList.remove('hidden');
|
||||
eyeHide.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 1: Password Login ──
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
hideLockout();
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
@@ -125,10 +161,29 @@
|
||||
pendingUsername = username;
|
||||
pendingPassword = password;
|
||||
showTotpStep();
|
||||
} else if (res.status === 429) {
|
||||
// Account locked
|
||||
failCount = MAX_FAIL_DISPLAY;
|
||||
showError(data.detail || '登录尝试过多,账户已临时锁定');
|
||||
showLockout('账户已临时锁定,请15分钟后再试。');
|
||||
} else if (!res.ok) {
|
||||
failCount++;
|
||||
showError(data.detail || '登录失败');
|
||||
|
||||
// Show lockout warning after repeated failures
|
||||
if (failCount >= MAX_FAIL_DISPLAY) {
|
||||
showLockout(`连续登录失败 ${failCount} 次。多次失败后账户可能被临时锁定,请确认用户名和密码。`);
|
||||
} else if (failCount >= 3) {
|
||||
showLockout(`已失败 ${failCount} 次,请检查输入。`);
|
||||
}
|
||||
|
||||
// Shake animation on error
|
||||
const card = document.getElementById('loginCard');
|
||||
card.classList.add('animate-shake');
|
||||
setTimeout(() => card.classList.remove('animate-shake'), 500);
|
||||
} else {
|
||||
// Success — store tokens and redirect
|
||||
failCount = 0;
|
||||
onLoginSuccess(data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -165,10 +220,15 @@
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
failCount++;
|
||||
showError(data.detail || '验证码错误');
|
||||
if (failCount >= 3) {
|
||||
showLockout(`已失败 ${failCount} 次,请确认验证码正确。`);
|
||||
}
|
||||
document.getElementById('totpCode').value = '';
|
||||
document.getElementById('totpCode').focus();
|
||||
} else {
|
||||
failCount = 0;
|
||||
onLoginSuccess(data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -203,7 +263,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';
|
||||
}
|
||||
|
||||
@@ -218,6 +278,17 @@
|
||||
document.getElementById('errorMsg').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Lockout Warning ──
|
||||
function showLockout(msg) {
|
||||
const el = document.getElementById('lockoutMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideLockout() {
|
||||
document.getElementById('lockoutMsg').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Check existing session ──
|
||||
(function checkSession() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
@@ -228,5 +299,16 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+225
-17
@@ -1,28 +1,236 @@
|
||||
<!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 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/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">
|
||||
<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>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
|
||||
<!-- Push Form -->
|
||||
<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><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>
|
||||
<div id="pushResult" class="hidden mt-4 p-4 rounded-xl bg-slate-800/50 text-sm text-slate-300"></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><input id="destPath" placeholder="默认使用服务器配置的目标路径" 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>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button onclick="selectAllServers()" class="text-xs text-brand-light hover:underline">全选</button>
|
||||
<button onclick="deselectAllServers()" class="text-xs text-slate-400 hover:underline">全不选</button>
|
||||
</div>
|
||||
<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-40"></select>
|
||||
<div id="serverCount" class="text-slate-500 text-xs mt-1">已选择 0 台服务器</div>
|
||||
</div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">同步模式</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="incremental" checked class="accent-brand"> <span class="text-sm">增量同步</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="full" class="accent-brand"> <span class="text-sm">全量同步</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="checksum" class="accent-brand"> <span class="text-sm">校验和</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="block text-sm text-slate-300 mb-2">并发数</label><input id="concurrency" type="number" value="10" min="1" max="50" 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="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
</div>
|
||||
<button onclick="doPush()" id="pushBtn" class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed">开始推送</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
<div id="progressSection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-white font-semibold">推送进度</h2>
|
||||
<span id="progressStats" class="text-sm text-slate-400">0/0</span>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full bg-slate-800 rounded-full h-3 overflow-hidden">
|
||||
<div id="progressBar" class="h-full bg-brand rounded-full transition-all duration-500" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span class="text-green-400">✓ 成功: <span id="countSuccess">0</span></span>
|
||||
<span class="text-red-400">✗ 失败: <span id="countFailed">0</span></span>
|
||||
<span class="text-slate-400">◌ 等待: <span id="countPending">0</span></span>
|
||||
</div>
|
||||
<!-- Per-server Results -->
|
||||
<div id="serverResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- History Section -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-white font-semibold mb-3">最近推送记录</h2>
|
||||
<div id="pushHistory" class="space-y-2"><div class="text-slate-500 text-center py-6 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
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 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}
|
||||
initLayout('push');
|
||||
let _serversCache=[];
|
||||
let _pushInProgress=false;
|
||||
|
||||
async function loadServers(){
|
||||
const r=await apiFetch(API+'/servers/');if(!r)return;
|
||||
const data=await r.json();_serversCache=data.items||data;
|
||||
const sel=document.getElementById('targetServers');
|
||||
sel.innerHTML=_serversCache.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
|
||||
// Pre-select servers from batch push (servers.html)
|
||||
const batchIds=sessionStorage.getItem('batchPushIds');
|
||||
if(batchIds){
|
||||
try{
|
||||
const ids=JSON.parse(batchIds);
|
||||
Array.from(sel.options).forEach(o=>{
|
||||
if(ids.includes(parseInt(o.value)))o.selected=true;
|
||||
});
|
||||
sessionStorage.removeItem('batchPushIds');
|
||||
toast('info',`已预选 ${ids.length} 台服务器`);
|
||||
}catch(e){}
|
||||
}
|
||||
updateServerCount();
|
||||
}
|
||||
|
||||
document.getElementById('targetServers').addEventListener('change',updateServerCount);
|
||||
function updateServerCount(){
|
||||
const sel=document.getElementById('targetServers').selectedOptions.length;
|
||||
document.getElementById('serverCount').textContent=`已选择 ${sel} 台服务器`;
|
||||
}
|
||||
function selectAllServers(){
|
||||
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=true);updateServerCount();
|
||||
}
|
||||
function deselectAllServers(){
|
||||
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=false);updateServerCount();
|
||||
}
|
||||
|
||||
async function doPush(){
|
||||
if(_pushInProgress)return;
|
||||
const opts=document.getElementById('targetServers').selectedOptions;
|
||||
const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(!ids.length){toast('warning','请选择至少一台服务器');return}
|
||||
const srcPath=document.getElementById('srcPath').value;
|
||||
if(!srcPath){toast('warning','请输入源路径');return}
|
||||
const syncMode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
|
||||
const body={
|
||||
server_ids:ids,
|
||||
source_path:srcPath,
|
||||
target_path:document.getElementById('destPath').value||null,
|
||||
sync_mode:syncMode,
|
||||
concurrency:parseInt(document.getElementById('concurrency').value)||10,
|
||||
batch_size:parseInt(document.getElementById('batchSize').value)||50,
|
||||
};
|
||||
|
||||
_pushInProgress=true;
|
||||
const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
|
||||
|
||||
// Show progress section with pending states
|
||||
showProgress(ids);
|
||||
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/files',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(!r){toast('error','认证失败或请求被拒绝');resetProgress();return}
|
||||
const d=await r.json();
|
||||
updateProgressFromResult(d);
|
||||
toast(d.failed>0?'warning':'success',`推送完成: ${d.completed||0} 成功, ${d.failed||0} 失败`);
|
||||
}catch(e){
|
||||
toast('error','推送请求失败: '+e.message);
|
||||
resetProgress();
|
||||
}finally{
|
||||
_pushInProgress=false;btn.disabled=false;btn.textContent='开始推送';
|
||||
loadHistory(); // Refresh history
|
||||
}
|
||||
}
|
||||
|
||||
function showProgress(ids){
|
||||
const section=document.getElementById('progressSection');section.classList.remove('hidden');
|
||||
document.getElementById('progressBar').style.width='0%';
|
||||
document.getElementById('progressStats').textContent=`0/${ids.length}`;
|
||||
document.getElementById('countSuccess').textContent='0';
|
||||
document.getElementById('countFailed').textContent='0';
|
||||
document.getElementById('countPending').textContent=ids.length;
|
||||
// Build per-server rows
|
||||
const serversMap={};_serversCache.forEach(s=>serversMap[s.id]=s);
|
||||
document.getElementById('serverResults').innerHTML=ids.map(id=>{
|
||||
const s=serversMap[id];
|
||||
return `<div id="srv_${id}" class="flex items-center gap-3 px-3 py-2 bg-slate-800/50 rounded-lg">
|
||||
<span class="shrink-0 w-5 h-5 flex items-center justify-center text-slate-400" id="srv_icon_${id}">◌</span>
|
||||
<span class="text-sm text-slate-300 flex-1">${esc(s?.name||'服务器#'+id)}</span>
|
||||
<span class="text-xs text-slate-500" id="srv_status_${id}">等待中</span>
|
||||
<span class="text-xs text-slate-600 hidden" id="srv_detail_${id}"></span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateProgressFromResult(d){
|
||||
const total=d.total||0;const completed=d.completed||0;const failed=d.failed||0;
|
||||
const pct=total>0?Math.round(((completed+failed)/total)*100):0;
|
||||
document.getElementById('progressBar').style.width=pct+'%';
|
||||
document.getElementById('progressStats').textContent=`${completed+failed}/${total}`;
|
||||
document.getElementById('countSuccess').textContent=completed;
|
||||
document.getElementById('countFailed').textContent=failed;
|
||||
document.getElementById('countPending').textContent=total-completed-failed;
|
||||
|
||||
const serversMap={};_serversCache.forEach(s=>serversMap[s.id]=s);
|
||||
if(d.results){
|
||||
for(const[sid,log]of Object.entries(d.results)){
|
||||
const iconEl=document.getElementById('srv_icon_'+sid);
|
||||
const statusEl=document.getElementById('srv_status_'+sid);
|
||||
const detailEl=document.getElementById('srv_detail_'+sid);
|
||||
if(!iconEl)continue;
|
||||
if(log.status==='success'){
|
||||
iconEl.textContent='✓';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-green-400';
|
||||
statusEl.textContent='成功';statusEl.className='text-xs text-green-400';
|
||||
if(log.duration_seconds){detailEl.textContent=log.duration_seconds+'s';detailEl.classList.remove('hidden')}
|
||||
}else{
|
||||
iconEl.textContent='✗';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-red-400';
|
||||
statusEl.textContent='失败';statusEl.className='text-xs text-red-400';
|
||||
if(log.error_message){detailEl.textContent=log.error_message.substring(0,60);detailEl.classList.remove('hidden');detailEl.className='text-xs text-red-400/70'}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mark remaining as pending
|
||||
document.querySelectorAll('[id^="srv_status_"]').forEach(el=>{
|
||||
if(el.textContent==='等待中'){
|
||||
const id=el.id.replace('srv_status_','');
|
||||
const iconEl=document.getElementById('srv_icon_'+id);
|
||||
if(iconEl){iconEl.textContent='◌';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-500'}
|
||||
el.className='text-xs text-slate-500';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetProgress(){
|
||||
document.getElementById('progressSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Push History (recent sync logs) ──
|
||||
async function loadHistory(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/logs?limit=10');if(!r)return;
|
||||
const logs=await r.json();
|
||||
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>`<div class="bg-slate-900 rounded-lg border border-slate-800 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="${l.status==='success'?'text-green-400':'text-red-400'} text-sm">${l.status==='success'?'✓':'✗'}</span>
|
||||
<div>
|
||||
<span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.operator||'system')}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
|
||||
}catch(e){
|
||||
document.getElementById('pushHistory').innerHTML='<div class="text-slate-500 text-center py-6 text-sm">加载失败</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'})}
|
||||
|
||||
loadServers();
|
||||
loadHistory();
|
||||
</script>
|
||||
</body></html>
|
||||
+47
-20
@@ -1,53 +1,80 @@
|
||||
<!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 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/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 bg-brand/10 text-brand-light text-sm font-medium 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>
|
||||
<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>
|
||||
<div class="flex items-center gap-3">
|
||||
<select id="statusFilter" onchange="loadRetries()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="success">成功</option>
|
||||
</select>
|
||||
<button onclick="loadRetries()" 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="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>
|
||||
initLayout('retries');
|
||||
let _retriesCache=[];
|
||||
|
||||
async function loadRetries(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/retries/');if(!r)return;
|
||||
const jobs=await r.json();
|
||||
let jobs=await r.json();
|
||||
_retriesCache=jobs;
|
||||
|
||||
// Client-side status filter
|
||||
const statusFilter=document.getElementById('statusFilter').value;
|
||||
if(statusFilter)jobs=jobs.filter(j=>j.status===statusFilter);
|
||||
|
||||
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 statusIcon=j.status==='pending'?'⏳':j.status==='running'?'🔄':j.status==='failed'?'❌':'✅';
|
||||
const retryPct=Math.min((j.retry_count/j.max_retries)*100,100);
|
||||
const canRetry=j.status==='failed'||j.status==='pending';
|
||||
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-sm">${statusIcon}</span>
|
||||
<span class="text-white font-medium">${esc(j.server_name||'#'+j.server_id)}</span>
|
||||
<span class="${statusColor} text-xs">${esc(j.status)}</span>
|
||||
<span class="${statusColor} text-xs px-2 py-0.5 rounded ${j.status==='failed'?'bg-red-400/10':j.status==='pending'?'bg-yellow-400/10':j.status==='success'?'bg-green-400/10':'bg-blue-400/10'}">${esc(j.status)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-400">重试 ${j.retry_count}/${j.max_retries}</span>
|
||||
${canRetry?`<button onclick="retryJob(${j.id})" class="text-brand-light text-xs hover:underline px-2 py-1 bg-brand/10 rounded">重试</button>`:''}
|
||||
<button onclick="deleteJob(${j.id})" class="text-red-400 text-xs hover:underline px-2 py-1 bg-red-400/10 rounded">删除</button>
|
||||
</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>`:''}
|
||||
${j.operator?`<div class="mt-1 text-xs text-slate-600">操作人: ${esc(j.operator)}</div>`:''}
|
||||
${j.last_error?`<div class="mt-1 text-xs text-red-400/70 truncate" title="${esc(j.last_error)}">${esc(j.last_error.substring(0,100))}</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>'}
|
||||
}
|
||||
|
||||
async function retryJob(id){
|
||||
const r=await apiFetch(API+'/retries/'+id+'/retry',{method:'POST'});
|
||||
if(r&&r.ok)toast('success','重试任务已触发');else toast('error','触发重试失败');
|
||||
loadRetries();
|
||||
}
|
||||
|
||||
async function deleteJob(id){
|
||||
if(!confirm('确定删除此重试任务?'))return;
|
||||
const r=await apiFetch(API+'/retries/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','重试任务已删除');else toast('error','删除失败');
|
||||
loadRetries();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
+18
-93
@@ -1,20 +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 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/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 bg-brand/10 text-brand-light text-sm font-medium 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>
|
||||
<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>
|
||||
@@ -22,119 +8,58 @@
|
||||
</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>
|
||||
|
||||
<!-- Add/Edit Schedule Modal -->
|
||||
<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="/www/wwwroot/project/"></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><label class="block text-slate-400 text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步</option><option value="checksum">校验和</option></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>
|
||||
initLayout('schedules');
|
||||
let _schedsCache={};
|
||||
|
||||
async function loadScheds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/schedules/');
|
||||
if(!r)return;
|
||||
const scheds=await r.json();
|
||||
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>
|
||||
<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> · ${esc(s.sync_mode||'incremental')}${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;
|
||||
const sel=document.getElementById('sServers');
|
||||
sel.innerHTML=servers.map(s=>`<option value="${s.id}" ${selectedIds&&selectedIds.includes(s.id)?'selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
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 showAddSched(){document.getElementById('editSchedId').value='';document.getElementById('schedModalTitle').textContent='新建调度';document.getElementById('sName').value='';document.getElementById('sPath').value='';document.getElementById('sCron').value='';document.getElementById('sSyncMode').value='incremental';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||'';document.getElementById('sSyncMode').value=s.sync_mode||'incremental';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,
|
||||
};
|
||||
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),sync_mode:document.getElementById('sSyncMode').value||'incremental',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)});
|
||||
}
|
||||
if(id){const r=await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','调度已更新');else toast('error','更新失败')}else{const r=await apiFetch(API+'/schedules/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','调度已创建');else toast('error','创建失败')}
|
||||
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();
|
||||
}
|
||||
|
||||
async function toggleSched(id,enabled){const r=await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({enabled})});if(r&&r.ok)toast('info',enabled?'已启用':'已禁用');loadScheds()}
|
||||
async function deleteSched(id){if(!confirm('确定删除调度?'))return;const r=await apiFetch(API+'/schedules/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','已删除');else toast('error','删除失败');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>
|
||||
|
||||
+78
-15
@@ -1,39 +1,102 @@
|
||||
<!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 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/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>
|
||||
<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"><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>
|
||||
<div id="newScriptForm" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-3">
|
||||
|
||||
<!-- Add/Edit Script Form -->
|
||||
<div id="scriptForm" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-3">
|
||||
<input type="hidden" id="editScriptId">
|
||||
<h2 id="scriptFormTitle" class="text-white font-semibold">新建脚本</h2>
|
||||
<input id="scriptName" placeholder="脚本名称" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
|
||||
<input id="scriptCategory" placeholder="分类 (ops/deploy/check)" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
|
||||
<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>
|
||||
<textarea id="scriptContent" rows="10" 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="saveScript()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideScriptForm()" 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">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg 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 id="execResult" class="hidden p-3 bg-slate-950 rounded-lg text-sm font-mono text-slate-300 max-h-64 overflow-y-auto whitespace-pre-wrap"></div>
|
||||
<div class="flex gap-2"><button onclick="doExec()" id="execBtn" 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 src="/app/layout.js"></script>
|
||||
<script>
|
||||
let _execScriptId=null,_execScriptCmd='';
|
||||
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;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><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},'${esc(s.name)}',\`${esc(s.content?.replace(/`/g,'\\`')||'')}\`)" 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 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,name,cmd){_execScriptId=id;_execScriptCmd=cmd;document.getElementById('execScriptName').textContent=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')}
|
||||
initLayout('scripts');
|
||||
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 cursor-pointer hover:text-brand-light transition" onclick="showEditScript(${s.id})">${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="showEditScript(${s.id})" class="text-slate-400 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 max-h-32">${esc(s.content?.substring(0,300)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}}
|
||||
|
||||
function showNewScript(){
|
||||
document.getElementById('editScriptId').value='';
|
||||
document.getElementById('scriptFormTitle').textContent='新建脚本';
|
||||
document.getElementById('scriptName').value='';
|
||||
document.getElementById('scriptCategory').value='';
|
||||
document.getElementById('scriptContent').value='';
|
||||
document.getElementById('scriptForm').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showEditScript(id){
|
||||
const s=_scriptsCache.find(x=>x.id===id);if(!s)return;
|
||||
document.getElementById('editScriptId').value=s.id;
|
||||
document.getElementById('scriptFormTitle').textContent='编辑脚本';
|
||||
document.getElementById('scriptName').value=s.name||'';
|
||||
document.getElementById('scriptCategory').value=s.category||'';
|
||||
document.getElementById('scriptContent').value=s.content||'';
|
||||
document.getElementById('scriptForm').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideScriptForm(){document.getElementById('scriptForm').classList.add('hidden')}
|
||||
|
||||
async function saveScript(){
|
||||
const id=document.getElementById('editScriptId').value;
|
||||
const body={name:document.getElementById('scriptName').value,category:document.getElementById('scriptCategory').value||'ops',content:document.getElementById('scriptContent').value};
|
||||
if(!body.name||!body.content){toast('warning','名称和内容必填');return}
|
||||
let r;
|
||||
if(id){r=await apiFetch(API+'/scripts/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)})}
|
||||
else{r=await apiFetch(API+'/scripts/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)})}
|
||||
if(r&&r.ok)toast('success',id?'脚本已更新':'脚本已创建');else toast('error','保存失败');
|
||||
hideScriptForm();loadScripts();
|
||||
}
|
||||
|
||||
async function deleteScript(id){if(!confirm('确定删除脚本?'))return;const r=await apiFetch(API+'/scripts/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','脚本已删除');else toast('error','删除失败');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');document.getElementById('execBtn').disabled=false;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 r=await apiFetch(API+'/scripts/exec',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({script_id:_execScriptId,command:_execScriptCmd,server_ids:ids,timeout:timeout})});if(!r){el.textContent='认证失败';return}const d=await r.json();el.textContent=JSON.stringify(d,null,2)}
|
||||
|
||||
async function doExec(){
|
||||
const opts=document.getElementById('execServers').selectedOptions;const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(!ids.length){toast('warning','请选择服务器');return}
|
||||
const timeout=parseInt(document.getElementById('execTimeout').value)||60;
|
||||
const el=document.getElementById('execResult');el.classList.remove('hidden');el.textContent='执行中...';
|
||||
const btn=document.getElementById('execBtn');btn.disabled=true;btn.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})});
|
||||
btn.disabled=false;btn.textContent='执行';
|
||||
if(!r){el.textContent='认证失败';toast('error','认证失败');return}
|
||||
const d=await r.json();
|
||||
// Format execution results
|
||||
let output='';
|
||||
if(d.results){
|
||||
for(const[sid,res]of Object.entries(d.results)){
|
||||
output+=`\n── 服务器 #${sid} ──\n`;
|
||||
if(typeof res==='object'&&res.results){res.results.forEach(r=>{output+=`$ ${r.command||''}\n${r.stdout||''}${r.stderr?'STDERR: '+r.stderr+'\n':''}退出码: ${r.exit_code}\n`})}
|
||||
else{output+=JSON.stringify(res,null,2)}
|
||||
}
|
||||
}else{output=JSON.stringify(d,null,2)}
|
||||
el.textContent=output||'无输出';
|
||||
toast(d.status==='success'?'success':'warning','脚本执行完成');
|
||||
}
|
||||
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
loadScripts()
|
||||
</script>
|
||||
|
||||
+307
-49
@@ -1,38 +1,94 @@
|
||||
<!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>
|
||||
<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>
|
||||
<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 bg-brand/10 text-brand-light text-sm font-medium 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>
|
||||
<!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" 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" 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"><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><button onclick="showAddServer()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">+ 添加</button></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">
|
||||
<select id="categoryFilter" onchange="this.dataset.loaded='';loadServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部分类</option>
|
||||
</select>
|
||||
<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="loadServers()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
<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="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></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">分类</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="8" 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>
|
||||
<!-- Batch Action Bar -->
|
||||
<div id="batchBar" class="hidden mt-3 bg-brand/10 border border-brand/30 rounded-xl px-5 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-brand-light text-sm font-medium">已选择 <span id="selectedCount">0</span> 台服务器</span>
|
||||
<button onclick="clearSelection()" class="text-slate-400 hover:text-white text-xs underline">取消选择</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="batchPush()" class="px-4 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">批量推送</button>
|
||||
<button onclick="batchDelete()" class="px-4 py-1.5 bg-red-600/80 hover:bg-red-600 text-white text-sm rounded-lg transition">批量删除</button>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 overflow-y-auto py-8">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
|
||||
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
|
||||
<input type="hidden" id="editServerId">
|
||||
<!-- Basic -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">基本</div>
|
||||
<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><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>
|
||||
@@ -40,64 +96,266 @@
|
||||
<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 id="srvKeyPathRow"><label class="block text-slate-400 text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
|
||||
<!-- Agent -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">Agent</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="/www/wwwroot/"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">Agent端口</label><input id="srvAgentPort" type="number" value="8601" 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">Agent API Key</label><div class="flex items-center gap-2"><input id="srvAgentKey" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局API Key"><button type="button" onclick="generateAgentKey()" class="px-2 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg shrink-0 transition" title="自动生成密钥">🔑</button></div></div>
|
||||
</div>
|
||||
<!-- Organization -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
|
||||
<div class="grid grid-cols-3 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><select id="srvPlatform" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 —</option></select></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">节点</label><select id="srvNode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 —</option></select></div>
|
||||
</div>
|
||||
<!-- Other -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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><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>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
let selectedServerId = null;
|
||||
let _serversCache = {};
|
||||
initLayout('servers');
|
||||
let selectedServerId=null;
|
||||
let _serversCache={};
|
||||
let _currentDetailTab='info';
|
||||
let _selectedIds=new Set();
|
||||
|
||||
async function loadServers(){
|
||||
try{
|
||||
const res=await apiFetch(API+'/servers/');
|
||||
// Clear selection on reload (server list may have changed)
|
||||
_selectedIds.clear();
|
||||
const category=document.getElementById('categoryFilter').value;
|
||||
const url=API+'/servers/'+(category?'?category='+encodeURIComponent(category):'');
|
||||
const res=await apiFetch(url);
|
||||
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} 台`;
|
||||
|
||||
// Populate category filter from server data (only on first load)
|
||||
if(!document.getElementById('categoryFilter').dataset.loaded){
|
||||
const categories=[...new Set(servers.map(s=>s.category).filter(Boolean))].sort();
|
||||
const sel=document.getElementById('categoryFilter');
|
||||
const currentVal=sel.value;
|
||||
sel.innerHTML='<option value="">全部分类</option>'+categories.map(c=>`<option value="${esc(c)}" ${c===currentVal?'selected':''}>${esc(c)}</option>`).join('');
|
||||
sel.dataset.loaded='1';
|
||||
}
|
||||
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}">
|
||||
if(!servers.length){tbody.innerHTML='<tr><td colspan="8" 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 ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
|
||||
<td class="px-4 py-3" onclick="event.stopPropagation()"><input type="checkbox" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand srv-chk" data-id="${s.id}" onchange="toggleSelect(${s.id},this.checked)" ${_selectedIds.has(s.id)?'checked':''}></td>
|
||||
<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="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>
|
||||
<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>'}
|
||||
document.getElementById('selectAll').checked=false;
|
||||
updateBatchBar();
|
||||
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="8" class="px-4 py-8 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
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 apiFetch(API+'/servers/'+id,{method:'DELETE'});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')}
|
||||
|
||||
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)
|
||||
// Agent may send cpu_percent or cpu_usage — normalize both
|
||||
const sys=s.system_info;
|
||||
if(sys&&typeof sys==='object'){
|
||||
const cpu=sys.cpu_percent??sys.cpu_usage??null;
|
||||
const mem=sys.memory_percent??sys.mem_usage??sys.mem_percent??null;
|
||||
const disk=sys.disk_percent??sys.disk_usage??null;
|
||||
document.getElementById('siCPU').textContent=(cpu!=null?cpu.toFixed(1)+'%':'--');
|
||||
document.getElementById('siCPU').className='text-2xl font-bold '+(cpu>80?'text-red-400':cpu>60?'text-yellow-400':'text-green-400');
|
||||
document.getElementById('siMem').textContent=(mem!=null?mem.toFixed(1)+'%':'--');
|
||||
document.getElementById('siMem').className='text-2xl font-bold '+(mem>80?'text-red-400':mem>60?'text-yellow-400':'text-green-400');
|
||||
document.getElementById('siDisk').textContent=(disk!=null?disk.toFixed(1)+'%':'--');
|
||||
document.getElementById('siDisk').className='text-2xl font-bold '+(disk>80?'text-red-400':disk>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;const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','服务器已删除');else toast('error','删除失败');if(selectedServerId===id)hideDetail();loadServers()}
|
||||
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc','srvKeyPath','srvAgentKey'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('srvAgentPort').value='8601';document.getElementById('srvPlatform').value='';document.getElementById('srvNode').value='';toggleAuthFields();loadOrgSelects();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('srvKeyPath').value=s.ssh_key_path||'';document.getElementById('srvAgentPort').value=s.agent_port||8601;document.getElementById('srvAgentKey').value=s.agent_api_key||'';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';toggleAuthFields();loadOrgSelects().then(()=>{document.getElementById('srvPlatform').value=s.platform_id||'';document.getElementById('srvNode').value=s.node_id||''});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'});
|
||||
function toggleAuthFields(){const isKey=document.getElementById('srvAuth').value==='key';document.getElementById('srvPasswordRow').style.display=isKey?'none':'';document.getElementById('srvKeyPathRow').style.display=isKey?'':'none'}
|
||||
document.getElementById('srvAuth').addEventListener('change',toggleAuthFields);
|
||||
async function loadOrgSelects(){
|
||||
try{
|
||||
const [pr,nr]=await Promise.all([apiFetch(API+'/assets/platforms'),apiFetch(API+'/assets/nodes')]);
|
||||
if(pr){const plats=await pr.json();document.getElementById('srvPlatform').innerHTML='<option value="">— 无 —</option>'+plats.map(p=>`<option value="${p.id}">${esc(p.name)}</option>`).join('')}
|
||||
if(nr){const nodes=await nr.json();document.getElementById('srvNode').innerHTML='<option value="">— 无 —</option>'+nodes.map(n=>`<option value="${n.id}">${esc(n.name)}</option>`).join('')}
|
||||
}catch(e){}
|
||||
}
|
||||
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};
|
||||
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,agent_port:parseInt(document.getElementById('srvAgentPort').value)||8601,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null,platform_id:document.getElementById('srvPlatform').value?parseInt(document.getElementById('srvPlatform').value):null,node_id:document.getElementById('srvNode').value?parseInt(document.getElementById('srvNode').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()
|
||||
else{body.ssh_key_path=document.getElementById('srvKeyPath').value||null}
|
||||
const ak=document.getElementById('srvAgentKey').value;if(ak)body.agent_api_key=ak;
|
||||
if(!body.name||!body.domain){toast('warning','名称和地址必填');return}
|
||||
if(id){const r=await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已更新');else toast('error','更新失败')}else{const r=await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已添加');else toast('error','添加失败')}
|
||||
hideServerModal();loadServers();
|
||||
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
|
||||
}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
// ── Generate Agent API Key ──
|
||||
async function generateAgentKey(){
|
||||
const id=document.getElementById('editServerId').value;
|
||||
if(!id){toast('warning','请先保存服务器,再生成Agent密钥');return}
|
||||
if(!confirm('将为该服务器生成新的Agent API Key,旧密钥将失效。继续?'))return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/'+id+'/agent-key',{method:'POST',headers:apiHeadersJSON()});
|
||||
if(!r)return;
|
||||
const data=await r.json();
|
||||
document.getElementById('srvAgentKey').value=data.agent_api_key||'';
|
||||
toast('success','Agent API Key已生成,请保存服务器以应用');
|
||||
}catch(e){toast('error','生成密钥失败')}
|
||||
}
|
||||
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'})});
|
||||
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(3)')?.textContent.toLowerCase()||'';
|
||||
const addr=r.querySelector('td:nth-child(4)')?.textContent.toLowerCase()||'';
|
||||
const cat=r.querySelector('td:nth-child(5)')?.textContent.toLowerCase()||'';
|
||||
r.style.display=(!q||name.includes(q)||addr.includes(q)||cat.includes(q))?'':'none';
|
||||
});
|
||||
});
|
||||
|
||||
// ── Batch Operations ──
|
||||
function toggleSelect(id,checked){
|
||||
if(checked) _selectedIds.add(id); else _selectedIds.delete(id);
|
||||
updateBatchBar();
|
||||
}
|
||||
function toggleSelectAll(checked){
|
||||
const checkboxes=document.querySelectorAll('.srv-chk');
|
||||
checkboxes.forEach(cb=>{
|
||||
const id=parseInt(cb.dataset.id);
|
||||
cb.checked=checked;
|
||||
if(checked) _selectedIds.add(id); else _selectedIds.delete(id);
|
||||
});
|
||||
updateBatchBar();
|
||||
}
|
||||
function clearSelection(){
|
||||
_selectedIds.clear();
|
||||
document.querySelectorAll('.srv-chk').forEach(cb=>cb.checked=false);
|
||||
document.getElementById('selectAll').checked=false;
|
||||
updateBatchBar();
|
||||
}
|
||||
function updateBatchBar(){
|
||||
const bar=document.getElementById('batchBar');
|
||||
const count=_selectedIds.size;
|
||||
document.getElementById('selectedCount').textContent=count;
|
||||
bar.classList.toggle('hidden',count===0);
|
||||
// Update select-all state
|
||||
const total=document.querySelectorAll('.srv-chk').length;
|
||||
document.getElementById('selectAll').checked=total>0&&count===total;
|
||||
}
|
||||
function batchPush(){
|
||||
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
|
||||
// Store selected IDs to sessionStorage and redirect to push page
|
||||
sessionStorage.setItem('batchPushIds',JSON.stringify([..._selectedIds]));
|
||||
window.location.href='/app/push.html';
|
||||
}
|
||||
async function batchDelete(){
|
||||
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
|
||||
const ids=[..._selectedIds];
|
||||
if(!confirm(`确定删除 ${ids.length} 台服务器?此操作不可撤销。`))return;
|
||||
let ok=0,fail=0;
|
||||
for(const id of ids){
|
||||
const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)ok++;else fail++;
|
||||
}
|
||||
toast(ok?'success':'error',`删除完成: ${ok} 成功${fail?', '+fail+' 失败':''}`);
|
||||
clearSelection();
|
||||
loadServers();
|
||||
if(selectedServerId&&ids.includes(selectedServerId))hideDetail();
|
||||
}
|
||||
|
||||
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();
|
||||
</script>
|
||||
</body></html>
|
||||
+331
-32
@@ -1,43 +1,100 @@
|
||||
<!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 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/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>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
|
||||
<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>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
|
||||
<div class="mb-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full space-y-4">
|
||||
|
||||
<div class="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>
|
||||
|
||||
<!-- ── Account Security ── -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">账户安全</h2>
|
||||
|
||||
<!-- TOTP 2FA -->
|
||||
<div class="mb-5 pb-5 border-b border-slate-800">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<span class="text-white text-sm font-medium">双因素认证 (TOTP)</span>
|
||||
<span id="totpStatusBadge" class="ml-2 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">检测中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP: Disabled state — show setup button -->
|
||||
<div id="totpSetupArea">
|
||||
<p class="text-slate-400 text-xs mb-3">启用双因素认证后,登录时需要输入验证器App生成的一次性验证码。</p>
|
||||
<div id="totpIdleActions">
|
||||
<button onclick="startTotpSetup()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">启用 TOTP</button>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Step 1: Show secret + QR -->
|
||||
<div id="totpStep1" class="hidden space-y-3">
|
||||
<div class="bg-slate-800 rounded-lg p-4 text-center">
|
||||
<p class="text-slate-400 text-xs mb-2">使用验证器App扫描以下二维码或手动输入密钥</p>
|
||||
<canvas id="totpQrCanvas" class="mx-auto rounded border border-slate-700" width="200" height="200"></canvas>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">密钥 (手动输入)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all"></code>
|
||||
<button onclick="copyTotpSecret()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">输入验证码确认启用</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="totpVerifyCode" maxlength="6" placeholder="6位数字" class="w-32 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono text-center tracking-widest">
|
||||
<button onclick="verifyAndEnableTotp()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">确认启用</button>
|
||||
<button onclick="cancelTotpSetup()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP: Enabled state — show disable button -->
|
||||
<div id="totpEnabledArea" class="hidden">
|
||||
<p class="text-slate-400 text-xs mb-3">双因素认证已启用。禁用后登录将不再需要验证码。</p>
|
||||
<button onclick="disableTotp()" class="px-4 py-2 bg-red-900/50 hover:bg-red-900 text-red-300 text-sm rounded-lg transition">禁用 TOTP</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div>
|
||||
<span class="text-white text-sm font-medium">修改密码</span>
|
||||
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。</p>
|
||||
<div class="space-y-2 max-w-sm">
|
||||
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<button onclick="changePassword()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand Settings -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<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>
|
||||
|
||||
<!-- API Key (read-only with copy) -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-white font-medium mb-4">API 密钥</h2>
|
||||
<div id="apiKeySection" 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">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<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">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<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>
|
||||
@@ -48,19 +105,25 @@
|
||||
<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 src="/app/layout.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
||||
<script>
|
||||
initLayout('settings');
|
||||
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:'心跳间隔(秒)'}},
|
||||
infra:{keys:['db_pool_size','db_max_overflow','heartbeat_timeout','redis_url'],labels:{db_pool_size:'连接池大小',db_max_overflow:'最大溢出',heartbeat_timeout:'心跳超时(秒)',redis_url:'Redis URL'}},
|
||||
telegram:{keys:['telegram_bot_token','telegram_chat_id'],labels:{telegram_bot_token:'Bot Token',telegram_chat_id:'Chat ID'}},
|
||||
};
|
||||
let _allSettings={};
|
||||
let _currentAdminId=null;
|
||||
let _totpSecret='';
|
||||
let _totpUri='';
|
||||
|
||||
// ── Load settings + admin info ──
|
||||
async function loadSettings(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/settings/');if(!r)return;
|
||||
@@ -68,24 +131,260 @@
|
||||
_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=>`<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(_allSettings[key]||'')}" 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('');
|
||||
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('');
|
||||
}
|
||||
|
||||
// API Key section (read-only + copy)
|
||||
renderApiKeySection();
|
||||
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function renderApiKeySection(){
|
||||
const val=_allSettings['api_key']||'';
|
||||
const isMasked=val.includes('***')||val.includes('...');
|
||||
const el=document.getElementById('apiKeySection');
|
||||
if(isMasked){
|
||||
el.innerHTML=`
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
|
||||
<span id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none">${esc(val)}</span>
|
||||
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||||
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
<span class="text-slate-600 text-xs">🔒 不可修改</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||||
}else{
|
||||
el.innerHTML=`
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
|
||||
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all">${esc(val)}</code>
|
||||
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
let _apiKeyRaw='';
|
||||
|
||||
async function toggleApiKeyVisibility(){
|
||||
const btn=document.getElementById('apiKeyToggleBtn');
|
||||
const display=document.getElementById('apiKeyDisplay');
|
||||
if(btn.textContent.trim()==='显示'){
|
||||
try{
|
||||
const r=await apiFetch(API+'/settings/api-key/reveal',{method:'POST'});if(!r)return;
|
||||
const data=await r.json();
|
||||
_apiKeyRaw=data.value||'';
|
||||
display.textContent=_apiKeyRaw||'(空)';
|
||||
display.className='flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all';
|
||||
btn.textContent='隐藏';
|
||||
}catch(e){}
|
||||
}else{
|
||||
display.textContent=_allSettings['api_key']||'';
|
||||
display.className='flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none';
|
||||
btn.textContent='显示';
|
||||
_apiKeyRaw='';
|
||||
}
|
||||
}
|
||||
|
||||
function copyApiKey(){
|
||||
if(_apiKeyRaw){copyText(_apiKeyRaw);return}
|
||||
// Read from display element (works for both masked and non-masked states)
|
||||
const display=document.getElementById('apiKeyDisplay');
|
||||
const text=display.textContent||'';
|
||||
if(text&&text!=='(空)'&&!text.includes('***')&&!text.includes('...')){
|
||||
copyText(text);
|
||||
}else{
|
||||
// Need to reveal first
|
||||
toast('info','请先点击"显示"按钮查看完整密钥');
|
||||
}
|
||||
}
|
||||
|
||||
function copyTotpSecret(){
|
||||
const el=document.getElementById('totpSecretDisplay');
|
||||
copyText(el.textContent);
|
||||
}
|
||||
|
||||
function copyText(text){
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(text).then(()=>toast('success','已复制到剪贴板')).catch(()=>fallbackCopy(text));
|
||||
}else{
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text){
|
||||
const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';
|
||||
document.body.appendChild(ta);ta.select();
|
||||
try{document.execCommand('copy');toast('success','已复制到剪贴板')}catch(e){toast('error','复制失败')}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
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);
|
||||
const r=await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:v})});
|
||||
if(r&&r.ok)toast('success','设置已保存');else toast('error','保存失败');
|
||||
}
|
||||
|
||||
// ── TOTP Management ──
|
||||
async function loadTotpStatus(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/me');if(!r)return;
|
||||
const admin=await r.json();
|
||||
_currentAdminId=admin.id;
|
||||
updateTotpUI(admin.totp_enabled);
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function updateTotpUI(enabled){
|
||||
const badge=document.getElementById('totpStatusBadge');
|
||||
const setupArea=document.getElementById('totpSetupArea');
|
||||
const enabledArea=document.getElementById('totpEnabledArea');
|
||||
if(enabled){
|
||||
badge.textContent='已启用';
|
||||
badge.className='ml-2 text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400';
|
||||
setupArea.classList.add('hidden');
|
||||
enabledArea.classList.remove('hidden');
|
||||
}else{
|
||||
badge.textContent='未启用';
|
||||
badge.className='ml-2 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500';
|
||||
setupArea.classList.remove('hidden');
|
||||
enabledArea.classList.add('hidden');
|
||||
resetTotpSteps();
|
||||
}
|
||||
}
|
||||
|
||||
function resetTotpSteps(){
|
||||
document.getElementById('totpIdleActions').classList.remove('hidden');
|
||||
document.getElementById('totpStep1').classList.add('hidden');
|
||||
document.getElementById('totpVerifyCode').value='';
|
||||
}
|
||||
|
||||
async function startTotpSetup(){
|
||||
if(!_currentAdminId){toast('error','未获取用户信息');return}
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/totp/setup',{
|
||||
method:'POST',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({admin_id:_currentAdminId})
|
||||
});
|
||||
if(!r)return;
|
||||
const data=await r.json();
|
||||
if(!data.success){toast('error',data.reason||'设置失败');return}
|
||||
|
||||
_totpSecret=data.secret;
|
||||
_totpUri=data.uri;
|
||||
|
||||
// Show secret
|
||||
document.getElementById('totpSecretDisplay').textContent=data.secret;
|
||||
document.getElementById('totpIdleActions').classList.add('hidden');
|
||||
document.getElementById('totpStep1').classList.remove('hidden');
|
||||
|
||||
// Generate QR code
|
||||
try{
|
||||
new QRious({
|
||||
element:document.getElementById('totpQrCanvas'),
|
||||
value:data.uri,
|
||||
size:200,
|
||||
backgroundAlpha:0,
|
||||
foreground:'#e2e8f0',
|
||||
level:'M'
|
||||
});
|
||||
}catch(e){
|
||||
// QRious not loaded — fallback to text display
|
||||
document.getElementById('totpQrCanvas').style.display='none';
|
||||
}
|
||||
}catch(e){toast('error','TOTP设置请求失败')}
|
||||
}
|
||||
|
||||
async function verifyAndEnableTotp(){
|
||||
const code=document.getElementById('totpVerifyCode').value.trim();
|
||||
if(code.length!==6||!/^\d{6}$/.test(code)){toast('warning','请输入6位数字验证码');return}
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/totp/enable',{
|
||||
method:'POST',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({admin_id:_currentAdminId,totp_code:code})
|
||||
});
|
||||
if(!r)return;
|
||||
const data=await r.json();
|
||||
if(data.success){
|
||||
toast('success','TOTP已启用!下次登录需要验证码');
|
||||
updateTotpUI(true);
|
||||
}else{
|
||||
toast('error',data.message||data.reason||'验证码错误');
|
||||
}
|
||||
}catch(e){toast('error','启用失败')}
|
||||
}
|
||||
|
||||
async function disableTotp(){
|
||||
if(!confirm('确定要禁用双因素认证?禁用后登录将不再需要验证码。'))return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/totp/disable',{
|
||||
method:'POST',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({admin_id:_currentAdminId})
|
||||
});
|
||||
if(!r)return;
|
||||
const data=await r.json();
|
||||
if(data.success){
|
||||
toast('success','TOTP已禁用');
|
||||
updateTotpUI(false);
|
||||
}else{
|
||||
toast('error','禁用失败');
|
||||
}
|
||||
}catch(e){toast('error','禁用请求失败')}
|
||||
}
|
||||
|
||||
function cancelTotpSetup(){
|
||||
resetTotpSteps();
|
||||
}
|
||||
|
||||
// ── Change Password ──
|
||||
async function changePassword(){
|
||||
const current=document.getElementById('currentPassword').value;
|
||||
const newPw=document.getElementById('newPassword').value;
|
||||
const confirm=document.getElementById('confirmPassword').value;
|
||||
|
||||
if(!current||!newPw||!confirm){toast('warning','请填写所有密码字段');return}
|
||||
if(newPw.length<6){toast('warning','新密码至少6位');return}
|
||||
if(newPw!==confirm){toast('warning','两次输入的新密码不一致');return}
|
||||
|
||||
try{
|
||||
const r=await apiFetch(API+'/auth/password',{
|
||||
method:'PUT',headers:apiHeadersJSON(),
|
||||
body:JSON.stringify({current_password:current,new_password:newPw})
|
||||
});
|
||||
if(!r){toast('error','请求失败');return}
|
||||
if(r.ok){
|
||||
const data=await r.json();
|
||||
toast('success','密码已修改');
|
||||
document.getElementById('currentPassword').value='';
|
||||
document.getElementById('newPassword').value='';
|
||||
document.getElementById('confirmPassword').value='';
|
||||
}else{
|
||||
const err=await r.json().catch(()=>({}));
|
||||
toast('error',err.detail||'修改失败');
|
||||
}
|
||||
}catch(e){toast('error','修改请求失败')}
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
// ── Init ──
|
||||
loadSettings();
|
||||
loadTotpStatus();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+154
-154
@@ -1,193 +1,193 @@
|
||||
<!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" data-sidebar></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="/app/layout.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 urlParams = new URLSearchParams(window.location.search);
|
||||
const serverId = urlParams.get('server_id');
|
||||
initLayout('servers'); // terminal is under servers nav
|
||||
// ── 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 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,
|
||||
});
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||
term.open(document.getElementById('terminal'));
|
||||
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
// Initial fit
|
||||
setTimeout(() => fitAddon.fit(), 50);
|
||||
window.addEventListener('resize', () => { try { fitAddon.fit(); } catch(e){} });
|
||||
|
||||
window.addEventListener('resize', () => fitAddon.fit());
|
||||
// ── WebSocket ──
|
||||
let ws = null;
|
||||
|
||||
term.onData(data => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'DATA', data }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function connectTerminal() {
|
||||
function connect() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) { doLogout(); return; }
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${proto}//${window.location.host}/ws/terminal/${serverId}?token=${token}`;
|
||||
if (!token) { window.location.href = '/app/login.html'; return; }
|
||||
|
||||
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 === 4003) {
|
||||
term.write('\r\n\x1b[31m⚠ 授权失败: ' + (ev.reason || '服务器SSH凭据未配置') + '\x1b[0m\r\n');
|
||||
} 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>
|
||||
+4
-1
@@ -355,7 +355,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$configContent .= "define('APP_NAME', 'Nexus');\n";
|
||||
$configContent .= "define('APP_VERSION', '6.0.0');\n";
|
||||
$configContent .= "define('SESSION_LIFETIME', 28800);\n";
|
||||
$configContent .= "define('BT_WWWROOT', '/www/wwwroot');\n";
|
||||
$configContent .= "define('DEPLOY_ROOT', '" . addslashes(dirname(__DIR__)) . "');\n";
|
||||
$configContent .= "\ndate_default_timezone_set('" . addslashes($timezone) . "');\n";
|
||||
|
||||
file_put_contents($configPath, $configContent);
|
||||
@@ -377,6 +377,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$envContent .= "NEXUS_ENCRYPTION_KEY=\n\n";
|
||||
$envContent .= "# Redis\n";
|
||||
$envContent .= "NEXUS_REDIS_URL=$redisUrl\n\n";
|
||||
$envContent .= "# Deployment\n";
|
||||
$envContent .= "NEXUS_DEPLOY_PATH=" . addslashes(dirname(__DIR__)) . "\n";
|
||||
$envContent .= "NEXUS_CORS_ORIGINS=$siteUrl,http://localhost:8600\n\n";
|
||||
$envContent .= "# SSH\n";
|
||||
$envContent .= "NEXUS_SSH_STRICT_HOST_CHECKING=false\n\n";
|
||||
$envContent .= "# Health Check\n";
|
||||
|
||||
Reference in New Issue
Block a user