20 Commits

Author SHA1 Message Date
Your Name 6cb591212d refactor(files): P2 split UI into components with page context inject
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 05:14:13 +08:00
Your Name 7cbddf5d48 refactor(files): P1 split composables, Pinia store, directory cache
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 05:05:26 +08:00
Your Name cb738a8e9a fix: file manager double browse from v-select init and stale chunks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 04:36:46 +08:00
Your Name 5393252036 fix: eliminate double browse requests in file manager
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 04:29:11 +08:00
Your Name 3bc2843642 fix: browse序号保护防止token刷新重试导致的双刷新 2026-06-02 03:48:01 +08:00
Your Name 1d6d26d612 fix: 加载中隐藏空状态提示,消除进目录时的视觉双刷新 2026-06-02 03:19:27 +08:00
Your Name c65e3ea2a7 fix: 目录双击导致双刷新(e.detail守卫),横幅仅在实际权限错误时显示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 03:07:51 +08:00
Your Name 67dac0168f feat: 文件管理器复制粘贴守卫、2MB编辑限制、一键配置sudoers、编辑器外观重构
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 02:20:22 +08:00
Your Name 57d67c494b fix: 警告横幅关闭后进子目录不再重新出现,文案简化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 01:51:13 +08:00
Your Name 928e3c5fdb fix: 登录白名单 IP 绕过防暴破锁定
白名单内 IP 跳过失败次数锁定;失败仅审计不计数;成功登录清除该用户历史失败记录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 01:37:22 +08:00
Your Name 832e34fd98 fix: 修复 6 个 MEDIUM 级 Bug (锁定/重试/调度/分页/SSH池/token_version)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 01:29:06 +08:00
Your Name 19cfb7caaa fix: 系统全扫描修复 7 个 HIGH 级 Bug
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 01:22:00 +08:00
Your Name ea3046f635 fix: unix_ls parse_ls_la_line 兼容 long-iso 8字段格式
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 01:09:39 +08:00
Your Name 39a8cf16cf fix(ui): 调度与重试队列路由导航及前端资源一致性
侧栏可滚动与显式导航;懒加载失败提示;getList 兼容分页响应;生产 prune 清理 1065 个过期 chunk。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:36:08 +08:00
Your Name b62d4f6f9f fix(audit): 审计日志操作与目标类型全面中文化
补全 action/target 映射表与词段兜底;审计页筛选下拉使用中文标签。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:18:42 +08:00
Your Name 44c19f329a fix(terminal): Ctrl+D 快捷键文案改为退出
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:13:54 +08:00
Your Name 9e924562d2 fix(terminal): 右下角快捷键与右键菜单中文化并放大
快捷键区改为中文大按钮;工具栏字号/回滚/全屏中文化;右键菜单加大并在命令栏拦截英文系统菜单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:00:49 +08:00
Your Name e3eb0e65a5 fix(ui): 仪表盘/审计/调度/重试修复与终端快捷命令改版
统一终端页与全局侧栏菜单;快捷命令仅在终端执行,增删改排序迁入系统设置;命令输入区加高与 Enter/Shift+Enter 行为;后端自定义命令优先与 reorder API。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 21:56:07 +08:00
Your Name fc056059f9 docs: production deploy changelog for files/POSIX release
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 20:45:14 +08:00
Your Name bd6467dff9 docs: master plan execution progress and changelog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:58:50 +08:00
93 changed files with 10158 additions and 1935 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,14 @@
# 2026-06-01 审计日志操作中文展示
## 摘要
补全审计日志 `action` / `target_type` 中文映射(覆盖登录、服务器、脚本、文件、设置等全部后端动作);未登记动作按词段智能兜底为中文。审计页筛选下拉同步为中文标签。
## 涉及文件
- `frontend/src/utils/auditLabels.ts`
- `frontend/src/pages/AuditPage.vue`
## 验证
打开 `/#/audit`,「操作」列与筛选器应显示中文(如「登录成功」「文件推送」「写入远程文件」),不再出现 `create_server` 等英文蛇形命名。
@@ -0,0 +1,33 @@
# Changelog: 仪表盘/审计/调度/重试队列修复
**日期**: 2026-06-01
## 摘要
1. 仪表盘「最近操作」固定最近 10 条并中文展示
2. 审计日志全中文标签 + 正确分页参数
3. 仪表盘等业务页随侧栏自适应缩进
4. 修复调度/重试队列无法打开(路由未卸载、server_ids 格式)
## 涉及文件
- `frontend/src/App.vue``router-view :key``nexus-page-main`
- `frontend/src/pages/DashboardPage.vue`
- `frontend/src/pages/AuditPage.vue`
- `frontend/src/pages/SchedulesPage.vue`
- `frontend/src/pages/RetriesPage.vue`
- `frontend/src/utils/auditLabels.ts`(新)
- `frontend/src/utils/auditNormalize.ts`(新)
- `server/api/settings.py` — 审计响应 `resource_*` 别名
## 验证
1. `#/` 最近操作 ≤10 条,中文描述
2. `#/audit` 操作/目标列为中文
3. 打开侧栏 → 仪表盘表格区域随动缩进
4.`#/terminal` 点「调度」「重试队列」可正常切换页面
5. `cd frontend && npm run type-check`
## 部署
仅前端构建上传;后端 `settings.py` 小改需 `supervisorctl restart nexus`(可选,前端 normalize 已兼容)。
@@ -0,0 +1,32 @@
# Changelog — 文件管理/POSIX 生产部署
| 项 | 内容 |
|----|------|
| 日期 | 2026-06-01 |
| 类型 | 部署 |
## 摘要
生产部署:`git pull``bd6467d``supervisorctl restart nexus`Windows Node 22 构建前端并 scp 至 `web/app/`prune 732 个过期 assets。`/health` → ok`/app/` → 200。
## 动机
主计划 Phase B 部署闭环。
## 涉及文件
- 生产:`/www/wwwroot/api.synaglobal.vip/`(后端 + `web/app/index.html` + `assets/`
- 本地构建产物(gitignore,未提交)
## 迁移 / 重启
-`supervisorctl restart nexus`
- 已删除生产机遗留目录 `server/server/`bandit 误扫)
## 验证
```bash
ssh nexus "curl -s http://127.0.0.1:8600/health"
ssh nexus "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/app/"
# 浏览器:#/files → 归属列、权限对话框、顶栏提权提示
```
@@ -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,25 @@
# Changelog — 主计划长任务首轮执行
| 项 | 内容 |
|----|------|
| 日期 | 2026-06-01 |
| 类型 | 执行 / 部署 |
## 摘要
执行 [2026-06-01-master-backlog-plan.md](../project/2026-06-01-master-backlog-plan.md)Phase A 单测 44 通过;提交并推送 `d93c518``e704b01`;生产拉取、`supervisorctl restart nexus`、health `ok`;门控 6/7bandit 报生产机 `server/server/` 旧路径 HIGH);修复 `pre_deploy_check.sh` 默认 `DEPLOY_DIR` 与最新 audit 选取。
## 涉及文件
- 79+ 文件功能提交;`deploy/pre_deploy_check.sh``docs/audit/2026-06-01-master-execution-audit.md`;主计划进度更新
## 迁移 / 重启
- 生产已 `supervisorctl restart nexus`
- 前端 `npm run build` 待服务器 Node 环境确认
## 验证
- 本地:`pytest` 44 passed`ruff``import server.main`
- 生产:`curl http://127.0.0.1:8600/health``ok`
- 门控:`bash deploy/pre_deploy_check.sh` → 6/7Security 待清 `server/server` 重复目录或 bandit 路径)
@@ -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,21 @@
# 2026-06-01 调度 / 重试队列页面无法打开修复
## 摘要
- 侧栏增加可滚动区域与显式 `router.push`,避免「调度」「重试队列」在视口外或点击无跳转。
- 路由懒加载失败时全局提示 Ctrl+F5chunk 与 index.html 哈希不一致)。
- `http.getList` 兼容数组与 `{ items, total }` 两种 API 响应。
- 调度/重试页加载失败显示 toast;修复 `v-chip` 非法 `border="current sm"`
## 涉及文件
- `frontend/src/App.vue`
- `frontend/src/router/index.ts`
- `frontend/src/api/index.ts`
- `frontend/src/pages/SchedulesPage.vue`
- `frontend/src/pages/RetriesPage.vue`
## 验证
1. Ctrl+F5 后点击侧栏「调度」「重试队列」,URL 应为 `#/schedules``#/retries`,页面显示表格与标题。
2. 直接访问 `https://api.synaglobal.vip/app/#/schedules``#/retries` 应正常。
@@ -0,0 +1,36 @@
# Changelog: 终端页空状态布局修复
**日期**: 2026-06-01
## 摘要
修复 `#/terminal` 无会话时页面模板叠层:空状态与「选择服务器」对话框同时出现、工具栏显示无意义的 `...` / `断开`
## 动机
用户反馈终端页存在「模板问题」;生产页表现为空状态文案 + 弹窗服务器列表 + 未连接工具栏三层叠在一起,不符合 `2026-06-01-terminal-spa-iteration-design.md` 空状态规范。
## 变更
- **侧栏自适应**:保留 `v-main``--v-layout-left` 缩进;菜单开合时终端宽度随动,xterm 在动画后 `fit()`;桌面 persistent / 移动端 `temporary` 浮层
- **侧栏遮挡修复**`nexus-terminal-main` 不再 `padding:0`(保留 Vuetify 侧栏留白)
- 取消进入 `/terminal` 时自动 `showServerDialog = true`
- 空状态改为内嵌可搜索服务器列表(点击即 `newSession`
- 打开对话框时隐藏空状态(`!showServerDialog`
- 无会话时隐藏会话工具栏
- 侧栏 `v-list-subheader` 移入 `v-list`;浅色主题终端区背景跟随主题
- 快捷命令编辑文案去掉字面量 `\r`
## 涉及文件
- `frontend/src/pages/TerminalPage.vue`
## 验证
1. `cd frontend && npm run type-check`
2. `vite build` 后访问 `https://api.synaglobal.vip/app/#/terminal`:仅见内嵌列表,无自动弹窗
3. 标签栏「+」仍可打开对话框,且不与空状态同时显示
## 迁移 / 重启
仅前端构建部署。
@@ -0,0 +1,38 @@
# 2026-06-01 终端快捷命令与布局修正
## 摘要
- 移除终端页左侧「服务器列表」抽屉,避免与全局运维侧栏菜单混淆;新建会话仍通过标签栏「新建会话」与空状态选择服务器。
- 终端页快捷命令改为只读执行;添加/编辑/删除/排序迁移至 **系统设置 → 终端快捷命令**(锚点 `#terminal-quick-commands`)。
- 命令输入区加高约 3 倍(`ShellHighlightField` large + multiline);快捷命令区字号加大;Enter 发送、Shift+Enter 换行。
- 后端:自定义命令 `sort_order` 优先于内置(内置 `sort_order` 基值 1000+);新增 `PUT /api/terminal/quick-commands/reorder`
## 动机
用户反馈终端页误将左侧改为服务器列表、未充分验证;要求在终端不可直接增删命令,统一全局菜单,并在系统设置中管理顺序(自定义排最前)。
## 涉及文件
- `frontend/src/pages/TerminalPage.vue`
- `frontend/src/pages/SettingsPage.vue`
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
- `frontend/src/composables/useTerminalQuickCommands.ts`
- `frontend/src/components/ShellHighlightField.vue`
- `server/api/terminal.py`
## 迁移 / 重启
- 前端:重新 `vite build` 并部署 `web/app/`
- 后端:拉取代码后 `supervisorctl restart nexus`reorder 端点)。
## 后续(同日)
- 终端右下角「快捷键」区改为中文大按钮(中断/EOF/补全);工具栏字号/回滚/全屏中文化。
- 右键菜单加大(comfortable 密度)、靠近底部时向上弹出;命令栏右键同样显示中文菜单。
## 验证
1. 打开 `https://api.synaglobal.vip/app/#/terminal`:左侧仍为全局运维菜单,无第二列服务器列表。
2. 快捷命令仅可点击执行,无删除/添加按钮;「管理」跳转设置页锚点。
3. `https://api.synaglobal.vip/app/#/settings#terminal-quick-commands`:可添加、编辑、删除、上/下移自定义命令。
4. 终端底部输入框明显加高;Enter 发送、Shift+Enter 换行。
@@ -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 端点):✅ 已执行
- 数据库无变更
@@ -0,0 +1,57 @@
# Changelog: 文件管理器 P1 — Composable 拆分 + 目录缓存
**日期**: 2026-06-02
**类型**: 重构 (Refactor)
**影响范围**: `frontend/src/pages/FilesPage.vue``frontend/src/stores/files.ts``frontend/src/composables/files/*`
---
## 变更摘要
`docs/design/specs/2026-06-02-files-refactor-design.md` 完成 P1:将 1500+ 行 `FilesPage.vue` 业务逻辑拆分为 Pinia store 与域 composable;页面 script 约 110 行,仅负责装配与模板绑定。
---
## 动机
- 单文件难以维护,三处 `browse()` 入口难追踪
- 为 P2 组件拆分、P3 缓存、P4 REST API 打基础
---
## 主要改动
| 模块 | 说明 |
|------|------|
| `stores/files.ts` | 服务器/路径/列表/权限状态;5s TTL 目录缓存(LRU 50 项) |
| `useFilesBrowse.ts` | 浏览、路由同步、序号与 in-flight 去重 |
| `useFilesNavigation.ts` | 面包屑、初始化、路由 watch、服务器切换 |
| `useFilesPermissions.ts` | capability、权限横幅、一键 sudo |
| `useFilesActions.ts` | 上传/删除/重命名/压缩/剪贴板等 |
| `useFilesEditor.ts` | 预览、Monaco 编辑 |
| `useFilesPage.ts` | 页面级装配入口 |
| `FilesPage.vue` | 模板不变,script 瘦身为 `useFilesPage()` |
---
## 行为变化
- **重复进入同一目录(5s 内)**:命中内存缓存,0 次 `/sync/browse`
- **写操作后**`invalidateCache` 强制下次 `browse({ force: true })`
- 其余 UX 与 P0 后一致
---
## 是否需迁移 / 重启
- 仅前端重新构建部署
- 后端无变更
---
## 验证方式
1. `cd frontend && npx vite build`
2. 部署后 `prune_frontend_assets.py`
3. 文件管理:切换服务器、进目录、上传/删除/编辑/粘贴/sudo 配置
4. Network:同目录 5s 内二次进入无 browse 请求
@@ -0,0 +1,48 @@
# Changelog: 文件管理器 P2 — 组件拆分
**日期**: 2026-06-02
**类型**: 重构 (Refactor)
**影响范围**: `frontend/src/pages/FilesPage.vue``frontend/src/components/files/*`
---
## 变更摘要
将文件管理页模板拆分为 6 个子组件;`FilesPage.vue` 仅负责布局装配与 `provideFilesPage` 注入上下文。
---
## 动机
P1 已将逻辑迁入 composable,但单文件模板仍超 500 行,不利于维护与 P5/P6 局部优化。
---
## 新增文件
- `frontend/src/composables/files/filesPageContext.ts` — provide/inject 上下文
- `frontend/src/components/files/FilesToolbar.vue`
- `frontend/src/components/files/FilesPermissionAlert.vue`
- `frontend/src/components/files/FilesStatusBar.vue`
- `frontend/src/components/files/FilesBreadcrumb.vue`
- `frontend/src/components/files/FilesList.vue`
- `frontend/src/components/files/FilesDialogs.vue`
---
## 涉及文件
- `frontend/src/pages/FilesPage.vue` — 由 ~640 行降至 ~45 行
---
## 是否需迁移 / 重启
- 仅前端重新构建部署
---
## 验证方式
1. `cd frontend && npx vite build`
2. 文件管理:工具栏、列表、对话框、编辑器、右键菜单、快捷键
@@ -0,0 +1,92 @@
# 2026-06-02 MEDIUM 级 Bug 修复(6 项)
## 日期
2026-06-02
## 变更摘要
修复系统扫描中发现的 6 个 MEDIUM 级 Bug,提升安全性、数据准确性和系统健壮性。
---
## M1: 登录锁定按 username+ip 计数,可换 IP 绕过
**文件**: `server/infrastructure/database/admin_repo.py`
**问题**: `count_recent_failures` 的 WHERE 条件同时过滤 `username AND ip_address`,攻击者只需更换 IP 即可无限尝试同一账号密码。
**修复**: 移除 `ip_address` 维度,改为仅按 `username` 计数失败次数。换 IP 不再能绕过锁定。
---
## M2: 重复 PushRetryJob 创建(同一服务器多条 pending)
**文件**: `server/application/services/sync_engine_v2.py`
**问题**: 每次 push 失败都无条件创建新的 `PushRetryJob`,若多次触发同一路径的推送且反复失败,会堆积大量重复 pending 任务。
**修复**: 创建前先查询是否已存在相同 `(server_id, source_path, target_path, status=pending)` 的记录,存在则跳过创建。
---
## M3: schedule_runner 先标记已执行再同步,失败时调度记录丢失
**文件**: `server/background/schedule_runner.py`
**问题**: 执行前先将 `last_run_at = now``commit()`,若同步任务抛出异常,`last_run_at` 已更新,该次调度不会重新触发,相当于"静默跳过"了一次调度。
**修复**: 保存 `prev_last_run_at`,执行失败时将 `last_run_at` 回滚,允许下一个周期重新触发。`once` 模式不回滚(已标记 `enabled=False`,防止重复执行)。
---
## M4: servers.py is_online 过滤后 total 仍用 DB 总数,分页不准
**文件**: `server/api/servers.py`
**问题**: 当传入 `is_online` 过滤参数时,Redis 覆盖后的内存过滤结果与 DB 总数不一致,`total``pages` 字段仍反映 DB 全量数量,前端分页逻辑错乱。
**修复**: 内存过滤后重新计算 `total = len(result)``pages` 同步更新。
---
## M5: asyncssh_pool exec_ssh_command 超时后未踢出坏连接
**文件**: `server/infrastructure/ssh/asyncssh_pool.py`
**问题**: `asyncio.TimeoutError``finally` 中仍会 `release()` 超时的连接,将其放回连接池,下一次 `acquire()` 可能复用这个状态未知的坏连接,导致命令执行结果不可靠。
**修复**: 超时和连接错误时改为 `close_connection()` 而非 `release()`,将坏连接从池中彻底移除;一般异常仍 `release()`(可能仍可用)。
---
## M6: token_version += 1 在 NULL 时 TypeError
**文件**: `server/api/auth.py`, `server/application/services/auth_service.py`3 处)
**问题**: DB 中 `token_version``NULL`(直接 INSERT 未设默认值时)时,`admin.token_version += 1``TypeError: unsupported operand type(s) for +=: 'NoneType' and 'int'`,导致修改密码 / 禁用 TOTP / 强制登出接口 500。
**修复**: 所有 `token_version += 1` 统一改为 `(token_version or 0) + 1`,共修复 3 处。
---
## 涉及文件
| 文件 | 修复项 |
|------|-------|
| `server/infrastructure/database/admin_repo.py` | M1(锁定维度) |
| `server/application/services/sync_engine_v2.py` | M2(重复 RetryJob |
| `server/background/schedule_runner.py` | M3(调度回滚) |
| `server/api/servers.py` | M4(分页 total |
| `server/infrastructure/ssh/asyncssh_pool.py` | M5(超时踢连接) |
| `server/api/auth.py` | M6token_version None |
| `server/application/services/auth_service.py` | M6token_version None2 处) |
## 需要迁移/重启
- 后端重启生效(`supervisorctl restart nexus`
- 无数据库 schema 变更
## 验证方式
1. `python -c "import server.main"` — 导入无报错 ✓
2. 同一 IP 登录失败 5 次后,换 IP 仍被锁定
3. 查看 `push_retry_jobs` 表,相同路径不再出现多条 pending
4. 调度执行失败后 `last_run_at` 应保持原值
5. 服务器列表按在线状态过滤,分页 total 与实际条目数一致
@@ -0,0 +1,79 @@
# Changelog: 主动 JWT 续期 —— 消除文件管理器双刷新
**日期**: 2026-06-02
**类型**: 修复 (Bug Fix)
**影响范围**: frontend/src/stores/auth.ts · frontend/src/api/index.ts
---
## 变更摘要
实现客户端 JWT access token 提前续期机制,彻底消除文件管理器"进目录双刷新"问题。
---
## 动机
每次 access token 过期后,`api/index.ts``fetchAuthed()` 会:
1. 先发出一次业务请求 → 服务端返回 401
2. 触发 `/auth/refresh` 获取新 token
3. 用新 token 重发同一业务请求
导致每次进入目录产生 2 次 `/api/sync/browse` 请求,页面可见"双刷新"(`_browseSeq` 虽可防止旧数据覆盖,但无法消除 2 次请求本身)。
---
## 方案
### `frontend/src/stores/auth.ts`
- 新增 `decodeJwtExp(jwt)` 工具函数:不验签地从 JWT Payload 解码 `exp` 字段(秒)
- 新增 `tokenExp` computed:返回当前 token 的过期 unix 秒,无 token 时为 0
- 新增 `isTokenExpiringSoon(thresholdSec = 300)` 方法:当距过期 < `thresholdSec` 秒时返回 true
### `frontend/src/api/index.ts`
`fetchAuthed()` 发出实际请求之前加入一个提前续期分支:
```typescript
if (!_retry && !AUTH_SKIP_REFRESH.has(path) && auth.token && auth.isTokenExpiringSoon(300)) {
await tryRefreshSession()
}
```
若 token 将在 5 分钟内过期,先刷新再发请求,完全跳过"401 → 重试"路径。
原有的 401 兜底重试逻辑保留不变(处理极端并发或时钟偏差场景)。
---
## 涉及文件
| 文件 | 变更类型 |
|------|---------|
| `frontend/src/stores/auth.ts` | 新增 `decodeJwtExp` / `tokenExp` / `isTokenExpiringSoon` |
| `frontend/src/api/index.ts` | 在 `fetchAuthed` 中新增提前续期分支 |
---
## 是否需要迁移 / 重启
- **前端**: 需重新 `vite build` 后部署到服务器
- **后端**: 无变更,无需重启
- **数据库**: 无变更
---
## 追加修复(2026-06-02 续)
- `FilesPage.vue``v-select``onMounted` 赋值 `selectedServer` 时会触发 `@update:model-value``onServerChange` 再次 `browse()`;增加 `_initing` 守卫
- `FilesPage.vue``pageReady` 避免首屏短暂显示「请先选择服务器」
- `FilesPage.vue`:同路径并发 `browse()` 合并为单次 in-flight 请求
- 部署须执行 `deploy/prune_frontend_assets.py` 清理服务器上残留的旧 hash chunk(否则浏览器可能仍加载旧 `FilesPage-*.js`
## 验证方式
1. 登录后等待 access token 接近过期(或手动修改 token exp 为近期时间)
2. 进入文件管理器浏览目录
3. 打开 DevTools Network 过滤 `/sync/browse`
4. 预期:每次进目录只有 **1 条** `/sync/browse` 请求,不再出现 401 后重试的第 2 条
5. 空目录区不应再闪现「请先选择服务器」(已选服务器时)
@@ -0,0 +1,101 @@
# 2026-06-02 全系统 Bug 扫描与修复
## 日期
2026-06-02
## 变更摘要
对全系统进行静态代码扫描,发现并修复 **7 个 HIGH 级 Bug**
---
## HIGH-1: `download_file` SSH 连接在流传输前被过早释放
**文件**: `server/api/sync_v2.py`
**问题**: `finally` 块中 `ssh_pool.release()``StreamingResponse` 生成器尚未开始执行时就关闭连接,导致文件下载中断。
**修复**: 将 `release()` 移入 `file_generator()``finally` 子句,在流传输完成或客户端断开后再释放;提前退出(404/400/413)在各自分支 `await release()`
---
## HIGH-2: WebSocket JWT `tv` 默认值 `-1` 与 HTTP 中间件 `0` 不一致
**文件**: `server/api/websocket.py`
**问题**: `_verify_ws_token` 对无 `tv` 字段的旧 token 使用默认值 `-1`,而数据库 `token_version``NULL/0`,导致 `-1 != 0` 判断失败,合法用户 WebSocket 连接被拒。
**修复**: 对齐 HTTP 中间件逻辑,改为 `payload.get("tv") or 0`DB 侧 `admin.token_version or 0`
---
## HIGH-3: `retry_runner` 达到 `max_retries` 后任务永久僵尸
**文件**: `server/background/retry_runner.py`
**问题**: 重试次数达到 `max_retries` 时,任务状态仍维持 `pending``retry_count < max_retries` 条件不再触发,形成永不被处理的僵尸记录。
**修复**: 成功/失败分支均检查 `retry_count >= max_retries`,达到上限时将 `status` 置为 `failed`,记录原因日志。
---
## HIGH-4: `sync_v2.py` 和 `sync_engine_v2.py` 多处 `stderr` 为 `None` 引发 `TypeError`
**文件**: `server/api/sync_v2.py`, `server/application/services/sync_engine_v2.py`
**问题**: 多处直接执行 `result["stderr"][:N]`,当 SSH 执行结果中 `stderr``None` 时(如连接超时)抛 `TypeError: 'NoneType' object is not subscriptable`,导致 API 返回 500。
**修复**: 所有 `result["stderr"][:N]` 统一改为 `(result.get("stderr") or "")[:N]`,共修复 5 处。
---
## HIGH-5: `auth_service._verify_password` bcrypt 抛 `ValueError` 导致 500
**文件**: `server/application/services/auth_service.py`
**问题**: DB 中若存在格式异常的 `password_hash``bcrypt.checkpw` 会抛 `ValueError`,导致登录接口 500 而非 401。
**修复**: 用 `try/except` 包裹 `bcrypt.checkpw`,异常时返回 `False`(密码不匹配)。
---
## HIGH-6: `install.py /lock` 硬编码查询 `"admin"` 用户名
**文件**: `server/api/install.py`
**问题**: `/lock` 端点通过 `get_by_username("admin")` 验证管理员是否创建,若用户在步骤 4 使用了非 `admin` 的用户名(如 `administrator`),会错误地拒绝锁定(状态 400),且 DB 不可达时会静默放行锁定操作。
**修复**: 改为 `SELECT COUNT(*) FROM admins WHERE is_active = 1` 验证活跃管理员存在;DB 不可达时改为拒绝锁定(503)而非放行。
---
## HIGH-7: `heartbeat_flush.py` 单条失败污染整批事务
**文件**: `server/background/heartbeat_flush.py`
**问题**: 所有服务器在同一 `AsyncSession` 中批量更新,单条 `UPDATE` 失败时仅打日志,但会话处于需要 rollback 的状态,最终 `commit()` 使整批失败,日志显示 "N 台更新" 实际全部失败。同时修复 `datetime` 时区感知问题(naive datetime 写入 MySQL)。
**修复**: 每台服务器使用独立的 `AsyncSessionLocal()` 上下文,单条失败不影响其他;`fromisoformat` 后统一补 `tzinfo=timezone.utc`
---
## 涉及文件
| 文件 | 修复项 |
|------|-------|
| `server/api/sync_v2.py` | HIGH-1(下载连接)、HIGH-4stderr None, 3 处) |
| `server/api/websocket.py` | HIGH-2WebSocket tv |
| `server/api/install.py` | HIGH-6/lock 用户名) |
| `server/application/services/auth_service.py` | HIGH-5bcrypt ValueError |
| `server/application/services/sync_engine_v2.py` | HIGH-4stderr None, 2 处) |
| `server/background/retry_runner.py` | HIGH-3(僵尸任务) |
| `server/background/heartbeat_flush.py` | HIGH-7(事务污染、时区) |
## 需要迁移/重启
- 后端重启生效(`supervisorctl restart nexus`
- 无数据库 migration 变更
## 验证方式
1. `python -c "import server.main"` — 导入无报错 ✓
2. 登录测试 WebSocket 实时推送
3. 下载文件功能测试
4. 查看 `push_retry_jobs` 表确认无 `status=pending` 的僵尸记录
@@ -0,0 +1,53 @@
# 2026-06-02 文件管理器目录列表全空 Bug 修复(unix_ls long-iso 解析)
## 日期
2026-06-02
## 变更摘要
修复 `server/utils/unix_ls.py``parse_ls_la_line` 函数对 GNU `ls -la --time-style=long-iso`
输出格式的解析错误,导致所有普通文件和目录(非软链接)被静默丢弃。
## 根本原因
`ls -la --time-style=long-iso` 输出每行 **8 个字段**(日期和时间各占 1 个字段):
```
drwxr-xr-x 2 root root 4096 2024-05-22 10:30 dirname
```
而原始代码要求 `len(parts) >= 9`,导致所有普通文件/目录行被 `return None` 丢弃。
软链接能幸存,是因为它们的文件名包含 ` -> target`,在 **标准格式**3 个日期字段)下
使字段数达到 9,但在 long-iso 格式下仍只有 8 个字段,实际上也应该被修复。
**表现**
- root 用户(最高权限)浏览任意目录,返回条目数为 0,且 `error` 为空
- 只有软链接偶尔出现(取决于目标系统的 ls 实现)
- 浏览 `/` 只显示 `bin, lib, lib64, sbin` 这 4 个软链接,而非全部目录
## 修复方案
`parse_ls_la_line` 中检测字段 `parts[5]` 是否匹配 `YYYY-MM-DD`
- **long-iso** → 文件名在 `parts[7]`,修改时间为 `parts[5] + parts[6]`
- **standard** → 文件名在 `parts[8]`(同原逻辑)
两种格式均正确提取文件名、软链接目标、修改时间。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/utils/unix_ls.py` | 修复 `parse_ls_la_line`,兼容 long-iso 格式(8 字段) |
## 重要说明
- 此 Bug 与 SSH 用户权限(root / 非 root**无关**
- 使用 GNU `ls`(支持 `--time-style`)的服务器均受影响
- 使用 BusyBox `ls`(不支持 `--time-style`,会 fallback 到标准格式)的服务器不受影响
- **修复后无需重新配置 sudoers**,纯后端解析逻辑修复
## 需要迁移/重启
- 后端重启生效(`supervisorctl restart nexus`
## 验证方式
1. 运行 `python scripts/_test_unix_ls.py` — 全部 OK
2. 部署后访问 `/files?server_id=8`,应能看到 `/` 下的完整目录列表
3. 对比修复前后:`/etc`, `/usr`, `/var`, `/tmp` 等目录应正常显示
@@ -0,0 +1,30 @@
# Changelog: 登录白名单 IP 绕过防暴破锁定
**日期**: 2026-06-02
## 摘要
修复登录流程中「白名单 IP 仍受 15 分钟失败锁定」的问题:白名单内 IP 跳过 `count_recent_failures` 锁定检查;失败时不写入 `login_attempts` 失败计数,仅写审计日志;成功登录时清除该用户历史失败记录,避免被其他 IP 的累计失败连带锁住。
## 动机
运维/内网可信 IP 已在登录白名单中,不应再被全局用户名维度的失败次数锁定;同时白名单 IP 登录成功后应解除因外网误试导致的账户锁定状态。
## 涉及文件
- `server/application/services/auth_service.py``login()` 增加 `is_whitelisted_ip``_handle_login_failure()`、成功时 `clear_failures_for_username`
- `server/infrastructure/database/admin_repo.py``LoginAttemptRepositoryImpl.clear_failures_for_username`
- `server/domain/repositories/__init__.py``LoginAttemptRepository` 协议补充方法
## 迁移 / 重启
- 无需数据库迁移
- 需重启 Nexus 后端(`supervisorctl restart nexus`)后生效
## 验证方式
1. 本地:`python -c "import server.application.services.auth_service"`
2. 开启 `LOGIN_ALLOWLIST_ENABLED`,将当前 IP 加入白名单
3. 用非白名单 IP 对同一用户连续失败 5 次至锁定
4. 从白名单 IP 登录:应不被 `account_locked` 拦截;密码错误时 `audit_logs``login_failed``login_attempts` 不新增失败行
5. 白名单 IP 正确密码登录后,该用户 `login_attempts``success=0` 记录应被清除
+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
@@ -0,0 +1,35 @@
# 文件管理器重构 P1 — 拆分 Composable
**日期**: 2026-06-02
**设计**: `docs/design/specs/2026-06-02-files-refactor-design.md` §3 P1
**前置**: P0 JWT 主动续期(已完成)
## 目标
-`FilesPage.vue` 业务逻辑迁入 `stores/files.ts` + `composables/files/*`
- `FilesPage.vue` 仅保留模板、筛选 UI 状态、组合 composable(目标 script < 400 行,P2 再压至 ≤250
- 引入 Pinia 单一真相 + 5s 目录缓存(P3 子集)
## 文件清单
| 路径 | 职责 |
|------|------|
| `frontend/src/stores/files.ts` | 服务器/路径/列表/loading/权限/缓存 |
| `frontend/src/composables/files/constants.ts` | sessionStorage 键 |
| `frontend/src/composables/files/useFilesBrowse.ts` | browse、错误文案、emptyHint |
| `frontend/src/composables/files/useFilesNavigation.ts` | 路由同步、面包屑、初始化、watch |
| `frontend/src/composables/files/useFilesPermissions.ts` | capability、横幅、一键 sudo |
| `frontend/src/composables/files/useFilesEditor.ts` | 预览/编辑/保存回调 |
| `frontend/src/composables/files/useFilesActions.ts` | 上传/删除/重命名/压缩等 |
| `frontend/src/composables/files/index.ts` | 桶导出 |
| `frontend/src/pages/FilesPage.vue` | 瘦身后页面 |
## 测试要点
- `npx vite build` 通过
- 进目录仍 ≤1 次 `/sync/browse`(缓存命中 0 次)
- 切换服务器、面包屑、粘贴、编辑、sudo 一键配置行为不变
## 回滚
`git revert` P1 commit`FilesPage.vue` 单文件可独立回退。
@@ -0,0 +1,27 @@
# 文件管理器重构 P2 — 拆分 Vue 组件
**日期**: 2026-06-02
**设计**: `docs/design/specs/2026-06-02-files-refactor-design.md` §3 P2
**前置**: P1 composable + Pinia store
## 目标
- 模板迁入 `frontend/src/components/files/*`
- `FilesPage.vue` ≤ 250 行(实际 ~45 行模板 + ~25 行 script
- 子组件通过 `provideFilesPage` / `useFilesPageContext` 共享状态,避免 props 爆炸
## 组件清单
| 组件 | 职责 |
|------|------|
| `FilesToolbar.vue` | 服务器、路径、筛选、操作按钮 |
| `FilesPermissionAlert.vue` | 权限横幅 + 一键 sudo |
| `FilesStatusBar.vue` | 目录统计、剪贴板提示 |
| `FilesBreadcrumb.vue` | 面包屑 + 批量操作条 |
| `FilesList.vue` | 表格、拖拽上传、右键菜单 |
| `FilesDialogs.vue` | 上传/新建/删除/重命名/压缩/预览等对话框 |
## 验证
- `npx vite build`
- 文件管理全功能回归(与 P1 相同)
@@ -64,7 +64,7 @@ flowchart LR
| A-04 | ruff `server/` 零错误 | [x] | |
| A-05 | import 冒烟 | [x] | |
| A-06 | bandit 无 HIGH/MEDIUM | [x] | 门控仅 HIGH0 HIGH |
| A-07 | 7 门预检脚本 | [ ] | 生产需重启后重跑;已修 `DEPLOY_DIR` 默认仓库根 |
| A-07 | 7 门预检脚本 | [x] | 生产 7/7(已删 `server/server/` 遗留目录) |
| A-08 | 合并 changelog(本次主题) | [x] | 见 `docs/changelog/2026-06-01-*.md` 系列(posix / files / phase5 / phase2 / capability |
| A-09 | WSL 集成 smoke(若环境可用) | [ ] | `wsl_integration_smoke.sh` 或项目等价脚本 |
@@ -78,11 +78,11 @@ flowchart LR
|----|------|------|----------|
| DEP-01 | `git push origin main` | [x] | d93c518 |
| DEP-02 | 生产 `git fetch && reset --hard origin/main` | [x] | |
| DEP-03 | `supervisorctl restart nexus` | [ ] | 待 gate 通过后执行 |
| DEP-04 | 健康检查 | [x] | 拉取前已 ok;重启后待复验 |
| DEP-05 | 前端 `deploy/deploy-frontend.sh` | [ ] | `web/app/index.html` + `assets/` 已更新 |
| DEP-06 | 浏览器 `/app/` HTTP 200 | [ ] | 非旧 Tailwind 静态页 |
| DEP-07 | 记录部署日期到本文 | [ ] | 在 §部署记录 填一行 |
| DEP-03 | `supervisorctl restart nexus` | [x] | e704b01 后已 restart |
| DEP-04 | 健康检查 | [x] | `curl``ok` |
| DEP-05 | 前端 `deploy/deploy-frontend.sh` | [x] | Windows Node 22 `npm run build` + tar/scp |
| DEP-06 | 浏览器 `/app/` HTTP 200 | [x] | 生产 `curl` → 200 |
| DEP-07 | 记录部署日期到本文 | [x] | 见下表 |
**依赖**Phase A 的 A-07 通过。
**路径 SSOT**`/www/wwwroot/api.synaglobal.vip/` · SSH 别名 `nexus` · 端口 `8600`
@@ -91,7 +91,7 @@ flowchart LR
| 日期 | 操作人 | DEP-0106 | 备注 |
|------|--------|------------|------|
| | | | |
| 2026-06-01 | AI | 01-06 ✓ | bd6467d 后端 + 前端 FilesPage 等已上线;/app/ 200 |
---
@@ -231,7 +231,7 @@ flowchart LR
代码实现 [x] POSIX 1-7 + 文件管理 P1-P5 + 二期
本地单测门控 [x] Phase AA-07 生产待重跑)
正式审计 8 步 [x] Phase D(合订 master-execution-audit
生产部署 [~] Phase B(已 push+pullrestart+前端待完成)
生产部署 [x] Phase B
浏览器 L1-L4 [ ] Phase C
POSIX 13-18 [~] Phase E13 完成;14-18 待做)
全站 T1-T8 [ ] Phase F
+66 -9
View File
@@ -166,7 +166,14 @@
</v-app-bar>
<!-- Navigation Drawer (隐藏于登录页) -->
<v-navigation-drawer v-if="auth.isLoggedIn" v-model="drawer" color="surface" width="250">
<v-navigation-drawer
v-if="auth.isLoggedIn"
v-model="drawer"
color="surface"
width="250"
class="nexus-nav-drawer"
:temporary="mobile"
>
<v-list nav slim>
<v-list-subheader>运维</v-list-subheader>
@@ -181,6 +188,7 @@
link
nav
slim
@click="navigateTo(item.to)"
/>
</v-list>
@@ -200,6 +208,7 @@
link
nav
slim
@click="navigateTo(item.to)"
/>
</v-list>
@@ -219,8 +228,8 @@
</v-navigation-drawer>
<!-- Main Content -->
<v-main :class="{ 'nexus-terminal-main': route.path === '/terminal' }">
<router-view />
<v-main :class="mainLayoutClass">
<router-view :key="route.fullPath" />
</v-main>
<!-- Snackbar -->
@@ -231,9 +240,9 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch, onUnmounted } from 'vue'
import { ref, reactive, watch, onUnmounted, provide, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTheme } from 'vuetify'
import { useTheme, useDisplay } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { useWebSocket } from '@/composables/useWebSocket'
import { api, http } from '@/api'
@@ -273,6 +282,15 @@ async function toggleTheme() {
}
const drawer = ref(true)
const { mobile } = useDisplay()
/** 终端页监听菜单开合,在动画结束后 refit xterm */
provide('nexusDrawer', drawer)
const mainLayoutClass = computed(() => ({
'nexus-terminal-main': route.path === '/terminal',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
}))
const opsItems = [
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
@@ -347,7 +365,16 @@ function goToCredentials() {
}
function goToSchedules() {
searchMenuOpen.value = false
router.push('/schedules')
navigateTo('/schedules')
}
function navigateTo(to: string) {
if (route.path !== to) {
router.push(to).catch(() => {})
}
if (mobile.value) {
drawer.value = false
}
}
// ── Theme ──
@@ -380,18 +407,48 @@ interface SearchResults {
</script>
<style>
/* 终端页占满主内容区,避免 xterm 高度为 0 */
/*
* 终端页:高度占满内容区;宽度随 --v-layout-left 随侧栏开合自动缩进(勿 padding:0)
*/
.v-application .v-main.nexus-terminal-main {
display: flex;
flex-direction: column;
height: calc(100dvh - 64px);
height: calc(100dvh - var(--v-layout-top, 64px));
min-height: 0;
max-width: 100%;
overflow: hidden;
padding: 0;
box-sizing: border-box;
padding-top: 0 !important;
padding-bottom: 0 !important;
transition:
padding 0.2s cubic-bezier(0.4, 0, 0.2, 1),
height 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.v-application .v-main.nexus-terminal-main > .v-container {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
max-width: 100%;
width: 100%;
padding: 0;
}
/* 仪表盘等业务页:随侧栏 --v-layout-left 缩进,表格可横向滚动 */
.v-application .v-main.nexus-page-main {
box-sizing: border-box;
min-height: calc(100dvh - var(--v-layout-top, 64px));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
transition: padding 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.v-application .v-main.nexus-page-main > * {
min-width: 0;
max-width: 100%;
}
/* 侧栏菜单项过多时可滚动,避免「调度/重试队列」在视口外无法点击 */
.nexus-nav-drawer :deep(.v-navigation-drawer__content) {
overflow-y: auto;
}
</style>
+17 -2
View File
@@ -79,6 +79,12 @@ export async function fetchAuthed(
const { params, _retry, ...init } = opts
const url = buildApiUrl(path, params)
const auth = useAuthStore()
// 主动续期:距过期 < 5min 且非 auth 路径,先刷新再发请求(消除 401 重试双请求)
if (!_retry && !AUTH_SKIP_REFRESH.has(path) && auth.token && auth.isTokenExpiringSoon(300)) {
await tryRefreshSession()
}
const res = await fetch(url, {
...init,
headers: buildAuthHeaders(init),
@@ -206,8 +212,17 @@ export const http = {
api<T>(path, { method: 'GET', params }),
getList: async <T = any>(path: string, params?: Record<string, any>) => {
const arr = await api<T[]>(path, { method: 'GET', params })
return { items: (arr || []) as T[], total: (arr || []).length }
const data = await api<unknown>(path, { method: 'GET', params })
if (Array.isArray(data)) {
return { items: data as T[], total: data.length }
}
if (data && typeof data === 'object') {
const obj = data as { items?: T[]; total?: number }
if (Array.isArray(obj.items)) {
return { items: obj.items, total: obj.total ?? obj.items.length }
}
}
return { items: [] as T[], total: 0 }
},
post: <T = any>(path: string, body?: unknown) =>
+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) : ''))
@@ -1,7 +1,11 @@
<template>
<div
class="sh-field position-relative"
:class="{ 'sh-field--disabled': disabled, 'sh-field--empty': !modelValue }"
:class="{
'sh-field--disabled': disabled,
'sh-field--empty': !modelValue,
'sh-field--large': size === 'large',
}"
>
<div
v-if="modelValue"
@@ -37,12 +41,14 @@ const props = withDefaults(
disabled?: boolean
multiline?: boolean
rows?: number
size?: 'default' | 'large'
}>(),
{
placeholder: '',
disabled: false,
multiline: false,
rows: 3,
size: 'default',
},
)
@@ -123,4 +129,27 @@ function onKeydown(e: KeyboardEvent) {
.sh-field:not(.sh-field--empty) .sh-field__input {
color: transparent;
}
.sh-field--large .sh-field__highlight,
.sh-field--large .sh-field__input {
font-size: 1rem;
line-height: 1.55;
padding: 12px 14px;
}
.sh-field--large .sh-field__input--single {
min-height: 108px;
}
.sh-field--large .sh-field__highlight--single {
min-height: 108px;
}
.sh-field--large .sh-field__input--multi {
min-height: 108px;
}
.sh-field--large .sh-field__highlight--multi {
min-height: 108px;
}
</style>
@@ -0,0 +1,210 @@
<template>
<v-card id="terminal-quick-commands" elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center flex-wrap ga-2">
<v-icon class="mr-1">mdi-flash</v-icon>
终端快捷命令
<v-spacer />
<v-btn size="small" color="primary" variant="flat" prepend-icon="mdi-plus" @click="openCreate">
添加命令
</v-btn>
</v-card-title>
<v-divider />
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-4">
自定义命令显示在终端栏最前优先级最高终端页仅可点击执行在此添加编辑删除与调整顺序
</div>
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-3" />
<div v-if="customCommands().length === 0 && !loading" class="text-medium-emphasis text-body-2 mb-4">
暂无自定义命令点击添加命令创建
</div>
<v-list v-if="customCommands().length" density="compact" class="mb-4 pa-0">
<v-list-item
v-for="(cmd, index) in customCommands()"
:key="cmd.id"
border
rounded="lg"
class="mb-2"
>
<template #prepend>
<span class="text-caption text-medium-emphasis mr-2" style="min-width: 1.5rem">{{ index + 1 }}</span>
</template>
<v-list-item-title>{{ cmd.name }}</v-list-item-title>
<v-list-item-subtitle class="font-mono text-truncate">{{ cmd.cmd }}</v-list-item-subtitle>
<template #append>
<v-btn
icon="mdi-arrow-up"
size="x-small"
variant="text"
:disabled="index === 0 || savingOrder"
@click="moveUp(index)"
/>
<v-btn
icon="mdi-arrow-down"
size="x-small"
variant="text"
:disabled="index === customCommands().length - 1 || savingOrder"
@click="moveDown(index)"
/>
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="openEdit(cmd)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click="confirmRemove(cmd)" />
</template>
</v-list-item>
</v-list>
<div class="text-subtitle-2 mb-2">系统内置不可编辑</div>
<div class="d-flex flex-wrap ga-2">
<v-chip
v-for="cmd in builtinCommands()"
:key="cmd.id"
size="small"
variant="outlined"
label
>
{{ cmd.name }}
</v-chip>
</div>
</v-card-text>
<v-dialog v-model="showEditor" max-width="520">
<v-card border>
<v-card-title>{{ editingId ? '编辑命令' : '添加快捷命令' }}</v-card-title>
<v-card-text>
<v-text-field v-model="formName" label="显示名称" variant="outlined" density="compact" class="mb-3" />
<div class="text-body-2 text-medium-emphasis mb-2">命令内容发送时自动追加回车</div>
<ShellHighlightField v-model="formCmd" multiline :rows="4" placeholder="systemctl status nginx" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showEditor = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="saving" @click="save">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showDelete" max-width="400">
<v-card border>
<v-card-title>删除命令</v-card-title>
<v-card-text>确定删除{{ deletingName }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" :loading="saving" @click="doDelete">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ShellHighlightField from '@/components/ShellHighlightField.vue'
import { useSnackbar } from '@/composables/useSnackbar'
import { useTerminalQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
const snackbar = useSnackbar()
const { commands, loading, load, create, update, remove, reorder, customCommands, builtinCommands } =
useTerminalQuickCommands()
const showEditor = ref(false)
const editingId = ref<number | null>(null)
const formName = ref('')
const formCmd = ref('')
const saving = ref(false)
const savingOrder = ref(false)
const showDelete = ref(false)
const deletingId = ref<number | null>(null)
const deletingName = ref('')
function openCreate() {
editingId.value = null
formName.value = ''
formCmd.value = ''
showEditor.value = true
}
function openEdit(cmd: QuickCmdItem) {
editingId.value = cmd.id
formName.value = cmd.name
formCmd.value = cmd.cmd.replace(/\r$/, '')
showEditor.value = true
}
async function save() {
if (!formName.value.trim() || !formCmd.value.trim()) {
snackbar('名称和命令不能为空', 'warning')
return
}
let cmdText = formCmd.value.trim()
if (!cmdText.endsWith('\r')) cmdText += '\r'
saving.value = true
try {
if (editingId.value) await update(editingId.value, formName.value, cmdText)
else await create(formName.value, cmdText)
showEditor.value = false
snackbar('已保存')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
} finally {
saving.value = false
}
}
function confirmRemove(cmd: QuickCmdItem) {
deletingId.value = cmd.id
deletingName.value = cmd.name
showDelete.value = true
}
async function doDelete() {
if (!deletingId.value) return
saving.value = true
try {
await remove(deletingId.value)
showDelete.value = false
snackbar('已删除')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '删除失败', 'error')
} finally {
saving.value = false
}
}
async function moveUp(index: number) {
const list = customCommands()
if (index <= 0) return
const ids = list.map((c) => c.id)
const prev = ids[index - 1]
ids[index - 1] = ids[index]
ids[index] = prev
await applyOrder(ids)
}
async function moveDown(index: number) {
const list = customCommands()
if (index >= list.length - 1) return
const ids = list.map((c) => c.id)
const next = ids[index + 1]
ids[index + 1] = ids[index]
ids[index] = next
await applyOrder(ids)
}
async function applyOrder(ids: number[]) {
savingOrder.value = true
try {
await reorder(ids)
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '排序失败', 'error')
} finally {
savingOrder.value = false
}
}
onMounted(() => {
load().catch((e) => snackbar(e.message, 'error'))
})
</script>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-breadcrumbs :items="p.breadcrumbs" class="pa-0 mb-2">
<template #item="{ item, index }">
<v-breadcrumbs-item
:disabled="item.disabled"
:class="{ 'text-primary': !item.disabled, 'cursor-pointer': !item.disabled }"
@click="p.onBreadcrumbClick(index)"
>
{{ item.title }}
</v-breadcrumbs-item>
</template>
<template #divider>/</template>
</v-breadcrumbs>
<v-card v-if="p.selectedFileCount > 0" class="mb-4" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2">
<span class="text-body-2 mr-4">已选择 {{ p.selectedFileCount }} 个文件</span>
<v-spacer />
<v-btn size="small" variant="text" color="error" @click="p.batchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="p.selectedFiles = []">取消选择</v-btn>
</v-card-text>
</v-card>
</template>
@@ -0,0 +1,180 @@
<script setup lang="ts">
import FilePermissionDialog from '@/components/FilePermissionDialog.vue'
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-dialog v-model="p.showUpload" max-width="500">
<v-card border>
<v-card-title>上传文件</v-card-title>
<v-card-text>
<v-file-input v-model="p.uploadFiles" label="选择文件" variant="outlined" multiple show-size />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showUpload = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.uploading" @click="p.doUpload">上传</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showNewFile" max-width="400">
<v-card border>
<v-card-title>新建文件</v-card-title>
<v-card-text>
<v-text-field
v-model="p.newFileName"
label="文件名"
variant="outlined"
density="compact"
:rules="[p.validatePathSegment]"
placeholder="example.conf"
autofocus
@keydown.enter="p.doNewFile"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showNewFile = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doNewFile">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showMkdir" max-width="400">
<v-card border>
<v-card-title>新建目录</v-card-title>
<v-card-text>
<v-text-field
v-model="p.mkdirName"
label="目录名"
variant="outlined"
density="compact"
:rules="[p.validatePathSegment]"
@keydown.enter="p.doMkdir"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showMkdir = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="p.doMkdir">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showFileDelete" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>
确定要删除 <strong>{{ p.deletingFile?.name }}</strong>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showFileDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="p.doDeleteFile">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showRename" max-width="400">
<v-card border>
<v-card-title>重命名</v-card-title>
<v-card-text>
<v-text-field
v-model="p.newName"
label="新名称"
variant="outlined"
density="compact"
:rules="[p.required('名称')]"
autofocus
@keydown.enter="p.doRename"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showRename = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="p.doRename">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<FilePermissionDialog
v-model="p.showChmod"
:file="p.chmodFile"
:server-id="p.selectedServer"
:loading="p.chmodLoading"
@apply="p.doChmod"
/>
<v-dialog v-model="p.showCompress" max-width="440">
<v-card border>
<v-card-title>压缩</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">将压缩 {{ p.compressPaths.length }} 项到当前目录</p>
<v-text-field v-model="p.compressDest" label="压缩包路径" variant="outlined" density="compact" />
<v-select
v-model="p.compressFormat"
:items="[
{ title: 'tar.gz', value: 'tar.gz' },
{ title: 'zip', value: 'zip' },
]"
item-title="title"
item-value="value"
label="格式"
variant="outlined"
density="compact"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showCompress = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doCompress">压缩</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showDecompress" max-width="440">
<v-card border>
<v-card-title>解压</v-card-title>
<v-card-text>
<p class="text-body-2 mb-2 text-truncate">{{ p.decompressSource }}</p>
<v-text-field v-model="p.decompressDest" label="解压到目录" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showDecompress = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doDecompress">解压</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showPreview" max-width="900" scrollable>
<v-card border>
<v-card-title class="d-flex align-center">
<span class="text-truncate">{{ p.previewTitle }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="p.showPreview = false" />
</v-card-title>
<v-divider />
<v-card-text style="max-height: 70vh">
<v-progress-linear v-if="p.previewLoading" indeterminate class="mb-2" />
<pre v-else class="files-preview-body text-body-2">{{ p.previewContent }}</pre>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showPreview = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.files-preview-body {
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
margin: 0;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-card
elevation="0"
border
rounded="lg"
:class="{ 'files-drop-active': p.dropActive }"
@dragenter.prevent="p.dropActive = true"
@dragover.prevent="p.dropActive = true"
@dragleave.prevent="p.dropActive = false"
@drop.prevent="p.onDropFiles"
>
<v-data-table
v-model="p.selectedFiles"
:items="p.displayedFiles"
:headers="p.fileHeaders"
:loading="p.loading"
show-select
return-object
hover
density="comfortable"
item-value="name"
:row-props="p.fileRowProps"
>
<template #item.name="{ item }">
<div class="d-flex align-center ga-2 min-w-0">
<v-icon :color="p.entryIconColor(item)" size="20">
{{ p.entryIcon(item) }}
</v-icon>
<v-icon
v-if="p.isEntryLikelyUnreadable(item, p.sshUserForFiles)"
size="18"
color="warning"
title="当前 SSH 用户可能无法读取"
>
mdi-shield-lock
</v-icon>
<div class="min-w-0">
<span :class="{ 'font-weight-medium': item.type === 'directory' }">{{ item.name }}</span>
<div
v-if="item.type === 'symlink' && item.link_target"
class="text-caption text-medium-emphasis text-truncate"
>
{{ item.link_target }}
</div>
</div>
</div>
</template>
<template #item.owner="{ item }">
<v-tooltip :text="p.permissionTooltip(item)" location="top">
<template #activator="{ props: tipProps }">
<v-chip
v-bind="tipProps"
size="small"
variant="tonal"
:color="p.ownerChipColor(item.owner || '')"
class="text-none"
>
{{ p.formatOwnerChip(item) }}
</v-chip>
</template>
</v-tooltip>
</template>
<template #item.size="{ item }">
<span class="text-medium-emphasis">
{{ item.type === 'directory' ? '—' : p.formatSize(item.size) }}
</span>
</template>
<template #item.modified="{ item }">
<span class="text-medium-emphasis">{{ item.modified || '—' }}</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn
v-if="p.isRegularFile(item)"
variant="text"
size="x-small"
density="compact"
color="primary"
@click.stop="p.editFile(item)"
>
编辑
</v-btn>
<v-btn
v-if="p.isRegularFile(item)"
variant="text"
size="x-small"
density="compact"
@click.stop="p.previewFile(item)"
>
查看
</v-btn>
<v-btn
v-if="p.isRegularFile(item)"
variant="text"
size="x-small"
density="compact"
@click.stop="p.downloadFile(item)"
>
下载
</v-btn>
<v-btn
variant="text"
size="x-small"
color="error"
density="compact"
@click.stop="p.confirmDeleteFile(item)"
>
删除
</v-btn>
</div>
</template>
<template #no-data>
<div v-if="p.pageReady && !p.loading" class="text-center text-medium-emphasis py-8">
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
<div>{{ p.emptyHint }}</div>
<div v-if="p.selectedServer" class="text-caption mt-2 text-disabled">
单击目录进入 · 双击文件编辑 · Ctrl+A 全选 · Ctrl+C/V 复制粘贴
</div>
</div>
</template>
</v-data-table>
</v-card>
<v-menu v-model="p.showContextMenu" :target="[p.contextX, p.contextY]" location="bottom start">
<v-list density="compact" nav>
<v-list-item
v-if="p.contextFile && p.contextFile.type === 'directory'"
prepend-icon="mdi-folder-open"
@click="p.contextAction('open')"
>
<v-list-item-title>打开</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-pencil"
@click="p.contextAction('edit')"
>
<v-list-item-title>编辑</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-eye"
@click="p.contextAction('preview')"
>
<v-list-item-title>查看</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-download"
@click="p.contextAction('download')"
>
<v-list-item-title>下载</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile"
prepend-icon="mdi-content-copy"
@click="p.contextAction('copy')"
>
<v-list-item-title>复制 (Ctrl+C)</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile"
prepend-icon="mdi-content-cut"
@click="p.contextAction('cut')"
>
<v-list-item-title>剪切 (Ctrl+X)</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-content-paste"
:disabled="!p.canPasteHere"
@click="p.contextAction('paste')"
>
<v-list-item-title>粘贴到当前目录 (Ctrl+V)</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile?.type === 'directory' && p.canPasteHere"
prepend-icon="mdi-folder-arrow-down"
@click="p.contextAction('pasteInto')"
>
<v-list-item-title>粘贴到{{ p.contextFile.name }}</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-link-variant" @click="p.contextAction('copyPath')">
<v-list-item-title>复制路径</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-console" @click="p.contextAction('terminal')">
<v-list-item-title>终端</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
v-if="p.contextFile && (p.isRegularFile(p.contextFile) || p.contextFile.type === 'directory')"
prepend-icon="mdi-zip-box"
@click="p.contextAction('compress')"
>
<v-list-item-title>压缩</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isArchiveName(p.contextFile.name)"
prepend-icon="mdi-package-up"
@click="p.contextAction('decompress')"
>
<v-list-item-title>解压</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-file-rename-box" @click="p.contextAction('rename')">
<v-list-item-title>重命名</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-lock" @click="p.contextAction('chmod')">
<v-list-item-title>修改权限</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-delete" @click="p.contextAction('delete')">
<v-list-item-title class="text-error">删除</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<style scoped>
.files-drop-active {
outline: 2px dashed rgb(var(--v-theme-primary));
outline-offset: -2px;
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-alert
v-if="p.filesAccessHint"
:type="p.filesAccessHint.type"
variant="tonal"
closable
prominent
density="comfortable"
class="mb-4"
icon="mdi-shield-lock-outline"
@click:close="p.dismissFilesAccessHint"
>
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
<span>{{ p.filesAccessHint.text.split('目标机安装示例')[0].trim() }}</span>
<v-btn
v-if="p.sshUserForFiles && p.sshUserForFiles !== 'root'"
size="small"
variant="flat"
color="primary"
prepend-icon="mdi-auto-fix"
:loading="p.setupSudoLoading"
:disabled="p.setupSudoDone"
@click.stop="p.setupFilesSudo"
>
{{ p.setupSudoDone ? '已配置' : '一键配置 sudo 权限' }}
</v-btn>
</div>
</v-alert>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<div class="text-caption text-medium-emphasis mb-2 d-flex flex-wrap align-center ga-2">
<span>{{ p.statusSummary }}</span>
<v-chip v-if="p.browseError" size="x-small" color="warning" variant="tonal">
{{ p.browseError }}
</v-chip>
<v-chip
v-if="p.hasClipboard"
size="x-small"
color="info"
variant="tonal"
class="cursor-pointer"
@click="() => p.pasteClipboard()"
>
{{ p.clipboardSummary }} {{ p.pasteDestLabel }} · Ctrl+V 粘贴
</v-chip>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4">
<v-row align="center" dense>
<v-col cols="12" sm="4">
<v-select
v-model="p.selectedServer"
:items="p.serverList"
item-title="name"
item-value="id"
label="选择服务器"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-server"
@update:model-value="p.onServerChange"
/>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model="p.currentPath"
label="路径"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-folder"
append-inner-icon="mdi-arrow-right"
@click:append-inner="p.browseCurrent"
@keydown.enter="p.browseCurrent"
/>
</v-col>
<v-col cols="12" sm="3">
<v-text-field
v-model="p.fileFilter"
label="筛选文件名"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-select
v-model="p.extFilter"
:items="p.FILE_EXT_FILTERS"
item-title="title"
item-value="value"
label="类型"
variant="outlined"
density="compact"
hide-details
clearable
/>
</v-col>
<v-col cols="12" sm="2">
<v-select
v-model="p.sortBy"
:items="p.sortOptions"
item-title="title"
item-value="value"
label="排序"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" class="d-flex ga-2 flex-wrap justify-end">
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="p.showUpload = true">
上传
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-file-plus"
:disabled="!p.selectedServer"
@click="p.showNewFile = true"
>
新建文件
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-folder-plus" @click="p.showMkdir = true">
新建目录
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-zip-box"
:disabled="!p.selectedServer || !p.selectedFileCount"
@click="p.openCompressDialog()"
>
压缩
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-content-paste"
:disabled="!p.canPasteHere"
@click="() => p.pasteClipboard()"
>
粘贴
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-arrow-up" :disabled="!p.canGoUp" @click="p.goUp">
上级
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="p.browseCurrent">刷新</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-console"
:disabled="!p.selectedServer"
@click="p.openInTerminal"
>
终端
</v-btn>
</v-col>
</v-row>
</v-card>
</template>
@@ -0,0 +1,5 @@
export const FILES_LAST_SERVER_KEY = 'nexus:files:last_server_id'
export const FILES_HINT_DISMISSED_KEY = 'nexus:files-hint-dismissed'
export const FILES_CACHE_TTL_MS = 5000
export const FILES_CACHE_MAX_ENTRIES = 50
@@ -0,0 +1,18 @@
import { inject, provide, type InjectionKey } from 'vue'
import type { useFilesPage } from './useFilesPage'
export type FilesPageContext = ReturnType<typeof useFilesPage>
export const filesPageKey: InjectionKey<FilesPageContext> = Symbol('filesPage')
export function provideFilesPage(ctx: FilesPageContext): void {
provide(filesPageKey, ctx)
}
export function useFilesPageContext(): FilesPageContext {
const ctx = inject(filesPageKey)
if (!ctx) {
throw new Error('useFilesPageContext() must be used inside FilesPage')
}
return ctx
}
+9
View File
@@ -0,0 +1,9 @@
export { useFilesBrowse, isNotFoundError, friendlyBrowseError } from './useFilesBrowse'
export { useFilesNavigation } from './useFilesNavigation'
export { useFilesPermissions } from './useFilesPermissions'
export { useFilesEditor } from './useFilesEditor'
export { useFilesActions } from './useFilesActions'
export { useFilesSelection } from './useFilesSelection'
export { useFilesPage } from './useFilesPage'
export { provideFilesPage, useFilesPageContext } from './filesPageContext'
export type { FilesPageContext } from './filesPageContext'
@@ -0,0 +1,601 @@
import { computed, nextTick, ref, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { http } from '@/api'
import { useFilesClipboard } from '@/composables/useFilesClipboard'
import { useSnackbar } from '@/composables/useSnackbar'
import { useFilesStore } from '@/stores/files'
import type { FileEntry } from '@/types/api'
import { isArchiveFile } from '@/utils/fileSort'
import { saveBlobAsFile } from '@/utils/fileDownload'
import { invalidateCachedFile } from '@/utils/filePreload'
import {
joinRemotePath,
normalizeRemotePath,
validatePathSegment,
} from '@/utils/remotePath'
import type { useFilesSelection } from './useFilesSelection'
type SelectionApi = ReturnType<typeof useFilesSelection>
export function useFilesActions(options: {
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
invalidateAfterMutation: (parentPath?: string) => void
selection: SelectionApi
openEntry: (item: FileEntry) => void
editFile: (item: FileEntry) => Promise<void>
previewFile: (item: FileEntry) => Promise<void>
openInTerminal: () => void
editorWorkbench: Ref<{ openFile: (p: { path: string; name: string; content: string }) => void } | null>
actionLoading?: Ref<boolean>
}) {
const snackbar = useSnackbar()
const store = useFilesStore()
const { selectedServer, currentPath } = storeToRefs(store)
const { selectedFiles, selectedAbsolutePaths, clearSelection } = options.selection
const {
setFromSelection,
canPasteOnServer,
pasteToDirectory,
} = useFilesClipboard()
const dropActive = ref(false)
const showUpload = ref(false)
const showNewFile = ref(false)
const newFileName = ref('')
const showMkdir = ref(false)
const showCompress = ref(false)
const compressDest = ref('')
const compressFormat = ref<'tar.gz' | 'zip'>('tar.gz')
const compressPaths = ref<string[]>([])
const showDecompress = ref(false)
const decompressSource = ref('')
const decompressDest = ref('.')
const showFileDelete = ref(false)
const uploadFiles = ref<File[]>([])
const uploading = ref(false)
const mkdirName = ref('')
const actionLoading = options.actionLoading ?? ref(false)
const deletingFile = ref<FileEntry | null>(null)
const showRename = ref(false)
const newName = ref('')
const renamingFile = ref<FileEntry | null>(null)
const showChmod = ref(false)
const chmodLoading = ref(false)
const chmodFile = ref<FileEntry | null>(null)
const showContextMenu = ref(false)
const contextX = ref(0)
const contextY = ref(0)
const contextFile = ref<FileEntry | null>(null)
function resolvePasteDestination(explicitDest?: string): string {
if (explicitDest) return normalizeRemotePath(explicitDest)
const dirs = selectedFiles.value.filter((f) => f.type === 'directory')
if (dirs.length === 1 && selectedFiles.value.length === 1) {
return joinRemotePath(currentPath.value, dirs[0].name)
}
return normalizeRemotePath(currentPath.value)
}
const pasteDestLabel = computed(() => {
const dest = resolvePasteDestination()
const cur = normalizeRemotePath(currentPath.value)
return dest === cur ? '当前目录' : dest
})
function copyToClipboard(items?: FileEntry[]) {
if (!selectedServer.value) return
const paths = selectedAbsolutePaths(items)
if (!paths.length) {
snackbar('请先选择文件或目录', 'warning')
return
}
if (setFromSelection('copy', selectedServer.value, paths)) {
snackbar(`已复制 ${paths.length}`)
}
}
function cutToClipboard(items?: FileEntry[]) {
if (!selectedServer.value) return
const paths = selectedAbsolutePaths(items)
if (!paths.length) {
snackbar('请先选择文件或目录', 'warning')
return
}
if (setFromSelection('cut', selectedServer.value, paths)) {
snackbar(`已剪切 ${paths.length}`)
}
}
async function pasteClipboard(explicitDest?: string) {
if (!selectedServer.value) {
snackbar('请先选择服务器', 'warning')
return
}
if (!canPasteOnServer(selectedServer.value)) {
snackbar('剪贴板为空或服务器不一致', 'warning')
return
}
const targetDir = resolvePasteDestination(explicitDest)
actionLoading.value = true
try {
await pasteToDirectory(targetDir)
clearSelection()
options.invalidateAfterMutation(targetDir)
await options.browse({ force: true })
snackbar(`已粘贴到 ${targetDir}`)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '粘贴失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
async function onDropFiles(e: DragEvent) {
dropActive.value = false
if (!selectedServer.value) {
snackbar('请先选择服务器', 'warning')
return
}
const dropped = e.dataTransfer?.files
if (!dropped?.length) return
uploadFiles.value = Array.from(dropped)
await doUpload()
}
function contextAction(action: string) {
showContextMenu.value = false
if (!contextFile.value) return
const item = contextFile.value
switch (action) {
case 'open':
options.openEntry(item)
break
case 'copy':
copyToClipboard([item])
break
case 'cut':
cutToClipboard([item])
break
case 'paste':
void pasteClipboard()
break
case 'pasteInto':
if (item.type === 'directory') {
void pasteClipboard(joinRemotePath(currentPath.value, item.name))
}
break
case 'edit':
void options.editFile(item)
break
case 'preview':
void options.previewFile(item)
break
case 'download':
void downloadFile(item)
break
case 'copyPath':
void copyRemotePath(item)
break
case 'terminal':
options.openInTerminal()
break
case 'compress':
openCompressDialog([item])
break
case 'decompress':
openDecompressDialog(item)
break
case 'rename':
startRename(item)
break
case 'chmod':
startChmod(item)
break
case 'delete':
void confirmDeleteFile(item)
break
}
}
async function copyRemotePath(item: FileEntry) {
const path = joinRemotePath(currentPath.value, item.name)
try {
await navigator.clipboard.writeText(path)
snackbar('路径已复制')
} catch {
snackbar('复制失败', 'error')
}
}
function openCompressDialog(items?: FileEntry[]) {
if (!selectedServer.value) return
const targets = items?.length
? items
: selectedFiles.value.length
? selectedFiles.value
: []
if (!targets.length) {
snackbar('请选择要压缩的文件或目录', 'warning')
return
}
compressPaths.value = targets.map((f) =>
joinRemotePath(currentPath.value, f.name),
)
const base =
targets.length === 1 ? targets[0].name.replace(/\.[^.]+$/, '') : 'archive'
compressDest.value = joinRemotePath(currentPath.value, `${base}.tar.gz`)
compressFormat.value = 'tar.gz'
showCompress.value = true
}
function openDecompressDialog(item: FileEntry) {
if (!selectedServer.value) return
if (!isArchiveFile(item.name)) {
snackbar('仅支持 .tar.gz / .tgz / .zip', 'warning')
return
}
decompressSource.value = joinRemotePath(currentPath.value, item.name)
decompressDest.value = currentPath.value
showDecompress.value = true
}
async function doCompress() {
if (!selectedServer.value || !compressPaths.value.length || !compressDest.value) {
return
}
actionLoading.value = true
try {
let dest = compressDest.value
if (compressFormat.value === 'zip' && !dest.endsWith('.zip')) {
dest = dest.replace(/\.tar\.gz$/i, '.zip')
if (!dest.endsWith('.zip')) dest += '.zip'
}
await http.post('/sync/compress', {
server_id: selectedServer.value,
paths: compressPaths.value,
dest,
format: compressFormat.value,
})
showCompress.value = false
clearSelection()
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('压缩完成')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '压缩失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
async function doDecompress() {
if (!selectedServer.value || !decompressSource.value) return
actionLoading.value = true
try {
await http.post('/sync/decompress', {
server_id: selectedServer.value,
path: decompressSource.value,
dest: decompressDest.value || '.',
})
showDecompress.value = false
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('解压完成')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '解压失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
async function doNewFile() {
if (!selectedServer.value || !newFileName.value) return
const check = validatePathSegment(newFileName.value)
if (check !== true) {
snackbar(check, 'warning')
return
}
const name = newFileName.value
const path = joinRemotePath(currentPath.value, name)
actionLoading.value = true
try {
await http.post('/sync/write-file', {
server_id: selectedServer.value,
path,
content: '',
})
showNewFile.value = false
newFileName.value = ''
invalidateCachedFile(selectedServer.value, path)
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('文件已创建')
await nextTick()
options.editorWorkbench.value?.openFile({ path, name, content: '' })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '创建失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
function startRename(item: FileEntry) {
renamingFile.value = item
newName.value = item.name
showRename.value = true
}
async function doRename() {
if (!selectedServer.value || !renamingFile.value || !newName.value) return
try {
await http.post('/sync/file-ops', {
server_id: selectedServer.value,
operation: 'rename',
path: joinRemotePath(currentPath.value, renamingFile.value.name),
new_path: joinRemotePath(currentPath.value, newName.value),
})
showRename.value = false
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('重命名成功')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '重命名失败'
snackbar(msg, 'error')
}
}
function startChmod(item: FileEntry) {
chmodFile.value = item
showChmod.value = true
}
async function doChmod(payload: {
mode: string
owner?: string
group?: string
recursive?: boolean
}) {
if (!selectedServer.value || !chmodFile.value) return
chmodLoading.value = true
try {
await http.post('/sync/chmod', {
server_id: selectedServer.value,
path: joinRemotePath(currentPath.value, chmodFile.value.name),
mode: payload.mode,
owner: payload.owner,
group: payload.group,
recursive: payload.recursive ?? false,
})
showChmod.value = false
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('权限已修改')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '修改权限失败'
snackbar(msg, 'error')
} finally {
chmodLoading.value = false
}
}
async function batchDelete() {
if (!selectedServer.value || !selectedFiles.value.length) return
if (!confirm(`确定删除 ${selectedFiles.value.length} 个文件?`)) return
let ok = 0
let fail = 0
for (const f of selectedFiles.value) {
try {
await http.post('/sync/file-ops', {
server_id: selectedServer.value,
operation: 'delete',
path: joinRemotePath(currentPath.value, f.name),
})
ok++
} catch {
fail++
}
}
clearSelection()
options.invalidateAfterMutation()
await options.browse({ force: true })
if (fail === 0) snackbar(`已删除 ${ok}`)
else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success')
}
async function downloadFile(item: FileEntry) {
if (!selectedServer.value) return
try {
const remotePath = joinRemotePath(currentPath.value, item.name)
const { blob, filename } = await http.download('/sync/download', {
server_id: selectedServer.value,
path: remotePath,
})
saveBlobAsFile(blob, filename)
snackbar(`已下载 ${filename}`)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '下载失败'
snackbar(msg, 'error')
}
}
async function confirmDeleteFile(item: FileEntry) {
deletingFile.value = item
showFileDelete.value = true
}
async function doDeleteFile() {
if (!selectedServer.value || !deletingFile.value) return
actionLoading.value = true
try {
await http.post('/sync/file-ops', {
server_id: selectedServer.value,
operation: 'delete',
path: joinRemotePath(currentPath.value, deletingFile.value.name),
})
showFileDelete.value = false
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('已删除')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '删除失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
async function doMkdir() {
if (!selectedServer.value || !mkdirName.value) return
const check = validatePathSegment(mkdirName.value)
if (check !== true) {
snackbar(check, 'warning')
return
}
actionLoading.value = true
try {
await http.post('/sync/file-ops', {
server_id: selectedServer.value,
operation: 'mkdir',
path: joinRemotePath(currentPath.value, mkdirName.value),
})
showMkdir.value = false
mkdirName.value = ''
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('目录已创建')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '创建失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
async function doUpload() {
if (!selectedServer.value || !uploadFiles.value.length) return
uploading.value = true
try {
const form = new FormData()
for (const f of uploadFiles.value) form.append('files', f)
form.append('server_id', String(selectedServer.value))
form.append('remote_path', currentPath.value)
await http.upload('/sync/upload', form)
showUpload.value = false
uploadFiles.value = []
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('上传成功')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上传失败'
snackbar(msg, 'error')
} finally {
uploading.value = false
}
}
function isRowActionTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return Boolean(target.closest('button, a, input, .v-checkbox, .v-selection-control'))
}
function fileRowProps(data: { item: FileEntry }) {
const item = data.item
return {
class: item.type === 'directory' ? 'files-row--dir' : undefined,
style: {
cursor:
item.type === 'directory' || item.type === 'symlink' ? 'pointer' : 'default',
},
onClick: (e: MouseEvent) => {
if (isRowActionTarget(e.target)) return
if (e.detail !== 1) return
onRowClick(e, { item })
},
onDblclick: (e: MouseEvent) => {
if (isRowActionTarget(e.target)) return
onRowDblClick(e, { item })
},
onContextmenu: (e: MouseEvent) => {
if (isRowActionTarget(e.target)) return
onRowContext(e, { item })
},
}
}
function onRowClick(_event: Event, { item }: { item: FileEntry }) {
if (item.type === 'directory' || item.type === 'symlink') {
options.openEntry(item)
}
}
function onRowDblClick(_event: Event, { item }: { item: FileEntry }) {
if (item.type === 'file') {
void options.editFile(item)
}
}
function onRowContext(e: MouseEvent, { item }: { item: FileEntry }) {
e.preventDefault()
contextFile.value = item
contextX.value = e.clientX
contextY.value = e.clientY
showContextMenu.value = true
}
return {
dropActive,
showUpload,
showNewFile,
newFileName,
showMkdir,
showCompress,
compressDest,
compressFormat,
compressPaths,
showDecompress,
decompressSource,
decompressDest,
showFileDelete,
uploadFiles,
uploading,
mkdirName,
actionLoading,
deletingFile,
showRename,
newName,
renamingFile,
showChmod,
chmodLoading,
chmodFile,
showContextMenu,
contextX,
contextY,
contextFile,
copyToClipboard,
cutToClipboard,
pasteClipboard,
onDropFiles,
contextAction,
openCompressDialog,
openDecompressDialog,
doCompress,
doDecompress,
doNewFile,
startRename,
doRename,
startChmod,
doChmod,
batchDelete,
downloadFile,
confirmDeleteFile,
doDeleteFile,
doMkdir,
doUpload,
fileRowProps,
pasteDestLabel,
}
}
@@ -0,0 +1,177 @@
import { computed, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useFilesStore } from '@/stores/files'
import type { BrowseResponse, FileEntry } from '@/types/api'
import { parseBrowseResponse } from '@/utils/fileBrowse'
import {
clearFilePreloadCache,
preloadDirectoryFiles,
pruneFilePreloadCache,
} from '@/utils/filePreload'
import { DEFAULT_FILES_ROOT, normalizeRemotePath, parentRemotePath } from '@/utils/remotePath'
let _browseSeq = 0
let _browseInFlight: Promise<void> | null = null
let _browseInFlightKey = ''
export function isNotFoundError(err: string): boolean {
return err.includes('No such file or directory') || err.includes('not a directory')
}
export function friendlyBrowseError(err: string, path: string): string {
if (isNotFoundError(err)) {
return `路径不存在:${path},请检查服务器「target_path」设置`
}
if (err.includes('Permission denied') || err.includes('Operation not permitted')) {
return `无读取权限(${path}`
}
return err
}
export function useFilesBrowse(fileFilter?: Ref<string>) {
const snackbar = useSnackbar()
const route = useRoute()
const router = useRouter()
const store = useFilesStore()
const {
selectedServer,
currentPath,
files,
loading,
browseError,
pageReady,
filesCapability,
} = storeToRefs(store)
function syncRouteQuery() {
const query: Record<string, string | undefined> = {
...(route.query as Record<string, string>),
}
if (selectedServer.value) {
query.server_id = String(selectedServer.value)
query.path =
currentPath.value === DEFAULT_FILES_ROOT ? undefined : currentPath.value
} else {
delete query.server_id
delete query.path
}
router.replace({ query })
}
const emptyHint = computed(() => {
if (!pageReady.value) return '正在加载…'
if (!selectedServer.value) return '请先选择服务器'
if (browseError.value) return browseError.value
if (fileFilter?.value.trim()) return '无匹配文件'
const cap = filesCapability.value
if (cap?.is_root) {
return `目录为空(${currentPath.value}),可在上方修改路径或到「服务器」核对 target_path`
}
return `目录为空(${currentPath.value}`
})
async function browse(options?: { force?: boolean }) {
if (!selectedServer.value) return
const path = normalizeRemotePath(currentPath.value)
const serverId = selectedServer.value
const browseKey = `${serverId}:${path}`
if (!options?.force) {
const cached = store.getCached(serverId, path)
if (cached) {
files.value = cached
browseError.value = ''
loading.value = false
syncRouteQuery()
return
}
}
if (_browseInFlight && _browseInFlightKey === browseKey) {
return _browseInFlight
}
_browseInFlightKey = browseKey
const seq = ++_browseSeq
loading.value = true
currentPath.value = path
_browseInFlight = (async () => {
try {
const res = await http.post<BrowseResponse | FileEntry[]>('/sync/browse', {
server_id: serverId,
path,
})
if (seq !== _browseSeq) return
const { items, error } = parseBrowseResponse(res)
if (error && isNotFoundError(error) && path !== '/') {
browseError.value = ''
loading.value = false
const parent = parentRemotePath(path)
if (parent) {
snackbar(`路径 "${path}" 不存在,已自动跳转到上级目录`, 'info')
currentPath.value = parent
void browse()
return
}
}
browseError.value = error ? friendlyBrowseError(error, path) : ''
if (error && !isNotFoundError(error)) {
snackbar(friendlyBrowseError(error, path), 'warning')
}
files.value = items
if (!error) {
store.setCached(serverId, path, items)
}
if (serverId && items.length) {
pruneFilePreloadCache(serverId, path)
preloadDirectoryFiles(serverId, path, items)
}
} catch {
if (seq !== _browseSeq) return
files.value = []
snackbar('浏览目录失败', 'error')
} finally {
if (seq === _browseSeq) {
loading.value = false
syncRouteQuery()
}
if (_browseInFlightKey === browseKey) {
_browseInFlight = null
_browseInFlightKey = ''
}
}
})()
return _browseInFlight
}
function browseCurrent() {
if (!selectedServer.value) {
snackbar('请先选择服务器', 'warning')
return
}
currentPath.value = normalizeRemotePath(currentPath.value)
store.invalidateCache(selectedServer.value, currentPath.value)
void browse({ force: true })
}
function invalidateAfterMutation(parentPath?: string) {
if (!selectedServer.value) return
const p = parentPath ?? currentPath.value
store.invalidateCache(selectedServer.value, normalizeRemotePath(p))
clearFilePreloadCache()
}
return {
emptyHint,
browse,
browseCurrent,
invalidateAfterMutation,
}
}
@@ -0,0 +1,131 @@
import { nextTick, ref, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useSnackbar } from '@/composables/useSnackbar'
import { useFilesStore } from '@/stores/files'
import type { FileEntry } from '@/types/api'
import { isPermissionDeniedMessage } from '@/utils/fileMode'
import {
exceedsEditorMaxSize,
FILE_EDITOR_MAX_BYTES,
FILE_PRELOAD_MAX_BYTES,
invalidateCachedFile,
readRemoteFileContent,
} from '@/utils/filePreload'
import { joinRemotePath } from '@/utils/remotePath'
function formatSize(bytes: number | undefined) {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
export function useFilesEditor(options: {
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
invalidateAfterMutation: (parentPath?: string) => void
actionLoading: Ref<boolean>
}) {
const snackbar = useSnackbar()
const store = useFilesStore()
const { selectedServer, currentPath, lastReadDeniedPath, accessHintDismissed } =
storeToRefs(store)
const showPreview = ref(false)
const previewTitle = ref('')
const previewContent = ref('')
const previewLoading = ref(false)
const editorWorkbench = ref<{
openFile: (p: { path: string; name: string; content: string }) => void
} | null>(null)
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
if (exceedsEditorMaxSize(item.size)) {
snackbar(
`文件超过 2MB${formatSize(item.size)}),无法${action},请下载或使用终端`,
'warning',
)
return true
}
return false
}
function onEditorSaved(path: string) {
if (selectedServer.value && path) {
invalidateCachedFile(selectedServer.value, path)
}
options.invalidateAfterMutation()
void options.browse({ force: true })
}
async function previewFile(item: FileEntry) {
if (!selectedServer.value) return
if (rejectOversizedFile(item, '查看')) return
const remotePath = joinRemotePath(currentPath.value, item.name)
previewTitle.value = item.name
previewContent.value = ''
previewLoading.value = true
showPreview.value = true
if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) {
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
}
try {
const raw = await readRemoteFileContent(
selectedServer.value,
remotePath,
FILE_EDITOR_MAX_BYTES,
)
previewContent.value = raw || '(空文件)'
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '读取失败'
previewContent.value = msg
if (isPermissionDeniedMessage(msg)) {
lastReadDeniedPath.value = item.name
accessHintDismissed.value = false
}
snackbar(msg, 'error')
} finally {
previewLoading.value = false
}
}
async function editFile(item: FileEntry) {
if (!selectedServer.value) return
if (rejectOversizedFile(item, '编辑')) return
if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) {
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
}
options.actionLoading.value = true
try {
const path = joinRemotePath(currentPath.value, item.name)
const content = await readRemoteFileContent(
selectedServer.value,
path,
FILE_EDITOR_MAX_BYTES,
)
await nextTick()
editorWorkbench.value?.openFile({ path, name: item.name, content })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '读取失败'
if (isPermissionDeniedMessage(msg)) {
lastReadDeniedPath.value = item.name
accessHintDismissed.value = false
}
snackbar(msg, 'error')
} finally {
options.actionLoading.value = false
}
}
return {
showPreview,
previewTitle,
previewContent,
previewLoading,
editorWorkbench,
previewFile,
editFile,
onEditorSaved,
formatSize,
}
}
@@ -0,0 +1,206 @@
import { computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { useFilesStore } from '@/stores/files'
import type { FileEntry } from '@/types/api'
import {
buildRemoteBreadcrumbs,
DEFAULT_FILES_ROOT,
joinRemotePath,
normalizeRemotePath,
parentRemotePath,
type RemoteBreadcrumbItem,
} from '@/utils/remotePath'
import { FILES_HINT_DISMISSED_KEY, FILES_LAST_SERVER_KEY } from './constants'
export function useFilesNavigation(options: {
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
refreshFilesCapability: () => Promise<void>
clearSelection: () => void
}) {
const snackbar = useSnackbar()
const route = useRoute()
const router = useRouter()
const { servers: serverList, loadServers } = useServerList()
const store = useFilesStore()
const {
selectedServer,
currentPath,
pageReady,
routeReady,
initing,
accessHintDismissed,
} = storeToRefs(store)
const breadcrumbs = computed((): RemoteBreadcrumbItem[] =>
buildRemoteBreadcrumbs(currentPath.value),
)
const canGoUp = computed(() => parentRemotePath(currentPath.value) !== null)
function defaultPathForServer(serverId: number | null): string {
if (serverId == null) return DEFAULT_FILES_ROOT
const srv = serverList.value.find((s) => s.id === serverId)
const tp = srv?.target_path?.trim()
return tp ? normalizeRemotePath(tp) : DEFAULT_FILES_ROOT
}
function pathFromRouteQuery(): string {
const qPath = route.query.path
if (typeof qPath === 'string' && qPath.trim()) {
return normalizeRemotePath(qPath)
}
return defaultPathForServer(selectedServer.value)
}
function resolveInitialServerId(): number | null {
const qServer = route.query.server_id
if (qServer) {
const id = Number(qServer)
if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id
}
const stored = sessionStorage.getItem(FILES_LAST_SERVER_KEY)
if (stored) {
const id = Number(stored)
if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id
}
return serverList.value[0]?.id ?? null
}
function navigateToPath(path: string) {
if (!selectedServer.value) {
snackbar('请先选择服务器', 'warning')
return
}
currentPath.value = normalizeRemotePath(path)
options.clearSelection()
void options.browse()
}
function openEntry(item: FileEntry) {
if (item.type === 'directory') {
navigateToPath(joinRemotePath(currentPath.value, item.name))
return
}
if (item.type === 'symlink') {
const target = item.link_target?.trim()
if (target?.startsWith('/')) {
navigateToPath(target)
} else {
snackbar(
target ? `符号链接 → ${target}` : '相对符号链接(请在终端中打开)',
'info',
)
}
}
}
function goUp() {
const parent = parentRemotePath(currentPath.value)
if (parent != null) navigateToPath(parent)
}
function onBreadcrumbClick(index: number) {
const bc = breadcrumbs.value[index]
if (bc && !bc.disabled) navigateToPath(bc.path)
}
function openInTerminal() {
if (!selectedServer.value) return
router.push({
path: '/terminal',
query: {
server_id: String(selectedServer.value),
path: currentPath.value || '/',
},
})
}
function onServerChange() {
if (initing.value) return
if (selectedServer.value) {
sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value))
}
store.invalidateCache()
currentPath.value = defaultPathForServer(selectedServer.value)
options.clearSelection()
store.resetForServerChange()
sessionStorage.removeItem(FILES_HINT_DISMISSED_KEY)
void options.refreshFilesCapability()
void options.browse({ force: true })
}
onMounted(async () => {
initing.value = true
try {
await loadServers()
if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) {
accessHintDismissed.value = true
}
if (!serverList.value.length) {
snackbar('暂无可用服务器,请先在「服务器」页添加', 'warning')
pageReady.value = true
return
}
const initialId = resolveInitialServerId()
if (initialId == null) {
pageReady.value = true
return
}
selectedServer.value = initialId
const qPath = route.query.path
currentPath.value =
typeof qPath === 'string' && qPath
? normalizeRemotePath(qPath)
: defaultPathForServer(initialId)
await options.refreshFilesCapability()
await options.browse()
pageReady.value = true
routeReady.value = true
} finally {
initing.value = false
}
})
watch(
() => route.query.server_id,
(id) => {
if (!routeReady.value) return
if (id && Number(id) !== selectedServer.value) {
selectedServer.value = Number(id)
currentPath.value = pathFromRouteQuery()
store.resetForServerChange()
void options.refreshFilesCapability()
void options.browse({ force: true })
}
},
)
watch(
() => route.query.path,
() => {
if (!routeReady.value || !selectedServer.value) return
const next = pathFromRouteQuery()
if (next !== normalizeRemotePath(currentPath.value)) {
currentPath.value = next
options.clearSelection()
void options.browse()
}
},
)
return {
serverList,
breadcrumbs,
canGoUp,
navigateToPath,
openEntry,
goUp,
onBreadcrumbClick,
openInTerminal,
onServerChange,
defaultPathForServer,
}
}
@@ -0,0 +1,270 @@
/**
* 文件管理页 — 组合各域 composable,供 FilesPage.vue 使用
*/
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useFilesClipboard } from '@/composables/useFilesClipboard'
import { useFilesHotkeys } from '@/composables/useFilesHotkeys'
import { useFilesStore } from '@/stores/files'
import type { FileEntry } from '@/types/api'
import {
FILE_EXT_FILTERS,
isArchiveFile,
matchesExtensionFilter,
sortFileEntries,
type FileSortKey,
} from '@/utils/fileSort'
import {
formatOwnerChip,
isEntryLikelyUnreadable,
ownerChipColor,
permissionTooltip,
} from '@/utils/fileMode'
import { isPreloadEligible } from '@/utils/filePreload'
import { validatePathSegment } from '@/utils/remotePath'
import { required } from '@/utils/validation'
import { useFilesActions } from './useFilesActions'
import { useFilesBrowse } from './useFilesBrowse'
import { useFilesEditor } from './useFilesEditor'
import { useFilesNavigation } from './useFilesNavigation'
import { useFilesPermissions } from './useFilesPermissions'
import { useFilesSelection } from './useFilesSelection'
export function useFilesPage() {
const fileFilter = ref('')
const extFilter = ref('')
const sortBy = ref<FileSortKey>('name')
const sortOptions = [
{ title: '名称', value: 'name' as const },
{ title: '大小', value: 'size' as const },
{ title: '修改时间', value: 'modified' as const },
]
const store = useFilesStore()
const {
selectedServer,
currentPath,
files,
loading,
browseError,
pageReady,
setupSudoLoading,
setupSudoDone,
} = storeToRefs(store)
const selection = useFilesSelection()
const { selectedFiles, selectedFileCount, clearSelection } = selection
const { emptyHint, browse, browseCurrent, invalidateAfterMutation } =
useFilesBrowse(fileFilter)
const permissions = useFilesPermissions({ browse })
const {
sshUserForFiles,
filesAccessHint,
dismissFilesAccessHint,
setupFilesSudo,
} = permissions
const nav = useFilesNavigation({
browse,
refreshFilesCapability: permissions.refreshFilesCapability,
clearSelection,
})
const actionLoading = ref(false)
const editor = useFilesEditor({
browse,
invalidateAfterMutation,
actionLoading,
})
const actions = useFilesActions({
browse,
invalidateAfterMutation,
selection,
openEntry: nav.openEntry,
editFile: editor.editFile,
previewFile: editor.previewFile,
openInTerminal: nav.openInTerminal,
editorWorkbench: editor.editorWorkbench,
actionLoading,
})
const { hasClipboard, clipboardSummary, canPasteOnServer } = useFilesClipboard()
const canPasteHere = computed(() => canPasteOnServer(selectedServer.value))
const displayedFiles = computed(() => {
let list = sortFileEntries(files.value, sortBy.value, 'asc')
const ext = extFilter.value
if (ext) list = list.filter((f) => matchesExtensionFilter(f.name, ext))
const q = fileFilter.value.trim().toLowerCase()
if (q) list = list.filter((f) => f.name.toLowerCase().includes(q))
return list
})
const statusSummary = computed(() => {
const dirs = files.value.filter((f) => f.type === 'directory').length
const regular = files.value.filter((f) => f.type === 'file').length
const links = files.value.filter((f) => f.type === 'symlink').length
const preloadable = files.value.filter(isPreloadEligible).length
const parts = [`${dirs} 个目录`, `${regular} 个文件`]
if (links) parts.push(`${links} 个链接`)
if (preloadable) parts.push(`${preloadable} 个 ≤300KB 可预加载`)
return `${parts.join('')} · ${currentPath.value}`
})
function isArchiveName(name: string): boolean {
return isArchiveFile(name)
}
function entryIcon(item: FileEntry): string {
if (item.type === 'directory') return 'mdi-folder'
if (item.type === 'symlink') return 'mdi-link-variant'
return 'mdi-file-outline'
}
function entryIconColor(item: FileEntry): string {
if (item.type === 'directory') return 'amber'
if (item.type === 'symlink') return 'teal'
return 'blue'
}
function isRegularFile(item: FileEntry): boolean {
return item.type === 'file'
}
const fileHeaders = [
{ title: '名称', key: 'name' },
{ title: '归属', key: 'owner', width: 110, sortable: false },
{ title: '大小', key: 'size', width: 120 },
{ title: '修改时间', key: 'modified', width: 180 },
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
]
useFilesHotkeys(
{
refresh: browseCurrent,
goUp: nav.goUp,
renameSelection: () => {
if (selectedFiles.value.length === 1) {
actions.startRename(selectedFiles.value[0])
}
},
deleteSelection: () => {
if (selectedFiles.value.length > 0) void actions.batchDelete()
},
selectAll: () => {
selectedFiles.value = [...displayedFiles.value]
},
copySelection: () => actions.copyToClipboard(),
cutSelection: () => actions.cutToClipboard(),
pasteClipboard: () => {
void actions.pasteClipboard()
},
},
selectedFiles,
canPasteHere,
)
return {
FILE_EXT_FILTERS,
validatePathSegment,
required,
fileFilter,
extFilter,
sortBy,
sortOptions,
selectedServer,
currentPath,
loading,
browseError,
pageReady,
setupSudoLoading,
setupSudoDone,
serverList: nav.serverList,
onServerChange: nav.onServerChange,
browseCurrent,
filesAccessHint,
dismissFilesAccessHint,
setupFilesSudo,
sshUserForFiles,
statusSummary,
hasClipboard,
clipboardSummary,
pasteDestLabel: actions.pasteDestLabel,
canPasteHere,
breadcrumbs: nav.breadcrumbs,
onBreadcrumbClick: nav.onBreadcrumbClick,
canGoUp: nav.canGoUp,
goUp: nav.goUp,
selectedFiles,
selectedFileCount,
displayedFiles,
fileHeaders,
fileRowProps: actions.fileRowProps,
dropActive: actions.dropActive,
onDropFiles: actions.onDropFiles,
emptyHint,
entryIcon,
entryIconColor,
isEntryLikelyUnreadable,
formatOwnerChip,
ownerChipColor,
permissionTooltip,
formatSize: editor.formatSize,
isRegularFile,
isArchiveName,
showUpload: actions.showUpload,
uploadFiles: actions.uploadFiles,
uploading: actions.uploading,
doUpload: actions.doUpload,
showNewFile: actions.showNewFile,
newFileName: actions.newFileName,
doNewFile: actions.doNewFile,
showMkdir: actions.showMkdir,
mkdirName: actions.mkdirName,
doMkdir: actions.doMkdir,
showFileDelete: actions.showFileDelete,
deletingFile: actions.deletingFile,
doDeleteFile: actions.doDeleteFile,
showRename: actions.showRename,
newName: actions.newName,
doRename: actions.doRename,
showChmod: actions.showChmod,
chmodFile: actions.chmodFile,
chmodLoading: actions.chmodLoading,
doChmod: actions.doChmod,
showCompress: actions.showCompress,
compressDest: actions.compressDest,
compressFormat: actions.compressFormat,
compressPaths: actions.compressPaths,
doCompress: actions.doCompress,
showDecompress: actions.showDecompress,
decompressSource: actions.decompressSource,
decompressDest: actions.decompressDest,
doDecompress: actions.doDecompress,
showContextMenu: actions.showContextMenu,
contextX: actions.contextX,
contextY: actions.contextY,
contextFile: actions.contextFile,
contextAction: actions.contextAction,
actionLoading,
batchDelete: actions.batchDelete,
openCompressDialog: actions.openCompressDialog,
showPreview: editor.showPreview,
previewTitle: editor.previewTitle,
previewContent: editor.previewContent,
previewLoading: editor.previewLoading,
editorWorkbench: editor.editorWorkbench,
onEditorSaved: editor.onEditorSaved,
navigateToPath: nav.navigateToPath,
pasteClipboard: actions.pasteClipboard,
editFile: editor.editFile,
previewFile: editor.previewFile,
downloadFile: actions.downloadFile,
confirmDeleteFile: actions.confirmDeleteFile,
openInTerminal: nav.openInTerminal,
}
}
@@ -0,0 +1,101 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useFilesStore } from '@/stores/files'
import type { FilesCapabilityResult } from '@/types/api'
import {
buildFilesAccessHint,
isEntryLikelyUnreadable,
} from '@/utils/fileMode'
import { FILES_HINT_DISMISSED_KEY } from './constants'
import { isNotFoundError } from './useFilesBrowse'
export function useFilesPermissions(options: {
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
}) {
const snackbar = useSnackbar()
const store = useFilesStore()
const {
selectedServer,
files,
browseError,
filesCapability,
lastReadDeniedPath,
accessHintDismissed,
setupSudoLoading,
setupSudoDone,
} = storeToRefs(store)
const sshUserForFiles = computed(() => filesCapability.value?.ssh_user ?? null)
const unreadableFileCount = computed(() => {
const user = sshUserForFiles.value
if (!user) return 0
return files.value.filter((f) => isEntryLikelyUnreadable(f, user)).length
})
const filesAccessHint = computed(() => {
if (accessHintDismissed.value || !selectedServer.value) return null
if (browseError.value && isNotFoundError(browseError.value)) return null
const cap = filesCapability.value
return buildFilesAccessHint({
sshUser: cap?.ssh_user,
canSudo: cap?.can_sudo_nopasswd ?? false,
whitelistOk: cap?.whitelist_ok ?? false,
isRootSsh: cap?.is_root ?? false,
filesElevation: cap?.files_elevation,
capabilityMessage: cap?.message,
lastReadDeniedPath: lastReadDeniedPath.value,
unreadableCount: unreadableFileCount.value,
})
})
function dismissFilesAccessHint() {
accessHintDismissed.value = true
sessionStorage.setItem(FILES_HINT_DISMISSED_KEY, '1')
}
async function refreshFilesCapability() {
if (!selectedServer.value) {
filesCapability.value = null
return
}
try {
filesCapability.value = await http.get<FilesCapabilityResult>(
`/servers/${selectedServer.value}/files-capability`,
)
} catch {
filesCapability.value = null
}
}
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()
await options.browse({ force: true })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '配置失败'
snackbar(`一键配置失败:${msg}`, 'error')
} finally {
setupSudoLoading.value = false
}
}
return {
sshUserForFiles,
filesAccessHint,
dismissFilesAccessHint,
refreshFilesCapability,
setupFilesSudo,
}
}
@@ -0,0 +1,28 @@
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useFilesStore } from '@/stores/files'
import type { FileEntry } from '@/types/api'
import { joinRemotePath } from '@/utils/remotePath'
export function useFilesSelection() {
const store = useFilesStore()
const { currentPath } = storeToRefs(store)
const selectedFiles = ref<FileEntry[]>([])
const selectedFileCount = computed(() => selectedFiles.value.length)
function clearSelection() {
selectedFiles.value = []
}
function selectedAbsolutePaths(extra?: FileEntry[]): string[] {
const items = extra?.length ? extra : selectedFiles.value
return items.map((f) => joinRemotePath(currentPath.value, f.name))
}
return {
selectedFiles,
selectedFileCount,
clearSelection,
selectedAbsolutePaths,
}
}
+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 {
@@ -0,0 +1,73 @@
import { ref } from 'vue'
import { http } from '@/api'
export interface QuickCmdItem {
id: number
name: string
cmd: string
is_builtin: boolean
sort_order: number
}
export function sortQuickCommands(items: QuickCmdItem[]): QuickCmdItem[] {
return [...items].sort((a, b) => {
if (a.is_builtin !== b.is_builtin) return a.is_builtin ? 1 : -1
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
return a.id - b.id
})
}
export function useTerminalQuickCommands() {
const commands = ref<QuickCmdItem[]>([])
const loading = ref(false)
async function load() {
loading.value = true
try {
const res = await http.get<QuickCmdItem[]>('/terminal/quick-commands')
commands.value = sortQuickCommands(res || [])
} catch {
commands.value = []
throw new Error('加载快捷命令失败')
} finally {
loading.value = false
}
}
async function create(name: string, cmd: string) {
await http.post('/terminal/quick-commands', { name: name.trim(), cmd: cmd.trim() })
await load()
}
async function update(id: number, name: string, cmd: string) {
await http.put(`/terminal/quick-commands/${id}`, { name: name.trim(), cmd: cmd.trim() })
await load()
}
async function remove(id: number) {
await http.delete(`/terminal/quick-commands/${id}`)
await load()
}
async function reorder(customIds: number[]) {
const res = await http.put<{ items: QuickCmdItem[] }>('/terminal/quick-commands/reorder', {
ids: customIds,
})
commands.value = sortQuickCommands(res.items || [])
}
const customCommands = () => commands.value.filter((c) => !c.is_builtin)
const builtinCommands = () => commands.value.filter((c) => c.is_builtin)
return {
commands,
loading,
load,
create,
update,
remove,
reorder,
customCommands,
builtinCommands,
}
}
+19 -22
View File
@@ -13,7 +13,7 @@
variant="outlined"
density="compact"
hide-details
style="max-width: 160px"
style="max-width: 200px"
clearable
@update:model-value="page = 1; loadAudit()"
/>
@@ -57,7 +57,7 @@
>
<template #item.action="{ item }">
<v-chip :color="actionColor(item.action)" size="small" variant="tonal" label>
{{ item.action }}
{{ auditActionLabel(item.action) }}
</v-chip>
</template>
@@ -66,7 +66,9 @@
</template>
<template #item.target="{ item }">
<span class="text-body-2">{{ item.resource_type }}{{ item.resource_id ? ` #${item.resource_id}` : '' }}</span>
<span class="text-body-2">
{{ auditTargetLabel(item.resource_type) }}{{ item.resource_id ? ` #${item.resource_id}` : '' }}
</span>
</template>
<template #item.detail="{ item }">
@@ -95,6 +97,8 @@ import { ref, onMounted } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AuditItem } from '@/types/api'
import { auditActionLabel, auditTargetLabel, auditActionFilterOptions } from '@/utils/auditLabels'
import { normalizeAuditList } from '@/utils/auditNormalize'
const snackbar = useSnackbar()
@@ -107,15 +111,7 @@ const actionFilter = ref<string | null>(null)
const userFilter = ref('')
const dateFilter = ref('')
const actionOptions = [
{ label: '创建', value: 'create' },
{ label: '更新', value: 'update' },
{ label: '删除', value: 'delete' },
{ label: '登录', value: 'login' },
{ label: '登出', value: 'logout' },
{ label: '执行', value: 'execute' },
{ label: '推送', value: 'push' },
]
const actionOptions = auditActionFilterOptions
const headers = [
{ title: '操作', key: 'action', width: 100 },
@@ -130,14 +126,16 @@ const headers = [
async function loadAudit() {
loading.value = true
try {
const res = await http.get<PaginatedResponse<AuditItem>>('/audit/', {
page: page.value,
per_page: 50,
const limit = 50
const offset = (page.value - 1) * limit
const res = await http.get<PaginatedResponse<unknown>>('/audit/', {
limit,
offset,
action: actionFilter.value || undefined,
admin_username: userFilter.value || undefined,
date: dateFilter.value || undefined,
date_from: dateFilter.value || undefined,
})
logs.value = res.items || []
logs.value = normalizeAuditList(res.items || [])
total.value = res.total || 0
} catch { logs.value = []; snackbar('加载审计日志失败', 'error') }
finally { loading.value = false }
@@ -145,11 +143,10 @@ async function loadAudit() {
// ── Helpers ──
function actionColor(a: string) {
if (a === 'create') return 'success'
if (a === 'update') return 'info'
if (a === 'delete') return 'error'
if (a === 'login' || a === 'logout') return 'secondary'
if (a === 'execute') return 'warning'
if (a.startsWith('create') || a === 'login') return 'success'
if (a.startsWith('update')) return 'info'
if (a.startsWith('delete') || a === 'logout') return 'error'
if (a.includes('execute') || a.includes('push') || a.includes('retry')) return 'warning'
return 'primary'
}
+15 -6
View File
@@ -1,5 +1,5 @@
<template>
<v-container>
<v-container fluid class="pa-4 pa-md-6 dashboard-root">
<!-- Stats Cards -->
<v-row>
<v-col
@@ -57,7 +57,7 @@
<v-chip
:color="statusChipColor(item.status)"
:text="statusLabel(item.status)"
border="current sm"
border="sm"
size="x-small"
label
/>
@@ -230,10 +230,10 @@
<v-list-item
v-for="item in recentAudit"
:key="item.id"
:title="`${item.action} - ${item.resource_type}`"
:title="auditSummary(item)"
:subtitle="`${item.admin_username} · ${formatRelativeTime(item.created_at)}`"
class="px-0"
lines="one"
lines="two"
>
<template #append>
<v-chip size="x-small" variant="tonal" label>{{ item.ip_address || '—' }}</v-chip>
@@ -260,6 +260,13 @@
.stat-card:hover .stat-icon {
transform: scale(1.1);
}
.dashboard-root {
min-width: 0;
max-width: 100%;
}
.dashboard-root :deep(.v-table) {
overflow-x: auto;
}
</style>
<script setup lang="ts">
@@ -270,6 +277,8 @@ import { useSnackbar } from '@/composables/useSnackbar'
import { useWebSocket } from '@/composables/useWebSocket'
import { useServerPagination } from '@/composables/useServerPagination'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { auditSummary } from '@/utils/auditLabels'
import { normalizeAuditList } from '@/utils/auditNormalize'
import type { ServerApiItem, AuditItem, AlertLogItem } from '@/types/api'
const snackbar = useSnackbar()
@@ -365,8 +374,8 @@ async function loadAlerts() {
async function loadRecentAudit() {
try {
const res = await http.get<{ items: AuditItem[] }>('/audit/', { per_page: 5 })
recentAudit.value = res.items || []
const res = await http.get<{ items: unknown[] }>('/audit/', { limit: 10 })
recentAudit.value = normalizeAuditList(res.items || []).slice(0, 10)
} catch { recentAudit.value = [] }
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -98,7 +98,7 @@
</div>
<div class="text-caption text-medium-emphasis text-truncate">{{ s.domain }}</div>
<div v-if="pushStatus[s.id]" class="text-caption mt-1">
<v-chip :color="pushColor(pushStatus[s.id])" size="x-small" variant="tonal" label border="current sm">{{ pushStatus[s.id] }}</v-chip>
<v-chip :color="pushColor(pushStatus[s.id])" size="x-small" variant="tonal" label border="sm">{{ pushStatus[s.id] }}</v-chip>
</div>
</v-card>
</v-col>
@@ -205,7 +205,7 @@
@update:page="logPage = $event; loadLogs()"
>
<template #item.status="{ item }">
<v-chip :color="item.status === 'success' ? 'success' : item.status === 'failed' ? 'error' : 'warning'" size="x-small" variant="tonal" label border="current sm">
<v-chip :color="item.status === 'success' ? 'success' : item.status === 'failed' ? 'error' : 'warning'" size="x-small" variant="tonal" label border="sm">
{{ item.status === 'success' ? '成功' : item.status === 'failed' ? '失败' : item.status }}
</v-chip>
</template>
+25 -13
View File
@@ -1,7 +1,7 @@
<template>
<v-container fluid class="pa-6">
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
<v-card-title class="d-flex align-center text-h6">
重试队列
<v-spacer />
<v-select
@@ -31,13 +31,15 @@
@update:page="page = $event; loadRetries()"
>
<template #item.status="{ item }">
<v-chip :color="retryColor(item.status)" size="x-small" variant="tonal" label border="current sm">
<v-chip :color="retryColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ retryLabel(item.status) }}
</v-chip>
</template>
<template #item.operation="{ item }">
<span class="font-weight-medium">{{ item.operation }}</span>
<span class="font-weight-medium text-truncate d-inline-block" style="max-width: 240px">
{{ retryOperationLabel(item) }}
</span>
</template>
<template #item.server_name="{ item }">
@@ -112,26 +114,29 @@ const statusOptions = [
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'operation' },
{ title: '任务', key: 'operation' },
{ title: '服务器', key: 'server_name' },
{ title: '次数', key: 'retry_count', width: 100 },
{ title: '下次重试', key: 'next_retry_at', width: 140 },
{ title: '创建时间', key: 'created_at', width: 160 },
{ title: '操作', key: 'actions', width: 120, align: 'end' as const },
{ title: '', key: 'actions', width: 120, align: 'end' as const, sortable: false },
]
// Data loading
async function loadRetries() {
loading.value = true
try {
const res = await http.getList<RetryItem>('/retries/', {
page: page.value,
per_page: 20,
status: statusFilter.value || undefined,
})
retries.value = res.items
total.value = res.total
} catch { retries.value = [] }
const res = await http.getList<RetryItem>('/retries/', { limit: 200 })
let all = res.items || []
if (statusFilter.value) all = all.filter((r) => r.status === statusFilter.value)
total.value = all.length
const start = (page.value - 1) * 20
retries.value = all.slice(start, start + 20)
} catch (e: unknown) {
retries.value = []
total.value = 0
snackbar(e instanceof Error ? e.message : '加载重试队列失败', 'error')
}
finally { loading.value = false }
}
@@ -174,6 +179,13 @@ function retryLabel(s: string) {
return s
}
function retryOperationLabel(item: RetryItem): string {
if (item.source_path && item.target_path) {
return `${item.source_path}${item.target_path}`
}
return item.source_path || item.target_path || '文件推送'
}
onMounted(() => loadRetries())
</script>
+74 -21
View File
@@ -1,7 +1,7 @@
<template>
<v-container fluid class="pa-6">
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
<v-card-title class="d-flex align-center text-h6">
推送调度
<v-spacer />
<v-btn color="primary" variant="flat" size="small" prepend-icon="mdi-plus" @click="openEditor(null)">新建调度</v-btn>
@@ -16,8 +16,8 @@
:items-per-page="itemsPerPage"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadSchedules() }"
@update:items-per-page="(n: number) => { itemsPerPage = n; page = 1; loadSchedules() }"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.enabled="{ item }">
<v-switch
@@ -32,12 +32,12 @@
<template #item.cron_expr="{ item }">
<v-chip size="small" variant="tonal" label prepend-icon="mdi-clock-outline">
{{ item.cron_expr }}
{{ item.cron_expr || '—' }}
</v-chip>
</template>
<template #item.server_count="{ item }">
<span class="text-medium-emphasis">{{ item.server_ids?.length || 0 }} </span>
<span class="text-medium-emphasis">{{ serverIdCount(item.server_ids) }} </span>
</template>
<template #item.last_run_at="{ item }">
@@ -64,8 +64,8 @@
<v-card border>
<v-card-title>{{ editingSchedule ? '编辑调度' : '新建调度' }}</v-card-title>
<v-card-text>
<v-text-field v-model="form.name" label="调度名称" variant="outlined" density="compact" class="mb-2" :rules="[required('任务名称')]" />
<v-text-field v-model="form.cron" label="Cron 表达式" variant="outlined" density="compact" hint="例如: */30 * * * *" persistent-hint class="mb-2" :rules="[required('Cron表达式'), cronExpression()]" />
<v-text-field v-model="form.name" label="调度名称" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="form.cron" label="Cron 表达式" variant="outlined" density="compact" hint="例如: */30 * * * *" persistent-hint class="mb-2" />
<v-text-field v-model="form.source_path" label="推送源路径" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="form.target_path" label="目标路径" variant="outlined" density="compact" class="mb-2" />
<v-select
@@ -110,7 +110,6 @@ import { ref, onMounted } from 'vue'
import { http } from '@/api'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { required, cronExpression } from '@/utils/validation'
import type { ScheduleItem } from '@/types/api'
const snackbar = useSnackbar()
@@ -152,17 +151,69 @@ const headers = [
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
]
function parseServerIds(raw: unknown): number[] {
if (Array.isArray(raw)) return raw.map((x) => Number(x)).filter((n) => !Number.isNaN(n))
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.map((x) => Number(x)).filter((n) => !Number.isNaN(n))
} catch { /* ignore */ }
}
return []
}
function serverIdCount(raw: unknown): number {
return parseServerIds(raw).length
}
function normalizeSchedule(row: ScheduleItem): ScheduleItem {
return { ...row, server_ids: parseServerIds(row.server_ids) }
}
function buildSchedulePayload() {
const ids = form.value.server_ids
if (!form.value.name.trim()) throw new Error('请填写调度名称')
if (!form.value.cron.trim()) throw new Error('请填写 Cron 表达式')
if (!/^[\d\s*/,\-]+$/.test(form.value.cron.trim())) throw new Error('Cron 表达式格式不正确')
if (!ids.length) throw new Error('请至少选择一台服务器')
if (!form.value.source_path.trim()) throw new Error('请填写推送源路径')
return {
name: form.value.name.trim(),
cron_expr: form.value.cron.trim(),
source_path: form.value.source_path.trim(),
server_ids: JSON.stringify(ids),
run_mode: 'cron' as const,
schedule_type: 'push' as const,
enabled: form.value.enabled,
sync_mode: 'incremental',
}
}
function onPageChange(p: number) {
page.value = p
loadSchedules()
}
function onItemsPerPageChange(n: number) {
itemsPerPage.value = n
page.value = 1
loadSchedules()
}
// Data loading
async function loadSchedules() {
loading.value = true
try {
const res = await http.getList<ScheduleItem>('/schedules/', {
page: page.value,
per_page: itemsPerPage.value,
})
schedules.value = res.items
totalItems.value = res.total
} catch { schedules.value = [] }
const res = await http.getList<ScheduleItem>('/schedules/')
const all = (res.items || []).map(normalizeSchedule)
totalItems.value = all.length
const start = (page.value - 1) * itemsPerPage.value
schedules.value = all.slice(start, start + itemsPerPage.value)
} catch (e: unknown) {
schedules.value = []
totalItems.value = 0
snackbar(e instanceof Error ? e.message : '加载调度列表失败', 'error')
}
finally { loading.value = false }
}
@@ -171,8 +222,8 @@ function openEditor(schedule: ScheduleItem | null) {
editingSchedule.value = schedule
if (schedule) {
form.value = {
name: schedule.name, cron: schedule.cron_expr, source_path: schedule.source_path || '',
target_path: schedule.target_path || '', server_ids: schedule.server_ids || [],
name: schedule.name, cron: schedule.cron_expr || '', source_path: schedule.source_path || '',
target_path: schedule.target_path || '', server_ids: parseServerIds(schedule.server_ids),
enabled: schedule.enabled,
}
} else {
@@ -184,8 +235,9 @@ function openEditor(schedule: ScheduleItem | null) {
async function saveSchedule() {
saving.value = true
try {
if (editingSchedule.value) await http.put(`/schedules/${editingSchedule.value.id}`, form.value)
else await http.post('/schedules/', form.value)
const payload = buildSchedulePayload()
if (editingSchedule.value) await http.put(`/schedules/${editingSchedule.value.id}`, payload)
else await http.post('/schedules/', payload)
showEditor.value = false
loadSchedules()
snackbar('保存成功')
@@ -209,8 +261,9 @@ async function runNow(item: ScheduleItem) {
runningId.value = item.id
try {
await http.post('/sync/files', {
source_path: item.source_path, target_path: item.target_path,
server_ids: item.server_ids,
source_path: item.source_path,
target_path: item.target_path || '/tmp',
server_ids: parseServerIds(item.server_ids),
})
snackbar('已触发推送')
} catch (e: any) { snackbar(e.message || '执行失败', 'error') }
+1 -1
View File
@@ -73,7 +73,7 @@
@click:row="(_e: Event, { item }: { item: ServerApiItem }) => showDetail(item)"
>
<template #item.status="{ item }">
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="current sm">
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ statusLabel(item.status) }}
</v-chip>
</template>
+3
View File
@@ -45,6 +45,8 @@
</v-card-actions>
</v-card>
<TerminalQuickCommandsSettings />
<!-- Telegram Settings -->
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
@@ -197,6 +199,7 @@ import { http } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useSnackbar } from '@/composables/useSnackbar'
import { required, numberRange, isNumber, matchOther } from '@/utils/validation'
import TerminalQuickCommandsSettings from '@/components/TerminalQuickCommandsSettings.vue'
import type { SettingsResponse, AllowlistResponse, SettingItem } from '@/types/api'
const snackbar = useSnackbar()
+282 -197
View File
@@ -4,55 +4,37 @@
class="pa-0 d-flex flex-column terminal-root"
:class="theme.global.current.value.dark ? 'terminal-root--dark' : 'terminal-root--light'"
>
<!-- Server Sidebar -->
<v-navigation-drawer v-model="showServerSidebar" width="250" location="start" temporary>
<v-list-subheader>服务器列表</v-list-subheader>
<v-text-field
v-model="sidebarSearch"
prepend-inner-icon="mdi-magnify"
placeholder="搜索..."
variant="outlined"
density="compact"
hide-details
class="mx-3 mb-2"
/>
<v-list density="compact" nav>
<v-list-item
v-for="s in sidebarFilteredServers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
@click="newSession(s.id); showServerSidebar = false"
>
<template #prepend>
<v-icon :color="s.is_online !== false ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
</v-list-item>
<v-list-item v-if="sidebarFilteredServers.length === 0" class="text-center text-medium-emphasis">
暂无服务器
</v-list-item>
</v-list>
</v-navigation-drawer>
<!-- Context Menu -->
<v-menu v-model="showTermMenu" :target="[menuX, menuY]" location="bottom start">
<v-list density="compact" nav>
<v-list-item prepend-icon="mdi-content-copy" @click="termCopy">
<!-- 终端/命令栏右键菜单中文加大避免浏览器英文菜单 -->
<v-menu
v-model="showTermMenu"
:target="[menuX, menuY]"
:location="termMenuLocation"
content-class="terminal-ctx-menu"
>
<v-list density="comfortable" nav class="py-1">
<v-list-item prepend-icon="mdi-content-copy" rounded="lg" @click="termCopy">
<v-list-item-title>复制</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-content-paste" @click="termPaste">
<v-list-item prepend-icon="mdi-content-paste" rounded="lg" @click="termPaste">
<v-list-item-title>粘贴</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-select-all" @click="termSelectAll">
<v-list-item prepend-icon="mdi-select-all" rounded="lg" @click="termSelectAll">
<v-list-item-title>全选</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-delete-sweep" @click="termClear">
<v-list-item-title>清屏</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-logout" @click="termDisconnect" :disabled="!activeSession">
<v-list-item-title class="text-error">断开连接</v-list-item-title>
</v-list-item>
<template v-if="termMenuContext === 'terminal'">
<v-divider class="my-1" />
<v-list-item prepend-icon="mdi-delete-sweep" rounded="lg" @click="termClear">
<v-list-item-title>清屏</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-logout"
rounded="lg"
:disabled="!activeSession"
@click="termDisconnect"
>
<v-list-item-title class="text-error">断开连接</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-menu>
@@ -60,7 +42,6 @@
<div class="d-flex flex-column flex-grow-1" style="min-height: 0">
<!-- Tab Bar -->
<div class="d-flex align-center px-2 py-1 shrink-0" style="background: rgb(var(--v-theme-surface)); border-bottom: 1px solid rgb(var(--v-theme-surface-variant))">
<v-btn icon="mdi-server-network" size="small" variant="text" @click="showServerSidebar = !showServerSidebar" class="mr-1" />
<div class="d-flex align-center ga-1 overflow-x-auto flex-grow-1">
<v-chip
v-for="tab in sessions"
@@ -73,19 +54,23 @@
@click="switchTab(tab.id)"
>
<template #prepend>
<v-icon start size="8" :color="tab.status === 'connected' ? 'success' : tab.status === 'connecting' ? 'warning' : 'grey'">mdi-circle</v-icon>
<v-icon size="8" :color="tab.status === 'connected' ? 'success' : tab.status === 'connecting' ? 'warning' : 'grey'">mdi-circle</v-icon>
</template>
<span class="text-truncate" style="max-width: 100px">{{ tab.serverName }}</span>
<v-icon end size="12" class="ml-1" @click.stop="closeTab(tab.id)">mdi-close</v-icon>
</v-chip>
<v-btn icon size="x-small" variant="text" @click="newSession()">
<v-icon size="14">mdi-plus</v-icon>
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="newSession()">
新建会话
</v-btn>
</div>
</div>
<!-- Toolbar -->
<div class="d-flex align-center justify-between px-4 py-1 shrink-0" style="background: rgb(var(--v-theme-surface)); border-bottom: 1px solid rgb(var(--v-theme-surface-variant)); min-height: 32px">
<!-- Toolbar有会话时才显示避免空页出现... / 断开 -->
<div
v-if="sessions.length > 0"
class="d-flex align-center justify-space-between px-4 py-1 shrink-0"
style="background: rgb(var(--v-theme-surface)); border-bottom: 1px solid rgb(var(--v-theme-surface-variant)); min-height: 32px"
>
<div class="d-flex align-center ga-3">
<span class="text-body-2 font-weight-medium">{{ activeSession?.serverName || '...' }}</span>
<span v-if="activeSession?.cwd" class="text-caption text-medium-emphasis font-mono" style="max-width: 200px">{{ activeSession.cwd }}</span>
@@ -104,23 +89,27 @@
<span v-if="activeSession?.uptime" class="text-caption text-medium-emphasis">{{ activeSession.uptime }}</span>
<span v-if="activeSession?.pingMs !== null && activeSession?.pingMs !== undefined" class="text-caption text-medium-emphasis">{{ activeSession.pingMs }}ms</span>
</div>
<div class="d-flex align-center ga-2">
<v-btn variant="text" size="x-small" density="compact" @click="changeFontSize(-1)">A</v-btn>
<span class="text-caption">{{ fontSize }}</span>
<v-btn variant="text" size="x-small" density="compact" @click="changeFontSize(1)">A+</v-btn>
<div class="d-flex align-center ga-2 terminal-toolbar-actions">
<span class="text-body-2 text-medium-emphasis shrink-0">字号</span>
<v-btn variant="tonal" size="small" @click="changeFontSize(-1)">缩小</v-btn>
<span class="text-body-2 font-weight-medium" style="min-width: 1.5rem; text-align: center">{{ fontSize }}</span>
<v-btn variant="tonal" size="small" @click="changeFontSize(1)">放大</v-btn>
<v-select
v-model="scrollback"
:items="[1000, 5000, 10000, 50000]"
density="compact"
:items="scrollbackOptions"
item-title="title"
item-value="value"
label="回滚行数"
density="comfortable"
hide-details
variant="outlined"
style="max-width: 90px"
class="terminal-toolbar-scrollback"
@update:model-value="changeScrollback"
/>
<v-btn variant="text" size="x-small" density="compact" @click="toggleFullscreen">
<v-icon size="14">mdi-fullscreen</v-icon>
<v-btn variant="tonal" size="small" prepend-icon="mdi-fullscreen" @click="toggleFullscreen">
全屏
</v-btn>
<v-btn variant="text" size="x-small" density="compact" color="error" @click="disconnect">
<v-btn variant="tonal" size="small" color="error" prepend-icon="mdi-link-off" @click="disconnect">
断开
</v-btn>
</div>
@@ -141,17 +130,46 @@
}"
/>
<!-- Empty: no sessions -->
<!-- Empty: 内嵌服务器列表设计 §空状态不与对话框叠层 -->
<div
v-if="sessions.length === 0 && !showDisconnectOverlay"
class="d-flex flex-column align-center justify-center fill-height pa-8 text-center"
v-if="sessions.length === 0 && !showDisconnectOverlay && !showServerDialog"
class="d-flex flex-column align-center justify-center fill-height pa-6"
>
<v-icon size="56" color="primary" class="mb-4">mdi-console-network</v-icon>
<div class="text-h6 mb-2">WebSSH 终端</div>
<div class="text-body-2 text-medium-emphasis mb-6">选择一台服务器开始会话或从服务器列表点击终端进入</div>
<v-btn color="primary" variant="flat" prepend-icon="mdi-server" @click="showServerDialog = true">
选择服务器
</v-btn>
<v-icon size="56" color="primary" class="mb-3">mdi-console-network</v-icon>
<div class="text-h6 mb-1 text-center">WebSSH 终端</div>
<div class="text-body-2 text-medium-emphasis mb-4 text-center">
选择一台服务器开始会话或从服务器页点击终端
</div>
<v-card border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
<v-card-text class="pa-3">
<v-text-field
v-model="serverSearch"
prepend-inner-icon="mdi-magnify"
placeholder="搜索服务器…"
variant="outlined"
density="compact"
hide-details
class="mb-2"
/>
<v-list density="compact" class="empty-server-picker__list" nav>
<v-list-item
v-for="s in filteredServers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg"
@click="newSession(s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
</v-list-item>
<v-list-item v-if="filteredServers.length === 0" class="text-medium-emphasis">
暂无匹配的服务器
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</div>
<!-- Disconnect overlay -->
@@ -171,47 +189,59 @@
</v-overlay>
</div>
<!-- Quick Commands -->
<div class="d-flex align-center ga-1 px-3 py-1 overflow-x-auto shrink-0" style="background: rgb(var(--v-theme-surface)); border-top: 1px solid rgb(var(--v-theme-surface-variant))">
<span class="text-caption shrink-0"></span>
<v-chip
v-for="cmd in quickCmds"
:key="cmd.id"
size="x-small"
:variant="cmd.is_builtin ? 'outlined' : 'tonal'"
:color="cmd.is_builtin ? 'default' : 'primary'"
label
class="cursor-pointer"
@click="execQuickCmd(cmd.cmd)"
@dblclick="!cmd.is_builtin && editQuickCmd(cmd)"
<!-- Quick Commands仅执行管理见系统设置 -->
<div class="terminal-quick-bar shrink-0">
<span class="terminal-quick-bar__label">快捷命令</span>
<div class="d-flex align-center ga-1 flex-grow-1 overflow-x-auto">
<v-chip
v-for="cmd in quickCmds"
:key="cmd.id"
size="small"
:variant="cmd.is_builtin ? 'outlined' : 'flat'"
:color="cmd.is_builtin ? 'default' : 'primary'"
label
class="cursor-pointer"
@click="execQuickCmd(cmd.cmd)"
>
{{ cmd.name }}
</v-chip>
</div>
<v-btn
size="small"
variant="text"
prepend-icon="mdi-cog-outline"
:to="{ path: '/settings', hash: '#terminal-quick-commands' }"
>
{{ cmd.name }}
<v-icon v-if="!cmd.is_builtin" end size="10" class="ml-1" @click.stop="deleteQuickCmd(cmd.id)">mdi-close</v-icon>
</v-chip>
<v-btn icon size="x-small" variant="text" @click="addQuickCmd">
<v-icon size="12">mdi-plus</v-icon>
管理
</v-btn>
</div>
<!-- Command BarShell 语法高亮 -->
<!-- Command BarShell 语法高亮 3 倍高度 -->
<div
class="d-flex align-center ga-2 px-3 py-1 shrink-0 cmd-bar"
class="d-flex align-start ga-2 px-3 py-2 shrink-0 cmd-bar cmd-bar--tall"
:class="{ 'opacity-50 pointer-events-none': !activeSession?.ws }"
@contextmenu="onCmdContext"
>
<span class="text-body-2 font-mono text-primary shrink-0"></span>
<div class="flex-grow-1">
<span class="cmd-bar__prompt font-mono text-primary shrink-0"></span>
<div class="flex-grow-1 cmd-bar__input-wrap">
<ShellHighlightField
v-model="cmdInput"
placeholder="输入命令,Enter 发送…(语法高亮)"
multiline
:rows="3"
size="large"
placeholder="输入命令,Enter 发送,Shift+Enter 换行"
:disabled="!activeSession?.ws"
@keydown="onCmdKeydown"
/>
</div>
<v-btn size="small" variant="tonal" color="primary" @click="sendCmd" :disabled="!activeSession?.ws">发送</v-btn>
<div class="d-flex ga-1 pl-2" style="border-left: 1px solid rgb(var(--v-theme-surface-variant))">
<v-btn size="x-small" variant="outlined" density="compact" @click="sendCtrl('c')" title="Ctrl+C">^C</v-btn>
<v-btn size="x-small" variant="outlined" density="compact" @click="sendCtrl('d')" title="Ctrl+D">^D</v-btn>
<v-btn size="x-small" variant="outlined" density="compact" @click="sendKey('\t')" title="Tab补全">Tab</v-btn>
<v-btn size="default" variant="tonal" color="primary" class="mt-1" @click="sendCmd" :disabled="!activeSession?.ws">发送</v-btn>
<div class="terminal-hotkeys shrink-0 pl-3">
<div class="text-body-2 font-weight-medium text-medium-emphasis mb-2">快捷键</div>
<div class="d-flex flex-column ga-2">
<v-btn size="default" variant="outlined" block @click="sendCtrl('c')">中断 (Ctrl+C)</v-btn>
<v-btn size="default" variant="outlined" block @click="sendCtrl('d')">退出 (Ctrl+D)</v-btn>
<v-btn size="default" variant="outlined" block @click="sendKey('\t')">补全 (Tab)</v-btn>
</div>
</div>
</div>
</div>
@@ -231,7 +261,7 @@
@click="newSession(s.id); showServerDialog = false"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8" start>mdi-circle</v-icon>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
</v-list-item>
</v-list>
@@ -239,27 +269,11 @@
</v-card>
</v-dialog>
<!-- Quick Cmd Dialog -->
<v-dialog v-model="showQcDialog" max-width="400">
<v-card border>
<v-card-title>{{ qcEditId ? '编辑命令' : '添加命令' }}</v-card-title>
<v-card-text>
<v-text-field v-model="qcName" label="命令名称" variant="outlined" density="compact" class="mb-3" />
<div class="text-caption text-medium-emphasis mb-1">命令内容Shell 语法高亮发送时自动补 \\r</div>
<ShellHighlightField v-model="qcCmd" multiline :rows="4" placeholder="systemctl status nginx" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showQcDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveQuickCmd">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick, inject, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTheme } from 'vuetify'
import { http } from '@/api'
@@ -271,12 +285,14 @@ import '@xterm/xterm/css/xterm.css'
import { useSnackbar } from '@/composables/useSnackbar'
import { buildWebSocketUrl } from '@/utils/wsUrl'
import ShellHighlightField from '@/components/ShellHighlightField.vue'
import { sortQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
const route = useRoute()
const router = useRouter()
const theme = useTheme()
const auth = useAuthStore()
const snackbar = useSnackbar()
const nexusDrawer = inject<Ref<boolean>>('nexusDrawer', ref(true))
type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'
@@ -320,6 +336,24 @@ const activeSessionId = ref('')
const termContainer = ref<HTMLElement | null>(null)
const termRefs = new Map<string, HTMLElement>()
function refitAllTerminals() {
sessions.value.forEach((s) => {
try {
s.fitAddon?.fit()
} catch {
/* container size 0 during transition */
}
})
}
/** 侧栏开合 → v-main padding 变化 → 动画结束后 refit xterm */
watch(nexusDrawer, () => {
nextTick(() => {
refitAllTerminals()
setTimeout(refitAllTerminals, 220)
})
})
const activeSession = computed(() =>
sessions.value.find(s => s.id === activeSessionId.value) || null
)
@@ -342,28 +376,18 @@ const servers = ref<{ id: number; name: string; domain: string; is_online: boole
const showTermMenu = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const termMenuContext = ref<'terminal' | 'command'>('terminal')
const termMenuLocation = computed(() => (menuY.value > window.innerHeight * 0.55 ? 'top end' : 'bottom end'))
// Server sidebar
const showServerSidebar = ref(false)
const sidebarSearch = ref('')
const sidebarFilteredServers = computed(() => {
const q = sidebarSearch.value.toLowerCase().trim()
return servers.value.filter(s => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q))
})
const scrollbackOptions = [
{ title: '1 千行', value: 1000 },
{ title: '5 千行', value: 5000 },
{ title: '1 万行', value: 10000 },
{ title: '5 万行', value: 50000 },
]
// Quick commands (from MySQL API)
interface QuickCmdItem {
id: number
name: string
cmd: string
is_builtin: boolean
sort_order: number
}
// Quick commands
const quickCmds = ref<QuickCmdItem[]>([])
const qcName = ref('')
const qcCmd = ref('')
const qcEditId = ref<number | null>(null)
const showQcDialog = ref(false)
const filteredServers = computed(() => {
const q = serverSearch.value.toLowerCase().trim()
return servers.value.filter(s => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q))
@@ -399,10 +423,7 @@ async function applyRouteQuery() {
const path = sanitizeRemotePath(String(route.query.path || ''))
const forceNew = route.query.new_tab === '1' || route.query.new_tab === 'true'
if (!id) {
if (sessions.value.length === 0) showServerDialog.value = true
return
}
if (!id) return
if (!forceNew) {
const existing = sessions.value.find(s => s.serverId === id)
@@ -416,7 +437,7 @@ async function applyRouteQuery() {
}
function onCmdKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendCmd()
return
@@ -787,18 +808,43 @@ function disconnect() {
}
// Context menu handlers
function onTermContext(e: MouseEvent) {
function openTermMenu(e: MouseEvent, context: 'terminal' | 'command') {
e.preventDefault()
termMenuContext.value = context
menuX.value = e.clientX
menuY.value = e.clientY
showTermMenu.value = true
}
function onTermContext(e: MouseEvent) {
openTermMenu(e, 'terminal')
}
function onCmdContext(e: MouseEvent) {
openTermMenu(e, 'command')
}
function getCmdInputEl(): HTMLTextAreaElement | null {
const root = document.querySelector('.cmd-bar--tall .sh-field__input--multi')
return root instanceof HTMLTextAreaElement ? root : null
}
function termCopy() {
showTermMenu.value = false
if (termMenuContext.value === 'command') {
const el = getCmdInputEl()
const text = el?.value.substring(el.selectionStart ?? 0, el.selectionEnd ?? 0) || ''
if (text) {
navigator.clipboard.writeText(text).catch(() => snackbar('复制失败', 'error'))
return
}
}
const sel = window.getSelection()?.toString()
if (sel) {
navigator.clipboard.writeText(sel).catch(() => snackbar('复制失败', 'error'))
} else if (activeSession.value?.term) {
const buffer = activeSession.value.term.getSelection()
if (buffer) navigator.clipboard.writeText(buffer).catch(() => snackbar('复制失败', 'error'))
}
}
@@ -806,7 +852,22 @@ async function termPaste() {
showTermMenu.value = false
try {
const text = await navigator.clipboard.readText()
if (activeSession.value?.term && text) {
if (!text) return
if (termMenuContext.value === 'command') {
const el = getCmdInputEl()
if (el) {
const start = el.selectionStart ?? el.value.length
const end = el.selectionEnd ?? start
const next = el.value.slice(0, start) + text + el.value.slice(end)
cmdInput.value = next
await nextTick()
el.focus()
const pos = start + text.length
el.setSelectionRange(pos, pos)
}
return
}
if (activeSession.value?.term) {
activeSession.value.term.paste(text)
}
} catch {
@@ -816,6 +877,14 @@ async function termPaste() {
function termSelectAll() {
showTermMenu.value = false
if (termMenuContext.value === 'command') {
const el = getCmdInputEl()
if (el) {
el.focus()
el.select()
}
return
}
if (activeSession.value?.term) {
activeSession.value.term.selectAll()
}
@@ -976,16 +1045,15 @@ function toggleFullscreen() {
async function loadQuickCmds() {
try {
const res = await http.get<QuickCmdItem[]>('/terminal/quick-commands')
quickCmds.value = res || []
quickCmds.value = sortQuickCommands(res || [])
} catch {
// Fallback: use built-in commands only
quickCmds.value = [
{ id: 1, name: '系统状态', cmd: "systemctl status --no-pager 2>&1 | head -20\r", is_builtin: true, sort_order: 1 },
{ id: 2, name: '系统日志', cmd: "journalctl -n 30 --no-pager 2>&1\r", is_builtin: true, sort_order: 2 },
{ id: 3, name: '磁盘使用', cmd: "df -h\r", is_builtin: true, sort_order: 3 },
{ id: 4, name: '内存使用', cmd: "free -h\r", is_builtin: true, sort_order: 4 },
{ id: 5, name: '进程列表', cmd: "ps aux --sort=-%mem 2>&1 | head -15\r", is_builtin: true, sort_order: 5 },
]
quickCmds.value = sortQuickCommands([
{ id: 1, name: '系统状态', cmd: "systemctl status --no-pager 2>&1 | head -20\r", is_builtin: true, sort_order: 1001 },
{ id: 2, name: '系统日志', cmd: "journalctl -n 30 --no-pager 2>&1\r", is_builtin: true, sort_order: 1002 },
{ id: 3, name: '磁盘使用', cmd: "df -h\r", is_builtin: true, sort_order: 1003 },
{ id: 4, name: '内存使用', cmd: "free -h\r", is_builtin: true, sort_order: 1004 },
{ id: 5, name: '进程列表', cmd: "ps aux --sort=-%mem 2>&1 | head -15\r", is_builtin: true, sort_order: 1005 },
])
}
}
@@ -999,53 +1067,6 @@ function execQuickCmd(cmd: string) {
s.term?.focus()
}
function addQuickCmd() {
qcEditId.value = null
qcName.value = ''
qcCmd.value = ''
showQcDialog.value = true
}
function editQuickCmd(cmd: QuickCmdItem) {
qcEditId.value = cmd.id
qcName.value = cmd.name
qcCmd.value = cmd.cmd
showQcDialog.value = true
}
async function saveQuickCmd() {
if (!qcName.value.trim() || !qcCmd.value.trim()) {
snackbar('名称和命令不能为空', 'warning')
return
}
try {
if (qcEditId.value) {
await http.put(`/terminal/quick-commands/${qcEditId.value}`, {
name: qcName.value,
cmd: qcCmd.value,
})
} else {
await http.post('/terminal/quick-commands', {
name: qcName.value,
cmd: qcCmd.value,
})
}
showQcDialog.value = false
await loadQuickCmds()
} catch (e: any) {
snackbar(e?.message || '保存失败', 'error')
}
}
async function deleteQuickCmd(id: number) {
try {
await http.delete(`/terminal/quick-commands/${id}`)
await loadQuickCmds()
} catch (e: any) {
snackbar(e?.message || '删除失败', 'error')
}
}
// Load server list
async function loadServers() {
try {
@@ -1107,24 +1128,88 @@ onBeforeUnmount(() => {
<style scoped>
.terminal-root {
background: #0b1120;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
width: 100%;
max-width: 100%;
height: 100%;
box-sizing: border-box;
}
.terminal-root--dark {
background: #0b1120;
}
.terminal-root--light {
background: rgb(var(--v-theme-background));
}
.empty-server-picker__list {
max-height: min(42vh, 320px);
overflow-y: auto;
}
.fill-height {
flex: 1 1 auto;
min-height: 200px;
}
.cursor-pointer { cursor: pointer; }
/* 服务器列表抽屉须高于终端内容,且从主内容区左缘弹出(非全局侧栏位) */
.font-mono { font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace; }
:deep(.xterm) { padding: 4px; height: 100%; }
:deep(.xterm-viewport) { overflow-y: auto !important; }
.terminal-quick-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgb(var(--v-theme-surface));
border-top: 1px solid rgb(var(--v-theme-surface-variant));
}
.terminal-quick-bar__label {
font-size: 0.9375rem;
font-weight: 600;
flex-shrink: 0;
color: rgb(var(--v-theme-on-surface));
}
.cmd-bar {
background: rgb(var(--v-theme-surface));
border-top: 1px solid rgb(var(--v-theme-surface-variant));
}
.cmd-bar--tall {
min-height: 120px;
}
.cmd-bar__prompt {
font-size: 1.125rem;
line-height: 1.55;
padding-top: 12px;
}
.cmd-bar__input-wrap {
min-width: 0;
}
.terminal-hotkeys {
border-left: 1px solid rgb(var(--v-theme-surface-variant));
min-width: 9.5rem;
}
.terminal-toolbar-scrollback {
min-width: 8.5rem;
max-width: 10rem;
}
:deep(.terminal-ctx-menu) {
min-width: 11.5rem;
}
:deep(.terminal-ctx-menu .v-list-item-title) {
font-size: 1rem;
font-weight: 500;
}
:deep(.terminal-ctx-menu .v-icon) {
font-size: 1.35rem;
}
/* Shell 语法高亮(命令栏 + 快捷命令编辑) */
.terminal-root--dark :deep(.sh-keyword) { color: #60a5fa; font-weight: 500; }
+13
View File
@@ -20,6 +20,19 @@ const routes = [
const router = createRouter({ history: createWebHashHistory(), routes })
/** 懒加载 chunk 失败时提示强刷(多因 index 与 assets 哈希不一致) */
router.onError((err, to) => {
const msg = err?.message || ''
if (
msg.includes('Failed to fetch dynamically imported module')
|| msg.includes('Importing a module script failed')
|| msg.includes('error loading dynamically imported module')
) {
const w = window as Window & { $snackbar?: (text: string, color?: string) => void }
w.$snackbar?.(`页面「${String(to.name || to.path)}」加载失败,请 Ctrl+F5 强制刷新`, 'error')
}
})
// Auth guard — restore session from HttpOnly refresh cookie when needed
router.beforeEach(async (to) => {
const auth = useAuthStore()
+26
View File
@@ -7,6 +7,21 @@ import { ApiError, TotpRequiredError, api, http } from '@/api'
export { TotpRequiredError }
/** 从 JWT 不验签地解码 exp 字段(秒)。失败返回 0。 */
function decodeJwtExp(jwt: string): number {
try {
let part = jwt.split('.')[1]
if (!part) return 0
const pad = part.length % 4
if (pad) part += '='.repeat(4 - pad)
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'))
const obj = JSON.parse(json) as { exp?: number }
return typeof obj.exp === 'number' ? obj.exp : 0
} catch {
return 0
}
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const admin = ref<AdminProfile | null>(null)
@@ -14,6 +29,15 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!token.value)
/** token 过期时间(unix 秒),无 token 时为 0 */
const tokenExp = computed(() => (token.value ? decodeJwtExp(token.value) : 0))
/** token 是否即将过期(默认 5 分钟内) */
function isTokenExpiringSoon(thresholdSec = 300): boolean {
const exp = tokenExp.value
return exp > 0 && exp - Math.floor(Date.now() / 1000) < thresholdSec
}
function setAccessToken(accessToken: string) {
token.value = accessToken
}
@@ -89,6 +113,8 @@ export const useAuthStore = defineStore('auth', () => {
admin,
isLoggedIn,
bootstrapped,
tokenExp,
isTokenExpiringSoon,
setAccessToken,
tryRestoreSession,
login,
+97
View File
@@ -0,0 +1,97 @@
/**
* Pinia ///
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { FileEntry, FilesCapabilityResult } from '@/types/api'
import { DEFAULT_FILES_ROOT } from '@/utils/remotePath'
import { FILES_CACHE_MAX_ENTRIES, FILES_CACHE_TTL_MS } from '@/composables/files/constants'
interface FilesCacheEntry {
items: FileEntry[]
at: number
}
function cacheKey(serverId: number, path: string): string {
return `${serverId}:${path}`
}
export const useFilesStore = defineStore('files', () => {
const selectedServer = ref<number | null>(null)
const currentPath = ref(DEFAULT_FILES_ROOT)
const files = ref<FileEntry[]>([])
const loading = ref(false)
const browseError = ref('')
const filesCapability = ref<FilesCapabilityResult | null>(null)
const lastReadDeniedPath = ref<string | null>(null)
const accessHintDismissed = ref(false)
const setupSudoLoading = ref(false)
const setupSudoDone = ref(false)
const pageReady = ref(false)
/** 路由 watch 与 onMounted 协调 */
const routeReady = ref(false)
/** 阻止 v-select 初始化触发 onServerChange */
const initing = ref(false)
const fileCache = new Map<string, FilesCacheEntry>()
function getCached(serverId: number, path: string): FileEntry[] | null {
const entry = fileCache.get(cacheKey(serverId, path))
if (!entry) return null
if (Date.now() - entry.at > FILES_CACHE_TTL_MS) {
fileCache.delete(cacheKey(serverId, path))
return null
}
return entry.items
}
function setCached(serverId: number, path: string, items: FileEntry[]) {
const key = cacheKey(serverId, path)
fileCache.set(key, { items, at: Date.now() })
if (fileCache.size > FILES_CACHE_MAX_ENTRIES) {
const oldest = [...fileCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
if (oldest) fileCache.delete(oldest[0])
}
}
function invalidateCache(serverId?: number | null, path?: string) {
if (serverId == null) {
fileCache.clear()
return
}
if (path == null) {
const prefix = `${serverId}:`
for (const key of [...fileCache.keys()]) {
if (key.startsWith(prefix)) fileCache.delete(key)
}
return
}
fileCache.delete(cacheKey(serverId, path))
}
function resetForServerChange() {
lastReadDeniedPath.value = null
accessHintDismissed.value = false
setupSudoDone.value = false
}
return {
selectedServer,
currentPath,
files,
loading,
browseError,
filesCapability,
lastReadDeniedPath,
accessHintDismissed,
setupSudoLoading,
setupSudoDone,
pageReady,
routeReady,
initing,
getCached,
setCached,
invalidateCache,
resetForServerChange,
}
})
+3
View File
@@ -211,8 +211,11 @@ export interface AuditItem {
id: number
admin_username: string
action: string
/** API 字段为 target_type;兼容旧字段名 */
resource_type: string
target_type?: string
resource_id: number | null
target_id?: number | null
detail: string | null
ip_address: string | null
created_at: string | null
+290
View File
@@ -0,0 +1,290 @@
/** 审计日志 action / target_type 中文展示(与后端 AuditLog.action 对齐) */
const ACTION_LABELS: Record<string, string> = {
// 通用 CRUD
create: '创建',
update: '更新',
delete: '删除',
login: '登录',
logout: '登出',
execute: '执行',
push: '推送',
// 认证 / 会话
login_success: '登录成功',
login_locked: '登录锁定',
login_ip_blocked: '登录 IP 被拒绝',
refresh_invalid_format: '刷新令牌格式无效',
refresh_admin_not_found: '刷新令牌用户不存在',
refresh_version_mismatch: '刷新令牌版本失效',
refresh_token_not_found: '刷新令牌会话不存在',
refresh_legacy_format: '旧版刷新令牌已废弃',
change_password: '修改密码',
setup_totp: '初始化双因素认证',
enable_totp: '启用双因素认证',
disable_totp: '禁用双因素认证',
webssh_token: '签发 WebSSH 令牌',
// 服务器 / Agent
create_server: '创建服务器',
update_server: '更新服务器',
delete_server: '删除服务器',
import_servers: '导入服务器',
generate_agent_key: '生成 Agent 密钥',
install_agent: '安装 Agent',
uninstall_agent: '卸载 Agent',
upgrade_agent: '升级 Agent',
reveal_install_cmd: '查看安装命令',
batch_install_agent: '批量安装 Agent',
batch_upgrade_agent: '批量升级 Agent',
batch_uninstall_agent: '批量卸载 Agent',
batch_detect_path: '批量检测路径',
// 资产
create_platform: '创建平台',
update_platform: '更新平台',
delete_platform: '删除平台',
create_node: '创建节点',
update_node: '更新节点',
delete_node: '删除节点',
// 脚本 / 凭据 / 执行
create_script: '创建脚本',
update_script: '更新脚本',
delete_script: '删除脚本',
create_credential: '创建数据库凭据',
update_credential: '更新数据库凭据',
delete_credential: '删除数据库凭据',
execute_started: '开始执行脚本',
execute_command: '执行脚本',
stop_execution: '停止脚本执行',
mark_stuck: '标记执行卡住',
retry_execution: '重试脚本执行',
server_auto_stuck: '服务器执行超时卡住',
auto_stuck: '执行超时自动标记',
script_job_callback: '脚本任务回调',
// 终端 / WebSSH
create_quick_command: '添加快捷命令',
update_quick_command: '更新快捷命令',
delete_quick_command: '删除快捷命令',
reorder_quick_commands: '调整快捷命令顺序',
webssh_connect: 'WebSSH 连接',
webssh_disconnect: 'WebSSH 断开',
// 系统设置
reveal_api_key: '查看 API 密钥',
update_setting: '更新系统设置',
create_schedule: '创建推送调度',
update_schedule: '更新推送调度',
delete_schedule: '删除推送调度',
create_preset: '创建密码预设',
update_preset: '更新密码预设',
delete_preset: '删除密码预设',
reveal_preset: '查看密码预设',
create_ssh_key_preset: '创建 SSH 密钥预设',
update_ssh_key_preset: '更新 SSH 密钥预设',
delete_ssh_key_preset: '删除 SSH 密钥预设',
reveal_ssh_key_preset: '查看 SSH 密钥预设',
reveal_telegram_token: '查看 Telegram Token',
parse_subscription: '解析订阅节点',
update_ip_allowlist: '更新登录 IP 白名单',
toggle_ip_allowlist: '开关登录 IP 白名单',
add_manual_ips: '添加白名单 IP',
remove_allowlist_ip: '移除白名单 IP',
retry_job: '手动重试推送',
delete_retry: '删除重试任务',
// 同步 / 文件
sync_files: '文件推送',
sync_preview: '推送预览',
sync_cancel: '取消推送',
sync_verify: '推送校验',
validate_source_path: '验证源路径',
sync_push: '文件推送',
file_copy: '复制远程文件',
file_move: '移动远程文件',
file_delete: '删除远程文件',
file_rename: '重命名远程文件',
file_mkdir: '新建远程目录',
browse_directory: '浏览远程目录',
file_diff: '对比远程文件',
file_upload: '上传文件到服务器',
file_download: '从服务器下载文件',
file_read: '读取远程文件',
file_write: '写入远程文件',
file_permission_change: '修改文件权限',
file_compress: '压缩远程文件',
file_decompress: '解压远程文件',
local_file_delete: '删除本地文件',
local_file_rename: '重命名本地文件',
local_file_preview: '预览本地文件',
upload_zip: '上传并解压 ZIP',
diagnose: '服务器诊断',
chmod: '修改权限',
chown: '修改属主',
}
const TARGET_LABELS: Record<string, string> = {
server: '服务器',
schedule: '调度',
script: '脚本',
credential: '数据库凭据',
db_credential: '数据库凭据',
admin: '管理员',
setting: '系统设置',
quick_command: '快捷命令',
retry: '重试任务',
sync: '同步',
file: '文件',
webssh: '终端会话',
platform: '平台',
node: '节点',
script_execution: '脚本执行',
preset: '密码预设',
ssh_key_preset: 'SSH 密钥预设',
}
/** 常见英文片段 → 中文(用于未登记 action 的兜底) */
const TOKEN_ZH: Record<string, string> = {
create: '创建',
update: '更新',
delete: '删除',
batch: '批量',
reveal: '查看',
install: '安装',
uninstall: '卸载',
upgrade: '升级',
import: '导入',
export: '导出',
generate: '生成',
execute: '执行',
retry: '重试',
stop: '停止',
mark: '标记',
auto: '自动',
stuck: '卡住',
server: '服务器',
script: '脚本',
file: '文件',
sync: '同步',
push: '推送',
quick: '快捷',
command: '命令',
commands: '命令',
preset: '预设',
schedule: '调度',
setting: '设置',
platform: '平台',
node: '节点',
credential: '凭据',
admin: '管理员',
login: '登录',
logout: '登出',
webssh: 'WebSSH',
connect: '连接',
disconnect: '断开',
token: '令牌',
permission: '权限',
change: '变更',
reorder: '排序',
local: '本地',
remote: '远程',
preview: '预览',
verify: '验证',
validate: '验证',
browse: '浏览',
directory: '目录',
upload: '上传',
download: '下载',
read: '读取',
write: '写入',
copy: '复制',
move: '移动',
rename: '重命名',
mkdir: '新建目录',
compress: '压缩',
decompress: '解压',
diagnose: '诊断',
parse: '解析',
subscription: '订阅',
allowlist: '白名单',
manual: '手动',
ips: 'IP',
ip: 'IP',
telegram: 'Telegram',
agent: 'Agent',
job: '任务',
execution: '执行',
started: '开始',
success: '成功',
locked: '锁定',
blocked: '被拒绝',
invalid: '无效',
format: '格式',
version: '版本',
mismatch: '不匹配',
not: '未',
found: '找到',
legacy: '旧版',
totp: '双因素认证',
password: '密码',
key: '密钥',
api: 'API',
ssh: 'SSH',
detect: '检测',
path: '路径',
cancel: '取消',
diff: '对比',
zip: 'ZIP',
callback: '回调',
}
function fallbackActionLabel(action: string): string {
const parts = action.toLowerCase().split('_').filter(Boolean)
if (parts.length === 0) return '—'
const zh = parts.map((p) => TOKEN_ZH[p] ?? p)
return zh.join('')
}
export function auditActionLabel(action: string): string {
if (!action) return '—'
const key = action.trim()
if (ACTION_LABELS[key]) return ACTION_LABELS[key]
return fallbackActionLabel(key)
}
export function auditTargetLabel(type: string): string {
if (!type) return '—'
const key = type.trim()
if (TARGET_LABELS[key]) return TARGET_LABELS[key]
return fallbackActionLabel(key)
}
/** 审计页「操作类型」筛选(中文标签 + 英文 value) */
export const auditActionFilterOptions: { label: string; value: string }[] = [
{ label: '登录成功', value: 'login_success' },
{ label: '登出', value: 'logout' },
{ label: '修改密码', value: 'change_password' },
{ label: '创建服务器', value: 'create_server' },
{ label: '更新服务器', value: 'update_server' },
{ label: '删除服务器', value: 'delete_server' },
{ label: '文件推送', value: 'sync_files' },
{ label: '执行脚本', value: 'execute_command' },
{ label: 'WebSSH 连接', value: 'webssh_connect' },
{ label: '写入远程文件', value: 'file_write' },
{ label: '更新系统设置', value: 'update_setting' },
{ label: '手动重试推送', value: 'retry_job' },
]
export function auditSummary(item: {
action: string
resource_type?: string
target_type?: string
detail?: string | null
}): string {
const type = item.resource_type || item.target_type || ''
const base = `${auditActionLabel(item.action)} · ${auditTargetLabel(type)}`
const detail = (item.detail || '').trim()
return detail ? `${base}${detail}` : base
}
+23
View File
@@ -0,0 +1,23 @@
import type { AuditItem } from '@/types/api'
/** 统一后端 target_* 与前端 resource_* 字段 */
export function normalizeAuditItem(raw: Record<string, unknown>): AuditItem {
const targetType = String(raw.target_type ?? raw.resource_type ?? '')
const targetId = raw.target_id ?? raw.resource_id
return {
id: Number(raw.id),
admin_username: String(raw.admin_username ?? ''),
action: String(raw.action ?? ''),
resource_type: targetType,
target_type: targetType,
resource_id: targetId != null && targetId !== '' ? Number(targetId) : null,
target_id: targetId != null && targetId !== '' ? Number(targetId) : null,
detail: raw.detail != null ? String(raw.detail) : null,
ip_address: raw.ip_address != null ? String(raw.ip_address) : null,
created_at: raw.created_at != null ? String(raw.created_at) : null,
}
}
export function normalizeAuditList(items: unknown[]): AuditItem[] {
return items.map((row) => normalizeAuditItem(row as Record<string, unknown>))
}
+37 -15
View File
@@ -67,35 +67,57 @@ 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 {
// root SSH 用户无需任何提示
if (params.isRootSsh) return null
const lines: string[] = []
if (params.lastReadDeniedPath) {
lines.push(`无法读取「${params.lastReadDeniedPath}」:当前 SSH 用户可能无读权限。`)
}
const n = params.unreadableCount ?? 0
// 只在有实际权限问题时显示提示
if (params.lastReadDeniedPath) {
lines.push(`无法读取「${params.lastReadDeniedPath}」:当前 SSH 用户无权限。`)
}
if (n > 0 && params.sshUser) {
lines.push(`当前目录约 ${n} 个文件对 SSH 用户「${params.sshUser}可能不可读(根据 ls 权限推断)`)
lines.push(`当前目录约 ${n} 个文件对 SSH 用户「${params.sshUser}」不可读。`)
}
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 (lines.length > 0) {
if (params.filesElevation === 'off') {
lines.push('此服务器「文件提权」已关闭,不会自动 sudo。')
} else if (!params.canSudo) {
lines.push('请配置免密 sudo(NOPASSWD)或使用「一键配置」,或改用 root SSH 用户。')
} else if (!params.whitelistOk) {
lines.push('已有 sudo 但白名单不完整(须含 bash、chmod、chown、tee、cat 等)。')
} else {
lines.push('系统已自动尝试 sudo -n 提权,如仍失败请检查 sudoers 白名单。')
}
}
if (!lines.length) return null
const needsSudoersDoc =
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.canSudo || params.filesElevation === 'off') ? 'error' : 'warning'
const body = lines.join(' ')
return { type, text: `${body}${suffix}` }
}
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())
+1 -1
View File
@@ -301,7 +301,7 @@ async def change_password(
current_admin.password_hash = new_hash
# Security: invalidate all existing sessions (refresh tokens + JWT token_version)
current_admin.token_version += 1
current_admin.token_version = (current_admin.token_version or 0) + 1
await admin_repo.update(current_admin)
# Clear all refresh tokens from Redis (all devices logged out)
+10 -7
View File
@@ -636,23 +636,26 @@ async def lock_install():
"""
_reject_if_installed()
# Verify admin account exists before allowing lock
# Verify at least one active admin account exists before allowing lock
try:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from sqlalchemy import text as _text
async with AsyncSessionLocal() as session:
repo = AdminRepositoryImpl(session)
admin = await repo.get_by_username("admin")
if not admin:
result = await session.execute(
_text("SELECT COUNT(*) FROM admins WHERE is_active = 1")
)
count = result.scalar() or 0
if count == 0:
raise HTTPException(
status_code=400,
detail="无法锁定:管理员账户尚未创建。请先完成安装步骤4。",
detail="无法锁定:尚无活跃管理员账户。请先完成安装步骤4。",
)
except HTTPException:
raise
except Exception as e:
logger.warning("Failed to verify admin account before locking: %s", e)
# If DB is unreachable, allow lock (admin may have been created but DB is temporarily down)
# If DB is temporarily unreachable, conservatively reject lock
raise HTTPException(status_code=503, detail="数据库暂时不可用,无法验证管理员账户,请稍后重试。")
# Create lock marker file
INSTALL_LOCK.write_text(
+97 -1
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
@@ -205,6 +206,8 @@ async def list_servers(
# changed a server's status vs what MySQL reported. Remove mismatches.
if is_online is not None:
result = [s for s in result if s.get("is_online") == is_online]
# Recalculate total/pages to reflect actual filtered count
total = len(result)
# Trim to per_page (we over-fetched from DB to compensate)
result = result[:per_page]
@@ -213,7 +216,7 @@ async def list_servers(
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 1,
}
@@ -951,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,
+3
View File
@@ -1370,6 +1370,9 @@ def _audit_to_dict(log: AuditLog) -> dict:
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
# 前端历史字段别名
"resource_type": log.target_type,
"resource_id": log.target_id,
"detail": log.detail,
"ip_address": log.ip_address,
"created_at": str(log.created_at) if log.created_at else None,
+28 -10
View File
@@ -268,21 +268,25 @@ async def browse_directory(
raise HTTPException(status_code=400, detail=str(exc)) from exc
import shlex
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
from server.utils.unix_ls import parse_ls_la_line, perms_to_mode_octal
path_q = shlex.quote(path)
result = await exec_ssh_command(
# 与写文件/chmod 一致:非 root SSH 在 Permission denied 时按 files_elevation 尝试 sudo -n
result = await exec_ssh_command_with_fallback(
server,
f"ls -la --time-style=long-iso {path_q}",
timeout=10,
)
if result["exit_code"] != 0:
result = await exec_ssh_command(server, f"ls -la {path_q}", timeout=10)
result = await exec_ssh_command_with_fallback(
server, f"ls -la {path_q}", timeout=10
)
await _audit_sync("browse_directory", "server", server_id, f"Browse {server.name}:{path}", admin.username, request)
if result["exit_code"] != 0:
return {"path": path, "items": [], "error": result["stderr"][:500]}
return {"path": path, "items": [], "error": (result.get("stderr") or "")[:500]}
raw_entries = []
for line in result["stdout"].split("\n"):
@@ -644,7 +648,7 @@ async def diagnose_push(
if r["exit_code"] == 0 and "ok" in r["stdout"]:
result["ssh_ok"] = True
else:
result["errors"].append(f"SSH 连接异常: {r['stderr'][:200] or 'exit code ' + str(r['exit_code'])}")
result["errors"].append(f"SSH 连接异常: {(r.get('stderr') or '')[:200] or 'exit code ' + str(r['exit_code'])}")
# Cannot continue if SSH fails
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH不通", admin.username, request)
return result
@@ -696,7 +700,7 @@ async def diagnose_push(
)
result["path_writable"] = r["exit_code"] == 0
if r["exit_code"] != 0:
result["errors"].append(f"目标路径不可写: {r['stderr'][:200]}")
result["errors"].append(f"目标路径不可写: {(r.get('stderr') or '')[:200]}")
except Exception as e:
result["errors"].append(f"写入测试失败: {e}")
result["path_writable"] = False
@@ -1233,22 +1237,29 @@ async def download_file(
raise HTTPException(status_code=400, detail=str(exc)) from exc
conn = await ssh_pool.acquire(server)
# NOTE: connection is released INSIDE file_generator (after stream finishes),
# not in a finally block here — releasing before the generator runs would close
# the SFTP channel mid-download.
try:
async with sftp_session(conn) as sftp:
# Check file exists and size
try:
stat = await sftp.stat(remote_path)
except FileNotFoundError:
await ssh_pool.release(server.id)
raise HTTPException(status_code=404, detail="文件不存在") from None
except Exception as e:
await ssh_pool.release(server.id)
raise HTTPException(status_code=502, detail=f"无法访问文件: {e}") from e
if stat.type not in ("regular file", "unknown"):
await ssh_pool.release(server.id)
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
# H-15: Download file size limit (100MB)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
await ssh_pool.release(server.id)
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
@@ -1256,9 +1267,13 @@ async def download_file(
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
async def file_generator():
async with sftp.open(remote_path, "rb") as f:
while chunk := await f.read(65536):
yield chunk
try:
async with sftp.open(remote_path, "rb") as f:
while chunk := await f.read(65536):
yield chunk
finally:
# Release after stream is fully consumed (or on client disconnect)
await ssh_pool.release(server.id)
await _audit_sync(
"file_download", "server", payload.server_id,
@@ -1271,8 +1286,11 @@ async def download_file(
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
finally:
except HTTPException:
raise
except Exception as e:
await ssh_pool.release(server.id)
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
@router.post("/read-file")
@@ -1555,7 +1573,7 @@ async def decompress_file(
result = await exec_ssh_command_with_fallback(server, cmd, timeout=120)
if result["exit_code"] != 0:
raise HTTPException(status_code=502, detail=f"解压失败: {result['stderr'][:300]}")
raise HTTPException(status_code=502, detail=f"解压失败: {(result.get('stderr') or '')[:300]}")
await _audit_sync(
"file_decompress", "server", payload.server_id,
+63 -14
View File
@@ -28,14 +28,22 @@ class QuickCommandUpdate(BaseModel):
cmd: str | None = Field(None, min_length=1, max_length=500, description="命令内容")
class QuickCommandReorder(BaseModel):
"""自定义命令 id 列表,按展示顺序(靠前 = 优先级更高)"""
ids: list[int] = Field(..., min_length=1)
# ── Built-in commands seed data ──
# 内置命令 sort_order 从 1000 起,保证自定义命令(0…)始终排在前面
BUILTIN_SORT_BASE = 1000
BUILTIN_COMMANDS = [
{"name": "系统状态", "cmd": "systemctl status --no-pager 2>&1 | head -20\r", "sort_order": 1},
{"name": "系统日志", "cmd": "journalctl -n 30 --no-pager 2>&1\r", "sort_order": 2},
{"name": "磁盘使用", "cmd": "df -h\r", "sort_order": 3},
{"name": "内存使用", "cmd": "free -h\r", "sort_order": 4},
{"name": "进程列表", "cmd": "ps aux --sort=-%mem 2>&1 | head -15\r", "sort_order": 5},
{"name": "系统状态", "cmd": "systemctl status --no-pager 2>&1 | head -20\r", "sort_order": BUILTIN_SORT_BASE + 1},
{"name": "系统日志", "cmd": "journalctl -n 30 --no-pager 2>&1\r", "sort_order": BUILTIN_SORT_BASE + 2},
{"name": "磁盘使用", "cmd": "df -h\r", "sort_order": BUILTIN_SORT_BASE + 3},
{"name": "内存使用", "cmd": "free -h\r", "sort_order": BUILTIN_SORT_BASE + 4},
{"name": "进程列表", "cmd": "ps aux --sort=-%mem 2>&1 | head -15\r", "sort_order": BUILTIN_SORT_BASE + 5},
]
@@ -67,10 +75,13 @@ async def list_quick_commands(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List all quick commands (built-in + custom), sorted by sort_order then id"""
"""List all quick commands: 自定义优先(sort_order 小在前),其次内置"""
result = await db.execute(
select(QuickCommand)
.order_by(QuickCommand.sort_order.asc(), QuickCommand.id.asc())
select(QuickCommand).order_by(
QuickCommand.is_builtin.asc(),
QuickCommand.sort_order.asc(),
QuickCommand.id.asc(),
)
)
commands = result.scalars().all()
return [_qc_to_dict(qc) for qc in commands]
@@ -83,16 +94,15 @@ async def create_quick_command(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a custom quick command"""
# Get max sort_order for custom commands to append at end
"""Create a custom quick command(插入队首:sort_order 比现有自定义最小值再小 1)"""
result = await db.execute(
select(QuickCommand)
select(QuickCommand.sort_order)
.where(QuickCommand.is_builtin.is_(False))
.order_by(QuickCommand.sort_order.desc())
.order_by(QuickCommand.sort_order.asc())
.limit(1)
)
last = result.scalar_one_or_none()
next_order = (last.sort_order + 1) if last else (len(BUILTIN_COMMANDS) + 1)
min_order = result.scalar_one_or_none()
next_order = (min_order - 1) if min_order is not None else 0
qc = QuickCommand(
name=payload.name,
@@ -149,6 +159,45 @@ async def update_quick_command(
return _qc_to_dict(qc)
@router.put("/quick-commands/reorder", response_model=dict)
async def reorder_quick_commands(
payload: QuickCommandReorder,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""重排自定义快捷命令顺序(ids[0] 优先级最高)"""
for idx, cmd_id in enumerate(payload.ids):
result = await db.execute(select(QuickCommand).where(QuickCommand.id == cmd_id))
qc = result.scalar_one_or_none()
if not qc:
raise HTTPException(status_code=404, detail=f"快捷命令 #{cmd_id} 不存在")
if qc.is_builtin:
raise HTTPException(status_code=403, detail="系统内置命令不可排序")
qc.sort_order = idx
await db.commit()
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="reorder_quick_commands",
target_type="quick_command",
target_id=0,
detail=f"顺序={','.join(str(i) for i in payload.ids)}",
ip_address=request.client.host if request.client else "",
))
result = await db.execute(
select(QuickCommand).order_by(
QuickCommand.is_builtin.asc(),
QuickCommand.sort_order.asc(),
QuickCommand.id.asc(),
)
)
return {"items": [_qc_to_dict(qc) for qc in result.scalars().all()]}
@router.delete("/quick-commands/{id}", status_code=204)
async def delete_quick_command(
id: int,
+3 -2
View File
@@ -336,8 +336,9 @@ async def _verify_ws_token(token: str):
return None
# Primary: token_version must match (immediate invalidation on password change)
token_tv = payload.get("tv", -1)
if token_tv != admin.token_version:
token_tv = payload.get("tv") or 0
admin_tv = admin.token_version or 0
if token_tv != admin_tv:
return None
# Secondary: updated_at timestamp (defense in depth)
+50 -15
View File
@@ -98,6 +98,7 @@ class AuthService:
# ── IP allowlist check ──
# Only enforced when login_allowlist_enabled = "true"
allowlist_on = (getattr(settings, "LOGIN_ALLOWLIST_ENABLED", "false") or "false").lower()
is_whitelisted_ip = False
if allowlist_on in ("true", "1", "yes", "on") and ip_address and ip_address not in ("", "unknown"):
# Combine subscription IPs + manual IPs
sub_ips_raw = (getattr(settings, "LOGIN_SUBSCRIPTION_IPS", "") or "").strip()
@@ -115,29 +116,38 @@ class AuthService:
"reason": "ip_blocked",
"message": f"当前 IP ({ip_address}) 不在登录白名单中,请检查代理或联系管理员",
}
if allowed_ips and _ip_in_allowlist(ip_address, allowed_ips):
is_whitelisted_ip = True
# Check brute-force lockout
failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES)
if failures >= MAX_LOGIN_FAILURES:
await self._audit(
"login_locked", "admin", 0, f"账号已锁定: {username}",
ip_address=ip_address, admin_username=username,
)
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
# Check brute-force lockout (trusted whitelist IPs skip lockout counting)
if not is_whitelisted_ip:
failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES)
if failures >= MAX_LOGIN_FAILURES:
await self._audit(
"login_locked", "admin", 0, f"账号已锁定: {username}",
ip_address=ip_address, admin_username=username,
)
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
# Find admin
admin = await self.admin_repo.get_by_username(username)
if not admin:
await self._record_attempt(username, ip_address, False)
await self._handle_login_failure(
username, ip_address, is_whitelisted_ip, "login_failed", "用户名不存在",
)
return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"}
if not admin.is_active:
await self._record_attempt(username, ip_address, False)
await self._handle_login_failure(
username, ip_address, is_whitelisted_ip, "login_failed", "账户已禁用",
)
return {"success": False, "reason": "account_disabled", "message": "账户已被禁用"}
# Verify password
if not self._verify_password(password, admin.password_hash):
await self._record_attempt(username, ip_address, False)
await self._handle_login_failure(
username, ip_address, is_whitelisted_ip, "login_failed", "密码错误",
)
return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"}
# Verify TOTP (if enabled)
@@ -145,7 +155,9 @@ class AuthService:
if not totp_code:
return {"success": False, "reason": "totp_required", "message": "需要TOTP验证码"}
if not self._verify_totp(totp_code, admin.totp_secret):
await self._record_attempt(username, ip_address, False)
await self._handle_login_failure(
username, ip_address, is_whitelisted_ip, "login_failed", "TOTP验证码错误",
)
return {"success": False, "reason": "invalid_totp", "message": "TOTP验证码错误"}
# Success — update admin first (so JWT captures latest updated_at/token_version)
@@ -159,6 +171,8 @@ class AuthService:
# Store refresh token in Redis (supports multi-device login)
await self._store_refresh_token(admin.id, refresh_token)
if is_whitelisted_ip:
await self.attempt_repo.clear_failures_for_username(username)
await self._record_attempt(username, ip_address, True)
await self._audit(
"login_success", "admin", admin.id, f"登录: {username}",
@@ -283,7 +297,7 @@ class AuthService:
"""
admin = await self.admin_repo.get_by_id(admin_id)
if admin:
admin.token_version += 1 # Invalidate all existing tokens
admin.token_version = (admin.token_version or 0) + 1 # Invalidate all existing tokens
await self.admin_repo.update(admin)
await self._delete_all_refresh_tokens(admin.id)
return {"success": True, "message": "已登出所有设备"}
@@ -378,7 +392,7 @@ class AuthService:
admin.totp_enabled = False
admin.totp_secret = None
admin.token_version += 1
admin.token_version = (admin.token_version or 0) + 1
await self.admin_repo.update(admin)
# Invalidate all refresh tokens (all devices must re-login)
await self._delete_all_refresh_tokens(admin.id)
@@ -481,7 +495,10 @@ class AuthService:
def _verify_password(self, password: str, password_hash: str) -> bool:
"""Verify password against stored bcrypt hash"""
return bcrypt.checkpw(password.encode(), password_hash.encode())
try:
return bcrypt.checkpw(password.encode(), password_hash.encode())
except (ValueError, Exception):
return False
@staticmethod
def _verify_totp(code: str, secret: str) -> bool:
@@ -504,6 +521,24 @@ class AuthService:
return True
return False
async def _handle_login_failure(
self,
username: str,
ip_address: str,
is_whitelisted_ip: bool,
audit_action: str,
audit_detail: str,
) -> None:
"""Record failed login: audit always for whitelist IPs; brute-force count only otherwise."""
if is_whitelisted_ip:
await self._audit(
audit_action, "admin", 0,
f"{audit_detail} (用户: {username}, 白名单IP)",
ip_address=ip_address, admin_username=username,
)
else:
await self._record_attempt(username, ip_address, False)
async def _record_attempt(self, username: str, ip_address: str, success: bool):
"""Record login attempt for brute-force tracking"""
attempt = LoginAttempt(username=username, ip_address=ip_address, success=success)
+29 -17
View File
@@ -149,30 +149,42 @@ class SyncEngineV2:
sync_log.duration_seconds = int(
(sync_log.finished_at - sync_log.started_at).total_seconds()
) if sync_log.started_at else 0
sync_log.error_message = result["stderr"][:1000] if result["exit_code"] != 0 else None
sync_log.error_message = (result.get("stderr") or "")[:1000] if result["exit_code"] != 0 else None
sync_log.diff_summary = result["stdout"][:2000] if result["exit_code"] == 0 else None
sync_log = await self.sync_log_repo.update(sync_log)
# Auto-create retry job for failed pushes
# Auto-create retry job for failed pushes (dedup: one pending per server+path)
retry_job_id = None
if sync_log.status == "failed":
try:
from server.domain.models import PushRetryJob
retry_job = PushRetryJob(
server_id=server.id,
server_name=server.name,
operator=operator,
source_path=source_path,
target_path=dest,
status="pending",
retry_count=0,
max_retries=3,
next_retry_at=datetime.now(timezone.utc),
last_error=sync_log.error_message[:500] if sync_log.error_message else None,
from sqlalchemy import select as _select
existing = await self.retry_repo.session.execute(
_select(PushRetryJob).where(
PushRetryJob.server_id == server.id,
PushRetryJob.source_path == source_path,
PushRetryJob.target_path == dest,
PushRetryJob.status == "pending",
)
)
retry_job = await self.retry_repo.create(retry_job)
retry_job_id = retry_job.id
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
if existing.scalars().first() is None:
retry_job = PushRetryJob(
server_id=server.id,
server_name=server.name,
operator=operator,
source_path=source_path,
target_path=dest,
status="pending",
retry_count=0,
max_retries=3,
next_retry_at=datetime.now(timezone.utc),
last_error=sync_log.error_message[:500] if sync_log.error_message else None,
)
retry_job = await self.retry_repo.create(retry_job)
retry_job_id = retry_job.id
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
else:
logger.debug(f"Retry job already pending for server {server.id}, skipping duplicate")
except Exception as e:
logger.warning(f"Failed to create retry job for server {server.id}: {e}")
@@ -274,7 +286,7 @@ class SyncEngineV2:
return {
"server_id": server_id,
"server_name": server.name,
"error": result["stderr"][:500] or f"rsync exited with code {result['exit_code']}",
"error": (result.get("stderr") or "")[:500] or f"rsync exited with code {result['exit_code']}",
"exit_code": result["exit_code"],
}
+37 -38
View File
@@ -53,45 +53,44 @@ async def heartbeat_flush_loop():
continue
flushed = 0
try:
async with AsyncSessionLocal() as session:
for key in keys:
skipped = 0
for key in keys:
try:
async with AsyncSessionLocal() as session:
server_id = int(key.replace(REDIS_KEY_PREFIX, ""))
data = await redis.hgetall(key)
if not data:
continue
is_online = data.get("is_online") == "True"
system_info = data.get("system_info", "{}")
agent_version = data.get("agent_version", "")
# Parse last_heartbeat — ensure timezone-aware
last_hb_str = data.get("last_heartbeat", "")
try:
server_id = int(key.replace(REDIS_KEY_PREFIX, ""))
data = await redis.hgetall(key)
last_heartbeat = datetime.fromisoformat(last_hb_str)
if last_heartbeat.tzinfo is None:
last_heartbeat = last_heartbeat.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
last_heartbeat = datetime.now(timezone.utc)
if not data:
continue
is_online = data.get("is_online") == "True"
system_info = data.get("system_info", "{}")
agent_version = data.get("agent_version", "")
# Parse last_heartbeat
last_hb_str = data.get("last_heartbeat", "")
try:
last_heartbeat = datetime.fromisoformat(last_hb_str)
except (ValueError, TypeError):
last_heartbeat = datetime.now(timezone.utc)
await session.execute(
update(Server)
.where(Server.id == server_id)
.values(
is_online=is_online,
system_info=system_info,
last_heartbeat=last_heartbeat,
agent_version=agent_version,
)
await session.execute(
update(Server)
.where(Server.id == server_id)
.values(
is_online=is_online,
system_info=system_info,
last_heartbeat=last_heartbeat,
agent_version=agent_version,
)
flushed += 1
except Exception as e:
logger.warning(f"Failed to flush server {key}: {e}")
# Don't rollback here — just skip this server and continue.
# A partial UPDATE on a single server won't corrupt others.
# The final commit() will persist all successful updates.
)
await session.commit()
flushed += 1
except Exception as e:
logger.warning(f"Failed to flush server {key}: {e}")
skipped += 1
await session.commit()
logger.info(f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL")
except Exception as e:
logger.error(f"Heartbeat flush batch failed: {e}")
logger.info(f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL" +
(f", {skipped} failed" if skipped else ""))
+10 -2
View File
@@ -67,6 +67,10 @@ async def retry_runner_loop():
if sync_result["failed"] == 0:
job.status = "completed"
logger.info(f"Retry job {job.id}: success after {job.retry_count} attempts")
elif job.retry_count >= job.max_retries:
job.status = "failed"
job.last_error = f"Retry exhausted after {job.retry_count} attempts"
logger.warning(f"Retry job {job.id}: max retries reached, marking failed")
else:
# Still failing — schedule next retry with backoff
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
@@ -83,9 +87,13 @@ async def retry_runner_loop():
except Exception as e:
logger.error(f"Retry job {job.id} execution error: {e}")
job.retry_count += 1
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
job.next_retry_at = now + timedelta(seconds=backoff)
job.last_error = str(e)[:500]
if job.retry_count >= job.max_retries:
job.status = "failed"
logger.warning(f"Retry job {job.id}: max retries reached (exception), marking failed")
else:
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
job.next_retry_at = now + timedelta(seconds=backoff)
await session.commit()
if retried:
+83 -71
View File
@@ -84,9 +84,9 @@ async def schedule_runner_loop():
triggered = 0
for schedule in schedules:
try:
run_mode = getattr(schedule, 'run_mode', None) or 'cron'
run_mode = getattr(schedule, 'run_mode', None) or 'cron'
try:
# ── One-time schedule: check fire_at ──
if run_mode == 'once':
fire_at = getattr(schedule, 'fire_at', None)
@@ -123,91 +123,103 @@ async def schedule_runner_loop():
continue
# Mark as running BEFORE execution to prevent double-trigger
# (if execution takes longer than SCHEDULE_CHECK_INTERVAL)
# (if execution takes longer than SCHEDULE_CHECK_INTERVAL).
# On failure we reset last_run_at so the schedule can retry.
prev_last_run_at = schedule.last_run_at
schedule.last_run_at = now
if run_mode == 'once':
schedule.enabled = False
await session.commit()
schedule_type = getattr(schedule, 'schedule_type', 'push') or 'push'
execution_ok = True
if schedule_type == 'script':
# ── Script execution schedule ──
from server.application.services.script_service import ScriptService
from server.infrastructure.database.script_repo import (
ScriptRepositoryImpl, ScriptExecutionRepositoryImpl,
)
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
try:
if schedule_type == 'script':
# ── Script execution schedule ──
from server.application.services.script_service import ScriptService
from server.infrastructure.database.script_repo import (
ScriptRepositoryImpl, ScriptExecutionRepositoryImpl,
)
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
cmd = getattr(schedule, 'script_content', None) or ''
script_id = getattr(schedule, 'script_id', None)
cmd = getattr(schedule, 'script_content', None) or ''
script_id = getattr(schedule, 'script_id', None)
# If no inline command, load from script library
if not cmd and script_id:
script = await ScriptRepositoryImpl(session).get_by_id(script_id)
if script:
cmd = script.content or ''
# If no inline command, load from script library
if not cmd and script_id:
script = await ScriptRepositoryImpl(session).get_by_id(script_id)
if script:
cmd = script.content or ''
if not cmd:
logger.error(f"Schedule '{schedule.name}': no command or script content, skipping")
continue
if not cmd:
logger.error(f"Schedule '{schedule.name}': no command or script content, skipping")
execution_ok = False
else:
svc = ScriptService(
script_repo=ScriptRepositoryImpl(session),
execution_repo=ScriptExecutionRepositoryImpl(session),
credential_repo=DbCredentialRepositoryImpl(session),
server_repo=ServerRepositoryImpl(session),
audit_repo=AuditLogRepositoryImpl(session),
)
timeout = getattr(schedule, 'exec_timeout', None) or 60
long_task = bool(getattr(schedule, 'long_task', False))
execution = await svc.execute_command(
command=cmd,
server_ids=server_ids,
script_id=script_id,
timeout=timeout,
operator=f"schedule:{schedule.name}",
long_task=long_task,
)
logger.info(
f"Schedule '{schedule.name}' (script) triggered: "
f"execution_id={execution.id} on {len(server_ids)} servers"
)
else:
# ── File push schedule (original behaviour) ──
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
svc = ScriptService(
script_repo=ScriptRepositoryImpl(session),
execution_repo=ScriptExecutionRepositoryImpl(session),
credential_repo=DbCredentialRepositoryImpl(session),
server_repo=ServerRepositoryImpl(session),
audit_repo=AuditLogRepositoryImpl(session),
)
timeout = getattr(schedule, 'exec_timeout', None) or 60
long_task = bool(getattr(schedule, 'long_task', False))
execution = await svc.execute_command(
command=cmd,
server_ids=server_ids,
script_id=script_id,
timeout=timeout,
operator=f"schedule:{schedule.name}",
long_task=long_task,
)
logger.info(
f"Schedule '{schedule.name}' (script) triggered: "
f"execution_id={execution.id} on {len(server_ids)} servers"
)
engine = SyncEngineV2(
server_repo=ServerRepositoryImpl(session),
sync_log_repo=SyncLogRepositoryImpl(session),
audit_repo=AuditLogRepositoryImpl(session),
retry_repo=PushRetryJobRepositoryImpl(session),
)
result = await engine.sync_files(
server_ids=server_ids,
source_path=schedule.source_path or '/tmp',
sync_mode=getattr(schedule, 'sync_mode', None) or "incremental",
trigger_type="schedule",
operator=f"schedule:{schedule.name}",
)
logger.info(
f"Schedule '{schedule.name}' (push) triggered: "
f"{result['completed']}/{result['total']} succeeded"
)
except Exception as exec_err:
logger.error(f"Schedule {schedule.id} execution failed: {exec_err}")
execution_ok = False
if not execution_ok and run_mode != 'once':
# Roll back last_run_at so the schedule can re-fire next cycle
schedule.last_run_at = prev_last_run_at
await session.commit()
else:
# ── File push schedule (original behaviour) ──
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
await session.commit()
engine = SyncEngineV2(
server_repo=ServerRepositoryImpl(session),
sync_log_repo=SyncLogRepositoryImpl(session),
audit_repo=AuditLogRepositoryImpl(session),
retry_repo=PushRetryJobRepositoryImpl(session),
)
result = await engine.sync_files(
server_ids=server_ids,
source_path=schedule.source_path or '/tmp',
sync_mode=getattr(schedule, 'sync_mode', None) or "incremental",
trigger_type="schedule",
operator=f"schedule:{schedule.name}",
)
logger.info(
f"Schedule '{schedule.name}' (push) triggered: "
f"{result['completed']}/{result['total']} succeeded"
)
# last_run_at + enabled already set before execution (prevent double-trigger)
await session.commit()
triggered += 1
except Exception as e:
logger.error(f"Schedule {schedule.id} execution failed: {e}")
logger.error(f"Schedule {schedule.id} setup/parse failed: {e}")
if triggered:
logger.info(f"Schedule runner: {triggered} schedules triggered")
+1
View File
@@ -47,6 +47,7 @@ class LoginAttemptRepository(Protocol):
async def create(self, attempt: LoginAttempt) -> LoginAttempt: ...
async def count_recent_failures(self, username: str, ip_address: str, minutes: int = 15) -> int: ...
async def clear_failures_for_username(self, username: str) -> int: ...
class SettingRepository(Protocol):
+14 -3
View File
@@ -1,7 +1,7 @@
"""Nexus — Admin & LoginAttempt Repository (Async SQLAlchemy)"""
from typing import Optional
from sqlalchemy import select, func
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Admin, LoginAttempt
@@ -41,11 +41,22 @@ class LoginAttemptRepositoryImpl:
async def count_recent_failures(self, username: str, ip_address: str, minutes: int = 15) -> int:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
# Lock by username (primary) OR by IP (secondary) — whichever is over threshold.
# Using IP-only for counting means an attacker can rotate IPs to bypass;
# we count all IPs that attempted this username so rotating IPs doesn't help.
result = await self.session.execute(
select(func.count(LoginAttempt.id))
.where(LoginAttempt.username == username)
.where(LoginAttempt.ip_address == ip_address)
.where(LoginAttempt.success == False)
.where(LoginAttempt.attempted_at >= cutoff)
)
return result.scalar_one() or 0
return result.scalar_one() or 0
async def clear_failures_for_username(self, username: str) -> int:
result = await self.session.execute(
delete(LoginAttempt)
.where(LoginAttempt.username == username)
.where(LoginAttempt.success == False)
)
await self.session.commit()
return result.rowcount or 0
+20 -13
View File
@@ -392,22 +392,25 @@ async def exec_ssh_command(server: Server, command: str, timeout: int = 30) -> d
W2: Replaces paramiko-based exec_command from pool.py.
Returns structured result dict with stdout/stderr/exit_code.
"""
conn = None
try:
conn = await ssh_pool.acquire(server)
try:
result = await asyncio.wait_for(
conn.run(command, timeout=timeout),
timeout=timeout + 5,
)
return {
"status": "success" if result.exit_status == 0 else "failed",
"stdout": result.stdout[:10000],
"stderr": result.stderr[:10000],
"exit_code": result.exit_status,
}
finally:
await ssh_pool.release(server.id)
result = await asyncio.wait_for(
conn.run(command, timeout=timeout),
timeout=timeout + 5,
)
await ssh_pool.release(server.id)
return {
"status": "success" if result.exit_status == 0 else "failed",
"stdout": result.stdout[:10000],
"stderr": result.stderr[:10000],
"exit_code": result.exit_status,
}
except asyncio.TimeoutError:
# Timed-out connection may be in an unknown state — evict so it is NOT
# returned to the pool and potentially reused for a future command.
if conn is not None:
await ssh_pool.close_connection(server.id)
return {
"status": "timeout",
"stdout": "",
@@ -415,6 +418,8 @@ async def exec_ssh_command(server: Server, command: str, timeout: int = 30) -> d
"exit_code": -1,
}
except ConnectionError as e:
if conn is not None:
await ssh_pool.close_connection(server.id)
return {
"status": "connection_error",
"stdout": "",
@@ -422,6 +427,8 @@ async def exec_ssh_command(server: Server, command: str, timeout: int = 30) -> d
"exit_code": -1,
}
except Exception as e:
if conn is not None:
await ssh_pool.release(server.id)
return {
"status": "error",
"stdout": "",
+32 -4
View File
@@ -20,18 +20,46 @@ def modified_from_ls_parts(parts: list[str]) -> str:
def parse_ls_la_line(line: str) -> dict | None:
"""Parse one ``ls -la`` line; returns None for total/skip lines."""
"""Parse one ``ls -la`` line; returns None for total/skip lines.
Handles two timestamp formats produced by different ``ls`` implementations:
- Standard (3-token date): ``perms nlinks owner group size Mon DD HH:MM name`` 9+ fields
- GNU long-iso (2-token): ``perms nlinks owner group size YYYY-MM-DD HH:MM name`` 8+ fields
With ``--time-style=long-iso`` the date collapses from 3 tokens to 2, so
the total token count drops from 9 to 8. Directories and regular files were
silently dropped (only symlinks survived because their names contain
``" -> target"`` which pushed the count back to 9).
"""
stripped = line.strip()
if not stripped or stripped.startswith("total "):
return None
# Split into at most 9 pieces so filenames with spaces are kept intact.
parts = stripped.split(None, 8)
if len(parts) < 9:
if len(parts) < 8:
return None
perms = parts[0]
is_dir = perms.startswith("d")
is_link = perms.startswith("l")
name = parts[8]
# Detect format by checking whether field[5] is an ISO date (YYYY-MM-DD).
# long-iso: [perms nlinks owner group size DATE TIME name] → name at index 7
# standard: [perms nlinks owner group size MON DAY TIME name] → name at index 8
if _ISO_DATE.match(parts[5]):
# long-iso format — name is at index 7 (or rest of string)
if len(parts) < 8:
return None
name = parts[7]
modified = f"{parts[5]} {parts[6].split('.', 1)[0]}"
else:
# standard format — need at least 9 tokens for the name at index 8
if len(parts) < 9:
return None
name = parts[8]
modified = modified_from_ls_parts(parts)
link_target = None
if is_link and " -> " in name:
name, link_target = name.split(" -> ", 1)
@@ -47,7 +75,7 @@ def parse_ls_la_line(line: str) -> dict | None:
"perms": perms,
"owner": parts[2],
"group": parts[3] if len(parts) > 3 else "",
"modified": modified_from_ls_parts(parts),
"modified": modified,
}
+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)