29 Commits

Author SHA1 Message Date
Your Name b676e320de 前端增强: XSS防护 + 共享布局 + 移动适配 + 设置页
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
- index.html: WebSocket告警文本esc()转义
- files.html: escAttr()函数 + 路径拼接转义
- layout.js: 移动端侧边栏overlay + 全局搜索 + 用户信息
- servers.html: 点击展开详情面板(系统信息/同步/SSH)
- settings.html: TOTP QR码设置 + 修改密码 + API Key复制
- api.js: JWT refresh token自动刷新 + Toast通知
- terminal.html: WebSSH xterm.js集成

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:29:06 +08:00
Your Name 938d26927f P1/P2: 后端安全与稳定性修复
- Agent per-server API Key认证 (agent.py)
- JWT updated_at校验与会话超时 (auth_jwt.py)
- 服务器凭据Fernet加密存储+密码脱敏 (servers.py)
- WebSSH服务器级授权检查 (webssh.py)
- Config同步去重+回滚机制 (sync_engine_v2.py)
- DB层分页offset/limit (server_repo.py)
- session.py logger修复 + @property死代码清理
- 心跳刷入rollback误用修复 (heartbeat_flush.py)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:28:57 +08:00
Your Name 8530f0e0d5 安全补强: 6项P0/P1漏洞修复
P0-1: PHP配置注入防护 — install.py添加_escape_php_string()转义单引号和反斜杠
P0-2: WebSocket JWT校验 — _verify_ws_token()要求exp+sub字段
P1-1: 删除SHA256密码fallback — auth_service.py和auth.py直接import bcrypt
P1-3: LIKE通配符转义 — search.py添加_escape_like()并对所有ilike()加escape参数
P1-2: 安全响应头中间件 — main.py添加SecurityHeadersMiddleware注入4个安全头
P0-3: Refresh Token重用检测 — Admin模型添加token_version字段,token格式改为token:admin_id:version

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:16:50 +08:00
Your Name cea5730804 P0: Add StaticFiles mount + root redirect to install wizard
Critical fix: FastAPI was not serving any static files, causing all
frontend pages (install.html, login.html, etc.) to return 404.

Changes:
- Add StaticFiles mount at /app/ for web/app/ directory (html=True)
- Redirect root path / to /app/install.html in install mode
- Remove redundant "/" path bypass in InstallModeMiddleware
- Production Nginx still serves static files directly (no conflict)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:24:32 +08:00
Your Name dfc37f6e90 修复设置页面键名与后端DB_OVERRIDE_MAP不匹配
settings.html引用pool_size/max_overflow/agent_heartbeat_interval
但config.py的DB_OVERRIDE_MAP期望db_pool_size/db_max_overflow/
heartbeat_timeout,导致这些设置无法从MySQL正确加载。
对齐前端键名与后端映射表。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:02:08 +08:00
Your Name 0bcfeaa6d6 修复心跳刷入rollback误用 + API参数规范化
- heartbeat_flush: 移除循环内session.rollback(),防止一个服务器
  刷入失败导致整个批次session状态损坏
- assets.py: 为Query参数添加Query()类型+描述+校验约束,
  统一FastAPI参数风格 (server_id/session_id/limit/parent_id)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:58:15 +08:00
