Compare commits

...

19 Commits

Author SHA1 Message Date
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
Your Name 5590391779 feat: Nexus Agent rebrand + server_id + exec endpoint + trigger_type
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
Agent (agent.py):
- Rebrand MultiSync → Nexus throughout
- Add server_id to heartbeat payload (required by backend AgentHeartbeat schema)
- Add /exec endpoint for remote command execution via Nexus
- Update heartbeat fields to match backend: cpu_usage, mem_usage, disk_usage
- Use 60s default interval (matches CLAUDE.md spec)

Agent (agent.sh):
- Rebrand, add SERVER_ID env var, send in heartbeat payload
- Update service name to nexus-agent

Agent (install.sh):
- Rebrand, add --id parameter for server_id
- Write server_id to config.json
- Service name: nexus-agent
- Add config.example.json for reference

SyncEngineV2:
- Add trigger_type parameter to sync_files() (was hardcoded "manual")
- Schedule/retry runners can now pass "schedule"/"retry" trigger types

Nginx:
- Add /agent/ to static asset locations for agent file downloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 00:37:39 +08:00
Your Name 56993e4f98 fix: auth logout, Nginx configs, missing dependency, CORS
- auth: logout endpoint now accepts refresh_token (was admin_id which
  frontend can't know) — added logout_by_token() to AuthService
- api.js: sendBeacon uses Blob with application/json content-type
  (was sending text/plain which FastAPI couldn't parse)
- Nginx: fixed SPA fallback to 404 (not SPA), added install.php
  PHP-FPM handler, added production HTTPS config for api.synaglobal.vip
- requirements: added cryptography==44.0.0 (used by crypto.py but
  was missing from requirements.txt)
- models: removed unused Fernet import from domain models
- CORS: added http://api.synaglobal.vip origin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 00:30:28 +08:00
Your Name cd3c35b5e6 fix: add per-field save buttons to settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 00:04:29 +08:00
Your Name f0c586a345 feat: complete frontend interactive features + unified sidebar
- credentials: add credential CRUD form (type, host, port, user, password, db)
- schedules: add enable/disable toggle, edit modal, server selection
- retries: add status badges, retry progress bar, error display, refresh
- audit: add action filter dropdown, IP column, better status badges
- settings: reorganize into sections (brand/alert/infra/telegram)
- servers: add/edit modal with full form fields
- scripts: add execute modal + delete button
- All pages: unified sidebar with all nav items + hover states
- install.php: fix post-AdminLTE removal link paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 00:02:46 +08:00
Your Name 00a6491e59 chore: remove AdminLTE PHP frontend (keep only install.php)
All functionality now covered by web/app/*.html (Alpine.js + Tailwind).
Deleted: 29 PHP pages, ace editor, chart.min.js, app.js, style.css.
Kept: install.php (installation wizard with DB table creation + .env write).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:42:21 +08:00
Your Name 7caa880ebd feat: unified api.js auth + Pydantic models + JWT security hardening
- All Alpine.js pages now use shared api.js for JWT auto-refresh (9 pages)
- Dashboard uses /servers/stats endpoint instead of fetching all servers
- Replaced all raw dict params with Pydantic models (agent, settings, presets)
- Added AgentHeartbeat, AgentExec, SettingUpdatePayload, DbCredentialCreate schemas
- JWT decode now requires exp+sub claims; better error logging
- ServerService.push_to_servers delegates to SyncService.batch_push
- SyncService handles None audit_repo/retry_repo gracefully
- Branding: web/app.js + web/style.css MultiSync→Nexus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:38:01 +08:00
Your Name a584792603 feat: Pydantic request models + pagination + JWT auto-refresh
- server/api/schemas.py: New shared Pydantic models for all API routes
  (ServerCreate/Update/Push/Check, SyncFiles/Commands/Config/Sftp/Browse,
  ScriptCreate/Update/Execute, ScheduleCreate/Update, PlatformCreate/Update,
  NodeCreate/Update, PaginatedResponse)
- servers.py: Replace raw dict with Pydantic models, add pagination
  (page/per_page params, response now includes items/total/pages)
- sync_v2.py: Replace raw dict with SyncFiles/Commands/Config/Sftp/Browse
- scripts.py: Replace raw dict with ScriptCreate/Update/Execute
- assets.py: Replace raw dict with PlatformCreate/Update, NodeCreate/Update
- web/app/api.js: Shared JS auth module with JWT auto-refresh (2min
  before expiry), 401 retry with refresh token, doLogout with
  sendBeacon to backend, backward-compatible ah() alias
- web/app/servers.html: Use api.js, handle paginated response format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:18:49 +08:00
Your Name 1a27327036 fix: PHP frontend JWT auth + API endpoints + missing tables + branding
- login.php: Fix JWT response format (success/access_token vs status=ok),
  store JWT tokens in session, support TOTP 202 flow
- api_client.php: Dual auth (JWT Bearer + X-API-Key fallback), add
  auto-refresh on 401, fix API endpoints (checkServerHealth → array,
  getServerLogs, getAuditLogs, getDashboardStats field mapping)
- api_proxy.php: Fix check_all to use /api/servers/check (not per-server),
  fix get_logs to support global endpoint, restart_multisync → restart_nexus
- install.php: Add 4 missing tables (platforms, nodes, ssh_sessions,
  command_logs), fix table creation order for FK dependencies, add JWT
  columns to admins, add new indexes, add platform_id/node_id/protocols/
  extra_attrs/connectivity to servers
- logout.php: Call /api/auth/logout to invalidate JWT refresh token
- index.php: Use JWT Bearer auth for API calls
- server_service.py: Implement real check_all_servers with concurrent
  httpx Agent health checks (was stub)
- servers.py: Add GET /api/servers/logs global sync logs endpoint
- Branding: MultiSync → Nexus across all PHP files (sidebar, titles,
  TOTP issuer, version strings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:11:30 +08:00
Your Name 814e11588e feat: P1 background tasks + server stats endpoint
1. schedule_runner.py: cron-based schedule execution every 60s
   - Parses 5-field cron expressions (*, */N, ranges, lists)
   - Triggers SyncEngineV2.sync_files for due schedules
   - Prevents re-trigger within same minute
2. retry_runner.py: automatic retry of failed pushes every 5min
   - Exponential backoff (60s base, doubles per retry, max 64x)
   - Respects max_retries from PushRetryJob model
3. GET /api/servers/stats: dashboard aggregation endpoint
   - Returns total/online/offline counts + category breakdown
   - Reads real-time status from Redis, falls back to MySQL
   - Registered before /{id} to avoid path collision
4. main.py: register schedule_runner + retry_runner as background tasks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:58:17 +08:00
Your Name 73c1776e1e fix: P0 critical runtime issues
1. conftest.py: add missing `from server.main import app` import
2. dependencies.py: get_db() reuses middleware session instead of
   creating independent AsyncSessionLocal (fixes double-session bug)
3. auth_jwt.py: get_optional_admin uses request.state.db instead of
   creating leaked AsyncSessionLocal session
4. config.py: default DATABASE_URL uses mysql+aiomysql (not pymysql)
5. sync_engine_v2.py: sanitize config keys with regex, escape values
   to prevent command injection in sync_config
6. sync_v2.py: add POST /api/sync/browse endpoint for remote dir listing
7. files.html: browseDir calls real API instead of placeholder stub

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:54:35 +08:00
125 changed files with 5337 additions and 16320 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/
+49 -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,54 @@ Nexus/
| 心跳数据流 | Agent→Redis→前端直读→10min→MySQL | 前端实时, MySQL只存历史 |
| 告警推送 | WebSocket→前端 + Telegram | 即时感知 |
| Python守护 | 3层: Supervisor + Python自检(30s) + Shell(cron 1min) | 全覆盖 |
| install.php安装后 | rename→install.php.locked + .env锁定 | 防重装 |
| 配置源 | install→config.php(PHP)+settings表(MySQL)+.env(Python) | 三一致 |
| 安装向导 | install.html + FastAPI API | D2: 替代install.php |
| 配置源 | 安装向导→config.php(PHP兼容)+settings表(MySQL)+.env(Python) | 三一致 |
| Agent告警心跳 | CPU/内存>80% 或变化>10% 时主动上报 | 智能监控 |
| 仓库统一 | 所有代码统一在Nexus仓库 | 不再用两个仓库 |
## 实现状态 (Phase A+B+C)
## 实现状态 (Phase A+B+C+D) — ~95% 完成
### ✅ 已完成
### ✅ 已完成 (全部6步 + 技术债务)
| 模块 | 文件 | 说明 |
|------|------|------|
| **Step 0: 基础设施** | | Redis + WebSocket |
| E1 WebSocket推送 | server/api/websocket.py | alert/recovery/system broadcast |
| E2 心跳→Redis+告警 | server/api/agent.py | Redis write + threshold detection |
| E3 Redis→MySQL刷新 | server/background/heartbeat_flush.py | 10min batch |
| **Step 1: 数据层** | | 14张表 |
| E4 load_settings_from_db | server/config.py | MySQL overrides mutable settings |
| E5 Python自检 | server/background/self_monitor.py | 30s loop |
| E6 Telegram推送 | server/infrastructure/telegram/__init__.py | alert/recovery/system |
| E7 Redis实时状态 | server/api/servers.py | frontend reads Redis directly |
| E8 /health端点 | server/api/health.py | Shell health_monitor.sh checks this |
| E9 lifespan+后台任务 | server/main.py | startup: init_db + load_settings + background tasks |
| D1 Supervisor | deploy/nexus.conf | autorestart nexus process |
| D2 Shell健康检查 | deploy/health_monitor.sh | cron 1min, 3 failures→restart+Telegram |
| P1 install.php | web/install.php | 5步向导 (欢迎→环境→DB+Redis+API→管理员+品牌→完成清单) |
| P2 config.php | web/config.php | data/config.php + MySQL settings override |
| **Step 2: 认证层** | | JWT + TOTP |
| **Step 3: Web SSH** | | asyncssh + xterm.js |
| E5 asyncssh连接池 | server/infrastructure/ssh/asyncssh_pool.py | 引用计数模式 |
| E6 WebSSH前端 | web/app/terminal.html | xterm.js + Koko协议 + 自动resize |
| **Step 4: Sync引擎** | | 4种同步模式 |
| **Step 5: 前端迁移** | | Tailwind+Alpine.js |
| E7 12个前端页面 | web/app/*.html | 含install.html安装向导 |
| E8 安装向导API | server/api/install.py | 6端点, 无JWT, 临时引擎 |
| E9 条件启动模式 | server/main.py | 无.env=安装模式 |
| **后台任务** | | |
| E10 Python自检 | server/background/self_monitor.py | 30s loop |
| E11 Telegram推送 | server/infrastructure/telegram/__init__.py | alert/recovery/system |
| E12 /health端点 | server/api/health.py | Shell health_monitor.sh checks this |
| **技术债务** | | |
| D1 ✅ paramiko删除 | — | pool.py已删除, 全部迁移asyncssh |
| D2 ✅ install迁移 | server/api/install.py | install.php→install.html+API |
| D3 ✅ WebSSH前端 | web/app/terminal.html | xterm.js+Koko协议+全屏 |
| D4 文件编辑器 | — | 低优先级, Monaco/ACE |
### 🔄 待测试 (Phase D)
### 🔄 待测试
- T1: 心跳Redis写入验证
- T2: WebSocket连接测试
- T3: 3层守护测试 (kill Python→Supervisor重启)
- T4: Telegram推送测试
- T5: install.php 5步流程测试
- T5: install.html 5步流程测试
### 测试流程 (用户确认)
1. WSL本地基础测试 → 2. 推到仓库 → 3. 外网服务器正式测试
## 配置三写机制
install.php Step 3 POST处理同时写入:
1. `web/data/config.php` — PHP前端配置 (API_BASE_URL, DB_*, API_KEY)
安装向导 Step 3 POST处理同时写入:
1. `web/data/config.php` — PHP兼容配置 (API_BASE_URL, DB_*, API_KEY)
2. `.env` — Python后端启动必需 (NEXUS_前缀)
3. `settings` MySQL表 — 共享动态配置 (api_key, thresholds, Telegram等)
@@ -93,4 +115,4 @@ Agent心跳(60s) → Redis(实时) → 前端直读 → 10min批量 → MySQL(
恢复: 之前告警的指标恢复正常 → 自动推送恢复通知
Redis心跳key: heartbeat:{server_id} (HSET, TTL=600s)
Redis告警key: alerts:{server_id} (SET, TTL=3600s)
```
```
+11 -15
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,10 @@
# Nexus v6.0 — Nginx Reverse Proxy Config (JC2+JC3)
# Target: 172.31.170.47 (WSL internal)
# Backend: uvicorn 0.0.0.0:8600 (multi-worker with uvloop)
# Frontend: /web/app/ (Tailwind CSS v4 + Alpine.js, pure static, no PHP-FPM)
# Nexus v6.0 — Nginx Reverse Proxy Config (Template)
# *** Copy this file and replace ALL {{PLACEHOLDER}} values before deploying ***
#
# JC3: PHP-FPM removed — all pages migrated to pure HTML/JS
# {{SERVER_NAME}} = your domain or IP (e.g. nexus.example.com or 192.168.1.100)
# {{DEPLOY_DIR}} = installation root (e.g. /opt/nexus or /www/wwwroot/nexus.example.com)
# {{LOG_DIR}} = nginx log directory (e.g. /var/log/nginx or /www/wwwlogs)
# {{PHP_SOCK}} = PHP-FPM socket path (e.g. unix:/tmp/php-cgi-82.sock) — only if install.php is used
# ── Upstream: Python FastAPI backend ──
upstream nexus_api {
@@ -13,10 +14,10 @@ upstream nexus_api {
server {
listen 80;
server_name 172.31.170.47;
server_name {{SERVER_NAME}};
# NEW: Document root = new Tailwind app directory (no more PHP)
root /www/wwwroot/api.synaglobal.vip/web/app;
# Document root = frontend static files
root {{DEPLOY_DIR}}/web/app;
index index.html;
# ── Python API proxy ──
@@ -40,7 +41,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s; # Long-lived for terminal sessions
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
@@ -50,9 +51,18 @@ server {
proxy_set_header Host $host;
}
# ── Legacy install.php redirect ──
location = /install.php {
return 301 /app/install.html;
# ── install.php (PHP-FPM for installer, root = /web/) ──
location ~ ^/install\.php$ {
root {{DEPLOY_DIR}}/web;
fastcgi_pass {{PHP_SOCK}};
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# ── Installer + Agent assets (root = /web/) ──
location ~ ^/(css|js|vendor|agent)/ {
root {{DEPLOY_DIR}}/web;
try_files $uri =404;
}
# ── Static assets ──
@@ -70,15 +80,15 @@ server {
deny all;
}
# ── SPA fallback: all paths → index.html ──
# ── Static HTML pages (no SPA — each page is standalone) ──
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ =404;
}
# ── Logging ──
access_log /www/wwwlogs/172.31.170.47_nexus.log;
error_log /www/wwwlogs/172.31.170.47_nexus.error.log;
access_log {{LOG_DIR}}/{{SERVER_NAME}}_nexus.log;
error_log {{LOG_DIR}}/{{SERVER_NAME}}_nexus.error.log;
# ── Upload limit ──
client_max_body_size 100m;
}
}
+116
View File
@@ -0,0 +1,116 @@
# Nexus v6.0 — Nginx Production Config (HTTPS Template)
# *** Copy this file and replace ALL {{PLACEHOLDER}} values before deploying ***
#
# {{SERVER_NAME}} = your domain (e.g. nexus.example.com)
# {{DEPLOY_DIR}} = installation root (e.g. /opt/nexus or /www/wwwroot/nexus.example.com)
# {{LOG_DIR}} = nginx log directory (e.g. /var/log/nginx or /www/wwwlogs)
# {{SSL_CERT}} = SSL certificate fullchain path
# {{SSL_KEY}} = SSL private key path
# {{PHP_SOCK}} = PHP-FPM socket path (e.g. unix:/tmp/php-cgi-82.sock) — only if install.php is used
# ── Upstream: Python FastAPI backend ──
upstream nexus_api {
server 127.0.0.1:8600;
keepalive 32;
}
# ── HTTP → HTTPS redirect ──
server {
listen 80;
server_name {{SERVER_NAME}};
return 301 https://$host$request_uri;
}
# ── HTTPS ──
server {
listen 443 ssl http2;
server_name {{SERVER_NAME}};
# SSL
ssl_certificate {{SSL_CERT}};
ssl_certificate_key {{SSL_KEY}};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Document root = frontend static files
root {{DEPLOY_DIR}}/web/app;
index index.html;
# ── install.php (PHP-FPM for installer, root = /web/) ──
location ~ ^/install\.php$ {
root {{DEPLOY_DIR}}/web;
fastcgi_pass {{PHP_SOCK}};
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# ── Installer + Agent assets (root = /web/) ──
location ~ ^/(css|js|vendor|agent)/ {
root {{DEPLOY_DIR}}/web;
try_files $uri =404;
}
# ── Python API proxy ──
location /api/ {
proxy_pass http://nexus_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 10s;
}
# ── WebSocket proxy (alerts + WebSSH terminal) ──
location /ws/ {
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# ── Health endpoint ──
location /health {
proxy_pass http://nexus_api;
proxy_set_header Host $host;
}
# ── Static assets (cache aggressively) ──
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# ── Deny sensitive files ──
location ~ /\. {
deny all;
}
location ~ /data/config\.php$ {
deny all;
}
# ── Static HTML pages (no SPA — each page is standalone) ──
location / {
try_files $uri $uri/ =404;
}
# ── Logging ──
access_log {{LOG_DIR}}/{{SERVER_NAME}}.log;
error_log {{LOG_DIR}}/{{SERVER_NAME}}.error.log;
# ── Upload limit ──
client_max_body_size 100m;
}
+64 -25
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())
+1 -6
View File
@@ -5,19 +5,14 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
aiomysql==0.2.0
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.7.0
python-multipart==0.0.19
jinja2==3.1.5
aiofiles==24.1.0
httpx==0.28.1
apscheduler==3.11.0
websockets==14.1
paramiko==3.5.0
asyncssh==2.17.0
PyJWT==2.10.1
bcrypt==4.2.1
cryptography==44.0.0
pyyaml==6.0.2
rich==13.9.4
redis[hiredis]==5.2.1
+13 -38
View File
@@ -16,6 +16,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from server.api.dependencies import get_server_service
from server.api.schemas import AgentHeartbeat, AgentExec
from server.api.websocket import broadcast_alert, broadcast_recovery
from server.application.services.server_service import ServerService
from server.config import settings
@@ -81,7 +82,7 @@ def _detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> l
@router.post("/heartbeat", response_model=dict)
async def receive_heartbeat(
payload: dict,
payload: AgentHeartbeat,
api_key: str = Depends(_verify_api_key),
service: ServerService = Depends(get_server_service),
):
@@ -92,25 +93,11 @@ async def receive_heartbeat(
2. Detect alerts (CPU/mem/disk > threshold) → WebSocket + Telegram push
3. Detect recoveries (previously alerted metrics now normal) → push
4. Also update MySQL via ServerService (for persistent record)
Body: {
"server_id": 1,
"is_online": true,
"system_info": {"cpu": 45, "mem": 60, "disk": 70, ...},
"agent_version": "1.2.0"
}
"""
server_id = payload.get("server_id")
if not server_id:
server_name = payload.get("server_name")
if server_name:
raise HTTPException(status_code=400, detail="server_id required (name lookup not yet implemented)")
raise HTTPException(status_code=400, detail="server_id required")
is_online = payload.get("is_online", True)
system_info = payload.get("system_info", {})
agent_version = payload.get("agent_version", "")
server_name = payload.get("server_name", "")
server_id = payload.server_id
is_online = payload.is_online
system_info = payload.system_info or {}
agent_version = payload.agent_version or ""
# ── 1. Write to Redis (real-time data) ──
redis = get_redis()
@@ -121,7 +108,6 @@ async def receive_heartbeat(
"system_info": json.dumps(system_info),
"last_heartbeat": datetime.now(timezone.utc).isoformat(),
"agent_version": agent_version,
"server_name": server_name,
})
# Set TTL = flush interval × 1.5 (prevent stale online status after key expires)
await redis.expire(redis_key, REDIS_KEY_EXPIRE)
@@ -140,7 +126,7 @@ async def receive_heartbeat(
alerts = _detect_alerts(system_info, alert_thresholds)
for alert_type, alert_value in alerts:
logger.warning(f"Alert: server {server_id} {alert_type}={alert_value}%")
await broadcast_alert(server_id, alert_type, alert_value, server_name)
await broadcast_alert(server_id, alert_type, alert_value, f"server-{server_id}")
# Track active alert in Redis
try:
@@ -159,7 +145,7 @@ async def receive_heartbeat(
recoveries = _detect_recovery(system_info, prev_alerts, alert_thresholds)
for metric, value in recoveries:
logger.info(f"Recovery: server {server_id} {metric}={value}%")
await broadcast_recovery(server_id, metric, value, server_name)
await broadcast_recovery(server_id, metric, value, f"server-{server_id}")
# Remove recovered alert from Redis tracking
for prev in prev_alerts:
@@ -169,35 +155,24 @@ async def receive_heartbeat(
logger.error(f"Recovery detection failed for server {server_id}: {e}")
# ── 4. Also update MySQL (persistent record for non-real-time queries) ──
await service.update_heartbeat(server_id, payload)
await service.update_heartbeat(server_id, payload.model_dump())
return {"status": "ok", "server_id": server_id}
@router.post("/exec", response_model=dict)
async def agent_exec(
payload: dict,
payload: AgentExec,
api_key: str = Depends(_verify_api_key),
):
"""Execute a shell command on this Agent server
This endpoint is called by Nexus to dispatch commands to remote Agents.
Each Agent server runs its own instance of this endpoint.
Body: {
"command": "systemctl restart nginx",
"timeout": 30,
"sudo": false
}
"""
command = payload.get("command")
if not command:
raise HTTPException(status_code=400, detail="command required")
timeout = min(payload.get("timeout", 30), 300)
use_sudo = payload.get("sudo", False)
if use_sudo:
command = payload.command
timeout = payload.timeout
if payload.sudo:
command = f"sudo {command}"
import asyncio
+109 -14
View File
@@ -1,12 +1,16 @@
"""Nexus — Platform & Node API Routes (Asset organization layer)
All operations require JWT authentication.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import PlatformCreate, PlatformUpdate, NodeCreate, NodeUpdate
from server.infrastructure.database.platform_node_repo import PlatformRepositoryImpl, NodeRepositoryImpl
from server.domain.models import Platform, Node
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Platform, Node, Admin, AuditLog
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +20,10 @@ router = APIRouter(prefix="/api/assets", tags=["assets"])
# ── Platforms ──
@router.get("/platforms", response_model=list)
async def list_platforms(db: AsyncSession = Depends(get_db)):
async def list_platforms(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List all platform templates"""
repo = PlatformRepositoryImpl(db)
platforms = await repo.get_all()
@@ -24,7 +31,11 @@ async def list_platforms(db: AsyncSession = Depends(get_db)):
@router.get("/platforms/{id}", response_model=dict)
async def get_platform(id: int, db: AsyncSession = Depends(get_db)):
async def get_platform(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Get a single platform by ID"""
repo = PlatformRepositoryImpl(db)
platform = await repo.get_by_id(id)
@@ -34,42 +45,82 @@ async def get_platform(id: int, db: AsyncSession = Depends(get_db)):
@router.post("/platforms", response_model=dict, status_code=201)
async def create_platform(payload: dict, db: AsyncSession = Depends(get_db)):
async def create_platform(
payload: PlatformCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new platform template"""
repo = PlatformRepositoryImpl(db)
platform = Platform(**payload)
platform = Platform(**payload.model_dump(exclude_none=True))
created = await repo.create(platform)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_platform",
target_type="platform", target_id=created.id,
detail=f"name={created.name}", ip_address="",
))
return _platform_to_dict(created)
@router.put("/platforms/{id}", response_model=dict)
async def update_platform(id: int, payload: dict, db: AsyncSession = Depends(get_db)):
async def update_platform(
id: int,
payload: PlatformUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a platform template"""
repo = PlatformRepositoryImpl(db)
platform = await repo.get_by_id(id)
if not platform:
raise HTTPException(status_code=404, detail="Platform not found")
for key, value in payload.items():
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(platform, key) and key != "id":
setattr(platform, key, value)
updated = await repo.update(platform)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_platform",
target_type="platform", target_id=id,
detail=f"name={updated.name}", ip_address="",
))
return _platform_to_dict(updated)
@router.delete("/platforms/{id}", status_code=204)
async def delete_platform(id: int, db: AsyncSession = Depends(get_db)):
async def delete_platform(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a platform template"""
repo = PlatformRepositoryImpl(db)
platform = await repo.get_by_id(id)
if not platform:
raise HTTPException(status_code=404, detail="Platform not found")
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Platform not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_platform",
target_type="platform", target_id=id,
detail=f"name={platform.name}", ip_address="",
))
# ── Nodes (Tree) ──
@router.get("/nodes", response_model=list)
async def list_nodes(
parent_id: Optional[int] = None,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List nodes — all, or children of a specific parent"""
@@ -82,7 +133,10 @@ async def list_nodes(
@router.get("/nodes/tree", response_model=list)
async def get_node_tree(db: AsyncSession = Depends(get_db)):
async def get_node_tree(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Get full node tree structure"""
repo = NodeRepositoryImpl(db)
all_nodes = await repo.get_all()
@@ -90,36 +144,75 @@ async def get_node_tree(db: AsyncSession = Depends(get_db)):
@router.post("/nodes", response_model=dict, status_code=201)
async def create_node(payload: dict, db: AsyncSession = Depends(get_db)):
async def create_node(
payload: NodeCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new node"""
repo = NodeRepositoryImpl(db)
node = Node(**payload)
node = Node(**payload.model_dump(exclude_none=True))
created = await repo.create(node)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_node",
target_type="node", target_id=created.id,
detail=f"name={created.name}", ip_address="",
))
return _node_to_dict(created)
@router.put("/nodes/{id}", response_model=dict)
async def update_node(id: int, payload: dict, db: AsyncSession = Depends(get_db)):
async def update_node(
id: int,
payload: NodeUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a node"""
repo = NodeRepositoryImpl(db)
node = await repo.get_by_id(id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
for key, value in payload.items():
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(node, key) and key != "id":
setattr(node, key, value)
updated = await repo.update(node)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_node",
target_type="node", target_id=id,
detail=f"name={updated.name}", ip_address="",
))
return _node_to_dict(updated)
@router.delete("/nodes/{id}", status_code=204)
async def delete_node(id: int, db: AsyncSession = Depends(get_db)):
async def delete_node(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a node"""
repo = NodeRepositoryImpl(db)
node = await repo.get_by_id(id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Node not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_node",
target_type="node", target_id=id,
detail=f"name={node.name}", ip_address="",
))
# ── SSH Sessions & Command Logs ──
@@ -127,6 +220,7 @@ async def delete_node(id: int, db: AsyncSession = Depends(get_db)):
async def list_ssh_sessions(
server_id: Optional[int] = None,
limit: int = 50,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List SSH sessions"""
@@ -147,6 +241,7 @@ async def list_command_logs(
server_id: Optional[int] = None,
session_id: Optional[str] = None,
limit: int = 200,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List command logs"""
+5 -2
View File
@@ -29,7 +29,7 @@ class RefreshRequest(BaseModel):
class LogoutRequest(BaseModel):
admin_id: int
refresh_token: Optional[str] = None
class TotpSetupRequest(BaseModel):
@@ -85,7 +85,10 @@ async def logout(
service: AuthService = Depends(get_auth_service),
):
"""Invalidate refresh token (client should also discard access token)"""
result = await service.logout(payload.admin_id)
if payload.refresh_token:
result = await service.logout_by_token(payload.refresh_token)
else:
return {"success": True, "message": "已登出"}
return result
+22 -10
View File
@@ -80,7 +80,16 @@ async def _verify_token(token: str, request: Request) -> Optional[Admin]:
"""Verify JWT access token → return Admin or None"""
try:
import jwt as pyjwt
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
payload = pyjwt.decode(
token, settings.SECRET_KEY, algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
except pyjwt.ExpiredSignatureError:
logger.debug("JWT token expired")
return None
except pyjwt.InvalidTokenError as e:
logger.debug(f"JWT invalid: {e}")
return None
except Exception:
return None
@@ -106,18 +115,21 @@ async def _verify_token(token: str, request: Request) -> Optional[Admin]:
async def get_optional_admin(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Optional[Admin]:
"""Optional JWT auth — returns Admin if token valid, None otherwise.
Used for endpoints that work with or without auth (e.g., public data with optional user context).
Uses middleware session via request.state.db (no leaked sessions).
"""
if not credentials:
return None
# Can't use _verify_token without request here, so do inline
try:
import jwt as pyjwt
payload = pyjwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=["HS256"])
payload = pyjwt.decode(
credentials.credentials, settings.SECRET_KEY, algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
admin_id = payload.get("sub")
if not admin_id:
return None
@@ -125,11 +137,11 @@ async def get_optional_admin(
admin_id = int(admin_id)
except (ValueError, TypeError):
return None
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
async with AsyncSessionLocal() as session:
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
return admin if admin and admin.is_active else None
# Use middleware session instead of creating independent session
session = await _get_request_session(request)
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
return admin if admin and admin.is_active else None
except Exception:
return None
+14 -9
View File
@@ -29,18 +29,23 @@ from server.application.services.auth_service import AuthService
from server.application.services.sync_service import SyncService
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Yield an async database session (for use as FastAPI Depends)
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
"""Yield DB session from middleware (request.state.db).
For routes that use get_db() directly (settings, schedules, etc.),
we still use the yield pattern which properly closes the session.
These routes don't go through the service factories.
DbSessionMiddleware ensures every /api/ request has a session.
This dependency simply yields it so routes can use Depends(get_db).
No double-session: one session per request, shared everywhere.
"""
session = AsyncSessionLocal()
try:
session = getattr(request.state, "db", None)
if session is not None:
yield session
finally:
await session.close()
else:
# Fallback for tests or edge cases without middleware
session = AsyncSessionLocal()
try:
yield session
finally:
await session.close()
async def _get_request_session(request: Request) -> AsyncSession:
+613
View File
@@ -0,0 +1,613 @@
"""Nexus — Installation Wizard API
Runs BEFORE the main app is fully configured (no .env required).
Provides endpoints for the 5-step install wizard.
Security: All endpoints refuse to operate once .env exists or install is locked.
"""
import os
import secrets
import logging
import subprocess
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
logger = logging.getLogger("nexus.install")
router = APIRouter(prefix="/api/install", tags=["install"])
# ── Paths ──
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
ENV_FILE = ROOT_DIR / ".env"
LOCK_FILE = ROOT_DIR / "web" / "install.php.locked"
INSTALL_LOCK = ROOT_DIR / ".install_locked"
CONFIG_PHP_DIR = ROOT_DIR / "web" / "data"
CONFIG_PHP = CONFIG_PHP_DIR / "config.php"
STATE_FILE = ROOT_DIR / ".install_state.json"
def _is_installed() -> bool:
return ENV_FILE.exists() or INSTALL_LOCK.exists()
def _is_locked() -> bool:
return INSTALL_LOCK.exists() or LOCK_FILE.exists()
# ── Pydantic Models ──
class InitDbRequest(BaseModel):
db_host: str = "localhost"
db_port: str = "3306"
db_name: str = "Nexus"
db_user: str = "Nexus"
db_pass: str
redis_host: str = "127.0.0.1"
redis_port: str = "6379"
redis_db: str = "0"
redis_pass: str = ""
api_port: str = "8600"
timezone: str = "Asia/Shanghai"
site_url: str = ""
class CreateAdminRequest(BaseModel):
db_host: str = "localhost"
db_port: str = "3306"
db_name: str = "Nexus"
db_user: str = "Nexus"
db_pass: str
admin_username: str = "admin"
admin_password: str
admin_email: str = ""
system_name: str = "Nexus"
system_title: str = "Nexus — 服务器运维管理平台"
# ── Helpers ──
def _make_db_url(req: InitDbRequest | CreateAdminRequest) -> str:
return (
f"mysql+aiomysql://{req.db_user}:{req.db_pass}"
f"@{req.db_host}:{req.db_port}/{req.db_name}"
)
def _build_redis_url(req: InitDbRequest) -> str:
url = "redis://"
if req.redis_pass:
url += f"{req.redis_pass}@"
url += f"{req.redis_host}:{req.redis_port}/{req.redis_db}"
return url
def _write_env(req: InitDbRequest, secret_key: str, api_key: str,
pool_size: int, max_overflow: int, redis_url: str,
site_url: str) -> None:
install_dir = str(ROOT_DIR)
db_url = (
f"mysql+aiomysql://{req.db_user}:{req.db_pass}"
f"@{req.db_host}:{req.db_port}/{req.db_name}"
)
lines = [
"# Nexus .env — Auto-generated by installer",
f"# {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC",
"",
"NEXUS_SYSTEM_NAME=Nexus",
"NEXUS_SYSTEM_TITLE=Nexus — 服务器运维管理平台",
"",
"NEXUS_HOST=0.0.0.0",
f"NEXUS_PORT={req.api_port}",
"",
"# Database (MySQL 8.4 + aiomysql async driver)",
f"NEXUS_DATABASE_URL={db_url}",
f"NEXUS_DB_POOL_SIZE={pool_size}",
f"NEXUS_DB_MAX_OVERFLOW={max_overflow}",
"",
"# Security — PRODUCTION KEYS (immutable after install)",
f"NEXUS_SECRET_KEY={secret_key}",
f"NEXUS_API_KEY={api_key}",
"NEXUS_ENCRYPTION_KEY=",
"",
"# Redis",
f"NEXUS_REDIS_URL={redis_url}",
"",
"# Deployment",
f"NEXUS_DEPLOY_PATH={install_dir}",
f"NEXUS_CORS_ORIGINS={site_url},http://localhost:{req.api_port}",
"",
"# SSH",
"NEXUS_SSH_STRICT_HOST_CHECKING=false",
"",
"# Health Check",
"NEXUS_HEALTH_CHECK_INTERVAL=60",
f"NEXUS_API_BASE_URL={site_url}",
"",
"# Alert Thresholds",
"NEXUS_CPU_ALERT_THRESHOLD=80",
"NEXUS_MEM_ALERT_THRESHOLD=80",
"NEXUS_DISK_ALERT_THRESHOLD=80",
"",
"# Telegram Alerts (optional — configure in Settings UI)",
"NEXUS_TELEGRAM_BOT_TOKEN=",
"NEXUS_TELEGRAM_CHAT_ID=",
"",
]
ENV_FILE.write_text("\n".join(lines))
logger.info(f".env written to {ENV_FILE}")
def _write_config_php(req: InitDbRequest, api_key: str, site_url: str) -> None:
CONFIG_PHP_DIR.mkdir(parents=True, exist_ok=True)
install_dir = str(ROOT_DIR)
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
lines = [
"<?php",
"// Nexus Configuration — Auto-generated by installer",
f"// {now}",
"",
f"define('API_BASE_URL', '{site_url}');",
f"define('API_KEY', '{api_key}');",
f"define('DB_HOST', '{req.db_host}');",
f"define('DB_PORT', '{req.db_port}');",
f"define('DB_NAME', '{req.db_name}');",
f"define('DB_USER', '{req.db_user}');",
f"define('DB_PASS', '{req.db_pass}');",
"define('APP_NAME', 'Nexus');",
"define('APP_VERSION', '6.0.0');",
"define('SESSION_LIFETIME', 28800);",
f"define('DEPLOY_ROOT', '{install_dir}');",
"",
f"date_default_timezone_set('{req.timezone}');",
"",
]
CONFIG_PHP.write_text("\n".join(lines))
logger.info(f"config.php written to {CONFIG_PHP}")
def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
"""Best-effort: configure Supervisor, crontab, health_monitor.sh"""
results: list[str] = []
# 1. Log directory
log_dir = Path("/var/log/nexus")
try:
log_dir.mkdir(parents=True, exist_ok=True)
results.append(f"✓ 日志目录已创建 {log_dir}")
except OSError:
results.append(f"✗ 日志目录创建失败(需 root 权限)")
# 2. Supervisor config
supervisor_conf = (
f"[program:nexus]\n"
f"command={install_dir}/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port {api_port}\n"
f"directory={install_dir}\n"
f"user=root\n"
f"autostart=true\n"
f"autorestart=true\n"
f"startretries=10\n"
f"startsecs=3\n"
f"stopwaitsecs=10\n"
f"stopsignal=INT\n"
f"environment=PATH=\"{install_dir}/venv/bin:/usr/local/bin:%(ENV_PATH)s\",HOME=\"/root\"\n"
f"stderr_logfile=/var/log/nexus/error.log\n"
f"stdout_logfile=/var/log/nexus/access.log\n"
f"stderr_logfile_maxbytes=50MB\n"
f"stdout_logfile_maxbytes=50MB\n"
f"stderr_logfile_backups=5\n"
f"stdout_logfile_backups=5\n"
)
conf_path = Path("/etc/supervisor/conf.d/nexus.conf")
try:
conf_path.parent.mkdir(parents=True, exist_ok=True)
conf_path.write_text(supervisor_conf)
results.append(f"✓ Supervisor 配置已写入 {conf_path}")
except OSError:
results.append("✗ Supervisor 配置写入失败(需 root 权限)")
# 3. Update health_monitor.sh INSTALL_DIR
health_sh = Path(install_dir) / "deploy" / "health_monitor.sh"
if health_sh.exists():
try:
content = health_sh.read_text()
import re
content = re.sub(
r'INSTALL_DIR="[^"]*"',
f'INSTALL_DIR="{install_dir}"',
content,
)
health_sh.write_text(content)
health_sh.chmod(0o755)
results.append("✓ health_monitor.sh INSTALL_DIR 已更新")
except OSError:
results.append("✗ health_monitor.sh 更新失败")
else:
results.append("⚠ health_monitor.sh 未找到,跳过")
# 4. Crontab
cron_entry = f"* * * * * {install_dir}/deploy/health_monitor.sh"
try:
current = subprocess.run(
["crontab", "-l"], capture_output=True, text=True, timeout=5
).stdout or ""
if "health_monitor.sh" not in current:
new_cron = current.rstrip() + "\n" + cron_entry + "\n"
proc = subprocess.run(
["crontab", "-"], input=new_cron, capture_output=True, text=True, timeout=5
)
if proc.returncode == 0:
results.append("✓ Crontab 已配置(每分钟健康检查)")
else:
results.append("✗ Crontab 配置失败 — 请手动添加")
else:
results.append("✓ Crontab 已存在 health_monitor 条目")
except Exception:
results.append("⚠ Crontab 配置跳过(权限不足或 cron 不可用)")
# 5. Reload Supervisor
try:
subprocess.run(["supervisorctl", "reread"], capture_output=True, timeout=5)
subprocess.run(["supervisorctl", "update"], capture_output=True, timeout=5)
results.append("✓ Supervisor 已重载配置")
except Exception:
results.append("⚠ Supervisor 未运行 — 请手动执行 supervisorctl reread && supervisorctl update")
return results
# ── Endpoints ──
@router.get("/status")
async def install_status():
"""Check whether Nexus is already installed."""
return {
"installed": _is_installed(),
"locked": _is_locked(),
}
@router.get("/env-check")
async def env_check():
"""Detect environment readiness — Python, MySQL client, Redis, write permissions."""
import sys
import importlib
checks = []
# Python version
checks.append({
"name": "Python 版本",
"required": "3.12+",
"current": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"pass": sys.version_info >= (3, 12),
})
# MySQL client (aiomysql)
try:
import aiomysql
checks.append({
"name": "aiomysql 驱动",
"required": "已安装",
"current": f"{aiomysql.__version__}",
"pass": True,
})
except ImportError:
checks.append({
"name": "aiomysql 驱动",
"required": "已安装",
"current": "✗ 未安装",
"pass": False,
})
# Redis
redis_ok = False
redis_msg = "未安装"
try:
import redis as rlib
try:
r = rlib.Redis(host="127.0.0.1", port=6379, socket_timeout=0.5)
r.ping()
redis_ok = True
redis_msg = "✓ 已连接 127.0.0.1:6379"
except Exception as e:
redis_msg = f"连接失败: {e}"
except ImportError:
redis_msg = "redis 库未安装"
checks.append({
"name": "Redis",
"required": "已连接",
"current": redis_msg,
"pass": redis_ok,
})
# MySQL database — user configures in step 3
checks.append({
"name": "MySQL 数据库",
"required": "步骤3配置",
"current": "— 请在步骤3填写数据库连接信息",
"pass": True,
})
# Write permissions
data_writable = CONFIG_PHP_DIR.exists() and os.access(CONFIG_PHP_DIR, os.W_OK) or \
os.access(ROOT_DIR / "web", os.W_OK)
root_writable = os.access(ROOT_DIR, os.W_OK)
checks.append({
"name": "web/data 写入权限",
"required": "可写",
"current": "✓ 可写" if data_writable else "✗ 不可写",
"pass": data_writable,
})
checks.append({
"name": "根目录写入权限 (.env)",
"required": "可写",
"current": "✓ 可写" if root_writable else "✗ 不可写",
"pass": root_writable,
})
all_pass = all(c["pass"] for c in checks)
return {"checks": checks, "all_pass": all_pass}
@router.post("/init-db")
async def init_db(req: InitDbRequest):
"""Step 3: Connect to MySQL, create tables, write configs."""
if _is_installed():
raise HTTPException(400, "系统已安装,不可重复初始化")
db_url = _make_db_url(req)
redis_url = _build_redis_url(req)
try:
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
except Exception as e:
raise HTTPException(400, f"数据库引擎创建失败: {e}")
try:
async with engine.begin() as conn:
# Create tables using SQLAlchemy metadata
from server.domain.models import Base
await conn.run_sync(Base.metadata.create_all)
# Create additional indexes
indexes = [
"ALTER TABLE servers ADD INDEX idx_servers_is_online (is_online)",
"ALTER TABLE servers ADD INDEX idx_servers_category (category)",
"ALTER TABLE servers ADD INDEX idx_servers_platform_id (platform_id)",
"ALTER TABLE servers ADD INDEX idx_servers_node_id (node_id)",
"ALTER TABLE sync_logs ADD INDEX idx_sync_logs_srv_start (server_id, started_at)",
"ALTER TABLE login_attempts ADD INDEX idx_login_attempts_username_ip (username, ip_address)",
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_server_id (server_id)",
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_admin_id (admin_id)",
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_srv_time (server_id, created_at)",
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_session_id (session_id)",
]
for idx_sql in indexes:
try:
await conn.execute(text(idx_sql))
except Exception:
pass # Index may already exist
# Calculate pool_size from max_connections
try:
result = await conn.execute(text("SHOW VARIABLES LIKE 'max_connections'"))
row = result.fetchone()
max_conn = max(100, int(row[1]) if row else 100)
pool_size = max(20, int(max_conn * 0.4))
max_overflow = max(20, int(max_conn * 0.3))
except Exception:
pool_size, max_overflow = 160, 120
# Generate keys
secret_key = secrets.token_hex(32)
api_key = secrets.token_hex(16)
# Insert settings
settings_kv = {
"api_key": api_key,
"secret_key": secret_key,
"system_name": "Nexus",
"system_title": "Nexus — 服务器运维管理平台",
"db_pool_size": str(pool_size),
"db_max_overflow": str(max_overflow),
"redis_url": redis_url,
"heartbeat_timeout": "60",
"cpu_alert_threshold": "80",
"mem_alert_threshold": "80",
"disk_alert_threshold": "80",
"telegram_bot_token": "",
"telegram_chat_id": "",
}
for k, v in settings_kv.items():
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": k, "v": v, "v2": v},
)
except Exception as e:
await engine.dispose()
raise HTTPException(400, f"数据库初始化失败: {e}")
await engine.dispose()
# Write .env
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
_write_env(req, secret_key, api_key, pool_size, max_overflow, redis_url, site_url)
# Write config.php
_write_config_php(req, api_key, site_url)
# Configure process guardian (best-effort)
install_dir = str(ROOT_DIR)
guardian_results = _configure_guardian(install_dir, req.api_port)
# Save install state for step 4
import json
state = {
"db_host": req.db_host,
"db_port": req.db_port,
"db_name": req.db_name,
"db_user": req.db_user,
"db_pass": req.db_pass,
"pool_size": pool_size,
"max_overflow": max_overflow,
"install_dir": install_dir,
"site_url": site_url,
"api_port": req.api_port,
"guardian_results": guardian_results,
"step": 3,
}
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False))
return {
"success": True,
"pool_size": pool_size,
"max_overflow": max_overflow,
"tables_created": 14,
"guardian_results": guardian_results,
}
@router.post("/create-admin")
async def create_admin(req: CreateAdminRequest):
"""Step 4: Create admin account and set brand name."""
if _is_locked():
raise HTTPException(400, "系统已锁定")
if len(req.admin_password) < 6:
raise HTTPException(400, "密码长度至少6位")
db_url = _make_db_url(req)
try:
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
async with engine.begin() as conn:
# Hash password with bcrypt
import bcrypt
password_hash = bcrypt.hashpw(
req.admin_password.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
# Insert admin
await conn.execute(
text(
"INSERT INTO admins (username, password_hash, email) "
"VALUES (:u, :h, :e) "
"ON DUPLICATE KEY UPDATE password_hash = :h2, email = :e2"
),
{
"u": req.admin_username,
"h": password_hash,
"e": req.admin_email or None,
"h2": password_hash,
"e2": req.admin_email or None,
},
)
# Update brand in settings
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": "system_name", "v": req.system_name, "v2": req.system_name},
)
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": "system_title", "v": req.system_title, "v2": req.system_title},
)
await engine.dispose()
except Exception as e:
raise HTTPException(400, f"创建管理员失败: {e}")
# Update config.php with brand
if CONFIG_PHP.exists():
import re
content = CONFIG_PHP.read_text()
content = re.sub(
r"define\('APP_NAME', '.*?'\);",
f"define('APP_NAME', '{req.system_name}');",
content,
)
CONFIG_PHP.write_text(content)
# Update .env with brand
if ENV_FILE.exists():
import re
content = ENV_FILE.read_text()
content = re.sub(
r"NEXUS_SYSTEM_NAME=.*",
f"NEXUS_SYSTEM_NAME={req.system_name}",
content,
)
content = re.sub(
r"NEXUS_SYSTEM_TITLE=.*",
f"NEXUS_SYSTEM_TITLE={req.system_title}",
content,
)
ENV_FILE.write_text(content)
# Update state
if STATE_FILE.exists():
import json
state = json.loads(STATE_FILE.read_text())
state["step"] = 4
state["system_name"] = req.system_name
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False))
return {"success": True}
@router.post("/lock")
async def lock_install():
"""Step 5: Lock the installer — prevent re-installation."""
# Create lock marker files
INSTALL_LOCK.write_text(
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n"
)
# Also create the legacy lock file (install.php.locked) for compatibility
try:
LOCK_FILE.write_text("locked\n")
except OSError:
pass
# Clean up state file
if STATE_FILE.exists():
try:
STATE_FILE.unlink()
except OSError:
pass
return {"success": True}
@router.get("/state")
async def get_install_state():
"""Get current install state (for step navigation)."""
import json
if not STATE_FILE.exists():
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
try:
state = json.loads(STATE_FILE.read_text())
state["installed"] = _is_installed()
state["locked"] = _is_locked()
return state
except Exception:
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
+221
View File
@@ -0,0 +1,221 @@
"""Nexus — Pydantic Request/Response Models
Shared schemas for API validation, replacing raw `dict` parameters.
"""
from typing import Optional, List
from pydantic import BaseModel, Field
# ── Setting ──
class SettingUpdatePayload(BaseModel):
value: str = Field(..., min_length=0)
# ── Agent ──
class AgentHeartbeat(BaseModel):
server_id: int
is_online: bool = True
system_info: Optional[dict] = None
agent_version: Optional[str] = None
class AgentExec(BaseModel):
command: str = Field(..., min_length=1)
timeout: int = Field(30, ge=1, le=300)
sudo: bool = False
# ── Server ──
class ServerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
domain: str = Field(..., min_length=1, max_length=255)
port: int = Field(22, ge=1, le=65535)
username: str = Field("root", max_length=100)
auth_method: str = Field("key", pattern="^(key|password)$")
password: Optional[str] = None
ssh_key_path: Optional[str] = None
ssh_key_private: Optional[str] = None
ssh_key_public: Optional[str] = None
agent_port: int = Field(8601, ge=1, le=65535)
agent_api_key: Optional[str] = None
description: Optional[str] = None
target_path: Optional[str] = None
category: Optional[str] = None
platform_id: Optional[int] = None
node_id: Optional[int] = None
protocols: Optional[list] = None
extra_attrs: Optional[dict] = None
class ServerUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
domain: Optional[str] = Field(None, min_length=1, max_length=255)
port: Optional[int] = Field(None, ge=1, le=65535)
username: Optional[str] = None
auth_method: Optional[str] = Field(None, pattern="^(key|password)$")
password: Optional[str] = None
ssh_key_path: Optional[str] = None
ssh_key_private: Optional[str] = None
ssh_key_public: Optional[str] = None
agent_port: Optional[int] = Field(None, ge=1, le=65535)
agent_api_key: Optional[str] = None
description: Optional[str] = None
target_path: Optional[str] = None
category: Optional[str] = None
platform_id: Optional[int] = None
node_id: Optional[int] = None
protocols: Optional[list] = None
extra_attrs: Optional[dict] = None
connectivity: Optional[str] = None
class ServerPush(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
source_path: str = Field(..., min_length=1)
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
batch_size: int = Field(50, ge=1, le=200)
concurrency: int = Field(10, ge=1, le=50)
class ServerCheck(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
# ── Sync ──
class SyncFiles(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
batch_size: int = Field(50, ge=1, le=200)
concurrency: int = Field(10, ge=1, le=50)
class SyncCommands(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
commands: List[str] = Field(..., min_length=1)
timeout: int = Field(60, ge=1, le=600)
concurrency: int = Field(10, ge=1, le=50)
class SyncConfig(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
config: dict = Field(..., min_length=1)
concurrency: int = Field(10, ge=1, le=50)
class SyncSftp(BaseModel):
server_id: int
operation: str = Field("put", pattern="^(put|get)$")
local_path: str = Field(..., min_length=1)
remote_path: str = Field(..., min_length=1)
class SyncBrowse(BaseModel):
server_id: int
path: str = Field("/", min_length=1)
# ── Script ──
class ScriptCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
category: str = Field("ops", pattern="^(ops|deploy|check|cleanup)$")
content: str = Field(..., min_length=1)
description: Optional[str] = None
created_by: Optional[str] = None
class ScriptUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = Field(None, pattern="^(ops|deploy|check|cleanup)$")
content: Optional[str] = None
description: Optional[str] = None
class ScriptExecute(BaseModel):
script_id: Optional[int] = None
command: str = Field(..., min_length=1)
server_ids: List[int] = Field(..., min_length=1)
timeout: int = Field(60, ge=1, le=600)
# ── DB Credential ──
class DbCredentialCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
db_type: str = Field("mysql", pattern="^(mysql|postgresql|mariadb|mongodb|redis)$")
host: str = Field(..., min_length=1)
port: int = Field(3306, ge=1, le=65535)
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
database: Optional[str] = None
# ── Schedule ──
class ScheduleCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
source_path: str = Field(..., min_length=1)
server_ids: str = Field(..., min_length=3) # JSON array string
cron_expr: str = Field(..., min_length=9, max_length=100)
enabled: bool = True
class ScheduleUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
source_path: Optional[str] = None
server_ids: Optional[str] = None
cron_expr: Optional[str] = None
enabled: Optional[bool] = None
# ── Preset ──
class PresetCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
encrypted_pw: str = Field(..., min_length=1)
# ── Platform / Node ──
class PlatformCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
category: str = Field(..., min_length=1, max_length=50)
type: str = Field(..., min_length=1, max_length=50)
default_protocols: Optional[list] = None
charset: str = "utf-8"
class PlatformUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = None
type: Optional[str] = None
default_protocols: Optional[list] = None
charset: Optional[str] = None
class NodeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
parent_id: Optional[int] = None
sort_order: int = 0
class NodeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
parent_id: Optional[int] = None
sort_order: Optional[int] = None
# ── Pagination ──
class PaginatedResponse(BaseModel):
items: list
total: int
page: int
per_page: int
pages: int
+82 -54
View File
@@ -1,13 +1,19 @@
"""Nexus — Scripts API Routes (Script library CRUD + command execution)
Presentation layer — receives HTTP requests, delegates to ScriptService.
All operations require JWT authentication.
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from server.api.dependencies import get_script_service
from server.api.dependencies import get_script_service, get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ScriptCreate, ScriptUpdate, ScriptExecute, DbCredentialCreate
from server.application.services.script_service import ScriptService
from server.domain.models import Script, DbCredential
from server.domain.models import Script, DbCredential, Admin, AuditLog
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
@@ -17,6 +23,7 @@ router = APIRouter(prefix="/api/scripts", tags=["scripts"])
@router.get("/", response_model=list)
async def list_scripts(
category: Optional[str] = Query(None, description="Filter by category"),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List all scripts, optionally filtered by category"""
@@ -27,6 +34,7 @@ async def list_scripts(
@router.get("/{id}", response_model=dict)
async def get_script(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Get a single script by ID"""
@@ -38,82 +46,90 @@ async def get_script(
@router.post("/", response_model=dict, status_code=201)
async def create_script(
payload: dict,
payload: ScriptCreate,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Create a new script
Body: {
"name": "restart-nginx",
"category": "ops",
"content": "systemctl restart nginx",
"description": "Restart nginx service",
"created_by": "admin"
}
"""
script = Script(**payload)
"""Create a new script"""
script = Script(**payload.model_dump(exclude_none=True))
created = await service.create_script(script)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_script",
target_type="script", target_id=created.id,
detail=f"name={created.name}", ip_address="",
))
return _script_to_dict(created)
@router.put("/{id}", response_model=dict)
async def update_script(
id: int,
payload: dict,
payload: ScriptUpdate,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Update an existing script"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
for key, value in payload.items():
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(script, key) and key != "id":
setattr(script, key, value)
updated = await service.update_script(script)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_script",
target_type="script", target_id=id,
detail=f"name={updated.name}", ip_address="",
))
return _script_to_dict(updated)
@router.delete("/{id}", status_code=204)
async def delete_script(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a script"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
result = await service.delete_script(id)
if not result:
raise HTTPException(status_code=404, detail="Script not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_script",
target_type="script", target_id=id,
detail=f"name={script.name}", ip_address="",
))
# ── Command Execution ──
@router.post("/exec", response_model=dict)
async def execute_command(
payload: dict,
payload: ScriptExecute,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Execute a shell command on multiple servers via Agent /api/exec
Body: {
"command": "systemctl restart nginx", // or script content
"server_ids": [1, 2, 3],
"script_id": null, // Optional: link to saved script
"credential_id": null, // Optional: DB credential for $DB_* vars
"timeout": 30,
"operator": "admin"
}
"""
command = payload.get("command")
server_ids = payload.get("server_ids", [])
if not command or not server_ids:
raise HTTPException(status_code=400, detail="command and server_ids required")
"""Execute a shell command on multiple servers via Agent /api/exec"""
execution = await service.execute_command(
command=command,
server_ids=server_ids,
script_id=payload.get("script_id"),
credential_id=payload.get("credential_id"),
timeout=payload.get("timeout", 30),
operator=payload.get("operator", "admin"),
command=payload.command,
server_ids=payload.server_ids,
script_id=payload.script_id,
timeout=payload.timeout,
operator=admin.username,
)
return _execution_to_dict(execution)
@@ -121,6 +137,7 @@ async def execute_command(
@router.get("/executions/{id}", response_model=dict)
async def get_execution(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Get execution result by ID"""
@@ -134,6 +151,7 @@ async def get_execution(
@router.get("/credentials", response_model=list)
async def list_credentials(
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List all database credentials"""
@@ -143,36 +161,46 @@ async def list_credentials(
@router.post("/credentials", response_model=dict, status_code=201)
async def create_credential(
payload: dict,
payload: DbCredentialCreate,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Create a database credential (password will be encrypted)
Body: {
"name": "production-mysql",
"db_type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"encrypted_password": "actual_password", // Will be encrypted before storage
"database": "app_db"
}
"""
credential = DbCredential(**payload)
"""Create a database credential (password will be encrypted)"""
data = payload.model_dump(exclude_none=True)
data["encrypted_password"] = data.pop("password", "")
credential = DbCredential(**data)
created = await service.create_credential(credential)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_credential",
target_type="credential", target_id=created.id,
detail=f"name={created.name}", ip_address="",
))
return _credential_to_dict(created)
@router.delete("/credentials/{id}", status_code=204)
async def delete_credential(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a database credential"""
result = await service.delete_credential(id)
if not result:
raise HTTPException(status_code=404, detail="Credential not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_credential",
target_type="credential", target_id=id,
ip_address="",
))
# ── Helper functions ──
@@ -214,4 +242,4 @@ def _credential_to_dict(credential: DbCredential) -> dict:
"username": credential.username,
"database": credential.database,
"created_at": str(credential.created_at) if credential.created_at else None,
}
}
+141 -35
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,11 +12,15 @@ from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from server.api.dependencies import get_server_service, get_sync_service, get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ServerCreate, ServerUpdate, ServerPush, ServerCheck
from server.application.services.server_service import ServerService
from server.application.services.sync_service import SyncService
from server.domain.models import Server
from server.domain.models import Server, SyncLog, Admin, AuditLog
from server.infrastructure.redis.client import get_redis
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("nexus.servers")
@@ -27,12 +32,15 @@ REDIS_KEY_PREFIX = "heartbeat:"
# ── CRUD ──
@router.get("/", response_model=list)
@router.get("/", response_model=dict)
async def list_servers(
category: Optional[str] = Query(None, description="Filter by category"),
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(50, ge=1, le=200, description="Items per page"),
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""List all servers with live status from Redis
"""List servers with live status from Redis (paginated)
Base info (name/IP/category) from MySQL, real-time status from Redis.
If Redis heartbeat key exists, override is_online/system_info/last_heartbeat.
@@ -40,8 +48,13 @@ async def list_servers(
servers = await service.list_servers(category)
redis = get_redis()
total = len(servers)
start = (page - 1) * per_page
end = start + per_page
page_servers = servers[start:end]
result = []
for server in servers:
for server in page_servers:
server_data = _server_to_dict(server)
# Overlay Redis heartbeat data (real-time)
@@ -56,18 +69,68 @@ async def list_servers(
pass # Keep MySQL value
server_data["last_heartbeat"] = heartbeat.get("last_heartbeat", server_data.get("last_heartbeat"))
server_data["agent_version"] = heartbeat.get("agent_version", server_data.get("agent_version", ""))
server_data["_source"] = "redis" # Frontend knows data is fresh
server_data["_source"] = "redis"
except Exception as e:
logger.warning(f"Redis read failed for server {server.id}: {e}")
result.append(server_data)
return result
return {
"items": result,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
}
# ── Dashboard Stats (must be before /{id} to avoid path collision) ──
@router.get("/stats", response_model=dict)
async def server_stats(
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""Aggregated server stats for dashboard — efficient single query"""
servers = await service.list_servers()
redis = get_redis()
total = len(servers)
online = 0
offline = 0
alerts = 0
for server in servers:
try:
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{server.id}")
is_online = heartbeat.get("is_online") == "True" if heartbeat else server.is_online
if await redis.exists(f"alerts:{server.id}"):
alerts += 1
except Exception:
is_online = server.is_online
if is_online:
online += 1
else:
offline += 1
categories = {}
for server in servers:
cat = server.category or "uncategorized"
categories[cat] = categories.get(cat, 0) + 1
return {
"total": total,
"online": online,
"offline": offline,
"alerts": alerts,
"categories": categories,
}
@router.get("/{id}", response_model=dict)
async def get_server(
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""Get a single server by ID with live status from Redis"""
@@ -99,94 +162,137 @@ async def get_server(
@router.post("/", response_model=dict, status_code=201)
async def create_server(
server_data: dict,
payload: ServerCreate,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Create a new server"""
server = Server(**server_data)
server = Server(**payload.model_dump(exclude_none=True))
created = await service.create_server(server)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_server",
target_type="server",
target_id=created.id,
detail=f"name={created.name} domain={created.domain}",
ip_address="",
))
return _server_to_dict(created)
@router.put("/{id}", response_model=dict)
async def update_server(
id: int,
server_data: dict,
payload: ServerUpdate,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Update an existing server"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
for key, value in server_data.items():
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(server, key) and key != "id":
setattr(server, key, value)
updated = await service.update_server(server)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_server",
target_type="server",
target_id=id,
detail=f"name={updated.name}",
ip_address="",
))
return _server_to_dict(updated)
@router.delete("/{id}", status_code=204)
async def delete_server(
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a server"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
result = await service.delete_server(id)
if not result:
raise HTTPException(status_code=404, detail="Server not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_server",
target_type="server",
target_id=id,
detail=f"name={server.name}",
ip_address="",
))
# ── Health Check ──
@router.post("/check", response_model=dict)
async def check_servers(
server_ids: List[int],
payload: ServerCheck,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""Check health status of specified servers (Agent-based, no SSH)"""
return await service.check_all_servers(server_ids)
return await service.check_all_servers(payload.server_ids)
# ── Push ──
@router.post("/push", response_model=dict)
async def push_to_servers(
payload: dict,
payload: ServerPush,
admin: Admin = Depends(get_current_admin),
sync_service: SyncService = Depends(get_sync_service),
):
"""Push files to multiple servers (batch mode with concurrency control)
Body: {
"server_ids": [1, 2, 3, ...],
"source_path": "/path/to/files",
"sync_mode": "incremental",
"operator": "admin",
"batch_size": 50,
"concurrency": 10
}
"""
server_ids = payload.get("server_ids", [])
source_path = payload.get("source_path")
if not server_ids or not source_path:
raise HTTPException(status_code=400, detail="server_ids and source_path required")
"""Push files to multiple servers (batch mode with concurrency control)"""
results = await sync_service.batch_push(
server_ids=server_ids,
source_path=source_path,
sync_mode=payload.get("sync_mode", "incremental"),
operator=payload.get("operator", "admin"),
batch_size=payload.get("batch_size", 50),
concurrency=payload.get("concurrency", 10),
server_ids=payload.server_ids,
source_path=payload.source_path,
sync_mode=payload.sync_mode,
operator=admin.username,
batch_size=payload.batch_size,
concurrency=payload.concurrency,
)
return {sid: _sync_log_to_dict(log) for sid, log in results.items()}
# ── Sync Logs ──
@router.get("/logs", response_model=list)
async def get_all_sync_logs(
limit: int = Query(100, ge=1, le=500),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Get recent sync logs across all servers (for dashboard charts)"""
result = await db.execute(
select(SyncLog).order_by(SyncLog.started_at.desc()).limit(limit)
)
logs = result.scalars().all()
return [_sync_log_to_dict(log) for log in logs]
@router.get("/{id}/logs", response_model=list)
async def get_server_logs(
id: int,
limit: int = Query(50, ge=1, le=200),
admin: Admin = Depends(get_current_admin),
sync_service: SyncService = Depends(get_sync_service),
):
"""Get sync logs for a specific server"""
+141 -40
View File
@@ -1,50 +1,86 @@
"""Nexus — Settings & Schedules API Routes
Presentation layer — system settings + push schedules + password presets + audit logs.
Security: All write operations require JWT authentication.
Immutable settings (secret_key, api_key, encryption_key, database_url) cannot be modified via API.
Sensitive values are masked in GET responses.
"""
from typing import Optional, List
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, SettingUpdatePayload
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog, Admin
from sqlalchemy.ext.asyncio import AsyncSession
# Settings that cannot be modified via API (encryption consistency)
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
# Settings whose values are masked in GET responses
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "redis_url"}
router = APIRouter(prefix="/api/settings", tags=["settings"])
# ── System Settings ──
@router.get("/", response_model=list)
async def list_settings(db: AsyncSession = Depends(get_db)):
"""List all system settings"""
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all system settings (sensitive values masked)"""
repo = SettingRepositoryImpl(db)
settings = await repo.get_all()
return [{"key": s.key, "value": s.value, "updated_at": str(s.updated_at)} for s in settings]
result = []
for s in settings:
val = s.value
if s.key in SENSITIVE_KEYS and val:
val = val[:8] + "..." if len(val) > 8 else "***"
result.append({"key": s.key, "value": val, "updated_at": str(s.updated_at)})
return result
@router.get("/{key}", response_model=dict)
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
"""Get a single setting by key"""
async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""Get a single setting by key (sensitive values masked)"""
repo = SettingRepositoryImpl(db)
value = await repo.get(key)
if value is None:
raise HTTPException(status_code=404, detail="Setting not found")
if key in SENSITIVE_KEYS and value:
value = value[:8] + "..." if len(value) > 8 else "***"
return {"key": key, "value": value}
@router.put("/{key}", response_model=dict)
async def set_setting(key: str, payload: dict, db: AsyncSession = Depends(get_db)):
"""Set a system setting value"""
async def set_setting(
key: str,
payload: SettingUpdatePayload,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Set a system setting value (immutable keys are rejected)"""
if key in IMMUTABLE_KEYS:
raise HTTPException(status_code=403, detail=f"Setting '{key}' is immutable and cannot be modified via API")
repo = SettingRepositoryImpl(db)
value = payload.get("value")
if value is None:
raise HTTPException(status_code=400, detail="value required")
setting = await repo.set(key, str(value))
setting = await repo.set(key, str(payload.value))
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_setting",
target_type="setting",
detail=f"key={key}",
ip_address="",
))
return {"key": setting.key, "value": setting.value}
@@ -54,7 +90,7 @@ schedule_router = APIRouter(prefix="/api/schedules", tags=["schedules"])
@schedule_router.get("/", response_model=list)
async def list_schedules(db: AsyncSession = Depends(get_db)):
async def list_schedules(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all push schedules"""
repo = PushScheduleRepositoryImpl(db)
schedules = await repo.get_all()
@@ -62,45 +98,80 @@ async def list_schedules(db: AsyncSession = Depends(get_db)):
@schedule_router.post("/", response_model=dict, status_code=201)
async def create_schedule(payload: dict, db: AsyncSession = Depends(get_db)):
"""Create a new push schedule
Body: {
"name": "Daily web push",
"source_path": "/www/wwwroot/site",
"server_ids": "[1,2,3]",
"cron_expr": "0 2 * * *",
"enabled": true
}
"""
async def create_schedule(
payload: ScheduleCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new push schedule"""
repo = PushScheduleRepositoryImpl(db)
schedule = PushSchedule(**payload)
schedule = PushSchedule(**payload.model_dump())
created = await repo.create(schedule)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_schedule",
target_type="schedule",
target_id=created.id,
detail=f"name={created.name}",
ip_address="",
))
return _schedule_to_dict(created)
@schedule_router.put("/{id}", response_model=dict)
async def update_schedule(id: int, payload: dict, db: AsyncSession = Depends(get_db)):
async def update_schedule(
id: int,
payload: ScheduleUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a push schedule"""
repo = PushScheduleRepositoryImpl(db)
schedule = await repo.get_by_id(id)
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
for key, value in payload.items():
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(schedule, key) and key != "id":
setattr(schedule, key, value)
updated = await repo.update(schedule)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_schedule",
target_type="schedule",
target_id=id,
detail=f"name={updated.name}",
ip_address="",
))
return _schedule_to_dict(updated)
@schedule_router.delete("/{id}", status_code=204)
async def delete_schedule(id: int, db: AsyncSession = Depends(get_db)):
async def delete_schedule(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a push schedule"""
repo = PushScheduleRepositoryImpl(db)
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Schedule not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_schedule",
target_type="schedule",
target_id=id,
ip_address="",
))
# ── Password Presets ──
@@ -108,7 +179,7 @@ preset_router = APIRouter(prefix="/api/presets", tags=["presets"])
@preset_router.get("/", response_model=list)
async def list_presets(db: AsyncSession = Depends(get_db)):
async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all password presets"""
repo = PasswordPresetRepositoryImpl(db)
presets = await repo.get_all()
@@ -116,27 +187,52 @@ async def list_presets(db: AsyncSession = Depends(get_db)):
@preset_router.post("/", response_model=dict, status_code=201)
async def create_preset(payload: dict, db: AsyncSession = Depends(get_db)):
"""Create a password preset (password will be encrypted)
Body: {"name": "root-default", "encrypted_pw": "actual_password"}
"""
async def create_preset(
payload: PresetCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a password preset (password will be encrypted)"""
from server.infrastructure.database.crypto import encrypt_value
repo = PasswordPresetRepositoryImpl(db)
payload["encrypted_pw"] = encrypt_value(payload.get("encrypted_pw", ""))
preset = PasswordPreset(**payload)
encrypted = encrypt_value(payload.encrypted_pw)
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
created = await repo.create(preset)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_preset",
target_type="preset",
target_id=created.id,
detail=f"name={created.name}",
ip_address="",
))
return {"id": created.id, "name": created.name}
@preset_router.delete("/{id}", status_code=204)
async def delete_preset(id: int, db: AsyncSession = Depends(get_db)):
async def delete_preset(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a password preset"""
repo = PasswordPresetRepositoryImpl(db)
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Preset not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_preset",
target_type="preset",
target_id=id,
ip_address="",
))
# ── Audit Logs ──
@@ -147,6 +243,7 @@ audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
async def list_audit_logs(
action: Optional[str] = None,
limit: int = 200,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List recent audit logs, optionally filtered by action"""
@@ -164,7 +261,11 @@ retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
@retry_router.get("/", response_model=list)
async def list_retry_jobs(limit: int = 100, db: AsyncSession = Depends(get_db)):
async def list_retry_jobs(
limit: int = 100,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List pending retry jobs"""
repo = PushRetryJobRepositoryImpl(db)
jobs = await repo.get_pending(limit)
@@ -213,4 +314,4 @@ def _retry_to_dict(job: PushRetryJob) -> dict:
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
"last_error": job.last_error,
"created_at": str(job.created_at) if job.created_at else None,
}
}
+74 -37
View File
@@ -8,6 +8,7 @@ S4: POST /api/sync/sftp — SFTP transfer
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncCommands, SyncConfig, SyncSftp, SyncBrowse
from server.api.dependencies import get_server_service
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -33,83 +34,119 @@ async def _get_sync_engine(request: Request) -> SyncEngineV2:
@router.post("/files")
async def sync_files(
payload: dict,
payload: SyncFiles,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""S1: File sync via rsync over SSH (with parallel execution)"""
engine = await _get_sync_engine(request)
server_ids = payload.get("server_ids", [])
source_path = payload.get("source_path")
if not server_ids or not source_path:
raise HTTPException(status_code=400, detail="server_ids and source_path required")
return await engine.sync_files(
server_ids=server_ids,
source_path=source_path,
target_path=payload.get("target_path"),
sync_mode=payload.get("sync_mode", "incremental"),
server_ids=payload.server_ids,
source_path=payload.source_path,
target_path=payload.target_path,
sync_mode=payload.sync_mode,
operator=admin.username,
batch_size=payload.get("batch_size", 50),
concurrency=payload.get("concurrency", 10),
batch_size=payload.batch_size,
concurrency=payload.concurrency,
)
@router.post("/commands")
async def sync_commands(
payload: dict,
payload: SyncCommands,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""S2: Execute commands on multiple servers via SSH in parallel"""
engine = await _get_sync_engine(request)
server_ids = payload.get("server_ids", [])
commands = payload.get("commands", [])
if not server_ids or not commands:
raise HTTPException(status_code=400, detail="server_ids and commands required")
return await engine.sync_commands(
server_ids=server_ids,
commands=commands,
server_ids=payload.server_ids,
commands=payload.commands,
operator=admin.username,
timeout=payload.get("timeout", 60),
concurrency=payload.get("concurrency", 10),
timeout=payload.timeout,
concurrency=payload.concurrency,
)
@router.post("/config")
async def sync_config(
payload: dict,
payload: SyncConfig,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""S3: Push system configuration changes to multiple servers"""
engine = await _get_sync_engine(request)
server_ids = payload.get("server_ids", [])
config_updates = payload.get("config", {})
if not server_ids or not config_updates:
raise HTTPException(status_code=400, detail="server_ids and config required")
return await engine.sync_config(
server_ids=server_ids,
config_updates=config_updates,
server_ids=payload.server_ids,
config_updates=payload.config,
operator=admin.username,
concurrency=payload.get("concurrency", 10),
concurrency=payload.concurrency,
)
@router.post("/sftp")
async def sftp_transfer(
payload: dict,
payload: SyncSftp,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""S4: SFTP file transfer to/from a server"""
engine = await _get_sync_engine(request)
return await engine.sftp_transfer(
server_id=payload["server_id"],
operation=payload.get("operation", "put"),
local_path=payload["local_path"],
remote_path=payload["remote_path"],
server_id=payload.server_id,
operation=payload.operation,
local_path=payload.local_path,
remote_path=payload.remote_path,
operator=admin.username,
)
)
@router.post("/browse")
async def browse_directory(
payload: SyncBrowse,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Browse remote directory listing via SSH"""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.database.server_repo import ServerRepositoryImpl
session = request.state.db
server_id = payload.server_id
path = payload.path
if not server_id:
raise HTTPException(status_code=400, detail="server_id required")
repo = ServerRepositoryImpl(session)
server = await repo.get_by_id(server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
# Use ls -la for detailed listing, parse output
import shlex
result = await exec_ssh_command(server, f"ls -la {shlex.quote(path)}", timeout=10)
if result["exit_code"] != 0:
return {"path": path, "entries": [], "error": result["stderr"][:500]}
entries = []
for line in result["stdout"].split("\n")[1:]: # Skip "total" line
if not line.strip():
continue
parts = line.split(None, 8)
if len(parts) >= 9:
perms, _, owner, group, size, *date_parts, name = (
parts[0], parts[1], parts[2], parts[3], parts[4],
parts[5:-1], parts[-1]
)
is_dir = parts[0].startswith("d")
entries.append({
"name": parts[-1],
"is_dir": is_dir,
"size": parts[4],
"perms": parts[0],
"owner": parts[2],
"modified": " ".join(parts[5:8]),
})
return {"path": path, "entries": entries}
+6 -8
View File
@@ -248,22 +248,20 @@ async def alert_ws(
async def _verify_ws_token(token: str):
"""Verify JWT token for WebSocket connection (same logic as get_current_admin)"""
try:
from server.application.services.auth_service import AuthService
from server.infrastructure.redis.client import get_redis_sync
# Use get_redis_sync since this might be called during shutdown
import jwt as pyjwt
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from server.infrastructure.database.login_attempt_repo import LoginAttemptRepositoryImpl # noqa
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
# Quick JWT decode without full service
import jwt as pyjwt
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
admin_id = payload.get("sub")
if not admin_id:
return None
try:
admin_id = int(admin_id)
except (ValueError, TypeError):
return None
async with AsyncSessionLocal() as session:
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
+11 -1
View File
@@ -148,7 +148,7 @@ class AuthService:
return admin
async def logout(self, admin_id: int) -> dict:
"""Invalidate refresh token"""
"""Invalidate refresh token by admin ID"""
admin = await self.admin_repo.get_by_id(admin_id)
if admin:
admin.jwt_refresh_token = None
@@ -156,6 +156,16 @@ class AuthService:
await self.admin_repo.update(admin)
return {"success": True, "message": "已登出"}
async def logout_by_token(self, refresh_token: str) -> dict:
"""Invalidate refresh token by token value (used by frontend logout)"""
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
if admin:
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
await self.admin_repo.update(admin)
await self._audit("logout", "admin", admin.id, f"Logout: {admin.username}")
return {"success": True, "message": "已登出"}
async def setup_totp(self, admin_id: int) -> dict:
"""Generate TOTP secret for an admin user (before enabling)"""
import base64
+11 -5
View File
@@ -161,16 +161,20 @@ class ScriptService:
async def test_credential_connection(self, credential: DbCredential) -> dict:
"""Test database connection using provided credentials (before saving)"""
# This runs a simple connectivity test via Agent /api/exec
# e.g., mysql -h $host -u $user -p'$pass' -e 'SELECT 1'
import shlex
from server.infrastructure.database.crypto import decrypt_value
password = credential.encrypted_password # Not yet encrypted if testing before save
if credential.id:
password = decrypt_value(credential.encrypted_password)
test_cmd = f"mysql -h {credential.host} -P {credential.port} -u {credential.username} -p'{password}' -e 'SELECT 1' 2>/dev/null && echo 'OK' || echo 'FAIL'"
# Pick any online server to test from (or the Nexus server itself)
test_cmd = (
f"mysql -h {shlex.quote(credential.host)} "
f"-P {credential.port} "
f"-u {shlex.quote(credential.username)} "
f"-p{shlex.quote(password)} "
f"-e 'SELECT 1' 2>/dev/null && echo 'OK' || echo 'FAIL'"
)
return {"status": "test_command_ready", "command": test_cmd}
# ── Private helpers ──
@@ -204,7 +208,9 @@ class ScriptService:
) -> dict:
"""Call Agent /api/exec endpoint to run a command on a remote server"""
url = f"http://{host}:{port}/api/exec"
headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
headers = {"Content-Type": "application/json"}
if api_key:
headers["X-API-Key"] = api_key
payload = {"command": command, "timeout": timeout}
async with httpx.AsyncClient(timeout=timeout + 5) as client:
+53 -33
View File
@@ -9,7 +9,6 @@ from typing import Optional, List, Dict
from server.domain.models import Server, SyncLog
from server.domain.repositories import ServerRepository, SyncLogRepository
from server.infrastructure.ssh.pool import SSHConfig, exec_command
from server.infrastructure.redis.client import get_redis
logger = logging.getLogger("nexus.server_service")
@@ -48,17 +47,38 @@ class ServerService:
await self.server_repo.update_heartbeat(id, is_online, system_info, agent_version)
async def check_all_servers(self, server_ids: List[int]) -> Dict[int, dict]:
"""Check health of multiple servers via Agent API (no SSH)"""
"""Check health of multiple servers via Agent API (async concurrent)"""
import httpx
results = {}
# TODO: Use httpx to call each Agent's /health endpoint
for id in server_ids:
server = await self.server_repo.get_by_id(id)
if server and server.is_online:
results[id] = {"status": "online", "system_info": json.loads(server.system_info or "{}")}
elif server:
results[id] = {"status": "offline"}
async def _check_one(server: Server) -> tuple:
url = f"http://{server.domain}:{server.agent_port}/health"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
headers = {}
if server.agent_api_key:
headers["X-API-Key"] = server.agent_api_key
resp = await client.get(url, headers=headers)
if resp.status_code == 200:
data = resp.json()
return server.id, {"status": "online", "system_info": data}
return server.id, {"status": "offline", "error": f"HTTP {resp.status_code}"}
except Exception as e:
return server.id, {"status": "offline", "error": str(e)[:200]}
servers = []
for sid in server_ids:
server = await self.server_repo.get_by_id(sid)
if server:
servers.append(server)
else:
results[id] = {"status": "not_found"}
results[sid] = {"status": "not_found"}
if servers:
check_results = await asyncio.gather(*[_check_one(s) for s in servers])
for sid, result in check_results:
results[sid] = result
return results
async def push_to_servers(
@@ -67,29 +87,29 @@ class ServerService:
source_path: str,
sync_mode: str = "incremental",
operator: str = "admin",
batch_size: int = 50,
concurrency: int = 10,
) -> Dict[int, SyncLog]:
"""Push files to multiple servers in batches"""
results = {}
servers = []
for id in server_ids:
server = await self.server_repo.get_by_id(id)
if server:
servers.append(server)
"""Push files to multiple servers — delegates to SyncService.batch_push.
# TODO: Implement batch push with asyncio.Semaphore(10)
# batch_size=50, interval=30s
for server in servers:
target_path = server.target_path or f"/www/wwwroot/"
log = SyncLog(
server_id=server.id,
source_path=source_path,
target_path=target_path,
trigger_type="batch",
operator=operator,
status="pending",
sync_mode=sync_mode,
)
log = await self.sync_log_repo.create(log)
results[server.id] = log
Note: API routes should use SyncService directly via Depends(get_sync_service).
This method exists for internal callers (e.g. schedule_runner) that already
have a ServerService instance.
"""
from server.application.services.sync_service import SyncService
return results
# Build a SyncService sharing our existing repos + the extra ones it needs
sync_service = SyncService(
server_repo=self.server_repo,
sync_log_repo=self.sync_log_repo,
audit_repo=None, # audit handled by caller if needed
retry_repo=None, # retry handled by caller if needed
)
return await sync_service.batch_push(
server_ids=server_ids,
source_path=source_path,
sync_mode=sync_mode,
operator=operator,
batch_size=batch_size,
concurrency=concurrency,
)
+12 -5
View File
@@ -55,6 +55,7 @@ class SyncEngineV2:
operator: str = "admin",
batch_size: int = 50,
concurrency: int = 10,
trigger_type: str = "manual",
) -> dict:
"""S1: File sync using rsync over SSH"""
concurrency = min(concurrency, MAX_CONCURRENT)
@@ -80,7 +81,7 @@ class SyncEngineV2:
server_id=server.id,
source_path=source_path,
target_path=target_path or server.target_path or "/tmp/sync",
trigger_type="manual",
trigger_type=trigger_type,
operator=operator,
status="running",
sync_mode=sync_mode,
@@ -89,7 +90,8 @@ class SyncEngineV2:
sync_log = await self.sync_log_repo.create(sync_log)
# Execute rsync
rsync_cmd = f"rsync -az --delete {source_path}/ {target_path or server.target_path or '/tmp/sync'}/"
import shlex
rsync_cmd = f"rsync -az --delete {shlex.quote(source_path)}/ {shlex.quote(target_path or server.target_path or '/tmp/sync')}/"
result = await exec_ssh_command(server, rsync_cmd, timeout=300)
# Update sync log
@@ -201,12 +203,17 @@ class SyncEngineV2:
Config updates are applied as shell commands:
- `sysctl` for kernel params
- `echo` for /etc/sysctl.conf entries
- Custom script for other config types
"""
import re, shlex
commands = []
for key, value in config_updates.items():
commands.append(f"sysctl -w {key}={value}")
commands.append(f"echo '{key}={value}' >> /etc/sysctl.d/99-nexus.conf")
# Sanitize: only allow alphanumeric/dot/underscore/dash keys
if not re.match(r'^[a-zA-Z0-9._-]+$', str(key)):
logger.warning(f"Skipping invalid config key: {key!r}")
continue
safe_value = shlex.quote(str(value))
commands.append(f"sysctl -w {key}={safe_value}")
commands.append(f"echo {key}={safe_value} >> /etc/sysctl.d/99-nexus.conf")
return await self.sync_commands(
server_ids=server_ids,
+32 -51
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")
@@ -146,8 +148,11 @@ class SyncService:
async def _push_single(
self, server: Server, source_path: str, sync_mode: str, operator: str,
) -> SyncLog:
"""Push files to a single server via rsync-over-SSH"""
target_path = server.target_path or "/www/wwwroot/"
"""Push files to a single server via rsync-over-SSH (asyncssh pool)
W3: Uses asyncssh pool instead of paramiko. rsync command built with shlex.quote().
"""
target_path = server.target_path or "/tmp/nexus-sync"
# Build rsync flags based on sync_mode
rsync_flags = self._rsync_flags(sync_mode)
@@ -164,46 +169,21 @@ class SyncService:
)
log = await self.sync_log_repo.create(log)
start_time = __import__("datetime").datetime.now(timezone.utc)
start_time = datetime.now(timezone.utc)
try:
# Build SSH config from server model
ssh_config = SSHConfig.from_server(server)
# Build rsync command with safe quoting (W3: shlex.quote prevents injection)
rsync_cmd = (
f"rsync {rsync_flags} "
f"-e 'ssh -p {server.port} -o StrictHostKeyChecking=no' "
f"{shlex.quote(source_path)}/ "
f"{shlex.quote(server.username or 'root')}@{shlex.quote(server.domain)}:{shlex.quote(target_path)}/"
)
# Build rsync command
# For SSH Key: use in-memory key written to temp file
key_file = None
ssh_opts = "-o StrictHostKeyChecking=no"
# Execute rsync via asyncssh pool
result = await exec_ssh_command(server, rsync_cmd, timeout=DEFAULT_TIMEOUT)
if server.ssh_key_configured and server.ssh_key_private:
# Write decrypted key to temp file (600 permission)
from server.infrastructure.database.crypto import decrypt_value
key_content = decrypt_value(server.ssh_key_private)
key_file = tempfile.NamedTemporaryFile(mode='w', suffix='_key', delete=False)
key_file.write(key_content)
key_file.close()
os.chmod(key_file.name, 0o600)
ssh_opts += f" -i {key_file.name}"
elif server.auth_method == "password" and server.password:
# Use sshpass with environment variable (not -p flag)
# sshpass -e SSHPASS=<password> rsync -e "sshpass -e ssh ..."
from server.infrastructure.database.crypto import decrypt_value
password = decrypt_value(server.password)
ssh_opts = f"sshpass -e ssh {ssh_opts}"
rsync_cmd = f"rsync {rsync_flags} -e 'ssh -p {server.port} {ssh_opts}' {source_path} {server.username}@{server.domain}:{target_path}"
# Execute rsync via SSH
result = await exec_command(ssh_config, rsync_cmd, timeout=DEFAULT_TIMEOUT)
# Clean up temp key file
if key_file:
try:
os.unlink(key_file.name)
except OSError:
pass
duration = int((__import__("datetime").datetime.now(timezone.utc) - start_time).total_seconds())
duration = int((datetime.now(timezone.utc) - start_time).total_seconds())
# Parse rsync output for file statistics
stdout = result.get("stdout", "")
@@ -215,14 +195,14 @@ class SyncService:
files_transferred=files_transferred,
duration_seconds=duration,
diff_summary=stdout[:2000],
finished_at=__import__("datetime").datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
)
else:
log = await self.sync_log_repo.update_status(
log.id, "failed",
error_message=result.get("stderr", "Unknown error")[:2000],
duration_seconds=duration,
finished_at=__import__("datetime").datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
)
except Exception as e:
@@ -230,7 +210,7 @@ class SyncService:
log = await self.sync_log_repo.update_status(
log.id, "failed",
error_message=str(e)[:2000],
finished_at=__import__("datetime").datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
)
return log
@@ -240,15 +220,17 @@ class SyncService:
async def _add_retry(self, server: Server, source_path: str, operator: str):
"""Add failed push to retry queue"""
if not self.retry_repo:
return
from server.domain.models import PushRetryJob
from datetime import datetime, timezone, timedelta
from datetime import timedelta
job = PushRetryJob(
server_id=server.id,
server_name=server.name,
operator=operator,
source_path=source_path,
target_path=server.target_path or "/www/wwwroot/",
target_path=server.target_path or "/tmp/nexus-sync",
status="pending",
next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5),
)
@@ -269,17 +251,16 @@ class SyncService:
def _parse_rsync_transferred(self, stdout: str) -> int:
"""Parse rsync output to count transferred files"""
# rsync -v output contains lines like: "sent X bytes received Y bytes Z bytes/sec"
# and a summary line with file counts
for line in stdout.split("\n"):
if "Number of regular files transferred:" in line:
try:
return int(line.split(":")[1].strip())
except (ValueError, IndexError):
pass
# Fallback: count non-summary lines
return 0
async def _audit(self, action: str, target_type: str, target_id: int, detail: str):
if not self.audit_repo:
return
log = AuditLog(action=action, target_type=target_type, target_id=target_id, detail=detail)
await self.audit_repo.create(log)
await self.audit_repo.create(log)
+3 -2
View File
@@ -53,7 +53,7 @@ async def heartbeat_flush_loop():
continue
if not keys:
logger.debug("No heartbeat keys in Redis — skip flush")
logger.info("No heartbeat keys in Redis — no agents reporting yet")
continue
flushed = 0
@@ -90,7 +90,8 @@ async def heartbeat_flush_loop():
)
flushed += 1
except Exception as e:
logger.error(f"Failed to flush server {key}: {e}")
logger.warning(f"Failed to flush server {key}: {e}")
await session.rollback()
await session.commit()
logger.info(f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL")
+95
View File
@@ -0,0 +1,95 @@
"""Nexus — Retry Runner Background Task
Every 5 minutes: check pending PushRetryJobs, retry failed pushes with exponential backoff.
"""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from server.infrastructure.database.session import AsyncSessionLocal
from server.domain.models import PushRetryJob
logger = logging.getLogger("nexus.retry_runner")
RETRY_CHECK_INTERVAL = 300 # 5 minutes
RETRY_BACKOFF_BASE = 60 # seconds — doubles each retry
async def retry_runner_loop():
"""Background task: every 5min, retry pending retry jobs that are due."""
logger.info("Retry runner loop started (interval: 5min)")
while True:
await asyncio.sleep(RETRY_CHECK_INTERVAL)
try:
async with AsyncSessionLocal() as session:
from sqlalchemy import select
now = datetime.now(timezone.utc)
result = await session.execute(
select(PushRetryJob).where(
PushRetryJob.status == "pending",
PushRetryJob.retry_count < PushRetryJob.max_retries,
PushRetryJob.next_retry_at <= now,
)
)
jobs = result.scalars().all()
if not jobs:
continue
retried = 0
for job in jobs:
try:
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
engine = SyncEngineV2(
server_repo=ServerRepositoryImpl(session),
sync_log_repo=SyncLogRepositoryImpl(session),
audit_repo=AuditLogRepositoryImpl(session),
retry_repo=PushRetryJobRepositoryImpl(session),
)
sync_result = await engine.sync_files(
server_ids=[job.server_id],
source_path=job.source_path,
target_path=job.target_path,
trigger_type="retry",
operator=job.operator or "retry_runner",
)
job.retry_count += 1
if sync_result["failed"] == 0:
job.status = "completed"
logger.info(f"Retry job {job.id}: success after {job.retry_count} attempts")
else:
# Still failing — schedule next retry with backoff
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
job.next_retry_at = now + timedelta(seconds=backoff)
job.last_error = f"Retry {job.retry_count} failed"
logger.warning(
f"Retry job {job.id}: attempt {job.retry_count} failed, "
f"next retry in {backoff}s"
)
await session.commit()
retried += 1
except Exception as e:
logger.error(f"Retry job {job.id} execution error: {e}")
job.retry_count += 1
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
job.next_retry_at = now + timedelta(seconds=backoff)
job.last_error = str(e)[:500]
await session.commit()
if retried:
logger.info(f"Retry runner: {retried} jobs processed")
except Exception as e:
logger.error(f"Retry runner check failed: {e}")
+137
View File
@@ -0,0 +1,137 @@
"""Nexus — Schedule Runner Background Task
Every 60 seconds: check PushSchedule cron expressions, trigger due schedules.
"""
import asyncio
import json
import logging
from datetime import datetime, timezone
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis.client import get_redis_sync
from server.domain.models import PushSchedule, SyncLog, AuditLog
logger = logging.getLogger("nexus.schedule_runner")
SCHEDULE_CHECK_INTERVAL = 60 # seconds
def _cron_match(cron_expr: str, now: datetime) -> bool:
"""Check if current time matches a 5-field cron expression (min hour dom month dow).
Supports: * (any), */N (step), specific values, comma-separated lists.
"""
parts = cron_expr.strip().split()
if len(parts) != 5:
return False
fields = [
(now.minute, range(0, 60)),
(now.hour, range(0, 24)),
(now.day, range(1, 32)),
(now.month, range(1, 13)),
((now.weekday() + 1) % 7, range(0, 7)), # cron: 0=Sunday, Python weekday(): 0=Monday
]
for (current_val, _), field_expr in zip(fields, parts):
if not _field_match(field_expr, current_val):
return False
return True
def _field_match(expr: str, value: int) -> bool:
"""Match a single cron field expression against a value."""
for part in expr.split(","):
if part == "*":
return True
if part.startswith("*/"):
step = int(part[2:])
return value % step == 0
if "-" in part:
low, high = part.split("-", 1)
if int(low) <= value <= int(high):
return True
else:
# cron: 7 also means Sunday (=0)
if value == int(part) or (int(part) == 7 and value == 0):
return True
return False
async def schedule_runner_loop():
"""Background task: check schedules every 60s, trigger due cron jobs."""
logger.info("Schedule runner loop started (interval: 60s)")
while True:
await asyncio.sleep(SCHEDULE_CHECK_INTERVAL)
try:
async with AsyncSessionLocal() as session:
from sqlalchemy import select
result = await session.execute(
select(PushSchedule).where(PushSchedule.enabled == True)
)
schedules = result.scalars().all()
now = datetime.now(timezone.utc)
triggered = 0
for schedule in schedules:
try:
if not _cron_match(schedule.cron_expr, now):
continue
# Don't re-trigger if already ran this minute
if schedule.last_run_at:
seconds_since = (now - schedule.last_run_at).total_seconds()
if seconds_since < SCHEDULE_CHECK_INTERVAL:
continue
# Parse server_ids from JSON
try:
server_ids = json.loads(schedule.server_ids)
except (json.JSONDecodeError, TypeError):
logger.error(f"Schedule {schedule.id}: invalid server_ids JSON")
continue
if not server_ids:
continue
# Execute sync via SyncEngineV2
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
engine = SyncEngineV2(
server_repo=ServerRepositoryImpl(session),
sync_log_repo=SyncLogRepositoryImpl(session),
audit_repo=AuditLogRepositoryImpl(session),
retry_repo=PushRetryJobRepositoryImpl(session),
)
result = await engine.sync_files(
server_ids=server_ids,
source_path=schedule.source_path,
trigger_type="schedule",
operator=f"schedule:{schedule.name}",
)
# Update last_run_at
schedule.last_run_at = now
await session.commit()
triggered += 1
logger.info(
f"Schedule '{schedule.name}' triggered: "
f"{result['completed']}/{result['total']} succeeded"
)
except Exception as e:
logger.error(f"Schedule {schedule.id} execution failed: {e}")
if triggered:
logger.info(f"Schedule runner: {triggered} schedules triggered")
except Exception as e:
logger.error(f"Schedule runner check failed: {e}")
+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}"
)
)
+11 -3
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+pymysql://root:password@127.0.0.1:3306/nexus"
DB_POOL_SIZE: Optional[int] = 100
DB_MAX_OVERFLOW: Optional[int] = 100
DATABASE_URL: str = "mysql+aiomysql://root:password@127.0.0.1:3306/nexus"
# Pool params: install.php auto-calculates from MySQL max_connections
# Formula: pool_size = max(20, maxConn * 0.4), overflow = max(20, maxConn * 0.3)
# MySQL max_connections=400 → pool=160, overflow=120, max=280
# These defaults match install.php; override via .env or MySQL settings table
DB_POOL_SIZE: Optional[int] = 160
DB_MAX_OVERFLOW: Optional[int] = 120
# Security (immutable — changing breaks encryption/auth)
SECRET_KEY: str = "" # Must be set in .env; empty = startup error
-2
View File
@@ -13,8 +13,6 @@ from sqlalchemy import (
)
from sqlalchemy.orm import declarative_base, relationship
from cryptography.fernet import Fernet
Base = declarative_base()
-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}")
+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
+2 -2
View File
@@ -16,8 +16,8 @@ def _ensure_engine():
if _engine is None:
_engine = create_async_engine(
settings.DATABASE_URL.replace("mysql+pymysql", "mysql+aiomysql"),
pool_size=max(5, int(settings.DB_POOL_SIZE or 100)),
max_overflow=max(5, int(settings.DB_MAX_OVERFLOW or 100)),
pool_size=max(5, int(settings.DB_POOL_SIZE or 160)),
max_overflow=max(5, int(settings.DB_MAX_OVERFLOW or 120)),
pool_recycle=300,
pool_pre_ping=True,
)
+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
+84 -14
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,12 +9,19 @@ Lifespan startup:
5. Start Redis Pub/Sub subscriber (ADR-010)
6. Launch background tasks (heartbeat flush, self-monitor)
Lifespan startup (no .env install mode):
- Only the /api/install/ routes are functional
- All other API routes return 503 "System not configured"
- User completes the install wizard at /app/install.html
D2: install.php install.html migration (conditional lifespan)
D7: DB session leak fix middleware auto-manages session lifecycle per request.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
@@ -23,7 +30,14 @@ from starlette.middleware.base import BaseHTTPMiddleware
from server.config import settings
from server.infrastructure.database.session import init_db, AsyncSessionLocal
# API routes
# Detect install mode: no .env = first-time setup
ROOT_DIR = Path(__file__).resolve().parent.parent
INSTALL_MODE = not (ROOT_DIR / ".env").exists()
# Install wizard (no JWT required — runs before system is configured)
from server.api.install import router as install_router
# API routes (require JWT + full app init)
from server.api.servers import router as servers_router
from server.api.auth import router as auth_router
from server.api.agent import router as agent_router
@@ -44,6 +58,8 @@ from server.api.sync_v2 import router as sync_v2_router
# Background tasks
from server.background.heartbeat_flush import heartbeat_flush_loop
from server.background.self_monitor import self_monitor_loop
from server.background.schedule_runner import schedule_runner_loop
from server.background.retry_runner import retry_runner_loop
logger = logging.getLogger("nexus")
@@ -69,8 +85,17 @@ class DbSessionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Skip non-API routes (WebSocket, health, static files)
# WebSocket connections are long-lived — never wrap them in a DB session
path = request.url.path
if not path.startswith("/api/") and not path.startswith("/ws/"):
if not path.startswith("/api/"):
return await call_next(request)
# Skip install API (it manages its own DB connections)
if path.startswith("/api/install/"):
return await call_next(request)
# Skip in install mode (no global engine available)
if INSTALL_MODE:
return await call_next(request)
# Open session for this request
@@ -86,9 +111,19 @@ class DbSessionMiddleware(BaseHTTPMiddleware):
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifecycle: startup → background tasks → shutdown"""
"""Application lifecycle: startup → background tasks → shutdown
# ── Startup ──
Install mode (no .env): only install API + static files available.
Normal mode (.env exists): full initialization with DB, Redis, background tasks.
"""
# ── Install mode: skip everything ──
if INSTALL_MODE:
logger.info("⚠ INSTALL MODE — .env not found. Visit /app/install.html to configure.")
yield
return
# ── Normal mode: full startup ──
if not settings.SECRET_KEY:
logger.error("SECRET_KEY is empty! Set it in .env or MySQL settings table.")
raise SystemExit("SECRET_KEY is required for Nexus to start.")
@@ -117,7 +152,9 @@ async def lifespan(app: FastAPI):
# 5. Launch background tasks
task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush")
task_monitor = asyncio.create_task(self_monitor_loop(), name="self_monitor")
_background_tasks.extend([task_flush, task_monitor])
task_schedule = asyncio.create_task(schedule_runner_loop(), name="schedule_runner")
task_retry = asyncio.create_task(retry_runner_loop(), name="retry_runner")
_background_tasks.extend([task_flush, task_monitor, task_schedule, task_retry])
# 6. Start asyncssh connection pool (W1)
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
@@ -125,7 +162,8 @@ async def lifespan(app: FastAPI):
logger.info(
f"{settings.SYSTEM_NAME} v6.0.0 started — "
f"background tasks: heartbeat_flush (10min), self_monitor (30s)"
f"background tasks: heartbeat_flush(10min), self_monitor(30s), "
f"schedule_runner(60s), retry_runner(5min)"
)
yield
@@ -149,33 +187,65 @@ async def lifespan(app: FastAPI):
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
await ssh_pool.stop()
# Close shared Telegram httpx client
from server.infrastructure.telegram import close_client as close_telegram_client
await close_telegram_client()
logger.info(f"{settings.SYSTEM_NAME} shutting down — background tasks cancelled")
app = FastAPI(
title=settings.SYSTEM_NAME,
description=f"{settings.SYSTEM_TITLE}",
title=settings.SYSTEM_NAME if not INSTALL_MODE else "Nexus Installer",
description=f"{settings.SYSTEM_TITLE}" if not INSTALL_MODE else "Nexus Installation Wizard",
version="6.0.0",
lifespan=lifespan,
)
# ── Install mode middleware: block non-install routes when not configured ──
class InstallModeMiddleware(BaseHTTPMiddleware):
"""In install mode, only /api/install/, /app/, /health are accessible.
All other API routes return 503 Service Unavailable."""
async def dispatch(self, request: Request, call_next):
if INSTALL_MODE:
path = request.url.path
# Allow: install API, static files, health check
if path.startswith("/api/install/") or path.startswith("/app/") or path == "/health":
return await call_next(request)
if path == "/" or path.startswith("/ws/"):
return await call_next(request)
# Block everything else
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=503,
content={"detail": "系统尚未配置,请先访问 /app/install.html 完成安装"},
)
return await call_next(request)
# Install mode middleware (must be first — before DB session middleware)
app.add_middleware(InstallModeMiddleware)
# D7: DB session leak fix middleware (must be added BEFORE CORS so it wraps all requests)
app.add_middleware(DbSessionMiddleware)
# CORS — restrict to actual frontend origin (ECC security fix: was allow_origins=["*"])
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://172.31.170.47", # WSL development
"https://api.synaglobal.vip", # Production HTTPS
"http://localhost:8600", # Local dev
],
allow_origins=settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS else ["http://localhost:8600"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register all API routers
# D2: Install wizard (always registered — works in both modes)
app.include_router(install_router)
# Normal API routers (require full app init)
app.include_router(servers_router)
app.include_router(auth_router)
app.include_router(agent_router)
+8 -5
View File
@@ -1,6 +1,6 @@
"""Nexus — Test Configuration (pytest + async fixtures)
Fixed from original: wired up dependency_overrides properly,
Fixed: wired up dependency_overrides properly,
added test Redis mock, added admin fixture for JWT auth tests.
"""
@@ -47,6 +47,7 @@ async def db_session(test_engine):
@pytest.fixture(scope="function")
async def api_client(db_session):
"""Create an async HTTP client for API integration tests"""
from server.main import app
from server.api.dependencies import get_db
async def override_get_session():
@@ -67,9 +68,12 @@ async def test_admin(db_session):
import bcrypt
from server.application.services.auth_service import AuthService
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from server.infrastructure.database.login_attempt_repo import LoginAttemptRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
# LoginAttemptRepositoryImpl requires login_attempt_repo which may not exist in test DB
# Use a simple mock approach
from unittest.mock import MagicMock
password_hash = bcrypt.hashpw("test123".encode(), bcrypt.gensalt()).decode()
admin = Admin(
username="testadmin",
@@ -81,12 +85,11 @@ async def test_admin(db_session):
await db_session.commit()
await db_session.refresh(admin)
# Generate JWT token for this admin
auth_service = AuthService(
admin_repo=AdminRepositoryImpl(db_session),
attempt_repo=LoginAttemptRepositoryImpl(db_session),
attempt_repo=MagicMock(),
audit_repo=AuditLogRepositoryImpl(db_session),
)
token = auth_service._create_access_token(admin)
return {"admin": admin, "token": token}
return {"admin": admin, "token": token}
+96 -41
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}")
-36
View File
@@ -1,36 +0,0 @@
<?php
/**
* Python 后端安装提醒 + 服务状态
* PHP 服务端检测(127.0.0.1),无 CORS 问题
*/
$currentPage = basename($_SERVER['PHP_SELF']);
// 服务端检测 API/DB/WS 状态(走本地 127.0.0.1
$apiOnline = false; $dbOnline = false; $wsOnline = false;
if (!isset($_SESSION['svc_checked']) || $_SESSION['svc_checked'] < time() - 60) {
$ch = curl_init('http://127.0.0.1:8600/health/services');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3]);
$resp = @json_decode(curl_exec($ch), true);
curl_close($ch);
$apiOnline = ($resp['api'] ?? '') === 'online';
$dbOnline = ($resp['db'] ?? '') === 'online';
$wsOnline = ($resp['ws'] ?? '') === 'online';
$_SESSION['svc_api'] = $apiOnline;
$_SESSION['svc_db'] = $dbOnline;
$_SESSION['svc_ws'] = $wsOnline;
$_SESSION['svc_checked'] = time();
} else {
$apiOnline = $_SESSION['svc_api'] ?? false;
$dbOnline = $_SESSION['svc_db'] ?? false;
$wsOnline = $_SESSION['svc_ws'] ?? false;
}
if ($currentPage === 'login.php') return;
?>
<?php if (!$apiOnline): ?>
<div style="background:#fef3c7;border-bottom:2px solid #f59e0b;padding:8px 20px;font-size:13px;display:flex;align-items:center;gap:12px;">
<span style="flex:1;"><b>Python 后端未运行</b> 推送、重试、定时调度等功能需要后端支持</span>
<a href="https://api.synaglobal.vip/settings.php" style="background:#f59e0b;color:#fff;padding:4px 12px;border-radius:4px;text-decoration:none;font-size:12px;font-weight:600;">前往配置 </a>
<button onclick="this.parentElement.style.display='none'" style="background:none;border:none;font-size:18px;cursor:pointer;color:#92400e;">×</button>
</div>
<?php endif; ?>
-66
View File
@@ -1,66 +0,0 @@
<?php
/**
* .env 配置检测横幅
* 检查 Python 后端的 .env 是否正确配置
* 问题修复后横幅自动消失
*/
$currentPage = basename($_SERVER['PHP_SELF']);
if (in_array($currentPage, ['login.php', 'install.php'])) return;
// 每 5 分钟检测一次
if (isset($_SESSION['env_checked']) && $_SESSION['env_checked'] > time() - 300) {
return; // 已检测且正常
}
$_SESSION['env_checked'] = time();
$apiBase = defined('API_BASE_URL') ? API_BASE_URL : 'http://localhost:8600';
$apiKey = defined('API_KEY') ? API_KEY : '';
$warnings = [];
// 检查:Python API 是否在线
$ch = curl_init($apiBase . '/health');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3]);
$resp = @curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
// API 不在线 → 不检查 .env(由 _banner.php 处理)
$_SESSION['env_warnings'] = [];
return;
}
// 检查:SECRET_KEY 是否为默认值(通过 /api/servers/config 获取)
$ch = curl_init($apiBase . '/api/servers/config');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3,
CURLOPT_HTTPHEADER => $apiKey ? ["X-API-Key: $apiKey"] : [],
]);
$resp = @curl_exec($ch);
$configData = json_decode($resp, true) ?: [];
curl_close($ch);
if (empty($configData)) {
$warnings[] = ['type' => 'api', 'msg' => 'API_KEY 未配置或与 Python .env 不一致'];
} else {
// 检查默认值
if (($configData['api_key'] ?? '') === '') {
$warnings[] = ['type' => 'apikey', 'msg' => 'API_KEY 为空 — API 端点无认证保护'];
}
if (($configData['telegram_bot_token'] ?? '') === '' && ($configData['telegram_chat_id'] ?? '') === '') {
// Telegram 是可选的,仅提示
}
}
$_SESSION['env_warnings'] = $warnings;
$showBanner = !empty($warnings);
if ($showBanner):
?>
<div id="envBanner" style="background:#fff3cd;border-bottom:2px solid #ffc107;padding:10px 20px;font-size:13px;display:flex;align-items:center;gap:12px;">
<span style="font-size:16px;">⚠️</span>
<span style="flex:1;"><b>.env 配置警告</b> <?= count($warnings) ?> 项需要处理:<?= implode('', array_column($warnings, 'msg')) ?></span>
<a href="settings.php" style="background:#ffc107;color:#000;padding:6px 14px;border-radius:4px;text-decoration:none;font-size:12px;font-weight:600;">前往修复 </a>
<button onclick="document.getElementById('envBanner').style.display='none'" style="background:none;border:none;font-size:18px;cursor:pointer;color:#856404;">×</button>
</div>
<?php endif; ?>
-8
View File
@@ -1,8 +0,0 @@
</div><!-- /.content-wrapper -->
<aside class="control-sidebar control-sidebar-dark"></aside>
</div><!-- /.wrapper -->
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/js/adminlte.min.js"></script>
</body>
</html>
-50
View File
@@ -1,50 +0,0 @@
<?php
// AdminLTE 布局头部 — 所有页面引入
require_once __DIR__ . '/_banner.php';
$current_page = basename($_SERVER['SCRIPT_NAME']);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $pageTitle ?? 'MultiSync' ?></title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/css/adminlte.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="style.css">
<style>
.content-wrapper { background: #f4f6f9; }
.card { box-shadow: 0 0 1px rgba(0,0,0,.125), 0 1px 3px rgba(0,0,0,.2); border: none; }
.table th { border-top: none; font-size: 13px; font-weight: 600; color: #6c757d; }
.table td { font-size: 13px; vertical-align: middle; }
.badge-success { background: #28a745; }
.btn-xs { padding: 1px 6px; font-size: 11px; }
</style>
<script>
// 全局推送横条
function escHtml(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function renderPushBanner(data){
var total=data.total||0,success=data.success||0,failed=data.failed||0;
var h='<div id="pushBanner" style="background:'+(failed===0?'#d4edda':'#fff3cd')+';border-bottom:1px solid '+(failed===0?'#c3e6cb':'#ffeeba')+';padding:8px 20px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:12px">';
h+='<b>'+(failed===0?'✅':'⚠')+' 推送完成:</b> '+total+' 台,成功 '+success+(failed>0?',失败 '+failed:'');
(data.results||[]).slice(0,3).forEach(function(r){
var icon=r.status==='success'?'✅':r.status==='skipped'?'⚠':'❌';
h+='<span style="font-size:11px;color:#555;">'+icon+' '+escHtml(r.server_name||'')+': '+escHtml(r.message||'')+'</span>';
});
h+='<button onclick="dismissBanner()" style="border:none;background:none;cursor:pointer;font-size:16px;color:#999;">×</button></div>';
var existing=document.getElementById('pushBanner');if(existing)existing.remove();
var wrapper=document.querySelector('.wrapper');if(wrapper)wrapper.insertAdjacentHTML('afterbegin',h);
// 3 秒后消失
clearTimeout(window._bannerTimer);
window._bannerTimer=setTimeout(function(){var b=document.getElementById('pushBanner');if(b)b.remove();sessionStorage.removeItem('pushResult');},3000);
}
function dismissBanner(){var b=document.getElementById('pushBanner');if(b)b.remove();sessionStorage.removeItem('pushResult');clearTimeout(window._bannerTimer);}
document.addEventListener('DOMContentLoaded',function(){
var raw=sessionStorage.getItem('pushResult');
if(raw){try{renderPushBanner(JSON.parse(raw))}catch(e){}}
});
</script>
</head>
<body class="hold-transition sidebar-mini">
<div class="wrapper">
-41
View File
@@ -1,41 +0,0 @@
<?php
// 侧边栏组件 — 所有页面引入
$current_page = basename($_SERVER['SCRIPT_NAME']);
?>
<nav class="sidebar">
<div class="sidebar-header">
<h1><i class="fas fa-sync-alt"></i> MultiSync</h1>
<span class="version">v1.0.0</span>
</div>
<ul class="nav-menu">
<li class="nav-section-title">主菜单</li>
<li<?= $current_page === 'index.php' ? ' class="active"' : '' ?>>
<a href="index.php"><i class="fas fa-tachometer-alt"></i> <span>仪表盘</span></a></li>
<li<?= $current_page === 'servers.php' ? ' class="active"' : '' ?>>
<a href="servers.php"><i class="fas fa-server"></i> <span>服务器管理</span></a></li>
<li<?= $current_page === 'retry.php' ? ' class="active"' : '' ?>>
<a href="retry.php"><i class="fas fa-redo-alt"></i> <span>重试队列</span></a></li>
<li<?= $current_page === 'logs.php' ? ' class="active"' : '' ?>>
<a href="logs.php"><i class="fas fa-history"></i> <span>同步日志</span></a></li>
<li<?= $current_page === 'files.php' ? ' class="active"' : '' ?>>
<a href="files.php"><i class="fas fa-folder-open"></i> <span>文件管理</span></a></li>
<li class="nav-section-title">系统</li>
<li<?= $current_page === 'guide.php' ? ' class="active"' : '' ?>>
<a href="guide.php"><i class="fas fa-compass"></i> <span>使用引导</span></a></li>
<li<?= $current_page === 'deploy.php' ? ' class="active"' : '' ?>>
<a href="deploy.php"><i class="fas fa-rocket"></i> <span>部署 Agent</span></a></li>
<li<?= $current_page === 'settings.php' ? ' class="active"' : '' ?>>
<a href="settings.php"><i class="fas fa-cog"></i> <span>系统设置</span></a></li>
<li>
<a href="logout.php"><i class="fas fa-sign-out-alt"></i> <span>退出登录</span></a></li>
</ul>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar"><?= strtoupper(substr(CURRENT_USER, 0, 1)) ?></div>
<div class="user-meta">
<div class="name"><?= htmlspecialchars(CURRENT_USER) ?></div>
<div class="role">管理员</div>
</div>
</div>
</div>
</nav>
-45
View File
@@ -1,45 +0,0 @@
<?php
$cp = basename($_SERVER['SCRIPT_NAME']);
$menu = [
['name' => '仪表盘', 'icon' => 'tachometer-alt', 'link' => 'index.php', 'page' => 'index.php'],
['name' => '服务器管理', 'icon' => 'server', 'link' => 'servers.php', 'page' => 'servers.php'],
['name' => '定时推送', 'icon' => 'clock', 'link' => 'schedules.php', 'page' => 'schedules.php'],
['name' => '重试队列', 'icon' => 'redo-alt', 'link' => 'retry.php', 'page' => 'retry.php'],
['name' => '同步日志', 'icon' => 'history', 'link' => 'logs.php', 'page' => 'logs.php'],
['name' => '文件管理', 'icon' => 'folder-open', 'link' => 'fm.php', 'page' => 'fm.php'],
];
$sysMenu = [
['name' => '审计日志', 'icon' => 'shield-alt', 'link' => 'audit.php', 'page' => 'audit.php'],
['name' => '使用引导', 'icon' => 'compass', 'link' => 'guide.php', 'page' => 'guide.php'],
['name' => '部署 Agent', 'icon' => 'rocket', 'link' => 'deploy.php', 'page' => 'deploy.php'],
['name' => '系统设置', 'icon' => 'cog', 'link' => 'settings.php', 'page' => 'settings.php'],
];
?>
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
<ul class="navbar-nav"><li class="nav-item"><a class="nav-link" data-widget="pushmenu" href="#"><i class="fas fa-bars"></i></a></li></ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item"><span class="nav-link text-muted"><?= htmlspecialchars(CURRENT_USER) ?></span></li>
<li class="nav-item"><a class="nav-link" href="logout.php"><i class="fas fa-sign-out-alt"></i></a></li>
</ul>
</nav>
<aside class="main-sidebar sidebar-dark-primary elevation-4">
<a href="index.php" class="brand-link"><i class="fas fa-sync-alt brand-image"></i><span class="brand-text font-weight-light">MultiSync</span></a>
<div class="sidebar">
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu">
<?php foreach ($menu as $m): $active = ($cp === $m['page']) ? ' active' : ''; ?>
<li class="nav-item">
<a href="<?=$m['link']?>" class="nav-link<?=$active?>"><i class="nav-icon fas fa-<?=$m['icon']?>"></i><p><?=$m['name']?></p></a>
</li>
<?php endforeach; ?>
<li class="nav-header">系统</li>
<?php foreach ($sysMenu as $m): $active = ($cp === $m['page']) ? ' active' : ''; ?>
<li class="nav-item">
<a href="<?=$m['link']?>" class="nav-link<?=$active?>"><i class="nav-icon fas fa-<?=$m['icon']?>"></i><p><?=$m['name']?></p></a>
</li>
<?php endforeach; ?>
</ul>
</nav>
</div>
</aside>
-1
View File
File diff suppressed because one or more lines are too long
+129 -102
View File
@@ -1,6 +1,6 @@
"""
MultiSync Agent - Runs on each sub-server
Provides: health endpoint, heartbeat, config reload
Nexus Agent Runs on each managed server
Provides: health endpoint, heartbeat, command execution, config reload
"""
import os
import sys
@@ -9,7 +9,8 @@ import time
import asyncio
import logging
import platform
from datetime import datetime
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@@ -18,7 +19,7 @@ import psutil
from fastapi import FastAPI, HTTPException, Header, Request
from fastapi.responses import JSONResponse
# --- Configuration (JSON, 主服务器可推送更新) ---
# --- Configuration ---
CONFIG_PATH = Path(__file__).parent / "config.json"
@@ -26,7 +27,6 @@ def load_config():
if CONFIG_PATH.exists():
with open(CONFIG_PATH) as f:
return json.load(f)
# Fallback to example
example = Path(__file__).parent / "config.example.json"
if example.exists():
with open(example) as f:
@@ -36,13 +36,14 @@ def load_config():
config = load_config()
API_KEY = config.get("api_key", "")
SERVER_ID = config.get("server_id", 0)
CENTRAL_URL = config.get("central", {}).get("url", "")
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
HEARTBEAT_INTERVAL = config.get("heartbeat_interval", 30)
HEARTBEAT_INTERVAL = config.get("heartbeat_interval", 60)
# --- Logging ---
log_file = config.get("log_file", "/var/log/multisync-agent.log")
log_file = config.get("log_file", "/var/log/nexus-agent.log")
log_level = config.get("log_level", "INFO")
logging.basicConfig(
@@ -53,11 +54,11 @@ logging.basicConfig(
logging.FileHandler(log_file, mode="a") if log_file != "stdout" else logging.StreamHandler(sys.stdout),
],
)
logger = logging.getLogger("multisync-agent")
logger = logging.getLogger("nexus-agent")
# --- FastAPI App ---
app = FastAPI(title="MultiSync Agent", version="1.0.0")
app = FastAPI(title="Nexus Agent", version="2.0.0")
def verify_api_key(x_api_key: str = Header(default="")):
@@ -69,7 +70,7 @@ def verify_api_key(x_api_key: str = Header(default="")):
@app.get("/health")
async def health_check():
"""Health check endpoint - called by central server"""
"""Health check endpoint called by Nexus central server"""
try:
cpu_percent = psutil.cpu_percent(interval=0.5)
memory = psutil.virtual_memory()
@@ -79,80 +80,104 @@ async def health_check():
"hostname": platform.node(),
"platform": platform.platform(),
"python_version": platform.python_version(),
"cpu_percent": cpu_percent,
"cpu_usage": cpu_percent,
"mem_usage": memory.percent,
"disk_usage": round(disk.percent, 1),
"memory_total_gb": round(memory.total / (1024**3), 2),
"memory_used_gb": round(memory.used / (1024**3), 2),
"memory_percent": memory.percent,
"disk_total_gb": round(disk.total / (1024**3), 2),
"disk_used_gb": round(disk.used / (1024**3), 2),
"disk_percent": round(disk.percent, 1),
"uptime_seconds": int(time.time() - psutil.boot_time()),
"agent_time": datetime.utcnow().isoformat(),
"agent_time": datetime.now(timezone.utc).isoformat(),
}
return {"status": "healthy", "system_info": system_info}
except Exception as e:
return {"status": "degraded", "error": str(e)}
# --- Command Execution ---
@app.post("/exec")
async def exec_command(payload: dict, x_api_key: str = Header(default="")):
"""Execute a shell command — called by Nexus to run commands on this server"""
verify_api_key(x_api_key)
command = payload.get("command", "")
timeout = payload.get("timeout", 30)
sudo = payload.get("sudo", False)
if not command:
raise HTTPException(status_code=400, detail="command is required")
if sudo:
command = f"sudo {command}"
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
exit_code = proc.returncode or 0
return {
"status": "healthy",
"system_info": system_info,
"status": "success" if exit_code == 0 else "failed",
"stdout": stdout.decode()[:10000],
"stderr": stderr.decode()[:10000],
"exit_code": exit_code,
}
except asyncio.TimeoutError:
proc.kill()
return {
"status": "timeout",
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"exit_code": -1,
}
except Exception as e:
return {
"status": "degraded",
"error": str(e),
}
return {"status": "error", "stdout": "", "stderr": str(e), "exit_code": -1}
# --- Config Reload ---
@app.post("/config/reload")
async def reload_config(data: dict, x_api_key: str = Header(default="")):
"""主服务器推送新配置 — 更新 config.json 并重载"""
"""Nexus pushes new config — update config.json and reload"""
verify_api_key(x_api_key)
try:
now = datetime.utcnow().isoformat()
now = datetime.now(timezone.utc).isoformat()
data["updated_at"] = now
data["updated_by"] = "master"
data["updated_by"] = "nexus"
with open(CONFIG_PATH, "w") as f:
json.dump(data, f, indent=2)
# 重新加载配置
global CENTRAL_URL, CENTRAL_API_KEY, config
global SERVER_ID, CENTRAL_URL, CENTRAL_API_KEY, config
config = data
CENTRAL_URL = config.get("central", {}).get("url", "")
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
return {"status": "ok", "message": "Config updated", "central_url": CENTRAL_URL, "updated_at": now}
SERVER_ID = config.get("server_id", SERVER_ID)
CENTRAL_URL = config.get("central", {}).get("url", CENTRAL_URL)
CENTRAL_API_KEY = config.get("central", {}).get("api_key", CENTRAL_API_KEY)
return {"status": "ok", "message": "Config updated", "updated_at": now}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/uninstall")
async def uninstall_self(x_api_key: str = Header(default="")):
"""主服务器远程触发卸载 — Agent 执行卸载脚本并停止自身"""
verify_api_key(x_api_key)
import subprocess, os as _os
script = "/opt/multisync-agent/uninstall.sh"
if _os.path.exists(script):
try:
proc = subprocess.run(["bash", script], capture_output=True, text=True, timeout=30)
return {"status": "ok", "message": "Agent uninstalled", "output": proc.stdout[:500]}
except subprocess.TimeoutExpired:
return {"status": "timeout", "message": "Uninstall script timed out"}
except Exception as e:
return {"status": "error", "message": str(e)}
else:
return {"status": "not_found", "message": f"Uninstall script not found: {script}"}
# --- Heartbeat to Central Server ---
_last_metrics = {}
_last_full_sync = 0
HISTORY_SIZE = 10
# 初始化采样窗口(避免重启后空窗等 5 分钟)
_cpu_init = psutil.cpu_percent()
_mem_init = psutil.virtual_memory().percent
_disk_init = psutil.disk_usage("/").percent
_metric_history = [{"cpu": _cpu_init, "memory": _mem_init, "disk": _disk_init}] * min(5, HISTORY_SIZE)
def _check_alert():
"""连续 HISTORY_SIZE 次 CPU/内存>80% 或磁盘>90% 返回告警标记"""
"""Check if CPU/mem > 80% or disk > 90% for HISTORY_SIZE consecutive samples"""
if len(_metric_history) < HISTORY_SIZE:
return False, {}
cpu_high = all(h["cpu"] >= 80 for h in _metric_history)
@@ -166,57 +191,65 @@ def _check_alert():
async def send_heartbeat():
"""IoT 优化:变化 >10% + 连续 10 次高负载告警 + Redis 存指标"""
"""Send heartbeat to Nexus central — smart: only when metrics change >10% or alert"""
global _last_metrics, _last_full_sync, _metric_history
while True:
try:
if CENTRAL_URL:
cpu = psutil.cpu_percent()
mem = psutil.virtual_memory().percent
disk = psutil.disk_usage("/").percent
if not CENTRAL_URL or not SERVER_ID:
await asyncio.sleep(HEARTBEAT_INTERVAL)
continue
now_ts = time.time()
changed = (
abs(cpu - _last_metrics.get("cpu", 0)) >= 10 or
abs(mem - _last_metrics.get("memory", 0)) >= 10 or
abs(disk - _last_metrics.get("disk", 0)) >= 10
)
force_sync = (now_ts - _last_full_sync) > 600 # 10 分钟强制全量
cpu = psutil.cpu_percent()
mem = psutil.virtual_memory().percent
disk = psutil.disk_usage("/").percent
# 滑动窗口记录采样
_metric_history.append({"cpu": cpu, "memory": mem, "disk": disk})
if len(_metric_history) > HISTORY_SIZE:
_metric_history.pop(0)
now_ts = time.time()
changed = (
abs(cpu - _last_metrics.get("cpu", 0)) >= 10 or
abs(mem - _last_metrics.get("memory", 0)) >= 10 or
abs(disk - _last_metrics.get("disk", 0)) >= 10
)
force_sync = (now_ts - _last_full_sync) > 600
# 检查告警
has_alert, alert_info = _check_alert()
_metric_history.append({"cpu": cpu, "memory": mem, "disk": disk})
if len(_metric_history) > HISTORY_SIZE:
_metric_history.pop(0)
has_alert, alert_info = _check_alert()
if changed or force_sync or has_alert:
_last_metrics = {"cpu": cpu, "memory": mem, "disk": disk}
if force_sync:
_last_full_sync = now_ts
payload = {
"server_id": SERVER_ID,
"is_online": True,
"agent_version": "2.0.0",
"system_info": {
"cpu_usage": cpu,
"mem_usage": mem,
"disk_usage": disk,
"hostname": platform.node(),
"platform": platform.platform(),
},
}
if has_alert:
payload["alert"] = alert_info
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{CENTRAL_URL}/api/agent/heartbeat",
json=payload,
headers={"X-API-Key": CENTRAL_API_KEY},
)
if resp.status_code == 200:
logger.debug("Heartbeat sent")
else:
logger.warning(f"Heartbeat failed: {resp.status_code}")
else:
logger.debug("Metrics unchanged, skip heartbeat")
if changed or force_sync or has_alert:
_last_metrics = {"cpu": cpu, "memory": mem, "disk": disk}
if force_sync: _last_full_sync = now_ts
payload = {
"timestamp": datetime.utcnow().isoformat(),
"agent_version": "2.0.0",
"system_info": {
"hostname": platform.node(),
"cpu": cpu, "memory": mem, "disk": disk,
"history": _metric_history,
},
}
if has_alert:
payload["alert"] = alert_info
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{CENTRAL_URL}/api/agent/heartbeat",
json=payload,
headers={"X-API-Key": CENTRAL_API_KEY},
)
if resp.status_code == 200:
logger.debug("Heartbeat sent successfully")
else:
logger.warning(f"Heartbeat failed: {resp.status_code}")
else:
logger.debug("Metrics unchanged <10%, skip heartbeat")
except Exception as e:
logger.warning(f"Heartbeat error: {e}")
@@ -224,14 +257,10 @@ async def send_heartbeat():
# --- App Lifecycle ---
# 注:TriggerFileHandler + start_watchers 已删除(触发文件机制已废弃)
# Agent 仅保留心跳上报 + 健康接口
@app.on_event("startup")
async def startup():
logger.info("MultiSync Agent starting...")
# Start heartbeat
logger.info(f"Nexus Agent starting (server_id={SERVER_ID})")
asyncio.create_task(send_heartbeat())
host = config.get("server", {}).get("host", "0.0.0.0")
@@ -241,13 +270,11 @@ async def startup():
@app.on_event("shutdown")
async def shutdown():
logger.info("MultiSync Agent stopped")
logger.info("Nexus Agent stopped")
# --- Main ---
if __name__ == "__main__":
host = config.get("server", {}).get("host", "0.0.0.0")
port = config.get("server", {}).get("port", 8601)
import uvicorn
host = config["server"]["host"]
port = config["server"]["port"]
uvicorn.run(app, host=host, port=port)
+12 -11
View File
@@ -1,12 +1,13 @@
#!/bin/bash
# ================================================================
# MultiSync Shell Agent — 心跳 + 健康接口
# Nexus Shell Agent — 心跳 + 健康接口
# 依赖: bash, curl
# ================================================================
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-30}"
LOG_FILE="${LOG_FILE:-/var/log/multisync-agent.log}"
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-60}"
LOG_FILE="${LOG_FILE:-/var/log/nexus-agent.log}"
CENTRAL_URL="${CENTRAL_URL:-}"
CENTRAL_API_KEY="${CENTRAL_API_KEY:-}"
SERVER_ID="${SERVER_ID:-0}"
AGENT_PORT="${AGENT_PORT:-8601}"
log() {
@@ -36,7 +37,7 @@ heartbeat_loop() {
local info=$(get_system_info)
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
-H "X-API-Key: ${CENTRAL_API_KEY}" \
-d "{\"agent_port\":${AGENT_PORT},\"system_info\":${info}}" \
-d "{\"server_id\":${SERVER_ID},\"is_online\":true,\"system_info\":${info}}" \
"${CENTRAL_URL}/api/agent/heartbeat" 2>/dev/null
if [ $? -eq 0 ]; then
log "INFO" "心跳 OK"
@@ -50,7 +51,7 @@ heartbeat_loop() {
health_server() {
while true; do
if command -v nc >/dev/null 2>&1; then
printf "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"status\":\"healthy\",\"agent\":\"shell\"}" \
printf "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"status\":\"healthy\",\"agent\":\"nexus-shell\"}" \
| nc -l -p "${AGENT_PORT}" -q 1 2>/dev/null
else
exec 3<>/dev/tcp/0.0.0.0/${AGENT_PORT} 2>/dev/null && {
@@ -69,10 +70,11 @@ trap cleanup SIGINT SIGTERM
main() {
echo "=========================================="
echo " MultiSync Shell Agent — 心跳上报"
echo " Nexus Shell Agent — 心跳上报"
echo "=========================================="
check_deps
log "INFO" "中央服务器: ${CENTRAL_URL}"
log "INFO" "服务器ID: ${SERVER_ID}"
log "INFO" "心跳间隔: ${HEARTBEAT_INTERVAL}"
heartbeat_loop &
@@ -88,10 +90,9 @@ main() {
}
case "${1:-}" in
install) bash "$(dirname "$0")/install.sh" "${@:2}" ;;
stop) systemctl stop multisync-agent; echo "已停止" ;;
restart) systemctl restart multisync-agent; echo "已重启" ;;
status) systemctl status multisync-agent --no-pager 2>/dev/null || echo "未运行" ;;
log|logs) journalctl -u multisync-agent -f --no-pager ;;
stop) systemctl stop nexus-agent; echo "已停止" ;;
restart) systemctl restart nexus-agent; echo "已重启" ;;
status) systemctl status nexus-agent --no-pager 2>/dev/null || echo "未运行" ;;
log|logs) journalctl -u nexus-agent -f --no-pager ;;
*) main ;;
esac
+15
View File
@@ -0,0 +1,15 @@
{
"server_id": 0,
"central": {
"url": "https://YOUR_NEXUS_DOMAIN",
"api_key": "YOUR_API_KEY"
},
"api_key": "YOUR_API_KEY",
"heartbeat_interval": 60,
"log_file": "/var/log/nexus-agent.log",
"log_level": "INFO",
"server": {
"host": "0.0.0.0",
"port": 8601
}
}
+27 -24
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# ================================================================
# MultiSync Python Agent Install Script
# Usage: curl -fsSL https://主服务器/agent/install.sh | bash -s -- --url https://主服务器 --key KEY
# Nexus Agent Install Script
# Usage: curl -fsSL https://YOUR_NEXUS_DOMAIN/agent/install.sh | bash -s -- --url https://YOUR_NEXUS_DOMAIN --key API_KEY --id SERVER_ID
# ================================================================
set -e
@@ -9,30 +9,32 @@ set -e
CENTRAL_URL=""
WEB_URL=""
API_KEY=""
SERVER_ID=""
AGENT_PORT="8601"
WATCH_DIRS="/var/www/html"
INSTALL_DIR="/opt/multisync-agent"
INSTALL_DIR="/opt/nexus-agent"
while [[ $# -gt 0 ]]; do
case $1 in
--url) CENTRAL_URL="$2"; shift 2 ;;
--web-url) WEB_URL="$2"; shift 2 ;;
--key) API_KEY="$2"; shift 2 ;;
--id) SERVER_ID="$2"; shift 2 ;;
--port) AGENT_PORT="$2"; shift 2 ;;
--dirs) WATCH_DIRS="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -z "$CENTRAL_URL" ] && echo "ERROR: --url is required" && exit 1
[ -z "$SERVER_ID" ] && echo "ERROR: --id is required (server ID from Nexus dashboard)" && exit 1
[ -z "$API_KEY" ] && echo "ERROR: --key is required (API key from Nexus settings)" && exit 1
echo ""
echo "=========================================="
echo " MultiSync Python Agent Install"
echo " Nexus Agent Install"
echo "=========================================="
echo " Server: $CENTRAL_URL"
echo " Dirs: $WATCH_DIRS"
echo " Port: $AGENT_PORT"
echo " Server: $CENTRAL_URL"
echo " ID: $SERVER_ID"
echo " Port: $AGENT_PORT"
echo "=========================================="
echo ""
@@ -56,7 +58,7 @@ mkdir -p "$INSTALL_DIR"
# 3. Download agent.py
echo "[3/5] Download agent.py..."
[ -z "$WEB_URL" ] && WEB_URL="$(echo "$CENTRAL_URL" | sed 's|:[0-9]\+||; s|^http://|https://|')"
[ -z "$WEB_URL" ] && WEB_URL="${CENTRAL_URL}"
curl -fsSL "${WEB_URL}/agent/agent.py" -o "$INSTALL_DIR/agent.py" || {
echo "ERROR: Cannot download from $WEB_URL/agent/agent.py"
exit 1
@@ -66,21 +68,22 @@ curl -fsSL "${WEB_URL}/agent/agent.py" -o "$INSTALL_DIR/agent.py" || {
echo "[4/5] Write config..."
cat > "$INSTALL_DIR/config.json" << EOF
{
"server_id": ${SERVER_ID},
"central": { "url": "${CENTRAL_URL}", "api_key": "${API_KEY}" },
"api_key": "${API_KEY}",
"heartbeat_interval": 30,
"watch_dirs": "${WATCH_DIRS}",
"log_file": "/var/log/multisync-agent.log",
"log_level": "INFO"
"heartbeat_interval": 60,
"log_file": "/var/log/nexus-agent.log",
"log_level": "INFO",
"server": { "host": "0.0.0.0", "port": ${AGENT_PORT} }
}
EOF
# 5. systemd + firewall + start
echo "[5/5] Setup service + firewall + start..."
cat > /etc/systemd/system/multisync-agent.service << EOF
cat > /etc/systemd/system/nexus-agent.service << EOF
[Unit]
Description=MultiSync Python Agent
Description=Nexus Agent
After=network.target
[Service]
@@ -96,7 +99,6 @@ WantedBy=multi-user.target
EOF
# Firewall
AGENT_PORT="${AGENT_PORT:-8601}"
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q 'Status: active'; then
ufw allow ${AGENT_PORT}/tcp 2>/dev/null && echo " ufw: opened ${AGENT_PORT}/tcp"
elif command -v firewall-cmd &>/dev/null && firewall-cmd --state 2>/dev/null | grep -q 'running'; then
@@ -109,12 +111,12 @@ else
fi
systemctl daemon-reload
systemctl enable multisync-agent
systemctl start multisync-agent
systemctl enable nexus-agent
systemctl start nexus-agent
sleep 2
STATUS="running"
systemctl is-active --quiet multisync-agent || STATUS="FAILED"
systemctl is-active --quiet nexus-agent || STATUS="FAILED"
HOSTNAME=$(hostname 2>/dev/null || echo "unknown")
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
@@ -124,10 +126,11 @@ echo "=========================================="
echo " Done! Status: ${STATUS}"
echo "=========================================="
echo ""
echo " Agent: http://${IP}:${AGENT_PORT}"
echo " Agent: http://${IP}:${AGENT_PORT}"
echo " Server ID: ${SERVER_ID}"
echo ""
echo " Commands:"
echo " systemctl status multisync-agent"
echo " journalctl -u multisync-agent -f"
echo " systemctl restart multisync-agent"
echo " systemctl status nexus-agent"
echo " journalctl -u nexus-agent -f"
echo " systemctl restart nexus-agent"
echo ""
-148
View File
@@ -1,148 +0,0 @@
<?php
/**
* API Client - communicates with FastAPI backend
*/
require_once __DIR__ . '/config.php';
class ApiClient {
private $baseUrl;
private $apiKey;
public function __construct() {
$this->baseUrl = API_BASE_URL;
// Read API key from MySQL first (settings page), fallback to config constant
$key = defined('API_KEY') ? API_KEY : '';
try {
require_once __DIR__ . '/db.php';
$pdo = db()->getPDO();
$stmt = $pdo->prepare("SELECT value FROM settings WHERE `key` = 'api_key'");
$stmt->execute();
$row = $stmt->fetch();
if ($row && !empty($row['value'])) $key = $row['value'];
} catch (Exception $e) {}
$this->apiKey = $key;
}
/**
* Make API request
*/
public function request($method, $endpoint, $data = null) {
$url = $this->baseUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
'X-API-Key: ' . $this->apiKey,
]);
switch (strtoupper($method)) {
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
if ($data) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
break;
case 'PUT':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
if ($data) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
break;
case 'DELETE':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
break;
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
return ['error' => "Connection error: $error", 'code' => 0];
}
$decoded = json_decode($response, true);
return ['data' => $decoded, 'code' => $httpCode];
}
// --- Servers ---
public function getServers() {
return $this->request('GET', '/api/servers/');
}
public function getServer($id) {
return $this->request('GET', "/api/servers/$id");
}
public function createServer($data) {
return $this->request('POST', '/api/servers/', $data);
}
public function updateServer($id, $data) {
return $this->request('PUT', "/api/servers/$id", $data);
}
public function deleteServer($id) {
return $this->request('DELETE', "/api/servers/$id");
}
public function checkServerHealth($id) {
return $this->request('POST', "/api/servers/$id/check");
}
// --- Push ---
public function pushServers($sourcePath, $serverIds) {
return $this->request('POST', '/api/servers/push', [
'source_path' => $sourcePath,
'server_ids' => $serverIds,
]);
}
// --- Logs ---
public function getAllLogs($limit = 100) {
return $this->request('GET', "/api/servers/logs?limit=$limit");
}
// --- Audit ---
public function getAuditLogs($params = []) {
$query = http_build_query($params);
return $this->request('GET', "/api/servers/audit-logs?$query");
}
// --- Dashboard ---
public function getDashboardStats() {
// SQL COUNT — no need to fetch all 2000 servers
$statsResp = $this->request('GET', '/api/servers/stats');
$stats = $statsResp['data'] ?? [];
$logsResp = $this->getAllLogs(10);
$recentLogs = $logsResp['data'] ?? [];
return [
'total_servers' => $stats['total_servers'] ?? 0,
'online_servers' => $stats['online_servers'] ?? 0,
'offline_servers' => $stats['offline_servers'] ?? 0,
'push_ready' => $stats['push_ready'] ?? 0,
'recent_logs' => is_array($recentLogs) ? $recentLogs : [],
];
}
}
// Singleton
function api() {
static $client = null;
if ($client === null) {
$client = new ApiClient();
}
return $client;
}
-448
View File
@@ -1,448 +0,0 @@
<?php
/**
* API Proxy - forwards requests from frontend to FastAPI backend
* This handles CORS and provides a single point for frontend API calls
*/
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/api_client.php';
header('Content-Type: application/json');
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$serverHost = $_SERVER['HTTP_HOST'] ?? '';
// 仅当请求 Origin 与自身 Host 一致时允许跨域(支持换域名)
header('Access-Control-Allow-Origin: ' . ($origin && strpos($origin, $serverHost) !== false ? $origin : 'null'));
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Auth check: return 401 JSON for unauthenticated API requests.
// Note: auth.php (line 6) already checks $_SESSION['logged_in'] and exits
// with a JSON 401 for AJAX requests. This is a belt-and-suspenders guard
// so that even if auth.php's behavior changes, API endpoints remain protected.
if (empty($_SESSION['logged_in'])) {
http_response_code(401);
echo json_encode(['code' => 401, 'error' => 'Unauthorized']);
exit;
}
$action = $_GET['action'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?: [];
// 路径安全:限制文件操作在允许目录内(防路径穿越)
// 路径不存在时,检查父目录是否在允许范围内(支持新建/重命名操作)
define('FM_ALLOWED_PATHS', ['/www/wwwroot', '/www/backup', '/tmp', '/www']);
function safePath($p) {
$real = @realpath($p);
if ($real) {
foreach (FM_ALLOWED_PATHS as $prefix) {
$rb = @realpath($prefix);
if ($rb && strpos($real . '/', $rb . '/') === 0) return $real;
}
return null;
}
// 路径不存在:校验父目录
$parent = dirname($p);
$realParent = @realpath($parent);
if (!$realParent) return null;
foreach (FM_ALLOWED_PATHS as $prefix) {
$rb = @realpath($prefix);
if ($rb && strpos($realParent . '/', $rb . '/') === 0) return $p;
}
return null;
}
// --- File Manager Actions (for source directory browsing) ---
$fmActions = ['browse', 'upload', 'mkdir', 'mkfile', 'rename', 'delete_file'];
if (in_array($action, $fmActions)) {
header('Content-Type: application/json');
if ($action === 'browse') { /* handled below */ }
elseif ($action === 'upload') {
$path = safePath($_POST['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
$uploaded = [];
foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmp) {
$name = $_FILES['files']['name'][$i] ?? 'unknown';
$dest = rtrim($path, '/') . '/' . basename($name);
if (move_uploaded_file($tmp, $dest)) $uploaded[] = $name;
}
echo json_encode(['code' => 200, 'uploaded' => $uploaded], JSON_UNESCAPED_UNICODE);
exit;
}
elseif ($action === 'mkdir') {
$base = safePath($input['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
$p = $base . '/' . basename($input['name'] ?? 'new-folder');
if (mkdir($p, 0755, true)) echo json_encode(['code' => 200]); else echo json_encode(['code' => 500, 'error' => '创建失败']);
exit;
}
elseif ($action === 'mkfile') {
$base = safePath($input['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
$p = $base . '/' . basename($input['name'] ?? 'new-file');
if (file_put_contents($p, '') !== false) echo json_encode(['code' => 200]); else echo json_encode(['code' => 500, 'error' => '创建失败']);
exit;
}
elseif ($action === 'rename') {
$old = safePath($input['path'] ?? '') ?? $input['path'];
$new = dirname($old) . '/' . basename($input['name'] ?? '');
if ($old && $new && safePath(dirname($new)) && rename($old, $new)) echo json_encode(['code' => 200]); else echo json_encode(['code' => 500, 'error' => '重命名失败']);
exit;
}
elseif ($action === 'delete_file') {
$p = safePath($input['path'] ?? '') ?? $input['path'];
if ($p && is_dir($p)) {
// 递归删除目录
$it = new RecursiveDirectoryIterator($p, RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
$file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath());
}
$ok = rmdir($p);
} else {
$ok = unlink($p);
}
echo json_encode(['code' => $ok ? 200 : 500, 'error' => $ok ? '' : '删除失败']);
exit;
}
exit;
}
$result = null;
try {
switch ($action) {
// --- Server actions ---
case 'get_servers':
$result = api()->getServers();
break;
case 'get_server':
$id = $_GET['id'] ?? null;
$result = api()->getServer($id);
break;
case 'check_server':
$id = $_GET['id'] ?? null;
$result = api()->checkServerHealth($id);
break;
case 'check_all':
$servers = api()->getServers()['data'] ?? [];
// Concurrent health checks using curl_multi
$handles = [];
$mh = curl_multi_init();
foreach ($servers as $s) {
$ch = curl_init(API_BASE . '/api/servers/' . $s['id'] . '/health');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
if (API_KEY) curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . API_KEY]);
curl_multi_add_handle($mh, $ch);
$handles[$s['id']] = $ch;
}
// Execute all requests concurrently
do { $status = curl_multi_exec($mh, $active); } while ($status === CURLM_CALL_MULTI_PERFORM);
while ($active && $status === CURLM_OK) {
curl_multi_select($mh, 1);
do { $status = curl_multi_exec($mh, $active); } while ($status === CURLM_CALL_MULTI_PERFORM);
}
// Collect results
$results = [];
foreach ($servers as $s) {
$ch = $handles[$s['id']];
$resp = curl_multi_getcontent($ch);
$data = json_decode($resp, true) ?: ['server_id' => $s['id'], 'is_online' => false];
$results[] = $data;
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
$result = ['data' => $results, 'code' => 200];
break;
// --- Push ---
case 'push_servers':
$input = json_decode(file_get_contents('php://input'), true);
$result = api()->pushServers($input['source_path'] ?? '', $input['server_ids'] ?? []);
break;
// --- Logs ---
case 'get_logs':
$limit = (int)($_GET['limit'] ?? 100);
$result = api()->getAllLogs($limit);
break;
// --- Dashboard ---
case 'dashboard':
$result = ['data' => api()->getDashboardStats(), 'code' => 200];
break;
// --- File Browser (local source directory) ---
case 'browse':
$path = safePath($_GET['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
$path = rtrim($path, '/') ?: '/';
if (!is_dir($path)) { $path = '/www/wwwroot'; }
$parent = dirname($path);
if ($parent === $path) $parent = null;
$entries = [];
$dirs = []; $files = [];
foreach (scandir($path) as $name) {
if ($name === '.') continue;
if ($name === '..') { $dirs[] = ['name'=>'..','path'=>$parent,'type'=>'parent']; continue; }
$full = $path . '/' . $name;
$isDir = is_dir($full);
if ($name[0] === '.') continue;
$stat = stat($full);
$entry = [
'name' => $name,
'path' => str_replace('//', '/', $full),
'type' => $isDir ? 'dir' : 'file',
'size' => $isDir ? 0 : $stat['size'],
'mtime' => date('Y-m-d H:i', $stat['mtime']),
'mtime_ts' => $stat['mtime'],
'perms' => substr(sprintf('%o', $stat['mode']), -3),
'owner' => function_exists('posix_getpwuid') ? posix_getpwuid($stat['uid'])['name'] ?? $stat['uid'] : $stat['uid'],
'group' => function_exists('posix_getgrgid') ? posix_getgrgid($stat['gid'])['name'] ?? $stat['gid'] : $stat['gid'],
];
if ($isDir) { $dirs[] = $entry; } else { $files[] = $entry; }
}
usort($dirs, fn($a,$b) => strcasecmp($a['name'], $b['name']));
usort($files, fn($a,$b) => strcasecmp($a['name'], $b['name']));
$entries = array_merge($dirs, $files);
$result = ['path' => $path, 'parent' => $parent, 'entries' => $entries, 'code' => 200];
break;
case 'file_search':
$type = $_POST['type'] ?? 'content';
$text = $_POST['text'] ?? '';
$dir = $_POST['dir'] ?? '/www/wwwroot';
$ext = $_POST['ext'] ?? '';
$dir = safePath($dir) ?? '/www/wwwroot';
if (empty($text)) { $result = ['code'=>400, 'error'=>'请输入搜索内容']; break; }
$results = [];
if ($type === 'filename') {
// Search file names
$cmd = "find " . escapeshellarg($dir) . " -type f -name '*" . escapeshellarg($text) . "*' 2>/dev/null | head -500";
if ($ext) {
$cmd = "find " . escapeshellarg($dir) . " -type f -name '*" . escapeshellarg($text) . "*' -name '*." . escapeshellarg($ext) . "' 2>/dev/null | head -500";
}
exec($cmd, $lines);
foreach ($lines as $line) {
$results[] = ['file' => $line, 'line' => '', 'content' => ''];
}
} else {
// Search file contents (grep)
if ($ext && !preg_match('/^[a-zA-Z0-9]+$/', $ext)) {
$result = ['code' => 400, 'error' => 'Invalid file extension'];
break;
}
$extPattern = $ext ? "--include=*.$ext" : "--include=*.{php,py,js,html,css,json,md,txt,log,sh,sql,yml,yaml,xml,conf,ini,env,htaccess}";
$cmd = "grep -rnI --exclude-dir={.git,node_modules,__pycache__,vendor,.cache} $extPattern " . escapeshellarg($text) . " " . escapeshellarg($dir) . " 2>/dev/null | head -500";
exec($cmd, $lines);
foreach ($lines as $line) {
if (preg_match('/^(.+?):(\d+):(.*)$/', $line, $m)) {
$results[] = ['file' => $m[1], 'line' => $m[2], 'content' => htmlspecialchars(substr($m[3], 0, 200))];
}
}
}
$result = ['code' => 200, 'results' => $results, 'count' => count($results)];
break;
case 'get_public_ip':
// 从服务端获取公网 IP(浏览器端会拿到用户自己的 IP)
$ip = null;
$ctx = stream_context_create(['http' => ['timeout' => 3]]);
$resp = @file_get_contents('https://api.ipify.org?format=json', false, $ctx);
if ($resp) { $data = json_decode($resp, true); $ip = $data['ip'] ?? null; }
if (!$ip) {
$resp = @file_get_contents('https://httpbin.org/ip', false, $ctx);
if ($resp) { $data = json_decode($resp, true); $ip = $data['origin'] ?? null; }
}
if (!$ip) { $ip = $_SERVER['SERVER_ADDR'] ?? '未知'; }
$result = ['code' => 200, 'ip' => $ip];
break;
case 'restart_multisync':
exec('sudo systemctl restart multisync 2>&1', $out, $code);
$result = ['code' => $code === 0 ? 200 : 500, 'message' => $code === 0 ? 'Restarted' : implode("\n", $out)];
break;
// === File Manager v2 API ===
case 'file_list':
$path = safePath($_POST['path'] ?? '/www/wwwroot') ?? '/www/wwwroot';
$sort = $_POST['sort'] ?? 'name';
$order = $_POST['order'] ?? 'asc';
$search = $_POST['search'] ?? '';
$page = max(1, intval($_POST['page'] ?? 1));
$perPage = 100;
if (!in_array($sort, ['name','size','mtime'])) $sort = 'name';
if ($order !== 'desc') $order = 'asc';
$entries = [];
foreach (scandir($path) as $name) {
if ($name === '.' || $name === '..') continue;
if ($search && stripos($name, $search) === false) continue;
$full = $path . '/' . $name;
$isDir = is_dir($full);
$stat = stat($full);
$entries[] = [
'name' => $name, 'type' => $isDir ? 'dir' : 'file',
'size' => $isDir ? 0 : $stat['size'],
'mtime' => $stat['mtime'], 'mtime_str' => date('Y-m-d H:i', $stat['mtime']),
'perms' => substr(sprintf('%o', $stat['mode']), -3),
];
}
// Sort
usort($entries, function($a, $b) use ($sort, $order) {
if ($a['type'] !== $b['type']) return $a['type'] === 'dir' ? -1 : 1;
$va = $a[$sort]; $vb = $b[$sort];
$cmp = is_numeric($va) ? $va - $vb : strcasecmp($va, $vb);
return $order === 'desc' ? -$cmp : $cmp;
});
$total = count($entries);
$entries = array_slice($entries, ($page - 1) * $perPage, $perPage);
$result = ['code' => 200, 'path' => $path,
'parent' => dirname($path) === $path ? null : dirname($path),
'entries' => $entries, 'total' => $total, 'page' => $page];
break;
case 'file_read':
$path = safePath($_POST['path'] ?? '') ?? '';
if (!$path || !is_file($path)) { $result = ['code' => 404, 'error' => 'File not found']; break; }
$size = filesize($path);
$maxRead = 3 * 1024 * 1024; // 3MB
if ($size > $maxRead) {
$content = file_get_contents($path, false, null, $size - $maxRead, $maxRead);
$content = "[... truncated, showing last 3MB ...]\n" . $content;
} else {
$content = file_get_contents($path);
}
$encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312'], true) ?: 'UTF-8';
$result = ['code' => 200, 'content' => $content, 'encoding' => $encoding,
'size' => $size, 'mtime' => filemtime($path), 'readonly' => !is_writable($path)];
break;
case 'file_write':
$path = safePath($_POST['path'] ?? '') ?? '';
$content = $_POST['content'] ?? '';
$encoding = $_POST['encoding'] ?? 'UTF-8';
$mtime = intval($_POST['mtime'] ?? 0);
if (!$path) { $result = ['code' => 400, 'error' => 'Invalid path']; break; }
if ($mtime && file_exists($path) && filemtime($path) !== $mtime) {
$result = ['code' => 409, 'error' => 'File modified by another process'];
break;
}
if ($encoding !== 'UTF-8') $content = mb_convert_encoding($content, $encoding, 'UTF-8');
file_put_contents($path, $content);
$result = ['code' => 200, 'ok' => true, 'mtime' => filemtime($path)];
break;
case 'file_create':
$path = safePath($_POST['path'] ?? '') ?? '';
$type = $_POST['type'] ?? 'file';
if (!$path) { $result = ['code' => 400, 'error' => 'Invalid path']; break; }
if ($type === 'dir') { mkdir($path, 0755, true); }
else { file_put_contents($path, ''); }
$result = ['code' => 200, 'ok' => true];
break;
case 'file_delete':
$path = safePath($_POST['path'] ?? '') ?? '';
if (!$path || !file_exists($path)) { $result = ['code' => 404, 'error' => 'Not found']; break; }
if (is_dir($path)) {
$it = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) { $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); }
rmdir($path);
} else { unlink($path); }
$result = ['code' => 200, 'ok' => true];
break;
case 'file_rename':
$old = safePath($_POST['old'] ?? '') ?? '';
$new = safePath($_POST['new'] ?? '') ?? '';
if (!$old || !$new || !file_exists($old)) { $result = ['code' => 404, 'error' => 'Not found']; break; }
rename($old, $new);
$result = ['code' => 200, 'ok' => true];
break;
case 'file_copy':
$src = safePath($_POST['src'] ?? '') ?? '';
$dst = $_POST['dst'] ?? '';
$dstSafe = safePath(dirname($dst)) ? $dst : null;
if (!$src || !$dstSafe || !file_exists($src)) { $result = ['code' => 404, 'error' => 'Not found']; break; }
if (is_dir($src)) {
$it = new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS);
foreach (new RecursiveIteratorIterator($it, RecursiveIteratorIterator::SELF_FIRST) as $item) {
$target = $dst . '/' . $it->getSubPathName();
$item->isDir() ? mkdir($target, 0755, true) : copy($item, $target);
}
} else { copy($src, $dst); }
$result = ['code' => 200, 'ok' => true];
break;
case 'file_chmod':
$path = safePath($_POST['path'] ?? '') ?? '';
$mode = $_POST['mode'] ?? '0755';
// 仅允许安全的权限掩码
$allowedModes = ['0755', '0644', '0750', '0640', '0700', '0600'];
if (!in_array($mode, $allowedModes)) {
$result = ['code' => 400, 'error' => 'Permission mode not allowed'];
break;
}
if (!$path || !file_exists($path)) { $result = ['code' => 404, 'error' => 'Not found']; break; }
chmod($path, octdec($mode));
$result = ['code' => 200, 'ok' => true];
break;
case 'file_upload':
$dir = safePath($_POST['dir'] ?? '/www/wwwroot') ?? '/www/wwwroot';
$uploaded = []; $failed = [];
$maxSize = 500 * 1024 * 1024; // 500MB
foreach ($_FILES as $f) {
if ($f['error'] ?? 0) { $failed[] = ['name' => $f['name'] ?? '?', 'error' => 'Upload error']; continue; }
if ($f['size'] > $maxSize) { $failed[] = ['name' => $f['name'] ?? '?', 'error' => 'File too large (max 500MB)']; continue; }
$dest = rtrim($dir, '/') . '/' . basename($f['name'] ?? 'file');
if (move_uploaded_file($f['tmp_name'], $dest)) $uploaded[] = basename($dest);
else $failed[] = ['name' => $f['name'] ?? '?', 'error' => 'Move failed'];
}
$result = ['code' => 200, 'uploaded' => $uploaded, 'failed' => $failed];
break;
case 'file_compress':
$dir = safePath($_POST['dir'] ?? '') ?? '';
$name = $_POST['name'] ?? 'archive';
$paths = $_POST['paths'] ?? [];
if (!$dir || empty($paths)) { $result = ['code' => 400, 'error' => 'Invalid params']; break; }
$zipFile = rtrim($dir, '/') . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $name) . '.zip';
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
$result = ['code' => 500, 'error' => 'Cannot create archive']; break;
}
foreach ((array)$paths as $p) {
$sp = safePath($p) ?? $p;
if (is_dir($sp)) {
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($sp, RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($it as $f) { $zip->addFile($f->getRealPath(), substr($f->getRealPath(), strlen(dirname($sp)) + 1)); }
} elseif (is_file($sp)) {
$zip->addFile($sp, basename($sp));
}
}
$zip->close();
$result = ['code' => 200, 'ok' => true, 'file' => $zipFile];
break;
// === End File Manager v2 ===
default:
http_response_code(400);
$result = ['error' => 'Unknown action', 'code' => 400];
}
} catch (Exception $e) {
http_response_code(500);
$result = ['error' => $e->getMessage(), 'code' => 500];
}
echo json_encode($result, JSON_UNESCAPED_UNICODE);
-86
View File
@@ -1,86 +0,0 @@
/**
* MultiSync - Frontend JavaScript
*/
/**
* Show a toast notification
*/
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* Confirm dialog with callback
*/
function confirmAction(message, callback) {
if (confirm(message)) {
callback();
}
}
/**
* Format bytes to human readable
*/
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
/**
* Format duration in seconds to human readable
*/
function formatDuration(seconds) {
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.floor(seconds/60)}${seconds%60}`;
return `${Math.floor(seconds/3600)}${Math.floor((seconds%3600)/60)}`;
}
/**
* API proxy for AJAX calls
*/
async function apiCall(endpoint, method = 'GET', data = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json',
},
};
if (data) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(`api_proxy.php?action=${endpoint}`, options);
return await response.json();
} catch (error) {
console.error('API call failed:', error);
showToast('请求失败: ' + error.message, 'error');
throw error;
}
}
/**
* Loading state management
*/
function setLoading(element, loading) {
if (loading) {
element.disabled = true;
element.dataset.originalText = element.innerHTML;
element.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
} else {
element.disabled = false;
element.innerHTML = element.dataset.originalText || element.innerHTML;
}
}
+104
View File
@@ -0,0 +1,104 @@
// Nexus — Shared API auth module (JWT Bearer + auto-refresh)
// Include before page-specific scripts: <script src="/app/api.js"></script>
const API = window.location.origin + '/api';
function _getTokens() {
return {
access: localStorage.getItem('access_token') || '',
refresh: localStorage.getItem('refresh_token') || '',
expires: parseInt(localStorage.getItem('token_expires') || '0'),
};
}
function _isTokenExpiring() {
// Refresh if token expires within 2 minutes
return Date.now() > _getTokens().expires - 120000;
}
async function _refreshTokens() {
const { refresh } = _getTokens();
if (!refresh) return false;
try {
const res = await fetch(API + '/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!res.ok) return false;
const data = await res.json();
if (!data.success) return false;
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token || refresh);
localStorage.setItem('token_expires', String(Date.now() + (data.expires_in || 1800) * 1000));
return true;
} catch {
return false;
}
}
function apiHeaders() {
const { access } = _getTokens();
return { 'Authorization': 'Bearer ' + access };
}
function apiHeadersJSON() {
return { ...apiHeaders(), 'Content-Type': 'application/json' };
}
async function apiFetch(url, options = {}) {
// Auto-refresh if token is about to expire
if (_isTokenExpiring()) {
const refreshed = await _refreshTokens();
if (!refreshed) {
_logout();
return null;
}
}
// Add auth headers
options.headers = { ...apiHeaders(), ...(options.headers || {}) };
const res = await fetch(url, options);
// If 401, try refresh once
if (res.status === 401) {
const refreshed = await _refreshTokens();
if (refreshed) {
options.headers = { ...apiHeaders(), ...(options.headers || {}) };
return fetch(url, options);
}
_logout();
return null;
}
return res;
}
function _logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('token_expires');
localStorage.removeItem('admin');
window.location.href = '/app/login.html';
}
function doLogout() {
// Notify backend (best-effort, don't block)
const { access, refresh } = _getTokens();
if (access) {
try {
const blob = new Blob(
[JSON.stringify({ refresh_token: refresh })],
{ type: 'application/json' }
);
navigator.sendBeacon(API + '/auth/logout', blob);
} catch {}
}
_logout();
}
// Backward-compatible alias
const token = localStorage.getItem('access_token') || '';
if (!token) window.location.href = '/app/login.html';
function ah() { return apiHeaders(); }
+40 -13
View File
@@ -1,25 +1,52 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 审计日志</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><a href="/app/index.html" class="text-white font-bold text-lg">Nexus</a></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/audit.html" class="block px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">📋 审计日志</a><a href="/app/settings.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">审计日志</h1></div></header>
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">审计日志</h1></div>
<div class="flex items-center gap-3">
<select id="actionFilter" onchange="loadAudit()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<option value="">全部操作</option>
<option value="create_server">添加服务器</option>
<option value="update_server">更新服务器</option>
<option value="delete_server">删除服务器</option>
<option value="sync_files">文件推送</option>
<option value="sync_commands">命令执行</option>
<option value="update_setting">修改设置</option>
<option value="login">登录</option>
</select>
<button onclick="loadAudit()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="auditTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs"><tr><th class="text-left px-4 py-3">操作人</th><th class="text-left px-4 py-3">操作</th><th class="text-left px-4 py-3">目标</th><th class="text-left px-4 py-3">详情</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">操作人</th><th class="text-left px-4 py-3">操作</th><th class="text-left px-4 py-3">目标</th><th class="text-left px-4 py-3">详情</th><th class="text-left px-4 py-3">IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
function ah(){return{'Authorization':'Bearer '+token}}
function doLogout(){localStorage.clear();location.href='/app/login.html'}
initLayout('audit');
async function loadAudit(){
const r=await fetch(API+'/audit/?limit=100',{headers:ah()});
const action=document.getElementById('actionFilter').value;
const url=API+'/audit/?limit=200'+(action?'&action='+encodeURIComponent(action):'');
const r=await apiFetch(url);if(!r)return;
const logs=await r.json();const tbody=document.getElementById('auditTbody');
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无日志</td></tr>';return}
tbody.innerHTML=logs.map(l=>`<tr class="hover:bg-slate-800/30"><td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':'bg-slate-800 text-slate-300'}">${esc(l.action)}</span></td><td class="px-4 py-3 text-slate-400">${esc(l.target_type||'--')}</td><td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate">${esc((l.detail||'').substring(0,80))}</td><td class="px-4 py-3 text-slate-500 text-xs text-right">${fmt(l.created_at)}</td></tr>`).join('')
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">暂无日志</td></tr>';return}
tbody.innerHTML=logs.map(l=>{
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')?'bg-blue-400/10 text-blue-400':'bg-slate-800 text-slate-300';
return `<tr class="hover:bg-slate-800/30 transition">
<td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${badge}">${esc(l.action)}</span></td>
<td class="px-4 py-3 text-slate-400 text-xs">${esc(l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
<td class="px-4 py-3 text-slate-600 text-xs">${esc(l.ip_address||'--')}</td>
<td class="px-4 py-3 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`}).join('')
}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN')}
loadAudit()
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
loadAudit();
</script>
</body></html>
</body></html>
+173 -11
View File
@@ -1,15 +1,177 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 凭据管理</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><span class="text-white font-bold text-lg">Nexus</span></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">🔑 凭据</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><h1 class="text-white font-semibold">凭据管理</h1></header>
<main class="flex-1 overflow-y-auto p-6"><div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div></main>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
<nav class="flex-1 py-3 px-3 space-y-1">
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a>
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">🔑 凭据</a>
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a>
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a>
</nav>
<div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div>
</aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">凭据管理</h1></div>
<button onclick="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
</header>
<!-- Tabs -->
<div class="bg-slate-900 border-b border-slate-800 px-6 flex">
<button onclick="switchTab('db')" id="tabDB" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">数据库凭据</button>
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">密码预设</button>
</div>
<main class="flex-1 overflow-y-auto p-6">
<!-- DB Credentials -->
<div id="dbSection">
<div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
</div>
<!-- Password Presets -->
<div id="presetSection" class="hidden">
<div id="presetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
</div>
<!-- Add DB Credential Modal -->
<div id="credModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
<h2 class="text-white font-semibold text-lg">新建数据库凭据</h2>
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
<div><label class="block text-slate-400 text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
</div>
<div><label class="block text-slate-400 text-xs mb-1">主机</label><input id="credHost" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="root"></div>
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="credPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
</div>
<div><label class="block text-slate-400 text-xs mb-1">数据库</label><input id="credDb" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
<div class="flex gap-2 pt-2"><button onclick="createCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
<!-- Add Password Preset Modal -->
<div id="presetModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
<h2 class="text-white font-semibold text-lg">新建密码预设</h2>
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="输入密码(将加密存储)"></div>
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
<div class="flex gap-2 pt-2"><button onclick="createPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script>
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
function ah(){return{'Authorization':'Bearer '+token}}
function doLogout(){localStorage.clear();location.href='/app/login.html'}
async function loadCreds(){const r=await fetch(API+'/presets/',{headers:ah()});const p=await r.json();document.getElementById('credsList').innerHTML=p.length?p.map(c=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><span class="text-white">${esc(c.name)}</span><span class="text-slate-500 text-xs ml-2">#${c.id}</span></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无凭据</div>'}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
loadCreds()
const DB_ICONS={mysql:'🐬',postgresql:'🐘',mariadb:'🐬',mongodb:'🍃',redis:'🔴'};
const DB_PORTS={mysql:3306,postgresql:5432,mariadb:3306,mongodb:27017,redis:6379};
function switchTab(t){
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
document.getElementById('dbSection').classList.toggle('hidden',t!=='db');
document.getElementById('presetSection').classList.toggle('hidden',t!=='preset');
if(t==='preset')loadPresets();
}
// ── DB Credentials ──
async function loadCreds(){
try{
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
const creds=await r.json();
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-lg">${DB_ICONS[c.db_type]||'🗄'}</span>
<div>
<span class="text-white font-medium">${esc(c.name)}</span>
<div class="text-slate-500 text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
</div>
</div>
<button onclick="deleteCred(${c.id})" class="text-red-400 text-xs hover:underline">删除</button>
</div>
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无数据库凭据</div>';
}catch(e){document.getElementById('credsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
function showAddCred(){
const t=Alpine?Alpine.store?'db':document.querySelector('[x-data]')?.__x?.$data?.tab:'db':'db';
// Check which tab is active
if(!document.getElementById('dbSection').classList.contains('hidden')){
['credName','credHost','credUser','credPass','credDb'].forEach(id=>document.getElementById(id).value='');
document.getElementById('credType').value='mysql';
document.getElementById('credPort').value='3306';
document.getElementById('credModal').classList.remove('hidden');
}else{
document.getElementById('presetName').value='';
document.getElementById('presetPass').value='';
document.getElementById('presetModal').classList.remove('hidden');
}
}
function hideCredModal(){document.getElementById('credModal').classList.add('hidden')}
function hidePresetModal(){document.getElementById('presetModal').classList.add('hidden')}
document.getElementById('credType').addEventListener('change',()=>{
const t=document.getElementById('credType').value;
document.getElementById('credPort').value=DB_PORTS[t]||3306;
});
async function createCred(){
const body={name:document.getElementById('credName').value,db_type:document.getElementById('credType').value,host:document.getElementById('credHost').value,port:parseInt(document.getElementById('credPort').value)||3306,username:document.getElementById('credUser').value,password:document.getElementById('credPass').value,database:document.getElementById('credDb').value||null};
if(!body.name||!body.host||!body.username||!body.password){alert('名称、主机、用户名、密码必填');return}
await apiFetch(API+'/scripts/credentials',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
hideCredModal();loadCreds();
}
async function deleteCred(id){
if(!confirm('确定删除凭据?'))return;
await apiFetch(API+'/scripts/credentials/'+id,{method:'DELETE'});
loadCreds();
}
// ── Password Presets ──
async function loadPresets(){
try{
const r=await apiFetch(API+'/presets/');if(!r)return;
const presets=await r.json();
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-lg">🔑</span>
<div>
<span class="text-white font-medium">${esc(p.name)}</span>
<div class="text-slate-500 text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
</div>
</div>
<button onclick="deletePreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
</div>
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无密码预设</div>';
}catch(e){document.getElementById('presetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
async function createPreset(){
const name=document.getElementById('presetName').value;
const pw=document.getElementById('presetPass').value;
if(!name||!pw){alert('名称和密码必填');return}
await apiFetch(API+'/presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({name,encrypted_pw:pw})});
hidePresetModal();loadPresets();
}
async function deletePreset(id){
if(!confirm('确定删除密码预设?'))return;
await apiFetch(API+'/presets/'+id,{method:'DELETE'});
loadPresets();
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
loadCreds();
</script>
</body></html>
</body></html>
+6 -7
View File
@@ -1,15 +1,14 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 文件管理</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">📜 脚本库</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">文件管理</h1></div><div class="flex items-center gap-3"><select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option>-- 选择服务器 --</option></select><input id="dirPath" placeholder="/www/wwwroot/" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm w-64 placeholder-slate-500"><button onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">浏览</button></div></header>
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a><a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">文件管理</h1></div><div class="flex items-center gap-3"><select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option>-- 选择服务器 --</option></select><input id="dirPath" placeholder="/" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm w-64 placeholder-slate-500"><button onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">浏览</button></div></header>
<main class="flex-1 overflow-y-auto p-6"><div id="fileList" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden"><div class="px-4 py-8 text-center text-slate-500">选择服务器并输入目录路径</div></div></main>
</div>
<script src="/app/api.js"></script>
<script>
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
function ah(){return{'Authorization':'Bearer '+token}}
function doLogout(){localStorage.clear();location.href='/app/login.html'}
async function loadServers(){const r=await fetch(API+'/servers/',{headers:ah()});const servers=await r.json();document.getElementById('serverSelect').innerHTML='<option>-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${s.name} (${s.domain})</option>`).join('')}
function browseDir(){const sid=document.getElementById('serverSelect').value;const dir=document.getElementById('dirPath').value||'/www/wwwroot/';document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">📂 浏览 '+dir+'</div>'}
async function loadServers(){const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;document.getElementById('serverSelect').innerHTML='<option value="">-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${s.name} (${s.domain})</option>`).join('')}
async function browseDir(path){if(path){document.getElementById('dirPath').value=path}const sid=document.getElementById('serverSelect').value;const dir=document.getElementById('dirPath').value||'/';if(!sid){alert('请先选择服务器');return}document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">加载中...</div>';try{const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:parseInt(sid),path:dir})});if(!r){document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-red-400">认证失败</div>';return}const d=await r.json();if(d.error){document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">${esc(d.error)}</div>`;return}document.getElementById('fileList').innerHTML=d.entries.length?d.entries.map(e=>`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${(dir.replace(/\/$/,'')+'/'+e.name).replace(/'/g,"\\'")}')"`:''}"><span class="${e.is_dir?'text-yellow-400':'text-slate-400'}">${e.is_dir?'📁':'📄'}</span><span class="text-white text-sm flex-1">${esc(e.name)}</span><span class="text-slate-500 text-xs">${e.size}</span><span class="text-slate-600 text-xs">${e.perms}</span></div>`).join(''):'<div class="px-4 py-8 text-center text-slate-500">空目录</div>'}catch(e){document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">连接失败: ${esc(e.message)}</div>`}}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
loadServers();
</script>
</body></html>
+201 -47
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,20 +11,18 @@
--color-brand: oklch(55% 0.2 250);
--color-brand-light: oklch(75% 0.15 250);
--color-brand-dark: oklch(35% 0.18 250);
--color-surface: oklch(98.5% 0.002 250);
}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<!-- Sidebar (F2) -->
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
<div class="px-5 py-4 border-b border-slate-800">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center">
<svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg>
</div>
<span class="text-white font-bold text-lg">Nexus</span>
<span id="brandName" class="text-white font-bold text-lg">Nexus</span>
</div>
</div>
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
@@ -48,9 +46,7 @@
</div>
</aside>
<!-- Main -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Header -->
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<button @click="sidebarOpen = !sidebarOpen" class="text-slate-400 hover:text-white transition">
@@ -59,18 +55,15 @@
<h1 class="text-white font-semibold">仪表盘</h1>
</div>
<div class="flex items-center gap-3">
<button @click="darkMode = !darkMode" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">
<span x-show="darkMode">🌙</span><span x-show="!darkMode">☀️</span>
</button>
<button onclick="refreshAll()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">刷新</button>
<span id="headerUser" class="text-slate-400 text-sm">...</span>
</div>
</header>
<!-- Dashboard Content (F4) -->
<main class="flex-1 overflow-y-auto p-6 space-y-6">
<!-- Stats -->
<div id="statsRow" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Stats Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="text-slate-400 text-sm mb-1">服务器总数</div>
<div class="text-3xl font-bold text-white" id="statTotal">--</div>
@@ -89,41 +82,136 @@
</div>
</div>
<!-- Recent Activity -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<h2 class="text-white font-semibold mb-4">最近活动</h2>
<div id="recentActivity" class="text-slate-400 text-sm">加载中...</div>
<!-- Live Alerts + Category Pie -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Alerts -->
<div class="lg:col-span-2">
<div id="alertBanner" class="hidden bg-red-900/30 border border-red-700/50 rounded-xl p-4">
<div class="flex items-center justify-between mb-2">
<h2 class="text-red-300 font-semibold text-sm">实时告警</h2>
<button onclick="clearAlerts()" class="text-red-400 hover:text-red-300 text-xs">清除</button>
</div>
<div id="alertList" class="space-y-1 max-h-40 overflow-y-auto text-sm"></div>
</div>
<!-- No alerts state -->
<div id="noAlertBanner" class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex items-center gap-2 text-slate-500 text-sm">
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span>
所有服务器运行正常
</div>
</div>
</div>
<!-- Category Breakdown -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<h2 class="text-white font-semibold mb-4 text-sm">分类分布</h2>
<div id="categoryBars" class="space-y-3">
<div class="text-slate-500 text-sm">加载中...</div>
</div>
</div>
</div>
<!-- Recent Sync + Recent Audit -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Recent Sync Logs -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white font-semibold text-sm">最近同步</h2>
<a href="/app/push.html" class="text-brand-light text-xs hover:underline">查看全部</a>
</div>
<div id="recentSyncs" class="text-slate-400 text-sm">加载中...</div>
</div>
<!-- Recent Audit -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white font-semibold text-sm">最近活动</h2>
<a href="/app/audit.html" class="text-brand-light text-xs hover:underline">查看全部</a>
</div>
<div id="recentActivity" class="text-slate-400 text-sm">加载中...</div>
</div>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script>
const API = window.location.origin + '/api';
const token = getToken();
const MAX_ALERTS = 20;
let _alerts = [];
let _ws = null;
let _wsRetry = 0;
let _stats = null;
// ── Load Dashboard ──
// ── Dashboard Stats ──
async function loadDashboard() {
try {
const res = await fetch(API + '/servers/', { headers: authHeaders() });
const servers = await res.json();
const res = await apiFetch(API + '/servers/stats');
if (!res) return;
_stats = await res.json();
document.getElementById('statTotal').textContent = _stats.total || 0;
document.getElementById('statOnline').textContent = _stats.online || 0;
document.getElementById('statOffline').textContent = _stats.offline || 0;
document.getElementById('statAlerts').textContent = _stats.alerts || 0;
// Category breakdown
renderCategories(_stats.categories || {});
} catch(e) {}
}
const total = servers.length;
const online = servers.filter(s => s.is_online).length;
const offline = total - online;
const alerts = online; // Simplified
function renderCategories(categories) {
const entries = Object.entries(categories);
if (!entries.length) {
document.getElementById('categoryBars').innerHTML = '<div class="text-slate-500 text-sm">暂无分类数据</div>';
return;
}
const total = entries.reduce((s,[,v]) => s+v, 0);
const colors = ['bg-brand','bg-green-500','bg-yellow-500','bg-purple-500','bg-cyan-500','bg-pink-500'];
document.getElementById('categoryBars').innerHTML = entries.map(([cat,count],i) => {
const pct = Math.round(count/total*100);
return `<div>
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-slate-500">${count}台 (${pct}%)</span></div>
<div class="w-full bg-slate-800 rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
</div>`;
}).join('');
}
document.getElementById('statTotal').textContent = total;
document.getElementById('statOnline').textContent = online;
document.getElementById('statOffline').textContent = offline;
document.getElementById('statAlerts').textContent = '0';
// ── Recent Syncs ──
async function loadSyncs() {
try {
const res = await apiFetch(API + '/servers/logs?limit=8');
if (!res) return;
const logs = await res.json();
if (!logs.length) {
document.getElementById('recentSyncs').innerHTML = '<div class="text-slate-500 text-sm">暂无同步记录</div>';
return;
}
document.getElementById('recentSyncs').innerHTML = logs.map(l => {
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
const icon = l.status==='success'?'✅':l.status==='failed'?'❌':'⏳';
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-slate-300 text-xs">${esc(l.sync_mode||'sync')} #${l.server_id||'?'}</span><span class="${sc} text-xs">${esc(l.status)}</span></div>
<span class="text-slate-600 text-xs">${fmtTime(l.started_at)}</span>
</div>`;
}).join('');
} catch(e) {
document.getElementById('recentSyncs').textContent = '加载失败';
}
}
// Recent activity from audit
try {
const auditRes = await fetch(API + '/audit/?limit=10', { headers: authHeaders() });
const audits = await auditRes.json();
const html = audits.map(a => `<div class="py-2 border-b border-slate-800 last:border-0"><span class="text-brand-light">${a.admin_username || 'system'}</span> <span class="text-slate-500">${a.action}</span> <span class="text-slate-600 text-xs float-right">${fmtTime(a.created_at)}</span></div>`).join('') || '<div class="text-slate-500">暂无活动</div>';
document.getElementById('recentActivity').innerHTML = html;
} catch(e) {}
// ── Recent Activity ──
async function loadActivity() {
try {
const res = await apiFetch(API + '/audit/?limit=8');
if (!res) return;
const audits = await res.json();
if (!audits.length) {
document.getElementById('recentActivity').innerHTML = '<div class="text-slate-500 text-sm">暂无活动</div>';
return;
}
document.getElementById('recentActivity').innerHTML = audits.map(a => {
const icon = a.action.includes('delete')?'🔴':a.action.includes('create')?'🔵':a.action.includes('login')?'🟢':'⚪';
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-slate-500 text-xs">${esc(a.action)}</span></div>
<span class="text-slate-600 text-xs">${fmtTime(a.created_at)}</span>
</div>`;
}).join('');
} catch(e) {
document.getElementById('recentActivity').textContent = '加载失败';
}
@@ -132,26 +220,92 @@
// ── User Info ──
async function loadUser() {
try {
const res = await fetch(API + '/auth/me', { headers: authHeaders() });
if (!res.ok) { doLogout(); return; }
const res = await apiFetch(API + '/auth/me');
if (!res) { doLogout(); return; }
const user = await res.json();
document.getElementById('sidebarUser').textContent = user.username;
document.getElementById('headerUser').textContent = user.username;
} catch(e) {}
}
// ── Helpers ──
function getToken() {
const t = localStorage.getItem('access_token');
if (!t) { window.location.href = '/app/login.html'; return ''; }
return t;
// ── Brand Name ──
function loadBrand() {
const admin = localStorage.getItem('admin');
if (admin) {
try { const a = JSON.parse(admin); if (a.system_name) document.getElementById('brandName').textContent = a.system_name; } catch(e) {}
}
}
function authHeaders() { return { 'Authorization': `Bearer ${token}` }; }
function fmtTime(t) { if (!t) return ''; const d = new Date(t + 'Z'); return d.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
function doLogout() { localStorage.clear(); window.location.href = '/app/login.html'; }
// ── WebSocket Real-time Updates ──
function connectWS() {
const token = localStorage.getItem('access_token');
if (!token) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
_ws = new WebSocket(`${proto}://${location.host}/ws/alerts?token=${token}`);
_ws.onopen = () => { _wsRetry = 0; };
_ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'ping') { _ws.send('pong'); return; }
loadDashboard();
if (msg.type === 'alert') {
_addAlert('🔴', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
} else if (msg.type === 'recovery') {
_addAlert('🟢', `${msg.server_name} ${msg.metric} 恢复正常 (${msg.value.toFixed(1)}%)`);
} else if (msg.type === 'system') {
_addAlert('⚠️', msg.message);
}
} catch(ex) {}
};
_ws.onclose = () => {
const delay = Math.min(1000 * Math.pow(2, _wsRetry), 30000);
_wsRetry++;
setTimeout(connectWS, delay);
};
_ws.onerror = () => { _ws.close(); };
}
function _addAlert(icon, text) {
_alerts.unshift({ icon, text, time: new Date() });
if (_alerts.length > MAX_ALERTS) _alerts.pop();
renderAlerts();
}
function renderAlerts() {
if (!_alerts.length) return;
document.getElementById('alertBanner').classList.remove('hidden');
document.getElementById('noAlertBanner').classList.add('hidden');
document.getElementById('alertList').innerHTML = _alerts.map(a =>
`<div class="text-slate-300">${a.icon} ${a.text} <span class="text-slate-600 text-xs float-right">${fmtTimeObj(a.time)}</span></div>`
).join('');
}
function clearAlerts() {
_alerts = [];
document.getElementById('alertBanner').classList.add('hidden');
document.getElementById('noAlertBanner').classList.remove('hidden');
}
function refreshAll() {
loadDashboard();
loadActivity();
loadSyncs();
}
// ── Helpers ──
function fmtTime(t) { if (!t) return ''; return new Date(t + 'Z').toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
function fmtTimeObj(d) { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
loadBrand();
loadUser();
loadDashboard();
loadActivity();
loadSyncs();
connectWS();
// Auto-refresh every 30s
setInterval(() => { loadDashboard(); loadSyncs(); }, 30000);
</script>
</body>
</html>
</html>
+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">install.php 已重命名为 install.php.locked — 防止重复安装</p>
<!-- Guardian results -->
<div x-show="initResult?.guardian_results?.length" class="text-left mt-6 mb-4 rounded-lg p-4"
:class="guardianAllOk ? 'bg-green-50 border-l-4 border-green-500' : 'bg-amber-50 border-l-4 border-amber-500'">
<div class="font-semibold text-sm mb-2" :class="guardianAllOk ? 'text-green-700' : 'text-amber-700'"
x-text="guardianAllOk ? '✓ 进程守护已自动配置' : '⚠ 进程守护部分配置失败'"></div>
<template x-for="r in (initResult?.guardian_results || [])" :key="r">
<div class="text-xs leading-relaxed" x-text="r"></div>
</template>
</div>
<!-- Post-install checklist -->
<div class="text-left mt-6 space-y-3">
<h3 class="text-sm font-bold text-slate-700 text-center mb-3">━━━ 后续配置清单(必须完成)━━━</h3>
<!-- 1. Supervisor -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">1</span>
Supervisor 进程守护
</div>
<div x-show="!guardianAllOk" class="text-xs text-slate-500 space-y-1">
<p>安装: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y supervisor</code></p>
<p>配置文件复制到: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/etc/supervisor/conf.d/nexus.conf</code></p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'supervisorConf')">复制配置</button><pre id="supervisorConf">[program:nexus]
command=<span x-text="installDir"></span>/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port <span x-text="form.api_port"></span>
directory=<span x-text="installDir"></span>
user=root
autostart=true
autorestart=true
startretries=10
startsecs=3
stopwaitsecs=10
stopsignal=INT
environment=PATH="<span x-text="installDir"></span>/venv/bin:%(ENV_PATH)s"
stderr_logfile=/var/log/nexus/error.log
stdout_logfile=/var/log/nexus/access.log
stderr_logfile_maxbytes=50MB
stdout_logfile_maxbytes=50MB</pre></div>
<p>启动: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus</code></p>
</div>
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — Supervisor 配置已写入,服务已重载</div>
</div>
<!-- 2. Python venv -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">2</span>
Python 虚拟环境 + 依赖
</div>
<div class="text-xs text-slate-500 space-y-1">
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="installDir"></span></code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">python3.12 -m venv venv</code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">source venv/bin/activate</code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">pip install -r requirements.txt</code></p>
</div>
</div>
<!-- 3. Nginx -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">3</span>
Nginx 反向代理 (API + WebSocket)
</div>
<div class="text-xs text-slate-500">
<p class="mb-1">宝塔 → 网站 → 配置文件 → <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">#PHP-INFO-END</code> 后面粘贴:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxConf')">复制配置</button><pre id="nginxConf"> # Nexus Python API + WebSocket
location ^~ /api/ {
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /health {
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location ^~ /ws/ {
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}</pre></div>
</div>
</div>
<!-- 4. Nginx Rewrite -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">4</span>
Nginx 伪静态
</div>
<div class="text-xs text-slate-500">
<p class="mb-1">宝塔 → 网站 → 伪静态 → 粘贴:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxRewrite')">复制</button><pre id="nginxRewrite">location /app/ {
try_files $uri $uri/ /app/index.html;
}
location / {
try_files $uri $uri/ =404;
}</pre></div>
</div>
</div>
<!-- 5. SSL -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">5</span>
SSL证书 (强制HTTPS)
</div>
<div class="text-xs text-slate-500">宝塔 → 网站 → SSL → Let's Encrypt → 一键申请 → 开启强制HTTPS</div>
</div>
<!-- 6. Health Monitor -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">6</span>
Shell 健康检查 (外部守护 + Telegram告警)
</div>
<div x-show="!guardianAllOk" class="text-xs text-slate-500">
<p>配置 crontab:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'crontab')">复制</button><pre id="crontab">* * * * * <span x-text="installDir"></span>/deploy/health_monitor.sh</pre></div>
</div>
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — health_monitor.sh INSTALL_DIR 已更新,Crontab 已添加</div>
<div class="text-xs text-slate-500 mt-2">
<b>3层守护机制:</b><br>
Layer 1: Supervisor — Python崩溃自动重启<br>
Layer 2: Python self_monitor — 每30s检查Redis/MySQL/WebSocket<br>
Layer 3: Shell脚本 — 每分钟HTTP检查,连续3次失败→Supervisor重启+Telegram告警
</div>
</div>
<!-- 7. Security -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">7</span>
安全收尾
</div>
<div class="text-xs text-slate-500 space-y-1">
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">install.php</code> 已自动重命名为 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">install.php.locked</code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 包含 SECRET_KEY/API_KEY — <b>安装后不可修改</b>(加密一致性)</p>
<p>✓ 防火墙限制端口 <span x-text="form.api_port"></span> (仅允许本机和子服务器IP)</p>
<p>✓ 修改管理员默认密码</p>
</div>
</div>
</div>
<div class="flex justify-center mt-6">
<a href="/app/index.html" class="bg-green-500 hover:bg-green-600 text-white px-8 py-3 rounded-lg font-semibold text-lg transition">
进入管理面板 →
</a>
</div>
</div>
</template>
</div>
</div>
<script>
function installWizard() {
const API = window.location.origin;
return {
step: 1,
loading: false,
installed: false,
error: '',
success: '',
envLoading: false,
envChecks: [],
envAllPass: false,
initResult: null,
installDir: '/opt/nexus',
form: {
db_host: 'localhost',
db_port: '3306',
db_name: 'Nexus',
db_user: 'Nexus',
db_pass: '',
redis_host: '127.0.0.1',
redis_port: '6379',
redis_db: '0',
redis_pass: '',
api_port: '8600',
timezone: 'Asia/Shanghai',
site_url: '',
},
adminForm: {
admin_username: 'admin',
admin_password: '',
admin_email: '',
system_name: 'Nexus',
system_title: 'Nexus — 服务器运维管理平台',
},
get guardianAllOk() {
const results = this.initResult?.guardian_results || [];
if (!results.length) return false;
return results.every(r => !r.includes('✗') && !r.includes('⚠'));
},
async init() {
// Auto-detect site URL
const proto = location.protocol === 'https:' ? 'https' : 'http';
this.form.site_url = proto + '://' + location.host;
// Check install status
try {
const r = await fetch(API + '/api/install/status');
if (r.ok) {
const d = await r.json();
this.installed = d.installed;
if (d.installed) return;
}
} catch(e) {}
// Try to restore state
try {
const r = await fetch(API + '/api/install/state');
if (r.ok) {
const d = await r.json();
if (d.step > 1) this.step = d.step;
if (d.db_host) Object.assign(this.form, {
db_host: d.db_host, db_port: d.db_port, db_name: d.db_name,
db_user: d.db_user, db_pass: d.db_pass, api_port: d.api_port || '8600',
});
if (d.install_dir) this.installDir = d.install_dir;
if (d.pool_size) this.initResult = { pool_size: d.pool_size, max_overflow: d.max_overflow, guardian_results: d.guardian_results || [] };
}
} catch(e) {}
},
async checkEnv() {
this.envLoading = true;
this.error = '';
try {
const r = await fetch(API + '/api/install/env-check');
if (r.ok) {
const d = await r.json();
this.envChecks = d.checks;
this.envAllPass = d.all_pass;
}
} catch(e) {
this.error = '环境检测请求失败: ' + e.message;
}
this.envLoading = false;
},
async initDb() {
this.loading = true;
this.error = '';
this.success = '';
try {
const r = await fetch(API + '/api/install/init-db', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(this.form),
});
const d = await r.json();
if (r.ok && d.success) {
this.success = '数据库初始化成功!已创建 ' + (d.tables_created || 14) + ' 张表。';
this.initResult = d;
this.step = 4;
} else {
this.error = d.detail || '初始化失败';
}
} catch(e) {
this.error = '请求失败: ' + e.message;
}
this.loading = false;
},
async createAdmin() {
this.loading = true;
this.error = '';
try {
const body = {
...this.adminForm,
db_host: this.form.db_host,
db_port: this.form.db_port,
db_name: this.form.db_name,
db_user: this.form.db_user,
db_pass: this.form.db_pass,
};
const r = await fetch(API + '/api/install/create-admin', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const d = await r.json();
if (r.ok && d.success) {
// Lock the installer
await fetch(API + '/api/install/lock', { method: 'POST' });
this.step = 5;
} else {
this.error = d.detail || '创建管理员失败';
}
} catch(e) {
this.error = '请求失败: ' + e.message;
}
this.loading = false;
},
copyCode(event, id) {
const el = document.getElementById(id);
if (!el) return;
const text = el.textContent;
const btn = event.target;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '已复制 ✓';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = '复制'; btn.classList.remove('copied'); }, 2000);
});
},
};
}
</script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
// Nexus — Shared Sidebar + Layout Component
// Usage: Include after Alpine.js. Call initLayout() on each page.
// Sets sidebar, highlights active nav item, loads user info.
function initLayout(activeNav) {
// activeNav: 'index'|'servers'|'files'|'push'|'scripts'|'credentials'|'schedules'|'retries'|'audit'|'settings'
const navItems = [
{ id:'index', icon:'🏠', label:'仪表盘', href:'/app/index.html' },
{ id:'servers', icon:'🖥', label:'服务器', href:'/app/servers.html' },
{ id:'files', icon:'📁', label:'文件管理', href:'/app/files.html' },
{ id:'push', icon:'📤', label:'推送', href:'/app/push.html' },
{ id:'scripts', icon:'📜', label:'脚本库', href:'/app/scripts.html' },
{ id:'credentials', icon:'🔑', label:'凭据', href:'/app/credentials.html' },
{ id:'schedules', icon:'⏰', label:'调度', href:'/app/schedules.html' },
{ id:'retries', icon:'🔄', label:'重试队列', href:'/app/retries.html' },
];
const bottomNav = [
{ id:'audit', icon:'📋', label:'审计日志', href:'/app/audit.html' },
{ id:'settings', icon:'⚙️', label:'设置', href:'/app/settings.html' },
];
// Build sidebar HTML
const navHtml = navItems.map(n =>
`<a href="${n.href}" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition ${n.id===activeNav?'bg-brand/10 text-brand-light font-medium':'text-slate-400 hover:text-white hover:bg-slate-800'}">${n.icon} ${n.label}</a>`
).join('');
const bottomHtml = bottomNav.map(n =>
`<a href="${n.href}" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition ${n.id===activeNav?'bg-brand/10 text-brand-light font-medium':'text-slate-400 hover:text-white hover:bg-slate-800'}">${n.icon} ${n.label}</a>`
).join('');
// Find sidebar container
const sidebar = document.querySelector('[data-sidebar]');
if (!sidebar) return;
sidebar.innerHTML = `
<div class="px-5 py-4 border-b border-slate-800">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center">
<svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/>
</svg>
</div>
<span id="brandName" class="text-white font-bold text-lg">Nexus</span>
</div>
</div>
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
${navHtml}
<div class="border-t border-slate-800 my-2"></div>
${bottomHtml}
</nav>
<div class="border-t border-slate-800 p-4">
<div class="flex items-center justify-between">
<span id="sidebarUser" class="text-slate-400 text-sm">...</span>
<button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button>
</div>
</div>
`;
// Load user info
loadLayoutUser();
}
async function loadLayoutUser() {
try {
const r = await apiFetch(API + '/auth/me');
if (!r) { doLogout(); return; }
const u = await r.json();
const el = document.getElementById('sidebarUser');
if (el) el.textContent = u.username;
const headerEl = document.getElementById('headerUser');
if (headerEl) headerEl.textContent = u.username;
// Set brand name
const brandEl = document.getElementById('brandName');
if (brandEl && u.system_name) brandEl.textContent = u.system_name;
} catch(e) {}
}
+1 -1
View File
@@ -203,7 +203,7 @@
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('admin', JSON.stringify(data.admin));
localStorage.setItem('token_expires', Date.now() + data.expires_in * 1000);
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
window.location.href = '/app/index.html';
}
+7 -8
View File
@@ -1,10 +1,10 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 推送</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">🖥 服务器</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">📜 脚本库</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">⏰ 调度</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a><a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">批量推送</h1></div></header>
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div><label class="block text-sm text-slate-300 mb-2">源路径</label><input id="srcPath" placeholder="/www/wwwroot/source" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">源路径</label><input id="srcPath" placeholder="/home/deploy/source" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label><select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-32"><option>加载中...</option></select></div>
<div><label class="block text-sm text-slate-300 mb-2">同步模式</label><select id="syncMode" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步</option><option value="checksum">校验和</option></select></div>
<button onclick="doPush()" id="pushBtn" class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition">开始推送</button>
@@ -12,18 +12,17 @@
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script>
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
function ah(){return{'Authorization':'Bearer '+token,'Content-Type':'application/json'}}
function doLogout(){localStorage.clear();location.href='/app/login.html'}
async function loadServers(){const r=await fetch(API+'/servers/',{headers:ah()});const s=await r.json();document.getElementById('targetServers').innerHTML=s.map(s=>`<option value="${s.id}">${s.name} (${s.domain})</option>`).join('')}
async function loadServers(){const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;document.getElementById('targetServers').innerHTML=servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${s.domain})</option>`).join('')}
async function doPush(){const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
const opts=document.getElementById('targetServers').selectedOptions;const ids=Array.from(opts).map(o=>parseInt(o.value));
const body={server_ids:ids,source_path:document.getElementById('srcPath').value,sync_mode:document.getElementById('syncMode').value};
const r=await fetch(API+'/sync/files',{method:'POST',headers:ah(),body:JSON.stringify(body)});
const d=await r.json();document.getElementById('pushResult').classList.remove('hidden');
const r=await apiFetch(API+'/sync/files',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){btn.disabled=false;btn.textContent='开始推送';return}const d=await r.json();document.getElementById('pushResult').classList.remove('hidden');
document.getElementById('pushResult').innerHTML=`完成: ${d.completed||0} 成功, ${d.failed||0} 失败 (共${d.total||0}台)`;
btn.disabled=false;btn.textContent='开始推送'}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
loadServers();
</script>
</body></html>
+38 -10
View File
@@ -1,15 +1,43 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 重试队列</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><a href="/app/index.html" class="text-white font-bold text-lg">Nexus</a></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/push.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">📤 推送</a><a href="/app/schedules.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⏰ 调度</a><a href="/app/retries.html" class="block px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">🔄 重试队列</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><h1 class="text-white font-semibold">重试队列</h1></header>
<main class="flex-1 overflow-y-auto p-6"><div id="retryList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div></main>
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">重试队列</h1></div>
<button onclick="loadRetries()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="retryList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
function ah(){return{'Authorization':'Bearer '+token}}
function doLogout(){localStorage.clear();location.href='/app/login.html'}
async function loadRetries(){const r=await fetch(API+'/retries/',{headers:ah()});const j=await r.json();document.getElementById('retryList').innerHTML=j.length?j.map(j=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white">${esc(j.server_name||'#'+j.server_id)}</span><span class="text-xs text-slate-400">重试 ${j.retry_count}/${j.max_retries}</span></div><div class="mt-1 text-xs text-slate-500">${esc(j.source_path)} → ${esc(j.target_path)}</div></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无重试任务</div>'}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
loadRetries()
initLayout('retries');
async function loadRetries(){
try{
const r=await apiFetch(API+'/retries/');if(!r)return;
const jobs=await r.json();
document.getElementById('retryList').innerHTML=jobs.length?jobs.map(j=>{
const statusColor=j.status==='pending'?'text-yellow-400':j.status==='running'?'text-blue-400':j.status==='failed'?'text-red-400':'text-green-400';
const retryPct=Math.min((j.retry_count/j.max_retries)*100,100);
return `<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-white font-medium">${esc(j.server_name||'#'+j.server_id)}</span>
<span class="${statusColor} text-xs">${esc(j.status)}</span>
</div>
<span class="text-xs text-slate-400">重试 ${j.retry_count}/${j.max_retries}</span>
</div>
<div class="mt-2 text-xs text-slate-500">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
${j.last_error?`<div class="mt-1 text-xs text-red-400/70 truncate">${esc(j.last_error)}</div>`:''}
<div class="mt-2 w-full bg-slate-800 rounded-full h-1"><div class="h-1 rounded-full ${retryPct>=80?'bg-red-400':retryPct>=50?'bg-yellow-400':'bg-brand'}" style="width:${retryPct}%"></div></div>
${j.next_retry_at?`<div class="mt-1 text-xs text-slate-600">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
</div>`}).join(''):'<div class="text-slate-500 text-center py-8">暂无重试任务</div>';
}catch(e){document.getElementById('retryList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
loadRetries();
</script>
</body></html>
</body></html>
+57 -18
View File
@@ -1,25 +1,64 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 定时调度</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><span class="text-white font-bold text-lg">Nexus</span></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">📤 推送</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🔄 重试</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center justify-between"><h1 class="text-white font-semibold">定时调度</h1><button onclick="showNew()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建</button></div></header>
<main class="flex-1 overflow-y-auto p-6"><div id="schedList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div id="newForm" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-3 max-w-lg">
<input id="sName" placeholder="名称" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
<input id="sPath" placeholder="源路径" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
<input id="sCron" placeholder="Cron: 0 2 * * *" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
<div class="flex gap-2"><button onclick="createSched()" class="px-4 py-2 bg-brand text-white rounded-lg text-sm">保存</button><button onclick="document.getElementById('newForm').classList.add('hidden')" class="px-4 py-2 bg-slate-800 text-slate-300 rounded-lg text-sm">取消</button></div>
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">定时调度</h1></div>
<button onclick="showAddSched()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="schedList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div id="schedModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 id="schedModalTitle" class="text-white font-semibold text-lg">新建调度</h2>
<input type="hidden" id="editSchedId">
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="sName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="每日推送"></div>
<div><label class="block text-slate-400 text-xs mb-1">源路径</label><input id="sPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
<div><label class="block text-slate-400 text-xs mb-1">Cron 表达式</label><input id="sCron" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *"><div class="text-slate-600 text-xs mt-1">分 时 日 月 周 · 例: 0 2 * * * = 每天凌晨2点</div></div>
<div><label class="block text-slate-400 text-xs mb-1">目标服务器</label><select id="sServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select></div>
<div class="flex gap-2 pt-2"><button onclick="saveSched()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSchedModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
function ah(c){const h={'Authorization':'Bearer '+token};if(c)h['Content-Type']='application/json';return h}
function doLogout(){localStorage.clear();location.href='/app/login.html'}
async function loadScheds(){const r=await fetch(API+'/schedules/',{headers:ah()});const s=await r.json();document.getElementById('schedList').innerHTML=s.length?s.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 flex items-center justify-between"><div><span class="text-white">${esc(s.name)}</span><span class="text-slate-500 text-xs ml-2">${s.cron_expr}</span><span class="text-xs ml-2 ${s.enabled?'text-green-400':'text-red-400'}">${s.enabled?'启用':'禁用'}</span></div><button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无调度</div>'}
function showNew(){document.getElementById('newForm').classList.remove('hidden')}
async function createSched(){await fetch(API+'/schedules/',{method:'POST',headers:ah(true),body:JSON.stringify({name:document.getElementById('sName').value,source_path:document.getElementById('sPath').value,cron_expr:document.getElementById('sCron').value,server_ids:'[]',enabled:true})});loadScheds();document.getElementById('newForm').classList.add('hidden')}
async function deleteSched(id){await fetch(API+'/schedules/'+id,{method:'DELETE',headers:ah()});loadScheds()}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
loadScheds()
initLayout('schedules');
let _schedsCache={};
async function loadScheds(){
try{
const r=await apiFetch(API+'/schedules/');if(!r)return;const scheds=await r.json();
_schedsCache={};scheds.forEach(s=>_schedsCache[s.id]=s);
document.getElementById('schedList').innerHTML=scheds.length?scheds.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<button onclick="toggleSched(${s.id},${!s.enabled})" class="w-10 h-6 rounded-full transition-colors ${s.enabled?'bg-brand':'bg-slate-700'} relative shrink-0" title="${s.enabled?'点击禁用':'点击启用'}"><span class="absolute top-0.5 ${s.enabled?'right-0.5':'left-0.5'} w-5 h-5 bg-white rounded-full shadow transition-all"></span></button>
<div><span class="text-white font-medium">${esc(s.name)}</span><div class="text-slate-500 text-xs mt-0.5"><span class="font-mono">${esc(s.cron_expr)}</span>${s.last_run_at?' · 上次: '+fmtTime(s.last_run_at):''}</div></div>
</div>
<div class="flex items-center gap-2"><button onclick="showEditSched(${s.id})" class="text-slate-400 hover:text-white text-xs transition">编辑</button><button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div>
</div>
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无调度</div>';
}catch(e){document.getElementById('schedList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
async function loadServersIntoSelect(selectedIds){
const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;
document.getElementById('sServers').innerHTML=servers.map(s=>`<option value="${s.id}" ${selectedIds&&selectedIds.includes(s.id)?'selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
}
function showAddSched(){document.getElementById('editSchedId').value='';document.getElementById('schedModalTitle').textContent='新建调度';document.getElementById('sName').value='';document.getElementById('sPath').value='';document.getElementById('sCron').value='';loadServersIntoSelect([]);document.getElementById('schedModal').classList.remove('hidden')}
async function showEditSched(id){const s=_schedsCache[id];if(!s)return;document.getElementById('editSchedId').value=s.id;document.getElementById('schedModalTitle').textContent='编辑调度';document.getElementById('sName').value=s.name||'';document.getElementById('sPath').value=s.source_path||'';document.getElementById('sCron').value=s.cron_expr||'';let serverIds=[];try{serverIds=JSON.parse(s.server_ids||'[]')}catch(e){}await loadServersIntoSelect(serverIds);document.getElementById('schedModal').classList.remove('hidden')}
function hideSchedModal(){document.getElementById('schedModal').classList.add('hidden')}
async function saveSched(){
const id=document.getElementById('editSchedId').value;const opts=document.getElementById('sServers').selectedOptions;const serverIds=Array.from(opts).map(o=>parseInt(o.value));
const body={name:document.getElementById('sName').value,source_path:document.getElementById('sPath').value,cron_expr:document.getElementById('sCron').value,server_ids:JSON.stringify(serverIds),enabled:true};
if(!body.name||!body.cron_expr){alert('名称和Cron必填');return}
if(id){await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)})}else{await apiFetch(API+'/schedules/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)})}
hideSchedModal();loadScheds();
}
async function toggleSched(id,enabled){await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({enabled})});loadScheds()}
async function deleteSched(id){if(!confirm('确定删除调度?'))return;await apiFetch(API+'/schedules/'+id,{method:'DELETE'});loadScheds()}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
loadScheds();
</script>
</body></html>
</body></html>
+20 -7
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 脚本库</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🔑 凭据</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a><a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">脚本库</h1></div><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></header>
<main class="flex-1 overflow-y-auto p-6">
<div id="scriptList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
@@ -10,17 +10,30 @@
<textarea id="scriptContent" rows="8" placeholder="Shell 命令内容" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm font-mono"></textarea>
<div class="flex gap-2"><button onclick="createScript()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideNewScript()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
<!-- Execute Modal -->
<div id="execModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
<h2 class="text-white font-semibold text-lg">执行脚本</h2>
<p id="execScriptName" class="text-slate-400 text-sm"></p>
<div><label class="block text-slate-400 text-xs mb-1">选择服务器</label><select id="execServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select></div>
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="execTimeout" type="number" value="60" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div id="execResult" class="hidden p-3 bg-slate-950 rounded-lg text-sm font-mono text-slate-300 max-h-48 overflow-y-auto"></div>
<div class="flex gap-2"><button onclick="doExec()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">执行</button><button onclick="hideExecModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
</div>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script>
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
function ah(c){const h={'Authorization':'Bearer '+token};if(c)h['Content-Type']='application/json';return h}
function doLogout(){localStorage.clear();location.href='/app/login.html'}
async function loadScripts(){try{const r=await fetch(API+'/scripts/',{headers:ah()});const scripts=await r.json();document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white font-medium">${esc(s.name)}</span><span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-400">${s.category||'ops'}</span></div><pre class="mt-2 text-sm text-slate-400 font-mono bg-slate-950 rounded-lg p-3 overflow-x-auto">${esc(s.content?.substring(0,500)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}
}
let _scriptsCache=[];
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;const scripts=await r.json();_scriptsCache=scripts;document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white font-medium">${esc(s.name)}</span><div class="flex items-center gap-2"><span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-400">${s.category||'ops'}</span><button onclick="showExec(${s.id})" class="text-brand-light text-xs hover:underline">执行</button><button onclick="deleteScript(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div></div><pre class="mt-2 text-sm text-slate-400 font-mono bg-slate-950 rounded-lg p-3 overflow-x-auto">${esc(s.content?.substring(0,500)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}}
function showNewScript(){document.getElementById('newScriptForm').classList.remove('hidden')}
function hideNewScript(){document.getElementById('newScriptForm').classList.add('hidden')}
async function createScript(){const b={name:document.getElementById('scriptName').value,category:document.getElementById('scriptCategory').value||'ops',content:document.getElementById('scriptContent').value};await fetch(API+'/scripts/',{method:'POST',headers:ah(true),body:JSON.stringify(b)});hideNewScript();loadScripts()}
async function createScript(){const b={name:document.getElementById('scriptName').value,category:document.getElementById('scriptCategory').value||'ops',content:document.getElementById('scriptContent').value};await apiFetch(API+'/scripts/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(b)});hideNewScript();loadScripts()}
async function deleteScript(id){if(!confirm('确定删除脚本?'))return;await apiFetch(API+'/scripts/'+id,{method:'DELETE'});loadScripts()}
async function showExec(id){const s=_scriptsCache.find(x=>x.id===id);if(!s)return;document.getElementById('execScriptName').textContent=s.name;document.getElementById('execResult').classList.add('hidden');const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;document.getElementById('execServers').innerHTML=servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');document.getElementById('execModal').classList.remove('hidden');document.getElementById('execModal').dataset.scriptId=id;document.getElementById('execModal').dataset.cmd=s.content||''}
function hideExecModal(){document.getElementById('execModal').classList.add('hidden')}
async function doExec(){const opts=document.getElementById('execServers').selectedOptions;const ids=Array.from(opts).map(o=>parseInt(o.value));if(!ids.length){alert('请选择服务器');return}const timeout=parseInt(document.getElementById('execTimeout').value)||60;const el=document.getElementById('execResult');el.classList.remove('hidden');el.textContent='执行中...';const modal=document.getElementById('execModal');const scriptId=parseInt(modal.dataset.scriptId)||null;const cmd=modal.dataset.cmd||'';const r=await apiFetch(API+'/scripts/exec',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({script_id:scriptId,command:cmd,server_ids:ids,timeout:timeout})});if(!r){el.textContent='认证失败';return}const d=await r.json();el.textContent=JSON.stringify(d,null,2)}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
loadScripts()
</script>
+198 -22
View File
@@ -1,4 +1,9 @@
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: true }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — 服务器</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
<style>
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
.detail-panel.open{max-height:800px}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
@@ -18,50 +23,221 @@
<div class="border-t border-slate-800 p-4"><div class="flex items-center justify-between"><span id="sidebarUser" class="text-slate-400 text-sm">...</span><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div></div>
</aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div><div class="flex items-center gap-3"><input id="searchInput" type="text" placeholder="搜索服务器..." class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48"><a href="/app/terminal.html" id="sshLink" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a></div></header>
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div>
<div class="flex items-center gap-3">
<input id="searchInput" type="text" placeholder="搜索服务器..." class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button onclick="showAddServer()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 添加</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="serversTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">平台</th><th class="text-left px-4 py-3">Agent版本</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">分类</th><th class="text-left px-4 py-3">Agent</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
</div>
<div class="mt-4 flex items-center justify-between text-xs text-slate-500"><span id="serverCount">共 -- 台</span><button onclick="loadServers()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">刷新</button></div>
<!-- Server Detail Panel -->
<div id="detailPanel" class="hidden mt-4 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-800/30">
<div class="flex items-center gap-3">
<span id="detailStatus" class="inline-block w-3 h-3 rounded-full"></span>
<h2 id="detailName" class="text-white font-semibold text-lg"></h2>
<span id="detailAddr" class="text-slate-400 text-sm"></span>
</div>
<div class="flex items-center gap-2">
<a id="detailSSHLink" href="#" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a>
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
<button onclick="hideDetail()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-800 px-6">
<button onclick="showDetailTab('info')" id="tabInfo" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">系统信息</button>
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">同步日志</button>
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH会话</button>
</div>
<!-- Tab Content: System Info -->
<div id="tabContentInfo" class="p-6">
<div id="sysInfoGrid" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">系统版本</div><div id="siOS" class="text-lg font-medium text-white truncate">--</div></div>
</div>
<div id="sysInfoMeta" class="mt-4 grid grid-cols-3 gap-4 text-sm">
<div><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300">--</span></div>
<div><span class="text-slate-500">目标路径:</span><span id="siTarget" class="text-slate-300">--</span></div>
<div><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300">--</span></div>
</div>
</div>
<!-- Tab Content: Sync Logs -->
<div id="tabContentSync" class="hidden p-6">
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
</div>
<!-- Tab Content: SSH Sessions -->
<div id="tabContentSSH" class="hidden p-6">
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
</div>
</div>
<!-- Add/Edit Server Modal -->
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
<input type="hidden" id="editServerId">
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
<div><label class="block text-slate-400 text-xs mb-1">地址</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
</div>
<div class="grid grid-cols-3 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="srvUser" value="root" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-slate-400 text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="key">密钥</option><option value="password">密码</option></select></div>
</div>
<div id="srvPasswordRow"><label class="block text-slate-400 text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="SSH密码"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">分类</label><input id="srvCategory" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="production"></div>
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/target"></div>
</div>
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
<div class="mt-4 flex items-center justify-between text-xs text-slate-500"><span id="serverCount">共 -- 台</span><div class="flex gap-2"><button onclick="loadServers()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">刷新</button></div></div>
</main>
</div>
<script src="/app/api.js"></script>
<script>
const API = window.location.origin + '/api';
const token = getToken();
let selectedServerId = null;
function authHeaders(){return{'Authorization':'Bearer '+token,'Content-Type':'application/json'}}
function getToken(){const t=localStorage.getItem('access_token');if(!t){window.location.href='/app/login.html';return'';}return t}
function doLogout(){localStorage.clear();window.location.href='/app/login.html'}
let selectedServerId=null;
let _serversCache={};
let _currentDetailTab='info';
async function loadServers(){
try{
const res=await fetch(API+'/servers/',{headers:authHeaders()});
const servers=await res.json();
document.getElementById('serverCount').textContent=`共 ${servers.length} 台`;
const res=await apiFetch(API+'/servers/');
if(!res) return;
const data=await res.json();
const servers=data.items||data;
const total=data.total||servers.length;
_serversCache={};servers.forEach(s=>_serversCache[s.id]=s);
document.getElementById('serverCount').textContent=`共 ${total} 台`;
const tbody=document.getElementById('serversTbody');
if(!servers.length){tbody.innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">暂无服务器</td></tr>';return}
tbody.innerHTML=servers.map(s=>`<tr class="hover:bg-slate-800/30 transition cursor-pointer" onclick="selectServer(${s.id})" id="row-${s.id}">
tbody.innerHTML=servers.map(s=>`<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
<td class="px-4 py-3"><span class="inline-block w-2.5 h-2.5 rounded-full ${s.is_online?'bg-green-400':'bg-red-400'} ${s.is_online?'':'animate-pulse'}"></span></td>
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
<td class="px-4 py-3 text-slate-400">${s.platform_id||s.category||'--'}</td>
<td class="px-4 py-3 text-slate-400">${s.category||'--'}</td>
<td class="px-4 py-3 text-slate-400">${s.agent_version||'--'}</td>
<td class="px-4 py-3 text-slate-500 text-xs">${fmtTime(s.last_heartbeat)}</td>
<td class="px-4 py-3 text-right"><a href="/app/terminal.html?server_id=${s.id}" class="text-brand-light hover:underline text-xs mr-2">终端</a><button onclick="deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button></td>
<td class="px-4 py-3 text-right"><a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a><button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button><button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button></td>
</tr>`).join('');
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-red-400">加载失败: '+esc(e.message)+'</td></tr>'}
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-red-400">加载失败</td></tr>'}
}
async function selectServer(id){
selectedServerId=id;
// Highlight row
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
document.getElementById('row-'+id)?.classList.add('bg-brand/5');
// Show detail panel
const s=_serversCache[id];if(!s)return;
document.getElementById('detailName').textContent=s.name;
document.getElementById('detailAddr').textContent=s.domain+':'+(s.port||22);
document.getElementById('detailStatus').className='inline-block w-3 h-3 rounded-full '+(s.is_online?'bg-green-400':'bg-red-400');
document.getElementById('detailSSHLink').href='/app/terminal.html?server_id='+id;
document.getElementById('detailPanel').classList.remove('hidden');
// Load detail data
loadSystemInfo(s);
showDetailTab('info');
}
function hideDetail(){
document.getElementById('detailPanel').classList.add('hidden');
selectedServerId=null;
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
}
function loadSystemInfo(s){
// System info from Redis (via server object or direct read)
const sys=s.system_info;
if(sys&&typeof sys==='object'){
document.getElementById('siCPU').textContent=(sys.cpu_percent!=null?sys.cpu_percent.toFixed(1)+'%':'--');
document.getElementById('siCPU').className='text-2xl font-bold '+(sys.cpu_percent>80?'text-red-400':sys.cpu_percent>60?'text-yellow-400':'text-green-400');
document.getElementById('siMem').textContent=(sys.memory_percent!=null?sys.memory_percent.toFixed(1)+'%':'--');
document.getElementById('siMem').className='text-2xl font-bold '+(sys.memory_percent>80?'text-red-400':sys.memory_percent>60?'text-yellow-400':'text-green-400');
document.getElementById('siDisk').textContent=(sys.disk_percent!=null?sys.disk_percent.toFixed(1)+'%':'--');
document.getElementById('siDisk').className='text-2xl font-bold '+(sys.disk_percent>80?'text-red-400':sys.disk_percent>60?'text-yellow-400':'text-green-400');
document.getElementById('siOS').textContent=sys.os||sys.platform||'--';
}else{
document.getElementById('siCPU').textContent='--';
document.getElementById('siMem').textContent='--';
document.getElementById('siDisk').textContent='--';
document.getElementById('siOS').textContent='--';
}
document.getElementById('siDesc').textContent=s.description||'--';
document.getElementById('siTarget').textContent=s.target_path||'--';
document.getElementById('siAuth').textContent=s.auth_method==='password'?'密码':'SSH密钥';
}
async function loadSyncLogs(){
if(!selectedServerId)return;
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/logs?limit=20');
if(!r)return;const logs=await r.json();
const tbody=document.getElementById('syncLogTbody');
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-slate-500">暂无同步记录</td></tr>';return}
tbody.innerHTML=logs.map(l=>{
const sc=l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-slate-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-slate-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
}catch(e){document.getElementById('syncLogTbody').innerHTML='<tr><td colspan="5" class="py-4 text-center text-red-400">加载失败</td></tr>'}
}
async function loadSSHSessions(){
if(!selectedServerId)return;
try{
const r=await apiFetch(API+'/assets/ssh-sessions?server_id='+selectedServerId+'&limit=20');
if(!r)return;const sessions=await r.json();
const tbody=document.getElementById('sshSessTbody');
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-slate-500">暂无SSH会话</td></tr>';return}
tbody.innerHTML=sessions.map(s=>{
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-slate-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
}catch(e){document.getElementById('sshSessTbody').innerHTML='<tr><td colspan="4" class="py-4 text-center text-red-400">加载失败</td></tr>'}
}
function showDetailTab(tab){
_currentDetailTab=tab;
['Info','Sync','SSH'].forEach(t=>{
document.getElementById('tabContent'+t).classList.toggle('hidden',t.toLowerCase()!==tab);
const btn=document.getElementById('tab'+t);
if(t.toLowerCase()===tab){btn.className='px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition'}
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition'}
});
if(tab==='sync')loadSyncLogs();
if(tab==='ssh')loadSSHSessions();
}
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(selectedServerId===id)hideDetail();loadServers()}
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('serverModal').classList.remove('hidden')}
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';document.getElementById('serverModal').classList.remove('hidden')}
function hideServerModal(){document.getElementById('serverModal').classList.add('hidden')}
document.getElementById('srvAuth').addEventListener('change',()=>{document.getElementById('srvPasswordRow').style.display=document.getElementById('srvAuth').value==='password'?'':'none'});
async function saveServer(){
const id=document.getElementById('editServerId').value;
const body={name:document.getElementById('srvName').value,domain:document.getElementById('srvDomain').value,port:parseInt(document.getElementById('srvPort').value)||22,username:document.getElementById('srvUser').value||'root',auth_method:document.getElementById('srvAuth').value,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null};
if(body.auth_method==='password'){body.password=document.getElementById('srvPassword').value}
if(!body.name||!body.domain){alert('名称和地址必填');return}
if(id){await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)})}else{await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)})}
hideServerModal();loadServers();
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
}
function selectServer(id){selectedServerId=id;document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('bg-brand/5'));document.getElementById('row-'+id)?.classList.add('bg-brand/5');document.getElementById('sshLink').href='/app/terminal.html?server_id='+id}
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;await fetch(API+'/servers/'+id,{method:'DELETE',headers:authHeaders()});loadServers()}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
document.getElementById('searchInput').addEventListener('input',()=>{const q=document.getElementById('searchInput').value.toLowerCase();document.querySelectorAll('#serversTbody tr').forEach(r=>{const name=r.querySelector('td:nth-child(2)')?.textContent.toLowerCase()||'';const addr=r.querySelector('td:nth-child(3)')?.textContent.toLowerCase()||'';r.style.display=(!q||name.includes(q)||addr.includes(q))?'':'none'})});
loadServers();
async function loadUser(){try{const r=await apiFetch(API+'/auth/me');if(!r){doLogout();return}const u=await r.json();document.getElementById('sidebarUser').textContent=u.username}catch(e){}}
loadUser();
async function loadUser(){try{const r=await fetch(API+'/auth/me',{headers:authHeaders()});if(!r.ok){doLogout();return}const u=await r.json();document.getElementById('sidebarUser').textContent=u.username}catch(e){}}
</script>
</body></html>
</body></html>
+93 -15
View File
@@ -1,25 +1,103 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 系统设置</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><a href="/app/index.html" class="text-white font-bold text-lg">Nexus</a></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🏠 仪表盘</a><a href="/app/servers.html" class="block px-3 py-2.5 rounded-lg text-slate-400 hover:text-white text-sm">🖥 服务器</a><a href="/app/settings.html" class="block px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><h1 class="text-white font-semibold">系统设置</h1></header>
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
<nav class="flex-1 py-3 px-3 space-y-1">
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a>
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a>
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a>
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">⚙️ 设置</a>
</nav>
<div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div>
</aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">系统设置</h1></div>
</header>
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
<div id="settingsForm" class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div class="text-slate-400 text-sm mb-4">可修改的运行时配置SECRET_KEY/API_KEY/DATABASE_URL 不可在此修改</div>
<div id="settingsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div class="mb-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="text-slate-400 text-xs">以下为可修改的运行时配置SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改</div>
</div>
<!-- Brand Settings -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
<h2 class="text-white font-medium mb-4">品牌</h2>
<div id="brandSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Alert Thresholds -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
<h2 class="text-white font-medium mb-4">告警阈值 (%)</h2>
<div id="alertSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Infrastructure -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-4">
<h2 class="text-white font-medium mb-4">基础设施</h2>
<div id="infraSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Telegram -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-white font-medium mb-4">Telegram 通知</h2>
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<div id="saveMsg" class="hidden mt-4 text-center text-green-400 text-sm">已保存</div>
</main>
</div>
<script src="/app/api.js"></script>
<script>
const API=window.location.origin+'/api';const token=localStorage.getItem('access_token')||'';if(!token)location.href='/app/login.html';
function ah(c){const h={'Authorization':'Bearer '+token};if(c)h['Content-Type']='application/json';return h}
function doLogout(){localStorage.clear();location.href='/app/login.html'}
const SECTIONS={
brand:{keys:['system_name','system_title'],labels:{system_name:'系统名称',system_title:'页面标题'}},
alert:{keys:['cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold'],labels:{cpu_alert_threshold:'CPU 告警阈值',mem_alert_threshold:'内存告警阈值',disk_alert_threshold:'磁盘告警阈值'}},
infra:{keys:['redis_url','pool_size','max_overflow','agent_heartbeat_interval'],labels:{redis_url:'Redis URL',pool_size:'连接池大小',max_overflow:'最大溢出',agent_heartbeat_interval:'心跳间隔(秒)'}},
telegram:{keys:['telegram_bot_token','telegram_chat_id'],labels:{telegram_bot_token:'Bot Token',telegram_chat_id:'Chat ID'}},
};
let _allSettings={};
async function loadSettings(){
try{const r=await fetch(API+'/settings/',{headers:ah()});const settings=await r.json();
const mutableKeys=['system_name','system_title','redis_url','telegram_bot_token','telegram_chat_id','cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold','agent_heartbeat_interval','pool_size','max_overflow'];
document.getElementById('settingsList').innerHTML=settings.filter(s=>mutableKeys.includes(s.key)).map(s=>`<div class="flex items-center gap-3"><label class="text-slate-400 text-sm w-40 shrink-0">${s.key}</label><input id="set_${s.key}" value="${esc(s.value||'')}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><button onclick="saveSetting('${s.key}')" class="px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0">保存</button></div>`).join('')}catch(e){}
try{
const r=await apiFetch(API+'/settings/');if(!r)return;
const settings=await r.json();
_allSettings={};settings.forEach(s=>_allSettings[s.key]=s.value);
for(const[section,cfg]of Object.entries(SECTIONS)){
const el=document.getElementById(section+'Section');
el.innerHTML=cfg.keys.map(key=>{
const val=_allSettings[key]||'';
const isMasked=val.includes('***')||val.includes('...');
if(isMasked){
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm select-none">${esc(val)}</span>
<span class="text-slate-600 text-xs">🔒 安全锁定</span>
</div>`;
}
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<input id="set_${key}" value="${esc(val)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<button onclick="saveSetting('${key}')" class="px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
</div>`;
}).join('');
}
}catch(e){}
}
async function saveSetting(key){const v=document.getElementById('set_'+key).value;await fetch(API+'/settings/'+key,{method:'PUT',headers:ah(true),body:JSON.stringify({value:v})})}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
loadSettings()
async function saveSetting(key){
const v=document.getElementById('set_'+key).value;
await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:v})});
const msg=document.getElementById('saveMsg');
msg.classList.remove('hidden');
setTimeout(()=>msg.classList.add('hidden'),2000);
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
loadSettings();
</script>
</body></html>
</body></html>
+171 -167
View File
@@ -1,201 +1,205 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus — Web SSH 终端</title>
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: false, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{height:calc(100vh - 48px)}#terminalWrap.fs{height:100vh;position:fixed;inset:0;z-index:100}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<!-- Tailwind CSS v4 CDN -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<!-- Sidebar (collapsed by default — terminal needs space) -->
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a>
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a>
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
<div class="border-t border-slate-800 my-2"></div>
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计日志</a>
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a>
</nav>
<div class="border-t border-slate-800 p-4"><div class="flex items-center justify-between"><span id="sidebarUser" class="text-slate-400 text-sm">...</span><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div></div>
</aside>
<style type="text/tailwindcss">
@theme {
--color-brand: oklch(55% 0.2 250);
--color-brand-light: oklch(75% 0.15 250);
--color-brand-dark: oklch(35% 0.18 250);
--color-surface: oklch(98.5% 0.002 250);
}
#terminal { height: calc(100vh - 120px); }
.xterm { padding: 8px; }
</style>
</head>
<body class="bg-slate-950 min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-slate-900 border-b border-slate-800 px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/app/index.html" class="text-slate-400 hover:text-white transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<div>
<h1 class="text-white font-semibold">Web SSH 终端</h1>
<p id="serverInfo" class="text-slate-400 text-sm">连接中...</p>
<div class="flex-1 flex flex-col min-w-0">
<!-- Toolbar -->
<header x-show="!fullscreen" class="bg-slate-900 border-b border-slate-800 px-4 py-2 flex items-center justify-between h-12 shrink-0">
<div class="flex items-center gap-3">
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<span class="text-slate-500 text-sm">SSH</span>
<span class="text-white font-semibold text-sm" x-text="serverName"></span>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs" :class="connected ? 'bg-green-900/30 text-green-400' : 'bg-slate-800 text-slate-500'">
<span class="w-1.5 h-1.5 rounded-full" :class="connected ? 'bg-green-400 animate-pulse' : 'bg-slate-600'"></span>
<span x-text="connected ? '已连接' : '未连接'"></span>
</span>
</div>
</div>
<div class="flex items-center gap-2">
<button id="resizeBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">
自适应窗口
</button>
<button id="disconnectBtn" class="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 text-sm rounded-lg transition">
断开连接
</button>
</div>
</header>
<div class="flex items-center gap-2">
<span class="text-slate-600 text-xs font-mono" x-text="cols+'×'+rows"></span>
<button onclick="toggleFullscreen()" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" x-text="fullscreen?'退出全屏':'全屏'">全屏</button>
<button onclick="disconnect()" class="px-2 py-1 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-xs rounded transition">断开</button>
<a href="/app/servers.html" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition">↩ 返回</a>
</div>
</header>
<!-- Terminal Container -->
<div class="flex-1 p-2">
<div id="terminal" class="bg-slate-900 rounded-lg border border-slate-800"></div>
<!-- Terminal -->
<div id="terminalWrap" class="bg-slate-950"><div id="terminal"></div></div>
</div>
<!-- Status Bar -->
<footer class="bg-slate-900 border-t border-slate-800 px-4 py-2 flex items-center justify-between text-xs text-slate-500">
<div class="flex items-center gap-4">
<span id="connectionStatus" class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
连接中...
</span>
</div>
<div class="flex items-center gap-4">
<span>Alt+F 切换全屏</span>
</div>
</footer>
<script src="/app/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script>
const API = window.location.origin;
const urlParams = new URLSearchParams(window.location.search);
const serverId = urlParams.get('server_id');
// ── Parse server_id ──
const params = new URLSearchParams(window.location.search);
const serverId = parseInt(params.get('server_id'));
if (!serverId) window.location.href = '/app/servers.html';
if (!serverId) {
alert('缺少 server_id 参数');
window.location.href = '/app/index.html';
}
// ── Alpine helper ──
function $d() { return Alpine.$data(document.body); }
let term, ws, fitAddon;
let sessionId = null;
// ── xterm.js ──
const term = new Terminal({
cursorBlink: true,
cursorStyle: 'bar',
fontSize: 14,
fontFamily: '"Cascadia Code","Fira Code","JetBrains Mono",Menlo,monospace',
theme: {
background: '#0b1120',
foreground: '#e2e8f0',
cursor: '#7c8bf4',
selectionBackground: '#334155',
black: '#1e293b', red: '#f87171', green: '#4ade80', yellow: '#fbbf24',
blue: '#60a5fa', magenta: '#c084fc', cyan: '#22d3ee', white: '#e2e8f0',
brightBlack: '#64748b', brightRed: '#fca5a5', brightGreen: '#86efac',
brightYellow: '#fde047', brightBlue: '#93c5fd', brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9', brightWhite: '#f8fafc',
},
allowProposedApi: true,
});
function getToken() {
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(document.getElementById('terminal'));
// Initial fit
setTimeout(() => fitAddon.fit(), 50);
window.addEventListener('resize', () => { try { fitAddon.fit(); } catch(e){} });
// ── WebSocket ──
let ws = null;
function connect() {
const token = localStorage.getItem('access_token');
if (!token) {
alert('请先登录');
window.location.href = '/app/login.html';
}
return token;
}
if (!token) { window.location.href = '/app/login.html'; return; }
function initTerminal() {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
theme: {
background: '#0f172a',
foreground: '#e2e8f0',
cursor: '#38bdf8',
selectionBackground: '#38bdf830',
},
rows: 24,
cols: 80,
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
window.addEventListener('resize', () => fitAddon.fit());
term.onData(data => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'DATA', data }));
}
});
}
function connectTerminal() {
const token = getToken();
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${window.location.host}/ws/terminal/${serverId}?token=${token}`;
ws = new WebSocket(wsUrl);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws/terminal/${serverId}?token=${encodeURIComponent(token)}`);
ws.onopen = () => {
updateStatus('connected', '已连接');
document.getElementById('disconnectBtn').disabled = false;
$d().connected = true;
term.focus();
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'TERMINAL_INIT':
sessionId = msg.session_id;
document.getElementById('serverInfo').textContent = `${msg.server_name} (ID: ${msg.server_id})`;
term.focus();
break;
case 'DATA':
term.write(msg.data);
break;
case 'CLOSE':
disconnect();
break;
case 'ERROR':
term.writeln('\r\n\x1b[31m错误: ' + msg.message + '\x1b[0m');
break;
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'TERMINAL_INIT':
$d().serverName = msg.server_name || ('服务器 #' + msg.server_id);
document.title = `Nexus — ${$d().serverName}`;
// Send initial size
const d = fitAddon.proposeDimensions();
if (d && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols: d.cols, rows: d.rows }));
$d().cols = d.cols; $d().rows = d.rows;
}
break;
case 'DATA':
term.write(msg.data);
break;
case 'ERROR':
term.write('\r\n\x1b[31m⚠ ' + msg.message + '\x1b[0m\r\n');
break;
case 'CLOSE':
$d().connected = false;
term.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n');
break;
}
} catch (err) {
term.write(e.data);
}
};
ws.onclose = (ev) => {
$d().connected = false;
if (ev.code === 4001) {
term.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n');
setTimeout(() => location.href = '/app/login.html', 2000);
} else if (ev.code === 4004) {
term.write('\r\n\x1b[31m⚠ 服务器不存在\x1b[0m\r\n');
} else if (ev.code !== 1000) {
term.write('\r\n\x1b[33m⚠ 连接断开 (' + ev.code + ')\x1b[0m\r\n');
}
};
ws.onerror = () => {
updateStatus('error', '连接错误');
term.writeln('\r\n\x1b[31mWebSocket 错误\x1b[0m');
};
ws.onclose = () => {
updateStatus('disconnected', '已断开');
term.writeln('\r\n\x1b[33m连接已关闭\x1b[0m');
$d().connected = false;
term.write('\r\n\x1b[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>

Some files were not shown because too many files have changed in this diff Show More