Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8efbc2a90 | |||
| 2cb0b57281 | |||
| 80d6aad54b | |||
| 58843a1ef4 | |||
| 7add9384c1 | |||
| 6720ca3474 | |||
| 2e797f86b7 | |||
| 5ea5c3a411 | |||
| 3195787461 | |||
| 97c47a120f | |||
| 028dc2c8b6 | |||
| 51b6d0cdff | |||
| e5929694c3 | |||
| b768da3ab8 | |||
| 45ca57bf3f | |||
| 14bbfc4ab7 | |||
| 770469c4aa | |||
| b4436143a6 | |||
| 7c99298419 | |||
| 461ac81221 |
@@ -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 获取 API(2026-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/#147:SSH✅、`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 → 200,url 含 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
|
||||
```
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-value(id)数组,不是行对象 */
|
||||
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) {
|
||||
|
||||
@@ -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' } {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user