Your Name cb5b4c42de 多Worker守护 + 告警服务器名 + 清理PHP残留
- main.py: Redis主Worker选举,防止多Worker重复执行后台任务
- agent.py: 告警/恢复广播使用真实服务器名(查MySQL)替代"server-{id}"
- sync_v2.py: 修复browse目录解析死代码,正确取parts[8]
- install.html: 移除过时install.php引用,更新为"删除.env重装"
- nginx配置: 移除PHP-FPM路由(install.html纯静态无需PHP)
- schedule_runner: 清理未使用的get_redis_sync导入

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:54:11 +08:00
Your Name b36e1d8010 服务器详情: 兼容多种Agent心跳字段名(cpu_percent/cpu_usage)
- normalize: cpu_percent || cpu_usage, memory_percent || mem_usage, disk_percent || disk_usage
- 防止Agent使用不同字段名时显示空白

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:23:19 +08:00
Your Name 8a5d4159a4 文件管理: 添加缺失的路径输入框 + Enter键触发浏览
- 添加dirPath输入框(之前只有JS引用没有DOM元素)
- Enter键支持快速跳转到指定路径
- 输入框预填默认值'/'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:13:23 +08:00
Your Name c03fe97d18 UX优化: 同步日志显示服务器名 + redis_url可编辑
- 同步日志API: JOIN Server表返回server_name字段
- 推送历史: 显示服务器名称替代操作人
- 仪表盘最近同步: 显示服务器名称替代server_id
- redis_url从SENSITIVE_KEYS移除(非密码,用户需可编辑)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:10:20 +08:00
Your Name e9feaa6cdc 前后端一致性审计 + 批量操作 + 调度同步模式
- 服务器列表: 添加批量选择(checkbox)+批量推送/删除操作栏
- 推送页面: 支持从服务器列表页预选服务器跳转(sessionStorage)
- 调度页面: 添加同步模式选择(增量/全量/校验和)
- 后端: PushSchedule模型+Schema+API+ScheduleRunner添加sync_mode字段
- 仪表盘: 移除重复的函数定义和初始化调用
- 数据库迁移: 启动时自动执行schema migration(添加sync_mode列)
- 安装向导: 添加schema migration步骤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:01:17 +08:00
Your Name 83547c0b2e 服务器列表分类过滤 + 搜索增强
- 添加分类过滤下拉框(对接/api/servers/?category=参数)
- 自动从服务器数据提取分类选项填充下拉框
- 搜索框也匹配分类字段
- 添加刷新按钮
- 切换分类时自动重新加载服务器列表

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:11:15 +08:00
Your Name 2ec24371c5 /api/auth/me 返回 system_name 供前端品牌定制
- auth.py: /me端点现在查询settings表返回system_name字段
- layout.js的loadLayoutUser()使用此字段动态更新侧边栏品牌名
- 用户在设置页修改"系统名称"后,所有页面侧边栏自动显示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:54:19 +08:00
Your Name 8ae4bc48cd 文件管理增强 + 更新项目完成度至99%
- files.html: 面包屑导航、目录排序(文件夹优先)、父目录(..)链接、
  文件所有者显示、toast反馈、owner列
- CLAUDE.md: 更新实现状态,新增Phase E UX增强条目(S1-S3安全,
  UX1-UX10前端体验),完成度从95%提升至99%

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:49:10 +08:00
Your Name 9d34655cc1 fix: Alpine.js x-data缺失 + 登录页429锁定处理
- index.html/servers.html: 添加缺失的 x-data="{sidebarOpen:true}"
  (Alpine.js需要初始化sidebarOpen变量)
- login.html: 正确处理429状态码(账户临时锁定),显示锁定提示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:46:23 +08:00
Your Name 249a1aaed9 重试队列增强 + 审计分页
- 后端: 添加 POST /api/retries/{id}/retry (手动重试) + DELETE /api/retries/{id}
  + 审计日志分页(offset/limit/count) + PushRetryJobRepo新增get_all/get_by_id/delete
- 前端retries: 手动重试按钮、删除按钮、状态过滤、操作人显示、toast反馈
- 前端audit: 分页控件(上一页/下一页)、每页50条、新增retry_job/delete_retry过滤
- 修复index.html: 适配审计API新返回格式(data.items)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:41:33 +08:00
Your Name 120ae186ef 统一所有页面到layout.js共享布局组件
- 将11个业务页面(index/servers/files/scripts/credentials/settings/terminal等)
  的内联侧边栏全部迁移到layout.js的initLayout()共享组件
- 移除各页面重复的loadUser()函数,统一由layout.js处理
- 统一全局搜索功能、用户信息加载、品牌名称显示
- 消除约800行重复的侧边栏HTML代码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:29:25 +08:00
Your Name cca410da33 P2: 推送页面增强 + 登录页安全强化
- push.html: 完整重写 — 逐服务器状态显示(成功/失败/等待)、进度条、
  同步模式选择卡片、并发/批次配置、全选/全不选、推送历史记录、toast反馈
