feat: 批量IP添加、脚本库历史、推送权限记录与脚本SSH执行修复
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

批量添加替代CSV并支持去重;脚本库页内嵌执行历史;推送历史记录 rsync 权限策略;
脚本执行不再依赖 Agent 在线;服务器同步日志与相关单测补齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-08 03:15:40 +08:00
parent fb6a4be797
commit c8b0663508
27 changed files with 1133 additions and 214 deletions
+2 -1
View File
@@ -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/6B-01/B-04/B-05
C API无UI [ ] 0/4
D 产品backlog [ ] 0/5
E 工程债 [ ] 0/22
E 工程债 [~] 2/22E-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 # 404Dockerfile 未 COPY,已修待再部署)
```
## 下一批(计划 Week 1 剩余)
- 再部署含 CSV Dockerfile 修复
- A-02A-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服务器
1 名称 地址 SSH端口 用户名 认证方式 密码 密钥路径 私钥内容 分类 目标路径 备注
2 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,
+11
View File
@@ -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 },
]
+9
View File
@@ -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 },
]
+249 -77
View File
@@ -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(() => {
+165 -39
View File
@@ -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&#10;192.168.1.11&#10;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) {
+26 -1
View File
@@ -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
+44
View File
@@ -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
View File
@@ -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)),
)
+20 -4
View File
@@ -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,
+1
View File
@@ -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',
+72 -44
View File
@@ -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
+6 -1
View File
@@ -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):
+65
View File
@@ -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
View File
@@ -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>