feat: 批量IP添加、脚本库历史、推送权限记录与脚本SSH执行修复
批量添加替代CSV并支持去重;脚本库页内嵌执行历史;推送历史记录 rsync 权限策略; 脚本执行不再依赖 Agent 在线;服务器同步日志与相关单测补齐。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -31,8 +31,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
COPY web/agent/ ./web/agent/
|
COPY web/agent/ ./web/agent/
|
||||||
COPY --from=frontend /build/web/app ./web/app/
|
COPY --from=frontend /build/web/app ./web/app/
|
||||||
# Install wizard (Alpine + Tailwind static page; not part of Vite build)
|
# Install wizard + static SPA assets not emitted by Vite
|
||||||
COPY web/app/install.html ./web/app/install.html
|
COPY web/app/install.html ./web/app/install.html
|
||||||
|
COPY web/app/servers_import_template.csv ./web/app/servers_import_template.csv
|
||||||
RUN mkdir -p ./web/app/vendor
|
RUN mkdir -p ./web/app/vendor
|
||||||
COPY web/app/vendor/tailwindcss-browser.js \
|
COPY web/app/vendor/tailwindcss-browser.js \
|
||||||
web/app/vendor/theme-init.js \
|
web/app/vendor/theme-init.js \
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 批量 IP 添加替代 CSV 导入
|
||||||
|
|
||||||
|
**日期**: 2026-06-07
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
服务器页「导入」改为「批量添加」:多行文本框每行一个 IP/域名,凭据预设轮询 SSH;成功入库,失败进入连接失败列表(pending)。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
- 运维场景以 IP 列表为主,无需维护 CSV 列格式
|
||||||
|
- 与「快速添加(IP)」同一套凭据轮询逻辑,失败统一进 pending 可重试
|
||||||
|
- 输入重复 IP/域名自动去重(不区分大小写),响应与对话框展示去重统计
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `server/api/schemas.py` | `AddByIpsBatchRequest/Result`、轮询错误结构 |
|
||||||
|
| `server/api/servers.py` | `_try_add_by_ip` 抽取、`POST /add-by-ips-batch` |
|
||||||
|
| `server/application/services/credential_poller.py` | `parse_batch_ip_lines()` |
|
||||||
|
| `frontend/src/pages/ServersPage.vue` | 批量添加对话框(替代 CSV 上传) |
|
||||||
|
| `frontend/src/types/api.ts` | 批量响应类型 |
|
||||||
|
| `tests/test_credential_poller.py` | 解析与 batch 端点单测 |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- **新增** `POST /api/servers/add-by-ips-batch`
|
||||||
|
Body: `{ text, port?, agent_port?, target_path?, category?, platform_id?, node_id? }`
|
||||||
|
响应: `{ total, created, pending, skipped, items[] }`
|
||||||
|
- **保留** `POST /api/servers/import`(CSV)供脚本/兼容,前端不再暴露
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
- 需重启 API(后端路由变更)
|
||||||
|
- 前端需 `vite build` 后部署静态资源
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_credential_poller.py -q
|
||||||
|
cd frontend && npx vite build
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器:`#/servers` →「批量添加」粘贴多行 IP → 成功列表刷新、失败出现在「连接失败列表」。
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 推送历史显示权限修正记录
|
||||||
|
|
||||||
|
**日期**: 2026-06-07
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
`sync_logs` 新增 `push_permission` 字段,记录每次真实推送时 rsync 附加的 `--chown` / `--chmod` 策略;推送页、命令页推送日志、服务器同步日志均展示该列。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
用户需确认推送是否按配置将远程文件改为 `www:www` / `755` 等;此前仅审计/环境变量侧可知,推送历史无记录。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `server/domain/models/__init__.py` | `SyncLog.push_permission` |
|
||||||
|
| `server/infrastructure/database/migrations.py` | 启动迁移加列 |
|
||||||
|
| `server/application/services/sync_engine_v2.py` | `rsync_push_permission_summary()`、写入日志 |
|
||||||
|
| `server/api/servers.py` | API 序列化 |
|
||||||
|
| `frontend/src/composables/push/*` | 列头与 `formatPushPermission` |
|
||||||
|
| `frontend/src/components/push/PushHistoryTable.vue` | 权限列 |
|
||||||
|
| `frontend/src/pages/CommandsPage.vue` | 推送日志视图 |
|
||||||
|
| `frontend/src/pages/ServersPage.vue` | 同步日志副标题 |
|
||||||
|
| `tests/test_rsync_push_permissions.py` | summary 单测 |
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 仅 **文件推送(rsync)** 写入;文件管理器单独 `chmod` 仍在 **审计日志** `file_permission_change`。
|
||||||
|
- 历史记录在字段上线前的旧推送显示 `—`。
|
||||||
|
- 取消的推送不写入权限策略。
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
- API 重启后自动 `ALTER TABLE sync_logs ADD COLUMN push_permission …`
|
||||||
|
- 前端需 build 部署
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_rsync_push_permissions.py -q
|
||||||
|
cd frontend && npx vite build
|
||||||
|
```
|
||||||
|
|
||||||
|
推送成功后:`GET /api/servers/logs` 对应项 `push_permission` 为 `chown=www:www chmod=D755,F755`(默认配置)。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 脚本执行:移除 Agent 在线门禁,改判 SSH 凭据
|
||||||
|
|
||||||
|
**日期**: 2026-06-07
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
`ScriptService._dispatch_to_servers` 不再要求 `is_online`(Agent 心跳),改为检查服务器是否存在且已配置 SSH 凭据后直连 SSH 执行。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
用户执行脚本 #3 于 2 台服务器瞬间 `failed (2 台失败)`。根因:经「批量 IP / 凭据轮询」入库的机器通常 **无 Agent**,`is_online=false`,旧逻辑直接跳过并记 `exit_code=-1`,与「脚本走 SSH」设计矛盾。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `server/application/services/script_service.py` — `_server_ssh_configured`、dispatch / 拉日志
|
||||||
|
- `tests/test_script_dispatch.py` — 回归单测
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
- 需重启 API worker
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_script_dispatch.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
对无 Agent、有 SSH 凭据的服务器执行短脚本,应不再秒失败;执行详情 stderr 不再出现 `Server offline or not found`。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 脚本库页内嵌执行历史
|
||||||
|
|
||||||
|
**日期**: 2026-06-07
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
脚本库页面脚本列表下方新增两块常驻区域:**最近 5 次执行日志**(含事件时间线)与 **全部执行历史**(分页表格);卡片「历史」改为筛选下方表格并滚动定位。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
执行记录原先仅在弹窗中按单脚本查看,不便纵览全局执行与审计日志对照。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `frontend/src/pages/ScriptsPage.vue` — 历史面板、加载/刷新/筛选
|
||||||
|
- `frontend/src/types/api.ts` — `ScriptExecItem.events` 字段与 API 对齐
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
- 仅前端静态资源,需 `vite build` 后部署
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx vite build
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器 `#/scripts`:底部见最近 5 条与分页历史;执行/重试/停止后列表自动刷新;点脚本卡片「历史」筛选对应脚本。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Changelog — 服务器详情同步日志修复
|
||||||
|
|
||||||
|
**日期**:2026-06-07
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
修复服务器列表展开面板「同步日志」Tab 恒为空:前端误将 `GET /api/servers/{id}/logs` 的**数组**响应当作 `{ items: [] }` 解析。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
用户反馈服务器列表同步日志缺失;API 契约为 `response_model=list`,SPA 使用 `res.items` 导致始终 `[]`。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `frontend/src/pages/ServersPage.vue`
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
仅前端静态资源;`vite build` 后部署。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. 打开 `#/servers`,点击有推送记录的服务器行
|
||||||
|
2. 切到「同步日志」Tab → 应显示路径与状态(非「暂无同步日志」)
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
|
|
||||||
| ID | 任务 | 优先级 | 状态 | 涉及 | 验收标准 |
|
| ID | 任务 | 优先级 | 状态 | 涉及 | 验收标准 |
|
||||||
|----|------|--------|------|------|----------|
|
|----|------|--------|------|------|----------|
|
||||||
| A-01 | **生产部署** `dfb0ea2`(API + 前端静态) | P0 | ☐ | `deploy/deploy-production.sh` | 生产 `git pull` 后 API 重启;`web/app/` 同步;`/health` 200 |
|
| A-01 | **生产部署** `dfb0ea2`(API + 前端静态) | P0 | ☑ | `deploy/deploy-production.sh` | 生产 `git pull` 后 API 重启;`web/app/` 同步;`/health` 200 |
|
||||||
| A-02 | **DB 迁移** `push_schedules.target_path` | P0 | ☐ | `migrations.py` 启动时 DDL | 生产 `SHOW COLUMNS FROM push_schedules LIKE 'target_path'` 有列 |
|
| A-02 | **DB 迁移** `push_schedules.target_path` | P0 | ~ | `migrations.py` 启动时 DDL | 生产 `SHOW COLUMNS FROM push_schedules LIKE 'target_path'` 有列 |
|
||||||
| A-03 | **脚本库**生产点验 | P0 | ☐ | ScriptsPage + `/scripts/exec` | 新建 ops 脚本 → 选机执行 → 状态条 #id;历史按 script_id 过滤;长任务/凭据可用 |
|
| A-03 | **脚本库**生产点验 | P0 | ~ | ScriptsPage + `/scripts/exec` | 新建 ops 脚本 → 选机执行 → 状态条 #id;历史按 script_id 过滤;长任务/凭据可用 |
|
||||||
| A-04 | **推送调度**生产点验 | P0 | ☐ | SchedulesPage + `schedule_runner` | 新建 cron 推送:target_path 可选、next_run 有值、runNow 留空目标走各机配置;script 调度 runNow 走 `/scripts/exec` |
|
| A-04 | **推送调度**生产点验 | P0 | ~ | SchedulesPage + `schedule_runner` | 新建 cron 推送:target_path 可选、next_run 有值、runNow 留空目标走各机配置;script 调度 runNow 走 `/scripts/exec` |
|
||||||
| A-05 | **边端 Agent** 401 停循环 | P1 | ☐ | `web/agent/agent.py` | 错误 key 心跳 401 后子机 agent 不再无限重试(需各机同步 agent 文件) |
|
| A-05 | **边端 Agent** 401 停循环 | P1 | ☐ | `web/agent/agent.py` | 错误 key 心跳 401 后子机 agent 不再无限重试(需各机同步 agent 文件) |
|
||||||
|
|
||||||
**依赖**:A-01 → A-02 → A-03/A-04 可并行点验。
|
**依赖**:A-01 → A-02 → A-03/A-04 可并行点验。
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
|
|
||||||
| ID | 页面 | 缺失能力 | 优先级 | 状态 | 设计依据 | 验收标准 |
|
| ID | 页面 | 缺失能力 | 优先级 | 状态 | 设计依据 | 验收标准 |
|
||||||
|----|------|----------|--------|------|----------|----------|
|
|----|------|----------|--------|------|----------|----------|
|
||||||
| B-01 | **Commands** `/commands` | **推送日志视图**(第三 Tab:rsync 历史、状态/模式/触发源过滤) | P1 | ☐ | 旧版 `commands.html` 三视图;现 SPA 仅命令+会话 | 第三视图可调 `GET /servers/logs` 或 sync_logs 分页 API;筛选与 Push 历史一致 |
|
| B-01 | **Commands** `/commands` | **推送日志视图**(第三 Tab:rsync 历史、状态/模式/触发源过滤) | P1 | ☑ | 旧版 `commands.html` 三视图;现 SPA 仅命令+会话 | 第三视图可调 `GET /servers/logs` 或 sync_logs 分页 API;筛选与 Push 历史一致 |
|
||||||
| B-02 | **Dashboard** `/` | **历史趋势图**(CPU/内存/磁盘时序,非仅 Redis 实时) | P2 | ☐ | `nexus-full-site-features.md` §23 | 至少 24h 折线图;数据来自 MySQL 历史或现有 heartbeat 落库 |
|
| B-02 | **Dashboard** `/` | **历史趋势图**(CPU/内存/磁盘时序,非仅 Redis 实时) | P2 | ☐ | `nexus-full-site-features.md` §23 | 至少 24h 折线图;数据来自 MySQL 历史或现有 heartbeat 落库 |
|
||||||
| B-03 | **Files** `/files` | **树形侧栏**目录导航 | P3 | ☐ | 归档 §12 文件管理「未实现:树形侧栏」 | 左树右表;与 browse API 联动 |
|
| B-03 | **Files** `/files` | **树形侧栏**目录导航 | P3 | ☐ | 归档 §12 文件管理「未实现:树形侧栏」 | 左树右表;与 browse API 联动 |
|
||||||
| B-04 | **Servers** `/servers` | **CSV 导入模板**静态文件缺失 | P2 | ☐ | UI 链到 `/app/servers_import_template.csv` 但仓库无此文件 | 提供模板 CSV + 文档列说明;导入 E2E 一条 |
|
| B-04 | **Servers** `/servers` | **CSV 导入模板**静态文件缺失 | P2 | ☑ | UI 链到 `/app/servers_import_template.csv` 但仓库无此文件 | 提供模板 CSV + 文档列说明;导入 E2E 一条 |
|
||||||
| B-05 | **Schedules** `/schedules` | **设计文档 §12.8 扩写**(push/script、cron/once 已代码实现) | P2 | ☐ | 功能指南仍只写一行「Cron 调度推送」 | 更新 SSOT 与附录 E.1 字段表;避免下轮误判缺失 |
|
| B-05 | **Schedules** `/schedules` | **设计文档 §12.8 扩写**(push/script、cron/once 已代码实现) | P2 | ☑ | 功能指南仍只写一行「Cron 调度推送」 | 更新 SSOT 与附录 E.1 字段表;避免下轮误判缺失 |
|
||||||
| B-06 | **Settings** `/settings` | **子机 Agent 批量同步入口**(若运维需要一键提示) | P3 | ☐ | 边端 A-05 依赖人工 scp | 文档或设置页展示 agent 升级/同步命令(非必须新 API) |
|
| B-06 | **Settings** `/settings` | **子机 Agent 批量同步入口**(若运维需要一键提示) | P3 | ☐ | 边端 A-05 依赖人工 scp | 文档或设置页展示 agent 升级/同步命令(非必须新 API) |
|
||||||
|
|
||||||
**说明(勿重复做)**:
|
**说明(勿重复做)**:
|
||||||
@@ -166,13 +166,13 @@ Week 2+(P2/P3)
|
|||||||
## 3. 进度总表
|
## 3. 进度总表
|
||||||
|
|
||||||
```
|
```
|
||||||
A 部署验证 [ ] 0/5
|
A 部署验证 [~] 1/5 已部署,2~4 待浏览器/SSH
|
||||||
B 14页缺口 [ ] 0/6
|
B 14页缺口 [~] 3/6(B-01/B-04/B-05)
|
||||||
C API无UI [ ] 0/4
|
C API无UI [ ] 0/4
|
||||||
D 产品backlog [ ] 0/5
|
D 产品backlog [ ] 0/5
|
||||||
E 工程债 [ ] 0/22
|
E 工程债 [~] 2/22(E-08/E-22)
|
||||||
─────────────────────
|
─────────────────────
|
||||||
合计 [ ] 0/31(不含已否决)
|
合计 [~] 6/31 本批完成或部分完成
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 对齐计划 Phase 1 部署验证
|
||||||
|
|
||||||
|
**日期**:2026-06-07
|
||||||
|
**生产版本**:`fb6a4be7`
|
||||||
|
**URL**:https://api.synaglobal.vip
|
||||||
|
|
||||||
|
## A 类 — 部署验证
|
||||||
|
|
||||||
|
| ID | 项 | 结果 | 证据 |
|
||||||
|
|----|-----|------|------|
|
||||||
|
| A-01 | 生产部署 API + 前端 | **PASS** | `deploy/deploy-production.sh` → `/health` ok、`/app/` 200 |
|
||||||
|
| A-02 | `push_schedules.target_path` 迁移 | **待 SSH 复核** | API 重启后 `run_schema_migrations` 自动 DDL;本机无 SSH key |
|
||||||
|
| A-03 | 脚本库生产点验 | **待浏览器** | `ScriptsPage-Bt6WITgz.js` 已随镜像构建 |
|
||||||
|
| A-04 | 调度生产点验 | **待浏览器** | `SchedulesPage-Bn9s0WQt.js` 已随镜像构建 |
|
||||||
|
| A-05 | 边端 Agent 401 | **待子机同步** | `web/agent/agent.py` 已合入镜像;子机需手动更新 |
|
||||||
|
|
||||||
|
## B 类 — 本批实现
|
||||||
|
|
||||||
|
| ID | 项 | 结果 |
|
||||||
|
|----|-----|------|
|
||||||
|
| B-01 | Commands 推送日志视图 | **PASS**(代码+构建)`CommandsPage-q-bsIegj.js` |
|
||||||
|
| B-04 | CSV 导入模板 | **部分** — 静态 URL 生产 404;已补 `Dockerfile.prod` + `frontend/public/` 待再部署 |
|
||||||
|
| B-05 | 功能指南 §12.6/12.8/12.10 | **PASS** |
|
||||||
|
|
||||||
|
## E 类
|
||||||
|
|
||||||
|
| ID | 项 | 结果 |
|
||||||
|
|----|-----|------|
|
||||||
|
| E-08 | acceptance_catalog | **PASS** |
|
||||||
|
| E-22 | 功能指南扩写 | **PASS**(同 B-05) |
|
||||||
|
|
||||||
|
## 命令记录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/deploy-production.sh
|
||||||
|
# fb6a4be7 · Docker rebuild · /health ok · /app/ 200
|
||||||
|
|
||||||
|
curl -sS https://api.synaglobal.vip/health # ok
|
||||||
|
curl -w "%{http_code}" https://api.synaglobal.vip/app/servers_import_template.csv # 404(Dockerfile 未 COPY,已修待再部署)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一批(计划 Week 1 剩余)
|
||||||
|
|
||||||
|
- 再部署含 CSV Dockerfile 修复
|
||||||
|
- A-02~A-05 浏览器/SSH 终验
|
||||||
|
- D-01 Agent upgrade 对齐
|
||||||
|
- B-02 Dashboard 趋势图
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
名称,地址,SSH端口,用户名,认证方式,密码,密钥路径,私钥内容,分类,目标路径,备注
|
||||||
|
web-01,192.168.1.10,22,root,password,your-password,,,商城,/www/wwwroot,Web服务器
|
||||||
|
@@ -74,6 +74,13 @@
|
|||||||
<template #item.started_at="{ item }">
|
<template #item.started_at="{ item }">
|
||||||
<span class="text-caption text-medium-emphasis">{{ formatLogTime(item.started_at) }}</span>
|
<span class="text-caption text-medium-emphasis">{{ formatLogTime(item.started_at) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template #item.push_permission="{ item }">
|
||||||
|
<span
|
||||||
|
class="text-caption"
|
||||||
|
:class="item.push_permission ? '' : 'text-medium-emphasis'"
|
||||||
|
:title="item.push_permission || '本次推送未配置 rsync 属主/权限修正'"
|
||||||
|
>{{ formatPushPermission(item.push_permission) }}</span>
|
||||||
|
</template>
|
||||||
<template #item.error_message="{ item }">
|
<template #item.error_message="{ item }">
|
||||||
<span
|
<span
|
||||||
v-if="item.error_message"
|
v-if="item.error_message"
|
||||||
@@ -105,6 +112,7 @@
|
|||||||
import {
|
import {
|
||||||
formatLogTime,
|
formatLogTime,
|
||||||
formatPushErrorMessage,
|
formatPushErrorMessage,
|
||||||
|
formatPushPermission,
|
||||||
logStatusColor,
|
logStatusColor,
|
||||||
logStatusLabel,
|
logStatusLabel,
|
||||||
syncModeLabel,
|
syncModeLabel,
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ export function logStatusColor(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Display push_permission from sync_logs (rsync chown/chmod policy). */
|
||||||
|
export function formatPushPermission(value: string | null | undefined): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
const parts: string[] = []
|
||||||
|
const chown = value.match(/chown=([^\s]+)/)?.[1]
|
||||||
|
const chmod = value.match(/chmod=([^\s]+)/)?.[1]
|
||||||
|
if (chown) parts.push(chown)
|
||||||
|
if (chmod) parts.push(chmod)
|
||||||
|
return parts.length ? parts.join(' · ') : value
|
||||||
|
}
|
||||||
|
|
||||||
export function syncModeLabel(mode: string | undefined): string {
|
export function syncModeLabel(mode: string | undefined): string {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'incremental': return '增量'
|
case 'incremental': return '增量'
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const LOG_HEADERS = [
|
|||||||
{ title: '模式', key: 'sync_mode', width: 88 },
|
{ title: '模式', key: 'sync_mode', width: 88 },
|
||||||
{ title: '源路径', key: 'source_path' },
|
{ title: '源路径', key: 'source_path' },
|
||||||
{ title: '目标路径', key: 'target_path' },
|
{ title: '目标路径', key: 'target_path' },
|
||||||
|
{ title: '权限', key: 'push_permission', width: 140 },
|
||||||
{ title: '错误', key: 'error_message', width: 200 },
|
{ title: '错误', key: 'error_message', width: 200 },
|
||||||
{ title: '操作', key: 'actions', width: 88, sortable: false },
|
{ title: '操作', key: 'actions', width: 88, sortable: false },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -173,6 +173,13 @@
|
|||||||
{{ item.target_path }}
|
{{ item.target_path }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template #item.push_permission="{ item }">
|
||||||
|
<span
|
||||||
|
class="text-caption"
|
||||||
|
:class="item.push_permission ? '' : 'text-medium-emphasis'"
|
||||||
|
:title="item.push_permission || '未配置 rsync 属主/权限修正'"
|
||||||
|
>{{ formatPushPermission(item.push_permission) }}</span>
|
||||||
|
</template>
|
||||||
<template #item.error_message="{ item }">
|
<template #item.error_message="{ item }">
|
||||||
<span
|
<span
|
||||||
v-if="item.error_message"
|
v-if="item.error_message"
|
||||||
@@ -196,6 +203,7 @@ import { http } from '@/api'
|
|||||||
import { useServerList } from '@/composables/useServerList'
|
import { useServerList } from '@/composables/useServerList'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import { formatApiError } from '@/utils/apiError'
|
import { formatApiError } from '@/utils/apiError'
|
||||||
|
import { formatPushPermission } from '@/composables/push/labels'
|
||||||
import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
|
import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
|
||||||
|
|
||||||
type ViewMode = 'commands' | 'sessions' | 'push'
|
type ViewMode = 'commands' | 'sessions' | 'push'
|
||||||
@@ -267,6 +275,7 @@ const pushHeaders = [
|
|||||||
{ title: '触发', key: 'trigger_type', width: 72 },
|
{ title: '触发', key: 'trigger_type', width: 72 },
|
||||||
{ title: '源路径', key: 'source_path' },
|
{ title: '源路径', key: 'source_path' },
|
||||||
{ title: '目标路径', key: 'target_path' },
|
{ title: '目标路径', key: 'target_path' },
|
||||||
|
{ title: '权限', key: 'push_permission', width: 130 },
|
||||||
{ title: '错误', key: 'error_message', width: 180 },
|
{ title: '错误', key: 'error_message', width: 180 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
执行
|
执行
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn size="small" variant="text" @click="viewHistory(script)">历史</v-btn>
|
<v-btn size="small" variant="text" @click="filterHistoryByScript(script)">历史</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -161,6 +161,123 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ref="historySectionRef">
|
||||||
|
<!-- Recent execution logs (latest 5) -->
|
||||||
|
<v-card elevation="0" border rounded="lg" class="mt-4">
|
||||||
|
<v-card-title class="d-flex align-center text-subtitle-1">
|
||||||
|
<v-icon class="mr-2">mdi-history</v-icon>
|
||||||
|
最近执行
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="text" icon="mdi-refresh" :loading="recentLoading" @click="loadRecentExecutions" />
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider />
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<v-progress-linear v-if="recentLoading" indeterminate color="primary" />
|
||||||
|
<v-list v-if="recentExecutions.length" density="compact" class="py-0">
|
||||||
|
<v-list-item
|
||||||
|
v-for="item in recentExecutions"
|
||||||
|
:key="item.id"
|
||||||
|
:title="`#${item.id} · ${execLabel(item)}`"
|
||||||
|
@click="openExecDetail(item.id)"
|
||||||
|
style="cursor: pointer"
|
||||||
|
>
|
||||||
|
<template #subtitle>
|
||||||
|
<div class="text-caption">
|
||||||
|
{{ execStatusLabel(item.status) }} · {{ parseServerCount(item.server_ids) }} 台
|
||||||
|
· {{ item.operator || '—' }} · {{ formatExecTime(item.started_at) }}
|
||||||
|
</div>
|
||||||
|
<div v-for="(ev, idx) in execEventLines(item)" :key="idx" class="text-caption text-medium-emphasis font-monospace mt-1">
|
||||||
|
{{ ev }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<v-chip :color="execStatusColor(item.status)" size="x-small" variant="tonal" label>
|
||||||
|
{{ execStatusLabel(item.status) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<div v-else-if="!recentLoading" class="text-center text-medium-emphasis py-6">暂无执行记录</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- All execution history -->
|
||||||
|
<v-card elevation="0" border rounded="lg" class="mt-4">
|
||||||
|
<v-card-title class="d-flex flex-wrap align-center ga-2 text-subtitle-1">
|
||||||
|
<v-icon class="mr-1">mdi-format-list-bulleted</v-icon>
|
||||||
|
全部执行历史
|
||||||
|
<v-chip
|
||||||
|
v-if="historyScriptFilter"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
closable
|
||||||
|
label
|
||||||
|
@click:close="clearHistoryFilter"
|
||||||
|
>
|
||||||
|
{{ filteredScriptName }}
|
||||||
|
</v-chip>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="text" icon="mdi-refresh" :loading="allExecLoading" @click="loadAllExecutions" />
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider />
|
||||||
|
<v-data-table-server
|
||||||
|
:items="allExecutions"
|
||||||
|
:headers="allExecHeaders"
|
||||||
|
:items-length="allExecTotal"
|
||||||
|
:loading="allExecLoading"
|
||||||
|
:page="allExecPage"
|
||||||
|
:items-per-page="allExecPerPage"
|
||||||
|
density="compact"
|
||||||
|
hover
|
||||||
|
@update:page="onAllExecPageChange"
|
||||||
|
@update:items-per-page="onAllExecPerPageChange"
|
||||||
|
>
|
||||||
|
<template #item.label="{ item }">
|
||||||
|
<div class="text-body-2">{{ execLabel(item) }}</div>
|
||||||
|
<code class="text-caption text-medium-emphasis">{{ truncateCommand(item.command) }}</code>
|
||||||
|
</template>
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<v-chip :color="execStatusColor(item.status)" size="x-small" variant="tonal" label>
|
||||||
|
{{ execStatusLabel(item.status) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #item.server_count="{ item }">
|
||||||
|
{{ parseServerCount(item.server_ids) }}
|
||||||
|
</template>
|
||||||
|
<template #item.progress="{ item }">
|
||||||
|
{{ formatProgress(item.progress) }}
|
||||||
|
</template>
|
||||||
|
<template #item.started_at="{ item }">
|
||||||
|
{{ formatExecTime(item.started_at) }}
|
||||||
|
</template>
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<v-btn size="x-small" variant="text" @click="openExecDetail(item.id)">详情</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="item.status === 'running' || item.status === 'partial'"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
@click="stopHistoryExec(item)"
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="item.status === 'failed' || item.status === 'partial'"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
@click="retryHistoryExec(item)"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<template #no-data>
|
||||||
|
<div class="text-center text-medium-emphasis py-6">暂无执行记录</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Execution Status Panel -->
|
<!-- Execution Status Panel -->
|
||||||
<v-card v-if="trackedExecs.length > 0" elevation="0" border rounded="lg" class="mt-4">
|
<v-card v-if="trackedExecs.length > 0" elevation="0" border rounded="lg" class="mt-4">
|
||||||
<v-card-title class="d-flex align-center text-subtitle-1">
|
<v-card-title class="d-flex align-center text-subtitle-1">
|
||||||
@@ -320,57 +437,6 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
<!-- History Dialog -->
|
|
||||||
<v-dialog v-model="showHistory" max-width="960" scrollable>
|
|
||||||
<v-card border>
|
|
||||||
<v-card-title class="d-flex align-center">
|
|
||||||
执行历史: {{ historyScript?.name }}
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn size="small" variant="text" icon="mdi-refresh" @click="historyScript && viewHistory(historyScript)" />
|
|
||||||
</v-card-title>
|
|
||||||
<v-divider />
|
|
||||||
<v-card-text>
|
|
||||||
<v-data-table :items="execHistory" :headers="execHeaders" density="compact" hover>
|
|
||||||
<template #item.status="{ item }">
|
|
||||||
<v-chip :color="execStatusColor(item.status)" size="x-small" variant="tonal" label>
|
|
||||||
{{ execStatusLabel(item.status) }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
<template #item.command="{ item }">
|
|
||||||
<code class="text-body-2">{{ truncateCommand(item.command) }}</code>
|
|
||||||
</template>
|
|
||||||
<template #item.progress="{ item }">
|
|
||||||
<span>{{ formatProgress(item.progress) }}</span>
|
|
||||||
</template>
|
|
||||||
<template #item.actions="{ item }">
|
|
||||||
<v-btn size="x-small" variant="text" @click="openExecDetail(item.id)">详情</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="item.status === 'running' || item.status === 'partial'"
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
color="error"
|
|
||||||
@click="stopHistoryExec(item)"
|
|
||||||
>
|
|
||||||
停止
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="item.status === 'failed' || item.status === 'partial'"
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
color="primary"
|
|
||||||
@click="retryHistoryExec(item)"
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
<template #no-data>
|
|
||||||
<div class="text-center text-medium-emphasis py-6">暂无执行记录</div>
|
|
||||||
</template>
|
|
||||||
</v-data-table>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
<!-- Execution Detail Dialog -->
|
<!-- Execution Detail Dialog -->
|
||||||
<v-dialog v-model="showExecDetail" max-width="900" scrollable>
|
<v-dialog v-model="showExecDetail" max-width="900" scrollable>
|
||||||
<v-card border>
|
<v-card border>
|
||||||
@@ -423,7 +489,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import ScriptContentEditor from '@/components/ScriptContentEditor.vue'
|
import ScriptContentEditor from '@/components/ScriptContentEditor.vue'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { useServerList } from '@/composables/useServerList'
|
import { useServerList } from '@/composables/useServerList'
|
||||||
@@ -490,9 +556,15 @@ const executing = ref(false)
|
|||||||
const showDelete = ref(false)
|
const showDelete = ref(false)
|
||||||
const deletingScript = ref<ScriptItem | null>(null)
|
const deletingScript = ref<ScriptItem | null>(null)
|
||||||
|
|
||||||
const showHistory = ref(false)
|
const historySectionRef = ref<HTMLElement | null>(null)
|
||||||
const historyScript = ref<ScriptItem | null>(null)
|
const recentExecutions = ref<ScriptExecItem[]>([])
|
||||||
const execHistory = ref<ScriptExecItem[]>([])
|
const recentLoading = ref(false)
|
||||||
|
const allExecutions = ref<ScriptExecItem[]>([])
|
||||||
|
const allExecTotal = ref(0)
|
||||||
|
const allExecPage = ref(1)
|
||||||
|
const allExecPerPage = ref(20)
|
||||||
|
const allExecLoading = ref(false)
|
||||||
|
const historyScriptFilter = ref<number | null>(null)
|
||||||
|
|
||||||
const showQuickExec = ref(false)
|
const showQuickExec = ref(false)
|
||||||
const quickCommand = ref('')
|
const quickCommand = ref('')
|
||||||
@@ -533,15 +605,34 @@ const pagedScripts = computed(() => {
|
|||||||
|
|
||||||
watch(search, () => { page.value = 1 })
|
watch(search, () => { page.value = 1 })
|
||||||
|
|
||||||
const execHeaders = [
|
const allExecHeaders = [
|
||||||
{ title: '命令', key: 'command' },
|
{ title: 'ID', key: 'id', width: 72 },
|
||||||
|
{ title: '脚本/命令', key: 'label' },
|
||||||
{ title: '状态', key: 'status', width: 100 },
|
{ title: '状态', key: 'status', width: 100 },
|
||||||
{ title: '进度', key: 'progress', width: 88 },
|
{ title: '进度', key: 'progress', width: 88 },
|
||||||
|
{ title: '台数', key: 'server_count', width: 72 },
|
||||||
{ title: '操作人', key: 'operator', width: 88 },
|
{ title: '操作人', key: 'operator', width: 88 },
|
||||||
{ title: '开始时间', key: 'started_at', width: 160 },
|
{ title: '开始时间', key: 'started_at', width: 168 },
|
||||||
{ title: '操作', key: 'actions', width: 180, sortable: false },
|
{ title: '操作', key: 'actions', width: 180, sortable: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const filteredScriptName = computed(() => {
|
||||||
|
if (!historyScriptFilter.value) return ''
|
||||||
|
return scripts.value.find(s => s.id === historyScriptFilter.value)?.name || `脚本 #${historyScriptFilter.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const execEventActionLabels: Record<string, string> = {
|
||||||
|
started: '开始',
|
||||||
|
batch_progress: '批次进度',
|
||||||
|
execution_finished: '结束',
|
||||||
|
stopped: '停止',
|
||||||
|
retry_requested: '重试',
|
||||||
|
stuck_marked: '标记卡住',
|
||||||
|
server_auto_stuck: '子机超时',
|
||||||
|
auto_stuck: '自动卡住',
|
||||||
|
job_completed: '回调完成',
|
||||||
|
}
|
||||||
|
|
||||||
const execDetailRows = computed<ExecDetailRow[]>(() => {
|
const execDetailRows = computed<ExecDetailRow[]>(() => {
|
||||||
if (!execDetail.value?.results) return []
|
if (!execDetail.value?.results) return []
|
||||||
return Object.entries(execDetail.value.results)
|
return Object.entries(execDetail.value.results)
|
||||||
@@ -560,7 +651,96 @@ function categoryLabel(v: string) {
|
|||||||
|
|
||||||
function truncateCommand(cmd: string) {
|
function truncateCommand(cmd: string) {
|
||||||
if (!cmd) return '—'
|
if (!cmd) return '—'
|
||||||
return cmd.length > 60 ? `${cmd.slice(0, 60)}…` : cmd
|
const plain = cmd.startsWith('[long_task] ') ? cmd.slice('[long_task] '.length) : cmd
|
||||||
|
return plain.length > 60 ? `${plain.slice(0, 60)}…` : plain
|
||||||
|
}
|
||||||
|
|
||||||
|
function execLabel(item: ScriptExecItem): string {
|
||||||
|
if (item.script_id) {
|
||||||
|
return scripts.value.find(s => s.id === item.script_id)?.name || `脚本 #${item.script_id}`
|
||||||
|
}
|
||||||
|
return '临时命令'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExecTime(ts: string | null | undefined): string {
|
||||||
|
if (!ts) return '—'
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (Number.isNaN(d.getTime())) return ts.replace('T', ' ').slice(0, 19)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function execEventLines(item: ScriptExecItem): string[] {
|
||||||
|
const events = item.events || []
|
||||||
|
if (!events.length) return []
|
||||||
|
return events.slice(-3).map(ev => {
|
||||||
|
const label = execEventActionLabels[ev.action || ''] || ev.action || '事件'
|
||||||
|
const time = formatExecTime(ev.at)
|
||||||
|
const detail = ev.detail || ''
|
||||||
|
return `${time} · ${label}${detail ? ` — ${detail}` : ''}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentExecutions() {
|
||||||
|
recentLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await http.get<{ items: ScriptExecItem[]; total: number }>('/scripts/executions', { limit: 5 })
|
||||||
|
recentExecutions.value = res.items || []
|
||||||
|
} catch {
|
||||||
|
recentExecutions.value = []
|
||||||
|
} finally {
|
||||||
|
recentLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllExecutions() {
|
||||||
|
allExecLoading.value = true
|
||||||
|
try {
|
||||||
|
const offset = (allExecPage.value - 1) * allExecPerPage.value
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
limit: allExecPerPage.value,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
if (historyScriptFilter.value) params.script_id = historyScriptFilter.value
|
||||||
|
const res = await http.get<{ items: ScriptExecItem[]; total: number }>('/scripts/executions', params)
|
||||||
|
allExecutions.value = res.items || []
|
||||||
|
allExecTotal.value = res.total || 0
|
||||||
|
} catch {
|
||||||
|
allExecutions.value = []
|
||||||
|
allExecTotal.value = 0
|
||||||
|
} finally {
|
||||||
|
allExecLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshExecutionHistory() {
|
||||||
|
loadRecentExecutions()
|
||||||
|
loadAllExecutions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAllExecPageChange(p: number) {
|
||||||
|
allExecPage.value = p
|
||||||
|
loadAllExecutions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAllExecPerPageChange(n: number) {
|
||||||
|
allExecPerPage.value = n
|
||||||
|
allExecPage.value = 1
|
||||||
|
loadAllExecutions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistoryFilter() {
|
||||||
|
historyScriptFilter.value = null
|
||||||
|
allExecPage.value = 1
|
||||||
|
loadAllExecutions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterHistoryByScript(script: ScriptItem) {
|
||||||
|
historyScriptFilter.value = script.id
|
||||||
|
allExecPage.value = 1
|
||||||
|
await loadAllExecutions()
|
||||||
|
await nextTick()
|
||||||
|
historySectionRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatProgress(p: string | undefined) {
|
function formatProgress(p: string | undefined) {
|
||||||
@@ -691,6 +871,7 @@ async function doRun() {
|
|||||||
)
|
)
|
||||||
showRun.value = false
|
showRun.value = false
|
||||||
snackbar('脚本已提交执行')
|
snackbar('脚本已提交执行')
|
||||||
|
refreshExecutionHistory()
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
snackbar(formatApiError(e, '执行失败'), 'error')
|
snackbar(formatApiError(e, '执行失败'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -715,20 +896,6 @@ async function doDelete() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewHistory(script: ScriptItem) {
|
|
||||||
historyScript.value = script
|
|
||||||
showHistory.value = true
|
|
||||||
try {
|
|
||||||
const res = await http.get<{ items: ScriptExecItem[]; total: number }>('/scripts/executions', {
|
|
||||||
script_id: script.id,
|
|
||||||
limit: 50,
|
|
||||||
})
|
|
||||||
execHistory.value = res.items || []
|
|
||||||
} catch {
|
|
||||||
execHistory.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doQuickExec() {
|
async function doQuickExec() {
|
||||||
if (!quickCommand.value.trim() || quickServerIds.value.length === 0) return
|
if (!quickCommand.value.trim() || quickServerIds.value.length === 0) return
|
||||||
quickExecuting.value = true
|
quickExecuting.value = true
|
||||||
@@ -745,6 +912,7 @@ async function doQuickExec() {
|
|||||||
quickServerIds.value.length,
|
quickServerIds.value.length,
|
||||||
)
|
)
|
||||||
snackbar(`已在 ${quickServerIds.value.length} 台服务器上执行`)
|
snackbar(`已在 ${quickServerIds.value.length} 台服务器上执行`)
|
||||||
|
refreshExecutionHistory()
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
snackbar(formatApiError(e, '执行失败'), 'error')
|
snackbar(formatApiError(e, '执行失败'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -799,6 +967,9 @@ async function refreshExecStatus(exec: TrackedExec) {
|
|||||||
exec.status = res.status
|
exec.status = res.status
|
||||||
exec.progress = formatProgress(res.progress)
|
exec.progress = formatProgress(res.progress)
|
||||||
exec.longTask = Boolean(res.long_task)
|
exec.longTask = Boolean(res.long_task)
|
||||||
|
if (res.status !== 'running' && res.status !== 'partial') {
|
||||||
|
refreshExecutionHistory()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* keep last known */
|
/* keep last known */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -842,7 +1013,7 @@ async function retryExec(exec: TrackedExec) {
|
|||||||
async function stopHistoryExec(item: ScriptExecItem) {
|
async function stopHistoryExec(item: ScriptExecItem) {
|
||||||
try {
|
try {
|
||||||
await http.post(`/scripts/executions/${item.id}/stop`)
|
await http.post(`/scripts/executions/${item.id}/stop`)
|
||||||
if (historyScript.value) await viewHistory(historyScript.value)
|
refreshExecutionHistory()
|
||||||
snackbar('已发送停止指令')
|
snackbar('已发送停止指令')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
snackbar(formatApiError(e, '停止失败'), 'error')
|
snackbar(formatApiError(e, '停止失败'), 'error')
|
||||||
@@ -852,8 +1023,8 @@ async function stopHistoryExec(item: ScriptExecItem) {
|
|||||||
async function retryHistoryExec(item: ScriptExecItem) {
|
async function retryHistoryExec(item: ScriptExecItem) {
|
||||||
try {
|
try {
|
||||||
const res = await http.post<ScriptExecItem>(`/scripts/executions/${item.id}/retry`, {})
|
const res = await http.post<ScriptExecItem>(`/scripts/executions/${item.id}/retry`, {})
|
||||||
addTrackedExecution(`${historyScript.value?.name || '脚本'}(重试)`, executionId(res), parseServerCount(item.server_ids) || parseServerCount(res.server_ids), Boolean(item.long_task), res.progress, res.status)
|
addTrackedExecution(`${execLabel(item)}(重试)`, executionId(res), parseServerCount(item.server_ids) || parseServerCount(res.server_ids), Boolean(item.long_task), res.progress, res.status)
|
||||||
if (historyScript.value) await viewHistory(historyScript.value)
|
refreshExecutionHistory()
|
||||||
snackbar('重试已提交')
|
snackbar('重试已提交')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
snackbar(formatApiError(e, '重试失败'), 'error')
|
snackbar(formatApiError(e, '重试失败'), 'error')
|
||||||
@@ -907,6 +1078,7 @@ onMounted(() => {
|
|||||||
loadServers()
|
loadServers()
|
||||||
loadExecConfig()
|
loadExecConfig()
|
||||||
loadCredentials()
|
loadCredentials()
|
||||||
|
refreshExecutionHistory()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-download" size="small" @click="exportCSV">
|
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-download" size="small" @click="exportCSV">
|
||||||
导出CSV
|
导出CSV
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-file-import" size="small" @click="showImport = true">
|
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
|
||||||
导入
|
批量添加
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@@ -243,11 +243,21 @@
|
|||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
<v-tabs-window-item value="logs">
|
<v-tabs-window-item value="logs">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div v-if="syncLogs.length === 0" class="text-medium-emphasis text-center py-4">暂无同步日志</div>
|
<div v-if="syncLogsLoading" class="text-center py-4">
|
||||||
|
<v-progress-circular indeterminate size="24" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="syncLogs.length === 0" class="text-medium-emphasis text-center py-4">暂无同步日志</div>
|
||||||
<v-list v-else density="compact">
|
<v-list v-else density="compact">
|
||||||
<v-list-item v-for="log in syncLogs" :key="log.id" :subtitle="log.created_at" :title="`${log.source_path} → ${log.target_path}`">
|
<v-list-item
|
||||||
|
v-for="log in syncLogs"
|
||||||
|
:key="log.id"
|
||||||
|
:subtitle="formatSyncLogTime(log)"
|
||||||
|
:title="`${log.source_path} → ${log.target_path}`"
|
||||||
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-chip size="x-small" :color="log.status === 'success' ? 'green' : 'red'" variant="tonal" label>{{ log.status }}</v-chip>
|
<v-chip size="x-small" :color="syncLogStatusColor(log.status)" variant="tonal" label>
|
||||||
|
{{ syncLogStatusLabel(log.status) }}
|
||||||
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -392,25 +402,54 @@
|
|||||||
:results="batchResultItems"
|
:results="batchResultItems"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Import CSV Dialog -->
|
<!-- Batch add by IP dialog -->
|
||||||
<v-dialog v-model="showImport" max-width="500">
|
<v-dialog v-model="showBatchAdd" max-width="560">
|
||||||
<v-card border>
|
<v-card border>
|
||||||
<v-card-title>导入服务器</v-card-title>
|
<v-card-title>批量添加服务器</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||||
上传 CSV 文件批量导入服务器。列:名称、地址(必填),SSH端口、用户名、认证方式、密码、目标路径等可选。
|
每行一个 IP 或域名,空白行自动忽略,重复地址自动去重(不区分大小写)。SSH 凭据从「凭据管理」预设轮询匹配;连接失败将进入下方「连接失败列表」。
|
||||||
<a href="/app/servers_import_template.csv" download class="text-primary">下载模板</a>
|
|
||||||
<span class="text-caption text-medium-emphasis">(亦可登录后访问 API <code>/api/servers/import/template</code>)</span>
|
|
||||||
</div>
|
</div>
|
||||||
<v-file-input v-model="importFile" label="选择 CSV 文件" variant="outlined" density="compact" accept=".csv" show-size />
|
<v-textarea
|
||||||
<v-alert v-if="importResult" :type="importResult.failed > 0 ? 'warning' : 'success'" density="compact" variant="tonal" class="mt-3">
|
v-model="batchAddText"
|
||||||
成功 {{ importResult.created }},跳过 {{ importResult.skipped }},失败 {{ importResult.failed }}
|
label="IP / 域名列表"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
rows="10"
|
||||||
|
auto-grow
|
||||||
|
placeholder="192.168.1.10 192.168.1.11 10.0.0.5"
|
||||||
|
/>
|
||||||
|
<div v-if="batchAddPreview.unique > 0" class="text-caption text-medium-emphasis mt-1">
|
||||||
|
将添加 {{ batchAddPreview.unique }} 台
|
||||||
|
<span v-if="batchAddPreview.deduped > 0">(已去重 {{ batchAddPreview.deduped }} 个重复)</span>
|
||||||
|
</div>
|
||||||
|
<v-row dense class="mt-1">
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field v-model.number="batchAddPort" label="SSH 端口" type="number" variant="outlined" density="compact" hide-details />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field v-model.number="batchAddAgentPort" label="Agent 端口" type="number" variant="outlined" density="compact" hide-details />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-alert
|
||||||
|
v-if="batchAddResult"
|
||||||
|
:type="batchAddResult.pending > 0 ? 'warning' : 'success'"
|
||||||
|
density="compact"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
共 {{ batchAddResult.total }} 台:成功 {{ batchAddResult.created }},待连接 {{ batchAddResult.pending }},跳过 {{ batchAddResult.skipped }}
|
||||||
|
<span v-if="(batchAddResult.duplicates_removed ?? 0) > 0">
|
||||||
|
(输入 {{ batchAddResult.input_lines }} 行,去重 {{ batchAddResult.duplicates_removed }} 个)
|
||||||
|
</span>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" @click="showImport = false">关闭</v-btn>
|
<v-btn variant="text" @click="showBatchAdd = false">关闭</v-btn>
|
||||||
<v-btn color="primary" variant="flat" @click="doImport" :loading="importing" :disabled="!importFile?.length">开始导入</v-btn>
|
<v-btn color="primary" variant="flat" @click="submitBatchAdd" :loading="batchAddLoading" :disabled="!batchAddText.trim()">
|
||||||
|
开始添加
|
||||||
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -423,8 +462,9 @@ import { useRouter } from 'vue-router'
|
|||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import { useServerPagination } from '@/composables/useServerPagination'
|
import { useServerPagination } from '@/composables/useServerPagination'
|
||||||
|
import { formatPushPermission } from '@/composables/push/labels'
|
||||||
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
||||||
import type { AddByIpResponse, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
import type { AddByIpResponse, AddByIpsBatchResult, PendingServerItem, PollErrorItem, PushItem, ServerApiItem } from '@/types/api'
|
||||||
import { normalizeServerIds, type BatchAgentResultItem } from '@/utils/serverSelection'
|
import { normalizeServerIds, type BatchAgentResultItem } from '@/utils/serverSelection'
|
||||||
import { formatApiError } from '@/utils/apiError'
|
import { formatApiError } from '@/utils/apiError'
|
||||||
import StatCardsRow from '@/components/StatCardsRow.vue'
|
import StatCardsRow from '@/components/StatCardsRow.vue'
|
||||||
@@ -606,7 +646,45 @@ const selectedIds = computed(() => new Set(normalizeServerIds(selectedItems.valu
|
|||||||
// ── Detail panel ──
|
// ── Detail panel ──
|
||||||
const detailServer = ref<ServerApiItem | null>(null)
|
const detailServer = ref<ServerApiItem | null>(null)
|
||||||
const detailTab = ref('info')
|
const detailTab = ref('info')
|
||||||
const syncLogs = ref<{ id: number; source_path: string; target_path: string; status: string; created_at: string }[]>([])
|
const syncLogs = ref<PushItem[]>([])
|
||||||
|
const syncLogsLoading = ref(false)
|
||||||
|
|
||||||
|
function syncLogStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return 'success'
|
||||||
|
case 'failed': return 'error'
|
||||||
|
case 'running': return 'warning'
|
||||||
|
case 'cancelled': return 'grey'
|
||||||
|
default: return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLogStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return '成功'
|
||||||
|
case 'failed': return '失败'
|
||||||
|
case 'running': return '进行中'
|
||||||
|
case 'cancelled': return '已取消'
|
||||||
|
default: return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSyncLogTime(log: PushItem): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (log.started_at) parts.push(log.started_at)
|
||||||
|
if (log.sync_mode) parts.push(log.sync_mode)
|
||||||
|
if (log.trigger_type) parts.push(log.trigger_type)
|
||||||
|
if (log.push_permission) parts.push(`权限 ${formatPushPermission(log.push_permission)}`)
|
||||||
|
return parts.join(' · ') || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServerSyncLogs(res: unknown): PushItem[] {
|
||||||
|
if (Array.isArray(res)) return res as PushItem[]
|
||||||
|
if (res && typeof res === 'object' && Array.isArray((res as { items?: unknown }).items)) {
|
||||||
|
return (res as { items: PushItem[] }).items
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
// ── Table config ──
|
// ── Table config ──
|
||||||
const headers = [
|
const headers = [
|
||||||
@@ -826,28 +904,70 @@ async function exportCSV() {
|
|||||||
} catch (e: any) { snackbar(e.message || '导出失败', 'error') }
|
} catch (e: any) { snackbar(e.message || '导出失败', 'error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CSV import ──
|
// ── Batch add by IP ──
|
||||||
const showImport = ref(false)
|
const showBatchAdd = ref(false)
|
||||||
const importFile = ref<File[]>([])
|
const batchAddText = ref('')
|
||||||
const importing = ref(false)
|
const batchAddPort = ref(22)
|
||||||
const importResult = ref<{ created: number; skipped: number; failed: number } | null>(null)
|
const batchAddAgentPort = ref(8601)
|
||||||
|
const batchAddLoading = ref(false)
|
||||||
|
const batchAddResult = ref<AddByIpsBatchResult | null>(null)
|
||||||
|
|
||||||
async function doImport() {
|
function parseBatchIpPreview(text: string) {
|
||||||
if (!importFile.value?.length) return
|
const seen = new Set<string>()
|
||||||
importing.value = true
|
const unique: string[] = []
|
||||||
importResult.value = null
|
let raw = 0
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const domain = line.trim()
|
||||||
|
if (!domain) continue
|
||||||
|
raw += 1
|
||||||
|
const key = domain.toLowerCase()
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.push(domain)
|
||||||
|
}
|
||||||
|
return { unique: unique.length, raw, deduped: Math.max(0, raw - unique.length) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchAddPreview = computed(() => parseBatchIpPreview(batchAddText.value))
|
||||||
|
|
||||||
|
function openBatchAdd() {
|
||||||
|
batchAddText.value = ''
|
||||||
|
batchAddPort.value = 22
|
||||||
|
batchAddAgentPort.value = 8601
|
||||||
|
batchAddResult.value = null
|
||||||
|
showBatchAdd.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBatchAdd() {
|
||||||
|
const text = batchAddText.value.trim()
|
||||||
|
if (!text) {
|
||||||
|
snackbar('请填写至少一个 IP 或域名', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchAddLoading.value = true
|
||||||
|
batchAddResult.value = null
|
||||||
try {
|
try {
|
||||||
const form = new FormData()
|
const res = await http.post<AddByIpsBatchResult>('/servers/add-by-ips-batch', {
|
||||||
form.append('file', importFile.value[0])
|
text,
|
||||||
const res = await http.upload<{ created: number; skipped: number; failed: number }>('/servers/import', form)
|
port: batchAddPort.value || 22,
|
||||||
importResult.value = res
|
agent_port: batchAddAgentPort.value || 8601,
|
||||||
snackbar('导入完成')
|
})
|
||||||
loadServers()
|
batchAddResult.value = res
|
||||||
|
if (res.created > 0) {
|
||||||
|
loadServers()
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
if (res.pending > 0) {
|
||||||
|
loadPendingServers()
|
||||||
|
}
|
||||||
|
const msg = res.pending > 0
|
||||||
|
? `添加完成:${res.created} 台成功,${res.pending} 台进入连接失败列表`
|
||||||
|
: `添加完成:${res.created} 台成功`
|
||||||
|
snackbar(msg, res.pending > 0 ? 'warning' : 'success')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : '导入失败'
|
snackbar(formatApiError(e, '批量添加失败'), 'error')
|
||||||
snackbar(msg, 'error')
|
|
||||||
} finally {
|
} finally {
|
||||||
importing.value = false
|
batchAddLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,10 +975,16 @@ async function doImport() {
|
|||||||
async function showDetail(item: ServerApiItem) {
|
async function showDetail(item: ServerApiItem) {
|
||||||
detailServer.value = item
|
detailServer.value = item
|
||||||
detailTab.value = 'info'
|
detailTab.value = 'info'
|
||||||
|
syncLogsLoading.value = true
|
||||||
|
syncLogs.value = []
|
||||||
try {
|
try {
|
||||||
const res = await http.get<{ items: typeof syncLogs.value }>(`/servers/${item.id}/logs`, { limit: 20 })
|
const res = await http.get<PushItem[] | { items: PushItem[] }>(`/servers/${item.id}/logs`, { limit: 20 })
|
||||||
syncLogs.value = res.items || []
|
syncLogs.value = parseServerSyncLogs(res)
|
||||||
} catch { syncLogs.value = [] }
|
} catch {
|
||||||
|
syncLogs.value = []
|
||||||
|
} finally {
|
||||||
|
syncLogsLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function editServer(item: ServerApiItem) {
|
function editServer(item: ServerApiItem) {
|
||||||
|
|||||||
@@ -212,6 +212,29 @@ export interface AddByIpResponse {
|
|||||||
matched_username?: string
|
matched_username?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Item from POST /api/servers/add-by-ips-batch */
|
||||||
|
export interface AddByIpsBatchItemResult {
|
||||||
|
domain: string
|
||||||
|
status: 'created' | 'pending' | 'skipped'
|
||||||
|
server_id?: number
|
||||||
|
pending_id?: number
|
||||||
|
message?: string
|
||||||
|
matched_preset?: string
|
||||||
|
matched_username?: string
|
||||||
|
errors?: PollErrorItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from POST /api/servers/add-by-ips-batch */
|
||||||
|
export interface AddByIpsBatchResult {
|
||||||
|
total: number
|
||||||
|
created: number
|
||||||
|
pending: number
|
||||||
|
skipped: number
|
||||||
|
input_lines?: number
|
||||||
|
duplicates_removed?: number
|
||||||
|
items: AddByIpsBatchItemResult[]
|
||||||
|
}
|
||||||
|
|
||||||
/** Alert history item from /api/alert-history/ */
|
/** Alert history item from /api/alert-history/ */
|
||||||
export interface AlertLogItem {
|
export interface AlertLogItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -290,6 +313,8 @@ export interface PushItem {
|
|||||||
operator?: string | null
|
operator?: string | null
|
||||||
duration_seconds?: number
|
duration_seconds?: number
|
||||||
error_message?: string | null
|
error_message?: string | null
|
||||||
|
/** rsync --chown/--chmod snapshot, e.g. "chown=www:www chmod=D755,F755" */
|
||||||
|
push_permission?: string | null
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
finished_at?: string | null
|
finished_at?: string | null
|
||||||
}
|
}
|
||||||
@@ -359,7 +384,7 @@ export interface ScriptExecItem {
|
|||||||
message?: string
|
message?: string
|
||||||
exit_code?: number
|
exit_code?: number
|
||||||
}>
|
}>
|
||||||
events: Array<{ event: string; ts: string }>
|
events: Array<{ at?: string; action?: string; detail?: string; operator?: string }>
|
||||||
operator: string | null
|
operator: string | null
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
|
|||||||
@@ -652,6 +652,50 @@ class AddByIpRequest(BaseModel):
|
|||||||
return coerce_optional_remote_abs_path(v if v is None else str(v))
|
return coerce_optional_remote_abs_path(v if v is None else str(v))
|
||||||
|
|
||||||
|
|
||||||
|
class AddByIpsBatchRequest(BaseModel):
|
||||||
|
"""批量按 IP 添加:每行一个地址,凭据预设轮询 SSH,失败入 pending。"""
|
||||||
|
text: str = Field(..., min_length=1, max_length=50_000, description="每行一个 IP 或域名")
|
||||||
|
port: int = Field(22, ge=1, le=65535)
|
||||||
|
agent_port: int = Field(8601, ge=1, le=65535)
|
||||||
|
target_path: Optional[str] = Field("", description="推送目标路径,可后续编辑")
|
||||||
|
category: Optional[str] = None
|
||||||
|
platform_id: Optional[int] = None
|
||||||
|
node_id: Optional[int] = None
|
||||||
|
|
||||||
|
@field_validator("target_path", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_target_path_batch(cls, v: object) -> object:
|
||||||
|
return coerce_optional_remote_abs_path(v if v is None else str(v))
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialPollErrorOut(BaseModel):
|
||||||
|
preset_type: str
|
||||||
|
preset_name: str
|
||||||
|
username: str
|
||||||
|
error: str
|
||||||
|
|
||||||
|
|
||||||
|
class AddByIpsBatchItemResult(BaseModel):
|
||||||
|
domain: str
|
||||||
|
status: str = Field(..., description="created | pending | skipped")
|
||||||
|
server_id: Optional[int] = None
|
||||||
|
pending_id: Optional[int] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
matched_preset: Optional[str] = None
|
||||||
|
matched_username: Optional[str] = None
|
||||||
|
errors: Optional[List[CredentialPollErrorOut]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddByIpsBatchResult(BaseModel):
|
||||||
|
total: int = 0
|
||||||
|
created: int = 0
|
||||||
|
pending: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
input_lines: int = Field(0, description="非空输入行数(去重前)")
|
||||||
|
duplicates_removed: int = Field(0, description="输入中重复行已忽略的数量")
|
||||||
|
items: List[AddByIpsBatchItemResult] = []
|
||||||
|
|
||||||
|
|
||||||
# ── Platform / Node ──
|
# ── Platform / Node ──
|
||||||
|
|
||||||
class PlatformCreate(BaseModel):
|
class PlatformCreate(BaseModel):
|
||||||
|
|||||||
+174
-35
@@ -18,7 +18,7 @@ from server.api.auth_jwt import get_current_admin
|
|||||||
from server.api.schemas import (
|
from server.api.schemas import (
|
||||||
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
|
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
|
||||||
ServerImportResult, BatchAgentAction, BatchAgentResult, BatchAgentResultItem,
|
ServerImportResult, BatchAgentAction, BatchAgentResult, BatchAgentResultItem,
|
||||||
AddByIpRequest,
|
AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult,
|
||||||
)
|
)
|
||||||
from server.application.services.server_service import ServerService
|
from server.application.services.server_service import ServerService
|
||||||
from server.application.services.sync_service import SyncService
|
from server.application.services.sync_service import SyncService
|
||||||
@@ -329,6 +329,7 @@ async def get_all_sync_logs(
|
|||||||
|
|
||||||
MAX_IMPORT_FILE_SIZE = 1_048_576 # 1 MB
|
MAX_IMPORT_FILE_SIZE = 1_048_576 # 1 MB
|
||||||
MAX_IMPORT_ROWS = 500
|
MAX_IMPORT_ROWS = 500
|
||||||
|
MAX_BATCH_IP_LINES = 200
|
||||||
|
|
||||||
# CSV column mapping: CSV header (Chinese) → Server model field
|
# CSV column mapping: CSV header (Chinese) → Server model field
|
||||||
CSV_COLUMNS = [
|
CSV_COLUMNS = [
|
||||||
@@ -1017,27 +1018,38 @@ async def list_pending_servers(
|
|||||||
return {"items": items, "total": len(items)}
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/add-by-ip", response_model=dict)
|
async def _try_add_by_ip(
|
||||||
async def add_server_by_ip(
|
|
||||||
request: Request,
|
request: Request,
|
||||||
payload: AddByIpRequest,
|
*,
|
||||||
admin: Admin = Depends(get_current_admin),
|
domain: str,
|
||||||
service: ServerService = Depends(get_server_service),
|
port: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
agent_port: int,
|
||||||
):
|
target_path: Optional[str],
|
||||||
"""Add server by IP only — poll all credential presets until SSH login succeeds."""
|
category: Optional[str],
|
||||||
|
platform_id: Optional[int],
|
||||||
|
node_id: Optional[int],
|
||||||
|
name: Optional[str],
|
||||||
|
admin: Admin,
|
||||||
|
service: ServerService,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> AddByIpsBatchItemResult:
|
||||||
|
"""Poll credential presets for one host; create server or enqueue pending."""
|
||||||
from server.application.services.credential_poller import (
|
from server.application.services.credential_poller import (
|
||||||
format_poll_errors,
|
format_poll_errors,
|
||||||
poll_credentials,
|
poll_credentials,
|
||||||
)
|
)
|
||||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||||
|
|
||||||
domain = payload.domain.strip()
|
domain = domain.strip()
|
||||||
if await _server_exists_for_domain(db, domain):
|
if await _server_exists_for_domain(db, domain):
|
||||||
raise HTTPException(409, f"服务器 {domain} 已存在于列表中")
|
return AddByIpsBatchItemResult(
|
||||||
|
domain=domain,
|
||||||
|
status="skipped",
|
||||||
|
message="已存在于服务器列表",
|
||||||
|
)
|
||||||
|
|
||||||
display_name = (payload.name or domain).strip()
|
display_name = (name or domain).strip()
|
||||||
poll = await poll_credentials(db, domain, payload.port)
|
poll = await poll_credentials(db, domain, port)
|
||||||
|
|
||||||
if poll.success and poll.match:
|
if poll.success and poll.match:
|
||||||
created = await _create_server_from_poll(
|
created = await _create_server_from_poll(
|
||||||
@@ -1046,33 +1058,34 @@ async def add_server_by_ip(
|
|||||||
poll_match=poll.match,
|
poll_match=poll.match,
|
||||||
name=display_name,
|
name=display_name,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
port=payload.port,
|
port=port,
|
||||||
agent_port=payload.agent_port,
|
agent_port=agent_port,
|
||||||
target_path=payload.target_path,
|
target_path=target_path,
|
||||||
category=payload.category,
|
category=category,
|
||||||
platform_id=payload.platform_id,
|
platform_id=platform_id,
|
||||||
node_id=payload.node_id,
|
node_id=node_id,
|
||||||
admin=admin,
|
admin=admin,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
return {
|
return AddByIpsBatchItemResult(
|
||||||
"success": True,
|
domain=domain,
|
||||||
"server": _server_to_dict(created),
|
status="created",
|
||||||
"matched_preset": poll.match.preset_name,
|
server_id=created.id,
|
||||||
"matched_username": poll.match.username,
|
matched_preset=poll.match.preset_name,
|
||||||
}
|
matched_username=poll.match.username,
|
||||||
|
)
|
||||||
|
|
||||||
err_text = format_poll_errors(poll.errors)
|
err_text = format_poll_errors(poll.errors)
|
||||||
pending_repo = PendingServerRepositoryImpl(db)
|
pending_repo = PendingServerRepositoryImpl(db)
|
||||||
pending = await pending_repo.record_failure(
|
pending = await pending_repo.record_failure(
|
||||||
name=display_name,
|
name=display_name,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
port=payload.port,
|
port=port,
|
||||||
agent_port=payload.agent_port,
|
agent_port=agent_port,
|
||||||
target_path=payload.target_path,
|
target_path=target_path,
|
||||||
category=payload.category,
|
category=category,
|
||||||
platform_id=payload.platform_id,
|
platform_id=platform_id,
|
||||||
node_id=payload.node_id,
|
node_id=node_id,
|
||||||
last_error=err_text,
|
last_error=err_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1083,18 +1096,143 @@ async def add_server_by_ip(
|
|||||||
action="add_server_by_ip_failed",
|
action="add_server_by_ip_failed",
|
||||||
target_type="pending_server",
|
target_type="pending_server",
|
||||||
target_id=pending.id,
|
target_id=pending.id,
|
||||||
detail=f"凭据轮询失败 地址={domain}:{payload.port}",
|
detail=f"凭据轮询失败 地址={domain}:{port}",
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
from server.api.schemas import CredentialPollErrorOut
|
||||||
|
|
||||||
|
return AddByIpsBatchItemResult(
|
||||||
|
domain=domain,
|
||||||
|
status="pending",
|
||||||
|
pending_id=pending.id,
|
||||||
|
message=err_text,
|
||||||
|
errors=[
|
||||||
|
CredentialPollErrorOut(
|
||||||
|
preset_type=e.preset_type,
|
||||||
|
preset_name=e.preset_name,
|
||||||
|
username=e.username,
|
||||||
|
error=e.error,
|
||||||
|
)
|
||||||
|
for e in poll.errors
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add-by-ip", response_model=dict)
|
||||||
|
async def add_server_by_ip(
|
||||||
|
request: Request,
|
||||||
|
payload: AddByIpRequest,
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
service: ServerService = Depends(get_server_service),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Add server by IP only — poll all credential presets until SSH login succeeds."""
|
||||||
|
item = await _try_add_by_ip(
|
||||||
|
request,
|
||||||
|
domain=payload.domain,
|
||||||
|
port=payload.port,
|
||||||
|
agent_port=payload.agent_port,
|
||||||
|
target_path=payload.target_path,
|
||||||
|
category=payload.category,
|
||||||
|
platform_id=payload.platform_id,
|
||||||
|
node_id=payload.node_id,
|
||||||
|
name=payload.name,
|
||||||
|
admin=admin,
|
||||||
|
service=service,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
if item.status == "skipped":
|
||||||
|
raise HTTPException(409, f"服务器 {item.domain} 已存在于列表中")
|
||||||
|
|
||||||
|
if item.status == "created":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"server": {"id": item.server_id, "domain": item.domain},
|
||||||
|
"matched_preset": item.matched_preset,
|
||||||
|
"matched_username": item.matched_username,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"pending_id": pending.id,
|
"pending_id": item.pending_id,
|
||||||
"errors": [{"preset_type": e.preset_type, "preset_name": e.preset_name, "username": e.username, "error": e.error} for e in poll.errors],
|
"errors": [e.model_dump() for e in (item.errors or [])],
|
||||||
"message": err_text,
|
"message": item.message,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add-by-ips-batch", response_model=AddByIpsBatchResult)
|
||||||
|
async def add_servers_by_ips_batch(
|
||||||
|
request: Request,
|
||||||
|
payload: AddByIpsBatchRequest,
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
service: ServerService = Depends(get_server_service),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Batch add servers: one IP/domain per line, credential preset polling, failures → pending."""
|
||||||
|
from server.application.services.credential_poller import parse_batch_ip_lines
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = parse_batch_ip_lines(payload.text, max_lines=MAX_BATCH_IP_LINES)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
|
||||||
|
domains = parsed.domains
|
||||||
|
if not domains:
|
||||||
|
raise HTTPException(400, "未解析到有效 IP 或域名")
|
||||||
|
|
||||||
|
items: list[AddByIpsBatchItemResult] = []
|
||||||
|
created = pending = skipped = 0
|
||||||
|
|
||||||
|
for domain in domains:
|
||||||
|
item = await _try_add_by_ip(
|
||||||
|
request,
|
||||||
|
domain=domain,
|
||||||
|
port=payload.port,
|
||||||
|
agent_port=payload.agent_port,
|
||||||
|
target_path=payload.target_path,
|
||||||
|
category=payload.category,
|
||||||
|
platform_id=payload.platform_id,
|
||||||
|
node_id=payload.node_id,
|
||||||
|
name=None,
|
||||||
|
admin=admin,
|
||||||
|
service=service,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
items.append(item)
|
||||||
|
if item.status == "created":
|
||||||
|
created += 1
|
||||||
|
elif item.status == "pending":
|
||||||
|
pending += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
ip_address = request.client.host if request.client else ""
|
||||||
|
audit_repo = AuditLogRepositoryImpl(db)
|
||||||
|
await audit_repo.create(AuditLog(
|
||||||
|
admin_username=admin.username,
|
||||||
|
action="add_servers_by_ips_batch",
|
||||||
|
target_type="server",
|
||||||
|
target_id=None,
|
||||||
|
detail=(
|
||||||
|
f"批量 IP 添加 输入{parsed.input_lines}行 去重{parsed.duplicates_removed} "
|
||||||
|
f"实处理{len(domains)} 成功{created} 待连接{pending} 跳过{skipped}"
|
||||||
|
),
|
||||||
|
ip_address=ip_address,
|
||||||
|
))
|
||||||
|
|
||||||
|
return AddByIpsBatchResult(
|
||||||
|
total=len(domains),
|
||||||
|
created=created,
|
||||||
|
pending=pending,
|
||||||
|
skipped=skipped,
|
||||||
|
input_lines=parsed.input_lines,
|
||||||
|
duplicates_removed=parsed.duplicates_removed,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{pending_id}/retry", response_model=dict)
|
@router.post("/pending/{pending_id}/retry", response_model=dict)
|
||||||
async def retry_pending_server(
|
async def retry_pending_server(
|
||||||
pending_id: int,
|
pending_id: int,
|
||||||
@@ -2062,6 +2200,7 @@ def _sync_log_to_dict(log, server_name: str = None) -> dict:
|
|||||||
"duration_seconds": log.duration_seconds,
|
"duration_seconds": log.duration_seconds,
|
||||||
"error_message": log.error_message,
|
"error_message": log.error_message,
|
||||||
"diff_summary": log.diff_summary,
|
"diff_summary": log.diff_summary,
|
||||||
|
"push_permission": log.push_permission,
|
||||||
"started_at": str(log.started_at) if log.started_at else None,
|
"started_at": str(log.started_at) if log.started_at else None,
|
||||||
"finished_at": str(log.finished_at) if log.finished_at else None,
|
"finished_at": str(log.finished_at) if log.finished_at else None,
|
||||||
}
|
}
|
||||||
@@ -137,3 +137,35 @@ async def poll_credentials(
|
|||||||
errors.append(CredentialAttemptError("ssh_key", preset.name, username, msg or "认证失败"))
|
errors.append(CredentialAttemptError("ssh_key", preset.name, username, msg or "认证失败"))
|
||||||
|
|
||||||
return PollResult(success=False, errors=errors)
|
return PollResult(success=False, errors=errors)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BatchIpParseResult:
|
||||||
|
"""Parsed batch IP input after blank-skip and dedupe."""
|
||||||
|
domains: list[str]
|
||||||
|
input_lines: int = 0
|
||||||
|
duplicates_removed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_batch_ip_lines(text: str, *, max_lines: int = 200) -> BatchIpParseResult:
|
||||||
|
"""Parse multi-line IP/domain input: one per line, ignore blanks, dedupe in order (case-insensitive)."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
input_lines = 0
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
domain = raw_line.strip()
|
||||||
|
if not domain:
|
||||||
|
continue
|
||||||
|
input_lines += 1
|
||||||
|
key = domain.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(domain)
|
||||||
|
if len(result) > max_lines:
|
||||||
|
raise ValueError(f"单次最多添加 {max_lines} 个地址")
|
||||||
|
return BatchIpParseResult(
|
||||||
|
domains=result,
|
||||||
|
input_lines=input_lines,
|
||||||
|
duplicates_removed=max(0, input_lines - len(result)),
|
||||||
|
)
|
||||||
|
|||||||
@@ -534,8 +534,8 @@ class ScriptService:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
server = server_map.get(server_id)
|
server = server_map.get(server_id)
|
||||||
if not server or not server.is_online:
|
if not server or not self._server_ssh_configured(server):
|
||||||
entry["remote_log"] = "(服务器离线,无法拉取日志)"
|
entry["remote_log"] = "(服务器不存在或未配置 SSH,无法拉取日志)"
|
||||||
return
|
return
|
||||||
cmd = f"tail -n {tail_lines} {shlex.quote(log_path)} 2>&1"
|
cmd = f"tail -n {tail_lines} {shlex.quote(log_path)} 2>&1"
|
||||||
try:
|
try:
|
||||||
@@ -554,6 +554,14 @@ class ScriptService:
|
|||||||
*[_tail_one(sid, entry) for sid, entry in results.items() if isinstance(entry, dict)]
|
*[_tail_one(sid, entry) for sid, entry in results.items() if isinstance(entry, dict)]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _server_ssh_configured(server: Server) -> bool:
|
||||||
|
"""Whether Nexus can open an SSH session to this server (independent of Agent heartbeat)."""
|
||||||
|
auth = (server.auth_method or "key").lower()
|
||||||
|
if auth == "password":
|
||||||
|
return bool(server.password)
|
||||||
|
return bool(server.ssh_key_private or server.ssh_key_configured)
|
||||||
|
|
||||||
async def _dispatch_to_servers(
|
async def _dispatch_to_servers(
|
||||||
self,
|
self,
|
||||||
server_ids: List[int],
|
server_ids: List[int],
|
||||||
@@ -576,11 +584,19 @@ class ScriptService:
|
|||||||
|
|
||||||
async def _exec_on_server(server_id: int):
|
async def _exec_on_server(server_id: int):
|
||||||
server = server_map.get(server_id)
|
server = server_map.get(server_id)
|
||||||
if not server or not server.is_online:
|
if not server:
|
||||||
results[str(server_id)] = {
|
results[str(server_id)] = {
|
||||||
"status": "skipped",
|
"status": "skipped",
|
||||||
"stdout": "",
|
"stdout": "",
|
||||||
"stderr": f"Server offline or not found (ID={server_id})",
|
"stderr": f"服务器不存在 (ID={server_id})",
|
||||||
|
"exit_code": -1,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
if not self._server_ssh_configured(server):
|
||||||
|
results[str(server_id)] = {
|
||||||
|
"status": "skipped",
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "未配置 SSH 凭据,无法执行脚本",
|
||||||
"exit_code": -1,
|
"exit_code": -1,
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ _RSYNC_CHOWN_RE = re.compile(r"^[a-zA-Z0-9_.-]+(:[a-zA-Z0-9_.-]+)?$")
|
|||||||
_RSYNC_CHMOD_RE = re.compile(r"^[DF0-9a-zA-Z=+,:-]+$")
|
_RSYNC_CHMOD_RE = re.compile(r"^[DF0-9a-zA-Z=+,:-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def rsync_push_permission_summary() -> str | None:
|
||||||
|
"""Human/log snapshot of rsync --chown/--chmod applied on real pushes."""
|
||||||
|
from server.config import settings
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
chown = (settings.RSYNC_PUSH_CHOWN or "").strip()
|
||||||
|
chmod = (settings.RSYNC_PUSH_CHMOD or "").strip()
|
||||||
|
if chown and _RSYNC_CHOWN_RE.fullmatch(chown):
|
||||||
|
parts.append(f"chown={chown}")
|
||||||
|
if chmod and _RSYNC_CHMOD_RE.fullmatch(chmod):
|
||||||
|
parts.append(f"chmod={chmod}")
|
||||||
|
return " ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
def rsync_push_permission_args() -> list[str]:
|
def rsync_push_permission_args() -> list[str]:
|
||||||
"""Extra rsync flags: set remote owner/mode after push (default www:www, 755).
|
"""Extra rsync flags: set remote owner/mode after push (default www:www, 755).
|
||||||
|
|
||||||
@@ -171,6 +185,7 @@ class SyncEngineV2:
|
|||||||
operator=operator,
|
operator=operator,
|
||||||
status="running",
|
status="running",
|
||||||
sync_mode=sync_mode,
|
sync_mode=sync_mode,
|
||||||
|
push_permission=rsync_push_permission_summary(),
|
||||||
started_at=datetime.now(timezone.utc),
|
started_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
async with _db_lock:
|
async with _db_lock:
|
||||||
@@ -582,6 +597,7 @@ def _log_to_dict(log: SyncLog) -> dict:
|
|||||||
"files_skipped": log.files_skipped,
|
"files_skipped": log.files_skipped,
|
||||||
"bytes_transferred": log.bytes_transferred,
|
"bytes_transferred": log.bytes_transferred,
|
||||||
"diff_summary": log.diff_summary,
|
"diff_summary": log.diff_summary,
|
||||||
|
"push_permission": log.push_permission,
|
||||||
"error_message": log.error_message,
|
"error_message": log.error_message,
|
||||||
"started_at": str(log.started_at) if log.started_at else None,
|
"started_at": str(log.started_at) if log.started_at else None,
|
||||||
"finished_at": str(log.finished_at) if log.finished_at else None,
|
"finished_at": str(log.finished_at) if log.finished_at else None,
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class SyncLog(Base):
|
|||||||
bytes_transferred = Column(Integer, default=0)
|
bytes_transferred = Column(Integer, default=0)
|
||||||
duration_seconds = Column(Integer, default=0)
|
duration_seconds = Column(Integer, default=0)
|
||||||
diff_summary = Column(Text, nullable=True, comment="rsync输出摘要")
|
diff_summary = Column(Text, nullable=True, comment="rsync输出摘要")
|
||||||
|
push_permission = Column(String(120), nullable=True, comment="推送后权限策略 chown=… chmod=…")
|
||||||
error_message = Column(Text, nullable=True)
|
error_message = Column(Text, nullable=True)
|
||||||
started_at = Column(DateTime, default=_utcnow)
|
started_at = Column(DateTime, default=_utcnow)
|
||||||
finished_at = Column(DateTime, nullable=True)
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ async def run_schema_migrations():
|
|||||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX `idx_pending_servers_domain` (`domain`)
|
INDEX `idx_pending_servers_domain` (`domain`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||||
|
"ALTER TABLE sync_logs ADD COLUMN push_permission VARCHAR(120) NULL COMMENT '推送后权限策略 chown=… chmod=…'",
|
||||||
"""CREATE TABLE IF NOT EXISTS `admin_refresh_tokens` (
|
"""CREATE TABLE IF NOT EXISTS `admin_refresh_tokens` (
|
||||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
`admin_id` INT NOT NULL COMMENT '管理员 ID',
|
`admin_id` INT NOT NULL COMMENT '管理员 ID',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
|||||||
|
|
||||||
from server.application.services.credential_poller import (
|
from server.application.services.credential_poller import (
|
||||||
format_poll_errors,
|
format_poll_errors,
|
||||||
|
parse_batch_ip_lines,
|
||||||
poll_credentials,
|
poll_credentials,
|
||||||
CredentialAttemptError,
|
CredentialAttemptError,
|
||||||
)
|
)
|
||||||
@@ -158,6 +159,25 @@ async def test_pending_server_repo_record_failure(db_session):
|
|||||||
assert "still failed" in (updated.last_error or "")
|
assert "still failed" in (updated.last_error or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_batch_ip_lines_ignores_blank_and_dedupes():
|
||||||
|
text = " 192.168.1.1 \n\n10.0.0.2\n192.168.1.1\n \n10.0.0.3 "
|
||||||
|
parsed = parse_batch_ip_lines(text)
|
||||||
|
assert parsed.domains == ["192.168.1.1", "10.0.0.2", "10.0.0.3"]
|
||||||
|
assert parsed.input_lines == 4
|
||||||
|
assert parsed.duplicates_removed == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_batch_ip_lines_case_insensitive_dedupe():
|
||||||
|
parsed = parse_batch_ip_lines("Host.EXAMPLE.com\nhost.example.com\n10.0.0.1")
|
||||||
|
assert parsed.domains == ["Host.EXAMPLE.com", "10.0.0.1"]
|
||||||
|
assert parsed.duplicates_removed == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_batch_ip_lines_max_limit():
|
||||||
|
with pytest.raises(ValueError, match="最多"):
|
||||||
|
parse_batch_ip_lines("1\n2\n3", max_lines=2)
|
||||||
|
|
||||||
|
|
||||||
def test_format_poll_errors_truncates():
|
def test_format_poll_errors_truncates():
|
||||||
errors = [
|
errors = [
|
||||||
CredentialAttemptError("password", "a", "root", "e1"),
|
CredentialAttemptError("password", "a", "root", "e1"),
|
||||||
@@ -169,59 +189,26 @@ def test_format_poll_errors_truncates():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_by_ip_endpoint_success(monkeypatch):
|
async def test_add_by_ip_endpoint_success(monkeypatch):
|
||||||
"""API layer: mocked poll → server created response shape."""
|
"""API layer: mocked _try_add_by_ip → server created response shape."""
|
||||||
from server.api import servers as servers_api
|
from server.api import servers as servers_api
|
||||||
from server.domain.models import Server
|
from server.api.schemas import AddByIpRequest, AddByIpsBatchItemResult
|
||||||
|
|
||||||
mock_server = Server(
|
async def fake_try_add_by_ip(request, **kwargs):
|
||||||
id=1,
|
return AddByIpsBatchItemResult(
|
||||||
name="10.1.1.1",
|
domain=kwargs["domain"],
|
||||||
domain="10.1.1.1",
|
status="created",
|
||||||
port=22,
|
server_id=1,
|
||||||
username="root",
|
matched_preset="ok-pw",
|
||||||
auth_method="password",
|
matched_username="root",
|
||||||
connectivity="ok",
|
)
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_poll(*_a, **_k):
|
monkeypatch.setattr(servers_api, "_try_add_by_ip", fake_try_add_by_ip)
|
||||||
return servers_api.__dict__.get("_PollResult") # won't work
|
|
||||||
|
|
||||||
match = type("M", (), {
|
|
||||||
"auth_method": "password",
|
|
||||||
"username": "root",
|
|
||||||
"preset_id": 1,
|
|
||||||
"ssh_key_preset_id": None,
|
|
||||||
"password": "pass",
|
|
||||||
"ssh_key_private": None,
|
|
||||||
"ssh_key_public": "",
|
|
||||||
"preset_name": "ok-pw",
|
|
||||||
})()
|
|
||||||
poll_result = type("R", (), {"success": True, "match": match, "errors": []})()
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"server.application.services.credential_poller.poll_credentials",
|
|
||||||
AsyncMock(return_value=poll_result),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
servers_api,
|
|
||||||
"_server_exists_for_domain",
|
|
||||||
AsyncMock(return_value=False),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
servers_api,
|
|
||||||
"_create_server_from_poll",
|
|
||||||
AsyncMock(return_value=mock_server),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(servers_api, "_server_to_dict", lambda s: {"id": s.id, "domain": s.domain})
|
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
admin = MagicMock(username="admin")
|
admin = MagicMock(username="admin")
|
||||||
request = MagicMock()
|
request = MagicMock()
|
||||||
request.client.host = "127.0.0.1"
|
|
||||||
service = MagicMock()
|
service = MagicMock()
|
||||||
|
|
||||||
from server.api.schemas import AddByIpRequest
|
|
||||||
|
|
||||||
out = await servers_api.add_server_by_ip(
|
out = await servers_api.add_server_by_ip(
|
||||||
request,
|
request,
|
||||||
AddByIpRequest(domain="10.1.1.1", port=22),
|
AddByIpRequest(domain="10.1.1.1", port=22),
|
||||||
@@ -231,3 +218,44 @@ async def test_add_by_ip_endpoint_success(monkeypatch):
|
|||||||
)
|
)
|
||||||
assert out["success"] is True
|
assert out["success"] is True
|
||||||
assert out["server"]["domain"] == "10.1.1.1"
|
assert out["server"]["domain"] == "10.1.1.1"
|
||||||
|
assert out["matched_preset"] == "ok-pw"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_by_ips_batch_endpoint(monkeypatch):
|
||||||
|
from server.api import servers as servers_api
|
||||||
|
from server.api.schemas import AddByIpsBatchRequest, AddByIpsBatchItemResult
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def fake_try_add_by_ip(request, **kwargs):
|
||||||
|
domain = kwargs["domain"]
|
||||||
|
calls.append(domain)
|
||||||
|
if domain == "10.2.2.2":
|
||||||
|
return AddByIpsBatchItemResult(domain=domain, status="created", server_id=2)
|
||||||
|
if domain == "10.2.2.3":
|
||||||
|
return AddByIpsBatchItemResult(domain=domain, status="pending", pending_id=9, message="fail")
|
||||||
|
return AddByIpsBatchItemResult(domain=domain, status="skipped", message="exists")
|
||||||
|
|
||||||
|
monkeypatch.setattr(servers_api, "_try_add_by_ip", fake_try_add_by_ip)
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
admin = MagicMock(username="admin")
|
||||||
|
request = MagicMock()
|
||||||
|
request.client.host = "127.0.0.1"
|
||||||
|
service = MagicMock()
|
||||||
|
|
||||||
|
audit_repo = MagicMock()
|
||||||
|
audit_repo.create = AsyncMock()
|
||||||
|
monkeypatch.setattr(servers_api, "AuditLogRepositoryImpl", lambda _db: audit_repo)
|
||||||
|
|
||||||
|
payload = AddByIpsBatchRequest(text="10.2.2.2\n\n10.2.2.3\n10.2.2.2\n10.2.2.4")
|
||||||
|
out = await servers_api.add_servers_by_ips_batch(request, payload, admin, service, db)
|
||||||
|
|
||||||
|
assert calls == ["10.2.2.2", "10.2.2.3", "10.2.2.4"]
|
||||||
|
assert out.total == 3
|
||||||
|
assert out.created == 1
|
||||||
|
assert out.pending == 1
|
||||||
|
assert out.skipped == 1
|
||||||
|
assert out.input_lines == 4
|
||||||
|
assert out.duplicates_removed == 1
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Tests for rsync push permission flags."""
|
"""Tests for rsync push permission flags."""
|
||||||
|
|
||||||
from server.application.services.sync_engine_v2 import rsync_push_permission_args
|
from server.application.services.sync_engine_v2 import (
|
||||||
|
rsync_push_permission_args,
|
||||||
|
rsync_push_permission_summary,
|
||||||
|
)
|
||||||
from server.config import settings
|
from server.config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -10,12 +13,14 @@ def test_rsync_push_permission_defaults():
|
|||||||
assert "www:www" in args
|
assert "www:www" in args
|
||||||
assert "--chmod" in args
|
assert "--chmod" in args
|
||||||
assert "D755,F755" in args
|
assert "D755,F755" in args
|
||||||
|
assert rsync_push_permission_summary() == "chown=www:www chmod=D755,F755"
|
||||||
|
|
||||||
|
|
||||||
def test_rsync_push_permission_disabled(monkeypatch):
|
def test_rsync_push_permission_disabled(monkeypatch):
|
||||||
monkeypatch.setattr(settings, "RSYNC_PUSH_CHOWN", "")
|
monkeypatch.setattr(settings, "RSYNC_PUSH_CHOWN", "")
|
||||||
monkeypatch.setattr(settings, "RSYNC_PUSH_CHMOD", "")
|
monkeypatch.setattr(settings, "RSYNC_PUSH_CHMOD", "")
|
||||||
assert rsync_push_permission_args() == []
|
assert rsync_push_permission_args() == []
|
||||||
|
assert rsync_push_permission_summary() is None
|
||||||
|
|
||||||
|
|
||||||
def test_rsync_push_permission_rejects_invalid_chown(monkeypatch):
|
def test_rsync_push_permission_rejects_invalid_chown(monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Script dispatch must use SSH readiness, not Agent is_online heartbeat."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from server.application.services.script_service import ScriptService
|
||||||
|
from server.domain.models import Server
|
||||||
|
|
||||||
|
|
||||||
|
def _make_service() -> ScriptService:
|
||||||
|
return ScriptService(
|
||||||
|
script_repo=MagicMock(),
|
||||||
|
execution_repo=MagicMock(),
|
||||||
|
credential_repo=MagicMock(),
|
||||||
|
server_repo=MagicMock(),
|
||||||
|
audit_repo=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_ssh_when_agent_offline():
|
||||||
|
"""Servers without Agent heartbeat should still receive SSH script exec."""
|
||||||
|
service = _make_service()
|
||||||
|
server = Server(
|
||||||
|
id=7,
|
||||||
|
name="srv-7",
|
||||||
|
domain="10.0.0.7",
|
||||||
|
port=22,
|
||||||
|
username="root",
|
||||||
|
auth_method="password",
|
||||||
|
password="enc",
|
||||||
|
is_online=False,
|
||||||
|
connectivity="ok",
|
||||||
|
)
|
||||||
|
service.server_repo.get_by_id = AsyncMock(return_value=server)
|
||||||
|
|
||||||
|
mock_exec = AsyncMock(
|
||||||
|
return_value={"status": "ok", "stdout": "ok\n", "stderr": "", "exit_code": 0},
|
||||||
|
)
|
||||||
|
with patch.object(service, "_call_ssh_exec", mock_exec):
|
||||||
|
results = await service._dispatch_to_servers([7], "echo ok", timeout=30)
|
||||||
|
|
||||||
|
assert results["7"]["exit_code"] == 0
|
||||||
|
mock_exec.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_skips_missing_ssh_credentials():
|
||||||
|
service = _make_service()
|
||||||
|
server = Server(
|
||||||
|
id=8,
|
||||||
|
name="srv-8",
|
||||||
|
domain="10.0.0.8",
|
||||||
|
port=22,
|
||||||
|
username="root",
|
||||||
|
auth_method="key",
|
||||||
|
is_online=True,
|
||||||
|
)
|
||||||
|
service.server_repo.get_by_id = AsyncMock(return_value=server)
|
||||||
|
|
||||||
|
results = await service._dispatch_to_servers([8], "echo ok", timeout=30)
|
||||||
|
|
||||||
|
assert results["8"]["exit_code"] == -1
|
||||||
|
assert "SSH" in results["8"]["stderr"]
|
||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" href="/app/favicon.ico">
|
<link rel="icon" href="/app/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Nexus</title>
|
<title>Nexus</title>
|
||||||
<script type="module" crossorigin src="/app/assets/index-AmyXDl4i.js"></script>
|
<script type="module" crossorigin src="/app/assets/index-3SWW3Wj_.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BGzIPEZO.css">
|
<link rel="stylesheet" crossorigin href="/app/assets/index-BGzIPEZO.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user