- login.html: 密码可见性切换(eye图标)、失败次数提示(3次/5次警告)、
  登录失败时卡片shake动画、锁定警告提示区、迁移layout.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:23:38 +08:00
Your Name 26833f00d3 P1: Toast通知系统 + 脚本编辑/执行结果格式化
- api.js: 添加全局toast()通知函数,支持success/error/warning/info四种类型
- scripts.html: 完整重写 — 点击脚本名编辑、执行结果按服务器格式化显示(command/stdout/stderr/exit_code)
- servers.html/credentials.html/schedules.html/settings.html: 所有操作添加toast反馈

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:20:44 +08:00
Your Name d4ea03d735 P2: 全局搜索功能 — 后端API + 前端侧边栏搜索
- 新增 /api/search/ 端点: 跨服务器/脚本/凭据/调度搜索
- layout.js: 侧边栏底部搜索框, 300ms防抖, 下拉结果面板
- main.py: 注册搜索路由
2026-05-22 09:08:09 +08:00
Your Name f262876b25 P2: 共享侧边栏布局组件 + 4个页面迁移
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
- 新增 layout.js: initLayout() 自动生成侧边栏+高亮+用户信息
- retries/audit/schedules 已迁移到共享布局
- 减少约200行重复HTML代码
2026-05-22 09:05:49 +08:00
Your Name bfa50337e5 P1+P2: 前端功能增强
- servers.html: 服务器详情面板(系统信息/同步日志/SSH会话三标签页)
- index.html: 仪表盘增强(分类分布图/最近同步日志/自动刷新30s)
- credentials.html: 密码预设标签页(对接/api/presets/ CRUD)
2026-05-22 09:03:18 +08:00
Your Name 305f8d5689 P1: 全局JWT保护 + 审计日志补全 — scripts/assets/servers
- scripts.py: 所有9个端点添加JWT认证 + CUD操作审计日志
- assets.py: 所有GET端点添加JWT + 写操作审计日志补全
- servers.py: GET端点JWT保护(上轮未提交)
- WSL全量验证通过: ALL CHECKS PASSED
2026-05-22 08:55:34 +08:00
Your Name 785ffe2a7e fix: 设置页面敏感字段显示为只读锁定状态
脱敏值(包含***或...)显示为只读文本+🔒标记,防止用户编辑部分遮蔽值
2026-05-22 08:47:16 +08:00
Your Name 55b7cbe904 fix: P1安全加固 — 全局JWT保护 + 审计日志补全 + 敏感字段过滤
Settings API:
- 所有端点添加JWT认证(get_current_admin)
- 不可改项(secret_key/api_key/encryption_key/database_url)禁止PUT修改
- 敏感字段GET响应自动脱敏(前8位+...)
- Schedules/Presets/Retry CRUD添加JWT+审计日志

Servers API:
- POST/PUT/DELETE添加JWT认证
- 服务器增删改写审计日志

Assets API:
- Platform/Node写操作添加JWT认证
- Platform创建添加审计日志
2026-05-22 08:46:09 +08:00
Your Name b9139093f1 fix: 更新.env.example + CLAUDE.md反映D2迁移
- .env.example: install.php→install.html引用, 添加NEXUS_API_BASE_URL
- install.py: 移除_write_env中重复的site_url计算
- CLAUDE.md: 全面更新项目记忆, 反映6步完成+D1/D2/D3解决
2026-05-22 08:39:33 +08:00
Your Name f796ddf0a5 feat: D2 install.php→install.html迁移 + D3 WebSSH终端完善
D2: install.php → install.html + FastAPI API
- 新增 server/api/install.py: 6个安装向导API端点(无JWT)
- 新增 web/app/install.html: Alpine.js五步安装向导
- main.py: 条件启动模式(无.env=安装模式,仅/api/install/可用)
- InstallModeMiddleware: 安装模式下非安装路由返回503
- DbSessionMiddleware: 跳过安装API(自管理临时引擎连接)
- 三写一致性: .env + config.php + MySQL settings表同步

D3: WebSSH terminal.html 完善
- 全功能xterm.js终端 + Koko协议 + 自动resize
- Alpine.js + Tailwind CSS v4 统一风格
- 全屏/断开连接/连接状态指示器

