20 Commits

Author SHA1 Message Date
Nexus Agent 8092000880 fix(watch): psutil SSH 安装 apt 走 sudo
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
非 root 用户 apt-get 未加 sudo 导致 Permission denied;补充 run_root 与中文错误提示。
2026-06-12 04:09:10 +08:00
Nexus Agent e57d689fb2 docs(audit): 补 test_watch_probe_errors 到 psutil 安装审计 2026-06-12 04:04:59 +08:00
Nexus Agent 75efd506f5 feat(watch): 监测槽 SSH 一键安装 psutil
探针报 psutil missing 时显示安装按钮;POST install-psutil 经 SSH pip/apt 安装并审计。
2026-06-12 04:04:48 +08:00
Nexus Agent 32a1885f0d feat(watch): 监测槽开关、8h/24h、到期暂停与探针修复
支持随时暂停/恢复实时监测并保留槽位;TTL 到期自动暂停不占删槽;修复到期竞态导致空槽与唯一约束冲突;探针 parse_error 与中文错误;移除失败 snackbar。
2026-06-12 03:47:56 +08:00
Nexus Agent 7bb85aac41 feat(servers): 新服务器 Agent 安装/升级引导提示
修正仅有 API Key 误判为已装 Agent;列表与展开详情提示安装/升级并提供一键操作。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 03:16:12 +08:00
Nexus Agent f37047f746 docs(audit): 文件搜索与监测槽点击修复审计
补 Step3/Closure/DoD;选机对话框加载失败改 snackbar 提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 03:10:15 +08:00
Nexus Agent 6b8965aaf6 fix(watch): 监测槽空槽选机与卡片点击进历史
空槽弹出搜索选机对话框;已填槽整卡可点进监测历史;已在监测的 + 给出提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 03:07:00 +08:00
Nexus Agent 6140d68875 feat(files): 文件管理页服务器选择支持搜索
2000+ 子机场景下可搜索名称/域名/路径/ID,并加载全量服务器列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 03:02:28 +08:00
Nexus Agent b46922fe92 feat(watch): 4 槽实时监测 — 5s 探针、WS、历史页与告警联动
宝塔式自选子机监测:Pin CRUD、全指标 SSH/Redis 探针、探针落库、
/ws/watch 推送、监测历史与进程抽屉;Agent 可选附带 net/disk IO。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 02:56:37 +08:00
Nexus Agent f6e8b29640 fix(ui): 服务器/终端/推送搜索历史互不共享
终端独立 terminal_search 上下文(10条),与 servers_search、push_search 隔离。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 02:03:45 +08:00
Nexus Agent e0133b9098 feat(push,terminal): 推送搜索历史5条与终端中间搜索
推送页独立 combobox 历史(MySQL push_search,上限5);终端空态搜索回中间卡片,右侧栏仅列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 01:58:33 +08:00
Nexus Agent 724375c884 fix(push): 移除点击复制服务器名称
名称点击恢复为与卡片/行一致的勾选行为。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 01:51:39 +08:00
Nexus Agent 989b17902b feat(terminal): 右侧服务器列表与搜索历史
终端与会话并行:右侧常驻选服列表,combobox 共用服务器页最近 10 条搜索历史。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 01:47:46 +08:00
Nexus Agent 441aa0b9d2 docs: 部署门控审计(移除浏览器 + 推送 UI)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 01:44:17 +08:00
Nexus Agent 540712500f fix: 移除 iframe 浏览器并优化推送页服务器展示
删除内置浏览器前后端;推送页支持卡片/列表切换、点击名称复制与长名称展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 01:43:51 +08:00
Nexus Agent 17abd62261 feat(servers): 新服务器接入自动 sudo 配置与路径探测
创建/导入/IP 添加成功后后台 onboard 任务;前端注册进度条与 toast。
2026-06-12 00:19:31 +08:00
Nexus Agent 3a4b2ff3eb docs(reports): 未设路径 fleet 探测与推送验证报告 2026-06-11 23:50:11 +08:00
Nexus Agent 2877fd90a4 fix(scripts): batch_detect 不依赖 Redis 批任务结构 2026-06-11 23:48:28 +08:00
Nexus Agent e6abd4ae73 chore(deploy): 补 Gate7 审计与 shell LF 修复 2026-06-11 23:45:44 +08:00
Nexus Agent 29b053b3c0 fix(sync): 未设路径推送回退 /www/wwwroot,UI 判定不变
空或占位 /www/wwwroot 仍算「未设路径」;rsync 推送/预览/验证改用 resolve_push_target_path,并新增批量 detect-path 脚本。
2026-06-11 23:42:58 +08:00
118 changed files with 8190 additions and 1424 deletions
@@ -0,0 +1,41 @@
# 审计 — 未设路径推送回退 /www/wwwroot
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/posix_paths.py` | `resolve_push_target_path`、默认 `/www/wwwroot` |
| `server/application/services/sync_engine_v2.py` | 推送/预览使用 resolve |
| `scripts/verify_push_elevation.py` | 验证脚本对齐 |
| `scripts/batch_detect_target_path.py` | 批量 detect-path |
| `scripts/batch_detect_target_path.sh` | 包装入口 |
| `tests/test_posix_paths.py` | unset UI 与 push 回退分离单测 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 未改 `is_unset_target_path` UI 判定 | PASS |
| H2 | 推送 override 仍优先 | PASS — resolve 先 override |
| H3 | 路径校验仍走 normalize_remote_abs_path | PASS |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | is_unset_target_path 未动 |
| H2 | PASS | resolve_push_target_path |
| H3 | PASS | posix_paths.py |
## DoD
- [x] pytest test_posix_paths
- [x] changelog 2026-06-11-push-default-wwwroot.md
- [x] batch_detect_target_path.sh
## 验证
```bash
pytest tests/test_posix_paths.py -q
bash scripts/batch_detect_target_path.sh --unset-only --dry-run
```
@@ -0,0 +1,48 @@
# 审计 — 移除内置浏览器 + 推送页服务器 UI
**Changelog**: `docs/changelog/2026-06-11-remove-embedded-browser.md` · `docs/changelog/2026-06-11-push-server-name-display.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/App.vue` | 移除浏览器侧栏与 GlobalBrowserPanel |
| `frontend/src/router/index.ts` | 移除 `/browser` |
| `frontend/src/pages/ServersPage.vue` | 站点 → 新标签打开 |
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 |
| `frontend/src/pages/BrowserPage.vue` | 删除 |
| `server/api/browser.py` | 删除 |
| `server/utils/browser_ui_state.py` | 删除 |
| `server/main.py` | 注销 browser 路由 |
| `frontend/src/components/push/PushServerGrid.vue` | 卡片/列表切换、名称复制 |
| `frontend/src/composables/push/usePushServerViewMode.ts` | 视图偏好 localStorage |
| `frontend/src/components/push/PushProgressList.vue` | 名称 truncate + title |
| `frontend/src/components/push/PushHistoryTable.vue` | 服务器列 slot |
| `frontend/src/components/push/PushPreviewDialog.vue` | 名称 truncate + title |
| `frontend/src/composables/push/usePushLogs.ts` | 列宽 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | SSRF / 安全 | PASS — 删除 iframe 浏览器 API;推送页无新后端 |
| H2 | 无静默吞错 | PASS |
| H3 | clipboard | PASS — copy 失败有 snackbar |
| H4 | CUD 审计 | N/A — 删除 browser state API |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 前端 UI + 删除 dead code |
## DoD
- [x] `npm run type-check`
- [x] `import server.main`
- [ ] 生产 `/app/#/push` 验证列表切换与复制
## 验证
侧栏无浏览器;推送页卡片/列表切换;点击名称复制。
@@ -0,0 +1,43 @@
# 审计 — 移除 iframe 内置浏览器
**Changelog**: `docs/changelog/2026-06-11-remove-embedded-browser.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 |
| `frontend/src/pages/BrowserPage.vue` | 删除 |
| `server/api/browser.py` | 删除 |
| `server/utils/browser_ui_state.py` | 删除 |
| `tests/test_browser_ui_state.py` | 删除 |
| `frontend/src/App.vue` | 移除 GlobalBrowserPanel、侧栏项 |
| `frontend/src/router/index.ts` | 移除路由 |
| `frontend/src/pages/ServersPage.vue` | 站点 → window.open |
| `server/main.py` | 移除 router |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无 SSRF 新增 | PASS — 删除 iframe,站点按钮仅客户端 window.open |
| H2 | 无静默吞错 | PASS — 无新增 |
| H3 | 无密钥 | PASS |
| H4 | CUD 审计 | N/A — 删除 API,无新 CUD |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 纯删除 + 外链兜底 |
## DoD
- [x] type-check
- [x] 删除 `/api/browser` 路由
- [ ] 生产部署(待用户批准)
## 验证
侧栏无浏览器;站点按钮新标签打开公网 URL。
@@ -0,0 +1,41 @@
# 审计 — 新服务器接入自动化
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/application/services/server_onboarding_service.py` | 调度入口 |
| `server/application/services/server_batch_service.py` | `_run_onboard` |
| `server/application/server_batch_common.py` | `detect_and_save_target_path` |
| `server/api/servers.py` | 创建/导入/IP 添加钩子 |
| `server/api/schemas.py` | `onboarding_job_id` |
| `frontend/src/composables/servers/useServerFormDialog.ts` | 任务注册 |
| `tests/test_server_onboarding.py` | 单测 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | sudoers 仍走白名单模板 | PASS |
| H2 | 创建 API 不因 onboard 失败而 5xx | PASS — 后台 job |
| H3 | 探测失败不阻断推送 | PASS — wwwroot 回退 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | files_sudoers_service |
| H2 | PASS | schedule 独立 try/except |
| H3 | PASS | detect 失败 stdout 提示 |
## DoD
- [x] pytest test_server_onboarding
- [x] changelog 2026-06-11-server-onboarding-automation.md
- [x] 设计 docs/design/specs/2026-06-11-server-onboarding-automation-design.md
## 验证
```bash
pytest tests/test_server_onboarding.py -q
```
@@ -0,0 +1,117 @@
# 审计 — 监测槽开关 / 8h·24h / 暂停占槽 / 探针中文错误
**日期**2026-06-11
**Changelog**:
- `docs/changelog/2026-06-11-watch-slot-monitoring-toggle.md`
- `docs/changelog/2026-06-11-watch-probe-parse-error-fix.md`
## 范围(未提交工作区,+799 / −124 行)
| 层级 | 文件 |
|------|------|
| 模型/迁移 | `server/domain/models/__init__.py``monitoring_enabled``ttl_hours` |
| | `server/infrastructure/database/migrations.py` |
| Repository | `server/infrastructure/database/watch_repo.py` |
| Service | `server/application/services/watch_service.py` |
| API | `server/api/watch.py`PATCH monitoring / ttl |
| 探针 | `server/background/watch_probe_runner.py` |
| | `server/infrastructure/ssh/remote_probe.py` |
| 工具 | `server/utils/watch_metrics.py` |
| | `server/utils/watch_state.py`**新增** Redis 清理) |
| | `server/utils/watch_probe_errors.py`**新增** 中文错误) |
| 前端 | `frontend/src/composables/useWatchPins.ts` |
| | `frontend/src/components/watch/WatchSlotCard.vue` |
| | `frontend/src/components/watch/WatchSlotRow.vue` |
| | `frontend/src/components/watch/WatchProbeRecordsTable.vue` |
| | `frontend/src/utils/watchFormat.ts` |
| | `frontend/src/api/index.ts``http.patch` |
| 测试 | `tests/test_watch_pins.py` |
| | `tests/test_watch_metrics.py` |
| | `tests/test_watch_probe_errors.py`**新增** |
## 功能摘要
1. **实时监测开关**`monitoring_enabled`;关则停 5s 探针,槽位保留
2. **8h / 24h**`ttl_hours` + `PATCH /pins/{slot}/ttl`;默认 8h,显式切 24h
3. **暂停永久占槽**`monitoring_enabled=false` 时不参与 TTL 过期删除
4. **时长到期**:自动暂停(等同关开关),结束 session,**不删 pin**
5. **恢复 Agent 60s**:无活跃监测时清理 `watch:live/proc/last`Agent 心跳逻辑未改
6. **parse_error 修复**Agent 有 CPU/内存时 SSH 失败不拖垮整轮;SSH 脚本加固
7. **错误中文**:后端写入 + 前端 `formatProbeError` 兼容历史英文
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 鉴权 / IDOR | **PASS** — 新 PATCH 均 `get_current_admin`repo 带 `admin_id` |
| H2 | 无静默吞错 | **PASS** — 探针失败仍落库;Redis 清理失败会抛错(生产有 Redis) |
| H3 | 无新密钥 | **PASS** |
| H4 | CUD 审计 | **PASS**`watch_pin_pause` / `watch_pin_resume` / `watch_pin_ttl` / remove |
| H5 | 输入边界 | **PASS**`Literal[8,24]``slot_index` 03 |
| H6 | 迁移 | **PASS** — ALTER + duplicate column 容错 |
| H7 | 产品风险 | **RISK 接受** — 4 槽均可永久暂停占用,需手动 X 释放(用户明确要求) |
## 逐模块走读
### 后端
| 模块 | 结论 | 备注 |
|------|------|------|
| `watch_repo._pin_still_occupied` | PASS | 暂停不占 TTL;开启监测才看过期 |
| `expire_due_pins` | PASS | 改为暂停 + end session,不 delete pin |
| `set_monitoring_enabled` + `_ensure_open_session` | PASS | TTL 到期后再开自动新 session |
| `_restore_idle_agent_watch` | PASS | 全局无监测才清 Redis |
| `replace_slot` | PASS | 保留槽位原 `ttl_hours`(审查项已修) |
| `probe_server_metrics` | PASS | Agent 基础指标优先;SSH 仅补 IO |
| `watch_probe_error_zh` | PASS | 与前端映射一致 |
### 前端
| 模块 | 结论 | 备注 |
|------|------|------|
| `WatchSlotCard` | PASS | 开关 + 8h/24h + 暂停态 UI |
| `tickCountdown` | PASS | 归零 `refreshPins`,不再误置 `empty` |
| `formatProbeError` | PASS | 状态/错误/来源中文化 |
| `WatchProbeRecordsTable` | PASS | 筛选器与列中文 |
## 审计中发现并修复
| 项 | 严重度 | 处理 |
|----|--------|------|
| `mock_watch_redis` 未 patch `watch_state.get_redis` | **P1** | 已修:`remove_slot` / `set_slot_monitoring` 单测 2 失败 → 19 passed |
## 遗留建议(非阻塞)
| 项 | 说明 |
|----|------|
| 设计文档 | 开关/暂停语义变更较大,建议补 `docs/design/specs/` 短文(当前仅 changelog |
| HTTP 集成测试 | PATCH monitoring/ttl 的 404/422 路径未覆盖 |
| TTL 到期审计 | 后台 `expire_due_pins` 无 audit_log(可接受,session.end_reason=expired 可查) |
| PATCH 404 语义 | 「槽位无效」与「槽位为空」均 404,与 POST 422 不一致 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H6 | **PASS** | 鉴权、审计、迁移、探针落库均符合铁律 |
| H7 | **RISK 接受** | 用户要求暂停永久占槽 |
## DoD
- [x] `.venv/bin/pytest tests/test_watch_metrics.py tests/test_watch_pins.py tests/test_watch_probe_errors.py`**19 passed**
- [x] `cd frontend && npm run type-check`
- [x] 审计修复:test fixture patch `watch_state.get_redis`
- [ ] `bash scripts/local_verify.sh` / 门控 7 道(部署前)
- [ ] 生产浏览器:开关暂停、24h 切换、到期自动暂停、中文错误展示
## 验证命令
```bash
.venv/bin/pytest tests/test_watch_metrics.py tests/test_watch_pins.py tests/test_watch_probe_errors.py -q
cd frontend && npm run type-check
bash deploy/pre_deploy_check.sh
```
## 合并建议
**Approve with notes** — 功能完整、测试绿、安全与审计达标;部署前跑门控 + 浏览器终验。遗留 HTTP 集成测试与设计文档可跟进 PR。
@@ -0,0 +1,84 @@
# 审计 — 文件管理服务器搜索 + 监测槽点击修复
**日期**2026-06-12
**Changelog**:
- `docs/changelog/2026-06-11-files-server-search.md`commit `6140d68`
- `docs/changelog/2026-06-11-watch-slot-click-fix.md`commit `6b8965a`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/components/files/FilesToolbar.vue` | v-select → v-autocomplete 可搜索选服 |
| `frontend/src/composables/files/useFilesNavigation.ts` | 全量 loadServers、filteredServerList |
| `frontend/src/composables/files/useFilesPage.ts` | 暴露搜索状态 |
| `frontend/src/components/watch/WatchSlotCard.vue` | 空槽/满槽点击、hover |
| `frontend/src/components/watch/WatchSlotPickDialog.vue` | 空槽选机对话框(新增) |
| `frontend/src/components/watch/WatchSlotRow.vue` | 串联 pick 与 pinServer |
| `frontend/src/components/watch/WatchSparkline.vue` | pointer-events 防挡点击 |
| `frontend/src/composables/useWatchPins.ts` | pinServer 支持 slot_index |
| `frontend/src/pages/ServersPage.vue` | 已在监测 + 提示 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 鉴权 / IDOR | PASS — `/servers/``/watch/pins` 均走 JWT;Pin 按 admin 隔离(后端已有) |
| H2 | 无静默吞错 | PASS(审计中修复)— WatchSlotPickDialog 加载失败改 snackbarpin 失败经 formatApiError |
| H3 | 无新密钥 | PASS — 纯前端 UI |
| H4 | XSS | PASS — 无 v-html;服务器名由 API 返回、Vuetify 文本渲染 |
| H5 | 输入边界 | PASS — serverId/slotIndex 为 number;后端 Pydantic ge/le 校验 |
| H6 | 性能 | RISK 接受 — 全量拉取 servers(与终端/推送一致);2000+ 台客户端 filter |
## 逐行走读(前端 5 文件)
| 文件 | 行数 | Read | 结论 |
|------|------|------|------|
| WatchSlotPickDialog.vue | 108 | 1108 | 逐行完成 ✓ |
| WatchSlotCard.vue | 138 | 1138 | 逐行完成 ✓ |
| WatchSlotRow.vue | 98 | 198 | 逐行完成 ✓ |
| useWatchPins.tspinServer 段) | 222 | 160178 | 逐行完成 ✓ |
| useFilesNavigation.ts(搜索段) | 244 | 2647,139170 | 逐行完成 ✓ |
### Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | SAFE | fetchPagePerPage/http 带 authPOST body 仅 server_id + slot_index |
| H2 | SAFE | 选机对话框 load 失败 snackbaronPickServer 409/422 均有 snackbar |
| H3 | SAFE | 无 env/密钥 |
| H4 | SAFE | 模板无 innerHTML |
| H5 | SAFE | slot_index 03 后端校验;空 selectedId 禁用「加入」 |
| H6 | SAFE(接受) | 与项目内终端/推送全量列表模式一致 |
### 交互验收
| 场景 | 预期 | 审计结论 |
|------|------|----------|
| 文件页搜索服务器 | 名称/域名/路径/ID 过滤 | PASStype-check |
| 点击虚线空槽 | 弹出选机 → POST slot_index | PASS(代码路径完整) |
| 点击满槽卡片 | 路由 WatchMetrics + query | PASS |
| 列表 + 已 Pin | snackbar 槽位号 | PASS |
| 满 4 槽替换对话框 | replace_slot POST | PASS(既有逻辑未改坏) |
## DoD
- [x] `pytest tests/test_watch_metrics.py tests/test_watch_pins.py` — 10 passed
- [x] `cd frontend && npm run type-check`
- [x] 审计修复:WatchSlotPickDialog 加载失败 snackbar
- [x] 生产已部署 `6b8965a`(监测槽点击);`6140d68`(文件搜索)
- [ ] Gate 3 test_api.py — 需本地 API :8600 运行(审计时未起服务)
## 验证命令
```bash
.venv/bin/pytest tests/test_watch_metrics.py tests/test_watch_pins.py -q
cd frontend && npm run type-check
bash scripts/local_verify.sh # 含 Gate 3
bash deploy/pre_deploy_check.sh
```
## 备注
- 监测槽空槽点击为设计文档明确要求(`2026-06-12-server-watch-slots-design.md` §信息架构),原实现遗漏,已补。
- 无后端变更;无需迁移/重启 API(仅前端)。
@@ -0,0 +1,17 @@
# 审计 — psutil 安装 apt sudo 修复
**日期**2026-06-12
**Changelog**: `docs/changelog/2026-06-12-psutil-install-sudo-fix.md`
## 范围
| 文件 |
|------|
| `server/utils/psutil_install.py` |
| `server/application/server_batch_common.py` |
| `tests/test_psutil_install.py` |
## Step 3 / Closure / DoD
- H1 PASS — 行为修复,无新 API
- DoD: pytest test_psutil_install 5 passed
@@ -0,0 +1,33 @@
# 审计 — 推送页移除点击复制名称
**Changelog**: `docs/changelog/2026-06-12-push-remove-name-copy.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/components/push/PushServerGrid.vue` | 移除名称点击复制 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 安全 | PASS — 无新 API |
| H2 | 无静默吞错 | PASS |
| H3 | 无新密钥 | PASS |
| H4 | CUD | N/A |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 纯前端交互回退 |
## DoD
- [x] type-check
- [ ] 生产 `#/push` 验证
## 验证
点击名称仅勾选服务器,无复制 toast。
+46
View File
@@ -0,0 +1,46 @@
# 审计 — 推送搜索历史 + 终端搜索回中间
**Changelog**:
- `docs/changelog/2026-06-12-push-server-search-history.md`
- `docs/changelog/2026-06-12-terminal-search-center-empty-state.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/server_search_history.py` | push 上下文,上限 5 |
| `server/api/servers.py` | push-search-history API |
| `frontend/src/composables/useServerSearchHistory.ts` | scope 参数 |
| `frontend/src/composables/push/usePushServers.ts` | 推送搜索历史 |
| `frontend/src/composables/push/usePushPage.ts` | 加载历史 |
| `frontend/src/components/push/PushServerGrid.vue` | combobox |
| `frontend/src/pages/PushPage.vue` | 绑定历史 |
| `frontend/src/pages/TerminalPage.vue` | 中间空态搜索 |
| `frontend/src/components/terminal/TerminalArea.vue` | empty-picker 插槽 |
| `frontend/src/components/terminal/TerminalServerPicker.vue` | 侧栏无搜索 |
| `tests/test_server_search_history.py` | push 上限与 API |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 安全 | PASS — 按 admin 隔离偏好 |
| H2 | 无静默吞错 | PASS |
| H3 | 无新密钥 | PASS |
| H4 | CUD | N/A — 仅 UI 偏好 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 复用 admin_ui_preferences |
## DoD
- [x] type-check
- [x] pytest test_server_search_history
- [ ] 生产 `#/push` / `#/terminal` 验证
## 验证
推送:combobox 最近 5 条。终端:无会话时中间搜索,右侧仅列表。
@@ -0,0 +1,48 @@
# 审计 — 移除内置浏览器 + 推送页服务器 UI
**Changelog**: `docs/changelog/2026-06-11-remove-embedded-browser.md` · `docs/changelog/2026-06-11-push-server-name-display.md`
**Commit**: `5407125`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/App.vue` | 移除浏览器侧栏与 GlobalBrowserPanel |
| `frontend/src/router/index.ts` | 移除 `/browser` |
| `frontend/src/pages/ServersPage.vue` | 站点 → 新标签打开 |
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 |
| `frontend/src/pages/BrowserPage.vue` | 删除 |
| `server/api/browser.py` | 删除 |
| `server/utils/browser_ui_state.py` | 删除 |
| `server/main.py` | 注销 browser 路由 |
| `frontend/src/components/push/PushServerGrid.vue` | 卡片/列表切换、名称复制 |
| `frontend/src/composables/push/usePushServerViewMode.ts` | 视图偏好 localStorage |
| `frontend/src/components/push/PushProgressList.vue` | 名称 truncate + title |
| `frontend/src/components/push/PushHistoryTable.vue` | 服务器列 slot |
| `frontend/src/components/push/PushPreviewDialog.vue` | 名称 truncate + title |
| `frontend/src/composables/push/usePushLogs.ts` | 列宽 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | SSRF / 安全 | PASS — 删除 iframe 浏览器 API |
| H2 | 无静默吞错 | PASS |
| H3 | clipboard | PASS — copy 失败有 snackbar |
| H4 | CUD 审计 | N/A |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 前端 UI + 删除 dead code |
## DoD
- [x] type-check · import server.main
- [ ] 生产推送页验证
## 验证
侧栏无浏览器;推送页卡片/列表;点击名称复制。
@@ -0,0 +1,38 @@
# 审计 — 各页搜索历史互不共享
**Changelog**: `docs/changelog/2026-06-12-search-history-isolated-scopes.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/server_search_history.py` | terminal 上下文,上限 10 |
| `server/api/servers.py` | terminal-search-history API |
| `frontend/src/composables/useServerSearchHistory.ts` | terminal scope |
| `frontend/src/composables/terminal/useTerminalSessions.ts` | 独立历史 |
| `tests/test_server_search_history.py` | 隔离与 API 测试 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 安全 | PASS — admin 隔离 |
| H2 | 无静默吞错 | PASS |
| H3 | 无新密钥 | PASS |
| H4 | CUD | N/A |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | admin_ui_preferences 分 context |
## DoD
- [x] type-check
- [x] pytest test_server_search_history
- [ ] 生产三页互不串历史验证
## 验证
servers / terminal / push 各搜一词,下拉互不可见。
@@ -0,0 +1,46 @@
# 审计 — 2026-06-12 新服务器接入自动化(生产部署)
## 范围(文件清单)
| 文件 | 说明 |
|------|------|
| `server/application/services/server_onboarding_service.py` | 调度入口 |
| `server/application/services/server_batch_service.py` | `_run_onboard` |
| `server/application/server_batch_common.py` | `detect_and_save_target_path` |
| `server/api/servers.py` | 创建/导入/IP 添加钩子 |
| `server/api/schemas.py` | `onboarding_job_id` |
| `frontend/src/composables/servers/useServerFormDialog.ts` | 任务注册 |
| `frontend/src/pages/ServersPage.vue` | IP 批量添加注册 |
| `frontend/src/types/api.ts` | 类型 |
| `frontend/src/utils/auditLabels.ts` | batch_server_onboard 标签 |
| `scripts/batch_detect_target_path.py` | 复用 detect_and_save_target_path |
| `tests/test_server_onboarding.py` | 单测 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | sudoers 白名单 | PASS |
| H2 | 创建 API 不 5xx | PASS |
| H3 | 探测失败可推 wwwroot | PASS |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | files_sudoers_service |
| H2 | PASS | server_onboarding_service |
| H3 | PASS | _run_onboard |
## DoD
- [x] pytest test_server_onboarding
- [x] changelog 2026-06-12-server-onboarding-automation.md
- [x] 7/7 门控
## 验证
```bash
pytest tests/test_server_onboarding.py -q
bash deploy/pre_deploy_check.sh
```
@@ -0,0 +1,65 @@
# 审计 — 服务器实时监测槽(M1+M2+M3,无 CSV
**Changelog**: `docs/changelog/2026-06-12-server-watch-slots.md`
**设计**: `docs/design/specs/2026-06-12-server-watch-slots-design.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/domain/models/__init__.py` | WatchPinSession / WatchPin / WatchProbeRecord |
| `server/infrastructure/database/migrations.py` | watch_* 表 + processes_json |
| `server/infrastructure/database/watch_repo.py` | Pin CRUD、探针/会话查询 |
| `server/application/services/watch_service.py` | 业务编排 |
| `server/api/watch.py` | REST API |
| `server/background/watch_probe_runner.py` | 5s 探针、进程、告警 |
| `server/api/websocket.py` | `/ws/watch` |
| `server/utils/watch_metrics.py` / `watch_alerts.py` | 指标与告警联动 |
| `server/infrastructure/ssh/remote_probe.py` | SSH 全量探针 + 进程 |
| `server/main.py` | 路由 + watch_probe 任务 |
| `web/agent/agent.py` | 心跳 net_io/disk_ioM3 可选) |
| `frontend/src/composables/useWatchPins.ts` | Pin + WS |
| `frontend/src/components/watch/WatchSlotRow.vue` | 槽位行 |
| `frontend/src/components/watch/WatchSlotCard.vue` | 单槽卡片 |
| `frontend/src/components/watch/WatchSparkline.vue` | 迷你折线 |
| `frontend/src/components/watch/WatchProcessDrawer.vue` | 进程 Top5 |
| `frontend/src/components/watch/WatchTrendChart.vue` | 历史趋势 |
| `frontend/src/components/watch/WatchProbeRecordsTable.vue` | 探针记录表 |
| `frontend/src/utils/watchFormat.ts` | 速率格式化 |
| `frontend/src/pages/WatchMetricsPage.vue` | 监测历史 |
| `frontend/src/pages/ServersPage.vue` | 槽位行 + 列表 + |
| `frontend/src/router/index.ts` / `App.vue` | 路由与导航 |
| `server/api/servers.py` | 移除未使用 import |
| `tests/test_watch_metrics.py` / `test_watch_pins.py` | 单测 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 安全 | PASS — admin 隔离 Pin/探针记录;WS JWT |
| H2 | 无静默吞错 | PASS — 探针失败 INSERT + error 字段 |
| H3 | 无新密钥 | PASS |
| H4 | CUD 审计 | PASS — watch_pin_add/remove/replace |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | Depends(get_current_admin);探针每 5s 落库 |
## DoD
- [x] local_verify
- [x] pytest test_watch_*
- [x] frontend type-check
- [ ] 生产浏览器:Pin 后 5s 指标更新、#/watch-metrics
## 验证
```bash
bash scripts/local_verify.sh
.venv/bin/pytest tests/test_watch_metrics.py tests/test_watch_pins.py -q
cd frontend && npm run type-check
```
未 Pin 子机无额外 SSH;满 4 槽替换对话框;Agent 升级可选(减 SSH,非强制)。
@@ -0,0 +1,38 @@
# 审计 — 终端页右侧服务器列表与搜索历史
**Changelog**: `docs/changelog/2026-06-12-terminal-server-rail-search-history.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/pages/TerminalPage.vue` | 左右分栏,右侧常驻列表 |
| `frontend/src/components/terminal/TerminalArea.vue` | 空态文案 |
| `frontend/src/components/terminal/TerminalServerPicker.vue` | sidebar 模式 + combobox |
| `frontend/src/components/terminal/TerminalServerPickerSearch.vue` | 新建 |
| `frontend/src/components/terminal/TerminalServerPickerList.vue` | 新建 |
| `frontend/src/composables/terminal/useTerminalSessions.ts` | search-history API |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 安全 | PASS — 复用已有 search-history API |
| H2 | 无静默吞错 | PASS |
| H3 | 无新密钥 | PASS |
| H4 | CUD | N/A |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 纯前端布局 + 已有 API |
## DoD
- [x] type-check
- [ ] 生产 `#/terminal` 验证
## 验证
右侧列表 + 搜索历史 10 条;与会话并行。
@@ -0,0 +1,37 @@
# 审计 — 监测槽 SSH 安装 psutil
**日期**2026-06-12
**Changelog**: `docs/changelog/2026-06-12-watch-install-psutil-ssh.md`
## 范围
| 文件 |
|------|
| `server/utils/psutil_install.py` |
| `server/application/services/watch_service.py` |
| `server/api/watch.py` |
| `frontend/src/components/watch/WatchSlotCard.vue` |
| `frontend/src/components/watch/WatchSlotRow.vue` |
| `frontend/src/utils/watchFormat.ts` |
| `server/utils/watch_probe_errors.py` |
| `tests/test_psutil_install.py` |
| `tests/test_watch_pins.py` |
| `tests/test_watch_probe_errors.py` |
## Step 3
| H | 结论 |
|---|------|
| H1 鉴权 | PASS — 须 JWT;仅已 Pin 的服务器可安装 |
| H2 吞错 | PASS — SSH/安装失败 422 + snackbar |
| H4 审计 | PASS — `watch_install_psutil` |
## Closure
H1H4 PASS
## DoD
- [x] pytest test_psutil_install + test_watch_pins
- [x] npm run type-check
- [ ] 生产浏览器:psutil 错误时出现按钮且安装后探针恢复
@@ -0,0 +1,67 @@
# 审计 — 监测槽开关 / TTL / 到期竞态 / 探针中文 / 移除提示
**日期**2026-06-12
**Changelog**:
- `docs/changelog/2026-06-11-watch-slot-monitoring-toggle.md`
- `docs/changelog/2026-06-11-watch-probe-parse-error-fix.md`
- `docs/changelog/2026-06-12-watch-ttl-expiry-race-fix.md`
## 范围(文件清单)
| 层级 | 文件 |
|------|------|
| 模型/迁移 | `server/domain/models/__init__.py` |
| | `server/infrastructure/database/migrations.py` |
| Repository | `server/infrastructure/database/watch_repo.py` |
| Service | `server/application/services/watch_service.py` |
| API | `server/api/watch.py` |
| 探针 | `server/background/watch_probe_runner.py` |
| | `server/infrastructure/ssh/remote_probe.py` |
| 工具 | `server/utils/watch_metrics.py` |
| | `server/utils/watch_state.py` |
| | `server/utils/watch_probe_errors.py` |
| 前端 | `frontend/src/api/index.ts` |
| | `frontend/src/composables/useWatchPins.ts` |
| | `frontend/src/components/watch/WatchSlotCard.vue` |
| | `frontend/src/components/watch/WatchSlotRow.vue` |
| | `frontend/src/components/watch/WatchProbeRecordsTable.vue` |
| | `frontend/src/utils/watchFormat.ts` |
| 测试 | `tests/test_watch_pins.py` |
| | `tests/test_watch_metrics.py` |
| | `tests/test_watch_probe_errors.py` |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 鉴权 / IDOR | PASS — PATCH/DELETE 均 `get_current_admin` + admin_id 隔离 |
| H2 | 无静默吞错 | PASS — 探针失败落库;前端 remove/monitoring/ttl 均有 snackbar |
| H3 | 无新密钥 | PASS |
| H4 | CUD 审计 | PASS — pause/resume/ttl/remove |
| H5 | 输入边界 | PASS — `Literal[8,24]`、slot 03 |
| H6 | 迁移 | PASS — monitoring_enabled + ttl_hours ALTER |
| H7 | TTL 竞态 | PASS — `_finalize_due_pins` 读/写路径同步到期 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H6 | PASS | 鉴权、审计、迁移、探针落库 |
| H7 | PASS | 到期 ghost pin 不再显示空槽 |
## DoD
- [x] `pytest tests/test_watch_*.py` — 21 passed
- [x] `npm run type-check`
- [x] P0 TTL 竞态修复 + P2 onRemove snackbar
- [ ] `bash deploy/pre_deploy_check.sh`
- [ ] 生产浏览器终验
## 验证命令
```bash
.venv/bin/pytest tests/test_watch_*.py -q
cd frontend && npm run type-check
bash scripts/local_verify.sh
bash deploy/pre_deploy_check.sh
```
@@ -0,0 +1,26 @@
# 2026-06-11 文件管理:服务器可搜索选择
## 摘要
文件管理页「选择服务器」由固定下拉改为可搜索的 `v-autocomplete`,支持按名称、域名、部署路径、ID 过滤;初始化时拉取全量服务器列表(分页合并),与终端/推送页一致。
## 动机
2000+ 子机场景下原 `v-select` 仅加载前 200 台且无搜索,难以快速定位目标服务器。
## 涉及文件
- `frontend/src/composables/files/useFilesNavigation.ts` — 全量 `loadServers({ all: true })``serverSearchQuery``filteredServerList`
- `frontend/src/composables/files/useFilesPage.ts` — 暴露搜索状态给工具栏
- `frontend/src/components/files/FilesToolbar.vue``v-select``v-autocomplete`
## 迁移 / 重启
无。仅前端构建部署。
## 验证
```bash
cd frontend && npm run type-check
# 浏览器:#/files → 服务器框输入域名/名称片段 → 下拉过滤 → 选中后正常 browse
```
@@ -0,0 +1,41 @@
# Changelog — 2026-06-11 推送默认目标 /www/wwwroot(未设路径 UI 不变)
## 摘要
未配置 `target_path` 的服务器,推送/预览/诊断回退目录由 `/tmp/sync` 改为 `/www/wwwroot`(宝塔站点根)。**「未设路径」**筛选与面板逻辑不变(仍为空或精确 `/www/wwwroot`)。
## 动机
- 非 root 子机普遍无 `/tmp/sync`,导致 44 台验证失败
- 运维确认:`/www/wwwroot` 可作为推送目标,与「未设路径」管理状态不冲突
## 变更
| 文件 | 说明 |
|------|------|
| `server/utils/posix_paths.py` | `DEFAULT_PUSH_TARGET_PATH``resolve_push_target_path()` |
| `server/application/services/sync_engine_v2.py` | 推送/预览使用 resolve |
| `scripts/verify_push_elevation.py` | 同上 |
| `scripts/batch_detect_target_path.py` | 批量 detect-path`--unset-only` |
| `tests/test_posix_paths.py` | 回退与 unset UI 分离单测 |
## 行为说明
| `target_path` | 未设路径 UI | 实际推送目标 |
|---------------|-------------|--------------|
| 空 | 是 | `/www/wwwroot` |
| `/www/wwwroot` | 是 | `/www/wwwroot` |
| `/www/wwwroot/site` | 否 | `/www/wwwroot/site` |
推送页填写目标路径仍覆盖全部选中机。
## 迁移 / 重启
需重启 API / 重建 Docker 镜像。无 DB 迁移。
## 验证
```bash
pytest tests/test_posix_paths.py -q
bash scripts/batch_detect_target_path.sh --unset-only --dry-run
```
@@ -0,0 +1,36 @@
# 推送页服务器名称显示优化
**日期**2026-06-11
## 动机
推送服务器卡片名称过长时被单行截断,且无悬停提示,难以区分相似主机名。
## 变更
- **服务器网格**:名称最多显示 2 行(`line-clamp`);悬停 `title` 展示「名称 + 域名」
- **卡片 / 列表切换**:工具栏图标切换,偏好存 `localStorage``nexus_push_server_view_v1`
- **列表视图**:表格列展示名称、域名、目标路径、勾选与推送状态
- **点击名称复制**:卡片与列表中点击名称复制到剪贴板(`@click.stop`,不触发勾选)
- **推送进度 / 历史 / 预览**:省略号 + `title` 完整名称;历史列宽 120→200
## 涉及文件
- `frontend/src/components/push/PushServerGrid.vue`
- `frontend/src/composables/push/usePushServerViewMode.ts`(新建)
- `frontend/src/components/push/PushProgressList.vue`
- `frontend/src/components/push/PushHistoryTable.vue`
- `frontend/src/components/push/PushPreviewDialog.vue`
- `frontend/src/composables/push/usePushLogs.ts`
## 验证
```bash
cd frontend && npm run type-check
```
推送页:长名称卡片显示两行;悬停见全名;切换列表视图;点击名称 toast「名称已复制」。
## 迁移
无。仅前端构建部署。
@@ -0,0 +1,45 @@
# 移除 iframe 内置浏览器
**日期**2026-06-11
**动机**:用户拒绝 iframe 方案,待独立 Worker + Playwright Chromium 重建;先清理现有实现避免误导。
## 变更摘要
删除全局浮动浏览器、侧栏「浏览器」页、`/api/browser/state` 及关联前后端代码。
## 涉及文件
| 文件 | 动作 |
|------|------|
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 |
| `frontend/src/pages/BrowserPage.vue` | 删除 |
| `server/api/browser.py` | 删除 |
| `server/utils/browser_ui_state.py` | 删除 |
| `tests/test_browser_ui_state.py` | 删除 |
| `frontend/src/App.vue` | 移除挂载与侧栏入口 |
| `frontend/src/router/index.ts` | 移除 `/browser` |
| `frontend/src/pages/ServersPage.vue` | 「站点」改为新标签打开 |
| `server/main.py` | 注销 browser 路由 |
**保留**`frontend/src/utils/browserUrl.ts`(域名 → https URL,供「站点」外链)
## 迁移 / 重启
- 无需 DB 迁移;`admin_ui_preferences``embedded_browser` 上下文可留空或手动清理
- 需重启 API + 前端构建部署
## 验证
```bash
cd frontend && npm run type-check
pytest tests/ -q --ignore=tests/integration # 或 bash scripts/local_verify.sh
```
- 侧栏无「浏览器」
- `#/browser` 不再存在(404/回仪表盘)
- 服务器列表「站点」在新标签打开 `https://{domain}`
## 后续
服务端远程浏览器见 `docs/design/plans/2026-06-11-browser-worker-server-procurement.md`
@@ -0,0 +1,40 @@
# Changelog — 2026-06-11 新服务器接入自动化
## 摘要
创建/导入/凭据轮询添加服务器成功后,后台自动执行 **sudoers 配置(含 rsync** + **目标路径探测**,无需再手动点「一键配置 sudo」和「检测目标路径」。
## 动机
- 非 root 新机推送失败多因未配 sudoers
- 运维希望加机后开箱即用
## 变更
| 文件 | 说明 |
|------|------|
| `server/application/services/server_onboarding_service.py` | 调度 `onboard` 批任务 |
| `server/application/services/server_batch_service.py` | op `onboard` |
| `server/application/server_batch_common.py` | `detect_and_save_target_path` 抽取 |
| `server/api/servers.py` | 创建/导入/IP 添加后触发 |
| `frontend/.../useServerFormDialog.ts` | 进度条 + toast |
| `tests/test_server_onboarding.py` | 单测 |
## 行为
1. **非 root**`install_nexus_files_sudoers`(失败则任务项失败)
2. **未设路径**:探测 workerman.bat;失败仍标记成功,提示可推 `/www/wwwroot`
3. **root**:跳过 sudo,仅探测(若未设路径)
API 响应可选字段:`onboarding_job_id``onboarding_label`
## 迁移 / 重启
需重启 API / 重建镜像。无 DB 迁移。
## 验证
```bash
pytest tests/test_server_onboarding.py -q
# 新建 ubuntu 服务器 → 底部任务栏「新服务器初始化」
```
@@ -0,0 +1,41 @@
# 监测槽 parse_error 修复
**日期**: 2026-06-11
## 摘要
修复监测槽显示 `parse_error` 的常见路径:Agent 有心跳但缺 IO 时 SSH 补采失败导致整轮探针失败。
## 根因
- `redis_sample_has_io` 为 false 时强制走 SSHSSH JSON 解析失败且无完整 Redis IO 时返回 `parse_error`
- 已装 Agent 的机器通常有 CPU/内存心跳,不应因 SSH 失败而整轮失败
- SSH 脚本在 `getloadavg`/`net_io_counters` 异常时可能无 JSON 输出
## 修复
- Agent 有 CPU/内存时优先用 RedisSSH 仅补充 IOSSH 失败仍显示 Agent 指标
- SSH 探针脚本增加 try/exceptJSON 解析支持行内 `{` 前缀噪声
- 前端 `parse_error` 显示为「探针解析失败」,并展示 `metrics.error` 详情
## 2026-06-11 补充:错误中文提示
- 新增 `server/utils/watch_probe_errors.py`,新探针失败写入中文 `error`
- 前端 `formatProbeError` / `probeStatusLabel` / `probeSourceLabel` 统一中文(含历史英文记录)
- 监测历史探针记录表:状态筛选与错误列中文化
## 涉及文件
- `server/background/watch_probe_runner.py`
- `server/utils/watch_metrics.py`
- `server/infrastructure/ssh/remote_probe.py`
- `frontend/src/utils/watchFormat.ts`
- `frontend/src/components/watch/WatchSlotCard.vue`
- `tests/test_watch_metrics.py`
## 验证
```bash
pytest tests/test_watch_metrics.py -q
cd frontend && npm run type-check
```
@@ -0,0 +1,30 @@
# 2026-06-11 监测槽点击交互修复
## 摘要
修复服务器页实时监测槽「点击无反应」:空槽可点击选机、已填槽点击进历史;列表「+」已在监测时给出提示。
## 动机
设计文档要求空槽显示「+ 添加监测」并可点击选机,实现中未绑定事件;已填槽仅小字「历史」可点,整体卡片无交互。
## 涉及文件
- `frontend/src/components/watch/WatchSlotCard.vue` — 空/满槽点击、hover、文案
- `frontend/src/components/watch/WatchSlotPickDialog.vue` — 选机对话框(新增)
- `frontend/src/components/watch/WatchSlotRow.vue` — 串联选机与 pin API
- `frontend/src/composables/useWatchPins.ts``pinServer` 支持 `slot_index`
- `frontend/src/components/watch/WatchSparkline.vue``pointer-events: none` 避免图表挡点击
- `frontend/src/pages/ServersPage.vue` — 已在监测时 snackbar 提示
## 迁移 / 重启
无。前端构建部署即可。
## 验证
```bash
cd frontend && npm run type-check
```
浏览器 `#/servers`:点击虚线空槽 → 搜索选服务器 → 加入;点击有数据的槽卡片 → 跳转监测历史;列表对已监测服务器点 + → 提示槽位号。
@@ -0,0 +1,55 @@
# 监测槽实时监测开关
**日期**: 2026-06-11
## 摘要
为每台已固定监测槽的服务器增加「实时监测」开关。关闭后停止 5 秒探针与 WebSocket 推送,槽位仍保留同一台服务器,8 小时倒计时暂停;重新开启时重置 8 小时窗口。
## 动机
用户希望随时关闭某台服务器的实时监测,而不必等到 8 小时到期或点击删除释放槽位。
## 涉及文件
- `server/domain/models/__init__.py``WatchPin.monitoring_enabled`
- `server/infrastructure/database/migrations.py` — 列迁移
- `server/infrastructure/database/watch_repo.py` — 暂停/恢复、过期逻辑
- `server/application/services/watch_service.py``set_slot_monitoring`、槽位 payload
- `server/api/watch.py``PATCH /api/watch/pins/{slot_index}/monitoring`
- `server/background/watch_probe_runner.py` — 仅探针已开启的 pin
- `frontend/src/api/index.ts``http.patch`
- `frontend/src/composables/useWatchPins.ts`
- `frontend/src/components/watch/WatchSlotCard.vue`
- `frontend/src/components/watch/WatchSlotRow.vue`
- `tests/test_watch_pins.py`
## 迁移 / 重启
- 需 API 重启以执行 schema migration`monitoring_enabled` 列)
- 无需手动 SQL
## 验证
```bash
pytest tests/test_watch_pins.py -q
cd frontend && npm run type-check
```
- 仪表盘监测槽:开关关闭 → 卡片灰显、无实时指标;重新开启 → 恢复探针并重置倒计时
- 时长切换:默认 8h;点击 24h → 重置为 24 小时窗口;暂停时切换时长保留偏好,开启后生效
## 2026-06-11 补充:暂停永久占槽 + 恢复 Agent 60s
- **暂停/时长到期**:不再删除 pin;`monitoring_enabled=false`,槽位永久保留直至手动移除或重新开启
- **8h/24h 到期**:自动暂停(等同关开关),结束 session(`expired`),保留槽位
- **恢复 Agent 模式**:当无任何槽位对该机开启 5s 监测时,清理 `watch:live/proc/last` Redis;子机 Agent 始终 60s 智能心跳(CPU 波动 >10% 或超阈值才上报),无需改 Agent 配置
- **重新开启**:若 session 已结束则自动开新 session
- **涉及**`server/utils/watch_state.py``watch_repo.expire_due_pins``watch_service``watch_probe_runner`、前端 countdown 到期 refresh
## 2026-06-11 补充:8h / 24h 时长选项
- `watch_pins.ttl_hours` 列(默认 8,仅允许 8 或 24)
- `PATCH /api/watch/pins/{slot_index}/ttl` — 切换时长;监测开启时同步重置 `expires_at`
- `POST /api/watch/pins` 可选 `ttl_hours: 8 | 24`(默认 8
- 槽位卡片「8h / 24h」切换按钮,须显式点击才改为 24h
@@ -0,0 +1,19 @@
# Changelog — psutil SSH 安装 apt sudo 修复
**日期**2026-06-12
## 摘要
修复非 root SSH 用户点击「安装 psutilSSH)」时 `apt-get` 无 sudo 导致 Permission denied。
## 变更
- `psutil_install` 脚本增加 `run_root()``apt/dnf/yum` 与系统 pip 在非 root 时走 `sudo`
- `sudo_wrap` sudoers 补充 `python3` / `pip3` 路径
- 安装失败时返回中文提示(apt lock / 需免密 sudo
## 验证
```bash
.venv/bin/pytest tests/test_psutil_install.py -q
```
@@ -0,0 +1,29 @@
# 推送页移除点击复制名称
**日期**2026-06-12
## 动机
用户不需要点击服务器名称复制到剪贴板;点击应仅用于勾选服务器。
## 变更
- 移除卡片/列表视图中名称的 `@click.stop` 与剪贴板写入
- 移除 `push-server-copy-name` 样式(copy 光标、虚线下划线)
- 保留悬停 `title` 展示完整名称(卡片含域名)
## 涉及文件
- `frontend/src/components/push/PushServerGrid.vue`
## 验证
```bash
cd frontend && npm run type-check
```
推送页:点击名称与卡片其他区域一样切换勾选;无复制 toast。
## 迁移
无。仅前端构建部署。
@@ -0,0 +1,37 @@
# 推送页服务器搜索历史(5 条)
**日期**2026-06-12
## 动机
推送页搜索服务器需记住最近查询,便于重复筛选;与服务器页历史独立,最多保留 5 条。
## 变更
- **后端**`GET/PUT /api/servers/push-search-history``admin_ui_preferences` 上下文 `push_search`,上限 5 条
- **前端**:搜索框改为 combobox;回车或选历史项写入;与服务器页历史互不影响
- **复用**`useServerSearchHistory('push')` 与现有 MySQL 偏好存储一致
## 涉及文件
- `server/utils/server_search_history.py`
- `server/api/servers.py`
- `frontend/src/composables/useServerSearchHistory.ts`
- `frontend/src/composables/push/usePushServers.ts`
- `frontend/src/composables/push/usePushPage.ts`
- `frontend/src/components/push/PushServerGrid.vue`
- `frontend/src/pages/PushPage.vue`
- `tests/test_server_search_history.py`
## 验证
```bash
cd frontend && npm run type-check
pytest tests/test_server_search_history.py -q
```
推送页:输入关键词回车 → 下拉可见最近 5 条;换浏览器登录同一账号仍可恢复。
## 迁移
无新表;首次写入自动创建 `push_search` 偏好记录。
@@ -0,0 +1,35 @@
# 各页搜索历史互不共享
**日期**2026-06-12
## 动机
服务器页、终端页、推送页的搜索历史应各自独立,避免在一页搜索后其他页面下拉出现相同记录。
## 变更
- **终端**`GET/PUT /api/servers/terminal-search-history`,上下文 `terminal_search`,上限 10 条
- **推送**:已有 `push_search`5 条),不变
- **服务器页**:仍为 `servers_search`12 条),不变
- 前端 `useServerSearchHistory('terminal')` 替代原先与服务器页共用的默认 scope
## 涉及文件
- `server/utils/server_search_history.py`
- `server/api/servers.py`
- `frontend/src/composables/useServerSearchHistory.ts`
- `frontend/src/composables/terminal/useTerminalSessions.ts`
- `tests/test_server_search_history.py`
## 验证
```bash
cd frontend && npm run type-check
.venv/bin/pytest tests/test_server_search_history.py -q
```
三页分别搜索后,各自下拉仅见本页历史。
## 迁移
无新表;终端首次写入创建 `terminal_search` 偏好。原写入 `servers_search` 的终端记录不再显示(需用户在终端重新搜索)。
@@ -0,0 +1,33 @@
# 2026-06-12 新服务器 Agent 安装/升级提示
## 摘要
新添加的服务器不再因预生成 `agent_api_key` 误显示「离线」;列表与展开详情明确提示需安装或升级 Agent,并提供一键操作。
## 动机
用户添加服务器后 CPU/内存/心跳为空,界面无指引。根因:仅有 DB 中的 API Key 被当作「已装 Agent」,实际从未安装。
## 涉及文件
- `server/utils/agent_version.py``agent_is_installed``compute_agent_guidance`
- `server/api/servers.py` — 列表/详情返回 `agent_action*`;状态判定修正
- `frontend/src/components/servers/ServerAgentHint.vue` — 提示条(新增)
- `frontend/src/components/servers/ServerInlineDetail.vue` — 展开详情提示
- `frontend/src/pages/ServersPage.vue` — 列表「需安装/需升级」芯片、单台安装/升级
- `frontend/src/composables/servers/useServerFormDialog.ts` — 创建后 toast
- `frontend/src/types/api.ts` — 类型
- `tests/test_agent_version.py` — 单测
## 迁移 / 重启
需重启 API;前端构建部署。
## 验证
```bash
.venv/bin/pytest tests/test_agent_version.py -q
cd frontend && npm run type-check
```
添加服务器 → 状态应为「未知」→ 展开行见「尚未安装 Agent」→ 点「安装 Agent」。
@@ -0,0 +1,25 @@
# Changelog — 2026-06-12 新服务器接入自动化(生产部署)
## 摘要
部署 `7ba5e21`:创建/导入/IP 添加服务器后自动后台 **onboard**(非 root sudoers + 未设路径探测)。
## 动机
避免新机重复出现推送 Permission denied / 未配 sudoers。
## 变更
同 [2026-06-11-server-onboarding-automation.md](./2026-06-11-server-onboarding-automation.md)。
## 迁移 / 重启
重建 Docker 镜像并重启 API。无 DB 迁移。
## 验证
```bash
pytest tests/test_server_onboarding.py -q
# 生产:新建 ubuntu 子机 → 任务栏「新服务器初始化」
curl -s https://api.synaglobal.vip/health
```
@@ -0,0 +1,54 @@
# Changelog — 服务器实时监测槽(M1+M2+M3,无 CSV
**日期**2026-06-12
**类型**:新功能
## 摘要
实现宝塔式 **4 槽实时监测**5s 探针、WebSocket 推送、Pin CRUD、历史趋势与探针记录 Tab、进程 Top5 抽屉;M3 含 Agent 心跳附带 `net_io`/`disk_io`(减 SSH)及监测探针告警联动。**不含 CSV 导出**(按产品要求跳过)。
## 动机
运维需在服务器页对自选少数子机做近实时 CPU/内存/磁盘/网络/磁盘 IO 监视,并保留可回溯探针记录。
## 涉及文件
### 后端
- `server/domain/models/__init__.py``WatchPinSession` / `WatchPin` / `WatchProbeRecord`(含 `processes_json`
- `server/infrastructure/database/migrations.py` — 三表 + `processes_json`
- `server/infrastructure/database/watch_repo.py`
- `server/application/services/watch_service.py`
- `server/api/watch.py` — pins / live / metrics / probe-records / sessions / processes
- `server/background/watch_probe_runner.py` — 5s 探针、10s 进程、告警、Redis 窗口
- `server/api/websocket.py``/ws/watch` + `nexus:watch`
- `server/utils/watch_metrics.py` / `watch_alerts.py`
- `server/infrastructure/ssh/remote_probe.py`
- `server/main.py`
- `web/agent/agent.py` — 心跳附带 net/disk IO
### 前端
- `frontend/src/composables/useWatchPins.ts`
- `frontend/src/components/watch/*`
- `frontend/src/pages/WatchMetricsPage.vue`
- `frontend/src/pages/ServersPage.vue` — 槽位行 + 列表「+」
- `frontend/src/router/index.ts` / `App.vue`
### 测试
- `tests/test_watch_metrics.py`
- `tests/test_watch_pins.py`
## 迁移 / 重启
- 启动时自动 DDL`watch_*` 表)
-**重启 API**primary worker 启动 `watch_probe` 5s 任务)
- Agent 升级后心跳才带 IO(可选,减 SSH)
## 验证
```bash
pytest tests/test_watch_metrics.py tests/test_watch_pins.py -q
cd frontend && npm run type-check
bash scripts/local_verify.sh
```
浏览器:服务器页 Pin → 5s 内指标更新;`#/watch-metrics` 趋势 + 探针记录;满 4 槽替换对话框。
@@ -0,0 +1,31 @@
# 终端搜索框回到中间空态
**日期**2026-06-12
## 动机
终端页右侧栏保留服务器列表,但搜索框应仍在中间空态卡片(与改版前一致),不在右侧栏重复。
## 变更
- 恢复 `TerminalArea``#empty-picker` 插槽与中间 inline 选服卡片(含搜索 + 历史)
- 右侧 `sidebar` 模式仅展示服务器列表,移除顶部 combobox
- 空态文案恢复为「选择一台服务器开始会话…」
## 涉及文件
- `frontend/src/pages/TerminalPage.vue`
- `frontend/src/components/terminal/TerminalArea.vue`
- `frontend/src/components/terminal/TerminalServerPicker.vue`
## 验证
```bash
cd frontend && npm run type-check
```
无会话时:中间有搜索框;右侧仅列表。中间搜索仍过滤两侧列表。
## 迁移
无。仅前端构建部署。
@@ -0,0 +1,34 @@
# 终端页右侧服务器列表与搜索历史
**日期**2026-06-12
## 动机
终端与会话选服需同时进行:左侧操作 SSH,右侧随时选下一台;搜索词与服务器页共用最近记录。
## 变更
- 终端页 **右侧常驻** 服务器列表(300px),有会话时也可切换目标
- 搜索框改为 **v-combobox**,与服务器页共用 `/api/servers/search-history`(展示最近 **10** 条)
- 回车或选历史项写入历史;空终端区提示「从右侧列表选择」
## 涉及文件
- `frontend/src/pages/TerminalPage.vue`
- `frontend/src/components/terminal/TerminalArea.vue`
- `frontend/src/components/terminal/TerminalServerPicker.vue`
- `frontend/src/components/terminal/TerminalServerPickerSearch.vue`(新建)
- `frontend/src/components/terminal/TerminalServerPickerList.vue`(新建)
- `frontend/src/composables/terminal/useTerminalSessions.ts`
## 验证
```bash
cd frontend && npm run type-check
```
打开 `#/terminal`:右侧列表;搜索下拉见历史;左侧终端可并行使用。
## 迁移
无。仅前端构建部署。
@@ -0,0 +1,40 @@
# Changelog — 监测槽一键 SSH 安装 psutil
**日期**2026-06-12
## 摘要
监测槽探针报「子机未安装 psutil」时,卡片内显示 **「安装 psutilSSH)」** 按钮,通过 SSH 在子机系统 Python 安装 psutilpip --user → apt/yum → pip)。
## 动机
SSH 监测探针依赖系统 `python3` 的 psutilAgent venv 内的 psutil 无法被探针脚本使用。手动 SSH 安装成本高。
## 变更
- 后端:`POST /api/watch/servers/{server_id}/install-psutil`(须已 Pin 该服务器)
- `server/utils/psutil_install.py`:远程安装脚本 + 验证
- 前端:`WatchSlotCard` 在 psutil 错误时显示按钮;`WatchSlotRow` 调用 API 并 refresh
- 审计:`watch_install_psutil`
## 涉及文件
- `server/utils/psutil_install.py`(新)
- `server/application/services/watch_service.py`
- `server/api/watch.py`
- `frontend/src/components/watch/WatchSlotCard.vue`
- `frontend/src/components/watch/WatchSlotRow.vue`
- `frontend/src/utils/watchFormat.ts`
- `tests/test_psutil_install.py`(新)
- `tests/test_watch_pins.py`
## 迁移 / 重启
- 无需迁移;需重启 API + 前端构建
## 验证
```bash
.venv/bin/pytest tests/test_psutil_install.py tests/test_watch_pins.py -q
cd frontend && npm run type-check
```
@@ -0,0 +1,35 @@
# Changelog — 监测槽 TTL 到期竞态修复
**日期**2026-06-12
## 摘要
修复 TTL 到期与后台 `expire_due_pins`(5s 探针循环)之间的竞态窗口:槽位短暂显示为空、无法添加/移除、可能触发唯一约束冲突。
## 动机
`list_active_pins_for_admin``monitoring_enabled=true``expires_at<=now` 时排除 pin,但 DB 行仍在且 `monitoring_enabled` 仍为 true,直到探针循环执行 `expire_due_pins`。此窗口内 UI 显示空槽,但 `create_pin` 会因 `uq_watch_pins_admin_slot` 失败。
## 变更
- `WatchService._finalize_due_pins()`:在读/写路径同步到期 pin(暂停 + end session + 按需清 Redis
-`list_slots``pin_server``replace_slot``remove_slot``set_slot_monitoring``set_slot_ttl` 入口调用
- 新增单测:`test_watch_pin_ttl_expiry_list_syncs_before_display``test_watch_pin_ttl_expiry_pin_server_no_duplicate`
- **P2**`WatchSlotRow.onRemove` 失败时 snackbar 提示
## 涉及文件
- `server/application/services/watch_service.py`
- `frontend/src/components/watch/WatchSlotRow.vue`
- `tests/test_watch_pins.py`
## 迁移 / 重启
- 无需迁移
- 需重启 API
## 验证
```bash
.venv/bin/pytest tests/test_watch_pins.py -q
```
@@ -0,0 +1,19 @@
# 实施 — 新服务器接入自动化
## 文件
| 文件 | 变更 |
|------|------|
| `server/application/server_batch_common.py` | `detect_and_save_target_path` |
| `server/application/services/server_onboarding_service.py` | 调度入口 |
| `server/application/services/server_batch_service.py` | op `onboard` |
| `server/api/servers.py` | 创建/导入后触发 |
| `server/api/schemas.py` | `onboarding_job_id` |
| `frontend/.../useServerFormDialog.ts` | 注册进度条 |
| `tests/test_server_onboarding.py` | 单测 |
## 验证
```bash
pytest tests/test_server_onboarding.py -q
```
@@ -0,0 +1,34 @@
# 设计 — 新服务器接入自动化
## 背景
非 root 子机推送需目标机 `nexus-files` sudoers;未设 `target_path` 时推送回退 `/www/wwwroot`。此前需人工「一键配置 sudo」+「检测目标路径」。
## 目标
创建/导入/凭据轮询添加服务器成功后,**后台自动**:
1. 非 root:安装/补全 `nexus-files` sudoers(含 rsync
2. `target_path` 仍为「未设路径」:SSH 探测 workerman.bat 并写入站点目录
## 方案
新增批量操作 `onboard`,复用现有 `server_batch_service` 后台任务 + WebSocket 进度。
| 入口 | 触发 |
|------|------|
| POST `/servers/` | 单台 → job |
| CSV import | 批量 created_ids → 一个 job |
| 凭据轮询 / add-by-ip / pending retry | `_create_server_from_poll` 内触发 |
root SSH:跳过 sudo 步骤;已有具体 `target_path`:跳过探测。
## 非目标
- 不自动安装 Agent
- 不因 onboarding 失败阻断创建 API(仅记录 batch job 失败项)
## 验收
- 新建 ubuntu 服务器 → 返回 `onboarding_job_id`,任务含 sudo + detect
- pytest 覆盖 onboard 编排逻辑
@@ -0,0 +1,386 @@
# 设计 — 服务器实时监测槽(4 台自选 · 宝塔式)
**日期**2026-06-12
**状态**:待实施
**关联讨论**:非全舰队;SSH/Redis 探针;8h 自动下架;独立历史页
## 背景与目标
运维需要在 **服务器页****手动选定的少数子机**(最多 **4 台**)做 **近实时** 资源监视(CPU、内存、磁盘、负载、网络/磁盘 IO、进程 Top),体验接近 **宝塔面板监控**:数字与曲线 **约 5 秒刷新**,而非现有 10 分钟历史粒度。
**不在范围**:全舰队 5s 探针;改造现有 Schedules(推送/脚本)页;无 SSH 且无 Agent 的机器无法探针(需明确失败态)。
## 已确认产品决策
| 项 | 决定 |
|----|------|
| 同时监测上限 | **4 台**(固定 4 槽) |
| 选机方式 | **服务器列表** 每行 **「+」** 加入监测;槽位区也可点「+」选机 |
| 范围 | **仅 Pin 的子机** 高频采样;未 Pin **零额外 SSH/探针** |
| 生命周期 | 添加后 **8 小时** 自动下架;用户可手动删除;重复添加同一台 **重置 8h** |
| 实时感 | 目标 **5s** 刷新(探针 + WebSocket 推送) |
| 数据源 | **有 Agent 且 Redis 新鲜** → 读 Redis;否则 **SSH psutil 脚本** |
| 历史 | Pin 期间 **每次探针均落库**(成功/失败皆有记录);**独立页面** 查趋势 + **探针记录**7 天 purge |
| 隔离 | **按管理员** 4 槽(独立表 + `admin_id` |
| **探针记录** | **强制**:每 5s 一轮必有 DB 行;失败写 `probe_status` + `error`**禁止只打日志不落库** |
| **指标范围** | **每轮 5s 全量刷新**:CPU、内存、**磁盘使用率**、负载、**磁盘读写**、**网络入口/出口(带宽)**、进程 Top(10s);见下节 |
## 指标清单(每轮探针必采 · 与宝塔对齐)
> **原则**:Pin 期间 **所有下列指标同一 5s 周期采集、落库、推 WS、刷新 UI**;不得把磁盘/网络放到「二期」或降频。
| 类别 | 字段 | 展示(槽位卡) | 说明 |
|------|------|----------------|------|
| CPU | `cpu_pct` | 圆环 + 曲线 | % |
| 内存 | `mem_pct` | 圆环 + 曲线 | % |
| **磁盘空间** | `disk_pct` | 圆环 + 曲线 | 根分区 `/` 使用率 %(与 Agent/SSH 一致) |
| 负载 | `load_1/5/15` | 数字 | load average |
| **磁盘 IO** | `disk_read_bytes`, `disk_write_bytes` | **读/写速率** | 累计字节;API/前端用相邻两点差算 **MB/s** |
| **网络出入口** | `net_bytes_recv`, `net_bytes_sent` | **下行↓ / 上行↑ 速率** | recv=入口/下载,sent=出口/上传;差算 **Mbps 或 MB/s** |
| 进程 | `processes_json` | Top5 表 | 每 **10s** 刷新内容,仍每 5s 写探针行 |
**速率计算**(中央统一,避免前端各算各的):
```
rate_sent = (sent[t] - sent[t-1]) / (ts[t] - ts[t-1]) → 出口带宽
rate_recv = (recv[t] - recv[t-1]) / (ts[t] - ts[t-1]) → 入口带宽
disk_read_rate / disk_write_rate 同理
```
首点无上一采样时速率显示 `—`WS 推送 payload 含 **已算好的** `net_up_bps`, `net_down_bps`, `disk_read_bps`, `disk_write_bps` 供卡片直接用。
**数据源**
| 路径 | CPU/MEM/DISK% | 网络 cumulative | 磁盘 IO cumulative |
|------|---------------|-----------------|---------------------|
| RedisAgent 心跳) | ✅ `system_info` 已有 | ⚠ 心跳 **默认无** → Pin 机走 SSH 补采 **或** Agent 小改带上 `net_io`/`disk_io`(推荐 **M1SSH 脚本一次拿全** |
| SSH 探针 | ✅ psutil 一次 JSON | ✅ `psutil.net_io_counters()` | ✅ `psutil.disk_io_counters()` |
Pin 且 Redis 仅含 cpu/mem/disk% 时:**同轮 SSH 补采 IO/网络**,合并为一行 `watch_probe_records``source=redis+ssh` 或主来源记 `ssh`)。
**UI(槽位卡 · 宝塔式)**
```
CPU 78% MEM 62% DISK 41%
↑ 12.3 MB/s ↓ 8.6 MB/s ← 网络出入口(实时)
读 2.1 MB/s 写 0.4 MB/s ← 磁盘 IO(可选一行,M1 即显示)
[15min 折线:CPU / MEM / DISK / 下行带宽 可切换图例]
```
## 方案对比
| 方案 | 优点 | 缺点 |
|------|------|------|
| A. 仅复用 `server_metric_samples`(10min) | 无新表 | 无法宝塔式实时 |
| B. 全舰队 Agent 5s 心跳 | 真实时 | 2000+ 台不可接受 |
| C. **自选 4 台 + 5s 探针 + WS(选定)** | 负载可控;SSH 覆盖无 Agent;体验接近宝塔 | 需新表、worker、WS 通道 |
| D. 前端 5s 轮询 HTTP | 实现简单 | 延迟与连接数差于 WS;仍要后台探针 |
**选定 C**:后台 `watch_probe` 仅对 Pin 列表采样;Redis 滑动窗口 + MySQL 持久化;WebSocket 推送到浏览器。
## 信息架构
### 服务器页布局
```
StatCardsRow(现有)
WatchSlotRow(新增,4 格)
空槽:虚线卡片「+ 添加监测」
有槽:主机名、CPU/MEM/**DISK** 三环、15min 滚动折线(**CPU+内存+磁盘** 可切换)、网络/磁盘 IO 速率、剩余时间、删除
服务器列表(现有)
操作列新增:mdi-plus 图标按钮「加入实时监测」
已在监测:显示已占用槽位或 disabled + tooltip「已在监测 · 槽 2」
```
### 列表「+」交互
1. 点击 **+** → 若未满 4 槽:直接占用 **第一个空槽**(或弹出选槽 1~4,**默认最小空槽号**)。
2. 若已满 4 槽:**对话框**「监测已满,请选择要替换的槽位」→ 选槽 → 替换。
3. 已在监测的 server**+ 变为 mdi-check 或隐藏**tooltip 提示槽位与剩余时间。
4. 无 SSH 凭据且无 Agent+ 可点但 POST 返回 422snackbar 说明原因(不静默失败)。
### 独立历史页
- 路由:`#/watch-metrics`(侧栏「监测历史」或从 WatchSlotRow「查看历史 →」进入)
- **Tab A — 资源趋势**:选 14 台、时间范围 1h/6h/24h/7d;曲线含 **CPU / 内存 / 磁盘使用率 / 网络↑↓ / 磁盘读写的速率**(由相邻探针差分计算);离线 shading;导出 CSV
- **Tab B — 探针记录**:按服务器/时间/状态筛选的 **只读表格**(见下文「探针记录」);支持导出 CSV
- 与 Dashboard 舰队均值页区分:本页 **单机/自选多机** + **Pin 期间细粒度 + 全量探针日志**
## 探针记录(强制)
> **原则**:监测槽的价值不仅在实时曲线,还在 **可回溯的探针执行记录**——SSH 超时、凭据失败、Redis 过期等必须可查,不得仅写应用日志或静默丢弃。
### 记什么
每一轮 `watch_probe` 对每台 **Pin 中的 server**(去重后)**必须 INSERT 一行**,无论成功或失败:
| 字段语义 | 成功 | 失败示例 |
|----------|------|----------|
| `probe_status` | `ok` | `ssh_timeout` / `ssh_auth` / `no_cred` / `redis_stale` / `offline` / `parse_error` |
| 指标列 | 有值 | **NULL**(禁止用 0 冒充) |
| `error` | NULL | 人类可读摘要(≤255 字) |
| `duration_ms` | 探针耗时 | 同上 |
| `source` | `redis` / `ssh` | 尝试的数据源 |
### 与会话关联
每次用户点列表 **+** 加入监测,创建一条 **`watch_pin_sessions`**8h 监测会话);探针记录通过 `session_id` 归属到「哪一段 Pin 期间」。
Pin 到期或手动删除 → 更新 `ended_at` + `end_reason``expired` / `manual` / `replaced`);**已写入的探针记录不删**,保留至 purge 策略。
### UI 在哪看
1. **监测历史页 · 探针记录 Tab** — 主入口,可跨会话、跨机器查
2. **监测槽卡片 · 「记录」链接** — 跳转并预填当前 server + 当前 session 时间范围
3. **服务器列表展开详情(可选 M2** — 若该台正在 Pin,展示最近 20 条探针摘要
### 运维用途
- 排查「曲线断档」:按 `probe_status=ssh_timeout` 过滤
- 对比 Redis vs SSH:看 `source`
- 审计:谁在何时 Pin 了哪台(`watch_pin_sessions` + audit log
## 数据模型
### `watch_pin_sessions`
```sql
watch_pin_sessions (
id INT PK AUTO_INCREMENT,
admin_id INT NOT NULL,
server_id INT NOT NULL,
slot_index TINYINT NOT NULL,
started_at DATETIME NOT NULL,
ended_at DATETIME NULL,
end_reason VARCHAR(16) NULL, -- expired | manual | replaced
INDEX (admin_id, started_at),
INDEX (server_id, started_at)
)
```
`watch_pins` 当前活跃 Pin 可 FK 指向 `session_id`;到期删 pin 行,**session 保留**。
### `watch_pins`
```sql
watch_pins (
id INT PK AUTO_INCREMENT,
admin_id INT NOT NULL FK admins,
session_id INT NOT NULL FK watch_pin_sessions,
slot_index TINYINT NOT NULL, -- 0..3
server_id INT NOT NULL FK servers,
pinned_at DATETIME NOT NULL, -- UTC naive
expires_at DATETIME NOT NULL, -- pinned_at + 8h
UNIQUE (admin_id, slot_index),
UNIQUE (admin_id, server_id),
INDEX (expires_at)
)
```
### `watch_probe_records`
> 表名实现可用 `watch_probe_records`;与早期草案 `watch_metric_samples` 合并为 **同一张表**(指标 + 探针状态一体),避免双写。
```sql
watch_probe_records (
id BIGINT PK AUTO_INCREMENT,
session_id INT NOT NULL FK watch_pin_sessions,
server_id INT NOT NULL,
admin_id INT NOT NULL, -- Pin 发起人(查询隔离)
recorded_at DATETIME NOT NULL,
source VARCHAR(16) NOT NULL, -- redis | ssh
probe_status VARCHAR(24) NOT NULL, -- ok | ssh_timeout | ...
duration_ms INT NOT NULL,
is_online BOOLEAN NOT NULL,
cpu_pct SMALLINT NULL,
mem_pct SMALLINT NULL,
disk_pct SMALLINT NULL, -- 根分区 / 使用率 %
disk_mount VARCHAR(255) NULL DEFAULT '/', -- 采样分区(M1 固定 /
load_1 FLOAT NULL,
load_5 FLOAT NULL,
load_15 FLOAT NULL,
net_bytes_sent BIGINT NULL, -- 累计:网络出口(上传)
net_bytes_recv BIGINT NULL, -- 累计:网络入口(下载)
disk_read_bytes BIGINT NULL,
disk_write_bytes BIGINT NULL,
net_up_bps BIGINT NULL, -- 出口带宽 bytes/s(写入时算好)
net_down_bps BIGINT NULL, -- 入口带宽 bytes/s
disk_read_bps BIGINT NULL,
disk_write_bps BIGINT NULL,
processes_json JSON NULL, -- top 510s 更新内容
error VARCHAR(255) NULL,
INDEX (session_id, recorded_at),
INDEX (server_id, recorded_at),
INDEX (admin_id, recorded_at),
INDEX (probe_status, recorded_at)
)
```
**写入纪律**
- `watch_probe` **每 5s 每台 Pin 机至少 1 INSERT**;进程 Top 可每 2 轮更新 `processes_json`(仍写行,仅进程字段可空)。
- 探针异常:`probe_status != ok` 时指标列全 NULL + `error` 必填。
- **保留 7 天**`watch_records_purge` 每日 batch DELETE。
**估算**1 台 Pin 8h ≈ 5760 行(5s);4 台 ≈ 2.3 万行/8h,7 天可接受。
### Redis(实时滑动窗口)
- Key`watch:live:{server_id}`**5s 一点**,保留 **30 分钟**;每点 JSON **含完整指标**
```json
{
"ts": "2026-06-12T14:05:05Z",
"cpu_pct": 12,
"mem_pct": 45,
"disk_pct": 61,
"load_1": 0.8,
"net_bytes_sent": 102400,
"net_bytes_recv": 204800,
"net_up_bps": 1310720,
"net_down_bps": 917504,
"disk_read_bytes": 4096,
"disk_write_bytes": 8192,
"disk_read_bps": 2202009,
"disk_write_bps": 419430,
"probe_status": "ok",
"source": "ssh"
}
```
- Key`watch:proc:{server_id}` — 进程 JSONTTL 60s**10s 更新**
- Pub/Sub`nexus:watch` — payload 与上同结构 + `server_id`, `admin_ids[]`
## 后台任务
| 任务 | 间隔 | 职责 |
|------|------|------|
| `watch_probe` | **5s** | 读 Pin 并集;Redis 或 SSH**每轮每台必 INSERT `watch_probe_records`**;写 Redis 窗口;Pub/Sub WS |
| `watch_pin_expiry` | 60s | 过期 pin → 更新 session.`ended_at`;删 pin 行 |
| `watch_records_purge` | 每日 | DELETE `watch_probe_records` / 旧 session 元数据 > 7d |
**Primary worker only**(与 `heartbeat_flush` 一致)。
### SSH 探针脚本(单次 JSON,指标一次带回)
`remote_probe` 基础上扩展,**一次 exec** 输出:
```json
{
"cpu_usage": 12.5,
"mem_usage": 45.2,
"disk_usage": 61.0,
"disk_mount": "/",
"load_1": 0.82,
"load_5": 0.91,
"load_15": 1.05,
"net_bytes_sent": 123456789,
"net_bytes_recv": 987654321,
"disk_read_bytes": 1234567890,
"disk_write_bytes": 9876543210,
"top_processes": [{"name":"nginx","pid":1234,"cpu_pct":2.1,"mem_pct":1.2}]
}
```
超时 **8s**;失败仍 INSERT;**禁止**只采 CPU/内存而跳过磁盘与网络出入口。
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/watch/pins` | 当前管理员 4 槽 + 最新实时指标 + 剩余秒数 + `session_id` |
| POST | `/api/watch/pins` | `{ "server_id": 1, "slot_index"?: 0 }`;创建 session;满则 409 |
| PUT | `/api/watch/pins/{slot}` | 替换槽内 server_id(旧 session `end_reason=replaced` |
| DELETE | `/api/watch/pins/{slot}` | 手动移除(session `end_reason=manual` |
| GET | `/api/watch/live?server_ids=1,2` | 读 Redis 窗口(兜底轮询) |
| GET | `/api/watch/metrics?server_ids=&hours=24` | 从 `watch_probe_records` 聚合曲线(仅 `probe_status=ok` |
| GET | `/api/watch/probe-records` | **探针记录分页**query: `server_id`, `session_id`, `hours`, `probe_status`, `page` |
| GET | `/api/watch/sessions?hours=168` | Pin 会话列表(起止时间、机器、结束原因、探针成功/失败计数) |
**`GET /api/watch/probe-records` 响应示例**
```json
{
"items": [
{
"id": 9912,
"recorded_at": "2026-06-12 14:05:05",
"server_id": 42,
"server_name": "web-01",
"source": "ssh",
"probe_status": "ssh_timeout",
"duration_ms": 8001,
"error": "SSH exec timed out after 8s",
"cpu_pct": null,
"disk_pct": null,
"net_up_bps": null,
"net_down_bps": null
}
],
"total": 5760,
"page": 1,
"per_page": 50
}
```
鉴权:`Depends(get_current_admin)`;仅可查 **本人 `admin_id`** 的 session/记录;CUD Pin 写 audit log`watch_pin_add/remove/replace`)。
## WebSocket
- 端点:复用 `/ws/alerts` 扩展 `type: watch_metrics` **或**`/ws/watch`(推荐 **新 channel**,避免告警拥塞)
- 订阅:连接后带 JWT;服务端维护 `admin_id → socket`;仅推送 **该 admin 当前 Pin 的 server_id** 更新
- 前端:Servers 页 mount 时 connectunmount disconnect;收到包更新 WatchSlotRow 与 ECharts append
## 前端组件
| 组件/页 | 职责 |
|---------|------|
| `WatchSlotRow.vue` | StatCardsRow 下 4 卡;gauge + 15min 折线;倒计时 |
| `WatchSlotCard.vue` | 单槽 UI:三环 + **↑出口 ↓入口** + 磁盘读写速率 |
| `useWatchPins.ts` | Pin CRUD、WS、倒计时 |
| `WatchMetricsPage.vue` | 历史趋势 + **探针记录 Tab**`v-data-table-server` |
| `WatchProbeRecordsTable.vue` | 状态、source、CPU/MEM/**DISK**、**网络↑↓**、耗时、error |
| `ServersPage.vue` | 操作列 `mdi-plus`;接 POST pin |
**图表**ECharts(与 `FleetTrendEChart` 同栈);实时 `appendData` + 窗口上限 360 点。
## 安全与性能
- **硬上限**:每 admin 4 pin;探针 server 去重(多 admin pin 同一台 → 探针 **1 次**WS **分 admin 推**
- **SSH**:仅 Pin 集合;连接池复用;并发 ≤4(可配置)
- **无静默吞错**:SSH 失败、无凭据、超时 → 卡槽红态 + **探针记录行** + 最后 error 文案
- **进程 JSON**:命令行截断 80 字符,不落盘敏感 env
- **估算负载**100 admin × 4 pin 极端 = 400 台(理论);实际运维 **个位数 admin × 4** → 探针 QPS ≈ 0.8/s 可接受
## 与现有能力关系
| 现有 | 关系 |
|------|------|
| Agent 60s 心跳 + Redis | Pin 且 Redis 新鲜 → **优先**,减 SSH |
| `server_metric_samples` 10min | **保留**;与 watch 探针记录 **独立**;长历史兜底 |
| 列表展开 `ServerMetricSparklines` | **保留**;与监测槽互补 |
| Schedules | **不扩展** schedule_type |
## 验收标准
1. 服务器列表每行有 **+**,点击后 **5s 内** CPU/内存/磁盘% 与 **网络出入口速率** 连续更新。
2. 满 4 槽再点 **+** 出现替换对话框;手动删除后槽变空。
3. **8h** 到期自动消失(误差 ≤ 2min);再次 **+** 同一台重置 8h。
4. 未 Pin 的子机:抓包/日志 **无** 额外 SSH 探针。
5. `#/watch-metrics` **探针记录 Tab** 可见每次 5s 探针(含失败);按 session 筛选与 Pin 时段一致。
6. SSH 失败:卡槽红态 + **DB 有 `probe_status=ssh_*` 行****不**显示 0% 冒充正常。
7. 模拟 SSH 超时单测:断言 **仍有 INSERT**`cpu_pct IS NULL`
8. 探针记录与趋势页均含 **disk_pct、net_up_bps、net_down_bps** 列/序列;相邻 5s 点带宽差分正确。
9. `local_verify` + 单测(含速率差分、全指标 SSH mock+ type-check。
## 实施分期
| 阶段 | 内容 |
|------|------|
| **M1** | 表 + 探针 **全指标(含磁盘% + 网络出入口 + 磁盘 IO 速率)** + 5s WS + 4 槽 UI + 列表 **+** |
| **M2** | 进程 Top5 抽屉 + **探针记录 Tab** + 历史趋势多序列(含带宽曲线) |
| **M3** | CSV 导出 + Agent 可选补 net_io(减 SSH+ 告警联动 |
## 回滚
- 删除路由与 UI;停 `watch_probe`;表可保留;Redis key TTL 自愈。
@@ -0,0 +1,64 @@
# 未设路径 Fleet 探测与推送验证 — 2026-06-11
## 部署
- 版本:`e6abd4a`(推送默认 `/www/wwwroot` + sudo rsync
- 健康:`https://api.synaglobal.vip/health` 200
## 行为确认
| 概念 | 说明 |
|------|------|
| 「未设路径」UI | `target_path` 为空或精确 `/www/wwwroot` |
| 推送目标 | 空时落到 `/www/wwwroot`(宝塔站点根,可传文件) |
| 两者关系 | **独立**:面板仍显示未设路径,推送可用 wwwroot |
## 批量 detect-path`--unset-only`
- 匹配:**102 台**
- 成功:**9 台**(写入具体站点目录)
- 失败:**93 台**(多为「未找到 workerman.bat」)
### 探测成功清单
| ID | 名称 | 写入路径 |
|----|------|----------|
| 245 | 武汉市辰风落科技有限公司 | `/www/wwwroot/crmeb` |
| 246 | 子-武汉市辰风落科技有限公司 | `/www/wwwroot/crmeb` |
| 303 | 落风窗(武汉市)科技有限公司 | `/www/wwwroot/shop` |
| 304 | 子-落风窗(武汉市)科技有限公司 | `/www/wwwroot/shop` |
| 305 | 武汉市事野落电子商务有限公司 | `/www/wwwroot/shop` |
| 306 | 子-武汉市事野落电子商务有限公司 | `/www/wwwroot/shop` |
| 315 | 漫窗星(武汉市)电子商务有限公司 | `/www/wwwroot/shop/yaciwangluo.cn` |
| 316 | 子-漫窗星(武汉市)电子商务有限公司 | `/www/wwwroot/shop/yaciwangluo.cn` |
| 371 | 冲量银海-泽初 | `/www/wwwroot/whzecw.com` |
93 台失败需人工确认站点结构(无 workerman.bat 或非宝塔布局)。
## 非 root 推送验证(`verify_push_elevation.py --non-root-only`
| 指标 | 改 wwwroot 前 | 改 wwwroot 后 |
|------|---------------|---------------|
| 通过 | 24/68 | **67/68** |
| 失败 | 44dest=/tmp/sync | 1 |
### 唯一失败
- **#342** 子-武汉市清风无恙电子商务有限公司
- `dest=/www/wwwroot/shop`
- `rsync_sudo=False`(其余检查 write/elevated 通过)
- 建议:对该机重新执行「一键配置 sudo」或 `batch_patch_files_sudoers.sh --ids 342`
### wwwroot 根目录推送
37 台非 root 子机 `dest=/www/wwwroot` 验证通过(sudo rsync + 写入 + dry-run)。
## 命令(生产容器)
```bash
sudo docker cp /opt/nexus/scripts/batch_detect_target_path.py nexus-prod-nexus-1:/app/scripts/
sudo docker exec -w /app nexus-prod-nexus-1 python scripts/batch_detect_target_path.py --unset-only --concurrency 10
sudo docker exec -w /app nexus-prod-nexus-1 python scripts/verify_push_elevation.py --non-root-only --concurrency 10
```
日志:`/tmp/detect-full.txt``/tmp/verify-push.txt`(生产主机)
@@ -0,0 +1,40 @@
# Bug 巡查 — 监测槽增强(2026-06-12
**范围**:工作区未提交的 watch 开关 / TTL / 探针修复
**方法**:代码走读 + 单测 + 竞态分析
## 发现与处理
| # | 严重度 | 问题 | 状态 |
|---|--------|------|------|
| 1 | **P0** | TTL 到期后 ~5s 内槽位显示「空」,但 DB ghost pin 仍在;添加服务器 → 唯一约束冲突;点 X 移除 → 404「槽位为空」 | **已修** — API 读/写路径同步 `_finalize_due_pins` |
| 2 | P2 | `onRemove` 无 try/catch,API 失败时未提示 | 未修(低概率,可跟进) |
| 3 | P3 | 暂停态仍可通过 `get_processes` 拉取进程(数据可能过期) | 设计可接受 |
| 4 | — | Gate 7 被并行 Agent 提示改动阻塞 | 非 watch bug |
## P0 根因
```
expires_at 已过
→ list 查询 or_(paused, expires_at>now) 排除该行
→ UI empty=true
→ DB 仍有 monitoring_enabled=true 的行
→ create_pin / pick 槽位 → IntegrityError
```
探针循环每 5s 才跑 `expire_due_pins`,此前无 API 侧兜底。
## 修复
`WatchService._finalize_due_pins()``list_slots` 及所有槽位变更 API 入口调用,与探针循环逻辑一致:暂停 pin、结束 session、无活跃监测时清 Redis。
## 测试
```text
pytest tests/test_watch_pins.py → 7 passed
pytest tests/test_watch_*.py → 21 passed
```
## 未部署
生产仍为旧版本;修复需部署后浏览器终验 TTL 归零场景。
@@ -0,0 +1,100 @@
# 巡检 — 监测槽开关 / 8h·24h / 暂停占槽 / 探针修复
**日期**2026-06-12
**范围**:工作区未提交改动(监测槽增强 + parse_error 修复 + 中文错误)
**生产**https://api.synaglobal.vip**尚未部署本批改动**
## 进度条
| 步骤 | 状态 |
|------|------|
| 实现 | ✅ 工作区完成 |
| 本地验证 | ✅ L2b 26/26 + ruff |
| 单测 | ✅ watch 23/23 |
| 类型检查 | ✅ vue-tsc |
| 门控 7/7 | ⚠️ 6/7Gate 7 被 **Agent 提示** 并行改动阻塞,非 watch 本身) |
| 部署 | ❌ 未执行 |
| 生产健康 | ✅ `/health``ok` |
| 浏览器终验 | ❌ 待部署后 |
## 本地验证
### 单测
```text
pytest tests/test_watch_*.py tests/test_watch_probe_errors.py
→ 23 passed
```
### L2b API E2Elocal_verify.sh
```text
26/26 passed
ruff F: All checks passed
═══ All local checks passed ═══
```
### 前端
```text
npm run type-check → PASS
```
### 门控 pre_deploy_check.sh
| Gate | 结果 |
|------|------|
| 1 Changelog | PASS2026-06-12-server-agent-action-hint.md |
| 2 Audit | PASS2026-06-12-files-watch-ui-fixes.md |
| 3 Test | PASS |
| 4 Lint | PASS |
| 5 Import | PASS |
| 6 Security | PASS |
| 7 Review | **BLOCK** — 以下文件有 diff 但未列入当日 audit |
Gate 7 阻塞文件(**Agent 版本提示**,与监测槽无关):
- `frontend/src/components/servers/ServerAgentHint.vue`
- `frontend/src/components/servers/ServerInlineDetail.vue`
- `frontend/src/composables/servers/useServerFormDialog.ts`
- `frontend/src/types/api.ts`
- `server/api/servers.py`
- `server/utils/agent_version.py`
- `tests/test_agent_version.py`
监测槽改动已有独立审计:`docs/audit/2026-06-11-watch-slot-monitoring-enhancements.md`
## 生产探针
| 检查 | 结果 |
|------|------|
| `GET /health` | 200 `ok` |
| `GET /app/` | 200 |
| `GET /api/watch/pins`(无 JWT | 401(预期) |
| `PATCH /api/watch/pins/0/monitoring`(无 JWT) | 401(端点存在性需部署后带 JWT 终验) |
**说明**:生产当前运行的是已部署版本;监测槽开关 / TTL PATCH / 中文错误 / parse_error 修复均在工作区,**线上行为未变**。
## 功能验收清单(部署后浏览器终验)
1. **开关暂停**:关实时监测 → 5s 探针停止,槽位仍占用,UI 显示暂停态
2. **重新开启**:开开关 → 若 session 已结束则自动开新 session,探针恢复
3. **8h / 24h**:默认 8h;点 24h 后 `expires_at` 延长;replace 槽位保留原 TTL
4. **TTL 到期**:自动暂停(不删 pin),Agent 恢复 60s 智能心跳
5. **parse_error**Agent 有 CPU/内存时 SSH 失败不应整轮 parse_error
6. **中文错误**:探针失败状态与错误列显示中文
## 部署前待办
1. **Gate 7**:为 Agent 提示改动补 audit,或与监测槽分两次提交/部署
2. **迁移**`watch_pins.monitoring_enabled` + `ttl_hours`migrations.py 已含)
3. **部署**:用户批准后 `bash deploy/deploy-production.sh`
4. **前端构建**Docker 内 vite build 或 `deploy/deploy-frontend.sh`
## 本次巡检修复
- 删除 `server/infrastructure/ssh/remote_probe.py` 未使用的 `import logging`local_verify ruff F401
## 结论
**代码质量就绪**(单测 + L2b + lint + 类型检查均通过)。**部署被 Gate 7 阻塞**(并行 Agent 提示改动缺 audit)。监测槽功能本身审计完备,建议与 Agent 提示改动解耦提交,或补全 audit 后一并部署。
+1 -5
View File
@@ -223,8 +223,6 @@
{{ snackbar.text }}
</v-snackbar>
<GlobalBrowserPanel v-if="auth.isLoggedIn" :show-launcher="auth.isLoggedIn" />
</v-app>
</template>
@@ -249,8 +247,6 @@ import {
} from '@/composables/useScriptSubmitToast'
import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api'
@@ -346,8 +342,8 @@ const scriptExecBarOffset = computed(() => {
const opsItems = [
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
{ to: '/watch-metrics', title: '监测历史', icon: 'mdi-chart-line' },
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
{ to: '/browser', title: '浏览器', icon: 'mdi-web' },
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
+3
View File
@@ -296,6 +296,9 @@ export const http = {
put: <T = any>(path: string, body?: unknown) =>
api<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: <T = any>(path: string, body?: unknown) =>
api<T>(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: <T = any>(path: string) =>
api<T>(path, { method: 'DELETE' }),
@@ -1,543 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="panelOpen && tabs.length"
class="global-browser-float"
:class="{ 'global-browser-float--maximized': maximized }"
:style="maximized ? undefined : panelStyle"
>
<v-card border rounded="lg" class="global-browser-panel d-flex flex-column h-100">
<div
class="global-browser-toolbar d-flex align-center flex-shrink-0"
:class="{ 'global-browser-toolbar--draggable': !maximized }"
@mousedown="onToolbarMouseDown"
>
<div
v-if="!maximized"
class="global-browser-drag-strip flex-shrink-0"
title="拖动"
@mousedown="onToolbarMouseDown"
>
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
</div>
<v-tabs
:model-value="activeTabId ?? undefined"
density="compact"
show-arrows
class="global-browser-tabs flex-grow-1 min-w-0"
@update:model-value="onTabSelected"
@mousedown.stop
>
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
<v-btn
v-if="tabs.length > 1"
icon="mdi-close"
size="x-small"
variant="text"
class="ml-1 opacity-60"
density="compact"
@click.stop="closeTab(tab.id)"
/>
</v-tab>
</v-tabs>
<v-btn
size="small"
variant="text"
icon="mdi-plus"
title="新标签"
density="compact"
@mousedown.stop
@click="newEmptyTab"
/>
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" @mousedown.stop />
<v-btn
size="small"
variant="text"
icon="mdi-history"
title="浏览记录"
density="compact"
@mousedown.stop
@click="showHistory = !showHistory"
/>
<v-btn
size="small"
variant="text"
icon="mdi-restart"
title="重启浏览器(保留历史记录)"
density="compact"
@mousedown.stop
@click="onRestart"
/>
<v-btn
size="small"
variant="text"
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
density="compact"
@mousedown.stop
@click="maximized = !maximized"
/>
<v-btn
size="small"
variant="text"
icon="mdi-window-minimize"
title="最小化(可拖动)"
density="compact"
class="mr-1"
@mousedown.stop
@click="minimizePanel"
/>
</div>
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="goBack" />
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="goForward" />
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="refreshActive" />
<v-text-field
v-model="addressDraft"
density="compact"
variant="outlined"
hide-details
placeholder="https://example.com"
prepend-inner-icon="mdi-web"
class="global-browser-url mx-2"
@keydown.enter.prevent="onGo"
/>
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
<v-btn
icon="mdi-open-in-new"
variant="text"
size="small"
:disabled="!activeTab?.url"
@click="openExternal"
/>
</div>
<v-alert
v-if="lastError"
type="warning"
density="compact"
variant="tonal"
class="ma-2 mb-0 flex-shrink-0"
closable
@click:close="lastError = null"
>
{{ lastError }}
</v-alert>
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
<div class="text-caption font-weight-medium px-3 py-2">浏览记录已同步账号</div>
<v-list density="compact" nav class="py-0 global-browser-history-list">
<v-list-item
v-for="(visit, idx) in visits"
:key="`${visit.at}-${idx}`"
:title="visit.title"
:subtitle="visit.url"
@click="openFromHistory(visit.url)"
/>
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
</v-list>
</aside>
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
<div
v-if="!activeTab?.url"
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
>
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
<p class="text-body-2 text-center">输入地址后按回车或点打开</p>
<p class="text-caption mt-2 text-center">
浏览器不可关闭仅可最小化或重启页面在您当前的 Chrome / Firefox 引擎中渲染
</p>
</div>
<template v-else>
<iframe
:key="activeTab.frameKey"
:src="activeTab.url"
class="global-browser-frame flex-grow-1"
title="内置浏览器"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
referrerpolicy="no-referrer-when-downgrade"
/>
<p class="text-caption text-medium-emphasis px-2 py-1 flex-shrink-0 global-browser-hint">
若空白可能禁止 iframe 嵌入 请用新标签打开
</p>
</template>
</div>
</div>
<div
v-if="!maximized"
class="global-browser-resize"
title="拖动调整大小"
@mousedown.stop="onResizeStart"
/>
</v-card>
</div>
<!-- Minimized draggable floating bar (any corner) -->
<div
v-if="minimized && tabs.length"
class="global-browser-mini-float"
:style="miniPanelStyle"
@mousedown="onMiniFloatMouseDown"
>
<v-card border rounded="lg" class="global-browser-mini-panel h-100 d-flex flex-column">
<div
class="global-browser-mini-toolbar d-flex align-center px-2 flex-grow-1 global-browser-toolbar--draggable"
@dblclick.stop="openPanel"
>
<div
class="global-browser-drag-strip global-browser-drag-strip--mini flex-shrink-0"
title="拖动"
@mousedown.stop="onMiniDragStart"
>
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
</div>
<v-icon icon="mdi-web" size="18" class="mr-2 flex-shrink-0" />
<span
class="text-caption text-truncate flex-grow-1 min-w-0 global-browser-mini-title"
@click="onMiniTitleClick"
>
{{ activeTab ? tabLabel(activeTab) : '浏览器' }}
</span>
<v-chip v-if="tabs.length > 1" size="x-small" class="mx-1 flex-shrink-0">{{ tabs.length }}</v-chip>
<v-btn
size="x-small"
variant="text"
icon="mdi-restart"
title="重启浏览器"
@mousedown.stop
@click.stop="onRestart"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-dock-window"
title="展开"
@mousedown.stop
@click.stop="openPanel"
/>
</div>
</v-card>
</div>
<v-btn
v-if="showLauncher"
class="global-browser-launcher"
color="primary"
icon="mdi-web"
size="large"
elevation="4"
title="展开浏览器"
@click="onLauncherClick"
/>
</Teleport>
<v-dialog v-model="showRestartConfirm" max-width="400">
<v-card border>
<v-card-title>重启浏览器</v-card-title>
<v-card-text>将关闭所有标签并打开空白页浏览历史记录会保留</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useFloatingPanel, type FloatRect } from '@/composables/useFloatingPanel'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
defineProps<{ showLauncher: boolean }>()
const showHistory = ref(false)
const showRestartConfirm = ref(false)
const maximized = ref(false)
const {
panelOpen,
minimized,
lastError,
addressDraft,
visits,
tabs,
activeTabId,
activeTab,
panelRect,
minimizedRect,
canGoBack,
canGoForward,
tabLabel,
openPanel,
minimizePanel,
restartBrowser,
navigate,
refreshActive,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
bootstrap,
} = useGlobalBrowser()
function defaultMiniRect(): FloatRect {
const w = 320
const h = 52
return {
x: Math.max(16, window.innerWidth - w - 16),
y: Math.max(16, window.innerHeight - h - 16),
w,
h,
}
}
const panelFallback: FloatRect = { x: 80, y: 72, w: 980, h: 640 }
const { rect, panelStyle, startDrag, startResize, centerOnScreen } = useFloatingPanel(
'nexus_global_browser_rect_v1',
panelFallback,
)
const {
rect: miniRect,
panelStyle: miniPanelStyle,
startDrag: startMiniDrag,
} = useFloatingPanel('nexus_global_browser_mini_rect_v1', defaultMiniRect(), {
minW: 200,
minH: 44,
maxW: 520,
maxH: 56,
})
let miniDragMoved = false
let suppressMiniTitleClick = false
function seedRectFromServer() {
if (panelRect.value) {
rect.value = { ...panelRect.value }
} else {
centerOnScreen()
}
if (minimizedRect.value) {
miniRect.value = { ...minimizedRect.value }
} else {
miniRect.value = defaultMiniRect()
}
}
onMounted(async () => {
await bootstrap()
seedRectFromServer()
})
watch(rect, (value) => {
savePanelRect({ ...value })
}, { deep: true })
watch(miniRect, (value) => {
saveMinimizedRect({ ...value })
}, { deep: true })
function isDragBlockedTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return true
return Boolean(target.closest('button, .v-tab, .v-btn, input, a, .v-field, .v-chip'))
}
function onToolbarMouseDown(e: MouseEvent) {
if (maximized.value) return
if (isDragBlockedTarget(e.target)) return
e.preventDefault()
startDrag(e)
}
function onMiniDragStart(e: MouseEvent) {
if (e.button !== 0) return
e.preventDefault()
miniDragMoved = false
const originX = e.clientX
const originY = e.clientY
const onMove = (ev: MouseEvent) => {
if (Math.abs(ev.clientX - originX) + Math.abs(ev.clientY - originY) > 4) {
miniDragMoved = true
}
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
suppressMiniTitleClick = miniDragMoved
if (miniDragMoved) {
window.setTimeout(() => {
suppressMiniTitleClick = false
}, 0)
}
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
startMiniDrag(e)
}
function onMiniFloatMouseDown(e: MouseEvent) {
if (e.button !== 0) return
if (isDragBlockedTarget(e.target)) return
if ((e.target as HTMLElement).closest('.global-browser-drag-strip')) return
e.preventDefault()
onMiniDragStart(e)
}
function onResizeStart(e: MouseEvent) {
startResize(e)
}
function onTabSelected(id: unknown) {
if (typeof id === 'string') switchTab(id)
}
function onGo() {
navigate(addressDraft.value)
}
function onLauncherClick() {
if (tabs.value.length) {
openPanel()
return
}
newEmptyTab()
}
function onMiniTitleClick() {
if (suppressMiniTitleClick) return
openPanel()
}
function onRestart() {
showRestartConfirm.value = true
}
function confirmRestart() {
showRestartConfirm.value = false
restartBrowser()
}
</script>
<style scoped>
.global-browser-float,
.global-browser-mini-float {
position: fixed;
z-index: 2350;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
touch-action: none;
}
.global-browser-float--maximized {
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
left: 0 !important;
top: 0 !important;
}
.global-browser-panel,
.global-browser-mini-panel {
overflow: hidden;
position: relative;
height: 100%;
background: rgb(var(--v-theme-surface));
}
.global-browser-toolbar,
.global-browser-mini-toolbar {
height: 40px;
min-height: 40px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-mini-toolbar {
height: 100%;
min-height: 0;
border-bottom: none;
}
.global-browser-toolbar--draggable {
cursor: default;
user-select: none;
}
.global-browser-drag-strip {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 40px;
margin-left: 4px;
cursor: move;
touch-action: none;
}
.global-browser-drag-strip--mini {
width: 24px;
height: 100%;
margin-left: 0;
margin-right: 4px;
}
.global-browser-mini-title {
cursor: pointer;
}
.global-browser-nav {
background: rgb(var(--v-theme-surface));
}
.global-browser-url {
flex: 1 1 auto;
min-width: 100px;
}
.global-browser-main {
min-height: 0;
}
.global-browser-history {
width: 280px;
max-width: 40%;
background: rgb(var(--v-theme-surface));
}
.global-browser-history-list {
max-height: 100%;
overflow-y: auto;
}
.global-browser-frame {
width: 100%;
min-height: 0;
border: 0;
background: rgb(var(--v-theme-surface));
}
.global-browser-hint {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-resize {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
z-index: 3;
background: linear-gradient(
135deg,
transparent 50%,
rgba(var(--v-theme-on-surface), 0.25) 50%
);
}
.global-browser-launcher {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 2340;
}
</style>
+24 -3
View File
@@ -8,17 +8,38 @@ const p = useFilesPageContext()
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4">
<v-row align="center" dense>
<v-col cols="12" sm="4">
<v-select
<v-autocomplete
v-model="p.selectedServer"
:items="p.serverList"
v-model:search="p.serverSearchQuery"
:items="p.filteredServerList"
:loading="p.serversLoading"
item-title="name"
item-value="id"
label="选择服务器"
placeholder="搜索名称 / 域名 / 路径 / ID"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-server"
:custom-filter="() => true"
@update:model-value="p.onServerChange"
/>
>
<template #item="{ props: itemProps, item }">
<v-list-item
v-bind="itemProps"
:subtitle="item.domain || item.target_path"
>
<template #prepend>
<v-icon
:color="item.is_online ? 'success' : 'grey'"
size="8"
class="mr-2"
>
mdi-circle
</v-icon>
</template>
</v-list-item>
</template>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
@@ -63,6 +63,12 @@
density="comfortable"
@update:page="$emit('update:page', $event); $emit('page-change')"
>
<template #item.server_name="{ item }">
<span
class="text-body-2 push-history-server-name"
:title="item.server_name ?? ''"
>{{ item.server_name }}</span>
</template>
<template #item.status="{ item }">
<v-chip :color="logStatusColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ logStatusLabel(item.status) }}
@@ -145,3 +151,14 @@ defineEmits<{
diagnose: [item: PushItem]
}>()
</script>
<style scoped>
.push-history-server-name {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
@@ -39,7 +39,9 @@
</thead>
<tbody>
<tr v-for="row in results" :key="row.server_id">
<td>{{ row.server_name }}</td>
<td>
<span class="push-preview-server-name" :title="row.server_name">{{ row.server_name }}</span>
</td>
<td>{{ row.stats?.files_total ?? '—' }}</td>
<td>{{ row.stats?.files_transferred ?? '—' }}</td>
<td>{{ row.stats ? formatSize(row.stats.transfer_size_bytes ?? 0) : '—' }}</td>
@@ -89,3 +91,14 @@ defineEmits<{
'confirm-push': []
}>()
</script>
<style scoped>
.push-preview-server-name {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
@@ -47,7 +47,10 @@
</span>
</div>
<v-list density="compact">
<v-list-item v-for="s in items" :key="s.id" :title="s.name">
<v-list-item v-for="s in items" :key="s.id">
<template #title>
<span class="push-progress-name" :title="s.name">{{ s.name }}</span>
</template>
<template #append>
<v-icon v-if="s.status === 'pending'" color="grey">mdi-clock-outline</v-icon>
<v-icon v-else-if="s.status === 'running'" color="blue" class="mdi-spin">mdi-loading</v-icon>
@@ -85,3 +88,12 @@ defineEmits<{
'retry-all': []
}>()
</script>
<style scoped>
.push-progress-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+159 -13
View File
@@ -1,24 +1,29 @@
<template>
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center">
<v-card-title class="d-flex align-center flex-wrap ga-2">
<v-checkbox
:model-value="selectAll"
label="全选"
density="compact"
hide-details
class="mr-4"
class="mr-2"
@update:model-value="$emit('toggle-all')"
/>
<v-text-field
<v-combobox
:model-value="serverSearch"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索服务器..."
density="compact"
hide-details
variant="outlined"
rounded
clearable
:auto-select-first="false"
placeholder="输入后回车,或从历史选择"
style="max-width: 240px"
@update:model-value="$emit('update:serverSearch', $event)"
@update:model-value="onSearchUpdate"
@keydown.enter.prevent="$emit('search-commit')"
/>
<v-btn
icon="mdi-refresh"
@@ -28,6 +33,18 @@
title="刷新在线状态"
@click="$emit('refresh')"
/>
<v-spacer />
<v-btn-toggle
v-model="viewMode"
mandatory
density="compact"
color="primary"
variant="outlined"
divided
>
<v-btn value="grid" size="small" icon="mdi-view-grid" title="卡片视图" />
<v-btn value="list" size="small" icon="mdi-view-list" title="列表视图" />
</v-btn-toggle>
</v-card-title>
<v-divider />
<v-card-text class="pa-2">
@@ -52,22 +69,30 @@
({{ group.servers.filter(s => selectedIds.has(s.id)).length }}/{{ group.servers.length }})
</span>
</div>
<v-row dense>
<!-- 卡片视图 -->
<v-row v-if="viewMode === 'grid'" dense>
<v-col v-for="s in group.servers" :key="s.id" cols="6" sm="4" md="3" lg="2">
<v-card
:color="selectedIds.has(s.id) ? 'primary' : undefined"
:variant="selectedIds.has(s.id) ? 'tonal' : 'outlined'"
rounded="lg"
class="pa-3 cursor-pointer"
class="pa-3 cursor-pointer push-server-card"
@click="$emit('toggle-server', s.id)"
>
<div class="d-flex align-center ga-2">
<v-icon :color="serverOnlineColor(s.is_online)" size="12">mdi-circle</v-icon>
<span class="text-body-2 font-weight-medium text-truncate">{{ s.name }}</span>
<div class="d-flex align-center ga-2 min-w-0">
<v-icon :color="serverOnlineColor(s.is_online)" size="12" class="flex-shrink-0">mdi-circle</v-icon>
<span
class="text-body-2 font-weight-medium push-server-card__name"
:title="serverLabelTitle(s)"
>{{ s.name }}</span>
</div>
<div class="text-caption text-medium-emphasis text-truncate">{{ s.domain }}</div>
<div
class="text-caption text-medium-emphasis text-truncate"
class="text-caption text-medium-emphasis push-server-card__line"
:title="s.domain || ''"
>{{ s.domain }}</div>
<div
class="text-caption text-medium-emphasis push-server-card__line"
:title="s.target_path || '未配置目标路径'"
>
{{ s.target_path?.trim() || '未配置' }}
@@ -86,6 +111,63 @@
</v-card>
</v-col>
</v-row>
<!-- 列表视图 -->
<v-table v-else density="compact" class="push-server-list rounded-lg border">
<thead>
<tr>
<th class="push-server-list__col-check" />
<th class="push-server-list__col-online" />
<th>名称</th>
<th>域名</th>
<th>目标路径</th>
<th class="push-server-list__col-status">状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in group.servers"
:key="s.id"
class="push-server-list__row cursor-pointer"
:class="{ 'push-server-list__row--selected': selectedIds.has(s.id) }"
@click="$emit('toggle-server', s.id)"
>
<td @click.stop>
<v-checkbox
:model-value="selectedIds.has(s.id)"
density="compact"
hide-details
@update:model-value="$emit('toggle-server', s.id)"
/>
</td>
<td>
<v-icon :color="serverOnlineColor(s.is_online)" size="12">mdi-circle</v-icon>
</td>
<td class="push-server-list__name-cell">
<span :title="s.name">{{ s.name }}</span>
</td>
<td class="text-caption text-medium-emphasis push-server-list__ellipsis" :title="s.domain || ''">
{{ s.domain || '—' }}
</td>
<td class="text-caption text-medium-emphasis push-server-list__ellipsis" :title="s.target_path || '未配置'">
{{ s.target_path?.trim() || '未配置' }}
</td>
<td>
<v-chip
v-if="pushStatus[s.id]"
:color="pushStatusChipColor(pushStatus[s.id])"
size="x-small"
variant="tonal"
label
border="sm"
>
{{ pushStatus[s.id] }}
</v-chip>
<span v-else class="text-caption text-medium-emphasis"></span>
</td>
</tr>
</tbody>
</v-table>
</div>
</template>
</v-card-text>
@@ -94,11 +176,13 @@
<script setup lang="ts">
import { pushStatusChipColor, serverOnlineColor } from '@/composables/push/labels'
import { usePushServerViewMode } from '@/composables/push/usePushServerViewMode'
import type { PushServerItem } from '@/composables/push/types'
defineProps<{
selectAll: boolean
serverSearch: string
searchHistory: string[]
serversLoading: boolean
serversByCategory: { category: string; label: string; servers: PushServerItem[] }[]
selectedIds: Set<number>
@@ -106,11 +190,73 @@ defineProps<{
isCategoryAllSelected: (category: string) => boolean
}>()
defineEmits<{
'update:serverSearch': [value: string]
const emit = defineEmits<{
'update:serverSearch': [value: string | null]
'search-commit': []
'toggle-all': []
refresh: []
'toggle-category': [category: string]
'toggle-server': [id: number]
}>()
const { viewMode } = usePushServerViewMode()
function onSearchUpdate(val: string | null) {
emit('update:serverSearch', val)
}
function serverLabelTitle(s: PushServerItem): string {
const parts = [s.name]
if (s.domain?.trim()) parts.push(s.domain.trim())
return parts.join('\n')
}
</script>
<style scoped>
.push-server-card__name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-word;
min-width: 0;
flex: 1 1 auto;
line-height: 1.25;
}
.push-server-card__line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.push-server-list__col-check {
width: 44px;
}
.push-server-list__col-online {
width: 28px;
}
.push-server-list__col-status {
width: 96px;
}
.push-server-list__row--selected {
background: rgba(var(--v-theme-primary), 0.08);
}
.push-server-list__name-cell {
max-width: 0;
width: 32%;
word-break: break-word;
}
.push-server-list__ellipsis {
max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.push-server-list :deep(th:nth-child(4)),
.push-server-list :deep(td:nth-child(4)) {
width: 24%;
}
.push-server-list :deep(th:nth-child(5)),
.push-server-list :deep(td:nth-child(5)) {
width: 28%;
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { ServerAgentAction } from '@/types/api'
const props = defineProps<{
action?: ServerAgentAction | null
message?: string | null
compact?: boolean
loading?: boolean
}>()
defineEmits<{
install: []
upgrade: []
diagnose: []
}>()
function alertType(action: ServerAgentAction | null | undefined): 'info' | 'warning' | 'error' {
if (action === 'install') return 'info'
if (action === 'upgrade') return 'warning'
if (action === 'offline') return 'error'
return 'info'
}
</script>
<template>
<v-alert
v-if="props.action && props.action !== 'ok' && props.message"
:type="alertType(props.action)"
variant="tonal"
:density="props.compact ? 'compact' : 'default'"
class="mb-0"
>
<div class="d-flex flex-wrap align-center ga-2">
<span class="text-body-2">{{ props.message }}</span>
<v-spacer v-if="!props.compact" />
<div class="d-flex ga-1 flex-shrink-0">
<v-btn
v-if="props.action === 'install'"
size="small"
variant="flat"
color="primary"
:loading="props.loading"
@click="$emit('install')"
>
安装 Agent
</v-btn>
<v-btn
v-if="props.action === 'upgrade'"
size="small"
variant="flat"
color="warning"
:loading="props.loading"
@click="$emit('upgrade')"
>
升级 Agent
</v-btn>
<v-btn
v-if="props.action === 'offline'"
size="small"
variant="text"
@click="$emit('diagnose')"
>
诊断
</v-btn>
</div>
</div>
</v-alert>
</template>
@@ -1,5 +1,15 @@
<template>
<div class="server-inline-detail pa-3 rounded-b-lg">
<ServerAgentHint
class="mb-3"
:action="displayServer.agent_action"
:message="displayServer.agent_action_message"
:loading="props.agentActionLoading"
@install="$emit('install-agent')"
@upgrade="$emit('upgrade-agent')"
@diagnose="$emit('diagnose-agent')"
/>
<div class="d-flex flex-wrap align-center ga-2 mb-3">
<v-chip
:color="statusChipColor(displayServer.status)"
@@ -111,14 +121,19 @@ import type {
} from '@/types/api'
import { formatDateTimeBeijing } from '@/utils/datetime'
import ServerMetricSparklines from '@/components/servers/ServerMetricSparklines.vue'
import ServerAgentHint from '@/components/servers/ServerAgentHint.vue'
const props = defineProps<{
server: ServerApiItem
active?: boolean
agentActionLoading?: boolean
}>()
defineEmits<{
'edit-path': []
'install-agent': []
'upgrade-agent': []
'diagnose-agent': []
}>()
const detail = ref<ServerApiItem | null>(null)
@@ -1,82 +1,79 @@
<template>
<v-card v-if="mode === 'inline'" border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
<v-card-text class="pa-3">
<v-text-field
:model-value="search"
prepend-inner-icon="mdi-magnify"
placeholder="搜索服务器…"
variant="outlined"
density="compact"
hide-details
class="mb-2"
@update:model-value="$emit('update:search', $event)"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" class="empty-server-picker__list" nav>
<v-list-item
v-for="s in servers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
</v-list-item>
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
暂无匹配的服务器
</v-list-item>
</v-list>
</v-card-text>
</v-card>
<template v-else>
<v-text-field
:model-value="search"
prepend-inner-icon="mdi-magnify"
placeholder="搜索…"
variant="outlined"
density="compact"
class="mb-3"
@update:model-value="$emit('update:search', $event)"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" max-height="300" style="overflow-y: auto">
<aside v-if="mode === 'sidebar'" class="terminal-server-rail d-flex flex-column h-100">
<div class="terminal-server-rail__head px-3 py-2 text-subtitle-2 font-weight-medium border-b">
服务器
<span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span>
</div>
<v-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-2" />
<v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0">
<v-list-item
v-for="s in servers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg"
class="mb-1"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
<v-list-item-title class="text-body-2 text-truncate" :title="s.name">{{ s.name }}</v-list-item-title>
<v-list-item-subtitle class="text-truncate" :title="s.domain">{{ s.domain }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
暂无匹配的服务器
</v-list-item>
</v-list>
</aside>
<v-card v-else-if="mode === 'inline'" border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
<v-card-text class="pa-3">
<PickerSearch
:search="search"
:search-history="searchHistory"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" class="empty-server-picker__list" nav>
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
</v-card-text>
</v-card>
<template v-else>
<PickerSearch
:search="search"
:search-history="searchHistory"
class="mb-3"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" max-height="300" style="overflow-y: auto">
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
</template>
</template>
<script setup lang="ts">
import PickerListItems from '@/components/terminal/TerminalServerPickerList.vue'
import PickerSearch from '@/components/terminal/TerminalServerPickerSearch.vue'
import type { TerminalServerItem } from '@/composables/terminal/types'
withDefaults(
defineProps<{
servers: TerminalServerItem[]
search: string
search?: string
searchHistory?: string[]
loading?: boolean
mode?: 'inline' | 'dialog'
mode?: 'inline' | 'dialog' | 'sidebar'
}>(),
{ mode: 'inline', loading: false },
{ mode: 'inline', loading: false, search: '', searchHistory: () => [] },
)
defineEmits<{
'update:search': [value: string]
'update:search': [value: string | null]
'search-commit': []
select: [serverId: number]
}>()
</script>
@@ -86,4 +83,15 @@ defineEmits<{
max-height: min(42vh, 320px);
overflow-y: auto;
}
.terminal-server-rail {
width: 300px;
flex-shrink: 0;
min-height: 0;
background: rgb(var(--v-theme-surface));
border-left: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.terminal-server-rail__list {
overflow-y: auto;
min-height: 0;
}
</style>
@@ -0,0 +1,29 @@
<template>
<v-list-item
v-for="s in servers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
</v-list-item>
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
暂无匹配的服务器
</v-list-item>
</template>
<script setup lang="ts">
import type { TerminalServerItem } from '@/composables/terminal/types'
defineProps<{
servers: TerminalServerItem[]
}>()
defineEmits<{
select: [serverId: number]
}>()
</script>
@@ -0,0 +1,27 @@
<template>
<v-combobox
:model-value="search"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
placeholder="搜索名称/域名…"
variant="outlined"
density="compact"
hide-details
clearable
:auto-select-first="false"
@update:model-value="$emit('update:search', $event)"
@keydown.enter.prevent="$emit('search-commit')"
/>
</template>
<script setup lang="ts">
defineProps<{
search: string
searchHistory: string[]
}>()
defineEmits<{
'update:search': [value: string | null]
'search-commit': []
}>()
</script>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { http } from '@/api'
import { formatBytesPerSec, probeStatusColor, probeStatusLabel, formatProbeError, probeSourceLabel, PROBE_STATUS_FILTER_ITEMS } from '@/utils/watchFormat'
export interface ProbeRecordRow {
id: number
recorded_at?: string
server_id?: number
server_name?: string
source?: string
probe_status?: string
duration_ms?: number
cpu_pct?: number | null
mem_pct?: number | null
disk_pct?: number | null
net_up_bps?: number | null
net_down_bps?: number | null
error?: string | null
}
const props = defineProps<{
serverId?: number | null
sessionId?: number | null
hours?: number
}>()
const items = ref<ProbeRecordRow[]>([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const statusFilter = ref<string | null>(null)
const headers = [
{ title: '时间', key: 'recorded_at', width: 160 },
{ title: '服务器', key: 'server_name' },
{ title: '状态', key: 'probe_status', width: 100 },
{ title: '来源', key: 'source', width: 72 },
{ title: 'CPU', key: 'cpu_pct', width: 64 },
{ title: 'MEM', key: 'mem_pct', width: 64 },
{ title: 'DISK', key: 'disk_pct', width: 64 },
{ title: '↑', key: 'net_up_bps', width: 88 },
{ title: '↓', key: 'net_down_bps', width: 88 },
{ title: '耗时', key: 'duration_ms', width: 72 },
{ title: '错误', key: 'error' },
]
async function load() {
loading.value = true
try {
const data = await http.get<{ items: ProbeRecordRow[]; total: number }>('/watch/probe-records', {
server_id: props.serverId ?? undefined,
session_id: props.sessionId ?? undefined,
probe_status: statusFilter.value ?? undefined,
hours: props.hours ?? 24,
page: page.value,
per_page: 50,
})
items.value = data.items || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
watch(
() => [props.serverId, props.sessionId, props.hours, page.value, statusFilter.value] as const,
() => { load() },
{ immediate: true },
)
</script>
<template>
<div>
<div class="d-flex align-center ga-2 mb-3">
<v-select
v-model="statusFilter"
:items="PROBE_STATUS_FILTER_ITEMS"
item-title="title"
item-value="value"
label="探针状态"
density="compact"
hide-details
variant="outlined"
style="max-width: 180px"
@update:model-value="page = 1"
/>
<v-spacer />
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="load">
刷新
</v-btn>
</div>
<v-data-table
:headers="headers"
:items="items"
:loading="loading"
density="compact"
item-value="id"
hide-default-footer
>
<template #item.probe_status="{ item }">
<v-chip size="x-small" :color="probeStatusColor(item.probe_status)" variant="tonal" label>
{{ probeStatusLabel(item.probe_status) }}
</v-chip>
</template>
<template #item.source="{ item }">{{ probeSourceLabel(item.source) }}</template>
<template #item.net_up_bps="{ item }">{{ formatBytesPerSec(item.net_up_bps) }}</template>
<template #item.net_down_bps="{ item }">{{ formatBytesPerSec(item.net_down_bps) }}</template>
<template #item.duration_ms="{ item }">{{ item.duration_ms ?? '—' }} ms</template>
<template #item.error="{ item }">
<span
class="text-caption text-truncate d-inline-block"
style="max-width: 200px"
:title="formatProbeError(item.error, item.probe_status)"
>{{ formatProbeError(item.error, item.probe_status) }}</span>
</template>
</v-data-table>
<div class="d-flex justify-center align-center ga-2 mt-3">
<v-btn size="small" variant="text" :disabled="page <= 1" @click="page -= 1">上一页</v-btn>
<span class="text-caption"> {{ page }} · {{ total }} </span>
<v-btn size="small" variant="text" :disabled="page * 50 >= total" @click="page += 1">下一页</v-btn>
</div>
</div>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { http } from '@/api'
import type { WatchSlot, WatchProcess } from '@/composables/useWatchPins'
const props = defineProps<{
modelValue: boolean
slotData: WatchSlot | null
}>()
const emit = defineEmits<{ 'update:modelValue': [v: boolean] }>()
const processes = ref<WatchProcess[]>([])
const loading = ref(false)
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
async function loadProcesses() {
if (!props.slotData?.server_id) return
loading.value = true
try {
const data = await http.get<{ processes: WatchProcess[] }>(
`/watch/processes/${props.slotData.server_id}`,
)
processes.value = data.processes || props.slotData.processes || []
} catch {
processes.value = props.slotData.processes || []
} finally {
loading.value = false
}
}
watch(
() => [open.value, props.slotData?.server_id] as const,
([isOpen]) => {
if (isOpen) loadProcesses()
},
)
</script>
<template>
<v-navigation-drawer
v-model="open"
location="right"
temporary
width="360"
>
<v-toolbar density="compact" flat>
<v-toolbar-title class="text-subtitle-1">
进程 Top5 · {{ slotData?.server_name }}
</v-toolbar-title>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="open = false" />
</v-toolbar>
<v-divider />
<v-progress-linear v-if="loading" indeterminate />
<v-list v-if="processes.length" density="compact">
<v-list-item v-for="(p, i) in processes" :key="`${p.pid}-${i}`">
<v-list-item-title class="text-body-2">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>PID {{ p.pid ?? '—' }} · CPU {{ p.cpu_pct ?? '—' }}% · MEM {{ p.mem_pct ?? '—' }}%</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="pa-4 text-medium-emphasis text-caption">
暂无进程数据 10 秒刷新一次
</div>
</v-navigation-drawer>
</template>
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WatchSlot } from '@/composables/useWatchPins'
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError } from '@/utils/watchFormat'
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
const props = defineProps<{
slot: WatchSlot
psutilInstallLoading?: boolean
}>()
const emit = defineEmits<{
remove: [slotIndex: number]
processes: [slot: WatchSlot]
history: [slot: WatchSlot]
pick: [slotIndex: number]
monitoring: [slotIndex: number, enabled: boolean]
ttl: [slotIndex: number, hours: 8 | 24]
installPsutil: [slotIndex: number]
}>()
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24)
const showPsutilInstall = computed(() =>
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
)
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
function pct(v: number | null | undefined) {
return v != null ? `${v}%` : '—'
}
</script>
<template>
<v-card
v-if="!slot.empty"
elevation="0"
border
rounded="lg"
class="watch-slot-card pa-3 watch-slot-card--filled"
:class="{
'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok',
'watch-slot-card--paused': !monitoringOn,
}"
role="button"
tabindex="0"
@click="emit('history', slot)"
@keydown.enter.prevent="emit('history', slot)"
>
<div class="d-flex align-center mb-2" @click.stop>
<div class="text-subtitle-2 text-truncate flex-grow-1" :title="slot.server_name">
{{ slot.server_name || `ID ${slot.server_id}` }}
</div>
<v-chip size="x-small" variant="tonal" color="info" label>
{{ slot.slot_index + 1 }}
</v-chip>
<v-switch
:model-value="monitoringOn"
density="compact"
hide-details
color="primary"
class="watch-slot-switch ml-1"
@click.stop
@update:model-value="emit('monitoring', slot.slot_index, $event ?? false)"
/>
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
</div>
<div class="d-flex align-center mb-2 ga-1" @click.stop>
<span class="text-caption text-medium-emphasis mr-1">时长</span>
<v-btn-toggle
:model-value="slotTtl"
density="compact"
variant="outlined"
divided
mandatory
@update:model-value="emit('ttl', slot.slot_index, ($event ?? 8) as 8 | 24)"
>
<v-btn :value="8" size="x-small">8h</v-btn>
<v-btn :value="24" size="x-small">24h</v-btn>
</v-btn-toggle>
</div>
<div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop>
实时监测已关闭 · 槽位保留 · Agent 恢复 60s 智能上报
</div>
<template v-if="monitoringOn">
<div @click.stop>
<v-chip
v-if="slot.metrics?.probe_status"
size="x-small"
:color="probeStatusColor(slot.metrics.probe_status)"
variant="tonal"
class="mb-2"
label
>
{{ probeStatusLabel(slot.metrics.probe_status) }}
</v-chip>
<div class="d-flex justify-space-between text-caption mb-1">
<span>CPU {{ pct(slot.metrics?.cpu_pct) }}</span>
<span>MEM {{ pct(slot.metrics?.mem_pct) }}</span>
<span>DISK {{ pct(slot.metrics?.disk_pct) }}</span>
</div>
<div class="text-caption text-medium-emphasis mb-1">
{{ formatBytesPerSec(slot.metrics?.net_up_bps) }}
· {{ formatBytesPerSec(slot.metrics?.net_down_bps) }}
</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ formatBytesPerSec(slot.metrics?.disk_read_bps) }}
· {{ formatBytesPerSec(slot.metrics?.disk_write_bps) }}
</div>
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1">
<v-btn value="cpu_pct" size="x-small">CPU</v-btn>
<v-btn value="mem_pct" size="x-small">MEM</v-btn>
<v-btn value="disk_pct" size="x-small">DISK</v-btn>
</v-btn-toggle>
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
<div
v-if="slot.metrics?.error"
class="text-caption text-error mt-1"
:title="formatProbeError(slot.metrics.error, slot.metrics.probe_status)"
>
<div class="text-truncate">
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
</div>
<v-btn
v-if="showPsutilInstall"
size="x-small"
variant="tonal"
color="primary"
class="mt-1"
:loading="props.psutilInstallLoading"
@click.stop="emit('installPsutil', slot.slot_index)"
>
安装 psutilSSH
</v-btn>
</div>
<div class="d-flex align-center justify-space-between mt-2">
<span class="text-caption text-medium-emphasis">
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ slotTtl }}h
</span>
<div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
</div>
</template>
<div v-else class="d-flex align-center justify-space-between mt-2" @click.stop>
<span class="text-caption text-medium-emphasis">已暂停 · {{ slotTtl }}h</span>
<div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
</v-card>
<v-card
v-else
elevation="0"
border
rounded="lg"
class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4"
variant="outlined"
role="button"
tabindex="0"
@click="emit('pick', slot.slot_index)"
@keydown.enter.prevent="emit('pick', slot.slot_index)"
>
<div class="text-center text-medium-emphasis">
<v-icon size="28" class="mb-1">mdi-plus-circle-outline</v-icon>
<div class="text-caption"> {{ slot.slot_index + 1 }} · 添加监测</div>
</div>
</v-card>
</template>
<style scoped>
.watch-slot-card {
min-height: 220px;
}
.watch-slot-card--empty {
min-height: 220px;
border-style: dashed !important;
cursor: pointer;
transition: background-color 0.15s ease;
}
.watch-slot-card--empty:hover {
background: rgba(var(--v-theme-primary), 0.04);
}
.watch-slot-card--filled {
cursor: pointer;
}
.watch-slot-card--filled:hover {
background: rgba(var(--v-theme-surface-variant), 0.3);
}
.watch-slot-card--error {
border-color: rgb(var(--v-theme-error)) !important;
}
.watch-slot-card--paused {
opacity: 0.72;
background: rgba(var(--v-theme-surface-variant), 0.15);
}
.watch-slot-switch {
flex: 0 0 auto;
transform: scale(0.85);
}
</style>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { useSnackbar } from '@/composables/useSnackbar'
import type { ServerApiItem } from '@/types/api'
const open = defineModel<boolean>({ required: true })
const snackbar = useSnackbar()
const props = defineProps<{
slotIndex: number | null
loading?: boolean
}>()
const emit = defineEmits<{
pick: [serverId: number]
}>()
const search = ref('')
const listLoading = ref(false)
const servers = ref<ServerApiItem[]>([])
const selectedId = ref<number | null>(null)
const filteredServers = computed(() => {
const q = search.value.toLowerCase().trim()
if (!q) return servers.value
return servers.value.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
(s.domain || '').toLowerCase().includes(q) ||
(s.target_path || '').toLowerCase().includes(q) ||
String(s.id).includes(q),
)
})
watch(open, async (visible) => {
if (!visible) {
search.value = ''
selectedId.value = null
return
}
listLoading.value = true
try {
const res = await fetchPagePerPage<ServerApiItem>('/servers/', {}, 1, -1)
servers.value = res.items
} catch {
servers.value = []
snackbar('加载服务器列表失败', 'warning')
} finally {
listLoading.value = false
}
})
function confirmPick() {
if (selectedId.value == null) return
emit('pick', selectedId.value)
}
</script>
<template>
<v-dialog v-model="open" max-width="480" @keydown.enter.prevent="confirmPick">
<v-card rounded="lg">
<v-card-title>
加入监测
<span v-if="props.slotIndex != null" class="text-caption text-medium-emphasis ml-2">
{{ props.slotIndex + 1 }}
</span>
</v-card-title>
<v-card-text>
<v-autocomplete
v-model="selectedId"
v-model:search="search"
:items="filteredServers"
:loading="listLoading"
item-title="name"
item-value="id"
label="搜索服务器"
placeholder="名称 / 域名 / 路径 / ID"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
autofocus
:custom-filter="() => true"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :subtitle="item.domain">
<template #prepend>
<v-icon :color="item.is_online ? 'success' : 'grey'" size="8" class="mr-2">
mdi-circle
</v-icon>
</template>
</v-list-item>
</template>
</v-autocomplete>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="open = false">取消</v-btn>
<v-btn
color="primary"
variant="flat"
:disabled="selectedId == null"
:loading="loading"
@click="confirmPick"
>
加入
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import WatchSlotCard from '@/components/watch/WatchSlotCard.vue'
import WatchProcessDrawer from '@/components/watch/WatchProcessDrawer.vue'
import WatchSlotPickDialog from '@/components/watch/WatchSlotPickDialog.vue'
import { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { http } from '@/api'
const router = useRouter()
const snackbar = useSnackbar()
const { slots, loading, refreshPins, removeSlot, pinServer, setSlotMonitoring, setSlotTtl } = useWatchPins()
const processDrawer = ref(false)
const processSlot = ref<WatchSlot | null>(null)
const showPickDialog = ref(false)
const pickSlotIndex = ref<number | null>(null)
const pickLoading = ref(false)
const psutilLoadingSlot = ref<number | null>(null)
onMounted(() => {
refreshPins()
})
async function onRemove(slotIndex: number) {
try {
await removeSlot(slotIndex)
snackbar(`已移除监测 · 槽 ${slotIndex + 1}`, 'success')
} catch (e: unknown) {
snackbar(formatApiError(e, '移除监测失败'), 'error')
}
}
async function onMonitoring(slotIndex: number, enabled: boolean) {
try {
await setSlotMonitoring(slotIndex, enabled)
snackbar(enabled ? `已开启实时监测 · 槽 ${slotIndex + 1}` : `已暂停实时监测 · 槽 ${slotIndex + 1}`, 'success')
} catch (e: unknown) {
snackbar(formatApiError(e, '更新监测开关失败'), 'error')
}
}
async function onTtl(slotIndex: number, hours: 8 | 24) {
const slot = slots.value.find((s) => s.slot_index === slotIndex)
if (!slot || slot.empty || slot.ttl_hours === hours) return
try {
await setSlotTtl(slotIndex, hours)
snackbar(`监测时长已设为 ${hours} 小时 · 槽 ${slotIndex + 1}`, 'success')
} catch (e: unknown) {
snackbar(formatApiError(e, '更新监测时长失败'), 'error')
}
}
function openProcesses(slot: WatchSlot) {
processSlot.value = slot
processDrawer.value = true
}
function openHistory(slot: WatchSlot) {
router.push({
name: 'WatchMetrics',
query: {
server_id: String(slot.server_id),
session_id: slot.session_id ? String(slot.session_id) : undefined,
},
})
}
function onPickSlot(slotIndex: number) {
pickSlotIndex.value = slotIndex
showPickDialog.value = true
}
async function onInstallPsutil(slotIndex: number) {
const slot = slots.value.find((s) => s.slot_index === slotIndex)
if (!slot?.server_id) return
psutilLoadingSlot.value = slotIndex
try {
const data = await http.post<{ message?: string }>(
`/watch/servers/${slot.server_id}/install-psutil`,
{},
)
snackbar(data.message || 'psutil 安装成功', 'success')
await refreshPins()
} catch (e: unknown) {
snackbar(formatApiError(e, '安装 psutil 失败'), 'error')
} finally {
psutilLoadingSlot.value = null
}
}
async function onPickServer(serverId: number) {
if (pickSlotIndex.value == null) return
pickLoading.value = true
try {
const res = await pinServer(serverId, { slotIndex: pickSlotIndex.value })
if (res.ok) {
snackbar(`已加入监测 · 槽 ${pickSlotIndex.value + 1}`, 'success')
showPickDialog.value = false
} else if (res.full) {
snackbar('监测槽已满,请先移除一台', 'warning')
} else {
snackbar(res.error || '加入监测失败', 'error')
}
} finally {
pickLoading.value = false
}
}
</script>
<template>
<div class="watch-slot-row mb-4">
<div class="d-flex align-center mb-2">
<span class="text-subtitle-2">实时监测</span>
<v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 · 默认 8h · 暂停不占 TTL</v-chip>
<v-spacer />
<v-btn size="small" variant="text" prepend-icon="mdi-history" :to="{ name: 'WatchMetrics' }">
监测历史
</v-btn>
<v-btn icon="mdi-refresh" size="small" variant="text" :loading="loading" @click="refreshPins" />
</div>
<v-row dense>
<v-col v-for="slot in slots" :key="slot.slot_index" cols="12" sm="6" lg="3">
<WatchSlotCard
:slot="slot"
:psutil-install-loading="psutilLoadingSlot === slot.slot_index"
@remove="onRemove"
@processes="openProcesses"
@history="openHistory"
@pick="onPickSlot"
@monitoring="onMonitoring"
@ttl="onTtl"
@install-psutil="onInstallPsutil"
/>
</v-col>
</v-row>
<WatchProcessDrawer v-model="processDrawer" :slot-data="processSlot" />
<WatchSlotPickDialog
v-model="showPickDialog"
:slot-index="pickSlotIndex"
:loading="pickLoading"
@pick="onPickServer"
/>
</div>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import type { WatchSparkPoint } from '@/composables/useWatchPins'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent])
const props = defineProps<{
points: WatchSparkPoint[]
metric?: 'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_down_bps'
height?: number
}>()
const metricKey = computed(() => props.metric || 'cpu_pct')
const chartOption = computed(() => {
const key = metricKey.value
const xs = props.points.map((p) => p.ts || '')
const ys = props.points.map((p) => (p[key] ?? null) as number | null)
return {
animation: false,
grid: { left: 4, right: 4, top: 4, bottom: 4 },
xAxis: { type: 'category', show: false, data: xs },
yAxis: { type: 'value', show: false, min: 0 },
tooltip: { trigger: 'axis', confine: true },
series: [{
type: 'line',
data: ys,
showSymbol: false,
smooth: true,
lineStyle: { width: 1.5 },
areaStyle: { opacity: 0.08 },
}],
}
})
</script>
<template>
<v-chart
class="watch-sparkline"
:style="{ height: `${height ?? 48}px` }"
:option="chartOption"
:update-options="{ notMerge: true }"
autoresize
/>
</template>
<style scoped>
.watch-sparkline {
width: 100%;
pointer-events: none;
}
</style>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent, DataZoomComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent, DataZoomComponent])
export interface WatchTrendPoint {
recorded_at?: string
server_id?: number
cpu_pct?: number | null
mem_pct?: number | null
disk_pct?: number | null
net_up_bps?: number | null
net_down_bps?: number | null
disk_read_bps?: number | null
disk_write_bps?: number | null
}
const props = withDefaults(defineProps<{
points: WatchTrendPoint[]
series: Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>
height?: number
}>(), {
height: 360,
})
const labels: Record<string, string> = {
cpu_pct: 'CPU %',
mem_pct: '内存 %',
disk_pct: '磁盘 %',
net_up_bps: '上行 B/s',
net_down_bps: '下行 B/s',
disk_read_bps: '磁盘读 B/s',
disk_write_bps: '磁盘写 B/s',
}
const chartOption = computed(() => {
const xs = props.points.map((p) => p.recorded_at || '')
const series = props.series.map((key) => ({
name: labels[key] || key,
type: 'line',
showSymbol: false,
smooth: true,
data: props.points.map((p) => p[key] ?? null),
}))
return {
tooltip: { trigger: 'axis' },
legend: { type: 'scroll', top: 0 },
grid: { left: 48, right: 16, top: 40, bottom: 56 },
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 8 }],
xAxis: { type: 'category', data: xs, axisLabel: { showMaxLabel: true } },
yAxis: { type: 'value', scale: true },
series,
}
})
</script>
<template>
<v-chart
class="watch-trend-chart"
:style="{ height: `${height}px` }"
:option="chartOption"
:update-options="{ notMerge: true }"
autoresize
/>
</template>
<style scoped>
.watch-trend-chart {
width: 100%;
}
</style>
@@ -1,7 +1,7 @@
import { computed, onMounted, onActivated, watch } from 'vue'
import { computed, onMounted, onActivated, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { useServerList } from '@/composables/useServerList'
import { useServerList, type ServerBrief } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { useFilesStore } from '@/stores/files'
import type { FileEntry } from '@/types/api'
@@ -23,7 +23,28 @@ export function useFilesNavigation(options: {
const snackbar = useSnackbar()
const route = useRoute()
const router = useRouter()
const { servers: serverList, loadServers } = useServerList()
const { servers: serverList, loading: serversLoading, loadServers } = useServerList()
const serverSearchQuery = ref('')
function serverMatchesQuery(s: ServerBrief, q: string): boolean {
return (
s.name.toLowerCase().includes(q) ||
(s.domain || '').toLowerCase().includes(q) ||
(s.target_path || '').toLowerCase().includes(q) ||
String(s.id).includes(q)
)
}
const filteredServerList = computed(() => {
const q = serverSearchQuery.value.toLowerCase().trim()
let list = q ? serverList.value.filter((s) => serverMatchesQuery(s, q)) : serverList.value
const selId = selectedServer.value
if (selId != null && !list.some((s) => s.id === selId)) {
const sel = serverList.value.find((s) => s.id === selId)
if (sel) list = [sel, ...list]
}
return list
})
const store = useFilesStore()
const {
selectedServer,
@@ -120,6 +141,7 @@ export function useFilesNavigation(options: {
function onServerChange() {
if (initing.value) return
serverSearchQuery.value = ''
if (selectedServer.value) {
sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value))
}
@@ -139,7 +161,7 @@ export function useFilesNavigation(options: {
onMounted(async () => {
initing.value = true
try {
await loadServers()
await loadServers({ all: true })
if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) {
accessHintDismissed.value = true
}
@@ -205,6 +227,9 @@ export function useFilesNavigation(options: {
return {
serverList,
filteredServerList,
serverSearchQuery,
serversLoading,
breadcrumbs,
canGoUp,
navigateToPath,
@@ -186,6 +186,9 @@ export function useFilesPage() {
setupSudoLoading,
setupSudoDone,
serverList: nav.serverList,
filteredServerList: nav.filteredServerList,
serverSearchQuery: nav.serverSearchQuery,
serversLoading: nav.serversLoading,
onServerChange: nav.onServerChange,
browseCurrent,
filesAccessHint,
+1 -1
View File
@@ -24,7 +24,7 @@ const LOG_SYNC_MODE_OPTIONS = [
const LOG_HEADERS = [
{ title: '时间', key: 'started_at', width: 170 },
{ title: '服务器', key: 'server_name', width: 120 },
{ title: '服务器', key: 'server_name', width: 200 },
{ title: '状态', key: 'status', width: 88 },
{ title: '模式', key: 'sync_mode', width: 88 },
{ title: '源路径', key: 'source_path' },
@@ -34,6 +34,7 @@ export function usePushPage() {
onMounted(() => {
refreshPush(false)
void servers.loadSearchHistory()
servers.startAutoRefresh(() => progress.pushing.value)
pushReady = true
})
@@ -0,0 +1,28 @@
import { ref, watch } from 'vue'
export type PushServerViewMode = 'grid' | 'list'
const LS_KEY = 'nexus_push_server_view_v1'
function readStoredViewMode(): PushServerViewMode {
try {
const raw = localStorage.getItem(LS_KEY)
return raw === 'list' ? 'list' : 'grid'
} catch {
return 'grid'
}
}
const viewMode = ref<PushServerViewMode>(readStoredViewMode())
watch(viewMode, (mode) => {
try {
localStorage.setItem(LS_KEY, mode)
} catch {
/* ignore quota / private mode */
}
})
export function usePushServerViewMode() {
return { viewMode }
}
@@ -1,14 +1,45 @@
import { ref, computed, type Ref } from 'vue'
import { categoryDisplayLabel } from '@/composables/useServerCategories'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import type { PushServerItem } from './types'
const PUSH_SEARCH_HISTORY = 5
export function usePushServers(pushStatus: Ref<Record<number, string>>) {
const servers = ref<PushServerItem[]>([])
const serversLoading = ref(false)
const serverSearch = ref('')
const selectedIds = ref<Set<number>>(new Set())
const { items: pushSearchHistory, loadHistoryOnly, record: recordPushSearch } =
useServerSearchHistory('push')
const serverSearchHistory = computed(() =>
pushSearchHistory.value.slice(0, PUSH_SEARCH_HISTORY),
)
function commitServerSearch() {
void recordPushSearch(serverSearch.value)
}
function onServerSearchUpdate(val: string | null) {
serverSearch.value = val ?? ''
if (val == null || val === '') {
void recordPushSearch('')
return
}
const picked = String(val).trim()
if (pushSearchHistory.value.includes(picked)) {
serverSearch.value = picked
void recordPushSearch(picked)
}
}
async function loadSearchHistory() {
await loadHistoryOnly()
}
let refreshTimer: ReturnType<typeof setInterval> | null = null
async function loadServers(silent = false) {
@@ -113,12 +144,16 @@ export function usePushServers(pushStatus: Ref<Record<number, string>>) {
servers,
serversLoading,
serverSearch,
serverSearchHistory,
selectedIds,
pushStatus,
filteredServers,
selectAll,
serversByCategory,
loadServers,
loadSearchHistory,
commitServerSearch,
onServerSearchUpdate,
startAutoRefresh,
stopAutoRefresh,
isCategoryAllSelected,
@@ -1,6 +1,8 @@
import { ref, computed } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { registerServerBatchJob } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import { formatApiError } from '@/utils/apiError'
import type { FilesElevationMode, ServerApiItem } from '@/types/api'
@@ -229,7 +231,26 @@ export function useServerFormDialog(onSaved: () => void) {
if (isEditing.value && editingId.value != null) {
await http.put(`/servers/${editingId.value}`, payload)
} else {
await http.post('/servers/', payload)
const created = await http.post<ServerApiItem & {
onboarding_job_id?: number
onboarding_label?: string
}>('/servers/', payload)
if (created.onboarding_job_id) {
registerServerBatchJob(
created.onboarding_job_id,
created.onboarding_label || '新服务器初始化',
1,
'onboard',
'0/1',
'running',
)
showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)')
}
if (created.agent_action === 'install') {
showScriptSubmitToast(
created.agent_action_message || '请展开服务器行并点击「安装 Agent」以采集 CPU/内存等指标',
)
}
}
close()
onSaved()
@@ -29,6 +29,7 @@ import { XTERM_CLASSIC_THEME } from './xtermTheme'
import { decodeTerminalPayload } from '@/utils/terminalPayload'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import type { ServerApiItem } from '@/types/api'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
const MAX_TABS = 10
@@ -47,6 +48,8 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
const serversLoading = ref(false)
const serverSearch = ref('')
const quickCmds = ref<QuickCmdItem[]>([])
const { items: terminalSearchHistory, loadHistoryOnly, record: recordTerminalSearch } =
useServerSearchHistory('terminal')
const activeSession = computed(
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
@@ -708,6 +711,7 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
async function loadServers() {
serversLoading.value = true
try {
await loadHistoryOnly()
const { items } = await fetchPagePerPage<ServerApiItem>('/servers/', {}, 1, -1)
servers.value = items.map(toTerminalServerItem)
} catch {
@@ -718,6 +722,23 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
}
}
function commitServerSearch() {
void recordTerminalSearch(serverSearch.value)
}
function onServerSearchUpdate(val: string | null) {
if (val == null || val === '') {
serverSearch.value = ''
void recordTerminalSearch('')
return
}
const picked = String(val).trim()
serverSearch.value = picked
if (terminalSearchHistory.value.includes(picked)) {
void recordTerminalSearch(picked)
}
}
function onGlobalKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'l') {
e.preventDefault()
@@ -771,6 +792,7 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
servers,
serversLoading,
serverSearch,
terminalSearchHistory,
filteredServers,
showEmptyPicker,
quickCmds,
@@ -792,6 +814,8 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
loadQuickCmds,
execQuickCmd,
loadServers,
commitServerSearch,
onServerSearchUpdate,
applyRouteQuery,
onGlobalKeydown,
disposeAllSessions,
@@ -1,384 +0,0 @@
/**
* Global floating browser — persists tabs/history to MySQL via /api/browser/state.
* Singleton module state survives route changes.
*/
import { computed, ref } from 'vue'
import { http } from '@/api'
import { normalizeBrowserUrl } from '@/utils/browserUrl'
import type { FloatRect } from '@/composables/useFloatingPanel'
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
const SAVE_DEBOUNCE_MS = 700
export interface BrowserVisit {
url: string
title: string
at: string
}
export interface BrowserTab {
id: string
url: string
title: string
stack: string[]
stackIndex: number
frameKey: number
}
export interface BrowserStateResponse {
url_history: string[]
visits: BrowserVisit[]
tabs: Array<{
id: string
url: string
title?: string
stack?: string[]
stack_index?: number
}>
active_tab_id: string | null
minimized: boolean
rect: FloatRect | null
minimized_rect: FloatRect | null
}
const bootstrapped = ref(false)
const saving = ref(false)
const panelOpen = ref(false)
const minimized = ref(false)
const maximized = ref(false)
const lastError = ref<string | null>(null)
const addressDraft = ref('')
const urlHistory = ref<string[]>([])
const visits = ref<BrowserVisit[]>([])
const tabs = ref<BrowserTab[]>([])
const activeTabId = ref<string | null>(null)
const panelRect = ref<FloatRect | null>(null)
const minimizedRect = ref<FloatRect | null>(null)
let saveTimer: ReturnType<typeof setTimeout> | null = null
function tabLabel(tab: BrowserTab): string {
try {
return tab.title || new URL(tab.url).hostname
} catch {
return tab.title || tab.url
}
}
function newTabId(): string {
return crypto.randomUUID()
}
function serializeTabs(): BrowserStateResponse['tabs'] {
return tabs.value.map((t) => ({
id: t.id,
url: t.url,
title: t.title,
stack: t.stack,
stack_index: t.stackIndex,
}))
}
function applyServerState(data: BrowserStateResponse) {
urlHistory.value = data.url_history || []
visits.value = (data.visits || []).map((v) => ({
url: v.url,
title: v.title || v.url,
at: v.at,
}))
tabs.value = (data.tabs || []).map((t) => ({
id: t.id,
url: t.url,
title: t.title || t.url,
stack: t.stack?.length ? t.stack : [t.url],
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
frameKey: 0,
}))
activeTabId.value = data.active_tab_id
if (!activeTabId.value && tabs.value.length) {
activeTabId.value = tabs.value[0]!.id
}
minimized.value = Boolean(data.minimized)
panelRect.value = data.rect
minimizedRect.value = data.minimized_rect
panelOpen.value = tabs.value.length > 0 && !minimized.value
syncAddressFromActive()
}
function syncAddressFromActive() {
const tab = activeTab.value
addressDraft.value = tab?.url ?? ''
}
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
const canGoBack = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex > 0 : false
})
const canGoForward = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex < tab.stack.length - 1 : false
})
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void persistState(extra)
}, SAVE_DEBOUNCE_MS)
}
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
saving.value = true
try {
const body: Record<string, unknown> = {
tabs: serializeTabs(),
active_tab_id: activeTabId.value,
minimized: minimized.value,
rect: panelRect.value,
minimized_rect: minimizedRect.value,
}
if (extra?.recordUrl) {
body.record_url = extra.recordUrl
if (extra.recordTitle) body.record_title = extra.recordTitle
} else {
body.url_history = urlHistory.value
body.visits = visits.value
}
const res = await http.put<BrowserStateResponse>('/browser/state', body)
applyServerState(res)
} catch {
/* keep local state */
} finally {
saving.value = false
}
}
async function migrateLegacyHistory() {
try {
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
if (!urls.length) return
for (const url of [...urls].reverse()) {
const normalized = normalizeBrowserUrl(url)
if (normalized) {
await persistState({ recordUrl: normalized, recordTitle: normalized })
}
}
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
} catch {
/* ignore */
}
}
async function bootstrap() {
if (bootstrapped.value) return
try {
const res = await http.get<BrowserStateResponse>('/browser/state')
applyServerState(res)
if (!visits.value.length && !urlHistory.value.length) {
await migrateLegacyHistory()
}
} catch {
/* offline */
} finally {
bootstrapped.value = true
}
}
function openPanel() {
panelOpen.value = true
minimized.value = false
scheduleSave()
}
function minimizePanel() {
minimized.value = true
panelOpen.value = false
scheduleSave()
}
/** Reset to a single blank tab; browsing history is kept. */
function restartBrowser() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
frameKey: 0,
}
tabs.value = [tab]
activeTabId.value = tab.id
addressDraft.value = ''
lastError.value = null
openPanel()
scheduleSave()
}
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
const normalized = normalizeBrowserUrl(input)
if (!normalized) {
lastError.value = '请输入有效的 http 或 https 地址'
return false
}
lastError.value = null
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
const tab: BrowserTab = {
id: newTabId(),
url: normalized,
title: opts?.title || normalized,
stack: [normalized],
stackIndex: 0,
frameKey: 0,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
} else {
const tab = activeTab.value
const stack = tab.stack.slice(0, tab.stackIndex + 1)
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
tab.url = normalized
if (opts?.title) tab.title = opts.title
tab.stack = stack
tab.stackIndex = stack.length - 1
tab.frameKey += 1
}
addressDraft.value = normalized
openPanel()
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
return true
}
function navigate(input: string) {
return openUrl(input)
}
function refreshActive() {
const tab = activeTab.value
if (!tab) return
tab.frameKey += 1
}
function goBack() {
const tab = activeTab.value
if (!tab || tab.stackIndex <= 0) return
tab.stackIndex -= 1
tab.url = tab.stack[tab.stackIndex]!
tab.frameKey += 1
addressDraft.value = tab.url
scheduleSave()
}
function goForward() {
const tab = activeTab.value
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
tab.stackIndex += 1
tab.url = tab.stack[tab.stackIndex]!
tab.frameKey += 1
addressDraft.value = tab.url
scheduleSave()
}
function openExternal() {
const tab = activeTab.value
if (!tab) return
window.open(tab.url, '_blank', 'noopener,noreferrer')
}
function switchTab(id: string) {
activeTabId.value = id
syncAddressFromActive()
scheduleSave()
}
function closeTab(id: string) {
if (tabs.value.length <= 1) return
const idx = tabs.value.findIndex((t) => t.id === id)
if (idx < 0) return
const next = tabs.value.filter((t) => t.id !== id)
tabs.value = next
if (activeTabId.value === id) {
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
}
syncAddressFromActive()
scheduleSave()
}
function newEmptyTab() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
frameKey: 0,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
addressDraft.value = ''
openPanel()
scheduleSave()
}
function openFromHistory(url: string) {
openUrl(url)
}
function savePanelRect(rect: FloatRect) {
panelRect.value = rect
scheduleSave()
}
function saveMinimizedRect(rect: FloatRect) {
minimizedRect.value = rect
scheduleSave()
}
export function useGlobalBrowser() {
return {
bootstrapped,
saving,
panelOpen,
minimized,
maximized,
lastError,
addressDraft,
urlHistory,
visits,
tabs,
activeTabId,
activeTab,
panelRect,
minimizedRect,
isActive,
canGoBack,
canGoForward,
tabLabel,
bootstrap,
openPanel,
minimizePanel,
restartBrowser,
openUrl,
navigate,
refreshActive,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
scheduleSave,
}
}
@@ -33,24 +33,35 @@ function clearLegacyLocal() {
}
}
/** Servers page search history — persisted per admin in MySQL. */
export function useServerSearchHistory() {
/** Servers / push page search history — persisted per admin in MySQL. */
export type ServerSearchHistoryScope = 'servers' | 'push' | 'terminal'
const HISTORY_ENDPOINT: Record<ServerSearchHistoryScope, string> = {
servers: '/servers/search-history',
push: '/servers/push-search-history',
terminal: '/servers/terminal-search-history',
}
/** Per-admin search history in MySQL (servers or push scope). */
export function useServerSearchHistory(scope: ServerSearchHistoryScope = 'servers') {
const items = ref<string[]>([])
const bootstrapped = ref(false)
const endpoint = HISTORY_ENDPOINT[scope]
async function load(): Promise<void> {
const res = await http.get<ServerSearchHistoryResponse>('/servers/search-history')
const res = await http.get<ServerSearchHistoryResponse>(endpoint)
items.value = res.history || []
}
async function migrateLegacyIfEmpty(): Promise<boolean> {
if (scope !== 'servers') return false
const legacy = readLegacyLocal()
if (!legacy) return false
if (items.value.length > 0) {
clearLegacyLocal()
return false
}
await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
await http.put<ServerSearchHistoryResponse>(endpoint, {
history: legacy.history,
last: legacy.last || legacy.history[0] || '',
})
@@ -76,7 +87,7 @@ export function useServerSearchHistory() {
async function record(query: string | null | undefined) {
const normalized = String(query ?? '').trim()
try {
const res = await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
const res = await http.put<ServerSearchHistoryResponse>(endpoint, {
query: normalized,
})
items.value = res.history || []
+253
View File
@@ -0,0 +1,253 @@
/**
* Watch slot pins — CRUD, WebSocket metrics, countdown.
*/
import { ref, computed, onScopeDispose } from 'vue'
import { http, ApiError } from '@/api'
import { buildWebSocketUrl } from '@/utils/wsUrl'
import { useAuthStore } from '@/stores/auth'
import { formatApiError } from '@/utils/apiError'
export interface WatchMetrics {
probe_status?: string
source?: string
is_online?: boolean
cpu_pct?: number | null
mem_pct?: number | null
disk_pct?: number | null
net_up_bps?: number | null
net_down_bps?: number | null
disk_read_bps?: number | null
disk_write_bps?: number | null
error?: string | null
processes?: WatchProcess[]
ts?: string
}
export interface WatchProcess {
pid?: number
name?: string
cpu_pct?: number
mem_pct?: number
}
export interface WatchSparkPoint {
ts?: string
cpu_pct?: number | null
mem_pct?: number | null
disk_pct?: number | null
net_down_bps?: number | null
}
export interface WatchSlot {
slot_index: number
empty: boolean
pin_id?: number
server_id?: number
server_name?: string
session_id?: number
pinned_at?: string
expires_at?: string
remaining_sec?: number
monitoring_enabled?: boolean
ttl_hours?: 8 | 24
metrics?: WatchMetrics | null
sparkline?: WatchSparkPoint[]
processes?: WatchProcess[]
}
const slots = ref<WatchSlot[]>([
{ slot_index: 0, empty: true },
{ slot_index: 1, empty: true },
{ slot_index: 2, empty: true },
{ slot_index: 3, empty: true },
])
const loading = ref(false)
const wsConnected = ref(false)
let ws: WebSocket | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
let consumerCount = 0
function applySlots(data: { slots?: WatchSlot[] }) {
if (Array.isArray(data.slots)) {
slots.value = data.slots
}
}
function tickCountdown() {
let shouldRefresh = false
for (const slot of slots.value) {
if (slot.empty || slot.monitoring_enabled === false) continue
if (slot.remaining_sec != null && slot.remaining_sec > 0) {
slot.remaining_sec -= 1
if (slot.remaining_sec <= 0) {
shouldRefresh = true
}
}
}
if (shouldRefresh) {
void refreshPins()
}
}
function mergeMetrics(serverId: number, metrics: WatchMetrics) {
for (const slot of slots.value) {
if (slot.empty || slot.server_id !== serverId || slot.monitoring_enabled === false) continue
slot.metrics = { ...(slot.metrics || {}), ...metrics }
if (metrics.processes?.length) {
slot.processes = metrics.processes
}
const point: WatchSparkPoint = {
ts: metrics.ts,
cpu_pct: metrics.cpu_pct,
mem_pct: metrics.mem_pct,
disk_pct: metrics.disk_pct,
net_down_bps: metrics.net_down_bps,
}
if (!slot.sparkline) slot.sparkline = []
slot.sparkline.push(point)
if (slot.sparkline.length > 180) slot.sparkline.shift()
}
}
function connectWs() {
const auth = useAuthStore()
if (!auth.token || ws?.readyState === WebSocket.OPEN) return
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
const url = buildWebSocketUrl('/ws/watch', { token: auth.token })
const socket = new WebSocket(url)
socket.onopen = () => { wsConnected.value = true }
socket.onmessage = (event) => {
if (event.data === 'pong') return
try {
const msg = JSON.parse(event.data)
if (msg.type === 'ping') {
socket.send('pong')
return
}
if (msg.type === 'watch_metrics' && msg.server_id && msg.metrics) {
mergeMetrics(msg.server_id, msg.metrics)
}
} catch { /* ignore */ }
}
socket.onclose = (ev) => {
wsConnected.value = false
ws = null
if (ev.code !== 4001 && ev.code !== 4401 && auth.isLoggedIn) {
setTimeout(connectWs, 3000)
}
}
ws = socket
}
function disconnectWs() {
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
wsConnected.value = false
}
async function refreshPins() {
loading.value = true
try {
const data = await http.get<{ slots: WatchSlot[] }>('/watch/pins')
applySlots(data)
} finally {
loading.value = false
}
}
async function pinServer(
serverId: number,
opts?: number | { replaceSlot?: number; slotIndex?: number },
) {
const options = typeof opts === 'number' ? { replaceSlot: opts } : opts
try {
const body: Record<string, number> = { server_id: serverId }
if (options?.replaceSlot != null) body.replace_slot = options.replaceSlot
if (options?.slotIndex != null) body.slot_index = options.slotIndex
const data = await http.post<{ slots: WatchSlot[] }>('/watch/pins', body)
applySlots(data)
return { ok: true as const }
} catch (e: unknown) {
if (e instanceof ApiError && e.status === 409) {
return { ok: false as const, full: true }
}
return { ok: false as const, full: false, error: formatApiError(e, '加入监测失败') }
}
}
async function removeSlot(slotIndex: number) {
const data = await http.delete<{ slots: WatchSlot[] }>(`/watch/pins/${slotIndex}`)
applySlots(data)
}
async function setSlotMonitoring(slotIndex: number, monitoringEnabled: boolean) {
const data = await http.patch<{ slots: WatchSlot[] }>(
`/watch/pins/${slotIndex}/monitoring`,
{ monitoring_enabled: monitoringEnabled },
)
applySlots(data)
}
export type WatchTtlHours = 8 | 24
async function setSlotTtl(slotIndex: number, ttlHours: WatchTtlHours) {
const data = await http.patch<{ slots: WatchSlot[] }>(
`/watch/pins/${slotIndex}/ttl`,
{ ttl_hours: ttlHours },
)
applySlots(data)
}
export function useWatchPins() {
consumerCount += 1
if (consumerCount === 1) {
if (!countdownTimer) {
countdownTimer = setInterval(tickCountdown, 1000)
}
connectWs()
}
onScopeDispose(() => {
consumerCount = Math.max(0, consumerCount - 1)
if (consumerCount === 0) {
disconnectWs()
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
})
const pinnedMap = computed(() => {
const map: Record<number, number> = {}
for (const s of slots.value) {
if (!s.empty && s.server_id != null) {
map[s.server_id] = s.slot_index
}
}
return map
})
const filledCount = computed(() => slots.value.filter((s) => !s.empty).length)
return {
slots,
loading,
wsConnected,
pinnedMap,
filledCount,
refreshPins,
pinServer,
removeSlot,
setSlotMonitoring,
setSlotTtl,
}
}
-29
View File
@@ -1,29 +0,0 @@
<template>
<div class="pa-6 text-center text-medium-emphasis">正在打开浏览器</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
const route = useRoute()
const router = useRouter()
const browser = useGlobalBrowser()
onMounted(async () => {
await browser.bootstrap()
const url = typeof route.query.url === 'string' ? route.query.url : ''
if (url) {
browser.openUrl(url)
} else if (!browser.tabs.value.length) {
browser.newEmptyTab()
} else {
browser.openPanel()
}
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
? route.query.redirect
: '/'
router.replace(redirect)
})
</script>
+4 -1
View File
@@ -62,12 +62,15 @@
<PushServerGrid
:select-all="servers.selectAll"
v-model:server-search="servers.serverSearch"
:server-search="servers.serverSearch"
:search-history="servers.serverSearchHistory"
:servers-loading="servers.serversLoading"
:servers-by-category="servers.serversByCategory"
:selected-ids="servers.selectedIds"
:push-status="servers.pushStatus"
:is-category-all-selected="servers.isCategoryAllSelected"
@update:server-search="servers.onServerSearchUpdate"
@search-commit="servers.commitServerSearch"
@toggle-all="servers.toggleAll"
@refresh="servers.loadServers"
@toggle-category="servers.toggleCategory"
+162 -5
View File
@@ -7,6 +7,7 @@
@click="onStatCardClick"
@refresh="refreshStatCards"
/>
<WatchSlotRow />
<div v-if="statusFilterLabel || unsetPathFocus" class="d-flex align-center flex-wrap ga-2 mb-3">
<v-chip
v-if="statusFilterLabel"
@@ -235,7 +236,29 @@
</template>
<template #item.agent_version="{ item }">
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
<div>
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
<v-chip
v-if="item.agent_action === 'install'"
size="x-small"
color="info"
variant="tonal"
label
class="mt-1"
>
需安装
</v-chip>
<v-chip
v-else-if="item.agent_action === 'upgrade'"
size="x-small"
color="warning"
variant="tonal"
label
class="mt-1"
>
需升级
</v-chip>
</div>
</template>
<template #item.last_heartbeat="{ item }">
@@ -243,7 +266,17 @@
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<div class="d-flex ga-1 align-center">
<v-btn
:icon="pinnedMap[item.id] != null ? 'mdi-check' : 'mdi-plus'"
size="x-small"
variant="text"
:color="pinnedMap[item.id] != null ? 'success' : 'primary'"
density="compact"
:title="pinnedMap[item.id] != null ? `已在监测 · 槽 ${pinnedMap[item.id]! + 1}` : '加入实时监测'"
:loading="pinLoadingId === item.id"
@click.stop="onPinServer(item)"
/>
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(item)">终端</v-btn>
<v-btn
v-if="item.domain"
@@ -267,7 +300,11 @@
<ServerInlineDetail
:server="item"
:active="expandedMainIds.includes(item.id)"
:agent-action-loading="agentActionLoadingId === item.id"
@edit-path="startEditPath(item)"
@install-agent="installAgentOne(item)"
@upgrade-agent="upgradeAgentOne(item)"
@diagnose-agent="openAgentDiagnose(item)"
/>
</td>
</tr>
@@ -650,6 +687,35 @@
:result="agentDiagnoseResult"
:target-name="agentDiagnoseTarget?.name"
/>
<v-dialog v-model="showReplacePin" max-width="420">
<v-card rounded="lg">
<v-card-title>监测槽已满</v-card-title>
<v-card-text class="text-body-2">
请选择要替换的槽位当前 Pin 的服务将被移除
</v-card-text>
<v-card-text>
<v-row dense>
<v-col v-for="slot in watchSlots" :key="slot.slot_index" cols="6">
<v-btn
block
variant="tonal"
:color="slot.empty ? 'primary' : 'warning'"
@click="confirmReplaceSlot(slot.slot_index)"
>
{{ slot.slot_index + 1 }}
<span v-if="!slot.empty" class="text-caption ml-1">({{ slot.server_name }})</span>
<span v-else class="text-caption ml-1">()</span>
</v-btn>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showReplacePin = false">取消</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
@@ -672,10 +738,11 @@ import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServ
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
import WatchSlotRow from '@/components/watch/WatchSlotRow.vue'
import { useWatchPins } from '@/composables/useWatchPins'
import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
@@ -727,6 +794,23 @@ async function submitServerBatchJob(
return res
}
function registerOnboardingJob(
jobId: number | undefined,
label: string | undefined,
total: number,
) {
if (!jobId) return
registerServerBatchJob(
jobId,
label || '新服务器初始化',
total,
'onboard',
`0/${total}`,
'running',
)
showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)')
}
onMounted(() => {
offBatchComplete = onScriptExecutionComplete((alert) => {
if (alert.taskKind !== 'server_batch') return
@@ -745,8 +829,52 @@ defineOptions({ name: 'ServersPage' })
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const { pinnedMap, pinServer: doPinServer, slots: watchSlots } = useWatchPins()
const pinLoadingId = ref<number | null>(null)
const showReplacePin = ref(false)
const replacePinServerId = ref<number | null>(null)
async function onPinServer(item: ServerApiItem) {
const existingSlot = pinnedMap.value[item.id]
if (existingSlot != null) {
snackbar(`已在监测 · 槽 ${existingSlot + 1}`, 'info')
return
}
pinLoadingId.value = item.id
try {
const res = await doPinServer(item.id)
if (res.ok) {
snackbar('已加入实时监测', 'success')
return
}
if (res.full) {
replacePinServerId.value = item.id
showReplacePin.value = true
return
}
snackbar(res.error || '加入监测失败', 'error')
} finally {
pinLoadingId.value = null
}
}
async function confirmReplaceSlot(slotIndex: number) {
if (replacePinServerId.value == null) return
pinLoadingId.value = replacePinServerId.value
try {
const res = await doPinServer(replacePinServerId.value, slotIndex)
if (res.ok) {
snackbar(`已替换槽 ${slotIndex + 1}`, 'success')
showReplacePin.value = false
replacePinServerId.value = null
} else {
snackbar(res.error || '替换失败', 'error')
}
} finally {
pinLoadingId.value = null
}
}
const router = useRouter()
const globalBrowser = useGlobalBrowser()
const route = useRoute()
const showCredentialsDialog = ref(false)
@@ -1173,6 +1301,7 @@ async function submitQuickAdd() {
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
if (res.success && res.server) {
showQuickAdd.value = false
registerOnboardingJob(res.onboarding_job_id, res.onboarding_label, 1)
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
loadServers()
loadStats()
@@ -1193,6 +1322,7 @@ async function retryPending(item: PendingServerItem) {
try {
const res = await http.post<AddByIpResponse>(`/servers/pending/${item.id}/retry`, {})
if (res.success && res.server) {
registerOnboardingJob(res.onboarding_job_id, res.onboarding_label, 1)
snackbar(`重试成功:${res.matched_preset} (${res.matched_username})`)
loadServers()
loadStats()
@@ -1415,7 +1545,7 @@ function openBrowser(item: ServerApiItem) {
snackbar('该服务器无有效域名', 'warning')
return
}
globalBrowser.openUrl(url, { title: item.name })
window.open(url, '_blank', 'noopener,noreferrer')
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
@@ -1424,6 +1554,7 @@ function openFiles(item: ServerApiItem) {
const showAgentDiagnose = ref(false)
const agentDiagnoseLoading = ref(false)
const agentDiagnoseLoadingId = ref<number | null>(null)
const agentActionLoadingId = ref<number | null>(null)
const agentDiagnoseResult = ref<AgentDiagnoseResult | null>(null)
const agentDiagnoseTarget = ref<ServerApiItem | null>(null)
@@ -1444,6 +1575,31 @@ async function openAgentDiagnose(item: ServerApiItem) {
}
}
async function installAgentOne(item: ServerApiItem) {
agentActionLoadingId.value = item.id
try {
await http.post(`/servers/${item.id}/install-agent`, {})
snackbar(`Agent 安装完成:${item.name}`, 'success')
await _load()
} catch (e: unknown) {
snackbar(formatApiError(e, '安装 Agent 失败'), 'error')
} finally {
agentActionLoadingId.value = null
}
}
async function upgradeAgentOne(item: ServerApiItem) {
agentActionLoadingId.value = item.id
try {
await submitServerBatchJob('upgrade-agent', [item.id])
snackbar(`已提交 Agent 升级:${item.name}`, 'success')
} catch (e: unknown) {
snackbar(formatApiError(e, '提交升级失败'), 'error')
} finally {
agentActionLoadingId.value = null
}
}
function editServer(item: ServerApiItem) {
void openEditServerDialog(item)
}
@@ -1705,6 +1861,7 @@ async function submitBatchAdd() {
})
batchAddResult.value = res
if (res.created > 0) {
registerOnboardingJob(res.onboarding_job_id, '新服务器初始化', res.created)
loadServers()
loadStats()
}
+72 -57
View File
@@ -36,62 +36,73 @@
</v-list>
</v-menu>
<div class="d-flex flex-column flex-grow-1" style="min-height: 0">
<TerminalTabBar
:sessions="sessions"
:active-session-id="activeSessionId"
@switch="switchTab"
@close="closeTab"
@new="newSession()"
/>
<div class="d-flex flex-grow-1 min-h-0 terminal-main-row">
<div class="d-flex flex-column flex-grow-1 min-w-0 min-h-0">
<TerminalTabBar
:sessions="sessions"
:active-session-id="activeSessionId"
@switch="switchTab"
@close="closeTab"
@new="newSession()"
/>
<TerminalToolbar
v-if="sessions.length > 0"
:session="activeSession"
:font-size="fontSize"
:scrollback="scrollback"
@font-change="changeFontSize"
@scrollback-change="changeScrollback"
@fullscreen="toggleFullscreen"
@disconnect="disconnect"
/>
<TerminalToolbar
v-if="sessions.length > 0"
:session="activeSession"
:font-size="fontSize"
:scrollback="scrollback"
@font-change="changeFontSize"
@scrollback-change="changeScrollback"
@fullscreen="toggleFullscreen"
@disconnect="disconnect"
/>
<TerminalArea
ref="areaRef"
:sessions="sessions"
:active-session-id="activeSessionId"
:active-session="activeSession"
:show-empty="showEmptyPicker"
@context-menu="onTermContext"
@pane-ref="setTermRef"
@reconnect="reconnect"
@go-servers="router.push('/servers')"
>
<template #empty-picker>
<TerminalServerPicker
:servers="filteredServers"
:search="serverSearch"
:loading="serversLoading"
mode="inline"
@update:search="onServerSearch"
@select="(id) => newSession(id)"
/>
</template>
</TerminalArea>
<TerminalArea
ref="areaRef"
:sessions="sessions"
:active-session-id="activeSessionId"
:active-session="activeSession"
:show-empty="showEmptyPicker"
@context-menu="onTermContext"
@pane-ref="setTermRef"
@reconnect="reconnect"
@go-servers="router.push('/servers')"
>
<template #empty-picker>
<TerminalServerPicker
:servers="filteredServers"
:search="serverSearch"
:search-history="terminalSearchHistory"
:loading="serversLoading"
mode="inline"
@update:search="onServerSearchUpdate"
@search-commit="commitServerSearch"
@select="(id) => newSession(id)"
/>
</template>
</TerminalArea>
<TerminalQuickBar
:commands="quickCmds"
:disconnect-disabled="!activeSession"
@exec="execQuickCmd"
@disconnect="disconnect"
/>
<TerminalQuickBar
:commands="quickCmds"
:disconnect-disabled="!activeSession"
@exec="execQuickCmd"
@disconnect="disconnect"
/>
<TerminalCmdBar
ref="cmdBarRef"
v-model="cmdInput"
:disabled="cmdBarDisabled"
@keydown="onCmdKeydown"
@context-menu="onCmdContext"
<TerminalCmdBar
ref="cmdBarRef"
v-model="cmdInput"
:disabled="cmdBarDisabled"
@keydown="onCmdKeydown"
@context-menu="onCmdContext"
/>
</div>
<TerminalServerPicker
mode="sidebar"
:servers="filteredServers"
:loading="serversLoading"
@select="(id) => newSession(id)"
/>
</div>
@@ -102,9 +113,11 @@
<TerminalServerPicker
:servers="filteredServers"
:search="serverSearch"
:search-history="terminalSearchHistory"
:loading="serversLoading"
mode="dialog"
@update:search="onServerSearch"
@update:search="onServerSearchUpdate"
@search-commit="commitServerSearch"
@select="onDialogSelect"
/>
</v-card-text>
@@ -140,6 +153,7 @@ const {
termContainer,
showServerDialog,
serverSearch,
terminalSearchHistory,
filteredServers,
showEmptyPicker,
quickCmds,
@@ -160,6 +174,8 @@ const {
execQuickCmd,
loadServers,
serversLoading,
commitServerSearch,
onServerSearchUpdate,
applyRouteQuery,
onGlobalKeydown,
disposeAllSessions,
@@ -185,10 +201,6 @@ const termMenuLocation = computed(() =>
menuY.value > window.innerHeight * 0.55 ? 'top end' : 'bottom end',
)
function onServerSearch(v: string) {
serverSearch.value = v
}
function openTermMenu(e: MouseEvent, context: 'terminal' | 'command') {
e.preventDefault()
termMenuContext.value = context
@@ -293,6 +305,9 @@ onBeforeUnmount(() => {
.terminal-root--classic {
background: #000000;
}
.terminal-main-row {
min-height: 0;
}
.font-mono {
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
}
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { http } from '@/api'
import WatchTrendChart, { type WatchTrendPoint } from '@/components/watch/WatchTrendChart.vue'
import WatchProbeRecordsTable from '@/components/watch/WatchProbeRecordsTable.vue'
import { useWatchPins } from '@/composables/useWatchPins'
defineOptions({ name: 'WatchMetricsPage' })
const route = useRoute()
const tab = ref('trend')
const hours = ref(24)
const serverId = ref<number | null>(null)
const sessionId = ref<number | null>(null)
const points = ref<WatchTrendPoint[]>([])
const loading = ref(false)
const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>>([
'cpu_pct', 'mem_pct', 'disk_pct', 'net_down_bps',
])
const { slots, refreshPins } = useWatchPins()
const serverOptions = computed(() =>
slots.value
.filter((s) => !s.empty && s.server_id)
.map((s) => ({ title: s.server_name || `ID ${s.server_id}`, value: s.server_id! })),
)
async function loadTrend() {
if (!serverId.value) {
points.value = []
return
}
loading.value = true
try {
const data = await http.get<{ points: WatchTrendPoint[] }>('/watch/metrics', {
server_ids: String(serverId.value),
hours: hours.value,
})
points.value = data.points || []
} finally {
loading.value = false
}
}
onMounted(async () => {
await refreshPins()
const qSid = route.query.server_id
const qSess = route.query.session_id
if (qSid) serverId.value = Number(qSid)
if (qSess) sessionId.value = Number(qSess)
if (!serverId.value && serverOptions.value.length) {
serverId.value = serverOptions.value[0].value
}
})
watch([serverId, hours], loadTrend, { immediate: true })
</script>
<template>
<v-container fluid class="pa-4 pa-md-6">
<div class="d-flex align-center mb-4">
<h1 class="text-h6">监测历史</h1>
<v-spacer />
<v-btn variant="text" prepend-icon="mdi-arrow-left" :to="{ name: 'Servers' }">返回服务器</v-btn>
</div>
<v-card elevation="0" border rounded="lg">
<v-tabs v-model="tab" color="primary">
<v-tab value="trend">资源趋势</v-tab>
<v-tab value="records">探针记录</v-tab>
</v-tabs>
<v-divider />
<v-card-text>
<v-window v-model="tab">
<v-window-item value="trend">
<div class="d-flex flex-wrap ga-2 mb-4">
<v-select
v-model="serverId"
:items="serverOptions"
item-title="title"
item-value="value"
label="服务器"
density="compact"
hide-details
variant="outlined"
style="min-width: 200px"
/>
<v-btn-toggle v-model="hours" mandatory density="compact" variant="outlined" divided>
<v-btn :value="1" size="small">1h</v-btn>
<v-btn :value="6" size="small">6h</v-btn>
<v-btn :value="24" size="small">24h</v-btn>
<v-btn :value="168" size="small">7d</v-btn>
</v-btn-toggle>
<v-select
v-model="selectedSeries"
:items="[
{ title: 'CPU %', value: 'cpu_pct' },
{ title: '内存 %', value: 'mem_pct' },
{ title: '磁盘 %', value: 'disk_pct' },
{ title: '上行', value: 'net_up_bps' },
{ title: '下行', value: 'net_down_bps' },
]"
item-title="title"
item-value="value"
label="曲线"
multiple
chips
density="compact"
hide-details
variant="outlined"
style="min-width: 280px"
/>
</div>
<v-progress-linear v-if="loading" indeterminate class="mb-2" />
<WatchTrendChart v-if="points.length" :points="points" :series="selectedSeries" />
<div v-else class="text-center text-medium-emphasis py-8">
暂无趋势数据需先 Pin 监测并等待探针采样
</div>
</v-window-item>
<v-window-item value="records">
<WatchProbeRecordsTable
:server-id="serverId"
:session-id="sessionId"
:hours="hours"
/>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
</v-container>
</template>
+1 -1
View File
@@ -4,8 +4,8 @@ import { useAuthStore } from '@/stores/auth'
const routes = [
{ path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') },
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
{ path: '/watch-metrics', name: 'WatchMetrics', component: () => import('@/pages/WatchMetricsPage.vue') },
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
+8
View File
@@ -149,6 +149,8 @@ export interface FilesCapabilityResult {
}
/** Server item from /api/servers/ */
export type ServerAgentAction = 'install' | 'upgrade' | 'offline' | 'ok'
export interface ServerApiItem {
id: number
name: string
@@ -175,6 +177,9 @@ export interface ServerApiItem {
last_heartbeat: string | null
agent_version: string | null
status: string
agent_action?: ServerAgentAction
agent_action_message?: string | null
central_agent_version?: string | null
created_at: string | null
updated_at: string | null
system_info?: {
@@ -266,6 +271,8 @@ export interface AddByIpResponse {
message?: string
matched_preset?: string
matched_username?: string
onboarding_job_id?: number
onboarding_label?: string
}
/** Item from POST /api/servers/add-by-ips-batch */
@@ -289,6 +296,7 @@ export interface AddByIpsBatchResult {
input_lines?: number
duplicates_removed?: number
items: AddByIpsBatchItemResult[]
onboarding_job_id?: number
}
/** Alert history item from /api/alert-history/ */
+1
View File
@@ -39,6 +39,7 @@ const ACTION_LABELS: Record<string, string> = {
batch_upgrade_agent: '批量升级 Agent',
batch_uninstall_agent: '批量卸载 Agent',
batch_detect_path: '批量检测路径',
batch_server_onboard: '新服务器初始化',
batch_health_check: '批量健康检查',
batch_update_category: '批量修改分类',
+122
View File
@@ -0,0 +1,122 @@
/** Format watch probe byte rates for display. */
export function formatBytesPerSec(bps: number | null | undefined): string {
if (bps == null || bps < 0) return '—'
if (bps < 1024) return `${bps} B/s`
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
}
export function formatRemaining(sec: number | null | undefined): string {
if (sec == null || sec <= 0) return '已到期'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
if (h > 0) return `${h}h ${m}m`
return `${m}m ${sec % 60}s`
}
const PROBE_STATUS_LABELS: Record<string, string> = {
ok: '正常',
parse_error: '探针解析失败',
ssh_timeout: 'SSH 超时',
ssh_auth: 'SSH 认证失败',
no_cred: '无凭据',
redis_stale: 'Agent 心跳过期',
offline: '离线',
}
const PROBE_ERROR_EXACT: Record<string, string> = {
parse_error: '探针返回数据无法解析',
'psutil missing': '子机未安装 psutil',
ssh_probe_failed: 'SSH 探针脚本执行失败',
'SSH watch probe failed': 'SSH 监测探针失败',
'SSH probe failed': 'SSH 探针失败',
timeout: '连接超时',
'探针失败': '探针失败',
'无 Agent 且 SSH 探针失败': '无 Agent 且 SSH 探针失败',
'服务器不存在': '服务器不存在',
}
/** Probe status code → Chinese label. */
export function probeStatusLabel(status: string | null | undefined): string {
if (!status || status === 'ok') return PROBE_STATUS_LABELS.ok
if (PROBE_STATUS_LABELS[status]) return PROBE_STATUS_LABELS[status]
if (status.startsWith('parse_error')) return PROBE_STATUS_LABELS.parse_error
return status
}
/** Probe source code → Chinese label. */
export function probeSourceLabel(source: string | null | undefined): string {
if (!source) return '—'
const map: Record<string, string> = {
redis: 'Agent',
ssh: 'SSH',
mixed: 'Agent+SSH',
}
return map[source] ?? source
}
/** Error detail → Chinese (covers legacy English rows in DB). */
export function formatProbeError(
error: string | null | undefined,
status?: string | null,
): string {
if (!error?.trim()) {
if (status && status !== 'ok') return probeStatusLabel(status)
return '—'
}
const text = error.trim()
if (PROBE_ERROR_EXACT[text]) return PROBE_ERROR_EXACT[text]
const lower = text.toLowerCase()
if (lower.startsWith('parse_error')) {
const detail = text.includes(':') ? text.split(':').slice(1).join(':').trim() : ''
if (detail && PROBE_ERROR_EXACT[detail]) {
return `探针返回数据无法解析:${PROBE_ERROR_EXACT[detail]}`
}
if (detail) return `探针返回数据无法解析:${detail}`
return PROBE_ERROR_EXACT.parse_error
}
if (lower.includes('timed out') || lower.endsWith('timeout')) {
return `SSH 连接超时:${text}`
}
if (lower.includes('permission denied') || lower.includes('authentication failed')) {
return `SSH 认证失败:${text}`
}
if (lower.includes('psutil missing')) {
return PROBE_ERROR_EXACT['psutil missing']
}
if (lower.includes('ssh_probe_failed') || lower.includes('ssh watch probe failed')) {
return PROBE_ERROR_EXACT.ssh_probe_failed
}
if (/[\u4e00-\u9fff]/.test(text)) return text
return `探针异常:${text}`
}
/** True when probe error indicates missing psutil on the remote host. */
export function isPsutilMissingError(
error: string | null | undefined,
status?: string | null,
): boolean {
const text = (error || '').toLowerCase()
if (text.includes('psutil missing')) return true
if (status?.toLowerCase().includes('parse_error') && text.includes('psutil')) return true
return false
}
export function probeStatusColor(status: string | null | undefined): string {
if (!status || status === 'ok') return 'success'
if (status.includes('timeout') || status.includes('auth')) return 'warning'
return 'error'
}
/** Status filter options for probe records table. */
export const PROBE_STATUS_FILTER_ITEMS = [
{ title: '全部状态', value: null as string | null },
{ title: '正常', value: 'ok' },
{ title: 'SSH 超时', value: 'ssh_timeout' },
{ title: '探针解析失败', value: 'parse_error' },
{ title: '无凭据', value: 'no_cred' },
{ title: '离线', value: 'offline' },
]
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Batch detect target_path via SSH (workerman.bat under /www/wwwroot).
Targets servers with「未设路径」(empty or ``/www/wwwroot`` placeholder) by default.
Usage:
python scripts/batch_detect_target_path.py --unset-only
python scripts/batch_detect_target_path.py --ids 245,246
python scripts/batch_detect_target_path.py --name 温胜夜 --dry-run
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _load_dotenv() -> None:
env_path = ROOT / ".env"
if not env_path.is_file():
return
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip("\"'")
if key.startswith("NEXUS_") and key[6:] not in os.environ:
os.environ[key[6:]] = value
elif key and key not in os.environ:
os.environ[key] = value
async def _fetch_servers(
session: Any,
*,
ids: list[int] | None,
name_like: str | None,
unset_only: bool,
limit: int,
) -> list[Any]:
from sqlalchemy import or_, select
from server.domain.models import Server
from server.infrastructure.database.server_repo import _target_path_unset_filter
q = select(Server).order_by(Server.id)
if ids:
q = q.where(Server.id.in_(ids))
if name_like:
q = q.where(or_(Server.name.like(f"%{name_like}%"), Server.domain.like(f"%{name_like}%")))
if unset_only:
q = q.where(_target_path_unset_filter(True))
result = await session.execute(q)
servers = list(result.scalars().all())
if limit > 0:
servers = servers[:limit]
return servers
async def _detect_one_server(server: Any) -> dict[str, Any]:
from server.application.server_batch_common import detect_and_save_target_path
from server.application.services.server_batch_service import result_item_dict
sid = server.id
label = server.name or str(sid)
try:
outcome = await detect_and_save_target_path(server)
if outcome.get("success"):
return result_item_dict(
server_id=sid,
server_name=label,
success=True,
stdout=str(outcome.get("stdout") or ""),
)
return result_item_dict(
server_id=sid,
server_name=label,
success=False,
error=str(outcome.get("error") or "探测失败"),
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
async def run_detect(args: argparse.Namespace) -> list[dict[str, Any]]:
from server.config import settings
from server.infrastructure.database.session import AsyncSessionLocal, init_db
from server.infrastructure.redis.client import close_redis, init_redis
await init_db()
await init_redis(settings.REDIS_URL)
ids: list[int] | None = None
if args.ids:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
async with AsyncSessionLocal() as session:
servers = await _fetch_servers(
session,
ids=ids,
name_like=args.name,
unset_only=args.unset_only,
limit=args.limit,
)
if not servers:
print("No servers matched.", file=sys.stderr)
await close_redis()
return []
if args.dry_run:
for s in servers:
print(f"[DRY] #{s.id} {s.name} target_path={s.target_path!r}")
print(f"\nWould detect: {len(servers)} servers")
await close_redis()
return [{"server_id": s.id, "dry_run": True} for s in servers]
sem = asyncio.Semaphore(max(1, getattr(args, "concurrency", 8)))
async def _one(server: Any) -> dict[str, Any]:
async with sem:
return await _detect_one_server(server)
results = await asyncio.gather(*[_one(s) for s in servers])
await close_redis()
ok = sum(1 for r in results if r.get("success"))
fail = len(results) - ok
print(f"\nDetect: {ok} ok / {fail} fail / {len(results)} total")
for r in results:
status = "OK" if r.get("success") else "FAIL"
print(
f"[{status}] #{r.get('server_id')} {r.get('server_name')}: "
f"{r.get('stdout') or r.get('error') or ''}"
)
return list(results)
def main() -> int:
_load_dotenv()
parser = argparse.ArgumentParser(description="Batch detect server target_path")
parser.add_argument("--ids", type=str, help="Comma-separated server ids")
parser.add_argument("--name", type=str, help="Filter name/domain LIKE")
parser.add_argument("--unset-only", action="store_true", help="Only「未设路径」servers")
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--concurrency", type=int, default=8)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not args.ids and not args.name and not args.unset_only:
parser.error("Specify --ids, --name, or --unset-only")
results = asyncio.run(run_detect(args))
if args.dry_run:
return 0 if results else 2
return 0 if results and all(r.get("success") for r in results) else 1
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Batch detect target_path on「未设路径」servers (SSH find workerman.bat).
#
# Usage:
# bash scripts/batch_detect_target_path.sh --unset-only
# bash scripts/batch_detect_target_path.sh --unset-only --dry-run
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS="${ROOT}/deploy/nexus-1panel.secrets.sh"
if [[ -f "$SECRETS" ]]; then
# shellcheck disable=SC1090
source "$SECRETS"
fi
PYTHON="${ROOT}/.venv/bin/python"
[[ -x "$PYTHON" ]] || PYTHON="${ROOT}/venv/bin/python"
[[ -x "$PYTHON" ]] || PYTHON=python3
exec "$PYTHON" "${ROOT}/scripts/batch_detect_target_path.py" "$@"
+25 -9
View File
@@ -38,7 +38,13 @@ def _load_dotenv() -> None:
os.environ[key] = value
async def _fetch_servers(session: Any, *, ids: list[int] | None, name_like: str | None) -> list[Any]:
async def _fetch_servers(
session: Any,
*,
ids: list[int] | None,
name_like: str | None,
non_root_only: bool = False,
) -> list[Any]:
from sqlalchemy import or_, select
from server.domain.models import Server
@@ -48,6 +54,8 @@ async def _fetch_servers(session: Any, *, ids: list[int] | None, name_like: str
q = q.where(Server.id.in_(ids))
if name_like:
q = q.where(or_(Server.name.like(f"%{name_like}%"), Server.domain.like(f"%{name_like}%")))
if non_root_only:
q = q.where(Server.username.isnot(None)).where(Server.username != "root")
result = await session.execute(q)
return list(result.scalars().all())
@@ -74,7 +82,7 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
from server.config import settings
from server.infrastructure.database.session import AsyncSessionLocal, init_db
from server.infrastructure.redis.client import close_redis, init_redis
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX, normalize_remote_abs_path
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX, normalize_remote_abs_path, resolve_push_target_path
await init_db()
await init_redis(settings.REDIS_URL)
@@ -84,7 +92,9 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
async with AsyncSessionLocal() as session:
servers = await _fetch_servers(session, ids=ids, name_like=args.name)
servers = await _fetch_servers(
session, ids=ids, name_like=args.name, non_root_only=args.non_root_only
)
if not servers:
print("No servers matched.", file=sys.stderr)
@@ -98,10 +108,11 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
fh.write("nexus push elevation verify\n")
results: list[dict[str, Any]] = []
sem = asyncio.Semaphore(max(1, getattr(args, "concurrency", 8)))
try:
for server in servers:
dest = normalize_remote_abs_path(server.target_path or "/tmp/sync")
async def _verify_one(server: Any) -> None:
async with sem:
dest = resolve_push_target_path(server.target_path)
row: dict[str, Any] = {
"server_id": server.id,
"server_name": server.name,
@@ -111,7 +122,7 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
row["rsync_sudo"] = await probe_rsync_sudo(server)
write = await _diagnose_write(server, dest)
row.update(write_check=write)
row["write_check"] = write
preview = await _rsync_push(
server, staging, dest, sync_mode="incremental", dry_run=True
@@ -154,6 +165,9 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
print(f" dry_run_err: {row['dry_run_stderr']}", flush=True)
if row.get("push_stderr"):
print(f" push_err: {row['push_stderr']}", flush=True)
try:
await asyncio.gather(*[_verify_one(s) for s in servers])
finally:
import shutil
@@ -170,10 +184,12 @@ def main() -> int:
parser = argparse.ArgumentParser(description="Verify push elevation on servers")
parser.add_argument("--ids", type=str, help="Comma-separated server ids")
parser.add_argument("--name", type=str, help="Filter name/domain")
parser.add_argument("--non-root-only", action="store_true", help="All servers except username=root")
parser.add_argument("--concurrency", type=int, default=8, help="Parallel servers (default 8)")
parser.add_argument("--push", action="store_true", help="Run real probe file push after dry-run")
args = parser.parse_args()
if not args.ids and not args.name:
parser.error("Specify --ids or --name")
if not args.ids and not args.name and not args.non_root_only:
parser.error("Specify --ids, --name, or --non-root-only")
results = asyncio.run(run_verify(args))
return 0 if results and all(r.get("ok") for r in results) else 1
-87
View File
@@ -1,87 +0,0 @@
"""Embedded browser UI preferences API."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.domain.models import Admin
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.browser_ui_state import (
BROWSER_UI_CONTEXT,
merge_browser_state,
parse_browser_state,
record_visit,
)
router = APIRouter(prefix="/api/browser", tags=["browser"])
class BrowserVisitRecord(BaseModel):
url: str
title: str | None = None
class BrowserTabState(BaseModel):
id: str
url: str
title: str | None = None
stack: list[str] | None = None
stack_index: int | None = None
class BrowserPanelRect(BaseModel):
x: float
y: float
w: float
h: float
class BrowserStateUpdate(BaseModel):
url_history: list[str] | None = None
visits: list[dict] | None = None
tabs: list[BrowserTabState] | None = None
active_tab_id: str | None = None
minimized: bool | None = None
rect: BrowserPanelRect | None = None
minimized_rect: BrowserPanelRect | None = None
record_url: str | None = Field(default=None, description="Append one visit + history entry")
record_title: str | None = None
@router.get("/state")
async def get_browser_state(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, BROWSER_UI_CONTEXT)
return parse_browser_state(raw)
@router.put("/state")
async def put_browser_state(
payload: BrowserStateUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
current = parse_browser_state(await repo.get(admin.id, BROWSER_UI_CONTEXT))
incoming = payload.model_dump(exclude_none=True)
if payload.tabs is not None:
incoming["tabs"] = [t.model_dump(exclude_none=True) for t in payload.tabs]
if payload.rect is not None:
incoming["rect"] = payload.rect.model_dump()
if payload.minimized_rect is not None:
incoming["minimized_rect"] = payload.minimized_rect.model_dump()
merged = merge_browser_state(current, incoming)
if payload.record_url:
merged = record_visit(merged, payload.record_url, payload.record_title)
await repo.upsert(admin.id, BROWSER_UI_CONTEXT, merged)
return merged
+2
View File
@@ -130,6 +130,7 @@ class ServerImportResult(BaseModel):
failed: int = 0
errors: List[dict] = [] # [{row, name, domain, error}]
created_ids: List[int] = []
onboarding_job_id: Optional[int] = None
class BatchAgentAction(BaseModel):
@@ -772,6 +773,7 @@ class AddByIpsBatchResult(BaseModel):
input_lines: int = Field(0, description="非空输入行数(去重前)")
duplicates_removed: int = Field(0, description="输入中重复行已忽略的数量")
items: List[AddByIpsBatchItemResult] = []
onboarding_job_id: Optional[int] = None
# ── Platform / Node ──
+176 -10
View File
@@ -5,7 +5,7 @@ Live server status comes from Redis (real-time), base info from MySQL.
All write operations require JWT authentication and produce audit logs.
"""
import asyncio
import json
import logging
from typing import Optional
@@ -49,9 +49,35 @@ router = APIRouter(prefix="/api/servers", tags=["servers"])
REDIS_KEY_PREFIX = "heartbeat:"
async def _schedule_server_onboarding(server_ids: list[int], operator: str) -> int | None:
"""Fire-and-forget batch job: sudoers + detect-path for newly created servers."""
if not server_ids:
return None
from server.application.services.server_onboarding_service import schedule_server_onboarding
job = await schedule_server_onboarding(server_ids, operator=operator or "admin")
if not job:
return None
job_id = job.get("job_id")
return int(job_id) if job_id is not None else None
def _server_has_agent_monitoring(server: Server) -> bool:
"""True when this server is expected to run Nexus Agent (key or version on file)."""
return bool((server.agent_api_key or "").strip() or (server.agent_version or "").strip())
"""True when Agent has actually reported in (version/heartbeat), not key-only."""
from server.utils.agent_version import agent_is_installed
return agent_is_installed(agent_version_db=server.agent_version, heartbeat=None)
def _apply_agent_guidance(server_data: dict, server: Server, heartbeat: dict | None) -> None:
from server.utils.agent_version import compute_agent_guidance
server_data.update(
compute_agent_guidance(
agent_version_db=server.agent_version,
heartbeat=heartbeat,
)
)
# ── CRUD ──
@@ -331,6 +357,116 @@ async def update_server_search_history(
return state
@router.get("/push-search-history", response_model=dict)
async def get_push_search_history(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Return push page server search history (max 5) for the current admin."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
MAX_PUSH_SEARCH_HISTORY,
PUSH_SEARCH_CONTEXT,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, PUSH_SEARCH_CONTEXT)
return parse_search_state(raw, max_history=MAX_PUSH_SEARCH_HISTORY)
@router.put("/push-search-history", response_model=dict)
async def update_push_search_history(
payload: ServerSearchHistoryUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Record a push page search query or replace full history (max 5)."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
MAX_PUSH_SEARCH_HISTORY,
PUSH_SEARCH_CONTEXT,
apply_search_record,
apply_search_replace,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
state = parse_search_state(
await repo.get(admin.id, PUSH_SEARCH_CONTEXT),
max_history=MAX_PUSH_SEARCH_HISTORY,
)
if payload.history is not None:
state = apply_search_replace(
history=payload.history,
last=payload.last,
max_history=MAX_PUSH_SEARCH_HISTORY,
)
else:
state = apply_search_record(
state,
payload.query or "",
max_history=MAX_PUSH_SEARCH_HISTORY,
)
await repo.upsert(admin.id, PUSH_SEARCH_CONTEXT, state)
return state
@router.get("/terminal-search-history", response_model=dict)
async def get_terminal_search_history(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Return terminal page server search history (max 10) for the current admin."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
MAX_TERMINAL_SEARCH_HISTORY,
TERMINAL_SEARCH_CONTEXT,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, TERMINAL_SEARCH_CONTEXT)
return parse_search_state(raw, max_history=MAX_TERMINAL_SEARCH_HISTORY)
@router.put("/terminal-search-history", response_model=dict)
async def update_terminal_search_history(
payload: ServerSearchHistoryUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Record a terminal page search query or replace full history (max 10)."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
MAX_TERMINAL_SEARCH_HISTORY,
TERMINAL_SEARCH_CONTEXT,
apply_search_record,
apply_search_replace,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
state = parse_search_state(
await repo.get(admin.id, TERMINAL_SEARCH_CONTEXT),
max_history=MAX_TERMINAL_SEARCH_HISTORY,
)
if payload.history is not None:
state = apply_search_replace(
history=payload.history,
last=payload.last,
max_history=MAX_TERMINAL_SEARCH_HISTORY,
)
else:
state = apply_search_record(
state,
payload.query or "",
max_history=MAX_TERMINAL_SEARCH_HISTORY,
)
await repo.upsert(admin.id, TERMINAL_SEARCH_CONTEXT, state)
return state
@router.get("/logs", response_model=dict)
async def get_all_sync_logs(
server_id: Optional[int] = Query(None, description="Filter by server ID"),
@@ -590,6 +726,10 @@ async def import_servers(
result.total = result.created + result.skipped + result.failed
onboarding_job_id = await _schedule_server_onboarding(result.created_ids, admin.username)
if onboarding_job_id is not None:
result.onboarding_job_id = onboarding_job_id
# Audit log
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
@@ -961,12 +1101,19 @@ async def add_server_by_ip(
raise HTTPException(409, f"服务器 {item.domain} 已存在于列表中")
if item.status == "created":
return {
onboarding_job_id = None
if item.server_id is not None:
onboarding_job_id = await _schedule_server_onboarding([item.server_id], admin.username)
body: dict = {
"success": True,
"server": {"id": item.server_id, "domain": item.domain},
"matched_preset": item.matched_preset,
"matched_username": item.matched_username,
}
if onboarding_job_id is not None:
body["onboarding_job_id"] = onboarding_job_id
body["onboarding_label"] = "新服务器初始化"
return body
return {
"success": False,
@@ -1022,6 +1169,9 @@ async def add_servers_by_ips_batch(
else:
skipped += 1
onboarding_ids = [i.server_id for i in items if i.status == "created" and i.server_id is not None]
onboarding_job_id = await _schedule_server_onboarding(onboarding_ids, admin.username)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
@@ -1044,6 +1194,7 @@ async def add_servers_by_ips_batch(
input_lines=parsed.input_lines,
duplicates_removed=parsed.duplicates_removed,
items=items,
onboarding_job_id=onboarding_job_id,
)
@@ -1090,12 +1241,17 @@ async def retry_pending_server(
request=request,
)
await pending_repo.delete(pending_id)
return {
onboarding_job_id = await _schedule_server_onboarding([created.id], admin.username)
body: dict = {
"success": True,
"server": _server_to_dict(created),
"matched_preset": poll.match.preset_name,
"matched_username": poll.match.username,
}
if onboarding_job_id is not None:
body["onboarding_job_id"] = onboarding_job_id
body["onboarding_label"] = "新服务器初始化"
return body
err_text = format_poll_errors(poll.errors)
pending.attempts = (pending.attempts or 0) + 1
@@ -1346,7 +1502,12 @@ async def create_server(
ip_address=ip_address,
))
return _server_to_dict(created)
resp = _server_to_dict(created)
onboarding_job_id = await _schedule_server_onboarding([created.id], admin.username)
if onboarding_job_id is not None:
resp["onboarding_job_id"] = onboarding_job_id
resp["onboarding_label"] = "新服务器初始化"
return resp
@router.put("/{id}", response_model=dict)
@@ -1975,7 +2136,9 @@ def _derive_server_status(is_online: bool | None, agent_version: str | None, *,
def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict | None) -> None:
"""Overlay Redis heartbeat onto server_data and refresh derived status."""
has_agent = _server_has_agent_monitoring(server)
from server.utils.agent_version import agent_is_installed
installed = agent_is_installed(agent_version_db=server.agent_version, heartbeat=heartbeat)
if heartbeat:
server_data["is_online"] = heartbeat.get("is_online") == "True"
if heartbeat.get("system_info"):
@@ -1995,21 +2158,22 @@ def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict
server_data["time_drift_seconds"] = 0.0
server_data["drift_level"] = heartbeat.get("drift_level", "ok")
server_data["_source"] = "redis"
elif has_agent:
elif installed:
server_data["is_online"] = False
server_data["_source"] = "redis_miss"
server_data["status"] = _derive_server_status(
server_data.get("is_online"),
server_data.get("agent_version") or server.agent_version,
has_agent=has_agent,
has_agent=installed,
)
_apply_agent_guidance(server_data, server, heartbeat)
def _server_to_dict(server: Server) -> dict:
"""Convert Server model to API response dict (hide sensitive fields)"""
from server.utils.files_elevation import get_files_elevation
return {
data = {
"id": server.id,
"name": server.name,
"domain": server.domain,
@@ -2048,6 +2212,8 @@ def _server_to_dict(server: Server) -> dict:
"created_at": str(server.created_at) if server.created_at else None,
"updated_at": str(server.updated_at) if server.updated_at else None,
}
_apply_agent_guidance(data, server, None)
return data
def _sync_log_to_dict(log, server_name: str = None) -> dict:
+256
View File
@@ -0,0 +1,256 @@
"""Watch slot API — pin CRUD and live metrics."""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.application.services.watch_service import SlotsFullError, WatchService
from server.domain.models import Admin
from server.utils.watch_metrics import WATCH_PIN_TTL_HOURS
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/watch", tags=["watch"])
class WatchPinCreate(BaseModel):
server_id: int = Field(..., gt=0)
slot_index: int | None = Field(None, ge=0, le=3)
replace_slot: int | None = Field(None, ge=0, le=3, description="监测已满时指定替换槽位")
ttl_hours: Literal[8, 24] = Field(
WATCH_PIN_TTL_HOURS,
description="监测窗口时长,默认 8 小时;24 小时需显式指定",
)
class WatchPinReplace(BaseModel):
server_id: int = Field(..., gt=0)
class WatchPinMonitoringUpdate(BaseModel):
monitoring_enabled: bool = Field(..., description="是否开启此槽位实时监测")
class WatchPinTtlUpdate(BaseModel):
ttl_hours: Literal[8, 24] = Field(..., description="监测窗口时长:8 或 24 小时")
def _client_ip(request: Request) -> str:
return request.client.host if request.client else ""
@router.get("/pins")
async def list_watch_pins(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
return await svc.list_slots(admin.id)
@router.post("/pins")
async def create_watch_pin(
payload: WatchPinCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.pin_server(
admin=admin,
server_id=payload.server_id,
slot_index=payload.slot_index,
replace_slot=payload.replace_slot,
ttl_hours=payload.ttl_hours,
ip_address=_client_ip(request),
)
except SlotsFullError as e:
raise HTTPException(status_code=409, detail="监测槽已满,请选择要替换的槽位") from e
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
@router.put("/pins/{slot_index}")
async def replace_watch_pin(
slot_index: int,
payload: WatchPinReplace,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.replace_slot(
admin=admin,
slot_index=slot_index,
server_id=payload.server_id,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
@router.delete("/pins/{slot_index}")
async def delete_watch_pin(
slot_index: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.remove_slot(
admin=admin,
slot_index=slot_index,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.patch("/pins/{slot_index}/monitoring")
async def update_watch_pin_monitoring(
slot_index: int,
payload: WatchPinMonitoringUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.set_slot_monitoring(
admin=admin,
slot_index=slot_index,
monitoring_enabled=payload.monitoring_enabled,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.patch("/pins/{slot_index}/ttl")
async def update_watch_pin_ttl(
slot_index: int,
payload: WatchPinTtlUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.set_slot_ttl(
admin=admin,
slot_index=slot_index,
ttl_hours=payload.ttl_hours,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.get("/metrics")
async def get_watch_metrics(
server_ids: str = Query(..., description="逗号分隔 server_id"),
hours: int = Query(24, ge=1, le=168),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
ids = _parse_server_ids(server_ids)
svc = WatchService(db)
return await svc.get_metrics(admin.id, server_ids=ids, hours=hours)
@router.get("/probe-records")
async def get_watch_probe_records(
server_id: int | None = None,
session_id: int | None = None,
probe_status: str | None = None,
hours: int = Query(24, ge=1, le=168),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
return await svc.get_probe_records(
admin.id,
server_id=server_id,
session_id=session_id,
probe_status=probe_status,
hours=hours,
page=page,
per_page=per_page,
)
@router.get("/sessions")
async def get_watch_sessions(
hours: int = Query(168, ge=1, le=168),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
return await svc.get_sessions(admin.id, hours=hours)
@router.get("/processes/{server_id}")
async def get_watch_processes(
server_id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
pinned = await svc.pinned_server_map(admin.id)
if server_id not in pinned:
raise HTTPException(status_code=404, detail="该服务器不在当前监测槽中")
return await svc.get_processes(server_id)
@router.post("/servers/{server_id}/install-psutil")
async def install_watch_psutil(
server_id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Install psutil on the pinned server via SSH (for watch probe metrics)."""
svc = WatchService(db)
try:
return await svc.install_psutil_ssh(
admin=admin,
server_id=server_id,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
def _parse_server_ids(server_ids: str) -> list[int]:
ids: list[int] = []
for part in server_ids.split(","):
part = part.strip()
if not part:
continue
try:
ids.append(int(part))
except ValueError:
raise HTTPException(status_code=422, detail="server_ids 格式无效") from None
if not ids:
raise HTTPException(status_code=422, detail="server_ids 不能为空")
return ids
@router.get("/live")
async def get_watch_live(
server_ids: str = Query(..., description="逗号分隔 server_id"),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
ids = _parse_server_ids(server_ids)
svc = WatchService(db)
return await svc.get_live_for_servers(ids)
+79 -3
View File
@@ -29,6 +29,7 @@ router = APIRouter()
# Redis Pub/Sub channel names (alerts vs push progress — separate to avoid cross-traffic)
REDIS_CHANNEL = "nexus:alerts"
REDIS_SYNC_CHANNEL = "nexus:sync"
REDIS_WATCH_CHANNEL = "nexus:watch"
class ConnectionManager:
@@ -178,6 +179,7 @@ class ConnectionManager:
# Global connection managers for this worker
manager = ConnectionManager()
sync_manager = ConnectionManager()
watch_manager = ConnectionManager()
# ── Redis Pub/Sub Layer 2 ──
@@ -194,13 +196,14 @@ async def start_redis_subscriber():
from server.infrastructure.redis.client import get_redis
redis = get_redis()
_redis_subscriber = redis.pubsub()
await _redis_subscriber.subscribe(REDIS_CHANNEL, REDIS_SYNC_CHANNEL)
await _redis_subscriber.subscribe(REDIS_CHANNEL, REDIS_SYNC_CHANNEL, REDIS_WATCH_CHANNEL)
_pubsub_task = asyncio.create_task(_redis_listen_loop(), name="ws_redis_sub")
logger.info(
"Redis Pub/Sub subscriber started on channels: %s, %s",
"Redis Pub/Sub subscriber started on channels: %s, %s, %s",
REDIS_CHANNEL,
REDIS_SYNC_CHANNEL,
REDIS_WATCH_CHANNEL,
)
except Exception as e:
logger.error(f"Redis Pub/Sub subscriber failed to start: {e}")
@@ -221,7 +224,7 @@ async def stop_redis_subscriber():
if _redis_subscriber:
try:
await _redis_subscriber.unsubscribe(REDIS_CHANNEL, REDIS_SYNC_CHANNEL)
await _redis_subscriber.unsubscribe(REDIS_CHANNEL, REDIS_SYNC_CHANNEL, REDIS_WATCH_CHANNEL)
await _redis_subscriber.aclose()
except Exception:
logger.debug("Failed to unsubscribe Redis Pub/Sub during shutdown", exc_info=True)
@@ -246,6 +249,8 @@ async def _redis_listen_loop():
msg = json.loads(data)
if channel == REDIS_SYNC_CHANNEL:
await sync_manager.broadcast_local(msg)
elif channel == REDIS_WATCH_CHANNEL:
await _broadcast_watch_local(msg)
else:
await manager.broadcast_local(msg)
except asyncio.CancelledError:
@@ -289,6 +294,77 @@ async def _dispatch_ws_message(message: dict) -> None:
await manager.broadcast_local(message)
async def _broadcast_watch_local(message: dict) -> None:
"""Push watch_metrics to local /ws/watch clients filtered by admin_id."""
admin_ids = message.get("admin_ids") or []
if not admin_ids:
return
admin_set = {int(a) for a in admin_ids}
payload = {
"type": "watch_metrics",
"server_id": message.get("server_id"),
"metrics": message.get("metrics") or {},
}
stale: list[str] = []
for client_id, conn in watch_manager._connections.items():
parts = client_id.split(":", 2)
if len(parts) < 2:
continue
try:
admin_id = int(parts[1])
except ValueError:
continue
if admin_id not in admin_set:
continue
try:
await conn["websocket"].send_json(payload)
except Exception:
stale.append(client_id)
for client_id in stale:
watch_manager.disconnect(client_id)
async def publish_watch_metrics(message: dict) -> None:
"""Publish watch probe update to Redis; local clients via subscriber."""
if _pubsub_task is not None and _redis_subscriber is not None:
if not await _publish_to_redis(message, REDIS_WATCH_CHANNEL):
await _broadcast_watch_local(message)
else:
await _broadcast_watch_local(message)
@router.websocket("/ws/watch")
async def watch_metrics_ws(
websocket: WebSocket,
token: Optional[str] = Query(None, description="JWT access token"),
):
"""WebSocket for real-time watch slot metrics (5s probe updates)."""
if not token:
await websocket.close(code=4001, reason="Missing JWT token")
return
admin = await _verify_ws_token(token)
if not admin:
await websocket.close(code=4401, reason="Invalid or expired JWT token")
return
client_id = f"watch:{admin.id}:{id(websocket)}"
await watch_manager.connect(client_id, websocket)
try:
while True:
data = await websocket.receive_text()
if data == "pong":
watch_manager.update_pong(client_id)
elif data == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
watch_manager.disconnect(client_id)
except Exception as e:
logger.warning(f"Watch WebSocket error for {client_id}: {e}")
watch_manager.disconnect(client_id)
# ── WebSocket Endpoint ──
@router.websocket("/ws/alerts")
+25
View File
@@ -45,6 +45,8 @@ def sudo_wrap(cmd: str, ssh_user: str) -> str:
f"{ssh_user} ALL=(ALL) NOPASSWD: "
"/usr/bin/systemctl, "
"/usr/bin/apt-get, /usr/bin/yum, /usr/bin/dnf, /usr/bin/apk, "
"/usr/bin/python3, /usr/bin/python3.10, /usr/bin/python3.11, /usr/bin/python3.12, "
"/usr/bin/pip3, "
"/usr/bin/rm, /usr/bin/mkdir, /usr/bin/cp, /usr/bin/tee, "
"/usr/bin/curl, /usr/bin/fuser, /usr/bin/kill, /usr/bin/pkill, "
f"{shlex.quote('/opt/nexus-agent/.venv/bin/pip')}*"
@@ -144,6 +146,29 @@ async def update_server_target_path(sid: int, target_dir: str) -> None:
await session.commit()
async def detect_and_save_target_path(server: Server, *, timeout: int = 30) -> dict[str, object]:
"""Find workerman.bat under /www/wwwroot and persist parent dir as target_path."""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
sid = server.id
ssh_user = (server.username or "root").strip() or "root"
cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null"
cmd = sudo_wrap(cmd, ssh_user)
r = await exec_ssh_command(server, cmd, timeout=timeout)
if r["exit_code"] != 0 and not r.get("stdout", "").strip():
return {
"success": False,
"error": f"搜索失败 (exit {r['exit_code']})",
}
found = (r.get("stdout") or "").strip()
if not found:
return {"success": False, "error": "未找到 workerman.bat"}
first_match = found.split("\n")[0].strip()
target_dir = posix_dirname(first_match)
await update_server_target_path(sid, target_dir)
return {"success": True, "target_dir": target_dir, "stdout": f"target_path → {target_dir}"}
async def mark_agent_uninstalled(sid: int) -> None:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -10,14 +10,13 @@ from typing import Any, Optional
from server.application.server_batch_common import (
agent_install_skip_result,
batch_server_display_name,
detect_and_save_target_path,
ensure_agent_api_key,
install_error_msg,
mark_agent_uninstalled,
posix_dirname,
prefetch_batch_context,
result_item_dict,
sudo_wrap,
update_server_target_path,
)
from server.utils.agent_version import get_central_agent_version
from server.config import settings
@@ -38,6 +37,7 @@ OP_LABELS = {
"category": "批量修改分类",
"health-check": "健康检查",
"detect-path": "自动检测目标路径",
"onboard": "新服务器初始化",
"install-agent": "批量安装 Agent",
"upgrade-agent": "批量升级 Agent",
"uninstall-agent": "批量卸载 Agent",
@@ -47,6 +47,7 @@ AUDIT_ACTIONS = {
"category": "batch_update_category",
"health-check": "batch_health_check",
"detect-path": "batch_detect_path",
"onboard": "batch_server_onboard",
"install-agent": "batch_install_agent",
"upgrade-agent": "batch_upgrade_agent",
"uninstall-agent": "batch_uninstall_agent",
@@ -325,6 +326,8 @@ async def _run_background(job_id: int) -> None:
await _run_health_check(live)
elif op == "detect-path":
await _run_detect_path(live)
elif op == "onboard":
await _run_onboard(live)
elif op == "install-agent":
await _run_install_agent(live)
elif op == "upgrade-agent":
@@ -373,6 +376,7 @@ async def _write_audit(live: dict[str, Any]) -> None:
"category": f"批量修改分类 更新{stats['success']}",
"health-check": f"批量健康检查: {stats['success']}/{stats['total']} 在线",
"detect-path": f"批量检测路径: {stats['success']}/{stats['total']} 成功",
"onboard": f"新服务器初始化: {stats['success']}/{stats['total']} 成功",
"install-agent": f"批量安装Agent: {stats['success']}/{stats['total']} 成功",
"upgrade-agent": f"批量升级Agent: {stats['success']}/{stats['total']} 成功",
"uninstall-agent": f"批量卸载Agent: {stats['success']}/{stats['total']} 成功",
@@ -497,24 +501,19 @@ async def _run_detect_path(live: dict[str, Any]) -> None:
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
ssh_user = (server.username or "root").strip()
cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null"
cmd = sudo_wrap(cmd, ssh_user)
r = await exec_ssh_command(server, cmd, timeout=30)
if r["exit_code"] != 0 and not r.get("stdout", "").strip():
outcome = await detect_and_save_target_path(server)
if outcome.get("success"):
return result_item_dict(
server_id=sid, server_name=label, success=False,
error=f"搜索失败 (exit {r['exit_code']})",
server_id=sid,
server_name=label,
success=True,
stdout=str(outcome.get("stdout") or ""),
)
found = r["stdout"].strip()
if not found:
return result_item_dict(server_id=sid, server_name=label, success=False, error="未找到 workerman.bat")
first_match = found.split("\n")[0].strip()
target_dir = posix_dirname(first_match)
await update_server_target_path(sid, target_dir)
return result_item_dict(
server_id=sid, server_name=label, success=True,
stdout=f"target_path → {target_dir}",
server_id=sid,
server_name=label,
success=False,
error=str(outcome.get("error") or "探测失败"),
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
@@ -522,6 +521,61 @@ async def _run_detect_path(live: dict[str, Any]) -> None:
await _run_with_semaphore(live, handler)
async def _run_onboard(live: dict[str, Any]) -> None:
from server.application.services.files_sudoers_service import install_nexus_files_sudoers
from server.utils.posix_paths import is_unset_target_path
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
steps: list[str] = []
try:
ssh_user = (server.username or "root").strip() or "root"
if ssh_user == "root":
steps.append("sudo: skip(root)")
else:
sudo_outcome = await install_nexus_files_sudoers(server)
action = sudo_outcome.get("action") or "unknown"
if not sudo_outcome.get("ok"):
detail = sudo_outcome.get("detail") or action
return result_item_dict(
server_id=sid,
server_name=label,
success=False,
error=f"sudo 配置失败: {detail}",
stdout="; ".join(steps),
)
steps.append(f"sudo: {action}")
if is_unset_target_path(server.target_path):
detect_outcome = await detect_and_save_target_path(server)
if detect_outcome.get("success"):
steps.append(str(detect_outcome.get("stdout") or "path: ok"))
else:
steps.append(f"path: {detect_outcome.get('error') or 'fail'}(推送仍可用 /www/wwwroot")
else:
steps.append("path: already_set")
return result_item_dict(
server_id=sid,
server_name=label,
success=True,
stdout="; ".join(steps),
)
except Exception as e:
return result_item_dict(
server_id=sid,
server_name=label,
success=False,
error=str(e)[:300],
stdout="; ".join(steps),
)
await _run_with_semaphore(live, handler)
async def _run_install_agent(live: dict[str, Any]) -> None:
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
@@ -0,0 +1,30 @@
"""Schedule post-create server onboarding (sudoers + target path detect)."""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger("nexus.server_onboarding")
async def schedule_server_onboarding(
server_ids: list[int],
*,
operator: str,
) -> dict[str, Any] | None:
"""Start background batch job ``onboard`` for *server_ids*; returns job dict or None."""
ids = [int(x) for x in server_ids if x]
if not ids:
return None
from server.application.services.server_batch_service import start_batch_job
try:
return await start_batch_job(
op="onboard",
server_ids=ids,
operator=operator or "admin",
)
except Exception as exc:
logger.warning("schedule_server_onboarding failed ids=%s: %s", ids, exc)
return None
@@ -25,6 +25,7 @@ from server.utils.posix_paths import (
UPLOAD_STAGING_PREFIX,
normalize_remote_abs_path,
resolve_nexus_push_source_path,
resolve_push_target_path,
to_posix,
)
@@ -147,7 +148,7 @@ class SyncEngineV2:
sync_log = SyncLog(
server_id=server.id,
source_path=source_path,
target_path=target_path or server.target_path or "/tmp/sync",
target_path=resolve_push_target_path(server.target_path, target_path),
trigger_type=trigger_type,
operator=operator,
status="cancelled",
@@ -181,7 +182,7 @@ class SyncEngineV2:
except Exception as e:
logger.warning(f"Cancel check failed for batch {batch_id}, proceeding with push: {e}")
dest = target_path or server.target_path or "/tmp/sync"
dest = resolve_push_target_path(server.target_path, target_path)
sync_log = SyncLog(
server_id=server.id,
source_path=source_path,
@@ -383,7 +384,7 @@ class SyncEngineV2:
except PosixPathError as exc:
return {"error": str(exc), "exit_code": -1}
dest = target_path or server.target_path or "/tmp/sync"
dest = resolve_push_target_path(server.target_path, target_path)
result = await _rsync_push(server, source_path, dest, sync_mode, dry_run=True, verbose=verbose)
if result["exit_code"] not in (0, 23):
@@ -0,0 +1,484 @@
"""Watch slot business logic — pin CRUD and live metrics assembly."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Admin, AuditLog, Server
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.watch_repo import WatchRepositoryImpl
from server.infrastructure.redis.client import get_redis
from server.utils.watch_state import clear_watch_redis_snapshot
from server.utils.watch_metrics import (
WATCH_MAX_SLOTS,
WATCH_PIN_TTL_HOURS,
normalize_watch_ttl_hours,
server_can_be_watched,
)
REDIS_WATCH_LIVE_PREFIX = "watch:live:"
REDIS_WATCH_PROC_PREFIX = "watch:proc:"
WATCH_SPARKLINE_MAX = 180 # 15 min @ 5s
def _utc_naive_now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _remaining_sec(expires_at: datetime) -> int:
now = _utc_naive_now()
return max(0, int((expires_at - now).total_seconds()))
async def _load_live_metrics(server_id: int) -> dict[str, Any] | None:
redis = get_redis()
raw = await redis.lindex(f"{REDIS_WATCH_LIVE_PREFIX}{server_id}", 0)
if not raw:
return None
try:
data = json.loads(raw)
return data if isinstance(data, dict) else None
except json.JSONDecodeError:
return None
async def _load_sparkline(server_id: int) -> list[dict[str, Any]]:
redis = get_redis()
rows = await redis.lrange(f"{REDIS_WATCH_LIVE_PREFIX}{server_id}", 0, WATCH_SPARKLINE_MAX - 1)
out: list[dict[str, Any]] = []
for raw in reversed(rows):
try:
point = json.loads(raw)
if isinstance(point, dict):
out.append(point)
except json.JSONDecodeError:
continue
return out
async def _load_processes(server_id: int) -> list[dict[str, Any]] | None:
redis = get_redis()
raw = await redis.get(f"{REDIS_WATCH_PROC_PREFIX}{server_id}")
if not raw:
return None
try:
data = json.loads(raw)
return data if isinstance(data, list) else None
except json.JSONDecodeError:
return None
async def _restore_idle_agent_watch(watch_repo: WatchRepositoryImpl, server_id: int) -> None:
"""Stop 5s watch snapshots when no slot actively monitors this server (Agent stays 60s)."""
if await watch_repo.count_monitoring_pins_for_server(server_id) == 0:
await clear_watch_redis_snapshot(server_id)
async def _finalize_due_pins(watch_repo: WatchRepositoryImpl, db: AsyncSession) -> None:
"""Pause TTL-expired pins on read/write paths (probe loop also runs this every 5s)."""
expired_servers = await watch_repo.expire_due_pins()
if not expired_servers:
return
for server_id in expired_servers:
if await watch_repo.count_monitoring_pins_for_server(server_id) == 0:
await clear_watch_redis_snapshot(server_id)
await db.commit()
async def _slot_payload(
*,
slot_index: int,
pin_row: dict[str, Any] | None,
) -> dict[str, Any]:
if not pin_row:
return {"slot_index": slot_index, "empty": True}
monitoring_enabled = pin_row.get("monitoring_enabled", True)
metrics = None
sparkline: list[dict[str, Any]] = []
processes = None
if monitoring_enabled:
metrics = await _load_live_metrics(pin_row["server_id"])
sparkline = await _load_sparkline(pin_row["server_id"])
processes = await _load_processes(pin_row["server_id"])
if metrics and processes:
metrics = {**metrics, "processes": processes}
remaining = _remaining_sec(pin_row["expires_at"]) if monitoring_enabled else None
return {
"slot_index": slot_index,
"empty": False,
"pin_id": pin_row["pin_id"],
"server_id": pin_row["server_id"],
"server_name": pin_row.get("server_name") or "",
"session_id": pin_row["session_id"],
"pinned_at": pin_row["pinned_at"].isoformat() if pin_row.get("pinned_at") else None,
"expires_at": pin_row["expires_at"].isoformat() if pin_row.get("expires_at") else None,
"remaining_sec": remaining,
"monitoring_enabled": monitoring_enabled,
"ttl_hours": int(pin_row.get("ttl_hours") or WATCH_PIN_TTL_HOURS),
"metrics": metrics,
"sparkline": sparkline,
"processes": processes,
}
class WatchService:
def __init__(self, db: AsyncSession):
self.db = db
self.watch_repo = WatchRepositoryImpl(db)
self.server_repo = ServerRepositoryImpl(db)
self.audit_repo = AuditLogRepositoryImpl(db)
async def list_slots(self, admin_id: int) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
pins = await self.watch_repo.list_active_pins_for_admin(admin_id)
by_slot = {p["slot_index"]: p for p in pins}
slots = [
await _slot_payload(slot_index=i, pin_row=by_slot.get(i))
for i in range(WATCH_MAX_SLOTS)
]
return {"slots": slots}
async def pin_server(
self,
*,
admin: Admin,
server_id: int,
slot_index: int | None = None,
replace_slot: int | None = None,
ttl_hours: int = WATCH_PIN_TTL_HOURS,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
hours = normalize_watch_ttl_hours(ttl_hours)
server = await self.server_repo.get_by_id(server_id)
if not server:
raise ValueError("服务器不存在")
ok, reason = server_can_be_watched(server)
if not ok:
raise ValueError(reason)
existing = await self.watch_repo.get_pin_by_server(admin.id, server_id)
if existing:
await self.watch_repo.reset_pin_ttl(admin.id, server_id, hours)
await self._audit(
admin=admin,
action="watch_pin_add",
server=server,
detail=f"重置监测 {hours}h · 槽 {existing.slot_index + 1}",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
if slot_index is not None:
await self.watch_repo.replace_pin_server(
admin_id=admin.id,
slot_index=slot_index,
server_id=server_id,
ttl_hours=hours,
)
await self._audit(
admin=admin,
action="watch_pin_replace" if replace_slot is not None else "watch_pin_add",
server=server,
detail=f"加入监测 · 槽 {slot_index + 1} · {hours}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
empty = await self.watch_repo.first_empty_slot(admin.id)
if empty is not None:
await self.watch_repo.create_pin(
admin_id=admin.id,
server_id=server_id,
slot_index=empty,
ttl_hours=hours,
)
await self._audit(
admin=admin,
action="watch_pin_add",
server=server,
detail=f"加入监测 · 槽 {empty + 1} · {hours}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
if replace_slot is not None:
await self.watch_repo.replace_pin_server(
admin_id=admin.id,
slot_index=replace_slot,
server_id=server_id,
ttl_hours=hours,
)
await self._audit(
admin=admin,
action="watch_pin_replace",
server=server,
detail=f"替换监测 · 槽 {replace_slot + 1} · {hours}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
raise SlotsFullError()
async def replace_slot(
self,
*,
admin: Admin,
slot_index: int,
server_id: int,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效")
server = await self.server_repo.get_by_id(server_id)
if not server:
raise ValueError("服务器不存在")
ok, reason = server_can_be_watched(server)
if not ok:
raise ValueError(reason)
other = await self.watch_repo.get_pin_by_server(admin.id, server_id)
if other and other.slot_index != slot_index:
await self.watch_repo.end_session(other.session_id, "replaced")
await self.watch_repo.delete_pin(other.id)
existing = await self.watch_repo.get_pin_by_slot(admin.id, slot_index)
slot_ttl = normalize_watch_ttl_hours(int(existing.ttl_hours or 8)) if existing else WATCH_PIN_TTL_HOURS
await self.watch_repo.replace_pin_server(
admin_id=admin.id,
slot_index=slot_index,
server_id=server_id,
ttl_hours=slot_ttl,
)
await self._audit(
admin=admin,
action="watch_pin_replace",
server=server,
detail=f"替换监测 · 槽 {slot_index + 1} · {slot_ttl}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
async def remove_slot(
self,
*,
admin: Admin,
slot_index: int,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效")
pin = await self.watch_repo.remove_pin_by_slot(admin.id, slot_index)
if not pin:
raise ValueError("槽位为空")
server_id = pin.server_id
server = await self.server_repo.get_by_id(server_id)
await self._audit(
admin=admin,
action="watch_pin_remove",
server=server,
detail=f"移除监测 · 槽 {slot_index + 1}",
ip_address=ip_address,
)
await self.db.commit()
await _restore_idle_agent_watch(self.watch_repo, server_id)
return await self.list_slots(admin.id)
async def set_slot_monitoring(
self,
*,
admin: Admin,
slot_index: int,
monitoring_enabled: bool,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效")
pin = await self.watch_repo.set_monitoring_enabled(
admin.id,
slot_index,
monitoring_enabled,
)
if not pin:
raise ValueError("槽位为空")
server = await self.server_repo.get_by_id(pin.server_id)
hours = int(pin.ttl_hours or WATCH_PIN_TTL_HOURS)
action = "watch_pin_resume" if monitoring_enabled else "watch_pin_pause"
detail = (
f"开启实时监测 · 槽 {slot_index + 1} · 重置 {hours}h"
if monitoring_enabled
else f"暂停实时监测 · 槽 {slot_index + 1} · 恢复 Agent 60s"
)
await self._audit(
admin=admin,
action=action,
server=server,
detail=detail,
ip_address=ip_address,
)
await self.db.commit()
if not monitoring_enabled:
await _restore_idle_agent_watch(self.watch_repo, pin.server_id)
return await self.list_slots(admin.id)
async def set_slot_ttl(
self,
*,
admin: Admin,
slot_index: int,
ttl_hours: int,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效")
hours = normalize_watch_ttl_hours(ttl_hours)
pin = await self.watch_repo.set_pin_ttl_hours(admin.id, slot_index, hours)
if not pin:
raise ValueError("槽位为空")
server = await self.server_repo.get_by_id(pin.server_id)
await self._audit(
admin=admin,
action="watch_pin_ttl",
server=server,
detail=f"监测时长 · 槽 {slot_index + 1} · {hours}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
async def get_live_for_servers(self, server_ids: list[int]) -> dict[str, Any]:
items: list[dict[str, Any]] = []
for sid in server_ids:
metrics = await _load_live_metrics(sid)
sparkline = await _load_sparkline(sid)
items.append({"server_id": sid, "metrics": metrics, "sparkline": sparkline})
return {"items": items}
async def pinned_server_map(self, admin_id: int) -> dict[int, int]:
"""server_id → slot_index (0-based) for list UI."""
pins = await self.watch_repo.list_active_pins_for_admin(admin_id)
return {p["server_id"]: p["slot_index"] for p in pins}
async def get_metrics(
self,
admin_id: int,
*,
server_ids: list[int],
hours: int = 24,
) -> dict[str, Any]:
since = _utc_naive_now() - timedelta(hours=max(1, min(hours, 168)))
points = await self.watch_repo.list_metrics_series(
admin_id,
server_ids=server_ids,
since=since,
ok_only=True,
)
return {"points": points, "hours": hours}
async def get_probe_records(
self,
admin_id: int,
*,
server_id: int | None = None,
session_id: int | None = None,
probe_status: str | None = None,
hours: int = 24,
page: int = 1,
per_page: int = 50,
) -> dict[str, Any]:
since = _utc_naive_now() - timedelta(hours=max(1, min(hours, 168)))
items, total = await self.watch_repo.list_probe_records(
admin_id,
server_id=server_id,
session_id=session_id,
probe_status=probe_status,
since=since,
page=page,
per_page=per_page,
)
return {"items": items, "total": total, "page": page, "per_page": per_page}
async def get_sessions(self, admin_id: int, *, hours: int = 168) -> dict[str, Any]:
since = _utc_naive_now() - timedelta(hours=max(1, min(hours, 168)))
sessions = await self.watch_repo.list_sessions(admin_id, since=since)
return {"sessions": sessions, "hours": hours}
async def get_processes(self, server_id: int) -> dict[str, Any]:
processes = await _load_processes(server_id)
return {"server_id": server_id, "processes": processes or []}
async def install_psutil_ssh(
self,
*,
admin: Admin,
server_id: int,
ip_address: str = "",
) -> dict[str, Any]:
pinned = await self.pinned_server_map(admin.id)
if server_id not in pinned:
raise ValueError("该服务器不在当前监测槽中")
server = await self.server_repo.get_by_id(server_id)
if not server:
raise ValueError("服务器不存在")
from server.utils.psutil_install import install_psutil_via_ssh
try:
result = await install_psutil_via_ssh(server)
except Exception as e:
raise ValueError(f"SSH 连接失败: {e}") from e
if not result.get("success"):
msg = str(result.get("message") or "psutil 安装失败")
raise ValueError(msg)
await self._audit(
admin=admin,
action="watch_install_psutil",
server=server,
detail=f"SSH 安装 psutil · {server.name or server_id}",
ip_address=ip_address,
)
await self.db.commit()
return {
"success": True,
"server_id": server_id,
"message": result.get("message") or "psutil 已就绪",
}
async def _audit(
self,
*,
admin: Admin,
action: str,
server: Server | None,
detail: str,
ip_address: str,
) -> None:
await self.audit_repo.create(
AuditLog(
admin_username=admin.username,
action=action,
target_type="server",
target_id=server.id if server else 0,
detail=detail,
ip_address=ip_address,
)
)
class SlotsFullError(Exception):
"""All 4 watch slots occupied."""
+302
View File
@@ -0,0 +1,302 @@
"""Background watch probe loop — 5s sampling for pinned servers."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
from server.domain.models import Server
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.watch_repo import WatchRepositoryImpl
from server.infrastructure.redis.client import get_redis
from server.infrastructure.ssh.remote_probe import ssh_watch_probe, ssh_watch_processes
from server.utils.watch_alerts import process_watch_probe_alerts
from server.utils.watch_probe_errors import watch_probe_error_zh
from server.utils.watch_state import clear_watch_redis_snapshot
from server.utils.watch_metrics import (
WATCH_PROBE_INTERVAL_SEC,
WATCH_PROBE_TIMEOUT_SEC,
WATCH_REDIS_FRESH_SEC,
WatchProbeSample,
_RateState,
apply_rates_to_sample,
failure_sample,
merge_redis_and_ssh,
parse_redis_watch_payload,
parse_ssh_watch_payload,
redis_sample_has_io,
redis_sample_has_basic_metrics,
)
logger = logging.getLogger("nexus.watch_probe")
REDIS_HEARTBEAT_PREFIX = "heartbeat:"
REDIS_WATCH_LAST_PREFIX = "watch:last:"
REDIS_WATCH_LIVE_PREFIX = "watch:live:"
REDIS_WATCH_PROC_PREFIX = "watch:proc:"
WATCH_LIVE_MAX_POINTS = 360
WATCH_RECORDS_RETENTION_DAYS = 7
_process_cycle: dict[int, int] = defaultdict(int)
def _utc_naive_now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _heartbeat_fresh(heartbeat: dict[str, str]) -> bool:
if not heartbeat or heartbeat.get("is_online") != "True":
return False
ts = heartbeat.get("last_heartbeat") or ""
if not ts:
return False
try:
agent_ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if agent_ts.tzinfo is None:
agent_ts = agent_ts.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - agent_ts).total_seconds()
return age <= WATCH_REDIS_FRESH_SEC
except Exception:
return False
async def _load_rate_state(redis, server_id: int) -> _RateState | None:
raw = await redis.get(f"{REDIS_WATCH_LAST_PREFIX}{server_id}")
if not raw:
return None
try:
data = json.loads(raw)
return _RateState(
net_bytes_sent=data.get("net_bytes_sent"),
net_bytes_recv=data.get("net_bytes_recv"),
disk_read_bytes=data.get("disk_read_bytes"),
disk_write_bytes=data.get("disk_write_bytes"),
recorded_at_ts=data.get("recorded_at_ts"),
)
except (json.JSONDecodeError, TypeError):
return None
async def _save_rate_state(redis, server_id: int, state: _RateState) -> None:
await redis.set(
f"{REDIS_WATCH_LAST_PREFIX}{server_id}",
json.dumps(
{
"net_bytes_sent": state.net_bytes_sent,
"net_bytes_recv": state.net_bytes_recv,
"disk_read_bytes": state.disk_read_bytes,
"disk_write_bytes": state.disk_write_bytes,
"recorded_at_ts": state.recorded_at_ts,
}
),
ex=600,
)
async def _append_live_point(redis, server_id: int, point: dict[str, Any]) -> None:
key = f"{REDIS_WATCH_LIVE_PREFIX}{server_id}"
await redis.lpush(key, json.dumps(point))
await redis.ltrim(key, 0, WATCH_LIVE_MAX_POINTS - 1)
await redis.expire(key, 3600)
async def _save_processes(redis, server_id: int, processes: list[dict[str, Any]] | None) -> None:
if not processes:
return
await redis.set(f"{REDIS_WATCH_PROC_PREFIX}{server_id}", json.dumps(processes), ex=60)
async def probe_server_metrics(
server: Server,
heartbeat: dict[str, str] | None,
*,
fetch_processes: bool = False,
) -> WatchProbeSample:
redis_sample: WatchProbeSample | None = None
if heartbeat and _heartbeat_fresh(heartbeat):
redis_sample = parse_redis_watch_payload(heartbeat)
if redis_sample is not None and redis_sample_has_io(redis_sample):
sample = redis_sample
elif redis_sample is not None and redis_sample_has_basic_metrics(redis_sample):
# Agent CPU/mem available — SSH only supplements IO; SSH failure must not block display
ssh_result = await ssh_watch_probe(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
if ssh_result.get("ok"):
ssh_sample = parse_ssh_watch_payload(ssh_result["payload"])
ssh_sample.duration_ms = int(ssh_result.get("duration_ms") or 0)
sample = merge_redis_and_ssh(redis_sample, ssh_sample)
else:
sample = redis_sample
else:
ssh_result = await ssh_watch_probe(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
if ssh_result.get("ok"):
ssh_sample = parse_ssh_watch_payload(ssh_result["payload"])
ssh_sample.duration_ms = int(ssh_result.get("duration_ms") or 0)
if redis_sample is not None:
sample = merge_redis_and_ssh(redis_sample, ssh_sample)
else:
sample = ssh_sample
elif redis_sample is not None:
redis_sample.duration_ms = int(ssh_result.get("duration_ms") or 0)
sample = redis_sample
else:
status = ssh_result.get("probe_status") or "offline"
if not heartbeat and status == "offline":
return failure_sample(
status="no_cred",
source="ssh",
error=watch_probe_error_zh(
ssh_result.get("error") or "无 Agent 且 SSH 探针失败",
status="no_cred",
) or "无 Agent 且 SSH 探针失败",
duration_ms=int(ssh_result.get("duration_ms") or 0),
)
return failure_sample(
status=status,
source="ssh",
error=watch_probe_error_zh(ssh_result.get("error") or "探针失败", status=status) or "探针失败",
duration_ms=int(ssh_result.get("duration_ms") or 0),
)
if fetch_processes and sample.probe_status == "ok":
procs = await ssh_watch_processes(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
if procs:
sample.processes_json = procs
return sample
async def _run_probe_cycle() -> None:
async with AsyncSessionLocal() as db:
watch_repo = WatchRepositoryImpl(db)
server_repo = ServerRepositoryImpl(db)
expired_servers = await watch_repo.expire_due_pins()
for server_id in expired_servers:
if await watch_repo.count_monitoring_pins_for_server(server_id) == 0:
await clear_watch_redis_snapshot(server_id)
pins = await watch_repo.list_active_pins_all(monitoring_only=True)
if not pins:
await db.commit()
return
by_server: dict[int, list[dict[str, Any]]] = defaultdict(list)
for p in pins:
by_server[p["server_id"]].append(p)
redis = get_redis()
now = _utc_naive_now()
now_ts = time.time()
for server_id, pin_rows in by_server.items():
server = await server_repo.get_by_id(server_id)
if not server:
sample = failure_sample(status="offline", source="ssh", error="服务器不存在")
server_name = f"server-{server_id}"
else:
server_name = server.name or f"server-{server_id}"
heartbeat = await redis.hgetall(f"{REDIS_HEARTBEAT_PREFIX}{server_id}")
_process_cycle[server_id] += 1
fetch_procs = _process_cycle[server_id] % 2 == 0
sample = await probe_server_metrics(
server,
heartbeat or None,
fetch_processes=fetch_procs,
)
prev = await _load_rate_state(redis, server_id)
new_state = apply_rates_to_sample(sample, prev, now_ts)
await _save_rate_state(redis, server_id, new_state)
live = sample.to_live_dict(server_id=server_id)
live["ts"] = now.isoformat()
await _append_live_point(redis, server_id, live)
if sample.processes_json:
await _save_processes(redis, server_id, sample.processes_json)
if sample.probe_status == "ok" and server:
try:
await process_watch_probe_alerts(
server_id=server_id,
server_name=server_name,
sample=sample,
)
except Exception:
logger.debug("watch alert processing failed", exc_info=True)
admin_ids = [r["admin_id"] for r in pin_rows]
try:
from server.api.websocket import publish_watch_metrics
await publish_watch_metrics(
{
"type": "watch_metrics",
"server_id": server_id,
"admin_ids": admin_ids,
"metrics": live,
}
)
except Exception:
logger.debug("watch WS publish failed", exc_info=True)
for row in pin_rows:
await watch_repo.insert_probe_record(
session_id=row["session_id"],
server_id=server_id,
admin_id=row["admin_id"],
recorded_at=now,
source=sample.source,
probe_status=sample.probe_status,
duration_ms=sample.duration_ms,
is_online=sample.is_online,
cpu_pct=sample.cpu_pct,
mem_pct=sample.mem_pct,
disk_pct=sample.disk_pct,
disk_mount=sample.disk_mount,
load_1=sample.load_1,
load_5=sample.load_5,
load_15=sample.load_15,
net_bytes_sent=sample.net_bytes_sent,
net_bytes_recv=sample.net_bytes_recv,
disk_read_bytes=sample.disk_read_bytes,
disk_write_bytes=sample.disk_write_bytes,
net_up_bps=sample.net_up_bps,
net_down_bps=sample.net_down_bps,
disk_read_bps=sample.disk_read_bps,
disk_write_bps=sample.disk_write_bps,
processes_json=sample.processes_json,
error=sample.error,
)
await db.commit()
async def _purge_old_records() -> None:
cutoff = _utc_naive_now() - timedelta(days=WATCH_RECORDS_RETENTION_DAYS)
async with AsyncSessionLocal() as db:
repo = WatchRepositoryImpl(db)
deleted = await repo.purge_records_before(cutoff)
await db.commit()
if deleted:
logger.info("Purged %s watch_probe_records older than %s days", deleted, WATCH_RECORDS_RETENTION_DAYS)
async def watch_probe_loop() -> None:
"""Run watch probes every 5 seconds (primary worker only)."""
purge_counter = 0
while True:
try:
await _run_probe_cycle()
purge_counter += 1
if purge_counter >= 720: # ~1h at 5s
purge_counter = 0
await _purge_old_records()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("watch_probe cycle failed")
await asyncio.sleep(WATCH_PROBE_INTERVAL_SEC)
+89 -1
View File
@@ -6,7 +6,7 @@ import datetime
from datetime import timezone
import uuid
from sqlalchemy import (
Column, Integer, String, Boolean,
Column, Integer, BigInteger, Float, String, Boolean,
DateTime, Text, ForeignKey, Index, JSON
)
from sqlalchemy.dialects.mysql import MEDIUMTEXT
@@ -154,6 +154,94 @@ class FleetMetricSample(Base):
)
class WatchPinSession(Base):
"""One 8h monitoring session when admin pins a server to a watch slot."""
__tablename__ = "watch_pin_sessions"
id = Column(Integer, primary_key=True, autoincrement=True)
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="CASCADE"), nullable=False)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
slot_index = Column(Integer, nullable=False, comment="槽位 0-3")
started_at = Column(DateTime, nullable=False, comment="UTC naive")
ended_at = Column(DateTime, nullable=True, comment="UTC naive")
end_reason = Column(String(16), nullable=True, comment="expired|manual|replaced")
__table_args__ = (
Index("idx_watch_pin_sess_admin_started", "admin_id", "started_at"),
Index("idx_watch_pin_sess_server_started", "server_id", "started_at"),
)
class WatchPin(Base):
"""Active watch slot (max 4 per admin); removed when expired or manual delete."""
__tablename__ = "watch_pins"
id = Column(Integer, primary_key=True, autoincrement=True)
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="CASCADE"), nullable=False)
session_id = Column(Integer, ForeignKey("watch_pin_sessions.id", ondelete="CASCADE"), nullable=False)
slot_index = Column(Integer, nullable=False, comment="槽位 0-3")
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
pinned_at = Column(DateTime, nullable=False, comment="UTC naive")
expires_at = Column(DateTime, nullable=False, comment="UTC naive")
monitoring_enabled = Column(
Boolean,
nullable=False,
default=True,
comment="是否开启实时探针(关闭时保留槽位、暂停倒计时)",
)
ttl_hours = Column(
Integer,
nullable=False,
default=8,
comment="监测窗口时长(小时),仅允许 8 或 24",
)
__table_args__ = (
Index("uq_watch_pins_admin_slot", "admin_id", "slot_index", unique=True),
Index("uq_watch_pins_admin_server", "admin_id", "server_id", unique=True),
Index("idx_watch_pins_expires", "expires_at"),
)
class WatchProbeRecord(Base):
"""Per-probe sample (5s) while server is pinned — success or failure."""
__tablename__ = "watch_probe_records"
id = Column(Integer, primary_key=True, autoincrement=True)
session_id = Column(Integer, ForeignKey("watch_pin_sessions.id", ondelete="CASCADE"), nullable=False)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="CASCADE"), nullable=False)
recorded_at = Column(DateTime, nullable=False, comment="UTC naive")
source = Column(String(16), nullable=False, comment="redis|ssh|mixed")
probe_status = Column(String(24), nullable=False, comment="ok|ssh_timeout|...")
duration_ms = Column(Integer, nullable=False, default=0)
is_online = Column(Boolean, nullable=False, default=False)
cpu_pct = Column(Integer, nullable=True)
mem_pct = Column(Integer, nullable=True)
disk_pct = Column(Integer, nullable=True)
disk_mount = Column(String(255), nullable=True, default="/")
load_1 = Column(Float, nullable=True)
load_5 = Column(Float, nullable=True)
load_15 = Column(Float, nullable=True)
net_bytes_sent = Column(BigInteger, nullable=True)
net_bytes_recv = Column(BigInteger, nullable=True)
disk_read_bytes = Column(BigInteger, nullable=True)
disk_write_bytes = Column(BigInteger, nullable=True)
net_up_bps = Column(BigInteger, nullable=True)
net_down_bps = Column(BigInteger, nullable=True)
disk_read_bps = Column(BigInteger, nullable=True)
disk_write_bps = Column(BigInteger, nullable=True)
processes_json = Column(JSON, nullable=True, comment="Top5 processes snapshot")
error = Column(String(255), nullable=True)
__table_args__ = (
Index("idx_watch_probe_sess_recorded", "session_id", "recorded_at"),
Index("idx_watch_probe_server_recorded", "server_id", "recorded_at"),
Index("idx_watch_probe_admin_recorded", "admin_id", "recorded_at"),
Index("idx_watch_probe_status_recorded", "probe_status", "recorded_at"),
)
class SyncLog(Base):
"""Push operation log"""
__tablename__ = "sync_logs"
@@ -218,6 +218,73 @@ async def run_schema_migrations():
PRIMARY KEY (`admin_id`, `context`),
CONSTRAINT `fk_admin_ui_pref_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS `watch_pin_sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`admin_id` INT NOT NULL,
`server_id` INT NOT NULL,
`slot_index` INT NOT NULL,
`started_at` DATETIME NOT NULL,
`ended_at` DATETIME NULL,
`end_reason` VARCHAR(16) NULL,
INDEX `idx_watch_pin_sess_admin_started` (`admin_id`, `started_at`),
INDEX `idx_watch_pin_sess_server_started` (`server_id`, `started_at`),
CONSTRAINT `fk_watch_sess_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_watch_sess_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS `watch_pins` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`admin_id` INT NOT NULL,
`session_id` INT NOT NULL,
`slot_index` INT NOT NULL,
`server_id` INT NOT NULL,
`pinned_at` DATETIME NOT NULL,
`expires_at` DATETIME NOT NULL,
`monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针',
`ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24',
UNIQUE KEY `uq_watch_pins_admin_slot` (`admin_id`, `slot_index`),
UNIQUE KEY `uq_watch_pins_admin_server` (`admin_id`, `server_id`),
INDEX `idx_watch_pins_expires` (`expires_at`),
CONSTRAINT `fk_watch_pin_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_watch_pin_session` FOREIGN KEY (`session_id`) REFERENCES `watch_pin_sessions` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_watch_pin_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS `watch_probe_records` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`session_id` INT NOT NULL,
`server_id` INT NOT NULL,
`admin_id` INT NOT NULL,
`recorded_at` DATETIME NOT NULL,
`source` VARCHAR(16) NOT NULL,
`probe_status` VARCHAR(24) NOT NULL,
`duration_ms` INT NOT NULL DEFAULT 0,
`is_online` TINYINT(1) NOT NULL DEFAULT 0,
`cpu_pct` INT NULL,
`mem_pct` INT NULL,
`disk_pct` INT NULL,
`disk_mount` VARCHAR(255) NULL DEFAULT '/',
`load_1` DOUBLE NULL,
`load_5` DOUBLE NULL,
`load_15` DOUBLE NULL,
`net_bytes_sent` BIGINT NULL,
`net_bytes_recv` BIGINT NULL,
`disk_read_bytes` BIGINT NULL,
`disk_write_bytes` BIGINT NULL,
`net_up_bps` BIGINT NULL,
`net_down_bps` BIGINT NULL,
`disk_read_bps` BIGINT NULL,
`disk_write_bps` BIGINT NULL,
`error` VARCHAR(255) NULL,
INDEX `idx_watch_probe_sess_recorded` (`session_id`, `recorded_at`),
INDEX `idx_watch_probe_server_recorded` (`server_id`, `recorded_at`),
INDEX `idx_watch_probe_admin_recorded` (`admin_id`, `recorded_at`),
INDEX `idx_watch_probe_status_recorded` (`probe_status`, `recorded_at`),
CONSTRAINT `fk_watch_probe_sess` FOREIGN KEY (`session_id`) REFERENCES `watch_pin_sessions` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_watch_probe_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_watch_probe_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"ALTER TABLE `watch_probe_records` ADD COLUMN `processes_json` JSON NULL COMMENT 'Top5 processes snapshot'",
"ALTER TABLE `watch_pins` ADD COLUMN `monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针'",
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24'",
]
async with AsyncSessionLocal() as session:
for sql in migrations:
@@ -0,0 +1,442 @@
"""Repository for watch pins, sessions, and probe records."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Server, WatchPin, WatchPinSession, WatchProbeRecord
from server.utils.watch_metrics import normalize_watch_ttl_hours
def _utc_naive_now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _pin_still_occupied(pin: WatchPin, now: datetime) -> bool:
"""Pin occupies a slot until manual remove or TTL expiry while monitoring is on."""
if not pin.monitoring_enabled:
return True
return pin.expires_at > now
class WatchRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def list_active_pins_for_admin(self, admin_id: int) -> list[dict[str, Any]]:
now = _utc_naive_now()
q = (
select(WatchPin, WatchPinSession, Server.name)
.join(WatchPinSession, WatchPin.session_id == WatchPinSession.id)
.join(Server, WatchPin.server_id == Server.id)
.where(
WatchPin.admin_id == admin_id,
or_(WatchPin.monitoring_enabled.is_(False), WatchPin.expires_at > now),
)
.order_by(WatchPin.slot_index)
)
rows = (await self.session.execute(q)).all()
out: list[dict[str, Any]] = []
for pin, sess, server_name in rows:
if not _pin_still_occupied(pin, now):
continue
out.append(
{
"pin_id": pin.id,
"slot_index": pin.slot_index,
"server_id": pin.server_id,
"server_name": server_name,
"session_id": sess.id,
"pinned_at": pin.pinned_at,
"expires_at": pin.expires_at,
"monitoring_enabled": bool(pin.monitoring_enabled),
"ttl_hours": int(pin.ttl_hours or 8),
}
)
return out
async def list_active_pins_all(self, *, monitoring_only: bool = False) -> list[dict[str, Any]]:
now = _utc_naive_now()
q = (
select(WatchPin, WatchPinSession)
.join(WatchPinSession, WatchPin.session_id == WatchPinSession.id)
)
rows = (await self.session.execute(q)).all()
out: list[dict[str, Any]] = []
for pin, sess in rows:
if not _pin_still_occupied(pin, now):
continue
if monitoring_only and not pin.monitoring_enabled:
continue
out.append(
{
"pin_id": pin.id,
"admin_id": pin.admin_id,
"slot_index": pin.slot_index,
"server_id": pin.server_id,
"session_id": sess.id,
}
)
return out
async def get_pin_by_slot(self, admin_id: int, slot_index: int) -> WatchPin | None:
q = select(WatchPin).where(
WatchPin.admin_id == admin_id,
WatchPin.slot_index == slot_index,
)
pin = (await self.session.execute(q)).scalar_one_or_none()
if pin and _pin_still_occupied(pin, _utc_naive_now()):
return pin
return None
async def get_pin_by_server(self, admin_id: int, server_id: int) -> WatchPin | None:
q = select(WatchPin).where(
WatchPin.admin_id == admin_id,
WatchPin.server_id == server_id,
)
pin = (await self.session.execute(q)).scalar_one_or_none()
if pin and _pin_still_occupied(pin, _utc_naive_now()):
return pin
return None
async def create_pin(
self,
*,
admin_id: int,
server_id: int,
slot_index: int,
ttl_hours: int = 8,
) -> dict[str, Any]:
now = _utc_naive_now()
hours = normalize_watch_ttl_hours(ttl_hours)
expires = now + timedelta(hours=hours)
sess = WatchPinSession(
admin_id=admin_id,
server_id=server_id,
slot_index=slot_index,
started_at=now,
)
self.session.add(sess)
await self.session.flush()
pin = WatchPin(
admin_id=admin_id,
session_id=sess.id,
slot_index=slot_index,
server_id=server_id,
pinned_at=now,
expires_at=expires,
monitoring_enabled=True,
ttl_hours=hours,
)
self.session.add(pin)
await self.session.flush()
return {
"pin_id": pin.id,
"session_id": sess.id,
"slot_index": slot_index,
"server_id": server_id,
"pinned_at": now,
"expires_at": expires,
"ttl_hours": hours,
}
async def end_session(self, session_id: int, reason: str) -> None:
q = select(WatchPinSession).where(WatchPinSession.id == session_id)
sess = (await self.session.execute(q)).scalar_one_or_none()
if sess and sess.ended_at is None:
sess.ended_at = _utc_naive_now()
sess.end_reason = reason
async def delete_pin(self, pin_id: int) -> None:
await self.session.execute(delete(WatchPin).where(WatchPin.id == pin_id))
async def delete_pin_by_slot(self, admin_id: int, slot_index: int) -> WatchPin | None:
pin = await self.get_pin_by_slot(admin_id, slot_index)
if not pin:
return None
await self.end_session(pin.session_id, "manual")
await self.delete_pin(pin.id)
return pin
async def replace_pin_server(
self,
*,
admin_id: int,
slot_index: int,
server_id: int,
ttl_hours: int = 8,
) -> dict[str, Any]:
existing = await self.get_pin_by_slot(admin_id, slot_index)
if existing:
await self.end_session(existing.session_id, "replaced")
await self.delete_pin(existing.id)
other = await self.get_pin_by_server(admin_id, server_id)
if other and other.slot_index != slot_index:
await self.end_session(other.session_id, "replaced")
await self.delete_pin(other.id)
return await self.create_pin(
admin_id=admin_id,
server_id=server_id,
slot_index=slot_index,
ttl_hours=ttl_hours,
)
async def reset_pin_ttl(self, admin_id: int, server_id: int, ttl_hours: int | None = None) -> dict[str, Any]:
"""Re-pin same server: end old session and start fresh monitoring window."""
pin = await self.get_pin_by_server(admin_id, server_id)
if not pin:
raise ValueError("pin not found")
slot_index = pin.slot_index
hours = normalize_watch_ttl_hours(ttl_hours if ttl_hours is not None else int(pin.ttl_hours or 8))
await self.end_session(pin.session_id, "replaced")
await self.delete_pin(pin.id)
return await self.create_pin(
admin_id=admin_id,
server_id=server_id,
slot_index=slot_index,
ttl_hours=hours,
)
async def remove_pin_by_slot(self, admin_id: int, slot_index: int) -> WatchPin | None:
return await self.delete_pin_by_slot(admin_id, slot_index)
async def count_monitoring_pins_for_server(self, server_id: int) -> int:
q = select(func.count()).select_from(WatchPin).where(
WatchPin.server_id == server_id,
WatchPin.monitoring_enabled.is_(True),
)
return int((await self.session.execute(q)).scalar_one() or 0)
async def _ensure_open_session(self, pin: WatchPin) -> None:
q = select(WatchPinSession).where(WatchPinSession.id == pin.session_id)
sess = (await self.session.execute(q)).scalar_one_or_none()
if sess and sess.ended_at is None:
return
now = _utc_naive_now()
new_sess = WatchPinSession(
admin_id=pin.admin_id,
server_id=pin.server_id,
slot_index=pin.slot_index,
started_at=now,
)
self.session.add(new_sess)
await self.session.flush()
pin.session_id = new_sess.id
async def set_monitoring_enabled(
self,
admin_id: int,
slot_index: int,
enabled: bool,
*,
ttl_hours: int | None = None,
) -> WatchPin | None:
pin = await self.get_pin_by_slot(admin_id, slot_index)
if not pin:
return None
if ttl_hours is not None:
pin.ttl_hours = normalize_watch_ttl_hours(ttl_hours)
if enabled:
await self._ensure_open_session(pin)
hours = normalize_watch_ttl_hours(int(pin.ttl_hours or 8))
pin.ttl_hours = hours
pin.monitoring_enabled = True
pin.expires_at = _utc_naive_now() + timedelta(hours=hours)
else:
pin.monitoring_enabled = False
await self.session.flush()
return pin
async def set_pin_ttl_hours(
self,
admin_id: int,
slot_index: int,
ttl_hours: int,
) -> WatchPin | None:
pin = await self.get_pin_by_slot(admin_id, slot_index)
if not pin:
return None
hours = normalize_watch_ttl_hours(ttl_hours)
pin.ttl_hours = hours
if pin.monitoring_enabled:
pin.expires_at = _utc_naive_now() + timedelta(hours=hours)
await self.session.flush()
return pin
async def expire_due_pins(self) -> list[int]:
now = _utc_naive_now()
q = select(WatchPin).where(
WatchPin.expires_at <= now,
WatchPin.monitoring_enabled.is_(True),
)
pins = (await self.session.execute(q)).scalars().all()
server_ids: list[int] = []
for pin in pins:
await self.end_session(pin.session_id, "expired")
pin.monitoring_enabled = False
server_ids.append(pin.server_id)
return server_ids
async def insert_probe_record(self, **fields: Any) -> WatchProbeRecord:
row = WatchProbeRecord(**fields)
self.session.add(row)
await self.session.flush()
return row
async def get_latest_probe_for_session(self, session_id: int) -> WatchProbeRecord | None:
q = (
select(WatchProbeRecord)
.where(WatchProbeRecord.session_id == session_id)
.order_by(WatchProbeRecord.recorded_at.desc())
.limit(1)
)
return (await self.session.execute(q)).scalar_one_or_none()
async def purge_records_before(self, cutoff: datetime) -> int:
res = await self.session.execute(
delete(WatchProbeRecord).where(WatchProbeRecord.recorded_at < cutoff)
)
return int(res.rowcount or 0)
async def first_empty_slot(self, admin_id: int) -> int | None:
active = await self.list_active_pins_for_admin(admin_id)
used = {p["slot_index"] for p in active}
for i in range(4):
if i not in used:
return i
return None
async def list_probe_records(
self,
admin_id: int,
*,
server_id: int | None = None,
session_id: int | None = None,
probe_status: str | None = None,
since: datetime | None = None,
page: int = 1,
per_page: int = 50,
) -> tuple[list[dict[str, Any]], int]:
filters = [WatchProbeRecord.admin_id == admin_id]
if server_id is not None:
filters.append(WatchProbeRecord.server_id == server_id)
if session_id is not None:
filters.append(WatchProbeRecord.session_id == session_id)
if probe_status:
filters.append(WatchProbeRecord.probe_status == probe_status)
if since is not None:
filters.append(WatchProbeRecord.recorded_at >= since)
count_q = select(func.count()).select_from(WatchProbeRecord).where(*filters)
total = int((await self.session.execute(count_q)).scalar_one() or 0)
q = (
select(WatchProbeRecord, Server.name)
.join(Server, WatchProbeRecord.server_id == Server.id)
.where(*filters)
.order_by(WatchProbeRecord.recorded_at.desc())
.offset(max(0, page - 1) * per_page)
.limit(per_page)
)
rows = (await self.session.execute(q)).all()
items: list[dict[str, Any]] = []
for rec, server_name in rows:
items.append(
{
"id": rec.id,
"recorded_at": rec.recorded_at.isoformat() if rec.recorded_at else None,
"session_id": rec.session_id,
"server_id": rec.server_id,
"server_name": server_name,
"source": rec.source,
"probe_status": rec.probe_status,
"duration_ms": rec.duration_ms,
"cpu_pct": rec.cpu_pct,
"mem_pct": rec.mem_pct,
"disk_pct": rec.disk_pct,
"net_up_bps": rec.net_up_bps,
"net_down_bps": rec.net_down_bps,
"disk_read_bps": rec.disk_read_bps,
"disk_write_bps": rec.disk_write_bps,
"error": rec.error,
}
)
return items, total
async def list_metrics_series(
self,
admin_id: int,
*,
server_ids: list[int],
since: datetime,
ok_only: bool = True,
) -> list[dict[str, Any]]:
if not server_ids:
return []
filters = [
WatchProbeRecord.admin_id == admin_id,
WatchProbeRecord.server_id.in_(server_ids),
WatchProbeRecord.recorded_at >= since,
]
if ok_only:
filters.append(WatchProbeRecord.probe_status == "ok")
q = (
select(WatchProbeRecord)
.where(*filters)
.order_by(WatchProbeRecord.recorded_at.asc())
)
rows = (await self.session.execute(q)).scalars().all()
return [
{
"server_id": r.server_id,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
"cpu_pct": r.cpu_pct,
"mem_pct": r.mem_pct,
"disk_pct": r.disk_pct,
"net_up_bps": r.net_up_bps,
"net_down_bps": r.net_down_bps,
"disk_read_bps": r.disk_read_bps,
"disk_write_bps": r.disk_write_bps,
"probe_status": r.probe_status,
}
for r in rows
]
async def list_sessions(self, admin_id: int, *, since: datetime) -> list[dict[str, Any]]:
q = (
select(WatchPinSession, Server.name)
.join(Server, WatchPinSession.server_id == Server.id)
.where(WatchPinSession.admin_id == admin_id, WatchPinSession.started_at >= since)
.order_by(WatchPinSession.started_at.desc())
)
rows = (await self.session.execute(q)).all()
out: list[dict[str, Any]] = []
for sess, server_name in rows:
ok_q = select(func.count()).select_from(WatchProbeRecord).where(
WatchProbeRecord.session_id == sess.id,
WatchProbeRecord.probe_status == "ok",
)
fail_q = select(func.count()).select_from(WatchProbeRecord).where(
WatchProbeRecord.session_id == sess.id,
WatchProbeRecord.probe_status != "ok",
)
ok_count = int((await self.session.execute(ok_q)).scalar_one() or 0)
fail_count = int((await self.session.execute(fail_q)).scalar_one() or 0)
out.append(
{
"session_id": sess.id,
"server_id": sess.server_id,
"server_name": server_name,
"slot_index": sess.slot_index,
"started_at": sess.started_at.isoformat() if sess.started_at else None,
"ended_at": sess.ended_at.isoformat() if sess.ended_at else None,
"end_reason": sess.end_reason,
"probe_ok_count": ok_count,
"probe_fail_count": fail_count,
}
)
return out
+200 -9
View File
@@ -1,13 +1,12 @@
"""SSH-based remote health / metrics probe (no Agent HTTP port required)."""
import json
import logging
from typing import Any
from server.domain.models import Server
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
logger = logging.getLogger("nexus.remote_probe")
from server.utils.watch_probe_errors import watch_probe_error_zh
# Emits one JSON line: {"status":"healthy","system_info":{...}}
SSH_HEALTH_METRICS_CMD = r"""python3 -c "
@@ -48,17 +47,147 @@ print(json.dumps({'status': 'healthy', 'system_info': info}))
"""
def _parse_health_stdout(stdout: str) -> dict[str, Any] | None:
for line in reversed(stdout.strip().splitlines()):
SSH_WATCH_METRICS_CMD = r"""python3 -c "
import json
try:
import psutil
except ImportError:
print(json.dumps({'status':'error','error':'psutil missing'}))
raise SystemExit(0)
try:
load = list(psutil.getloadavg())
except (AttributeError, OSError, NotImplementedError):
load = [0.0, 0.0, 0.0]
try:
net = psutil.net_io_counters()
except Exception:
net = None
try:
disk_io = psutil.disk_io_counters()
except Exception:
disk_io = None
try:
cpu = psutil.cpu_percent(interval=0.3)
mem = psutil.virtual_memory().percent
disk = round(psutil.disk_usage('/').percent, 1)
except Exception as exc:
print(json.dumps({'status':'error','error': str(exc)[:200]}))
raise SystemExit(0)
out = {
'status': 'ok',
'cpu_usage': cpu,
'mem_usage': mem,
'disk_usage': disk,
'disk_mount': '/',
'load_avg': load,
'net_io': {
'bytes_sent': int(net.bytes_sent) if net else 0,
'bytes_recv': int(net.bytes_recv) if net else 0,
},
'disk_io': {
'read_bytes': int(disk_io.read_bytes) if disk_io else 0,
'write_bytes': int(disk_io.write_bytes) if disk_io else 0,
},
}
print(json.dumps(out))
" 2>/dev/null || python3.12 -c "
import json
try:
import psutil
except ImportError:
print(json.dumps({'status':'error','error':'psutil missing'}))
raise SystemExit(0)
try:
load = list(psutil.getloadavg())
except (AttributeError, OSError, NotImplementedError):
load = [0.0, 0.0, 0.0]
try:
net = psutil.net_io_counters()
except Exception:
net = None
try:
disk_io = psutil.disk_io_counters()
except Exception:
disk_io = None
try:
cpu = psutil.cpu_percent(interval=0.3)
mem = psutil.virtual_memory().percent
disk = round(psutil.disk_usage('/').percent, 1)
except Exception as exc:
print(json.dumps({'status':'error','error': str(exc)[:200]}))
raise SystemExit(0)
out = {
'status': 'ok',
'cpu_usage': cpu,
'mem_usage': mem,
'disk_usage': disk,
'disk_mount': '/',
'load_avg': load,
'net_io': {
'bytes_sent': int(net.bytes_sent) if net else 0,
'bytes_recv': int(net.bytes_recv) if net else 0,
},
'disk_io': {
'read_bytes': int(disk_io.read_bytes) if disk_io else 0,
'write_bytes': int(disk_io.write_bytes) if disk_io else 0,
},
}
print(json.dumps(out))
" 2>/dev/null || echo '{"status":"error","error":"ssh_probe_failed"}'
"""
SSH_WATCH_PROCESSES_CMD = r"""python3 -c "
import json
try:
import psutil
except ImportError:
print(json.dumps({'status':'error','error':'psutil missing'}))
raise SystemExit(0)
procs = []
for p in psutil.process_iter(['pid','name','cpu_percent','memory_percent']):
try:
i = p.info
procs.append({
'pid': i.get('pid'),
'name': (i.get('name') or '')[:80],
'cpu_pct': round(float(i.get('cpu_percent') or 0), 1),
'mem_pct': round(float(i.get('memory_percent') or 0), 1),
})
except Exception:
pass
procs.sort(key=lambda x: x.get('cpu_pct') or 0, reverse=True)
print(json.dumps({'status':'ok','top_processes': procs[:5]}))
" 2>/dev/null || echo '{"status":"error","error":"ssh_probe_failed"}'
"""
def _parse_json_stdout(stdout: str) -> dict[str, Any] | None:
text = (stdout or "").strip()
if not text:
return None
candidates: list[str] = []
for line in reversed(text.splitlines()):
line = line.strip()
if not line.startswith("{"):
if not line:
continue
start = line.find("{")
if start >= 0:
candidates.append(line[start:])
elif line.startswith("{"):
candidates.append(line)
if text.startswith("{"):
candidates.append(text)
seen: set[str] = set()
for chunk in candidates:
if chunk in seen:
continue
seen.add(chunk)
try:
data = json.loads(line)
if isinstance(data, dict) and data.get("status"):
return data
data = json.loads(chunk)
except json.JSONDecodeError:
continue
if isinstance(data, dict) and data.get("status"):
return data
return None
@@ -71,7 +200,7 @@ async def ssh_health_probe(server: Server, timeout: int = 15) -> dict[str, Any]:
"error": (result.get("stderr") or result.get("stdout") or "SSH probe failed")[:300],
"channel": "ssh",
}
parsed = _parse_health_stdout(result.get("stdout", ""))
parsed = _parse_json_stdout(result.get("stdout", ""))
if not parsed:
return {
"status": "online",
@@ -83,3 +212,65 @@ async def ssh_health_probe(server: Server, timeout: int = 15) -> dict[str, Any]:
"system_info": parsed,
"channel": "ssh",
}
async def ssh_watch_probe(server: Server, timeout: int = 8) -> dict[str, Any]:
"""Full watch metrics over SSH (CPU/mem/disk/net/disk-io)."""
import time
started = time.monotonic()
result = await exec_ssh_command(server, SSH_WATCH_METRICS_CMD, timeout=timeout)
duration_ms = int((time.monotonic() - started) * 1000)
if result.get("exit_code", -1) != 0:
err_raw = (result.get("stderr") or result.get("stdout") or "SSH watch probe failed")[:300]
if "timed out" in err_raw.lower() or duration_ms >= timeout * 1000 - 50:
return {
"ok": False,
"probe_status": "ssh_timeout",
"error": watch_probe_error_zh(err_raw, status="ssh_timeout"),
"duration_ms": duration_ms,
}
if "auth" in err_raw.lower() or "permission denied" in err_raw.lower():
return {
"ok": False,
"probe_status": "ssh_auth",
"error": watch_probe_error_zh(err_raw, status="ssh_auth"),
"duration_ms": duration_ms,
}
return {
"ok": False,
"probe_status": "offline",
"error": watch_probe_error_zh(err_raw, status="offline"),
"duration_ms": duration_ms,
}
parsed = _parse_json_stdout(result.get("stdout", ""))
if not parsed or parsed.get("status") != "ok":
raw = (result.get("stdout") or "").strip().replace("\n", " ")[:200]
err_key = (parsed or {}).get("error") or "parse_error"
err_combined = f"parse_error: {raw}" if err_key == "parse_error" and raw else str(err_key)
return {
"ok": False,
"probe_status": "parse_error",
"error": watch_probe_error_zh(err_combined, status="parse_error"),
"duration_ms": duration_ms,
}
parsed["duration_ms"] = duration_ms
return {"ok": True, "payload": parsed, "duration_ms": duration_ms}
async def ssh_watch_processes(server: Server, timeout: int = 8) -> list[dict[str, Any]] | None:
"""Fetch top-5 processes over SSH (10s cadence during watch)."""
result = await exec_ssh_command(server, SSH_WATCH_PROCESSES_CMD, timeout=timeout)
if result.get("exit_code", -1) != 0:
return None
parsed = _parse_json_stdout(result.get("stdout", ""))
if not parsed or parsed.get("status") != "ok":
return None
from server.utils.watch_metrics import sanitize_processes
return sanitize_processes(parsed.get("top_processes"))
def _parse_health_stdout(stdout: str) -> dict[str, Any] | None:
return _parse_json_stdout(stdout)
+11 -7
View File
@@ -71,6 +71,7 @@ from server.api.sync_v2 import router as sync_v2_router
from server.api.files import router as files_router
from server.api.search import router as search_router
from server.api.terminal import router as terminal_router
from server.api.watch import router as watch_router
# Background tasks
from server.background.heartbeat_flush import heartbeat_flush_loop
@@ -82,6 +83,7 @@ from server.background.retry_runner import retry_runner_loop
from server.background.ip_allowlist_refresh import ip_allowlist_refresh_loop
from server.background.upload_staging_cleanup import upload_staging_cleanup_loop
from server.background.sync_log_purge import sync_log_purge_loop
from server.background.watch_probe_runner import watch_probe_loop
logger = logging.getLogger("nexus")
@@ -315,6 +317,9 @@ async def lifespan(app: FastAPI):
task_sync_log_purge = asyncio.create_task(
sync_log_purge_loop(), name="sync_log_purge"
)
task_watch_probe = asyncio.create_task(
watch_probe_loop(), name="watch_probe"
)
_background_tasks.extend([
task_flush,
task_script_flush,
@@ -326,6 +331,7 @@ async def lifespan(app: FastAPI):
task_upload_cleanup,
task_batch_reconcile,
task_sync_log_purge,
task_watch_probe,
])
logger.info("Primary worker — background tasks launched")
else:
@@ -340,16 +346,18 @@ async def lifespan(app: FastAPI):
f"background tasks: heartbeat_flush(10min), script_execution_flush(60s), "
f"server_batch_reconcile(60s), sync_log_purge(24h), self_monitor(30s), "
f"server_offline_monitor(30s), "
f"schedule_runner(60s), retry_runner(5min), upload_staging_cleanup(1h)"
f"schedule_runner(60s), retry_runner(5min), upload_staging_cleanup(1h), "
f"watch_probe(5s)"
)
yield
# ── Shutdown ──
# Cancel WebSocket heartbeat task (H11)
from server.api.websocket import manager as ws_manager, sync_manager as ws_sync_manager
from server.api.websocket import manager as ws_manager, sync_manager as ws_sync_manager, watch_manager as ws_watch_manager
ws_manager.cancel_heartbeat()
ws_sync_manager.cancel_heartbeat()
ws_watch_manager.cancel_heartbeat()
for task in _background_tasks:
task.cancel()
@@ -686,11 +694,7 @@ app.include_router(search_router)
# Terminal Quick Commands
app.include_router(terminal_router)
# Embedded browser UI state
from server.api.browser import router as browser_router # noqa: E402
app.include_router(browser_router)
app.include_router(watch_router)
# ── Custom 404: return blank HTML to hide backend identity ──

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