feat: 推送页面 5 项迭代 — WS实时进度 + 分组选择 + 历史增强 + 文件操作 + 推送校验
F1: broadcast_sync_progress() + batch_id + 前端WS逐台更新 F2: get_paginated() platform_id/node_id + /categories端点 + 筛选下拉框 F4: _sync_log_to_dict() 补全字段 + diff_summary捕获 + 可展开详情面板 F7: /local-file-ops端点 + 文件管理器删除/重命名 F8: /verify端点 md5sum对比 + 推送后校验UI Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
+329
-5
@@ -46,6 +46,69 @@ def _sudo_wrap(cmd: str, ssh_user: str) -> str:
|
||||
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")
|
||||
|
||||
router = APIRouter(prefix="/api/servers", tags=["servers"])
|
||||
@@ -58,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),
|
||||
@@ -74,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 = []
|
||||
@@ -82,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:
|
||||
@@ -100,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}")
|
||||
|
||||
@@ -178,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"),
|
||||
@@ -497,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])
|
||||
@@ -581,7 +681,7 @@ async def batch_upgrade_agent(
|
||||
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])
|
||||
@@ -604,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,
|
||||
@@ -927,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
|
||||
@@ -951,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,
|
||||
@@ -1115,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
|
||||
@@ -1212,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,
|
||||
}
|
||||
+348
-2
@@ -9,7 +9,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncPreview, FileOperation
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, SyncVerify
|
||||
from server.api.dependencies import get_server_service
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
@@ -191,7 +191,115 @@ async def browse_directory(
|
||||
return {"path": path, "entries": entries}
|
||||
|
||||
|
||||
# ── Shared audit helper for sync routes ──
|
||||
@router.post("/browse-local")
|
||||
async def browse_local_directory(
|
||||
payload: SyncBrowseLocal,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Browse a local directory on the Nexus server (for upload file manager).
|
||||
|
||||
Only allows browsing under /tmp/nexus_upload_* directories.
|
||||
Returns entries with name, is_dir, size.
|
||||
"""
|
||||
import os
|
||||
|
||||
path = payload.path.rstrip("/") or "/"
|
||||
|
||||
# Security: only allow browsing under /tmp/nexus_upload_*
|
||||
real_path = os.path.realpath(path)
|
||||
if not real_path.startswith("/tmp/nexus_upload_"):
|
||||
raise HTTPException(status_code=403, detail="仅允许浏览上传临时目录")
|
||||
|
||||
if not os.path.isdir(real_path):
|
||||
raise HTTPException(status_code=404, detail="目录不存在")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in sorted(os.scandir(real_path), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"is_dir": entry.is_dir(),
|
||||
"size": stat.st_size if not entry.is_dir() else 0,
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="无权限访问该目录")
|
||||
|
||||
return {"path": real_path, "entries": entries}
|
||||
|
||||
|
||||
@router.post("/local-file-ops")
|
||||
async def local_file_operation(
|
||||
payload: LocalFileOperation,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Delete or rename a file/directory in the local upload staging area.
|
||||
|
||||
Only allows operations under /tmp/nexus_upload_* directories.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
path = payload.path.rstrip("/") or "/"
|
||||
|
||||
# Security: only allow operations under /tmp/nexus_upload_*
|
||||
real_path = os.path.realpath(path)
|
||||
if not real_path.startswith("/tmp/nexus_upload_"):
|
||||
raise HTTPException(status_code=403, detail="仅允许操作上传临时目录中的文件")
|
||||
|
||||
if payload.operation == "delete":
|
||||
if not os.path.exists(real_path):
|
||||
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
||||
try:
|
||||
if os.path.isdir(real_path):
|
||||
shutil.rmtree(real_path)
|
||||
else:
|
||||
os.unlink(real_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除失败: {e}")
|
||||
|
||||
await _audit_sync(
|
||||
"local_file_delete", "sync", 0,
|
||||
f"删除: {path}", admin.username, request,
|
||||
)
|
||||
return {"success": True, "operation": "delete", "path": path}
|
||||
|
||||
elif payload.operation == "rename":
|
||||
if not payload.new_name:
|
||||
raise HTTPException(status_code=400, detail="new_name 必填")
|
||||
# Sanitize: strip slashes and path traversal
|
||||
safe_name = payload.new_name.replace("/", "_").replace("\\", "_").strip()
|
||||
if not safe_name or safe_name == "." or safe_name == "..":
|
||||
raise HTTPException(status_code=400, detail="无效的新名称")
|
||||
|
||||
if not os.path.exists(real_path):
|
||||
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
||||
|
||||
parent_dir = os.path.dirname(real_path)
|
||||
new_path = os.path.join(parent_dir, safe_name)
|
||||
|
||||
# Security: new path must also be under upload dir
|
||||
if not os.path.realpath(new_path).startswith("/tmp/nexus_upload_"):
|
||||
raise HTTPException(status_code=403, detail="目标路径不在上传临时目录内")
|
||||
|
||||
if os.path.exists(new_path):
|
||||
raise HTTPException(status_code=400, detail="目标名称已存在")
|
||||
|
||||
try:
|
||||
os.rename(real_path, new_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"重命名失败: {e}")
|
||||
|
||||
await _audit_sync(
|
||||
"local_file_rename", "sync", 0,
|
||||
f"重命名: {path} → {safe_name}", admin.username, request,
|
||||
)
|
||||
return {"success": True, "operation": "rename", "path": path, "new_name": safe_name}
|
||||
|
||||
async def _audit_sync(action: str, target_type: str, target_id: int, detail: str, operator: str, request: Request):
|
||||
"""Create audit log entry for sync operations"""
|
||||
@@ -303,3 +411,241 @@ async def upload_file(
|
||||
"filename": safe_filename,
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
|
||||
# ── ZIP Upload & Extract (for push workflow) ──
|
||||
|
||||
MAX_ZIP_FILE_SIZE = 524_288_000 # 500 MB
|
||||
|
||||
|
||||
@router.post("/upload-zip")
|
||||
async def upload_zip(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Upload a ZIP file to Nexus, extract it, return the source path for rsync push.
|
||||
|
||||
Multipart form fields:
|
||||
- file: UploadFile (required, must be .zip)
|
||||
|
||||
Returns {"source_path": "/tmp/nexus_upload_xxx/", "file_count": N}
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
import shutil
|
||||
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
|
||||
if not file or not hasattr(file, "filename") or not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择 ZIP 文件")
|
||||
|
||||
filename = file.filename
|
||||
if not filename.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
|
||||
|
||||
# Read with size check
|
||||
content = await file.read()
|
||||
if len(content) > MAX_ZIP_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_ZIP_FILE_SIZE // (1024*1024)}MB)")
|
||||
if len(content) == 0:
|
||||
raise HTTPException(status_code=400, detail="文件为空")
|
||||
|
||||
# Create temp directory
|
||||
upload_id = uuid.uuid4().hex[:12]
|
||||
extract_dir = f"/tmp/nexus_upload_{upload_id}"
|
||||
zip_path = f"{extract_dir}.zip"
|
||||
|
||||
try:
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
|
||||
# Save ZIP
|
||||
with open(zip_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Validate and extract with zip slip protection
|
||||
file_count = 0
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for info in zf.infolist():
|
||||
# Zip slip: ensure no path traversal
|
||||
real_path = os.path.realpath(os.path.join(extract_dir, info.filename))
|
||||
if not real_path.startswith(os.path.realpath(extract_dir)):
|
||||
raise HTTPException(status_code=400, detail=f"ZIP 包含非法路径: {info.filename}")
|
||||
if info.is_dir():
|
||||
continue
|
||||
file_count += 1
|
||||
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
# Clean up ZIP file
|
||||
os.unlink(zip_path)
|
||||
|
||||
if file_count == 0:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=400, detail="ZIP 文件为空(无文件)")
|
||||
|
||||
# Build file listing (relative paths, capped at 500)
|
||||
files = []
|
||||
for root, dirs, fnames in os.walk(extract_dir):
|
||||
for fn in fnames:
|
||||
rel = os.path.relpath(os.path.join(root, fn), extract_dir)
|
||||
files.append(rel)
|
||||
if len(files) >= 500:
|
||||
break
|
||||
if len(files) >= 500:
|
||||
break
|
||||
files_truncated = file_count > len(files)
|
||||
|
||||
# Audit
|
||||
await _audit_sync(
|
||||
"upload_zip", "sync", 0,
|
||||
f"上传 ZIP: {filename} ({len(content)} bytes) → {extract_dir} ({file_count} 个文件)",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"source_path": extract_dir,
|
||||
"file_count": file_count,
|
||||
"files": files,
|
||||
"files_truncated": files_truncated,
|
||||
"filename": filename,
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
try:
|
||||
os.unlink(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except zipfile.BadZipFile:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
try:
|
||||
os.unlink(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=400, detail="无效的 ZIP 文件")
|
||||
except Exception as e:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
try:
|
||||
os.unlink(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"解压失败: {e}")
|
||||
|
||||
|
||||
# ── Post-Push Verification (md5sum comparison) ──
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_sync(
|
||||
payload: SyncVerify,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Verify pushed files by comparing md5sums between source and target servers.
|
||||
|
||||
Walks the local source directory and computes md5 for each file,
|
||||
then runs md5sum on each target server via SSH and compares.
|
||||
Returns per-server results: matched / missing / mismatched file lists.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import shlex
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
|
||||
session = request.state.db
|
||||
|
||||
if not os.path.isdir(payload.source_path):
|
||||
raise HTTPException(status_code=400, detail=f"源路径不存在: {payload.source_path}")
|
||||
|
||||
# Build local file manifest: {relative_path: md5hex}
|
||||
local_files = {}
|
||||
for root, dirs, fnames in os.walk(payload.source_path):
|
||||
for fn in fnames:
|
||||
full = os.path.join(root, fn)
|
||||
rel = os.path.relpath(full, payload.source_path)
|
||||
if len(local_files) >= payload.max_files:
|
||||
break
|
||||
try:
|
||||
h = hashlib.md5()
|
||||
with open(full, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
local_files[rel] = h.hexdigest()
|
||||
except OSError:
|
||||
continue
|
||||
if len(local_files) >= payload.max_files:
|
||||
break
|
||||
|
||||
if not local_files:
|
||||
raise HTTPException(status_code=400, detail="源目录无文件可校验")
|
||||
|
||||
# Verify against each server concurrently
|
||||
sem = asyncio.Semaphore(5)
|
||||
results = {}
|
||||
|
||||
async def _verify_one(sid: int):
|
||||
async with sem:
|
||||
server = await ServerRepositoryImpl(session).get_by_id(sid)
|
||||
if not server:
|
||||
results[sid] = {"server_id": sid, "server_name": "", "error": "服务器不存在"}
|
||||
return
|
||||
|
||||
dest = payload.target_path or server.target_path or "/tmp/sync"
|
||||
# Run md5sum on remote server for the same relative paths
|
||||
# Build a file list for md5sum to check
|
||||
file_list = " ".join(shlex.quote(f) for f in local_files.keys())
|
||||
cmd = f"cd {shlex.quote(dest)} && md5sum {file_list} 2>/dev/null"
|
||||
|
||||
try:
|
||||
r = await exec_ssh_command(server, cmd, timeout=60)
|
||||
except Exception as e:
|
||||
results[sid] = {
|
||||
"server_id": sid, "server_name": server.name,
|
||||
"error": f"SSH 连接失败: {e}",
|
||||
}
|
||||
return
|
||||
|
||||
# Parse md5sum output: "hash filename"
|
||||
remote_files = {}
|
||||
if r["exit_code"] == 0:
|
||||
for line in r["stdout"].strip().split("\n"):
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
remote_files[parts[1]] = parts[0]
|
||||
|
||||
matched = []
|
||||
missing = []
|
||||
mismatched = []
|
||||
for rel, local_hash in local_files.items():
|
||||
if rel not in remote_files:
|
||||
missing.append(rel)
|
||||
elif remote_files[rel] != local_hash:
|
||||
mismatched.append(rel)
|
||||
else:
|
||||
matched.append(rel)
|
||||
|
||||
results[sid] = {
|
||||
"server_id": sid,
|
||||
"server_name": server.name,
|
||||
"matched": len(matched),
|
||||
"missing": len(missing),
|
||||
"mismatched": len(mismatched),
|
||||
"missing_files": missing[:50],
|
||||
"mismatched_files": mismatched[:50],
|
||||
}
|
||||
|
||||
await asyncio.gather(*[_verify_one(sid) for sid in payload.server_ids])
|
||||
|
||||
# Audit
|
||||
await _audit_sync(
|
||||
"sync_verify", "sync", 0,
|
||||
f"推送校验: {payload.source_path} → {len(payload.server_ids)} 台服务器",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"results": results, "total_local_files": len(local_files)}
|
||||
|
||||
@@ -427,3 +427,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)
|
||||
|
||||
|
||||
@@ -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,14 +110,28 @@ 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"文件推送: {source_path} → {total} 台服务器 ({completed} 成功, {failed} 失败)",
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
+335
-23
@@ -8,12 +8,48 @@
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
|
||||
<!-- Push Form -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div><label class="block text-sm text-slate-300 mb-2">源路径</label><input id="srcPath" placeholder="/home/deploy/source" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<!-- Source: ZIP upload only -->
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 mb-2 block">上传 ZIP</label>
|
||||
<div id="sourceUpload" class="space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="zipFile" type="file" accept=".zip" class="flex-1 text-sm text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:bg-brand file:text-white hover:file:bg-brand-dark">
|
||||
<button onclick="uploadZip()" id="uploadZipBtn" class="px-4 py-2.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition whitespace-nowrap">上传并解压</button>
|
||||
</div>
|
||||
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-slate-400">
|
||||
<div class="animate-spin w-4 h-4 border-2 border-brand border-t-transparent rounded-full"></div>
|
||||
上传并解压中...
|
||||
</div>
|
||||
<div id="uploadResult" class="hidden space-y-2">
|
||||
<div class="flex items-center gap-3 px-3 py-2 bg-green-900/20 border border-green-700/30 rounded-lg">
|
||||
<span class="text-green-400 text-sm">✓</span>
|
||||
<span id="uploadedInfo" class="text-xs text-slate-400"></span>
|
||||
<button onclick="clearUpload()" class="text-xs text-slate-500 hover:text-slate-300 ml-auto">✕ 清除</button>
|
||||
</div>
|
||||
<!-- File Manager -->
|
||||
<div id="fileManager" class="bg-slate-800/50 border border-slate-700/50 rounded-lg overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700/50">
|
||||
<span class="text-xs text-slate-400">📁</span>
|
||||
<span id="fmBreadcrumb" class="text-xs text-slate-300 flex-1 truncate"></span>
|
||||
<span id="fmCount" class="text-xs text-slate-500"></span>
|
||||
</div>
|
||||
<div id="fmEntries" class="max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标路径</label><input id="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<select id="filterCategory" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
|
||||
<option value="">全部分类</option>
|
||||
</select>
|
||||
<select id="filterPlatform" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
|
||||
<option value="">全部平台</option>
|
||||
</select>
|
||||
<button onclick="selectAllServers()" class="text-xs text-brand-light hover:underline">全选</button>
|
||||
<button onclick="deselectAllServers()" class="text-xs text-slate-400 hover:underline">全不选</button>
|
||||
<button onclick="selectFilteredServers()" class="text-xs text-brand-light hover:underline">选中当前筛选</button>
|
||||
</div>
|
||||
<select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-40"></select>
|
||||
<div id="serverCount" class="text-slate-500 text-xs mt-1">已选择 0 台服务器</div>
|
||||
@@ -75,6 +111,9 @@
|
||||
<div><label class="block text-sm text-slate-300 mb-2">批次大小</label><input id="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
</div>
|
||||
<button onclick="doPush()" id="pushBtn" class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed">开始推送</button>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-400 -mt-2">
|
||||
<input type="checkbox" id="verifyAfterPush" class="accent-brand"> 推送后校验(md5sum 对比)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
@@ -101,6 +140,15 @@
|
||||
<h2 class="text-white font-semibold mb-3">最近推送记录</h2>
|
||||
<div id="pushHistory" class="space-y-2"><div class="text-slate-500 text-center py-6 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Verify Result Section -->
|
||||
<div id="verifySection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-white font-semibold">推送校验结果</h2>
|
||||
<button onclick="document.getElementById('verifySection').classList.add('hidden')" class="text-xs text-slate-500 hover:text-slate-300">✕ 关闭</button>
|
||||
</div>
|
||||
<div id="verifyResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
@@ -109,18 +157,208 @@
|
||||
initLayout('push');
|
||||
let _serversCache=[];
|
||||
let _pushInProgress=false;
|
||||
let _uploadSourcePath=''; // set after ZIP upload
|
||||
let _fmCurrentPath=''; // current browsed path in file manager
|
||||
let _pushBatchId=''; // current push batch_id for WS filtering
|
||||
let _pushWs=null; // WebSocket connection for push progress
|
||||
|
||||
// ── ZIP upload ──
|
||||
|
||||
// ── WebSocket for real-time push progress ──
|
||||
function connectPushWS(){
|
||||
if(_pushWs&&_pushWs.readyState<=1)return; // already connected/connecting
|
||||
const token=localStorage.getItem('access_token');
|
||||
if(!token)return;
|
||||
const proto=location.protocol==='https:'?'wss:':'ws:';
|
||||
const url=proto+'//'+location.host+'/ws/alerts?token='+encodeURIComponent(token);
|
||||
try{
|
||||
_pushWs=new WebSocket(url);
|
||||
_pushWs.onmessage=function(ev){
|
||||
try{
|
||||
const msg=JSON.parse(ev.data);
|
||||
if(msg.type==='sync_progress'&&msg.batch_id===_pushBatchId){
|
||||
updateProgressFromWS(msg);
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
_pushWs.onclose=function(){
|
||||
_pushWs=null;
|
||||
// Reconnect if push is still in progress
|
||||
if(_pushInProgress) setTimeout(connectPushWS,3000);
|
||||
};
|
||||
_pushWs.onerror=function(){_pushWs=null};
|
||||
}catch(e){_pushWs=null}
|
||||
}
|
||||
|
||||
function updateProgressFromWS(msg){
|
||||
const sid=msg.server_id;
|
||||
const iconEl=document.getElementById('srv_icon_'+sid);
|
||||
const statusEl=document.getElementById('srv_status_'+sid);
|
||||
const detailEl=document.getElementById('srv_detail_'+sid);
|
||||
if(!iconEl||!statusEl)return;
|
||||
|
||||
if(msg.status==='success'){
|
||||
iconEl.textContent='✓';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-green-400';
|
||||
statusEl.textContent='成功';statusEl.className='text-xs text-green-400';
|
||||
if(msg.duration_seconds!=null){detailEl.textContent=msg.duration_seconds+'s';detailEl.classList.remove('hidden');detailEl.className='text-xs text-green-400/70'}
|
||||
}else{
|
||||
iconEl.textContent='✗';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-red-400';
|
||||
statusEl.textContent='失败';statusEl.className='text-xs text-red-400';
|
||||
if(msg.error_message){detailEl.textContent=msg.error_message.substring(0,80);detailEl.classList.remove('hidden');detailEl.className='text-xs text-red-400/70'}
|
||||
}
|
||||
// Update progress bar and counters
|
||||
const total=msg.total||0;
|
||||
const done=msg.completed+msg.failed;
|
||||
const pct=total>0?Math.round((done/total)*100):0;
|
||||
document.getElementById('progressBar').style.width=pct+'%';
|
||||
document.getElementById('progressStats').textContent=done+'/'+total;
|
||||
document.getElementById('countSuccess').textContent=msg.completed;
|
||||
document.getElementById('countFailed').textContent=msg.failed;
|
||||
document.getElementById('countPending').textContent=Math.max(0,total-done);
|
||||
}
|
||||
|
||||
async function uploadZip(){
|
||||
const file=document.getElementById('zipFile').files[0];
|
||||
if(!file){toast('warning','请选择 ZIP 文件');return}
|
||||
if(!file.name.toLowerCase().endsWith('.zip')){toast('warning','仅支持 .zip 格式');return}
|
||||
const form=new FormData();form.append('file',file);
|
||||
const btn=document.getElementById('uploadZipBtn');
|
||||
btn.disabled=true;btn.textContent='上传中...';
|
||||
document.getElementById('uploadProgress').classList.remove('hidden');
|
||||
document.getElementById('uploadResult').classList.add('hidden');
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/upload-zip',{method:'POST',body:form});
|
||||
if(!r){toast('error','上传失败:认证错误');return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){toast('error','上传失败: '+(d.detail||r.status));return}
|
||||
_uploadSourcePath=d.source_path;
|
||||
document.getElementById('uploadProgress').classList.add('hidden');
|
||||
document.getElementById('uploadResult').classList.remove('hidden');
|
||||
document.getElementById('uploadedInfo').textContent=`已解压 ${d.file_count} 个文件 (${fmtBytes(d.size)})`;
|
||||
// Open file manager at root
|
||||
browseLocal(d.source_path);
|
||||
toast('success',`ZIP 解压完成: ${d.file_count} 个文件`);
|
||||
}catch(e){
|
||||
toast('error','上传异常: '+e.message);
|
||||
document.getElementById('uploadProgress').classList.add('hidden');
|
||||
}finally{
|
||||
btn.disabled=false;btn.textContent='上传并解压';
|
||||
}
|
||||
}
|
||||
|
||||
function clearUpload(){
|
||||
_uploadSourcePath='';
|
||||
_fmCurrentPath='';
|
||||
document.getElementById('zipFile').value='';
|
||||
document.getElementById('uploadResult').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── File Manager (browse local upload dir) ──
|
||||
async function browseLocal(path){
|
||||
_fmCurrentPath=path;
|
||||
// Update breadcrumb
|
||||
const rel=path.replace(_uploadSourcePath,'').replace(/^\//,'');
|
||||
document.getElementById('fmBreadcrumb').textContent=rel?'📁 '+rel:'📁 /';
|
||||
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-slate-500">加载中...</div>';
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/browse-local',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({path})});
|
||||
if(!r){toast('error','浏览失败');return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){toast('error','浏览失败: '+(d.detail||r.status));return}
|
||||
renderFileManager(d);
|
||||
}catch(e){
|
||||
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-red-400">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileManager(d){
|
||||
const entries=d.entries||[];
|
||||
const dirs=entries.filter(e=>e.is_dir);
|
||||
const files=entries.filter(e=>!e.is_dir);
|
||||
document.getElementById('fmCount').textContent=`${dirs.length} 目录, ${files.length} 文件`;
|
||||
let html='';
|
||||
// Parent dir link (if not at root)
|
||||
if(d.path!==_uploadSourcePath){
|
||||
const parent=d.path.substring(0,d.path.lastIndexOf('/'))||_uploadSourcePath;
|
||||
html+=`<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(parent)}')">
|
||||
<span class="text-slate-400">📁</span><span class="text-slate-300">..</span></div>`;
|
||||
}
|
||||
// Directories
|
||||
for(const e of dirs){
|
||||
const fullPath=d.path+'/'+e.name;
|
||||
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(fullPath)}')">
|
||||
<span class="text-brand-light">📁</span><span class="text-slate-200 flex-1 truncate">${esc(e.name)}</span>
|
||||
<span class="hidden group-hover:flex items-center gap-1 shrink-0">
|
||||
<button onclick="event.stopPropagation();renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名">✏</button>
|
||||
<button onclick="event.stopPropagation();deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button>
|
||||
</span></div>`;
|
||||
}
|
||||
// Files
|
||||
for(const e of files){
|
||||
const fullPath=d.path+'/'+e.name;
|
||||
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
<span class="text-slate-500">📄</span><span class="text-slate-300 flex-1 truncate">${esc(e.name)}</span>
|
||||
<span class="text-slate-600 shrink-0">${fmtBytes(e.size)}</span>
|
||||
<span class="hidden group-hover:flex items-center gap-1 shrink-0">
|
||||
<button onclick="renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名">✏</button>
|
||||
<button onclick="deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button>
|
||||
</span></div>`;
|
||||
}
|
||||
if(!dirs.length&&!files.length){
|
||||
html='<div class="px-3 py-2 text-xs text-slate-500">空目录</div>';
|
||||
}
|
||||
document.getElementById('fmEntries').innerHTML=html;
|
||||
}
|
||||
|
||||
async function deleteLocalFile(path,name){
|
||||
if(!confirm(`确认删除 "${name}"?`))return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/local-file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({operation:'delete',path})});
|
||||
if(!r){toast('error','删除失败');return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){toast('error','删除失败: '+(d.detail||r.status));return}
|
||||
toast('success','已删除: '+name);
|
||||
browseLocal(_fmCurrentPath);
|
||||
}catch(e){toast('error','删除异常: '+e.message)}
|
||||
}
|
||||
|
||||
async function renameLocalFile(path,oldName){
|
||||
const newName=prompt('输入新名称:',oldName);
|
||||
if(!newName||newName===oldName)return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/local-file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({operation:'rename',path,new_name:newName})});
|
||||
if(!r){toast('error','重命名失败');return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){toast('error','重命名失败: '+(d.detail||r.status));return}
|
||||
toast('success',`已重命名: ${oldName} → ${newName}`);
|
||||
browseLocal(_fmCurrentPath);
|
||||
}catch(e){toast('error','重命名异常: '+e.message)}
|
||||
}
|
||||
|
||||
async function loadServers(){
|
||||
const r=await apiFetch(API+'/servers/');if(!r)return;
|
||||
const data=await r.json();_serversCache=data.items||data;
|
||||
const sel=document.getElementById('targetServers');
|
||||
sel.innerHTML=_serversCache.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
// Load categories/platforms for filter dropdowns
|
||||
try{
|
||||
const cr=await apiFetch(API+'/servers/categories');if(cr){
|
||||
const cd=await cr.json();
|
||||
const catSel=document.getElementById('filterCategory');
|
||||
const platSel=document.getElementById('filterPlatform');
|
||||
(cd.categories||[]).forEach(c=>{
|
||||
const o=document.createElement('option');o.value=c.name;o.textContent=c.name+' ('+c.count+')';catSel.appendChild(o);
|
||||
});
|
||||
(cd.platforms||[]).forEach(p=>{
|
||||
const o=document.createElement('option');o.value=p.id;o.textContent=p.name+(p.category?' ('+p.category+')':'');platSel.appendChild(o);
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
// Load server list
|
||||
await filterServers();
|
||||
|
||||
// Pre-select servers from batch push (servers.html)
|
||||
const batchIds=sessionStorage.getItem('batchPushIds');
|
||||
if(batchIds){
|
||||
try{
|
||||
const ids=JSON.parse(batchIds);
|
||||
const sel=document.getElementById('targetServers');
|
||||
Array.from(sel.options).forEach(o=>{
|
||||
if(ids.includes(parseInt(o.value)))o.selected=true;
|
||||
});
|
||||
@@ -131,6 +369,21 @@
|
||||
updateServerCount();
|
||||
}
|
||||
|
||||
async function filterServers(){
|
||||
const cat=document.getElementById('filterCategory').value;
|
||||
const plat=document.getElementById('filterPlatform').value;
|
||||
let url=API+'/servers/?per_page=200';
|
||||
if(cat)url+='&category='+encodeURIComponent(cat);
|
||||
if(plat)url+='&platform_id='+plat;
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();_serversCache=data.items||data;
|
||||
const sel=document.getElementById('targetServers');
|
||||
// Preserve existing selections
|
||||
const prevSelected=new Set(Array.from(sel.selectedOptions).map(o=>parseInt(o.value)));
|
||||
sel.innerHTML=_serversCache.map(s=>`<option value="${s.id}"${prevSelected.has(s.id)?' selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
updateServerCount();
|
||||
}
|
||||
|
||||
document.getElementById('targetServers').addEventListener('change',updateServerCount);
|
||||
function updateServerCount(){
|
||||
const sel=document.getElementById('targetServers').selectedOptions.length;
|
||||
@@ -142,14 +395,17 @@
|
||||
function deselectAllServers(){
|
||||
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=false);updateServerCount();
|
||||
}
|
||||
function selectFilteredServers(){
|
||||
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=true);updateServerCount();
|
||||
}
|
||||
|
||||
async function doPush(){
|
||||
if(_pushInProgress)return;
|
||||
const opts=document.getElementById('targetServers').selectedOptions;
|
||||
const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(!ids.length){toast('warning','请选择至少一台服务器');return}
|
||||
const srcPath=document.getElementById('srcPath').value;
|
||||
if(!srcPath){toast('warning','请输入源路径');return}
|
||||
const srcPath=_uploadSourcePath;
|
||||
if(!srcPath){toast('warning','请先上传 ZIP 文件');return}
|
||||
const syncMode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
|
||||
const body={
|
||||
server_ids:ids,
|
||||
@@ -161,22 +417,31 @@
|
||||
};
|
||||
|
||||
_pushInProgress=true;
|
||||
_pushBatchId=''; // reset, will be set from HTTP response
|
||||
const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
|
||||
|
||||
// Show progress section with pending states
|
||||
showProgress(ids);
|
||||
// Connect WebSocket for real-time progress
|
||||
connectPushWS();
|
||||
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/files',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(!r){toast('error','认证失败或请求被拒绝');resetProgress();return}
|
||||
const d=await r.json();
|
||||
if(d.batch_id)_pushBatchId=d.batch_id; // for late WS filtering
|
||||
updateProgressFromResult(d);
|
||||
toast(d.failed>0?'warning':'success',`推送完成: ${d.completed||0} 成功, ${d.failed||0} 失败`);
|
||||
// Post-push verification if checked
|
||||
if(document.getElementById('verifyAfterPush').checked&&d.completed>0){
|
||||
doVerify(ids,_uploadSourcePath,document.getElementById('destPath').value||null);
|
||||
}
|
||||
}catch(e){
|
||||
toast('error','推送请求失败: '+e.message);
|
||||
resetProgress();
|
||||
}finally{
|
||||
_pushInProgress=false;btn.disabled=false;btn.textContent='开始推送';
|
||||
if(_pushWs){try{_pushWs.close()}catch(e){}_pushWs=null}
|
||||
loadHistory(); // Refresh history
|
||||
}
|
||||
}
|
||||
@@ -243,24 +508,71 @@
|
||||
document.getElementById('progressSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Post-Push Verify (md5sum) ──
|
||||
async function doVerify(ids,sourcePath,targetPath){
|
||||
const section=document.getElementById('verifySection');
|
||||
section.classList.remove('hidden');
|
||||
document.getElementById('verifyResults').innerHTML='<div class="text-slate-500 text-sm text-center py-4">校验中...</div>';
|
||||
try{
|
||||
const body={server_ids:ids,source_path:sourcePath,target_path:targetPath||null};
|
||||
const r=await apiFetch(API+'/sync/verify',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(!r){document.getElementById('verifyResults').innerHTML='<div class="text-red-400 text-sm">校验请求失败</div>';return}
|
||||
const d=await r.json();
|
||||
if(!r.ok){document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">${esc(d.detail||'校验失败')}</div>`;return}
|
||||
const results=d.results||{};
|
||||
document.getElementById('verifyResults').innerHTML=Object.values(results).map(v=>{
|
||||
if(v.error)return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 border-l-red-500">
|
||||
<span class="text-red-400 text-sm">✗</span> <span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span>
|
||||
<span class="text-xs text-red-400 ml-2">${esc(v.error)}</span></div>`;
|
||||
const allOk=v.missing===0&&v.mismatched===0;
|
||||
const border=allOk?'border-l-green-500':'border-l-amber-500';
|
||||
let detail='';
|
||||
if(v.missing>0)detail+=`<div class="text-xs text-amber-400 mt-1">缺失 ${v.missing} 文件: ${v.missing_files.slice(0,5).map(esc).join(', ')}${v.missing>5?' ...':''}</div>`;
|
||||
if(v.mismatched>0)detail+=`<div class="text-xs text-red-400 mt-1">不一致 ${v.mismatched} 文件: ${v.mismatched_files.slice(0,5).map(esc).join(', ')}${v.mismatched>5?' ...':''}</div>`;
|
||||
return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 ${border}">
|
||||
<span class="${allOk?'text-green-400':'text-amber-400'} text-sm">${allOk?'✓':'⚠'}</span>
|
||||
<span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span>
|
||||
<span class="text-xs text-slate-500 ml-2">${v.matched} 匹配${v.missing>0?`, ${v.missing} 缺失`:''}${v.mismatched>0?`, ${v.mismatched} 不一致`:''}</span>
|
||||
${detail}</div>`;
|
||||
}).join('');
|
||||
}catch(e){
|
||||
document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">校验异常: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push History (recent sync logs) ──
|
||||
async function loadHistory(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/logs?limit=10');if(!r)return;
|
||||
const r=await apiFetch(API+'/servers/logs?limit=15');if(!r)return;
|
||||
const res=await r.json();const logs=res.items||res;
|
||||
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>`<div class="bg-slate-900 rounded-lg border border-slate-800 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="${l.status==='success'?'text-green-400':'text-red-400'} text-sm">${l.status==='success'?'✓':'✗'}</span>
|
||||
<div>
|
||||
<span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.operator||'system')}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span>
|
||||
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>{
|
||||
const ok=l.status==='success';
|
||||
const border=ok?'border-l-2 border-l-green-500':'border-l-2 border-l-red-500';
|
||||
let summary=`${esc(l.source_path||'')} → ${esc(l.target_path||'')}`;
|
||||
if(l.duration_seconds!=null)summary+=` · ${l.duration_seconds}s`;
|
||||
let detail='';
|
||||
if(l.files_transferred!=null)detail+=`<div class="text-xs text-slate-400">传输: ${l.files_transferred} 文件</div>`;
|
||||
if(l.bytes_transferred!=null&&l.bytes_transferred>0)detail+=`<div class="text-xs text-slate-400">大小: ${fmtBytes(l.bytes_transferred)}</div>`;
|
||||
if(l.error_message)detail+=`<div class="text-xs text-red-400/80 break-all">${esc(l.error_message.substring(0,300))}</div>`;
|
||||
if(l.diff_summary)detail+=`<details class="mt-1"><summary class="text-xs text-slate-500 cursor-pointer hover:text-slate-300">查看 diff</summary><pre class="text-xs text-slate-500 bg-slate-900 rounded p-2 mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap">${esc(l.diff_summary.substring(0,2000))}</pre></details>`;
|
||||
return `<div class="bg-slate-900 rounded-lg border border-slate-800 ${border} px-4 py-3 cursor-pointer hover:bg-slate-800/50 transition" onclick="this.querySelector('.hist-detail')?.classList.toggle('hidden')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="${ok?'text-green-400':'text-red-400'} text-sm">${ok?'✓':'✗'}</span>
|
||||
<div>
|
||||
<span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.operator||'system')}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
|
||||
<div class="text-xs text-slate-500 mt-1 truncate">${summary}</div>
|
||||
<div class="hist-detail hidden mt-2 space-y-1 border-t border-slate-700/50 pt-2">${detail}</div>
|
||||
</div>`;
|
||||
}).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
|
||||
}catch(e){
|
||||
document.getElementById('pushHistory').innerHTML='<div class="text-slate-500 text-center py-6 text-sm">加载失败</div>';
|
||||
}
|
||||
@@ -283,8 +595,8 @@
|
||||
const opts=document.getElementById('targetServers').selectedOptions;
|
||||
const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(!ids.length){toast('warning','请先选择服务器');return}
|
||||
const srcPath=document.getElementById('srcPath').value;
|
||||
if(!srcPath){toast('warning','请输入源路径');return}
|
||||
const srcPath=_uploadSourcePath;
|
||||
if(!srcPath){toast('warning','请先上传 ZIP 文件');return}
|
||||
|
||||
const firstServerId=ids[0];
|
||||
const syncMode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
|
||||
|
||||
Reference in New Issue
Block a user