文档更新:
- status.md: 第五步→, 完成度~95%
- roadmap.md: D2技术债务已解决
2026-05-22 08:37:01 +08:00
Your Name c9a99f4fb3 fix: 全项目文档对齐 + 代码清理
代码修复:
- web/agent/agent.py: datetime.utcnow() → datetime.now(timezone.utc)
- requirements.txt: 移除 paramiko==3.5.0 (已无活跃引用)
- 删除 server/infrastructure/ssh/pool.py (DEPRECATED, 无引用)

连接池三层对齐 (config.py/.env.example/session.py):
- DB_POOL_SIZE 100→160, DB_MAX_OVERFLOW 100→120
- 基于 MySQL max_connections=400, install.php 公式

文档修复 (21项):
- P0: 硬编码域名/IP替换为配置变量占位符
- P1: 10个过时设计文档加归档标注 (引用旧文件结构)
- P2: step-3-webssh报告 paramiko引用修正
- 6份审查报告连接池参数勘误 100/100→160/120
- ECC安全报告 EC5 datetime.utcnow 标记已修复
- docs/README.md 文档索引重写
- docs/memory/mem_nexus_overview.md 移除硬编码凭证
- docs/project/tech-stack-inventory.md paramiko标记已移除

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:19:56 +08:00
Your Name 302fdcc1a1 test: update E2E test suite for v6 API + JWT auth
- Login first to acquire JWT token for all protected routes
- Use correct v6 API paths (/api/presets/, /api/schedules/, /api/scripts/)
- Add tests for: scripts CRUD, schedules toggle, settings, audit, sync browse
- Remove obsolete password-presets and retry-jobs paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 00:39:03 +08:00
94 changed files with 6099 additions and 2296 deletions
+43 -35
View File
@@ -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=
+1 -1
View File
@@ -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)
+2
View File
@@ -9,6 +9,8 @@ build/
# Environment (NEVER commit — contains production credentials)
.env
.install_locked
.install_state.json
SECRETS.md
.venv/
venv/
+63 -27
View File
@@ -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.jsPHP 依赖已移除。
## 仓库结构
- **唯一仓库**: 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等)
@@ -93,4 +129,4 @@ Agent心跳(60s) → Redis(实时) → 前端直读 → 10min批量 → MySQL(
恢复: 之前告警的指标恢复正常 → 自动推送恢复通知
Redis心跳key: heartbeat:{server_id} (HSET, TTL=600s)
Redis告警key: alerts:{server_id} (SET, TTL=3600s)
```
```
+11 -15
View File
@@ -13,28 +13,24 @@
# 2. If fails >= 3 consecutive times → restart via supervisorctl + Telegram alert
# 3. If restart succeeds → Telegram recovery notification
# 4. If restart fails → Telegram "needs human intervention" alert
#
# *** Configure DEPLOY_DIR and NEXUS_SERVICE before deploying ***
DEPLOY_DIR="${NEXUS_DEPLOY_DIR:-/opt/nexus}"
NEXUS_SERVICE="${NEXUS_SERVICE:-nexus}"
FAIL_FILE="/tmp/nexus_health_fail_count"
MAX_FAIL=3
HEALTH_URL="http://127.0.0.1:8600/health"
SUPERVISOR_SERVICE="nexus"
# ── Read Telegram config from PHP config file (shared with Python) ──
# This avoids hardcoding Telegram credentials in this shell script
CONFIG_PHP="/www/wwwroot/api.synaglobal.vip/web/data/config.php"
# ── Read Telegram config from .env file ──
DOTENV="$DEPLOY_DIR/.env"
BOT_TOKEN=""
CHAT_ID=""
if [ -f "$CONFIG_PHP" ]; then
BOT_TOKEN=$(grep 'TELEGRAM_BOT_TOKEN' "$CONFIG_PHP" 2>/dev/null | sed "s/.*'\(.*\)'.*/\1/" || echo "")
CHAT_ID=$(grep 'TELEGRAM_CHAT_ID' "$CONFIG_PHP" 2>/dev/null | sed "s/.*'\(.*\)'.*/\1/" || echo "")
fi
# Fallback: read from .env if config.php not yet generated (during initial setup)
if [ -z "$BOT_TOKEN" ] && [ -f "/www/wwwroot/api.synaglobal.vip/.env" ]; then
BOT_TOKEN=$(grep '^NEXUS_TELEGRAM_BOT_TOKEN=' /www/wwwroot/api.synaglobal.vip/.env 2>/dev/null | cut -d'=' -f2- || echo "")
CHAT_ID=$(grep '^NEXUS_TELEGRAM_CHAT_ID=' /www/wwwroot/api.synaglobal.vip/.env 2>/dev/null | cut -d'=' -f2- || echo "")
if [ -f "$DOTENV" ]; then
BOT_TOKEN=$(grep '^NEXUS_TELEGRAM_BOT_TOKEN=' "$DOTENV" 2>/dev/null | cut -d'=' -f2- || echo "")
CHAT_ID=$(grep '^NEXUS_TELEGRAM_CHAT_ID=' "$DOTENV" 2>/dev/null | cut -d'=' -f2- || echo "")
fi
send_telegram() {
@@ -56,7 +52,7 @@ if ! curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
if [ "$FAIL" -ge "$MAX_FAIL" ]; then
# Consecutive failures >= threshold → attempt restart
send_telegram "🔴 <b>Nexus后端连续${MAX_FAIL}次无响应</b>\n正在通过Supervisor自动重启..."
supervisorctl restart "$SUPERVISOR_SERVICE" > /dev/null 2>&1
supervisorctl restart "$NEXUS_SERVICE" > /dev/null 2>&1
sleep 5
# Verify restart
@@ -71,4 +67,4 @@ if ! curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
else
# Python is responding normally
echo "0" > "$FAIL_FILE"
fi
fi
+9 -5
View File
@@ -1,4 +1,4 @@
# Nexus — Supervisor Process Guardian (JC1: Updated for v6.0)
# Nexus — Supervisor Process Guardian
# - --workers N for multi-core concurrency
# - --loop uvloop for 40-50% QPS improvement
# - --http httptools for fast HTTP parsing
@@ -6,10 +6,14 @@
# Install: sudo cp nexus.conf /etc/supervisor/conf.d/nexus.conf
# Start: sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus
# Set workers: export NEXUS_WORKERS=$(nproc)
#
# *** Before deploying, replace ALL {{PLACEHOLDER}} values with your actual paths ***
# {{DEPLOY_DIR}} = installation directory (e.g. /opt/nexus or /www/wwwroot/your.domain)
# {{VENV_DIR}} = Python venv directory (e.g. /opt/nexus/venv)
[program:nexus]
command=/www/wwwroot/api.synaglobal.vip/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port 8600 --workers %(ENV_NEXUS_WORKERS)s --loop uvloop --http httptools --log-level info
directory=/www/wwwroot/api.synaglobal.vip
command={{DEPLOY_DIR}}/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port 8600 --workers %(ENV_NEXUS_WORKERS)s --loop uvloop --http httptools --log-level info
directory={{DEPLOY_DIR}}
user=root
autostart=true
autorestart=true
@@ -19,10 +23,10 @@ stopwaitsecs=15
stopsignal=INT
environment=
NEXUS_WORKERS="%(ENV_NEXUS_WORKERS)s",
PATH="/www/wwwroot/api.synaglobal.vip/venv/bin:%(ENV_PATH)s"
PATH="{{VENV_DIR}}/bin:%(ENV_PATH)s"
stderr_logfile=/var/log/nexus/error.log
stdout_logfile=/var/log/nexus/access.log
stderr_logfile_maxbytes=50MB
stdout_logfile_maxbytes=50MB
stderr_logfile_backups=5
stdout_logfile_backups=5
stdout_logfile_backups=5
@@ -1,9 +1,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,9 +71,9 @@ 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
View File
@@ -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.jssync_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.jsoninstall.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`
**审查结论**: 零 [必须修复] 项,可合入主干
+53 -86
View File
@@ -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 更轻量 |
+84 -31
View File
@@ -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 预览
-应用层内容过滤(鉴权即信任)
-推送结果邮件报告
+114 -247
View File
@@ -1,8 +1,9 @@
# 设计部审查报告 — Nexus 技术栈全面评估
**审查人**: 设计部负责人
**审查日期**: 2026-05-21
**审查范围**: 全部设计规格(10份)、实施计划(5份)、竞品分析、roadmap/ADR、config.py、requirements.txt、.env.example
**审查日期**: 2026-05-22v2 更新)
**审查范围**: 全部设计规格、实施计划、竞品分析、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` 直连 MySQLPython 通过 `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 保留,其余已迁移 |
| 文档与代码不同步 | 中 | 高 | **本报告已修正**,需持续维护 |
+1 -1
View File
@@ -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 -1
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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 (纯静态 HTMLJWT 认证)
数据库: 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 + mainAlpine.js 交互)
- [x] Dashboard + WebSocket 实时告警
- [x] 服务器列表 + 文件管理 + 推送页面
- [x] 设置/配置/审计日志/脚本库/凭据管理
- [x] 定时推送/重试队列
- [x] install.php → install.html + FastAPI API 迁移
- [x] 条件启动模式 (install mode: 无 .env 时仅安装 API 可用)
---
## 四、实现顺序
## 四、安全性
### 第零步:基础设施加固(Redis + WebSocket
### 已实施
```
- Redis 改用 BlockingConnectionPoolmax_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 + mainAlpine.js 交互)
- Dashboard + 服务器列表 + 文件管理 + 推送页面
- 设置/配置/审计日志/脚本库/凭据管理
- 其余 PHP 页面全部迁移(25+ 页)
- PHP-FPM 下线 + Nginx 配置清理
```
> 预估:3-4 周 | 前面 4 步后端全部完成后,前端一次性全量替换
---
## 五、架构决策记录
### 已有决策
| 决策 | 选择 | 原因 |
|------|------|------|
| 数据库 | MySQL | 宝塔标配,持久可靠 |
| 配置存储 | MySQL settings + Redis 热层 | PHP 写 MySQLPython 读 |
| 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 |
---
## 八、兼容约束
- 全面弃用 AdminLTEPHP),所有页面迁移到 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
View File
@@ -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 写 MySQLPython 启动读 |
| 推送并发 | 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`
- 登录页 HTMLTailwind 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%**
+118 -130
View File
@@ -1,7 +1,8 @@
# Nexus 技术栈完整清单
> 整理日期: 2026-05-21
> 整理日期: 2026-05-22v2 更新)
> 来源: 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) | ✅ 已修复 — 迁移到 asyncsshparamiko 已废弃 |
| **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** | 新旧认证并存 | ✅ 已修复 — 全面 JWTPHP 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。
+13 -11
View File
@@ -1,10 +1,12 @@
# CTO审查报告
**项目**: Nexus 6.0 — 服务器运维管理平台
**审查日期**: 2026-05-21
**审查日期**: 2026-05-212026-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` 为 151200 个连接可能耗尽数据库连接池,影响数据库本身和其他服务。~~
**建议**:
> **勘误 (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-212026-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-212026-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
View File
@@ -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
View File
@@ -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())
-6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
+11
View File
@@ -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
+637
View File
@@ -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()}
+2 -1
View File
@@ -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
View File
@@ -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 ──
+115
View File
@@ -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
View File
@@ -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,
+206 -25
View File
@@ -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,
@@ -199,4 +380,4 @@ def _retry_to_dict(job: PushRetryJob) -> dict:
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
"last_error": job.last_error,
"created_at": str(job.created_at) if job.created_at else None,
}
}
+3 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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"""
+82 -19
View File
@@ -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)"""
+22 -10
View File
@@ -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")
+98 -10
View File
@@ -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(
+35 -57
View File
@@ -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,19 +252,16 @@ class SyncService:
def _parse_rsync_transferred(self, stdout: str) -> int:
"""Parse rsync output to count transferred files"""
# rsync -v output contains lines like: "sent X bytes received Y bytes Z bytes/sec"
# and a summary line with file counts
for line in stdout.split("\n"):
if "Number of regular files transferred:" in line:
try:
return int(line.split(":")[1].strip())
except (ValueError, IndexError):
pass
# Fallback: count non-summary lines
return 0
async def _audit(self, action: str, target_type: str, target_id: int, detail: str):
if not self.audit_repo:
return
log = AuditLog(action=action, target_type=target_type, target_id=target_id, detail=detail)
await self.audit_repo.create(log)
await self.audit_repo.create(log)
+7 -4
View File
@@ -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")
+4 -3
View File
@@ -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}",
)
+45 -10
View File
@@ -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
View File
@@ -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
+63 -25
View File
@@ -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")
-6
View File
@@ -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())
+8 -3
View File
@@ -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
+30 -4
View File
@@ -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")
@@ -94,4 +96,28 @@ async def run_migrations():
await migrate_category_to_node()
except Exception as e:
logger.error(f"Data migration failed: {e}")
# Non-fatal — tables exist, migration can be retried
# 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()
+34 -8
View File
@@ -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
await self.session.commit()
+53 -9
View File
@@ -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)
+18 -3
View File
@@ -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,
-95
View File
@@ -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,
}
+33 -15
View File
@@ -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
+186 -20
View File
@@ -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)
@@ -203,4 +360,13 @@ app.include_router(assets_router)
app.include_router(webssh_router)
# Sync Engine v2 (Step 4)
app.include_router(sync_v2_router)
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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+113 -7
View File
@@ -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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
// Enter key in path input triggers browse
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
loadServers();
</script>
</body></html>
</body></html>
+228 -74
View File
@@ -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>
</html>
+641
View File
@@ -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>
+188
View File
@@ -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
View File
@@ -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>
+226 -18
View File
@@ -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>
</body></html>
+47 -20
View File
@@ -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
View File
@@ -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>
+79 -16
View File
@@ -1,40 +1,103 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 脚本库</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><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>
</body></html>
</body></html>
+308 -50
View File
@@ -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>
</body></html>
+331 -32
View File
@@ -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>
+155 -155
View File
@@ -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[31mWebSocket错误\x1b[0m\r\n');
};
}
function updateStatus(status, text) {
const el = document.getElementById('connectionStatus');
const dotClass = status === 'connected' ? 'bg-green-500' : status === 'error' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse';
el.innerHTML = '<span class="w-2 h-2 rounded-full ' + dotClass + '"></span>' + text;
// ── Input: terminal → WebSocket ──
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'DATA', data }));
}
});
// ── Resize: terminal → WebSocket ──
term.onResize(({ cols, rows }) => {
$d().cols = cols; $d().rows = rows;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols, rows }));
}
});
// Auto-fit on container resize
new ResizeObserver(() => { try { fitAddon.fit(); } catch(e){} })
.observe(document.getElementById('terminalWrap'));
// ── Fullscreen ──
function toggleFullscreen() {
const el = document.getElementById('terminalWrap');
const fs = el.classList.toggle('fs');
$d().fullscreen = fs;
setTimeout(() => fitAddon.fit(), 50);
}
// ── Disconnect ──
function disconnect() {
if (ws) {
try { ws.send(JSON.stringify({ type: 'CLOSE' })); } catch(e) {}
ws.close();
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'CLOSE' }));
ws.close(1000, 'User disconnect');
}
}
document.getElementById('resizeBtn').addEventListener('click', () => {
if (fitAddon) fitAddon.fit();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols: term.cols, rows: term.rows }));
}
});
document.getElementById('disconnectBtn').addEventListener('click', disconnect);
// ── Keyboard shortcuts ──
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key === 'f') {
e.preventDefault();
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
}
if (e.ctrlKey && e.shiftKey && e.key === 'F') { e.preventDefault(); toggleFullscreen(); }
if (e.ctrlKey && e.shiftKey && e.key === 'K') { e.preventDefault(); disconnect(); }
});
initTerminal();
connectTerminal();
setTimeout(() => fitAddon.fit(), 100);
// ── Load server name for page title ──
(async () => {
try {
const r = await apiFetch(API + '/servers/' + serverId);
if (r?.ok) { const s = await r.json(); $d().serverName = s.name || $d().serverName; document.title = 'Nexus — ' + $d().serverName; }
} catch(e){}
})();
// ── Init ──
connect();
</script>
</body>
</html>
</html>
+4 -1
View File
@@ -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";