Merge branch 'claude/condescending-ishizaka-3cff3a'
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# Audit: 远程安装按钮修复 + api_base_url 端点
|
||||
|
||||
**Date**: 2026-05-27
|
||||
**Scope**: servers.html detail panel "远程安装" button fix + backend api_base_url exposure
|
||||
|
||||
## Step 1: Register Changed Files
|
||||
|
||||
| # | File | Change Type |
|
||||
|---|------|-------------|
|
||||
| 1 | `server/api/servers.py` | NEW endpoint `GET /meta/api_base_url` |
|
||||
| 2 | `server/config.py` | DB_OVERRIDE_MAP + `ensure_api_base_url_in_db()` |
|
||||
| 3 | `server/main.py` | Startup migration call |
|
||||
| 4 | `server/api/install.py` | settings_kv add api_base_url |
|
||||
| 5 | `web/app/servers.html` | loadApiBaseUrl() + base_url_conf fix |
|
||||
| 6 | `web/app/settings.html` | infra section add api_base_url |
|
||||
|
||||
## Step 2: Full Read
|
||||
|
||||
All files read and verified in this session.
|
||||
|
||||
## Step 3: Rule Scan (H = High/Medium/Low)
|
||||
|
||||
| # | Rule | File | Line | Finding | Severity |
|
||||
|---|------|------|------|---------|----------|
|
||||
| H1 | Auth required on new endpoint | servers.py | 229-235 | `Depends(get_current_admin)` present | ✅ PASS |
|
||||
| H2 | No sensitive data exposure | servers.py | 235 | Returns `settings.API_BASE_URL` (public URL) | ✅ PASS |
|
||||
| H3 | SQL injection in migration | config.py | 197-207 | Uses ORM `session.add()`, not raw SQL | ✅ PASS |
|
||||
| H4 | Route collision | servers.py | 229 | `/meta/api_base_url` BEFORE `/{id}` (line 585+) | ✅ PASS |
|
||||
| H5 | Input validation | servers.py | 229-235 | No user input accepted (read-only endpoint) | ✅ PASS |
|
||||
| H6 | Race condition in migration | config.py | 197-207 | `existing is None` check + commit; concurrent startups may both insert but `ON DUPLICATE KEY` schema handles it — actually this uses ORM add, not upsert. BUT `settings` table has UNIQUE key on `key` column, so duplicate insert would raise IntegrityError caught by `except Exception`. Safe. | ✅ PASS |
|
||||
| H7 | XSS in frontend | servers.html | 375 | `_apiBaseUrl` used as boolean only (not rendered as HTML) | ✅ PASS |
|
||||
|
||||
## Step 4: Closure Table
|
||||
|
||||
| H# | Verdict | Evidence |
|
||||
|----|---------|----------|
|
||||
| H1 | ✅ Safe | JWT auth via `get_current_admin` |
|
||||
| H2 | ✅ Safe | API_BASE_URL is public site URL, not secret |
|
||||
| H3 | ✅ Safe | ORM `session.add()`, no f-string SQL |
|
||||
| H4 | ✅ Safe | `/meta/api_base_url` defined at line 229, `/{id}` at line 585+ |
|
||||
| H5 | ✅ Safe | GET endpoint, no user input |
|
||||
| H6 | ✅ Safe | UNIQUE constraint on `key` column; exception caught and logged |
|
||||
| H7 | ✅ Safe | `_apiBaseUrl` only used in `!!(_apiBaseUrl)` boolean check |
|
||||
|
||||
## Step 5: Entry Points
|
||||
|
||||
| Endpoint | Method | Auth | Input |
|
||||
|----------|--------|------|-------|
|
||||
| `/api/servers/meta/api_base_url` | GET | JWT | None |
|
||||
|
||||
## Step 6: Input Validation
|
||||
|
||||
No user input on new endpoint. Read-only.
|
||||
|
||||
## Step 7: Sinks
|
||||
|
||||
- `settings.API_BASE_URL` → returned as JSON value. No shell, no SQL, no HTML rendering.
|
||||
|
||||
## Step 8: Classification
|
||||
|
||||
| Category | Items | Status |
|
||||
|----------|-------|--------|
|
||||
| Security (12) | Auth, injection, XSS, data exposure | All PASS |
|
||||
| Logic (8) | Route order, migration race, boolean logic | All PASS |
|
||||
| Performance (5) | One extra API call on page load (cached), startup migration one-time | PASS |
|
||||
| Quality (5) | Clean code, no TODOs, proper error handling | PASS |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] All HIGH findings have PASS verdict
|
||||
- [x] No security vulnerabilities introduced
|
||||
- [x] No TODO/FIXME left
|
||||
- [x] Syntax checks pass
|
||||
- [x] Changelog updated
|
||||
@@ -1,50 +1,88 @@
|
||||
# 2026-05-27 批量按钮修复 + 审计日志中文化 + terminal断开反馈
|
||||
# 2026-05-27 批量按钮修复 + 审计日志中文化 + terminal断开反馈 + 远程安装按钮修复 + Agent安装sudo兼容
|
||||
|
||||
## 变更摘要
|
||||
1. 修复 servers.html 批量"安装 Agent"按钮:hasAgent 判断从 `agent_api_key_set` 改为 `agent_version`,解决未安装 Agent 的服务器按钮不可用的问题
|
||||
2. 所有后端 AuditLog detail 字段统一为中文(此前混杂英文如 `name=xxx`、`WebSSH to xxx` 等)
|
||||
3. audit.html 操作列和目标类型列显示中文映射(`create_server` → `添加服务器`)
|
||||
4. terminal.html 断开连接时显示覆盖层 + 重新连接按钮
|
||||
4. audit.html 新增 ACTION_NAMES 覆盖所有已知 action 类型(含 webssh_token、refresh_token_mismatch 等)
|
||||
5. mcp/Nexus_server.py deploy 流程修复:git pull 先于 gate check + origin/main 替代 origin/master
|
||||
6. **修复详情面板"远程安装"按钮始终禁用的 bug**:`s._base_url` 不存在于服务器对象,改为从新 API `GET /api/servers/meta/api_base_url` 获取全局 `NEXUS_API_BASE_URL`
|
||||
7. `api_base_url` 加入 DB_OVERRIDE_MAP,可在设置页面管理
|
||||
8. 安装向导新增 `api_base_url` 写入 MySQL settings 表
|
||||
9. 启动时自动迁移:若 MySQL 缺 `api_base_url` 但 `.env` 有值,自动插入
|
||||
10. **Agent install.sh 非 root 用户兼容**:自动检测当前用户,非 root 时使用 `sudo` 执行 `mkdir`/`apt-get`/`systemctl`/`tee` 等特权操作,解决 Ubuntu 等非 root SSH 用户无法写入 `/opt` 的问题
|
||||
11. **后端 _sudo_wrap 辅助函数**:`servers.py` 新增 `_sudo_wrap(cmd, ssh_user)` — 非 root 用户自动配置 NOPASSWD sudo(写入 `/etc/sudoers.d/nexus-agent`),命令执行后自动清理;应用于 install-agent / batch-install / upgrade-agent / batch-upgrade 共 4 个端点
|
||||
12. **install.sh venv 创建 set -e 兼容**:`python3 -m venv` 在缺 `python3-venv` 的 Ubuntu 系统上返回 exit 1,`set -e` 导致脚本直接退出,apt-get 安装 python3-venv 的回退逻辑从未执行。修复:首次 venv 创建加 `|| true` 允许失败,然后检查 activate 是否存在再走回退路径
|
||||
|
||||
## 动机
|
||||
1. 批量操作中,`agent_api_key_set=True` 不等于 Agent 已安装(只代表生成了 Key),导致武汉市风莞溪服务器(有 Key 但无 Agent)的"安装"按钮被错误禁用
|
||||
2. 审计日志详情混合中英文,运维人员阅读不便
|
||||
3. terminal.html 断开连接后无视觉反馈,用户不知道连接已断开
|
||||
3. MCP deploy 工具先执行 gate check 再 git pull,导致 changelog/audit 文件不在磁盘上被 BLOCK
|
||||
4. **详情面板"远程安装"按钮始终禁用**:`s._base_url` 是前端代码中的错误引用(`API_BASE_URL` 是全局设置,不是服务器的字段),导致所有服务器都无法远程安装 Agent
|
||||
5. **非 root 用户远程安装 Agent 失败**:Ubuntu 等系统默认用户(如 `ubuntu`)无 `/opt` 写权限,install.sh 直接 `mkdir -p /opt/nexus-agent` 报 `Permission denied`
|
||||
6. **install.sh venv 创建被 set -e 终止**:Ubuntu 24.04 默认未安装 `python3-venv`,`python3 -m venv` 返回 exit 1,`set -e` 导致脚本直接退出,apt-get 安装 python3-venv 的回退逻辑从未执行
|
||||
|
||||
## 涉及文件
|
||||
|
||||
### 前端
|
||||
- `web/app/servers.html` — updateBatchBar() hasAgent 从 `s.agent_api_key_set` 改为 `s.agent_version`
|
||||
- `web/app/terminal.html` — 断开连接覆盖层 + 重新连接按钮
|
||||
- `web/app/audit.html` — 新增 ACTION_NAMES / TARGET_NAMES 中文映射
|
||||
- `web/app/servers.html` — updateBatchBar() hasAgent 从 `s.agent_api_key_set` 改为 `s.agent_version`;loadAgentInstall() base_url_conf 从 `s._base_url` 改为 `_apiBaseUrl`(从新 API 加载);新增 `loadApiBaseUrl()` 函数
|
||||
- `web/app/audit.html` — 新增 ACTION_NAMES / TARGET_NAMES 中文映射(覆盖所有已知 action/target_type)
|
||||
- `web/app/settings.html` — 基础设施区块新增 `api_base_url`(主站对外 URL)
|
||||
|
||||
### 后端(detail 字段中文)
|
||||
### Agent 安装脚本
|
||||
- `web/agent/install.sh` — 新增非 root 用户 sudo 兼容:`id -u` 检测 → `$SUDO` 变量 → `mkdir`/`apt-get`/`chown`/`tee`/`systemctl` 全部走 sudo;venv 创建加 `|| true` 兼容 set -e
|
||||
|
||||
### 后端
|
||||
- `server/api/servers.py` — 新增 `GET /api/servers/meta/api_base_url` 端点(返回 `settings.API_BASE_URL`)
|
||||
- `server/config.py` — DB_OVERRIDE_MAP 新增 `api_base_url` → `API_BASE_URL`;新增 `ensure_api_base_url_in_db()` 启动迁移方法
|
||||
- `server/main.py` — 启动时调用 `settings.ensure_api_base_url_in_db(session)` 迁移现有安装的 api_base_url
|
||||
- `server/api/install.py` — 安装向导 settings_kv 新增 `api_base_url`
|
||||
- `server/api/auth.py` — 修改密码详情
|
||||
- `server/api/servers.py` — 服务器创建/更新/删除/Agent操作详情
|
||||
- `server/api/scripts.py` — 凭据/脚本操作详情
|
||||
- `server/api/assets.py` — 平台/节点操作详情
|
||||
- `server/api/settings.py` — 设置/调度/预设操作详情
|
||||
- `server/api/settings.py` — 设置/调度/预设/重试操作详情
|
||||
- `server/api/webssh.py` — WebSSH 连接/断开详情
|
||||
- `server/api/sync_v2.py` — 文件操作详情
|
||||
- `server/api/agent.py` — 长任务回调详情
|
||||
- `server/background/script_execution_flush.py` — 执行刷新详情
|
||||
- `server/application/services/auth_service.py` — 登录/TOTP/Token/WebSSH令牌详情
|
||||
- `server/application/services/script_service.py` — 脚本/凭据/执行详情
|
||||
- `server/application/services/sync_engine_v2.py` — 文件推送详情
|
||||
|
||||
### MCP
|
||||
- `mcp/Nexus_server.py` — deploy: git pull 先于 gate check + origin/main
|
||||
|
||||
## 技术细节
|
||||
- `agent_version` 由 Agent 心跳时写入(如 `"2.0.0"`),未安装 Agent 时为 `None`
|
||||
- `agent_api_key_set` 仍保留在安装命令上下文中使用(检测是否生成了 Key,是安装的前提条件)
|
||||
- `API_BASE_URL` 是全局设置(来自 `.env` 的 `NEXUS_API_BASE_URL`),不是服务器字段
|
||||
- 前端通过 `GET /api/servers/meta/api_base_url` 获取值,缓存到 `_apiBaseUrl` 变量
|
||||
- `api_base_url` 加入 `DB_OVERRIDE_MAP` 后,设置页面可查看/修改,MySQL 中的值会覆盖 `.env`
|
||||
- 启动迁移逻辑:若 MySQL settings 表无 `api_base_url` 行但有 `.env` 值,自动插入(仅一次)
|
||||
- 前端 ACTION_NAMES 映射覆盖所有已知 action 类型,未知 action 仍显示原始英文值
|
||||
- terminal.html 的 disconnectOverlay 使用 `z-50` + `backdrop-blur-sm` 半透明遮罩
|
||||
- 历史数据(已存入 MySQL 的 detail)不会追溯更改,仅新操作生效
|
||||
- MCP deploy 流程:git pull → gate check → supervisorctl restart
|
||||
- install.sh sudo 兼容:`id -u == 0` 时 `SUDO=""`,否则检查 `sudo` 可用性;`$SUDO mkdir -p /opt/nexus-agent` + `$SUDO chown` 给当前用户 → 后续 venv/pip 无需 sudo;`$SUDO tee /etc/systemd/system/nexus-agent.service` + `$SUDO systemctl` 处理 systemd 操作
|
||||
- install.sh venv `set -e` 兼容:首次 `python3 -m venv` 加 `2>/dev/null || true`,允许在缺 `python3-venv` 时失败不退出;然后检查 `.venv/bin/activate` 是否存在,不存在则 `$SUDO apt-get install python3-venv` 后重试
|
||||
|
||||
## 需要迁移/重启
|
||||
- 是:后端 Python 需重启(detail 字段格式变更)
|
||||
- 否:无数据库迁移
|
||||
- 是:后端 Python 需重启(新端点 + 启动迁移逻辑 + DB_OVERRIDE_MAP 变更)
|
||||
- 否:无数据库 schema 迁移(仅向 settings 表插入一行)
|
||||
|
||||
## 验证方式
|
||||
1. 选择未安装 Agent 的服务器 → "安装 Agent" 按钮可点击 ✅
|
||||
2. 选择已安装 Agent 的服务器 → "升级 Agent" 按钮可点击 ✅
|
||||
3. 审计日志页面操作列显示中文 ✅
|
||||
4. 审计日志详情显示中文 ✅
|
||||
5. terminal 断开后显示覆盖层 + 重新连接按钮 ✅
|
||||
4. 新操作审计日志详情显示中文 ✅
|
||||
5. MCP deploy 工具流程正确(git pull 先于 gate check)✅
|
||||
6. 详情面板:有 agent_api_key 的服务器 → "远程安装"按钮可用 ✅
|
||||
7. 详情面板:无 API Key 的服务器 → 显示"请先生成 Key"警告 ✅
|
||||
8. 设置页面 → 基础设施区块显示"主站对外 URL" ✅
|
||||
9. 启动日志 → 显示 "Migrated api_base_url to MySQL settings table" ✅
|
||||
10. 非 root 用户远程安装 Agent → install.sh 自动使用 sudo,`/opt/nexus-agent` 创建成功 ✅
|
||||
11. root 用户远程安装 Agent → install.sh 无 sudo 前缀,行为不变 ✅
|
||||
12. 缺 python3-venv 的 Ubuntu → install.sh 自动 apt-get install python3-venv 后重试 ✅
|
||||
|
||||
## 进度条
|
||||
☑实现 □WSL验证 □审计8步 □部署 □健康检查 □浏览器验证 □changelog
|
||||
☑实现 ☑WSL验证 ☑审计8步 ☑部署 ☑健康检查 ☑浏览器验证 ☑changelog
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 2026-05-28 推送页面 5 项迭代功能
|
||||
|
||||
## 变更摘要
|
||||
1. F1: 推送进度实时追踪 — WebSocket 逐台推送进度更新
|
||||
2. F2: 服务器分组选择 — 分类/平台筛选 + 选中当前筛选
|
||||
3. F4: 推送历史增强 — 可展开详情面板(耗时/错误/diff)
|
||||
4. F7: 文件管理器操作 — 解压文件删除/重命名
|
||||
5. F8: 推送后校验 — md5sum 对比本地源与远程目标
|
||||
|
||||
## 动机
|
||||
推送页面已完成 ZIP 上传 + rsync 推送基础功能,但存在 5 个体验缺陷:批量推送无实时反馈、2000+ 服务器无法分组选择、历史记录无详情、文件管理器只读、推送后无法校验文件一致性。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
### 后端
|
||||
- `server/api/websocket.py` — 新增 `broadcast_sync_progress()` 函数
|
||||
- 参数: batch_id, server_id, server_name, status, completed, failed, total, error_message, duration_seconds
|
||||
- 通过 `_dispatch_ws_message()` 发布到 Redis Pub/Sub + 本地 WebSocket
|
||||
- `server/application/services/sync_engine_v2.py` — 推送引擎改造
|
||||
- `sync_files()`: 新增 `batch_id` (uuid) 用于 WS 消息过滤
|
||||
- `_sync_one()`: 计数器更新后调用 `broadcast_sync_progress()`
|
||||
- `_sync_one()`: 捕获 `diff_summary = result["stdout"][:2000]`
|
||||
- `_log_to_dict()`: 新增 files_transferred, files_skipped, bytes_transferred, diff_summary
|
||||
- 返回 dict 新增 `batch_id`
|
||||
- `server/api/servers.py` — 服务器列表 + 历史增强
|
||||
- `list_servers()`: 新增 `platform_id`, `node_id` Query 参数
|
||||
- 新增 `GET /api/servers/categories` 端点(分类+平台列表)
|
||||
- `_sync_log_to_dict()`: 新增 files_skipped, bytes_transferred, diff_summary
|
||||
- `server/infrastructure/database/server_repo.py` — `get_paginated()` 增加 `platform_id`, `node_id` 参数
|
||||
- `server/api/schemas.py` — 新增 `LocalFileOperation`, `SyncVerify` 模型
|
||||
- `server/api/sync_v2.py` — 新增端点
|
||||
- `POST /api/sync/local-file-ops`: 删除/重命名上传临时目录中的文件
|
||||
- `POST /api/sync/verify`: md5sum 对比本地源与远程目标
|
||||
|
||||
### 前端
|
||||
- `web/app/push.html` — 全面改造
|
||||
- F1: WebSocket 连接 `connectPushWS()` + `updateProgressFromWS()` 逐台更新
|
||||
- F2: 分类/平台筛选下拉框 + `filterServers()` + "选中当前筛选" 按钮
|
||||
- F4: 历史记录可点击展开详情(源/目标路径、耗时、错误、diff_summary)
|
||||
- F7: 文件管理器 hover 显示操作按钮 + `deleteLocalFile()`/`renameLocalFile()`
|
||||
- F8: "推送后校验" 复选框 + `doVerify()` + 校验结果面板
|
||||
|
||||
## 安全影响
|
||||
- F1 (WS): WS 消息需 JWT 认证 + batch_id 过滤防混淆
|
||||
- F7 (文件操作): `os.path.realpath()` + `startswith("/tmp/nexus_upload_")` 双重校验,新路径也必须在上传目录内
|
||||
- F8 (校验): SSH 命令中文件名通过 `shlex.quote()` 防注入
|
||||
- 所有新端点需 JWT 认证 + 审计日志
|
||||
|
||||
## 需要迁移/重启
|
||||
- 是:后端 Python 需重启(supervisorctl restart nexus)
|
||||
- 否:无数据库 schema 迁移
|
||||
|
||||
## 验证方式
|
||||
1. F1: 推送 3+ 台服务器 → 观察 WS 逐台更新状态(不等全部完成)
|
||||
2. F2: 分类筛选 → 选中筛选 → 服务器列表正确过滤
|
||||
3. F4: 点击历史记录 → 展开详情 → 显示耗时/错误/diff
|
||||
4. F7: 上传 ZIP → 文件管理器 → 删除/重命名文件 → 刷新确认
|
||||
5. F8: 勾选校验 → 推送 → 校验结果正确显示匹配/缺失
|
||||
|
||||
## 进度条
|
||||
☑实现 ☑WSL验证 ☐审计8步 ☐部署 ☐健康检查 ☐浏览器验证 ☐changelog
|
||||
@@ -469,6 +469,7 @@ async def init_db(req: InitDbRequest):
|
||||
settings_kv = {
|
||||
"api_key": api_key,
|
||||
"secret_key": secret_key,
|
||||
"api_base_url": site_url,
|
||||
"system_name": "Nexus",
|
||||
"system_title": "Nexus — 服务器运维管理平台",
|
||||
"db_pool_size": str(pool_size),
|
||||
|
||||
@@ -135,6 +135,26 @@ class SyncBrowse(BaseModel):
|
||||
path: str = Field("/", min_length=1)
|
||||
|
||||
|
||||
class SyncBrowseLocal(BaseModel):
|
||||
"""Browse a local directory on the Nexus server (for upload file manager)."""
|
||||
path: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LocalFileOperation(BaseModel):
|
||||
"""Delete or rename a file/directory in the local upload staging area."""
|
||||
operation: str = Field(..., pattern="^(delete|rename)$")
|
||||
path: str = Field(..., min_length=1)
|
||||
new_name: Optional[str] = None # required for rename
|
||||
|
||||
|
||||
class SyncVerify(BaseModel):
|
||||
"""Post-push verification: compare local source with remote target via md5sum."""
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
source_path: str = Field(..., min_length=1)
|
||||
target_path: Optional[str] = None
|
||||
max_files: int = Field(200, ge=1, le=500)
|
||||
|
||||
|
||||
class FileOperation(BaseModel):
|
||||
"""Remote file operation via SSH exec (delete / rename / mkdir)."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
|
||||
+377
-14
@@ -27,6 +27,87 @@ from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import shlex
|
||||
|
||||
|
||||
def _sudo_wrap(cmd: str, ssh_user: str) -> str:
|
||||
"""Wrap a command with sudo setup/teardown for non-root SSH users.
|
||||
|
||||
For non-root users, pre-configures NOPASSWD sudo before the command
|
||||
and cleans up the sudoers entry afterwards. For root, returns cmd as-is.
|
||||
"""
|
||||
if ssh_user == "root":
|
||||
return cmd
|
||||
sudoers_tag = "nexus-agent"
|
||||
setup = (
|
||||
f"echo {shlex.quote(ssh_user + ' ALL=(ALL) NOPASSWD:ALL')} "
|
||||
f"| sudo tee /etc/sudoers.d/{sudoers_tag} > /dev/null 2>&1; "
|
||||
)
|
||||
cleanup = f"; sudo rm -f /etc/sudoers.d/{sudoers_tag} 2>/dev/null"
|
||||
return setup + cmd + cleanup
|
||||
|
||||
# Translation table: install.sh ERROR lines (English) → Chinese
|
||||
_INSTALL_ERROR_ZH: dict[str, str] = {
|
||||
"Python 3.10+ required but not found and could not be installed automatically.":
|
||||
"Python 版本低于 3.10,且无法自动安装",
|
||||
"sudo requires a password. Configure passwordless sudo first:":
|
||||
"sudo 需要密码,请先配置免密 sudo",
|
||||
"Running as non-root and sudo is not available.":
|
||||
"非 root 用户且无 sudo 权限",
|
||||
"--url is required":
|
||||
"缺少 --url 参数",
|
||||
"--id is required (server ID from Nexus dashboard)":
|
||||
"缺少 --id 参数(服务器 ID)",
|
||||
"--key is required (API key from Nexus settings)":
|
||||
"缺少 --key 参数(API Key)",
|
||||
"venv creation failed even after installing python3-venv":
|
||||
"虚拟环境创建失败(python3-venv 模块不可用)",
|
||||
"Cannot download from":
|
||||
"无法下载 agent.py",
|
||||
"upgrade_ok":
|
||||
"升级验证失败",
|
||||
}
|
||||
|
||||
|
||||
def _install_error_msg(stdout: str, stderr: str, exit_code: int) -> str:
|
||||
"""Extract concise error reason from install.sh / upgrade output.
|
||||
|
||||
install.sh writes ``ERROR: ...`` lines to **stdout** (not stderr),
|
||||
so extracting only stderr yields empty/unhelpful messages like
|
||||
"安装失败 (exit 1)". This function finds the first ``ERROR:`` line
|
||||
in stdout, translates it to Chinese, and appends the original English
|
||||
for full context. Falls back to the last non-empty stdout line,
|
||||
then stderr, and finally a generic exit-code message.
|
||||
"""
|
||||
# 1. Prefer ERROR: lines from stdout (install.sh convention)
|
||||
for line in reversed(stdout.split("\n")):
|
||||
line = line.strip()
|
||||
if line.startswith("ERROR:"):
|
||||
en = line.replace("ERROR: ", "")
|
||||
zh = None
|
||||
# Exact match first
|
||||
if en in _INSTALL_ERROR_ZH:
|
||||
zh = _INSTALL_ERROR_ZH[en]
|
||||
else:
|
||||
# Prefix match (e.g. "Cannot download from https://...")
|
||||
for prefix, translation in _INSTALL_ERROR_ZH.items():
|
||||
if en.startswith(prefix):
|
||||
zh = translation
|
||||
break
|
||||
if zh:
|
||||
return f"{zh}({en[:200]})"
|
||||
return en[:300]
|
||||
# 2. Last non-empty stdout line (may contain useful context)
|
||||
for line in reversed(stdout.split("\n")):
|
||||
line = line.strip()
|
||||
if line:
|
||||
return line[:300]
|
||||
# 3. stderr
|
||||
if stderr.strip():
|
||||
return stderr.strip()[:300]
|
||||
# 4. Generic
|
||||
return f"exit {exit_code}"
|
||||
|
||||
|
||||
logger = logging.getLogger("nexus.servers")
|
||||
|
||||
@@ -40,6 +121,8 @@ REDIS_KEY_PREFIX = "heartbeat:"
|
||||
@router.get("/", response_model=dict)
|
||||
async def list_servers(
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
platform_id: Optional[int] = Query(None, description="Filter by platform ID"),
|
||||
node_id: Optional[int] = Query(None, description="Filter by node ID"),
|
||||
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),
|
||||
@@ -56,7 +139,7 @@ async def list_servers(
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
repo = ServerRepositoryImpl(db)
|
||||
page_servers, total = await repo.get_paginated(category, offset, per_page)
|
||||
page_servers, total = await repo.get_paginated(category, platform_id, node_id, offset, per_page)
|
||||
redis = get_redis()
|
||||
|
||||
result = []
|
||||
@@ -64,6 +147,10 @@ async def list_servers(
|
||||
server_data = _server_to_dict(server)
|
||||
|
||||
# Overlay Redis heartbeat data (real-time)
|
||||
# Redis is the source of truth for Agent status.
|
||||
# If a server has/had an Agent (agent_version set), absence of a Redis
|
||||
# heartbeat key means the Agent is offline (heartbeat TTL expired).
|
||||
# Only fall back to MySQL is_online for servers that never had an Agent.
|
||||
try:
|
||||
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{server.id}")
|
||||
if heartbeat:
|
||||
@@ -82,6 +169,10 @@ async def list_servers(
|
||||
server_data["time_drift_seconds"] = 0.0
|
||||
server_data["drift_level"] = heartbeat.get("drift_level", "ok")
|
||||
server_data["_source"] = "redis"
|
||||
elif server.agent_version:
|
||||
# Had Agent but no heartbeat → Agent is offline
|
||||
server_data["is_online"] = False
|
||||
server_data["_source"] = "redis_miss"
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis read failed for server {server.id}: {e}")
|
||||
|
||||
@@ -160,6 +251,33 @@ async def server_stats(
|
||||
|
||||
# ── Sync Logs ──
|
||||
|
||||
@router.get("/categories", response_model=dict)
|
||||
async def server_categories(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return distinct categories with counts and platform list for server filtering."""
|
||||
from sqlalchemy import func as sqlfunc
|
||||
from server.domain.models import Platform
|
||||
|
||||
# Categories with server counts
|
||||
cat_result = await db.execute(
|
||||
select(Server.category, sqlfunc.count(Server.id))
|
||||
.group_by(Server.category)
|
||||
)
|
||||
categories = []
|
||||
for cat, cnt in cat_result.all():
|
||||
categories.append({"name": cat or "uncategorized", "count": cnt})
|
||||
|
||||
# Platforms
|
||||
plat_result = await db.execute(select(Platform).order_by(Platform.id))
|
||||
platforms = []
|
||||
for p in plat_result.scalars().all():
|
||||
platforms.append({"id": p.id, "name": p.name, "category": p.category or ""})
|
||||
|
||||
return {"categories": categories, "platforms": platforms}
|
||||
|
||||
|
||||
@router.get("/logs", response_model=dict)
|
||||
async def get_all_sync_logs(
|
||||
server_id: Optional[int] = Query(None, description="Filter by server ID"),
|
||||
@@ -226,6 +344,15 @@ def _sanitize_csv_cell(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
@router.get("/meta/api_base_url")
|
||||
async def get_api_base_url(admin: Admin = Depends(get_current_admin)):
|
||||
"""Return the configured API_BASE_URL (from .env / settings).
|
||||
|
||||
Used by the frontend to determine if Agent install commands can be generated.
|
||||
"""
|
||||
return {"api_base_url": settings.API_BASE_URL or ""}
|
||||
|
||||
|
||||
@router.get("/import/template")
|
||||
async def download_import_template(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
@@ -454,12 +581,14 @@ async def batch_install_agent(
|
||||
api_key = server.agent_api_key or settings.API_KEY
|
||||
if not api_key:
|
||||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error="请先生成 Agent API Key")
|
||||
ssh_user = (server.username or "root").strip()
|
||||
install_cmd = (
|
||||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
|
||||
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
|
||||
f"--id {sid} --port {shlex.quote(str(server.agent_port or 8601))}"
|
||||
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||||
)
|
||||
install_cmd = _sudo_wrap(install_cmd, ssh_user)
|
||||
r = await exec_ssh_command(server, install_cmd, timeout=180)
|
||||
ok = r["exit_code"] == 0
|
||||
# Also detect install.sh reporting FAILED in stdout
|
||||
@@ -468,7 +597,7 @@ async def batch_install_agent(
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid, server_name=server_name, success=ok,
|
||||
stdout=r["stdout"][:2000] if ok else r["stdout"][:1000],
|
||||
error="" if ok else (r.get("stderr", "")[:500] or f"安装失败 (exit {r['exit_code']}), 查看stdout"),
|
||||
error="" if ok else _install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
|
||||
)
|
||||
except Exception as e:
|
||||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
|
||||
@@ -522,8 +651,9 @@ async def batch_upgrade_agent(
|
||||
agent_url = f"{base_url}/agent/agent.py"
|
||||
install_dir = "/opt/nexus-agent"
|
||||
venv_python = f"{install_dir}/.venv/bin/python"
|
||||
ssh_user = (server.username or "root").strip()
|
||||
|
||||
upgrade_cmd = (
|
||||
upgrade_cmd = _sudo_wrap(
|
||||
f"{venv_python} -c 'import sys; v=sys.version_info; sys.exit(0 if v.major==3 and v.minor>=10 else 1)' "
|
||||
f"&& {venv_python} -m pip install -q fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19 "
|
||||
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
|
||||
@@ -531,7 +661,8 @@ async def batch_upgrade_agent(
|
||||
f"&& systemctl restart nexus-agent "
|
||||
f"&& sleep 2 "
|
||||
f"&& systemctl is-active nexus-agent "
|
||||
f"&& echo 'upgrade_ok'"
|
||||
f"&& echo 'upgrade_ok'",
|
||||
ssh_user,
|
||||
)
|
||||
r = await exec_ssh_command(server, upgrade_cmd, timeout=120)
|
||||
ok = r["exit_code"] == 0 and "upgrade_ok" in r["stdout"]
|
||||
@@ -539,18 +670,18 @@ async def batch_upgrade_agent(
|
||||
if not ok:
|
||||
# Attempt rollback
|
||||
try:
|
||||
await exec_ssh_command(
|
||||
server,
|
||||
rollback = _sudo_wrap(
|
||||
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py && systemctl restart nexus-agent",
|
||||
timeout=30,
|
||||
ssh_user,
|
||||
)
|
||||
await exec_ssh_command(server, rollback, timeout=30)
|
||||
except Exception as rb_err:
|
||||
logger.warning(f"Rollback failed for server {sid}: {rb_err}")
|
||||
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid, server_name=server.name, success=ok,
|
||||
stdout=r["stdout"][:500] if ok else "",
|
||||
error=r["stderr"][:300] if not ok else "",
|
||||
error=_install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]) if not ok else "",
|
||||
)
|
||||
except Exception as e:
|
||||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error=str(e)[:200])
|
||||
@@ -573,6 +704,153 @@ async def batch_upgrade_agent(
|
||||
|
||||
return BatchAgentResult(results=results)
|
||||
|
||||
|
||||
@router.post("/batch/detect-path", response_model=BatchAgentResult)
|
||||
async def batch_detect_path(
|
||||
request: Request,
|
||||
payload: BatchAgentAction,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""SSH 到各服务器搜索 workerman.bat,自动设置 target_path。
|
||||
|
||||
递归搜索 /www/wwwroot 下的 workerman.bat 文件,找到后取其目录路径
|
||||
作为服务器的 target_path 写入数据库。找到第一个即停止搜索。
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
sem = asyncio.Semaphore(5)
|
||||
results = []
|
||||
|
||||
async def _detect_one(sid: int):
|
||||
async with sem:
|
||||
server_name = ""
|
||||
try:
|
||||
server = await service.get_server(sid)
|
||||
if not server:
|
||||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
|
||||
server_name = server.name
|
||||
ssh_user = (server.username or "root").strip()
|
||||
# find 第一个 workerman.bat 即停止 (-quit)
|
||||
cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null"
|
||||
cmd = _sudo_wrap(cmd, ssh_user)
|
||||
r = await exec_ssh_command(server, cmd, timeout=30)
|
||||
if r["exit_code"] != 0 and not r.get("stdout", "").strip():
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid, server_name=server_name, success=False,
|
||||
error=f"搜索失败 (exit {r['exit_code']})",
|
||||
)
|
||||
found = r["stdout"].strip()
|
||||
if not found:
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid, server_name=server_name, success=False,
|
||||
error="未找到 workerman.bat",
|
||||
)
|
||||
# 取目录路径(可能有多个结果,取第一个)
|
||||
first_match = found.split("\n")[0].strip()
|
||||
target_dir = os.path.dirname(first_match)
|
||||
# 更新服务器 target_path
|
||||
server.target_path = target_dir
|
||||
await db.commit()
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid, server_name=server_name, success=True,
|
||||
stdout=f"target_path → {target_dir}",
|
||||
)
|
||||
except Exception as e:
|
||||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
|
||||
|
||||
items = await asyncio.gather(*[_detect_one(sid) for sid in payload.server_ids])
|
||||
results = list(items)
|
||||
|
||||
# Audit
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
ok_count = sum(1 for r in results if r.success)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="batch_detect_path",
|
||||
target_type="server",
|
||||
target_id=0,
|
||||
detail=f"批量检测路径: {ok_count}/{len(results)} 成功",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return BatchAgentResult(results=results)
|
||||
|
||||
|
||||
@router.post("/batch/uninstall-agent", response_model=BatchAgentResult)
|
||||
async def batch_uninstall_agent(
|
||||
request: Request,
|
||||
payload: BatchAgentAction,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Uninstall Nexus Agent on multiple servers via SSH (concurrent, max 5 parallel)."""
|
||||
import asyncio
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
import shlex
|
||||
|
||||
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
|
||||
|
||||
sem = asyncio.Semaphore(5)
|
||||
results = []
|
||||
|
||||
async def _uninstall_one(sid: int):
|
||||
async with sem:
|
||||
server_name = ""
|
||||
try:
|
||||
server = await service.get_server(sid)
|
||||
if not server:
|
||||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
|
||||
server_name = server.name
|
||||
ssh_user = (server.username or "root").strip()
|
||||
|
||||
uninstall_cmd = (
|
||||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
|
||||
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||||
)
|
||||
uninstall_cmd = _sudo_wrap(uninstall_cmd, ssh_user)
|
||||
r = await exec_ssh_command(server, uninstall_cmd, timeout=60)
|
||||
ok = r["exit_code"] == 0
|
||||
|
||||
if ok:
|
||||
server.is_online = False
|
||||
server.agent_version = None
|
||||
await db.commit()
|
||||
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid, server_name=server_name, success=ok,
|
||||
stdout=r["stdout"][:500] if ok else "",
|
||||
error="" if ok else _install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
|
||||
)
|
||||
except Exception as e:
|
||||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
|
||||
|
||||
items = await asyncio.gather(*[_uninstall_one(sid) for sid in payload.server_ids])
|
||||
results = list(items)
|
||||
|
||||
# Audit
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
ok_count = sum(1 for r in results if r.success)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="batch_uninstall_agent",
|
||||
target_type="server",
|
||||
target_id=0,
|
||||
detail=f"批量卸载 Agent: {ok_count}/{len(results)} 成功",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return BatchAgentResult(results=results)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=dict)
|
||||
async def get_server(
|
||||
id: int,
|
||||
@@ -873,6 +1151,7 @@ async def install_agent_remote(
|
||||
raise HTTPException(status_code=400, detail="请先为该服务器生成 Agent API Key")
|
||||
|
||||
agent_port = server.agent_port or 8601
|
||||
ssh_user = (server.username or "root").strip()
|
||||
|
||||
# Build install command — download first, then execute (avoids pipe hiding curl errors)
|
||||
install_cmd = (
|
||||
@@ -882,6 +1161,10 @@ async def install_agent_remote(
|
||||
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||||
)
|
||||
|
||||
# For non-root SSH users, pre-configure passwordless sudo so install.sh
|
||||
# can run apt-get / systemctl / mkdir /opt without prompting.
|
||||
install_cmd = _sudo_wrap(install_cmd, ssh_user)
|
||||
|
||||
# Execute via SSH
|
||||
try:
|
||||
result = await exec_ssh_command(server, install_cmd, timeout=120)
|
||||
@@ -891,7 +1174,7 @@ async def install_agent_remote(
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"安装脚本失败 (exit {result['exit_code']}): {result['stderr'][:500]}",
|
||||
detail=f"安装失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])}",
|
||||
)
|
||||
|
||||
# Audit
|
||||
@@ -915,6 +1198,80 @@ async def install_agent_remote(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{id}/uninstall-agent", response_model=dict)
|
||||
async def uninstall_agent_remote(
|
||||
request: Request,
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Uninstall Nexus Agent on the managed server via SSH.
|
||||
|
||||
Stops the systemd service, removes the install directory and log file,
|
||||
then clears the agent metadata (is_online, agent_version) in the database.
|
||||
"""
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
import shlex
|
||||
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
|
||||
if not base_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="NEXUS_API_BASE_URL 未配置,无法下载卸载脚本。",
|
||||
)
|
||||
|
||||
ssh_user = (server.username or "root").strip()
|
||||
|
||||
# Build uninstall command — download uninstall.sh then execute
|
||||
uninstall_cmd = (
|
||||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
|
||||
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||||
)
|
||||
uninstall_cmd = _sudo_wrap(uninstall_cmd, ssh_user)
|
||||
|
||||
# Execute via SSH
|
||||
try:
|
||||
result = await exec_ssh_command(server, uninstall_cmd, timeout=60)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}")
|
||||
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"卸载失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])}",
|
||||
)
|
||||
|
||||
# Clear agent metadata in database
|
||||
server.is_online = False
|
||||
server.agent_version = None
|
||||
await db.commit()
|
||||
|
||||
# Audit
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="uninstall_agent",
|
||||
target_type="server",
|
||||
target_id=id,
|
||||
detail=f"Agent 远程卸载: {server.name} ({server.domain})",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"server_id": id,
|
||||
"server_name": server.name,
|
||||
"stdout": result["stdout"][:3000],
|
||||
"exit_code": result["exit_code"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{id}/agent-install-cmd", response_model=dict)
|
||||
async def get_agent_install_cmd(
|
||||
id: int,
|
||||
@@ -1013,6 +1370,7 @@ async def upgrade_agent(
|
||||
agent_url = f"{base_url}/agent/agent.py"
|
||||
install_dir = "/opt/nexus-agent"
|
||||
venv_python = f"{install_dir}/.venv/bin/python"
|
||||
ssh_user = (server.username or "root").strip()
|
||||
|
||||
# Step 1: Check Python version in venv
|
||||
pyver_cmd = (
|
||||
@@ -1047,14 +1405,15 @@ async def upgrade_agent(
|
||||
backup_cmd = f"cp {install_dir}/agent.py {install_dir}/agent.py.bak"
|
||||
|
||||
# Step 4: pip install + backup + download new agent.py → restart service
|
||||
upgrade_cmd = (
|
||||
upgrade_cmd = _sudo_wrap(
|
||||
f"{pip_cmd} "
|
||||
f"&& {backup_cmd} "
|
||||
f"&& curl -fsSL {shlex.quote(agent_url)} -o {install_dir}/agent.py "
|
||||
f"&& systemctl restart nexus-agent "
|
||||
f"&& sleep 2 "
|
||||
f"&& systemctl is-active nexus-agent "
|
||||
f"&& echo 'upgrade_ok'"
|
||||
f"&& echo 'upgrade_ok'",
|
||||
ssh_user,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1066,9 +1425,10 @@ async def upgrade_agent(
|
||||
|
||||
if not success:
|
||||
# Rollback: restore backup and restart
|
||||
rollback_cmd = (
|
||||
rollback_cmd = _sudo_wrap(
|
||||
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py "
|
||||
f"&& systemctl restart nexus-agent"
|
||||
f"&& systemctl restart nexus-agent",
|
||||
ssh_user,
|
||||
)
|
||||
try:
|
||||
await exec_ssh_command(server, rollback_cmd, timeout=30)
|
||||
@@ -1076,7 +1436,7 @@ async def upgrade_agent(
|
||||
logger.warning(f"Rollback failed for server {id}: {rb_err}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"升级失败 (exit {result['exit_code']}): {result['stderr'][:300]},已回滚至旧版本",
|
||||
detail=f"升级失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])},已回滚至旧版本",
|
||||
)
|
||||
|
||||
# Audit
|
||||
@@ -1173,8 +1533,11 @@ def _sync_log_to_dict(log, server_name: str = None) -> dict:
|
||||
"sync_mode": log.sync_mode,
|
||||
"files_total": log.files_total,
|
||||
"files_transferred": log.files_transferred,
|
||||
"files_skipped": log.files_skipped,
|
||||
"bytes_transferred": log.bytes_transferred,
|
||||
"duration_seconds": log.duration_seconds,
|
||||
"error_message": log.error_message,
|
||||
"diff_summary": log.diff_summary,
|
||||
"started_at": str(log.started_at) if log.started_at else None,
|
||||
"finished_at": str(log.finished_at) if log.finished_at else None,
|
||||
}
|
||||
@@ -1013,7 +1013,7 @@ async def retry_job(
|
||||
action="retry_job",
|
||||
target_type="retry",
|
||||
target_id=id,
|
||||
detail=f"Manual retry triggered for job #{id}",
|
||||
detail=f"手动重试任务 #{id}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
@@ -1040,7 +1040,7 @@ async def delete_retry_job(
|
||||
action="delete_retry",
|
||||
target_type="retry",
|
||||
target_id=id,
|
||||
detail=f"Retry job #{id} deleted",
|
||||
detail=f"删除重试任务 #{id}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
|
||||
+348
-2
@@ -9,7 +9,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncPreview, FileOperation
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, SyncVerify
|
||||
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
|
||||
@@ -191,7 +191,115 @@ async def browse_directory(
|
||||
return {"path": path, "entries": entries}
|
||||
|
||||
|
||||
# ── Shared audit helper for sync routes ──
|
||||
@router.post("/browse-local")
|
||||
async def browse_local_directory(
|
||||
payload: SyncBrowseLocal,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Browse a local directory on the Nexus server (for upload file manager).
|
||||
|
||||
Only allows browsing under /tmp/nexus_upload_* directories.
|
||||
Returns entries with name, is_dir, size.
|
||||
"""
|
||||
import os
|
||||
|
||||
path = payload.path.rstrip("/") or "/"
|
||||
|
||||
# Security: only allow browsing under /tmp/nexus_upload_*
|
||||
real_path = os.path.realpath(path)
|
||||
if not real_path.startswith("/tmp/nexus_upload_"):
|
||||
raise HTTPException(status_code=403, detail="仅允许浏览上传临时目录")
|
||||
|
||||
if not os.path.isdir(real_path):
|
||||
raise HTTPException(status_code=404, detail="目录不存在")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in sorted(os.scandir(real_path), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"is_dir": entry.is_dir(),
|
||||
"size": stat.st_size if not entry.is_dir() else 0,
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="无权限访问该目录")
|
||||
|
||||
return {"path": real_path, "entries": entries}
|
||||
|
||||
|
||||
@router.post("/local-file-ops")
|
||||
async def local_file_operation(
|
||||
payload: LocalFileOperation,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Delete or rename a file/directory in the local upload staging area.
|
||||
|
||||
Only allows operations under /tmp/nexus_upload_* directories.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
path = payload.path.rstrip("/") or "/"
|
||||
|
||||
# Security: only allow operations under /tmp/nexus_upload_*
|
||||
real_path = os.path.realpath(path)
|
||||
if not real_path.startswith("/tmp/nexus_upload_"):
|
||||
raise HTTPException(status_code=403, detail="仅允许操作上传临时目录中的文件")
|
||||
|
||||
if payload.operation == "delete":
|
||||
if not os.path.exists(real_path):
|
||||
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
||||
try:
|
||||
if os.path.isdir(real_path):
|
||||
shutil.rmtree(real_path)
|
||||
else:
|
||||
os.unlink(real_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除失败: {e}")
|
||||
|
||||
await _audit_sync(
|
||||
"local_file_delete", "sync", 0,
|
||||
f"删除: {path}", admin.username, request,
|
||||
)
|
||||
return {"success": True, "operation": "delete", "path": path}
|
||||
|
||||
elif payload.operation == "rename":
|
||||
if not payload.new_name:
|
||||
raise HTTPException(status_code=400, detail="new_name 必填")
|
||||
# Sanitize: strip slashes and path traversal
|
||||
safe_name = payload.new_name.replace("/", "_").replace("\\", "_").strip()
|
||||
if not safe_name or safe_name == "." or safe_name == "..":
|
||||
raise HTTPException(status_code=400, detail="无效的新名称")
|
||||
|
||||
if not os.path.exists(real_path):
|
||||
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
||||
|
||||
parent_dir = os.path.dirname(real_path)
|
||||
new_path = os.path.join(parent_dir, safe_name)
|
||||
|
||||
# Security: new path must also be under upload dir
|
||||
if not os.path.realpath(new_path).startswith("/tmp/nexus_upload_"):
|
||||
raise HTTPException(status_code=403, detail="目标路径不在上传临时目录内")
|
||||
|
||||
if os.path.exists(new_path):
|
||||
raise HTTPException(status_code=400, detail="目标名称已存在")
|
||||
|
||||
try:
|
||||
os.rename(real_path, new_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"重命名失败: {e}")
|
||||
|
||||
await _audit_sync(
|
||||
"local_file_rename", "sync", 0,
|
||||
f"重命名: {path} → {safe_name}", admin.username, request,
|
||||
)
|
||||
return {"success": True, "operation": "rename", "path": path, "new_name": safe_name}
|
||||
|
||||
async def _audit_sync(action: str, target_type: str, target_id: int, detail: str, operator: str, request: Request):
|
||||
"""Create audit log entry for sync operations"""
|
||||
@@ -303,3 +411,241 @@ async def upload_file(
|
||||
"filename": safe_filename,
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
|
||||
# ── ZIP Upload & Extract (for push workflow) ──
|
||||
|
||||
MAX_ZIP_FILE_SIZE = 524_288_000 # 500 MB
|
||||
|
||||
|
||||
@router.post("/upload-zip")
|
||||
async def upload_zip(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Upload a ZIP file to Nexus, extract it, return the source path for rsync push.
|
||||
|
||||
Multipart form fields:
|
||||
- file: UploadFile (required, must be .zip)
|
||||
|
||||
Returns {"source_path": "/tmp/nexus_upload_xxx/", "file_count": N}
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
import shutil
|
||||
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
|
||||
if not file or not hasattr(file, "filename") or not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择 ZIP 文件")
|
||||
|
||||
filename = file.filename
|
||||
if not filename.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
|
||||
|
||||
# Read with size check
|
||||
content = await file.read()
|
||||
if len(content) > MAX_ZIP_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_ZIP_FILE_SIZE // (1024*1024)}MB)")
|
||||
if len(content) == 0:
|
||||
raise HTTPException(status_code=400, detail="文件为空")
|
||||
|
||||
# Create temp directory
|
||||
upload_id = uuid.uuid4().hex[:12]
|
||||
extract_dir = f"/tmp/nexus_upload_{upload_id}"
|
||||
zip_path = f"{extract_dir}.zip"
|
||||
|
||||
try:
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
|
||||
# Save ZIP
|
||||
with open(zip_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Validate and extract with zip slip protection
|
||||
file_count = 0
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for info in zf.infolist():
|
||||
# Zip slip: ensure no path traversal
|
||||
real_path = os.path.realpath(os.path.join(extract_dir, info.filename))
|
||||
if not real_path.startswith(os.path.realpath(extract_dir)):
|
||||
raise HTTPException(status_code=400, detail=f"ZIP 包含非法路径: {info.filename}")
|
||||
if info.is_dir():
|
||||
continue
|
||||
file_count += 1
|
||||
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
# Clean up ZIP file
|
||||
os.unlink(zip_path)
|
||||
|
||||
if file_count == 0:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=400, detail="ZIP 文件为空(无文件)")
|
||||
|
||||
# Build file listing (relative paths, capped at 500)
|
||||
files = []
|
||||
for root, dirs, fnames in os.walk(extract_dir):
|
||||
for fn in fnames:
|
||||
rel = os.path.relpath(os.path.join(root, fn), extract_dir)
|
||||
files.append(rel)
|
||||
if len(files) >= 500:
|
||||
break
|
||||
if len(files) >= 500:
|
||||
break
|
||||
files_truncated = file_count > len(files)
|
||||
|
||||
# Audit
|
||||
await _audit_sync(
|
||||
"upload_zip", "sync", 0,
|
||||
f"上传 ZIP: {filename} ({len(content)} bytes) → {extract_dir} ({file_count} 个文件)",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"source_path": extract_dir,
|
||||
"file_count": file_count,
|
||||
"files": files,
|
||||
"files_truncated": files_truncated,
|
||||
"filename": filename,
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
try:
|
||||
os.unlink(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except zipfile.BadZipFile:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
try:
|
||||
os.unlink(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=400, detail="无效的 ZIP 文件")
|
||||
except Exception as e:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
try:
|
||||
os.unlink(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"解压失败: {e}")
|
||||
|
||||
|
||||
# ── Post-Push Verification (md5sum comparison) ──
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_sync(
|
||||
payload: SyncVerify,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Verify pushed files by comparing md5sums between source and target servers.
|
||||
|
||||
Walks the local source directory and computes md5 for each file,
|
||||
then runs md5sum on each target server via SSH and compares.
|
||||
Returns per-server results: matched / missing / mismatched file lists.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import shlex
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
|
||||
session = request.state.db
|
||||
|
||||
if not os.path.isdir(payload.source_path):
|
||||
raise HTTPException(status_code=400, detail=f"源路径不存在: {payload.source_path}")
|
||||
|
||||
# Build local file manifest: {relative_path: md5hex}
|
||||
local_files = {}
|
||||
for root, dirs, fnames in os.walk(payload.source_path):
|
||||
for fn in fnames:
|
||||
full = os.path.join(root, fn)
|
||||
rel = os.path.relpath(full, payload.source_path)
|
||||
if len(local_files) >= payload.max_files:
|
||||
break
|
||||
try:
|
||||
h = hashlib.md5()
|
||||
with open(full, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
local_files[rel] = h.hexdigest()
|
||||
except OSError:
|
||||
continue
|
||||
if len(local_files) >= payload.max_files:
|
||||
break
|
||||
|
||||
if not local_files:
|
||||
raise HTTPException(status_code=400, detail="源目录无文件可校验")
|
||||
|
||||
# Verify against each server concurrently
|
||||
sem = asyncio.Semaphore(5)
|
||||
results = {}
|
||||
|
||||
async def _verify_one(sid: int):
|
||||
async with sem:
|
||||
server = await ServerRepositoryImpl(session).get_by_id(sid)
|
||||
if not server:
|
||||
results[sid] = {"server_id": sid, "server_name": "", "error": "服务器不存在"}
|
||||
return
|
||||
|
||||
dest = payload.target_path or server.target_path or "/tmp/sync"
|
||||
# Run md5sum on remote server for the same relative paths
|
||||
# Build a file list for md5sum to check
|
||||
file_list = " ".join(shlex.quote(f) for f in local_files.keys())
|
||||
cmd = f"cd {shlex.quote(dest)} && md5sum {file_list} 2>/dev/null"
|
||||
|
||||
try:
|
||||
r = await exec_ssh_command(server, cmd, timeout=60)
|
||||
except Exception as e:
|
||||
results[sid] = {
|
||||
"server_id": sid, "server_name": server.name,
|
||||
"error": f"SSH 连接失败: {e}",
|
||||
}
|
||||
return
|
||||
|
||||
# Parse md5sum output: "hash filename"
|
||||
remote_files = {}
|
||||
if r["exit_code"] == 0:
|
||||
for line in r["stdout"].strip().split("\n"):
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
remote_files[parts[1]] = parts[0]
|
||||
|
||||
matched = []
|
||||
missing = []
|
||||
mismatched = []
|
||||
for rel, local_hash in local_files.items():
|
||||
if rel not in remote_files:
|
||||
missing.append(rel)
|
||||
elif remote_files[rel] != local_hash:
|
||||
mismatched.append(rel)
|
||||
else:
|
||||
matched.append(rel)
|
||||
|
||||
results[sid] = {
|
||||
"server_id": sid,
|
||||
"server_name": server.name,
|
||||
"matched": len(matched),
|
||||
"missing": len(missing),
|
||||
"mismatched": len(mismatched),
|
||||
"missing_files": missing[:50],
|
||||
"mismatched_files": mismatched[:50],
|
||||
}
|
||||
|
||||
await asyncio.gather(*[_verify_one(sid) for sid in payload.server_ids])
|
||||
|
||||
# Audit
|
||||
await _audit_sync(
|
||||
"sync_verify", "sync", 0,
|
||||
f"推送校验: {payload.source_path} → {len(payload.server_ids)} 台服务器",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"results": results, "total_local_files": len(local_files)}
|
||||
|
||||
@@ -468,3 +468,28 @@ async def broadcast_system_event(event_type: str, message: str):
|
||||
|
||||
await _dispatch_ws_message(msg)
|
||||
|
||||
|
||||
async def broadcast_sync_progress(
|
||||
batch_id: str, server_id: int, server_name: str,
|
||||
status: str, completed: int, failed: int, total: int,
|
||||
error_message: str = None, duration_seconds: int = None,
|
||||
):
|
||||
"""Broadcast per-server push progress to all WebSocket clients.
|
||||
|
||||
Called from sync_engine_v2._sync_one() after each server completes.
|
||||
Frontend uses batch_id to filter stale events from previous pushes.
|
||||
"""
|
||||
msg = {
|
||||
"type": "sync_progress",
|
||||
"batch_id": batch_id,
|
||||
"server_id": server_id,
|
||||
"server_name": server_name,
|
||||
"status": status,
|
||||
"completed": completed,
|
||||
"failed": failed,
|
||||
"total": total,
|
||||
"error_message": error_message,
|
||||
"duration_seconds": duration_seconds,
|
||||
}
|
||||
await _dispatch_ws_message(msg)
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class AuthService:
|
||||
if allowed_ips and not _ip_in_allowlist(ip_address, allowed_ips):
|
||||
await self._audit(
|
||||
"login_ip_blocked", "admin", 0,
|
||||
f"IP not in allowlist: {ip_address} (user: {username})",
|
||||
f"IP不在白名单: {ip_address} (用户: {username})",
|
||||
ip_address=ip_address, admin_username=username,
|
||||
)
|
||||
return {
|
||||
@@ -105,7 +105,7 @@ class AuthService:
|
||||
failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES)
|
||||
if failures >= MAX_LOGIN_FAILURES:
|
||||
await self._audit(
|
||||
"login_locked", "admin", 0, f"Account locked: {username}",
|
||||
"login_locked", "admin", 0, f"账号已锁定: {username}",
|
||||
ip_address=ip_address, admin_username=username,
|
||||
)
|
||||
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
|
||||
@@ -149,7 +149,7 @@ class AuthService:
|
||||
|
||||
await self._record_attempt(username, ip_address, True)
|
||||
await self._audit(
|
||||
"login_success", "admin", admin.id, f"Login: {username}",
|
||||
"login_success", "admin", admin.id, f"登录: {username}",
|
||||
ip_address=ip_address, admin_username=admin.username,
|
||||
)
|
||||
|
||||
@@ -183,7 +183,7 @@ class AuthService:
|
||||
token_version = int(token_version_str)
|
||||
except (ValueError, TypeError):
|
||||
await self._audit(
|
||||
"refresh_invalid_format", "admin", 0, "Refresh token has invalid format",
|
||||
"refresh_invalid_format", "admin", 0, "刷新令牌格式无效",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
@@ -193,7 +193,7 @@ class AuthService:
|
||||
if not admin:
|
||||
await self._audit(
|
||||
"refresh_admin_not_found", "admin", admin_id,
|
||||
"Refresh token references non-existent admin",
|
||||
"刷新令牌引用的管理员不存在",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
@@ -208,8 +208,8 @@ class AuthService:
|
||||
await self.admin_repo.update(admin)
|
||||
await self._audit(
|
||||
"refresh_reuse_attack", "admin", admin.id,
|
||||
f"Token reuse detected! DB version={original_version}, token version={token_version}. "
|
||||
f"All sessions invalidated (new version={admin.token_version}).",
|
||||
f"检测到令牌重用! 数据库版本={original_version}, 令牌版本={token_version}. "
|
||||
f"所有会话已失效 (新版本={admin.token_version}).",
|
||||
ip_address=ip_address, admin_username=admin.username,
|
||||
)
|
||||
return {"success": False, "reason": "token_reuse", "message": "检测到令牌重用,所有会话已失效,请重新登录"}
|
||||
@@ -218,7 +218,7 @@ class AuthService:
|
||||
if admin.jwt_refresh_token != refresh_token:
|
||||
await self._audit(
|
||||
"refresh_token_mismatch", "admin", admin.id,
|
||||
"Refresh token doesn't match stored token",
|
||||
"刷新令牌不匹配",
|
||||
ip_address=ip_address, admin_username=admin.username,
|
||||
)
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
@@ -228,7 +228,7 @@ class AuthService:
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
if not admin:
|
||||
await self._audit(
|
||||
"refresh_legacy_not_found", "admin", 0, "Legacy refresh token not found",
|
||||
"refresh_legacy_not_found", "admin", 0, "旧版刷新令牌未找到",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
@@ -313,7 +313,7 @@ class AuthService:
|
||||
admin.jwt_token_expires = None
|
||||
await self.admin_repo.update(admin)
|
||||
await self._audit(
|
||||
"logout", "admin", admin.id, "Logout",
|
||||
"logout", "admin", admin.id, "登出",
|
||||
admin_username=admin.username,
|
||||
)
|
||||
return {"success": True, "message": "已登出"}
|
||||
@@ -339,7 +339,7 @@ class AuthService:
|
||||
uri = f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}"
|
||||
|
||||
await self._audit(
|
||||
"setup_totp", "admin", admin_id, "TOTP setup initiated",
|
||||
"setup_totp", "admin", admin_id, "TOTP 设置已发起",
|
||||
admin_username=admin.username,
|
||||
)
|
||||
return {"success": True, "secret": secret, "uri": uri}
|
||||
@@ -356,7 +356,7 @@ class AuthService:
|
||||
admin.totp_enabled = True
|
||||
await self.admin_repo.update(admin)
|
||||
await self._audit(
|
||||
"enable_totp", "admin", admin_id, "TOTP enabled",
|
||||
"enable_totp", "admin", admin_id, "TOTP 已启用",
|
||||
admin_username=admin.username,
|
||||
)
|
||||
return {"success": True, "message": "TOTP已启用"}
|
||||
@@ -386,7 +386,7 @@ class AuthService:
|
||||
admin.token_version += 1
|
||||
await self.admin_repo.update(admin)
|
||||
await self._audit(
|
||||
"disable_totp", "admin", admin_id, "TOTP disabled",
|
||||
"disable_totp", "admin", admin_id, "TOTP 已禁用",
|
||||
admin_username=admin.username,
|
||||
)
|
||||
return {"success": True, "message": "TOTP已禁用"}
|
||||
@@ -401,7 +401,7 @@ class AuthService:
|
||||
"webssh_token",
|
||||
"server",
|
||||
server_id,
|
||||
f"WebSSH token issued for server {server_id}",
|
||||
f"为服务器 {server_id} 签发 WebSSH 令牌",
|
||||
admin_username=admin.username,
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -53,18 +53,18 @@ class ScriptService:
|
||||
|
||||
async def create_script(self, script: Script) -> Script:
|
||||
created = await self.script_repo.create(script)
|
||||
await self._audit("create_script", "script", created.id, f"Created script: {script.name}")
|
||||
await self._audit("create_script", "script", created.id, f"名称={script.name}")
|
||||
return created
|
||||
|
||||
async def update_script(self, script: Script) -> Script:
|
||||
updated = await self.script_repo.update(script)
|
||||
await self._audit("update_script", "script", updated.id, f"Updated script: {script.name}")
|
||||
await self._audit("update_script", "script", updated.id, f"名称={script.name}")
|
||||
return updated
|
||||
|
||||
async def delete_script(self, id: int) -> bool:
|
||||
result = await self.script_repo.delete(id)
|
||||
if result:
|
||||
await self._audit("delete_script", "script", id, f"Deleted script ID: {id}")
|
||||
await self._audit("delete_script", "script", id, f"脚本ID={id}")
|
||||
return result
|
||||
|
||||
# ── Command Execution (SSH) ──
|
||||
@@ -201,7 +201,7 @@ class ScriptService:
|
||||
|
||||
await self._audit(
|
||||
"execute_command", "script_execution", execution.id,
|
||||
f"Executed on {len(server_ids)} servers: {status} ({failed_count} failed)",
|
||||
f"执行于 {len(server_ids)} 台: {status} ({failed_count} 台失败)",
|
||||
operator=operator,
|
||||
)
|
||||
return await self.execution_repo.get_by_id(execution.id)
|
||||
@@ -382,7 +382,7 @@ class ScriptService:
|
||||
)
|
||||
await self._audit(
|
||||
"stop_execution", "script_execution", execution_id,
|
||||
f"Stopped {stopped} pending task(s) by {operator}",
|
||||
f"用户停止,共停止 {stopped} 台 pending 任务",
|
||||
operator=operator,
|
||||
)
|
||||
return await self.execution_repo.get_by_id(execution_id)
|
||||
@@ -465,7 +465,7 @@ class ScriptService:
|
||||
)
|
||||
await self._audit(
|
||||
"retry_execution", "script_execution", execution_id,
|
||||
f"Retry requested for servers {server_ids}",
|
||||
f"重试服务器 {server_ids}",
|
||||
operator=operator,
|
||||
)
|
||||
|
||||
@@ -595,13 +595,13 @@ class ScriptService:
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
credential.encrypted_password = encrypt_value(credential.encrypted_password)
|
||||
created = await self.credential_repo.create(credential)
|
||||
await self._audit("create_credential", "db_credential", created.id, f"Created credential: {credential.name}")
|
||||
await self._audit("create_credential", "db_credential", created.id, f"名称={credential.name}")
|
||||
return created
|
||||
|
||||
async def delete_credential(self, id: int) -> bool:
|
||||
result = await self.credential_repo.delete(id)
|
||||
if result:
|
||||
await self._audit("delete_credential", "db_credential", id, f"Deleted credential ID: {id}")
|
||||
await self._audit("delete_credential", "db_credential", id, f"凭据ID={id}")
|
||||
return result
|
||||
|
||||
# ── Private helpers ──
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
"""Nexus — Sync Engine v2 (Step 4 Upgrade)
|
||||
"""Nexus — Sync Engine v2
|
||||
|
||||
S1: Existing rsync push retained as Sync file mode
|
||||
S5: Parallel execution + result aggregation with Semaphore
|
||||
Rsync push from Nexus server to target servers via SSH.
|
||||
Supports: local source path / uploaded ZIP extraction.
|
||||
Parallel execution with Semaphore concurrency control.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import Server, SyncLog, AuditLog
|
||||
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
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command, ssh_pool
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
|
||||
logger = logging.getLogger("nexus.sync_v2")
|
||||
|
||||
# S5: Semaphore for concurrency control
|
||||
MAX_CONCURRENT = 10
|
||||
RSYNC_TIMEOUT = 300
|
||||
|
||||
|
||||
class SyncEngineV2:
|
||||
"""Unified sync engine: file sync + preview, with parallel execution (S5)"""
|
||||
"""Unified sync engine: rsync push from Nexus + preview + ZIP upload"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -41,7 +43,7 @@ class SyncEngineV2:
|
||||
self.audit_repo = audit_repo
|
||||
self.retry_repo = retry_repo
|
||||
|
||||
# ── S1: Sync File Mode (rsync-based) ──
|
||||
# ── Rsync push from Nexus to target servers ──
|
||||
|
||||
async def sync_files(
|
||||
self,
|
||||
@@ -54,12 +56,15 @@ class SyncEngineV2:
|
||||
concurrency: int = 10,
|
||||
trigger_type: str = "manual",
|
||||
) -> dict:
|
||||
"""S1: File sync using rsync over SSH"""
|
||||
"""Push files from Nexus to target servers via rsync over SSH"""
|
||||
if not os.path.isdir(source_path):
|
||||
return {"total": 0, "completed": 0, "failed": 0, "results": {},
|
||||
"error": f"源路径不存在: {source_path}"}
|
||||
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
concurrency = min(concurrency, MAX_CONCURRENT)
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
results = {}
|
||||
# Build server list
|
||||
servers = []
|
||||
for sid in server_ids:
|
||||
server = await self.server_repo.get_by_id(sid)
|
||||
@@ -70,15 +75,16 @@ class SyncEngineV2:
|
||||
completed = 0
|
||||
failed = 0
|
||||
_counter_lock = asyncio.Lock()
|
||||
results = {}
|
||||
|
||||
async def _sync_one(server: Server):
|
||||
nonlocal completed, failed
|
||||
async with sem:
|
||||
# Create sync log
|
||||
dest = target_path or server.target_path or "/tmp/sync"
|
||||
sync_log = SyncLog(
|
||||
server_id=server.id,
|
||||
source_path=source_path,
|
||||
target_path=target_path or server.target_path or "/tmp/sync",
|
||||
target_path=dest,
|
||||
trigger_type=trigger_type,
|
||||
operator=operator,
|
||||
status="running",
|
||||
@@ -87,18 +93,15 @@ class SyncEngineV2:
|
||||
)
|
||||
sync_log = await self.sync_log_repo.create(sync_log)
|
||||
|
||||
# Execute rsync
|
||||
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)
|
||||
result = await _rsync_push(server, source_path, dest, sync_mode)
|
||||
|
||||
# Update sync log
|
||||
sync_log.status = "success" if result["exit_code"] == 0 else "failed"
|
||||
sync_log.finished_at = datetime.now(timezone.utc)
|
||||
sync_log.duration_seconds = int(
|
||||
(sync_log.finished_at - sync_log.started_at).total_seconds()
|
||||
) if sync_log.started_at else 0
|
||||
sync_log.error_message = result["stderr"][:1000] if result["exit_code"] != 0 else None
|
||||
sync_log.diff_summary = result["stdout"][:2000] if result["exit_code"] == 0 else None
|
||||
sync_log = await self.sync_log_repo.update(sync_log)
|
||||
|
||||
async with _counter_lock:
|
||||
@@ -107,17 +110,31 @@ class SyncEngineV2:
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
results[server.id] = sync_log
|
||||
return sync_log
|
||||
# Broadcast per-server progress via WebSocket
|
||||
try:
|
||||
from server.api.websocket import broadcast_sync_progress
|
||||
await broadcast_sync_progress(
|
||||
batch_id=batch_id,
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
status=sync_log.status,
|
||||
completed=completed,
|
||||
failed=failed,
|
||||
total=total,
|
||||
error_message=sync_log.error_message[:200] if sync_log.error_message else None,
|
||||
duration_seconds=sync_log.duration_seconds,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"WS broadcast failed for server {server.id}: {e}")
|
||||
|
||||
results[server.id] = sync_log
|
||||
|
||||
# S5: Parallel execution with concurrency control
|
||||
tasks = [asyncio.create_task(_sync_one(s)) for s in servers]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Audit
|
||||
await self._audit(
|
||||
"sync_files", "sync", 0,
|
||||
f"File sync: {source_path} → {total} servers ({completed} ok, {failed} failed)",
|
||||
f"文件推送: {source_path} → {total} 台服务器 ({completed} 成功, {failed} 失败)",
|
||||
operator,
|
||||
)
|
||||
|
||||
@@ -125,24 +142,10 @@ class SyncEngineV2:
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"failed": failed,
|
||||
"batch_id": batch_id,
|
||||
"results": {sid: _log_to_dict(log) for sid, log in results.items()},
|
||||
}
|
||||
|
||||
async def _audit(self, action: str, target_type: str, target_id: int, detail: str, operator: str = "admin"):
|
||||
"""Create audit log entry"""
|
||||
try:
|
||||
audit_log = AuditLog(
|
||||
admin_username=operator,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
detail=detail,
|
||||
)
|
||||
await self.audit_repo.create(audit_log)
|
||||
except Exception as e:
|
||||
logger.error(f"Audit log failed: {e}")
|
||||
|
||||
|
||||
# ── Preview (dry-run) ──
|
||||
|
||||
async def preview_sync(
|
||||
@@ -153,49 +156,26 @@ class SyncEngineV2:
|
||||
sync_mode: str = "incremental",
|
||||
verbose: bool = False,
|
||||
) -> dict:
|
||||
"""Run rsync --dry-run --stats against one server; no data is transferred.
|
||||
|
||||
Returns parsed stats and optional file list (verbose, capped at 200 lines).
|
||||
"""
|
||||
import re
|
||||
"""Dry-run rsync --dry-run --stats against one server"""
|
||||
server = await self.server_repo.get_by_id(server_id)
|
||||
if not server:
|
||||
return {"error": "Server not found", "exit_code": -1}
|
||||
|
||||
target = target_path or server.target_path or "/tmp/sync"
|
||||
if not os.path.isdir(source_path):
|
||||
return {"error": f"源路径不存在: {source_path}", "exit_code": -1}
|
||||
|
||||
# Build flags matching the actual sync mode
|
||||
mode_flags = {
|
||||
"incremental": "-az",
|
||||
"full": "-az --delete",
|
||||
"overwrite": "-az --inplace",
|
||||
"checksum": "-az --checksum",
|
||||
}
|
||||
flags = mode_flags.get(sync_mode, "-az")
|
||||
dry_flags = f"{flags} --dry-run --stats"
|
||||
if verbose:
|
||||
dry_flags += " -v"
|
||||
dest = target_path or server.target_path or "/tmp/sync"
|
||||
result = await _rsync_push(server, source_path, dest, sync_mode, dry_run=True, verbose=verbose)
|
||||
|
||||
cmd = (
|
||||
f"rsync {dry_flags} "
|
||||
f"{shlex.quote(source_path)}/ "
|
||||
f"{shlex.quote(target)}/"
|
||||
)
|
||||
|
||||
result = await exec_ssh_command(server, cmd, timeout=60)
|
||||
stdout = result.get("stdout", "")
|
||||
stderr = result.get("stderr", "")
|
||||
exit_code = result.get("exit_code", -1)
|
||||
|
||||
# exit_code 23 = partial transfer (some files not transferred) is OK for dry-run
|
||||
if exit_code not in (0, 23):
|
||||
if result["exit_code"] not in (0, 23):
|
||||
return {
|
||||
"server_id": server_id,
|
||||
"server_name": server.name,
|
||||
"error": stderr[:500] or f"rsync exited with code {exit_code}",
|
||||
"exit_code": exit_code,
|
||||
"error": result["stderr"][:500] or f"rsync exited with code {result['exit_code']}",
|
||||
"exit_code": result["exit_code"],
|
||||
}
|
||||
|
||||
stdout = result.get("stdout", "")
|
||||
stats = _parse_rsync_stats(stdout)
|
||||
|
||||
files: list[str] = []
|
||||
@@ -220,26 +200,138 @@ class SyncEngineV2:
|
||||
"server_id": server_id,
|
||||
"server_name": server.name,
|
||||
"source_path": source_path,
|
||||
"target_path": target,
|
||||
"target_path": dest,
|
||||
"sync_mode": sync_mode,
|
||||
"dry_run": True,
|
||||
"stats": stats,
|
||||
"files": files,
|
||||
"files_truncated": files_truncated,
|
||||
"exit_code": exit_code,
|
||||
"exit_code": result["exit_code"],
|
||||
}
|
||||
|
||||
async def _audit(self, action: str, target_type: str, target_id: int, detail: str, operator: str = "admin"):
|
||||
try:
|
||||
audit_log = AuditLog(
|
||||
admin_username=operator,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
detail=detail,
|
||||
)
|
||||
await self.audit_repo.create(audit_log)
|
||||
except Exception as e:
|
||||
logger.error(f"Audit log failed: {e}")
|
||||
|
||||
|
||||
# ── Rsync execution on Nexus host ──
|
||||
|
||||
async def _rsync_push(
|
||||
server: Server,
|
||||
source_path: str,
|
||||
target_path: str,
|
||||
sync_mode: str = "incremental",
|
||||
dry_run: bool = False,
|
||||
verbose: bool = False,
|
||||
) -> dict:
|
||||
"""Execute rsync on Nexus host, pushing to target server via SSH.
|
||||
|
||||
Handles both password auth (sshpass) and key auth (temp key file).
|
||||
Returns {exit_code, stdout, stderr}.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
ssh_user = server.username or "root"
|
||||
ssh_host = server.domain
|
||||
ssh_port = server.port or 22
|
||||
dest = f"{ssh_user}@{ssh_host}:{target_path}"
|
||||
|
||||
# Build rsync args
|
||||
args = ["rsync", "-az"]
|
||||
if sync_mode == "full":
|
||||
args.append("--delete")
|
||||
elif sync_mode == "checksum":
|
||||
args.append("--checksum")
|
||||
elif sync_mode == "overwrite":
|
||||
args.append("--inplace")
|
||||
if dry_run:
|
||||
args.extend(["--dry-run", "--stats"])
|
||||
if verbose:
|
||||
args.append("-v")
|
||||
|
||||
# SSH options: port + no strict host key checking (matches asyncssh pool behavior)
|
||||
ssh_opts = f"ssh -o StrictHostKeyChecking=no -p {ssh_port}"
|
||||
|
||||
# Auth: prepare temp key file or sshpass
|
||||
key_file = None
|
||||
env_override = None
|
||||
|
||||
try:
|
||||
if server.auth_method == "password" and server.password:
|
||||
password = decrypt_value(server.password)
|
||||
# sshpass -p requires the password as an argument (visible in /proc on Linux,
|
||||
# but acceptable for internal ops tool; sshpass -e from env is slightly safer)
|
||||
env_override = {"SSHPASS": password}
|
||||
args = ["sshpass", "-e"] + args
|
||||
ssh_opts += " -o PubkeyAuthentication=no"
|
||||
elif server.ssh_key_configured and server.ssh_key_private:
|
||||
key_content = decrypt_value(server.ssh_key_private)
|
||||
key_file = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".key", prefix=f"nexus_rsync_{server.id}_",
|
||||
delete=False,
|
||||
)
|
||||
key_file.write(key_content)
|
||||
key_file.close()
|
||||
os.chmod(key_file.name, 0o600)
|
||||
ssh_opts += f" -i {shlex.quote(key_file.name)}"
|
||||
elif server.ssh_key_path:
|
||||
ssh_opts += f" -i {shlex.quote(server.ssh_key_path)}"
|
||||
else:
|
||||
return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"}
|
||||
|
||||
args.extend(["-e", ssh_opts])
|
||||
args.append(source_path.rstrip("/") + "/")
|
||||
args.append(dest.rstrip("/") + "/")
|
||||
|
||||
# Execute rsync on Nexus host
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, **(env_override or {})},
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=RSYNC_TIMEOUT + 30
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return {"exit_code": -1, "stdout": "", "stderr": f"rsync 超时 ({RSYNC_TIMEOUT}s)"}
|
||||
|
||||
return {
|
||||
"exit_code": proc.returncode or 0,
|
||||
"stdout": stdout.decode("utf-8", errors="replace")[:10000],
|
||||
"stderr": stderr.decode("utf-8", errors="replace")[:10000],
|
||||
}
|
||||
|
||||
finally:
|
||||
if key_file:
|
||||
try:
|
||||
os.unlink(key_file.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _parse_rsync_stats(output: str) -> dict:
|
||||
"""Extract key numbers from rsync --stats output."""
|
||||
import re
|
||||
stats: dict = {}
|
||||
patterns = {
|
||||
"files_total": r"Number of files:\s+([\d,]+)",
|
||||
"files_created": r"Number of created files:\s+([\d,]+)",
|
||||
"files_deleted": r"Number of deleted files:\s+([\d,]+)",
|
||||
"files_transferred": r"Number of regular files transferred:\s+([\d,]+)",
|
||||
"total_size_bytes": r"Total file size:\s+([\d,]+)\s+bytes",
|
||||
"files_total": r"Number of files:\s+([\d,]+)",
|
||||
"files_created": r"Number of created files:\s+([\d,]+)",
|
||||
"files_deleted": r"Number of deleted files:\s+([\d,]+)",
|
||||
"files_transferred": r"Number of regular files transferred:\s+([\d,]+)",
|
||||
"total_size_bytes": r"Total file size:\s+([\d,]+)\s+bytes",
|
||||
"transfer_size_bytes": r"Total transferred file size:\s+([\d,]+)\s+bytes",
|
||||
}
|
||||
for key, pattern in patterns.items():
|
||||
@@ -256,6 +348,10 @@ def _log_to_dict(log: SyncLog) -> dict:
|
||||
return {
|
||||
"id": log.id, "server_id": log.server_id,
|
||||
"status": log.status, "sync_mode": log.sync_mode,
|
||||
"files_transferred": log.files_transferred,
|
||||
"files_skipped": log.files_skipped,
|
||||
"bytes_transferred": log.bytes_transferred,
|
||||
"diff_summary": log.diff_summary,
|
||||
"error_message": log.error_message,
|
||||
"started_at": str(log.started_at) if log.started_at else None,
|
||||
"finished_at": str(log.finished_at) if log.finished_at else None,
|
||||
|
||||
@@ -123,6 +123,7 @@ class Settings(BaseSettings):
|
||||
"login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS",
|
||||
"login_manual_ips": "LOGIN_MANUAL_IPS",
|
||||
"login_allowed_ips": "LOGIN_ALLOWED_IPS",
|
||||
"api_base_url": "API_BASE_URL",
|
||||
# Notification toggles
|
||||
"notify_alert_cpu": "NOTIFY_ALERT_CPU",
|
||||
"notify_alert_mem": "NOTIFY_ALERT_MEM",
|
||||
@@ -182,5 +183,28 @@ class Settings(BaseSettings):
|
||||
logger.error(f"Failed to load settings from DB: {e}")
|
||||
return 0
|
||||
|
||||
async def ensure_api_base_url_in_db(self, session: AsyncSession) -> None:
|
||||
"""Migrate api_base_url from .env to MySQL settings table if missing.
|
||||
|
||||
Ensures existing installations (where .env has NEXUS_API_BASE_URL
|
||||
but the MySQL settings table was created before api_base_url was
|
||||
added to DB_OVERRIDE_MAP) have the value available for the settings API.
|
||||
"""
|
||||
from server.domain.models import Setting
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
sa_select(Setting).where(Setting.key == "api_base_url")
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is None and self.API_BASE_URL:
|
||||
new_setting = Setting(key="api_base_url", value=self.API_BASE_URL)
|
||||
session.add(new_setting)
|
||||
await session.commit()
|
||||
logger.info(f"Migrated api_base_url to MySQL settings table: {self.API_BASE_URL}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not migrate api_base_url to DB: {e}")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -27,6 +27,8 @@ class ServerRepositoryImpl:
|
||||
async def get_paginated(
|
||||
self,
|
||||
category: Optional[str] = None,
|
||||
platform_id: Optional[int] = None,
|
||||
node_id: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
) -> Tuple[List[Server], int]:
|
||||
@@ -34,20 +36,26 @@ class ServerRepositoryImpl:
|
||||
|
||||
Returns (servers, total_count) — DB-level pagination for 2000+ servers.
|
||||
"""
|
||||
# Base query
|
||||
base_query = select(Server)
|
||||
filters = []
|
||||
if category:
|
||||
base_query = base_query.where(Server.category == category)
|
||||
filters.append(Server.category == category)
|
||||
if platform_id:
|
||||
filters.append(Server.platform_id == platform_id)
|
||||
if node_id:
|
||||
filters.append(Server.node_id == node_id)
|
||||
|
||||
# Count query
|
||||
count_query = select(func.count(Server.id))
|
||||
if category:
|
||||
count_query = count_query.where(Server.category == category)
|
||||
for f in filters:
|
||||
count_query = count_query.where(f)
|
||||
count_result = await self.session.execute(count_query)
|
||||
total = count_result.scalar_one() or 0
|
||||
|
||||
# Paginated data query
|
||||
data_query = base_query.order_by(Server.id).offset(offset).limit(limit)
|
||||
data_query = select(Server)
|
||||
for f in filters:
|
||||
data_query = data_query.where(f)
|
||||
data_query = data_query.order_by(Server.id).offset(offset).limit(limit)
|
||||
result = await self.session.execute(data_query)
|
||||
servers = list(result.scalars().all())
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ async def lifespan(app: FastAPI):
|
||||
async with AsyncSessionLocal() as session:
|
||||
overridden = await settings.load_settings_from_db(session)
|
||||
logger.info(f"Settings from DB: {overridden} overrides applied")
|
||||
# Migrate api_base_url from .env to MySQL if missing (for existing installations)
|
||||
await settings.ensure_api_base_url_in_db(session)
|
||||
|
||||
# 4. Start Redis Pub/Sub subscriber (ADR-010)
|
||||
from server.api.websocket import start_redis_subscriber, stop_redis_subscriber
|
||||
|
||||
+38
-12
@@ -17,7 +17,27 @@ API_KEY=""
|
||||
SERVER_ID=""
|
||||
AGENT_PORT="8601"
|
||||
INSTALL_DIR="/opt/nexus-agent"
|
||||
VENV_DIR="${INSTALL_DIR}/.venv"
|
||||
|
||||
# --- Privilege detection ---
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=""
|
||||
else
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
# Test passwordless sudo (-n = non-interactive)
|
||||
if sudo -n true 2>/dev/null; then
|
||||
SUDO="sudo"
|
||||
echo " Non-root user detected — using sudo for privileged operations."
|
||||
else
|
||||
echo "ERROR: sudo requires a password. Configure passwordless sudo first:"
|
||||
echo " echo \"$(whoami) ALL=(ALL) NOPASSWD:ALL\" | sudo tee /etc/sudoers.d/nexus-agent"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Running as non-root and sudo is not available."
|
||||
echo " Either run as root or install sudo."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -66,15 +86,21 @@ echo " Using: $PYTHON ($($PYTHON --version 2>&1))"
|
||||
|
||||
# 2. Create dir + venv
|
||||
echo "[2/5] Create venv..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
VENV_DIR="${INSTALL_DIR}/.venv"
|
||||
$SUDO mkdir -p "$INSTALL_DIR"
|
||||
# If using sudo, give ownership to current user so venv/pip work without sudo
|
||||
if [ -n "$SUDO" ]; then
|
||||
$SUDO chown -R "$(id -u):$(id -g)" "$INSTALL_DIR"
|
||||
fi
|
||||
if [ ! -f "$VENV_DIR/bin/activate" ]; then
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
# venv creation silently fails when python3-venv is missing (dir exists but no activate)
|
||||
# Allow venv creation to fail (set -e won't abort) so we can install python3-venv
|
||||
"$PYTHON" -m venv "$VENV_DIR" 2>/dev/null || true
|
||||
# venv creation fails when python3-venv is missing — check if activate exists
|
||||
if [ ! -f "$VENV_DIR/bin/activate" ]; then
|
||||
echo " venv incomplete — installing python3-venv..."
|
||||
apt-get update -qq && apt-get install -y -qq "python$( "$PYTHON" -c 'import sys;print(f"{sys.version_info.major}.{sys.version_info.minor}")' )-venv" 2>/dev/null \
|
||||
|| apt-get install -y -qq python3-venv 2>/dev/null \
|
||||
|| { echo "ERROR: Cannot install python3-venv. Run: apt install python3-venv"; exit 1; }
|
||||
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq "python$( "$PYTHON" -c 'import sys;print(f"{sys.version_info.major}.{sys.version_info.minor}")' )-venv" 2>/dev/null \
|
||||
|| $SUDO apt-get install -y -qq python3-venv 2>/dev/null \
|
||||
|| { echo "ERROR: Cannot install python3-venv. Run: $SUDO apt install python3-venv"; exit 1; }
|
||||
rm -rf "$VENV_DIR"
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
if [ ! -f "$VENV_DIR/bin/activate" ]; then
|
||||
@@ -118,7 +144,7 @@ echo " Note: inbound TCP ${AGENT_PORT} is NOT required (heartbeat uses HTTPS ou
|
||||
# 5. systemd + start (bind localhost only)
|
||||
echo "[5/5] Setup systemd + start..."
|
||||
|
||||
cat > /etc/systemd/system/nexus-agent.service << EOF
|
||||
$SUDO tee /etc/systemd/system/nexus-agent.service > /dev/null << EOF
|
||||
[Unit]
|
||||
Description=Nexus Agent
|
||||
After=network.target
|
||||
@@ -136,13 +162,13 @@ RestartSec=5
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable nexus-agent
|
||||
systemctl start nexus-agent
|
||||
$SUDO systemctl daemon-reload
|
||||
$SUDO systemctl enable nexus-agent
|
||||
$SUDO systemctl start nexus-agent
|
||||
sleep 2
|
||||
|
||||
STATUS="running"
|
||||
systemctl is-active --quiet nexus-agent || STATUS="FAILED"
|
||||
$SUDO 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")
|
||||
|
||||
+6
-2
@@ -100,21 +100,25 @@
|
||||
'retry_execution':'重试执行','mark_stuck':'标记卡住','auto_stuck':'自动卡住',
|
||||
'server_auto_stuck':'服务器卡住','script_job_callback':'长任务回调',
|
||||
'login_success':'登录成功','login_locked':'登录锁定','login_failed':'登录失败',
|
||||
'login_ip_blocked':'IP不在白名单',
|
||||
'logout':'登出','change_password':'修改密码',
|
||||
'setup_totp':'设置 TOTP','enable_totp':'启用 TOTP','disable_totp':'禁用 TOTP',
|
||||
'reveal_api_key':'查看 API Key','refresh_reuse_attack':'Token 重用攻击',
|
||||
'refresh_invalid_format':'刷新令牌格式无效','refresh_admin_not_found':'令牌引用管理员不存在',
|
||||
'refresh_token_mismatch':'Token 不匹配','refresh_legacy_not_found':'旧版刷新令牌未找到',
|
||||
'update_setting':'修改设置','create_schedule':'新建调度','update_schedule':'更新调度',
|
||||
'delete_schedule':'删除调度','retry_job':'重试任务','delete_retry':'删除重试',
|
||||
'create_preset':'添加密码预设','update_preset':'更新密码预设','delete_preset':'删除密码预设',
|
||||
'reveal_preset':'查看密码预设',
|
||||
'create_ssh_key_preset':'添加密钥预设','update_ssh_key_preset':'更新密钥预设',
|
||||
'delete_ssh_key_preset':'删除密钥预设','reveal_ssh_key_preset':'查看密钥预设',
|
||||
'webssh_connect':'WebSSH 连接','webssh_disconnect':'WebSSH 断开',
|
||||
'webssh_connect':'WebSSH 连接','webssh_disconnect':'WebSSH 断开','webssh_token':'WebSSH 令牌',
|
||||
'refresh_token_mismatch':'Token 不匹配',
|
||||
'create_platform':'添加平台','update_platform':'更新平台','delete_platform':'删除平台',
|
||||
'create_node':'添加节点','update_node':'更新节点','delete_node':'删除节点',
|
||||
'parse_subscription':'解析订阅','update_ip_allowlist':'更新 IP 白名单','toggle_ip_allowlist':'切换 IP 白名单',
|
||||
};
|
||||
const TARGET_NAMES={'server':'服务器','credential':'凭据','script':'脚本','script_execution':'脚本执行','setting':'设置','schedule':'调度','preset':'密码预设','ssh_key_preset':'密钥预设','platform':'平台','node':'节点','admin':'管理员','retry':'重试'};
|
||||
const TARGET_NAMES={'server':'服务器','credential':'凭据','db_credential':'数据库凭据','script':'脚本','script_execution':'脚本执行','setting':'设置','schedule':'调度','preset':'密码预设','ssh_key_preset':'密钥预设','platform':'平台','node':'节点','admin':'管理员','retry':'重试'};
|
||||
|
||||
function clearFilters(){
|
||||
document.getElementById('dateFrom').value='';
|
||||
|
||||
+335
-23
@@ -8,12 +8,48 @@
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
|
||||
<!-- Push Form -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div><label class="block text-sm text-slate-300 mb-2">源路径</label><input id="srcPath" placeholder="/home/deploy/source" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<!-- Source: ZIP upload only -->
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 mb-2 block">上传 ZIP</label>
|
||||
<div id="sourceUpload" class="space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="zipFile" type="file" accept=".zip" class="flex-1 text-sm text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:bg-brand file:text-white hover:file:bg-brand-dark">
|
||||
<button onclick="uploadZip()" id="uploadZipBtn" class="px-4 py-2.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition whitespace-nowrap">上传并解压</button>
|
||||
</div>
|
||||
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-slate-400">
|
||||
<div class="animate-spin w-4 h-4 border-2 border-brand border-t-transparent rounded-full"></div>
|
||||
上传并解压中...
|
||||
</div>
|
||||
<div id="uploadResult" class="hidden space-y-2">
|
||||
<div class="flex items-center gap-3 px-3 py-2 bg-green-900/20 border border-green-700/30 rounded-lg">
|
||||
<span class="text-green-400 text-sm">✓</span>
|
||||
<span id="uploadedInfo" class="text-xs text-slate-400"></span>
|
||||
<button onclick="clearUpload()" class="text-xs text-slate-500 hover:text-slate-300 ml-auto">✕ 清除</button>
|
||||
</div>
|
||||
<!-- File Manager -->
|
||||
<div id="fileManager" class="bg-slate-800/50 border border-slate-700/50 rounded-lg overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700/50">
|
||||
<span class="text-xs text-slate-400">📁</span>
|
||||
<span id="fmBreadcrumb" class="text-xs text-slate-300 flex-1 truncate"></span>
|
||||
<span id="fmCount" class="text-xs text-slate-500"></span>
|
||||
</div>
|
||||
<div id="fmEntries" class="max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标路径</label><input id="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<select id="filterCategory" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
|
||||
<option value="">全部分类</option>
|
||||
</select>
|
||||
<select id="filterPlatform" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
|
||||
<option value="">全部平台</option>
|
||||
</select>
|
||||
<button onclick="selectAllServers()" class="text-xs text-brand-light hover:underline">全选</button>
|
||||
<button onclick="deselectAllServers()" class="text-xs text-slate-400 hover:underline">全不选</button>
|
||||
<button onclick="selectFilteredServers()" class="text-xs text-brand-light hover:underline">选中当前筛选</button>
|
||||
</div>
|
||||
<select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-40"></select>
|
||||
<div id="serverCount" class="text-slate-500 text-xs mt-1">已选择 0 台服务器</div>
|
||||
@@ -75,6 +111,9 @@
|
||||
<div><label class="block text-sm text-slate-300 mb-2">批次大小</label><input id="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
</div>
|
||||
<button onclick="doPush()" id="pushBtn" class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed">开始推送</button>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-400 -mt-2">
|
||||
<input type="checkbox" id="verifyAfterPush" class="accent-brand"> 推送后校验(md5sum 对比)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
@@ -101,6 +140,15 @@
|
||||
<h2 class="text-white font-semibold mb-3">最近推送记录</h2>
|
||||
<div id="pushHistory" class="space-y-2"><div class="text-slate-500 text-center py-6 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Verify Result Section -->
|
||||
<div id="verifySection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-white font-semibold">推送校验结果</h2>
|
||||
<button onclick="document.getElementById('verifySection').classList.add('hidden')" class="text-xs text-slate-500 hover:text-slate-300">✕ 关闭</button>
|
||||
</div>
|
||||
<div id="verifyResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
@@ -109,18 +157,208 @@
|
||||
initLayout('push');
|
||||
let _serversCache=[];
|
||||
let _pushInProgress=false;
|
||||
let _uploadSourcePath=''; // set after ZIP upload
|
||||
let _fmCurrentPath=''; // current browsed path in file manager
|
||||
let _pushBatchId=''; // current push batch_id for WS filtering
|
||||
let _pushWs=null; // WebSocket connection for push progress
|
||||
|
||||
// ── ZIP upload ──
|
||||
|
||||
// ── WebSocket for real-time push progress ──
|
||||
function connectPushWS(){
|
||||
if(_pushWs&&_pushWs.readyState<=1)return; // already connected/connecting
|
||||
const token=localStorage.getItem('access_token');
|
||||
if(!token)return;
|
||||
const proto=location.protocol==='https:'?'wss:':'ws:';
|
||||
const url=proto+'//'+location.host+'/ws/alerts?token='+encodeURIComponent(token);
|
||||
try{
|
||||
_pushWs=new WebSocket(url);
|
||||
_pushWs.onmessage=function(ev){
|
||||
try{
|
||||
const msg=JSON.parse(ev.data);
|
||||
if(msg.type==='sync_progress'&&msg.batch_id===_pushBatchId){
|
||||
updateProgressFromWS(msg);
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
_pushWs.onclose=function(){
|
||||
_pushWs=null;
|
||||
// Reconnect if push is still in progress
|
||||
if(_pushInProgress) setTimeout(connectPushWS,3000);
|
||||
};
|
||||
_pushWs.onerror=function(){_pushWs=null};
|
||||
}catch(e){_pushWs=null}
|
||||
}
|
||||
|
||||
function updateProgressFromWS(msg){
|
||||
const sid=msg.server_id;
|
||||
const iconEl=document.getElementById('srv_icon_'+sid);
|
||||
const statusEl=document.getElementById('srv_status_'+sid);
|
||||
const detailEl=document.getElementById('srv_detail_'+sid);
|
||||
if(!iconEl||!statusEl)return;
|
||||
|
||||
if(msg.status==='success'){
|
||||
iconEl.textContent='✓';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-green-400';
|
||||
statusEl.textContent='成功';statusEl.className='text-xs text-green-400';
|
||||
if(msg.duration_seconds!=null){detailEl.textContent=msg.duration_seconds+'s';detailEl.classList.remove('hidden');detailEl.className='text-xs text-green-400/70'}
|
||||
}else{
|
||||
iconEl.textContent='✗';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-red-400';
|
||||
statusEl.textContent='失败';statusEl.className='text-xs text-red-400';
|
||||
if(msg.error_message){detailEl.textContent=msg.error_message.substring(0,80);detailEl.classList.remove('hidden');detailEl.className='text-xs text-red-400/70'}
|
||||
}
|
||||
// Update progress bar and counters
|
||||
const total=msg.total||0;
|
||||
const done=msg.completed+msg.failed;
|
||||
const pct=total>0?Math.round((done/total)*100):0;
|
||||
document.getElementById('progressBar').style.width=pct+'%';
|
||||
document.getElementById('progressStats').textContent=done+'/'+total;
|
||||
document.getElementById('countSuccess').textContent=msg.completed;
|
||||
document.getElementById('countFailed').textContent=msg.failed;
|
||||
document.getElementById('countPending').textContent=Math.max(0,total-done);
|
||||
}
|
||||
|
||||
async function uploadZip(){
|
||||
const file=document.getElementById('zipFile').files[0];
|
||||
if(!file){toast('warning','请选择 ZIP 文件');return}
|
||||
if(!file.name.toLowerCase().endsWith('.zip')){toast('warning','仅支持 .zip 格式');return}
|
||||
const form=new FormData();form.append('file',file);
|
||||
const btn=document.getElementById('uploadZipBtn');
|
||||
btn.disabled=true;btn.textContent='上传中...';
|
||||
document.getElementById('uploadProgress').classList.remove('hidden');
|
||||
document.getElementById('uploadResult').classList.add('hidden');
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/upload-zip',{method:'POST',body:form});
|
||||
if(!r){toast('error','上传失败:认证错误');return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){toast('error','上传失败: '+(d.detail||r.status));return}
|
||||
_uploadSourcePath=d.source_path;
|
||||
document.getElementById('uploadProgress').classList.add('hidden');
|
||||
document.getElementById('uploadResult').classList.remove('hidden');
|
||||
document.getElementById('uploadedInfo').textContent=`已解压 ${d.file_count} 个文件 (${fmtBytes(d.size)})`;
|
||||
// Open file manager at root
|
||||
browseLocal(d.source_path);
|
||||
toast('success',`ZIP 解压完成: ${d.file_count} 个文件`);
|
||||
}catch(e){
|
||||
toast('error','上传异常: '+e.message);
|
||||
document.getElementById('uploadProgress').classList.add('hidden');
|
||||
}finally{
|
||||
btn.disabled=false;btn.textContent='上传并解压';
|
||||
}
|
||||
}
|
||||
|
||||
function clearUpload(){
|
||||
_uploadSourcePath='';
|
||||
_fmCurrentPath='';
|
||||
document.getElementById('zipFile').value='';
|
||||
document.getElementById('uploadResult').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── File Manager (browse local upload dir) ──
|
||||
async function browseLocal(path){
|
||||
_fmCurrentPath=path;
|
||||
// Update breadcrumb
|
||||
const rel=path.replace(_uploadSourcePath,'').replace(/^\//,'');
|
||||
document.getElementById('fmBreadcrumb').textContent=rel?'📁 '+rel:'📁 /';
|
||||
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-slate-500">加载中...</div>';
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/browse-local',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({path})});
|
||||
if(!r){toast('error','浏览失败');return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){toast('error','浏览失败: '+(d.detail||r.status));return}
|
||||
renderFileManager(d);
|
||||
}catch(e){
|
||||
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-red-400">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileManager(d){
|
||||
const entries=d.entries||[];
|
||||
const dirs=entries.filter(e=>e.is_dir);
|
||||
const files=entries.filter(e=>!e.is_dir);
|
||||
document.getElementById('fmCount').textContent=`${dirs.length} 目录, ${files.length} 文件`;
|
||||
let html='';
|
||||
// Parent dir link (if not at root)
|
||||
if(d.path!==_uploadSourcePath){
|
||||
const parent=d.path.substring(0,d.path.lastIndexOf('/'))||_uploadSourcePath;
|
||||
html+=`<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(parent)}')">
|
||||
<span class="text-slate-400">📁</span><span class="text-slate-300">..</span></div>`;
|
||||
}
|
||||
// Directories
|
||||
for(const e of dirs){
|
||||
const fullPath=d.path+'/'+e.name;
|
||||
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(fullPath)}')">
|
||||
<span class="text-brand-light">📁</span><span class="text-slate-200 flex-1 truncate">${esc(e.name)}</span>
|
||||
<span class="hidden group-hover:flex items-center gap-1 shrink-0">
|
||||
<button onclick="event.stopPropagation();renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名">✏</button>
|
||||
<button onclick="event.stopPropagation();deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button>
|
||||
</span></div>`;
|
||||
}
|
||||
// Files
|
||||
for(const e of files){
|
||||
const fullPath=d.path+'/'+e.name;
|
||||
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
<span class="text-slate-500">📄</span><span class="text-slate-300 flex-1 truncate">${esc(e.name)}</span>
|
||||
<span class="text-slate-600 shrink-0">${fmtBytes(e.size)}</span>
|
||||
<span class="hidden group-hover:flex items-center gap-1 shrink-0">
|
||||
<button onclick="renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名">✏</button>
|
||||
<button onclick="deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button>
|
||||
</span></div>`;
|
||||
}
|
||||
if(!dirs.length&&!files.length){
|
||||
html='<div class="px-3 py-2 text-xs text-slate-500">空目录</div>';
|
||||
}
|
||||
document.getElementById('fmEntries').innerHTML=html;
|
||||
}
|
||||
|
||||
async function deleteLocalFile(path,name){
|
||||
if(!confirm(`确认删除 "${name}"?`))return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/local-file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({operation:'delete',path})});
|
||||
if(!r){toast('error','删除失败');return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){toast('error','删除失败: '+(d.detail||r.status));return}
|
||||
toast('success','已删除: '+name);
|
||||
browseLocal(_fmCurrentPath);
|
||||
}catch(e){toast('error','删除异常: '+e.message)}
|
||||
}
|
||||
|
||||
async function renameLocalFile(path,oldName){
|
||||
const newName=prompt('输入新名称:',oldName);
|
||||
if(!newName||newName===oldName)return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/local-file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({operation:'rename',path,new_name:newName})});
|
||||
if(!r){toast('error','重命名失败');return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){toast('error','重命名失败: '+(d.detail||r.status));return}
|
||||
toast('success',`已重命名: ${oldName} → ${newName}`);
|
||||
browseLocal(_fmCurrentPath);
|
||||
}catch(e){toast('error','重命名异常: '+e.message)}
|
||||
}
|
||||
|
||||
async function loadServers(){
|
||||
const r=await apiFetch(API+'/servers/');if(!r)return;
|
||||
const data=await r.json();_serversCache=data.items||data;
|
||||
const sel=document.getElementById('targetServers');
|
||||
sel.innerHTML=_serversCache.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
// Load categories/platforms for filter dropdowns
|
||||
try{
|
||||
const cr=await apiFetch(API+'/servers/categories');if(cr){
|
||||
const cd=await cr.json();
|
||||
const catSel=document.getElementById('filterCategory');
|
||||
const platSel=document.getElementById('filterPlatform');
|
||||
(cd.categories||[]).forEach(c=>{
|
||||
const o=document.createElement('option');o.value=c.name;o.textContent=c.name+' ('+c.count+')';catSel.appendChild(o);
|
||||
});
|
||||
(cd.platforms||[]).forEach(p=>{
|
||||
const o=document.createElement('option');o.value=p.id;o.textContent=p.name+(p.category?' ('+p.category+')':'');platSel.appendChild(o);
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
// Load server list
|
||||
await filterServers();
|
||||
|
||||
// Pre-select servers from batch push (servers.html)
|
||||
const batchIds=sessionStorage.getItem('batchPushIds');
|
||||
if(batchIds){
|
||||
try{
|
||||
const ids=JSON.parse(batchIds);
|
||||
const sel=document.getElementById('targetServers');
|
||||
Array.from(sel.options).forEach(o=>{
|
||||
if(ids.includes(parseInt(o.value)))o.selected=true;
|
||||
});
|
||||
@@ -131,6 +369,21 @@
|
||||
updateServerCount();
|
||||
}
|
||||
|
||||
async function filterServers(){
|
||||
const cat=document.getElementById('filterCategory').value;
|
||||
const plat=document.getElementById('filterPlatform').value;
|
||||
let url=API+'/servers/?per_page=200';
|
||||
if(cat)url+='&category='+encodeURIComponent(cat);
|
||||
if(plat)url+='&platform_id='+plat;
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();_serversCache=data.items||data;
|
||||
const sel=document.getElementById('targetServers');
|
||||
// Preserve existing selections
|
||||
const prevSelected=new Set(Array.from(sel.selectedOptions).map(o=>parseInt(o.value)));
|
||||
sel.innerHTML=_serversCache.map(s=>`<option value="${s.id}"${prevSelected.has(s.id)?' selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
updateServerCount();
|
||||
}
|
||||
|
||||
document.getElementById('targetServers').addEventListener('change',updateServerCount);
|
||||
function updateServerCount(){
|
||||
const sel=document.getElementById('targetServers').selectedOptions.length;
|
||||
@@ -142,14 +395,17 @@
|
||||
function deselectAllServers(){
|
||||
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=false);updateServerCount();
|
||||
}
|
||||
function selectFilteredServers(){
|
||||
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=true);updateServerCount();
|
||||
}
|
||||
|
||||
async function doPush(){
|
||||
if(_pushInProgress)return;
|
||||
const opts=document.getElementById('targetServers').selectedOptions;
|
||||
const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(!ids.length){toast('warning','请选择至少一台服务器');return}
|
||||
const srcPath=document.getElementById('srcPath').value;
|
||||
if(!srcPath){toast('warning','请输入源路径');return}
|
||||
const srcPath=_uploadSourcePath;
|
||||
if(!srcPath){toast('warning','请先上传 ZIP 文件');return}
|
||||
const syncMode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
|
||||
const body={
|
||||
server_ids:ids,
|
||||
@@ -161,22 +417,31 @@
|
||||
};
|
||||
|
||||
_pushInProgress=true;
|
||||
_pushBatchId=''; // reset, will be set from HTTP response
|
||||
const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
|
||||
|
||||
// Show progress section with pending states
|
||||
showProgress(ids);
|
||||
// Connect WebSocket for real-time progress
|
||||
connectPushWS();
|
||||
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/files',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(!r){toast('error','认证失败或请求被拒绝');resetProgress();return}
|
||||
const d=await r.json();
|
||||
if(d.batch_id)_pushBatchId=d.batch_id; // for late WS filtering
|
||||
updateProgressFromResult(d);
|
||||
toast(d.failed>0?'warning':'success',`推送完成: ${d.completed||0} 成功, ${d.failed||0} 失败`);
|
||||
// Post-push verification if checked
|
||||
if(document.getElementById('verifyAfterPush').checked&&d.completed>0){
|
||||
doVerify(ids,_uploadSourcePath,document.getElementById('destPath').value||null);
|
||||
}
|
||||
}catch(e){
|
||||
toast('error','推送请求失败: '+e.message);
|
||||
resetProgress();
|
||||
}finally{
|
||||
_pushInProgress=false;btn.disabled=false;btn.textContent='开始推送';
|
||||
if(_pushWs){try{_pushWs.close()}catch(e){}_pushWs=null}
|
||||
loadHistory(); // Refresh history
|
||||
}
|
||||
}
|
||||
@@ -243,24 +508,71 @@
|
||||
document.getElementById('progressSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Post-Push Verify (md5sum) ──
|
||||
async function doVerify(ids,sourcePath,targetPath){
|
||||
const section=document.getElementById('verifySection');
|
||||
section.classList.remove('hidden');
|
||||
document.getElementById('verifyResults').innerHTML='<div class="text-slate-500 text-sm text-center py-4">校验中...</div>';
|
||||
try{
|
||||
const body={server_ids:ids,source_path:sourcePath,target_path:targetPath||null};
|
||||
const r=await apiFetch(API+'/sync/verify',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(!r){document.getElementById('verifyResults').innerHTML='<div class="text-red-400 text-sm">校验请求失败</div>';return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">${esc(d.detail||'校验失败')}</div>`;return}
|
||||
const results=d.results||{};
|
||||
document.getElementById('verifyResults').innerHTML=Object.values(results).map(v=>{
|
||||
if(v.error)return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 border-l-red-500">
|
||||
<span class="text-red-400 text-sm">✗</span> <span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span>
|
||||
<span class="text-xs text-red-400 ml-2">${esc(v.error)}</span></div>`;
|
||||
const allOk=v.missing===0&&v.mismatched===0;
|
||||
const border=allOk?'border-l-green-500':'border-l-amber-500';
|
||||
let detail='';
|
||||
if(v.missing>0)detail+=`<div class="text-xs text-amber-400 mt-1">缺失 ${v.missing} 文件: ${v.missing_files.slice(0,5).map(esc).join(', ')}${v.missing>5?' ...':''}</div>`;
|
||||
if(v.mismatched>0)detail+=`<div class="text-xs text-red-400 mt-1">不一致 ${v.mismatched} 文件: ${v.mismatched_files.slice(0,5).map(esc).join(', ')}${v.mismatched>5?' ...':''}</div>`;
|
||||
return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 ${border}">
|
||||
<span class="${allOk?'text-green-400':'text-amber-400'} text-sm">${allOk?'✓':'⚠'}</span>
|
||||
<span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span>
|
||||
<span class="text-xs text-slate-500 ml-2">${v.matched} 匹配${v.missing>0?`, ${v.missing} 缺失`:''}${v.mismatched>0?`, ${v.mismatched} 不一致`:''}</span>
|
||||
${detail}</div>`;
|
||||
}).join('');
|
||||
}catch(e){
|
||||
document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">校验异常: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push History (recent sync logs) ──
|
||||
async function loadHistory(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/logs?limit=10');if(!r)return;
|
||||
const r=await apiFetch(API+'/servers/logs?limit=15');if(!r)return;
|
||||
const res=await r.json();const logs=res.items||res;
|
||||
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>`<div class="bg-slate-900 rounded-lg border border-slate-800 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="${l.status==='success'?'text-green-400':'text-red-400'} text-sm">${l.status==='success'?'✓':'✗'}</span>
|
||||
<div>
|
||||
<span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.operator||'system')}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span>
|
||||
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>{
|
||||
const ok=l.status==='success';
|
||||
const border=ok?'border-l-2 border-l-green-500':'border-l-2 border-l-red-500';
|
||||
let summary=`${esc(l.source_path||'')} → ${esc(l.target_path||'')}`;
|
||||
if(l.duration_seconds!=null)summary+=` · ${l.duration_seconds}s`;
|
||||
let detail='';
|
||||
if(l.files_transferred!=null)detail+=`<div class="text-xs text-slate-400">传输: ${l.files_transferred} 文件</div>`;
|
||||
if(l.bytes_transferred!=null&&l.bytes_transferred>0)detail+=`<div class="text-xs text-slate-400">大小: ${fmtBytes(l.bytes_transferred)}</div>`;
|
||||
if(l.error_message)detail+=`<div class="text-xs text-red-400/80 break-all">${esc(l.error_message.substring(0,300))}</div>`;
|
||||
if(l.diff_summary)detail+=`<details class="mt-1"><summary class="text-xs text-slate-500 cursor-pointer hover:text-slate-300">查看 diff</summary><pre class="text-xs text-slate-500 bg-slate-900 rounded p-2 mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap">${esc(l.diff_summary.substring(0,2000))}</pre></details>`;
|
||||
return `<div class="bg-slate-900 rounded-lg border border-slate-800 ${border} px-4 py-3 cursor-pointer hover:bg-slate-800/50 transition" onclick="this.querySelector('.hist-detail')?.classList.toggle('hidden')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="${ok?'text-green-400':'text-red-400'} text-sm">${ok?'✓':'✗'}</span>
|
||||
<div>
|
||||
<span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.operator||'system')}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
|
||||
<div class="text-xs text-slate-500 mt-1 truncate">${summary}</div>
|
||||
<div class="hist-detail hidden mt-2 space-y-1 border-t border-slate-700/50 pt-2">${detail}</div>
|
||||
</div>`;
|
||||
}).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
|
||||
}catch(e){
|
||||
document.getElementById('pushHistory').innerHTML='<div class="text-slate-500 text-center py-6 text-sm">加载失败</div>';
|
||||
}
|
||||
@@ -283,8 +595,8 @@
|
||||
const opts=document.getElementById('targetServers').selectedOptions;
|
||||
const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(!ids.length){toast('warning','请先选择服务器');return}
|
||||
const srcPath=document.getElementById('srcPath').value;
|
||||
if(!srcPath){toast('warning','请输入源路径');return}
|
||||
const srcPath=_uploadSourcePath;
|
||||
if(!srcPath){toast('warning','请先上传 ZIP 文件');return}
|
||||
|
||||
const firstServerId=ids[0];
|
||||
const syncMode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
|
||||
|
||||
+13
-1
@@ -210,6 +210,17 @@
|
||||
let _serversCache={};
|
||||
let _currentDetailTab='info';
|
||||
let _selectedIds=new Set();
|
||||
let _apiBaseUrl=null; // cached from /api/servers/meta/api_base_url
|
||||
|
||||
async function loadApiBaseUrl(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/meta/api_base_url');
|
||||
if(r&&r.ok){
|
||||
const data=await r.json();
|
||||
_apiBaseUrl=data.api_base_url||null;
|
||||
}
|
||||
}catch(e){/* ignore — _apiBaseUrl stays null */}
|
||||
}
|
||||
|
||||
async function loadServers(){
|
||||
try{
|
||||
@@ -361,7 +372,7 @@
|
||||
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30">● Agent 在线</span>'
|
||||
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500 border border-slate-700">○ Agent 离线 / 未安装</span>';
|
||||
|
||||
const base_url_conf=!!(s._base_url);
|
||||
const base_url_conf=!!(_apiBaseUrl);
|
||||
const noKey=!s.agent_api_key_set;
|
||||
let warnings='';
|
||||
if(!base_url_conf)warnings+=`<div class="text-amber-400 text-xs">⚠ NEXUS_API_BASE_URL 未配置,请在系统设置中填写主站对外 URL,才能生成安装命令</div>`;
|
||||
@@ -993,6 +1004,7 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
loadApiBaseUrl();
|
||||
loadServers().then(()=>{
|
||||
const hid=new URLSearchParams(location.search).get('highlight');
|
||||
if(hid)selectServer(parseInt(hid));
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
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:['db_pool_size','db_max_overflow','heartbeat_timeout','redis_url'],labels:{db_pool_size:'连接池大小',db_max_overflow:'最大溢出',heartbeat_timeout:'心跳超时(秒)',redis_url:'Redis URL'}},
|
||||
infra:{keys:['api_base_url','db_pool_size','db_max_overflow','heartbeat_timeout','redis_url'],labels:{api_base_url:'主站对外 URL',db_pool_size:'连接池大小',db_max_overflow:'最大溢出',heartbeat_timeout:'心跳超时(秒)',redis_url:'Redis URL'}},
|
||||
telegram:{keys:['telegram_bot_token','telegram_chat_id'],labels:{telegram_bot_token:'Bot Token',telegram_chat_id:'Chat ID'}},
|
||||
};
|
||||
let _allSettings={};
|
||||
|
||||
Reference in New Issue
Block a user