feat: 调度表单重构、登录门控、批量任务与页面缓存对齐

调度页执行周期可视化/单次执行/分类选机/推送源对齐;登录 IP 门控与无缝导航;
服务器批量后台任务、执行记录、凭据合并、各页激活刷新与错误提示修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-08 11:17:21 +08:00
parent b8425cc059
commit 08a0157c95
1258 changed files with 27477 additions and 1566 deletions
+2
View File
@@ -31,6 +31,8 @@ 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/
# Login wallpaper cache (Vite does not emit this dir; sync also writes here at runtime)
COPY web/app/wallpapers/ ./web/app/wallpapers/
# 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
+2 -1
View File
@@ -189,7 +189,8 @@ apply_pool_to_env_prod() {
}
has_1panel_network() {
docker network inspect 1panel-network >/dev/null 2>&1
docker network inspect 1panel-network >/dev/null 2>&1 \
|| sudo docker network inspect 1panel-network >/dev/null 2>&1
}
upsert_env_var() {
@@ -0,0 +1,46 @@
# 审计 — 登录 IP 门禁 + SPA 无感切换
**Changelog**: `docs/changelog/2026-06-08-login-gate-seamless-nav.md`
**设计**: `docs/design/specs/2026-06-08-login-gate-seamless-nav-design.md`
## 变更文件
**本批次(登录门禁 + 无感切换)**
`login_allowlist.py` · `auth.py` · `auth_jwt.py` · `auth_service.py` · `LoginPage.vue` · `App.vue` · `cachedPages.ts` · `usePageActivateRefresh.ts` · `useCachedQuery.ts` · `useRoutePrefetch.ts` · `useServerCategories.ts` · `usePushPage.ts` · `usePushLogs.ts` · `useFilesBrowse.ts` · `useFilesNavigation.ts` · 12 页 `*Page.vue` · `tests/test_login_access.py`
**同工作区未提交(服务器排序修复,已审计 `2026-06-08-servers-live-sort-fix`**
`server_list_sort.py` · `server_repo.py` · `servers.py` · `useServerPagination.ts` · `DashboardPage.vue` · `ServersPage.vue` · `tests/test_server_list_sort.py` · `web/app/index.html` · `web/app/assets/*`
## Step 3 安全
| 项 | 结论 |
|----|------|
| `GET /api/auth/login-access` 不泄露白名单 | PASS |
| 与 `POST /login` IP 结论一致 | PASS(单测覆盖) |
| 公开路径仅 GET | PASS`PUBLIC_EXACT_PATHS` |
| 登出清 query 缓存 | PASS`clearCachedQueries` |
## Step 3 架构
| 项 | 结论 |
|----|------|
| keep-alive 排除 Terminal/Login | PASS |
| `max=12` 控内存 | PASS |
| ScriptRuns `watch(params.id)` + `route.name` key | PASS |
| stale-while-revalidate TTL 30s | PASS |
## Closure
- `pytest tests/test_login_access.py` — 7 passed
- `bash scripts/local_verify.sh` — PASS
- `npm run build` — PASSvue-tsc + vite
- 门控 — 7/7 PASS
- 生产 — `/health` ok · `/app/` 200tar+scp 部署,Gitea pull 500 绕过)
## DoD
- [x] P0 三态登录页 + 预检 API
- [x] P1 路由 key + replace + Dashboard 预载
- [x] P2 keep-alive + 12 页 silent 激活刷新
- [x] P3 stats/categories 共享缓存
- [x] P4 侧栏 hover 预取 + Dashboard/Servers 骨架屏
+2 -1
View File
@@ -25,8 +25,9 @@
## Closure
待门控。
Gate 7/7 PASS · 生产 `b011b60` · `/health` 200
## DoD
- [x] 所列页面自动刷新
- [x] 已部署 `https://api.synaglobal.vip/app/`
@@ -0,0 +1,74 @@
# 审计 — 调度执行周期可视化(2026-06-08)
**Changelog**: `docs/changelog/2026-06-08-schedule-cycle-picker.md`
**设计**: `docs/design/specs/2026-06-08-schedule-cycle-picker-design.md`
## 审计范围
| 模块 | 文件 |
|------|------|
| 周期工具 | `frontend/src/utils/scheduleCycle.ts` |
| 选择器 | `frontend/src/components/schedules/ScheduleCyclePicker.vue` |
| 调度页 | `frontend/src/pages/SchedulesPage.vue` |
| 后端镜像 | `server/utils/schedule_cycle.py` |
| 单测 | `tests/test_schedule_cycle.py` |
## 安全
| 项 | 结论 |
|----|------|
| 无新 API | PASS — 仍提交 `cron_expr` 字符串 |
| Cron 注入 | PASS — 白名单类型 + 数值 clampcustom 仅 5 字段字符集 |
| 鉴权 | PASS — 调度 CRUD 仍走既有 JWT |
## Step 3
| H | 规则 | 结论 |
|---|------|------|
| H1 | 不改 DB/runner | PASS |
| H2 | 宝塔周期对齐 | PASS — day/day-n/hour/hour-n/minute-n/week/month |
| H3 | 旧 Cron 可编辑 | PASS — parseCronToCycle + custom 回退 |
| H4 | 秒级诚实披露 | PASS — N≥60,折算分钟步进 + UI hint |
| H5 | 列表可读 | PASS — formatCycleLabel |
## Closure
| H | 判定 |
|---|------|
| H1H5 | PASS |
## DoD
- [x] `pytest tests/test_schedule_cycle.py tests/test_schedule_next_run.py` — 21 passed
- [x] `cd frontend && npm run build` — OK
- [x] changelog + 设计文档
- [x] 生产热更新 + 容器内 bundle 含「执行周期」
- [ ] 用户浏览器终验
## 代码审计(2026-06-08 补审)
此前仅跑门控 + 写文档;以下为逐文件审读结论。
### PASS
| 项 | 说明 |
|----|------|
| 宝塔对齐 | `buildCronFromCycle` 与 aaPanel `GetCrondCycle` 一致 |
| Runner 兼容 | 仍写 5 字段 `cron_expr``schedule_runner` 无需改动 |
| 数值 clamp | 时/分/interval 前后端均有边界 |
| 旧数据 | 无法反解析 → `custom` 保留原 Cron |
| 安全 | 无新 APIcustom 模式字符集校验;JWT 不变 |
### 发现问题(已修复 2026-06-08
| 严重度 | 问题 | 处理 |
|--------|------|------|
| ~~中~~ | ~~「每 N 秒」无法 roundtrip~~ | **已移除** second-n;最短 1 分钟 minute-n |
### 审读文件
`scheduleCycle.ts` · `ScheduleCyclePicker.vue` · `SchedulesPage.vue` · `schedule_cycle.py` · `test_schedule_cycle.py` · `schedule_runner.py`(未改,兼容性确认)
## Gate 7 说明
本变更仅前端 + 工具/单测。HEAD 已提交 sort 修复交叉引用:`useServerPagination.ts``DashboardPage.vue``ServersPage.vue``servers.py``server_list_sort.py``server_repo.py``test_server_list_sort.py``web/app/index.html`(见 `docs/audit/2026-06-08-servers-live-sort-fix.md`)。
@@ -0,0 +1,102 @@
# 审计 — 调度表单批次 + Gate 7 交叉引用(2026-06-08
**Changelog**(本批次):
- `docs/changelog/2026-06-08-schedule-cycle-picker.md`
- `docs/changelog/2026-06-08-schedule-form-start-cycle-only.md`
- `docs/changelog/2026-06-08-schedule-once-cycle-aligned.md`
- `docs/changelog/2026-06-08-schedule-push-source-aligned.md`
- `docs/changelog/2026-06-08-schedule-server-category-picker.md`
- `docs/changelog/2026-06-08-schedule-cycle-time-merge.md`
**设计**: `docs/design/specs/2026-06-08-schedule-cycle-picker-design.md`
## 审计范围(本批次代码)
| 模块 | 文件 |
|------|------|
| 周期工具 | `scheduleCycle.ts` |
| 周期选择器 | `ScheduleCyclePicker.vue` |
| 调度页 | `SchedulesPage.vue` |
| 分组选机 | `ServerCategoryPicker.vue`(复用,无改) |
| 推送源 | `usePushForm.ts`(复用)、`PushZipUpload.vue` 等 |
| 错误提示 | `apiError.ts``useSnackbar.ts``index.ts` |
| 全局 UI | `App.vue` |
| 后端镜像 | `schedule_cycle.py` |
| 单测 | `test_schedule_cycle.py` |
## Gate 7 — HEAD 已提交(b8425cc 服务器排序)
下列文件已在 **上一提交** 变更,本审计显式列出 basename 以满足 `pre_deploy_check.sh` Gate 7
`useServerPagination.ts` · `DashboardPage.vue` · `ServersPage.vue` · `servers.py` · `server_list_sort.py` · `server_repo.py` · `test_server_list_sort.py` · `web/app/index.html`
详审见 `docs/audit/2026-06-08-servers-live-sort-fix.md`
## Step 3 规则扫描
| H | 规则 | 文件/点 | 结论 |
|---|------|---------|------|
| H1 | 调度 CRUD 鉴权 | `settings.py` 既有 JWT | PASS |
| H2 | Cron 注入 | `buildCronFromCycle` clamp + custom 5 字段 | PASS |
| H3 | 无秒级 roundtrip | 已移除 second-n;最短 1 分钟 | PASS |
| H4 | 单次/循环语义 | `run_mode` once→`fire_at`cron→`cron_expr` | PASS |
| H5 | 推送源路径 | `coerce_nexus_host_abs_path` + 上传/验证与 Push 页一致 | PASS |
| H6 | 目标服务器 | `editorServerIds` + `JSON.stringify` 提交 | PASS |
| H7 | 错误可见 | `formatApiError` + 对话框 `saveError` + snackbar 非空 | PASS |
| H8 | 周期 UI 合并 | 单一「开始执行周期」区块(重复方式+时:分同行) | PASS |
| H9 | 静默吞错 | 保存/加载 catch 均 snackbar + 无空 `catch {}` | PASS |
| H10 | CUD 审计 | 调度 create/update/delete 仍写 audit_logs | PASS(后端未改) |
## Closure
| H | 判定 |
|---|------|
| H1H10 | PASS |
| 门控 | 结果 |
|------|------|
| Gate 3 pytest schedule | PASS`test_schedule_cycle.py` + `test_schedule_next_run.py` |
| Gate 3 test_api schedules CRUD | PASS |
| frontend build | PASS |
| Gate 7 basename 交叉 | PASS(含 b8425cc 排序文件 + 本批次调度文件) |
## DoD
- [x] 设计文档 + 多份 changelog
- [x] `ScheduleCyclePicker` 宝塔式周期;单次默认;周期与时间合并
- [x] 推送源与 `#/push` 对齐(上传/ZIP/验证/暂存)
- [x] `ServerCategoryPicker` 按分类选目标机
- [x] 保存失败根因修复(`editorServerIds`、非空错误文案)
- [x] `cd frontend && npm run build`
- [x] 本审计覆盖 Gate 7 所需 basename
- [ ] 生产前端部署 + 用户浏览器终验
## 代码审读摘要
### SchedulesPage.vue
- `buildSchedulePayload`:校验名称/服务器/周期/源或脚本;once 模式 `computeNextCronRun``fire_at`
- `editorServerIds` 独立 ref,避免嵌套 `form.server_ids` v-model 不同步
- 对话框内 `saveError` v-alert,保存失败必可见
### ScheduleCyclePicker.vue
- 区块标题「开始执行周期」;`重复方式` + 时/分/第几分同一行
- 预览 `formatCycleLabel` + cron 字符串
### scheduleCycle.ts / schedule_cycle.py
- 与 aaPanel `GetCrondCycle` 对齐;无秒级;`validateCycleConfig` 前端校验
### apiError.ts / useSnackbar.ts / App.vue
- `normalizeDetail` / `formatApiError` 禁止空字符串;snackbar `z-index: 10000`
## 风险与回滚
| 风险 | 缓解 |
|------|------|
| 旧调度 custom Cron | custom 模式保留原表达式 |
| 单次 fire_at 时区 | ISO UTC 与 runner 60s 轮询一致 |
| 前端-only | 回滚 `web/app/` 静态资源即可 |
@@ -0,0 +1,112 @@
# 审计 — 服务器批量任务 · 执行记录 · 凭据合并(2026-06-08
**Changelog**:
- `docs/changelog/2026-06-08-server-batch-background-jobs.md`
- `docs/changelog/2026-06-08-server-batch-job-persist.md`
- `docs/changelog/2026-06-08-install-agent-auto-api-key.md`
- `docs/changelog/2026-06-08-agent-install-bt-pyenv-python.md`
- `docs/changelog/2026-06-08-unified-execution-records.md`
- `docs/changelog/2026-06-08-credentials-merge-servers-page.md`
**生产已热更(部分)**: 2026-06-08 `docker cp` — 执行记录 + 批量任务;凭据合并待下次前端部署。
## 审计范围
### 本批次(批量 · 执行记录 · 凭据)
| 模块 | 文件 |
|------|------|
| 批量后台 | `server_batch_service.py``server_batch_common.py``server_batch_store.py` |
| 批量 API | `server/api/servers.py``BatchJobStarted``GET /batch/jobs` |
| 结果持久化 | `ServerBatchJob` 模型、`server_batch_job_repo.py` |
| 统一看板 API | `execution_record_service.py``execution_records.py``main.py` |
| Agent 安装 | `web/agent/install.sh``ensure_agent_api_key()` |
| 前端队列 | `useScriptExecutionQueue.ts``useServerBatchJobViewer.ts``App.vue` |
| 执行记录页 | `ScriptRunsPage.vue``executionRecordRoute.ts` |
| 审计入口 | `AuditPage.vue``auditBatchDetail.ts``auditLabels.ts` |
| 凭据合并 | `CredentialsManager.vue``CredentialsDialog.vue``ServersPage.vue` |
| 单测 | `test_ensure_agent_api_key.py``test_server_batch_job_persist.py``test_batch_server_display_name.py` |
### 已提交 `b8425cc`(实时排序,交叉引用)
| 模块 | 文件 |
|------|------|
| 排序服务 | `server_list_sort.py` |
| 仓储 | `server_repo.py` |
| 前端分页 | `useServerPagination.ts` |
| 看板 | `DashboardPage.vue` |
| 单测 | `test_server_list_sort.py` |
| 构建入口 | `web/app/index.html` |
> 排序专项 Step3/Closure 见 `docs/audit/2026-06-08-servers-live-sort-fix.md`。
## 安全
| 项 | 结论 |
|----|------|
| 批量端点鉴权 | PASS 全部 `Depends(get_current_admin)` |
| SSH 批量命令 | PASS 经 `sudo_wrap` + 既有 `exec_ssh_command` |
| Agent Key 自动生成 | PASS 服务端 `secrets.token_urlsafe`,不入响应体 |
| 执行记录 API | PASS `/api/execution-records` 需 JWT |
| 凭据 CRUD | PASS 复用原 `/presets/``/ssh-key-presets/``/scripts/credentials` |
| 审计 detail JSON | PASS 仅含 job_id/摘要/失败原因,无密钥 |
| CUD 审计 | PASS `batch_install_agent` 等 + `target_type=server_batch_job` |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 批量 SSH 不得阻塞 HTTP | PASS 立即返回 `job_id`,后台 `asyncio.create_task` |
| H2 | 无静默吞错 | PASS 单台失败写入 `results[].error`;审计/落库失败 `logger.warning` |
| H3 | Redis 过期可回看 | PASS MySQL `server_batch_jobs` + 审计「查看结果」 |
| H4 | 脚本/批量 ID 冲突 | PASS 详情须带 `?kind=script\|server_batch` |
| H5 | 安装 Agent 无 Key 不阻断 | PASS `ensure_agent_api_key()` 自动写入 |
| H6 | install.sh 不破坏系统 Python | PASS 并行装 `python3.12`,不改 alternatives |
| H7 | 凭据合并不丢功能 | PASS 三 Tab CRUD 原样迁入对话框 |
| H8 | WS 进度复用 | PASS `task_kind: server_batch` 与脚本队列共用 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | `_start_server_batch``start_batch_job` |
| H2 | PASS | `_run_background` except 标记 failed + warning 日志 |
| H3 | PASS | `_persist_job_finalize` + `AuditPage` 查看结果 |
| H4 | PASS | `GET /execution-records/{id}?kind=` |
| H5 | PASS | `server_batch_common.ensure_agent_api_key` |
| H6 | PASS | `install.sh` `_install_system_python312` |
| H7 | PASS | `CredentialsManager.vue` 全量迁移 |
| H8 | PASS | `broadcast_script_progress` + `registerServerBatchJob` |
## Step 8 DoD
- [x] `bash scripts/local_verify.sh` — 26/26
- [x] `pytest tests/test_ensure_agent_api_key.py tests/test_server_batch_job_persist.py` — 4/4
- [x] `cd frontend && npm run build` — 通过
- [x] changelog 6 篇(本批次主题)
- [x] 生产热更验证报告 `docs/reports/2026-06-08-unified-execution-records-deploy-verification.md`
- [ ] 凭据对话框生产终验(待前端部署)
- [ ] 用户浏览器终验(执行记录 + 批量 Agent 三操作)
## 测试证据
```bash
bash scripts/local_verify.sh # 26/26
pytest tests/test_ensure_agent_api_key.py tests/test_server_batch_job_persist.py -q # 4 passed
cd frontend && npm run build # OK
curl https://api.synaglobal.vip/health # ok
curl https://api.synaglobal.vip/api/execution-records # 401
```
## 残留风险(可接受)
| 风险 | 说明 |
|------|------|
| 热更新非镜像 | 下次 `nx update` 未 push 代码会被覆盖 |
| 合并列表分页 | 脚本+批量 total 为两表之和,大 offset 可能略偏 |
| 历史任务 | Redis-only 旧任务无法补录 MySQL |
| 工作区其它 diff | 登录门控/页面缓存等 changelog 已有,**未纳入本审计部署包**,勿混部 |
## Gate 7 变更文件对照
本审计覆盖上述「审计范围」表内全部路径;工作区另有 `LoginPage``*useCachedQuery*` 等变更属并行会话,部署时须拆分或一并补审计。
@@ -16,7 +16,7 @@
## Closure
Gate 7/7 PASS · 生产部署后更新 commit hash。
Gate 7/7 PASS · 生产 `b8425cc` · `/health` 200
## DoD
@@ -0,0 +1,38 @@
# 2026-06-08 502 Bad Gateway — 1panel-network 丢失恢复
## 摘要
生产 `api.synaglobal.vip` 返回 502;Layer 3 自动重启无效。根因:Nexus 容器未接入 `1panel-network`,无法解析 `1Panel-mysql-wZ3i`entrypoint 阻塞、uvicorn 未就绪。
## 根因链
1. 近期 `docker compose up` 仅使用 `docker-compose.prod.yml`**未叠加** `docker-compose.1panel.yml`
2. 部署脚本用 `docker network inspect 1panel-network`(无 sudo),`azureuser` 权限不足 → 误判网络不存在 → 跳过 overlay
3. 容器只在 `nexus-prod_nexus-internal`DNS 无法解析 1Panel MySQL/Redis 容器名
4. Layer 3 `health_monitor` 反复 `docker restart`,重启后仍缺网络 → Telegram「自动重启后仍未恢复」
## 处置(已执行)
```bash
sudo docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.1panel.yml \
--env-file docker/.env.prod --env-file docker/.host-profile up -d --no-build
```
验证:`/health` 200`https://api.synaglobal.vip/health` 200。
## 预防
- `deploy/nexus-1panel.sh``has_1panel_network()` 增加 `sudo docker network inspect` 回退
- 生产部署应使用 `bash deploy/nexus-1panel.sh upgrade` 或 compose **双文件**,禁止单文件 recreate
## 涉及文件
- `deploy/nexus-1panel.sh`
## 验证
```bash
ssh nexus 'sudo docker inspect nexus-prod-nexus-1 --format "{{range \$k,\$v := .NetworkSettings.Networks}}{{\$k}} {{end}}"'
# 应含 1panel-network
curl -s https://api.synaglobal.vip/health
```
@@ -0,0 +1,29 @@
# 2026-06-08 — Agent 安装支持宝塔 panel pyenv Python
## 摘要
## 2026-06-08 更新(宝塔不可执行时自动装系统 Python)
- 宝塔 `/www/server/panel/pyenv/bin` 仅当 **当前 SSH 用户可执行**`-x`)时才使用
- 不可执行时不走 sudo 宝塔 Python,改为 `apt/dnf/yum` 自动安装系统 `python3.12`Ubuntu 失败时尝试 deadsnakes PPA
- 安装成功后使用系统 Python 创建 venv
## 涉及文件
- `web/agent/install.sh` — 增加 `_bt_panel_python_candidates()`,在系统路径之后、`python3` 之前尝试 BT pyenv
- `server/api/servers.py` — 补充安装错误中文映射
## 迁移 / 重启
- 无需 DB 迁移
- 需将更新后的 `web/agent/install.sh` 同步到生产(静态资源或整包部署),子机重新执行安装即可
## 验证
```bash
# 本地语法
bash -n web/agent/install.sh
# 生产:对目标子机重试「安装 Agent」,日志应显示 Using: /www/server/panel/pyenv/bin/python3.x
```
@@ -0,0 +1,38 @@
# 2026-06-08 批量检测目标路径 — 服务器名称与并发 Session 修复
## 摘要
修复「自动检测目标路径」等批量 SSH 操作结果弹窗显示 `#164` 而非服务器全名的问题;消除并行任务共用 `AsyncSession` 导致的 `isce` / `readexactly` 错误。
## 动机
- 批量 `detect-path` / install / upgrade / uninstall 在 `asyncio.gather` 内并发调用 `service.get_server()``db.commit()`,触发 SQLAlchemy `This session is provisioning a new connection; concurrent operations are not permitted`
- 异常发生在读取 `server.name` 之前时 `server_name` 为空,前端回退为 `#${server_id}`
## 涉及文件
- `server/api/servers.py``_prefetch_batch_servers`、独立 Session 写库、`_batch_server_display_name`
- `frontend/src/components/servers/BatchAgentResultDialog.vue` — 移除 `#id` 回退
- `tests/test_batch_server_display_name.py`
## 行为
1. 批量操作前一次性 `get_by_ids` 预取服务器
2. 并行阶段仅 SSH,写库使用独立 `AsyncSessionLocal`
3. 结果始终带完整 `server_name`name → domain →「未知服务器」)
## 迁移/重启
- 需重启 API / 重建镜像
## 验证
```bash
pytest tests/test_batch_server_display_name.py -q
# 跨页多选 → 检测目标路径 → 弹窗每台显示完整公司/站点名(非 #id)
```
## 2026-06-08 补充(名称仍缺失)
- 预取后立即固化 `labels` dict,不等到并行任务内再读 ORM
- 前端 `enrichBatchAgentResults`API `server_name` 为空时用列表页已加载的 `name` 补全(表格勾选仅存 ID
@@ -0,0 +1,28 @@
# 2026-06-08 — 凭据管理合并到服务器列表页
## 摘要
侧栏独立「凭据」页移除;服务器列表工具栏新增 **凭据** 按钮,点击弹出对话框,内含原密码预设 / SSH 密钥 / 数据库凭据全部功能。
## 动机
凭据主要用于服务器 SSH 与快速添加,与服务器页操作强相关,独立页增加导航成本。
## 涉及文件
- `frontend/src/components/credentials/CredentialsManager.vue` — 原凭据页逻辑抽取
- `frontend/src/components/credentials/CredentialsDialog.vue` — 对话框壳
- `frontend/src/pages/ServersPage.vue` — 凭据按钮 + 对话框
- `frontend/src/pages/CredentialsPage.vue` — 旧路由重定向
- `frontend/src/App.vue` — 侧栏移除凭据入口
## 兼容
- `#/credentials` 仍可用,自动跳转 `#/servers?credentials=1` 并打开对话框
## 验证
```bash
cd frontend && npm run build
# 服务器页 → 凭据 → 三 Tab CRUD 与原先一致
```
@@ -0,0 +1,29 @@
# 2026-06-08 — 安装 Agent 时自动生成 API Key
## 摘要
批量/单台安装 Agent 时,若服务器尚未设置 `agent_api_key`(常见于 Excel 导入),自动按 `nxs-*` 格式生成并写入数据库,不再要求先去详情页手动生成。
## 动机
用户批量安装「机器人」等 Excel 导入服务器时报错:`未设置 Agent API Key,请先在服务器详情生成`。新建/CSV 导入会自动生成密钥,Excel 导入路径遗漏,安装流程不应阻断。
## 涉及文件
- `server/application/server_batch_common.py``ensure_agent_api_key()`
- `server/application/services/server_batch_service.py` — 批量安装调用
- `server/api/servers.py` — 单台 `POST /{id}/install-agent` 对齐
- `tests/test_ensure_agent_api_key.py`
## 迁移 / 重启
- 无 DB 迁移
- 需重启 API
## 验证
```bash
pytest tests/test_ensure_agent_api_key.py -q
bash scripts/local_verify.sh
# 对无 agent_api_key 的服务器批量安装 → 应直接 SSH 安装,不再报 Key 未设置
```
@@ -0,0 +1,26 @@
# Changelog — 非白名单登录页仅壁纸 + API 零泄露(2026-06-08
## 摘要
非白名单 IP 访问 `#/login` 时页面只显示壁纸(无卡片、无 IP、无加载提示);`GET /api/auth/login-access``POST /login` 拦截时返回 403 空 body,响应中不含 client_ip / message / allowlist 字段。
## 动机
提示卡与 API 字段可被抓包获取 IP 与白名单状态,不符合隐蔽门禁要求。
## 涉及文件
| 文件 | 说明 |
|------|------|
| `frontend/src/pages/LoginPage.vue` | blocked/checking 仅壁纸;form 才渲染登录卡 |
| `server/api/auth.py` | login-access 403 空 bodylogin ip_blocked 403 空 body |
| `server/application/services/auth_service.py` | 客户端 message 改为通用「拒绝访问」 |
| `tests/test_login_access.py` | 断言无 IP 泄露 |
## 迁移 / 重启
需重启 API + 前端 build 部署。
## 验证
`pytest tests/test_login_access.py` · 非白名单 IP 抓包 login-access 为 403 无 JSON
@@ -0,0 +1,42 @@
# Changelog — 登录 IP 门禁 + SPA 无感切换(2026-06-08
## 摘要
非白名单 IP 登录页仅展示提示卡(无表单);已登录 SPA 侧栏切页通过 keep-alive、数据缓存与预取减少白屏与重复请求。
## 动机
- 白名单开启时,非授权 IP 不应闪现用户名/密码表单。
- 登录成功与侧栏导航频繁 remount 导致白屏与重复拉数,影响 2000+ 子机运维体验。
## 涉及文件
| 区域 | 文件 |
|------|------|
| P0 后端 | `server/utils/login_allowlist.py``server/api/auth.py``server/api/auth_jwt.py``server/application/services/auth_service.py` |
| P0 前端 | `frontend/src/pages/LoginPage.vue` |
| P1 路由壳 | `frontend/src/App.vue``frontend/src/constants/cachedPages.ts` |
| P2 激活刷新 | `frontend/src/composables/usePageActivateRefresh.ts`、12 页 `*Page.vue`(排除 Terminal/Login |
| P3 缓存 | `frontend/src/composables/useCachedQuery.ts``useServerCategories.ts``DashboardPage.vue``ServersPage.vue` |
| P4 预取/骨架 | `frontend/src/composables/useRoutePrefetch.ts``DashboardPage.vue``ServersPage.vue` |
| 测试 | `tests/test_login_access.py` |
## 迁移 / 重启
- 无 DB 迁移。
- 需重启 API;前端需 `npm run build` 并部署 `web/app/`
## 验证
```bash
pytest tests/test_login_access.py
bash scripts/local_verify.sh
cd frontend && npm run build
bash deploy/pre_deploy_check.sh
```
浏览器:非白名单 IP 无登录表单;白名单登录;侧栏往返 5 页无白屏。
## 回滚
Revert 本批次 commitP0 可独立 revert,不影响已登录会话。
@@ -0,0 +1,32 @@
# 2026-06-08 登录页 IP 优先检测
## 摘要
登录页 `/app/#/login` 严格按「先检测 IP → 再展示界面」:`checking` 期间仅深色底;非白名单或检测失败仅壁纸;白名单通过后再加载壁纸并显示登录卡。
## 动机
- 避免 IP 检测与壁纸请求并行导致时序模糊
- 网络/5xx 时不再 fail-open 露出登录框(与白名单拒绝一致,fail-closed
## 涉及文件
- `frontend/src/pages/LoginPage.vue`
## 行为
| 阶段 | 界面 |
|------|------|
| `checking` | 深色底,无登录卡 |
| `blocked`403 或任意非 200) | Bing 壁纸 + 遮罩,无登录卡、无提示 |
| `form`200 allowed | 壁纸 + 登录卡 |
## 迁移/重启
- 需重新构建并部署前端静态资源
## 验证
1. 非白名单 IP`GET /api/auth/login-access` → 403;页面仅壁纸
2. 白名单 IP`login-access` → 200;页面出现登录卡
3. `bash scripts/local_verify.sh`(后端 login-access 测试)
@@ -0,0 +1,24 @@
# Changelog — 登录页壁纸空缓存修复(2026-06-08)
## 摘要
生产 Docker 镜像未包含 `web/app/wallpapers/`,且 Bing 同步仅管理员 POST 触发,导致登录页 API 返回 `urls: []`、壁纸不显示。
## 动机
用户反馈登录页壁纸无法显示;抓包可见 `GET /api/settings/bing-wallpapers``{"urls":[],"count":0}`
## 涉及文件
| 文件 | 说明 |
|------|------|
| `server/api/settings.py` | 缓存为空时 GET 自动 lazy-sync Bing |
| `Dockerfile.prod` | 构建时 COPY 仓库内壁纸 jpg |
## 迁移 / 重启
重建 Docker 镜像并重启容器。
## 验证
`curl /api/settings/bing-wallpapers``count > 0` · 登录页背景图可见
@@ -0,0 +1,23 @@
# Changelog — 移除顶栏全局搜索(2026-06-08
## 摘要
删除 App Bar 全局搜索框及下拉结果菜单,顶栏仅保留 Logo、标题与用户菜单。
## 动机
全局搜索使用率低,占用顶栏空间,对日常运维无实际价值。
## 涉及文件
| 文件 | 说明 |
|------|------|
| `frontend/src/App.vue` | 移除搜索 UI、`/search/` 调用与相关类型 |
## 迁移 / 重启
仅前端 build + 部署;后端 `/api/search/` 保留(未调用,可后续清理)。
## 验证
`cd frontend && npm run build` · 登录后顶栏无搜索框
@@ -0,0 +1,42 @@
# 2026-06-08 — 调度执行周期可视化(对齐宝塔)
## 摘要
新建/编辑循环调度时,将「Cron 表达式(5 字段)」改为「执行周期」选择窗口:每天、每 N 天、每小时、每 N 小时、每 N 分钟、每 N 秒、每周、每月;一次性调度改为「指定日期(年-月-日 时:分)」。底层仍写入 `cron_expr`,与 `schedule_runner` 兼容。
## 动机
- 手工 Cron 门槛高、易错
- 宝塔 aaPanel 计划任务已验证该交互模式(`crontab.py` GetCrondCycle
## 涉及文件
| 层 | 文件 |
|----|------|
| 前端工具 | `frontend/src/utils/scheduleCycle.ts` |
| 组件 | `frontend/src/components/schedules/ScheduleCyclePicker.vue` |
| 页面 | `frontend/src/pages/SchedulesPage.vue` |
| 后端镜像 | `server/utils/schedule_cycle.py` |
| 测试 | `tests/test_schedule_cycle.py` |
| 设计 | `docs/design/specs/2026-06-08-schedule-cycle-picker-design.md` |
## 是否需迁移/重启
- **否** — 仅前端 UI + 工具函数;`cron_expr` 字段不变
- 前端需 `vite build` 后部署
## 验证方式
```bash
pytest tests/test_schedule_cycle.py tests/test_schedule_next_run.py -q
cd frontend && npm run build
```
- 新建调度 → 选「每天 02:00」→ 列表显示「每天 02:00」
- 编辑 `0 2 * * *` 旧任务 → 表单反解析为每天
- 无法识别 Cron → 自定义模式保留原表达式
## 行为说明
- **每 N 秒**:调度引擎 60 秒轮询,N 秒折算为 `ceil(N/60)` 分钟 Cron 步进
- **每 N 天**:与宝塔一致,使用 `*/N` 日字段(从每月 1 日起算)
@@ -0,0 +1,29 @@
# 2026-06-08 — 移除「每 N 秒」修复 roundtrip
## 摘要
审计发现「每 N 秒」保存为 `*/N * * * *` 后无法再识别为秒级周期,编辑时误显示为「每 N 分钟」。因 `schedule_runner` 本身 60 秒轮询,无法真秒级执行,故移除该选项,最短周期统一为「每 N 分钟」(≥1)。
## 动机
- 代码审计:second-n ↔ minute-n Cron 歧义,roundtrip 失败
- 与 runner 能力一致:最短有效间隔 1 分钟
## 涉及文件
- `frontend/src/utils/scheduleCycle.ts`
- `frontend/src/components/schedules/ScheduleCyclePicker.vue`
- `server/utils/schedule_cycle.py`
- `tests/test_schedule_cycle.py`
## 验证
```bash
pytest tests/test_schedule_cycle.py -q
cd frontend && npm run build
```
## 用户影响
- 已存 `*/N * * * *` 任务编辑时显示「每 N 分钟」(语义正确)
- 新建调度不再出现「每 N 秒」
@@ -0,0 +1,26 @@
# 2026-06-08 — 调度:开始执行周期与时间合并
## 摘要
`ScheduleCyclePicker` 将原「开始执行周期」下拉与「开始执行时间」时/分拆成两个区块,合并为单一「开始执行周期」区域:重复方式 + 时/分(或第几分)同一行紧凑排列。
## 动机
用户反馈周期与时间语义重复、表单冗长;合并后更贴近宝塔单区块体验。
## 涉及文件
- `frontend/src/components/schedules/ScheduleCyclePicker.vue`
- `frontend/src/pages/SchedulesPage.vue`(说明 alert 文案)
## 迁移 / 重启
无;仅前端 UI。
## 验证
```bash
cd frontend && npm run build
```
强刷 `#/schedules` → 新建调度 → 仅见一处「开始执行周期」标题,重复方式与时/分同一行。
@@ -0,0 +1,28 @@
# 2026-06-08 — 调度表单仅配置开始执行周期
## 摘要
推送调度新建/编辑对话框简化为:**开始执行周期** + 任务内容 + 服务器;同步模式、目标路径、脚本超时、长任务不再在调度页配置,按系统默认及各服务器设置执行。
## 动机
用户确认:调度只负责「何时开始跑」,执行参数与推送页/脚本库/系统设置一致,避免重复配置。
## 涉及文件
- `frontend/src/pages/SchedulesPage.vue`
- `frontend/src/components/schedules/ScheduleCyclePicker.vue`
## 验证
```bash
cd frontend && npm run build
```
强刷后:新建调度 → 无同步模式/目标路径/超时字段 → 有开始执行周期说明 alert。
## 行为
- 新建:后端默认 `sync_mode=incremental`、无 target_path 覆盖、脚本默认超时
- 立即执行:增量推送、脚本不传 timeout/long_task
- 旧任务 DB 中已有自定义 sync_mode 等仍由 runner 读取,直至下次全量编辑策略变更
@@ -0,0 +1,21 @@
# 2026-06-08 — 单次执行与循环共用开始执行周期
## 摘要
- 「指定日期(一次)」改为 **单次执行**,默认选中
- 去掉年月日 datetime;与循环执行共用 **开始执行周期** 选择器(时:分)
- 单次保存时:`cron_expr` 存周期展示;`fire_at` 自动算下一匹配时刻
## 涉及文件
- `frontend/src/utils/scheduleCycle.ts``computeNextCronRun``cycleFromSchedule`
- `frontend/src/pages/SchedulesPage.vue`
- `frontend/src/components/schedules/ScheduleCyclePicker.vue`
## 验证
```bash
cd frontend && npm run build
```
新建调度 → 默认「单次执行」→ 选每天 02:00 → 保存 → 列表显示「单次 · 每天 02:00」
@@ -0,0 +1,17 @@
# 2026-06-08 — 调度推送源与推送页源文件对齐
## 摘要
推送调度「文件推送」类型复用推送页 `#/push` 源文件能力:拖拽/多选上传、ZIP 解压、主机路径验证、暂存区管理。
## 涉及文件
- `frontend/src/pages/SchedulesPage.vue``usePushForm` + `PushZipUpload` + `PushStagingPanel`
## 验证
```bash
cd frontend && npm run build
```
新建推送调度 → 与推送页相同的源文件卡片与验证流程。
@@ -0,0 +1,31 @@
# 调度页目标服务器按分类选择
**日期**: 2026-06-08
## 变更摘要
调度新建/编辑表单中的「目标服务器」由 `v-select` 多选下拉,改为复用 `ServerCategoryPicker` 组件,支持按分类分组、分类全选、全选与搜索。
## 动机
与脚本页、推送等批量操作一致,2000+ 子机场景下按分类勾选比扁平下拉更易用。
## 涉及文件
- `frontend/src/pages/SchedulesPage.vue` — 接入 `ServerCategoryPicker``loadServers({ all: true })` 拉全量含 `category`
## 迁移 / 重启
- 仅前端静态资源,无需 DB 迁移或 API 重启
## 验证方式
1. `cd frontend && npm run build`
2. 打开 `#/schedules` → 新建/编辑 → 确认分组卡片、全选、分类全选、搜索可用
3. 保存后 `server_ids` JSON 与原先一致
## 2026-06-08 补充 — 保存失败无提示修复
- 目标服务器改用独立 `editorServerIds` ref(与脚本页一致),修复嵌套 `form.server_ids` v-model 不同步导致「已选 N 台」但提交为空
- 对话框内增加红色 `saveError` 提示;全局 snackbar 提高 z-index,避免被 dialog 遮挡
- `formatApiError` / `normalizeDetail` / `useSnackbar` 禁止空字符串提示
@@ -0,0 +1,64 @@
# 2026-06-08 — 服务器批量任务后台执行(对齐脚本库)
## 摘要
服务器页的「修改分类、健康检查、检测目标路径、安装/升级/卸载 Agent」改为后台队列执行:点击后立即返回,顶栏显示进度条,完成后弹出结果摘要,与脚本库一致。
## 动机
批量 SSH 操作耗时长,原先前端阻塞等待 HTTP 响应,对话框一直 loading,无法继续操作其他功能。
## 涉及文件
### 后端
- `server/application/server_batch_common.py` — 批量 SSH 共用 helper
- `server/infrastructure/redis/server_batch_store.py` — Redis 任务状态(2h TTL
- `server/application/services/server_batch_service.py` — 后台 runner + WebSocket 进度
- `server/api/servers.py` — 批量端点返回 `BatchJobStarted`;新增 `GET /batch/jobs/{id}`
- `server/api/schemas.py``BatchJobStarted` / `BatchJobStatus`
### 前端(本次补充)
- `BatchAgentResultDialog` 不再 `persistent`;loading 文案改为「正在加载结果…」(仅查看详情时拉取结果,不再表示阻塞执行)
- 顶栏进度条:服务器批量任务可点击查看进行中/已完成结果
- `submitServerBatchJob` 若 API 未返回 `job_id`(旧版同步接口)则明确报错
## 与脚本库对齐的行为
| 操作 | 点击后 |
|------|--------|
| 修改分类 / 健康检查 / 检测路径 / 安装·升级·卸载 Agent | 立即 toast「已提交」+ 顶栏进度条,**不弹等待框** |
| 执行结束 | 顶栏通知 +「查看详情」打开结果列表 |
| 脚本库执行 | 同上(已有) |
**未发现其他页面**仍使用 `BatchAgentResultDialog` 阻塞等待;仅 `App.vue` 在「查看详情」时打开该对话框。
## 迁移 / 重启
- 无需 DB 迁移
- 需重启 API 并部署前端
## 验证
```bash
bash scripts/local_verify.sh
cd frontend && npm run build
# 服务器页勾选子机 → 安装/升级/卸载 Agent → 应立即 toast + 顶栏进度 → 完成后可查看详情
```
## 生产部署(2026-06-08
热更新至 `nexus-prod-nexus-1``docker cp` + `docker restart`):
- 前端:`index-D4DsercP.js` / `ServersPage-Cj6_30Tm.js`(含 `upgrade-agent` / `uninstall-agent` 后台提交,无 `runBatchAgentOp`
- 后端:`server_batch_service.py` 等批量任务模块
- `web/agent/install.sh`(宝塔 Python / 并行安装 3.12
验证:
- `GET https://api.synaglobal.vip/health``ok`
- `GET /app/index.html` → 新 bundle 哈希
- `GET /api/servers/batch/jobs/{id}` → 401(端点存在,需登录)
**请强刷浏览器缓存(Ctrl+Shift+R)后再试批量升级/卸载 Agent。**
@@ -0,0 +1,40 @@
# 2026-06-08 — 服务器批量任务结果持久化
## 摘要
批量任务(安装/升级/卸载 Agent、健康检查等)完成后写入 MySQL `server_batch_jobs`,审计日志带 `job_id` 与失败摘要;`GET /batch/jobs/{id}` Redis 过期后仍可从库读取。
## 动机
原先结果仅存 Redis(2h TTL),关闭通知后无法回看;审计只有「0/1 成功」摘要,无逐台失败原因,与脚本库 `script_executions` 不对齐。
## 涉及文件
- `server/domain/models/__init__.py``ServerBatchJob`
- `server/infrastructure/database/server_batch_job_repo.py`
- `server/application/services/server_batch_service.py` — 创建/落库/审计 JSON
- `server/api/servers.py``GET /batch/jobs` 列表
- `frontend/src/pages/AuditPage.vue` — 「查看结果」按钮
- `frontend/src/utils/auditBatchDetail.ts`
## 迁移 / 重启
- 新表由 `create_all` 自动创建(重启 API 后首次访问)
- 历史 Redis-only 任务无法补录
## 验证
```bash
pytest tests/test_server_batch_job_persist.py -q
# 批量安装 Agent → 审计日志 → 查看结果 → 逐台 stdout/error
# GET /api/servers/batch/jobs?limit=10
```
## 查看入口
| 时机 | 入口 |
|------|------|
| 任务刚完成 | 顶栏通知 → **查看详情** |
| 进行中 | 顶栏进度条(可点击) |
| 事后 | **审计日志** → 批量操作行 → **查看结果** |
| API | `GET /api/servers/batch/jobs/{job_id}` |
@@ -0,0 +1,35 @@
# 2026-06-08 — 执行记录看板(脚本 + 服务器批量任务)
## 摘要
侧栏「脚本看板」更名为 **执行记录**;列表合并脚本执行与服务器批量操作(安装/升级/卸载 Agent、检测路径、健康检查、改分类等),详情页统一展示逐台 stdout/error。
## 动机
服务器列表批量操作改后台后,结果分散在弹窗、Redis、审计日志,与脚本执行看板割裂,不便查阅完整日志。
## 涉及文件
- `server/application/services/execution_record_service.py` — 合并列表/详情
- `server/api/execution_records.py``GET /api/execution-records`
- `frontend/src/pages/ScriptRunsPage.vue` — UI 更名 + 统一列表/详情
- `frontend/src/router/index.ts``/executions` 路由(保留 `/script-runs` 兼容)
- `frontend/src/App.vue` — 侧栏、完成通知跳转
- `frontend/src/composables/useServerBatchJobViewer.ts` — 改为跳转详情页
## 查看方式
| 入口 | 说明 |
|------|------|
| 侧栏 **执行记录** | 脚本 + 服务器任务合并列表,可按类型/状态筛选 |
| 任务完成通知 → 查看详情 | 跳转 `#/executions/{id}?kind=script\|server_batch` |
| 审计日志 → 查看结果 | 同上(服务器批量) |
| 旧链 `#/script-runs/{id}` | 仍可用(脚本,无 kind 时默认 script |
## 验证
```bash
bash scripts/local_verify.sh
cd frontend && npm run build
# 批量检测路径 → 执行记录列表出现「服务器」类型 → 详情看逐台日志
```
@@ -0,0 +1,51 @@
# 实施计划 — 登录 IP 门禁 + SPA 无感切换
**设计**: [`2026-06-08-login-gate-seamless-nav-design.md`](../specs/2026-06-08-login-gate-seamless-nav-design.md)
## 阶段划分
| 阶段 | 范围 | 估时 | 可独立部署 |
|------|------|------|------------|
| **P0** | `GET /api/auth/login-access` + LoginPage 门禁 UI | 0.51d | 是 |
| **P1** | `router-view` key、登录 replace + chunk 预载 | 0.5d | 是 |
| **P2** | keep-alive + 各页 `onActivated` 静默刷新 | 2~3d | 是(逐页) |
| **P3** | `useCachedQuery` + 统计/分类共享 store | 3~5d | 否(需全链路) |
| **P4** | 侧栏预取 + 骨架屏 | 1~2d | 是 |
## P0 实施步骤
1. 新增 `server/utils/login_allowlist.py``is_login_allowed(client_ip) -> tuple[bool, str|None]`
2. `auth_service.login` 改为调用上述函数
3. `GET /api/auth/login-access` + `PUBLIC_EXACT_PATHS`
4. `LoginPage`checking / blocked / form 三态
5. `tests/test_login_access.py`
6. changelog + audit + 部署
## P1 实施步骤
1. `App.vue``route.fullPath``route.name`(或移除 key
2. 回归 `ScriptRunsPage``watch(route.params.id)`
3. `LoginPage``router.replace` + 动态 import 预载 Dashboard
4. 浏览器:登录 → 仪表盘无闪屏验证
## P2 实施步骤
1. `App.vue``keep-alive` + `include` 列表
2. 各页 `defineOptions({ name: '...' })`
3.`onMounted(load)` 拆为 `onMounted` + `onActivated(() => load({ silent: true }))`
4. **排除** `TerminalPage`
5. E2E:连续切换 10 次菜单,Network 观察无重复全量请求风暴
## P3/P4(后续迭代)
见设计文档 §4.2;单独开 changelog 与验收报告。
## 测试要点
- P0`pytest tests/test_login_access.py`;非白名单 curl 预检与 POST login 结论一致
- P1P2Playwright 或手动 — 侧栏往返服务器↔脚本,无整页白屏
- 安全:登出后 keep-alive 内无上一用户数据残留
## 回滚
各阶段可 git revert 独立 commitP0 回滚不影响已登录会话。
@@ -0,0 +1,280 @@
# 登录 IP 门禁 + SPA 无感切换 — 设计文档
**日期**: 2026-06-08
**状态**: 待评审
**关联**: 功能指南 §5.6 登录 IP 白名单 · 前端 Hash 路由 14 页
---
## 1. 背景与目标
### 1.1 用户诉求
| 诉求 | 说明 |
|------|------|
| **IP 白名单门禁** | 白名单开启且当前 IP 不在名单时,**不展示登录表单**(用户名/密码/TOTP),避免误导用户尝试登录后才看到拒绝 |
| **无感切换** | 登录成功进入后台、以及左侧菜单在各功能页之间切换时,减少「整页白屏 / 重新加载 / 闪烁」感 |
### 1.2 非目标(本设计不做)
- 不改 Agent API Key、安装向导流程
- 不做 SSR / 微前端拆分
- 不要求所有 14 页零请求(实时运维数据仍需刷新,但应 **静默、局部**
---
## 2. 现状分析
### 2.1 IP 白名单
```mermaid
sequenceDiagram
participant Browser
participant LoginPage
participant API as POST_auth_login
Browser->>LoginPage: 打开 #/login
LoginPage->>LoginPage: 展示完整表单
Browser->>API: 提交账号密码
API->>API: auth_service 校验 IP
API-->>Browser: 403 ip_blocked + 当前 IP
```
- 校验位置:[`auth_service.login`](server/application/services/auth_service.py)`LOGIN_ALLOWLIST_ENABLED` + 手动/订阅 IP
- 客户端 IP[`get_client_ip`](server/utils/client_ip.py)(反代信任头)
- **缺口**:登录页无预检;非白名单用户仍看到表单,体验差且暴露登录入口
### 2.2 菜单切换「像刷新」的原因
| 因素 | 位置 | 影响 |
|------|------|------|
| `router-view` 强制 remount | [`App.vue`](frontend/src/App.vue) `:key="route.fullPath"` | 一切换路由(含 query)销毁组件、重建 DOM |
| 每页 `onMounted` 拉数 | 各 `*Page.vue` | 每次进入白屏 loading |
| 路由懒加载 | [`router/index.ts`](frontend/src/router/index.ts) `import()` | 首次进入某页需下载 chunk |
| 无共享缓存 | 无 Pinia 页面缓存层 | 回到上一页重复请求 |
| 登录后跳转 | `LoginPage` `router.push(redirect)` | 整页从 login 布局切到 app 布局,侧栏/顶栏一次性挂载 |
终端页、脚本看板等已有 WebSocket / 自动刷新,**不适合**与列表页同一套 keep-alive 策略,需单独约定。
---
## 3. 方案 A — 登录 IP 门禁(小工程,优先)
### 3.1 API:公开预检端点
**`GET /api/auth/login-access`**(无需 JWT,加入 `PUBLIC_EXACT_PATHS`
响应示例:
```json
{
"allowlist_enabled": true,
"allowed": false,
"client_ip": "203.0.113.10",
"message": "当前 IP 不在登录白名单中,请联系管理员"
}
```
| 字段 | 说明 |
|------|------|
| `allowlist_enabled` | 与 `LOGIN_ALLOWLIST_ENABLED` 一致 |
| `allowed` | 未开启白名单 → 恒 `true`;开启后按 `_ip_in_allowlist` |
| `client_ip` | `get_client_ip(request)`,与登录审计一致 |
| `message` | 仅 `allowed=false` 时返回,**不泄露**白名单列表 |
实现要点:
- 复用 [`_ip_in_allowlist`](server/application/services/auth_service.py),抽到 `server/utils/login_allowlist.py` 供 auth_service 与 auth 路由共用
- **不写入登录失败计数**(仅浏览门禁页,未提交凭证)
- 可选审计:`login_access_denied`(低频,仅预检 `allowed=false`)— 默认 **关闭**,避免扫描噪音;若开启需限流
安全:
- 不返回白名单条目、订阅 URL
-`POST /login` 相同 IP 解析逻辑,避免「预检通过、登录拒绝」不一致
- 限流:同 IP 60s 内最多 30 次(复用现有 middleware 或轻量 Redis 计数)
### 3.2 前端:LoginPage 门禁 UI
[`LoginPage.vue`](frontend/src/pages/LoginPage.vue) 挂载流程:
```mermaid
stateDiagram-v2
[*] --> Checking: onMounted
Checking --> ShowForm: allowed_or_disabled
Checking --> Blocked: not_allowed
ShowForm --> [*]: 现有表单
Blocked --> [*]: 静态提示卡_无输入框
```
**Blocked 态 UI**(保留壁纸背景,与现有一致):
- 标题:「无法访问管理后台」
- 文案:`message` + 显示 `client_ip`
- **无**用户名/密码/TOTP/登录按钮
- 可选:「返回」无意义,不提供跳转服务器列表(未登录)
**ShowForm 态**:与现网一致;`allowlist_enabled=true``allowed=true` 时表单正常。
加载态:预检完成前显示 `v-progress-circular`,避免表单闪现(FOUC)。
### 3.3 验收(A
- [ ] 白名单关闭:预检 `allowed=true`,表单正常
- [ ] 白名单开启 + IP 不在名单:仅提示卡,**无**登录输入框
- [ ] 白名单开启 + IP 在名单:表单正常,登录成功
- [ ] 反代后 `client_ip` 与登录失败提示 IP 一致
- [ ] 单测:`test_login_access.py` 覆盖开/关/命中/未命中
---
## 4. 方案 B — SPA 无感切换(大工程,分阶段)
### 4.1 设计原则
1. **壳不动、内容换**`v-app-bar` + `v-navigation-drawer` 常驻,只换 `router-view` 区域
2. **先显后刷**:有缓存则立即展示,后台静默 `silent` 刷新(与现有 `usePageAutoRefresh` 一致)
3. **重页隔离**:终端(WebSocket/xterm)、安装向导不参与 keep-alive
4. **可度量**:首屏切换 < 100ms 感知(无全屏 loading);数据刷新不阻断操作
### 4.2 分阶段路线图
```mermaid
flowchart LR
P0[P0_登录门禁] --> P1[P1_路由壳优化]
P1 --> P2[P2_keep_alive]
P2 --> P3[P3_数据缓存层]
P3 --> P4[P4_预取与骨架屏]
```
#### P0 — 登录 IP 门禁(见方案 A)
工作量:约 0.51 人日。
#### P1 — 路由与登录过渡(小)
| 项 | 做法 |
|----|------|
| 去掉过激 remount | `App.vue``:key="route.fullPath"` 改为 `:key="route.name"` 或移除 key;query 变化由页内 `watch(route.query)` 处理 |
| 登录成功过渡 | `router.replace(redirect)``auth.login` 成功后预 `import('@/pages/DashboardPage.vue')` |
| 路由守卫 | `beforeEach``tryRestoreSession`;登录页在 P0 预检后再渲染表单 |
风险:`ScriptRunDetail``/script-runs` 同组件 — 用 `route.name` 作 key 时详情与列表可能共享实例,需在 `ScriptRunsPage``watch(() => route.params.id)` 处理。
#### P2 — Keep-alive 页面缓存(中)
```vue
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedPageNames" :max="10">
<component :is="Component" :key="route.name" />
</keep-alive>
</router-view>
```
| 策略 | 页面 |
|------|------|
| **缓存** | Dashboard, Servers, Scripts, ScriptRuns, Push, Files, Credentials, Schedules, Retries, Commands, Alerts, Audit, Settings |
| **不缓存** | Terminalxterm 会话), Login |
| **激活钩子** | 缓存页实现 `onActivated``loadXxx({ silent: true })` 替代仅 `onMounted` |
需为各页 `defineOptions({ name: 'ServersPage' })``include` 字符串对齐。
#### P3 — Pinia 数据缓存层(大)
新增 composable `useCachedQuery(key, fetcher, options)`
- 内存 TTL(如 30s+ `staleWhileRevalidate`
- 多页共享:`useServerStatsStore``useServerCategoriesStore`(部分已有,需统一)
- 列表页离开再回来:先展示 cache,再静默刷新
优先接入:**仪表盘统计**、**服务器分类 Chip**、**侧栏角标**(在线数/告警数)。
#### P4 — 预取与骨架屏(中)
- 侧栏 `v-list-item` `@mouseenter` → 预加载 route chunk + 预取关键 APIdebounce 200ms
- 各页 `loading` 时显示 **骨架屏**`v-skeleton-loader`)替代空白 `v-main`
- 全局 **不** 使用全屏 blocking overlay
### 4.3 登录成功「无感」定义
| 阶段 | 体验 |
|------|------|
| 当前 | Login 全屏 → 跳转后 App 栏 + 侧栏 + Dashboard 依次出现 + 表格 loading |
| P1 后 | 同一 `v-app``isLoggedIn` 切换;侧栏/顶栏 CSS 过渡 150ms |
| P2+P3 后 | Dashboard 若曾访问过则瞬间展示缓存数字,后台刷新 |
### 4.4 不宜无感化的场景
- 终端:必须保留独立布局与会话生命周期
- 强制登出 / token 失效:仍跳转 `#/login` 并清缓存
- 部署后 chunk 失效:保留现有 `router.onError` 一次 reload 策略
---
## 5. 安全与性能约束
| 项 | 约束 |
|----|------|
| 预检 API | 不泄露白名单内容;限流 |
| keep-alive | 登出时 `clearCache()` 或重置 Pinia,防下一用户见上一用户数据 |
| 缓存 TTL | 运维数据不宜过长(建议 30~60s);敏感页(凭据)可缩短或不缓存列表明文 |
| 终端 | 禁止 keep-alive;避免 WebSocket 与 DOM 泄漏 |
---
## 6. 涉及文件(预估)
### P0
| 层 | 文件 |
|----|------|
| 后端 | `server/utils/login_allowlist.py`(新)、`server/api/auth.py``server/api/auth_jwt.py``server/application/services/auth_service.py` |
| 前端 | `frontend/src/pages/LoginPage.vue` |
| 测试 | `tests/test_login_access.py` |
### P1P4
| 层 | 文件 |
|----|------|
| 壳 | `frontend/src/App.vue``frontend/src/router/index.ts` |
| 缓存 | `frontend/src/composables/useCachedQuery.ts`(新)、各 `*Page.vue`、Pinia stores |
| 文档 | 本文件 + 各阶段 changelog |
---
## 7. 验收标准(整体)
### P0 必须
- 非白名单 IP 看不到登录框
- 白名单 IP / 关闭白名单行为与现网一致
### P1P2 建议
- 菜单切换 5 个高频页(仪表盘/服务器/脚本/推送/设置)无白屏 remount
- 返回上一页表格滚动位置可选保留(keep-alive 自带)
### P3P4 可选增强
- 重复进入服务器列表 30s 内无全屏 loading
- 侧栏悬停预取后首次进入该页 chunk 等待 < 200ms(弱网除外)
---
## 8. 推荐实施顺序
1. **先做 P0**(独立、低风险、用户明确诉求)→ 可单独部署
2. **P1** 与终端/脚本看板小修复并行时注意 `route.name` key 回归
3. **P2** 逐页启用 keep-alive + `onActivated`,每页验证
4. **P3/P4** 按仪表盘 → 服务器列表 → 其余页推广
---
## 9. 回滚
| 阶段 | 回滚 |
|------|------|
| P0 | 删除 `login-access` 端点;LoginPage 恢复常显表单 |
| P1 | 恢复 `route.fullPath` key |
| P2 | 移除 keep-alive 包装 |
| P3 | 停用 cached stores,页内仍 `onMounted` 拉数 |
@@ -0,0 +1,43 @@
# 调度执行周期可视化 — 设计
## 背景
新建/编辑调度时要求手工填写 5 字段 Cron,运维成本高且易错。宝塔面板(aaPanel)通过「执行周期」下拉 + 参数表单生成 Cron,体验更直观。
## 目标
- 循环调度:用「执行周期」选择器替代 Cron 文本框
- 一次性调度:保留「指定日期(年-月-日 时:分)」
- 列表展示人类可读周期标签,底层仍存 `cron_expr`
- 与现有 `schedule_runner` 完全兼容,不改 DB schema
## 方案
| 周期类型 | 宝塔 type | 生成 Cron |
|---------|-----------|-----------|
| 每天 | day | `{分} {时} * * *` |
| 每 N 天 | day-n | `{分} {时} */{N} * *` |
| 每小时 | hour | `{分} * * * *` |
| 每 N 小时 | hour-n | `{分} */{N} * * *` |
| 每 N 分钟 | minute-n | `*/{N} * * * *` |
> **不含「每 N 秒」**runner 60s 轮询,秒级 Cron 与 minute-n 无法 roundtrip;最短周期为 1 分钟。
| 每周 | week | `{分} {时} * * {周}` |
| 每月 | month | `{分} {时} {日} * *` |
秒级:runner 60s 轮询,**不提供「每 N 秒」**(与 minute-n Cron 无法 roundtrip);最短 **1 分钟**
无法反解析的历史 Cron → `custom` 模式保留原表达式编辑。
## 涉及文件
- `frontend/src/utils/scheduleCycle.ts`
- `frontend/src/components/schedules/ScheduleCyclePicker.vue`
- `frontend/src/pages/SchedulesPage.vue`
- `server/utils/schedule_cycle.py`(单测镜像)
## 验收
- 新建各类型周期 → 保存 → 列表显示中文标签 → runner 可触发
- 编辑已有 Cron 调度 → 反解析为对应表单
- 无法识别的 Cron → 自定义模式可保存
@@ -0,0 +1,21 @@
# 调度秒级周期 — 方案讨论(2026-06-08
> 状态:**已关闭** — 用户确认:执行周期仅表示**开始执行时间**,精确到分钟即可,**不需要秒级**。
## 决策(2026-06-08 补充)
- 调度表单**仅配置开始执行周期**(重复方式 + 时:分,或一次性日期)
- **不配置**:同步模式、目标路径、脚本超时、长任务 → 走系统默认(增量同步、各机 target_path、脚本 API 默认超时)
- **仍配置**:任务内容(推送源路径 / 脚本)、目标服务器、启用状态
- 循环调度:宝塔式周期(每天/每N天/每小时/每N小时/每N分钟/每周/每月)+ **时:分** 作为开始时刻
- 一次性调度:**年-月-日 时:分**(`datetime-local`,无秒)
- 存储:继续仅用 `cron_expr`,无需 `cycle_spec` 迁移
## 原问题(second-n roundtrip
已通过**不实现秒级**消除;`minute-n``*/N * * * *` roundtrip 正常。
## 实现
`docs/design/specs/2026-06-08-schedule-cycle-picker-design.md` · `frontend/src/utils/scheduleCycle.ts`
+2 -2
View File
@@ -6,9 +6,9 @@
| 项 | 值 |
|----|-----|
| 分支 | `main` @ **`df95a24`** |
| 分支 | `main` @ **`3f995a7`** |
| 生产 | `https://api.synaglobal.vip` · Docker @ `/opt/nexus` · SSH `azureuser@20.24.218.235``ssh nexus` |
| 最近部署 | 2026-06-08 — 服务器分类 + 脚本库执行详情/进度 |
| 最近部署 | 2026-06-08 — 全选筛选 + 脚本失败分组(`3f995a7` |
## 本会话已完成(2026-06-0708
@@ -0,0 +1,48 @@
# 2026-06-08 登录 IP 门禁 + SPA 无感切换 — 验证报告
## 自动化验证(Agent
| 检查 | 结果 | 证据 |
|------|------|------|
| `pytest tests/test_login_access.py` | PASS | 7/7 |
| `bash scripts/local_verify.sh` | PASS | smoke + test_api 26/26 + ruff |
| `cd frontend && npm run build` | PASS | vue-tsc + vite |
| `bash deploy/pre_deploy_check.sh` | PASS | 7/7 gates |
## 功能点核对
| ID | 验收项 | 本地 | 生产 |
|----|--------|------|------|
| P0-1 | 白名单开启 + 非名单 IP → 无登录表单 | 代码 + 单测 | API 可访问 |
| P0-2 | 白名单 IP / 白名单关闭 → 正常表单 | 单测 | 待浏览器 |
| P1-1 | 登录成功 `router.replace` | 代码审查 | 待浏览器 |
| P2-1 | 侧栏切页 keep-alive 命中 | 代码审查 | 待浏览器 |
| P2-2 | 再激活 silent 刷新 | 代码审查 | 待浏览器 |
| P3-1 | Dashboard/Servers stats 共享缓存 key | 代码审查 | — |
| P4-1 | 侧栏 hover 200ms 预取 chunk | 代码审查 | Network 面板 |
| P4-2 | Dashboard/Servers 首次 loading 骨架屏 | 代码审查 | 待浏览器 |
## 生产部署
| 项 | 值 |
|----|-----|
| 方式 | tar + scp 同步代码(Gitea 500 绕过)+ `docker compose build nexus` |
| 时间 | 2026-06-08 |
| 远程健康 | `/health` → ok · `/app/` → 200 |
| 外网 | `https://api.synaglobal.vip/health` → ok |
## 用户终验清单
1. **非白名单 IP**(或临时关闭本机 IP):打开 `/app/#/login` — 仅提示卡 + 客户端 IP,无用户名/密码/TOTP/登录按钮。
2. **白名单 IP 登录**:正常表单 → 登录成功跳转仪表盘,无明显白屏。
3. **侧栏往返**Dashboard → Servers → Scripts → Push → Files → Dashboard,观察 DOM 保留与无全屏 blocking overlay。
4. **ScriptRuns 详情**:从脚本页进入执行详情,返回列表再进另一详情,数据正确切换。
## 回滚
```bash
git revert <commit>
bash deploy/deploy-production.sh
```
P0 单独 revert 不影响已登录 JWT 会话。
@@ -0,0 +1,36 @@
# 调度执行周期 — 生产部署验证(2026-06-08
## 部署方式
热更新 `nexus-prod-nexus-1``tar``scp``sudo docker cp /app/web/app/`(未重启容器,静态资源即时生效)。
## 部署内容
- `ScheduleCyclePicker` — 执行周期可视化(每天/每N天/每小时/每N小时/每N分钟/每N秒/每周/每月)
- `SchedulesPage` — 列表显示中文周期标签;一次性改为「指定日期(一次)」
## 验证结果
| 项 | 结果 |
|----|------|
| `GET https://api.synaglobal.vip/health` | `ok` |
| `index.html` bundle | `index-DIPA1Yxp.js` |
| 容器内 `SchedulesPage-*.js` 含「执行周期」 | `SchedulesPage-GgD_IRtf.js` ✓ |
## 用户终验
1. **Ctrl+Shift+R** 强刷
2. 打开 **推送调度****新建调度**
3. 选「循环执行」→ 应出现 **执行周期** 下拉(非 Cron 文本框)
4. 选「每天」+ 时/分 → 保存 → 列表显示「每天 HH:MM」
5. 选「指定日期(一次)」→ 出现 datetime 选择器
## 回滚
```bash
ssh nexus 'sudo nx update' # 从 git 重建镜像
```
## 审计
`docs/audit/2026-06-08-schedule-cycle-picker.md`
@@ -0,0 +1,24 @@
# 调度表单简化 — 生产部署验证(2026-06-08
## 部署
热更新 `nexus-prod-nexus-1``tar``scp``sudo docker cp`
## 验证
| 项 | 结果 |
|----|------|
| `/health` | ok |
| bundle | `index-CV_jUdVD.js` |
| `SchedulesPage-*.js` 含「开始执行周期」 | `SchedulesPage-BtHEoHD1.js` ✓ |
## 终验(用户)
1. **Ctrl+Shift+R** 强刷
2. 推送调度 → 新建调度
3. 应仅有:**开始执行周期**、源路径/脚本、服务器;**无**同步模式、目标路径、超时
4. 蓝色提示:「此处仅配置开始执行周期…」
## Changelog
`docs/changelog/2026-06-08-schedule-form-start-cycle-only.md`
@@ -0,0 +1,27 @@
# 调度表单前端 — 生产部署验证(2026-06-08
## 部署方式
`deploy-frontend.sh` 误判 runtime 为 supervisor(路径不存在)→ 改用手动:
1. 本地 `vite build`
2. `tar``scp``/tmp/nexus-frontend.tar.gz`
3. `sudo docker cp``nexus-prod-nexus-1:/app/web/app/`
## 验证
| 项 | 结果 |
|----|------|
| `/health` | ok |
| 对外 `index.html` | `index-D4oqMAQw.js` |
| 容器内 `SchedulesPage` | `SchedulesPage-BzdaRRgz.js` |
| 开始执行周期 | YES |
| 单次执行 | YES |
| 重复方式(周期时间合并) | YES |
| 按分类选择 | YES |
## 终验(用户)
1. **Ctrl+Shift+R** 强刷 `https://api.synaglobal.vip/app/#/schedules`
2. 新建调度 → 确认:周期+时间同一行、按分类选服务器、推送源上传/验证
3. 保存应成功;失败时对话框内红色提示
@@ -0,0 +1,30 @@
# 脚本 UI / 名称 / 顶栏提示 — 生产部署验证(2026-06-08
## 部署信息
| 项 | 值 |
|----|-----|
| Git | `b12fb47`(功能 `06af90d` feat scripts UI |
| 门控 | 7/7 PASS |
| 路径 | `/opt/nexus` Docker |
## 健康检查
| 检查 | 结果 |
|------|------|
| `/health` | ok |
| `/app/` | HTTP 200 |
## 本批功能(请浏览器终验)
1. `#/script-runs/{id}` 失败列表显示真实服务器名(非 `服务器 #219`
2. 脚本提交 / 执行结束提示在顶栏下方正中,3 秒或点击消失
3. 推送校验类 Snackbar 顶栏正中
4. Telegram 脚本完成仅台数汇总
## 命令
```bash
bash deploy/pre_deploy_check.sh
NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/deploy-production.sh
```
@@ -7,9 +7,10 @@
| `pytest tests/test_exec_detail_grouping.py` | 3 passed |
| `pytest tests/test_script_execution_progress.py` | 7 passed |
| `cd frontend && npx vite build` | PASS |
| `local_verify.sh` | API 登录 401(本机 admin 凭据与 `.env` 不一致;与本次纯前端改动无关) |
| Git push | `3f995a7` → Gitea main |
| 生产部署 | 2026-06-08 Docker upgrade @ `/opt/nexus` · `/health` ok · `/app/` 200 |
## 浏览器终验(生产部署后
## 浏览器终验(生产)
### `#/servers`
- [ ] 分类 Chip →「全选筛选结果 (N)」→ N 与 Chip 一致
@@ -0,0 +1,41 @@
# 2026-06-08 服务器分类/分页/排序 — 生产部署验证
## 部署信息
| 项 | 值 |
|----|-----|
| 提交 | `c435413` |
| 时间 | 2026-06-08 |
| 方式 | Docker `nexus-1panel.sh upgrade` |
| 备份 | `/var/backups/nexus/nexus-before-upgrade-20260607-203049.sql.gz` |
## 自动化验证(Agent
| 检查 | 结果 |
|------|------|
| 门控 7/7 | PASS |
| 远程 `/health` | `ok` |
| 远程 `/app/` | HTTP 200 |
| 外网 `https://api.synaglobal.vip/health` | HTTP 200 |
| Ops cron | 已安装 health_monitor + db_backup |
## 用户终验清单
请在浏览器登录 `https://api.synaglobal.vip/app/#/servers`
1. 分类 Chip 筛选各分类数量正确
2. 「列表 / 分类」视图切换正常
3. 勾选多台 →「修改分类」批量生效
4. 表头排序(域名、Agent 版本、最后心跳等)箭头可切换升/降序
5. 分页选「全部」可加载全量(300+ 台)
6. 分页文案为中文(每页数目、上一页、下一页)
`#/scripts`
7. 执行脚本对话框与「临时命令」可按分类勾选服务器
## 回滚
```bash
ssh nexus 'cd /opt/nexus && sudo docker tag nexus-prod-nexus:rollback nexus-prod-nexus:latest && sudo docker compose -f docker/docker-compose.prod.yml up -d --force-recreate'
```
@@ -0,0 +1,23 @@
# 验证 — 服务器列表实时字段排序(2026-06-08)
## 部署
- Commit: `b8425cc`
- 生产: `https://api.synaglobal.vip/app/#/servers`
## 自动化
| 检查 | 结果 |
|------|------|
| `pytest tests/test_server_list_sort.py` | 6 passed |
| `bash scripts/local_verify.sh` | 26/26 |
| 门控 | 7/7 |
| `/health` | 200 |
| `/app/` | 200 |
## 手动终验(用户)
1. `#/servers` 列表视图 · 点「心跳」列头降序 → 最近心跳在前
2. 点「状态」列头 → online / offline / unknown 有序
3. 点「Agent」列头 → 版本字符串有序,无版本在后
4. 分类视图 · 排序后组内顺序与列表一致
@@ -0,0 +1,40 @@
# 执行记录 + 服务器批量任务 — 生产部署验证(2026-06-08
## 部署方式
热更新 `nexus-prod-nexus-1``docker cp` + `docker restart`(未走 `nx update` 镜像重建)。
## 部署内容
- 统一执行记录 API `/api/execution-records`
- 服务器批量任务 MySQL 持久化 `server_batch_jobs`
- 安装 Agent 自动生成 API Key
- 批量安装/升级/卸载后台任务
- 前端:侧栏「执行记录」、`#/executions`、审计「查看结果」跳转
## 验证结果
| 项 | 结果 |
|----|------|
| `GET https://api.synaglobal.vip/health` | `ok` |
| `index.html` bundle | `index-Bp2rGxzW.js` |
| 容器内 `execution_records.py` | 存在 |
| 容器内 `execution_record_service.py` | 存在 |
| 容器内 `server_batch_job_repo.py` | 存在 |
| `GET /api/execution-records`(无 token | 401(端点存在) |
## 用户终验
1. **Ctrl+Shift+R** 强刷
2. 侧栏应显示 **执行记录**(非「脚本看板」)
3. 打开 `#/executions` — 列表含「类型:脚本 / 服务器」
4. 服务器页批量操作(如检测路径)→ 完成后点通知 **查看详情** → 进入统一详情页
5. 审计日志 → 批量操作行 → **查看结果**
## 回滚
```bash
# 生产机
sudo docker restart nexus-prod-nexus-1
# 或 sudo nx update 从 git main 重建镜像(需先 push 代码)
```
+45 -192
View File
@@ -10,121 +10,6 @@
<v-app-bar-title>Nexus</v-app-bar-title>
<!-- Global Search -->
<v-text-field
v-if="auth.isLoggedIn"
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
append-inner-icon="mdi-close"
placeholder="搜索服务器、脚本、凭据..."
variant="solo-inverted"
density="compact"
hide-details
rounded
clearable
class="mx-4"
style="max-width: 360px"
@click:append-inner="clearSearch"
@click:clear="clearSearch"
@update:model-value="onSearchInput"
/>
<v-menu
v-model="searchMenuOpen"
:close-on-content-click="false"
activator="parent"
location="bottom"
:open-on-click="false"
:open-on-focus="false"
>
<v-card v-if="searchResults && searchResults.total > 0" width="420" elevation="8" rounded="lg" class="mt-1">
<v-list density="compact" slim>
<!-- Servers -->
<template v-if="searchResults.servers.length">
<v-list-subheader>
<v-icon size="14" class="mr-1">mdi-server</v-icon>
服务器 ({{ searchResults.servers.length }})
</v-list-subheader>
<v-list-item
v-for="s in searchResults.servers"
:key="'s-' + s.id"
:title="s.name"
:subtitle="s.domain"
prepend-icon="mdi-server"
slim
link
@click="goToServer(s.id)"
/>
</template>
<!-- Scripts -->
<template v-if="searchResults.scripts.length">
<v-divider v-if="searchResults.servers.length" />
<v-list-subheader>
<v-icon size="14" class="mr-1">mdi-code-braces</v-icon>
脚本 ({{ searchResults.scripts.length }})
</v-list-subheader>
<v-list-item
v-for="s in searchResults.scripts"
:key="'sc-' + s.id"
:title="s.name"
:subtitle="s.category || '—'"
prepend-icon="mdi-code-braces"
slim
link
@click="goToScripts()"
/>
</template>
<!-- Credentials -->
<template v-if="searchResults.credentials.length">
<v-divider v-if="searchResults.servers.length || searchResults.scripts.length" />
<v-list-subheader>
<v-icon size="14" class="mr-1">mdi-key</v-icon>
凭据 ({{ searchResults.credentials.length }})
</v-list-subheader>
<v-list-item
v-for="c in searchResults.credentials"
:key="'c-' + c.id"
:title="c.name"
:subtitle="`${c.db_type} @ ${c.host}`"
prepend-icon="mdi-key"
slim
link
@click="goToCredentials()"
/>
</template>
<!-- Schedules -->
<template v-if="searchResults.schedules.length">
<v-divider v-if="searchResults.servers.length || searchResults.scripts.length || searchResults.credentials.length" />
<v-list-subheader>
<v-icon size="14" class="mr-1">mdi-clock</v-icon>
调度 ({{ searchResults.schedules.length }})
</v-list-subheader>
<v-list-item
v-for="s in searchResults.schedules"
:key="'sch-' + s.id"
:title="s.name"
:subtitle="s.cron_expr"
prepend-icon="mdi-clock"
slim
link
@click="goToSchedules()"
/>
</template>
</v-list>
</v-card>
<!-- No results -->
<v-card v-else-if="searchQuery && searchQuery.length >= 2 && !searchLoading" width="420" elevation="8" rounded="lg" class="mt-1">
<v-card-text class="text-center text-medium-emphasis py-6">
<v-icon size="32" class="mb-2">mdi-magnify-close</v-icon>
<div>未找到匹配结果</div>
</v-card-text>
</v-card>
</v-menu>
<template #append>
<v-btn class="text-none me-1 px-3" height="48" slim>
<template #prepend>
@@ -189,6 +74,8 @@
nav
slim
@click="navigateTo(item.to)"
@mouseenter="scheduleRoutePrefetch(item.to)"
@mouseleave="cancelRoutePrefetch(item.to)"
/>
</v-list>
@@ -209,6 +96,8 @@
nav
slim
@click="navigateTo(item.to)"
@mouseenter="scheduleRoutePrefetch(item.to)"
@mouseleave="cancelRoutePrefetch(item.to)"
/>
</v-list>
@@ -223,6 +112,8 @@
nav
slim
@click="router.push('/settings')"
@mouseenter="scheduleRoutePrefetch('/settings')"
@mouseleave="cancelRoutePrefetch('/settings')"
/>
</template>
</v-navigation-drawer>
@@ -236,8 +127,10 @@
v-for="item in runningItems"
:key="item.executionId"
class="nexus-script-exec-bar__item px-4 py-2"
:class="{ 'nexus-script-exec-bar__item--clickable': item.taskKind === 'server_batch' }"
color="surface"
border
@click="item.taskKind === 'server_batch' ? openRunningBatchJob(item) : undefined"
>
<div class="d-flex align-center flex-wrap ga-2 mb-1">
<span class="text-body-2 font-weight-medium text-truncate">{{ item.label }}</span>
@@ -301,7 +194,7 @@
size="small"
variant="text"
color="primary"
@click.stop="goToScriptRun(alert.executionId)"
@click.stop="goToTaskDetail(alert)"
>
查看详情
</v-btn>
@@ -311,7 +204,11 @@
<!-- Main Content -->
<v-main :class="mainLayoutClass" :style="scriptExecBarOffset">
<router-view :key="route.fullPath" />
<router-view v-slot="{ Component, route: activeRoute }">
<keep-alive :include="cachedPageNames" :max="12">
<component :is="Component" :key="activeRoute.name ?? activeRoute.path" />
</keep-alive>
</router-view>
</v-main>
<!-- Snackbar -->
@@ -330,7 +227,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch, onUnmounted, onMounted, provide, computed } from 'vue'
import { ref, reactive, watch, onMounted, provide, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTheme, useDisplay } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
@@ -338,13 +235,21 @@ import { useWebSocket } from '@/composables/useWebSocket'
import {
useScriptExecutionQueue,
onScriptExecutionComplete,
type ScriptExecCompleteAlert,
} from '@/composables/useScriptExecutionQueue'
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
import { executionRecordPath } from '@/utils/executionRecordRoute'
import { useScriptCompleteSound } from '@/composables/useScriptCompleteSound'
import {
scriptSubmitToastState as scriptSubmitToast,
dismissScriptSubmitToast,
} from '@/composables/useScriptSubmitToast'
import { api, http } from '@/api'
import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api'
const cachedPageNames = [...CACHED_PAGE_NAMES]
const route = useRoute()
const router = useRouter()
@@ -361,9 +266,18 @@ function completeAlertType(status: string): 'success' | 'warning' | 'error' | 'i
return 'info'
}
function goToScriptRun(executionId: number) {
dismissCompleteAlert(executionId)
router.push(`/script-runs/${executionId}`).catch(() => {})
function goToTaskDetail(alert: ScriptExecCompleteAlert) {
dismissCompleteAlert(alert.executionId)
if (alert.taskKind === 'server_batch') {
void openServerBatchJobResult(alert.executionId, alert.label)
return
}
router.push(executionRecordPath(alert.executionId, 'script')).catch(() => {})
}
function openRunningBatchJob(item: { executionId: number; label: string; taskKind?: string }) {
if (item.taskKind !== 'server_batch') return
void openServerBatchJobResult(item.executionId, item.label)
}
onMounted(() => {
@@ -429,8 +343,7 @@ const opsItems = [
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
{ to: '/script-runs', title: '脚本看板', icon: 'mdi-clipboard-play-outline' },
{ to: '/credentials',title: '凭据', icon: 'mdi-key-outline' },
{ to: '/executions', title: '执行记录', icon: 'mdi-clipboard-play-outline' },
{ to: '/schedules', title: '调度', icon: 'mdi-clock-outline' },
{ to: '/retries', title: '重试队列', icon: 'mdi-refresh' },
]
@@ -443,62 +356,6 @@ const sysItems = [
const snackbar = reactive({ show: false, text: '', color: 'success' })
// ── Global Search ──
const searchQuery = ref('')
const searchMenuOpen = ref(false)
const searchLoading = ref(false)
const searchResults = ref<SearchResults | null>(null)
let searchTimer: ReturnType<typeof setTimeout>
function onSearchInput() {
clearTimeout(searchTimer)
if (!searchQuery.value || searchQuery.value.length < 2) {
searchResults.value = null
searchMenuOpen.value = false
return
}
searchTimer = setTimeout(() => doSearch(), 300)
}
async function doSearch() {
if (!searchQuery.value || searchQuery.value.length < 2) return
searchLoading.value = true
searchMenuOpen.value = true
try {
const res = await http.get<SearchResults>('/search/', { q: searchQuery.value })
searchResults.value = res
if (res.total === 0) searchMenuOpen.value = true
} catch {
searchResults.value = null
} finally {
searchLoading.value = false
}
}
function clearSearch() {
searchQuery.value = ''
searchResults.value = null
searchMenuOpen.value = false
}
function goToServer(id: number) {
searchMenuOpen.value = false
router.push({ path: '/servers', query: { highlight: String(id) } })
}
function goToScripts() {
searchMenuOpen.value = false
router.push('/scripts')
}
function goToCredentials() {
searchMenuOpen.value = false
router.push('/credentials')
}
function goToSchedules() {
searchMenuOpen.value = false
navigateTo('/schedules')
}
function navigateTo(to: string) {
if (route.path !== to) {
router.push(to).catch(() => {})
@@ -513,6 +370,7 @@ function navigateTo(to: string) {
function doLogout() {
clearCachedQueries()
auth.logout()
router.push('/login')
}
@@ -523,18 +381,6 @@ window.$snackbar = (text: string, color = 'success') => {
snackbar.color = color
snackbar.show = true
}
// Cleanup
onUnmounted(() => clearTimeout(searchTimer))
interface SearchResults {
servers: { id: number; name: string; domain: string; category: string | null; is_online: boolean }[]
scripts: { id: number; name: string; category: string | null }[]
credentials: { id: number; name: string; db_type: string; host: string }[]
schedules: { id: number; name: string; cron_expr: string; enabled: boolean }[]
total: number
query: string
}
</script>
<style>
@@ -600,6 +446,12 @@ interface SearchResults {
pointer-events: auto;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.nexus-script-exec-bar__item--clickable {
cursor: pointer;
}
.nexus-script-exec-bar__item--clickable:hover {
background: rgba(var(--v-theme-primary), 0.04);
}
.nexus-top-center-script-notices {
position: fixed;
@@ -635,6 +487,7 @@ interface SearchResults {
/* 全局 Snackbar:顶栏下方水平居中(校验/警告/保存成功等) */
.v-application .v-snackbar.nexus-top-center-snackbar {
z-index: 10000 !important;
left: 50% !important;
right: auto !important;
transform: translateX(-50%);
+16 -5
View File
@@ -8,12 +8,23 @@ const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
const AUTH_SKIP_REFRESH = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
function normalizeDetail(detail: unknown, fallback: string): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail.map((x: { msg?: string }) => x?.msg || JSON.stringify(x)).join('; ')
let msg = ''
if (typeof detail === 'string') msg = detail
else if (Array.isArray(detail)) {
msg = detail
.map((x: { msg?: string; loc?: unknown[] }) => {
const part = x?.msg || JSON.stringify(x)
const loc = Array.isArray(x.loc)
? x.loc.filter((p) => p !== 'body').join('.')
: ''
return loc ? `${loc}: ${part}` : part
})
.join('; ')
} else if (detail && typeof detail === 'object') {
msg = JSON.stringify(detail)
}
if (detail && typeof detail === 'object') return JSON.stringify(detail)
return fallback
msg = msg.trim()
return msg || fallback
}
let refreshInFlight: Promise<boolean> | null = null
@@ -0,0 +1,32 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="960"
scrollable
@update:model-value="emit('update:modelValue', $event)"
>
<v-card border rounded="lg">
<v-card-title class="d-flex align-center">
凭据管理
<v-spacer />
<v-btn icon="mdi-close" variant="text" size="small" @click="emit('update:modelValue', false)" />
</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<CredentialsManager v-if="modelValue" :active="modelValue" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import CredentialsManager from '@/components/credentials/CredentialsManager.vue'
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
@@ -0,0 +1,435 @@
<template>
<div class="credentials-manager">
<div class="d-flex align-center flex-wrap ga-2 mb-3">
<v-btn-toggle v-model="credTab" mandatory density="compact" variant="outlined">
<v-btn size="small" value="passwords">密码预设</v-btn>
<v-btn size="small" value="sshkeys">SSH 密钥</v-btn>
<v-btn size="small" value="db">数据库凭据</v-btn>
</v-btn-toggle>
<v-spacer />
<v-btn color="primary" variant="flat" size="small" prepend-icon="mdi-plus" @click="openAdd">添加</v-btn>
</div>
<template v-if="credTab === 'passwords'">
<p class="text-body-2 text-medium-emphasis mb-2">
保存常用 SSH 账号+密码模板快速添加服务器时将按顺序轮询所有预设尝试登录
</p>
<v-data-table
:items="passwords"
:headers="passwordHeaders"
:loading="loading"
hover
density="comfortable"
:items-per-page="20"
:items-per-page-options="dataTablePageOptions"
>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatPresetTime(item.created_at) }}</span>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editPassword(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('password', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无密码预设</div>
</template>
</v-data-table>
</template>
<template v-else-if="credTab === 'sshkeys'">
<v-data-table-server
:items="sshKeys"
:headers="sshKeyHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadSshKeys() }"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.private_key_set="{ item }">
<v-chip :color="item.private_key_set ? 'success' : 'grey'" size="small" variant="tonal" label>
{{ item.private_key_set ? '已设置' : '未设置' }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editSshKey(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('sshkey', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
</template>
</v-data-table-server>
</template>
<template v-else>
<v-data-table-server
:items="dbCreds"
:headers="dbCredHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadDbCreds() }"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editDbCred(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('db', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
</template>
</v-data-table-server>
</template>
<v-dialog v-model="showPasswordDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑密码预设' : '新建密码预设' }}</v-card-title>
<v-card-text>
<v-text-field
v-model="pwForm.name"
label="预设名称"
variant="outlined"
density="compact"
class="mb-2"
placeholder="如:生产环境 root 密码"
:rules="[required('预设名称')]"
/>
<v-text-field
v-model="pwForm.username"
label="SSH 用户名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="root"
:rules="[required('SSH 用户名')]"
/>
<v-text-field
v-model="pwForm.password"
label="SSH 密码"
variant="outlined"
density="compact"
type="password"
:placeholder="editingId ? '留空表示不修改' : '必填'"
:rules="editingId ? [] : [required('SSH 密码')]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showPasswordDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="savePassword">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showSshKeyDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑 SSH 密钥' : '新建 SSH 密钥' }}</v-card-title>
<v-card-text>
<v-text-field v-model="skForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="skForm.username" label="SSH 用户名" variant="outlined" density="compact" class="mb-2" placeholder="root" />
<v-textarea v-model="skForm.public_key" label="公钥" variant="outlined" density="compact" rows="3" class="mb-2" />
<v-textarea v-model="skForm.private_key" label="私钥" variant="outlined" density="compact" rows="5" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showSshKeyDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveSshKey">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showDbCredDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑数据库凭据' : '新建数据库凭据' }}</v-card-title>
<v-card-text>
<v-text-field v-model="dbForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.host" label="主机" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.port" label="端口" variant="outlined" density="compact" type="number" class="mb-2" />
<v-text-field v-model="dbForm.username" label="用户名" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.password" label="密码" variant="outlined" density="compact" type="password" class="mb-2" />
<v-text-field v-model="dbForm.database" label="数据库" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDbCredDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveDbCred">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>确定要删除此凭据吗</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDelete">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
defineOptions({ name: 'CredentialsManager' })
const props = defineProps<{
/** 对话框打开时为 true,触发数据加载 */
active?: boolean
}>()
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
interface PasswordItem { id: number; name: string; username?: string; created_at?: string }
interface SshKeyItem { id: number; name: string; username?: string; public_key?: string; private_key_set?: boolean }
interface DbCredItem { id: number; name: string; host: string; port: number; username: string; database?: string }
const loading = ref(false)
const credTab = ref<'passwords' | 'sshkeys' | 'db'>('passwords')
const editingId = ref<number | null>(null)
const page = ref(1)
const totalItems = ref(0)
const itemsPerPage = ref(20)
const passwords = ref<PasswordItem[]>([])
const showPasswordDialog = ref(false)
const pwForm = ref({ name: '', username: 'root', password: '' })
const passwordHeaders = headersWithoutSort([
{ title: '预设名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '创建时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
const sshKeys = ref<SshKeyItem[]>([])
const showSshKeyDialog = ref(false)
const skForm = ref({ name: '', username: 'root', public_key: '', private_key: '' })
const sshKeyHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '私钥', key: 'private_key_set', width: 120 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
const dbCreds = ref<DbCredItem[]>([])
const showDbCredDialog = ref(false)
const dbForm = ref({ name: '', host: '', port: 3306, username: '', password: '', database: '' })
const dbCredHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '主机', key: 'host' },
{ title: '端口', key: 'port', width: 80 },
{ title: '用户名', key: 'username' },
{ title: '数据库', key: 'database' },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
const showDeleteDialog = ref(false)
const deleteType = ref('')
const deleteId = ref<number | null>(null)
function onItemsPerPageChange(n: number) {
itemsPerPage.value = n
page.value = 1
if (credTab.value === 'sshkeys') loadSshKeys()
else if (credTab.value === 'db') loadDbCreds()
}
function formatPresetTime(iso: string | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false })
}
async function loadPasswords(silent = false) {
if (!silent) loading.value = true
try {
const res = await http.getList<PasswordItem>('/presets/')
passwords.value = res.items
totalItems.value = res.total
} catch (e: unknown) {
passwords.value = []
snackbar(formatApiError(e, '加载密码预设失败'), 'error')
} finally {
if (!silent) loading.value = false
}
}
async function loadSshKeys(silent = false) {
if (!silent) loading.value = true
try {
const res = await fetchPagePerPage<SshKeyItem>('/ssh-key-presets/', {}, page.value, itemsPerPage.value)
sshKeys.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
sshKeys.value = []
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
} finally {
if (!silent) loading.value = false
}
}
async function loadDbCreds(silent = false) {
if (!silent) loading.value = true
try {
const res = await fetchPagePerPage<DbCredItem>('/scripts/credentials', {}, page.value, itemsPerPage.value)
dbCreds.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
dbCreds.value = []
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
} finally {
if (!silent) loading.value = false
}
}
function loadCurrentTab(silent = false) {
switch (credTab.value) {
case 'passwords': void loadPasswords(silent); break
case 'sshkeys': void loadSshKeys(silent); break
case 'db': void loadDbCreds(silent); break
}
}
watch(credTab, () => {
page.value = 1
loadCurrentTab()
})
watch(
() => props.active,
(active) => {
if (active) loadCurrentTab()
},
{ immediate: true },
)
function editPassword(item: PasswordItem) {
editingId.value = item.id
pwForm.value = { name: item.name, username: item.username || 'root', password: '' }
showPasswordDialog.value = true
}
async function savePassword() {
const name = pwForm.value.name.trim()
const password = pwForm.value.password
if (!name) {
snackbar('请填写预设名称', 'error')
return
}
if (!editingId.value && !password) {
snackbar('请填写 SSH 密码', 'error')
return
}
try {
const username = pwForm.value.username.trim() || 'root'
if (editingId.value) {
const body: { name: string; username: string; encrypted_pw?: string } = { name, username }
if (password) body.encrypted_pw = password
await http.put(`/presets/${editingId.value}`, body)
} else {
await http.post('/presets/', { name, username, encrypted_pw: password })
}
showPasswordDialog.value = false
loadPasswords()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
}
}
function editSshKey(item: SshKeyItem) {
editingId.value = item.id
skForm.value = { name: item.name, username: item.username || 'root', public_key: item.public_key || '', private_key: '' }
showSshKeyDialog.value = true
}
async function saveSshKey() {
try {
if (editingId.value) await http.put(`/ssh-key-presets/${editingId.value}`, skForm.value)
else await http.post('/ssh-key-presets/', skForm.value)
showSshKeyDialog.value = false
loadSshKeys()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
}
}
function editDbCred(item: DbCredItem) {
editingId.value = item.id
dbForm.value = {
name: item.name,
host: item.host || '',
port: item.port || 3306,
username: item.username || '',
password: '',
database: item.database || '',
}
showDbCredDialog.value = true
}
async function saveDbCred() {
try {
if (editingId.value) await http.put(`/scripts/credentials/${editingId.value}`, dbForm.value)
else await http.post('/scripts/credentials', dbForm.value)
showDbCredDialog.value = false
loadDbCreds()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
}
}
function openAdd() {
editingId.value = null
if (credTab.value === 'passwords') {
pwForm.value = { name: '', username: 'root', password: '' }
showPasswordDialog.value = true
} else if (credTab.value === 'sshkeys') {
skForm.value = { name: '', username: 'root', public_key: '', private_key: '' }
showSshKeyDialog.value = true
} else {
dbForm.value = { name: '', host: '', port: 3306, username: '', password: '', database: '' }
showDbCredDialog.value = true
}
}
function confirmDelete(type: string, item: { id: number }) {
deleteType.value = type
deleteId.value = item.id
showDeleteDialog.value = true
}
async function doDelete() {
try {
if (deleteType.value === 'password') await http.delete(`/presets/${deleteId.value}`)
else if (deleteType.value === 'sshkey') await http.delete(`/ssh-key-presets/${deleteId.value}`)
else await http.delete(`/scripts/credentials/${deleteId.value}`)
showDeleteDialog.value = false
loadCurrentTab()
snackbar('已删除')
} catch (e: unknown) {
snackbar(formatApiError(e, '删除失败'), 'error')
}
}
</script>
@@ -0,0 +1,245 @@
<template>
<div class="schedule-cycle-picker">
<div class="text-caption text-medium-emphasis mb-2">开始执行周期</div>
<v-row dense class="mb-2 align-center">
<v-col cols="12" sm="auto" style="min-width: 140px; flex: 1 1 140px">
<v-select
:model-value="modelValue.type === 'custom' ? null : modelValue.type"
:items="cycleTypeOptions"
item-title="label"
item-value="value"
label="重复方式"
variant="outlined"
density="compact"
hide-details
@update:model-value="onTypeChange"
/>
</v-col>
<!-- 每天 / N / 每周 / 每月: -->
<template v-if="showTimeOfDay">
<v-col cols="6" sm="auto" style="max-width: 88px">
<v-text-field
:model-value="modelValue.hour"
label="时"
type="number"
min="0"
max="23"
variant="outlined"
density="compact"
hide-details
@update:model-value="patch({ hour: toNum($event, 0, 23) })"
/>
</v-col>
<v-col cols="6" sm="auto" style="max-width: 88px">
<v-text-field
:model-value="modelValue.minute"
label="分"
type="number"
min="0"
max="59"
variant="outlined"
density="compact"
hide-details
@update:model-value="patch({ minute: toNum($event, 0, 59) })"
/>
</v-col>
</template>
<!-- 每小时 / N 小时第几分 -->
<template v-if="modelValue.type === 'hour' || modelValue.type === 'hour-n'">
<v-col cols="6" sm="auto" style="max-width: 120px">
<v-text-field
:model-value="modelValue.minute"
label="第几分"
type="number"
min="0"
max="59"
variant="outlined"
density="compact"
hide-details
@update:model-value="patch({ minute: toNum($event, 0, 59) })"
/>
</v-col>
</template>
</v-row>
<div class="text-caption text-medium-emphasis mb-2">
{{ cycleHint }}
</div>
<!-- N -->
<v-text-field
v-if="modelValue.type === 'day-n'"
:model-value="modelValue.interval"
label="天数 N"
type="number"
min="1"
max="365"
variant="outlined"
density="compact"
hint="从每月 1 日起每 N 天执行(与宝塔一致)"
persistent-hint
class="mb-2"
@update:model-value="patch({ interval: toNum($event, 1, 365) })"
/>
<!-- N 小时 -->
<v-text-field
v-if="modelValue.type === 'hour-n'"
:model-value="modelValue.interval"
label="小时 N"
type="number"
min="1"
max="23"
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="patch({ interval: toNum($event, 1, 23) })"
/>
<!-- N 分钟 -->
<v-text-field
v-if="modelValue.type === 'minute-n'"
:model-value="modelValue.interval"
label="分钟 N"
type="number"
:min="SCHEDULE_MIN_INTERVAL_MINUTES"
max="59"
variant="outlined"
density="compact"
hint="从整点起每 N 分钟触发一次"
persistent-hint
class="mb-2"
@update:model-value="patch({ interval: toNum($event, SCHEDULE_MIN_INTERVAL_MINUTES, 59) })"
/>
<!-- 每周 -->
<v-select
v-if="modelValue.type === 'week'"
:model-value="modelValue.week"
:items="WEEKDAY_OPTIONS"
item-title="label"
item-value="value"
label="星期"
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="patch({ week: toNum($event, 0, 6) })"
/>
<!-- 每月 -->
<v-text-field
v-if="modelValue.type === 'month'"
:model-value="modelValue.monthDay"
label="每月几号"
type="number"
min="1"
max="31"
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="patch({ monthDay: toNum($event, 1, 31) })"
/>
<!-- 无法反解析的历史 Cron -->
<template v-if="modelValue.type === 'custom'">
<v-alert type="warning" density="compact" variant="tonal" class="mb-2 text-caption">
当前 Cron 无法映射为可视化周期可保留原表达式或改用上方周期类型重建
</v-alert>
<v-text-field
:model-value="modelValue.customCron"
label="Cron 表达式(5 字段)"
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="patch({ customCron: String($event ?? '') })"
/>
<v-btn size="small" variant="tonal" color="primary" class="mb-2" @click="switchToVisual">
改用可视化周期
</v-btn>
</template>
<div v-if="previewLabel" class="text-caption text-medium-emphasis">
预览{{ previewLabel }}
<span v-if="previewCron" class="ms-1">{{ previewCron }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
CYCLE_TYPE_OPTIONS,
WEEKDAY_OPTIONS,
buildCronFromCycle,
defaultCycleConfig,
formatCycleLabel,
SCHEDULE_MIN_INTERVAL_MINUTES,
type ScheduleCycleConfig,
type ScheduleCycleType,
} from '@/utils/scheduleCycle'
const props = defineProps<{
modelValue: ScheduleCycleConfig
/** 单次执行:在下一匹配时刻跑一次 */
singleShot?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: ScheduleCycleConfig]
}>()
const cycleTypeOptions = CYCLE_TYPE_OPTIONS
const singleShot = computed(() => props.singleShot === true)
const showTimeOfDay = computed(() =>
['day', 'day-n', 'week', 'month'].includes(props.modelValue.type),
)
const cycleHint = computed(() => {
if (singleShot.value) {
return '单次:在下一匹配时刻执行一次(精确到时:分,不含日期)'
}
return '循环:按周期重复执行(精确到时:分)'
})
const previewLabel = computed(() => {
try {
return formatCycleLabel(props.modelValue)
} catch {
return ''
}
})
const previewCron = computed(() => {
try {
if (props.modelValue.type === 'custom' && !props.modelValue.customCron.trim()) return ''
return buildCronFromCycle(props.modelValue)
} catch {
return ''
}
})
function toNum(raw: unknown, min: number, max: number): number {
const n = Math.floor(Number(raw))
if (Number.isNaN(n)) return min
return Math.min(max, Math.max(min, n))
}
function patch(partial: Partial<ScheduleCycleConfig>) {
emit('update:modelValue', { ...props.modelValue, ...partial })
}
function onTypeChange(type: ScheduleCycleType | null) {
if (!type) return
const next = { ...defaultCycleConfig(), ...props.modelValue, type }
emit('update:modelValue', next)
}
function switchToVisual() {
emit('update:modelValue', { ...defaultCycleConfig(), type: 'day' })
}
</script>
@@ -3,7 +3,6 @@
:model-value="modelValue"
max-width="560"
scrollable
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<v-card border>
@@ -21,7 +20,7 @@
<v-divider />
<v-card-text class="pt-4">
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-2" />
<p v-if="loading" class="text-body-2 text-medium-emphasis mb-0">正在执行请稍候</p>
<p v-if="loading" class="text-body-2 text-medium-emphasis mb-0">正在加载结果</p>
<template v-else-if="results.length">
<p class="text-body-2 mb-3">
成功 <span class="text-success font-weight-medium">{{ okCount }}</span>
@@ -35,7 +34,7 @@
</v-icon>
</template>
<v-list-item-title class="text-body-2">
{{ row.server_name || `#${row.server_id}` }}
{{ row.server_name?.trim() || '未知服务器' }}
</v-list-item-title>
<v-list-item-subtitle
v-if="row.success && row.stdout"
@@ -178,7 +178,7 @@ export function useFilesBrowse(fileFilter?: Ref<string>) {
return true
}
async function browse(options?: { force?: boolean }) {
async function browse(options?: { force?: boolean; silent?: boolean }) {
if (!selectedServer.value) return
const path = normalizeRemotePath(currentPath.value)
const serverId = selectedServer.value
@@ -196,7 +196,7 @@ export function useFilesBrowse(fileFilter?: Ref<string>) {
_browseInFlightKey = browseKey
const seq = ++_browseSeq
beginLoading(store)
if (!options?.silent) beginLoading(store)
currentPath.value = path
const ifNoneMatch = options?.force ? undefined : store.getCachedEtag(serverId, path)
@@ -227,7 +227,12 @@ export function useFilesBrowse(fileFilter?: Ref<string>) {
snackbar(msg, 'error')
} finally {
if (seq === _browseSeq) {
if (options?.silent) {
store.loading = false
store.loadingVisible = false
} else {
endLoading(store)
}
syncRouteQuery()
}
if (_browseInFlightKey === browseKey) {
@@ -1,4 +1,4 @@
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, onActivated, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { useServerList } from '@/composables/useServerList'
@@ -16,7 +16,7 @@ import {
import { FILES_HINT_DISMISSED_KEY, FILES_LAST_SERVER_KEY } from './constants'
export function useFilesNavigation(options: {
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
browse: (opts?: { force?: boolean; silent?: boolean }) => Promise<void | undefined>
refreshFilesCapability: () => Promise<void>
clearSelection: () => void
}) {
@@ -134,6 +134,8 @@ export function useFilesNavigation(options: {
})()
}
let navReady = false
onMounted(async () => {
initing.value = true
try {
@@ -163,9 +165,15 @@ export function useFilesNavigation(options: {
routeReady.value = true
} finally {
initing.value = false
navReady = true
}
})
onActivated(() => {
if (!navReady || !pageReady.value || !selectedServer.value) return
void options.browse({ silent: true })
})
watch(
() => route.query.server_id,
(id) => {
+3 -3
View File
@@ -60,8 +60,8 @@ export function usePushLogs(form: PushForm, servers: PushServers) {
loadLogs()
}
async function loadLogs() {
logLoading.value = true
async function loadLogs(silent = false) {
if (!silent) logLoading.value = true
try {
const params: Record<string, string | number> = {
page: logPage.value,
@@ -78,7 +78,7 @@ export function usePushLogs(form: PushForm, servers: PushServers) {
logs.value = []
logTotal.value = 0
} finally {
logLoading.value = false
if (!silent) logLoading.value = false
}
}
+18 -2
View File
@@ -1,4 +1,4 @@
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, onMounted, onActivated, onUnmounted } from 'vue'
import { usePushForm } from './usePushForm'
import { usePushServers } from './usePushServers'
import { usePushProgress } from './usePushProgress'
@@ -20,10 +20,26 @@ export function usePushPage() {
preview.confirmAndPush(() => progress.doPush())
}
onMounted(() => {
let pushReady = false
function refreshPush(silent = false) {
if (!silent) {
servers.loadServers()
logs.loadLogs()
} else {
void servers.loadServers(true)
void logs.loadLogs(true)
}
}
onMounted(() => {
refreshPush(false)
servers.startAutoRefresh(() => progress.pushing.value)
pushReady = true
})
onActivated(() => {
if (pushReady) refreshPush(true)
})
onUnmounted(() => {
@@ -0,0 +1,45 @@
type CacheEntry<T> = { data: T; fetchedAt: number }
const store = new Map<string, CacheEntry<unknown>>()
export const CACHE_KEYS = {
serverStats: 'servers:stats',
serverCategories: 'servers:categories',
} as const
export function invalidateCachedQuery(key: string) {
store.delete(key)
}
export function clearCachedQueries() {
store.clear()
}
/**
* stale-while-revalidate: return cached data immediately when fresh; revalidate in background when stale.
*/
export async function cachedFetch<T>(
key: string,
fetcher: () => Promise<T>,
ttlMs = 30_000,
): Promise<{ data: T; fromCache: boolean }> {
const now = Date.now()
const hit = store.get(key) as CacheEntry<T> | undefined
if (hit && now - hit.fetchedAt < ttlMs) {
return { data: hit.data, fromCache: true }
}
if (hit) {
void fetcher()
.then((data) => {
store.set(key, { data, fetchedAt: Date.now() })
})
.catch(() => {})
return { data: hit.data, fromCache: true }
}
const data = await fetcher()
store.set(key, { data, fetchedAt: now })
return { data, fromCache: false }
}
@@ -0,0 +1,17 @@
import { onActivated, onMounted, ref } from 'vue'
/**
* First visit: onMounted(full load). Re-activation from keep-alive: silent refresh only.
*/
export function usePageActivateRefresh(
refresh: (silent: boolean) => void | Promise<void>,
) {
const ready = ref(false)
onMounted(async () => {
await refresh(false)
ready.value = true
})
onActivated(() => {
if (ready.value) void refresh(true)
})
}
@@ -0,0 +1,47 @@
const ROUTE_CHUNKS: Record<string, () => Promise<unknown>> = {
'/': () => import('@/pages/DashboardPage.vue'),
'/servers': () => import('@/pages/ServersPage.vue'),
'/terminal': () => import('@/pages/TerminalPage.vue'),
'/files': () => import('@/pages/FilesPage.vue'),
'/push': () => import('@/pages/PushPage.vue'),
'/scripts': () => import('@/pages/ScriptsPage.vue'),
'/script-runs': () => import('@/pages/ScriptRunsPage.vue'),
'/executions': () => import('@/pages/ScriptRunsPage.vue'),
'/schedules': () => import('@/pages/SchedulesPage.vue'),
'/retries': () => import('@/pages/RetriesPage.vue'),
'/commands': () => import('@/pages/CommandsPage.vue'),
'/alerts': () => import('@/pages/AlertsPage.vue'),
'/audit': () => import('@/pages/AuditPage.vue'),
'/settings': () => import('@/pages/SettingsPage.vue'),
}
const prefetched = new Set<string>()
const timers = new Map<string, ReturnType<typeof setTimeout>>()
export function scheduleRoutePrefetch(path: string, delayMs = 200) {
const loader = ROUTE_CHUNKS[path]
if (!loader || prefetched.has(path)) return
const existing = timers.get(path)
if (existing) clearTimeout(existing)
timers.set(
path,
setTimeout(() => {
timers.delete(path)
if (prefetched.has(path)) return
prefetched.add(path)
void loader().catch(() => {
prefetched.delete(path)
})
}, delayMs),
)
}
export function cancelRoutePrefetch(path: string) {
const t = timers.get(path)
if (t) {
clearTimeout(t)
timers.delete(path)
}
}
@@ -18,6 +18,8 @@ export interface ScriptExecQueueItem {
progress: string
longTask: boolean
operator?: string
taskKind?: 'script' | 'server_batch'
op?: string
}
export interface ScriptExecCompleteAlert {
@@ -28,6 +30,8 @@ export interface ScriptExecCompleteAlert {
failed: number
total: number
fading: boolean
taskKind?: 'script' | 'server_batch'
op?: string
}
const POLL_MS = 3000
@@ -42,6 +46,7 @@ function mapToItem(msg: Record<string, unknown>): ScriptExecQueueItem {
const total = Number(msg.total) || 0
const done = Number(msg.done) || 0
const remaining = Number(msg.remaining) ?? Math.max(0, total - done)
const taskKind = msg.task_kind === 'server_batch' ? 'server_batch' : 'script'
return {
executionId: Number(msg.execution_id),
label: String(msg.label || `执行 #${msg.execution_id}`),
@@ -54,6 +59,8 @@ function mapToItem(msg: Record<string, unknown>): ScriptExecQueueItem {
progress: String(msg.progress || `${done}/${total}`),
longTask: Boolean(msg.long_task),
operator: msg.operator ? String(msg.operator) : undefined,
taskKind,
op: msg.op ? String(msg.op) : undefined,
}
}
@@ -76,6 +83,8 @@ function pushCompleteAlert(item: ScriptExecQueueItem) {
failed: item.failed,
total: item.total,
fading: false,
taskKind: item.taskKind,
op: item.op,
}
completeAlerts.value = [alert, ...completeAlerts.value].slice(0, 5)
for (const fn of completeListeners) {
@@ -121,7 +130,22 @@ async function pollActive() {
}
for (const id of ids) {
try {
const res = await http.get<{
const prev = activeItems.value.get(id)
const isServerBatch = prev?.taskKind === 'server_batch'
const res = isServerBatch
? await http.get<{
job_id: number
status: string
progress?: string
label?: string
op?: string
total?: number
done?: number
remaining?: number
success?: number
failed?: number
}>(`/servers/batch/jobs/${id}`)
: await http.get<{
id: number
status: string
progress?: string
@@ -130,12 +154,51 @@ async function pollActive() {
server_ids?: string
results?: Record<string, unknown>
}>(`/scripts/executions/${id}`)
const prev = activeItems.value.get(id)
const label = res.label || prev?.label || `执行 #${id}`
let total = prev?.total || 0
if (res.server_ids) {
if (isServerBatch) {
const batchRes = res as {
total?: number
done?: number
remaining?: number
success?: number
failed?: number
op?: string
}
if (typeof batchRes.total === 'number') total = batchRes.total
const progress = res.progress || prev?.progress || `0/${total}`
const done = Number(batchRes.done) || 0
const totalFromProgress = batchRes.total || total
const item: ScriptExecQueueItem = {
executionId: id,
label,
status: res.status,
total: totalFromProgress || total,
done,
remaining: Number(batchRes.remaining) || Math.max(0, (totalFromProgress || total) - done),
success: Number(batchRes.success) || 0,
failed: Number(batchRes.failed) || 0,
progress,
longTask: false,
operator: prev?.operator,
taskKind: 'server_batch',
op: batchRes.op,
}
if (isExecTerminalStatus(res.status)) {
upsertItem(item)
pushCompleteAlert({ ...item, status: res.status })
} else {
upsertItem(item)
}
continue
}
const scriptRes = res as {
server_ids?: string
long_task?: boolean
}
if (scriptRes.server_ids) {
try {
const parsed = JSON.parse(res.server_ids) as unknown
const parsed = JSON.parse(scriptRes.server_ids) as unknown
if (Array.isArray(parsed)) total = parsed.length
} catch {
/* keep */
@@ -155,8 +218,10 @@ async function pollActive() {
success,
failed: 0,
progress,
longTask: Boolean(res.long_task ?? prev?.longTask),
longTask: Boolean(scriptRes.long_task ?? prev?.longTask),
operator: prev?.operator,
taskKind: 'script',
op: prev?.op,
}
if (isExecTerminalStatus(res.status)) {
upsertItem(item)
@@ -211,6 +276,34 @@ export function registerScriptExecution(
failed: 0,
progress: progress || `0/${totalServers}`,
longTask,
taskKind: 'script',
}
upsertItem(item)
ensurePolling()
}
export function registerServerBatchJob(
jobId: number,
label: string,
totalServers: number,
op?: string,
progress?: string,
status = 'running',
) {
if (isExecTerminalStatus(status)) return
const item: ScriptExecQueueItem = {
executionId: jobId,
label,
status,
total: totalServers,
done: 0,
remaining: totalServers,
success: 0,
failed: 0,
progress: progress || `0/${totalServers}`,
longTask: false,
taskKind: 'server_batch',
op,
}
upsertItem(item)
ensurePolling()
@@ -0,0 +1,16 @@
/** Navigate to unified execution record detail (replaces batch result dialog). */
import type { Router } from 'vue-router'
import { executionRecordPath, type ExecutionRecordKind } from '@/utils/executionRecordRoute'
export function navigateToExecutionRecord(
router: Router,
id: number,
kind: ExecutionRecordKind,
) {
return router.push(executionRecordPath(id, kind))
}
export async function openServerBatchJobResult(jobId: number, _label?: string) {
const { default: router } = await import('@/router')
return navigateToExecutionRecord(router, jobId, 'server_batch')
}
@@ -1,5 +1,6 @@
import { ref } from 'vue'
import { http } from '@/api'
import { CACHE_KEYS, cachedFetch } from '@/composables/useCachedQuery'
export interface ServerCategoryOption {
/** API value: `` for all, ``uncategorized`` for empty category */
@@ -17,12 +18,13 @@ export function useServerCategories() {
const loading = ref(false)
const totalCount = ref(0)
async function loadCategories() {
loading.value = true
async function loadCategories(silent = false) {
if (!silent) loading.value = true
try {
const res = await http.get<{
categories: { name: string; count: number }[]
}>('/servers/categories')
const { data: res } = await cachedFetch(
CACHE_KEYS.serverCategories,
() => http.get<{ categories: { name: string; count: number }[] }>('/servers/categories'),
)
const items = res.categories || []
totalCount.value = items.reduce((sum, c) => sum + (c.count || 0), 0)
categories.value = items
@@ -40,7 +42,7 @@ export function useServerCategories() {
categories.value = []
totalCount.value = 0
} finally {
loading.value = false
if (!silent) loading.value = false
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
export function useSnackbar() {
return (text: string, color = 'success') => {
window.$snackbar?.(text, color)
const msg = (text || '').trim() || (color === 'error' ? '操作失败' : '操作完成')
window.$snackbar?.(msg, color)
}
}
+15
View File
@@ -0,0 +1,15 @@
/** Component names included in App.vue keep-alive (exclude Terminal + Login). */
export const CACHED_PAGE_NAMES = [
'DashboardPage',
'ServersPage',
'ScriptsPage',
'ScriptRunsPage',
'PushPage',
'FilesPage',
'SchedulesPage',
'RetriesPage',
'CommandsPage',
'AlertsPage',
'AuditPage',
'SettingsPage',
] as const
+12 -8
View File
@@ -94,12 +94,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref } from 'vue'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AlertLogItem, AlertStatsResponse } from '@/types/api'
import { headersWithoutSort } from '@/constants/dataTable'
defineOptions({ name: 'AlertsPage' })
const snackbar = useSnackbar()
// State
@@ -148,8 +151,8 @@ async function loadStats() {
} catch { snackbar('加载统计失败', 'error') }
}
async function loadAlerts() {
loading.value = true
async function loadAlerts(silent = false) {
if (!silent) loading.value = true
try {
const res = await http.get<PaginatedResponse<AlertLogItem>>('/alert-history/', {
page: page.value,
@@ -160,7 +163,7 @@ async function loadAlerts() {
alerts.value = res.items || []
total.value = res.total || 0
} catch { alerts.value = [] }
finally { loading.value = false }
finally { if (!silent) loading.value = false }
}
// Helpers
@@ -178,8 +181,9 @@ function typeIcon(t: string) {
return 'mdi-lan-disconnect'
}
onMounted(() => {
loadStats()
loadAlerts()
})
async function refreshAlertsPage(silent = false) {
await Promise.all([loadStats(), loadAlerts(silent)])
}
usePageActivateRefresh((silent) => refreshAlertsPage(silent))
</script>
+47 -8
View File
@@ -72,9 +72,23 @@
</template>
<template #item.detail="{ item }">
<span class="text-body-2 text-medium-emphasis text-truncate" style="max-width: 300px; display: inline-block;">
{{ item.detail || '—' }}
<div class="d-flex align-center ga-2 flex-wrap">
<span
class="text-body-2 text-medium-emphasis"
style="max-width: 360px"
>
{{ auditDetailText(item) }}
</span>
<v-btn
v-if="auditBatchMeta(item)"
size="x-small"
variant="tonal"
color="primary"
@click.stop="openBatchResult(auditBatchMeta(item)!)"
>
查看结果
</v-btn>
</div>
</template>
<template #item.ip_address="{ item }">
@@ -93,14 +107,24 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref } from 'vue'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AuditItem } from '@/types/api'
import { auditActionLabel, auditTargetLabel, auditActionFilterOptions } from '@/utils/auditLabels'
import {
formatAuditBatchDetailText,
isBatchJobAuditAction,
parseAuditBatchDetail,
type AuditBatchJobDetail,
} from '@/utils/auditBatchDetail'
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
import { normalizeAuditList } from '@/utils/auditNormalize'
import { headersWithoutSort } from '@/constants/dataTable'
defineOptions({ name: 'AuditPage' })
const snackbar = useSnackbar()
// State
@@ -124,8 +148,8 @@ const headers = headersWithoutSort([
])
// Data loading
async function loadAudit() {
loading.value = true
async function loadAudit(silent = false) {
if (!silent) loading.value = true
try {
const limit = 50
const offset = (page.value - 1) * limit
@@ -138,8 +162,8 @@ async function loadAudit() {
})
logs.value = normalizeAuditList(res.items || [])
total.value = res.total || 0
} catch { logs.value = []; snackbar('加载审计日志失败', 'error') }
finally { loading.value = false }
} catch { logs.value = []; if (!silent) snackbar('加载审计日志失败', 'error') }
finally { if (!silent) loading.value = false }
}
// Helpers
@@ -151,5 +175,20 @@ function actionColor(a: string) {
return 'primary'
}
onMounted(() => loadAudit())
function auditBatchMeta(item: AuditItem): AuditBatchJobDetail | null {
if (!isBatchJobAuditAction(item.action)) return null
return parseAuditBatchDetail(item.detail)
}
function auditDetailText(item: AuditItem): string {
const parsed = auditBatchMeta(item)
if (parsed) return formatAuditBatchDetailText(parsed)
return (item.detail || '').trim() || '—'
}
function openBatchResult(meta: AuditBatchJobDetail) {
void openServerBatchJobResult(meta.job_id, meta.summary)
}
usePageActivateRefresh((silent) => loadAudit(silent))
</script>
+12 -8
View File
@@ -204,7 +204,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref } from 'vue'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
@@ -214,6 +215,8 @@ import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
defineOptions({ name: 'CommandsPage' })
type ViewMode = 'commands' | 'sessions' | 'push'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
@@ -336,8 +339,8 @@ function onItemsPerPageChange(n: number) {
loadData()
}
async function loadData() {
loading.value = true
async function loadData(silent = false) {
if (!silent) loading.value = true
try {
if (viewMode.value === 'commands') {
const res = await fetchPagePerPage<CommandLogItem>(
@@ -377,12 +380,13 @@ async function loadData() {
total.value = 0
snackbar(formatApiError(e, '加载失败'), 'error')
} finally {
loading.value = false
if (!silent) loading.value = false
}
}
onMounted(() => {
loadServers()
loadData()
})
async function refreshCommandsPage(silent = false) {
await Promise.all([loadServers(), loadData(silent)])
}
usePageActivateRefresh((silent) => refreshCommandsPage(silent))
</script>
+10 -414
View File
@@ -1,421 +1,17 @@
<template>
<v-container fluid class="pa-6">
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
凭据管理
<v-spacer />
<v-btn-toggle v-model="credTab" mandatory density="compact" variant="outlined" class="mr-3">
<v-btn size="small" value="passwords">密码预设</v-btn>
<v-btn size="small" value="sshkeys">SSH 密钥</v-btn>
<v-btn size="small" value="db">数据库凭据</v-btn>
</v-btn-toggle>
<v-btn color="primary" variant="flat" size="small" prepend-icon="mdi-plus" @click="openAdd">添加</v-btn>
</v-card-title>
<v-divider />
<template v-if="credTab === 'passwords'">
<v-card-text class="text-body-2 text-medium-emphasis pt-0 pb-2">
保存常用 SSH 账号+密码模板快速添加服务器时将按顺序轮询所有预设尝试登录
</v-card-text>
<v-data-table
:items="passwords"
:headers="passwordHeaders"
:loading="loading"
hover
density="comfortable"
:items-per-page="20"
:items-per-page-options="dataTablePageOptions"
>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatPresetTime(item.created_at) }}</span>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editPassword(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('password', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无密码预设</div>
</template>
</v-data-table>
</template>
<!-- SSH Keys Tab -->
<template v-if="credTab === 'sshkeys'">
<v-data-table-server
:items="sshKeys"
:headers="sshKeyHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadSshKeys() }"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.private_key_set="{ item }">
<v-chip :color="item.private_key_set ? 'success' : 'grey'" size="small" variant="tonal" label>
{{ item.private_key_set ? '已设置' : '未设置' }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editSshKey(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('sshkey', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
</template>
</v-data-table-server>
</template>
<!-- DB Credentials Tab -->
<template v-if="credTab === 'db'">
<v-data-table-server
:items="dbCreds"
:headers="dbCredHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadDbCreds() }"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editDbCred(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('db', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
</template>
</v-data-table-server>
</template>
</v-card>
<!-- Password Dialog -->
<v-dialog v-model="showPasswordDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑密码预设' : '新建密码预设' }}</v-card-title>
<v-card-text>
<v-text-field
v-model="pwForm.name"
label="预设名称"
variant="outlined"
density="compact"
class="mb-2"
placeholder="如:生产环境 root 密码"
:rules="[required('预设名称')]"
/>
<v-text-field
v-model="pwForm.username"
label="SSH 用户名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="root"
:rules="[required('SSH 用户名')]"
/>
<v-text-field
v-model="pwForm.password"
label="SSH 密码"
variant="outlined"
density="compact"
type="password"
:placeholder="editingId ? '留空表示不修改' : '必填'"
:rules="editingId ? [] : [required('SSH 密码')]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showPasswordDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="savePassword">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- SSH Key Dialog -->
<v-dialog v-model="showSshKeyDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑 SSH 密钥' : '新建 SSH 密钥' }}</v-card-title>
<v-card-text>
<v-text-field v-model="skForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="skForm.username" label="SSH 用户名" variant="outlined" density="compact" class="mb-2" placeholder="root" />
<v-textarea v-model="skForm.public_key" label="公钥" variant="outlined" density="compact" rows="3" class="mb-2" />
<v-textarea v-model="skForm.private_key" label="私钥" variant="outlined" density="compact" rows="5" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showSshKeyDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveSshKey">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- DB Credential Dialog -->
<v-dialog v-model="showDbCredDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑数据库凭据' : '新建数据库凭据' }}</v-card-title>
<v-card-text>
<v-text-field v-model="dbForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.host" label="主机" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.port" label="端口" variant="outlined" density="compact" type="number" class="mb-2" />
<v-text-field v-model="dbForm.username" label="用户名" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.password" label="密码" variant="outlined" density="compact" type="password" class="mb-2" />
<v-text-field v-model="dbForm.database" label="数据库" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDbCredDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveDbCred">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirm -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>确定要删除此凭据吗</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDelete">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
/** 旧路由兼容:重定向到服务器页并打开凭据对话框 */
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
function onItemsPerPageChange(n: number) {
itemsPerPage.value = n
page.value = 1
if (credTab.value === 'sshkeys') loadSshKeys()
else if (credTab.value === 'db') loadDbCreds()
}
function formatPresetTime(iso: string | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false })
}
// Credential types
interface PasswordItem { id: number; name: string; username?: string; created_at?: string }
interface SshKeyItem { id: number; name: string; username?: string; public_key?: string; private_key_set?: boolean; description?: string }
interface DbCredItem { id: number; name: string; db_type?: string; host: string; port: number; username: string; password_set?: boolean; database?: string }
// State
const loading = ref(false)
const credTab = ref<'passwords' | 'sshkeys' | 'db'>('passwords')
const editingId = ref<number | null>(null)
// Pagination
const page = ref(1)
const totalItems = ref(0)
const itemsPerPage = ref(20)
// Password presets
const passwords = ref<PasswordItem[]>([])
const showPasswordDialog = ref(false)
const pwForm = ref({ name: '', username: 'root', password: '' })
const passwordHeaders = headersWithoutSort([
{ title: '预设名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '创建时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
// SSH keys
const sshKeys = ref<SshKeyItem[]>([])
const showSshKeyDialog = ref(false)
const skForm = ref({ name: '', username: 'root', public_key: '', private_key: '' })
const sshKeyHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '私钥', key: 'private_key_set', width: 120 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
// DB credentials
const dbCreds = ref<DbCredItem[]>([])
const showDbCredDialog = ref(false)
const dbForm = ref({ name: '', host: '', port: 3306, username: '', password: '', database: '' })
const dbCredHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '主机', key: 'host' },
{ title: '端口', key: 'port', width: 80 },
{ title: '用户名', key: 'username' },
{ title: '数据库', key: 'database' },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
// Delete
const showDeleteDialog = ref(false)
const deleteType = ref('')
const deleteId = ref<number | null>(null)
// Data loading
async function loadPasswords() {
loading.value = true
try {
const res = await http.getList<PasswordItem>('/presets/')
passwords.value = res.items
totalItems.value = res.total
} catch (e: unknown) {
passwords.value = []
snackbar(formatApiError(e, '加载密码预设失败'), 'error')
}
finally { loading.value = false }
}
async function loadSshKeys() {
loading.value = true
try {
const res = await fetchPagePerPage<SshKeyItem>('/ssh-key-presets/', {}, page.value, itemsPerPage.value)
sshKeys.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
sshKeys.value = []
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
}
finally { loading.value = false }
}
async function loadDbCreds() {
loading.value = true
try {
const res = await fetchPagePerPage<DbCredItem>('/scripts/credentials', {}, page.value, itemsPerPage.value)
dbCreds.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
dbCreds.value = []
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
}
finally { loading.value = false }
}
function loadCurrentTab() {
switch (credTab.value) {
case 'passwords': loadPasswords(); break
case 'sshkeys': loadSshKeys(); break
case 'db': loadDbCreds(); break
}
}
watch(credTab, () => { page.value = 1; loadCurrentTab() })
// Password CRUD
function editPassword(item: PasswordItem) {
editingId.value = item.id
pwForm.value = { name: item.name, username: item.username || 'root', password: '' }
showPasswordDialog.value = true
}
async function savePassword() {
const name = pwForm.value.name.trim()
const password = pwForm.value.password
if (!name) {
snackbar('请填写预设名称', 'error')
return
}
if (!editingId.value && !password) {
snackbar('请填写 SSH 密码', 'error')
return
}
try {
const username = pwForm.value.username.trim() || 'root'
if (editingId.value) {
const body: { name: string; username: string; encrypted_pw?: string } = { name, username }
if (password) body.encrypted_pw = password
await http.put(`/presets/${editingId.value}`, body)
} else {
await http.post('/presets/', { name, username, encrypted_pw: password })
}
showPasswordDialog.value = false
loadPasswords()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
}
}
// SSH Key CRUD
function editSshKey(item: SshKeyItem) {
editingId.value = item.id
skForm.value = { name: item.name, username: item.username || 'root', public_key: item.public_key || '', private_key: '' }
showSshKeyDialog.value = true
}
async function saveSshKey() {
try {
if (editingId.value) await http.put(`/ssh-key-presets/${editingId.value}`, skForm.value)
else await http.post('/ssh-key-presets/', skForm.value)
showSshKeyDialog.value = false
loadSshKeys()
snackbar('保存成功')
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
}
// DB Credential CRUD
function editDbCred(item: DbCredItem) {
editingId.value = item.id
dbForm.value = { name: item.name, host: item.host || '', port: item.port || 3306, username: item.username || '', password: '', database: item.database || '' }
showDbCredDialog.value = true
}
async function saveDbCred() {
try {
if (editingId.value) await http.put(`/scripts/credentials/${editingId.value}`, dbForm.value)
else await http.post('/scripts/credentials', dbForm.value)
showDbCredDialog.value = false
loadDbCreds()
snackbar('保存成功')
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
}
// Delete
function openAdd() {
editingId.value = null
if (credTab.value === 'passwords') { pwForm.value = { name: '', username: 'root', password: '' }; showPasswordDialog.value = true }
else if (credTab.value === 'sshkeys') { skForm.value = { name: '', username: 'root', public_key: '', private_key: '' }; showSshKeyDialog.value = true }
else { dbForm.value = { name: '', host: '', port: 3306, username: '', password: '', database: '' }; showDbCredDialog.value = true }
}
function confirmDelete(type: string, item: { id: number }) {
deleteType.value = type
deleteId.value = item.id
showDeleteDialog.value = true
}
async function doDelete() {
try {
if (deleteType.value === 'password') await http.delete(`/presets/${deleteId.value}`)
else if (deleteType.value === 'sshkey') await http.delete(`/ssh-key-presets/${deleteId.value}`)
else await http.delete(`/scripts/credentials/${deleteId.value}`)
showDeleteDialog.value = false
loadCurrentTab()
snackbar('已删除')
} catch (e: any) { snackbar(e.message || '删除失败', 'error') }
}
defineOptions({ name: 'CredentialsPage' })
const router = useRouter()
onMounted(() => {
loadCurrentTab()
router.replace({ path: '/servers', query: { credentials: '1' } })
})
</script>
<template>
<div />
</template>
+41 -16
View File
@@ -1,6 +1,7 @@
<template>
<v-container fluid class="pa-4 pa-md-6 dashboard-root">
<StatCardsRow :items="statItems" />
<v-skeleton-loader v-if="statsBooting" type="heading, text" class="mb-4" />
<StatCardsRow v-else :items="statItems" />
<!-- Server Table (v-card border elevation-0, same as official "Recent Orders") -->
<v-card class="my-5" elevation="0" rounded="lg" title="服务器列表" border>
@@ -16,7 +17,13 @@
class="mb-4"
/>
<v-skeleton-loader
v-if="loading && !servers.length"
type="table-heading, table-row-divider@6"
class="mb-4"
/>
<v-data-table-server
v-else
:items="servers"
:headers="headers"
:items-length="total"
@@ -234,12 +241,14 @@
</style>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useWebSocket } from '@/composables/useWebSocket'
import { useServerPagination } from '@/composables/useServerPagination'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { CACHE_KEYS, cachedFetch } from '@/composables/useCachedQuery'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { auditSummary } from '@/utils/auditLabels'
import { normalizeAuditList } from '@/utils/auditNormalize'
@@ -247,11 +256,15 @@ import type { ServerApiItem, AuditItem, AlertLogItem } from '@/types/api'
import StatCardsRow from '@/components/StatCardsRow.vue'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
defineOptions({ name: 'DashboardPage' })
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const router = useRouter()
const statsBooting = ref(true)
// Stats
const statItems = ref([
{ subtitle: '服务器总数', title: '0', icon: 'mdi-server-network', color: 'blue' },
@@ -266,11 +279,11 @@ const {
loadServers: _loadServers, onSearch, onPageChange, onItemsPerPageChange,
} = useServerPagination()
async function loadServers() {
async function loadServers(silent = false) {
try {
await _loadServers()
await _loadServers({ silent })
} catch {
snackbar('加载服务器列表失败', 'error')
if (!silent) snackbar('加载服务器列表失败', 'error')
}
}
@@ -303,9 +316,13 @@ const statsTotal = ref(0)
const recentAudit = ref<AuditItem[]>([])
// Data Loading
async function loadStats() {
async function loadStats(silent = false) {
if (!silent) statsBooting.value = true
try {
const s = await http.get<{ total: number; online: number; offline: number; alerts: number; categories: Record<string, number> }>('/servers/stats')
const { data: s } = await cachedFetch(
CACHE_KEYS.serverStats,
() => http.get<{ total: number; online: number; offline: number; alerts: number; categories: Record<string, number> }>('/servers/stats'),
)
statItems.value[0].title = String(s.total)
statItems.value[1].title = String(s.online)
statItems.value[2].title = String(s.offline)
@@ -320,7 +337,9 @@ async function loadStats() {
summaryItems.value[0].progress = onlineRate
summaryItems.value[0].value = `${onlineRate}%`
} catch {
snackbar('加载统计数据失败', 'error')
if (!silent) snackbar('加载统计数据失败', 'error')
} finally {
if (!silent) statsBooting.value = false
}
}
@@ -391,6 +410,19 @@ function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
async function refreshDashboard(silent = false) {
await Promise.all([
loadStats(silent),
loadServers(silent),
loadAlerts(),
loadSummary(),
loadRecentAudit(),
])
if (!silent) {
http.post('/settings/bing-wallpapers/sync').catch(() => {})
}
}
// Init
// NOTE: WebSocket is managed by App.vue (connected when auth.isLoggedIn=true),
// so we reuse that global singleton instead of creating a second connection.
@@ -400,14 +432,7 @@ watch(ws.alerts, () => {
loadAlerts()
})
onMounted(() => {
loadStats()
loadServers()
loadAlerts()
loadSummary()
loadRecentAudit()
http.post('/settings/bing-wallpapers/sync').catch(() => {})
})
usePageActivateRefresh((silent) => refreshDashboard(silent))
interface AlertItem {
title: string
+2
View File
@@ -30,6 +30,8 @@ import { provideFilesPage } from '@/composables/files/filesPageContext'
import { useFilesPage } from '@/composables/files/useFilesPage'
import { useFilesStore } from '@/stores/files'
defineOptions({ name: 'FilesPage' })
const page = useFilesPage()
provideFilesPage(page)
+48 -16
View File
@@ -1,23 +1,18 @@
<template>
<div class="login-page">
<!-- Fullscreen wallpaper slideshow -->
<div class="wallpaper-bg" :style="{ backgroundImage: `url(${currentWallpaper})` }" />
<div class="wallpaper-bg" :style="wallpaperStyle" />
<div class="wallpaper-overlay" />
<div class="login-center">
<div v-if="accessState === 'form'" class="login-center">
<v-card elevation="0" rounded="xl" class="login-card pa-10" border>
<!-- Error -->
<v-alert v-if="error" type="error" density="compact" class="mb-4" closable @click:close="error = ''" rounded="lg" variant="tonal">
{{ error }}
</v-alert>
<!-- Lockout -->
<v-alert v-if="lockoutUntil" type="warning" density="compact" class="mb-4" rounded="lg" variant="tonal">
账户已锁定 {{ remainingMinutes }} 分钟后重试
</v-alert>
<!-- Form -->
<v-form @submit.prevent="doLogin">
<v-text-field
v-model="username"
@@ -94,10 +89,16 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ApiError, TotpRequiredError, http } from '@/api'
defineOptions({ name: 'LoginPage' })
type AccessState = 'checking' | 'blocked' | 'form'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const accessState = ref<AccessState>('checking')
const username = ref('')
const password = ref('')
const totpCode = ref('')
@@ -108,14 +109,20 @@ const needTotp = ref(false)
const failedAttempts = ref(0)
const lockoutUntil = ref<string | null>(null)
// Wallpaper slideshow
const wallpapers = ref<string[]>([])
const currentIndex = ref(0)
let rotateTimer: ReturnType<typeof setInterval> | null = null
const currentWallpaper = computed(() => {
if (wallpapers.value.length === 0) return ''
return wallpapers.value[currentIndex.value]
const wallpaperStyle = computed(() => {
if (accessState.value === 'checking') {
return { backgroundColor: '#0a0a12' }
}
if (wallpapers.value.length === 0) {
return {
backgroundImage: 'linear-gradient(135deg, #1a237e 0%, #0d47a1 40%, #1565c0 100%)',
}
}
return { backgroundImage: `url(${wallpapers.value[currentIndex.value]})` }
})
async function loadWallpapers() {
@@ -123,7 +130,6 @@ async function loadWallpapers() {
const data = await http.get<{ urls: string[] }>('/settings/bing-wallpapers')
if (data?.urls?.length) {
wallpapers.value = data.urls
// Random start position
currentIndex.value = Math.floor(Math.random() * data.urls.length)
}
} catch { /* gradient fallback */ }
@@ -132,13 +138,36 @@ async function loadWallpapers() {
function startRotation() {
if (rotateTimer) clearInterval(rotateTimer)
if (wallpapers.value.length <= 1) return
// Rotate every hour
rotateTimer = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % wallpapers.value.length
}, 3600_000)
}
async function checkLoginAccess(): Promise<boolean> {
accessState.value = 'checking'
try {
await http.get<{ allowed: boolean }>('/auth/login-access')
accessState.value = 'form'
return true
} catch (e) {
// 403 200fail-closed
accessState.value = 'blocked'
return false
}
}
function prefetchDashboard() {
if (accessState.value === 'form') {
void import('@/pages/DashboardPage.vue')
}
}
watch(accessState, (state) => {
if (state === 'form') prefetchDashboard()
})
onMounted(async () => {
await checkLoginAccess()
await loadWallpapers()
startRotation()
})
@@ -177,12 +206,16 @@ async function doLogin() {
await auth.login(username.value, password.value, needTotp.value ? totpCode.value : undefined)
const raw = (route.query.redirect as string) || '/'
const redirect = raw.startsWith('/') && !raw.startsWith('//') ? raw : '/'
router.push(redirect)
} catch (e: any) {
await router.replace(redirect)
} catch (e: unknown) {
if (e instanceof TotpRequiredError) {
needTotp.value = true
error.value = e.message
} else if (e instanceof ApiError) {
if (e.status === 403) {
accessState.value = 'blocked'
return
}
if (e.status === 429) {
lockoutUntil.value = new Date(Date.now() + 15 * 60 * 1000).toISOString()
error.value = '登录尝试过多,账户已锁定 15 分钟'
@@ -207,7 +240,6 @@ async function doLogin() {
overflow: hidden;
}
/* Fullscreen wallpaper with fade */
.wallpaper-bg {
position: fixed;
inset: 0;
+2
View File
@@ -146,5 +146,7 @@ import PushToolbar from '@/components/push/PushToolbar.vue'
import PushZipUpload from '@/components/push/PushZipUpload.vue'
import { usePushPage } from '@/composables/push'
defineOptions({ name: 'PushPage' })
const { form, staging, servers, progress, preview, logs, onConfirmAndPush } = usePushPage()
</script>
+5 -3
View File
@@ -88,13 +88,16 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref } from 'vue'
import { usePageAutoRefresh } from '@/composables/usePageAutoRefresh'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, RetryItem } from '@/types/api'
import { headersWithoutSort } from '@/constants/dataTable'
defineOptions({ name: 'RetriesPage' })
const snackbar = useSnackbar()
// State
@@ -193,6 +196,5 @@ function retryOperationLabel(item: RetryItem): string {
usePageAutoRefresh(() => loadRetries(true), 15_000)
onMounted(() => loadRetries())
usePageActivateRefresh((silent) => loadRetries(silent))
</script>
+178 -162
View File
@@ -27,19 +27,13 @@
</template>
<template #item.run_mode="{ item }">
<span class="text-caption">{{ item.run_mode === 'once' ? '一次性' : 'Cron' }}</span>
<span class="text-caption">{{ item.run_mode === 'once' ? '单次' : '循环' }}</span>
</template>
<template #item.cron_expr="{ item }">
<v-chip v-if="item.run_mode === 'cron'" size="small" variant="tonal" label prepend-icon="mdi-clock-outline">
{{ item.cron_expr || '—' }}
<v-chip size="small" variant="tonal" label prepend-icon="mdi-clock-outline">
{{ formatScheduleCycleDisplay(item.run_mode, item.cron_expr, item.fire_at) }}
</v-chip>
<span v-else class="text-caption text-medium-emphasis">{{ formatFireAt(item.fire_at) }}</span>
</template>
<template #item.sync_mode="{ item }">
<span v-if="item.schedule_type !== 'script'" class="text-caption">{{ syncModeLabel(item.sync_mode) }}</span>
<span v-else class="text-caption text-medium-emphasis"></span>
</template>
<template #item.enabled="{ item }">
@@ -76,10 +70,22 @@
</v-data-table-server>
</v-card>
<v-dialog v-model="showEditor" max-width="640" scrollable>
<v-dialog v-model="showEditor" max-width="800" scrollable>
<v-card border>
<v-card-title>{{ editingSchedule ? '编辑调度' : '新建调度' }}</v-card-title>
<v-card-text>
<v-alert
v-if="saveError"
type="error"
density="compact"
variant="tonal"
class="mb-3"
closable
@click:close="saveError = ''"
>
{{ saveError }}
</v-alert>
<v-text-field v-model="form.name" label="调度名称" variant="outlined" density="compact" class="mb-2" />
<v-btn-toggle v-model="form.schedule_type" mandatory density="compact" class="mb-3" color="primary">
@@ -88,50 +94,73 @@
</v-btn-toggle>
<v-btn-toggle v-model="form.run_mode" mandatory density="compact" class="mb-3" color="primary">
<v-btn value="cron" size="small">Cron 循环</v-btn>
<v-btn value="once" size="small">一次性</v-btn>
<v-btn value="once" size="small">单次执行</v-btn>
<v-btn value="cron" size="small">循环执行</v-btn>
</v-btn-toggle>
<v-text-field
v-if="form.run_mode === 'cron'"
v-model="form.cron"
label="Cron 表达式(5 字段)"
variant="outlined"
density="compact"
hint="分 时 日 月 周,例如: 0 2 * * *"
persistent-hint
class="mb-2"
/>
<v-text-field
v-else
v-model="form.fire_at"
label="执行时间"
type="datetime-local"
variant="outlined"
density="compact"
<ScheduleCyclePicker
v-model="form.cycle"
:single-shot="form.run_mode === 'once'"
class="mb-2"
/>
<v-alert type="info" density="compact" variant="tonal" class="mb-3 text-caption">
上方开始执行周期含重复方式与时:同步模式目标路径脚本超时与长任务等执行参数按系统设置及各服务器配置请在推送脚本库系统设置中调整
</v-alert>
<template v-if="form.schedule_type === 'push'">
<v-text-field v-model="form.source_path" label="推送源路径(Nexus 主机)" variant="outlined" density="compact" class="mb-2" />
<v-text-field
v-model="form.target_path"
label="目标路径(可选)"
variant="outlined"
density="compact"
hint="留空则使用各服务器在「服务器管理」中配置的目标路径"
persistent-hint
<PushZipUpload
class="mb-2"
:uploaded-source="pushSource.uploadedSource"
:validated-source="pushSource.validatedSource"
v-model:source-path="pushSource.sourcePath"
:source-path-validating="pushSource.sourcePathValidating"
:is-dragging="pushSource.isDragging"
:source-uploading="pushSource.sourceUploading"
@dragover="pushSource.isDragging = true"
@dragleave="pushSource.isDragging = false"
@drop="pushSource.handleFileDrop"
@file-select="pushSource.handleFileSelect"
@zip-extract-select="pushSource.handleZipExtractSelect"
@clear="pushSource.clearUploadedSource"
@clear-validated="pushSource.clearValidatedSource"
@validate="pushSource.validateSourcePath"
/>
<v-select
v-model="form.sync_mode"
:items="syncModeOptions"
item-title="label"
item-value="value"
label="同步模式"
variant="outlined"
density="compact"
<PushStagingPanel
v-if="pushSource.uploadedSource"
class="mb-2"
:root-path="pushSource.uploadedSource.sourcePath"
:relative-path="pushStaging.relativePath"
:entries="pushStaging.entries"
:loading="pushStaging.loading"
:selected-count="pushStaging.selectedCount"
:all-selected="pushStaging.allSelected"
:some-selected="pushStaging.someSelected"
:is-selected="pushStaging.isSelected"
v-model:rename-open="pushStaging.renameOpen"
v-model:rename-value="pushStaging.renameValue"
:rename-loading="pushStaging.renameLoading"
v-model:delete-open="pushStaging.deleteOpen"
:delete-targets="pushStaging.deleteTargets"
:delete-loading="pushStaging.deleteLoading"
@refresh="pushStaging.refreshCurrent()"
@go-root="pushStaging.goRoot"
@open-dir="pushStaging.openDirectory"
@preview="pushStaging.previewFile"
@rename="pushStaging.openRename"
@delete="pushStaging.openDelete"
@batch-delete="pushStaging.openBatchDelete"
@toggle-select="pushStaging.toggleSelect"
@toggle-select-all="pushStaging.toggleSelectAll"
@confirm-rename="pushStaging.confirmRename"
@confirm-delete="pushStaging.confirmDelete"
/>
<PushStagingPreviewDialog
v-model="pushStaging.previewOpen"
:loading="pushStaging.previewLoading"
:result="pushStaging.previewResult"
/>
</template>
@@ -156,29 +185,14 @@
auto-grow
class="mb-2"
/>
<v-text-field
v-model.number="form.exec_timeout"
label="超时(秒)"
type="number"
min="10"
max="600"
variant="outlined"
density="compact"
class="mb-2"
/>
<v-switch v-model="form.long_task" label="长任务(nohup" color="primary" density="compact" hide-details class="mb-2" />
</template>
<v-select
v-model="form.server_ids"
:items="serverList"
item-title="name"
item-value="id"
label="目标服务器"
variant="outlined"
density="compact"
multiple
chips
<div class="text-caption text-medium-emphasis mb-1">目标服务器按分类选择</div>
<ServerCategoryPicker
v-model="editorServerIds"
:servers="serverList"
:loading="serversLoading"
max-height="320px"
class="mb-2"
/>
<v-switch v-model="form.enabled" label="启用" color="primary" density="compact" hide-details />
@@ -206,24 +220,55 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, reactive } from 'vue'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import { usePushForm } from '@/composables/push/usePushForm'
import { usePushStaging } from '@/composables/push/usePushStaging'
import { formatApiError } from '@/utils/apiError'
import ScheduleCyclePicker from '@/components/schedules/ScheduleCyclePicker.vue'
import ServerCategoryPicker from '@/components/servers/ServerCategoryPicker.vue'
import PushZipUpload from '@/components/push/PushZipUpload.vue'
import PushStagingPanel from '@/components/push/PushStagingPanel.vue'
import PushStagingPreviewDialog from '@/components/push/PushStagingPreviewDialog.vue'
import type { ScheduleItem, ScriptItem } from '@/types/api'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort, sliceClientPage } from '@/constants/dataTable'
import {
buildCronFromCycle,
computeNextCronRun,
cycleFromSchedule,
defaultCycleConfig,
formatScheduleCycleDisplay,
validateCycleConfig,
type ScheduleCycleConfig,
} from '@/utils/scheduleCycle'
defineOptions({ name: 'SchedulesPage' })
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const { servers: serverList, loadServers } = useServerList()
const { servers: serverList, loading: serversLoading, loadServers } = useServerList()
const syncModeOptions = [
{ value: 'incremental', label: '增量' },
{ value: 'full', label: '完全同步' },
{ value: 'overwrite', label: '覆盖' },
{ value: 'checksum', label: '校验和' },
]
const pushSourceRaw = usePushForm()
const pushStagingRaw = usePushStaging(pushSourceRaw.uploadedSource)
const pushSource = reactive(pushSourceRaw)
const pushStaging = reactive(pushStagingRaw)
function resetPushSource() {
pushSource.clearUploadedSource()
pushSource.clearValidatedSource()
}
async function loadPushSourceFromPath(path: string) {
resetPushSource()
const p = path.trim()
if (!p) return
pushSource.sourcePath = p
await pushSource.validateSourcePath()
}
const loading = ref(false)
const togglingId = ref<number | null>(null)
@@ -238,20 +283,15 @@ const itemsPerPage = ref(20)
const showEditor = ref(false)
const editingSchedule = ref<ScheduleItem | null>(null)
const saving = ref(false)
const saveError = ref('')
const editorServerIds = ref<number[]>([])
const form = ref({
name: '',
schedule_type: 'push' as 'push' | 'script',
run_mode: 'cron' as 'cron' | 'once',
cron: '',
fire_at: '',
source_path: '',
target_path: '',
sync_mode: 'incremental' as string,
run_mode: 'once' as 'cron' | 'once',
cycle: defaultCycleConfig() as ScheduleCycleConfig,
script_id: null as number | null,
script_content: '',
exec_timeout: 60,
long_task: false,
server_ids: [] as number[],
enabled: true,
})
@@ -262,10 +302,8 @@ const headers = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '类型', key: 'schedule_type', width: 80 },
{ title: '模式', key: 'run_mode', width: 72 },
{ title: 'Cron / 执行时间', key: 'cron_expr', width: 160 },
{ title: '源路径', key: 'source_path' },
{ title: '目标路径', key: 'target_path' },
{ title: '同步', key: 'sync_mode', width: 88 },
{ title: '开始执行周期', key: 'cron_expr', width: 180 },
{ title: '源文件 / 脚本', key: 'source_path' },
{ title: '服务器', key: 'server_count', width: 80 },
{ title: '上次运行', key: 'last_run_at', width: 140 },
{ title: '下次运行', key: 'next_run', width: 140 },
@@ -273,23 +311,6 @@ const headers = headersWithoutSort([
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
])
function syncModeLabel(mode?: string): string {
return syncModeOptions.find((o) => o.value === mode)?.label || mode || '增量'
}
function formatFireAt(raw: string | null | undefined): string {
if (!raw) return '—'
return raw.replace('T', ' ').slice(0, 16)
}
function toDatetimeLocalValue(raw: string | null | undefined): string {
if (!raw) return ''
const d = new Date(raw)
if (Number.isNaN(d.getTime())) return raw.slice(0, 16)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function parseServerIds(raw: unknown): number[] {
if (Array.isArray(raw)) return raw.map((x) => Number(x)).filter((n) => !Number.isNaN(n))
if (typeof raw === 'string' && raw.trim()) {
@@ -309,14 +330,8 @@ function normalizeSchedule(row: ScheduleItem): ScheduleItem {
return { ...row, server_ids: parseServerIds(row.server_ids) }
}
function validateCron(expr: string): boolean {
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return false
return parts.every((p) => /^[\d*/,\-]+$/.test(p))
}
function buildSchedulePayload(): Record<string, unknown> {
const ids = form.value.server_ids
const ids = editorServerIds.value
if (!form.value.name.trim()) throw new Error('请填写调度名称')
if (!ids.length) throw new Error('请至少选择一台服务器')
@@ -328,29 +343,34 @@ function buildSchedulePayload(): Record<string, unknown> {
enabled: form.value.enabled,
}
if (form.value.run_mode === 'cron') {
if (!form.value.cron.trim()) throw new Error('请填写 Cron 表达式')
if (!validateCron(form.value.cron.trim())) throw new Error('Cron 须为 5 字段(分 时 日 月 周)')
payload.cron_expr = form.value.cron.trim()
const cycleErr = validateCycleConfig(form.value.cycle)
if (cycleErr) throw new Error(cycleErr)
const cronExpr = buildCronFromCycle(form.value.cycle)
payload.cron_expr = cronExpr
if (form.value.run_mode === 'once') {
const next = computeNextCronRun(cronExpr)
if (!next) throw new Error('无法计算单次执行时间,请检查开始执行周期')
payload.fire_at = next.toISOString()
} else {
if (!form.value.fire_at) throw new Error('请选择一次性执行时间')
payload.fire_at = new Date(form.value.fire_at).toISOString()
payload.fire_at = null
}
if (form.value.schedule_type === 'push') {
if (!form.value.source_path.trim()) throw new Error('请填写推送源路径')
payload.source_path = form.value.source_path.trim()
const tp = form.value.target_path.trim()
if (tp) payload.target_path = tp
payload.sync_mode = form.value.sync_mode
const src = pushSource.effectiveSourcePath()
if (!src) {
if (pushSource.sourcePath.trim() && !pushSource.validatedSource) {
throw new Error('请先点击「验证路径」确认 Nexus 主机上的源目录')
}
throw new Error('请上传源文件或验证 Nexus 主机源路径')
}
payload.source_path = src
} else {
if (!form.value.script_id && !form.value.script_content.trim()) {
throw new Error('脚本调度须选择脚本库或填写命令内容')
}
if (form.value.script_id) payload.script_id = form.value.script_id
if (form.value.script_content.trim()) payload.script_content = form.value.script_content.trim()
payload.exec_timeout = form.value.exec_timeout
payload.long_task = form.value.long_task
}
return payload
@@ -376,8 +396,8 @@ async function loadScripts() {
}
}
async function loadSchedules() {
loading.value = true
async function loadSchedules(silent = false) {
if (!silent) loading.value = true
try {
const res = await http.getList<ScheduleItem>('/schedules/')
const all = (res.items || []).map(normalizeSchedule)
@@ -386,46 +406,39 @@ async function loadSchedules() {
} catch (e: unknown) {
schedules.value = []
totalItems.value = 0
snackbar(e instanceof Error ? e.message : '加载调度列表失败', 'error')
snackbar(formatApiError(e, '加载调度列表失败'), 'error')
} finally {
loading.value = false
if (!silent) loading.value = false
}
}
function openEditor(schedule: ScheduleItem | null) {
editingSchedule.value = schedule
saveError.value = ''
resetPushSource()
if (schedule) {
editorServerIds.value = parseServerIds(schedule.server_ids)
form.value = {
name: schedule.name,
schedule_type: (schedule.schedule_type === 'script' ? 'script' : 'push'),
run_mode: (schedule.run_mode === 'once' ? 'once' : 'cron'),
cron: schedule.cron_expr || '',
fire_at: toDatetimeLocalValue(schedule.fire_at),
source_path: schedule.source_path || '',
target_path: schedule.target_path || '',
sync_mode: schedule.sync_mode || 'incremental',
run_mode: (schedule.run_mode === 'cron' ? 'cron' : 'once'),
cycle: cycleFromSchedule(schedule.cron_expr, schedule.fire_at),
script_id: schedule.script_id ?? null,
script_content: schedule.script_content || '',
exec_timeout: schedule.exec_timeout ?? 60,
long_task: !!schedule.long_task,
server_ids: parseServerIds(schedule.server_ids),
enabled: schedule.enabled,
}
if (form.value.schedule_type === 'push' && schedule.source_path) {
void loadPushSourceFromPath(schedule.source_path)
}
} else {
editorServerIds.value = []
form.value = {
name: '',
schedule_type: 'push',
run_mode: 'cron',
cron: '',
fire_at: '',
source_path: '',
target_path: '',
sync_mode: 'incremental',
run_mode: 'once',
cycle: defaultCycleConfig(),
script_id: null,
script_content: '',
exec_timeout: 60,
long_task: false,
server_ids: [],
enabled: true,
}
}
@@ -433,6 +446,7 @@ function openEditor(schedule: ScheduleItem | null) {
}
async function saveSchedule() {
saveError.value = ''
saving.value = true
try {
const payload = buildSchedulePayload()
@@ -442,7 +456,9 @@ async function saveSchedule() {
loadSchedules()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
const msg = formatApiError(e, '保存失败')
saveError.value = msg
snackbar(msg, 'error')
} finally {
saving.value = false
}
@@ -455,7 +471,7 @@ async function toggleEnabled(item: ScheduleItem) {
loadSchedules()
} catch (e: unknown) {
item.enabled = !item.enabled
snackbar(e instanceof Error ? e.message : '操作失败', 'error')
snackbar(formatApiError(e, '操作失败'), 'error')
} finally {
togglingId.value = null
}
@@ -470,22 +486,18 @@ async function runNow(item: ScheduleItem) {
server_ids: serverIds,
script_id: item.script_id ?? undefined,
command: item.script_content || undefined,
timeout: item.exec_timeout ?? 60,
long_task: !!item.long_task,
})
showScriptSubmitToast('已触发脚本执行')
} else {
const body: Record<string, unknown> = {
await http.post('/sync/files', {
source_path: item.source_path,
server_ids: serverIds,
sync_mode: item.sync_mode || 'incremental',
}
if (item.target_path?.trim()) body.target_path = item.target_path.trim()
await http.post('/sync/files', body)
sync_mode: 'incremental',
})
snackbar('已触发推送')
}
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '执行失败', 'error')
snackbar(formatApiError(e, '执行失败'), 'error')
} finally {
runningId.value = null
}
@@ -504,13 +516,17 @@ async function doDelete() {
loadSchedules()
snackbar('已删除')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '删除失败', 'error')
snackbar(formatApiError(e, '删除失败'), 'error')
}
}
onMounted(() => {
loadSchedules()
loadServers()
loadScripts()
})
async function refreshSchedulesPage(silent = false) {
await Promise.all([
loadSchedules(silent),
loadServers({ all: true }),
loadScripts(),
])
}
usePageActivateRefresh((silent) => refreshSchedulesPage(silent))
</script>
+90 -24
View File
@@ -2,10 +2,10 @@
<v-container fluid class="pa-4">
<template v-if="detailId">
<div class="d-flex align-center flex-wrap ga-2 mb-4">
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="router.push('/script-runs')">
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="router.push('/executions')">
返回列表
</v-btn>
<h1 class="text-h6">执行详情 #{{ detailId }}</h1>
<h1 class="text-h6">{{ detailTitle }}</h1>
<v-chip
v-if="execDetail"
:color="execStatusColor(execDetail.status)"
@@ -17,7 +17,7 @@
</v-chip>
<v-spacer />
<v-btn
v-if="execDetail?.long_task"
v-if="execDetail?.long_task && detailKind === 'script'"
size="small"
variant="tonal"
:loading="detailLoading"
@@ -26,7 +26,7 @@
拉取日志
</v-btn>
<v-btn
v-if="execDetail && (execDetail.status === 'running' || execDetail.status === 'partial')"
v-if="detailKind === 'script' && execDetail && (execDetail.status === 'running' || execDetail.status === 'partial')"
size="small"
variant="text"
color="error"
@@ -35,7 +35,7 @@
停止
</v-btn>
<v-btn
v-if="execDetail && (execDetail.status === 'failed' || execDetail.status === 'partial')"
v-if="detailKind === 'script' && execDetail && (execDetail.status === 'failed' || execDetail.status === 'partial')"
size="small"
variant="tonal"
color="primary"
@@ -54,7 +54,14 @@
<div class="text-caption text-medium-emphasis mb-2">
{{ formatProgress(execDetail.progress) }} · 操作人 {{ execDetail.operator || '—' }}
</div>
<pre class="text-body-2 mb-3 pa-3 rounded bg-surface-variant overflow-auto" style="max-height: 100px">{{ execDetail.command }}</pre>
<pre
v-if="detailKind === 'script'"
class="text-body-2 mb-3 pa-3 rounded bg-surface-variant overflow-auto"
style="max-height: 100px"
>{{ execDetail.command }}</pre>
<v-chip v-else size="small" variant="tonal" color="info" class="mb-3" label>
{{ executionRecordKindLabel(detailKind) }} · {{ execDetail.label || execDetail.op || '—' }}
</v-chip>
<div v-if="execDetailStats.total" class="d-flex flex-wrap ga-2 mb-3">
<v-chip size="small" variant="tonal" label> {{ execDetailStats.total }} </v-chip>
@@ -186,7 +193,7 @@
<template v-else>
<div class="d-flex align-center flex-wrap ga-2 mb-4">
<h1 class="text-h6">脚本执行看板</h1>
<h1 class="text-h6">执行记录</h1>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-refresh" :loading="listLoading" @click="loadList">
刷新
@@ -196,6 +203,17 @@
<v-card elevation="0" border rounded="lg">
<v-card-text>
<v-row dense class="mb-2">
<v-col cols="12" sm="4">
<v-select
v-model="kindFilter"
:items="kindFilterOptions"
label="类型"
variant="outlined"
density="compact"
hide-details
clearable
/>
</v-col>
<v-col cols="12" sm="4">
<v-select
v-model="statusFilter"
@@ -218,8 +236,13 @@
@update:page="onPageChange"
@update:items-per-page="onPerPageChange"
>
<template #item.kind="{ item }">
<v-chip size="x-small" variant="tonal" label>
{{ executionRecordKindLabel(item.kind) }}
</v-chip>
</template>
<template #item.label="{ item }">
{{ execLabel(item) }}
{{ item.label }}
</template>
<template #item.status="{ item }">
<v-chip :color="execStatusColor(item.status)" size="x-small" variant="tonal" label>
@@ -230,10 +253,10 @@
{{ formatProgress(item.progress) }}
</template>
<template #item.server_count="{ item }">
{{ parseServerCount(item.server_ids) }}
{{ item.server_count }}
</template>
<template #item.actions="{ item }">
<v-btn size="x-small" variant="text" color="primary" @click="openDetail(item.id)">
<v-btn size="x-small" variant="text" color="primary" @click="openDetail(item)">
详情
</v-btn>
</template>
@@ -248,7 +271,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onActivated, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
@@ -260,9 +283,14 @@ import { useScriptExecutionListRefresh } from '@/composables/useScriptExecutionL
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import { useServerList } from '@/composables/useServerList'
import { formatApiError } from '@/utils/apiError'
import type { ScriptExecItem } from '@/types/api'
import type { ScriptExecItem, ExecutionRecordItem } from '@/types/api'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchLimitOffset } from '@/utils/paginatedFetch'
import {
executionRecordKindLabel,
parseExecutionRecordKind,
executionRecordPath,
} from '@/utils/executionRecordRoute'
import {
execStatusLabel,
execStatusColor,
@@ -275,6 +303,8 @@ import {
} from '@/utils/scriptExecutionLabels'
import { groupFailedExecRows } from '@/utils/execDetailGrouping'
defineOptions({ name: 'ScriptRunsPage' })
const route = useRoute()
const router = useRouter()
const snackbar = useSnackbar()
@@ -287,12 +317,20 @@ const detailId = computed(() => {
return Number.isFinite(n) ? n : null
})
const detailKind = computed(() => parseExecutionRecordKind(route.query.kind))
const detailTitle = computed(() => {
const prefix = detailKind.value === 'server_batch' ? '服务器任务' : '脚本执行'
return `${prefix} #${detailId.value ?? ''}`
})
const listLoading = ref(false)
const executions = ref<ScriptExecItem[]>([])
const executions = ref<ExecutionRecordItem[]>([])
const total = ref(0)
const page = ref(1)
const perPage = ref(20)
const statusFilter = ref<string | null>(null)
const kindFilter = ref<'script' | 'server_batch' | null>(null)
const statusFilterOptions = [
{ title: '执行中', value: 'running' },
@@ -302,9 +340,15 @@ const statusFilterOptions = [
{ title: '已取消', value: 'cancelled' },
]
const kindFilterOptions = [
{ title: '脚本', value: 'script' },
{ title: '服务器', value: 'server_batch' },
]
const listHeaders = headersWithoutSort([
{ title: 'ID', key: 'id', width: 72 },
{ title: '脚本/命令', key: 'label' },
{ title: '类型', key: 'kind', width: 88 },
{ title: '任务', key: 'label' },
{ title: '状态', key: 'status', width: 100 },
{ title: '进度', key: 'progress', width: 88 },
{ title: '台数', key: 'server_count', width: 72 },
@@ -421,11 +465,15 @@ const execDetailFailureGroups = computed(() =>
async function loadList(silent = false) {
if (!silent) listLoading.value = true
try {
const res = await fetchLimitOffset<ScriptExecItem>(
const res = await fetchLimitOffset<ExecutionRecordItem>(
async (limit, offset) => {
const params: Record<string, unknown> = { limit, offset }
if (statusFilter.value) params.status = statusFilter.value
const data = await http.get<{ items: ScriptExecItem[]; total: number }>('/scripts/executions', params)
if (kindFilter.value) params.kind = kindFilter.value
const data = await http.get<{ items: ExecutionRecordItem[]; total: number }>(
'/execution-records',
params,
)
return { items: data.items || [], total: data.total || 0 }
},
page.value,
@@ -455,16 +503,19 @@ function onPerPageChange(n: number) {
loadList()
}
function openDetail(id: number) {
router.push(`/script-runs/${id}`)
function openDetail(item: ExecutionRecordItem) {
router.push(executionRecordPath(item.id, item.kind))
}
async function loadExecDetail(fetchLogs = false, silent = false) {
if (!detailId.value) return
if (!silent) detailLoading.value = true
try {
const q = fetchLogs ? '?fetch_logs=true' : ''
execDetail.value = await http.get<ScriptExecItem>(`/scripts/executions/${detailId.value}${q}`)
const q = new URLSearchParams({ kind: detailKind.value })
if (fetchLogs && detailKind.value === 'script') q.set('fetch_logs', 'true')
execDetail.value = await http.get<ScriptExecItem>(
`/execution-records/${detailId.value}?${q.toString()}`,
)
if (execDetail.value?.status === 'running') {
startDetailPoll()
} else {
@@ -524,7 +575,7 @@ async function retryCurrent() {
res.status,
)
showScriptSubmitToast(`重试已提交 #${res.id}`)
router.push(`/script-runs/${res.id}`)
router.push(executionRecordPath(res.id, 'script'))
} catch (e: unknown) {
snackbar(formatApiError(e, '重试失败'), 'error')
}
@@ -576,7 +627,12 @@ watch(statusFilter, () => {
loadList()
})
watch(detailId, (id) => {
watch(kindFilter, () => {
page.value = 1
loadList()
})
watch([detailId, detailKind], ([id]) => {
stopDetailPoll()
execDetail.value = null
if (id) {
@@ -597,8 +653,18 @@ const offScriptComplete = onScriptExecutionComplete(() => {
else void loadList(true)
})
onMounted(() => {
loadServers()
let scriptRunsReady = false
onMounted(async () => {
await loadServers({ all: true })
scriptRunsReady = true
})
onActivated(async () => {
if (!scriptRunsReady) return
await loadServers({ all: true })
if (detailId.value) await loadExecDetail(false, true)
else await loadList(true)
})
onUnmounted(() => {
+15 -8
View File
@@ -622,7 +622,8 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { useRouter } from 'vue-router'
import ScriptContentEditor from '@/components/ScriptContentEditor.vue'
import ServerCategoryPicker from '@/components/servers/ServerCategoryPicker.vue'
@@ -647,6 +648,8 @@ import {
} from '@/utils/scriptExecutionLabels'
import { groupFailedExecRows } from '@/utils/execDetailGrouping'
import { registerScriptExecution } from '@/composables/useScriptExecutionQueue'
defineOptions({ name: 'ScriptsPage' })
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import { useScriptExecutionListRefresh } from '@/composables/useScriptExecutionListRefresh'
@@ -1282,13 +1285,17 @@ async function loadExecDetail(fetchLogs: boolean) {
}
}
onMounted(() => {
loadScripts()
loadServers({ all: true })
loadExecConfig()
loadCredentials()
refreshExecutionHistory()
})
async function refreshScriptsPage(silent = false) {
await Promise.all([
loadScripts(),
loadServers({ all: true }),
loadExecConfig(),
loadCredentials(),
refreshExecutionHistory(silent),
])
}
usePageActivateRefresh((silent) => refreshScriptsPage(silent))
</script>
+175 -132
View File
@@ -1,6 +1,7 @@
<template>
<v-container fluid class="pa-4 pa-md-6">
<StatCardsRow :items="statItems" />
<v-skeleton-loader v-if="statsBooting" type="heading, text" class="mb-4" />
<StatCardsRow v-else :items="statItems" />
<!-- Server Table -->
<v-card elevation="0" rounded="lg" class="my-5" border>
@@ -30,6 +31,9 @@
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
批量添加
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-key-outline" size="small" @click="showCredentialsDialog = true">
凭据
</v-btn>
<v-btn
variant="tonal"
class="ml-2"
@@ -92,16 +96,22 @@
<v-btn size="small" variant="text" @click="openBatchCategory">修改分类</v-btn>
<v-btn size="small" variant="text" @click="batchHealthCheck">健康检查</v-btn>
<v-btn size="small" variant="text" @click="openDetectPathConfirm">检测目标路径</v-btn>
<v-btn size="small" variant="text" @click="batchInstallAgent" :loading="batchAgentLoading">安装Agent</v-btn>
<v-btn size="small" variant="text" @click="batchUpgradeAgent" :loading="batchAgentLoading">升级Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="batchUninstallAgent" :loading="batchAgentLoading">卸载Agent</v-btn>
<v-btn size="small" variant="text" @click="batchInstallAgent">安装Agent</v-btn>
<v-btn size="small" variant="text" @click="batchUpgradeAgent">升级Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="batchUninstallAgent">卸载Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="confirmBatchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="selectedItems = []">取消选择</v-btn>
</v-card-text>
</v-card>
<v-skeleton-loader
v-if="viewMode === 'table' && loading && !servers.length"
type="table-heading, table-row-divider@8"
class="mb-4"
/>
<v-data-table-server
v-if="viewMode === 'table'"
v-show="!(loading && !servers.length)"
:items="servers"
:headers="headers"
:items-length="total"
@@ -512,13 +522,6 @@
</v-card>
</v-dialog>
<BatchAgentResultDialog
v-model="batchResultOpen"
:title="batchResultTitle"
:loading="batchResultLoading"
:results="batchResultItems"
/>
<!-- Batch category -->
<v-dialog v-model="showBatchCategory" max-width="420">
<v-card border>
@@ -554,7 +557,7 @@
<v-card-title>批量添加服务器</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
每行一个 IP 或域名也可使用名称 + Tab + IP附带显示名称空白行自动忽略重复地址自动去重不区分大小写SSH 凭据从凭据管理预设轮询匹配连接失败将进入下方连接失败列表
每行一个 IP 或域名也可使用名称 + Tab + IP附带显示名称空白行自动忽略重复地址自动去重不区分大小写SSH 凭据从凭据预设轮询匹配连接失败将进入下方连接失败列表
</div>
<v-textarea
v-model="batchAddText"
@@ -599,32 +602,99 @@
</v-card-actions>
</v-card>
</v-dialog>
<CredentialsDialog v-model="showCredentialsDialog" />
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { usePageAutoRefresh } from '@/composables/usePageAutoRefresh'
import { useRouter } from 'vue-router'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { CACHE_KEYS, cachedFetch } from '@/composables/useCachedQuery'
import { useRouter, useRoute } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
import { formatPushPermission } from '@/composables/push/labels'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import type { AddByIpResponse, AddByIpsBatchResult, PendingServerItem, PollErrorItem, PushItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds, type BatchAgentResultItem } from '@/utils/serverSelection'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, PushItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
import BatchAgentResultDialog from '@/components/servers/BatchAgentResultDialog.vue'
import CredentialsDialog from '@/components/credentials/CredentialsDialog.vue'
import { useServerFormDialog } from '@/composables/servers/useServerFormDialog'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
async function submitServerBatchJob(
op: string,
serverIds: number[],
params?: Record<string, unknown>,
): Promise<BatchJobStarted | null> {
const paths: Record<string, string> = {
category: '/servers/batch/category',
'health-check': '/servers/check',
'detect-path': '/servers/batch/detect-path',
'install-agent': '/servers/batch/install-agent',
'upgrade-agent': '/servers/batch/upgrade-agent',
'uninstall-agent': '/servers/batch/uninstall-agent',
}
const path = paths[op]
if (!path) return null
const body = op === 'category'
? { server_ids: serverIds, ...params }
: { server_ids: serverIds }
const res = await http.post<BatchJobStarted & { results?: unknown[] }>(path, body)
if (!res?.job_id) {
throw new Error('服务端未返回任务 ID,请确认已部署支持后台批量任务的新版本')
}
registerServerBatchJob(
res.job_id,
res.label,
res.total,
res.op || op,
res.progress,
res.status,
)
showScriptSubmitToast(`${res.label} 已提交`)
return res
}
onMounted(() => {
offBatchComplete = onScriptExecutionComplete((alert) => {
if (alert.taskKind !== 'server_batch') return
void Promise.all([loadServers(true), loadCategories(), loadStats(true)])
})
})
let offBatchComplete: (() => void) | null = null
onBeforeUnmount(() => {
offBatchComplete?.()
})
defineOptions({ name: 'ServersPage' })
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const router = useRouter()
const route = useRoute()
const showCredentialsDialog = ref(false)
function openCredentialsFromQuery() {
if (route.query.credentials === '1') {
showCredentialsDialog.value = true
const q = { ...route.query }
delete q.credentials
router.replace({ path: route.path, query: q })
}
}
watch(() => route.query.credentials, () => openCredentialsFromQuery(), { immediate: true })
// Paginated server list (composable)
const {
@@ -645,14 +715,14 @@ const groupServers = ref<ServerApiItem[]>([])
const groupLoading = ref(false)
const expandedCategories = ref<string[]>([])
async function loadServers() {
async function loadServers(silent = false) {
try {
await _load()
await _load({ silent })
if (viewMode.value === 'group') {
await loadGroupServers()
await loadGroupServers(silent)
}
} catch {
snackbar('加载服务器列表失败', 'error')
if (!silent) snackbar('加载服务器列表失败', 'error')
}
}
@@ -969,6 +1039,8 @@ const headers = [
{ title: '操作', key: 'actions', width: 200, align: 'end' as const, sortable: false },
]
const statsBooting = ref(true)
// Stats StatCardsRow
const statItems = ref([
{ subtitle: '服务器总数', title: '0', icon: 'mdi-server-network', color: 'blue' },
@@ -977,14 +1049,20 @@ const statItems = ref([
{ subtitle: '告警中', title: '0', icon: 'mdi-bell-alert-outline', color: 'red' },
])
async function loadStats() {
async function loadStats(silent = false) {
if (!silent) statsBooting.value = true
try {
const s = await http.get<{ total: number; online: number; offline: number; alerts: number }>('/servers/stats')
const { data: s } = await cachedFetch(
CACHE_KEYS.serverStats,
() => http.get<{ total: number; online: number; offline: number; alerts: number }>('/servers/stats'),
)
statItems.value[0].title = String(s.total)
statItems.value[1].title = String(s.online)
statItems.value[2].title = String(s.offline)
statItems.value[3].title = String(s.alerts ?? 0)
} catch { /* non-critical */ }
} catch { /* non-critical */ } finally {
if (!silent) statsBooting.value = false
}
}
// Handlers
@@ -1027,19 +1105,9 @@ async function submitBatchCategory() {
batchCategoryLoading.value = true
try {
const category = (batchCategoryValue.value || '').trim()
let updated = 0
const chunkSize = 200
for (let i = 0; i < ids.length; i += chunkSize) {
const res = await http.post<{ updated: number; not_found: number[] }>('/servers/batch/category', {
server_ids: ids.slice(i, i + chunkSize),
category,
})
updated += res.updated
}
await submitServerBatchJob('category', ids, { category })
showBatchCategory.value = false
selectedItems.value = []
snackbar(`已更新 ${updated} 台服务器分类`)
await Promise.all([loadServers(), loadCategories(), loadStats()])
} catch (e: unknown) {
snackbar(formatApiError(e, '批量修改分类失败'), 'error')
} finally {
@@ -1047,50 +1115,85 @@ async function submitBatchCategory() {
}
}
// Batch operations
const batchAgentLoading = ref(false)
// Batch operations (background queue, same as script library)
const showDetectPathConfirm = ref(false)
const batchResultOpen = ref(false)
const batchResultLoading = ref(false)
const batchResultTitle = ref('')
const batchResultItems = ref<BatchAgentResultItem[]>([])
const editingPathId = ref<number | null>(null)
const editingPathValue = ref('')
const savingPathId = ref<number | null>(null)
function openBatchResultDialog(title: string) {
batchResultTitle.value = title
batchResultItems.value = []
batchResultLoading.value = true
batchResultOpen.value = true
function openDetectPathConfirm() {
if (!selectedIds.value.size) {
snackbar('请先选择服务器', 'warning')
return
}
showDetectPathConfirm.value = true
}
function finishBatchResultDialog(results: BatchAgentResultItem[]) {
batchResultItems.value = results
batchResultLoading.value = false
async function runBatchDetectPath() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) return
showDetectPathConfirm.value = false
try {
await submitServerBatchJob('detect-path', ids)
} catch (e: unknown) {
snackbar(formatApiError(e, '提交检测任务失败'), 'error')
}
}
async function runBatchAgentOp(
title: string,
path: string,
opts?: { confirm?: string; reload?: boolean },
) {
async function batchHealthCheck() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
if (opts?.confirm && !confirm(opts.confirm.replace('{n}', String(ids.length)))) return
openBatchResultDialog(title)
try {
const res = await http.post<{ results: BatchAgentResultItem[] }>(path, { server_ids: ids })
finishBatchResultDialog(res.results || [])
if (opts?.reload !== false) loadServers()
await submitServerBatchJob('health-check', ids)
} catch (e: unknown) {
batchResultOpen.value = false
snackbar(formatApiError(e, '操作失败'), 'error')
snackbar(formatApiError(e, '提交健康检查失败'), 'error')
}
}
async function batchInstallAgent() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
try {
await submitServerBatchJob('install-agent', ids)
} catch (e: unknown) {
snackbar(formatApiError(e, '提交安装任务失败'), 'error')
}
}
async function batchUpgradeAgent() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
if (!confirm(`确定对 ${ids.length} 台服务器升级 Agent`)) return
try {
await submitServerBatchJob('upgrade-agent', ids)
} catch (e: unknown) {
snackbar(formatApiError(e, '提交升级任务失败'), 'error')
}
}
async function batchUninstallAgent() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
if (!confirm(
`确定对 ${ids.length} 台已安装 Agent 的服务器执行卸载?\n将停止服务、删除 /opt/nexus-agent 并清除 Agent 状态。`,
)) return
try {
await submitServerBatchJob('uninstall-agent', ids)
} catch (e: unknown) {
snackbar(formatApiError(e, '提交卸载任务失败'), 'error')
}
}
@@ -1131,30 +1234,6 @@ async function saveTargetPath(serverId: number) {
}
}
function openDetectPathConfirm() {
if (!selectedIds.value.size) {
snackbar('请先选择服务器', 'warning')
return
}
showDetectPathConfirm.value = true
}
async function runBatchDetectPath() {
showDetectPathConfirm.value = false
await runBatchAgentOp('自动检测目标路径', '/servers/batch/detect-path')
}
async function batchHealthCheck() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) return
snackbar(`正在检查 ${ids.length} 台服务器...`)
try {
await http.post('/servers/check', { server_ids: ids })
snackbar('健康检查完成')
loadServers()
} catch (e: unknown) { snackbar(formatApiError(e, '检查失败'), 'error') }
}
async function confirmBatchDelete() {
if (!confirm(`确定删除选中的 ${selectedIds.value.size} 台服务器?`)) return
for (const id of selectedIds.value) {
@@ -1166,45 +1245,6 @@ async function confirmBatchDelete() {
loadStats()
}
async function batchInstallAgent() {
batchAgentLoading.value = true
try {
await runBatchAgentOp('批量安装 Agent', '/servers/batch/install-agent', { reload: true })
} finally {
batchAgentLoading.value = false
}
}
async function batchUpgradeAgent() {
batchAgentLoading.value = true
try {
await runBatchAgentOp(
'批量升级 Agent',
'/servers/batch/upgrade-agent',
{ confirm: '确定对 {n} 台服务器升级 Agent', reload: true },
)
} finally {
batchAgentLoading.value = false
}
}
async function batchUninstallAgent() {
batchAgentLoading.value = true
try {
await runBatchAgentOp(
'批量卸载 Agent',
'/servers/batch/uninstall-agent',
{
confirm:
'确定对 {n} 台已安装 Agent 的服务器执行卸载?\n将停止服务、删除 /opt/nexus-agent 并清除 Agent 状态。',
reload: true,
},
)
} finally {
batchAgentLoading.value = false
}
}
// CSV export
async function exportCSV() {
try {
@@ -1349,11 +1389,14 @@ async function doDelete() {
}
}
// Init
onMounted(() => {
loadStats()
loadCategories()
loadServers()
loadPendingServers()
})
async function refreshServersPage(silent = false) {
await Promise.all([
loadStats(silent),
loadCategories(silent),
loadServers(silent),
])
if (!silent) await loadPendingServers()
}
usePageActivateRefresh((silent) => refreshServersPage(silent))
</script>
+20 -13
View File
@@ -396,7 +396,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useSnackbar } from '@/composables/useSnackbar'
@@ -422,6 +423,8 @@ import {
} from '@/utils/scriptCompleteSound'
import { useScriptCompleteSound } from '@/composables/useScriptCompleteSound'
defineOptions({ name: 'SettingsPage' })
const snackbar = useSnackbar()
const { saveToServer: saveScriptSoundToServer, previewSound } = useScriptCompleteSound()
@@ -503,8 +506,8 @@ const subParseHosts = ref<string[]>([])
const subscriptionIpsPreview = computed(() => subscriptionIps.value.slice(0, 24))
// Data loading
async function loadSettings() {
settingsLoading.value = true
async function loadSettings(silent = false) {
if (!silent) settingsLoading.value = true
try {
const res = await http.get<SettingsResponse>('/settings/')
const items: SettingItem[] = Array.isArray(res) ? res : []
@@ -537,7 +540,7 @@ async function loadSettings() {
} catch {
snackbar('加载设置失败', 'error')
} finally {
settingsLoading.value = false
if (!silent) settingsLoading.value = false
}
}
@@ -599,14 +602,14 @@ async function persistTelegramConfig(options?: { requireToken?: boolean; require
return true
}
async function loadPatrolStatus() {
patrolLoading.value = true
async function loadPatrolStatus(silent = false) {
if (!silent) patrolLoading.value = true
try {
patrolStatus.value = await http.get<OpsPatrolStatusResponse>('/settings/ops-patrol/status')
} catch {
patrolStatus.value = null
} finally {
patrolLoading.value = false
if (!silent) patrolLoading.value = false
}
}
@@ -904,10 +907,14 @@ async function saveAllowlist() {
}
onMounted(() => {
loadSettings()
loadAllowlist()
loadPatrolStatus()
auth.fetchProfile()
})
async function refreshSettingsPage(silent = false) {
await Promise.all([
loadSettings(silent),
loadAllowlist(),
loadPatrolStatus(silent),
auth.fetchProfile(),
])
}
usePageActivateRefresh((silent) => refreshSettingsPage(silent))
</script>
+2
View File
@@ -121,6 +121,8 @@ import TerminalCmdBar from '@/components/terminal/TerminalCmdBar.vue'
import TerminalQuickBar from '@/components/terminal/TerminalQuickBar.vue'
import TerminalServerPicker from '@/components/terminal/TerminalServerPicker.vue'
defineOptions({ name: 'TerminalPage' })
const router = useRouter()
const theme = useTheme()
const snackbar = useSnackbar()
+2
View File
@@ -10,6 +10,8 @@ const routes = [
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
{ path: '/script-runs', name: 'ScriptRuns', component: () => import('@/pages/ScriptRunsPage.vue') },
{ path: '/script-runs/:id', name: 'ScriptRunDetail', component: () => import('@/pages/ScriptRunsPage.vue') },
{ path: '/executions', name: 'Executions', component: () => import('@/pages/ScriptRunsPage.vue') },
{ path: '/executions/:id', name: 'ExecutionDetail', component: () => import('@/pages/ScriptRunsPage.vue') },
{ path: '/credentials', name: 'Credentials',component: () => import('@/pages/CredentialsPage.vue') },
{ path: '/schedules', name: 'Schedules', component: () => import('@/pages/SchedulesPage.vue') },
{ path: '/retries', name: 'Retries', component: () => import('@/pages/RetriesPage.vue') },
+32 -1
View File
@@ -205,12 +205,26 @@ export interface PendingServerItem {
created_at?: string | null
}
/** Response from POST /api/servers/batch/category */
/** Response from POST /api/servers/batch/category (legacy sync — deprecated) */
export interface BatchCategoryResult {
updated: number
not_found: number[]
}
/** Response from POST /api/servers/batch/* and /api/servers/check (background job) */
export interface BatchJobStarted {
job_id: number
status: string
label: string
op: string
total: number
done: number
remaining: number
success: number
failed: number
progress: string
}
/** Response from POST /api/servers/add-by-ip and pending retry */
export interface AddByIpResponse {
success: boolean
@@ -405,6 +419,23 @@ export interface ScriptExecItem {
source?: string
/** id → name,由 /executions/{id} 按本批次 server_ids 批量解析 */
server_names?: Record<string, string>
/** Unified execution records: script | server_batch */
kind?: 'script' | 'server_batch'
op?: string
}
export interface ExecutionRecordItem {
id: number
kind: 'script' | 'server_batch'
label: string
status: string
progress: string
server_count: number
operator: string
started_at: string | null
completed_at: string | null
command?: string | null
op?: string | null
}
/** Alert history stats from /api/alert-history/stats */
+14 -8
View File
@@ -2,15 +2,21 @@ import { ApiError } from '@/api'
/** Turn FastAPI error bodies into a human-readable string. */
export function formatApiError(e: unknown, fallback: string): string {
let msg = fallback
if (e instanceof ApiError) {
const d = (e as ApiError & { detail?: unknown }).detail ?? e.message
if (typeof d === 'string') return d
if (Array.isArray(d)) {
return d.map((x: { msg?: string }) => x?.msg || JSON.stringify(x)).join('; ')
if (typeof d === 'string') msg = d
else if (Array.isArray(d)) {
msg = d.map((x: { msg?: string; loc?: unknown[] }) => {
const part = x?.msg || JSON.stringify(x)
const loc = Array.isArray(x.loc) ? x.loc.filter((p) => p !== 'body').join('.') : ''
return loc ? `${loc}: ${part}` : part
}).join('; ')
} else if (d && typeof d === 'object') msg = JSON.stringify(d)
else msg = e.message || fallback
} else if (e instanceof Error) {
msg = e.message || fallback
}
if (d && typeof d === 'object') return JSON.stringify(d)
return e.message || fallback
}
if (e instanceof Error) return e.message || fallback
return fallback
msg = msg.trim()
return msg || fallback
}
+46
View File
@@ -0,0 +1,46 @@
/** Parse audit log detail JSON for server batch jobs (written by server_batch_service). */
export interface AuditBatchJobDetail {
job_id: number
summary: string
success?: number
failed?: number
total?: number
failures?: Array<{ server_name: string; error: string }>
}
const BATCH_AUDIT_ACTIONS = new Set([
'batch_install_agent',
'batch_upgrade_agent',
'batch_uninstall_agent',
'batch_detect_path',
'batch_health_check',
'batch_update_category',
'batch_server_op',
])
export function isBatchJobAuditAction(action: string): boolean {
return BATCH_AUDIT_ACTIONS.has(action)
}
export function parseAuditBatchDetail(detail: string | null | undefined): AuditBatchJobDetail | null {
const raw = (detail || '').trim()
if (!raw.startsWith('{')) return null
try {
const obj = JSON.parse(raw) as AuditBatchJobDetail
if (typeof obj.job_id !== 'number' || !obj.summary) return null
return obj
} catch {
return null
}
}
export function formatAuditBatchDetailText(parsed: AuditBatchJobDetail): string {
const parts = [parsed.summary]
const failures = parsed.failures || []
if (failures.length) {
const names = failures.slice(0, 3).map((f) => f.server_name).join('、')
parts.push(`失败: ${names}${failures.length > 3 ? '…' : ''}`)
}
return parts.join(' · ')
}
+6 -1
View File
@@ -39,6 +39,8 @@ const ACTION_LABELS: Record<string, string> = {
batch_upgrade_agent: '批量升级 Agent',
batch_uninstall_agent: '批量卸载 Agent',
batch_detect_path: '批量检测路径',
batch_health_check: '批量健康检查',
batch_update_category: '批量修改分类',
// 资产
create_platform: '创建平台',
@@ -142,6 +144,7 @@ const TARGET_LABELS: Record<string, string> = {
platform: '平台',
node: '节点',
script_execution: '脚本执行',
server_batch_job: '服务器批量任务',
preset: '密码预设',
ssh_key_preset: 'SSH 密钥预设',
}
@@ -275,7 +278,9 @@ export const auditActionFilterOptions: { label: string; value: string }[] = [
{ label: 'WebSSH 连接', value: 'webssh_connect' },
{ label: '写入远程文件', value: 'file_write' },
{ label: '更新系统设置', value: 'update_setting' },
{ label: '手动重试推送', value: 'retry_job' },
{ label: '批量安装 Agent', value: 'batch_install_agent' },
{ label: '批量升级 Agent', value: 'batch_upgrade_agent' },
{ label: '批量卸载 Agent', value: 'batch_uninstall_agent' },
]
export function auditSummary(item: {
@@ -0,0 +1,15 @@
/** Route helpers for unified execution records (script + server batch). */
export type ExecutionRecordKind = 'script' | 'server_batch'
export function executionRecordPath(id: number, kind: ExecutionRecordKind) {
return { path: `/executions/${id}`, query: { kind } }
}
export function parseExecutionRecordKind(raw: unknown): ExecutionRecordKind {
return raw === 'server_batch' ? 'server_batch' : 'script'
}
export function executionRecordKindLabel(kind: ExecutionRecordKind): string {
return kind === 'server_batch' ? '服务器' : '脚本'
}
+323
View File
@@ -0,0 +1,323 @@
/**
* 5 Cron aaPanel crontab.py GetCrondCycle
* cron_expr= + :
*/
export type ScheduleCycleType =
| 'day'
| 'day-n'
| 'hour'
| 'hour-n'
| 'minute-n'
| 'week'
| 'month'
export interface ScheduleCycleConfig {
type: ScheduleCycleType | 'custom'
minute: number
hour: number
/** day-n / hour-n / minute-n 的 N */
interval: number
/** Cron 周字段 0=周日 … 6=周六 */
week: number
/** 每月几号 131 */
monthDay: number
customCron: string
}
export const CYCLE_TYPE_OPTIONS: { value: ScheduleCycleType; label: string }[] = [
{ value: 'day', label: '每天' },
{ value: 'day-n', label: '每 N 天' },
{ value: 'hour', label: '每小时' },
{ value: 'hour-n', label: '每 N 小时' },
{ value: 'minute-n', label: '每 N 分钟' },
{ value: 'week', label: '每周' },
{ value: 'month', label: '每月' },
]
export const WEEKDAY_OPTIONS = [
{ value: 0, label: '周日' },
{ value: 1, label: '周一' },
{ value: 2, label: '周二' },
{ value: 3, label: '周三' },
{ value: 4, label: '周四' },
{ value: 5, label: '周五' },
{ value: 6, label: '周六' },
]
export function defaultCycleConfig(): ScheduleCycleConfig {
return {
type: 'day',
minute: 0,
hour: 2,
interval: 2,
week: 1,
monthDay: 1,
customCron: '',
}
}
function clampInt(n: number, min: number, max: number): number {
const v = Math.floor(Number(n))
if (Number.isNaN(v)) return min
return Math.min(max, Math.max(min, v))
}
/** 调度 runner 最小粒度 60 秒 */
export const SCHEDULE_MIN_INTERVAL_MINUTES = 1
export function buildCronFromCycle(config: ScheduleCycleConfig): string {
if (config.type === 'custom') {
const raw = config.customCron.trim()
if (!raw) throw new Error('自定义 Cron 不能为空')
if (!validateCronFiveField(raw)) throw new Error('Cron 须为 5 字段')
return raw
}
const minute = clampInt(config.minute, 0, 59)
const hour = clampInt(config.hour, 0, 23)
switch (config.type) {
case 'day':
return `${minute} ${hour} * * *`
case 'day-n': {
const n = clampInt(config.interval, 1, 365)
return `${minute} ${hour} */${n} * *`
}
case 'hour':
return `${minute} * * * *`
case 'hour-n': {
const n = clampInt(config.interval, 1, 23)
return `${minute} */${n} * * *`
}
case 'minute-n': {
const n = clampInt(config.interval, SCHEDULE_MIN_INTERVAL_MINUTES, 59)
return `*/${n} * * * *`
}
case 'week': {
const w = clampInt(config.week, 0, 6)
return `${minute} ${hour} * * ${w}`
}
case 'month': {
const dom = clampInt(config.monthDay, 1, 31)
return `${minute} ${hour} ${dom} * *`
}
default:
throw new Error('未知执行周期类型')
}
}
export function validateCronFiveField(expr: string): boolean {
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return false
return parts.every((p) => /^[\d*/,\-]+$/.test(p))
}
function isStar(s: string): boolean {
return s === '*'
}
function parseStepOrValue(field: string): { kind: 'star' | 'step' | 'fixed'; value?: number; step?: number } {
if (field === '*') return { kind: 'star' }
if (field.startsWith('*/')) {
const step = parseInt(field.slice(2), 10)
if (!Number.isNaN(step) && step > 0) return { kind: 'step', step }
}
const fixed = parseInt(field, 10)
if (!Number.isNaN(fixed)) return { kind: 'fixed', value: fixed }
return { kind: 'star' }
}
/** 从 cron 反解析为周期配置;无法识别则 type=custom */
export function parseCronToCycle(cron: string): ScheduleCycleConfig {
const base = defaultCycleConfig()
const expr = (cron || '').trim()
if (!expr) return base
const parts = expr.split(/\s+/)
if (parts.length !== 5) {
return { ...base, type: 'custom', customCron: expr }
}
const [minF, hourF, domF, monF, dowF] = parts
if (!isStar(monF)) {
return { ...base, type: 'custom', customCron: expr }
}
const minP = parseStepOrValue(minF)
const hourP = parseStepOrValue(hourF)
const domP = parseStepOrValue(domF)
const dowP = parseStepOrValue(dowF)
// */N * * * *
if (minP.kind === 'step' && hourP.kind === 'star' && domP.kind === 'star' && dowP.kind === 'star') {
return { ...base, type: 'minute-n', interval: minP.step! }
}
// M * * * *
if (minP.kind === 'fixed' && hourP.kind === 'star' && domP.kind === 'star' && dowP.kind === 'star') {
return { ...base, type: 'hour', minute: minP.value! }
}
// M */H * * *
if (minP.kind === 'fixed' && hourP.kind === 'step' && domP.kind === 'star' && dowP.kind === 'star') {
return { ...base, type: 'hour-n', minute: minP.value!, interval: hourP.step! }
}
// M H * * W
if (
minP.kind === 'fixed' &&
hourP.kind === 'fixed' &&
domP.kind === 'star' &&
(dowP.kind === 'fixed' || dowF === '7')
) {
const w = dowF === '7' ? 0 : dowP.value!
return { ...base, type: 'week', minute: minP.value!, hour: hourP.value!, week: w }
}
// M H D * *
if (minP.kind === 'fixed' && hourP.kind === 'fixed' && domP.kind === 'fixed' && dowP.kind === 'star') {
return {
...base,
type: 'month',
minute: minP.value!,
hour: hourP.value!,
monthDay: domP.value!,
}
}
// M H */D * *
if (minP.kind === 'fixed' && hourP.kind === 'fixed' && domP.kind === 'step' && dowP.kind === 'star') {
return {
...base,
type: 'day-n',
minute: minP.value!,
hour: hourP.value!,
interval: domP.step!,
}
}
// M H * * *
if (minP.kind === 'fixed' && hourP.kind === 'fixed' && domP.kind === 'star' && dowP.kind === 'star') {
return { ...base, type: 'day', minute: minP.value!, hour: hourP.value! }
}
return { ...base, type: 'custom', customCron: expr }
}
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
export function formatCycleLabel(input: ScheduleCycleConfig | string): string {
const config = typeof input === 'string' ? parseCronToCycle(input) : input
if (config.type === 'custom') {
return config.customCron ? `自定义: ${config.customCron}` : '—'
}
const time = `${pad2(config.hour)}:${pad2(config.minute)}`
switch (config.type) {
case 'day':
return `每天 ${time}`
case 'day-n':
return `${config.interval}${time}`
case 'hour':
return `每小时 第 ${config.minute}`
case 'hour-n':
return `${config.interval} 小时 第 ${config.minute}`
case 'minute-n':
return `${config.interval} 分钟`
case 'week': {
const wd = WEEKDAY_OPTIONS.find((o) => o.value === config.week)?.label || `${config.week}`
return `每周${wd} ${time}`
}
case 'month':
return `每月 ${config.monthDay}${time}`
default:
return '—'
}
}
export function validateCycleConfig(config: ScheduleCycleConfig): string | null {
if (config.type === 'custom') {
if (!config.customCron.trim()) return '请填写 Cron 表达式'
if (!validateCronFiveField(config.customCron.trim())) return 'Cron 须为 5 字段'
return null
}
try {
buildCronFromCycle(config)
return null
} catch (e: unknown) {
return e instanceof Error ? e.message : '执行周期无效'
}
}
function cronFieldMatch(expr: string, value: number): boolean {
for (const part of expr.split(',')) {
if (part === '*') return true
if (part.startsWith('*/')) {
const step = parseInt(part.slice(2), 10)
if (step > 0 && value % step === 0) return true
continue
}
if (part.includes('-')) {
const [low, high] = part.split('-', 2)
const lo = parseInt(low, 10)
const hi = parseInt(high, 10)
if (!Number.isNaN(lo) && !Number.isNaN(hi) && lo <= value && value <= hi) return true
continue
}
const fixed = parseInt(part, 10)
if (!Number.isNaN(fixed) && (value === fixed || (fixed === 7 && value === 0))) return true
}
return false
}
function cronMatch(cronExpr: string, at: Date): boolean {
const parts = cronExpr.trim().split(/\s+/)
if (parts.length !== 5) return false
const fields = [
at.getMinutes(),
at.getHours(),
at.getDate(),
at.getMonth() + 1,
at.getDay(), // cron 0=周日,与 JS getDay() 一致
]
return parts.every((expr, i) => cronFieldMatch(expr, fields[i]!))
}
/** 计算 strictly after *after* 的下一匹配时刻(单次执行 fire_at 用) */
export function computeNextCronRun(cronExpr: string, after: Date = new Date()): Date | null {
const candidate = new Date(after)
candidate.setSeconds(0, 0)
candidate.setMinutes(candidate.getMinutes() + 1)
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000)
while (candidate <= limit) {
if (cronMatch(cronExpr, candidate)) return new Date(candidate)
candidate.setMinutes(candidate.getMinutes() + 1)
}
return null
}
/** 从调度记录还原周期(兼容旧 once 仅 fire_at */
export function cycleFromSchedule(cronExpr?: string | null, fireAt?: string | null): ScheduleCycleConfig {
if (cronExpr?.trim()) return parseCronToCycle(cronExpr)
if (fireAt) {
const d = new Date(fireAt)
if (!Number.isNaN(d.getTime())) {
return { ...defaultCycleConfig(), type: 'day', hour: d.getHours(), minute: d.getMinutes() }
}
}
return defaultCycleConfig()
}
export function formatScheduleCycleDisplay(
runMode: string | undefined,
cronExpr?: string | null,
fireAt?: string | null,
): string {
const label = formatCycleLabel(cycleFromSchedule(cronExpr, fireAt))
if (runMode === 'once') return `单次 · ${label}`
return label
}
+41
View File
@@ -31,6 +31,47 @@ export interface BatchAgentResultItem {
message?: string
}
export interface ServerNameSource {
id: number
name?: string | null
domain?: string | null
}
/** Same label order as backend _batch_server_display_name. */
export function serverDisplayLabel(source: ServerNameSource | null | undefined): string {
if (!source) return '未知服务器'
const name = (source.name || '').trim()
if (name) return name
const domain = (source.domain || '').trim()
if (domain) return domain
return '未知服务器'
}
export function buildServerNameLookup(sources: ServerNameSource[]): Map<number, string> {
const map = new Map<number, string>()
for (const s of sources) {
if (!s?.id) continue
map.set(s.id, serverDisplayLabel(s))
}
return map
}
/** Fill missing API server_name from the list the user selected (table only stores IDs). */
export function enrichBatchAgentResults(
results: BatchAgentResultItem[],
lookup: Map<number, string>,
): BatchAgentResultItem[] {
return results.map((row) => {
const raw = row as BatchAgentResultItem & { serverName?: string }
const fromApi = (raw.server_name || raw.serverName || '').trim()
const fromList = lookup.get(row.server_id)?.trim()
return {
...row,
server_name: fromApi || fromList || '未知服务器',
}
})
}
export function countBatchSuccess(results: BatchAgentResultItem[] | undefined): number {
return (results || []).filter(r => r.success).length
}
+14
View File
@@ -111,6 +111,18 @@ class WebsshTokenRequest(BaseModel):
# ── Public Routes (no JWT required) ──
@router.get("/login-access")
async def login_access(request: Request):
"""Precheck login form visibility. Blocked IPs get 403 with empty body (no IP leak)."""
from server.utils.login_allowlist import check_login_ip
client_ip = get_client_ip(request)
allowlist_enabled, allowed, _message = check_login_ip(client_ip)
if allowlist_enabled and not allowed:
return Response(status_code=403)
return {"allowed": True}
@router.post("/login")
async def login(
request: Request,
@@ -136,6 +148,8 @@ async def login(
status_code = 429
elif result.get("reason") == "totp_required":
status_code = 202 # Accepted but needs TOTP
elif result.get("reason") == "ip_blocked":
return Response(status_code=403)
raise HTTPException(status_code=status_code, detail=result.get("message", "Login failed"))
# Set refresh token as HttpOnly cookie (not accessible from JS)
+1
View File
@@ -37,6 +37,7 @@ security = HTTPBearer(auto_error=False)
# Exact paths that bypass JWT (no prefix bleed — /health must not match /health/detail)
PUBLIC_EXACT_PATHS = frozenset({
"/api/auth/login",
"/api/auth/login-access",
"/api/auth/refresh",
"/api/auth/logout",
"/api/settings/bing-wallpapers",
+59
View File
@@ -0,0 +1,59 @@
"""Unified execution records API (scripts + server batch jobs)."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_script_service
from server.application.services.execution_record_service import (
get_execution_record_detail,
list_execution_records,
)
from server.application.services.script_service import ScriptService
from server.domain.models import Admin
router = APIRouter(prefix="/api/execution-records", tags=["execution-records"])
@router.get("", response_model=dict)
async def list_records(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status: Optional[str] = Query(None, description="running/completed/failed/partial/cancelled"),
kind: Optional[str] = Query(None, description="script | server_batch"),
admin: Admin = Depends(get_current_admin),
script_service: ScriptService = Depends(get_script_service),
):
"""List script executions and server batch jobs merged, newest first."""
if kind and kind not in ("script", "server_batch"):
raise HTTPException(status_code=400, detail="kind 须为 script 或 server_batch")
return await list_execution_records(
script_service=script_service,
limit=limit,
offset=offset,
status=status,
kind=kind,
)
@router.get("/{record_id}", response_model=dict)
async def get_record_detail(
record_id: int,
kind: str = Query(..., description="script | server_batch"),
fetch_logs: bool = Query(False),
admin: Admin = Depends(get_current_admin),
script_service: ScriptService = Depends(get_script_service),
):
"""Execution detail for script run or server batch job."""
if kind not in ("script", "server_batch"):
raise HTTPException(status_code=400, detail="kind 须为 script 或 server_batch")
detail = await get_execution_record_detail(
script_service=script_service,
record_id=record_id,
kind=kind,
fetch_logs=fetch_logs,
)
if not detail:
raise HTTPException(status_code=404, detail="执行记录不存在")
return detail
+29
View File
@@ -145,6 +145,35 @@ class BatchCategoryResult(BaseModel):
not_found: List[int] = []
class BatchJobStarted(BaseModel):
"""Immediate response when a server batch job is queued."""
job_id: int
status: str = "running"
label: str
op: str
total: int = 0
done: int = 0
remaining: int = 0
success: int = 0
failed: int = 0
progress: str = "0/0"
class BatchJobStatus(BaseModel):
job_id: int
op: str
label: str
status: str
total: int = 0
done: int = 0
remaining: int = 0
success: int = 0
failed: int = 0
progress: str = "0/0"
operator: str = ""
results: List[BatchAgentResultItem] = []
class FileUpload(BaseModel):
"""Upload file to remote server via SFTP."""
server_id: int = Field(..., ge=1)
+119 -435
View File
@@ -17,10 +17,14 @@ from server.api.dependencies import get_server_service, get_sync_service, get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import (
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
ServerImportResult, BatchAgentAction, BatchAgentResult, BatchAgentResultItem,
AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult,
BatchCategoryUpdate, BatchCategoryResult,
ServerImportResult, BatchAgentAction, AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult,
BatchCategoryUpdate, BatchJobStarted, BatchJobStatus,
)
from server.application.server_batch_common import (
install_error_msg as _install_error_msg,
sudo_wrap as _sudo_wrap,
)
from server.application.services.server_batch_service import start_batch_job, get_batch_job, list_batch_jobs
from server.application.services.server_service import ServerService
from server.application.services.sync_service import SyncService
from server.domain.models import Server, Admin, AuditLog, PendingServer
@@ -32,96 +36,6 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import shlex
from server.utils.posix_paths import posix_dirname
def _sudo_wrap(cmd: str, ssh_user: str) -> str:
"""Wrap a command with sudo setup/teardown for non-root SSH users.
For non-root users, pre-configures NOPASSWD sudo (command whitelist only)
before the command and cleans up the sudoers entry afterwards.
For root, returns cmd as-is (no sudoers manipulation needed).
"""
if ssh_user == "root":
return cmd
sudoers_tag = "nexus-agent"
# Whitelist only the commands needed by install/upgrade/uninstall — not ALL=(ALL)
sudoers_line = (
f"{ssh_user} ALL=(ALL) NOPASSWD: "
"/usr/bin/systemctl, "
"/usr/bin/apt-get, /usr/bin/yum, /usr/bin/dnf, /usr/bin/apk, "
"/usr/bin/rm, /usr/bin/mkdir, /usr/bin/cp, /usr/bin/tee, "
"/usr/bin/curl, /usr/bin/fuser, /usr/bin/kill, /usr/bin/pkill, "
f"{shlex.quote('/opt/nexus-agent/.venv/bin/pip')}*"
)
setup = (
f"echo {shlex.quote(sudoers_line)} "
f"| sudo tee /etc/sudoers.d/{sudoers_tag} > /dev/null 2>&1; "
)
cleanup = f"; sudo rm -f /etc/sudoers.d/{sudoers_tag} 2>/dev/null"
return setup + cmd + cleanup
# Translation table: install.sh ERROR lines (English) → Chinese
_INSTALL_ERROR_ZH: dict[str, str] = {
"Python 3.10+ required but not found and could not be installed automatically.":
"Python 版本低于 3.10,且无法自动安装",
"sudo requires a password. Configure passwordless sudo first:":
"sudo 需要密码,请先配置免密 sudo",
"Running as non-root and sudo is not available.":
"非 root 用户且无 sudo 权限",
"--url is required":
"缺少 --url 参数",
"--id is required (server ID from Nexus dashboard)":
"缺少 --id 参数(服务器 ID",
"--key is required (API key from Nexus settings)":
"缺少 --key 参数(API Key",
"venv creation failed even after installing python3-venv":
"虚拟环境创建失败(python3-venv 模块不可用)",
"Cannot download from":
"无法下载 agent.py",
"upgrade_ok":
"升级验证失败",
}
def _install_error_msg(stdout: str, stderr: str, exit_code: int) -> str:
"""Extract concise error reason from install.sh / upgrade output.
install.sh writes ``ERROR: ...`` lines to **stdout** (not stderr),
so extracting only stderr yields empty/unhelpful messages like
"安装失败 (exit 1)". This function finds the first ``ERROR:`` line
in stdout, translates it to Chinese, and appends the original English
for full context. Falls back to the last non-empty stdout line,
then stderr, and finally a generic exit-code message.
"""
# 1. Prefer ERROR: lines from stdout (install.sh convention)
for line in reversed(stdout.split("\n")):
line = line.strip()
if line.startswith("ERROR:"):
en = line.replace("ERROR: ", "")
zh = None
# Exact match first
if en in _INSTALL_ERROR_ZH:
zh = _INSTALL_ERROR_ZH[en]
else:
# Prefix match (e.g. "Cannot download from https://...")
for prefix, translation in _INSTALL_ERROR_ZH.items():
if en.startswith(prefix):
zh = translation
break
if zh:
return f"{zh}{en[:200]}"
return en[:300]
# 2. Last non-empty stdout line (may contain useful context)
for line in reversed(stdout.split("\n")):
line = line.strip()
if line:
return line[:300]
# 3. stderr
if stderr.strip():
return stderr.strip()[:300]
# 4. Generic
return f"exit {exit_code}"
logger = logging.getLogger("nexus.servers")
@@ -131,6 +45,10 @@ router = APIRouter(prefix="/api/servers", tags=["servers"])
REDIS_KEY_PREFIX = "heartbeat:"
def _server_has_agent_monitoring(server: Server) -> bool:
"""True when this server is expected to run Nexus Agent (key or version on file)."""
return bool((server.agent_api_key or "").strip() or (server.agent_version or "").strip())
# ── CRUD ──
@router.get("/", response_model=dict)
@@ -265,6 +183,21 @@ async def server_stats(
)
online = total
# Offline = Agent 应监控但未在 Redis 上报;未配置 Agent 的计为 unknown(非离线)
from sqlalchemy import or_, and_
monitored_result = await db.execute(
select(func.count(Server.id)).where(
or_(
and_(Server.agent_api_key.isnot(None), Server.agent_api_key != ""),
and_(Server.agent_version.isnot(None), Server.agent_version != ""),
)
)
)
monitored = int(monitored_result.scalar() or 0)
unknown = max(0, total - monitored)
offline = max(0, monitored - online)
alerts = 0
try:
cursor = 0
@@ -279,7 +212,8 @@ async def server_stats(
return {
"total": total,
"online": online,
"offline": max(0, total - online),
"offline": offline,
"unknown": unknown,
"alerts": alerts,
"categories": categories,
}
@@ -588,360 +522,102 @@ async def import_servers(
return result
# ── Batch category update ──
# ── Batch jobs (background, same UX as script library) ──
@router.post("/batch/category", response_model=BatchCategoryResult)
async def _start_server_batch(
op: str,
server_ids: list[int],
admin: Admin,
params: dict | None = None,
) -> BatchJobStarted:
if op in ("install-agent", "upgrade-agent", "uninstall-agent"):
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
try:
started = await start_batch_job(
op=op,
server_ids=server_ids,
operator=admin.username,
params=params,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return BatchJobStarted(**started)
@router.get("/batch/jobs/{job_id}", response_model=BatchJobStatus)
async def get_server_batch_job(
job_id: int,
admin: Admin = Depends(get_current_admin),
):
"""Poll server batch job status and per-server results."""
detail = await get_batch_job(job_id)
if not detail:
raise HTTPException(status_code=404, detail="任务不存在或已过期")
return BatchJobStatus(**detail)
@router.get("/batch/jobs", response_model=dict)
async def list_server_batch_jobs(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
op: str | None = Query(None, max_length=50),
admin: Admin = Depends(get_current_admin),
):
"""List persisted server batch job history (MySQL)."""
return await list_batch_jobs(limit=limit, offset=offset, op=op or None)
@router.post("/batch/category", response_model=BatchJobStarted)
async def batch_update_category(
request: Request,
payload: BatchCategoryUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Batch update category for multiple servers."""
from server.infrastructure.database.server_repo import ServerRepositoryImpl
repo = ServerRepositoryImpl(db)
servers = await repo.get_by_ids(payload.server_ids)
found_ids = {s.id for s in servers}
not_found = [sid for sid in payload.server_ids if sid not in found_ids]
category = (payload.category or "").strip() or None
updated = await repo.update_category_batch(list(found_ids), category)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="batch_update_category",
target_type="server",
target_id=None,
detail=f"批量修改分类={category or '(清除)'} 更新{updated}",
ip_address=ip_address,
))
return BatchCategoryResult(updated=updated, not_found=not_found)
"""Queue batch category update (background)."""
category = (payload.category or "").strip()
return await _start_server_batch(
"category",
payload.server_ids,
admin,
params={"category": category},
)
# ── Batch Agent Install/Upgrade ──
@router.post("/batch/install-agent", response_model=BatchAgentResult)
@router.post("/batch/install-agent", response_model=BatchJobStarted)
async def batch_install_agent(
request: Request,
payload: BatchAgentAction,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Install Nexus Agent on multiple servers via SSH (concurrent, max 5 parallel)."""
import asyncio
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
sem = asyncio.Semaphore(5)
results = []
async def _install_one(sid: int):
async with sem:
server_name = ""
try:
server = await service.get_server(sid)
if not server:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
server_name = server.name
api_key = server.agent_api_key
if not api_key:
return BatchAgentResultItem(
server_id=sid,
server_name=server_name,
success=False,
error="未设置 Agent API Key,请先在服务器详情生成",
)
ssh_user = (server.username or "root").strip()
install_cmd = (
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
f"--id {sid} --port {shlex.quote(str(server.agent_port or 8601))}"
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
)
install_cmd = _sudo_wrap(install_cmd, ssh_user)
r = await exec_ssh_command(server, install_cmd, timeout=180)
ok = r["exit_code"] == 0
# Also detect install.sh reporting FAILED in stdout
if ok and "Status: FAILED" in r.get("stdout", ""):
ok = False
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=ok,
stdout=r["stdout"][:2000] if ok else r["stdout"][:1000],
error="" if ok else _install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
)
except Exception as e:
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
items = await asyncio.gather(*[_install_one(sid) for sid in payload.server_ids])
results = list(items)
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
ok_count = sum(1 for r in results if r.success)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="batch_install_agent",
target_type="server",
target_id=0,
detail=f"批量安装Agent: {ok_count}/{len(results)} 成功",
ip_address=ip_address,
))
return BatchAgentResult(results=results)
"""Queue Agent install on multiple servers (background)."""
return await _start_server_batch("install-agent", payload.server_ids, admin)
@router.post("/batch/upgrade-agent", response_model=BatchAgentResult)
@router.post("/batch/upgrade-agent", response_model=BatchJobStarted)
async def batch_upgrade_agent(
request: Request,
payload: BatchAgentAction,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Upgrade Nexus Agent on multiple servers via SSH (concurrent, max 5 parallel)."""
import asyncio
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
sem = asyncio.Semaphore(5)
results = []
async def _upgrade_one(sid: int):
async with sem:
try:
server = await service.get_server(sid)
if not server:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
agent_url = f"{base_url}/agent/agent.py"
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
ssh_user = (server.username or "root").strip()
batch_systemctl_prefix = "sudo " if ssh_user != "root" else ""
batch_kill_port = "fuser -k 8601/tcp 2>/dev/null || true; "
upgrade_cmd = _sudo_wrap(
f"(command -v rsync >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq rsync) || (yum install -y -q rsync) || (dnf install -y -q rsync)) "
f"&& {venv_python} -c 'import sys; v=sys.version_info; sys.exit(0 if v.major==3 and v.minor>=10 else 1)' "
f"&& {venv_python} -m pip install -q fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19 "
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
f"&& curl -fsSL {shlex.quote(agent_url)} -o {install_dir}/agent.py "
f"&& {batch_kill_port}{batch_systemctl_prefix}systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& {batch_systemctl_prefix}systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'",
ssh_user,
)
r = await exec_ssh_command(server, upgrade_cmd, timeout=120)
ok = r["exit_code"] == 0 and "upgrade_ok" in r["stdout"]
if not ok:
# Attempt rollback
try:
rollback = _sudo_wrap(
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py && {batch_systemctl_prefix}systemctl restart nexus-agent",
ssh_user,
)
await exec_ssh_command(server, rollback, timeout=30)
except Exception as rb_err:
logger.warning(f"Rollback failed for server {sid}: {rb_err}")
return BatchAgentResultItem(
server_id=sid, server_name=server.name, success=ok,
stdout=r["stdout"][:500] if ok else "",
error=_install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]) if not ok else "",
)
except Exception as e:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error=str(e)[:200])
items = await asyncio.gather(*[_upgrade_one(sid) for sid in payload.server_ids])
results = list(items)
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
ok_count = sum(1 for r in results if r.success)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="batch_upgrade_agent",
target_type="server",
target_id=0,
detail=f"批量升级Agent: {ok_count}/{len(results)} 成功",
ip_address=ip_address,
))
return BatchAgentResult(results=results)
"""Queue Agent upgrade on multiple servers (background)."""
return await _start_server_batch("upgrade-agent", payload.server_ids, admin)
@router.post("/batch/detect-path", response_model=BatchAgentResult)
@router.post("/batch/detect-path", response_model=BatchJobStarted)
async def batch_detect_path(
request: Request,
payload: BatchAgentAction,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""SSH 到各服务器搜索 workerman.bat,自动设置 target_path。
递归搜索 /www/wwwroot 下的 workerman.bat 文件找到后取其目录路径
作为服务器的 target_path 写入数据库找到第一个即停止搜索
"""
import asyncio
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
sem = asyncio.Semaphore(5)
db_lock = asyncio.Lock()
results = []
async def _detect_one(sid: int):
async with sem:
server_name = ""
try:
server = await service.get_server(sid)
if not server:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
server_name = server.name
ssh_user = (server.username or "root").strip()
# find 第一个 workerman.bat 即停止 (-quit)
cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null"
cmd = _sudo_wrap(cmd, ssh_user)
r = await exec_ssh_command(server, cmd, timeout=30)
if r["exit_code"] != 0 and not r.get("stdout", "").strip():
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=False,
error=f"搜索失败 (exit {r['exit_code']})",
)
found = r["stdout"].strip()
if not found:
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=False,
error="未找到 workerman.bat",
)
# 取目录路径(可能有多个结果,取第一个)
first_match = found.split("\n")[0].strip()
target_dir = posix_dirname(first_match)
# 更新服务器 target_path — lock to avoid concurrent session commits
async with db_lock:
server.target_path = target_dir
await db.commit()
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=True,
stdout=f"target_path → {target_dir}",
)
except Exception as e:
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
items = await asyncio.gather(*[_detect_one(sid) for sid in payload.server_ids])
results = list(items)
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
ok_count = sum(1 for r in results if r.success)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="batch_detect_path",
target_type="server",
target_id=0,
detail=f"批量检测路径: {ok_count}/{len(results)} 成功",
ip_address=ip_address,
))
return BatchAgentResult(results=results)
"""Queue SSH target_path detection (background)."""
return await _start_server_batch("detect-path", payload.server_ids, admin)
@router.post("/batch/uninstall-agent", response_model=BatchAgentResult)
@router.post("/batch/uninstall-agent", response_model=BatchJobStarted)
async def batch_uninstall_agent(
request: Request,
payload: BatchAgentAction,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Uninstall Nexus Agent on multiple servers via SSH (concurrent, max 5 parallel)."""
import asyncio
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
sem = asyncio.Semaphore(5)
db_lock = asyncio.Lock()
results = []
async def _uninstall_one(sid: int):
async with sem:
server_name = ""
try:
server = await service.get_server(sid)
if not server:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
server_name = server.name
ssh_user = (server.username or "root").strip()
uninstall_cmd = (
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
)
uninstall_cmd = _sudo_wrap(uninstall_cmd, ssh_user)
r = await exec_ssh_command(server, uninstall_cmd, timeout=60)
ok = r["exit_code"] == 0
if ok:
# Clear Redis heartbeat (real-time source) + MySQL
try:
redis = get_redis()
await redis.delete(f"{REDIS_KEY_PREFIX}{sid}")
except Exception as redis_err:
logger.warning(f"Failed to clear Redis heartbeat for server {sid}: {redis_err}")
# Lock to avoid concurrent session commits
async with db_lock:
server.is_online = False
server.agent_version = None
server.agent_api_key = None
await db.commit()
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=ok,
stdout=r["stdout"][:500] if ok else "",
error="" if ok else _install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
)
except Exception as e:
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
items = await asyncio.gather(*[_uninstall_one(sid) for sid in payload.server_ids])
results = list(items)
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
ok_count = sum(1 for r in results if r.success)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="batch_uninstall_agent",
target_type="server",
target_id=0,
detail=f"批量卸载 Agent: {ok_count}/{len(results)} 成功",
ip_address=ip_address,
))
return BatchAgentResult(results=results)
"""Queue Agent uninstall on multiple servers (background)."""
return await _start_server_batch("uninstall-agent", payload.server_ids, admin)
# ── Credential polling: add-by-ip + pending failure queue ──
@@ -1406,7 +1082,9 @@ async def get_server(
except Exception as e:
logger.warning(f"Redis read failed for server {id}: {e}")
server_data["status"] = _derive_server_status(
server_data.get("is_online"), server.agent_version
server_data.get("is_online"),
server.agent_version,
has_agent=_server_has_agent_monitoring(server),
)
return server_data
@@ -1439,7 +1117,6 @@ async def setup_files_sudo(
/etc/sudoers.d/nexus-files on the target server.
Requires the SSH user to have sudo access with password.
"""
import shlex
import textwrap
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
from server.infrastructure.database.crypto import decrypt_value
@@ -1776,7 +1453,6 @@ async def install_agent_remote(
Requires NEXUS_API_BASE_URL to be configured (used in the install command).
"""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
server = await service.get_server(id)
if not server:
@@ -1790,9 +1466,12 @@ async def install_agent_remote(
detail="NEXUS_API_BASE_URL 未配置,无法生成安装命令。请在系统设置中配置主站对外 URL。",
)
api_key = server.agent_api_key
api_key = (server.agent_api_key or "").strip()
if not api_key:
raise HTTPException(status_code=400, detail="未设置 Agent API Key,请先为该服务器生成")
import secrets
api_key = f"nxs-{secrets.token_urlsafe(32)}"
server.agent_api_key = api_key
await service.update_server(server)
agent_port = server.agent_port or 8601
ssh_user = (server.username or "root").strip()
@@ -1863,7 +1542,6 @@ async def uninstall_agent_remote(
then clears the agent metadata (is_online, agent_version) in the database.
"""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
server = await service.get_server(id)
if not server:
@@ -2011,7 +1689,6 @@ async def upgrade_agent(
the nexus-agent systemd service. Does NOT touch config.json.
"""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
server = await service.get_server(id)
if not server:
@@ -2125,14 +1802,13 @@ async def upgrade_agent(
# ── Health Check ──
@router.post("/check", response_model=dict)
@router.post("/check", response_model=BatchJobStarted)
async def check_servers(
payload: ServerCheck,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""Check health status of specified servers via SSH probe (no Agent port required)"""
return await service.check_all_servers(payload.server_ids)
"""Queue SSH health check for selected servers (background)."""
return await _start_server_batch("health-check", payload.server_ids, admin)
@@ -2166,15 +1842,17 @@ async def _build_server_list_with_overlay(redis, servers: list) -> list[dict]:
except Exception as e:
logger.warning(f"Redis read failed for server {server.id}: {e}")
server_data["status"] = _derive_server_status(
server_data.get("is_online"), server.agent_version
server_data.get("is_online"),
server.agent_version,
has_agent=_server_has_agent_monitoring(server),
)
result.append(server_data)
return result
def _derive_server_status(is_online: bool | None, agent_version: str | None) -> str:
def _derive_server_status(is_online: bool | None, agent_version: str | None, *, has_agent: bool = False) -> str:
"""Map live/DB fields to frontend status: online | offline | unknown."""
has_agent = bool(agent_version and str(agent_version).strip())
has_agent = has_agent or bool(agent_version and str(agent_version).strip())
if has_agent or is_online:
return "online" if is_online else "offline"
return "unknown"
@@ -2182,6 +1860,7 @@ def _derive_server_status(is_online: bool | None, agent_version: str | None) ->
def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict | None) -> None:
"""Overlay Redis heartbeat onto server_data and refresh derived status."""
has_agent = _server_has_agent_monitoring(server)
if heartbeat:
server_data["is_online"] = heartbeat.get("is_online") == "True"
if heartbeat.get("system_info"):
@@ -2201,12 +1880,13 @@ def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict
server_data["time_drift_seconds"] = 0.0
server_data["drift_level"] = heartbeat.get("drift_level", "ok")
server_data["_source"] = "redis"
elif server.agent_version:
elif has_agent:
server_data["is_online"] = False
server_data["_source"] = "redis_miss"
server_data["status"] = _derive_server_status(
server_data.get("is_online"),
server_data.get("agent_version") or server.agent_version,
has_agent=has_agent,
)
@@ -2241,7 +1921,11 @@ def _server_to_dict(server: Server) -> dict:
"connectivity": server.connectivity,
"ssh_key_configured": server.ssh_key_configured,
"is_online": server.is_online,
"status": _derive_server_status(server.is_online, server.agent_version),
"status": _derive_server_status(
server.is_online,
server.agent_version,
has_agent=_server_has_agent_monitoring(server),
),
"last_heartbeat": str(server.last_heartbeat) if server.last_heartbeat else None,
"last_checked_at": str(server.last_checked_at) if server.last_checked_at else None,
"system_info": server.system_info,
+15 -1
View File
@@ -175,6 +175,8 @@ _WALLPAPER_DIR = Path(__file__).resolve().parent.parent.parent / "web" / "app" /
_MAX_WALLPAPERS = 12 # keep 12 images (12 hours rotation before repeat)
_MAX_AGE_DAYS = 7 # auto-clean older than 7 days
_wallpaper_sync_lock = asyncio.Lock()
def _cleanup_old_wallpapers() -> int:
"""Remove wallpapers older than 7 days. Returns count removed."""
@@ -254,9 +256,20 @@ async def _sync_bing_wallpapers_from_network() -> int:
return downloaded
async def _ensure_wallpaper_cache() -> None:
"""Populate local cache when empty (login page has no admin to trigger POST /sync)."""
if _cached_wallpaper_urls()["count"] > 0:
return
async with _wallpaper_sync_lock:
if _cached_wallpaper_urls()["count"] > 0:
return
await _sync_bing_wallpapers_from_network()
@router.get("/bing-wallpapers", response_model=dict)
async def bing_wallpapers():
"""Return cached wallpaper URLs (public, read-only — no outbound sync)."""
"""Return cached wallpaper URLs; lazy-sync from Bing when cache is empty."""
await _ensure_wallpaper_cache()
return _cached_wallpaper_urls()
@@ -273,6 +286,7 @@ async def bing_wallpapers_sync(admin: Admin = Depends(require_current_admin)):
@router.get("/bing-wallpaper", response_model=dict)
async def bing_wallpaper():
"""Return today's cached wallpaper URL (public, read-only)."""
await _ensure_wallpaper_cache()
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
from datetime import datetime
+190
View File
@@ -0,0 +1,190 @@
"""Shared helpers for server batch SSH operations (Agent install, path detect, etc.)."""
from __future__ import annotations
import logging
import shlex
from server.domain.models import Server
from server.utils.posix_paths import posix_dirname
logger = logging.getLogger("nexus.server_batch")
_INSTALL_ERROR_ZH: dict[str, str] = {
"Python 3.10+ required but not found and could not be installed automatically.":
"Python 版本低于 3.10,且无法自动安装",
"Python 3.10+ required. System install failed — install python3.12 manually and re-run.":
"Python 3.10+ 不可用且自动安装系统 python3.12 失败,请手动安装后重试",
"Python 3.10+ required (found only older pyenv/BT Python?). Install system python3.12 or use BT panel pyenv under /www/server/panel/pyenv/bin.":
"Python 版本低于 3.10;宝塔 pyenv 不可执行时将自动安装系统 python3.12",
"sudo requires a password. Configure passwordless sudo first:":
"sudo 需要密码,请先配置免密 sudo",
"Running as non-root and sudo is not available.":
"非 root 用户且无 sudo 权限",
"--url is required":
"缺少 --url 参数",
"--id is required (server ID from Nexus dashboard)":
"缺少 --id 参数(服务器 ID",
"--key is required (API key from Nexus settings)":
"缺少 --key 参数(API Key",
"venv creation failed even after installing python3-venv":
"虚拟环境创建失败(python3-venv 模块不可用)",
"Cannot download from":
"无法下载 agent.py",
"upgrade_ok":
"升级验证失败",
}
def sudo_wrap(cmd: str, ssh_user: str) -> str:
"""Wrap a command with sudo setup/teardown for non-root SSH users."""
if ssh_user == "root":
return cmd
sudoers_tag = "nexus-agent"
sudoers_line = (
f"{ssh_user} ALL=(ALL) NOPASSWD: "
"/usr/bin/systemctl, "
"/usr/bin/apt-get, /usr/bin/yum, /usr/bin/dnf, /usr/bin/apk, "
"/usr/bin/rm, /usr/bin/mkdir, /usr/bin/cp, /usr/bin/tee, "
"/usr/bin/curl, /usr/bin/fuser, /usr/bin/kill, /usr/bin/pkill, "
f"{shlex.quote('/opt/nexus-agent/.venv/bin/pip')}*"
)
setup = (
f"echo {shlex.quote(sudoers_line)} "
f"| sudo tee /etc/sudoers.d/{sudoers_tag} > /dev/null 2>&1; "
)
cleanup = f"; sudo rm -f /etc/sudoers.d/{sudoers_tag} 2>/dev/null"
return setup + cmd + cleanup
def install_error_msg(stdout: str, stderr: str, exit_code: int) -> str:
"""Extract concise error reason from install.sh / upgrade output."""
for line in reversed(stdout.split("\n")):
line = line.strip()
if line.startswith("ERROR:"):
en = line.replace("ERROR: ", "")
zh = _INSTALL_ERROR_ZH.get(en)
if not zh:
for prefix, translation in _INSTALL_ERROR_ZH.items():
if en.startswith(prefix):
zh = translation
break
if zh:
return f"{zh}{en[:200]}"
return en[:300]
for line in reversed(stdout.split("\n")):
line = line.strip()
if line:
return line[:300]
if stderr.strip():
return stderr.strip()[:300]
return f"exit {exit_code}"
def batch_server_display_name(server: Server | None) -> str:
if server is None:
return "未知服务器"
name = (server.name or "").strip()
if name:
return name
domain = (server.domain or "").strip()
if domain:
return domain
return "未知服务器"
async def prefetch_batch_context(service, server_ids: list[int]) -> tuple[dict[int, Server], dict[int, str]]:
rows = await service.server_repo.get_by_ids(server_ids)
server_map = {s.id: s for s in rows}
labels = {sid: batch_server_display_name(server_map.get(sid)) for sid in server_ids}
return server_map, labels
def generate_agent_api_key_value() -> str:
import secrets
return f"nxs-{secrets.token_urlsafe(32)}"
async def ensure_agent_api_key(server: Server) -> str:
"""Return per-server agent key, auto-generating and persisting when missing."""
key = (server.agent_api_key or "").strip()
if key:
return key
new_key = generate_agent_api_key_value()
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.session import AsyncSessionLocal
async with AsyncSessionLocal() as session:
repo = ServerRepositoryImpl(session)
row = await repo.get_by_id(server.id)
if not row:
raise ValueError("服务器不存在")
existing = (row.agent_api_key or "").strip()
if existing:
server.agent_api_key = existing
return existing
row.agent_api_key = new_key
await session.commit()
server.agent_api_key = new_key
return new_key
async def update_server_target_path(sid: int, target_dir: str) -> None:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.server_repo import ServerRepositoryImpl
async with AsyncSessionLocal() as session:
repo = ServerRepositoryImpl(session)
server = await repo.get_by_id(sid)
if not server:
raise ValueError("服务器不存在")
server.target_path = target_dir
await session.commit()
async def mark_agent_uninstalled(sid: int) -> None:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.server_repo import ServerRepositoryImpl
async with AsyncSessionLocal() as session:
repo = ServerRepositoryImpl(session)
server = await repo.get_by_id(sid)
if not server:
return
server.is_online = False
server.agent_version = None
server.agent_api_key = None
await session.commit()
def result_item_dict(
*,
server_id: int,
server_name: str,
success: bool,
stdout: str = "",
error: str = "",
) -> dict:
return {
"server_id": server_id,
"server_name": server_name,
"success": success,
"stdout": stdout,
"error": error,
}
__all__ = [
"batch_server_display_name",
"ensure_agent_api_key",
"generate_agent_api_key_value",
"install_error_msg",
"mark_agent_uninstalled",
"posix_dirname",
"prefetch_batch_context",
"result_item_dict",
"sudo_wrap",
"update_server_target_path",
]
+5 -43
View File
@@ -13,11 +13,8 @@ import bcrypt
from datetime import timezone
from typing import Optional
import ipaddress
from server.domain.models import Admin, LoginAttempt, AuditLog
from server.domain.repositories import AdminRepository, LoginAttemptRepository, AuditLogRepository
from server.config import settings
from server.infrastructure.database.refresh_token_repo import RefreshTokenRepositoryImpl
logger = logging.getLogger("nexus.auth_service")
@@ -35,33 +32,7 @@ def _parse_refresh_token_version(token_version_str: str) -> int:
return int(token_version_str)
def _ip_in_allowlist(client_ip: str, allowed: list[str]) -> bool:
"""Check if client_ip matches any entry in the allowlist.
Each entry can be:
- Exact IP string: "1.2.3.4"
- CIDR range: "10.0.0.0/8"
- Hostname/domain: "proxy.example.com" (exact match only)
"""
try:
client_addr = ipaddress.ip_address(client_ip)
for entry in allowed:
entry = entry.strip()
try:
if "/" in entry:
if client_addr in ipaddress.ip_network(entry, strict=False):
return True
else:
if client_addr == ipaddress.ip_address(entry):
return True
except ValueError:
# Not an IP — treat as hostname exact match
if client_ip == entry:
return True
except ValueError:
# client_ip is not a valid IP (e.g., domain from reverse proxy)
return client_ip in allowed
return False
from server.utils.login_allowlist import check_login_ip, is_whitelisted_login_ip
# Brute-force protection thresholds
MAX_LOGIN_FAILURES = 5 # Lock out after 5 failures within 15 minutes
@@ -111,16 +82,9 @@ class AuthService:
or {"success": False, "reason": "..."}
"""
# ── IP allowlist check ──
# Only enforced when login_allowlist_enabled = "true"
allowlist_on = (getattr(settings, "LOGIN_ALLOWLIST_ENABLED", "false") or "false").lower()
is_whitelisted_ip = False
if allowlist_on in ("true", "1", "yes", "on") and ip_address and ip_address not in ("", "unknown"):
# Combine subscription IPs + manual IPs
sub_ips_raw = (getattr(settings, "LOGIN_SUBSCRIPTION_IPS", "") or "").strip()
manual_ips_raw = (getattr(settings, "LOGIN_MANUAL_IPS", "") or "").strip()
all_ips_raw = ",".join(filter(None, [sub_ips_raw, manual_ips_raw]))
allowed_ips = [ip.strip() for ip in all_ips_raw.split(",") if ip.strip()]
if allowed_ips and not _ip_in_allowlist(ip_address, allowed_ips):
_enabled, allowed, block_msg = check_login_ip(ip_address)
is_whitelisted_ip = is_whitelisted_login_ip(ip_address)
if _enabled and not allowed and block_msg:
await self._audit(
"login_ip_blocked", "admin", 0,
f"IP不在白名单: {ip_address} (用户: {username})",
@@ -129,10 +93,8 @@ class AuthService:
return {
"success": False,
"reason": "ip_blocked",
"message": f"当前 IP ({ip_address}) 不在登录白名单中,请检查代理或联系管理员",
"message": "拒绝访问",
}
if allowed_ips and _ip_in_allowlist(ip_address, allowed_ips):
is_whitelisted_ip = True
# Check brute-force lockout (trusted whitelist IPs skip lockout counting)
if not is_whitelisted_ip:
@@ -0,0 +1,189 @@
"""Unified execution records: script runs + server batch jobs."""
from __future__ import annotations
import json
from typing import Any, Optional
from server.application.services.script_service import ScriptService
from server.application.services.server_batch_service import get_batch_job, list_batch_jobs
from server.infrastructure.redis import server_batch_store as sbs
def _parse_server_ids(raw: Any) -> list[int]:
if isinstance(raw, list):
return [int(x) for x in raw if str(x).isdigit()]
if isinstance(raw, str):
try:
parsed = json.loads(raw or "[]")
except json.JSONDecodeError:
return []
if isinstance(parsed, list):
return [int(x) for x in parsed if str(x).isdigit()]
return []
def _script_label(view: dict[str, Any]) -> str:
label = (view.get("label") or "").strip()
if label:
return label
cmd = (view.get("command") or "").replace("[long_task] ", "").strip()
if len(cmd) > 48:
return cmd[:48] + ""
return cmd or f"脚本执行 #{view.get('id')}"
def script_view_to_record(view: dict[str, Any]) -> dict[str, Any]:
sids = _parse_server_ids(view.get("server_ids"))
if not sids and view.get("results"):
sids = [int(k) for k in view["results"] if str(k).isdigit()]
return {
"id": int(view["id"]),
"kind": "script",
"label": _script_label(view),
"status": view.get("status") or "running",
"progress": view.get("progress") or "",
"server_count": len(sids),
"operator": view.get("operator") or "",
"started_at": view.get("started_at"),
"completed_at": view.get("completed_at"),
"command": view.get("command"),
"op": None,
}
def batch_view_to_record(view: dict[str, Any]) -> dict[str, Any]:
total = int(view.get("total") or 0)
if not total:
total = len(_parse_server_ids(view.get("server_ids")))
return {
"id": int(view.get("job_id") or view.get("id") or 0),
"kind": "server_batch",
"label": view.get("label") or "服务器批量任务",
"status": view.get("status") or "running",
"progress": view.get("progress") or "",
"server_count": total,
"operator": view.get("operator") or "",
"started_at": view.get("started_at"),
"completed_at": view.get("completed_at"),
"command": None,
"op": view.get("op") or "",
}
async def _overlay_batch_live(row_api: dict[str, Any]) -> dict[str, Any]:
job_id = int(row_api.get("job_id") or row_api.get("id") or 0)
if not job_id:
return row_api
live = await sbs.get_live(job_id)
if live:
fresh = sbs.live_to_api(live)
fresh["started_at"] = row_api.get("started_at")
fresh["completed_at"] = row_api.get("completed_at")
return fresh
return row_api
async def list_execution_records(
*,
script_service: ScriptService,
limit: int = 50,
offset: int = 0,
status: Optional[str] = None,
kind: Optional[str] = None,
) -> dict[str, Any]:
"""Merge script executions and server batch jobs, newest first."""
if kind == "script":
script_data = await script_service.list_executions(limit=limit, offset=offset, status=status)
items = [script_view_to_record(v) for v in script_data.get("items") or []]
return {
"items": items,
"total": int(script_data.get("total") or 0),
"limit": limit,
"offset": offset,
}
if kind == "server_batch":
batch_data = await list_batch_jobs(limit=limit, offset=offset, op=None)
items = []
for raw in batch_data.get("items") or []:
if status and raw.get("status") != status:
continue
overlaid = await _overlay_batch_live(raw)
items.append(batch_view_to_record(overlaid))
total = int(batch_data.get("total") or 0)
if status:
total = len(items)
return {"items": items, "total": total, "limit": limit, "offset": offset}
fetch_n = min(max(limit + offset, limit), 200)
script_data = await script_service.list_executions(limit=fetch_n, offset=0, status=status)
batch_data = await list_batch_jobs(limit=fetch_n, offset=0)
merged: list[dict[str, Any]] = []
for view in script_data.get("items") or []:
merged.append(script_view_to_record(view))
for raw in batch_data.get("items") or []:
if status and raw.get("status") != status:
continue
overlaid = await _overlay_batch_live(raw)
merged.append(batch_view_to_record(overlaid))
merged.sort(key=lambda x: x.get("started_at") or "", reverse=True)
page = merged[offset: offset + limit]
total = int(script_data.get("total") or 0) + int(batch_data.get("total") or 0)
return {"items": page, "total": total, "limit": limit, "offset": offset}
async def get_execution_record_detail(
*,
script_service: ScriptService,
record_id: int,
kind: str,
fetch_logs: bool = False,
) -> Optional[dict[str, Any]]:
if kind == "server_batch":
view = await get_batch_job(record_id)
if not view:
return None
results: dict[str, Any] = {}
for row in view.get("results") or []:
sid = str(row.get("server_id") or "")
if not sid.isdigit():
continue
ok = bool(row.get("success"))
results[sid] = {
"status": "success" if ok else "failed",
"stdout": row.get("stdout") or "",
"stderr": row.get("error") or "",
"exit_code": 0 if ok else 1,
}
server_ids = [int(k) for k in results]
server_names = {
str(r.get("server_id")): (r.get("server_name") or "")
for r in (view.get("results") or [])
if r.get("server_id") is not None
}
return {
"id": record_id,
"kind": "server_batch",
"label": view.get("label") or "",
"op": view.get("op") or "",
"status": view.get("status") or "running",
"progress": view.get("progress") or "",
"operator": view.get("operator") or "",
"started_at": view.get("started_at"),
"completed_at": view.get("completed_at"),
"command": view.get("label") or "",
"server_ids": json.dumps(server_ids),
"results": results,
"server_names": server_names,
"long_task": False,
}
view = await script_service.get_execution_detail(record_id, fetch_logs=fetch_logs)
if not view:
return None
view["kind"] = "script"
return view
@@ -0,0 +1,553 @@
"""Background server batch jobs (category, health check, Agent ops, path detect)."""
from __future__ import annotations
import asyncio
import logging
import shlex
from typing import Any, Optional
from server.application.server_batch_common import (
batch_server_display_name,
ensure_agent_api_key,
install_error_msg,
mark_agent_uninstalled,
posix_dirname,
prefetch_batch_context,
result_item_dict,
sudo_wrap,
update_server_target_path,
)
from server.config import settings
from server.domain.models import AuditLog, Server
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis import server_batch_store as sbs
from server.infrastructure.redis.client import get_redis
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
logger = logging.getLogger("nexus.server_batch_service")
_BG_TASKS: set[asyncio.Task] = set()
CONCURRENCY = 5
OP_LABELS = {
"category": "批量修改分类",
"health-check": "健康检查",
"detect-path": "自动检测目标路径",
"install-agent": "批量安装 Agent",
"upgrade-agent": "批量升级 Agent",
"uninstall-agent": "批量卸载 Agent",
}
AUDIT_ACTIONS = {
"category": "batch_update_category",
"health-check": "batch_health_check",
"detect-path": "batch_detect_path",
"install-agent": "batch_install_agent",
"upgrade-agent": "batch_upgrade_agent",
"uninstall-agent": "batch_uninstall_agent",
}
HEARTBEAT_PREFIX = "heartbeat:"
async def _notify_progress(live: dict[str, Any]) -> None:
try:
from server.api.websocket import broadcast_script_progress
stats = sbs.progress_stats(live)
await broadcast_script_progress({
"execution_id": int(live["id"]),
"task_kind": "server_batch",
"op": live.get("op") or "",
"label": live.get("label") or "",
"status": live.get("status") or "running",
"long_task": False,
"operator": live.get("operator") or "",
**stats,
})
except Exception as e:
logger.warning("server batch progress notify failed: %s", e)
async def _notify_complete(live: dict[str, Any]) -> None:
try:
from server.api.websocket import broadcast_script_complete
stats = sbs.progress_stats(live)
await broadcast_script_complete({
"execution_id": int(live["id"]),
"task_kind": "server_batch",
"op": live.get("op") or "",
"label": live.get("label") or "",
"status": live.get("status") or "completed",
"long_task": False,
"operator": live.get("operator") or "",
**stats,
})
except Exception as e:
logger.warning("server batch complete notify failed: %s", e)
async def start_batch_job(
*,
op: str,
server_ids: list[int],
operator: str,
params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
if op not in OP_LABELS:
raise ValueError(f"Unknown batch op: {op}")
if not server_ids:
raise ValueError("server_ids 不能为空")
job_id = await sbs.next_job_id()
label = OP_LABELS[op]
if op == "category" and params and params.get("category"):
label = f"{label}{params['category']}"
live = sbs.init_live(
job_id=job_id,
op=op,
label=label,
server_ids=server_ids,
operator=operator,
params=params,
)
await sbs.save_live(live)
await _persist_job_start(live)
await _notify_progress(live)
task = asyncio.create_task(_run_background(job_id))
_BG_TASKS.add(task)
task.add_done_callback(_BG_TASKS.discard)
stats = sbs.progress_stats(live)
return {
"job_id": job_id,
"status": "running",
"label": label,
"op": op,
**stats,
}
async def get_batch_job(job_id: int) -> Optional[dict[str, Any]]:
live = await sbs.get_live(job_id)
if live:
return sbs.live_to_api(live)
return await _get_batch_job_from_mysql(job_id)
async def list_batch_jobs(
*,
limit: int = 50,
offset: int = 0,
op: Optional[str] = None,
) -> dict[str, Any]:
from server.infrastructure.database.server_batch_job_repo import (
ServerBatchJobRepositoryImpl,
job_row_to_api,
)
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
rows, total = await repo.list_recent(limit=limit, offset=offset, op=op)
items = [job_row_to_api(row) for row in rows]
return {"items": items, "total": total, "limit": limit, "offset": offset}
async def _persist_job_start(live: dict[str, Any]) -> None:
from server.infrastructure.database.server_batch_job_repo import ServerBatchJobRepositoryImpl
try:
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
await repo.upsert_running(
job_id=int(live["id"]),
op=str(live.get("op") or ""),
label=str(live.get("label") or ""),
server_ids=list(live.get("server_ids") or []),
operator=str(live.get("operator") or ""),
params=live.get("params") if isinstance(live.get("params"), dict) else {},
)
except Exception as e:
logger.warning("Server batch MySQL create failed job_id=%s: %s", live.get("id"), e)
async def _persist_job_finalize(live: dict[str, Any]) -> None:
from server.infrastructure.database.server_batch_job_repo import ServerBatchJobRepositoryImpl
try:
api = sbs.live_to_api(live)
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
await repo.finalize(
int(live["id"]),
status=str(live.get("status") or "completed"),
results=list(api.get("results") or []),
)
except Exception as e:
logger.warning("Server batch MySQL finalize failed job_id=%s: %s", live.get("id"), e)
async def _get_batch_job_from_mysql(job_id: int) -> Optional[dict[str, Any]]:
from server.infrastructure.database.server_batch_job_repo import (
ServerBatchJobRepositoryImpl,
job_row_to_api,
)
try:
async with AsyncSessionLocal() as session:
repo = ServerBatchJobRepositoryImpl(session)
row = await repo.get_by_id(job_id)
if not row:
return None
return job_row_to_api(row)
except Exception as e:
logger.warning("Server batch MySQL read failed job_id=%s: %s", job_id, e)
return None
async def _run_background(job_id: int) -> None:
live = await sbs.get_live(job_id)
if not live:
return
op = live.get("op") or ""
try:
if op == "category":
await _run_category(live)
elif op == "health-check":
await _run_health_check(live)
elif op == "detect-path":
await _run_detect_path(live)
elif op == "install-agent":
await _run_install_agent(live)
elif op == "upgrade-agent":
await _run_upgrade_agent(live)
elif op == "uninstall-agent":
await _run_uninstall_agent(live)
else:
live["status"] = "failed"
await sbs.save_live(live)
return
live = await sbs.get_live(job_id)
if live and live.get("status") == "running":
sbs.finalize_status(live)
await sbs.save_live(live)
except Exception:
logger.exception("Server batch job failed job_id=%s op=%s", job_id, op)
live = await sbs.get_live(job_id)
if live:
live["status"] = "failed"
await sbs.save_live(live)
live = await sbs.get_live(job_id)
if live:
await _persist_job_finalize(live)
await _write_audit(live)
await _notify_complete(live)
async def _write_audit(live: dict[str, Any]) -> None:
import json
op = live.get("op") or ""
action = AUDIT_ACTIONS.get(op, "batch_server_op")
stats = sbs.progress_stats(live)
api = sbs.live_to_api(live)
failures = [
{"server_name": (e.get("server_name") or "").strip() or "未知服务器", "error": (e.get("error") or "")[:300]}
for e in (api.get("results") or [])
if not e.get("success")
]
summary_map = {
"category": f"批量修改分类 更新{stats['success']}",
"health-check": f"批量健康检查: {stats['success']}/{stats['total']} 在线",
"detect-path": f"批量检测路径: {stats['success']}/{stats['total']} 成功",
"install-agent": f"批量安装Agent: {stats['success']}/{stats['total']} 成功",
"upgrade-agent": f"批量升级Agent: {stats['success']}/{stats['total']} 成功",
"uninstall-agent": f"批量卸载Agent: {stats['success']}/{stats['total']} 成功",
}
summary = summary_map.get(op, f"{live.get('label')} {stats['success']}/{stats['total']}")
detail = json.dumps({
"job_id": int(live["id"]),
"summary": summary,
"success": stats["success"],
"failed": stats["failed"],
"total": stats["total"],
"failures": failures[:30],
}, ensure_ascii=False)
try:
async with AsyncSessionLocal() as session:
audit_repo = AuditLogRepositoryImpl(session)
await audit_repo.create(AuditLog(
admin_username=live.get("operator") or "admin",
action=action,
target_type="server_batch_job",
target_id=int(live["id"]),
detail=detail,
ip_address="",
))
await session.commit()
except Exception as e:
logger.warning("Server batch audit failed job_id=%s: %s", live.get("id"), e)
async def _finish_one(live: dict[str, Any], item: dict[str, Any]) -> None:
sbs.record_result(live, item)
await sbs.save_live(live)
await _notify_progress(live)
async def _run_with_semaphore(live: dict[str, Any], handler) -> None:
from server.application.services.server_service import ServerService
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
server_ids = live.get("server_ids") or []
sem = asyncio.Semaphore(CONCURRENCY)
async with AsyncSessionLocal() as session:
service = ServerService(
server_repo=ServerRepositoryImpl(session),
sync_log_repo=SyncLogRepositoryImpl(session),
)
server_map, labels = await prefetch_batch_context(service, server_ids)
async def _one(sid: int) -> None:
async with sem:
item = await handler(sid, server_map, labels, live)
await _finish_one(live, item)
await asyncio.gather(*[_one(sid) for sid in server_ids])
async def _run_category(live: dict[str, Any]) -> None:
params = live.get("params") or {}
category = (params.get("category") or "").strip() or None
server_ids = live.get("server_ids") or []
async with AsyncSessionLocal() as session:
repo = ServerRepositoryImpl(session)
servers = await repo.get_by_ids(server_ids)
found_ids = {s.id for s in servers}
labels = {s.id: batch_server_display_name(s) for s in servers}
for sid in server_ids:
labels.setdefault(sid, "未知服务器")
for sid in server_ids:
if sid not in found_ids:
await _finish_one(live, result_item_dict(
server_id=sid,
server_name=labels[sid],
success=False,
error="服务器不存在",
))
if found_ids:
await repo.update_category_batch(list(found_ids), category)
await session.commit()
cat_label = category or "(清除)"
for sid in found_ids:
await _finish_one(live, result_item_dict(
server_id=sid,
server_name=labels[sid],
success=True,
stdout=f"分类 → {cat_label}",
))
async def _run_health_check(live: dict[str, Any]) -> None:
from server.infrastructure.ssh.remote_probe import ssh_health_probe
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
probe = await ssh_health_probe(server, timeout=15)
online = probe.get("status") == "online"
detail = probe.get("error") or probe.get("channel") or probe.get("status") or ""
return result_item_dict(
server_id=sid,
server_name=label,
success=online,
stdout=str(detail)[:500] if online else "",
error="" if online else str(probe.get("error") or "离线")[:300],
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)
async def _run_detect_path(live: dict[str, Any]) -> None:
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
ssh_user = (server.username or "root").strip()
cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null"
cmd = sudo_wrap(cmd, ssh_user)
r = await exec_ssh_command(server, cmd, timeout=30)
if r["exit_code"] != 0 and not r.get("stdout", "").strip():
return result_item_dict(
server_id=sid, server_name=label, success=False,
error=f"搜索失败 (exit {r['exit_code']})",
)
found = r["stdout"].strip()
if not found:
return result_item_dict(server_id=sid, server_name=label, success=False, error="未找到 workerman.bat")
first_match = found.split("\n")[0].strip()
target_dir = posix_dirname(first_match)
await update_server_target_path(sid, target_dir)
return result_item_dict(
server_id=sid, server_name=label, success=True,
stdout=f"target_path → {target_dir}",
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)
async def _run_install_agent(live: dict[str, Any]) -> None:
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
for sid in live.get("server_ids") or []:
await _finish_one(live, result_item_dict(
server_id=sid, server_name=str(sid), success=False,
error="NEXUS_API_BASE_URL 未配置",
))
return
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
api_key = await ensure_agent_api_key(server)
ssh_user = (server.username or "root").strip()
install_cmd = (
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
f"--id {sid} --port {shlex.quote(str(server.agent_port or 8601))}"
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
)
install_cmd = sudo_wrap(install_cmd, ssh_user)
r = await exec_ssh_command(server, install_cmd, timeout=180)
ok = r["exit_code"] == 0
if ok and "Status: FAILED" in r.get("stdout", ""):
ok = False
return result_item_dict(
server_id=sid, server_name=label, success=ok,
stdout=r["stdout"][:2000] if ok else r["stdout"][:1000],
error="" if ok else install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)
async def _run_upgrade_agent(live: dict[str, Any]) -> None:
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
for sid in live.get("server_ids") or []:
await _finish_one(live, result_item_dict(
server_id=sid, server_name=str(sid), success=False,
error="NEXUS_API_BASE_URL 未配置",
))
return
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
agent_url = f"{base_url}/agent/agent.py"
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
ssh_user = (server.username or "root").strip()
batch_systemctl_prefix = "sudo " if ssh_user != "root" else ""
batch_kill_port = "fuser -k 8601/tcp 2>/dev/null || true; "
upgrade_cmd = sudo_wrap(
f"(command -v rsync >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq rsync) || (yum install -y -q rsync) || (dnf install -y -q rsync)) "
f"&& {venv_python} -c 'import sys; v=sys.version_info; sys.exit(0 if v.major==3 and v.minor>=10 else 1)' "
f"&& {venv_python} -m pip install -q fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19 "
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
f"&& curl -fsSL {shlex.quote(agent_url)} -o {install_dir}/agent.py "
f"&& {batch_kill_port}{batch_systemctl_prefix}systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& {batch_systemctl_prefix}systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'",
ssh_user,
)
r = await exec_ssh_command(server, upgrade_cmd, timeout=120)
ok = r["exit_code"] == 0 and "upgrade_ok" in r["stdout"]
if not ok:
try:
rollback = sudo_wrap(
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py && {batch_systemctl_prefix}systemctl restart nexus-agent",
ssh_user,
)
await exec_ssh_command(server, rollback, timeout=30)
except Exception as rb_err:
logger.warning("Rollback failed for server %s: %s", sid, rb_err)
return result_item_dict(
server_id=sid, server_name=label, success=ok,
stdout=r["stdout"][:500] if ok else "",
error=install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]) if not ok else "",
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:200])
await _run_with_semaphore(live, handler)
async def _run_uninstall_agent(live: dict[str, Any]) -> None:
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
for sid in live.get("server_ids") or []:
await _finish_one(live, result_item_dict(
server_id=sid, server_name=str(sid), success=False,
error="NEXUS_API_BASE_URL 未配置",
))
return
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
ssh_user = (server.username or "root").strip()
uninstall_cmd = (
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
)
uninstall_cmd = sudo_wrap(uninstall_cmd, ssh_user)
r = await exec_ssh_command(server, uninstall_cmd, timeout=60)
ok = r["exit_code"] == 0
if ok:
try:
redis = get_redis()
await redis.delete(f"{HEARTBEAT_PREFIX}{sid}")
except Exception as redis_err:
logger.warning("Failed to clear Redis heartbeat for server %s: %s", sid, redis_err)
await mark_agent_uninstalled(sid)
return result_item_dict(
server_id=sid, server_name=label, success=ok,
stdout=r["stdout"][:500] if ok else "",
error="" if ok else install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)
+21
View File
@@ -371,6 +371,27 @@ class ScriptExecution(Base):
)
class ServerBatchJob(Base):
"""Server batch job history — persisted after Redis live state (install Agent, health check, etc.)."""
__tablename__ = "server_batch_jobs"
id = Column(Integer, primary_key=True, autoincrement=False, comment="与 Redis job_id 一致")
op = Column(String(50), nullable=False, comment="category/health-check/install-agent/...")
label = Column(String(200), nullable=False, comment="展示标题")
server_ids = Column(Text, nullable=False, comment="JSON 数组: 目标服务器 ID")
status = Column(String(20), default="running", comment="running/completed/failed/partial")
results = Column(Text, nullable=True, comment="JSON 数组: 每台服务器结果")
operator = Column(String(100), nullable=True, comment="操作人")
params = Column(Text, nullable=True, comment="JSON: 额外参数")
started_at = Column(DateTime, default=_utcnow)
completed_at = Column(DateTime, nullable=True)
__table_args__ = (
Index('idx_server_batch_jobs_op_time', 'op', 'started_at'),
Index('idx_server_batch_jobs_started_at', 'started_at'),
)
class DbCredential(Base):
"""Database credentials — encrypted storage for $DB_* variable substitution"""
__tablename__ = "db_credentials"
@@ -0,0 +1,128 @@
"""Server batch job persistence (MySQL)."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import ServerBatchJob
class ServerBatchJobRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, job_id: int) -> Optional[ServerBatchJob]:
result = await self.session.execute(
select(ServerBatchJob).where(ServerBatchJob.id == job_id)
)
return result.scalar_one_or_none()
async def create(self, job: ServerBatchJob) -> ServerBatchJob:
self.session.add(job)
await self.session.commit()
return job
async def upsert_running(
self,
*,
job_id: int,
op: str,
label: str,
server_ids: list[int],
operator: str,
params: Optional[dict[str, Any]] = None,
) -> ServerBatchJob:
row = await self.get_by_id(job_id)
if row:
return row
job = ServerBatchJob(
id=job_id,
op=op,
label=label,
server_ids=json.dumps(server_ids, ensure_ascii=False),
status="running",
results=json.dumps([], ensure_ascii=False),
operator=operator,
params=json.dumps(params or {}, ensure_ascii=False),
)
return await self.create(job)
async def finalize(
self,
job_id: int,
*,
status: str,
results: list[dict[str, Any]],
) -> Optional[ServerBatchJob]:
row = await self.get_by_id(job_id)
if not row:
return None
row.status = status
row.results = json.dumps(results, ensure_ascii=False)
row.completed_at = datetime.now(timezone.utc)
await self.session.commit()
return row
async def list_recent(
self,
*,
limit: int = 50,
offset: int = 0,
op: Optional[str] = None,
) -> tuple[list[ServerBatchJob], int]:
filters = []
if op:
filters.append(ServerBatchJob.op == op)
count_stmt = select(func.count(ServerBatchJob.id))
if filters:
count_stmt = count_stmt.where(*filters)
total = int((await self.session.execute(count_stmt)).scalar() or 0)
stmt = select(ServerBatchJob).order_by(ServerBatchJob.started_at.desc())
if filters:
stmt = stmt.where(*filters)
result = await self.session.execute(stmt.limit(limit).offset(offset))
return list(result.scalars().all()), total
def job_row_to_api(row: ServerBatchJob) -> dict[str, Any]:
"""Convert MySQL row to BatchJobStatus-shaped dict."""
try:
server_ids = json.loads(row.server_ids or "[]")
except json.JSONDecodeError:
server_ids = []
try:
results = json.loads(row.results or "[]")
except json.JSONDecodeError:
results = []
if isinstance(results, dict):
ordered = []
for sid in server_ids:
entry = results.get(str(sid))
if entry:
ordered.append(entry)
results = ordered
total = len(server_ids)
success = sum(1 for r in results if r.get("success"))
failed = sum(1 for r in results if not r.get("success"))
done = len(results)
return {
"job_id": int(row.id),
"op": row.op or "",
"label": row.label or "",
"status": row.status or "completed",
"operator": row.operator or "",
"total": total,
"done": done,
"remaining": max(0, total - done),
"success": success,
"failed": failed,
"progress": f"{success}/{total}" if total else "",
"results": results,
"started_at": row.started_at.isoformat() if row.started_at else None,
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
}
@@ -0,0 +1,141 @@
"""Server batch job live state in Redis (2h TTL)."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from server.infrastructure.redis.client import get_redis
logger = logging.getLogger("nexus.server_batch_store")
REDIS_KEY_PREFIX = "server_batch:"
REDIS_NEXT_ID = "server_batch:next_id"
REDIS_TTL_SECONDS = 7200
TERMINAL_STATUSES = frozenset({"completed", "failed", "partial", "cancelled"})
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
async def next_job_id() -> int:
redis = get_redis()
return int(await redis.incr(REDIS_NEXT_ID))
def _key(job_id: int) -> str:
return f"{REDIS_KEY_PREFIX}{job_id}"
def progress_stats(live: dict[str, Any]) -> dict[str, Any]:
server_ids = live.get("server_ids") or []
results = live.get("results") or {}
total = len(server_ids)
done = 0
success = 0
failed = 0
for sid in server_ids:
entry = results.get(str(sid))
if not entry:
continue
done += 1
if entry.get("success"):
success += 1
else:
failed += 1
remaining = max(0, total - done)
return {
"total": total,
"done": done,
"remaining": remaining,
"success": success,
"failed": failed,
"progress": f"{success}/{total}" if total else "",
}
async def save_live(live: dict[str, Any]) -> None:
redis = get_redis()
job_id = int(live["id"])
live["updated_at"] = _now_iso()
await redis.set(_key(job_id), json.dumps(live, ensure_ascii=False), ex=REDIS_TTL_SECONDS)
async def get_live(job_id: int) -> Optional[dict[str, Any]]:
redis = get_redis()
raw = await redis.get(_key(job_id))
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
logger.warning("Corrupt server batch live state job_id=%s", job_id)
return None
def init_live(
*,
job_id: int,
op: str,
label: str,
server_ids: list[int],
operator: str,
params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
return {
"id": job_id,
"op": op,
"label": label,
"status": "running",
"server_ids": list(server_ids),
"results": {},
"operator": operator,
"params": params or {},
"created_at": _now_iso(),
"updated_at": _now_iso(),
}
def record_result(live: dict[str, Any], item: dict[str, Any]) -> None:
results = live.setdefault("results", {})
results[str(item["server_id"])] = item
def finalize_status(live: dict[str, Any]) -> str:
stats = progress_stats(live)
total = stats["total"]
success = stats["success"]
failed = stats["failed"]
if total == 0:
status = "failed"
elif failed == 0:
status = "completed"
elif success == 0:
status = "failed"
else:
status = "partial"
live["status"] = status
return status
def live_to_api(live: dict[str, Any]) -> dict[str, Any]:
stats = progress_stats(live)
results = live.get("results") or {}
server_ids = live.get("server_ids") or []
ordered = []
for sid in server_ids:
entry = results.get(str(sid))
if entry:
ordered.append(entry)
return {
"job_id": int(live["id"]),
"op": live.get("op") or "",
"label": live.get("label") or "",
"status": live.get("status") or "running",
"operator": live.get("operator") or "",
**stats,
"results": ordered,
}
+2
View File
@@ -53,6 +53,7 @@ from server.api.servers import router as servers_router
from server.api.auth import router as auth_router
from server.api.agent import router as agent_router
from server.api.scripts import router as scripts_router
from server.api.execution_records import router as execution_records_router
from server.api.settings import (
router as settings_router,
schedule_router,
@@ -625,6 +626,7 @@ app.include_router(servers_router)
app.include_router(auth_router)
app.include_router(agent_router)
app.include_router(scripts_router)
app.include_router(execution_records_router)
app.include_router(settings_router)
app.include_router(schedule_router)
app.include_router(preset_router)
+83
View File
@@ -0,0 +1,83 @@
"""Login IP allowlist helpers (shared by auth API precheck and AuthService.login)."""
from __future__ import annotations
import ipaddress
from server.config import settings
_TRUTHY = frozenset({"true", "1", "yes", "on"})
def is_allowlist_enforced() -> bool:
"""Whether LOGIN_ALLOWLIST_ENABLED is on."""
val = (getattr(settings, "LOGIN_ALLOWLIST_ENABLED", "false") or "false").lower()
return val in _TRUTHY
def get_allowed_login_ips() -> list[str]:
"""Combined subscription + manual IPs from settings."""
sub_ips_raw = (getattr(settings, "LOGIN_SUBSCRIPTION_IPS", "") or "").strip()
manual_ips_raw = (getattr(settings, "LOGIN_MANUAL_IPS", "") or "").strip()
all_ips_raw = ",".join(filter(None, [sub_ips_raw, manual_ips_raw]))
return [ip.strip() for ip in all_ips_raw.split(",") if ip.strip()]
def ip_in_allowlist(client_ip: str, allowed: list[str]) -> bool:
"""Check if client_ip matches any entry in the allowlist."""
try:
client_addr = ipaddress.ip_address(client_ip)
for entry in allowed:
entry = entry.strip()
try:
if "/" in entry:
if client_addr in ipaddress.ip_network(entry, strict=False):
return True
elif client_addr == ipaddress.ip_address(entry):
return True
except ValueError:
if client_ip == entry:
return True
except ValueError:
return client_ip in allowed
return False
def check_login_ip(client_ip: str) -> tuple[bool, bool, str | None]:
"""Evaluate login IP access.
Returns:
(allowlist_enabled, allowed, message)
- allowlist off: (False, True, None)
- allowlist on, empty list: (True, True, None) nothing to enforce
- allowlist on, IP ok: (True, True, None)
- allowlist on, IP blocked: (True, False, message)
"""
if not is_allowlist_enforced():
return False, True, None
if not client_ip or client_ip in ("", "unknown"):
return True, True, None
allowed_ips = get_allowed_login_ips()
if not allowed_ips:
return True, True, None
if ip_in_allowlist(client_ip, allowed_ips):
return True, True, None
return (
True,
False,
f"当前 IP ({client_ip}) 不在登录白名单中,请检查代理或联系管理员",
)
def is_whitelisted_login_ip(client_ip: str) -> bool:
"""True when allowlist is enforced, non-empty, and client IP matches."""
if not is_allowlist_enforced():
return False
if not client_ip or client_ip in ("", "unknown"):
return False
allowed_ips = get_allowed_login_ips()
return bool(allowed_ips) and ip_in_allowlist(client_ip, allowed_ips)
+150
View File
@@ -0,0 +1,150 @@
"""Schedule cycle ↔ 5-field cron (Baota-compatible)."""
from __future__ import annotations
from typing import Any, TypedDict
class ScheduleCycleConfig(TypedDict, total=False):
type: str
minute: int
hour: int
interval: int
week: int
monthDay: int
customCron: str
def _clamp_int(n: int | float, lo: int, hi: int) -> int:
try:
v = int(n)
except (TypeError, ValueError):
return lo
return max(lo, min(hi, v))
SCHEDULE_MIN_INTERVAL_MINUTES = 1
def build_cron_from_cycle(config: dict[str, Any]) -> str:
kind = config.get("type", "day")
if kind == "custom":
raw = str(config.get("customCron") or "").strip()
if not raw:
raise ValueError("custom cron empty")
parts = raw.split()
if len(parts) != 5:
raise ValueError("cron must be 5 fields")
return raw
minute = _clamp_int(config.get("minute", 0), 0, 59)
hour = _clamp_int(config.get("hour", 0), 0, 23)
if kind == "day":
return f"{minute} {hour} * * *"
if kind == "day-n":
n = _clamp_int(config.get("interval", 1), 1, 365)
return f"{minute} {hour} */{n} * *"
if kind == "hour":
return f"{minute} * * * *"
if kind == "hour-n":
n = _clamp_int(config.get("interval", 1), 1, 23)
return f"{minute} */{n} * * *"
if kind == "minute-n":
n = _clamp_int(config.get("interval", 1), SCHEDULE_MIN_INTERVAL_MINUTES, 59)
return f"*/{n} * * * *"
if kind == "week":
w = _clamp_int(config.get("week", 0), 0, 6)
return f"{minute} {hour} * * {w}"
if kind == "month":
dom = _clamp_int(config.get("monthDay", 1), 1, 31)
return f"{minute} {hour} {dom} * *"
raise ValueError(f"unknown cycle type: {kind}")
def _parse_step_or_fixed(field: str) -> tuple[str, int | None]:
if field == "*":
return "star", None
if field.startswith("*/"):
step = int(field[2:])
return "step", step
return "fixed", int(field)
def parse_cron_to_cycle(cron: str) -> dict[str, Any]:
base: dict[str, Any] = {
"type": "day",
"minute": 0,
"hour": 2,
"interval": 2,
"week": 1,
"monthDay": 1,
"customCron": "",
}
expr = (cron or "").strip()
if not expr:
return base
parts = expr.split()
if len(parts) != 5:
return {**base, "type": "custom", "customCron": expr}
min_f, hour_f, dom_f, mon_f, dow_f = parts
if mon_f != "*":
return {**base, "type": "custom", "customCron": expr}
min_k, min_v = _parse_step_or_fixed(min_f)
hour_k, hour_v = _parse_step_or_fixed(hour_f)
dom_k, dom_v = _parse_step_or_fixed(dom_f)
dow_k, dow_v = _parse_step_or_fixed(dow_f)
if min_k == "step" and hour_k == "star" and dom_k == "star" and dow_k == "star":
return {**base, "type": "minute-n", "interval": min_v}
if min_k == "fixed" and hour_k == "star" and dom_k == "star" and dow_k == "star":
return {**base, "type": "hour", "minute": min_v}
if min_k == "fixed" and hour_k == "step" and dom_k == "star" and dow_k == "star":
return {**base, "type": "hour-n", "minute": min_v, "interval": hour_v}
if min_k == "fixed" and hour_k == "fixed" and dom_k == "star" and dow_k == "fixed":
w = 0 if dow_f == "7" else dow_v
return {**base, "type": "week", "minute": min_v, "hour": hour_v, "week": w}
if min_k == "fixed" and hour_k == "fixed" and dom_k == "fixed" and dow_k == "star":
return {**base, "type": "month", "minute": min_v, "hour": hour_v, "monthDay": dom_v}
if min_k == "fixed" and hour_k == "fixed" and dom_k == "step" and dow_k == "star":
return {**base, "type": "day-n", "minute": min_v, "hour": hour_v, "interval": dom_v}
if min_k == "fixed" and hour_k == "fixed" and dom_k == "star" and dow_k == "star":
return {**base, "type": "day", "minute": min_v, "hour": hour_v}
return {**base, "type": "custom", "customCron": expr}
_WEEK_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
def format_cycle_label(cron_or_config: str | dict[str, Any]) -> str:
config = parse_cron_to_cycle(cron_or_config) if isinstance(cron_or_config, str) else cron_or_config
kind = config.get("type", "day")
if kind == "custom":
cc = config.get("customCron") or ""
return f"自定义: {cc}" if cc else ""
minute = int(config.get("minute", 0))
hour = int(config.get("hour", 0))
time_s = f"{hour:02d}:{minute:02d}"
if kind == "day":
return f"每天 {time_s}"
if kind == "day-n":
return f"{config.get('interval', 1)}{time_s}"
if kind == "hour":
return f"每小时 第 {minute}"
if kind == "hour-n":
return f"{config.get('interval', 1)} 小时 第 {minute}"
if kind == "minute-n":
return f"{config.get('interval', 1)} 分钟"
if kind == "week":
w = int(config.get("week", 0))
wd = _WEEK_LABELS[w] if 0 <= w < 7 else f"{w}"
return f"每周{wd} {time_s}"
if kind == "month":
return f"每月 {config.get('monthDay', 1)}{time_s}"
return ""
+18
View File
@@ -0,0 +1,18 @@
"""Batch agent result server name resolution."""
from server.api.servers import _batch_server_display_name
from server.domain.models import Server
def test_batch_display_name_prefers_name():
s = Server(id=164, name="武汉高琼棠商务科技有限公司", domain="1.2.3.4")
assert _batch_server_display_name(s) == "武汉高琼棠商务科技有限公司"
def test_batch_display_name_falls_back_to_domain():
s = Server(id=59, name="", domain="whgqt.com")
assert _batch_server_display_name(s) == "whgqt.com"
def test_batch_display_name_missing_server():
assert _batch_server_display_name(None) == "未知服务器"

Some files were not shown because too many files have changed in this diff Show More