Compare commits

..

20 Commits

Author SHA1 Message Date
r c8efbc2a90 refactor(servers): 行操作移除删除按钮
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
删除改由批量栏「批量删除」执行,降低误删风险。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 11:35:47 +08:00
r 2cb0b57281 fix(servers): 提高行操作与批量栏文字可读性
tonal/flat 实底按钮、加大字号字重,恢复一键登录全文。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 11:32:32 +08:00
r 80d6aad54b style(servers): 行操作与批量栏分组图标化 UI
outlined 按钮组 + 批量栏运维/Agent/其他分区展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 11:29:27 +08:00
r 58843a1ef4 refactor(servers): 精简行操作并收拢批量诊断
一键登录文案、移除监测/站点行按钮,检测路径与诊断移至批量栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 11:23:39 +08:00
r 7add9384c1 feat: 终端路由保持会话 + 服务器列表按创建时间倒序
keep-alive 下终端切换不 dispose SSH;列表/推送/终端默认 newest first。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 11:11:00 +08:00
r 6720ca3474 feat(servers): 主列表宝塔登录 + 健康检查写心跳
主服务器列表操作列一键登录宝塔;SSH 健康检查写入 Redis 心跳并刷新在线状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 11:05:43 +08:00
r 2e797f86b7 feat(btpanel): 服务器列表增加在线状态列
批量读 Redis 心跳返回 status,前端与主服务器列表样式一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:54:04 +08:00
r 5ea5c3a411 fix(btpanel): 一键登录 URL 去掉安全入口路径
tmp_token 登录在 :port/login,非 :port/{admin}/login,后者 nginx 404。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:46:22 +08:00
r 3195787461 fix(btpanel): 非 root SSH bootstrap 自动 sudo 提权
ubuntu 等用户免密 sudo 下宝塔已装但脚本未提权导致 ssh_command_failed;附 ssh_timeout 运维清单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:40:46 +08:00
r 97c47a120f fix(btpanel): 面板登录 URL 使用宝塔 pyenv 执行 tools.py
系统 python3 缺 psutil 导致 SSH 回退失败;相对 tmp_token 自动拼安全入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:16:33 +08:00
r 028dc2c8b6 fix(btpanel): API base_url 不含安全入口路径
宝塔 API 走 :port/system,安全入口仅用于浏览器登录;修复代理 404。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:10:34 +08:00
r 51b6d0cdff fix(btpanel): API 签名改为 md5(time+token)
与面板 common.py 一致;原双重 md5 导致密钥校验失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:05:39 +08:00
r e5929694c3 fix(btpanel): bootstrap 写入 panel/config/api.json
宝塔读取 config/api.json 而非 data/api.json,错误路径导致白名单与 token 不生效。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:01:40 +08:00
r b768da3ab8 fix(btpanel): bootstrap 误判 exit_code=0 为失败
Python 中 0 or 1 为 1,导致 SSH 成功仍报 ssh_command_failed。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:52:22 +08:00
r 45ca57bf3f fix(ssh): 连接前从 preset_id 解析 SSH 密码
修复宝塔批量引导因仅关联密码预设、行内 password 为空而全部 ssh_command_failed。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:47:22 +08:00
r 14bbfc4ab7 fix(btpanel): 批量获取时跳过已配置 API 的服务器
前端仅提交未配置 ID;后端 batch 任务对已配置记为跳过。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:39:57 +08:00
r 770469c4aa fix(btpanel): 批量勾选提交真实 server id
v-data-table 勾选值为 id 数组,修复误传 undefined 导致 422。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:37:04 +08:00
r b4436143a6 feat(btpanel): 服务器列表批量 SSH 获取 API
支持全部未配置与多选批量引导;未配置 API 时禁用一键登录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:34:02 +08:00
r 7c99298419 fix(btpanel): 连接配置页始终显示全局设置
未选服务器时不再隐藏整页内容,避免白屏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:30:03 +08:00
r 461ac81221 fix(btpanel): 接受相对路径 tmp_token 登录链接
宝塔 tools.py 常返回 /login?tmp_token=...,现与 base_url 拼接为完整 URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:26:57 +08:00
51 changed files with 1691 additions and 211 deletions
@@ -0,0 +1,40 @@
# 终端页切换路由保持 SSH 会话
**日期**2026-06-18
## 摘要
用户从「终端」页导航至其他 SPA 页面时,WebSSH 会话不再被主动断开;返回终端页可继续操作同一标签与会话。
## 动机
此前 `TerminalPage` 被刻意排除在 `keep-alive` 之外,且 `onBeforeUnmount` 中调用 `disposeAllSessions()`,路由切换即卸载组件并关闭全部 WebSocket/SSH 连接,与运维「开终端查问题、切页看列表、再回来继续敲命令」的工作流冲突。
## 方案
1.`TerminalPage` 加入 `CACHED_PAGE_NAMES`,由 `App.vue` 现有 `<keep-alive>` 缓存实例。
2. 生命周期调整:
- `onDeactivated`:仅移除全局快捷键监听,**不** dispose 会话。
- `onActivated`:恢复快捷键、refit xterm、聚焦当前标签。
- `onBeforeUnmount`:仅在缓存淘汰或组件真正销毁时 dispose(与 `keep-alive max=12` 一致)。
- 监听 `auth.isLoggedIn`:登出时 dispose,避免缓存中残留 SSH 连接。
未将会话提升到全局 store`termNativeStore` 已是模块级单例,配合 keep-alive 即可,改动最小。
## 涉及文件
- `frontend/src/constants/cachedPages.ts`
- `frontend/src/pages/TerminalPage.vue`
## 迁移 / 重启
无。仅前端构建/热更新。
## 验证
```bash
cd frontend && npm run build
pytest tests/test_webssh_terminal_payload.py tests/test_terminal_quick_commands.py -q
```
手动:登录 → 终端连接一台服务器 → 侧栏切「服务器」→ 再切回「终端」→ 会话仍 connected,可继续输入;登出后重进终端应为空会话。
@@ -0,0 +1,27 @@
# 2026-06-21 宝塔 bootstrap 写入正确 api.json 路径
## 摘要
SSH bootstrap 将 API 配置写入 `/www/server/panel/data/api.json`,而宝塔面板实际读取 `/www/server/panel/config/api.json`,导致白名单与 token 未生效,所有代理 API 返回「IP校验失败」。
## 动机
生产验收:8 台已 configured 服务器全部代理失败;子机 exec 显示 `config/api.json` 白名单仍为旧 IP `172.96.253.26`,与 `data/api.json` 不一致。
## 涉及文件
- `server/infrastructure/btpanel/client.py` — 签名 `md5(time+token)`
- `server/infrastructure/btpanel/ssh_bootstrap.py``base_url` 不含安全入口路径
- `tests/test_btpanel_ssh_bootstrap.py``exit_code=0` 回归用例(上一轮)
## 迁移 / 重启
- 需部署 API 后,对已 configured 服务器**重新执行 bootstrap**(手动或批量)以合并白名单与 token 到正确文件。
- 无需 DB 迁移。
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py -q
# 生产:POST /api/btpanel/servers/{id}/bootstrap 后 GET .../system/total 应返回真实指标而非 IP 错误
```
@@ -0,0 +1,29 @@
# Changelog — 宝塔批量 SSH 获取 API2026-06-21
**日期**2026-06-21
## 摘要
服务器列表页增加批量/全量「SSH 获取 API」;未配置 API 时禁用一键登录(避免 404 临时链)。
## 动机
- 2000+ 子机需先写入 `api.json` 才能稳定一键登录
- 原批量入口仅在连接配置页,不易发现
- `all_unconfigured` 服务端解析未配置列表,突破请求体 500 台上限
## 涉及文件
- `server/api/btpanel_schemas.py``all_unconfigured`
- `server/application/services/btpanel_service.py`
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue`
- `frontend/src/pages/btpanel/BtPanelSettingsPage.vue`
- `frontend/src/api/btpanel.ts`
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_*.py -q
cd frontend && npx vite build
# 服务器列表 → 全部未配置 / 勾选 → 查看批量任务进度
```
@@ -0,0 +1,28 @@
# 2026-06-21 宝塔 bootstrap 非 root SSH 自动 sudo
## 摘要
ubuntu 等非 root SSH 用户(如 `preset_id=2`)可连机且宝塔已装,但 bootstrap 脚本未提权导致读 `port.pl` 失败,批量报 `ssh_command_failed`。现对 bootstrap、宝塔检测、面板地址探测、临时登录 SSH 统一 `sudo -n bash -c`
## 动机
生产抽样 #135/#136/#147SSH✅、`sudo -n`✅、root 下宝塔✅,非 sudo bootstrap 崩溃。
## 涉及文件
- `server/infrastructure/btpanel/ssh_bootstrap.py``wrap_sudo_nopasswd``ssh_sudo_required`
- `server/infrastructure/btpanel/ssh_login.py` — 检测/探测/登录命令提权
- `tests/test_btpanel_ssh_bootstrap.py`
- `docs/reports/2026-06-21-btpanel-ssh-timeout-servers.md` — 超时机器运维清单
## 迁移 / 重启
- 部署 API 后对未配置服务器重跑批量 bootstrap。
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py tests/test_btpanel_ssh_login.py -q
POST /api/btpanel/servers/135/bootstrap # ubuntu 抽样
POST /api/btpanel/servers/batch-bootstrap {"all_unconfigured": true}
```
@@ -0,0 +1,21 @@
# 2026-06-21 宝塔一键登录 URL 路径修复
## 摘要
临时登录链接误拼为 `https://ip:port/{安全入口}/login?tmp_token=...`,宝塔实际入口为 `https://ip:port/login?tmp_token=...`(带安全入口路径返回 nginx 404)。
## 动机
生产 curl`/1f78dd53/login?tmp_token=...` → 404`/login?tmp_token=...` → 302。
## 涉及文件
- `server/infrastructure/btpanel/ssh_login.py``_panel_port_base``_login_path_only``_discover_panel_login_base_url`
- `tests/test_btpanel_ssh_login.py`
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_login.py -q
POST /api/btpanel/servers/3/login-url # url 应为 ...:port/login?tmp_token=
```
@@ -0,0 +1,26 @@
# 2026-06-21 宝塔面板登录 URL 修复
## 摘要
修复「面板登录」`POST /api/btpanel/servers/{id}/login-url` 返回 500:SSH 回退改用宝塔自带 `pyenv` 执行 `tools.py`,相对路径自动探测含安全入口的浏览器地址。
## 动机
生产验收:系统 `python3``tools.py``psutil``get_temp_login_ipv4` 输出多行(裸 base + `/login?tmp_token=`),需解析并拼安全入口。
## 涉及文件
- `server/infrastructure/btpanel/ssh_login.py` — pyenv Python、多行解析、`resolve_temp_login_url`
- `server/application/services/btpanel_service.py` — API `set_temp_login` 相对路径走面板地址探测
- `tests/test_btpanel_ssh_login.py`
## 迁移 / 重启
- 部署 API 即可;无需 DB 迁移。
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_login.py -q
# 生产:POST /api/btpanel/servers/3/login-url → 200url 含 tmp_token 与安全入口
```
@@ -0,0 +1,19 @@
# 2026-06-21 宝塔服务器列表增加在线状态列
## 摘要
`GET /api/btpanel/servers` 与「宝塔 · 服务器列表」表格新增 **状态** 列(在线 / 离线 / 未知),与主服务器列表一致,优先读 Redis 心跳。
## 涉及文件
- `server/application/services/btpanel_service.py`
- `frontend/src/api/btpanel.ts`
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue`
- `tests/test_btpanel_server_list.py`
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_server_list.py -q
# 前端:宝塔 → 服务器列表,状态列显示在线/离线
```
@@ -0,0 +1,24 @@
# Changelog — 宝塔连接配置页空白修复(2026-06-21)
**日期**2026-06-21
## 摘要
修复「连接配置」在未选服务器时整页几乎空白:全局设置(中心 IP、自动获取、批量初始化)现始终可见。
## 动机
`BtPanelPageShell` 原先要求先选服务器才渲染插槽,全局配置被挡在门外,用户看到白屏或仅一条提示。
## 涉及文件
- `frontend/src/components/btpanel/BtPanelPageShell.vue` — 新增 `requireServer`(默认 true
- `frontend/src/pages/btpanel/BtPanelSettingsPage.vue``require-server=false`,单机块按选中状态展示
## 迁移 / 重启
- 仅前端
## 验证
打开 `#/btpanel/settings`:应立刻看到「全局」卡片;选服务器后显示「当前服务器」表单。
@@ -0,0 +1,28 @@
# Changelog — 宝塔临时登录相对路径修复(2026-06-21)
**日期**2026-06-21
## 摘要
修复 `tools.py get_temp_login_ipv4` / `set_temp_login` 返回 `/login?tmp_token=...` 相对路径时被误判为无效链接的问题。
## 动机
部分宝塔版本 SSH 输出不含协议与主机,原正则仅接受 `https://.../login?tmp_token=...`,导致一键登录报错。
## 涉及文件
- `server/infrastructure/btpanel/ssh_login.py``normalize_temp_login_url`、无 base_url 时 SSH 探测面板地址
- `server/infrastructure/btpanel/credentials.py``get_bt_panel_base_url`
- `server/application/services/btpanel_service.py` — API/SSH 登录统一规范化
- `tests/test_btpanel_ssh_login.py`
## 迁移 / 重启
- 需重启 API 容器
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_login.py -q
```
@@ -0,0 +1,31 @@
# 健康检查写入 Redis 心跳
**日期**2026-06-21
## 变更摘要
批量「健康检查」在 SSH 探测后,将结果写入 `heartbeat:{server_id}`(与 Agent 心跳同结构),列表/宝塔模块可立即反映在线状态;任务结果 stdout 附带心跳时间。
## 动机
健康检查仅返回成功/失败,不更新 Redis,导致 `#/servers` 状态列与宝塔列表仍显示旧心跳或 unknown。
## 涉及文件
- `server/application/server_connectivity.py``write_ssh_health_heartbeat``normalize_ssh_probe_system_info`
- `server/application/services/server_batch_service.py``_run_health_check` 写心跳
- `server/application/services/server_service.py``check_all_servers` 同步写心跳
- `server/api/servers.py``has_heartbeat` 时无 Agent 也显示 offline/online
- `tests/test_server_connectivity.py``tests/test_server_status_field.py`
## 迁移 / 重启
需重启 API worker(后端部署)。
## 验证方式
```bash
.venv/bin/pytest tests/test_server_connectivity.py tests/test_server_status_field.py -q
```
生产:对离线机执行健康检查后刷新 `#/servers`,状态列应更新;批量任务详情 stdout 含「心跳 2026-…」。
@@ -0,0 +1,27 @@
# 服务器列表默认按创建时间倒序
**日期**2026-06-21
## 变更摘要
服务器列表、推送/终端全量拉取默认 `sort_by=created_at&sort_order=desc`;后端 `server_repo` 无排序参数时同样默认最新在前。
## 动机
新机入库后应出现在列表顶部,便于运维发现与操作。
## 涉及文件
- `server/infrastructure/database/server_repo.py`
- `frontend/src/composables/useServerPagination.ts`
- `frontend/src/composables/push/usePushServers.ts`
- `frontend/src/composables/terminal/useTerminalSessions.ts`
- `frontend/src/utils/serverTableSort.ts`
## 迁移 / 重启
需 API 重启(后端排序逻辑)。
## 验证
打开 `#/servers`,新加服务器应排在前列;终端/推送页服务器下拉顺序一致。
@@ -0,0 +1,29 @@
# 主服务器列表增加宝塔一键登录
**日期**2026-06-21
## 变更摘要
在主服务器列表(`#/servers`)每行操作列增加「宝塔」按钮,调用 `POST /api/btpanel/servers/{id}/login-url` 在新标签页打开宝塔临时登录链接(API 优先,失败则 SSH 回退)。
## 动机
运维在浏览全量服务器时无需先进入宝塔子模块即可一键登录面板。
## 涉及文件
- `frontend/src/components/servers/ServerTableRowActions.vue` — 新增「宝塔」按钮
- `frontend/src/composables/btpanel/useBtPanelLogin.ts` — 登录逻辑复用
- `frontend/src/pages/ServersPage.vue` — 主表与未设路径表接线
- `frontend/src/components/servers/ServerUnsetPathPanel.vue` — 透传 loading / emit
- `frontend/src/constants/serverTableHeaders.ts` — 操作列宽 320→380
## 迁移 / 重启
无。仅前端构建部署。
## 验证方式
1. `cd frontend && npx vite build`
2. 打开 `#/servers`,任一行点击「宝塔」,应新标签打开宝塔登录页
3. 未配置 API 的机器仍可走 SSH 回退(需 SSH 可达且已装宝塔)
@@ -0,0 +1,43 @@
# 服务器列表操作列与批量栏调整
**日期**2026-06-21
## 变更摘要
- 行内宝塔按钮文案改为「一键登录」
- 移除行内「监测」「站点」按钮(站点链接仍在名称/域名列可点)
- 「检测路径」「诊断」从每行操作移至顶部批量操作栏(需勾选服务器)
- 批量「检测路径」文案统一;「诊断」须单选 1 台
## 动机
行操作过多拥挤;路径检测与 Agent 诊断属批量运维动作,与「健康检查」同类。
## 涉及文件
- `frontend/src/components/servers/ServerTableRowActions.vue`
- `frontend/src/components/servers/ServerBatchActionBar.vue`
- `frontend/src/pages/ServersPage.vue`
- `frontend/src/components/servers/ServerUnsetPathPanel.vue`
- `frontend/src/constants/serverTableHeaders.ts`
## 迁移 / 重启
仅前端构建部署。
## 验证
1. `#/servers` 行内见分组 outlined 按钮(终端/登录、文件/编辑、删除图标)
2. 勾选多台 → 批量栏分组展示运维 / Agent / 其他
3. 单选 1 台 → 批量栏「诊断」可用
## 2026-06-21 UI 迭代
- 行操作:`v-btn-group` outlined + 图标;宝塔入口显示「登录」+ tooltip
- 批量栏:浅底描边卡片,按运维/Agent/其他分组,tonal 按钮带图标
## 2026-06-21 可读性修正
- 行按钮改 `small` + `tonal` + 字重 600,恢复「一键登录」全文
- 批量栏改 `flat` 实色按钮、分组标题加深,底卡用 surface 实底
- 行操作移除「删除」;删除请用批量栏「批量删除」
@@ -0,0 +1,29 @@
# Changelog — SSH 预设凭据解析修复(2026-06-21
**日期**2026-06-21
## 摘要
修复仅设置 `preset_id`、未复制 `password` 字段时 SSH/宝塔批量引导全部失败(`ssh_command_failed`)。
## 动机
任务 #113 等 10/10 失败:服务器关联密码预设「商城默认密码」,但 `asyncssh_pool` 只读行内 `password`,未解析预设。
## 涉及文件
- `server/infrastructure/ssh/resolve_server_auth.py`(新)
- `server/infrastructure/ssh/asyncssh_pool.py``acquire` 前物化凭据
- `server/infrastructure/btpanel/ssh_bootstrap.py``ssh_no_credentials` 错误码
- `tests/test_resolve_server_auth.py`
## 迁移 / 重启
- 需重启 API 容器
## 验证
```bash
.venv/bin/pytest tests/test_resolve_server_auth.py tests/test_btpanel_*.py -q
# 重跑宝塔批量 SSH 获取 API
```
@@ -0,0 +1,37 @@
# 宝塔 bootstrap SSH 超时机器清单
生成时间:2026-06-21 02:40 UTC
## 说明
以下服务器在批量 bootstrap(任务 #119 / #121)中报告 `ssh_timeout`:中心机 SSH 在超时内无响应。
请在云平台核对:实例是否运行、公网 IP 是否与 Nexus `domain` 一致、安全组是否放行 **22** 端口。
**合计:17 台**
| ID | 名称 | SSH IP (domain) | preset_id | 分类 | 失败任务 |
|----|------|-----------------|-----------|------|----------|
| 4 | 武汉念曦焱网络科技有限公司 | 8.163.90.186 | — | 商城-待扫 | #119,#121 |
| 22 | 广州申迹网络科技有限公司 | 47.107.163.227 | — | 鞋靴 | #119,#121 |
| 24 | 子-东莞启楷网络科技有限公司 | 42.121.221.178 | — | 情趣/成人 | #119,#121 |
| 41 | 海南铭维网络科技有限公司-子 | 120.79.223.248 | — | 商城-待扫 | #119,#121 |
| 51 | 安庆驰探电子商务有限公司-子 | 120.25.48.230 | — | 商城-待扫 | #119,#121 |
| 101 | 东莞炎且网络科技有限公司 | 47.121.181.238 | — | 情趣/成人 | #119,#121 |
| 200 | 湖北昂翊电子商务有限公司 | 47.96.165.168 | — | 商城-待扫 | #119,#121 |
| 225 | 武汉市运来万电子有限公司 | 47.113.192.161 | — | 机器人 | #119,#121 |
| 313 | 武汉市温胜夜科技有限公司 | 42.193.171.106 | — | 包袋/配饰 | #119,#121 |
| 314 | 子-武汉市温胜夜科技有限公司 | 159.75.124.238 | — | 包袋/配饰 | #119,#121 |
| 315 | 漫窗星(武汉市)电子商务有限公司 | 159.75.25.162 | — | 机器人 | #119,#121 |
| 316 | 子-漫窗星(武汉市)电子商务有限公司 | 111.230.99.21 | — | 机器人 | #119,#121 |
| 317 | 武汉市雾霜月百货有限公司 | 129.204.187.80 | — | 机器人 | #119,#121 |
| 318 | 子-武汉市雾霜月百货有限公司 | 1.14.158.107 | — | 机器人 | #119,#121 |
| 332 | 湖北优点鼎电子商务有限公司 | 47.121.187.133 | — | 机器人 | #119,#121 |
| 367 | 冲量夏序 | 120.79.11.13 | — | 商城-待扫 | #119,#121 |
| 371 | 冲量银海-泽初 | 47.107.181.56 | — | 鞋靴 | #119,#121 |
## 运维检查命令(中心机)
```bash
# 替换 IP
nc -zv -w 5 <IP> 22
```
+10 -2
View File
@@ -5,6 +5,8 @@ export interface BtPanelServerRow {
name: string
domain: string
category: string | null
is_online: boolean
status: 'online' | 'offline' | 'unknown'
base_url: string | null
api_key_set: boolean
verify_ssl: boolean
@@ -85,10 +87,16 @@ export function bootstrapBtServer(serverId: number) {
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/bootstrap`, { method: 'POST', body: '{}' })
}
export function batchBootstrapBtServers(serverIds: number[]) {
export function batchBootstrapBtServers(
serverIds: number[],
options?: { allUnconfigured?: boolean },
) {
return api<BatchJobResponse>('/btpanel/servers/batch-bootstrap', {
method: 'POST',
body: JSON.stringify({ server_ids: serverIds }),
body: JSON.stringify({
server_ids: serverIds,
all_unconfigured: options?.allUnconfigured ?? false,
}),
})
}
@@ -16,9 +16,9 @@
</div>
</div>
<v-alert v-if="!serverId" type="info" variant="tonal" class="mb-4" text="请选择服务器" />
<v-alert v-if="props.requireServer && !serverId" type="info" variant="tonal" class="mb-4" text="请选择服务器" />
<slot v-else :server-id="serverId" :server="selected()" />
<slot :server-id="serverId" :server="selected()" />
</v-container>
</template>
@@ -28,10 +28,14 @@ import BtPanelServerPicker from '@/components/btpanel/BtPanelServerPicker.vue'
import { btPanelContextKey } from '@/composables/btpanel/btPanelContext'
import { useBtPanelServer } from '@/composables/btpanel/useBtPanelServer'
defineProps<{
const props = withDefaults(defineProps<{
title: string
subtitle?: string
}>()
/** 为 false 时未选服务器也渲染默认插槽(如连接配置页的全局块) */
requireServer?: boolean
}>(), {
requireServer: true,
})
const ctx = useBtPanelServer()
const { servers, serverId, loading, loadServers, setServerId, selected } = ctx
@@ -1,16 +1,79 @@
<template>
<v-card v-if="count > 0" class="mx-4 mb-2" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2 flex-wrap ga-1">
<span class="text-body-2 mr-4">已选择 {{ count }} 台服务器</span>
<v-spacer />
<v-btn size="small" variant="text" @click="$emit('batch-category')">修改分类</v-btn>
<v-btn size="small" variant="text" @click="$emit('health-check')">健康检查</v-btn>
<v-btn size="small" variant="text" @click="$emit('detect-path')">检测目标路径</v-btn>
<v-btn size="small" variant="text" @click="$emit('install-agent')">安装Agent</v-btn>
<v-btn size="small" variant="text" @click="$emit('upgrade-agent')">升级Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="$emit('uninstall-agent')">卸载Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="$emit('batch-delete')">批量删除</v-btn>
<v-btn size="small" variant="text" @click="$emit('clear')">取消选择</v-btn>
<v-card
v-if="count > 0"
class="server-batch-bar mx-4 mb-2"
variant="flat"
rounded="lg"
border
>
<v-card-text class="server-batch-bar__inner py-3">
<div class="d-flex align-center flex-wrap ga-3">
<v-chip color="primary" variant="flat" size="default" label prepend-icon="mdi-checkbox-marked-circle-outline">
已选 {{ count }}
</v-chip>
<v-divider vertical class="server-batch-bar__divider d-none d-sm-flex" />
<div class="server-batch-bar__section">
<span class="server-batch-bar__label">运维</span>
<div class="d-flex flex-wrap ga-2">
<v-btn size="default" variant="flat" color="primary" class="server-batch-bar__btn" prepend-icon="mdi-heart-pulse" @click="$emit('health-check')">
健康检查
</v-btn>
<v-btn size="default" variant="flat" color="primary" class="server-batch-bar__btn" prepend-icon="mdi-folder-search-outline" @click="$emit('detect-path')">
检测路径
</v-btn>
<v-btn
size="default"
variant="flat"
color="primary"
class="server-batch-bar__btn"
prepend-icon="mdi-stethoscope"
:loading="agentDiagnoseLoading"
@click="$emit('agent-diagnose')"
>
诊断
</v-btn>
</div>
</div>
<v-divider vertical class="server-batch-bar__divider d-none d-md-flex" />
<div class="server-batch-bar__section">
<span class="server-batch-bar__label">Agent</span>
<div class="d-flex flex-wrap ga-2">
<v-btn size="default" variant="flat" color="info" class="server-batch-bar__btn" prepend-icon="mdi-download" @click="$emit('install-agent')">
安装
</v-btn>
<v-btn size="default" variant="flat" color="warning" class="server-batch-bar__btn" prepend-icon="mdi-arrow-up-bold" @click="$emit('upgrade-agent')">
升级
</v-btn>
<v-btn size="default" variant="flat" color="error" class="server-batch-bar__btn" prepend-icon="mdi-package-down" @click="$emit('uninstall-agent')">
卸载
</v-btn>
</div>
</div>
<v-divider vertical class="server-batch-bar__divider d-none d-md-flex" />
<div class="server-batch-bar__section">
<span class="server-batch-bar__label">其他</span>
<div class="d-flex flex-wrap ga-2">
<v-btn size="default" variant="flat" color="secondary" class="server-batch-bar__btn" prepend-icon="mdi-tag-outline" @click="$emit('batch-category')">
修改分类
</v-btn>
<v-btn size="default" variant="flat" color="error" class="server-batch-bar__btn" prepend-icon="mdi-delete-sweep" @click="$emit('batch-delete')">
批量删除
</v-btn>
</div>
</div>
<v-spacer class="d-none d-lg-flex" />
<v-btn size="default" variant="outlined" class="server-batch-bar__btn" prepend-icon="mdi-close" @click="$emit('clear')">
取消选择
</v-btn>
</div>
</v-card-text>
</v-card>
</template>
@@ -18,12 +81,14 @@
<script setup lang="ts">
defineProps<{
count: number
agentDiagnoseLoading?: boolean
}>()
defineEmits<{
'batch-category': []
'health-check': []
'detect-path': []
'agent-diagnose': []
'install-agent': []
'upgrade-agent': []
'uninstall-agent': []
@@ -31,3 +96,43 @@ defineEmits<{
clear: []
}>()
</script>
<style scoped>
.server-batch-bar {
background: rgb(var(--v-theme-surface));
border-color: rgba(var(--v-theme-on-surface), 0.16) !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.server-batch-bar__inner {
padding-inline: 16px !important;
}
.server-batch-bar__section {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.server-batch-bar__label {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(var(--v-theme-on-surface), 0.82);
line-height: 1.2;
}
.server-batch-bar__btn {
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: none;
}
.server-batch-bar__divider {
align-self: stretch;
min-height: 48px;
opacity: 0.5;
}
</style>
@@ -0,0 +1,74 @@
<template>
<div class="server-row-actions d-flex align-center justify-end flex-nowrap ga-1">
<v-btn
size="small"
variant="tonal"
color="primary"
density="comfortable"
class="server-row-actions__btn"
prepend-icon="mdi-console"
title="WebSSH 终端"
@click.stop="$emit('terminal')"
>
终端
</v-btn>
<v-btn
size="small"
variant="tonal"
color="deep-orange"
density="comfortable"
class="server-row-actions__btn"
prepend-icon="mdi-login-variant"
title="一键登录宝塔面板"
:loading="btLoginLoading"
@click.stop="$emit('bt-login')"
>
一键登录
</v-btn>
<v-btn
size="small"
variant="tonal"
color="secondary"
density="comfortable"
class="server-row-actions__btn"
prepend-icon="mdi-folder-outline"
title="文件管理"
@click.stop="$emit('files')"
>
文件
</v-btn>
<v-btn
size="small"
variant="tonal"
density="comfortable"
class="server-row-actions__btn"
prepend-icon="mdi-pencil-outline"
title="编辑服务器"
@click.stop="$emit('edit')"
>
编辑
</v-btn>
</div>
</template>
<script setup lang="ts">
defineProps<{
btLoginLoading?: boolean
}>()
defineEmits<{
terminal: []
'bt-login': []
files: []
edit: []
}>()
</script>
<style scoped>
.server-row-actions__btn {
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: none;
}
</style>
@@ -14,18 +14,20 @@
</v-chip>
<v-spacer />
<v-btn
size="small"
variant="tonal"
:disabled="total === 0"
:loading="selectAllLoading"
@click="$emit('select-all-filtered')"
prepend-icon="mdi-folder-search"
size="small"
:disabled="selectedCount === 0"
title="请先勾选至少一台服务器"
@click="$emit('detect-path')"
>
全选筛选结果 ({{ total }})
检测路径
</v-btn>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-refresh"
class="ml-2"
:loading="loading"
@click="$emit('refresh')"
>
@@ -33,11 +35,28 @@
</v-btn>
</v-card-title>
<v-card-text class="pt-0 pb-2">
<div class="d-flex align-center flex-wrap ga-2">
<v-spacer />
<v-btn
size="small"
variant="tonal"
:disabled="total === 0"
:loading="selectAllLoading"
@click="$emit('select-all-filtered')"
>
全选筛选结果 ({{ total }})
</v-btn>
</div>
</v-card-text>
<ServerBatchActionBar
:count="selectedCount"
:agent-diagnose-loading="agentDiagnoseLoading"
@batch-category="$emit('batch-category')"
@health-check="$emit('health-check')"
@detect-path="$emit('detect-path')"
@agent-diagnose="$emit('agent-diagnose')"
@install-agent="$emit('install-agent')"
@upgrade-agent="$emit('upgrade-agent')"
@uninstall-agent="$emit('uninstall-agent')"
@@ -149,26 +168,13 @@
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="$emit('terminal', item)">
终端
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="$emit('files', item)">
文件
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="$emit('detect-path-single', item)">
检测路径
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="$emit('agent-diagnose', item)">
诊断
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="$emit('edit', item)">
编辑
</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click.stop="$emit('delete', item)">
删除
</v-btn>
</div>
<ServerTableRowActions
:bt-login-loading="btLoginLoadingId === item.id"
@terminal="$emit('terminal', item)"
@bt-login="$emit('bt-login', item)"
@files="$emit('files', item)"
@edit="$emit('edit', item)"
/>
</template>
<template #expanded-row="{ columns, item }">
@@ -198,6 +204,8 @@ import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status
import { normalizeServerIds } from '@/utils/serverSelection'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
import ServerTableRowActions from '@/components/servers/ServerTableRowActions.vue'
import { SERVER_DATA_TABLE_HEADERS } from '@/constants/serverTableHeaders'
const props = defineProps<{
highlight?: boolean
@@ -214,6 +222,8 @@ const props = defineProps<{
editingPathId: number | null
editingPathValue: string
savingPathId: number | null
btLoginLoadingId: number | null
agentDiagnoseLoading: boolean
}>()
const emit = defineEmits<{
@@ -227,17 +237,16 @@ const emit = defineEmits<{
'select-all-filtered': []
'toggle-expand': [item: ServerApiItem]
terminal: [item: ServerApiItem]
'bt-login': [item: ServerApiItem]
files: [item: ServerApiItem]
'detect-path-single': [item: ServerApiItem]
'agent-diagnose': [item: ServerApiItem]
edit: [item: ServerApiItem]
delete: [item: ServerApiItem]
'edit-path': [item: ServerApiItem]
'save-path': [serverId: number]
'cancel-edit-path': []
'batch-category': []
'health-check': []
'detect-path': []
'agent-diagnose': []
'install-agent': []
'upgrade-agent': []
'uninstall-agent': []
@@ -245,16 +254,7 @@ const emit = defineEmits<{
'clear-selection': []
}>()
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'domain' },
{ title: '分类', key: 'category', width: 100 },
{ title: '目标路径', key: 'target_path', width: 200 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 260, align: 'end' as const, sortable: false },
]
const headers = SERVER_DATA_TABLE_HEADERS
const selectedCount = computed(() => normalizeServerIds(props.selectedItems).length)
@@ -0,0 +1,21 @@
import { ref } from 'vue'
import { createBtLoginUrl } from '@/api/btpanel'
export function useBtPanelLogin() {
const loadingId = ref<number | null>(null)
async function openBtLogin(serverId: number) {
loadingId.value = serverId
try {
const { url } = await createBtLoginUrl(serverId)
window.open(url, '_blank', 'noopener,noreferrer')
window.$snackbar?.('已打开宝塔登录链接', 'success')
} catch (e: unknown) {
window.$snackbar?.(e instanceof Error ? e.message : '生成宝塔登录链接失败', 'error')
} finally {
loadingId.value = null
}
}
return { loadingId, openBtLogin }
}
@@ -45,7 +45,12 @@ export function usePushServers(pushStatus: Ref<Record<number, string>>) {
async function loadServers(silent = false) {
if (!silent) serversLoading.value = true
try {
const res = await fetchPagePerPage<PushServerItem>('/servers/', {}, 1, -1)
const res = await fetchPagePerPage<PushServerItem>(
'/servers/',
{ sort_by: 'created_at', sort_order: 'desc' },
1,
-1,
)
servers.value = res.items
} catch {
servers.value = []
@@ -729,7 +729,12 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
serversLoading.value = true
try {
await loadHistoryOnly()
const { items } = await fetchPagePerPage<ServerApiItem>('/servers/', {}, 1, -1)
const { items } = await fetchPagePerPage<ServerApiItem>(
'/servers/',
{ sort_by: 'created_at', sort_order: 'desc' },
1,
-1,
)
servers.value = items.map(toTerminalServerItem)
} catch {
servers.value = []
@@ -27,7 +27,7 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
const categoryFilter = ref('')
/** null = all; true = online only; false = offline only */
const isOnlineFilter = ref<boolean | null>(null)
const sortBy = ref<TableSortItem[]>([{ key: 'status', order: 'asc' }])
const sortBy = ref<TableSortItem[]>([{ key: 'created_at', order: 'desc' }])
function listQueryParams(): Record<string, unknown> {
const q = String(search.value ?? '').trim()
@@ -0,0 +1,11 @@
/** Shared column config for main / unset-path server tables. */
export const SERVER_DATA_TABLE_HEADERS = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'domain' },
{ title: '分类', key: 'category', width: 100 },
{ title: '目标路径', key: 'target_path', width: 200 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 330, align: 'end' as const, sortable: false },
]
+82 -88
View File
@@ -34,6 +34,48 @@
<span class="text-caption text-medium-emphasis">点击顶部统计卡可切换筛选或定位</span>
</div>
<ServerUnsetPathPanel
:highlight="unsetPathFocus"
:servers="unsetPathServers"
:loading="unsetPathLoading"
:total="unsetPathTotal"
:page="unsetPathPage"
:items-per-page="unsetPathItemsPerPage"
:items-per-page-options="dataTablePageOptions"
:expanded-ids="expandedUnsetIds"
v-model:selected-items="unsetSelectedItems"
v-model:sort-by="sortBy"
:select-all-loading="selectAllUnsetLoading"
:editing-path-id="editingPathId"
:editing-path-value="editingPathValue"
:saving-path-id="savingPathId"
:bt-login-loading-id="btLoginLoadingId"
:agent-diagnose-loading="agentDiagnoseLoading"
@refresh="loadUnsetPathServers()"
@update:page="onUnsetPageChange"
@update:items-per-page="onUnsetItemsPerPageChange"
@update:expanded-ids="expandedUnsetIds = $event"
@update:editing-path-value="editingPathValue = $event"
@select-all-filtered="selectAllUnsetFiltered"
@toggle-expand="toggleUnsetExpand"
@terminal="openTerminal"
@bt-login="openBtLogin($event.id)"
@files="openFiles"
@edit="editServer"
@edit-path="startEditPath"
@save-path="saveTargetPath"
@cancel-edit-path="cancelEditPath"
@batch-category="openBatchCategory('unset')"
@health-check="batchHealthCheck('unset')"
@detect-path="openDetectPathConfirm('unset')"
@agent-diagnose="batchAgentDiagnose('unset')"
@install-agent="batchInstallAgent('unset')"
@upgrade-agent="batchUpgradeAgent('unset')"
@uninstall-agent="batchUninstallAgent('unset')"
@batch-delete="confirmBatchDelete('unset')"
@clear-selection="unsetSelectedItems = []"
/>
<!-- Server Table -->
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
@@ -134,9 +176,11 @@
<!-- Batch Actions -->
<ServerBatchActionBar
:count="selectedIds.size"
:agent-diagnose-loading="agentDiagnoseLoading"
@batch-category="openBatchCategory('main')"
@health-check="batchHealthCheck('main')"
@detect-path="openDetectPathConfirm('main')"
@agent-diagnose="batchAgentDiagnose('main')"
@install-agent="batchInstallAgent('main')"
@upgrade-agent="batchUpgradeAgent('main')"
@uninstall-agent="batchUninstallAgent('main')"
@@ -279,32 +323,13 @@
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1 align-center">
<v-btn
:icon="pinnedMap[item.id] != null ? 'mdi-check' : 'mdi-plus'"
size="x-small"
variant="text"
:color="pinnedMap[item.id] != null ? 'success' : 'primary'"
density="compact"
:title="pinnedMap[item.id] != null ? `已在监测 · 槽 ${pinnedMap[item.id]! + 1}` : '加入实时监测'"
:loading="pinLoadingId === item.id"
@click.stop="onPinServer(item)"
/>
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(item)">终端</v-btn>
<v-btn
v-if="resolveServerSiteUrl(item)"
variant="text"
size="x-small"
density="compact"
@click.stop="openBrowser(item)"
>
站点
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="openFiles(item)">文件</v-btn>
<v-btn variant="text" size="x-small" density="compact" :loading="agentDiagnoseLoadingId === item.id" @click.stop="openAgentDiagnose(item)">诊断</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="editServer(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click.stop="confirmDelete(item)">删除</v-btn>
</div>
<ServerTableRowActions
:bt-login-loading="btLoginLoadingId === item.id"
@terminal="openTerminal(item)"
@bt-login="openBtLogin(item.id)"
@files="openFiles(item)"
@edit="editServer(item)"
/>
</template>
<template #expanded-row="{ columns, item }">
@@ -411,47 +436,6 @@
</div>
</v-card>
<ServerUnsetPathPanel
:highlight="unsetPathFocus"
:servers="unsetPathServers"
:loading="unsetPathLoading"
:total="unsetPathTotal"
:page="unsetPathPage"
:items-per-page="unsetPathItemsPerPage"
:items-per-page-options="dataTablePageOptions"
:expanded-ids="expandedUnsetIds"
v-model:selected-items="unsetSelectedItems"
v-model:sort-by="sortBy"
:select-all-loading="selectAllUnsetLoading"
:editing-path-id="editingPathId"
:editing-path-value="editingPathValue"
:saving-path-id="savingPathId"
@refresh="loadUnsetPathServers()"
@update:page="onUnsetPageChange"
@update:items-per-page="onUnsetItemsPerPageChange"
@update:expanded-ids="expandedUnsetIds = $event"
@update:editing-path-value="editingPathValue = $event"
@select-all-filtered="selectAllUnsetFiltered"
@toggle-expand="toggleUnsetExpand"
@terminal="openTerminal"
@files="openFiles"
@detect-path-single="detectPathSingle"
@agent-diagnose="openAgentDiagnose"
@edit="editServer"
@delete="confirmDelete"
@edit-path="startEditPath"
@save-path="saveTargetPath"
@cancel-edit-path="cancelEditPath"
@batch-category="openBatchCategory('unset')"
@health-check="batchHealthCheck('unset')"
@detect-path="openDetectPathConfirm('unset')"
@install-agent="batchInstallAgent('unset')"
@upgrade-agent="batchUpgradeAgent('unset')"
@uninstall-agent="batchUninstallAgent('unset')"
@batch-delete="confirmBatchDelete('unset')"
@clear-selection="unsetSelectedItems = []"
/>
<!-- Pending / failed SSH connections -->
<v-card id="pending-servers-panel" elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
@@ -770,12 +754,15 @@ import WatchSlotRow from '@/components/watch/WatchSlotRow.vue'
import { useWatchPins } from '@/composables/useWatchPins'
import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
import ServerTableRowActions from '@/components/servers/ServerTableRowActions.vue'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerUnsetPathPanel from '@/components/servers/ServerUnsetPathPanel.vue'
import { SERVER_DATA_TABLE_HEADERS } from '@/constants/serverTableHeaders'
import CredentialsDialog from '@/components/credentials/CredentialsDialog.vue'
import AgentDiagnoseDialog from '@/components/servers/AgentDiagnoseDialog.vue'
import type { AgentDiagnoseResult } from '@/types/agentDiagnose'
import { useServerFormDialog } from '@/composables/servers/useServerFormDialog'
import { useBtPanelLogin } from '@/composables/btpanel/useBtPanelLogin'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
import {
SERVER_STAT_CARD_KEYS,
@@ -856,6 +843,7 @@ const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const { pinnedMap, pinServer: doPinServer, slots: watchSlots } = useWatchPins()
const pinLoadingId = ref<number | null>(null)
const { loadingId: btLoginLoadingId, openBtLogin } = useBtPanelLogin()
const showReplacePin = ref(false)
const replacePinServerId = ref<number | null>(null)
@@ -1084,14 +1072,6 @@ function onUnsetItemsPerPageChange(n: number) {
void loadUnsetPathServers()
}
async function detectPathSingle(item: ServerApiItem) {
try {
await submitServerBatchJob('detect-path', [item.id])
} catch (e: unknown) {
snackbar(formatApiError(e, '提交检测任务失败'), 'error')
}
}
function clearExpandedForPathMigration(serverId: number, newPath: string) {
const unset = isUnsetTargetPath(newPath)
if (expandedMainIds.value.includes(serverId) && unset) expandedMainIds.value = []
@@ -1415,16 +1395,7 @@ const selectedItems = ref<Array<ServerApiItem | number>>([])
const selectedIds = computed(() => new Set(normalizeServerIds(selectedItems.value)))
// Table config
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'domain' },
{ title: '分类', key: 'category', width: 100 },
{ title: '目标路径', key: 'target_path', width: 200 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 200, align: 'end' as const, sortable: false },
]
const headers = SERVER_DATA_TABLE_HEADERS
const statsBooting = ref(true)
const statsRefreshing = ref(false)
@@ -1578,17 +1549,41 @@ function openFiles(item: ServerApiItem) {
const showAgentDiagnose = ref(false)
const agentDiagnoseLoading = ref(false)
const agentDiagnoseLoadingId = ref<number | null>(null)
const agentActionLoadingId = ref<number | null>(null)
const agentDiagnoseResult = ref<AgentDiagnoseResult | null>(null)
const agentDiagnoseTarget = ref<ServerApiItem | null>(null)
function resolveBatchServerItem(scope: BatchScope, serverId: number): ServerApiItem | undefined {
const { items } = batchSelection(scope)
const fromSelection = items.value.find(
(entry): entry is ServerApiItem => typeof entry === 'object' && entry.id === serverId,
)
if (fromSelection) return fromSelection
const list = scope === 'main' ? servers.value : unsetPathServers.value
return list.find(s => s.id === serverId)
}
async function batchAgentDiagnose(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (ids.length !== 1) {
snackbar(ids.length === 0 ? '请先选择 1 台服务器' : 'Agent 诊断每次仅支持单选 1 台', 'warning')
return
}
const item = resolveBatchServerItem(scope, ids[0])
if (!item) {
snackbar('未找到所选服务器', 'error')
return
}
await openAgentDiagnose(item)
}
async function openAgentDiagnose(item: ServerApiItem) {
agentDiagnoseTarget.value = item
agentDiagnoseResult.value = null
showAgentDiagnose.value = true
agentDiagnoseLoading.value = true
agentDiagnoseLoadingId.value = item.id
try {
agentDiagnoseResult.value = await http.post<AgentDiagnoseResult>(`/servers/${item.id}/agent-diagnose`, {})
} catch (e: unknown) {
@@ -1596,7 +1591,6 @@ async function openAgentDiagnose(item: ServerApiItem) {
snackbar(formatApiError(e, 'Agent 诊断失败'), 'error')
} finally {
agentDiagnoseLoading.value = false
agentDiagnoseLoadingId.value = null
}
}
+36 -4
View File
@@ -115,8 +115,9 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, inject, nextTick, type Ref } from 'vue'
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, inject, nextTick, type Ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import '@xterm/xterm/css/xterm.css'
import { useSnackbar } from '@/composables/useSnackbar'
import { useTerminalSessions } from '@/composables/terminal/useTerminalSessions'
@@ -131,6 +132,7 @@ import TerminalContextMenu from '@/components/terminal/TerminalContextMenu.vue'
defineOptions({ name: 'TerminalPage' })
const router = useRouter()
const auth = useAuthStore()
const snackbar = useSnackbar()
const nexusDrawer = inject<Ref<boolean>>('nexusDrawer', ref(true))
@@ -303,20 +305,50 @@ function onTermMenuKeydown(e: KeyboardEvent) {
}
}
onMounted(async () => {
function attachTerminalKeydown() {
document.addEventListener('keydown', onGlobalKeydown)
document.addEventListener('keydown', onTermMenuKeydown)
}
function detachTerminalKeydown() {
document.removeEventListener('keydown', onGlobalKeydown)
document.removeEventListener('keydown', onTermMenuKeydown)
}
onMounted(async () => {
await loadServers()
await loadQuickCmds()
await applyRouteQuery()
})
onActivated(() => {
attachTerminalKeydown()
showTermMenu.value = false
nextTick(() => {
refitAllTerminals()
setTimeout(refitAllTerminals, 220)
const s = activeSession.value
if (s) getNative(s.id)?.term?.focus()
})
})
onDeactivated(() => {
detachTerminalKeydown()
showTermMenu.value = false
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onGlobalKeydown)
document.removeEventListener('keydown', onTermMenuKeydown)
detachTerminalKeydown()
showTermMenu.value = false
disposeAllSessions()
})
watch(
() => auth.isLoggedIn,
(loggedIn) => {
if (!loggedIn) disposeAllSessions()
},
)
</script>
<style scoped>
+139 -7
View File
@@ -4,10 +4,30 @@
<div>
<h1 class="text-h6 font-weight-bold">宝塔 · 服务器列表</h1>
<p class="text-caption text-medium-emphasis mb-0">
选择服务器一键打开宝塔面板临时链接约 3 小时有效
先批量 SSH 获取 API再一键登录未配置 API 时登录链接易 404
</p>
</div>
<v-spacer />
<v-btn
color="secondary"
variant="tonal"
prepend-icon="mdi-download-network"
:disabled="!unconfiguredCount"
:loading="batchLoading"
@click="openBatchConfirm('all')"
>
全部未配置{{ unconfiguredCount }}
</v-btn>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-playlist-check"
:disabled="!selectedUnconfiguredCount"
:loading="batchLoading"
@click="openBatchConfirm('selected')"
>
选中获取 API{{ selectedUnconfiguredCount }}
</v-btn>
<v-text-field
v-model="search"
density="compact"
@@ -20,13 +40,20 @@
</div>
<v-data-table
v-model="selectedIds"
:headers="headers"
:items="filteredServers"
:loading="loading"
density="compact"
item-value="id"
show-select
hover
>
<template #item.status="{ item }">
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label>
{{ statusLabel(item.status) }}
</v-chip>
</template>
<template #item.configured="{ item }">
<v-chip size="x-small" :color="item.configured ? 'success' : 'warning'" variant="tonal">
{{ item.configured ? 'API 已配置' : '未配置' }}
@@ -54,8 +81,10 @@
variant="tonal"
color="primary"
prepend-icon="mdi-open-in-new"
:disabled="!item.configured"
:loading="loginLoadingId === item.id"
@click="openLogin(item.id)"
:title="item.configured ? '打开宝塔临时登录' : '请先 SSH 获取 API'"
@click="openLogin(item)"
>
一键登录
</v-btn>
@@ -69,23 +98,62 @@
</v-btn>
</template>
</v-data-table>
<v-dialog v-model="batchConfirmOpen" max-width="520">
<v-card title="批量 SSH 获取宝塔 API">
<v-card-text>
<p class="mb-2">
将对 <strong>{{ batchTargetCount }}</strong> 台服务器执行 SSH 引导开启子机
<code>api.json</code>写入 Nexus 加密凭据追加中心 IP 白名单
</p>
<p
v-if="batchMode === 'selected' && selectedConfiguredSkipCount > 0"
class="text-caption text-medium-emphasis mb-2"
>
已选 {{ selectedIds.length }} 其中 {{ selectedConfiguredSkipCount }} API 已配置将自动跳过
</p>
<p class="text-caption text-medium-emphasis mb-0">
前提子机 root SSH 可达已装宝塔未装会标记为安装中并每 5 分钟重试
连接配置中需已设置中心调用 IP
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="batchConfirmOpen = false">取消</v-btn>
<v-btn color="primary" :loading="batchLoading" @click="confirmBatchBootstrap">开始</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { createBtLoginUrl, listBtServers, type BtPanelServerRow } from '@/api/btpanel'
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
import {
batchBootstrapBtServers,
createBtLoginUrl,
listBtServers,
type BtPanelServerRow,
} from '@/api/btpanel'
import { statusChipColor, statusLabel } from '@/utils/status'
defineOptions({ name: 'BtPanelLoginPage' })
const loading = ref(false)
const batchLoading = ref(false)
const batchConfirmOpen = ref(false)
const batchMode = ref<'all' | 'selected'>('all')
const loginLoadingId = ref<number | null>(null)
const search = ref('')
const servers = ref<BtPanelServerRow[]>([])
/** v-data-table 勾选值为 item-valueid)数组,不是行对象 */
const selectedIds = ref<number[]>([])
const headers = [
{ title: '名称', key: 'name', minWidth: 120 },
{ title: '地址', key: 'domain', minWidth: 120 },
{ title: '状态', key: 'status', width: 88, sortable: true },
{ title: '分类', key: 'category', minWidth: 80 },
{ title: 'API', key: 'configured', width: 110, sortable: false },
{ title: '引导', key: 'bootstrap_state', width: 100, sortable: false },
@@ -101,6 +169,30 @@ const bootstrapStateLabels: Record<string, string> = {
disabled: '已关闭',
}
const unconfiguredCount = computed(() => servers.value.filter(s => !s.configured).length)
function serverById(id: number) {
return servers.value.find(s => s.id === id)
}
function selectedUnconfiguredIds(): number[] {
return selectedIds.value.filter((id) => {
const row = serverById(id)
return Boolean(row && !row.configured)
})
}
const selectedUnconfiguredCount = computed(() => selectedUnconfiguredIds().length)
const selectedConfiguredSkipCount = computed(() => {
if (!selectedIds.value.length) return 0
return selectedIds.value.length - selectedUnconfiguredCount.value
})
const batchTargetCount = computed(() =>
batchMode.value === 'all' ? unconfiguredCount.value : selectedUnconfiguredCount.value,
)
function bootstrapLabel(state: string) {
return bootstrapStateLabels[state] ?? state
}
@@ -116,17 +208,32 @@ const filteredServers = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return servers.value
return servers.value.filter(s =>
[s.name, s.domain, s.category ?? '', s.base_url ?? '']
[s.name, s.domain, s.category ?? '', s.base_url ?? '', statusLabel(s.status)]
.join(' ')
.toLowerCase()
.includes(q),
)
})
function openBatchConfirm(mode: 'all' | 'selected') {
batchMode.value = mode
if (mode === 'selected') {
if (!selectedUnconfiguredCount.value) {
window.$snackbar?.('所选服务器均已配置 API,无需重复获取', 'info')
return
}
} else if (!unconfiguredCount.value) {
window.$snackbar?.('没有未配置的服务器', 'info')
return
}
batchConfirmOpen.value = true
}
async function loadServers() {
loading.value = true
try {
servers.value = await listBtServers()
selectedIds.value = selectedIds.value.filter(id => servers.value.some(row => row.id === id))
} catch (e: unknown) {
window.$snackbar?.(e instanceof Error ? e.message : '加载服务器列表失败', 'error')
} finally {
@@ -134,10 +241,14 @@ async function loadServers() {
}
}
async function openLogin(serverId: number) {
loginLoadingId.value = serverId
async function openLogin(item: BtPanelServerRow) {
if (!item.configured) {
window.$snackbar?.('请先批量或单机「SSH 获取 API」', 'warning')
return
}
loginLoadingId.value = item.id
try {
const { url } = await createBtLoginUrl(serverId)
const { url } = await createBtLoginUrl(item.id)
window.open(url, '_blank', 'noopener,noreferrer')
window.$snackbar?.('已打开宝塔登录链接', 'success')
} catch (e: unknown) {
@@ -147,5 +258,26 @@ async function openLogin(serverId: number) {
}
}
async function confirmBatchBootstrap() {
batchLoading.value = true
try {
let job
if (batchMode.value === 'all') {
job = await batchBootstrapBtServers([], { allUnconfigured: true })
} else {
const ids = selectedUnconfiguredIds()
job = await batchBootstrapBtServers(ids)
}
batchConfirmOpen.value = false
window.$snackbar?.(`已启动批量任务 #${job.job_id}`, 'success')
await openServerBatchJobResult(job.job_id, job.label)
void loadServers()
} catch (e: unknown) {
window.$snackbar?.(e instanceof Error ? e.message : '批量任务启动失败', 'error')
} finally {
batchLoading.value = false
}
}
onMounted(() => { void loadServers() })
</script>
@@ -1,5 +1,9 @@
<template>
<BtPanelPageShell title="宝塔 · 连接配置" subtitle="SSH 自动获取 API 或手动填写面板地址与密钥">
<BtPanelPageShell
title="宝塔 · 连接配置"
subtitle="SSH 自动获取 API 或手动填写面板地址与密钥"
:require-server="false"
>
<template #default="{ serverId }">
<v-card max-width="720" variant="outlined" rounded="lg" class="mb-4">
<v-card-title class="text-subtitle-1">全局</v-card-title>
@@ -33,17 +37,34 @@
>
批量初始化未配置{{ unconfiguredIds.length }}
</v-btn>
<v-alert
v-if="globalLoaded && !serverList.length"
type="warning"
variant="tonal"
class="mt-4"
title="暂无服务器"
text="请先在「服务器」页添加子机,再回到此处配置宝塔连接。"
/>
</v-card-text>
</v-card>
<v-card max-width="720" variant="outlined" rounded="lg" :loading="loading">
<v-alert
v-if="!serverId"
type="info"
variant="tonal"
class="mb-4"
title="单机配置"
text="请在右上角选择服务器,或从「服务器列表」点击「连接配置」进入。"
/>
<v-card v-else max-width="720" variant="outlined" rounded="lg" :loading="loading">
<v-card-title class="text-subtitle-1">当前服务器</v-card-title>
<v-card-text>
<div class="d-flex flex-wrap align-center ga-2 mb-3">
<v-chip :color="btInstalledChipColor" size="small" variant="tonal">
SSH 检测{{ btInstalledLabel }}
</v-chip>
<v-btn
v-if="serverId"
icon="mdi-refresh"
size="x-small"
variant="text"
@@ -66,11 +87,11 @@
variant="tonal"
class="mb-4"
:loading="bootstrapping"
@click="runBootstrap(serverId!)"
@click="runBootstrap(serverId)"
>
立即 SSH 获取
</v-btn>
<v-form @submit.prevent="save(serverId!)">
<v-form @submit.prevent="save(serverId)">
<v-switch
v-model="form.auto_bootstrap"
label="自动获取(每 5 分钟重试,直至成功)"
@@ -145,6 +166,7 @@ const globalSettings = ref<BtPanelGlobalSettings | null>(null)
const serverList = ref<Awaited<ReturnType<typeof listBtServers>>>([])
const form = reactive({ base_url: '', api_key: '', verify_ssl: false, auto_bootstrap: true })
const globalForm = reactive({ bt_panel_source_ip: '', bt_panel_auto_bootstrap_enabled: true })
const globalLoaded = ref(false)
const bootstrapStateLabels: Record<string, string> = {
pending: '等待获取',
@@ -201,7 +223,7 @@ async function loadConfig(id: number, refreshBtInstalled = false) {
}
}
const { serverId } = useBtPanelPageLoad(async (id) => {
useBtPanelPageLoad(async (id) => {
await loadConfig(id)
})
@@ -224,6 +246,8 @@ async function loadGlobal() {
serverList.value = await listBtServers()
} catch (e: unknown) {
window.$snackbar?.(e instanceof Error ? e.message : '加载全局设置失败', 'error')
} finally {
globalLoaded.value = true
}
}
@@ -287,11 +311,10 @@ async function runBootstrap(id: number) {
}
async function confirmBatchBootstrap() {
const ids = unconfiguredIds.value
if (!ids.length) return
if (!unconfiguredIds.value.length) return
batchLoading.value = true
try {
const job = await batchBootstrapBtServers(ids)
const job = await batchBootstrapBtServers([], { allUnconfigured: true })
batchConfirmOpen.value = false
window.$snackbar?.(`已启动批量任务 #${job.job_id}`, 'success')
} catch (e: unknown) {
+1
View File
@@ -13,6 +13,7 @@ const SERVER_COLUMN_TO_API: Record<string, string> = {
agent: 'agent_version',
last_heartbeat: 'heartbeat',
heartbeat: 'heartbeat',
created_at: 'created_at',
}
export function toServerApiSort(sortBy: TableSortItem[]): { sort_by?: string; sort_order?: 'asc' | 'desc' } {
+1
View File
@@ -151,6 +151,7 @@ async def batch_bootstrap_bt_servers(
try:
return await _svc(db).batch_bootstrap(
body.server_ids,
all_unconfigured=body.all_unconfigured,
admin_username=admin.username,
ip_address=_client_ip(request),
)
+9 -2
View File
@@ -2,7 +2,7 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class BtPanelConfigUpdate(BaseModel):
@@ -18,7 +18,14 @@ class BtPanelGlobalSettingsUpdate(BaseModel):
class BtPanelBatchBootstrap(BaseModel):
server_ids: list[int] = Field(..., min_length=1, max_length=500)
server_ids: list[int] = Field(default_factory=list, max_length=500)
all_unconfigured: bool = False
@model_validator(mode="after")
def _require_targets(self) -> "BtPanelBatchBootstrap":
if not self.all_unconfigured and not self.server_ids:
raise ValueError("server_ids 不能为空,或设置 all_unconfigured=true")
return self
class BtPanelSiteAction(BaseModel):
+9 -2
View File
@@ -2138,10 +2138,16 @@ async def _build_server_list_with_overlay(redis, servers: list) -> list[dict]:
return result
def _derive_server_status(is_online: bool | None, agent_version: str | None, *, has_agent: bool = False) -> str:
def _derive_server_status(
is_online: bool | None,
agent_version: str | None,
*,
has_agent: bool = False,
has_heartbeat: bool = False,
) -> str:
"""Map live/DB fields to frontend status: online | offline | unknown."""
has_agent = has_agent or bool(agent_version and str(agent_version).strip())
if has_agent or is_online:
if has_agent or has_heartbeat or is_online:
return "online" if is_online else "offline"
return "unknown"
@@ -2177,6 +2183,7 @@ def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict
server_data.get("is_online"),
server_data.get("agent_version") or server.agent_version,
has_agent=installed,
has_heartbeat=bool(heartbeat),
)
_apply_agent_guidance(server_data, server, heartbeat)
+46
View File
@@ -2,6 +2,9 @@
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import and_, or_, select
@@ -9,6 +12,8 @@ from sqlalchemy import and_, or_, select
from server.domain.models import Server
from server.infrastructure.database.server_repo import _target_path_unset_filter
logger = logging.getLogger("nexus.server_connectivity")
REDIS_KEY_PREFIX = "heartbeat:"
@@ -102,3 +107,44 @@ async def scan_monitored_connectivity(
"offline": offline,
"offline_servers": offline_servers,
}
def normalize_ssh_probe_system_info(probe: dict[str, Any]) -> dict[str, Any]:
"""Flatten ssh_health_probe payload into Agent-compatible system_info."""
raw = probe.get("system_info") or {}
if isinstance(raw, dict) and isinstance(raw.get("system_info"), dict):
system_info = dict(raw["system_info"])
elif isinstance(raw, dict):
system_info = dict(raw)
else:
system_info = {}
system_info["probe_channel"] = str(probe.get("channel") or "ssh")
if probe.get("status") != "online" and probe.get("error"):
system_info["probe_error"] = str(probe["error"])[:300]
return system_info
async def write_ssh_health_heartbeat(
redis: Any,
server: Server,
probe: dict[str, Any],
) -> str:
"""Write SSH health-check result to Redis heartbeat (same shape as Agent). Returns last_heartbeat ISO."""
from server.background.heartbeat_flush import FLUSH_INTERVAL
is_online = probe.get("status") == "online"
system_info = normalize_ssh_probe_system_info(probe)
now = datetime.now(timezone.utc).isoformat()
agent_version = (server.agent_version or "").strip()
key = f"{REDIS_KEY_PREFIX}{server.id}"
await redis.hset(key, mapping={
"is_online": str(is_online),
"system_info": json.dumps(system_info, ensure_ascii=False),
"last_heartbeat": now,
"agent_version": agent_version,
"time_drift_seconds": "0",
"drift_level": "ok",
})
await redis.expire(key, int(FLUSH_INTERVAL * 1.5))
return now
+55 -12
View File
@@ -30,7 +30,11 @@ from server.infrastructure.btpanel.bootstrap_state import (
from server.infrastructure.btpanel.bootstrap_lock import bootstrap_lock
from server.infrastructure.btpanel.source_ip import get_bt_panel_source_ip
from server.infrastructure.btpanel.ssh_bootstrap import BootstrapRemoteError, ssh_bootstrap_panel
from server.infrastructure.btpanel.ssh_login import detect_bt_panel_installed, ssh_temp_login_url
from server.infrastructure.btpanel.ssh_login import (
detect_bt_panel_installed,
resolve_temp_login_url,
ssh_temp_login_url,
)
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -56,14 +60,37 @@ class BtPanelService:
return BtPanelClient(creds, server.id)
async def list_server_statuses(self) -> list[dict[str, Any]]:
from server.infrastructure.redis.client import get_redis
from server.utils.agent_version import agent_is_installed
servers = await self.servers.get_all()
out: list[dict[str, Any]] = []
redis = get_redis()
pipe = redis.pipeline()
for s in servers:
pipe.hgetall(f"heartbeat:{s.id}")
heartbeats = await pipe.execute()
out: list[dict[str, Any]] = []
for s, heartbeat in zip(servers, heartbeats):
hb = heartbeat or {}
if hb:
is_online = hb.get("is_online") == "True"
agent_version = hb.get("agent_version") or s.agent_version
else:
is_online = bool(s.is_online)
agent_version = s.agent_version
has_agent = agent_is_installed(agent_version_db=s.agent_version, heartbeat=hb if hb else None)
if has_agent or hb or is_online:
status = "online" if is_online else "offline"
else:
status = "unknown"
out.append({
"id": s.id,
"name": s.name,
"domain": s.domain,
"category": s.category,
"is_online": is_online,
"status": status,
**public_bt_panel_status(s),
})
return out
@@ -238,8 +265,8 @@ class BtPanelService:
server = await self.get_server(server_id)
if bt_panel_configured(server) and source == "background":
return {**public_bt_panel_status(server), "skipped": True, "reason": "already_configured"}
if bt_panel_configured(server) and source in ("background", "batch"):
return {**public_bt_panel_status(server), "ok": True, "skipped": True, "reason": "already_configured"}
if source == "background" and not is_eligible_for_background_bootstrap(server):
return {**public_bt_panel_status(server), "skipped": True, "reason": "not_due"}
@@ -325,14 +352,26 @@ class BtPanelService:
self,
server_ids: list[int],
*,
all_unconfigured: bool = False,
admin_username: str,
ip_address: str | None,
) -> dict[str, Any]:
from server.application.services.server_batch_service import start_batch_job
ids = [int(x) for x in server_ids if x]
if all_unconfigured:
statuses = await self.list_server_statuses()
ids = [int(s["id"]) for s in statuses if not s.get("configured")]
else:
ids = []
for x in server_ids:
try:
n = int(x)
except (TypeError, ValueError):
continue
if n > 0:
ids.append(n)
if not ids:
raise ValueError("server_ids 不能为空")
raise ValueError("没有可批量初始化的服务器(均已配置或未录入)")
return await start_batch_job(
op="bt-panel-bootstrap",
server_ids=ids,
@@ -378,14 +417,14 @@ class BtPanelService:
creds = read_bt_panel_credentials(server)
if creds:
try:
url = await self._login_url_via_api(creds, server.id)
url = await self._login_url_via_api(creds, server)
except BtPanelApiError as exc:
logger.warning("bt panel api temp login failed server=%s: %s", server_id, exc)
method = "ssh_fallback"
if not url:
method = "ssh"
url = await ssh_temp_login_url(server)
url = await ssh_temp_login_url(server, base_url=None)
await self.audit.create(AuditLog(
admin_username=admin_username,
@@ -397,20 +436,24 @@ class BtPanelService:
))
return {"url": url, "method": method}
async def _login_url_via_api(self, creds: BtPanelCredentials, server_id: int) -> str:
client = BtPanelClient(creds, server_id)
async def _login_url_via_api(
self,
creds: BtPanelCredentials,
server: Server,
) -> str:
client = BtPanelClient(creds, server.id)
data = await client.post("/config?action=set_temp_login", {})
if isinstance(data, dict):
for key in ("url", "login_url", "msg"):
val = data.get(key)
if isinstance(val, str) and "tmp_token=" in val:
return val.strip()
return await resolve_temp_login_url(server, val)
if data.get("status") and isinstance(data.get("data"), dict):
inner = data["data"]
for key in ("url", "login_url"):
val = inner.get(key)
if isinstance(val, str) and "tmp_token=" in val:
return val.strip()
return await resolve_temp_login_url(server, val)
raise BtPanelApiError("set_temp_login 未返回登录链接")
async def proxy(
@@ -476,6 +476,7 @@ async def _run_category(live: dict[str, Any]) -> None:
async def _run_health_check(live: dict[str, Any]) -> None:
from server.application.server_connectivity import write_ssh_health_heartbeat
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:
@@ -487,11 +488,21 @@ async def _run_health_check(live: dict[str, Any]) -> None:
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 ""
last_hb = ""
try:
last_hb = await write_ssh_health_heartbeat(get_redis(), server, probe)
except Exception as redis_err:
logger.warning("health check heartbeat write failed server=%s: %s", sid, redis_err)
hb_note = f" · 心跳 {last_hb[:19]}" if last_hb else ""
status_label = "在线" if online else "离线"
line = f"{status_label}{hb_note}"
if detail:
line = f"{line} · {detail}"
return result_item_dict(
server_id=sid,
server_name=label,
success=online,
stdout=str(detail)[:500] if online else "",
stdout=line[:500],
error="" if online else str(probe.get("error") or "离线")[:300],
)
except Exception as e:
@@ -747,6 +758,13 @@ async def _run_bt_panel_bootstrap(live: dict[str, Any]) -> None:
ip_address=None,
force=True,
)
if result.get("skipped"):
return result_item_dict(
server_id=sid,
server_name=label,
success=True,
stdout="已配置 API,已跳过",
)
if result.get("ok"):
actions = result.get("bootstrap_last_actions") or []
detail = ", ".join(actions) if actions else "已写入 API 配置"
@@ -41,13 +41,25 @@ class ServerService:
async def check_all_servers(self, server_ids: List[int]) -> Dict[int, dict]:
"""Check health of multiple servers via SSH (no Agent port 8601 required)."""
from server.application.server_connectivity import write_ssh_health_heartbeat
from server.infrastructure.redis.client import get_redis
from server.infrastructure.ssh.remote_probe import ssh_health_probe
results = {}
redis = get_redis()
async def _check_one(server: Server) -> tuple:
try:
probe = await ssh_health_probe(server, timeout=15)
try:
last_hb = await write_ssh_health_heartbeat(redis, server, probe)
probe = {**probe, "last_heartbeat": last_hb}
except Exception as redis_err:
logger.warning(
"health check heartbeat write failed server=%s: %s",
server.id,
redis_err,
)
return server.id, probe
except Exception as e:
return server.id, {"status": "offline", "error": str(e)[:200], "channel": "ssh"}
+2 -2
View File
@@ -33,8 +33,8 @@ class BtPanelClient:
def _sign(self) -> dict[str, int | str]:
now = int(time.time())
key_md5 = hashlib.md5(self.creds.api_key.encode()).hexdigest() # nosec B324 — 宝塔 API 协议要求
token = hashlib.md5(f"{now}{key_md5}".encode()).hexdigest() # nosec B324 — 宝塔 API 协议要求
# 宝塔 common.py: md5(request_time + api_token),非 md5(token) 后再签
token = hashlib.md5(f"{now}{self.creds.api_key}".encode()).hexdigest() # nosec B324
return {"request_time": now, "request_token": token}
def _cookie_key(self) -> str:
@@ -33,6 +33,11 @@ def bt_panel_configured(server: Server) -> bool:
return bool(str(block.get("base_url") or "").strip() and block.get("api_key_enc"))
def get_bt_panel_base_url(server: Server) -> str | None:
url = str(get_bt_panel_block(server).get("base_url") or "").strip().rstrip("/")
return url or None
def read_bt_panel_credentials(server: Server) -> BtPanelCredentials | None:
block = get_bt_panel_block(server)
base_url = str(block.get("base_url") or "").strip().rstrip("/")
+20 -5
View File
@@ -19,7 +19,7 @@ import json, os, secrets, sys, subprocess
PANEL_ROOT = "/www/server/panel"
DATA_DIR = os.path.join(PANEL_ROOT, "data")
API_PATH = os.path.join(DATA_DIR, "api.json")
API_PATH = os.path.join(PANEL_ROOT, "config", "api.json")
def read_text(path, default=""):
try:
@@ -51,8 +51,7 @@ def main():
scheme = "https" if ssl else "http"
host = panel_host or "127.0.0.1"
base = f"{scheme}://{host}:{port}"
if admin_path and admin_path != "/":
base += "/" + admin_path.strip("/")
# API 根路径不含安全入口;浏览器登录 URL 由 ssh_login 单独探测 admin_path
api = {}
if os.path.isfile(API_PATH):
try:
@@ -108,11 +107,22 @@ def build_bootstrap_command(*, center_ip: str, panel_host: str) -> str:
)
def wrap_sudo_nopasswd(command: str, ssh_user: str | None) -> str:
"""Run remote command as root when SSH user is non-root (passwordless sudo)."""
user = (ssh_user or "root").strip() or "root"
if user == "root":
return command
return f"sudo -n bash -c {shlex.quote(command)}"
def classify_ssh_result(result: dict[str, Any]) -> tuple[str, bool]:
"""Return (error_code, permanent)."""
status = result.get("status") or ""
stderr = (result.get("stderr") or "").lower()
stderr_full = (result.get("stderr") or "")
stderr = stderr_full.lower()
stdout = (result.get("stdout") or "").lower()
if "没有可用的 ssh 凭据" in stderr_full or "没有可用的 SSH 凭据" in stderr_full:
return "ssh_no_credentials", True
if status == "connection_error":
return "ssh_connection_failed", False
if status == "timeout":
@@ -128,7 +138,11 @@ def classify_ssh_result(result: dict[str, Any]) -> tuple[str, bool]:
)
if any(m in stderr for m in auth_markers):
return "ssh_auth_failed", True
if status != "success" or int(result.get("exit_code") or 1) != 0:
if "a password is required" in stderr and "sudo" in stderr:
return "ssh_sudo_required", True
exit_raw = result.get("exit_code")
exit_code = int(exit_raw) if exit_raw is not None else 1
if status != "success" or exit_code != 0:
if "permission denied" in stdout and "api.json" in stdout:
return "ssh_permission_denied", False
return "ssh_command_failed", False
@@ -157,6 +171,7 @@ class BootstrapRemoteError(Exception):
async def ssh_bootstrap_panel(server: Server, *, center_ip: str) -> dict[str, Any]:
cmd = build_bootstrap_command(center_ip=center_ip, panel_host=(server.domain or "").strip())
cmd = wrap_sudo_nopasswd(cmd, server.username)
result = await exec_ssh_command(server, cmd, timeout=60, login_shell=False)
err_code, permanent = classify_ssh_result(result)
if err_code:
+155 -11
View File
@@ -7,42 +7,186 @@ import re
from urllib.parse import urlparse
from server.domain.models import Server
from server.infrastructure.btpanel.ssh_bootstrap import wrap_sudo_nopasswd
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
logger = logging.getLogger("nexus.btpanel.ssh")
_BT_PANEL_TOOLS = "/www/server/panel/tools.py"
_URL_RE = re.compile(r"^https?://\S+/login\?tmp_token=\S+$", re.IGNORECASE)
_BT_PANEL_PYTHON = "/www/server/panel/pyenv/bin/python"
_FULL_LOGIN_RE = re.compile(r"^https?://\S+.*tmp_token=", re.IGNORECASE)
_REL_LOGIN_RE = re.compile(r"^/?login\?tmp_token=\S+", re.IGNORECASE)
SSH_TEMP_LOGIN_CMD = (
f"test -f {_BT_PANEL_TOOLS} && "
f"cd /www/server/panel && python3 tools.py get_temp_login_ipv4 2>/dev/null | tail -1"
f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; "
f"cd /www/server/panel && \"$P\" tools.py get_temp_login_ipv4 2>/dev/null | tail -3"
)
SSH_PANEL_BASE_CMD = (
f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; "
"$P -c \""
"import os; P='/www/server/panel/data'; "
"port=open(os.path.join(P,'port.pl')).read().strip() or '8888'; "
"admin=open(os.path.join(P,'admin_path.pl')).read().strip() "
"if os.path.isfile(os.path.join(P,'admin_path.pl')) else ''; "
"ssl=os.path.isfile(os.path.join(P,'ssl.pl')); "
"scheme='https' if ssl else 'http'; "
"host='__HOST__'; "
"base=f'{scheme}://{host}:{port}'; "
"admin=admin.strip('/'); "
"print(base + ('/' + admin if admin else ''))"
"\""
)
# 临时登录在 :port/login,不在 :port/{安全入口}/login(后者 nginx 404
SSH_PANEL_LOGIN_BASE_CMD = (
f"P={_BT_PANEL_PYTHON}; test -x \"$P\" || P=python3; "
"$P -c \""
"import os; P='/www/server/panel/data'; "
"port=open(os.path.join(P,'port.pl')).read().strip() or '8888'; "
"ssl=os.path.isfile(os.path.join(P,'ssl.pl')); "
"scheme='https' if ssl else 'http'; "
"host='__HOST__'; "
"print(f'{scheme}://{host}:{port}')"
"\""
)
DETECT_BT_CMD = "test -f /www/server/panel/class/common.py && echo BT_OK || echo BT_NO"
async def detect_bt_panel_installed(server: Server) -> bool:
result = await exec_ssh_command(server, DETECT_BT_CMD, timeout=20)
cmd = wrap_sudo_nopasswd(DETECT_BT_CMD, server.username)
result = await exec_ssh_command(server, cmd, timeout=20)
if result.get("status") != "success":
return False
return "BT_OK" in (result.get("stdout") or "")
def _panel_port_base(url: str) -> str:
"""``https://host:port`` — strip path (安全入口不属于 tmp_token 登录路径)."""
parsed = urlparse((url or "").strip())
if not parsed.scheme or not parsed.netloc:
return (url or "").strip().rstrip("/")
return f"{parsed.scheme}://{parsed.netloc}"
def _login_path_only(path: str) -> str:
"""``/1f78dd53/login`` → ``/login``."""
path = (path or "").strip() or "/login"
idx = path.find("/login")
if idx >= 0:
return path[idx:]
return path if path.startswith("/") else f"/{path}"
def _rewrite_login_host(url: str, prefer_host: str) -> str:
"""Replace panel hostname with server.domain so browser can reach the sub-server."""
prefer_host = prefer_host.strip()
if not prefer_host or not _URL_RE.match(url):
if not prefer_host or "tmp_token=" not in url or not url.startswith("http"):
return url
parsed = urlparse(url)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return f"{parsed.scheme}://{prefer_host}:{port}{parsed.path}?{parsed.query}"
path = _login_path_only(parsed.path or "/login")
query = parsed.query
suffix = f"{path}?{query}" if query else path
return f"{parsed.scheme}://{prefer_host}:{port}{suffix}"
async def ssh_temp_login_url(server: Server) -> str:
result = await exec_ssh_command(server, SSH_TEMP_LOGIN_CMD, timeout=45)
def normalize_temp_login_url(
raw: str,
*,
base_url: str | None,
prefer_host: str = "",
) -> str:
"""Accept full URL or Baota-relative ``/login?tmp_token=...``."""
line = (raw or "").strip()
if not line or "tmp_token=" not in line:
raise ValueError("missing tmp_token")
if _FULL_LOGIN_RE.match(line):
parsed = urlparse(line)
path = _login_path_only(parsed.path or "/login")
port_base = _panel_port_base(line)
rebuilt = f"{port_base}{path}"
if parsed.query:
rebuilt = f"{rebuilt}?{parsed.query}"
return _rewrite_login_host(rebuilt, prefer_host)
if _REL_LOGIN_RE.match(line):
base = _panel_port_base((base_url or "").strip())
if not base:
raise RuntimeError(
"宝塔返回相对登录路径,需要面板 base_url;请在连接配置保存地址或先 SSH 获取 API"
)
path = _login_path_only(line if line.startswith("/") else f"/{line}")
return _rewrite_login_host(f"{base}{path}", prefer_host)
raise RuntimeError(f"未获得有效临时登录链接: {line[:120]}")
def _extract_temp_login_line(stdout: str) -> str:
"""Pick the tools.py line containing tmp_token (may follow a bare base URL line)."""
lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()]
for line in reversed(lines):
if "tmp_token=" in line:
return line
return lines[-1] if lines else ""
async def _discover_panel_login_base_url(server: Server) -> str:
host = (server.domain or "127.0.0.1").strip()
cmd = wrap_sudo_nopasswd(SSH_PANEL_LOGIN_BASE_CMD.replace("__HOST__", host), server.username)
result = await exec_ssh_command(server, cmd, timeout=25)
if result.get("status") != "success":
raise RuntimeError(result.get("stderr") or result.get("stdout") or "SSH 探测面板地址失败")
base = (result.get("stdout") or "").strip().splitlines()[-1].strip()
if not base.startswith("http"):
raise RuntimeError(f"SSH 未解析出面板 login base: {base[:120]}")
return _panel_port_base(base)
async def _discover_panel_base_url(server: Server) -> str:
"""Browser panel entry with security path (not for tmp_token login)."""
host = (server.domain or "127.0.0.1").strip()
cmd = wrap_sudo_nopasswd(SSH_PANEL_BASE_CMD.replace("__HOST__", host), server.username)
result = await exec_ssh_command(server, cmd, timeout=25)
if result.get("status") != "success":
raise RuntimeError(result.get("stderr") or result.get("stdout") or "SSH 探测面板地址失败")
base = (result.get("stdout") or "").strip().splitlines()[-1].strip()
if not base.startswith("http"):
raise RuntimeError(f"SSH 未解析出面板 base_url: {base[:120]}")
return base.rstrip("/")
async def resolve_temp_login_url(server: Server, raw: str) -> str:
"""Normalize API/SSH temp login payload to a browser-reachable URL."""
line = raw.strip()
base: str | None = None
if _REL_LOGIN_RE.match(line):
base = await _discover_panel_login_base_url(server)
return normalize_temp_login_url(
line,
base_url=base,
prefer_host=server.domain or "",
)
async def ssh_temp_login_url(server: Server, *, base_url: str | None = None) -> str:
cmd = wrap_sudo_nopasswd(SSH_TEMP_LOGIN_CMD, server.username)
result = await exec_ssh_command(server, cmd, timeout=45)
if result.get("status") != "success":
raise RuntimeError(result.get("stderr") or result.get("stdout") or "SSH 执行失败")
line = (result.get("stdout") or "").strip().splitlines()[-1].strip()
if not _URL_RE.match(line):
raise RuntimeError(f"未获得有效临时登录链接: {line[:120]}")
return _rewrite_login_host(line, server.domain or "")
line = _extract_temp_login_line(result.get("stdout") or "")
if not line or "tmp_token=" not in line:
raise RuntimeError(
(result.get("stdout") or result.get("stderr") or "SSH 未返回临时登录链接")[:200]
)
if _REL_LOGIN_RE.match(line) and (base_url or "").strip():
return normalize_temp_login_url(
line,
base_url=_panel_port_base(base_url.strip()),
prefer_host=server.domain or "",
)
return await resolve_temp_login_url(server, line)
+17 -5
View File
@@ -64,9 +64,23 @@ _SORT_COLUMNS = {
"agent_version": Server.agent_version,
"category": Server.category,
"target_path": Server.target_path,
"created_at": Server.created_at,
}
def _server_list_order(sort_by: Optional[str], sort_order: Optional[str]):
"""Default: newest created first."""
if not sort_by:
return desc(Server.created_at), desc(Server.id)
sort_col = _SORT_COLUMNS.get(sort_by)
if sort_col is None:
return desc(Server.created_at), desc(Server.id)
reverse = sort_order == "desc"
primary = desc(sort_col) if reverse else asc(sort_col)
secondary = desc(Server.id) if reverse else asc(Server.id)
return primary, secondary
class ServerRepositoryImpl:
"""Async SQLAlchemy implementation of Server data access"""
@@ -141,10 +155,8 @@ class ServerRepositoryImpl:
for f in filters:
data_query = data_query.where(f)
# Sorting (whitelist-validated)
sort_col = _SORT_COLUMNS.get(sort_by, Server.id)
order_func = desc if sort_order == "desc" else asc
data_query = data_query.order_by(order_func(sort_col), Server.id)
primary, secondary = _server_list_order(sort_by, sort_order)
data_query = data_query.order_by(primary, secondary)
data_query = data_query.offset(offset).limit(fetch_limit)
result = await self.session.execute(data_query)
servers = list(result.scalars().all())
@@ -188,7 +200,7 @@ class ServerRepositoryImpl:
data_query = select(Server)
for f in filters:
data_query = data_query.where(f)
data_query = data_query.order_by(Server.id).limit(max_rows)
data_query = data_query.order_by(desc(Server.created_at), desc(Server.id)).limit(max_rows)
result = await self.session.execute(data_query)
servers = list(result.scalars().all())
return servers, total
@@ -143,6 +143,9 @@ class AsyncSSHPool:
Otherwise, create a new connection.
SSH handshake is done outside the lock to avoid blocking other pool operations.
"""
from server.infrastructure.ssh.resolve_server_auth import materialize_server_ssh_auth
await materialize_server_ssh_auth(server)
async with self._lock:
# Check for existing idle connection
pooled = self._pool.get(server.id)
@@ -0,0 +1,57 @@
"""Resolve SSH auth from password/SSH-key presets when server row has no inline secret."""
from __future__ import annotations
import logging
from server.domain.models import Server
logger = logging.getLogger("nexus.ssh.resolve_auth")
def _has_inline_ssh_auth(server: Server) -> bool:
if server.ssh_key_path:
return True
if server.ssh_key_configured and server.ssh_key_private:
return True
if server.auth_method == "password" and server.password:
return True
if server.password and server.auth_method != "key":
return True
return False
async def materialize_server_ssh_auth(server: Server) -> None:
"""Load ``preset_id`` / ``ssh_key_preset_id`` into ``server`` for SSH pool use."""
if _has_inline_ssh_auth(server):
return
if not server.preset_id and not server.ssh_key_preset_id:
return
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
async with AsyncSessionLocal() as session:
auth_method = (server.auth_method or "password").strip().lower()
if server.preset_id and auth_method == "password":
preset = await PasswordPresetRepositoryImpl(session).get_by_id(server.preset_id)
if preset:
server.password = preset.encrypted_pw
server.auth_method = "password"
server.username = (preset.username or server.username or "root").strip() or "root"
logger.debug("SSH auth from password preset id=%s server=%s", preset.id, server.id)
return
logger.warning("password preset id=%s missing for server=%s", server.preset_id, server.id)
if server.ssh_key_preset_id:
preset = await SshKeyPresetRepositoryImpl(session).get_by_id(server.ssh_key_preset_id)
if preset:
server.ssh_key_private = preset.encrypted_private_key
server.ssh_key_public = preset.public_key or server.ssh_key_public
server.ssh_key_configured = True
server.auth_method = "key"
server.username = (preset.username or server.username or "root").strip() or "root"
logger.debug("SSH auth from ssh_key preset id=%s server=%s", preset.id, server.id)
return
logger.warning("ssh_key preset id=%s missing for server=%s", server.ssh_key_preset_id, server.id)
+2 -3
View File
@@ -15,11 +15,10 @@ from server.infrastructure.btpanel.credentials import (
from server.domain.models import Server
def test_sign_algorithm_matches_official_demo():
def test_sign_algorithm_matches_panel_common():
api_key = "test-api-key"
now = 1700000000
key_md5 = hashlib.md5(api_key.encode()).hexdigest()
expected = hashlib.md5(f"{now}{key_md5}".encode()).hexdigest()
expected = hashlib.md5(f"{now}{api_key}".encode()).hexdigest()
creds = BtPanelCredentials(base_url="http://127.0.0.1:8888", api_key=api_key)
client = BtPanelClient(creds, server_id=1)
+47
View File
@@ -0,0 +1,47 @@
"""Tests for Baota server list API fields."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from server.application.services.btpanel_service import BtPanelService
from server.domain.models import Server
def _server(sid: int, *, is_online: bool = False, agent_version: str | None = None) -> Server:
return Server(
id=sid,
name=f"s{sid}",
domain=f"1.2.3.{sid}",
is_online=is_online,
agent_version=agent_version,
extra_attrs={},
)
@pytest.mark.asyncio
async def test_list_server_statuses_includes_live_online_from_redis():
svc = BtPanelService(AsyncMock())
servers = [_server(1, agent_version="1.0.0"), _server(2)]
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_redis.pipeline.return_value = mock_pipe
mock_pipe.execute = AsyncMock(return_value=[
{"is_online": "True", "agent_version": "1.0.0"},
{},
])
with patch.object(svc.servers, "get_all", AsyncMock(return_value=servers) ), patch(
"server.infrastructure.redis.client.get_redis",
return_value=mock_redis,
), patch(
"server.utils.agent_version.agent_is_installed",
side_effect=lambda agent_version_db, heartbeat: bool(agent_version_db or heartbeat),
):
rows = await svc.list_server_statuses()
assert rows[0]["is_online"] is True
assert rows[0]["status"] == "online"
assert rows[1]["is_online"] is False
assert rows[1]["status"] == "unknown"
+34
View File
@@ -21,6 +21,7 @@ from server.infrastructure.btpanel.ssh_bootstrap import (
build_bootstrap_command,
classify_ssh_result,
parse_bootstrap_stdout,
wrap_sudo_nopasswd,
)
from server.domain.models import Server
@@ -140,5 +141,38 @@ def test_build_bootstrap_command_includes_payload():
assert "nexus_bt_bootstrap.py" in cmd
def test_wrap_sudo_nopasswd_root_unchanged():
inner = "echo hi"
assert wrap_sudo_nopasswd(inner, "root") == inner
def test_wrap_sudo_nopasswd_ubuntu():
inner = "echo hi"
wrapped = wrap_sudo_nopasswd(inner, "ubuntu")
assert wrapped.startswith("sudo -n bash -c ")
assert inner in wrapped
def test_classify_sudo_password_required():
code, permanent = classify_ssh_result({
"status": "success",
"exit_code": 1,
"stderr": "sudo: a password is required",
})
assert code == "ssh_sudo_required"
assert permanent is True
def test_classify_ssh_result_success_exit_zero():
ok_stdout = json.dumps(
{"ok": True, "base_url": "https://x:8888", "api_key": "t", "actions": [], "verify_ssl": False}
)
code, permanent = classify_ssh_result(
{"status": "success", "stdout": ok_stdout, "stderr": "", "exit_code": 0}
)
assert code == ""
assert permanent is False
def test_retry_interval_constant():
assert BOOTSTRAP_RETRY_SECONDS == 300
+59 -1
View File
@@ -5,7 +5,12 @@ from unittest.mock import AsyncMock, patch
import pytest
from server.domain.models import Server
from server.infrastructure.btpanel.ssh_login import detect_bt_panel_installed
from server.infrastructure.btpanel.ssh_login import (
detect_bt_panel_installed,
normalize_temp_login_url,
_extract_temp_login_line,
ssh_temp_login_url,
)
@pytest.mark.asyncio
@@ -28,3 +33,56 @@ async def test_detect_bt_panel_installed_failed_ssh():
return_value={"status": "connection_error", "stdout": "", "stderr": "refused", "exit_code": -1},
):
assert await detect_bt_panel_installed(server) is False
def test_extract_temp_login_line_multiline():
stdout = "https://1.2.3.4:8888\n/login?tmp_token=abc123\n"
assert _extract_temp_login_line(stdout) == "/login?tmp_token=abc123"
def test_normalize_temp_login_relative_with_base_url():
url = normalize_temp_login_url(
"/login?tmp_token=abc123",
base_url="https://1.2.3.4:8888/secret",
prefer_host="1.2.3.4",
)
assert url == "https://1.2.3.4:8888/login?tmp_token=abc123"
def test_normalize_temp_login_full_url_strips_admin_path():
url = normalize_temp_login_url(
"https://127.0.0.1:8888/secret/login?tmp_token=abc123",
base_url=None,
prefer_host="5.6.7.8",
)
assert url == "https://5.6.7.8:8888/login?tmp_token=abc123"
def test_normalize_temp_login_full_url_rewrites_host():
url = normalize_temp_login_url(
"https://127.0.0.1:8888/login?tmp_token=abc123",
base_url="https://1.2.3.4:8888/secret",
prefer_host="5.6.7.8",
)
assert url == "https://5.6.7.8:8888/login?tmp_token=abc123"
@pytest.mark.asyncio
async def test_ssh_temp_login_url_accepts_relative_path():
server = Server(id=1, name="s", domain="5.6.7.8")
with patch(
"server.infrastructure.btpanel.ssh_login.exec_ssh_command",
new_callable=AsyncMock,
return_value={
"status": "success",
"stdout": "/login?tmp_token=VHj687eKwrPKr5dn\n",
"stderr": "",
"exit_code": 0,
},
):
url = await ssh_temp_login_url(
server,
base_url="https://5.6.7.8:8888/abc123",
)
assert url == "https://5.6.7.8:8888/login?tmp_token=VHj687eKwrPKr5dn"
+62
View File
@@ -0,0 +1,62 @@
"""Tests for SSH preset resolution on Server objects."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from server.domain.models import Server
from server.infrastructure.ssh.resolve_server_auth import materialize_server_ssh_auth
@pytest.mark.asyncio
async def test_materialize_password_preset():
server = Server(
id=1,
name="s",
domain="1.2.3.4",
auth_method="password",
preset_id=1,
)
preset = MagicMock()
preset.encrypted_pw = "enc-pw"
preset.username = "root"
mock_session = MagicMock()
with (
patch(
"server.infrastructure.database.session.AsyncSessionLocal",
) as session_cls,
patch(
"server.infrastructure.database.password_preset_repo.PasswordPresetRepositoryImpl"
) as repo_cls,
):
session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
session_cls.return_value.__aexit__ = AsyncMock(return_value=False)
repo_cls.return_value.get_by_id = AsyncMock(return_value=preset)
await materialize_server_ssh_auth(server)
assert server.password == "enc-pw"
assert server.username == "root"
@pytest.mark.asyncio
async def test_materialize_skips_when_inline_password():
server = Server(
id=1,
name="s",
domain="1.2.3.4",
auth_method="password",
password="already-set",
preset_id=1,
)
with patch(
"server.infrastructure.database.session.AsyncSessionLocal",
) as session_cls:
session_cls.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
session_cls.return_value.__aexit__ = AsyncMock(return_value=False)
with patch(
"server.infrastructure.database.password_preset_repo.PasswordPresetRepositoryImpl"
) as repo_cls:
repo_cls.return_value.get_by_id = AsyncMock()
await materialize_server_ssh_auth(server)
repo_cls.return_value.get_by_id.assert_not_called()
+49
View File
@@ -9,6 +9,8 @@ import pytest
from server.application.server_connectivity import (
count_monitored_connectivity,
item_matches_online_filter,
normalize_ssh_probe_system_info,
write_ssh_health_heartbeat,
)
@@ -42,3 +44,50 @@ async def test_count_monitored_connectivity_batches_redis():
out = await count_monitored_connectivity(redis, session, batch_size=2)
assert out == {"monitored": 3, "online": 1, "offline": 2}
def test_normalize_ssh_probe_system_info_nested():
probe = {
"status": "online",
"channel": "ssh",
"system_info": {
"status": "healthy",
"system_info": {"cpu_usage": 12.5, "probe": "ssh"},
},
}
out = normalize_ssh_probe_system_info(probe)
assert out["cpu_usage"] == 12.5
assert out["probe_channel"] == "ssh"
def test_normalize_ssh_probe_system_info_offline_error():
probe = {"status": "offline", "channel": "ssh", "error": "timeout"}
out = normalize_ssh_probe_system_info(probe)
assert out["probe_error"] == "timeout"
assert out["probe_channel"] == "ssh"
@pytest.mark.asyncio
async def test_write_ssh_health_heartbeat_redis_shape():
redis = AsyncMock()
server = MagicMock()
server.id = 42
server.agent_version = "2.1.0"
ts = await write_ssh_health_heartbeat(
redis,
server,
{
"status": "online",
"channel": "ssh",
"system_info": {"status": "healthy", "system_info": {"cpu_usage": 1.0}},
},
)
assert ts
redis.hset.assert_awaited_once()
mapping = redis.hset.await_args.kwargs["mapping"]
assert mapping["is_online"] == "True"
assert mapping["agent_version"] == "2.1.0"
assert "cpu_usage" in mapping["system_info"]
redis.expire.assert_awaited_once()
+8
View File
@@ -17,3 +17,11 @@ def test_unknown_without_agent():
def test_offline_with_agent_key_only():
assert _derive_server_status(False, None, has_agent=True) == "offline"
def test_offline_with_ssh_heartbeat_only():
assert _derive_server_status(False, None, has_heartbeat=True) == "offline"
def test_online_with_ssh_heartbeat_only():
assert _derive_server_status(True, None, has_heartbeat=True) == "online"