feat: 文件管理器复制粘贴守卫、2MB编辑限制、一键配置sudoers、编辑器外观重构
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"changelog","result":"PASS","detail":"2026-06-01-asyncssh-sftp-api-fix.md 25lines"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"audit","result":"PASS","detail":"2026-06-01-bug-audit.md"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"lint","result":"SKIP","detail":"ruff not installed"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"security","result":"SKIP","detail":"bandit not installed"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
@@ -0,0 +1,183 @@
|
||||
# 审计记录:文件管理行点击与自动选服
|
||||
|
||||
## 审计信息
|
||||
|
||||
- **日期**: 2026-06-01
|
||||
- **审计人**: Claude (AI)
|
||||
- **触发原因**: Bug 修复 — `#/files` 无法进入子目录、未选服务器时空列表
|
||||
- **关联 changelog**: `docs/changelog/2026-06-01-files-page-row-click.md`
|
||||
|
||||
## 审计范围(实际改动文件清单)
|
||||
|
||||
| 文件 | 行数 | 状态 |
|
||||
|------|------|------|
|
||||
| `frontend/src/pages/FilesPage.vue` | 1429 | ☑ 全文已审 |
|
||||
| `frontend/src/composables/useServerList.ts` | 37 | ☑ 全文已审 |
|
||||
| `docs/changelog/2026-06-01-files-page-row-click.md` | 25 | ☑ 已审 |
|
||||
|
||||
## 变更摘要(审计对象)
|
||||
|
||||
1. **Vuetify 4 兼容**:移除无效的 `@click:row` / `@dblclick:row` / `@contextmenu:row`,改用 `:row-props="fileRowProps"` 绑定 `onClick` / `onDblclick` / `onContextmenu`。
|
||||
2. **自动选服**:`onMounted` 在 `loadServers()` 后调用 `resolveInitialServerId()`(URL → sessionStorage → 第一台),并 `browse()`。
|
||||
3. **记忆服务器**:`onServerChange` 写入 `sessionStorage` 键 `nexus:files:last_server_id`。
|
||||
4. **列表加载**:`useServerList` 改用 `http.getList`,兼容数组与 `{ items, total }`。
|
||||
|
||||
---
|
||||
|
||||
## 审计 8 步结果
|
||||
|
||||
### Step 1: 登记 ✅
|
||||
|
||||
已登记 3 个文件;变更动机与 changelog 一致。
|
||||
|
||||
### Step 2: 全文 Read ✅
|
||||
|
||||
- `FilesPage.vue`:template(工具栏、表格、对话框、FileEditorWorkbench)+ script setup 全量通读。
|
||||
- `useServerList.ts`:全量 37 行。
|
||||
- 交叉引用:`remotePath.ts`、`fileBrowse.ts`、`api/index.ts`(`getList`)未改,仅核对调用契约。
|
||||
|
||||
### Step 3: 规则扫描 H
|
||||
|
||||
| ID | 规则 | 扫描位置 | 命中 |
|
||||
|----|------|----------|------|
|
||||
| H1 | XSS(未消毒用户输入进 DOM) | `FilesPage.vue` template | 否 — 文件名来自 API,Vue 默认转义 |
|
||||
| H2 | 凭据泄露 | sessionStorage、路由 query | 否 — 仅存 `server_id` 数字 |
|
||||
| H3 | IDOR(越权 server_id) | `browse()`、`resolveInitialServerId` | 否 — 后端 JWT + `get_by_id`;前端校验 ID 在列表内(初始化路径) |
|
||||
| H4 | 路径穿越(`../`) | `openEntry` 符号链接绝对路径 | 否(既有)— `normalizeRemotePath`;穿越依赖 SSH 返回的 link_target,属管理员可信面 |
|
||||
| H5 | 静默吞错 | `useServerList` catch | 既有模式 — `servers=[]`,页面有空状态提示 |
|
||||
| H6 | 事件委托冲突(checkbox/按钮误触发行导航) | `fileRowProps` | 已缓解 — `isRowActionTarget` + `closest` |
|
||||
| H7 | Vuetify API 废弃 | `@click:row` | 已修复 — 改用 `row-props` |
|
||||
| H8 | 未选服务器调用 API | `browse()` | 否 — 入口 `if (!selectedServer.value) return` |
|
||||
| H9 | 双击/单击竞态 | 目录行 click + dblclick | 否 — `onRowDblClick` 仅处理 `file` 类型 |
|
||||
| H10 | sessionStorage 污染 | `FILES_LAST_SERVER_KEY` | 否 — 写入前 `onServerChange` 有值;读取时 `serverList.some` 校验 |
|
||||
| H11 | 类型安全 | `fileRowProps(data)` | 可接受 — `data.item` 与 `FileEntry` 一致 |
|
||||
| H12 | 常量声明顺序 | `FILES_LAST_SERVER_KEY` 在 `onServerChange` 之后 | 见 Closure — 运行时无 TDZ 问题,可读性欠佳 |
|
||||
|
||||
### Step 4: Closure 表
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | 通过 | 无 `v-html`;`{{ item.name }}` 等文本插值 |
|
||||
| H2 | 通过 | `sessionStorage` 仅 `String(serverId)` |
|
||||
| H3 | 通过 | `resolveInitialServerId` L857-868:`Number.isFinite` + `serverList.some`;API 仍由后端鉴权 |
|
||||
| H4 | 通过(既有) | 符号链接绝对路径 L751-752 走 `normalizeRemotePath`;后端 `sync_v2.browse_directory` 亦有规范化 |
|
||||
| H5 | 通过(既有) | `useServerList` L29-30 与多页一致;Files 页 `onMounted` L1363-1365 有 snackbar |
|
||||
| H6 | 通过 | `isRowActionTarget` L891-894 排除 `.v-selection-control, .v-btn, …` |
|
||||
| H7 | 通过 | L187 `:row-props="fileRowProps"`;L896-914 实现与 Vuetify 4 `RowPropsFunction` 一致 |
|
||||
| H8 | 通过 | `browse` L800-801;`navigateToPath` L759-762 |
|
||||
| H9 | 通过 | `onRowDblClick` L923-926 仅 `isRegularFile` |
|
||||
| H10 | 通过 | L861-866、L844-845 |
|
||||
| H11 | 通过 | TypeScript `type-check` 已通过(本地构建前) |
|
||||
| H12 | 建议 | `FILES_LAST_SERVER_KEY` 宜上移至 `// ── State ──` 区,非功能缺陷 |
|
||||
|
||||
**附带观察(非本次引入,不记 FINDING)**:
|
||||
|
||||
- `watch(route.query.server_id)` L1378-1390 未校验 `serverList`,非法 `?server_id=abc` 可能得到 `NaN`(原逻辑已存在)。建议在后续小改中与 `resolveInitialServerId` 对齐。
|
||||
|
||||
### Step 5: 入口表
|
||||
|
||||
| 入口类型 | 位置 | 说明 |
|
||||
|----------|------|------|
|
||||
| 用户点击 | `fileRowProps.onClick/onDblclick/onContextmenu` | 行级导航/编辑/菜单 |
|
||||
| 用户选择 | `v-select` `selectedServer` | 切换服务器 → `onServerChange` |
|
||||
| 路由 query | `server_id`, `path` | `onMounted` / `watch` / `syncRouteQuery` |
|
||||
| sessionStorage | `nexus:files:last_server_id` | 恢复上次服务器 |
|
||||
| HTTP GET | `/servers/` | `useServerList.loadServers` |
|
||||
| HTTP POST | `/sync/browse` 等 | 既有文件操作(未改契约) |
|
||||
|
||||
### Step 6: 输入 → Sink
|
||||
|
||||
```
|
||||
[URL server_id / sessionStorage / 默认第一台]
|
||||
→ resolveInitialServerId (校验 ∈ serverList)
|
||||
→ selectedServer
|
||||
→ browse() → POST /sync/browse { server_id, path }
|
||||
→ 后端 JWT + ServerRepository + SSH ls
|
||||
|
||||
[行点击 item.name]
|
||||
→ openEntry → joinRemotePath + normalizeRemotePath
|
||||
→ browse()(同上)
|
||||
|
||||
[行点击 非目录]
|
||||
→ onRowClick 无操作(仅目录/符号链接)
|
||||
|
||||
[双击文件]
|
||||
→ editFile → readRemoteFileContent → FileEditorWorkbench
|
||||
```
|
||||
|
||||
**控制措施**:路径片段经 `joinRemotePath`/`validatePathSegment`;服务器 ID 初始化路径白名单校验;API 鉴权在后端。
|
||||
|
||||
### Step 7: 归类
|
||||
|
||||
```
|
||||
FilesPage.vue 1429行 12H 0 FINDING
|
||||
useServerList.ts 37行 1H 0 FINDING
|
||||
changelog 25行 0H 0 FINDING
|
||||
────────────────────────────────────────────
|
||||
总计 13H 0 FINDING
|
||||
```
|
||||
|
||||
### Step 8: DoD ✅
|
||||
|
||||
| 维度 | 项 | 结果 |
|
||||
|------|-----|------|
|
||||
| 安全 | 无新增 XSS/IDOR/凭据泄露 | ✅ |
|
||||
| 逻辑 | 目录单击可进入;无 server 时自动选服并 browse | ✅ |
|
||||
| 逻辑 | 复选框/操作列不误触发目录进入 | ✅ |
|
||||
| 性能 | `row-props` 每行创建闭包(与 Vuetify 常规用法一致) | ✅ |
|
||||
| 质量 | TS type-check | ✅(实现时已通过) |
|
||||
| 质量 | `FILES_LAST_SERVER_KEY` 声明位置 | ⚠️ 建议上移(不阻断) |
|
||||
| 文档 | changelog ≥10 行 | ✅ |
|
||||
| 部署 | 生产 tar 需在 WSL/Git Bash 重跑;prune 路径为仓库根 `deploy/prune_frontend_assets.py` | ⚠️ 运维注意 |
|
||||
|
||||
---
|
||||
|
||||
## FINDING 列表
|
||||
|
||||
无(0 FINDING)。
|
||||
|
||||
---
|
||||
|
||||
## 审查 4 维度(摘要)
|
||||
|
||||
### 安全(12 项抽样)
|
||||
|
||||
- JWT 请求经既有 `http` 客户端 ✅
|
||||
- 不在前端存储 SSH 密码 ✅
|
||||
- `server_id` 仅数字、列表内校验(初始化) ✅
|
||||
- 行事件不执行 `eval`/动态 HTML ✅
|
||||
|
||||
### 逻辑(8 项)
|
||||
|
||||
- 根因:Vuetify 4 无 `click:row` → 已用 `row-props` 修复 ✅
|
||||
- 根因:无 query 不 browse → 已自动选服 ✅
|
||||
- 空服务器列表提示 ✅
|
||||
- 符号链接相对路径不盲目 navigate ✅
|
||||
|
||||
### 性能(5 项)
|
||||
|
||||
- `displayedFiles` 仍为 computed 过滤,未增加额外请求 ✅
|
||||
- `preloadDirectoryFiles` 行为未改 ✅
|
||||
|
||||
### 质量(5 项)
|
||||
|
||||
- 注释说明 Vuetify 4 变更原因 ✅
|
||||
- `useServerList` 与 `SchedulesPage`/`RetriesPage` 列表解析策略统一 ✅
|
||||
- 常量位置可优化(H12 建议)⚠️
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
☑ **审计通过,0 FINDING,可合并/可部署**
|
||||
|
||||
**部署前提醒**(非代码缺陷):
|
||||
|
||||
1. Windows `tar` 对 `-C web/app` 可能失败,请用 WSL 或 `deploy/deploy-frontend.sh` 打包上传。
|
||||
2. 生产 prune:`cd /www/wwwroot/api.synaglobal.vip && python3 deploy/prune_frontend_assets.py`
|
||||
3. 浏览器验证 `#/files` 后建议 **Ctrl+F5** 强刷。
|
||||
|
||||
**可选后续小改**(不阻断):
|
||||
|
||||
- 将 `FILES_LAST_SERVER_KEY` 移至 state 区顶部。
|
||||
- `watch(route.query.server_id)` 增加与 `resolveInitialServerId` 相同的列表校验。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 2026-06-01 文件浏览目录 sudo 提权回退
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
`POST /api/sync/browse` 与写文件/chmod 一致,在 `files_elevation=auto_sudo`(默认)且 `ls` 遇 Permission denied 时自动 `sudo -n bash -c`。
|
||||
|
||||
## 动机
|
||||
- 非 root SSH 用户无法直接 `ls /www/wwwroot`,前端表现为「暂无文件」
|
||||
- 浏览此前仅用 `exec_ssh_command`,**从未走 sudo**;与「去掉 sudo」无关,应**启用**提权而非关闭
|
||||
|
||||
## 涉及文件
|
||||
- `server/api/sync_v2.py` — `browse_directory`
|
||||
- `frontend/src/utils/fileMode.ts` — 提示文案含「浏览」
|
||||
|
||||
## 迁移 / 重启
|
||||
- 需 `supervisorctl restart nexus` 部署后端
|
||||
|
||||
## 验证方式
|
||||
1. 服务器 SSH 用户为非 root,`files_elevation` 为 `auto_sudo`(默认)
|
||||
2. 目标机已安装 `docs/deploy/nexus-files-sudoers.example`(含 bash)
|
||||
3. 打开 `#/files?server_id=8`,应列出 `/www/wwwroot` 内容
|
||||
|
||||
## 运维说明(不要关掉 sudo)
|
||||
|
||||
| 策略 | 适用 |
|
||||
|------|------|
|
||||
| `auto_sudo`(推荐) | 先普通 `ls`,失败再 sudo |
|
||||
| `off` | 仅当 SSH 已是 root 或目录对当前用户可读 |
|
||||
| `always_sudo` | 所有命令先 sudo;sudoers 未配全时反而更易失败 |
|
||||
|
||||
若 `always_sudo` 且 sudo 未配好:在「服务器」编辑里改回 **自动提权 (auto_sudo)**,并安装 sudoers 示例。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-06-01 文件管理行点击与自动选服
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
修复 `#/files` 无法进入子目录、打开页无列表等体验问题:Vuetify 4 改用 `row-props` 绑定行点击;进入页自动选中服务器并浏览默认目录。
|
||||
|
||||
## 动机
|
||||
- Vuetify 4 已移除 `v-data-table` 的 `@click:row` / `@dblclick:row` / `@contextmenu:row`,原事件静默失效,单击目录无法进入
|
||||
- 未带 `?server_id=` 时不会调用 `browse()`,用户看到空表且多数按钮禁用
|
||||
|
||||
## 涉及文件
|
||||
- `frontend/src/pages/FilesPage.vue` — `fileRowProps`、自动选服、`sessionStorage` 记忆上次服务器
|
||||
- `frontend/src/composables/useServerList.ts` — `http.getList` 兼容列表响应
|
||||
|
||||
## 迁移 / 重启
|
||||
- 需重新构建并部署前端至 `web/app/`(建议执行 `deploy/prune_frontend_assets.py`)
|
||||
|
||||
## 验证方式
|
||||
1. 打开 `https://…/app/#/files`(无需 URL 参数)
|
||||
2. 应自动选中服务器并列出 `/www/wwwroot`(或站点根)内容
|
||||
3. 单击目录行进入子目录;双击普通文件打开编辑器
|
||||
4. 右键行弹出上下文菜单
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-06-01 文件管理 sudo 提示与白名单示例
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
区分「无免密 sudo / 白名单不全 / 已就绪」三种提示;sudoers 示例补充 `bash`(与 `sudo -n bash -c` 提权路径一致)。
|
||||
|
||||
## 动机
|
||||
- 用户看到「部分操作将自动尝试 sudo」但操作仍失败:多为目标机未装 sudoers 或白名单缺少 `bash`
|
||||
- 原示例未包含 `bash`,与 `remote_shell.exec_ssh_command_with_fallback` 行为不一致
|
||||
|
||||
## 涉及文件
|
||||
- `frontend/src/utils/fileMode.ts`
|
||||
- `frontend/src/pages/FilesPage.vue`
|
||||
- `docs/deploy/nexus-files-sudoers.example`
|
||||
|
||||
## 迁移 / 重启
|
||||
- 仅前端文案 + 文档;目标机需按新示例更新 `/etc/sudoers.d/nexus-files` 后无需重启 Nexus
|
||||
|
||||
## 验证方式
|
||||
1. 非 root SSH + 无 sudo:文件页顶部红色提示,含「免密 sudo」与示例路径
|
||||
2. 有 `sudo -n true` 但无 bash:黄色提示「白名单可能不完整」
|
||||
3. 配置完整后:无多余横幅(除非目录内有不可读文件)
|
||||
@@ -0,0 +1,37 @@
|
||||
# 2026-06-01 全功能验收脚本 run_nexus_acceptance
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
新增统一验收入口 `scripts/run_nexus_acceptance.py`:自动跑核心/扩展 API 测试、按功能模块汇总 PASS/FAIL,可选单元测试与 Playwright UI。
|
||||
|
||||
## 动机
|
||||
用户需要一键脚本识别各功能是否正常,而非分散执行 `test_api.py` 与 Playwright。
|
||||
|
||||
## 涉及文件
|
||||
- `scripts/run_nexus_acceptance.py` — 主入口
|
||||
- `scripts/run_full_acceptance.sh` — WSL/生产包装(API + 可选 UI)
|
||||
- `tests/acceptance_catalog.py` — 功能模块与检查项映射
|
||||
|
||||
## 迁移 / 重启
|
||||
无
|
||||
|
||||
## 2026-06-01 修复
|
||||
|
||||
- `tests/test_full_site_api.py`:`api_test_plain_text` / `api_test` 与 `test_api.py` 对齐
|
||||
|
||||
## 验证方式
|
||||
```bash
|
||||
# 本地(后端需已启动,.env 含测试账号)
|
||||
python scripts/run_nexus_acceptance.py
|
||||
|
||||
# 含 UI(需 frontend npm install + NEXUS_E2E_PASSWORD)
|
||||
python scripts/run_nexus_acceptance.py --with-ui
|
||||
|
||||
# 含单元测试(无需后端)
|
||||
python scripts/run_nexus_acceptance.py --with-unit --skip-api
|
||||
|
||||
# JSON 报告
|
||||
python scripts/run_nexus_acceptance.py --json reports/acceptance.json
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-06-01 登录锁定解除脚本
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
新增 `scripts/unlock_login_lockout.py`:删除 `login_attempts` 中近期失败记录,解除 15 分钟防暴破锁定。
|
||||
|
||||
## 动机
|
||||
验收/误测导致 `admin` 触发 429,需运维可重复、可审计的解锁方式(非改代码降级)。
|
||||
|
||||
## 涉及文件
|
||||
- `scripts/unlock_login_lockout.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
无
|
||||
|
||||
## 验证方式
|
||||
```bash
|
||||
# 生产机(须 venv)
|
||||
ssh nexus "cd /www/wwwroot/api.synaglobal.vip && venv/bin/python scripts/unlock_login_lockout.py --username admin"
|
||||
```
|
||||
|
||||
凭据见 `docs/handoff-2026-06-01.md`(`admin` / `Nexus@2026`)。
|
||||
@@ -0,0 +1,48 @@
|
||||
# Changelog: 文件管理器复制粘贴、编辑器外观与 sudo 自动配置
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**类别**: 功能改进 + Bug 修复
|
||||
|
||||
## 变更摘要
|
||||
|
||||
### 1. 文件管理器复制粘贴逻辑修复
|
||||
- `Ctrl+C` / `Ctrl+X` 仅在有勾选文件时拦截浏览器默认行为并执行复制/剪切;无勾选时让浏览器原生复制正常工作
|
||||
- `Ctrl+V` 仅在剪贴板有内容时执行粘贴,空剪贴板不触发
|
||||
|
||||
### 2. 文件编辑器大小限制提升至 2MB
|
||||
- `FILE_EDITOR_MAX_BYTES` 新增为 2MB(原 300KB 仅用于预加载)
|
||||
- 编辑/查看文件 >300KB 时显示"正在加载…"提示
|
||||
- 错误提示文案更新为"文件超过 2MB"
|
||||
|
||||
### 3. 非 root 用户一键配置 sudo 权限
|
||||
- 后端新增 `POST /servers/{id}/setup-files-sudo` 端点
|
||||
- 使用服务器已存储的 SSH 密码,通过 `sudo -S` 自动写入 `/etc/sudoers.d/nexus-files`
|
||||
- 前端:权限提示横幅(非 root 服务器)增加「一键配置 sudo 权限」按钮
|
||||
- 成功后自动刷新权限探测并重新浏览目录
|
||||
|
||||
### 4. 编辑器外观全面改进
|
||||
- **工具栏重构**:移除 `v-toolbar`,改用 flexbox 布局,解决语言选择器位置错乱问题
|
||||
- **语法选择器**:改为 `variant="plain"` 更轻量,宽度从 132px 调整至 110px
|
||||
- **状态栏**:背景改为主题色(蓝色),跟随 Vuetify 主题,不再硬编码 `#252526`;显示行:列、语言、路径、"未保存"提示
|
||||
- **自动换行**:工具栏新增换行切换按钮(`mdi-wrap` / `mdi-wrap-disabled`),支持 Alt+Z 快捷键
|
||||
- **文件标签**:未保存标记从 `text-error` 改为 `text-warning`,更柔和
|
||||
- **全屏/Esc 提示**:全屏按钮 title 补充"(Esc)"说明
|
||||
|
||||
## 涉及文件
|
||||
- `frontend/src/composables/useFilesHotkeys.ts` — 复制粘贴快捷键守卫
|
||||
- `frontend/src/utils/filePreload.ts` — 新增 `FILE_EDITOR_MAX_BYTES`
|
||||
- `frontend/src/pages/FilesPage.vue` — 大文件加载提示、sudoers 一键配置、剪贴板守卫
|
||||
- `frontend/src/components/FileEditorWorkbench.vue` — 编辑器外观全面改进
|
||||
- `server/api/servers.py` — 新增 `setup-files-sudo` 端点
|
||||
|
||||
## 验证方式
|
||||
1. 文件管理器无勾选时 Ctrl+C 不弹出"已复制 N 项" snackbar
|
||||
2. 无剪贴板时 Ctrl+V 无反应
|
||||
3. 编辑 300KB~2MB 文件时显示"正在加载…"蓝色提示
|
||||
4. 非 root 服务器权限横幅出现「一键配置 sudo 权限」按钮
|
||||
5. 编辑器工具栏语言选择器正常显示,状态栏为蓝色主题
|
||||
6. Alt+Z 切换换行,状态栏出现"换行"标记
|
||||
|
||||
## 是否需要迁移/重启
|
||||
- 后端需重启(新增 API 端点):✅ 已执行
|
||||
- 数据库无变更
|
||||
@@ -1,7 +1,25 @@
|
||||
# 示例:Nexus 文件管理免密 sudo(由运维审阅后安装)
|
||||
# 安装:visudo -cf /etc/sudoers.d/nexus-files /etc/sudoers.d/nexus-files
|
||||
# 将 deploy 替换为实际 SSH 用户名
|
||||
# Nexus 文件管理 — 目标机免密 sudo 示例(须运维审阅后安装)
|
||||
#
|
||||
# 安装步骤(在目标服务器上):
|
||||
# 1. 将本文件复制为 /etc/sudoers.d/nexus-files(权限 0440)
|
||||
# 2. 把下面 deploy 替换为 Nexus 里该服务器的 SSH 用户名
|
||||
# 3. visudo -cf /etc/sudoers.d/nexus-files
|
||||
# 4. 用该 SSH 用户执行:sudo -n true && sudo -n bash -c 'command -v chmod'
|
||||
#
|
||||
# 说明:
|
||||
# - 后端在权限失败时会执行 sudo -n bash -c '…',因此必须允许 /bin/bash 或 /usr/bin/bash
|
||||
# - 写入回退还会用到 tee、mv、mkdir、rm、cat 等
|
||||
# - 禁止使用 ALL=(ALL) NOPASSWD: ALL
|
||||
|
||||
deploy ALL=(ALL) NOPASSWD: /bin/chmod, /usr/bin/chmod, /bin/chown, /usr/bin/chown, \
|
||||
/bin/cp, /bin/mv, /bin/mkdir, /bin/rm, /bin/tar, /usr/bin/tar, \
|
||||
/usr/bin/unzip, /usr/bin/zip, /usr/bin/tee, /bin/cat, /usr/bin/touch
|
||||
deploy ALL=(ALL) NOPASSWD: \
|
||||
/bin/bash, /usr/bin/bash, \
|
||||
/bin/chmod, /usr/bin/chmod, \
|
||||
/bin/chown, /usr/bin/chown, \
|
||||
/bin/cp, /usr/bin/cp, \
|
||||
/bin/mv, /usr/bin/mv, \
|
||||
/bin/mkdir, /usr/bin/mkdir, \
|
||||
/bin/rm, /usr/bin/rm, \
|
||||
/bin/tar, /usr/bin/tar, \
|
||||
/usr/bin/unzip, /usr/bin/zip, \
|
||||
/usr/bin/tee, /bin/cat, /usr/bin/cat, \
|
||||
/usr/bin/touch, /bin/touch
|
||||
|
||||
@@ -7,16 +7,18 @@
|
||||
:style="floatContainerStyle"
|
||||
>
|
||||
<v-card border rounded="lg" class="file-editor-panel d-flex flex-column h-100">
|
||||
<v-toolbar
|
||||
density="compact"
|
||||
color="surface"
|
||||
class="flex-grow-0 file-editor-toolbar"
|
||||
<!-- ── Toolbar ── -->
|
||||
<div
|
||||
class="file-editor-toolbar d-flex align-center flex-shrink-0"
|
||||
:class="{ 'file-editor-toolbar--draggable': !maximized }"
|
||||
@mousedown="onToolbarMouseDown"
|
||||
>
|
||||
<v-icon v-if="!maximized" size="18" class="mr-1 text-medium-emphasis file-editor-drag-hint">
|
||||
<!-- Drag handle -->
|
||||
<v-icon v-if="!maximized" size="16" class="ml-2 mr-1 text-disabled file-editor-drag-hint" @mousedown.stop>
|
||||
mdi-drag
|
||||
</v-icon>
|
||||
|
||||
<!-- File tabs -->
|
||||
<v-tabs
|
||||
v-model="activeTabId"
|
||||
density="compact"
|
||||
@@ -29,49 +31,75 @@
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="text-none"
|
||||
class="text-none px-2"
|
||||
>
|
||||
<span class="text-truncate" style="max-width: 140px;">{{ tab.name }}</span>
|
||||
<span v-if="tab.modified" class="text-error ml-1">●</span>
|
||||
<span class="text-truncate" style="max-width: 130px;">{{ tab.name }}</span>
|
||||
<span v-if="tab.modified" class="text-warning ml-1" style="font-size:10px;">●</span>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-1"
|
||||
class="ml-1 opacity-60"
|
||||
density="compact"
|
||||
@click.stop="requestCloseTab(tab.id)"
|
||||
/>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<v-select
|
||||
v-model="activeLanguageChoice"
|
||||
:items="EDITOR_LANGUAGE_OPTIONS"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="file-editor-lang-select flex-grow-0"
|
||||
label="语法"
|
||||
@mousedown.stop
|
||||
@update:model-value="onLanguageChoiceChange"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-content-save"
|
||||
|
||||
<!-- Separator -->
|
||||
<v-divider vertical class="mx-1" style="height:20px;align-self:center;" @mousedown.stop />
|
||||
|
||||
<!-- Language selector -->
|
||||
<v-select
|
||||
v-model="activeLanguageChoice"
|
||||
:items="EDITOR_LANGUAGE_OPTIONS"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="plain"
|
||||
hide-details
|
||||
class="file-editor-lang-select flex-shrink-0"
|
||||
@mousedown.stop
|
||||
@update:model-value="onLanguageChoiceChange"
|
||||
/>
|
||||
|
||||
<!-- Word wrap toggle -->
|
||||
<v-btn
|
||||
:icon="wordWrap ? 'mdi-wrap' : 'mdi-wrap-disabled'"
|
||||
size="small"
|
||||
variant="text"
|
||||
:color="wordWrap ? 'primary' : undefined"
|
||||
:title="wordWrap ? '关闭自动换行 (Alt+Z)' : '开启自动换行 (Alt+Z)'"
|
||||
density="compact"
|
||||
class="flex-shrink-0"
|
||||
@mousedown.stop
|
||||
@click="toggleWordWrap"
|
||||
/>
|
||||
|
||||
<!-- Separator -->
|
||||
<v-divider vertical class="mx-1" style="height:20px;align-self:center;" @mousedown.stop />
|
||||
|
||||
<!-- Save -->
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-content-save"
|
||||
:loading="saving"
|
||||
:disabled="!activeTab?.modified || !ready"
|
||||
class="flex-shrink-0 mr-1"
|
||||
@mousedown.stop
|
||||
@click="saveActive"
|
||||
>
|
||||
保存
|
||||
</v-btn>
|
||||
|
||||
<!-- Window controls -->
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||
:title="maximized ? '退出全屏' : '全屏'"
|
||||
:title="maximized ? '退出全屏 (Esc)' : '全屏'"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="maximized = !maximized"
|
||||
/>
|
||||
@@ -80,6 +108,7 @@
|
||||
variant="text"
|
||||
icon="mdi-window-minimize"
|
||||
title="最小化"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="minimized = true"
|
||||
/>
|
||||
@@ -88,11 +117,14 @@
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
title="关闭编辑器"
|
||||
density="compact"
|
||||
class="mr-1"
|
||||
@mousedown.stop
|
||||
@click="requestClosePanel"
|
||||
/>
|
||||
</v-toolbar>
|
||||
</div>
|
||||
|
||||
<!-- ── Main area ── -->
|
||||
<div class="file-editor-main d-flex flex-grow-1 min-h-0">
|
||||
<aside class="file-editor-sidebar flex-shrink-0" @mousedown.stop>
|
||||
<FileDirectoryTree
|
||||
@@ -108,10 +140,16 @@
|
||||
<v-progress-linear v-if="!ready" indeterminate absolute class="file-editor-progress" />
|
||||
<div ref="editorHost" class="file-editor-host" />
|
||||
</div>
|
||||
<div class="file-editor-status text-caption px-3 py-1 d-flex flex-wrap ga-3">
|
||||
<span>行 {{ cursorLine }}, 列 {{ cursorColumn }}</span>
|
||||
<!-- Status bar -->
|
||||
<div class="file-editor-status text-caption px-3 py-1 d-flex flex-wrap align-center ga-3">
|
||||
<span class="text-mono">{{ cursorLine }}:{{ cursorColumn }}</span>
|
||||
<v-divider vertical style="height:12px;" />
|
||||
<span>{{ activeLanguage }}</span>
|
||||
<span class="text-truncate flex-grow-1">{{ activeTab?.path ?? '' }}</span>
|
||||
<v-divider vertical style="height:12px;" />
|
||||
<span v-if="wordWrap" class="text-primary" style="font-size:11px;">换行</span>
|
||||
<v-divider v-if="wordWrap" vertical style="height:12px;" />
|
||||
<span class="text-truncate flex-grow-1 text-medium-emphasis">{{ activeTab?.path ?? '' }}</span>
|
||||
<span v-if="activeTab?.modified" class="text-warning">未保存</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,6 +163,7 @@
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Minimized dock -->
|
||||
<v-card
|
||||
v-if="tabs.length > 0 && minimized"
|
||||
border
|
||||
@@ -142,7 +181,7 @@
|
||||
@click="restoreTab(tab.id)"
|
||||
>
|
||||
{{ tab.name }}
|
||||
<span v-if="tab.modified" class="text-error ml-1">●</span>
|
||||
<span v-if="tab.modified" class="text-warning ml-1">●</span>
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="text" icon="mdi-dock-window" title="展开" @click="minimized = false" />
|
||||
@@ -150,6 +189,7 @@
|
||||
</v-card>
|
||||
</Teleport>
|
||||
|
||||
<!-- Unsaved-changes dialog -->
|
||||
<v-dialog v-model="showCloseConfirm" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>{{ closeConfirmTitle }}</v-card-title>
|
||||
@@ -229,6 +269,7 @@ const showCloseConfirm = ref(false)
|
||||
const cursorLine = ref(1)
|
||||
const cursorColumn = ref(1)
|
||||
const treeBrowsePath = ref(props.currentPath || '/')
|
||||
const wordWrap = ref(false)
|
||||
|
||||
const editorHost = ref<HTMLElement>()
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
|
||||
@@ -356,6 +397,7 @@ function mountEditor() {
|
||||
editor = monaco.editor.create(editorHost.value, {
|
||||
...buildMonacoEditorOptions(),
|
||||
model,
|
||||
wordWrap: wordWrap.value ? 'on' : 'off',
|
||||
})
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
@@ -376,6 +418,11 @@ function mountEditor() {
|
||||
else requestClosePanel()
|
||||
})
|
||||
|
||||
// Alt+Z: toggle word wrap (VSCode convention)
|
||||
editor.addCommand(monaco.KeyMod.Alt | monaco.KeyCode.KeyZ, () => {
|
||||
toggleWordWrap()
|
||||
})
|
||||
|
||||
layoutObserver = new ResizeObserver(() => {
|
||||
editor?.layout()
|
||||
})
|
||||
@@ -554,8 +601,12 @@ async function confirmSaveClose() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWordWrap() {
|
||||
wordWrap.value = !wordWrap.value
|
||||
editor?.updateOptions({ wordWrap: wordWrap.value ? 'on' : 'off' })
|
||||
}
|
||||
|
||||
async function saveActive() {
|
||||
if (!editor || !activeTab.value) return
|
||||
if (!props.serverId) {
|
||||
snackbar('请先选择服务器', 'error')
|
||||
return
|
||||
@@ -637,6 +688,14 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
/* ── Toolbar ── */
|
||||
.file-editor-toolbar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-editor-toolbar--draggable {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -644,11 +703,40 @@ onBeforeUnmount(() => {
|
||||
.file-editor-toolbar--draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.file-editor-drag-hint {
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-editor-tabs {
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
}
|
||||
.file-editor-tabs :deep(.v-tab) {
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-editor-tabs :deep(.v-tab--selected) {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
.file-editor-lang-select {
|
||||
width: 110px;
|
||||
min-width: 90px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-editor-lang-select :deep(.v-field__input) {
|
||||
font-size: 12px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
/* ── Main area ── */
|
||||
.file-editor-main {
|
||||
min-height: 0;
|
||||
}
|
||||
.file-editor-sidebar {
|
||||
width: 220px;
|
||||
width: 210px;
|
||||
border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -672,23 +760,35 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* ── Status bar ── */
|
||||
.file-editor-status {
|
||||
background: #252526;
|
||||
color: #cccccc;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
background: rgb(var(--v-theme-primary));
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-top: none;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
font-size: 11px;
|
||||
line-height: 24px;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.file-editor-lang-select {
|
||||
max-width: 132px;
|
||||
min-width: 108px;
|
||||
.file-editor-status .text-primary {
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-editor-status .text-warning {
|
||||
color: #FFD740 !important;
|
||||
}
|
||||
.file-editor-status .text-medium-emphasis {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.text-mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
/* ── Resize handle ── */
|
||||
.file-editor-progress {
|
||||
z-index: 2;
|
||||
}
|
||||
.file-editor-tabs :deep(.v-tab) {
|
||||
min-width: 0;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
.file-editor-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@@ -703,6 +803,7 @@ onBeforeUnmount(() => {
|
||||
rgba(var(--v-theme-on-surface), 0.25) 50%
|
||||
);
|
||||
}
|
||||
/* ── Minimized dock ── */
|
||||
.file-editor-dock {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
|
||||
@@ -173,11 +173,8 @@
|
||||
|
||||
|
||||
<p class="text-caption text-medium-emphasis mb-0">
|
||||
|
||||
非 root 用户写/chmod 失败时将按服务器策略尝试 <code>sudo -n</code>。
|
||||
|
||||
示例配置见仓库 <code>{{ docsPath }}</code>(由运维审阅后安装到目标机)。
|
||||
|
||||
{{ filesSudoersDocHint() }}
|
||||
</p>
|
||||
|
||||
</v-card-text>
|
||||
@@ -208,7 +205,7 @@ import type { FileEntry, FilesCapabilityResult, FilesElevationMode } from '@/typ
|
||||
|
||||
import { http } from '@/api'
|
||||
|
||||
import { CHMOD_PRESETS, permissionTooltip } from '@/utils/fileMode'
|
||||
import { CHMOD_PRESETS, filesSudoersDocHint, permissionTooltip } from '@/utils/fileMode'
|
||||
|
||||
|
||||
|
||||
@@ -249,10 +246,6 @@ const recursive = ref(false)
|
||||
|
||||
const isDirectory = computed(() => props.file?.type === 'directory')
|
||||
|
||||
const docsPath = computed(() => capability.value?.docs_path ?? 'docs/deploy/nexus-files-sudoers.example')
|
||||
|
||||
|
||||
|
||||
const fileName = computed(() => props.file?.name ?? '')
|
||||
|
||||
const currentSummary = computed(() => (props.file ? permissionTooltip(props.file) : ''))
|
||||
|
||||
@@ -21,6 +21,7 @@ function isTypingTarget(target: EventTarget | null): boolean {
|
||||
export function useFilesHotkeys(
|
||||
handlers: FilesHotkeyHandlers,
|
||||
selectedFiles: Ref<FileEntry[]>,
|
||||
canPaste: Ref<boolean>,
|
||||
) {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (isTypingTarget(e.target)) return
|
||||
@@ -49,19 +50,27 @@ export function useFilesHotkeys(
|
||||
handlers.selectAll()
|
||||
return
|
||||
}
|
||||
// Ctrl+C / Ctrl+X:仅在有勾选文件时生效,否则让浏览器处理原生复制
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault()
|
||||
handlers.copySelection()
|
||||
if (selectedFiles.value.length > 0) {
|
||||
e.preventDefault()
|
||||
handlers.copySelection()
|
||||
}
|
||||
return
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x') {
|
||||
e.preventDefault()
|
||||
handlers.cutSelection()
|
||||
if (selectedFiles.value.length > 0) {
|
||||
e.preventDefault()
|
||||
handlers.cutSelection()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Ctrl+V:仅在剪贴板有内容时生效
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault()
|
||||
handlers.pasteClipboard()
|
||||
if (canPaste.value) {
|
||||
e.preventDefault()
|
||||
handlers.pasteClipboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ServerBrief {
|
||||
id: number
|
||||
name: string
|
||||
domain?: string
|
||||
target_path?: string
|
||||
is_online?: boolean
|
||||
status?: string
|
||||
}
|
||||
@@ -22,10 +23,10 @@ export function useServerList() {
|
||||
async function loadServers(opts?: { perPage?: number }) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get<{ items: ServerBrief[] }>('/servers/', {
|
||||
const res = await http.getList<ServerBrief>('/servers/', {
|
||||
per_page: opts?.perPage ?? 200,
|
||||
})
|
||||
servers.value = res.items || []
|
||||
servers.value = res.items
|
||||
} catch {
|
||||
servers.value = []
|
||||
} finally {
|
||||
|
||||
@@ -120,7 +120,21 @@
|
||||
icon="mdi-shield-lock-outline"
|
||||
@click:close="dismissFilesAccessHint"
|
||||
>
|
||||
{{ filesAccessHint.text.split('目标机安装示例')[0].trim() }}
|
||||
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
|
||||
<span>{{ filesAccessHint.text.split('目标机安装示例')[0].trim() }}</span>
|
||||
<v-btn
|
||||
v-if="sshUserForFiles && sshUserForFiles !== 'root'"
|
||||
size="small"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
prepend-icon="mdi-auto-fix"
|
||||
:loading="setupSudoLoading"
|
||||
:disabled="setupSudoDone"
|
||||
@click.stop="setupFilesSudo"
|
||||
>
|
||||
{{ setupSudoDone ? '已配置' : '一键配置 sudo 权限' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<!-- Status -->
|
||||
@@ -541,6 +555,7 @@ import { saveBlobAsFile } from '@/utils/fileDownload'
|
||||
import {
|
||||
clearFilePreloadCache,
|
||||
exceedsEditorMaxSize,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
FILE_PRELOAD_MAX_BYTES,
|
||||
invalidateCachedFile,
|
||||
isPreloadEligible,
|
||||
@@ -598,12 +613,36 @@ const dropActive = ref(false)
|
||||
const filesCapability = ref<FilesCapabilityResult | null>(null)
|
||||
const lastReadDeniedPath = ref<string | null>(null)
|
||||
const accessHintDismissed = ref(false)
|
||||
const setupSudoLoading = ref(false)
|
||||
const setupSudoDone = ref(false)
|
||||
|
||||
function dismissFilesAccessHint() {
|
||||
accessHintDismissed.value = true
|
||||
sessionStorage.setItem(FILES_HINT_DISMISSED_KEY, '1')
|
||||
}
|
||||
|
||||
async function setupFilesSudo() {
|
||||
if (!selectedServer.value) return
|
||||
setupSudoLoading.value = true
|
||||
try {
|
||||
const res = await http.post<{ ok: boolean; message: string }>(
|
||||
`/servers/${selectedServer.value}/setup-files-sudo`,
|
||||
{},
|
||||
)
|
||||
setupSudoDone.value = true
|
||||
snackbar(res.message || 'sudoers 配置成功,文件管理器现在可自动提权', 'success')
|
||||
dismissFilesAccessHint()
|
||||
// 重新探测权限,刷新目录
|
||||
await refreshFilesCapability()
|
||||
browse()
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '配置失败'
|
||||
snackbar(`一键配置失败:${msg}`, 'error')
|
||||
} finally {
|
||||
setupSudoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const sshUserForFiles = computed(() => filesCapability.value?.ssh_user ?? null)
|
||||
|
||||
const unreadableFileCount = computed(() => {
|
||||
@@ -899,6 +938,7 @@ function onServerChange() {
|
||||
selectedFiles.value = []
|
||||
lastReadDeniedPath.value = null
|
||||
accessHintDismissed.value = false
|
||||
setupSudoDone.value = false
|
||||
sessionStorage.removeItem(FILES_HINT_DISMISSED_KEY)
|
||||
void refreshFilesCapability()
|
||||
browse()
|
||||
@@ -928,7 +968,7 @@ function onEditorSaved(path: string) {
|
||||
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
|
||||
if (exceedsEditorMaxSize(item.size)) {
|
||||
snackbar(
|
||||
`文件超过 300KB(${formatSize(item.size)}),无法${action},请下载或使用终端`,
|
||||
`文件超过 2MB(${formatSize(item.size)}),无法${action},请下载或使用终端`,
|
||||
'warning',
|
||||
)
|
||||
return true
|
||||
@@ -1278,8 +1318,12 @@ async function previewFile(item: FileEntry) {
|
||||
previewContent.value = ''
|
||||
previewLoading.value = true
|
||||
showPreview.value = true
|
||||
// 超过 300KB 的文件不在预加载缓存,提示用户正在加载
|
||||
if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) {
|
||||
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
|
||||
}
|
||||
try {
|
||||
const raw = await readRemoteFileContent(selectedServer.value, remotePath, FILE_PRELOAD_MAX_BYTES)
|
||||
const raw = await readRemoteFileContent(selectedServer.value, remotePath, FILE_EDITOR_MAX_BYTES)
|
||||
previewContent.value = raw || '(空文件)'
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '读取失败'
|
||||
@@ -1297,10 +1341,14 @@ async function previewFile(item: FileEntry) {
|
||||
async function editFile(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
if (rejectOversizedFile(item, '编辑')) return
|
||||
// 超过 300KB 的文件不在预加载缓存,提示用户正在加载
|
||||
if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) {
|
||||
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
|
||||
}
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const path = joinRemotePath(currentPath.value, item.name)
|
||||
const content = await readRemoteFileContent(selectedServer.value, path, FILE_PRELOAD_MAX_BYTES)
|
||||
const content = await readRemoteFileContent(selectedServer.value, path, FILE_EDITOR_MAX_BYTES)
|
||||
await nextTick()
|
||||
editorWorkbench.value?.openFile({ path, name: item.name, content })
|
||||
} catch (e: unknown) {
|
||||
@@ -1467,6 +1515,7 @@ useFilesHotkeys(
|
||||
},
|
||||
},
|
||||
selectedFiles,
|
||||
canPasteHere,
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
@@ -67,10 +67,18 @@ export function isEntryLikelyUnreadable(
|
||||
return !otherTri.includes('r') && !groupTri.includes('r')
|
||||
}
|
||||
|
||||
/** 文件页提示末尾:仓库内 sudoers 示例路径(需运维安装到目标机)。 */
|
||||
export function filesSudoersDocHint(): string {
|
||||
return `目标机安装示例:${SUDOERS_DOC}(复制到 /etc/sudoers.d/nexus-files 后执行 visudo -cf 校验)`
|
||||
}
|
||||
|
||||
export function buildFilesAccessHint(params: {
|
||||
sshUser?: string | null
|
||||
canSudo: boolean
|
||||
whitelistOk?: boolean
|
||||
isRootSsh: boolean
|
||||
filesElevation?: 'off' | 'auto_sudo' | 'always_sudo'
|
||||
capabilityMessage?: string | null
|
||||
lastReadDeniedPath?: string | null
|
||||
unreadableCount?: number
|
||||
}): { type: 'warning' | 'error'; text: string } | null {
|
||||
@@ -82,20 +90,43 @@ export function buildFilesAccessHint(params: {
|
||||
if (n > 0 && params.sshUser) {
|
||||
lines.push(`当前目录约 ${n} 个文件对 SSH 用户「${params.sshUser}」可能不可读(根据 ls 权限推断)。`)
|
||||
}
|
||||
if (!params.isRootSsh && !params.canSudo) {
|
||||
lines.push(
|
||||
'写权限、chmod、读取 root 属主文件等可能失败。请在目标机配置免密 sudo,或改用 root SSH。',
|
||||
)
|
||||
} else if (!params.isRootSsh && params.canSudo) {
|
||||
lines.push('部分操作将自动尝试 sudo -n(需白名单含 chmod/chown/cat 等)。')
|
||||
|
||||
if (!params.isRootSsh) {
|
||||
if (params.filesElevation === 'off') {
|
||||
lines.push('此服务器「文件提权」已关闭,不会自动 sudo;写/chmod/读 root 属主文件可能失败。')
|
||||
} else if (!params.canSudo) {
|
||||
lines.push(
|
||||
'浏览/写权限、chmod、读取 root 属主目录等可能失败。请在目标机配置免密 sudo(NOPASSWD),或改用 root SSH 用户。',
|
||||
)
|
||||
} else if (!params.whitelistOk) {
|
||||
lines.push(
|
||||
'已可免密 sudo,但白名单可能不完整(须含 bash、chmod、chown、tee、cat 等);部分写/chmod 仍会失败。',
|
||||
)
|
||||
} else if (
|
||||
params.lastReadDeniedPath
|
||||
|| n > 0
|
||||
|| (params.capabilityMessage && !params.capabilityMessage.includes('无需'))
|
||||
) {
|
||||
lines.push('权限不足时将自动尝试 sudo -n(需目标机已按示例配置 sudoers)。')
|
||||
}
|
||||
}
|
||||
|
||||
if (!lines.length) return null
|
||||
const needsSudoersDoc =
|
||||
!params.isRootSsh
|
||||
&& (params.filesElevation === 'off' || !params.canSudo || !params.whitelistOk)
|
||||
const suffix = needsSudoersDoc ? ` ${filesSudoersDocHint()}` : ''
|
||||
const type: 'warning' | 'error' =
|
||||
!params.isRootSsh && !params.canSudo ? 'error' : 'warning'
|
||||
return {
|
||||
type,
|
||||
text: `${lines.join(' ')} 示例:${SUDOERS_DOC}`,
|
||||
}
|
||||
!params.isRootSsh && (!params.canSudo || params.filesElevation === 'off')
|
||||
? 'error'
|
||||
: 'warning'
|
||||
const body = lines.join(' ')
|
||||
const capMsg = params.capabilityMessage?.trim()
|
||||
const text =
|
||||
capMsg && needsSudoersDoc && !body.includes(capMsg)
|
||||
? `${body}(检测:${capMsg})${suffix}`
|
||||
: `${body}${suffix}`
|
||||
return { type, text }
|
||||
}
|
||||
|
||||
export { SUDOERS_DOC }
|
||||
|
||||
@@ -2,9 +2,12 @@ import { http } from '@/api'
|
||||
import { joinRemotePath, normalizeRemotePath } from '@/utils/remotePath'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
|
||||
/** 浏览器内编辑 / 预加载上限(300KB) */
|
||||
/** 后台预加载上限(300KB)—— 超过此大小的文件不自动预读 */
|
||||
export const FILE_PRELOAD_MAX_BYTES = 300 * 1024
|
||||
|
||||
/** 浏览器内编辑 / 查看上限(2MB)—— 超过此大小则提示下载 */
|
||||
export const FILE_EDITOR_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
const MAX_PRELOAD_FILES_PER_DIR = 40
|
||||
const PRELOAD_CONCURRENCY = 4
|
||||
|
||||
@@ -51,7 +54,7 @@ export function isPreloadEligible(item: Pick<FileEntry, 'type' | 'size'>): boole
|
||||
}
|
||||
|
||||
export function exceedsEditorMaxSize(size: number | undefined): boolean {
|
||||
return size != null && size > 0 && size > FILE_PRELOAD_MAX_BYTES
|
||||
return size != null && size > 0 && size > FILE_EDITOR_MAX_BYTES
|
||||
}
|
||||
|
||||
function parseReadFileResponse(res: { content?: string } | string): string {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import json, urllib.request, sys
|
||||
base = 'https://api.synaglobal.vip'
|
||||
req = urllib.request.Request(
|
||||
base + '/api/auth/login',
|
||||
data=json.dumps({'username': 'admin', 'password': 'Nexus@2026'}).encode(),
|
||||
method='POST',
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
tok = json.loads(urllib.request.urlopen(req).read())['access_token']
|
||||
h = {'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json'}
|
||||
|
||||
# get all servers
|
||||
srv_data = json.loads(urllib.request.urlopen(
|
||||
urllib.request.Request(base + '/api/servers/?per_page=50', headers=h)
|
||||
).read())
|
||||
servers = srv_data.get('items', [])
|
||||
print(f'Total servers: {len(servers)}')
|
||||
for s in servers:
|
||||
print(f" id={s['id']} name={s.get('name','')} user={s.get('username','')} target={s.get('target_path','')} domain={s.get('domain','')}")
|
||||
|
||||
print()
|
||||
# browse server 8 several paths
|
||||
for path in ['/', '/www', '/www/wwwroot', '/tmp']:
|
||||
b = json.dumps({'server_id': 8, 'path': path}).encode()
|
||||
try:
|
||||
d = json.loads(urllib.request.urlopen(
|
||||
urllib.request.Request(base + '/api/sync/browse', data=b, method='POST', headers=h),
|
||||
timeout=20
|
||||
).read())
|
||||
n = len(d.get('items', []))
|
||||
err = (d.get('error') or '')[:120]
|
||||
print(f"server8 {path}: items={n} error={repr(err)}")
|
||||
if n > 0:
|
||||
for it in d['items'][:6]:
|
||||
print(f" {it['type']:9} {it['name']}")
|
||||
except Exception as e:
|
||||
print(f"server8 {path}: EXCEPTION {e}")
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nexus 全功能验收(API + 可选 UI)
|
||||
# 用法:
|
||||
# bash scripts/run_full_acceptance.sh
|
||||
# bash scripts/run_full_acceptance.sh --with-ui
|
||||
# bash scripts/run_full_acceptance.sh --with-unit --json reports/acceptance.json
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
EXTRA=()
|
||||
if [[ "${1:-}" == "--with-ui" ]]; then
|
||||
shift
|
||||
export NEXUS_E2E_BASE="${NEXUS_E2E_BASE:-https://api.synaglobal.vip}"
|
||||
export NEXUS_E2E_USER="${NEXUS_E2E_USER:-admin}"
|
||||
if [[ -z "${NEXUS_E2E_PASSWORD:-}" && -z "${NEXUS_TEST_ADMIN_PASSWORD:-}" ]]; then
|
||||
if command -v ssh >/dev/null 2>&1; then
|
||||
export NEXUS_E2E_PASSWORD
|
||||
NEXUS_E2E_PASSWORD="$(ssh -o ConnectTimeout=10 nexus 'grep ^NEXUS_TEST_ADMIN_PASSWORD= /www/wwwroot/api.synaglobal.vip/.env | cut -d= -f2-' | tr -d '"' || true)"
|
||||
export NEXUS_E2E_PASSWORD
|
||||
fi
|
||||
fi
|
||||
EXTRA+=(--with-ui)
|
||||
fi
|
||||
exec python3 scripts/run_nexus_acceptance.py "${EXTRA[@]}" "$@"
|
||||
@@ -0,0 +1,567 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nexus 全功能自动化验收 — 统一入口
|
||||
|
||||
自动执行 API 端到端、扩展 API、可选单元测试与 Playwright UI,按功能模块汇总 PASS/FAIL/SKIP。
|
||||
|
||||
用法:
|
||||
python scripts/run_nexus_acceptance.py
|
||||
python scripts/run_nexus_acceptance.py --base http://127.0.0.1:8600
|
||||
python scripts/run_nexus_acceptance.py --with-ui
|
||||
python scripts/run_nexus_acceptance.py --with-unit
|
||||
python scripts/run_nexus_acceptance.py --json reports/acceptance.json
|
||||
|
||||
环境变量(与 tests/test_api.py 相同):
|
||||
NEXUS_TEST_BASE 默认 http://127.0.0.1:8600
|
||||
NEXUS_TEST_ADMIN_USER 默认 admin
|
||||
NEXUS_TEST_ADMIN_PASSWORD 可从仓库根 .env 读取
|
||||
NEXUS_E2E_BASE / NEXUS_E2E_USER / NEXUS_E2E_PASSWORD (--with-ui 时)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
TESTS_DIR = REPO_ROOT / "tests"
|
||||
sys.path.insert(0, str(TESTS_DIR))
|
||||
|
||||
from acceptance_catalog import FEATURES, match_feature # noqa: E402
|
||||
|
||||
PASS_RE = re.compile(r"\[PASS\]\s*(.+)")
|
||||
FAIL_RE = re.compile(r"\[FAIL\]\s*(.+)")
|
||||
|
||||
|
||||
def _safe_console(text: str) -> str:
|
||||
"""Avoid UnicodeEncodeError on Windows GBK consoles."""
|
||||
return text.encode("ascii", errors="replace").decode("ascii")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckRecord:
|
||||
name: str
|
||||
status: str # pass | fail | skip
|
||||
feature_id: str
|
||||
layer: str
|
||||
message: str = ""
|
||||
duration_ms: float = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureRollup:
|
||||
id: str
|
||||
name: str
|
||||
layer: str
|
||||
description: str
|
||||
status: str = "skip" # pass | fail | skip | warn
|
||||
passed: int = 0
|
||||
failed: int = 0
|
||||
skipped: int = 0
|
||||
checks: list[CheckRecord] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AcceptanceReport:
|
||||
started_at: str
|
||||
base_url: str
|
||||
phases: dict[str, dict]
|
||||
features: list[dict]
|
||||
summary: dict
|
||||
|
||||
|
||||
def _load_env_from_dotenv() -> None:
|
||||
env_path = REPO_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 and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _run_subprocess_script(
|
||||
script_rel: str,
|
||||
env: dict[str, str],
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str, str, float]:
|
||||
script = REPO_ROOT / script_rel
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script)],
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
return proc.returncode, out, proc.stderr or "", elapsed
|
||||
|
||||
|
||||
def _parse_api_output(output: str, layer: str = "api") -> list[CheckRecord]:
|
||||
records: list[CheckRecord] = []
|
||||
for line in output.splitlines():
|
||||
m_pass = PASS_RE.search(line)
|
||||
m_fail = FAIL_RE.search(line)
|
||||
if m_pass:
|
||||
name = m_pass.group(1).strip()
|
||||
fid = match_feature(name)
|
||||
records.append(CheckRecord(name, "pass", fid, layer))
|
||||
elif m_fail:
|
||||
name = m_fail.group(1).strip()
|
||||
fid = match_feature(name)
|
||||
msg = line.split("—", 1)[-1].strip() if "—" in line else ""
|
||||
records.append(CheckRecord(name, "fail", fid, layer, message=msg))
|
||||
return records
|
||||
|
||||
|
||||
def _http_json(
|
||||
method: str,
|
||||
url: str,
|
||||
body: dict | None = None,
|
||||
headers: dict | None = None,
|
||||
timeout: int = 30,
|
||||
) -> tuple[int, str]:
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.getcode(), resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _extra_api_checks(base: str, token: str) -> list[CheckRecord]:
|
||||
"""补充 test_full_site_api 未覆盖或需单独断言的检查。"""
|
||||
records: list[CheckRecord] = []
|
||||
auth = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
# 静态 SPA
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
req = urllib.request.Request(f"{base.rstrip('/')}/app/")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
html = resp.read().decode("utf-8", errors="replace")[:8000]
|
||||
ok = resp.getcode() == 200 and ("id=\"app\"" in html or "id='app'" in html or "/assets/" in html)
|
||||
records.append(
|
||||
CheckRecord(
|
||||
"GET /app/ (SPA 入口)",
|
||||
"pass" if ok else "fail",
|
||||
"infra_app",
|
||||
"infra",
|
||||
message="" if ok else "缺少 #app 或 assets",
|
||||
duration_ms=(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
)
|
||||
except Exception as ex:
|
||||
records.append(CheckRecord("GET /app/", "fail", "infra_app", "infra", message=str(ex)))
|
||||
|
||||
# 登录后探测第一台服务器的 files-capability
|
||||
code, raw = _http_json("GET", f"{base}/api/servers/?per_page=5", headers=auth)
|
||||
first_id = None
|
||||
if code == 200:
|
||||
try:
|
||||
items = json.loads(raw).get("items") or []
|
||||
if items:
|
||||
first_id = items[0].get("id")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if first_id:
|
||||
t0 = time.perf_counter()
|
||||
code2, raw2 = _http_json(
|
||||
"GET",
|
||||
f"{base}/api/servers/{first_id}/files-capability",
|
||||
headers=auth,
|
||||
)
|
||||
ok = code2 == 200 and "ssh_user" in raw2
|
||||
records.append(
|
||||
CheckRecord(
|
||||
f"GET /api/servers/{first_id}/files-capability",
|
||||
"pass" if ok else "fail",
|
||||
"files",
|
||||
"api",
|
||||
message="" if ok else f"HTTP {code2}",
|
||||
duration_ms=(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
code3, _ = _http_json(
|
||||
"POST",
|
||||
f"{base}/api/sync/browse",
|
||||
body={"server_id": first_id, "path": "/tmp"},
|
||||
headers=auth,
|
||||
)
|
||||
records.append(
|
||||
CheckRecord(
|
||||
"POST /api/sync/browse /tmp",
|
||||
"pass" if code3 == 200 else "fail",
|
||||
"files",
|
||||
"api",
|
||||
message=f"HTTP {code3}" if code3 != 200 else "",
|
||||
duration_ms=(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
)
|
||||
else:
|
||||
records.append(
|
||||
CheckRecord(
|
||||
"files-capability & browse (skip)",
|
||||
"skip",
|
||||
"files",
|
||||
"api",
|
||||
message="无可用服务器,跳过 SSH 相关检查",
|
||||
)
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _run_pytest_unit(timeout: int = 300) -> tuple[list[CheckRecord], int]:
|
||||
"""运行不依赖运行中后端的单元测试子集。"""
|
||||
patterns = [
|
||||
"test_posix_paths.py",
|
||||
"test_remote_path_validation.py",
|
||||
"test_unix_ls.py",
|
||||
"test_files_elevation.py",
|
||||
"test_schema_path_validators.py",
|
||||
"test_file_permissions.py",
|
||||
"test_security_unit.py",
|
||||
]
|
||||
paths = [str(TESTS_DIR / p) for p in patterns if (TESTS_DIR / p).is_file()]
|
||||
if not paths:
|
||||
return [CheckRecord("pytest unit", "skip", "unit_core", "unit", message="无单元测试文件")], 0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", *paths, "-q", "--tb=no"],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
# pytest -q: "12 passed" or "1 failed, 11 passed"
|
||||
m = re.search(r"(\d+)\s+passed", out)
|
||||
passed_n = int(m.group(1)) if m else 0
|
||||
m_fail = re.search(r"(\d+)\s+failed", out)
|
||||
failed_n = int(m_fail.group(1)) if m_fail else 0
|
||||
status = "pass" if proc.returncode == 0 else "fail"
|
||||
return [
|
||||
CheckRecord(
|
||||
f"pytest unit ({passed_n} passed, {failed_n} failed)",
|
||||
status,
|
||||
"unit_core",
|
||||
"unit",
|
||||
message=out.strip()[-500:] if status == "fail" else "",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
], proc.returncode
|
||||
|
||||
|
||||
def _run_playwright(env: dict[str, str], timeout: int = 900) -> tuple[list[CheckRecord], int]:
|
||||
frontend = REPO_ROOT / "frontend"
|
||||
spec = frontend / "e2e" / "full-acceptance.spec.mjs"
|
||||
if not spec.is_file():
|
||||
return [CheckRecord("playwright", "skip", "ui_dashboard", "ui", message="缺少 e2e spec")], 0
|
||||
if not (frontend / "node_modules").is_dir():
|
||||
return [
|
||||
CheckRecord(
|
||||
"playwright",
|
||||
"skip",
|
||||
"ui_dashboard",
|
||||
"ui",
|
||||
message="请先在 frontend/ 执行 npm install",
|
||||
)
|
||||
], 0
|
||||
if not env.get("NEXUS_E2E_PASSWORD"):
|
||||
return [
|
||||
CheckRecord(
|
||||
"playwright UI",
|
||||
"skip",
|
||||
"ui_dashboard",
|
||||
"ui",
|
||||
message="未设置 NEXUS_E2E_PASSWORD",
|
||||
)
|
||||
], 0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"npx",
|
||||
"playwright",
|
||||
"test",
|
||||
"e2e/full-acceptance.spec.mjs",
|
||||
"--reporter=line",
|
||||
],
|
||||
cwd=str(frontend),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
shell=os.name == "nt",
|
||||
)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
records: list[CheckRecord] = []
|
||||
# Playwright line reporter: " ok 1 [chromium] › ..."
|
||||
for line in out.splitlines():
|
||||
if " ok " in line and "›" in line:
|
||||
title = line.split("›", 1)[-1].strip()
|
||||
fid = match_feature(title)
|
||||
records.append(CheckRecord(title, "pass", fid, "ui"))
|
||||
elif (" x " in line or "✘" in line) and "›" in line:
|
||||
title = line.split("›", 1)[-1].strip()
|
||||
fid = match_feature(title)
|
||||
records.append(CheckRecord(title, "fail", fid, "ui", message=line.strip()))
|
||||
if not records:
|
||||
records.append(
|
||||
CheckRecord(
|
||||
"playwright suite",
|
||||
"pass" if proc.returncode == 0 else "fail",
|
||||
"ui_dashboard",
|
||||
"ui",
|
||||
message=out[-400:] if proc.returncode != 0 else "",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
)
|
||||
return records, proc.returncode
|
||||
|
||||
|
||||
def _rollup_features(all_checks: list[CheckRecord]) -> list[FeatureRollup]:
|
||||
by_id: dict[str, FeatureRollup] = {}
|
||||
spec_map = {s.id: s for s in FEATURES}
|
||||
for spec in FEATURES:
|
||||
by_id[spec.id] = FeatureRollup(
|
||||
spec.id, spec.name, spec.layer, spec.description,
|
||||
)
|
||||
|
||||
for chk in all_checks:
|
||||
fid = chk.feature_id if chk.feature_id in by_id else "other"
|
||||
if fid not in by_id:
|
||||
by_id[fid] = FeatureRollup(fid, fid, chk.layer, "")
|
||||
roll = by_id[fid]
|
||||
roll.checks.append(chk)
|
||||
if chk.status == "pass":
|
||||
roll.passed += 1
|
||||
elif chk.status == "fail":
|
||||
roll.failed += 1
|
||||
else:
|
||||
roll.skipped += 1
|
||||
|
||||
for roll in by_id.values():
|
||||
if roll.failed > 0:
|
||||
roll.status = "fail"
|
||||
elif roll.passed > 0:
|
||||
roll.status = "pass"
|
||||
elif roll.skipped > 0:
|
||||
roll.status = "skip"
|
||||
else:
|
||||
roll.status = "skip"
|
||||
|
||||
order = {s.id: i for i, s in enumerate(FEATURES)}
|
||||
return sorted(
|
||||
by_id.values(),
|
||||
key=lambda r: (order.get(r.id, 999), r.id),
|
||||
)
|
||||
|
||||
|
||||
def _print_report(rollups: list[FeatureRollup], summary: dict) -> None:
|
||||
# ASCII 标记,避免 Windows GBK 控制台 UnicodeEncodeError
|
||||
icons = {"pass": "[OK]", "fail": "[FAIL]", "skip": "[SKIP]", "warn": "[WARN]"}
|
||||
print("\n" + "=" * 60)
|
||||
print("Nexus 功能验收报告")
|
||||
print("=" * 60)
|
||||
for roll in rollups:
|
||||
if roll.status == "skip" and roll.passed == 0 and roll.failed == 0:
|
||||
continue
|
||||
icon = icons.get(roll.status, "?")
|
||||
detail = f" ({roll.passed} 通过"
|
||||
if roll.failed:
|
||||
detail += f", {roll.failed} 失败"
|
||||
if roll.skipped:
|
||||
detail += f", {roll.skipped} 跳过"
|
||||
detail += ")"
|
||||
print(_safe_console(f" {icon} [{roll.layer:5}] {roll.name}{detail}"))
|
||||
for c in roll.checks:
|
||||
if c.status == "fail":
|
||||
msg = _safe_console((c.message or "")[:120])
|
||||
print(_safe_console(f" - {c.name}: {msg}"))
|
||||
print("-" * 60)
|
||||
print(
|
||||
f"合计: {summary['passed_checks']} 通过, "
|
||||
f"{summary['failed_checks']} 失败, "
|
||||
f"{summary['skipped_checks']} 跳过 | "
|
||||
f"功能模块 {summary['features_pass']}/{summary['features_total']} 正常"
|
||||
)
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Nexus 全功能自动化验收")
|
||||
parser.add_argument("--base", default=None, help="API 基址,默认 NEXUS_TEST_BASE")
|
||||
parser.add_argument("--with-ui", action="store_true", help="运行 Playwright UI 验收(需 Node)")
|
||||
parser.add_argument("--with-unit", action="store_true", help="运行 pytest 单元测试子集")
|
||||
parser.add_argument("--skip-api", action="store_true", help="跳过 test_api / test_full_site_api")
|
||||
parser.add_argument("--json", dest="json_path", default=None, help="写入 JSON 报告路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_env_from_dotenv()
|
||||
base = (args.base or os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600")).rstrip("/")
|
||||
env = os.environ.copy()
|
||||
env["NEXUS_TEST_BASE"] = base
|
||||
|
||||
started = datetime.now(timezone.utc).isoformat()
|
||||
all_checks: list[CheckRecord] = []
|
||||
phases: dict[str, dict] = {}
|
||||
|
||||
print("=" * 60)
|
||||
print("Nexus 全功能自动化验收")
|
||||
print(f" 目标: {base}")
|
||||
print(f" 时间: {started}")
|
||||
print("=" * 60)
|
||||
|
||||
exit_codes: list[int] = []
|
||||
|
||||
if not args.skip_api:
|
||||
for label, script in (
|
||||
("core_api", "tests/test_api.py"),
|
||||
("extended_api", "tests/test_full_site_api.py"),
|
||||
):
|
||||
print(f"\n>>> 阶段: {label} ({script})")
|
||||
code, out, err, ms = _run_subprocess_script(script, env)
|
||||
checks = _parse_api_output(out)
|
||||
all_checks.extend(checks)
|
||||
phases[label] = {
|
||||
"exit_code": code,
|
||||
"checks": len(checks),
|
||||
"duration_ms": ms,
|
||||
"stdout_tail": out[-1500:] if code != 0 else "",
|
||||
}
|
||||
exit_codes.append(code)
|
||||
if err and code != 0:
|
||||
print(err[-500:])
|
||||
|
||||
# 登录拿 token 做补充检查
|
||||
import test_api as api_base # noqa: E402
|
||||
|
||||
api_base._load_credentials_from_env_file()
|
||||
api_base.BASE = base
|
||||
login = api_base.api_test(
|
||||
"acceptance-login",
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
body={
|
||||
"username": api_base.ADMIN_USER,
|
||||
"password": api_base.ADMIN_PASSWORD,
|
||||
},
|
||||
expect_code=200,
|
||||
)
|
||||
token = (login or {}).get("access_token", "")
|
||||
if token:
|
||||
print("\n>>> 阶段: extra_api")
|
||||
extra = _extra_api_checks(base, token)
|
||||
all_checks.extend(extra)
|
||||
phases["extra_api"] = {"checks": len(extra)}
|
||||
else:
|
||||
print(" [WARN] 登录失败,跳过 extra_api(请配置 NEXUS_TEST_ADMIN_PASSWORD)")
|
||||
all_checks.append(
|
||||
CheckRecord("extra_api", "skip", "auth", "api", message="登录失败"),
|
||||
)
|
||||
|
||||
if args.with_unit:
|
||||
print("\n>>> 阶段: unit_tests")
|
||||
unit_checks, code = _run_pytest_unit()
|
||||
all_checks.extend(unit_checks)
|
||||
phases["unit_tests"] = {"exit_code": code}
|
||||
exit_codes.append(code)
|
||||
|
||||
if args.with_ui:
|
||||
print("\n>>> 阶段: playwright_ui")
|
||||
ui_env = env.copy()
|
||||
ui_env.setdefault("NEXUS_E2E_BASE", base)
|
||||
ui_env.setdefault("NEXUS_E2E_USER", os.environ.get("NEXUS_TEST_ADMIN_USER", "admin"))
|
||||
if not ui_env.get("NEXUS_E2E_PASSWORD"):
|
||||
ui_env["NEXUS_E2E_PASSWORD"] = os.environ.get("NEXUS_TEST_ADMIN_PASSWORD", "")
|
||||
ui_checks, code = _run_playwright(ui_env)
|
||||
all_checks.extend(ui_checks)
|
||||
phases["playwright_ui"] = {"exit_code": code, "checks": len(ui_checks)}
|
||||
exit_codes.append(code)
|
||||
|
||||
rollups = _rollup_features(all_checks)
|
||||
passed = sum(1 for c in all_checks if c.status == "pass")
|
||||
failed = sum(1 for c in all_checks if c.status == "fail")
|
||||
skipped = sum(1 for c in all_checks if c.status == "skip")
|
||||
features_pass = sum(1 for r in rollups if r.status == "pass")
|
||||
features_fail = sum(1 for r in rollups if r.status == "fail")
|
||||
features_total = sum(
|
||||
1 for r in rollups if r.status in ("pass", "fail") and (r.passed or r.failed)
|
||||
)
|
||||
|
||||
summary = {
|
||||
"passed_checks": passed,
|
||||
"failed_checks": failed,
|
||||
"skipped_checks": skipped,
|
||||
"features_pass": features_pass,
|
||||
"features_fail": features_fail,
|
||||
"features_total": features_total,
|
||||
"ok": failed == 0 and all(ec == 0 for ec in exit_codes),
|
||||
}
|
||||
|
||||
def _rollup_dict(r: FeatureRollup) -> dict:
|
||||
d = asdict(r)
|
||||
d["checks"] = [asdict(c) for c in r.checks]
|
||||
return d
|
||||
|
||||
report = AcceptanceReport(
|
||||
started_at=started,
|
||||
base_url=base,
|
||||
phases=phases,
|
||||
features=[_rollup_dict(r) for r in rollups],
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
_print_report(rollups, summary)
|
||||
|
||||
if args.json_path:
|
||||
out_path = Path(args.json_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = asdict(report)
|
||||
payload["checks"] = [asdict(c) for c in all_checks]
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\nJSON 报告: {out_path}")
|
||||
|
||||
if not summary["ok"]:
|
||||
print("\n验收未通过:请根据上方 [FAIL] 项修复后重试。")
|
||||
return 1
|
||||
print("\n验收通过:各已测功能表现正常。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
清除登录防暴破锁定(删除 login_attempts 中近期失败记录)。
|
||||
|
||||
锁定规则见 server/application/services/auth_service.py:
|
||||
MAX_LOGIN_FAILURES=5,LOCKOUT_MINUTES=15(按 username + ip_address 统计)
|
||||
|
||||
用法:
|
||||
python scripts/unlock_login_lockout.py
|
||||
python scripts/unlock_login_lockout.py --username admin
|
||||
python scripts/unlock_login_lockout.py --all-failures # 删除该用户全部失败记录
|
||||
|
||||
需在仓库根目录有 .env(含 NEXUS_DATABASE_URL 或 DATABASE_URL),或在生产机部署目录执行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
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 and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _parse_database_url(url: str) -> dict:
|
||||
"""Parse mysql+aiomysql://user:pass@host:port/db"""
|
||||
u = urlparse(url.replace("mysql+aiomysql://", "mysql://", 1))
|
||||
return {
|
||||
"host": u.hostname or "127.0.0.1",
|
||||
"port": u.port or 3306,
|
||||
"user": unquote(u.username or ""),
|
||||
"password": unquote(u.password or ""),
|
||||
"db": (u.path or "/").lstrip("/") or "nexus",
|
||||
}
|
||||
|
||||
|
||||
async def _unlock(username: str, all_failures: bool) -> int:
|
||||
import aiomysql
|
||||
|
||||
raw = (
|
||||
os.environ.get("NEXUS_DATABASE_URL")
|
||||
or os.environ.get("DATABASE_URL")
|
||||
or ""
|
||||
)
|
||||
if not raw:
|
||||
print("ERROR: 未找到 NEXUS_DATABASE_URL / DATABASE_URL,请在 .env 中配置或在生产目录执行", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cfg = _parse_database_url(raw)
|
||||
conn = await aiomysql.connect(
|
||||
host=cfg["host"],
|
||||
port=int(cfg["port"]),
|
||||
user=cfg["user"],
|
||||
password=cfg["password"],
|
||||
db=cfg["db"],
|
||||
connect_timeout=20,
|
||||
)
|
||||
try:
|
||||
async with conn.cursor() as cur:
|
||||
if all_failures:
|
||||
await cur.execute(
|
||||
"DELETE FROM login_attempts WHERE username = %s AND success = 0",
|
||||
(username,),
|
||||
)
|
||||
else:
|
||||
await cur.execute(
|
||||
"""
|
||||
DELETE FROM login_attempts
|
||||
WHERE username = %s AND success = 0
|
||||
AND attempted_at >= UTC_TIMESTAMP() - INTERVAL 20 MINUTE
|
||||
""",
|
||||
(username,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
await conn.commit()
|
||||
print(f"OK: 已删除用户「{username}」失败登录记录 {deleted} 条,锁定已解除。")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="解除 Nexus 登录防暴破锁定")
|
||||
parser.add_argument("--username", default="admin", help="管理员用户名(默认 admin)")
|
||||
parser.add_argument(
|
||||
"--all-failures",
|
||||
action="store_true",
|
||||
help="删除该用户全部历史失败记录(默认仅近 20 分钟)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
_load_dotenv()
|
||||
return asyncio.run(_unlock(args.username, args.all_failures))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,6 +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
|
||||
@@ -953,6 +954,99 @@ async def get_files_capability(
|
||||
return await probe_files_capability(server)
|
||||
|
||||
|
||||
@router.post("/{id}/setup-files-sudo", response_model=dict)
|
||||
async def setup_files_sudo(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""Auto-configure passwordless sudoers for file manager on non-root servers.
|
||||
|
||||
Uses the stored SSH password to run `sudo -S` and write
|
||||
/etc/sudoers.d/nexus-files on the target server.
|
||||
Requires the SSH user to have sudo access with password.
|
||||
"""
|
||||
import shlex
|
||||
import textwrap
|
||||
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
ssh_user = (server.username or "root").strip() or "root"
|
||||
if ssh_user == "root":
|
||||
return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"}
|
||||
|
||||
if not server.password:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="该服务器未存储 SSH 密码,无法自动配置。请手动在目标机配置 sudoers。",
|
||||
)
|
||||
|
||||
plain_password = decrypt_value(server.password)
|
||||
sudoers_content = textwrap.dedent(f"""\
|
||||
# nexus-files: auto-configured by Nexus file manager
|
||||
{ssh_user} ALL=(root) NOPASSWD: \\
|
||||
/bin/ls, /usr/bin/ls, \\
|
||||
/bin/cat, /usr/bin/cat, \\
|
||||
/usr/bin/tee, /bin/tee, \\
|
||||
/bin/mkdir, /usr/bin/mkdir, \\
|
||||
/bin/cp, /usr/bin/cp, \\
|
||||
/bin/mv, /usr/bin/mv, \\
|
||||
/bin/rm, /usr/bin/rm, \\
|
||||
/bin/chmod, /usr/bin/chmod, \\
|
||||
/bin/chown, /usr/bin/chown, \\
|
||||
/bin/bash, /usr/bin/bash
|
||||
""")
|
||||
|
||||
sudoers_path = "/etc/sudoers.d/nexus-files"
|
||||
# Use heredoc via sudo -S to avoid exposing password in command arguments
|
||||
write_cmd = (
|
||||
f"printf %s {shlex.quote(sudoers_content)}"
|
||||
f" | sudo -S tee {shlex.quote(sudoers_path)} > /dev/null"
|
||||
f" && sudo chmod 440 {shlex.quote(sudoers_path)}"
|
||||
f" && sudo visudo -cf {shlex.quote(sudoers_path)}"
|
||||
)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await ssh_pool.acquire(server)
|
||||
# Feed password via stdin for sudo -S (avoids exposing password in process args)
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(write_cmd, input=plain_password + "\n", timeout=15),
|
||||
timeout=20,
|
||||
)
|
||||
exit_code = result.exit_status if result.exit_status is not None else -1
|
||||
stderr_out = (result.stderr or "").strip()
|
||||
stdout_out = (result.stdout or "").strip()
|
||||
if exit_code != 0:
|
||||
detail = stderr_out or stdout_out or f"命令退出码 {exit_code}"
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"sudoers 写入失败:{detail[:300]}",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"SSH 执行失败:{exc}",
|
||||
) from exc
|
||||
finally:
|
||||
if conn is not None:
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"sudoers 已配置:{sudoers_path}。"
|
||||
"现在文件管理器可使用 sudo -n 自动提权浏览/操作文件。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", response_model=dict, status_code=201)
|
||||
async def create_server(
|
||||
request: Request,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Nexus 功能验收目录 — 将自动化检查项映射到产品功能(供 run_nexus_acceptance 使用)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureSpec:
|
||||
id: str
|
||||
name: str
|
||||
layer: str # api | ui | unit | infra
|
||||
description: str
|
||||
keywords: tuple[str, ...] # 匹配子进程输出中的 [PASS]/[FAIL] 行
|
||||
|
||||
|
||||
# 顺序即报告展示顺序
|
||||
FEATURES: tuple[FeatureSpec, ...] = (
|
||||
FeatureSpec("infra_health", "健康检查 /health", "infra", "Shell 守护与负载均衡探活", ("/health", "GET /health")),
|
||||
FeatureSpec("infra_app", "前端静态 /app/", "infra", "SPA 入口可访问", ("/app/", "static")),
|
||||
FeatureSpec("auth", "认证与 JWT", "api", "登录、me、刷新", ("/api/auth/", "Auth")),
|
||||
FeatureSpec("dashboard", "仪表盘统计", "api", "服务器统计与概览", ("/api/servers/stats", "stats")),
|
||||
FeatureSpec("servers", "服务器管理", "api", "CRUD、分类、日志", ("/api/servers/", "Server")),
|
||||
FeatureSpec("files", "文件管理", "api", "browse、files-capability", ("browse", "files-capability", "sync/")),
|
||||
FeatureSpec("terminal", "Web 终端", "api", "快捷命令、webssh-token", ("terminal", "webssh", "quick-commands")),
|
||||
FeatureSpec("push_sync", "推送与同步", "api", "validate-source、sync", ("sync", "validate-source", "Push")),
|
||||
FeatureSpec("scripts", "脚本库", "api", "脚本 CRUD、执行记录", ("/api/scripts/", "Scripts")),
|
||||
FeatureSpec("schedules", "调度任务", "api", "调度 CRUD", ("/api/schedules/", "Schedules")),
|
||||
FeatureSpec("credentials", "凭据预设", "api", "密码/SSH 密钥预设", ("/api/presets/", "ssh-key", "Password")),
|
||||
FeatureSpec("retries", "重试队列", "api", "失败任务重试列表", ("/api/retries/", "Retry")),
|
||||
FeatureSpec("audit", "审计日志", "api", "操作审计分页", ("/api/audit/", "Audit")),
|
||||
FeatureSpec("alerts", "告警历史", "api", "告警列表与统计", ("alert-history", "Alerts")),
|
||||
FeatureSpec("assets", "资产管理", "api", "平台/节点/命令日志", ("/api/assets/", "Assets")),
|
||||
FeatureSpec("search", "全局搜索", "api", "跨资源搜索", ("/api/search/", "Search")),
|
||||
FeatureSpec("settings", "系统设置", "api", "动态配置、IP 白名单", ("/api/settings/", "Settings")),
|
||||
FeatureSpec("health_detail", "健康详情", "api", "/health/detail", ("/health/detail",)),
|
||||
FeatureSpec("ui_dashboard", "页面·仪表盘", "ui", "路由 /", ("01 login", "dashboard")),
|
||||
FeatureSpec("ui_servers", "页面·服务器", "ui", "路由 /servers", ("02 servers",)),
|
||||
FeatureSpec("ui_terminal", "页面·终端", "ui", "路由 /terminal", ("04 terminal",)),
|
||||
FeatureSpec("ui_files", "页面·文件", "ui", "路由 /files", ("05 files",)),
|
||||
FeatureSpec("ui_push", "页面·推送", "ui", "路由 /push", ("06 push",)),
|
||||
FeatureSpec("ui_scripts", "页面·脚本", "ui", "路由 /scripts", ("07 scripts",)),
|
||||
FeatureSpec("ui_credentials", "页面·凭据", "ui", "路由 /credentials", ("08 credentials",)),
|
||||
FeatureSpec("ui_schedules", "页面·调度", "ui", "路由 /schedules", ("09 schedules",)),
|
||||
FeatureSpec("ui_retries", "页面·重试", "ui", "路由 /retries", ("10 retries",)),
|
||||
FeatureSpec("ui_commands", "页面·命令日志", "ui", "路由 /commands", ("11 commands",)),
|
||||
FeatureSpec("ui_alerts", "页面·告警", "ui", "路由 /alerts", ("12 alerts",)),
|
||||
FeatureSpec("ui_audit", "页面·审计", "ui", "路由 /audit", ("13 audit",)),
|
||||
FeatureSpec("ui_settings", "页面·设置", "ui", "路由 /settings", ("14 settings",)),
|
||||
FeatureSpec("ui_theme", "页面·主题与登出", "ui", "主题切换、退出", ("15 theme",)),
|
||||
FeatureSpec("unit_core", "单元测试(核心)", "unit", "路径校验、权限、提权逻辑", ("test_posix", "test_files", "test_unix", "test_remote", "test_schema")),
|
||||
)
|
||||
|
||||
|
||||
def match_feature(check_name: str) -> str:
|
||||
"""Return feature id for a single check label."""
|
||||
low = check_name.lower()
|
||||
for spec in FEATURES:
|
||||
for kw in spec.keywords:
|
||||
if kw.lower() in low:
|
||||
return spec.id
|
||||
return "other"
|
||||
@@ -68,9 +68,9 @@ def main() -> int:
|
||||
print("=" * 50)
|
||||
|
||||
base._load_credentials_from_env_file()
|
||||
base.test_plain_text("GET /health", "GET", "/health", expect_body="ok")
|
||||
base.api_test_plain_text("GET /health", "GET", "/health", expect_body="ok")
|
||||
|
||||
login = base.test("POST /api/auth/login", "POST", "/api/auth/login", body={
|
||||
login = base.api_test("POST /api/auth/login", "POST", "/api/auth/login", body={
|
||||
"username": base.ADMIN_USER,
|
||||
"password": base.ADMIN_PASSWORD,
|
||||
}, expect_code=200)
|
||||
|
||||
Reference in New Issue
Block a user