20 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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