feat: 文件管理器复制粘贴守卫、2MB编辑限制、一键配置sudoers、编辑器外观重构

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-02 02:20:22 +08:00
parent 57d67c494b
commit 67dac0168f
26 changed files with 5637 additions and 87 deletions
+8
View File
@@ -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 | 否 — 文件名来自 APIVue 默认转义 |
| 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 端点):✅ 已执行
- 数据库无变更
+24 -6
View File
@@ -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
+146 -45
View File
@@ -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) : ''))
+15 -6
View 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()
}
}
}
+3 -2
View File
@@ -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 {
+53 -4
View File
@@ -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>
+42 -11
View File
@@ -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 }
+5 -2
View File
@@ -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
+37
View File
@@ -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}")
+24
View File
@@ -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[@]}" "$@"
+567
View File
@@ -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())
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
清除登录防暴破锁定(删除 login_attempts 中近期失败记录)。
锁定规则见 server/application/services/auth_service.py
MAX_LOGIN_FAILURES=5LOCKOUT_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())
+94
View File
@@ -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,
+64
View File
@@ -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"
+2 -2
View File
@@ -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)