Merge branch 'claude/condescending-ishizaka-3cff3a'

This commit is contained in:
Your Name
2026-05-28 21:02:09 +08:00
20 changed files with 1592 additions and 180 deletions
@@ -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` 全部走 sudovenv 创建加 `|| 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
+1
View File
@@ -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),
+20
View File
@@ -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
View File
@@ -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,
}
+2 -2
View File
@@ -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
View File
@@ -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)}
+25
View File
@@ -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)
+14 -14
View File
@@ -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 ──
+177 -81
View File
@@ -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,
+24
View File
@@ -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()
+14 -6
View File
@@ -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())
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
+1 -1
View File
@@ -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={};