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 web/agent/ ./web/agent/
|
||||
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/servers_import_template.csv ./web/app/servers_import_template.csv
|
||||
RUN mkdir -p ./web/app/vendor
|
||||
COPY web/app/vendor/tailwindcss-browser.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 | 任务 | 优先级 | 状态 | 涉及 | 验收标准 |
|
||||
|----|------|--------|------|------|----------|
|
||||
| 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-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-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-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-05 | **边端 Agent** 401 停循环 | P1 | ☐ | `web/agent/agent.py` | 错误 key 心跳 401 后子机 agent 不再无限重试(需各机同步 agent 文件) |
|
||||
|
||||
**依赖**:A-01 → A-02 → A-03/A-04 可并行点验。
|
||||
@@ -44,11 +44,11 @@
|
||||
|
||||
| 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-03 | **Files** `/files` | **树形侧栏**目录导航 | P3 | ☐ | 归档 §12 文件管理「未实现:树形侧栏」 | 左树右表;与 browse API 联动 |
|
||||
| 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-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-06 | **Settings** `/settings` | **子机 Agent 批量同步入口**(若运维需要一键提示) | P3 | ☐ | 边端 A-05 依赖人工 scp | 文档或设置页展示 agent 升级/同步命令(非必须新 API) |
|
||||
|
||||
**说明(勿重复做)**:
|
||||
@@ -166,13 +166,13 @@ Week 2+(P2/P3)
|
||||
## 3. 进度总表
|
||||
|
||||
```
|
||||
A 部署验证 [ ] 0/5
|
||||
B 14页缺口 [ ] 0/6
|
||||
A 部署验证 [~] 1/5 已部署,2~4 待浏览器/SSH
|
||||
B 14页缺口 [~] 3/6(B-01/B-04/B-05)
|
||||
C API无UI [ ] 0/4
|
||||
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 }">
|
||||
<span class="text-caption text-medium-emphasis">{{ formatLogTime(item.started_at) }}</span>
|
||||
</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 }">
|
||||
<span
|
||||
v-if="item.error_message"
|
||||
@@ -105,6 +112,7 @@
|
||||
import {
|
||||
formatLogTime,
|
||||
formatPushErrorMessage,
|
||||
formatPushPermission,
|
||||
logStatusColor,
|
||||
logStatusLabel,
|
||||
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 {
|
||||
switch (mode) {
|
||||
case 'incremental': return '增量'
|
||||
|
||||
@@ -29,6 +29,7 @@ const LOG_HEADERS = [
|
||||
{ title: '模式', key: 'sync_mode', width: 88 },
|
||||
{ title: '源路径', key: 'source_path' },
|
||||
{ title: '目标路径', key: 'target_path' },
|
||||
{ title: '权限', key: 'push_permission', width: 140 },
|
||||
{ title: '错误', key: 'error_message', width: 200 },
|
||||
{ title: '操作', key: 'actions', width: 88, sortable: false },
|
||||
]
|
||||
|
||||
@@ -173,6 +173,13 @@
|
||||
{{ item.target_path }}
|
||||
</span>
|
||||
</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 }">
|
||||
<span
|
||||
v-if="item.error_message"
|
||||
@@ -196,6 +203,7 @@ import { http } from '@/api'
|
||||
import { useServerList } from '@/composables/useServerList'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import { formatPushPermission } from '@/composables/push/labels'
|
||||
import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
|
||||
|
||||
type ViewMode = 'commands' | 'sessions' | 'push'
|
||||
@@ -267,6 +275,7 @@ const pushHeaders = [
|
||||
{ title: '触发', key: 'trigger_type', width: 72 },
|
||||
{ title: '源路径', key: 'source_path' },
|
||||
{ title: '目标路径', key: 'target_path' },
|
||||
{ title: '权限', key: 'push_permission', width: 130 },
|
||||
{ title: '错误', key: 'error_message', width: 180 },
|
||||
]
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
执行
|
||||
</v-btn>
|
||||
<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>
|
||||
</v-col>
|
||||
@@ -161,6 +161,123 @@
|
||||
/>
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -320,57 +437,6 @@
|
||||
</v-card>
|
||||
</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 -->
|
||||
<v-dialog v-model="showExecDetail" max-width="900" scrollable>
|
||||
<v-card border>
|
||||
@@ -423,7 +489,7 @@
|
||||
</template>
|
||||
|
||||
<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 { http } from '@/api'
|
||||
import { useServerList } from '@/composables/useServerList'
|
||||
@@ -490,9 +556,15 @@ const executing = ref(false)
|
||||
const showDelete = ref(false)
|
||||
const deletingScript = ref<ScriptItem | null>(null)
|
||||
|
||||
const showHistory = ref(false)
|
||||
const historyScript = ref<ScriptItem | null>(null)
|
||||
const execHistory = ref<ScriptExecItem[]>([])
|
||||
const historySectionRef = ref<HTMLElement | null>(null)
|
||||
const recentExecutions = 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 quickCommand = ref('')
|
||||
@@ -533,15 +605,34 @@ const pagedScripts = computed(() => {
|
||||
|
||||
watch(search, () => { page.value = 1 })
|
||||
|
||||
const execHeaders = [
|
||||
{ title: '命令', key: 'command' },
|
||||
const allExecHeaders = [
|
||||
{ title: 'ID', key: 'id', width: 72 },
|
||||
{ title: '脚本/命令', key: 'label' },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '进度', key: 'progress', width: 88 },
|
||||
{ title: '台数', key: 'server_count', width: 72 },
|
||||
{ title: '操作人', key: 'operator', width: 88 },
|
||||
{ title: '开始时间', key: 'started_at', width: 160 },
|
||||
{ title: '开始时间', key: 'started_at', width: 168 },
|
||||
{ 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[]>(() => {
|
||||
if (!execDetail.value?.results) return []
|
||||
return Object.entries(execDetail.value.results)
|
||||
@@ -560,7 +651,96 @@ function categoryLabel(v: string) {
|
||||
|
||||
function truncateCommand(cmd: string) {
|
||||
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) {
|
||||
@@ -691,6 +871,7 @@ async function doRun() {
|
||||
)
|
||||
showRun.value = false
|
||||
snackbar('脚本已提交执行')
|
||||
refreshExecutionHistory()
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '执行失败'), 'error')
|
||||
} 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() {
|
||||
if (!quickCommand.value.trim() || quickServerIds.value.length === 0) return
|
||||
quickExecuting.value = true
|
||||
@@ -745,6 +912,7 @@ async function doQuickExec() {
|
||||
quickServerIds.value.length,
|
||||
)
|
||||
snackbar(`已在 ${quickServerIds.value.length} 台服务器上执行`)
|
||||
refreshExecutionHistory()
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '执行失败'), 'error')
|
||||
} finally {
|
||||
@@ -799,6 +967,9 @@ async function refreshExecStatus(exec: TrackedExec) {
|
||||
exec.status = res.status
|
||||
exec.progress = formatProgress(res.progress)
|
||||
exec.longTask = Boolean(res.long_task)
|
||||
if (res.status !== 'running' && res.status !== 'partial') {
|
||||
refreshExecutionHistory()
|
||||
}
|
||||
} catch {
|
||||
/* keep last known */
|
||||
} finally {
|
||||
@@ -842,7 +1013,7 @@ async function retryExec(exec: TrackedExec) {
|
||||
async function stopHistoryExec(item: ScriptExecItem) {
|
||||
try {
|
||||
await http.post(`/scripts/executions/${item.id}/stop`)
|
||||
if (historyScript.value) await viewHistory(historyScript.value)
|
||||
refreshExecutionHistory()
|
||||
snackbar('已发送停止指令')
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '停止失败'), 'error')
|
||||
@@ -852,8 +1023,8 @@ async function stopHistoryExec(item: ScriptExecItem) {
|
||||
async function retryHistoryExec(item: ScriptExecItem) {
|
||||
try {
|
||||
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)
|
||||
if (historyScript.value) await viewHistory(historyScript.value)
|
||||
addTrackedExecution(`${execLabel(item)}(重试)`, executionId(res), parseServerCount(item.server_ids) || parseServerCount(res.server_ids), Boolean(item.long_task), res.progress, res.status)
|
||||
refreshExecutionHistory()
|
||||
snackbar('重试已提交')
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '重试失败'), 'error')
|
||||
@@ -907,6 +1078,7 @@ onMounted(() => {
|
||||
loadServers()
|
||||
loadExecConfig()
|
||||
loadCredentials()
|
||||
refreshExecutionHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-download" size="small" @click="exportCSV">
|
||||
导出CSV
|
||||
</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
|
||||
variant="tonal"
|
||||
@@ -243,11 +243,21 @@
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="logs">
|
||||
<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-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>
|
||||
<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>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -392,25 +402,54 @@
|
||||
:results="batchResultItems"
|
||||
/>
|
||||
|
||||
<!-- Import CSV Dialog -->
|
||||
<v-dialog v-model="showImport" max-width="500">
|
||||
<!-- Batch add by IP dialog -->
|
||||
<v-dialog v-model="showBatchAdd" max-width="560">
|
||||
<v-card border>
|
||||
<v-card-title>导入服务器</v-card-title>
|
||||
<v-card-title>批量添加服务器</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
上传 CSV 文件批量导入服务器。列:名称、地址(必填),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>
|
||||
每行一个 IP 或域名,空白行自动忽略,重复地址自动去重(不区分大小写)。SSH 凭据从「凭据管理」预设轮询匹配;连接失败将进入下方「连接失败列表」。
|
||||
</div>
|
||||
<v-file-input v-model="importFile" label="选择 CSV 文件" variant="outlined" density="compact" accept=".csv" show-size />
|
||||
<v-alert v-if="importResult" :type="importResult.failed > 0 ? 'warning' : 'success'" density="compact" variant="tonal" class="mt-3">
|
||||
成功 {{ importResult.created }},跳过 {{ importResult.skipped }},失败 {{ importResult.failed }}
|
||||
<v-textarea
|
||||
v-model="batchAddText"
|
||||
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-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showImport = false">关闭</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="doImport" :loading="importing" :disabled="!importFile?.length">开始导入</v-btn>
|
||||
<v-btn variant="text" @click="showBatchAdd = false">关闭</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="submitBatchAdd" :loading="batchAddLoading" :disabled="!batchAddText.trim()">
|
||||
开始添加
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -423,8 +462,9 @@ import { useRouter } from 'vue-router'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useServerPagination } from '@/composables/useServerPagination'
|
||||
import { formatPushPermission } from '@/composables/push/labels'
|
||||
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 { formatApiError } from '@/utils/apiError'
|
||||
import StatCardsRow from '@/components/StatCardsRow.vue'
|
||||
@@ -606,7 +646,45 @@ const selectedIds = computed(() => new Set(normalizeServerIds(selectedItems.valu
|
||||
// ── Detail panel ──
|
||||
const detailServer = ref<ServerApiItem | null>(null)
|
||||
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 ──
|
||||
const headers = [
|
||||
@@ -826,28 +904,70 @@ async function exportCSV() {
|
||||
} catch (e: any) { snackbar(e.message || '导出失败', 'error') }
|
||||
}
|
||||
|
||||
// ── CSV import ──
|
||||
const showImport = ref(false)
|
||||
const importFile = ref<File[]>([])
|
||||
const importing = ref(false)
|
||||
const importResult = ref<{ created: number; skipped: number; failed: number } | null>(null)
|
||||
// ── Batch add by IP ──
|
||||
const showBatchAdd = ref(false)
|
||||
const batchAddText = ref('')
|
||||
const batchAddPort = ref(22)
|
||||
const batchAddAgentPort = ref(8601)
|
||||
const batchAddLoading = ref(false)
|
||||
const batchAddResult = ref<AddByIpsBatchResult | null>(null)
|
||||
|
||||
async function doImport() {
|
||||
if (!importFile.value?.length) return
|
||||
importing.value = true
|
||||
importResult.value = null
|
||||
function parseBatchIpPreview(text: string) {
|
||||
const seen = new Set<string>()
|
||||
const unique: string[] = []
|
||||
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 {
|
||||
const form = new FormData()
|
||||
form.append('file', importFile.value[0])
|
||||
const res = await http.upload<{ created: number; skipped: number; failed: number }>('/servers/import', form)
|
||||
importResult.value = res
|
||||
snackbar('导入完成')
|
||||
loadServers()
|
||||
const res = await http.post<AddByIpsBatchResult>('/servers/add-by-ips-batch', {
|
||||
text,
|
||||
port: batchAddPort.value || 22,
|
||||
agent_port: batchAddAgentPort.value || 8601,
|
||||
})
|
||||
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) {
|
||||
const msg = e instanceof Error ? e.message : '导入失败'
|
||||
snackbar(msg, 'error')
|
||||
snackbar(formatApiError(e, '批量添加失败'), 'error')
|
||||
} finally {
|
||||
importing.value = false
|
||||
batchAddLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,10 +975,16 @@ async function doImport() {
|
||||
async function showDetail(item: ServerApiItem) {
|
||||
detailServer.value = item
|
||||
detailTab.value = 'info'
|
||||
syncLogsLoading.value = true
|
||||
syncLogs.value = []
|
||||
try {
|
||||
const res = await http.get<{ items: typeof syncLogs.value }>(`/servers/${item.id}/logs`, { limit: 20 })
|
||||
syncLogs.value = res.items || []
|
||||
} catch { syncLogs.value = [] }
|
||||
const res = await http.get<PushItem[] | { items: PushItem[] }>(`/servers/${item.id}/logs`, { limit: 20 })
|
||||
syncLogs.value = parseServerSyncLogs(res)
|
||||
} catch {
|
||||
syncLogs.value = []
|
||||
} finally {
|
||||
syncLogsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editServer(item: ServerApiItem) {
|
||||
|
||||
@@ -212,6 +212,29 @@ export interface AddByIpResponse {
|
||||
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/ */
|
||||
export interface AlertLogItem {
|
||||
id: number
|
||||
@@ -290,6 +313,8 @@ export interface PushItem {
|
||||
operator?: string | null
|
||||
duration_seconds?: number
|
||||
error_message?: string | null
|
||||
/** rsync --chown/--chmod snapshot, e.g. "chown=www:www chmod=D755,F755" */
|
||||
push_permission?: string | null
|
||||
started_at: string | null
|
||||
finished_at?: string | null
|
||||
}
|
||||
@@ -359,7 +384,7 @@ export interface ScriptExecItem {
|
||||
message?: string
|
||||
exit_code?: number
|
||||
}>
|
||||
events: Array<{ event: string; ts: string }>
|
||||
events: Array<{ at?: string; action?: string; detail?: string; operator?: string }>
|
||||
operator: string | null
|
||||
started_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))
|
||||
|
||||
|
||||
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 ──
|
||||
|
||||
class PlatformCreate(BaseModel):
|
||||
|
||||
+174
-35
@@ -18,7 +18,7 @@ from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import (
|
||||
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
|
||||
ServerImportResult, BatchAgentAction, BatchAgentResult, BatchAgentResultItem,
|
||||
AddByIpRequest,
|
||||
AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult,
|
||||
)
|
||||
from server.application.services.server_service import ServerService
|
||||
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_ROWS = 500
|
||||
MAX_BATCH_IP_LINES = 200
|
||||
|
||||
# CSV column mapping: CSV header (Chinese) → Server model field
|
||||
CSV_COLUMNS = [
|
||||
@@ -1017,27 +1018,38 @@ async def list_pending_servers(
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.post("/add-by-ip", response_model=dict)
|
||||
async def add_server_by_ip(
|
||||
async def _try_add_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."""
|
||||
*,
|
||||
domain: str,
|
||||
port: int,
|
||||
agent_port: int,
|
||||
target_path: Optional[str],
|
||||
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 (
|
||||
format_poll_errors,
|
||||
poll_credentials,
|
||||
)
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
domain = payload.domain.strip()
|
||||
domain = domain.strip()
|
||||
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()
|
||||
poll = await poll_credentials(db, domain, payload.port)
|
||||
display_name = (name or domain).strip()
|
||||
poll = await poll_credentials(db, domain, port)
|
||||
|
||||
if poll.success and poll.match:
|
||||
created = await _create_server_from_poll(
|
||||
@@ -1046,33 +1058,34 @@ async def add_server_by_ip(
|
||||
poll_match=poll.match,
|
||||
name=display_name,
|
||||
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,
|
||||
port=port,
|
||||
agent_port=agent_port,
|
||||
target_path=target_path,
|
||||
category=category,
|
||||
platform_id=platform_id,
|
||||
node_id=node_id,
|
||||
admin=admin,
|
||||
request=request,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"server": _server_to_dict(created),
|
||||
"matched_preset": poll.match.preset_name,
|
||||
"matched_username": poll.match.username,
|
||||
}
|
||||
return AddByIpsBatchItemResult(
|
||||
domain=domain,
|
||||
status="created",
|
||||
server_id=created.id,
|
||||
matched_preset=poll.match.preset_name,
|
||||
matched_username=poll.match.username,
|
||||
)
|
||||
|
||||
err_text = format_poll_errors(poll.errors)
|
||||
pending_repo = PendingServerRepositoryImpl(db)
|
||||
pending = await pending_repo.record_failure(
|
||||
name=display_name,
|
||||
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,
|
||||
port=port,
|
||||
agent_port=agent_port,
|
||||
target_path=target_path,
|
||||
category=category,
|
||||
platform_id=platform_id,
|
||||
node_id=node_id,
|
||||
last_error=err_text,
|
||||
)
|
||||
|
||||
@@ -1083,18 +1096,143 @@ async def add_server_by_ip(
|
||||
action="add_server_by_ip_failed",
|
||||
target_type="pending_server",
|
||||
target_id=pending.id,
|
||||
detail=f"凭据轮询失败 地址={domain}:{payload.port}",
|
||||
detail=f"凭据轮询失败 地址={domain}:{port}",
|
||||
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 {
|
||||
"success": False,
|
||||
"pending_id": pending.id,
|
||||
"errors": [{"preset_type": e.preset_type, "preset_name": e.preset_name, "username": e.username, "error": e.error} for e in poll.errors],
|
||||
"message": err_text,
|
||||
"pending_id": item.pending_id,
|
||||
"errors": [e.model_dump() for e in (item.errors or [])],
|
||||
"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)
|
||||
async def retry_pending_server(
|
||||
pending_id: int,
|
||||
@@ -2062,6 +2200,7 @@ def _sync_log_to_dict(log, server_name: str = None) -> dict:
|
||||
"duration_seconds": log.duration_seconds,
|
||||
"error_message": log.error_message,
|
||||
"diff_summary": log.diff_summary,
|
||||
"push_permission": log.push_permission,
|
||||
"started_at": str(log.started_at) if log.started_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 "认证失败"))
|
||||
|
||||
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:
|
||||
return
|
||||
server = server_map.get(server_id)
|
||||
if not server or not server.is_online:
|
||||
entry["remote_log"] = "(服务器离线,无法拉取日志)"
|
||||
if not server or not self._server_ssh_configured(server):
|
||||
entry["remote_log"] = "(服务器不存在或未配置 SSH,无法拉取日志)"
|
||||
return
|
||||
cmd = f"tail -n {tail_lines} {shlex.quote(log_path)} 2>&1"
|
||||
try:
|
||||
@@ -554,6 +554,14 @@ class ScriptService:
|
||||
*[_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(
|
||||
self,
|
||||
server_ids: List[int],
|
||||
@@ -576,11 +584,19 @@ class ScriptService:
|
||||
|
||||
async def _exec_on_server(server_id: int):
|
||||
server = server_map.get(server_id)
|
||||
if not server or not server.is_online:
|
||||
if not server:
|
||||
results[str(server_id)] = {
|
||||
"status": "skipped",
|
||||
"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,
|
||||
}
|
||||
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=+,:-]+$")
|
||||
|
||||
|
||||
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]:
|
||||
"""Extra rsync flags: set remote owner/mode after push (default www:www, 755).
|
||||
|
||||
@@ -171,6 +185,7 @@ class SyncEngineV2:
|
||||
operator=operator,
|
||||
status="running",
|
||||
sync_mode=sync_mode,
|
||||
push_permission=rsync_push_permission_summary(),
|
||||
started_at=datetime.now(timezone.utc),
|
||||
)
|
||||
async with _db_lock:
|
||||
@@ -582,6 +597,7 @@ def _log_to_dict(log: SyncLog) -> dict:
|
||||
"files_skipped": log.files_skipped,
|
||||
"bytes_transferred": log.bytes_transferred,
|
||||
"diff_summary": log.diff_summary,
|
||||
"push_permission": log.push_permission,
|
||||
"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,
|
||||
|
||||
@@ -138,6 +138,7 @@ class SyncLog(Base):
|
||||
bytes_transferred = Column(Integer, default=0)
|
||||
duration_seconds = Column(Integer, default=0)
|
||||
diff_summary = Column(Text, nullable=True, comment="rsync输出摘要")
|
||||
push_permission = Column(String(120), nullable=True, comment="推送后权限策略 chown=… chmod=…")
|
||||
error_message = Column(Text, nullable=True)
|
||||
started_at = Column(DateTime, default=_utcnow)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -178,6 +178,7 @@ async def run_schema_migrations():
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX `idx_pending_servers_domain` (`domain`)
|
||||
) 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` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`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 (
|
||||
format_poll_errors,
|
||||
parse_batch_ip_lines,
|
||||
poll_credentials,
|
||||
CredentialAttemptError,
|
||||
)
|
||||
@@ -158,6 +159,25 @@ async def test_pending_server_repo_record_failure(db_session):
|
||||
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():
|
||||
errors = [
|
||||
CredentialAttemptError("password", "a", "root", "e1"),
|
||||
@@ -169,59 +189,26 @@ def test_format_poll_errors_truncates():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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.domain.models import Server
|
||||
from server.api.schemas import AddByIpRequest, AddByIpsBatchItemResult
|
||||
|
||||
mock_server = Server(
|
||||
id=1,
|
||||
name="10.1.1.1",
|
||||
domain="10.1.1.1",
|
||||
port=22,
|
||||
username="root",
|
||||
auth_method="password",
|
||||
connectivity="ok",
|
||||
)
|
||||
async def fake_try_add_by_ip(request, **kwargs):
|
||||
return AddByIpsBatchItemResult(
|
||||
domain=kwargs["domain"],
|
||||
status="created",
|
||||
server_id=1,
|
||||
matched_preset="ok-pw",
|
||||
matched_username="root",
|
||||
)
|
||||
|
||||
async def fake_poll(*_a, **_k):
|
||||
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})
|
||||
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()
|
||||
|
||||
from server.api.schemas import AddByIpRequest
|
||||
|
||||
out = await servers_api.add_server_by_ip(
|
||||
request,
|
||||
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["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."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -10,12 +13,14 @@ def test_rsync_push_permission_defaults():
|
||||
assert "www:www" in args
|
||||
assert "--chmod" 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):
|
||||
monkeypatch.setattr(settings, "RSYNC_PUSH_CHOWN", "")
|
||||
monkeypatch.setattr(settings, "RSYNC_PUSH_CHMOD", "")
|
||||
assert rsync_push_permission_args() == []
|
||||
assert rsync_push_permission_summary() is None
|
||||
|
||||
|
||||
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">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user