Compare commits
20 Commits
f11ad35708
...
8092000880
| Author | SHA1 | Date | |
|---|---|---|---|
| 8092000880 | |||
| e57d689fb2 | |||
| 75efd506f5 | |||
| 32a1885f0d | |||
| 7bb85aac41 | |||
| f37047f746 | |||
| 6b8965aaf6 | |||
| 6140d68875 | |||
| b46922fe92 | |||
| f6e8b29640 | |||
| e0133b9098 | |||
| 724375c884 | |||
| 989b17902b | |||
| 441aa0b9d2 | |||
| 540712500f | |||
| 17abd62261 | |||
| 3a4b2ff3eb | |||
| 2877fd90a4 | |||
| e6abd4ae73 | |||
| 29b053b3c0 |
@@ -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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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` 0–3 |
|
||||
| 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H6 | **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 加载失败改 snackbar;pin 失败经 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 | 1–108 | 逐行完成 ✓ |
|
||||
| WatchSlotCard.vue | 138 | 1–138 | 逐行完成 ✓ |
|
||||
| WatchSlotRow.vue | 98 | 1–98 | 逐行完成 ✓ |
|
||||
| useWatchPins.ts(pinServer 段) | 222 | 160–178 | 逐行完成 ✓ |
|
||||
| useFilesNavigation.ts(搜索段) | 244 | 26–47,139–170 | 逐行完成 ✓ |
|
||||
|
||||
### Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | SAFE | fetchPagePerPage/http 带 auth;POST body 仅 server_id + slot_index |
|
||||
| H2 | SAFE | 选机对话框 load 失败 snackbar;onPickServer 409/422 均有 snackbar |
|
||||
| H3 | SAFE | 无 env/密钥 |
|
||||
| H4 | SAFE | 模板无 innerHTML |
|
||||
| H5 | SAFE | slot_index 0–3 后端校验;空 selectedId 禁用「加入」 |
|
||||
| H6 | SAFE(接受) | 与项目内终端/推送全量列表模式一致 |
|
||||
|
||||
### 交互验收
|
||||
|
||||
| 场景 | 预期 | 审计结论 |
|
||||
|------|------|----------|
|
||||
| 文件页搜索服务器 | 名称/域名/路径/ID 过滤 | PASS(type-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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | PASS | 纯前端交互回退 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] type-check
|
||||
- [ ] 生产 `#/push` 验证
|
||||
|
||||
## 验证
|
||||
|
||||
点击名称仅勾选服务器,无复制 toast。
|
||||
@@ -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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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_io(M3 可选) |
|
||||
| `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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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
|
||||
|
||||
H1–H4 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 0–3 |
|
||||
| H6 | 迁移 | PASS — monitoring_enabled + ttl_hours ALTER |
|
||||
| H7 | TTL 竞态 | PASS — `_finalize_due_pins` 读/写路径同步到期 |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H6 | 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 时强制走 SSH;SSH JSON 解析失败且无完整 Redis IO 时返回 `parse_error`
|
||||
- 已装 Agent 的机器通常有 CPU/内存心跳,不应因 SSH 失败而整轮失败
|
||||
- SSH 脚本在 `getloadavg`/`net_io_counters` 异常时可能无 JSON 输出
|
||||
|
||||
## 修复
|
||||
|
||||
- Agent 有 CPU/内存时优先用 Redis;SSH 仅补充 IO,SSH 失败仍显示 Agent 指标
|
||||
- SSH 探针脚本增加 try/except;JSON 解析支持行内 `{` 前缀噪声
|
||||
- 前端 `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 用户点击「安装 psutil(SSH)」时 `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」时,卡片内显示 **「安装 psutil(SSH)」** 按钮,通过 SSH 在子机系统 Python 安装 psutil(pip --user → apt/yum → pip)。
|
||||
|
||||
## 动机
|
||||
|
||||
SSH 监测探针依赖系统 `python3` 的 psutil;Agent 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 |
|
||||
|------|---------------|-----------------|---------------------|
|
||||
| Redis(Agent 心跳) | ✅ `system_info` 已有 | ⚠ 心跳 **默认无** → Pin 机走 SSH 补采 **或** Agent 小改带上 `net_io`/`disk_io`(推荐 **M1:SSH 脚本一次拿全**) |
|
||||
| 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 返回 422,snackbar 说明原因(不静默失败)。
|
||||
|
||||
### 独立历史页
|
||||
|
||||
- 路由:`#/watch-metrics`(侧栏「监测历史」或从 WatchSlotRow「查看历史 →」进入)
|
||||
- **Tab A — 资源趋势**:选 1~4 台、时间范围 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 5;10s 更新内容
|
||||
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}` — 进程 JSON,TTL 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 时 connect;unmount 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** |
|
||||
| 失败 | 44(dest=/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/7(Gate 7 被 **Agent 提示** 并行改动阻塞,非 watch 本身) |
|
||||
| 部署 | ❌ 未执行 |
|
||||
| 生产健康 | ✅ `/health` → `ok` |
|
||||
| 浏览器终验 | ❌ 待部署后 |
|
||||
|
||||
## 本地验证
|
||||
|
||||
### 单测
|
||||
|
||||
```text
|
||||
pytest tests/test_watch_*.py tests/test_watch_probe_errors.py
|
||||
→ 23 passed
|
||||
```
|
||||
|
||||
### L2b API E2E(local_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 | PASS(2026-06-12-server-agent-action-hint.md) |
|
||||
| 2 Audit | PASS(2026-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 后一并部署。
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
安装 psutil(SSH)
|
||||
</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,
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
|
||||
@@ -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/ */
|
||||
|
||||
@@ -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: '批量修改分类',
|
||||
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user