Compare commits
25
Commits
e555610037
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fdc55e17d | ||
|
|
37479f48c7 | ||
|
|
1914d38451 | ||
|
|
2aa2a1ac18 | ||
|
|
62f1661b2c | ||
|
|
8501deba07 | ||
|
|
78ddc17fce | ||
|
|
10f8e6bb66 | ||
|
|
917674880b | ||
|
|
8e6b3b92b9 | ||
|
|
29fc579275 | ||
|
|
d7a6e17531 | ||
|
|
c7a66c22b6 | ||
|
|
608872bf06 | ||
|
|
d3f0f57f6d | ||
|
|
b7ded69a7d | ||
|
|
b2ff255e32 | ||
|
|
369fc07b62 | ||
|
|
69d721055d | ||
|
|
3c1a603b90 | ||
|
|
07ca14ea34 | ||
|
|
14fb3563c5 | ||
|
|
942f8de3b7 | ||
|
|
815258c19b | ||
|
|
773196d689 |
@@ -17,7 +17,12 @@
|
||||
/data/
|
||||
/logs/
|
||||
/.e2e-data/
|
||||
/.tmp/
|
||||
/release/
|
||||
/samples/python-task/.venv/
|
||||
|
||||
# 字体二进制体积大(约 6.7 MB),不入库;用 tools/build-fonts.py 重新生成
|
||||
/frontend/public/fonts/LXGWWenKai-*.woff2
|
||||
|
||||
# Editors and operating system files
|
||||
/.idea/
|
||||
|
||||
@@ -146,8 +146,6 @@ Task
|
||||
├── timezone 默认使用系统时区,可选择 IANA 时区
|
||||
├── enabled 是否参与自动调度
|
||||
├── timeout_secs 单次执行超时
|
||||
├── concurrency_policy 首版固定为 skip
|
||||
├── retry_policy 首版允许 0 次重试,结构上预留
|
||||
├── action 带 type 标签的任务动作
|
||||
├── created_at
|
||||
└── updated_at
|
||||
@@ -462,3 +460,14 @@ cargo test --manifest-path backend/Cargo.toml
|
||||
4. 任务模板、导入导出和批量操作。
|
||||
5. 运行指标、告警和长期监控。
|
||||
6. HTTP 响应断言、认证方案和请求重放。
|
||||
|
||||
## 13. 已完成的后继迭代
|
||||
|
||||
计划阶段之外已经交付、并已写入 ADR 的改动:
|
||||
|
||||
- **本地 uv 管理**([ADR-0009](docs/decisions/0009-uv-and-managed-environments.md)):uv 只用程序目录副本或指定路径,
|
||||
不读系统 PATH;可从科大镜像下载并校验 sha256;`UV_*` 环境变量可配置,两个路径变量默认相对程序目录(`python` / `uv-cache`)。
|
||||
- **托管虚拟环境**:`data/envs/<名称>`、uv 下载的 Python 版本管理、预装包与已装包查看。
|
||||
- **环境时机调整**:新建任务保存时用 uv 创建 `.venv`,运行期只校验;运行任务会注入平台 uv、`UV_*` 与默认 `UV_DEFAULT_INDEX`。
|
||||
- **界面**:顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗(uv 与镜像源不再同页)。
|
||||
|
||||
|
||||
@@ -1,374 +1,664 @@
|
||||
# TaskPilot
|
||||
|
||||
TaskPilot 是一个面向 Windows 的本地定时任务管理平台。项目目标是用 React 提供任务管理界面,用 Rust 实现 Cron 调度、Windows 进程管理和运行日志。
|
||||
TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台:React 提供任务管理与观测界面,Rust 负责 Cron 调度、进程治理、运行日志与本地 API。
|
||||
|
||||
当前已完成 Phase 1 至 Phase 6:React 任务管理界面、Rust API、SQLite 持久化、Cron 调度、三类任务执行器、实时日志、安全边界、生产静态托管和 Windows 本地打包均已落地。
|
||||
- 单用户、单实例,只监听 `127.0.0.1`
|
||||
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
|
||||
- SQLite 持久化,重启后任务与历史仍在
|
||||
- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示
|
||||
- Python 包镜像管理:内置常用 PyPI 源,默认镜像在任务运行时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
|
||||
- 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体
|
||||
|
||||
仓库根目录的 `task_pilot.html` 是最初的单文件视觉原型,目前只作为 React 迁移期间的设计参考,不是正式前端入口。
|
||||
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。
|
||||
|
||||
React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标卡、任务表格、660px 编辑弹窗、Cron 预览区和深色日志查看器均在保留真实功能的前提下复刻。
|
||||
---
|
||||
|
||||
## 当前能力
|
||||
## 目录
|
||||
|
||||
- React 页面可以检测 Rust 后端连接状态。
|
||||
- Vite 开发服务器将 `/api` 代理到 Axum 后端。
|
||||
- Rust 服务提供健康检查和 OpenAPI 文档。
|
||||
- OpenAPI 可以导出为稳定快照,并生成前端 TypeScript 类型。
|
||||
- Cron POC 支持 5/6 位表达式、秒级调度、别名、月份/星期名称和 POSIX 日/星期语义。
|
||||
- 任务可以持久化到 SQLite,并支持创建、查询、更新和删除。
|
||||
- 任务列表支持名称/ID 搜索、启用状态筛选、Action 类型筛选和分页。
|
||||
- 脚本、HTTP 和二进制进程任务具有独立的类型化载荷与后端校验。
|
||||
- 启用的脚本任务由 Tokio 调度循环自动执行,并支持启动后恢复调度。
|
||||
- 后端重启后,已启用任务仍可持久化并重新注册。
|
||||
- 支持手动立即运行,重复触发时记录为 `skipped`,不会并发执行同一任务。
|
||||
- 每次运行记录触发来源、状态、耗时、退出码和错误信息。
|
||||
- 已实现 Windows Job Object,超时或关闭时会终止目标进程树。
|
||||
- stdout 和 stderr 会写入独立运行日志,并可通过 API 增量读取。
|
||||
- HTTP Action 支持 GET/POST、Header、Body、超时和期望状态码。
|
||||
- 二进制进程 Action 使用 Job Object 执行,并支持工作目录、参数和环境变量。
|
||||
- 运行中任务可以通过 API 或前端日志抽屉取消。
|
||||
- `/api/v1/events` 使用 SSE 推送运行状态和日志增量,前端断线时自动回退到轮询。
|
||||
- 运行历史支持按状态和触发方式筛选,并支持下载完整日志。
|
||||
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
|
||||
- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。
|
||||
- 编辑器会随工作目录实时探测虚拟环境,显示 `已关联虚拟环境: .venv\Scripts\python.exe` 并同步更新调用预览。
|
||||
- 工作目录支持「选择文件夹…」打开页面内的目录浏览器(盘符 → 逐级进入 → 使用此目录),选中后回填输入框,同时保留手动输入。
|
||||
- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。
|
||||
- 日志按系统 ANSI 代码页(如 GBK)解码非 UTF-8 输出,中文程序不再显示为 `����`,且长日志跨 64 KiB 分块读取不会丢失字符。
|
||||
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
|
||||
- React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。
|
||||
- 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。
|
||||
- Playwright 已覆盖运行日志、编辑、停用、删除和窄屏布局主流程。
|
||||
- 后端只监听 `127.0.0.1`,拒绝非本地 Origin。
|
||||
- 浏览器写操作需要运行期随机管理令牌,进程重启后自动更换。
|
||||
- Axum 在生产模式托管 React 静态资源,并支持 SPA 路由回退。
|
||||
- 弹窗和日志抽屉支持初始焦点、Tab 循环、Escape 关闭和焦点恢复。
|
||||
- 提供 Windows Release 打包和一键启动脚本。
|
||||
- 前端与后端均具备基础测试和统一质量检查命令。
|
||||
- [快速开始](#快速开始)
|
||||
- [功能总览](#功能总览)
|
||||
- [任务模型](#任务模型)
|
||||
- [调度与执行语义](#调度与执行语义)
|
||||
- [日志与编码](#日志与编码)
|
||||
- [Python 与虚拟环境](#python-与虚拟环境)
|
||||
- [Web 界面](#web-界面)
|
||||
- [安全模型](#安全模型)
|
||||
- [配置项](#配置项)
|
||||
- [API 参考](#api-参考)
|
||||
- [输入校验](#输入校验)
|
||||
- [数据与文件布局](#数据与文件布局)
|
||||
- [架构与模块](#架构与模块)
|
||||
- [开发与测试](#开发与测试)
|
||||
- [故障排查](#故障排查)
|
||||
- [已知限制](#已知限制)
|
||||
- [架构决策记录](#架构决策记录)
|
||||
|
||||
## 技术栈
|
||||
---
|
||||
|
||||
### 前端
|
||||
## 快速开始
|
||||
|
||||
- React 19
|
||||
- TypeScript 5.9
|
||||
- Vite 8
|
||||
- React Router
|
||||
- TanStack Query
|
||||
- React Hook Form
|
||||
- Zod
|
||||
- Vitest + Testing Library
|
||||
- Playwright
|
||||
|
||||
### 后端
|
||||
|
||||
- Rust 1.95.0 MSVC
|
||||
- Axum
|
||||
- Tokio
|
||||
- Serde
|
||||
- Utoipa / OpenAPI
|
||||
- Croner
|
||||
- SQLx / SQLite
|
||||
- Tower HTTP
|
||||
|
||||
### 工具链
|
||||
|
||||
- Volta 2.0.2
|
||||
- Node.js 24.12.0
|
||||
- pnpm 11.20.0
|
||||
|
||||
Node.js 和 pnpm 版本固定在 `frontend/package.json` 的 `volta` 字段中,Rust 版本固定在根目录的 `rust-toolchain.toml` 中。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Windows 10/11
|
||||
- Volta
|
||||
- Rustup
|
||||
- Visual Studio C++ Build Tools,供 Rust MSVC 工具链使用
|
||||
|
||||
首次使用 Volta 时会自动切换到项目固定版本:
|
||||
### 方式一:打包版(推荐)
|
||||
|
||||
```powershell
|
||||
volta --version
|
||||
node --version
|
||||
pnpm --version
|
||||
rustc --version
|
||||
.\scripts\build-release.ps1 # 构建前端 + Release 可执行文件 + 可分发目录
|
||||
.\release\TaskPilot\start.ps1 # 启动并访问 http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
`start.ps1 -OpenBrowser` 会自动打开浏览器。产物目录结构:
|
||||
|
||||
首次拉取项目后安装前端依赖:
|
||||
```text
|
||||
release/TaskPilot/
|
||||
├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管)
|
||||
├── web/ # 前端静态资源(字体可选)
|
||||
├── uv/ # 程序下载/自备的 uv.exe 放这里(uv 配置弹窗可切换来源)
|
||||
├── python/ # uv 下载的 Python 解释器(UV_PYTHON_INSTALL_DIR 默认相对值为 python)
|
||||
├── uv-cache/ # uv 缓存(UV_CACHE_DIR 默认相对值为 uv-cache,运行后才会出现)
|
||||
├── data/task_pilot.db # SQLite 数据库(首次启动创建)
|
||||
├── logs/{task_id}/{run_id}.log
|
||||
├── start.ps1
|
||||
└── README.md # 即 docs/RELEASE.md
|
||||
```
|
||||
|
||||
### 方式二:本地开发
|
||||
|
||||
```powershell
|
||||
# 前端依赖 @stash/ui(file:../../stash-ui),首次使用先在组件库构建 dist
|
||||
npm --prefix ..\stash-ui install
|
||||
npm --prefix ..\stash-ui run build
|
||||
|
||||
pnpm --dir frontend install
|
||||
cargo run --manifest-path backend/Cargo.toml --bin task-pilot-backend # 终端一:API,127.0.0.1:3000
|
||||
pnpm --dir frontend dev # 终端二:http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
终端一,启动 Rust API:
|
||||
Vite 把 `/api` 代理到后端。改后端端口时(`TASK_PILOT_PORT`)需同步修改
|
||||
`frontend/vite.config.ts` 的代理目标;打包版同源,不需要改前端。
|
||||
|
||||
`@stash/ui` 走 `file:` 依赖(pnpm 会复制一份构建产物):组件库重新 `npm run build`
|
||||
之后,需要再执行一次 `pnpm --dir frontend install` 才会同步新的 `dist`。
|
||||
|
||||
### 方式三:用 uv 跑官方示例与自检
|
||||
|
||||
仓库自带一个 uv 管理的演示任务和一个端到端自检脚本:
|
||||
|
||||
```powershell
|
||||
# 1) 在示例目录创建虚拟环境(Python 3.11)
|
||||
cd samples\python-task
|
||||
uv venv
|
||||
cd ..\..
|
||||
|
||||
# 2) 启动一个临时后端(任意终端)
|
||||
cargo run --manifest-path backend/Cargo.toml
|
||||
|
||||
# 3) 运行 14 项端到端自检
|
||||
uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
终端二,启动 React 前端:
|
||||
自检会真实创建任务、立即运行、读回日志,校验:健康检查、令牌、目录列举、
|
||||
虚拟环境探测、创建/调度注册、运行成功、中文日志、`VIRTUAL_ENV` 注入、
|
||||
无替换字符、统计口径、删除任务。
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend dev
|
||||
```text
|
||||
[OK ] venv probe links the demo environment status=linked path=.venv\Scripts\python.exe
|
||||
[OK ] task is registered in the schedule next_run_at=2026-10-25T19:00:00Z
|
||||
[OK ] run succeeded status=succeeded exit=0
|
||||
[OK ] log contains Chinese output
|
||||
14/14 checks passed
|
||||
```
|
||||
|
||||
访问 <http://127.0.0.1:5173>。Rust 服务默认监听 `127.0.0.1:3000`,可通过 `TASK_PILOT_PORT` 修改端口:
|
||||
`--keep` 保留任务,`--working-dir` 可换成任意含 `.venv` 的目录。
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_PORT = "3100"
|
||||
cargo run --manifest-path backend/Cargo.toml
|
||||
---
|
||||
|
||||
## 功能总览
|
||||
|
||||
| 分组 | 能力 |
|
||||
| --- | --- |
|
||||
| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选 |
|
||||
| 调度 | 5/6 位 Cron(支持秒级、别名、月份/星期名)、IANA 时区、启动恢复调度、下次触发时间 |
|
||||
| 执行 | 手动立即运行;同一任务重叠触发记为 `skipped`;启动时对账遗留运行 |
|
||||
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
||||
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
||||
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
||||
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、装包时走默认 PyPI 镜像 |
|
||||
| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务卡片 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
||||
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
||||
|
||||
---
|
||||
|
||||
## 任务模型
|
||||
|
||||
### 字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | 稳定标识,`[A-Za-z0-9_-]`,1–64 字符;同时决定日志目录名 |
|
||||
| `name` | 展示名,1–120 字符 |
|
||||
| `description` | 一句话说明,≤500 字符;显示在任务卡片副标题和详情里,可留空 |
|
||||
| `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` |
|
||||
| `timezone` | IANA 时区,例:`Asia/Shanghai` |
|
||||
| `enabled` | 是否注册调度 |
|
||||
| `timeout_secs` | 1–86400;超时终止整个进程树 |
|
||||
| `action` | 见下 |
|
||||
|
||||
### 三类 Action
|
||||
|
||||
```jsonc
|
||||
// 脚本(language 省略时按 interpreter 推断,旧任务兼容)
|
||||
{ "type": "script", "working_dir": "D:\\Projects\\sync", "venv": null,
|
||||
"language": "python", "interpreter": "python.exe", "script": "main.py",
|
||||
"args": ["--mode", "full"], "env": { "PYTHONUNBUFFERED": "1" } }
|
||||
|
||||
// Shell:cmd.exe /C run.bat;Node/Lua 同理
|
||||
{ "type": "script", "working_dir": "D:\\Projects\\ops", "language": "shell",
|
||||
"interpreter": "cmd.exe", "script": "run.bat", "args": [], "env": {} }
|
||||
|
||||
// HTTP
|
||||
{ "type": "http", "url": "https://api.example.com/health", "method": "GET",
|
||||
"headers": { "X-Token": "..." }, "body": null, "expected_status": [200, 204] }
|
||||
|
||||
// 进程
|
||||
{ "type": "process", "executable": "C:\\Tools\\agent.exe",
|
||||
"working_dir": "C:\\Tools", "args": ["--serve"], "env": {} }
|
||||
```
|
||||
|
||||
修改后端端口后,还需要同步调整 `frontend/vite.config.ts` 中的代理目标。
|
||||
### 运行状态机
|
||||
|
||||
## Windows Release
|
||||
|
||||
构建前端生产资源、Rust Release 可执行文件和可分发目录:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-release.ps1
|
||||
```text
|
||||
queued ──► running ──► succeeded / failed / timed_out / cancelled
|
||||
└────────────────► skipped (同一任务已有运行实例)
|
||||
```
|
||||
|
||||
产物位于 `release/TaskPilot/`。启动打包版本:
|
||||
终态不可再取消;进程被强杀后,下次启动会把遗留的 `queued`/`running`
|
||||
统一标记为 `cancelled`,并写入「TaskPilot 进程重启,运行被中断」。
|
||||
|
||||
```powershell
|
||||
.\release\TaskPilot\start.ps1
|
||||
```
|
||||
---
|
||||
|
||||
访问 <http://127.0.0.1:3000>。打包使用同源 API,因此 Release 模式修改 `TASK_PILOT_PORT` 后无需重新构建前端。
|
||||
## 调度与执行语义
|
||||
|
||||
打包目录中的 [RELEASE.md](docs/RELEASE.md) 会作为使用与故障排查说明一并复制。
|
||||
- **三类任务都参与调度**。启用即注册,停用即取消;后端重启后从数据库恢复注册。
|
||||
- 每个任务一个独立 Tokio 循环:计算下次触发 → 等待 → 调用执行器。
|
||||
- **重叠触发**:同一任务已有运行实例时,新触发记为 `skipped`,不会并发。
|
||||
- **没有全局并发上限**:10 个任务同时到点会各自启动一个进程。
|
||||
- **取消**:通过 `CancellationToken` + `TerminateJobObject`,5 秒内未退出则 `kill`。
|
||||
- **超时**:与取消同一路径,状态记为 `timed_out`。
|
||||
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
|
||||
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
|
||||
- **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。
|
||||
|
||||
SQLite 数据库默认创建在启动目录的 `data/task_pilot.db`。通过以下环境变量可以使用其他数据库路径:
|
||||
---
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_DATABASE_URL = "sqlite://data/custom.db"
|
||||
cargo run --manifest-path backend/Cargo.toml
|
||||
```
|
||||
## 日志与编码
|
||||
|
||||
数据库迁移会在后端启动时自动执行。
|
||||
文件布局:`logs/{task_id}/{run_id}.log`(UTF-8 写入)
|
||||
|
||||
运行日志默认写入 `logs/{task_id}/{run_id}.log`,可通过以下环境变量配置:
|
||||
- 每次运行 **5 MiB** 上限,超出后丢弃后续输出并写一条 `[system]` 说明。
|
||||
- **单行 256 KiB** 上限:超出部分丢弃、继续读到行尾,避免内存无界增长。
|
||||
- 保留期(默认 30 天)与总容量(默认 512 MiB)清理;**跳过正在写入的日志**。
|
||||
- 读取按 64 KiB 分块且对齐 UTF-8 边界;`complete` 只在"运行结束且读到文件末尾"时为真。
|
||||
- **合并推送**:子进程输出按行写入文件后,先在内存里按 **100 ms / 16 KiB** 窗口合并,
|
||||
每个窗口只发一条 SSE `log` 事件(偏移仍严格连续);低输出时由后台任务按时推,
|
||||
运行结束时在 `LogSink` 析构里兜底 flush,不会丢尾部。
|
||||
- **虚拟滚动**:日志抽屉只渲染视口附近的行(固定行高 20 px),几万行时 DOM 节点数恒定;
|
||||
增量先并进行缓冲、一帧最多重渲染一次。往上翻时停止跟随并显示「回到最新」。长行横向滚动、不折行。
|
||||
- **输出编码**:默认 `auto` = 逐行先按 UTF-8 解码,失败回退到系统 ANSI 代码页(简体中文为 GBK)。
|
||||
可用 `TASK_PILOT_LOG_ENCODING=gbk|utf-8|windows-1251` 强制。
|
||||
- **脱敏**:环境变量/Header 名包含 `SECRET`、`TOKEN`、`PASSWORD`、`PASS`、`API_KEY`、
|
||||
`PRIVATE_KEY`、`CREDENTIAL`、`AUTHORIZATION`、`AUTH` 时,其值在日志中被替换为 `[REDACTED]`。
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_LOG_DIR = "logs"
|
||||
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
|
||||
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
|
||||
$env:TASK_PILOT_LOG_ENCODING = "auto"
|
||||
```
|
||||
---
|
||||
|
||||
`TASK_PILOT_LOG_ENCODING` 默认为 `auto`:逐行先按 UTF-8 解码,失败时回退到系统 ANSI 代码页(简体中文为 GBK),保证中文程序输出不会变成 `����`。需要强制某一种编码时可填写 `gbk`、`utf-8`、`windows-1251` 等标签。
|
||||
## Python 与虚拟环境
|
||||
|
||||
单次运行日志达到 5 MiB 后会停止记录后续输出。程序启动和每次运行结束时会清理过期日志,并在总容量超限时优先删除最旧日志。
|
||||
编辑器和执行器共用同一份解析逻辑:
|
||||
|
||||
## API
|
||||
1. 手动指定的 `action.venv`
|
||||
2. 从解释器路径推断(`...\.venv\Scripts\python.exe`)
|
||||
3. 从工作目录向上查找 `.venv`、`venv`、`env`
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
虚拟环境必须同时存在 `pyvenv.cfg` 与 `Scripts\python.exe`,否则显式配置在保存时被拒绝。
|
||||
|
||||
**没有虚拟环境时**:
|
||||
|
||||
- 先看用户自己的环境:任务里指定的 `venv`、解释器路径、工作目录向上查找,命中就直接用,不重复创建。
|
||||
- 都没有时,**新建任务时会用 uv 自动创建 `.venv`**:`pyproject.toml` 走 `uv sync`,
|
||||
仅有 `requirements*.txt` 走 `uv venv .venv` + `uv pip install -r`,纯标准库脚本(没有依赖文件)建一个空环境。
|
||||
保存按钮会显示「正在创建虚拟环境…」,环境问题当场暴露。手动路径是编辑器里的「用 uv 创建环境」按钮。
|
||||
- 运行期只做校验、不再自动装依赖:没有 `.venv` 的 Python 任务会直接失败并提示先创建环境。
|
||||
- 创建时会使用当前默认 PyPI 镜像:注入 `UV_DEFAULT_INDEX`(实际生效的是它)以及兼容用的 `UV_INDEX_URL` / `PIP_INDEX_URL`,不会绕过镜像设置。
|
||||
- uv 只用平台认的两份:程序目录下的 `uv\uv.exe`(「程序目录 uv」,默认),或你指定的 `uv.exe`(「指定路径」)。
|
||||
**不读系统 PATH**——PATH 上的 uv 版本会随启动方式变化,不适合当调度器的隐式依赖。
|
||||
没有 uv 时在 uv 配置弹窗点「下载 uv」,从「下载镜像」(默认科大
|
||||
`mirrors.ustc.edu.cn/github-release`)下载当前架构的压缩包,
|
||||
校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去(弹窗状态条里会显示 uv 与解释器的下载地址);
|
||||
本机已经装了 uv 的话,把它填进「指定路径」即可。
|
||||
- **uv 的下载镜像不跟 PyPI 包索引混用**:它配在 uv 配置弹窗的「下载镜像」里(GitHub Release
|
||||
镜像根),uv 本体与 Python 解释器都从它派生(uv 地址 = `镜像根/astral-sh/uv/LatestRelease`,
|
||||
解释器地址 = `镜像根/astral-sh/python-build-standalone/releases/download`)。
|
||||
解释器这一步也可以单独覆盖:环境变量里就有一条 `UV_PYTHON_INSTALL_MIRROR`(默认跟随镜像根,
|
||||
改它的值即可换成别的发行源),它和 `UV_PYTHON_INSTALL_DIR`、`UV_CACHE_DIR` 一样是固定项,
|
||||
只能改值、不能删除。
|
||||
- uv 配置弹窗里还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`、`UV_HTTP_TIMEOUT`),
|
||||
会注入到每一次 uv 调用,并优先于镜像注入与进程环境。两个路径变量默认是**相对程序目录**的
|
||||
`python` / `uv-cache`(随包移动),也可以改成绝对路径。
|
||||
- Python 解释器的下载与查看也在 uv 配置弹窗里(`uv python install` / `uv python list`)——
|
||||
解释器属于 uv,只是建虚拟环境时要用;虚拟环境弹窗只从这份已下载列表里挑版本。
|
||||
- uv 不可用:保存任务时不会自动建环境,运行时会失败并提示先配置 uv。
|
||||
- 运行任务时会把平台 uv 目录加到 PATH 前面,并注入 `UV_PYTHON_INSTALL_DIR`、弹窗里配置的 `UV_*` 和默认 `UV_DEFAULT_INDEX`
|
||||
(任务自己配置的环境变量优先)。这样脚本里直接写 `uv ...` 用的也是平台这一份,装包默认走镜像;不会再注入 `PIP_*`。
|
||||
- 解释器由语言自动决定,界面不再显示解释器输入框:
|
||||
Python 始终使用虚拟环境里的 Python,Shell 使用 `cmd.exe`,Node 使用 `node.exe`,Lua 使用 `lua.exe`。
|
||||
自定义解释器仍可通过 API 的 `action.interpreter` 字段传入。
|
||||
|
||||
命中虚拟环境后注入:
|
||||
|
||||
| 变量 | 行为 |
|
||||
| --- | --- |
|
||||
| `PATH` | 虚拟环境 `Scripts` 目录置前,保留用户 PATH,按大小写不敏感去重 |
|
||||
| `VIRTUAL_ENV` / `VIRTUAL_ENV_PROMPT` | 指向虚拟环境根目录 |
|
||||
| `PYTHONUNBUFFERED` | 强制 `1`(实时日志依赖) |
|
||||
| `PYTHONIOENCODING` | 默认 `utf-8`,用户显式设置优先;避免 GBK 控制台下打印 emoji/生僻字符直接崩溃 |
|
||||
| `PYTHONHOME` / `PYTHONPATH` | 虚拟环境模式下清理继承的污染值 |
|
||||
|
||||
编辑器会实时显示 `● 已关联虚拟环境: .venv\Scripts\python.exe`,并同步更新调用预览。
|
||||
|
||||
---
|
||||
|
||||
## Web 界面
|
||||
|
||||
- **顶部品牌栏**:只保留品牌与副标题;引擎在线状态显示在左栏用户卡片底部(跟随健康检查)。
|
||||
页面级导航已去掉,配置都收到任务调度页的卡片里。
|
||||
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + **任务卡片**(每个任务一张卡片,一次取最多 100 条,不分页);
|
||||
右栏 uv(版本号 / 解释器数量 / 缓存目录 / Python 目录 / 下载镜像)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。
|
||||
- **任务卡片**:卡片顶部是任务名称(左侧主色竖线 + 整行标题,右上角是「详情」);内容区里是任务标识 + 类型标签、任务说明(没填时回退成执行目标)、Cron 与下次触发时间,以及状态和运行 / 日志 / 编辑 / 启停 / 删除。点「详情」打开只读详情弹窗(基本信息 / 执行配置 / 时间)。
|
||||
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
|
||||
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
|
||||
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
|
||||
缺少虚拟环境但项目有依赖文件时保存会自动用 uv 创建。解释器由语言自动选择,界面不再要求手填。
|
||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入;
|
||||
uv 的「指定路径」用同一个浏览器选文件(`include_files=true`,只列 `.exe`)。
|
||||
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||
- **uv 配置弹窗**:顶部状态条(版本 / 可执行文件 / uv 下载与解释器镜像地址 / 解释器与环境变量数量)+ 按配置顺序排的三段。
|
||||
① **uv 可执行文件**:单选「程序目录 uv / 指定路径」——程序目录只展示路径、不可编辑,缺失时才出现「下载 uv」;
|
||||
指定路径是可编辑输入框,右侧「浏览」(页面内文件浏览器挑 `uv.exe`,保存时校验能否运行);下面是「下载镜像」
|
||||
(GitHub Release 镜像根,uv 与解释器共用);② **环境变量**:`UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` 决定目录,
|
||||
`UV_PYTHON_INSTALL_MIRROR` 决定解释器下载源,这三项只能改值、不能删除(清空或重复会拦下保存),
|
||||
其余 `UV_*` 变量可增删;③ **Python 解释器**:按上面配置的目录列出已下载版本,填版本号即下载。
|
||||
底部关闭 / 保存设置;uv 来源不读系统 PATH。
|
||||
- **镜像源弹窗**:只放 PyPI 包索引镜像:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;
|
||||
可新增、编辑、启用停用、设为默认。包索引只在**装包**(建环境 / 预装依赖)时生效,任务运行时不注入镜像变量。
|
||||
uv 自己的下载镜像不在这里,在 uv 配置弹窗里。
|
||||
- **虚拟环境弹窗**:只负责**使用**已下载的解释器创建托管环境
|
||||
(`data\envs\<名称>`,从已下载版本里选 Python、可选预装包、查看已装包);解释器下载入口在 uv 配置弹窗。
|
||||
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
|
||||
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
|
||||
业务块(三栏工作台、任务卡片、日志控制台、表单栅格)。
|
||||
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
|
||||
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
|
||||
`tools/build-fonts.py` 生成,缺失时回退系统字体。
|
||||
|
||||
---
|
||||
|
||||
## 安全模型
|
||||
|
||||
| 边界 | 实现 |
|
||||
| --- | --- |
|
||||
| 监听地址 | 固定 `127.0.0.1`(`TASK_PILOT_PORT` 只改端口) |
|
||||
| Origin | 带 `Origin` 的请求必须是 `http://127.0.0.1:*` 或 `http://localhost:*`,否则 403 |
|
||||
| 管理令牌 | 非 GET/HEAD/OPTIONS 必须带 `X-TaskPilot-Token`;进程启动时随机生成,重启轮换 |
|
||||
| 比较方式 | 常量时间比较,避免时序侧信道 |
|
||||
| 请求体 | 上限 2 MiB |
|
||||
| 命令执行 | 参数以 argv 传递,不经过 Shell 拼接 |
|
||||
| 读取接口 | 只读接口(任务列表、日志、目录列举、统计)免令牌 |
|
||||
| 前端令牌 | 客户端缓存令牌;写请求 403 时丢弃缓存并重试一次 |
|
||||
|
||||
> 这是**本地单用户工具**的威胁模型:本机其他进程仍可调用只读接口。不要把端口暴露到局域网。
|
||||
|
||||
---
|
||||
|
||||
## 配置项
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/health` | 后端健康检查 |
|
||||
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
|
||||
| `TASK_PILOT_PORT` | `3000` | 监听端口(地址固定回环) |
|
||||
| `TASK_PILOT_DATABASE_URL` | `sqlite://data/task_pilot.db` | SQLite 路径;`sqlite::memory:` 用于测试 |
|
||||
| `TASK_PILOT_LOG_DIR` | `logs` | 运行日志根目录 |
|
||||
| `TASK_PILOT_STATIC_DIR` | `frontend/dist` | 生产静态资源目录(打包版由 `start.ps1` 设为 `web`) |
|
||||
| `TASK_PILOT_LOG_RETENTION_DAYS` | `30` | 日志保留天数 |
|
||||
| `TASK_PILOT_LOG_MAX_TOTAL_MB` | `512` | 日志总容量上限 |
|
||||
| `TASK_PILOT_LOG_ENCODING` | `auto` | 子进程输出编码;`auto` 为 UTF-8→系统代码页回退 |
|
||||
| `RUST_LOG` | `task_pilot_backend=debug,tower_http=debug` | 日志级别 |
|
||||
|
||||
数据库连接:SQLite 开启 `foreign_keys`、**WAL**、`busy_timeout=5s`、`synchronous=NORMAL`,最多 5 个连接。
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
### 系统
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/health` | 健康检查 |
|
||||
| `GET` | `/api/v1/session` | 获取当前进程的管理令牌 |
|
||||
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 |
|
||||
| `GET` | `/api/v1/system/directories` | 列出盘符或指定目录下的子文件夹 |
|
||||
| `GET` | `/api/v1/tasks` | 查询任务列表 |
|
||||
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
|
||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 |
|
||||
| `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) |
|
||||
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器);`include_files=true` 时一并返回文件 |
|
||||
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
|
||||
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
|
||||
|
||||
### Python 镜像
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/python/mirrors` | 镜像列表(默认项优先) |
|
||||
| `POST` | `/api/v1/python/mirrors` | 新增镜像 |
|
||||
| `GET` | `/api/v1/python/mirrors/{id}` | 镜像详情 |
|
||||
| `PUT` | `/api/v1/python/mirrors/{id}` | 更新名称 / 地址 / 信任主机 / 启用状态 |
|
||||
| `DELETE` | `/api/v1/python/mirrors/{id}` | 删除镜像 |
|
||||
| `POST` | `/api/v1/python/mirrors/{id}/default` | 设为默认(同一时间仅一个默认,并自动启用) |
|
||||
|
||||
### uv
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/uv` | 当前 uv 来源、可执行文件、版本、`UV_*` 环境变量与解析后的 `python_dir` / `cache_dir`、两个路径变量的相对默认值 |
|
||||
| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径、`extra_env` 和下载镜像根(`mirror_url`,空 = 内置默认);`custom` 会校验可运行 |
|
||||
| `POST` | `/api/v1/uv/download` | 从下载镜像取当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 |
|
||||
|
||||
### 托管虚拟环境
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/python/pythons` | uv 下载到平台目录(`UV_PYTHON_INSTALL_DIR`)的解释器(版本、路径) |
|
||||
| `POST` | `/api/v1/python/pythons` | 用 uv 下载指定 Python 版本(`version`,如 `3.12`),返回最新列表 |
|
||||
| `GET` | `/api/v1/python/envs` | 列出 `data/envs` 下的环境(名称、绝对路径、`pyvenv.cfg` 里的 Python 版本) |
|
||||
| `POST` | `/api/v1/python/envs` | 用 uv 创建环境(`name`、可选 `python_version`、可选 `packages` 预装) |
|
||||
| `GET` | `/api/v1/python/envs/{name}/packages` | 已安装的包(`uv pip list --format json`) |
|
||||
| `DELETE` | `/api/v1/python/envs/{name}` | 删除整个环境目录 |
|
||||
|
||||
### 任务与运行
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/tasks` | 任务列表(`q`、`enabled`、`action_type`、`page`、`page_size`) |
|
||||
| `POST` | `/api/v1/tasks` | 创建任务 |
|
||||
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
|
||||
| `PUT` | `/api/v1/tasks/{id}` | 完整更新任务 |
|
||||
| `DELETE` | `/api/v1/tasks/{id}` | 删除任务 |
|
||||
| `POST` | `/api/v1/tasks/{id}/enable` | 启用任务并注册调度 |
|
||||
| `POST` | `/api/v1/tasks/{id}/disable` | 停用任务并取消调度 |
|
||||
| `POST` | `/api/v1/tasks/{id}/run` | 手动立即执行 |
|
||||
| `GET` | `/api/v1/tasks/{id}/runs` | 查询任务运行历史 |
|
||||
| `GET` | `/api/v1/runs/{run_id}` | 查询单次运行状态 |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量读取运行日志 |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整运行日志 |
|
||||
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消正在运行的任务 |
|
||||
| `GET` | `/api/v1/events` | SSE 运行状态和日志事件 |
|
||||
| `GET` | `/api/v1/tasks/{id}` | 任务详情 |
|
||||
| `PUT` | `/api/v1/tasks/{id}` | 完整更新 |
|
||||
| `DELETE` | `/api/v1/tasks/{id}` | 删除(级联删除运行记录) |
|
||||
| `POST` | `/api/v1/tasks/{id}/enable` | 启用并注册调度 |
|
||||
| `POST` | `/api/v1/tasks/{id}/disable` | 停用并取消调度 |
|
||||
| `POST` | `/api/v1/tasks/{id}/run` | 手动立即执行(`202`) |
|
||||
| `GET` | `/api/v1/tasks/{id}/runs` | 运行历史(`status`、`trigger`、分页) |
|
||||
| `GET` | `/api/v1/runs/{run_id}` | 单次运行详情 |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量日志(`content`、`next_offset`、`complete`) |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整日志 |
|
||||
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消运行 |
|
||||
| `GET` | `/api/v1/events` | SSE:`run` / `log` / `lagged` / `ready` |
|
||||
|
||||
任务列表查询参数:
|
||||
### 示例
|
||||
|
||||
- `q`:按任务 ID 或名称搜索。
|
||||
- `enabled`:按启用状态筛选。
|
||||
- `action_type`:`script`、`http` 或 `process`。
|
||||
- `page`:页码,默认 `1`。
|
||||
- `page_size`:每页数量,默认 `20`,最大 `100`。
|
||||
创建任务:
|
||||
|
||||
运行历史查询额外支持 `status` 和 `trigger` 过滤。
|
||||
```http
|
||||
POST /api/v1/tasks
|
||||
X-TaskPilot-Token: <session token>
|
||||
Content-Type: application/json
|
||||
|
||||
健康检查示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "task-pilot-backend",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
创建脚本任务示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "sync_data",
|
||||
"name": "数据同步",
|
||||
"description": "把上游数据同步到本地仓库,失败时记录日志",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"enabled": true,
|
||||
"timeout_secs": 300,
|
||||
"retry_policy": {
|
||||
"max_attempts": 0,
|
||||
"delay_secs": 0
|
||||
},
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": "D:\\Projects\\sync",
|
||||
"interpreter": "python.exe",
|
||||
"script": "main.py",
|
||||
"args": ["--mode", "full"],
|
||||
"env": {
|
||||
"PYTHONUNBUFFERED": "1"
|
||||
}
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
校验失败和资源不存在时统一返回结构化错误:
|
||||
统计数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Cron 表达式无效",
|
||||
"field": "cron",
|
||||
"details": {}
|
||||
}
|
||||
{ "total": 12, "enabled": 9, "running": 1, "script": 7, "http": 3, "process": 2 }
|
||||
```
|
||||
|
||||
## 开发命令
|
||||
调度信息:
|
||||
|
||||
### 完整检查
|
||||
```json
|
||||
[ { "task_id": "sync_data", "next_run_at": "2026-10-26T18:00:00Z" } ]
|
||||
```
|
||||
|
||||
在项目根目录执行:
|
||||
统一错误结构:
|
||||
|
||||
```json
|
||||
{ "code": "VALIDATION_ERROR", "message": "Cron 表达式无效", "field": "cron", "details": {} }
|
||||
```
|
||||
|
||||
常见错误码:`VALIDATION_ERROR`、`INVALID_JSON`、`INVALID_QUERY`、`TASK_NOT_FOUND`、
|
||||
`RUN_NOT_FOUND`、`RUN_LOG_NOT_FOUND`、`RUN_NOT_CANCELLABLE`、`TASK_ID_CONFLICT`、
|
||||
`DIRECTORY_PATH_INVALID`、`DIRECTORY_NOT_FOUND`、`DIRECTORY_UNREADABLE`、`FORBIDDEN`、`INTERNAL_ERROR`。
|
||||
|
||||
---
|
||||
|
||||
## 输入校验
|
||||
|
||||
保存任务时逐项强制(失败返回 `400 VALIDATION_ERROR` 并带 `field`):
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `id` | 1–64,`[A-Za-z0-9_-]`;重复返回 `409 TASK_ID_CONFLICT` |
|
||||
| `name` | 去空格后 1–120 字符 |
|
||||
| `cron` | 可解析且存在未来触发点 |
|
||||
| `timezone` | 有效 IANA 名称 |
|
||||
| `timeout_secs` | 1–86400 |
|
||||
| `description` | ≤500 字符,可留空 |
|
||||
| `action.working_dir` / `script` | 非空 |
|
||||
| `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 |
|
||||
| `action.interpreter` | 可空;空时按语言默认(Python 为 `python.exe`,Shell 为 `cmd.exe`,Node 为 `node.exe`,Lua 为 `lua.exe`) |
|
||||
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
|
||||
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
|
||||
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
|
||||
| `action.url` | 可解析、限 `http`/`https`、必须有主机名 |
|
||||
| `action.headers` | 名称/值符合 HTTP 规范 |
|
||||
| `action.body` | ≤ 1 MiB |
|
||||
| `action.expected_status` | 每项 100–599 |
|
||||
| 请求体 | 未知字段直接拒绝(`deny_unknown_fields`) |
|
||||
|
||||
查询与读取:`page ≥ 1`、`page_size` 夹到 1–100、日志 `offset` 不得超过文件长度。
|
||||
|
||||
**不在保存时校验**:工作目录是否存在、脚本文件是否存在、解释器是否存在(虚拟环境除外)。
|
||||
这些在执行时才暴露,属于日志里的运行错误。
|
||||
|
||||
---
|
||||
|
||||
## 数据与文件布局
|
||||
|
||||
```text
|
||||
data/task_pilot.db # SQLite(WAL)
|
||||
data/envs/<名称>/ # 托管虚拟环境(「虚拟环境」弹窗创建)
|
||||
logs/{task_id}/{run_id}.log # UTF-8 运行日志
|
||||
uv/uv.exe # 平台 uv(下载或自备)
|
||||
python/<版本>/ # uv 下载的 Python 解释器
|
||||
uv-cache/ # uv 缓存(首次使用后出现)
|
||||
.tmp/ # check.ps1 的测试临时目录(已 gitignore)
|
||||
```
|
||||
|
||||
`tasks` 表:主键 `id`,索引 `updated_at`、`enabled`、`action_type`。
|
||||
`task_runs` 表:`task_id` 外键 `ON DELETE CASCADE`,`status`/`trigger_type` 带 CHECK 约束,
|
||||
索引 `(task_id, created_at DESC)` 与 `status`。
|
||||
`uv_settings` 表:单行配置(`source`、`custom_path`、`extra_env` JSON)。
|
||||
|
||||
---
|
||||
|
||||
## 架构与模块
|
||||
|
||||
```text
|
||||
React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + token)
|
||||
│
|
||||
├── tasks: routes → service(校验) → repository(SQL)
|
||||
├── runs : routes → repository
|
||||
├── Scheduler ──trigger──► TaskExecutor ──► Job Object
|
||||
└── broadcast(512) ──► SSE /api/v1/events
|
||||
```
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `api.rs` | 路由、OpenAPI 聚合、系统级接口(health/session/cron/stats/schedule) |
|
||||
| `tasks/` | 任务模型、校验、SQL、CRUD 路由 |
|
||||
| `runs/` | 运行记录、执行器、日志落盘、日志读取与 SSE |
|
||||
| `scheduler.rs` | 每任务一个调度循环、下次触发时间、注册与取消 |
|
||||
| `executor.rs` | 三类 Action 执行、并发闸门、超时/取消、日志脱敏与截断 |
|
||||
| `process_job.rs` | Windows Job Object(`KILL_ON_JOB_CLOSE`) |
|
||||
| `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入、用 uv 建环境 |
|
||||
| `python_envs.rs` | 托管虚拟环境与 uv Python 版本:`data/envs` 扫描、创建/删除、已装包 |
|
||||
| `uv.rs` | uv 来源解析与校验、镜像下载(sha256)、`UV_*` 环境变量与路径默认值 |
|
||||
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
|
||||
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器,可选返回文件) |
|
||||
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
|
||||
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
|
||||
|
||||
前端:
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
|
||||
| `components/AppHeader.tsx` | 顶部品牌栏 |
|
||||
| `pages/TaskDashboard.tsx` | 三栏工作台:概览/调度、任务卡片、uv/镜像/环境卡片与三个配置弹窗 |
|
||||
| `components/UvSettingsModal.tsx` | uv 弹窗:状态条、来源选择、下载镜像、Python 解释器下载与列表、`UV_*` 环境变量 |
|
||||
| `components/PythonEnvsSection.tsx` | 虚拟环境弹窗:用已下载解释器创建环境、环境列表、已装包 |
|
||||
| `components/PythonMirrorsSection.tsx` | 镜像源弹窗内容:列表、默认项、新增 / 编辑 / 删除 |
|
||||
| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) |
|
||||
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
|
||||
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器(`mode="file"` 时为文件选择器,用于挑 `uv.exe`) |
|
||||
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
|
||||
| `components/LogViewer.tsx` | 日志虚拟滚动、跟随底部/回到最新 |
|
||||
| `lib/logLines.ts` | 增量日志的行缓冲(残行拼接、CRLF 归一) |
|
||||
| `lib/logWindow.ts` | 可见行窗口计算 |
|
||||
| `api/client.ts` | 类型化客户端、令牌缓存、403 重试 |
|
||||
| `lib/taskForm.ts` | Zod 校验与表单 ↔ API 载荷映射 |
|
||||
|
||||
---
|
||||
|
||||
## 开发与测试
|
||||
|
||||
### 环境
|
||||
|
||||
- Windows 10/11
|
||||
- Rust 1.95(`rust-toolchain.toml` 固定,MSVC)
|
||||
- Node.js 24 / pnpm 11(`frontend/package.json` 的 `volta` 固定)
|
||||
- Visual Studio C++ Build Tools(Rust MSVC 需要)
|
||||
- 可选:`uv`(跑示例任务与自检脚本)
|
||||
|
||||
### 完整质量门
|
||||
|
||||
```powershell
|
||||
.\scripts\check.ps1
|
||||
```
|
||||
|
||||
该脚本依次运行:
|
||||
依次执行:前端 lint → 类型检查 → Vitest → 生产构建 → `cargo fmt --check` →
|
||||
`clippy -D warnings` → `cargo test` → Playwright(真实后端 + Vite + Chrome)。
|
||||
|
||||
- 前端 lint
|
||||
- TypeScript 类型检查
|
||||
- Vitest
|
||||
- 前端生产构建
|
||||
- Rust 格式检查
|
||||
- Rust Clippy
|
||||
- Rust 单元和集成测试
|
||||
- Playwright 浏览器端到端测试
|
||||
脚本会把 `TMP`/`TEMP` 指向仓库内 `.tmp/`,让测试不依赖系统临时目录的权限策略,
|
||||
结束时自动清理。
|
||||
|
||||
### 浏览器端到端测试
|
||||
### 单独命令
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend lint | typecheck | test | build
|
||||
cargo fmt --manifest-path backend/Cargo.toml -- --check
|
||||
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
|
||||
cargo test --manifest-path backend/Cargo.toml
|
||||
pnpm --dir frontend test:e2e
|
||||
```
|
||||
|
||||
端到端测试会清理并重建 `.e2e-data/`,启动临时 Rust 服务和 Vite,使用本机 Chrome 完成真实任务执行和页面操作。
|
||||
|
||||
### 生成接口契约和类型
|
||||
|
||||
修改后端 API DTO 或路由后执行:
|
||||
### 契约与类型
|
||||
|
||||
```powershell
|
||||
.\scripts\generate-api.ps1
|
||||
.\scripts\generate-api.ps1 # 更新 contracts/openapi.json 与 frontend/src/api/generated.ts
|
||||
```
|
||||
|
||||
该命令会更新:
|
||||
生成文件不要手工修改。
|
||||
|
||||
- `contracts/openapi.json`
|
||||
- `frontend/src/api/generated.ts`
|
||||
### 界面字体(可选)
|
||||
|
||||
生成文件不应手工修改。
|
||||
|
||||
### 单独检查
|
||||
字体二进制体积约 6.7 MB,**不纳入仓库**。首次克隆后如需与设计一致的中文界面:
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend lint
|
||||
pnpm --dir frontend typecheck
|
||||
pnpm --dir frontend test
|
||||
pnpm --dir frontend build
|
||||
|
||||
cargo fmt --manifest-path backend/Cargo.toml -- --check
|
||||
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
|
||||
cargo test --manifest-path backend/Cargo.toml
|
||||
uv run --with fonttools --with brotli tools/build-fonts.py
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
脚本会从 `lxgw-wenkai-webfont` 的 CDN 下载分包、合并并裁剪到 GB2312 + 中英文标点,
|
||||
输出到 `frontend/public/fonts/`(已被 `.gitignore` 忽略)。不生成也能正常运行,
|
||||
只是正文回退到系统字体;`Outfit-Medium.ttf`(55 KB)仍随仓库提供。
|
||||
|
||||
```text
|
||||
task_pilot/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── api.rs
|
||||
│ │ ├── cron.rs
|
||||
│ │ ├── database.rs
|
||||
│ │ ├── error.rs
|
||||
│ │ ├── lib.rs
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── process_job.rs
|
||||
│ │ ├── scheduler.rs
|
||||
│ │ ├── security.rs
|
||||
│ │ ├── state.rs
|
||||
│ │ ├── runs/
|
||||
│ │ └── tasks/
|
||||
│ ├── migrations/
|
||||
│ ├── tests/
|
||||
│ ├── Cargo.toml
|
||||
│ └── Cargo.lock
|
||||
├── contracts/
|
||||
│ └── openapi.json
|
||||
├── docs/
|
||||
│ └── decisions/
|
||||
├── frontend/
|
||||
│ ├── e2e/
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ ├── lib/
|
||||
│ │ └── pages/
|
||||
│ ├── package.json
|
||||
│ ├── playwright.config.ts
|
||||
│ ├── vite.config.ts
|
||||
│ └── vitest.config.ts
|
||||
├── scripts/
|
||||
│ ├── check.ps1
|
||||
│ ├── build-release.ps1
|
||||
│ ├── e2e.ps1
|
||||
│ ├── generate-api.ps1
|
||||
│ └── start.ps1
|
||||
├── task_pilot.html
|
||||
├── PLAN.md
|
||||
├── README.md
|
||||
└── rust-toolchain.toml
|
||||
```
|
||||
### 测试构成
|
||||
|
||||
架构决策记录:
|
||||
| 层 | 覆盖 |
|
||||
| --- | --- |
|
||||
| Rust 单元(78) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/镜像地址派生/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
||||
| Rust 集成(50) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置(含下载镜像)读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 |
|
||||
| 前端 Vitest(40) | 三栏工作台渲染、任务详情弹窗、uv 弹窗(保存来源与 `UV_*`、下载镜像、下载 uv、浏览选 `uv.exe`、下载 Python 版本)、镜像下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
||||
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
||||
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 现象 | 处理 |
|
||||
| --- | --- |
|
||||
| `start.ps1` 无法运行 | `powershell -ExecutionPolicy Bypass -File .\release\TaskPilot\start.ps1` |
|
||||
| 端口被占用 | `$env:TASK_PILOT_PORT = "3100"` 后重启 |
|
||||
| 任务不执行 | 确认已启用、Cron 与下次触发时间正确;查看运行历史里的错误信息 |
|
||||
| PowerShell 脚本任务报「不是数字签名」 | 执行策略拦未签名脚本:在参数里加 `-ExecutionPolicy Bypass`,或对脚本目录 `Unblock-File` |
|
||||
| 日志出现 `����` | 程序未按 UTF-8 输出:设置 `TASK_PILOT_LOG_ENCODING=gbk`(或让程序输出 UTF-8) |
|
||||
| 日志尾部缺失 | 单次运行超过 5 MiB 或单行超过 256 KiB,会截断并写 `[system]` 说明 |
|
||||
| 运行一直显示"运行中" | 已由启动对账修复;若仍出现,检查进程是否被系统挂起 |
|
||||
| 虚拟环境未生效 | 目录下需同时有 `pyvenv.cfg` 与 `Scripts\python.exe`;也可在表单里显式指定 |
|
||||
| `cargo test` 报临时目录拒绝访问 | 用 `.\scripts\check.ps1`(会把 TEMP 指向 `.tmp/`)或手动设置 `TEMP` 到可写目录 |
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 同一任务重叠触发时直接记为 `skipped`;没有全局并发上限与队列。
|
||||
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
||||
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
||||
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
||||
- Python 任务缺少虚拟环境时,新建任务会先用 uv 创建(需要网络与可写的 uv 缓存目录);运行期不会再自动装依赖。已有环境的项目不会被自动升级。
|
||||
- 托管虚拟环境只负责创建 / 删除 / 查看已装包,不会自动升级依赖;任务运行也不会改动它。
|
||||
- 默认缓存与 Python 目录在程序目录下(`uv-cache` / `python`),删除发布目录等于丢掉缓存和解释器;想复用全局目录就把 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR` 改成绝对路径。
|
||||
- 「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;网络不通时请自己下载 uv 放到程序同级 `uv` 目录或指定路径。
|
||||
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
||||
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
||||
- 错过的调度不会补跑。
|
||||
- 界面字体不入库(体积原因),需用 `tools/build-fonts.py` 生成;缺失时回退系统字体。
|
||||
|
||||
---
|
||||
|
||||
## 架构决策记录
|
||||
|
||||
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
|
||||
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
||||
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
||||
- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md)
|
||||
- [ADR-0005:由后端提供系统文件夹选择](docs/decisions/0005-folder-picker.md)
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 重试策略字段已持久化,但自动重试尚未实现。
|
||||
- HTTP 任务执行器尚未提供响应 Header 转换等高级断言,只校验状态码。
|
||||
- 敏感值脱敏基于变量名关键词和值替换,不是系统凭据保险箱。
|
||||
- Conda 环境不会按 venv 自动激活,目前需要显式填写其 Python 解释器路径。
|
||||
- 当前版本是本地单用户工具,不支持多用户账户和远程部署。
|
||||
- 尚未安装为 Windows 系统服务,关闭启动脚本后调度器也会停止。
|
||||
- 旧 `task_pilot.html` 暂时保留在根目录作为设计参考,正式入口为 React 应用。
|
||||
- [ADR-0006:视觉风格对齐主题并自托管字体](docs/decisions/0006-visual-theme-and-offline-fonts.md)
|
||||
- [ADR-0007:运行期可靠性优化](docs/decisions/0007-runtime-reliability.md)
|
||||
- [ADR-0008:日志合并推送与虚拟滚动](docs/decisions/0008-log-streaming-and-virtualization.md)
|
||||
- [ADR-0009:uv 与托管 Python 环境](docs/decisions/0009-uv-and-managed-environments.md)
|
||||
|
||||
## 开发计划
|
||||
|
||||
完整架构、数据模型、API 草案、阶段任务和验收标准见 [PLAN.md](PLAN.md)。六个开发阶段均已完成,后续进入维护、功能反馈和版本迭代。
|
||||
完整架构、数据模型、阶段任务与验收标准见 [PLAN.md](PLAN.md)。六个阶段均已完成,当前处于维护与迭代阶段。
|
||||
|
||||
Generated
+87
@@ -263,6 +263,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -318,6 +324,15 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croner"
|
||||
version = "4.0.1"
|
||||
@@ -455,6 +470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
@@ -553,6 +569,15 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -1234,6 +1259,18 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1835,6 +1872,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
@@ -2172,6 +2215,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -2187,6 +2231,7 @@ dependencies = [
|
||||
"utoipa-axum",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2450,6 +2495,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -2947,8 +2998,44 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f17fbc870bfac28c6228aed7d6e6df352e26bf7015c4313727e16a7a01ce5e"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"libc",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
@@ -14,6 +14,7 @@ futures-util = { version = "0.3.34", features = ["std"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
sha2 = "0.11.1"
|
||||
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] }
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.3", features = ["full"] }
|
||||
@@ -27,6 +28,7 @@ utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.3.0"
|
||||
uuid = { version = "1.28.0", features = ["v4"] }
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] }
|
||||
zip = { version = "9.4.1", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1.5"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cron TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
|
||||
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
|
||||
CHECK (concurrency_policy = 'skip'),
|
||||
retry_max_attempts INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (retry_max_attempts >= 0),
|
||||
retry_delay_secs INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (retry_delay_secs >= 0),
|
||||
action_type TEXT NOT NULL
|
||||
CHECK (action_type IN ('script', 'http', 'process')),
|
||||
action_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
|
||||
ON tasks(updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
|
||||
ON tasks(enabled);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
|
||||
ON tasks(action_type);
|
||||
@@ -0,0 +1,127 @@
|
||||
-- TaskPilot 基线结构:任务、运行记录、Python 镜像与 uv 设置。
|
||||
-- 这是全新起点(不再兼容旧库):任务不再持久化并发/重试策略,运行触发只有定时与手动。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
cron TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
|
||||
action_type TEXT NOT NULL
|
||||
CHECK (action_type IN ('script', 'http', 'process')),
|
||||
action_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
|
||||
ON tasks(updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
|
||||
ON tasks(enabled);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
|
||||
ON tasks(action_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL
|
||||
CHECK (trigger_type IN ('schedule', 'manual')),
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN (
|
||||
'queued', 'running', 'succeeded', 'failed',
|
||||
'timed_out', 'cancelled', 'skipped'
|
||||
)),
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
exit_code INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
|
||||
ON task_runs(task_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_status
|
||||
ON task_runs(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS python_mirrors (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
index_url TEXT NOT NULL,
|
||||
trusted_host TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 同一时间只允许一个默认镜像
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
|
||||
ON python_mirrors(is_default)
|
||||
WHERE is_default = 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
|
||||
ON python_mirrors(index_url);
|
||||
|
||||
INSERT OR IGNORE INTO python_mirrors
|
||||
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
||||
VALUES
|
||||
(
|
||||
'tsinghua',
|
||||
'清华 TUNA',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
NULL,
|
||||
1,
|
||||
1,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'aliyun',
|
||||
'阿里云',
|
||||
'https://mirrors.aliyun.com/pypi/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'ustc',
|
||||
'中科大',
|
||||
'https://pypi.mirrors.ustc.edu.cn/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'pypi',
|
||||
'PyPI 官方',
|
||||
'https://pypi.org/simple',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
);
|
||||
|
||||
-- uv 只认平台自带的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
|
||||
-- extra_env / mirror_url 分别存额外的 UV_* 环境变量和下载镜像。
|
||||
CREATE TABLE IF NOT EXISTS uv_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
|
||||
custom_path TEXT,
|
||||
extra_env TEXT NOT NULL DEFAULT '{}',
|
||||
mirror_url TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, extra_env, updated_at)
|
||||
VALUES (1, 'managed', NULL, '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
@@ -1,23 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL
|
||||
CHECK (trigger_type IN ('schedule', 'manual', 'retry')),
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN (
|
||||
'queued', 'running', 'succeeded', 'failed',
|
||||
'timed_out', 'cancelled', 'skipped'
|
||||
)),
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
exit_code INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
|
||||
ON task_runs(task_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_status
|
||||
ON task_runs(status);
|
||||
+148
-9
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
extract::{DefaultBodyLimit, State},
|
||||
http::{HeaderValue, Method, header},
|
||||
middleware,
|
||||
response::IntoResponse,
|
||||
@@ -18,7 +18,21 @@ use crate::{
|
||||
},
|
||||
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
|
||||
python_env::{
|
||||
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
|
||||
self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse,
|
||||
VenvProbeStatus, VenvSource,
|
||||
},
|
||||
python_envs::{
|
||||
__path_create_env, __path_delete_env, __path_install_python, __path_list_env_packages,
|
||||
__path_list_envs, __path_list_pythons, CreateManagedEnvRequest, InstallPythonRequest,
|
||||
ManagedEnvPackage, ManagedEnvSummary, ManagedPython,
|
||||
},
|
||||
python_mirrors::{
|
||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||
routes::{
|
||||
__path_create_python_mirror, __path_delete_python_mirror, __path_get_python_mirror,
|
||||
__path_list_python_mirrors, __path_set_default_python_mirror,
|
||||
__path_update_python_mirror,
|
||||
},
|
||||
},
|
||||
runs::{
|
||||
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
|
||||
@@ -31,14 +45,18 @@ use crate::{
|
||||
state::AppState,
|
||||
tasks::{
|
||||
model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, TaskAction,
|
||||
TaskActionType, TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
CreateTaskRequest, HttpMethod, ScheduledTaskResponse, ScriptLanguage, TaskAction,
|
||||
TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse, UpdateTaskRequest,
|
||||
},
|
||||
routes::{
|
||||
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
||||
__path_get_task, __path_list_tasks, __path_update_task,
|
||||
},
|
||||
},
|
||||
uv::{
|
||||
__path_download_uv, __path_get_uv_settings, __path_update_uv_settings,
|
||||
UpdateUvSettingsRequest, UvCandidate, UvSettingsResponse, UvSource,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
@@ -109,7 +127,10 @@ pub async fn preview_cron(
|
||||
chrono::Utc::now(),
|
||||
request.count,
|
||||
)
|
||||
.map_err(|error| ApiError::validation("expression", error.to_string()))?;
|
||||
.map_err(|error| {
|
||||
tracing::debug!(error = %error, "cron preview failed");
|
||||
ApiError::validation("expression", error.user_message())
|
||||
})?;
|
||||
Ok(Json(preview))
|
||||
}
|
||||
|
||||
@@ -124,21 +145,55 @@ pub async fn preview_cron(
|
||||
)
|
||||
)]
|
||||
pub async fn probe_python_venv(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<VenvProbeRequest>,
|
||||
) -> ApiResult<Json<VenvProbeResponse>> {
|
||||
tokio::task::spawn_blocking(move || python_env::probe_venv(&request))
|
||||
let uv = crate::uv::current(&state.pool).await.program();
|
||||
tokio::task::spawn_blocking(move || python_env::probe_venv(&request, &uv))
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/venv/create",
|
||||
tag = "system",
|
||||
request_body = CreatePythonVenvRequest,
|
||||
responses(
|
||||
(status = 200, description = "Created or reused project virtual environment", body = VenvProbeResponse),
|
||||
(status = 400, description = "Project cannot create a virtual environment", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_python_venv(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreatePythonVenvRequest>,
|
||||
) -> ApiResult<Json<VenvProbeResponse>> {
|
||||
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
|
||||
None
|
||||
});
|
||||
let uv = crate::uv::current(&state.pool).await;
|
||||
python_env::create_project_venv(
|
||||
&request.working_dir,
|
||||
mirror.as_ref(),
|
||||
&uv.program(),
|
||||
&uv.env(),
|
||||
)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/system/directories",
|
||||
tag = "system",
|
||||
params(ListDirectoriesQuery),
|
||||
responses(
|
||||
(status = 200, description = "Subdirectories of the requested path, or the drive list", body = DirectoryListingResponse),
|
||||
(status = 200, description = "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list", body = DirectoryListingResponse),
|
||||
(status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
@@ -162,6 +217,47 @@ pub async fn list_directories(
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/stats",
|
||||
tag = "tasks",
|
||||
responses((status = 200, description = "Dashboard counters", body = TaskStatsResponse))
|
||||
)]
|
||||
pub async fn task_stats(State(state): State<AppState>) -> ApiResult<Json<TaskStatsResponse>> {
|
||||
let counts = crate::tasks::repository::counts(&state.pool).await?;
|
||||
Ok(Json(TaskStatsResponse {
|
||||
total: counts.total,
|
||||
enabled: counts.enabled,
|
||||
running: u64::try_from(state.executor.running_count()).unwrap_or_default(),
|
||||
script: counts.script,
|
||||
http: counts.http,
|
||||
process: counts.process,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/schedule",
|
||||
tag = "tasks",
|
||||
responses(
|
||||
(status = 200, description = "Next trigger time per enabled task", body = Vec<ScheduledTaskResponse>)
|
||||
)
|
||||
)]
|
||||
pub async fn list_schedule(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<ScheduledTaskResponse>>> {
|
||||
let tasks = crate::tasks::repository::list_enabled_tasks(&state.pool).await?;
|
||||
Ok(Json(
|
||||
tasks
|
||||
.into_iter()
|
||||
.map(|task| ScheduledTaskResponse {
|
||||
next_run_at: state.scheduler.next_run_at(&task.id),
|
||||
task_id: task.id,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
@@ -174,7 +270,25 @@ pub async fn list_directories(
|
||||
session,
|
||||
preview_cron,
|
||||
probe_python_venv,
|
||||
create_python_venv,
|
||||
get_uv_settings,
|
||||
update_uv_settings,
|
||||
download_uv,
|
||||
list_python_mirrors,
|
||||
get_python_mirror,
|
||||
create_python_mirror,
|
||||
update_python_mirror,
|
||||
delete_python_mirror,
|
||||
set_default_python_mirror,
|
||||
list_envs,
|
||||
create_env,
|
||||
list_env_packages,
|
||||
delete_env,
|
||||
list_pythons,
|
||||
install_python,
|
||||
list_directories,
|
||||
task_stats,
|
||||
list_schedule,
|
||||
list_tasks,
|
||||
create_task,
|
||||
get_task,
|
||||
@@ -197,16 +311,30 @@ pub async fn list_directories(
|
||||
CronPreviewResponse,
|
||||
VenvProbeRequest,
|
||||
VenvProbeResponse,
|
||||
CreatePythonVenvRequest,
|
||||
VenvProbeStatus,
|
||||
VenvSource,
|
||||
InterpreterKind,
|
||||
PythonMirrorResponse,
|
||||
CreatePythonMirrorRequest,
|
||||
UpdatePythonMirrorRequest,
|
||||
UvSettingsResponse,
|
||||
UpdateUvSettingsRequest,
|
||||
UvCandidate,
|
||||
UvSource,
|
||||
ManagedEnvSummary,
|
||||
ManagedEnvPackage,
|
||||
CreateManagedEnvRequest,
|
||||
ManagedPython,
|
||||
InstallPythonRequest,
|
||||
DirectoryEntry,
|
||||
DirectoryListingResponse,
|
||||
TaskStatsResponse,
|
||||
ScheduledTaskResponse,
|
||||
ErrorResponse,
|
||||
TaskActionType,
|
||||
ScriptLanguage,
|
||||
HttpMethod,
|
||||
ConcurrencyPolicy,
|
||||
RetryPolicy,
|
||||
TaskAction,
|
||||
CreateTaskRequest,
|
||||
UpdateTaskRequest,
|
||||
@@ -251,11 +379,22 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/v1/python/venv",
|
||||
axum::routing::post(probe_python_venv),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/venv/create",
|
||||
axum::routing::post(create_python_venv),
|
||||
)
|
||||
.route("/api/v1/system/directories", get(list_directories))
|
||||
.route("/api/v1/stats", get(task_stats))
|
||||
.route("/api/v1/schedule", get(list_schedule))
|
||||
.route("/api/v1/openapi.json", get(openapi_json))
|
||||
.merge(crate::tasks::routes::router())
|
||||
.merge(crate::python_mirrors::routes::router())
|
||||
.merge(crate::python_envs::router())
|
||||
.merge(crate::uv::router())
|
||||
.merge(crate::runs::routes::router())
|
||||
.route("/api/{*path}", any(api_not_found))
|
||||
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
|
||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
|
||||
.layer(cors)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
|
||||
@@ -21,6 +21,27 @@ pub enum CronPreviewError {
|
||||
InvalidTimezone(String),
|
||||
}
|
||||
|
||||
impl CronPreviewError {
|
||||
/// 面向界面的中文说明。解析器原始错误(英文、带组件路径)只写日志,
|
||||
/// 不再直接抛给前端。
|
||||
pub fn user_message(&self) -> String {
|
||||
match self {
|
||||
Self::InvalidExpression(detail) => {
|
||||
if detail.contains("Invalid step syntax") {
|
||||
"Cron 表达式步长写法不正确:不要用「0/2」这种单数字步长,通配步长写「*/2」,范围步长写「1-10/2」"
|
||||
.to_owned()
|
||||
} else {
|
||||
"Cron 表达式无效,请检查字段取值与步长写法(通配步长用 */n,范围步长用 a-b/n)"
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
Self::TooManyRuns => format!("一次最多预览 {MAX_PREVIEW_RUNS} 次运行"),
|
||||
Self::NoFutureOccurrence => "Cron 表达式没有可计算的未来执行时间".to_owned(),
|
||||
Self::InvalidTimezone(_) => "时区必须是有效的 IANA 时区名称".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CronPreviewRequest {
|
||||
|
||||
+16
-9
@@ -1,20 +1,27 @@
|
||||
use std::{error::Error, str::FromStr};
|
||||
use std::{error::Error, str::FromStr, time::Duration};
|
||||
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
migrate::MigrateError,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
|
||||
};
|
||||
|
||||
pub async fn connect(database_url: &str) -> Result<SqlitePool, Box<dyn Error>> {
|
||||
let options = SqliteConnectOptions::from_str(database_url)?
|
||||
let in_memory = database_url.contains(":memory:");
|
||||
let mut options = SqliteConnectOptions::from_str(database_url)?
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
let max_connections = if database_url == "sqlite::memory:" {
|
||||
1
|
||||
} else {
|
||||
5
|
||||
};
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
|
||||
// 运行日志和状态写入与前端查询会并发访问同一个库;WAL 允许读写并行,
|
||||
// busy_timeout 则避免瞬时冲突直接返回 "database is locked"。
|
||||
if !in_memory {
|
||||
options = options
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal);
|
||||
}
|
||||
|
||||
let max_connections = if in_memory { 1 } else { 5 };
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect_with(options)
|
||||
|
||||
@@ -14,6 +14,9 @@ pub const MAX_ENTRIES: usize = 500;
|
||||
pub struct ListDirectoriesQuery {
|
||||
/// 绝对目录路径;留空表示列出盘符
|
||||
pub path: Option<String>,
|
||||
/// 是否一并返回文件(挑选可执行文件时用);默认只返回目录
|
||||
#[serde(default)]
|
||||
pub include_files: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
@@ -22,6 +25,8 @@ pub struct DirectoryEntry {
|
||||
pub name: String,
|
||||
/// 绝对路径,选中后回填到表单
|
||||
pub path: String,
|
||||
/// true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
@@ -79,6 +84,7 @@ pub fn list_directories(
|
||||
DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display()))
|
||||
})?;
|
||||
|
||||
let include_files = query.include_files.unwrap_or(false);
|
||||
let mut entries = Vec::new();
|
||||
let mut truncated = false;
|
||||
for entry in reader.flatten() {
|
||||
@@ -87,19 +93,26 @@ pub fn list_directories(
|
||||
.file_type()
|
||||
.map(|kind| kind.is_dir())
|
||||
.unwrap_or_else(|_| entry_path.is_dir());
|
||||
if !is_directory {
|
||||
if !is_directory && !include_files {
|
||||
continue;
|
||||
}
|
||||
entries.push(DirectoryEntry {
|
||||
name: entry.file_name().to_string_lossy().into_owned(),
|
||||
path: entry_path.to_string_lossy().into_owned(),
|
||||
is_dir: is_directory,
|
||||
});
|
||||
if entries.len() >= MAX_ENTRIES {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
entries.sort_by_key(|entry| entry.name.to_lowercase());
|
||||
// 目录在前、文件在后,各自按名称排序:挑选文件时不用在一堆文件里找文件夹。
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.is_dir
|
||||
.cmp(&left.is_dir)
|
||||
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(DirectoryListingResponse {
|
||||
path: Some(directory.to_string_lossy().into_owned()),
|
||||
@@ -126,6 +139,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
|
||||
DirectoryEntry {
|
||||
name: format!("{letter}:\\"),
|
||||
path: format!("{letter}:\\"),
|
||||
is_dir: true,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -136,6 +150,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
|
||||
vec![DirectoryEntry {
|
||||
name: "/".to_owned(),
|
||||
path: "/".to_owned(),
|
||||
is_dir: true,
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -146,6 +161,14 @@ mod tests {
|
||||
fn query(path: Option<&str>) -> ListDirectoriesQuery {
|
||||
ListDirectoriesQuery {
|
||||
path: path.map(str::to_owned),
|
||||
include_files: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn query_with_files(path: Option<&str>) -> ListDirectoriesQuery {
|
||||
ListDirectoriesQuery {
|
||||
path: path.map(str::to_owned),
|
||||
include_files: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +187,7 @@ mod tests {
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec!["Alpha", "zeta"]);
|
||||
assert!(listing.entries.iter().all(|entry| entry.is_dir));
|
||||
assert_eq!(
|
||||
listing.parent.as_deref(),
|
||||
Some(temp.path().parent().unwrap().to_str().unwrap())
|
||||
@@ -171,6 +195,26 @@ mod tests {
|
||||
assert!(!listing.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_files_when_requested_after_directories() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
fs::create_dir(temp.path().join("bin")).unwrap();
|
||||
fs::write(temp.path().join("zeta.exe"), "x").unwrap();
|
||||
fs::write(temp.path().join("Alpha.txt"), "x").unwrap();
|
||||
|
||||
let listing =
|
||||
list_directories(&query_with_files(Some(temp.path().to_str().unwrap()))).unwrap();
|
||||
|
||||
let names = listing
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec!["bin", "Alpha.txt", "zeta.exe"]);
|
||||
assert!(listing.entries[0].is_dir);
|
||||
assert!(listing.entries[1..].iter().all(|entry| !entry.is_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_absolute_paths_for_entries() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -109,6 +109,36 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mirror_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "MIRROR_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn env_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "ENV_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mirror_conflict(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: "MIRROR_CONFLICT",
|
||||
message: message.into(),
|
||||
field: Some("index_url".to_owned()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
|
||||
@@ -5,11 +5,14 @@ pub mod directory_browser;
|
||||
pub mod error;
|
||||
pub mod process_job;
|
||||
pub mod python_env;
|
||||
pub mod python_envs;
|
||||
pub mod python_mirrors;
|
||||
pub mod runs;
|
||||
pub mod scheduler;
|
||||
pub mod security;
|
||||
pub mod state;
|
||||
pub mod tasks;
|
||||
pub mod uv;
|
||||
|
||||
use axum::Router;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
+412
-47
@@ -2,14 +2,20 @@ use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
ffi::OsString,
|
||||
io,
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
use tokio::{
|
||||
process::Command,
|
||||
time::{Duration, timeout},
|
||||
};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::python_mirrors::model::DefaultPythonMirror;
|
||||
|
||||
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -27,6 +33,28 @@ impl PreparedPython {
|
||||
}
|
||||
command.envs(&self.environment);
|
||||
}
|
||||
|
||||
/// 把 `directory` 插到 PATH 最前面,让任务脚本里的 `uv` 用平台副本。
|
||||
pub fn prepend_path(&mut self, directory: &Path) -> io::Result<()> {
|
||||
let base = self
|
||||
.environment
|
||||
.get("PATH")
|
||||
.map(OsString::from)
|
||||
.or_else(|| env::var_os("PATH"))
|
||||
.unwrap_or_default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut paths = Vec::new();
|
||||
for path in std::iter::once(directory.to_path_buf()).chain(env::split_paths(&base)) {
|
||||
let key = path.to_string_lossy().to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
let joined = env::join_paths(paths).map_err(io::Error::other)?;
|
||||
self.environment
|
||||
.insert("PATH".to_owned(), joined.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare_script_environment(
|
||||
@@ -79,7 +107,7 @@ pub fn prepare_script_environment(
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
||||
apply_stdio_defaults(&mut environment);
|
||||
return Ok(PreparedPython {
|
||||
interpreter: python,
|
||||
virtual_env: Some(venv.clone()),
|
||||
@@ -93,7 +121,7 @@ pub fn prepare_script_environment(
|
||||
});
|
||||
}
|
||||
|
||||
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
||||
apply_stdio_defaults(&mut environment);
|
||||
Ok(PreparedPython {
|
||||
interpreter: interpreter_path,
|
||||
virtual_env: None,
|
||||
@@ -102,6 +130,17 @@ pub fn prepare_script_environment(
|
||||
})
|
||||
}
|
||||
|
||||
/// Python 的标准流默认跟随系统代码页(简体中文为 GBK),一旦打印 emoji 或生僻字符
|
||||
/// 就会 `UnicodeEncodeError` 直接失败。这里默认改用 UTF-8,日志侧本来就优先按 UTF-8 解码。
|
||||
/// 用户在任务里显式设置的 `PYTHONIOENCODING` 仍然优先。
|
||||
fn apply_stdio_defaults(environment: &mut BTreeMap<String, String>) {
|
||||
// 实时日志依赖标准流不缓冲,这一项始终强制。
|
||||
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
||||
environment
|
||||
.entry("PYTHONIOENCODING".to_owned())
|
||||
.or_insert_with(|| "utf-8".to_owned());
|
||||
}
|
||||
|
||||
/// 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -219,76 +258,148 @@ pub struct VenvProbeResponse {
|
||||
pub display_path: Option<String>,
|
||||
pub source: Option<VenvSource>,
|
||||
pub message: String,
|
||||
/// 本机 PATH 中是否能找到 uv
|
||||
pub uv_available: bool,
|
||||
/// 工作目录根部是否存在 pyproject.toml
|
||||
pub has_pyproject: bool,
|
||||
/// 工作目录根部是否存在 requirements*.txt
|
||||
pub has_requirements: bool,
|
||||
/// 探测到的 requirements 文件名(仅根目录)
|
||||
pub requirements_files: Vec<String>,
|
||||
/// 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)
|
||||
pub can_create: bool,
|
||||
}
|
||||
|
||||
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
||||
/// 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreatePythonVenvRequest {
|
||||
pub working_dir: String,
|
||||
}
|
||||
|
||||
pub fn probe_venv(request: &VenvProbeRequest, uv: &Path) -> VenvProbeResponse {
|
||||
let working_dir = request.working_dir.trim();
|
||||
let interpreter = request.interpreter.trim();
|
||||
let interpreter = {
|
||||
let trimmed = request.interpreter.trim();
|
||||
if trimmed.is_empty() {
|
||||
"python.exe"
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
let configured_venv = request
|
||||
.venv
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let interpreter_kind = classify_interpreter(interpreter);
|
||||
let uv = crate::uv::probe_version(uv).is_some();
|
||||
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
|
||||
|
||||
if working_dir.is_empty() {
|
||||
return incomplete_probe(interpreter_kind, "请先填写工作目录");
|
||||
}
|
||||
if interpreter.is_empty() {
|
||||
return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序");
|
||||
return respond(incomplete_probe(interpreter_kind, "请先填写工作目录"));
|
||||
}
|
||||
if !matches!(interpreter_kind, InterpreterKind::Python) {
|
||||
return VenvProbeResponse {
|
||||
return respond(VenvProbeResponse {
|
||||
status: VenvProbeStatus::NotApplicable,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: Some(interpreter.to_owned()),
|
||||
source: None,
|
||||
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
|
||||
};
|
||||
..base_probe()
|
||||
});
|
||||
}
|
||||
|
||||
let working_directory = match absolute_path(Path::new(working_dir)) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}"));
|
||||
return respond(incomplete_probe(
|
||||
interpreter_kind,
|
||||
&format!("工作目录无效: {error}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !working_directory.is_dir() {
|
||||
return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境");
|
||||
return respond(incomplete_probe(
|
||||
interpreter_kind,
|
||||
"工作目录不存在,暂时无法探测虚拟环境",
|
||||
));
|
||||
}
|
||||
|
||||
match resolve_venv(working_dir, interpreter, configured_venv) {
|
||||
Ok(ResolvedVenv {
|
||||
venv: Some(venv),
|
||||
source,
|
||||
}) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Linked,
|
||||
interpreter_kind,
|
||||
display_path: Some(python_display_path(&venv, working_dir)),
|
||||
venv_path: Some(venv.to_string_lossy().into_owned()),
|
||||
source,
|
||||
message: venv_source_message(source),
|
||||
},
|
||||
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Missing,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: Some(interpreter.to_owned()),
|
||||
source: None,
|
||||
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
|
||||
},
|
||||
Err(error) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Invalid,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: error.to_string(),
|
||||
respond(
|
||||
match resolve_venv(working_dir, interpreter, configured_venv) {
|
||||
Ok(ResolvedVenv {
|
||||
venv: Some(venv),
|
||||
source,
|
||||
}) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Linked,
|
||||
interpreter_kind,
|
||||
display_path: Some(python_display_path(&venv, working_dir)),
|
||||
venv_path: Some(venv.to_string_lossy().into_owned()),
|
||||
source,
|
||||
message: venv_source_message(source),
|
||||
..base_probe()
|
||||
},
|
||||
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Missing,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: Some(interpreter.to_owned()),
|
||||
source: None,
|
||||
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
|
||||
..base_probe()
|
||||
},
|
||||
Err(error) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Invalid,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: error.to_string(),
|
||||
..base_probe()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn base_probe() -> VenvProbeResponse {
|
||||
VenvProbeResponse {
|
||||
status: VenvProbeStatus::Incomplete,
|
||||
interpreter_kind: InterpreterKind::Other,
|
||||
venv_path: None,
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: String::new(),
|
||||
uv_available: false,
|
||||
has_pyproject: false,
|
||||
has_requirements: false,
|
||||
requirements_files: Vec::new(),
|
||||
can_create: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_probe(
|
||||
mut response: VenvProbeResponse,
|
||||
working_dir: &str,
|
||||
uv_available: bool,
|
||||
) -> VenvProbeResponse {
|
||||
let files = scan_project_files(working_dir);
|
||||
response.uv_available = uv_available;
|
||||
response.has_pyproject = files.has_pyproject;
|
||||
response.has_requirements = !files.requirements.is_empty();
|
||||
response.requirements_files = files.requirements;
|
||||
response.can_create = response.status == VenvProbeStatus::Missing && uv_available;
|
||||
if response.status == VenvProbeStatus::Missing {
|
||||
response.message = if response.can_create {
|
||||
"未检测到虚拟环境,保存任务时会用 uv 自动创建(首次较慢)".to_owned()
|
||||
} else {
|
||||
"未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv".to_owned()
|
||||
};
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
|
||||
VenvProbeResponse {
|
||||
status: VenvProbeStatus::Incomplete,
|
||||
@@ -297,9 +408,168 @@ fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvPro
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: message.to_owned(),
|
||||
..base_probe()
|
||||
}
|
||||
}
|
||||
|
||||
/// 工作目录根部的 Python 依赖文件(不递归子目录)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProjectFiles {
|
||||
pub has_pyproject: bool,
|
||||
pub requirements: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
|
||||
let mut files = ProjectFiles::default();
|
||||
let Ok(entries) = fs::read_dir(working_dir) else {
|
||||
return files;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower == "pyproject.toml" {
|
||||
files.has_pyproject = true;
|
||||
} else if lower.starts_with("requirements") && lower.ends_with(".txt") {
|
||||
files.requirements.push(name);
|
||||
}
|
||||
}
|
||||
files.requirements.sort();
|
||||
files
|
||||
}
|
||||
|
||||
/// 用 uv 创建/同步项目虚拟环境。优先返回已有环境。
|
||||
pub async fn create_project_venv(
|
||||
working_dir: &str,
|
||||
mirror: Option<&DefaultPythonMirror>,
|
||||
uv: &Path,
|
||||
uv_env: &BTreeMap<String, String>,
|
||||
) -> Result<VenvProbeResponse, String> {
|
||||
let working_directory =
|
||||
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
|
||||
if !working_directory.is_dir() {
|
||||
return Err("工作目录不存在,无法创建虚拟环境".to_owned());
|
||||
}
|
||||
|
||||
let probe_request = VenvProbeRequest {
|
||||
working_dir: working_dir.to_owned(),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
venv: None,
|
||||
};
|
||||
let existing = probe_venv(&probe_request, uv);
|
||||
if existing.status == VenvProbeStatus::Linked {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let files = scan_project_files(working_dir);
|
||||
if crate::uv::probe_version(uv).is_none() {
|
||||
return Err(
|
||||
"uv 不可用,无法自动创建虚拟环境;请先在「uv 管理」中配置,或手动创建 .venv".to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
// 纯标准库脚本没有依赖文件:建一个空环境即可,运行期仍然走 .venv 里的 Python。
|
||||
if files.has_pyproject {
|
||||
run_uv(&working_directory, &["sync"], mirror, uv, uv_env).await?;
|
||||
} else {
|
||||
run_uv(&working_directory, &["venv", ".venv"], mirror, uv, uv_env).await?;
|
||||
let python = working_directory
|
||||
.join(".venv")
|
||||
.join("Scripts")
|
||||
.join("python.exe");
|
||||
let python = python.to_string_lossy().into_owned();
|
||||
for requirement in &files.requirements {
|
||||
run_uv(
|
||||
&working_directory,
|
||||
&["pip", "install", "--python", &python, "-r", requirement],
|
||||
mirror,
|
||||
uv,
|
||||
uv_env,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let probe = probe_venv(&probe_request, uv);
|
||||
if probe.status != VenvProbeStatus::Linked {
|
||||
return Err(format!(
|
||||
"uv 执行完成,但没有找到有效的 .venv:{}",
|
||||
probe.message
|
||||
));
|
||||
}
|
||||
Ok(probe)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_uv(
|
||||
working_dir: &Path,
|
||||
args: &[&str],
|
||||
mirror: Option<&DefaultPythonMirror>,
|
||||
uv: &Path,
|
||||
uv_env: &BTreeMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let mut command = Command::new(uv);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(mirror) = mirror {
|
||||
command
|
||||
.env("PIP_INDEX_URL", &mirror.index_url)
|
||||
.env("UV_INDEX_URL", &mirror.index_url)
|
||||
.env("UV_DEFAULT_INDEX", &mirror.index_url);
|
||||
if let Some(host) = mirror
|
||||
.trusted_host
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
command.env("PIP_TRUSTED_HOST", host);
|
||||
}
|
||||
}
|
||||
// 页面里配置的 UV_* 覆盖镜像注入与进程环境,用户显式设置优先。
|
||||
command.envs(uv_env);
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(0x0800_0000);
|
||||
|
||||
let output = timeout(Duration::from_secs(600), command.output())
|
||||
.await
|
||||
.map_err(|_| format!("uv {} 超时(600 秒)", args.join(" ")))?
|
||||
.map_err(|error| format!("无法启动 uv: {error}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success() {
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
stderr.trim()
|
||||
};
|
||||
return Err(format!(
|
||||
"uv {} 失败: {}",
|
||||
args.join(" "),
|
||||
truncate_output(detail)
|
||||
));
|
||||
}
|
||||
Ok(stdout.into_owned())
|
||||
}
|
||||
|
||||
fn truncate_output(value: &str) -> String {
|
||||
const MAX_CHARS: usize = 1200;
|
||||
if value.chars().count() <= MAX_CHARS {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut truncated = value.chars().take(MAX_CHARS).collect::<String>();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
|
||||
fn venv_source_message(source: Option<VenvSource>) -> String {
|
||||
match source {
|
||||
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
|
||||
@@ -461,7 +731,7 @@ fn absolute_path(path: &Path) -> io::Result<PathBuf> {
|
||||
std::path::absolute(path)
|
||||
}
|
||||
|
||||
fn is_python_interpreter(interpreter: &str) -> bool {
|
||||
pub fn is_python_interpreter(interpreter: &str) -> bool {
|
||||
let name = Path::new(interpreter)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
@@ -551,6 +821,30 @@ mod tests {
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepends_the_uv_directory_to_path() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut prepared = prepare_script_environment(
|
||||
temp.path().to_str().unwrap(),
|
||||
"python.exe",
|
||||
None,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let uv_directory = temp.path().join("uv");
|
||||
|
||||
prepared.prepend_path(&uv_directory).unwrap();
|
||||
|
||||
let paths: Vec<_> = env::split_paths(&prepared.environment["PATH"]).collect();
|
||||
assert_eq!(paths.first(), Some(&uv_directory));
|
||||
// 重复插入要去重
|
||||
prepared.prepend_path(&uv_directory).unwrap();
|
||||
assert_eq!(
|
||||
env::split_paths(&prepared.environment["PATH"]).count(),
|
||||
paths.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_python_interpreter_is_not_modified() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -630,12 +924,57 @@ mod tests {
|
||||
assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_utf8_stdio_for_python_tasks() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
let prepared = prepare_script_environment(
|
||||
temp.path().to_str().unwrap(),
|
||||
"python.exe",
|
||||
None,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.environment.get("PYTHONIOENCODING"),
|
||||
Some(&"utf-8".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.environment.get("PYTHONUNBUFFERED"),
|
||||
Some(&"1".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_user_pythonioencoding_override() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut user_env = BTreeMap::new();
|
||||
user_env.insert("PYTHONIOENCODING".to_owned(), "gbk".to_owned());
|
||||
|
||||
let prepared = prepare_script_environment(
|
||||
temp.path().to_str().unwrap(),
|
||||
"python.exe",
|
||||
None,
|
||||
&user_env,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.environment.get("PYTHONIOENCODING"),
|
||||
Some(&"gbk".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse {
|
||||
probe_venv(&VenvProbeRequest {
|
||||
working_dir: working_dir.to_owned(),
|
||||
interpreter: interpreter.to_owned(),
|
||||
venv: venv.map(str::to_owned),
|
||||
})
|
||||
probe_venv(
|
||||
&VenvProbeRequest {
|
||||
working_dir: working_dir.to_owned(),
|
||||
interpreter: interpreter.to_owned(),
|
||||
venv: venv.map(str::to_owned),
|
||||
},
|
||||
Path::new("uv"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -682,6 +1021,32 @@ mod tests {
|
||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
||||
assert!(response.venv_path.is_none());
|
||||
assert_eq!(response.can_create, response.uv_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_project_dependency_files_for_uv_creation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
fs::write(temp.path().join("pyproject.toml"), "[project]\n").unwrap();
|
||||
fs::write(temp.path().join("requirements-dev.txt"), "").unwrap();
|
||||
|
||||
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||
|
||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||
assert!(response.has_pyproject);
|
||||
assert!(response.has_requirements);
|
||||
assert_eq!(response.requirements_files, vec!["requirements-dev.txt"]);
|
||||
assert_eq!(response.can_create, response.uv_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_offers_uv_creation_even_without_dependency_files() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||
|
||||
// 纯标准库脚本也能建一个空环境,所以这里只看 uv 是否可用。
|
||||
assert_eq!(response.can_create, response.uv_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path as AxumPath, State},
|
||||
http::StatusCode,
|
||||
routing::{delete, get},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiJson, ApiResult},
|
||||
python_env::run_uv,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// 预创建的虚拟环境放在 `data/envs` 下,按目录名管理。
|
||||
const ENV_ROOT: &str = "data/envs";
|
||||
const MAX_NAME_CHARS: usize = 64;
|
||||
const MAX_PACKAGES: usize = 50;
|
||||
const MAX_PACKAGE_CHARS: usize = 200;
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ManagedEnvSummary {
|
||||
pub name: String,
|
||||
/// 虚拟环境根目录的绝对路径
|
||||
pub path: String,
|
||||
/// 取自 `pyvenv.cfg` 的 `version_info`
|
||||
pub python_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ManagedEnvPackage {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateManagedEnvRequest {
|
||||
/// 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识
|
||||
pub name: String,
|
||||
/// 创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定
|
||||
#[serde(default)]
|
||||
pub python_version: Option<String>,
|
||||
/// 预装包,每行一个,例如 `numpy==2.1.0`
|
||||
#[serde(default)]
|
||||
pub packages: Vec<String>,
|
||||
}
|
||||
|
||||
fn root() -> PathBuf {
|
||||
std::path::absolute(Path::new(ENV_ROOT)).unwrap_or_else(|_| PathBuf::from(ENV_ROOT))
|
||||
}
|
||||
|
||||
fn validate_name(raw: &str) -> ApiResult<String> {
|
||||
let name = raw.trim();
|
||||
let valid = !name.is_empty()
|
||||
&& name.chars().count() <= MAX_NAME_CHARS
|
||||
&& !name.starts_with('.')
|
||||
&& name.chars().all(|character| {
|
||||
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
|
||||
});
|
||||
if !valid {
|
||||
return Err(ApiError::validation(
|
||||
"name",
|
||||
"环境名只能包含字母、数字、点、下划线、连字符,且不能以点开头",
|
||||
));
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
fn validate_python_version(raw: Option<&str>) -> ApiResult<Option<String>> {
|
||||
let Some(value) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.len() > 16
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_digit() || character == '.')
|
||||
{
|
||||
return Err(ApiError::validation(
|
||||
"python_version",
|
||||
"Python 版本只填数字和点,例如 3.11 或 3.12.1",
|
||||
));
|
||||
}
|
||||
Ok(Some(value.to_owned()))
|
||||
}
|
||||
|
||||
fn validate_packages(packages: &[String]) -> ApiResult<Vec<String>> {
|
||||
if packages.len() > MAX_PACKAGES {
|
||||
return Err(ApiError::validation(
|
||||
"packages",
|
||||
format!("一次最多预装 {MAX_PACKAGES} 个包"),
|
||||
));
|
||||
}
|
||||
let mut cleaned = Vec::with_capacity(packages.len());
|
||||
for package in packages {
|
||||
let package = package.trim();
|
||||
if package.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// 以 - 开头会被 uv 当成参数,直接拒绝。
|
||||
if package.starts_with('-') {
|
||||
return Err(ApiError::validation(
|
||||
"packages",
|
||||
format!("包名不能以 - 开头: {package}"),
|
||||
));
|
||||
}
|
||||
if package.chars().count() > MAX_PACKAGE_CHARS
|
||||
|| package.chars().any(|character| character.is_control())
|
||||
{
|
||||
return Err(ApiError::validation(
|
||||
"packages",
|
||||
format!("包名过长或包含非法字符: {package}"),
|
||||
));
|
||||
}
|
||||
cleaned.push(package.to_owned());
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
fn read_summary(name: &str, path: &Path) -> Option<ManagedEnvSummary> {
|
||||
let python = path.join("Scripts").join("python.exe");
|
||||
let config = path.join("pyvenv.cfg");
|
||||
if !python.is_file() || !config.is_file() {
|
||||
return None;
|
||||
}
|
||||
let python_version = fs::read_to_string(&config).ok().and_then(|text| {
|
||||
text.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
(key.trim() == "version_info").then(|| value.trim().to_owned())
|
||||
})
|
||||
});
|
||||
Some(ManagedEnvSummary {
|
||||
name: name.to_owned(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
python_version,
|
||||
})
|
||||
}
|
||||
|
||||
fn env_path(name: &str) -> ApiResult<PathBuf> {
|
||||
let path = root().join(name);
|
||||
if !path.is_dir() {
|
||||
return Err(ApiError::env_not_found(format!("虚拟环境 {name} 不存在")));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/envs",
|
||||
tag = "python-envs",
|
||||
responses(
|
||||
(status = 200, description = "Managed virtual environments", body = Vec<ManagedEnvSummary>)
|
||||
)
|
||||
)]
|
||||
pub async fn list_envs() -> ApiResult<Json<Vec<ManagedEnvSummary>>> {
|
||||
let directory = root();
|
||||
let mut envs = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&directory) {
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if let Some(summary) = read_summary(&name, &entry.path()) {
|
||||
envs.push(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
envs.sort_by_key(|env| env.name.to_lowercase());
|
||||
Ok(Json(envs))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/envs",
|
||||
tag = "python-envs",
|
||||
request_body = CreateManagedEnvRequest,
|
||||
responses(
|
||||
(status = 201, description = "Virtual environment created", body = ManagedEnvSummary),
|
||||
(status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_env(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreateManagedEnvRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<ManagedEnvSummary>)> {
|
||||
let name = validate_name(&request.name)?;
|
||||
let python_version = validate_python_version(request.python_version.as_deref())?;
|
||||
let packages = validate_packages(&request.packages)?;
|
||||
let directory = root();
|
||||
let target = directory.join(&name);
|
||||
if target.exists() {
|
||||
return Err(ApiError::bad_request(
|
||||
"ENV_EXISTS",
|
||||
format!("虚拟环境 {name} 已存在,先删除再重建"),
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(&directory).map_err(ApiError::internal)?;
|
||||
|
||||
let uv = crate::uv::current(&state.pool).await;
|
||||
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(%error, "读取默认 Python 镜像失败,创建环境时改用 uv 自身配置");
|
||||
None
|
||||
});
|
||||
let program = uv.program();
|
||||
if crate::uv::probe_version(&program).is_none() {
|
||||
return Err(ApiError::bad_request(
|
||||
"UV_UNAVAILABLE",
|
||||
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
|
||||
));
|
||||
}
|
||||
let target_arg = target.to_string_lossy().into_owned();
|
||||
|
||||
let mut venv_args = vec!["venv".to_owned(), target_arg.clone()];
|
||||
if let Some(version) = python_version {
|
||||
venv_args.push("--python".to_owned());
|
||||
venv_args.push(version);
|
||||
}
|
||||
if let Err(message) = run_uv(&directory, &owned(&venv_args), None, &program, &uv.env()).await {
|
||||
let _ = fs::remove_dir_all(&target);
|
||||
return Err(ApiError::bad_request(
|
||||
"ENV_CREATE_FAILED",
|
||||
format!("创建虚拟环境失败: {message}"),
|
||||
));
|
||||
}
|
||||
|
||||
if !packages.is_empty() {
|
||||
let python = target.join("Scripts").join("python.exe");
|
||||
let mut install_args = vec![
|
||||
"pip".to_owned(),
|
||||
"install".to_owned(),
|
||||
"--python".to_owned(),
|
||||
python.to_string_lossy().into_owned(),
|
||||
];
|
||||
install_args.extend(packages.clone());
|
||||
if let Err(message) = run_uv(
|
||||
&target,
|
||||
&owned(&install_args),
|
||||
mirror.as_ref(),
|
||||
&program,
|
||||
&uv.env(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = fs::remove_dir_all(&target);
|
||||
return Err(ApiError::bad_request(
|
||||
"ENV_CREATE_FAILED",
|
||||
format!("安装预装包失败: {message}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let summary = read_summary(&name, &target).ok_or_else(|| {
|
||||
ApiError::bad_request("ENV_CREATE_FAILED", "环境创建后没有找到 python.exe")
|
||||
})?;
|
||||
Ok((StatusCode::CREATED, Json(summary)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/envs/{name}/packages",
|
||||
tag = "python-envs",
|
||||
params(("name" = String, Path, description = "Environment name")),
|
||||
responses(
|
||||
(status = 200, description = "Installed packages", body = Vec<ManagedEnvPackage>),
|
||||
(status = 404, description = "Environment not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn list_env_packages(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> ApiResult<Json<Vec<ManagedEnvPackage>>> {
|
||||
let name = validate_name(&name)?;
|
||||
let path = env_path(&name)?;
|
||||
let python = path.join("Scripts").join("python.exe");
|
||||
let uv = crate::uv::current(&state.pool).await;
|
||||
let program = uv.program();
|
||||
if crate::uv::probe_version(&program).is_none() {
|
||||
return Err(ApiError::bad_request(
|
||||
"UV_UNAVAILABLE",
|
||||
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
|
||||
));
|
||||
}
|
||||
let arguments = [
|
||||
"pip".to_owned(),
|
||||
"list".to_owned(),
|
||||
"--python".to_owned(),
|
||||
python.to_string_lossy().into_owned(),
|
||||
"--format".to_owned(),
|
||||
"json".to_owned(),
|
||||
];
|
||||
let stdout = run_uv(&path, &owned(&arguments), None, &program, &uv.env())
|
||||
.await
|
||||
.map_err(|message| {
|
||||
ApiError::bad_request(
|
||||
"ENV_PACKAGES_FAILED",
|
||||
format!("读取已安装包失败: {message}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let packages = serde_json::from_str::<Vec<serde_json::Value>>(&stdout)
|
||||
.map_err(ApiError::internal)?
|
||||
.into_iter()
|
||||
.filter_map(|value| {
|
||||
let name = value.get("name")?.as_str()?.to_owned();
|
||||
let version = value
|
||||
.get("version")?
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
Some(ManagedEnvPackage { name, version })
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(packages))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/python/envs/{name}",
|
||||
tag = "python-envs",
|
||||
params(("name" = String, Path, description = "Environment name")),
|
||||
responses(
|
||||
(status = 204, description = "Environment deleted"),
|
||||
(status = 404, description = "Environment not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_env(AxumPath(name): AxumPath<String>) -> ApiResult<StatusCode> {
|
||||
let name = validate_name(&name)?;
|
||||
let path = env_path(&name)?;
|
||||
fs::remove_dir_all(&path).map_err(ApiError::internal)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ManagedPython {
|
||||
pub version: String,
|
||||
/// uv 下载到平台目录下的解释器路径
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InstallPythonRequest {
|
||||
/// 要下载的版本,例如 `3.12` 或 `3.12.4`
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
fn version_key(version: &str) -> Vec<u32> {
|
||||
version
|
||||
.split('.')
|
||||
.map(|part| part.parse().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 只返回 uv 下载到平台目录里的解释器(忽略系统里已有的 Python)。
|
||||
async fn installed_pythons(pool: &sqlx::SqlitePool) -> Result<Vec<ManagedPython>, String> {
|
||||
let uv = crate::uv::current(pool).await;
|
||||
let program = uv.program();
|
||||
if crate::uv::probe_version(&program).is_none() {
|
||||
return Err("uv 不可用:请先在「Python 环境」页下载或指定 uv".to_owned());
|
||||
}
|
||||
let directory = uv.python_install_dir();
|
||||
fs::create_dir_all(&directory).map_err(|error| format!("无法创建 Python 目录: {error}"))?;
|
||||
let arguments = [
|
||||
"python".to_owned(),
|
||||
"list".to_owned(),
|
||||
"--only-installed".to_owned(),
|
||||
"--output-format".to_owned(),
|
||||
"json".to_owned(),
|
||||
];
|
||||
let stdout = run_uv(&directory, &owned(&arguments), None, &program, &uv.env()).await?;
|
||||
|
||||
let prefix = directory.to_string_lossy().to_lowercase();
|
||||
let mut pythons: Vec<ManagedPython> = serde_json::from_str::<Vec<serde_json::Value>>(&stdout)
|
||||
.map_err(|error| format!("解析 uv python list 失败: {error}"))?
|
||||
.into_iter()
|
||||
.filter_map(|value| {
|
||||
let version = value.get("version")?.as_str()?.to_owned();
|
||||
// uv 按自己的 cwd 输出相对路径(这里的 cwd 就是平台 Python 目录),先补成绝对路径。
|
||||
let path = Path::new(value.get("path")?.as_str()?);
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
directory.join(path)
|
||||
};
|
||||
let path = absolute.to_string_lossy().into_owned();
|
||||
path.to_lowercase()
|
||||
.starts_with(&prefix)
|
||||
.then_some(ManagedPython { version, path })
|
||||
})
|
||||
.collect();
|
||||
pythons.sort_by_key(|python| version_key(&python.version));
|
||||
pythons.dedup_by(|left, right| left.version == right.version);
|
||||
Ok(pythons)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/pythons",
|
||||
tag = "python-envs",
|
||||
responses(
|
||||
(status = 200, description = "Pythons downloaded by uv into the platform directory", body = Vec<ManagedPython>),
|
||||
(status = 400, description = "uv unavailable", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn list_pythons(State(state): State<AppState>) -> ApiResult<Json<Vec<ManagedPython>>> {
|
||||
let pythons = installed_pythons(&state.pool)
|
||||
.await
|
||||
.map_err(|message| ApiError::bad_request("UV_UNAVAILABLE", message))?;
|
||||
Ok(Json(pythons))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/pythons",
|
||||
tag = "python-envs",
|
||||
request_body = InstallPythonRequest,
|
||||
responses(
|
||||
(status = 200, description = "Python downloaded; returns the installed list", body = Vec<ManagedPython>),
|
||||
(status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn install_python(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<InstallPythonRequest>,
|
||||
) -> ApiResult<Json<Vec<ManagedPython>>> {
|
||||
let version = validate_python_version(Some(&request.version))?
|
||||
.ok_or_else(|| ApiError::validation("version", "请填写要下载的 Python 版本,例如 3.12"))?;
|
||||
let uv = crate::uv::current(&state.pool).await;
|
||||
let program = uv.program();
|
||||
if crate::uv::probe_version(&program).is_none() {
|
||||
return Err(ApiError::bad_request(
|
||||
"UV_UNAVAILABLE",
|
||||
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
|
||||
));
|
||||
}
|
||||
let directory = uv.python_install_dir();
|
||||
fs::create_dir_all(&directory).map_err(ApiError::internal)?;
|
||||
|
||||
let arguments = ["python".to_owned(), "install".to_owned(), version.clone()];
|
||||
run_uv(&directory, &owned(&arguments), None, &program, &uv.env())
|
||||
.await
|
||||
.map_err(|message| {
|
||||
ApiError::bad_request(
|
||||
"PYTHON_INSTALL_FAILED",
|
||||
format!("下载 Python {version} 失败: {message}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let pythons = installed_pythons(&state.pool)
|
||||
.await
|
||||
.map_err(|message| ApiError::bad_request("UV_UNAVAILABLE", message))?;
|
||||
Ok(Json(pythons))
|
||||
}
|
||||
|
||||
fn owned(arguments: &[String]) -> Vec<&str> {
|
||||
arguments.iter().map(String::as_str).collect()
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/python/envs", get(list_envs).post(create_env))
|
||||
.route(
|
||||
"/api/v1/python/envs/{name}/packages",
|
||||
get(list_env_packages),
|
||||
)
|
||||
.route("/api/v1/python/envs/{name}", delete(delete_env))
|
||||
.route(
|
||||
"/api/v1/python/pythons",
|
||||
get(list_pythons).post(install_python),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_safe_environment_names() {
|
||||
assert_eq!(validate_name(" data-science ").unwrap(), "data-science");
|
||||
assert_eq!(validate_name("py3.11_web").unwrap(), "py3.11_web");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_environment_names() {
|
||||
for name in ["", "..", ".hidden", "a/b", "a\\b", "a b", "环境"] {
|
||||
assert!(validate_name(name).is_err(), "{name} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_python_versions() {
|
||||
assert_eq!(
|
||||
validate_python_version(Some(" 3.11 ")).unwrap().as_deref(),
|
||||
Some("3.11")
|
||||
);
|
||||
assert!(validate_python_version(Some("latest")).is_err());
|
||||
assert!(validate_python_version(Some("3.11 --system")).is_err());
|
||||
assert!(validate_python_version(None).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_packages_that_look_like_flags() {
|
||||
assert!(validate_packages(&["--index-url=http://x".to_owned()]).is_err());
|
||||
assert!(validate_packages(&["numpy==2.1.0".to_owned()]).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod model;
|
||||
pub mod repository;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,52 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 一个 Python 包索引镜像(pip / uv)。
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct PythonMirrorResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL`
|
||||
pub index_url: String,
|
||||
/// pip 的 `--trusted-host`;https 源通常为空
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
/// 当前默认镜像;同一时间最多一个
|
||||
pub is_default: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreatePythonMirrorRequest {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
#[serde(default)]
|
||||
pub trusted_host: Option<String>,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdatePythonMirrorRequest {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
#[serde(default)]
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
const fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 执行器每次运行 Python 任务时读取的默认镜像。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefaultPythonMirror {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
pub trusted_host: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
python_mirrors::{
|
||||
model::{DefaultPythonMirror, PythonMirrorResponse},
|
||||
service::ValidatedMirror,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct MirrorRow {
|
||||
id: String,
|
||||
name: String,
|
||||
index_url: String,
|
||||
trusted_host: Option<String>,
|
||||
enabled: bool,
|
||||
is_default: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MirrorRow> for PythonMirrorResponse {
|
||||
fn from(row: MirrorRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
index_url: row.index_url,
|
||||
trusted_host: row.trusted_host,
|
||||
enabled: row.enabled,
|
||||
is_default: row.is_default,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> ApiResult<Vec<PythonMirrorResponse>> {
|
||||
let rows = sqlx::query_as::<_, MirrorRow>(
|
||||
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
|
||||
FROM python_mirrors
|
||||
ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
|
||||
let row = sqlx::query_as::<_, MirrorRow>(
|
||||
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
|
||||
FROM python_mirrors WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult<PythonMirrorResponse> {
|
||||
if index_url_exists(pool, &input.index_url, None).await? {
|
||||
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().simple().to_string();
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO python_mirrors
|
||||
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.index_url)
|
||||
.bind(input.trusted_host.as_deref())
|
||||
.bind(input.enabled)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(map_mirror_error)?;
|
||||
|
||||
get(pool, &id).await
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
input: ValidatedMirror,
|
||||
) -> ApiResult<PythonMirrorResponse> {
|
||||
if index_url_exists(pool, &input.index_url, Some(id)).await? {
|
||||
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
"UPDATE python_mirrors
|
||||
SET name = ?, index_url = ?, trusted_host = ?, enabled = ?,
|
||||
is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END,
|
||||
updated_at = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.index_url)
|
||||
.bind(input.trusted_host.as_deref())
|
||||
.bind(input.enabled)
|
||||
.bind(input.enabled)
|
||||
.bind(now)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(map_mirror_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
get(pool, id).await
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
|
||||
let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。
|
||||
pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
|
||||
let now = Utc::now();
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
transaction.rollback().await?;
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1")
|
||||
.bind(now)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
get(pool, id).await
|
||||
}
|
||||
|
||||
/// 执行器读取:只返回「启用且是默认」的镜像。
|
||||
pub async fn default_enabled(pool: &SqlitePool) -> ApiResult<Option<DefaultPythonMirror>> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT name, index_url, trusted_host
|
||||
FROM python_mirrors
|
||||
WHERE enabled = 1 AND is_default = 1
|
||||
LIMIT 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(
|
||||
row.map(|(name, index_url, trusted_host)| DefaultPythonMirror {
|
||||
name,
|
||||
index_url,
|
||||
trusted_host,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn index_url_exists(
|
||||
pool: &SqlitePool,
|
||||
index_url: &str,
|
||||
excluding_id: Option<&str>,
|
||||
) -> ApiResult<bool> {
|
||||
let existing: Option<(String,)> = match excluding_id {
|
||||
Some(id) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?",
|
||||
)
|
||||
.bind(index_url)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)")
|
||||
.bind(index_url)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(existing.is_some())
|
||||
}
|
||||
|
||||
fn map_mirror_error(error: sqlx::Error) -> ApiError {
|
||||
if let sqlx::Error::Database(database_error) = &error
|
||||
&& database_error.is_unique_violation()
|
||||
{
|
||||
return ApiError::mirror_conflict("该索引地址已经存在");
|
||||
}
|
||||
ApiError::from(error)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiJson, ApiResult},
|
||||
python_mirrors::{
|
||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||
repository, service,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/python/mirrors",
|
||||
get(list_python_mirrors).post(create_python_mirror),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/mirrors/{id}",
|
||||
get(get_python_mirror)
|
||||
.put(update_python_mirror)
|
||||
.delete(delete_python_mirror),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/mirrors/{id}/default",
|
||||
post(set_default_python_mirror),
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/mirrors",
|
||||
tag = "python-mirrors",
|
||||
responses(
|
||||
(status = 200, description = "Python package mirrors", body = Vec<PythonMirrorResponse>)
|
||||
)
|
||||
)]
|
||||
pub async fn list_python_mirrors(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<PythonMirrorResponse>>> {
|
||||
Ok(Json(repository::list(&state.pool).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/mirrors",
|
||||
tag = "python-mirrors",
|
||||
request_body = CreatePythonMirrorRequest,
|
||||
responses(
|
||||
(status = 201, description = "Mirror created", body = PythonMirrorResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreatePythonMirrorRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<PythonMirrorResponse>)> {
|
||||
let input = service::validate(
|
||||
&request.name,
|
||||
&request.index_url,
|
||||
request.trusted_host.as_deref(),
|
||||
request.enabled,
|
||||
)?;
|
||||
let mirror = repository::create(&state.pool, input).await?;
|
||||
Ok((StatusCode::CREATED, Json(mirror)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Mirror detail", body = PythonMirrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
Ok(Json(repository::get(&state.pool, &id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
request_body = UpdatePythonMirrorRequest,
|
||||
responses(
|
||||
(status = 200, description = "Mirror updated", body = PythonMirrorResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn update_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
ApiJson(request): ApiJson<UpdatePythonMirrorRequest>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
let input = service::validate(
|
||||
&request.name,
|
||||
&request.index_url,
|
||||
request.trusted_host.as_deref(),
|
||||
request.enabled,
|
||||
)?;
|
||||
Ok(Json(repository::update(&state.pool, &id, input).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 204, description = "Mirror deleted"),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
repository::delete(&state.pool, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/mirrors/{id}/default",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Default mirror updated", body = PythonMirrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn set_default_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
Ok(Json(repository::set_default(&state.pool, &id).await?))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use url::Url;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
|
||||
const MAX_NAME_CHARS: usize = 64;
|
||||
const MAX_URL_CHARS: usize = 2048;
|
||||
|
||||
/// 校验并规范化后的镜像输入。
|
||||
#[derive(Debug)]
|
||||
pub struct ValidatedMirror {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub fn validate(
|
||||
name: &str,
|
||||
index_url: &str,
|
||||
trusted_host: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> ApiResult<ValidatedMirror> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ApiError::validation("name", "请输入镜像名称"));
|
||||
}
|
||||
if name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(ApiError::validation(
|
||||
"name",
|
||||
format!("镜像名称不能超过 {MAX_NAME_CHARS} 个字符"),
|
||||
));
|
||||
}
|
||||
|
||||
let index_url = index_url.trim();
|
||||
if index_url.is_empty() {
|
||||
return Err(ApiError::validation("index_url", "请输入索引地址"));
|
||||
}
|
||||
if index_url.chars().count() > MAX_URL_CHARS {
|
||||
return Err(ApiError::validation("index_url", "索引地址过长"));
|
||||
}
|
||||
let parsed = Url::parse(index_url)
|
||||
.map_err(|_| ApiError::validation("index_url", "索引地址不是有效的 URL"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(ApiError::validation(
|
||||
"index_url",
|
||||
"索引地址只支持 http 或 https",
|
||||
));
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
return Err(ApiError::validation("index_url", "索引地址缺少主机名"));
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(ApiError::validation(
|
||||
"index_url",
|
||||
"索引地址不能包含用户名或密码",
|
||||
));
|
||||
}
|
||||
|
||||
let trusted_host = trusted_host
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
if value.contains("://") || value.contains('/') || value.contains(char::is_whitespace) {
|
||||
return Err(ApiError::validation(
|
||||
"trusted_host",
|
||||
"信任主机只填主机名,可带端口,例如 mirrors.aliyun.com",
|
||||
));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(ValidatedMirror {
|
||||
name: name.to_owned(),
|
||||
index_url: index_url.to_owned(),
|
||||
trusted_host,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
+459
-52
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
sync::{
|
||||
@@ -16,7 +16,7 @@ use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
|
||||
io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
|
||||
process::{Child, Command},
|
||||
sync::{Mutex as AsyncMutex, broadcast},
|
||||
time::{Duration, sleep, timeout},
|
||||
@@ -31,11 +31,16 @@ use crate::{
|
||||
model::{RunStatus, RunTrigger, TaskRunResponse},
|
||||
repository,
|
||||
},
|
||||
tasks::model::{HttpMethod, TaskAction, TaskResponse},
|
||||
tasks::model::{HttpMethod, ScriptLanguage, TaskAction, TaskResponse},
|
||||
};
|
||||
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
const MAX_LOG_LINE_BYTES: usize = 256 * 1024;
|
||||
const MAX_HTTP_LOG_BODY_BYTES: usize = 64 * 1024;
|
||||
/// 合并推送的日志事件上限:攒够这么多字节就立即发一个事件。
|
||||
const LOG_EVENT_BATCH_BYTES: usize = 16 * 1024;
|
||||
/// 合并推送的时间窗口:超过这个时间还没攒满也发出去,保证低输出时也有实时感。
|
||||
const LOG_EVENT_BATCH_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const TERMINATION_WAIT_SECS: u64 = 5;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DEFAULT_RETENTION_DAYS: u64 = 30;
|
||||
@@ -174,13 +179,28 @@ struct RunningHandle {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LogSink {
|
||||
file: Arc<AsyncMutex<tokio::fs::File>>,
|
||||
size: Arc<AtomicU64>,
|
||||
truncated: Arc<AtomicBool>,
|
||||
inner: Arc<LogSinkInner>,
|
||||
}
|
||||
|
||||
struct LogSinkInner {
|
||||
file: AsyncMutex<tokio::fs::File>,
|
||||
size: AtomicU64,
|
||||
truncated: AtomicBool,
|
||||
run_id: i64,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
secrets: Arc<Vec<String>>,
|
||||
secrets: Vec<String>,
|
||||
encoding: OutputEncoding,
|
||||
/// 已经写进文件、但还没通过 SSE 推送出去的字节。
|
||||
pending: Mutex<PendingBatch>,
|
||||
}
|
||||
|
||||
/// 等待合并推送的一段日志。`offset` 是这段内容在日志文件中的起始偏移。
|
||||
#[derive(Default)]
|
||||
struct PendingBatch {
|
||||
offset: u64,
|
||||
bytes: usize,
|
||||
opened_at: Option<Instant>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
struct ScriptOutcome {
|
||||
@@ -255,7 +275,7 @@ impl TaskExecutor {
|
||||
let run = match repository::create_queued(&self.inner.pool, &task.id, trigger).await {
|
||||
Ok(run) => run,
|
||||
Err(error) => {
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
remove_running_if(&self.inner.running, &task.id, None);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
@@ -301,6 +321,11 @@ impl TaskExecutor {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
/// 当前正在执行的任务数量(用于统计接口)。
|
||||
pub fn running_count(&self) -> usize {
|
||||
lock_running(&self.inner.running).len()
|
||||
}
|
||||
|
||||
pub fn log_path(&self, task_id: &str, run_id: i64) -> PathBuf {
|
||||
self.inner
|
||||
.logs_dir
|
||||
@@ -320,18 +345,44 @@ impl TaskExecutor {
|
||||
let _guard = self.inner.cleanup_lock.lock().await;
|
||||
let logs_dir = self.inner.logs_dir.clone();
|
||||
let config = self.inner.log_config.clone();
|
||||
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config))
|
||||
let active = self.active_log_paths();
|
||||
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config, &active))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
fn active_log_paths(&self) -> std::collections::HashSet<PathBuf> {
|
||||
lock_running(&self.inner.running)
|
||||
.iter()
|
||||
.filter_map(|(task_id, handle)| {
|
||||
handle.run_id.map(|run_id| {
|
||||
self.inner
|
||||
.logs_dir
|
||||
.join(task_id)
|
||||
.join(format!("{run_id}.log"))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn execute_run(&self, task: TaskResponse, run_id: i64, cancellation: CancellationToken) {
|
||||
let started_at = Utc::now();
|
||||
let started = Instant::now();
|
||||
if let Err(error) = repository::mark_running(&self.inner.pool, run_id, started_at).await {
|
||||
tracing::error!(run_id, error = %error, "failed to mark task run as running");
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
return;
|
||||
match repository::mark_running(&self.inner.pool, run_id, started_at).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::warn!(
|
||||
run_id,
|
||||
"run row disappeared before it could be marked running"
|
||||
);
|
||||
remove_running_if(&self.inner.running, &task.id, Some(run_id));
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(run_id, error = %error, "failed to mark task run as running");
|
||||
remove_running_if(&self.inner.running, &task.id, Some(run_id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.emit_run_by_id(run_id).await;
|
||||
|
||||
@@ -339,7 +390,7 @@ impl TaskExecutor {
|
||||
let finished_at = Utc::now();
|
||||
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
if let Err(error) = repository::complete(
|
||||
match repository::complete(
|
||||
&self.inner.pool,
|
||||
run_id,
|
||||
outcome.status,
|
||||
@@ -350,11 +401,19 @@ impl TaskExecutor {
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(run_id, error = %error, "failed to complete task run");
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::warn!(
|
||||
run_id,
|
||||
"run row disappeared before completion could be recorded"
|
||||
);
|
||||
}
|
||||
Err(error) => tracing::error!(run_id, error = %error, "failed to complete task run"),
|
||||
}
|
||||
|
||||
self.emit_run_by_id(run_id).await;
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
// 只清理属于自己的句柄:取消后立刻重跑时,旧 run 收尾不能删掉新 run 的闸门。
|
||||
remove_running_if(&self.inner.running, &task.id, Some(run_id));
|
||||
if let Err(error) = self.cleanup_logs().await {
|
||||
tracing::warn!(%error, "failed to clean task logs");
|
||||
}
|
||||
@@ -370,12 +429,15 @@ impl TaskExecutor {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
venv,
|
||||
language,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
let prepared = match prepare_script_environment(
|
||||
let script_language =
|
||||
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
|
||||
let mut prepared = match prepare_script_environment(
|
||||
working_dir,
|
||||
interpreter,
|
||||
venv.as_deref(),
|
||||
@@ -386,13 +448,58 @@ impl TaskExecutor {
|
||||
return failed_outcome(format!("准备 Python 运行环境失败: {error}"));
|
||||
}
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
|
||||
// 环境在保存任务时创建;运行期只做校验,避免跑到一半才开始装依赖。
|
||||
if prepared.virtual_env.is_none() && script_language == ScriptLanguage::Python {
|
||||
return failed_outcome(
|
||||
"项目还没有 .venv;请在编辑器里用 uv 创建虚拟环境(新建任务时会自动创建)",
|
||||
);
|
||||
}
|
||||
if let Some(venv) = &prepared.virtual_env {
|
||||
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
}
|
||||
|
||||
// 平台已经不依赖系统 PATH 的 uv:把平台副本放进任务进程的 PATH,
|
||||
// 脚本里直接写 `uv ...` 也能用,而且和平台是同一个版本。
|
||||
let uv = crate::uv::current(&self.inner.pool).await;
|
||||
if script_language == ScriptLanguage::Python
|
||||
&& let Some(directory) = uv.program().parent()
|
||||
&& let Err(error) = prepared.prepend_path(directory)
|
||||
{
|
||||
tracing::warn!(%error, "failed to prepend the managed uv directory to PATH");
|
||||
}
|
||||
|
||||
let mut command = Command::new(&prepared.interpreter);
|
||||
prepared.apply(&mut command);
|
||||
|
||||
// uv 的平台配置(UV_PYTHON_INSTALL_DIR、页面里配的 UV_*)和默认镜像也带进任务进程;
|
||||
// 任务自己配的环境变量优先。
|
||||
if script_language == ScriptLanguage::Python {
|
||||
for (key, value) in uv.env() {
|
||||
if !env_has(env, &key) {
|
||||
command.env(key, value);
|
||||
}
|
||||
}
|
||||
if let Ok(Some(mirror)) =
|
||||
crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||
{
|
||||
for key in ["UV_DEFAULT_INDEX", "UV_INDEX_URL"] {
|
||||
if !env_has(env, key) {
|
||||
command.env(key, &mirror.index_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if script_language == ScriptLanguage::Shell
|
||||
&& is_cmd_interpreter(interpreter)
|
||||
&& !script.trim_start().starts_with('/')
|
||||
{
|
||||
command.arg("/C");
|
||||
}
|
||||
command.arg(script).args(args).current_dir(working_dir);
|
||||
let startup_note = prepared
|
||||
.virtual_env
|
||||
.as_ref()
|
||||
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
|
||||
self.run_command(task, run_id, cancellation, command, startup_note)
|
||||
.await
|
||||
}
|
||||
@@ -426,7 +533,7 @@ impl TaskExecutor {
|
||||
let secrets = sensitive_values(&task.action);
|
||||
let sink = match LogSink::create(
|
||||
&log_path,
|
||||
task,
|
||||
&task.id,
|
||||
run_id,
|
||||
self.inner.events.clone(),
|
||||
secrets,
|
||||
@@ -591,7 +698,7 @@ impl TaskExecutor {
|
||||
let log_path = self.log_path(&task.id, run_id);
|
||||
let sink = match LogSink::create(
|
||||
&log_path,
|
||||
task,
|
||||
&task.id,
|
||||
run_id,
|
||||
self.inner.events.clone(),
|
||||
sensitive_values(&task.action),
|
||||
@@ -772,7 +879,7 @@ impl TaskExecutor {
|
||||
impl LogSink {
|
||||
async fn create(
|
||||
path: &Path,
|
||||
task: &TaskResponse,
|
||||
task_id: &str,
|
||||
run_id: i64,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
secrets: Vec<String>,
|
||||
@@ -782,8 +889,7 @@ impl LogSink {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let header = format!(
|
||||
"[system] TaskPilot run {run_id} for task {} started at {}\n",
|
||||
task.id,
|
||||
"[system] TaskPilot run {run_id} for task {task_id} started at {}\n",
|
||||
Utc::now().to_rfc3339()
|
||||
);
|
||||
tokio::fs::write(path, header.as_bytes()).await?;
|
||||
@@ -794,45 +900,59 @@ impl LogSink {
|
||||
let mut secrets = secrets;
|
||||
secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
|
||||
|
||||
Ok(Self {
|
||||
file: Arc::new(AsyncMutex::new(file)),
|
||||
size: Arc::new(AtomicU64::new(header.len() as u64)),
|
||||
truncated: Arc::new(AtomicBool::new(false)),
|
||||
let inner = Arc::new(LogSinkInner {
|
||||
file: AsyncMutex::new(file),
|
||||
size: AtomicU64::new(header.len() as u64),
|
||||
truncated: AtomicBool::new(false),
|
||||
run_id,
|
||||
events,
|
||||
secrets: Arc::new(secrets),
|
||||
secrets,
|
||||
encoding,
|
||||
})
|
||||
pending: Mutex::new(PendingBatch::default()),
|
||||
});
|
||||
spawn_pending_flusher(&inner);
|
||||
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
fn decode_output(&self, bytes: &[u8]) -> String {
|
||||
self.encoding.decode(bytes)
|
||||
self.inner.encoding.decode(bytes)
|
||||
}
|
||||
|
||||
async fn append(&self, text: &str) {
|
||||
let redacted = redact_text(text, &self.secrets);
|
||||
let redacted = redact_text(text, &self.inner.secrets);
|
||||
let bytes = redacted.as_bytes();
|
||||
let current = self.size.load(Ordering::Relaxed);
|
||||
let mut file = self.inner.file.lock().await;
|
||||
let current = self.inner.size.load(Ordering::Relaxed);
|
||||
if current + bytes.len() as u64 > MAX_LOG_BYTES {
|
||||
if !self.truncated.swap(true, Ordering::Relaxed) {
|
||||
if !self.inner.truncated.swap(true, Ordering::Relaxed) {
|
||||
let notice = format!("[system] 日志超过 {MAX_LOG_BYTES} 字节,后续输出已丢弃\n");
|
||||
let mut file = self.file.lock().await;
|
||||
let _ = file.write_all(notice.as_bytes()).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut file = self.file.lock().await;
|
||||
if file.write_all(bytes).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let next = self.size.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
|
||||
let _ = self.events.send(ExecutionEvent::Log {
|
||||
run_id: self.run_id,
|
||||
offset: current,
|
||||
next_offset: next,
|
||||
content: redacted,
|
||||
});
|
||||
let next = current + bytes.len() as u64;
|
||||
self.inner.size.store(next, Ordering::Relaxed);
|
||||
|
||||
// 文件写入是串行的,所以 pending 也在文件锁内更新,保证推送的偏移连续。
|
||||
let mut pending = lock_pending(&self.inner.pending);
|
||||
if pending.bytes == 0 {
|
||||
pending.offset = current;
|
||||
pending.opened_at = Some(Instant::now());
|
||||
}
|
||||
pending.bytes += bytes.len();
|
||||
pending.text.push_str(&redacted);
|
||||
|
||||
let batch_full = pending.bytes >= LOG_EVENT_BATCH_BYTES;
|
||||
let window_elapsed = pending
|
||||
.opened_at
|
||||
.is_some_and(|opened| opened.elapsed() >= LOG_EVENT_BATCH_INTERVAL);
|
||||
if batch_full || window_elapsed {
|
||||
emit_pending(&self.inner, &mut pending);
|
||||
}
|
||||
}
|
||||
|
||||
async fn system(&self, message: &str) {
|
||||
@@ -844,6 +964,57 @@ impl LogSink {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LogSinkInner {
|
||||
fn drop(&mut self) {
|
||||
let mut pending = lock_pending(&self.pending);
|
||||
emit_pending(self, &mut pending);
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_pending(pending: &Mutex<PendingBatch>) -> std::sync::MutexGuard<'_, PendingBatch> {
|
||||
pending
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// 把待推送的日志合并成一个事件发出去,并清空缓冲区。
|
||||
fn emit_pending(inner: &LogSinkInner, pending: &mut PendingBatch) {
|
||||
if pending.bytes == 0 {
|
||||
return;
|
||||
}
|
||||
let batch = std::mem::take(pending);
|
||||
let _ = inner.events.send(ExecutionEvent::Log {
|
||||
run_id: inner.run_id,
|
||||
offset: batch.offset,
|
||||
next_offset: batch.offset + batch.bytes as u64,
|
||||
content: batch.text,
|
||||
});
|
||||
}
|
||||
|
||||
fn flush_pending_if_due(inner: &LogSinkInner) {
|
||||
let mut pending = lock_pending(&inner.pending);
|
||||
let due = pending
|
||||
.opened_at
|
||||
.is_some_and(|opened| opened.elapsed() >= LOG_EVENT_BATCH_INTERVAL);
|
||||
if due {
|
||||
emit_pending(inner, &mut pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// 低输出量时不会触发"攒满即发",靠这个后台任务按时间窗口把日志推出去。
|
||||
fn spawn_pending_flusher(inner: &Arc<LogSinkInner>) {
|
||||
let weak = Arc::downgrade(inner);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
sleep(LOG_EVENT_BATCH_INTERVAL / 2).await;
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
break;
|
||||
};
|
||||
flush_pending_if_due(&inner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn lock_running(
|
||||
running: &Mutex<HashMap<String, RunningHandle>>,
|
||||
) -> std::sync::MutexGuard<'_, HashMap<String, RunningHandle>> {
|
||||
@@ -852,6 +1023,21 @@ fn lock_running(
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// 只在句柄仍属于指定 run 时移除;`None` 表示"刚插入、还没拿到 run id"的占位句柄。
|
||||
fn remove_running_if(
|
||||
running: &Mutex<HashMap<String, RunningHandle>>,
|
||||
task_id: &str,
|
||||
run_id: Option<i64>,
|
||||
) {
|
||||
let mut running = lock_running(running);
|
||||
if running
|
||||
.get(task_id)
|
||||
.is_some_and(|handle| handle.run_id == run_id)
|
||||
{
|
||||
running.remove(task_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_log_reader<R>(
|
||||
reader: R,
|
||||
stream: &'static str,
|
||||
@@ -864,12 +1050,17 @@ where
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_until(b'\n', &mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
match read_line_capped(&mut reader, &mut line, MAX_LOG_LINE_BYTES).await {
|
||||
Ok((0, _)) => break,
|
||||
Ok((_, truncated)) => {
|
||||
let text = sink.decode_output(&line);
|
||||
sink.append(&format!("[{stream}] {text}")).await;
|
||||
let mut payload = format!("[{stream}] {text}");
|
||||
if truncated {
|
||||
payload.push_str(&format!(
|
||||
"\n[system] 单行输出超过 {MAX_LOG_LINE_BYTES} 字节,已截断\n"
|
||||
));
|
||||
}
|
||||
sink.append(&payload).await;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(stream, %error, "failed to read task output");
|
||||
@@ -880,6 +1071,38 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// 读取一行,最多保留 `max` 字节。超出上限时丢弃剩余部分但继续读到换行,
|
||||
/// 既保住内存上限,也不会让后续输出错位。
|
||||
async fn read_line_capped<R>(
|
||||
reader: &mut R,
|
||||
buffer: &mut Vec<u8>,
|
||||
max: usize,
|
||||
) -> std::io::Result<(usize, bool)>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
{
|
||||
buffer.clear();
|
||||
let mut truncated = false;
|
||||
loop {
|
||||
let chunk = reader.fill_buf().await?;
|
||||
if chunk.is_empty() {
|
||||
return Ok((buffer.len(), truncated));
|
||||
}
|
||||
let newline = chunk.iter().position(|byte| *byte == b'\n');
|
||||
let take = newline.map_or(chunk.len(), |index| index + 1);
|
||||
let remaining = max.saturating_sub(buffer.len());
|
||||
let copied = take.min(remaining);
|
||||
buffer.extend_from_slice(&chunk[..copied]);
|
||||
if copied < take {
|
||||
truncated = true;
|
||||
}
|
||||
reader.consume(take);
|
||||
if newline.is_some() {
|
||||
return Ok((buffer.len(), truncated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_after_termination(child: &mut Child) {
|
||||
if timeout(Duration::from_secs(TERMINATION_WAIT_SECS), child.wait())
|
||||
.await
|
||||
@@ -889,6 +1112,18 @@ async fn wait_after_termination(child: &mut Child) {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_has(env: &BTreeMap<String, String>, key: &str) -> bool {
|
||||
env.keys()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
fn is_cmd_interpreter(interpreter: &str) -> bool {
|
||||
Path::new(interpreter)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.is_some_and(|stem| stem.eq_ignore_ascii_case("cmd"))
|
||||
}
|
||||
|
||||
fn sensitive_values(action: &TaskAction) -> Vec<String> {
|
||||
match action {
|
||||
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
|
||||
@@ -935,13 +1170,19 @@ fn failed_outcome(message: impl Into<String>) -> ScriptOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_logs_sync(root: &Path, config: &LogRetentionConfig) -> std::io::Result<()> {
|
||||
fn cleanup_logs_sync(
|
||||
root: &Path,
|
||||
config: &LogRetentionConfig,
|
||||
active: &std::collections::HashSet<PathBuf>,
|
||||
) -> std::io::Result<()> {
|
||||
if !root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_log_files(root, &mut files)?;
|
||||
// 正在写入的日志不参与保留期/容量清理。
|
||||
files.retain(|(path, _)| !active.contains(path));
|
||||
let retention = Duration::from_secs(config.retention_days.saturating_mul(86_400));
|
||||
let cutoff = SystemTime::now().checked_sub(retention);
|
||||
|
||||
@@ -1104,6 +1345,7 @@ mod tests {
|
||||
retention_days: 30,
|
||||
max_total_bytes: 10,
|
||||
},
|
||||
&std::collections::HashSet::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let total = std::fs::read_dir(temp.path())
|
||||
@@ -1112,4 +1354,169 @@ mod tests {
|
||||
.sum::<u64>();
|
||||
assert!(total <= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_skips_logs_of_running_tasks() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let active = temp.path().join("active.log");
|
||||
std::fs::write(&active, vec![b'x'; 32]).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let stale = temp.path().join("stale.log");
|
||||
std::fs::write(&stale, vec![b'x'; 32]).unwrap();
|
||||
|
||||
let mut protected = std::collections::HashSet::new();
|
||||
protected.insert(active.clone());
|
||||
cleanup_logs_sync(
|
||||
temp.path(),
|
||||
&LogRetentionConfig {
|
||||
retention_days: 0,
|
||||
max_total_bytes: 1,
|
||||
},
|
||||
&protected,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(active.exists(), "active log must survive cleanup");
|
||||
assert!(!stale.exists(), "stale log should be removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capped_line_reader_truncates_but_keeps_reading() {
|
||||
let payload = [b'a'; 64];
|
||||
let mut reader = BufReader::new(&payload[..]);
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
let (bytes, truncated) = read_line_capped(&mut reader, &mut buffer, 16)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(bytes, 16);
|
||||
assert!(truncated);
|
||||
|
||||
let (next, next_truncated) = read_line_capped(&mut reader, &mut buffer, 16)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(next, 0);
|
||||
assert!(!next_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_only_the_matching_run_handle() {
|
||||
let running = Mutex::new(HashMap::from([(
|
||||
"task".to_owned(),
|
||||
RunningHandle {
|
||||
run_id: Some(2),
|
||||
cancellation: CancellationToken::new(),
|
||||
},
|
||||
)]));
|
||||
|
||||
remove_running_if(&running, "task", Some(1));
|
||||
assert!(lock_running(&running).contains_key("task"));
|
||||
|
||||
remove_running_if(&running, "task", Some(2));
|
||||
assert!(!lock_running(&running).contains_key("task"));
|
||||
}
|
||||
|
||||
async fn test_sink() -> (
|
||||
LogSink,
|
||||
broadcast::Receiver<ExecutionEvent>,
|
||||
tempfile::TempDir,
|
||||
PathBuf,
|
||||
) {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join("run.log");
|
||||
let (events, receiver) = broadcast::channel(32);
|
||||
let sink = LogSink::create(
|
||||
&path,
|
||||
"task",
|
||||
7,
|
||||
events,
|
||||
Vec::new(),
|
||||
OutputEncoding::from_env(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(sink, receiver, temp, path)
|
||||
}
|
||||
|
||||
async fn recv_log(receiver: &mut broadcast::Receiver<ExecutionEvent>) -> (u64, u64, String) {
|
||||
let event = timeout(Duration::from_secs(2), receiver.recv())
|
||||
.await
|
||||
.expect("log event should arrive")
|
||||
.expect("log channel should stay open");
|
||||
match event {
|
||||
ExecutionEvent::Log {
|
||||
run_id,
|
||||
offset,
|
||||
next_offset,
|
||||
content,
|
||||
} => {
|
||||
assert_eq!(run_id, 7);
|
||||
(offset, next_offset, content)
|
||||
}
|
||||
other => panic!("expected log event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coalesces_rapid_output_into_a_single_log_event() {
|
||||
let (sink, mut receiver, _temp, path) = test_sink().await;
|
||||
|
||||
sink.append("first line\n").await;
|
||||
sink.append("second line\n").await;
|
||||
drop(sink);
|
||||
|
||||
let (offset, next_offset, content) = recv_log(&mut receiver).await;
|
||||
assert_eq!(content, "first line\nsecond line\n");
|
||||
assert_eq!(next_offset - offset, content.len() as u64);
|
||||
let on_disk = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(on_disk.ends_with(&content));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flushes_the_pending_batch_when_the_sink_is_dropped() {
|
||||
let (sink, mut receiver, _temp, _path) = test_sink().await;
|
||||
|
||||
sink.append("only line\n").await;
|
||||
drop(sink);
|
||||
|
||||
let (offset, next_offset, content) = recv_log(&mut receiver).await;
|
||||
assert_eq!(content, "only line\n");
|
||||
assert_eq!(next_offset - offset, content.len() as u64);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flushes_a_full_batch_without_waiting_for_the_window() {
|
||||
let (sink, mut receiver, _temp, _path) = test_sink().await;
|
||||
|
||||
sink.append(&"x".repeat(LOG_EVENT_BATCH_BYTES)).await;
|
||||
|
||||
let (_offset, _next_offset, content) = recv_log(&mut receiver).await;
|
||||
assert_eq!(content.len(), LOG_EVENT_BATCH_BYTES);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flushes_low_volume_output_within_the_time_window() {
|
||||
let (sink, mut receiver, _temp, _path) = test_sink().await;
|
||||
|
||||
sink.append("quiet output\n").await;
|
||||
|
||||
let (_offset, _next_offset, content) = recv_log(&mut receiver).await;
|
||||
assert_eq!(content, "quiet output\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batches_keep_contiguous_offsets_across_flushes() {
|
||||
let (sink, mut receiver, _temp, _path) = test_sink().await;
|
||||
|
||||
sink.append(&"x".repeat(LOG_EVENT_BATCH_BYTES)).await;
|
||||
let (first_offset, first_next, first_content) = recv_log(&mut receiver).await;
|
||||
sink.append("tail\n").await;
|
||||
let (second_offset, second_next, second_content) = recv_log(&mut receiver).await;
|
||||
|
||||
assert_eq!(first_next - first_offset, first_content.len() as u64);
|
||||
assert_eq!(second_offset, first_next);
|
||||
assert_eq!(second_next - second_offset, second_content.len() as u64);
|
||||
assert_eq!(second_content, "tail\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use utoipa::{IntoParams, ToSchema};
|
||||
pub enum RunTrigger {
|
||||
Schedule,
|
||||
Manual,
|
||||
Retry,
|
||||
}
|
||||
|
||||
impl RunTrigger {
|
||||
@@ -15,7 +14,6 @@ impl RunTrigger {
|
||||
match self {
|
||||
Self::Schedule => "schedule",
|
||||
Self::Manual => "manual",
|
||||
Self::Retry => "retry",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,20 +119,22 @@ pub async fn create_skipped(
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回是否真的更新到了运行记录;记录被删除(例如任务被删)时返回 false。
|
||||
pub async fn mark_running(
|
||||
pool: &SqlitePool,
|
||||
run_id: i64,
|
||||
started_at: DateTime<Utc>,
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query("UPDATE task_runs SET status = ?, started_at = ? WHERE id = ?")
|
||||
) -> ApiResult<bool> {
|
||||
let result = sqlx::query("UPDATE task_runs SET status = ?, started_at = ? WHERE id = ?")
|
||||
.bind(RunStatus::Running.as_str())
|
||||
.bind(started_at)
|
||||
.bind(run_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// 返回是否真的更新到了运行记录。
|
||||
pub async fn complete(
|
||||
pool: &SqlitePool,
|
||||
run_id: i64,
|
||||
@@ -141,8 +143,8 @@ pub async fn complete(
|
||||
duration_ms: u64,
|
||||
exit_code: Option<i32>,
|
||||
error_message: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query(
|
||||
) -> ApiResult<bool> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE task_runs SET
|
||||
status = ?, finished_at = ?, duration_ms = ?,
|
||||
exit_code = ?, error_message = ?
|
||||
@@ -156,7 +158,22 @@ pub async fn complete(
|
||||
.bind(run_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// 启动时收敛上一次进程遗留下来的非终态运行,避免界面永远显示"运行中"。
|
||||
pub async fn fail_interrupted_runs(pool: &SqlitePool) -> ApiResult<u64> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE task_runs SET status = ?, finished_at = ?, error_message = ?
|
||||
WHERE status IN ('queued', 'running')",
|
||||
)
|
||||
.bind(RunStatus::Cancelled.as_str())
|
||||
.bind(Utc::now())
|
||||
.bind("TaskPilot 进程重启,运行被中断")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, run_id: i64) -> ApiResult<Option<TaskRunResponse>> {
|
||||
@@ -238,7 +255,6 @@ fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
|
||||
match value {
|
||||
"schedule" => Ok(RunTrigger::Schedule),
|
||||
"manual" => Ok(RunTrigger::Manual),
|
||||
"retry" => Ok(RunTrigger::Retry),
|
||||
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
|
||||
}
|
||||
}
|
||||
|
||||
+52
-14
@@ -14,18 +14,23 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
runs::{executor::TaskExecutor, model::RunTrigger},
|
||||
tasks::{model::TaskAction, model::TaskResponse, repository},
|
||||
tasks::{model::TaskResponse, repository},
|
||||
};
|
||||
|
||||
struct ScheduledTask {
|
||||
cancellation: CancellationToken,
|
||||
handle: JoinHandle<()>,
|
||||
/// 只用来持有任务;取消时通过令牌让循环自行退出,不直接 abort。
|
||||
/// 字段以下划线开头是因为它只承担"保持任务存活"的作用。
|
||||
_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
type NextRuns = Arc<Mutex<HashMap<String, DateTime<Utc>>>>;
|
||||
|
||||
struct SchedulerInner {
|
||||
executor: TaskExecutor,
|
||||
shutdown: CancellationToken,
|
||||
jobs: Mutex<HashMap<String, ScheduledTask>>,
|
||||
next_runs: NextRuns,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -40,19 +45,22 @@ impl Scheduler {
|
||||
executor,
|
||||
shutdown: CancellationToken::new(),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
next_runs: Arc::new(Mutex::new(HashMap::new())),
|
||||
}),
|
||||
};
|
||||
|
||||
for task in repository::list_enabled_script_tasks(&pool).await? {
|
||||
for task in repository::list_enabled_tasks(&pool).await? {
|
||||
scheduler.refresh(task);
|
||||
}
|
||||
|
||||
Ok(scheduler)
|
||||
}
|
||||
|
||||
/// 重新注册任务:停用会取消调度,启用则按 cron 新建循环。
|
||||
/// 三类 Action 都参与调度,执行器本身负责区分脚本、HTTP 与进程。
|
||||
pub fn refresh(&self, task: TaskResponse) {
|
||||
self.cancel(&task.id);
|
||||
if !task.enabled || !matches!(task.action, TaskAction::Script { .. }) {
|
||||
if !task.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,38 +69,52 @@ impl Scheduler {
|
||||
let executor = self.inner.executor.clone();
|
||||
let shutdown = self.inner.shutdown.clone();
|
||||
let cancellation_for_loop = cancellation.clone();
|
||||
let next_runs = Arc::clone(&self.inner.next_runs);
|
||||
let handle = tokio::spawn(async move {
|
||||
schedule_loop(task_for_loop, executor, cancellation_for_loop, shutdown).await;
|
||||
schedule_loop(
|
||||
task_for_loop,
|
||||
executor,
|
||||
cancellation_for_loop,
|
||||
shutdown,
|
||||
next_runs,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
lock_jobs(&self.inner.jobs).insert(
|
||||
task.id,
|
||||
ScheduledTask {
|
||||
cancellation,
|
||||
handle,
|
||||
_handle: handle,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 取消注册。循环会在下一个 select 点退出,不会留下半途的 running 记录。
|
||||
pub fn cancel(&self, task_id: &str) {
|
||||
if let Some(job) = lock_jobs(&self.inner.jobs).remove(task_id) {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
lock_next_runs(&self.inner.next_runs).remove(task_id);
|
||||
}
|
||||
|
||||
pub fn is_scheduled(&self, task_id: &str) -> bool {
|
||||
lock_jobs(&self.inner.jobs).contains_key(task_id)
|
||||
}
|
||||
|
||||
/// 下次触发时间;未注册或刚取消时返回 None。
|
||||
pub fn next_run_at(&self, task_id: &str) -> Option<DateTime<Utc>> {
|
||||
lock_next_runs(&self.inner.next_runs).get(task_id).copied()
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.inner.shutdown.cancel();
|
||||
let mut jobs = lock_jobs(&self.inner.jobs);
|
||||
for job in jobs.values() {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
jobs.clear();
|
||||
lock_next_runs(&self.inner.next_runs).clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +127,6 @@ impl Drop for SchedulerInner {
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
for job in jobs.values() {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +136,7 @@ async fn schedule_loop(
|
||||
executor: TaskExecutor,
|
||||
cancellation: CancellationToken,
|
||||
shutdown: CancellationToken,
|
||||
next_runs: NextRuns,
|
||||
) {
|
||||
loop {
|
||||
let now = Utc::now();
|
||||
@@ -122,14 +144,22 @@ async fn schedule_loop(
|
||||
Ok(next) => next,
|
||||
Err(error) => {
|
||||
tracing::error!(task_id = %task.id, %error, "cron schedule calculation failed");
|
||||
lock_next_runs(&next_runs).remove(&task.id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
lock_next_runs(&next_runs).insert(task.id.clone(), next);
|
||||
let delay = (next - now).to_std().unwrap_or_default();
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => return,
|
||||
_ = shutdown.cancelled() => return,
|
||||
_ = cancellation.cancelled() => {
|
||||
lock_next_runs(&next_runs).remove(&task.id);
|
||||
return;
|
||||
}
|
||||
_ = shutdown.cancelled() => {
|
||||
lock_next_runs(&next_runs).remove(&task.id);
|
||||
return;
|
||||
}
|
||||
_ = tokio::time::sleep(delay) => {
|
||||
if let Err(error) = executor.trigger(&task, RunTrigger::Schedule).await {
|
||||
tracing::error!(task_id = %task.id, %error, "scheduled task trigger failed");
|
||||
@@ -156,11 +186,19 @@ fn lock_jobs(
|
||||
jobs.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn lock_next_runs(
|
||||
next_runs: &Mutex<HashMap<String, DateTime<Utc>>>,
|
||||
) -> MutexGuard<'_, HashMap<String, DateTime<Utc>>> {
|
||||
next_runs
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy};
|
||||
use crate::tasks::model::{ScriptLanguage, TaskAction};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -168,15 +206,15 @@ mod tests {
|
||||
TaskResponse {
|
||||
id: "schedule_test".to_owned(),
|
||||
name: "Schedule test".to_owned(),
|
||||
description: String::new(),
|
||||
cron: cron.to_owned(),
|
||||
timezone: timezone.to_owned(),
|
||||
enabled: true,
|
||||
timeout_secs: 30,
|
||||
concurrency_policy: ConcurrencyPolicy::Skip,
|
||||
retry_policy: RetryPolicy::default(),
|
||||
action: TaskAction::Script {
|
||||
working_dir: ".".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Shell),
|
||||
interpreter: "cmd.exe".to_owned(),
|
||||
script: "/C".to_owned(),
|
||||
args: vec!["exit".to_owned(), "0".to_owned()],
|
||||
|
||||
@@ -33,6 +33,13 @@ impl AppState {
|
||||
log_config.max_total_bytes = megabytes.saturating_mul(1024 * 1024);
|
||||
}
|
||||
let executor = TaskExecutor::new(pool.clone(), logs_dir, log_config);
|
||||
let reconciled = crate::runs::repository::fail_interrupted_runs(&pool).await?;
|
||||
if reconciled > 0 {
|
||||
tracing::warn!(
|
||||
count = reconciled,
|
||||
"marked runs interrupted by the previous shutdown as cancelled"
|
||||
);
|
||||
}
|
||||
executor.cleanup_logs().await.map_err(ApiError::internal)?;
|
||||
let scheduler = Scheduler::start(pool.clone(), executor.clone()).await?;
|
||||
|
||||
|
||||
+98
-27
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -8,6 +8,56 @@ use utoipa::ToSchema;
|
||||
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScriptLanguage {
|
||||
Python,
|
||||
Shell,
|
||||
Node,
|
||||
Lua,
|
||||
}
|
||||
|
||||
impl ScriptLanguage {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Python => "python",
|
||||
Self::Shell => "shell",
|
||||
Self::Node => "node",
|
||||
Self::Lua => "lua",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn default_interpreter(self) -> &'static str {
|
||||
match self {
|
||||
Self::Python => "python.exe",
|
||||
Self::Shell => "cmd.exe",
|
||||
Self::Node => "node.exe",
|
||||
Self::Lua => "lua.exe",
|
||||
}
|
||||
}
|
||||
|
||||
/// 旧任务没有 `language` 字段时,从解释器文件名推断。
|
||||
pub fn from_interpreter(interpreter: &str) -> Self {
|
||||
let stem = Path::new(interpreter)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or(interpreter)
|
||||
.to_ascii_lowercase();
|
||||
if matches!(stem.as_str(), "node" | "nodejs") {
|
||||
Self::Node
|
||||
} else if stem.starts_with("lua") {
|
||||
Self::Lua
|
||||
} else if matches!(
|
||||
stem.as_str(),
|
||||
"cmd" | "powershell" | "pwsh" | "bash" | "sh" | "zsh"
|
||||
) {
|
||||
Self::Shell
|
||||
} else {
|
||||
Self::Python
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskActionType {
|
||||
@@ -34,22 +84,6 @@ pub enum HttpMethod {
|
||||
Post,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConcurrencyPolicy {
|
||||
#[default]
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RetryPolicy {
|
||||
#[serde(default)]
|
||||
pub max_attempts: u32,
|
||||
#[serde(default)]
|
||||
pub delay_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum TaskAction {
|
||||
@@ -57,6 +91,8 @@ pub enum TaskAction {
|
||||
working_dir: String,
|
||||
#[serde(default)]
|
||||
venv: Option<String>,
|
||||
#[serde(default)]
|
||||
language: Option<ScriptLanguage>,
|
||||
interpreter: String,
|
||||
script: String,
|
||||
#[serde(default)]
|
||||
@@ -93,6 +129,18 @@ impl TaskAction {
|
||||
Self::Process { .. } => TaskActionType::Process,
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本语言;旧任务未存 language 时从解释器推断。
|
||||
pub fn script_language(&self) -> Option<ScriptLanguage> {
|
||||
match self {
|
||||
Self::Script {
|
||||
language,
|
||||
interpreter,
|
||||
..
|
||||
} => Some(language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
@@ -112,6 +160,9 @@ const fn default_timeout_secs() -> u64 {
|
||||
pub struct CreateTaskRequest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 一句话说明,展示在任务卡片和详情里
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub cron: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
@@ -119,10 +170,6 @@ pub struct CreateTaskRequest {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_timeout_secs")]
|
||||
pub timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
#[serde(default)]
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
}
|
||||
|
||||
@@ -130,6 +177,9 @@ pub struct CreateTaskRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateTaskRequest {
|
||||
pub name: String,
|
||||
/// 一句话说明,展示在任务卡片和详情里
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub cron: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
@@ -137,10 +187,6 @@ pub struct UpdateTaskRequest {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_timeout_secs")]
|
||||
pub timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
#[serde(default)]
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
}
|
||||
|
||||
@@ -148,12 +194,12 @@ pub struct UpdateTaskRequest {
|
||||
pub struct TaskResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 一句话说明
|
||||
pub description: String,
|
||||
pub cron: String,
|
||||
pub timezone: String,
|
||||
pub enabled: bool,
|
||||
pub timeout_secs: u64,
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -176,3 +222,28 @@ pub struct ListTasksQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
|
||||
/// 仪表盘指标:一次请求拿到全部计数,避免为每个指标各发一次列表查询。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TaskStatsResponse {
|
||||
/// 任务总数
|
||||
pub total: u64,
|
||||
/// 已启用任务数
|
||||
pub enabled: u64,
|
||||
/// 当前正在执行的任务数
|
||||
pub running: u64,
|
||||
/// 脚本类任务数
|
||||
pub script: u64,
|
||||
/// HTTP 类任务数
|
||||
pub http: u64,
|
||||
/// 进程类任务数
|
||||
pub process: u64,
|
||||
}
|
||||
|
||||
/// 已注册调度的任务及其下次触发时间。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ScheduledTaskResponse {
|
||||
pub task_id: String,
|
||||
/// 下次触发时间;调度正在重建时可能为空
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -4,17 +4,48 @@ use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
tasks::model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
|
||||
TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
CreateTaskRequest, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
|
||||
UpdateTaskRequest,
|
||||
},
|
||||
};
|
||||
|
||||
const TASK_COLUMNS: &str = "
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||
action_type, action_json, created_at, updated_at
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct TaskCounts {
|
||||
pub total: u64,
|
||||
pub enabled: u64,
|
||||
pub script: u64,
|
||||
pub http: u64,
|
||||
pub process: u64,
|
||||
}
|
||||
|
||||
/// 单条 SQL 统计全部指标,供仪表盘使用。
|
||||
pub async fn counts(pool: &SqlitePool) -> ApiResult<TaskCounts> {
|
||||
let (total, enabled, script, http, process): (i64, i64, i64, i64, i64) = sqlx::query_as(
|
||||
"SELECT
|
||||
COUNT(*),
|
||||
COALESCE(SUM(enabled), 0),
|
||||
COALESCE(SUM(action_type = 'script'), 0),
|
||||
COALESCE(SUM(action_type = 'http'), 0),
|
||||
COALESCE(SUM(action_type = 'process'), 0)
|
||||
FROM tasks",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(TaskCounts {
|
||||
total: u64::try_from(total).unwrap_or_default(),
|
||||
enabled: u64::try_from(enabled).unwrap_or_default(),
|
||||
script: u64::try_from(script).unwrap_or_default(),
|
||||
http: u64::try_from(http).unwrap_or_default(),
|
||||
process: u64::try_from(process).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ListFilters {
|
||||
pub q: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
@@ -27,13 +58,11 @@ pub struct ListFilters {
|
||||
struct TaskRow {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
cron: String,
|
||||
timezone: String,
|
||||
enabled: bool,
|
||||
timeout_secs: i64,
|
||||
concurrency_policy: String,
|
||||
retry_max_attempts: i64,
|
||||
retry_delay_secs: i64,
|
||||
action_type: String,
|
||||
action_json: String,
|
||||
created_at: DateTime<Utc>,
|
||||
@@ -44,13 +73,6 @@ impl TryFrom<TaskRow> for TaskResponse {
|
||||
type Error = ApiError;
|
||||
|
||||
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
|
||||
if row.concurrency_policy != "skip" {
|
||||
return Err(ApiError::internal(format!(
|
||||
"unknown concurrency policy: {}",
|
||||
row.concurrency_policy
|
||||
)));
|
||||
}
|
||||
|
||||
let action: TaskAction = serde_json::from_str(&row.action_json)?;
|
||||
if row.action_type != action.action_type().as_str() {
|
||||
return Err(ApiError::internal(format!(
|
||||
@@ -62,15 +84,11 @@ impl TryFrom<TaskRow> for TaskResponse {
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
cron: row.cron,
|
||||
timezone: row.timezone,
|
||||
enabled: row.enabled,
|
||||
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
|
||||
concurrency_policy: ConcurrencyPolicy::Skip,
|
||||
retry_policy: RetryPolicy {
|
||||
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
|
||||
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
|
||||
},
|
||||
action,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
@@ -83,20 +101,17 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
|
||||
let action_json = serde_json::to_string(&request.action)?;
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO tasks (
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||
action_type, action_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&request.id)
|
||||
.bind(&request.name)
|
||||
.bind(&request.description)
|
||||
.bind(&request.cron)
|
||||
.bind(&request.timezone)
|
||||
.bind(request.enabled)
|
||||
.bind(request.timeout_secs as i64)
|
||||
.bind("skip")
|
||||
.bind(i64::from(request.retry_policy.max_attempts))
|
||||
.bind(request.retry_policy.delay_secs as i64)
|
||||
.bind(request.action.action_type().as_str())
|
||||
.bind(action_json)
|
||||
.bind(now)
|
||||
@@ -117,12 +132,11 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
|
||||
Ok(TaskResponse {
|
||||
id: request.id.clone(),
|
||||
name: request.name.clone(),
|
||||
description: request.description.clone(),
|
||||
cron: request.cron.clone(),
|
||||
timezone: request.timezone.clone(),
|
||||
enabled: request.enabled,
|
||||
timeout_secs: request.timeout_secs,
|
||||
concurrency_policy: request.concurrency_policy,
|
||||
retry_policy: request.retry_policy.clone(),
|
||||
action: request.action.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -132,9 +146,8 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
|
||||
let row = sqlx::query_as::<_, TaskRow>(
|
||||
"SELECT
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||
action_type, action_json, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE id = ?",
|
||||
)
|
||||
@@ -154,19 +167,16 @@ pub async fn update(
|
||||
let action_json = serde_json::to_string(&request.action)?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE tasks SET
|
||||
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
|
||||
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
|
||||
name = ?, description = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
|
||||
action_type = ?, action_json = ?, updated_at = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&request.name)
|
||||
.bind(&request.description)
|
||||
.bind(&request.cron)
|
||||
.bind(&request.timezone)
|
||||
.bind(request.enabled)
|
||||
.bind(request.timeout_secs as i64)
|
||||
.bind("skip")
|
||||
.bind(i64::from(request.retry_policy.max_attempts))
|
||||
.bind(request.retry_policy.delay_secs as i64)
|
||||
.bind(request.action.action_type().as_str())
|
||||
.bind(action_json)
|
||||
.bind(now)
|
||||
@@ -233,14 +243,14 @@ pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskLis
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_enabled_script_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
|
||||
/// 启动时恢复调度用的列表:所有启用任务都参与调度,不再只限脚本。
|
||||
pub async fn list_enabled_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
|
||||
let rows = sqlx::query_as::<_, TaskRow>(
|
||||
"SELECT
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||
action_type, action_json, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE enabled = 1 AND action_type = 'script'
|
||||
WHERE enabled = 1
|
||||
ORDER BY updated_at DESC, id ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::repository::{self, ListFilters};
|
||||
|
||||
const MAX_TASK_ID_LEN: usize = 64;
|
||||
const MAX_TASK_NAME_LEN: usize = 120;
|
||||
const MAX_DESCRIPTION_LEN: usize = 500;
|
||||
const MAX_TIMEOUT_SECS: u64 = 86_400;
|
||||
const MAX_ARGS: usize = 256;
|
||||
const MAX_ARG_LEN: usize = 2_048;
|
||||
@@ -26,16 +27,18 @@ const MAX_PAGE_SIZE: u32 = 100;
|
||||
|
||||
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.description = request.description.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
normalize_action(&mut request.action);
|
||||
|
||||
validate_task_id(&request.id)?;
|
||||
validate_description(&request.description)?;
|
||||
validate_common(
|
||||
&request.name,
|
||||
&request.cron,
|
||||
&request.timezone,
|
||||
request.timeout_secs,
|
||||
&request.retry_policy,
|
||||
)?;
|
||||
validate_action(&request.action)?;
|
||||
|
||||
@@ -55,15 +58,17 @@ pub async fn update(
|
||||
) -> ApiResult<TaskResponse> {
|
||||
validate_task_id(id)?;
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.description = request.description.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
normalize_action(&mut request.action);
|
||||
|
||||
validate_description(&request.description)?;
|
||||
validate_common(
|
||||
&request.name,
|
||||
&request.cron,
|
||||
&request.timezone,
|
||||
request.timeout_secs,
|
||||
&request.retry_policy,
|
||||
)?;
|
||||
validate_action(&request.action)?;
|
||||
|
||||
@@ -129,12 +134,21 @@ fn validate_task_id(id: &str) -> ApiResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_description(description: &str) -> ApiResult<()> {
|
||||
if description.chars().count() > MAX_DESCRIPTION_LEN {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"description",
|
||||
format!("任务说明不能超过 {MAX_DESCRIPTION_LEN} 个字符"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_common(
|
||||
name: &str,
|
||||
cron_expression: &str,
|
||||
timezone: &str,
|
||||
timeout_secs: u64,
|
||||
retry_policy: &RetryPolicy,
|
||||
) -> ApiResult<()> {
|
||||
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
@@ -148,42 +162,52 @@ fn validate_common(
|
||||
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
|
||||
));
|
||||
}
|
||||
if retry_policy.max_attempts > 10 {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"retry_policy.max_attempts",
|
||||
"最大重试次数不能超过 10",
|
||||
));
|
||||
}
|
||||
if retry_policy.delay_secs > 3_600 {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"retry_policy.delay_secs",
|
||||
"重试间隔不能超过 3600 秒",
|
||||
));
|
||||
}
|
||||
|
||||
Tz::from_str(timezone).map_err(|_| {
|
||||
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
|
||||
})?;
|
||||
|
||||
let cron = Cron::from_str(cron_expression).map_err(|error| {
|
||||
crate::error::ApiError::validation("cron", format!("Cron 表达式无效: {error}"))
|
||||
tracing::debug!(error = %error, "invalid cron expression");
|
||||
let message =
|
||||
crate::cron::CronPreviewError::InvalidExpression(error.to_string()).user_message();
|
||||
crate::error::ApiError::validation("cron", message)
|
||||
})?;
|
||||
cron.find_next_occurrence(&Utc::now(), false)
|
||||
.map_err(|error| {
|
||||
crate::error::ApiError::validation(
|
||||
"cron",
|
||||
format!("Cron 表达式没有可计算的未来执行时间: {error}"),
|
||||
)
|
||||
tracing::debug!(error = %error, "cron has no future occurrence");
|
||||
crate::error::ApiError::validation("cron", "Cron 表达式没有可计算的未来执行时间")
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_action(action: &mut TaskAction) {
|
||||
if let TaskAction::Script {
|
||||
venv,
|
||||
language,
|
||||
interpreter,
|
||||
..
|
||||
} = action
|
||||
{
|
||||
let resolved =
|
||||
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter.trim()));
|
||||
*language = Some(resolved);
|
||||
if interpreter.trim().is_empty() {
|
||||
*interpreter = resolved.default_interpreter().to_owned();
|
||||
}
|
||||
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
|
||||
*venv = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
||||
match action {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
venv,
|
||||
language: _,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
@@ -331,6 +355,7 @@ mod tests {
|
||||
TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Python),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: "main.py".to_owned(),
|
||||
args: vec!["--mode".to_owned(), "full".to_owned()],
|
||||
@@ -346,7 +371,6 @@ mod tests {
|
||||
"0 0 2 * * *",
|
||||
"Asia/Shanghai",
|
||||
300,
|
||||
&RetryPolicy::default(),
|
||||
)
|
||||
.expect("task fields should be valid");
|
||||
validate_action(&script_action()).expect("script action should be valid");
|
||||
@@ -359,7 +383,6 @@ mod tests {
|
||||
"*/0 * * * * *",
|
||||
"Asia/Shanghai",
|
||||
300,
|
||||
&RetryPolicy::default(),
|
||||
)
|
||||
.expect_err("invalid cron should fail");
|
||||
|
||||
@@ -371,6 +394,7 @@ mod tests {
|
||||
let action = TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Python),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: " ".to_owned(),
|
||||
args: Vec::new(),
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::SqlitePool;
|
||||
use url::Url;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiJson, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const UV_EXECUTABLE: &str = "uv.exe";
|
||||
/// 自己下载的 uv 放在程序目录下的这个子目录里。
|
||||
const UV_DIRECTORY: &str = "uv";
|
||||
/// uv 下载的 Python 放在程序目录下的这个子目录里(相对路径默认值)。
|
||||
const PYTHON_DIR_DEFAULT: &str = "python";
|
||||
/// uv 的缓存目录默认值(相对程序目录)。
|
||||
const CACHE_DIR_DEFAULT: &str = "uv-cache";
|
||||
const PYTHON_INSTALL_DIR_ENV: &str = "UV_PYTHON_INSTALL_DIR";
|
||||
const CACHE_DIR_ENV: &str = "UV_CACHE_DIR";
|
||||
const PYTHON_MIRROR_ENV: &str = "UV_PYTHON_INSTALL_MIRROR";
|
||||
/// 内置的 uv 下载镜像根(中科大维护的 GitHub Release 镜像);可在「镜像源」里改成别的镜像。
|
||||
const DEFAULT_MIRROR_BASE: &str = "https://mirrors.ustc.edu.cn/github-release";
|
||||
/// 镜像根下 uv 自己的发布目录。
|
||||
const UV_RELEASE_PATH: &str = "astral-sh/uv/LatestRelease";
|
||||
/// 镜像根下 python-build-standalone 的发布目录(uv 的 `UV_PYTHON_INSTALL_MIRROR`)。
|
||||
const PYTHON_RELEASE_PATH: &str = "astral-sh/python-build-standalone/releases/download";
|
||||
const MAX_UV_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024;
|
||||
const MAX_UV_ENV_ENTRIES: usize = 32;
|
||||
const MAX_UV_ENV_VALUE_CHARS: usize = 512;
|
||||
const MAX_MIRROR_URL_CHARS: usize = 2048;
|
||||
|
||||
/// uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
|
||||
/// 会随启动方式变化,不适合当调度器的隐式依赖。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UvSource {
|
||||
/// 程序目录 `uv\uv.exe`:下载或自己放进来
|
||||
Managed,
|
||||
/// 用户指定路径的 uv
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl UvSource {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Managed => "managed",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
// 旧版本存过 system;现在统一当成程序目录 uv,由用户下载或指定。
|
||||
"system" | "managed" => Some(Self::Managed),
|
||||
"custom" => Some(Self::Custom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UvSettings {
|
||||
pub source: UvSource,
|
||||
pub custom_path: Option<String>,
|
||||
pub extra_env: BTreeMap<String, String>,
|
||||
/// uv 与 Python 解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认镜像
|
||||
pub mirror_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UvSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env: BTreeMap::new(),
|
||||
mirror_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UvSettings {
|
||||
/// 执行时真正调用的 uv 程序。
|
||||
pub fn program(&self) -> PathBuf {
|
||||
match self.source {
|
||||
UvSource::Managed => managed_directory().join(UV_EXECUTABLE),
|
||||
UvSource::Custom => self
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| managed_directory().join(UV_EXECUTABLE)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入给 uv 的环境变量:两个路径变量没配时用相对默认值(`python` / `uv-cache`),
|
||||
/// 注入前统一解析成绝对路径——uv 的 cwd 会变,相对路径不能直接交给它。
|
||||
/// 解释器的下载镜像同理:用户没在 `UV_*` 里覆盖时,注入镜像源派生出的地址。
|
||||
pub fn env(&self) -> BTreeMap<String, String> {
|
||||
let mut env = self.extra_env.clone();
|
||||
if !env
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case(PYTHON_MIRROR_ENV))
|
||||
{
|
||||
env.insert(PYTHON_MIRROR_ENV.to_owned(), self.python_mirror_url());
|
||||
}
|
||||
for (key, default) in UV_PATH_DEFAULTS {
|
||||
let value = env
|
||||
.get(key)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(default)
|
||||
.trim()
|
||||
.to_owned();
|
||||
let resolved = resolve_platform_path(&value);
|
||||
env.insert(key.to_owned(), resolved.to_string_lossy().into_owned());
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// uv 与 Python 解释器的下载镜像根(去掉结尾斜杠);没配就用内置默认。
|
||||
pub fn mirror_base(&self) -> String {
|
||||
self.mirror_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MIRROR_BASE)
|
||||
.trim_end_matches('/')
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// uv 自己那个压缩包的下载地址前缀。
|
||||
pub fn uv_download_base(&self) -> String {
|
||||
format!("{}/{}", self.mirror_base(), UV_RELEASE_PATH)
|
||||
}
|
||||
|
||||
/// `UV_PYTHON_INSTALL_MIRROR`:uv 下载 Python 解释器的地址前缀。
|
||||
pub fn python_mirror_url(&self) -> String {
|
||||
format!("{}/{}", self.mirror_base(), PYTHON_RELEASE_PATH)
|
||||
}
|
||||
|
||||
/// uv 下载 Python 的目录(解析后的绝对路径)。
|
||||
pub fn python_install_dir(&self) -> PathBuf {
|
||||
let value = self
|
||||
.extra_env
|
||||
.get(PYTHON_INSTALL_DIR_ENV)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(PYTHON_DIR_DEFAULT);
|
||||
resolve_platform_path(value)
|
||||
}
|
||||
|
||||
/// uv 的缓存目录(解析后的绝对路径)。
|
||||
pub fn cache_dir(&self) -> PathBuf {
|
||||
let value = self
|
||||
.extra_env
|
||||
.get(CACHE_DIR_ENV)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(CACHE_DIR_DEFAULT);
|
||||
resolve_platform_path(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// 两个路径型 UV 变量的默认值:相对程序目录,随包移动;也可以改成绝对路径。
|
||||
const UV_PATH_DEFAULTS: [(&str, &str); 2] = [
|
||||
(PYTHON_INSTALL_DIR_ENV, PYTHON_DIR_DEFAULT),
|
||||
(CACHE_DIR_ENV, CACHE_DIR_DEFAULT),
|
||||
];
|
||||
|
||||
/// 相对路径按程序目录解析,绝对路径原样返回。
|
||||
fn resolve_platform_path(value: &str) -> PathBuf {
|
||||
let path = Path::new(value.trim());
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
executable_directory().join(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 程序目录下的 `uv` 目录(uv 下载后放这里)。
|
||||
pub fn managed_directory() -> PathBuf {
|
||||
executable_directory().join(UV_DIRECTORY)
|
||||
}
|
||||
|
||||
fn executable_directory() -> PathBuf {
|
||||
env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(Path::to_path_buf))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 运行 `<program> --version`;不是可用的 uv 时返回 `None`。
|
||||
pub fn probe_version(program: &Path) -> Option<String> {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
let output = command.output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||
version
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("uv ")
|
||||
.then_some(version)
|
||||
}
|
||||
|
||||
/// 当前架构对应的 Windows 压缩包名。
|
||||
const fn archive_name() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"uv-aarch64-pc-windows-msvc.zip"
|
||||
} else {
|
||||
"uv-x86_64-pc-windows-msvc.zip"
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `<zip>.sha256` 里取第一段十六进制摘要。
|
||||
fn parse_sha256(text: &str) -> Option<String> {
|
||||
let token = text.split_whitespace().next()?;
|
||||
(token.len() == 64 && token.chars().all(|character| character.is_ascii_hexdigit()))
|
||||
.then(|| token.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn hex_digest(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut hex = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
fn extract_uv_exe(archive: &[u8], destination: &Path) -> Result<(), String> {
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(archive))
|
||||
.map_err(|error| format!("uv 压缩包无效: {error}"))?;
|
||||
let mut entry = archive
|
||||
.by_name(UV_EXECUTABLE)
|
||||
.map_err(|_| format!("uv 压缩包里没有 {UV_EXECUTABLE}"))?;
|
||||
let mut file =
|
||||
std::fs::File::create(destination).map_err(|error| format!("无法写入 uv: {error}"))?;
|
||||
std::io::copy(&mut entry, &mut file).map_err(|error| format!("解压 uv 失败: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解压到临时文件、验证可运行、再替换掉旧的 uv。
|
||||
fn install_archive(archive: &[u8], managed: &Path) -> Result<String, String> {
|
||||
std::fs::create_dir_all(managed).map_err(|error| format!("无法创建 uv 目录: {error}"))?;
|
||||
let target = managed.join(UV_EXECUTABLE);
|
||||
let temporary = managed.join(format!("uv-download-{}.tmp", Uuid::new_v4().simple()));
|
||||
|
||||
extract_uv_exe(archive, &temporary)?;
|
||||
if probe_version(&temporary).is_none() {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
return Err("下载的 uv 无法运行,已丢弃".to_owned());
|
||||
}
|
||||
if target.exists() {
|
||||
std::fs::remove_file(&target).map_err(|error| format!("无法替换旧的 uv: {error}"))?;
|
||||
}
|
||||
std::fs::rename(&temporary, &target).map_err(|error| format!("无法安装 uv: {error}"))?;
|
||||
|
||||
probe_version(&target).ok_or_else(|| "安装后的 uv 无法运行".to_owned())
|
||||
}
|
||||
|
||||
/// 从镜像下载当前架构的 uv,校验 sha256 后安装到程序目录的 `uv` 文件夹。
|
||||
async fn download_and_install(base: &str) -> Result<String, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.user_agent("TaskPilot/0.1")
|
||||
.build()
|
||||
.map_err(|error| format!("无法创建下载客户端: {error}"))?;
|
||||
let url = format!("{}/{}", base.trim_end_matches('/'), archive_name());
|
||||
|
||||
let checksum = client
|
||||
.get(format!("{url}.sha256"))
|
||||
.send()
|
||||
.await
|
||||
.and_then(reqwest::Response::error_for_status)
|
||||
.map_err(|error| format!("下载 uv 校验文件失败: {error}"))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取 uv 校验文件失败: {error}"))?;
|
||||
let expected = parse_sha256(&checksum).ok_or_else(|| "uv 校验文件格式不正确".to_owned())?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(reqwest::Response::error_for_status)
|
||||
.map_err(|error| format!("下载 uv 失败: {error}"))?;
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_UV_ARCHIVE_BYTES)
|
||||
{
|
||||
return Err("uv 压缩包超出大小上限".to_owned());
|
||||
}
|
||||
let archive = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| format!("读取 uv 压缩包失败: {error}"))?;
|
||||
if archive.len() as u64 > MAX_UV_ARCHIVE_BYTES {
|
||||
return Err("uv 压缩包超出大小上限".to_owned());
|
||||
}
|
||||
if hex_digest(&Sha256::digest(&archive)) != expected {
|
||||
return Err("uv 压缩包 sha256 校验失败".to_owned());
|
||||
}
|
||||
|
||||
let managed = managed_directory();
|
||||
tokio::task::spawn_blocking(move || install_archive(&archive, &managed))
|
||||
.await
|
||||
.map_err(|error| format!("安装 uv 失败: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UvCandidate {
|
||||
pub source: UvSource,
|
||||
pub path: String,
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UvSettingsResponse {
|
||||
pub source: UvSource,
|
||||
pub custom_path: Option<String>,
|
||||
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
|
||||
pub extra_env: BTreeMap<String, String>,
|
||||
/// 程序目录下的 uv 目录
|
||||
pub managed_dir: String,
|
||||
/// 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)
|
||||
pub python_dir: String,
|
||||
/// 实际生效的 uv 缓存目录(绝对路径)
|
||||
pub cache_dir: String,
|
||||
/// 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认
|
||||
pub mirror_url: String,
|
||||
/// uv 自身压缩包的下载地址前缀
|
||||
pub uv_download_url: String,
|
||||
/// Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`)
|
||||
pub python_mirror_url: String,
|
||||
/// 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行
|
||||
pub env_defaults: BTreeMap<String, String>,
|
||||
/// 当前设置指向的 uv;不可用时 `version` 为空
|
||||
pub executable: Option<String>,
|
||||
pub version: Option<String>,
|
||||
/// 各来源的探测结果
|
||||
pub candidates: Vec<UvCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateUvSettingsRequest {
|
||||
pub source: UvSource,
|
||||
#[serde(default)]
|
||||
pub custom_path: Option<String>,
|
||||
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
|
||||
#[serde(default)]
|
||||
pub extra_env: BTreeMap<String, String>,
|
||||
/// uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认
|
||||
#[serde(default)]
|
||||
pub mirror_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。
|
||||
pub async fn current(pool: &SqlitePool) -> UvSettings {
|
||||
match load(pool).await {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "读取 uv 设置失败,回退到程序目录 uv");
|
||||
UvSettings::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(source: UvSource, program: &Path) -> UvCandidate {
|
||||
let version = probe_version(program);
|
||||
UvCandidate {
|
||||
source,
|
||||
path: program.to_string_lossy().into_owned(),
|
||||
available: version.is_some(),
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(settings: &UvSettings) -> UvSettingsResponse {
|
||||
let managed_directory = managed_directory();
|
||||
let mut candidates = vec![candidate(
|
||||
UvSource::Managed,
|
||||
&managed_directory.join(UV_EXECUTABLE),
|
||||
)];
|
||||
if let Some(path) = settings
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
candidates.push(candidate(UvSource::Custom, Path::new(path)));
|
||||
}
|
||||
let current = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.source == settings.source);
|
||||
|
||||
UvSettingsResponse {
|
||||
source: settings.source,
|
||||
custom_path: settings.custom_path.clone(),
|
||||
extra_env: settings.extra_env.clone(),
|
||||
managed_dir: managed_directory.to_string_lossy().into_owned(),
|
||||
python_dir: settings.python_install_dir().to_string_lossy().into_owned(),
|
||||
cache_dir: settings.cache_dir().to_string_lossy().into_owned(),
|
||||
mirror_url: settings.mirror_base(),
|
||||
uv_download_url: settings.uv_download_base(),
|
||||
python_mirror_url: settings
|
||||
.extra_env
|
||||
.get(PYTHON_MIRROR_ENV)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| settings.python_mirror_url()),
|
||||
env_defaults: {
|
||||
let mut defaults: BTreeMap<String, String> = UV_PATH_DEFAULTS
|
||||
.iter()
|
||||
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
||||
.collect();
|
||||
defaults.insert(
|
||||
PYTHON_MIRROR_ENV.to_owned(),
|
||||
settings.python_mirror_url(),
|
||||
);
|
||||
defaults
|
||||
},
|
||||
executable: current.map(|candidate| candidate.path.clone()),
|
||||
version: current.and_then(|candidate| candidate.version.clone()),
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
/// 只允许 `UV_` 前缀:这组变量是注入给 uv 进程的,不能借它改 PATH 之类的东西。
|
||||
fn validate_extra_env(extra_env: &BTreeMap<String, String>) -> ApiResult<BTreeMap<String, String>> {
|
||||
if extra_env.len() > MAX_UV_ENV_ENTRIES {
|
||||
return Err(ApiError::validation(
|
||||
"extra_env",
|
||||
format!("环境变量最多 {MAX_UV_ENV_ENTRIES} 条"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut cleaned = BTreeMap::new();
|
||||
for (name, value) in extra_env {
|
||||
let name = name.trim();
|
||||
let valid_name = name.starts_with("UV_")
|
||||
&& name.len() > 3
|
||||
&& name.chars().all(|character| {
|
||||
character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
|
||||
});
|
||||
if !valid_name {
|
||||
return Err(ApiError::validation(
|
||||
"extra_env",
|
||||
format!("环境变量名必须是大写的 UV_ 前缀: {name}"),
|
||||
));
|
||||
}
|
||||
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(ApiError::validation(
|
||||
"extra_env",
|
||||
format!("{name} 的值不能为空"),
|
||||
));
|
||||
}
|
||||
if value.chars().count() > MAX_UV_ENV_VALUE_CHARS {
|
||||
return Err(ApiError::validation(
|
||||
"extra_env",
|
||||
format!("{name} 的值不能超过 {MAX_UV_ENV_VALUE_CHARS} 个字符"),
|
||||
));
|
||||
}
|
||||
cleaned.insert(name.to_owned(), value.to_owned());
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
fn validate(request: UpdateUvSettingsRequest) -> ApiResult<UvSettings> {
|
||||
let custom_path = request
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned);
|
||||
|
||||
if request.source == UvSource::Custom {
|
||||
let path = custom_path.as_deref().ok_or_else(|| {
|
||||
ApiError::validation("custom_path", "请填写自己下载的 uv 可执行文件路径")
|
||||
})?;
|
||||
if !Path::new(path).is_file() {
|
||||
return Err(ApiError::validation(
|
||||
"custom_path",
|
||||
format!("找不到 uv 可执行文件: {path}"),
|
||||
));
|
||||
}
|
||||
if probe_version(Path::new(path)).is_none() {
|
||||
return Err(ApiError::validation(
|
||||
"custom_path",
|
||||
"该文件无法运行 `uv --version`,它可能不是 uv",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UvSettings {
|
||||
source: request.source,
|
||||
custom_path,
|
||||
extra_env: validate_extra_env(&request.extra_env)?,
|
||||
mirror_url: validate_mirror_url(request.mirror_url)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_mirror_url(value: Option<String>) -> ApiResult<Option<String>> {
|
||||
let Some(raw) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.chars().count() > MAX_MIRROR_URL_CHARS {
|
||||
return Err(ApiError::validation("url", "镜像地址过长"));
|
||||
}
|
||||
let parsed = Url::parse(trimmed)
|
||||
.map_err(|_| ApiError::validation("url", "镜像地址不是有效的 URL"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(ApiError::validation("url", "镜像地址只支持 http 或 https"));
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
return Err(ApiError::validation("url", "镜像地址缺少主机名"));
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(ApiError::validation("url", "镜像地址不能包含用户名或密码"));
|
||||
}
|
||||
Ok(Some(trimmed.to_owned()))
|
||||
}
|
||||
|
||||
async fn load(pool: &SqlitePool) -> ApiResult<UvSettings> {
|
||||
let row = sqlx::query_as::<_, (String, Option<String>, String, Option<String>)>(
|
||||
"SELECT source, custom_path, extra_env, mirror_url FROM uv_settings WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let Some((source, custom_path, extra_env, mirror_url)) = row else {
|
||||
return Ok(UvSettings::default());
|
||||
};
|
||||
Ok(UvSettings {
|
||||
source: UvSource::parse(&source).unwrap_or(UvSource::Managed),
|
||||
custom_path,
|
||||
extra_env: serde_json::from_str(&extra_env).unwrap_or_default(),
|
||||
mirror_url,
|
||||
})
|
||||
}
|
||||
|
||||
async fn save(pool: &SqlitePool, settings: &UvSettings) -> ApiResult<()> {
|
||||
let extra_env = serde_json::to_string(&settings.extra_env)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO uv_settings (id, source, custom_path, extra_env, mirror_url, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
custom_path = excluded.custom_path,
|
||||
extra_env = excluded.extra_env,
|
||||
mirror_url = excluded.mirror_url,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(settings.source.as_str())
|
||||
.bind(settings.custom_path.as_deref())
|
||||
.bind(extra_env)
|
||||
.bind(settings.mirror_url.as_deref())
|
||||
.bind(Utc::now())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/uv",
|
||||
tag = "uv",
|
||||
responses(
|
||||
(status = 200, description = "uv settings and detected executables", body = UvSettingsResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_uv_settings(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
|
||||
let settings = load(&state.pool).await?;
|
||||
tokio::task::spawn_blocking(move || describe(&settings))
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/uv",
|
||||
tag = "uv",
|
||||
request_body = UpdateUvSettingsRequest,
|
||||
responses(
|
||||
(status = 200, description = "uv settings saved", body = UvSettingsResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn update_uv_settings(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<UpdateUvSettingsRequest>,
|
||||
) -> ApiResult<Json<UvSettingsResponse>> {
|
||||
let settings = tokio::task::spawn_blocking(move || validate(request))
|
||||
.await
|
||||
.map_err(ApiError::internal)??;
|
||||
save(&state.pool, &settings).await?;
|
||||
tokio::task::spawn_blocking(move || describe(&settings))
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/uv/download",
|
||||
tag = "uv",
|
||||
responses(
|
||||
(status = 200, description = "uv downloaded from the mirror and switched to it", body = UvSettingsResponse),
|
||||
(status = 400, description = "Download, verification or install failed", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn download_uv(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
|
||||
// shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。
|
||||
let mut settings = load(&state.pool).await?;
|
||||
let version = download_and_install(&settings.uv_download_base())
|
||||
.await
|
||||
.map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?;
|
||||
tracing::info!(%version, "downloaded uv into the managed directory");
|
||||
|
||||
// 下载的目的就是让它生效,所以直接切到程序目录 uv。
|
||||
settings.source = UvSource::Managed;
|
||||
save(&state.pool, &settings).await?;
|
||||
tokio::task::spawn_blocking(move || describe(&settings))
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/uv", get(get_uv_settings).put(update_uv_settings))
|
||||
.route("/api/v1/uv/download", post(download_uv))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn managed_directory_sits_next_to_the_binary() {
|
||||
let managed = managed_directory();
|
||||
|
||||
assert_eq!(managed.file_name(), Some(std::ffi::OsStr::new("uv")));
|
||||
assert_eq!(managed.parent(), env::current_exe().unwrap().parent());
|
||||
}
|
||||
|
||||
fn request(source: UvSource, custom_path: Option<&str>) -> UpdateUvSettingsRequest {
|
||||
UpdateUvSettingsRequest {
|
||||
source,
|
||||
custom_path: custom_path.map(str::to_owned),
|
||||
extra_env: BTreeMap::new(),
|
||||
mirror_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_follows_the_configured_source() {
|
||||
let custom = UvSettings {
|
||||
source: UvSource::Custom,
|
||||
custom_path: Some(r"C:\tools\uv\uv.exe".to_owned()),
|
||||
extra_env: BTreeMap::new(),
|
||||
mirror_url: None,
|
||||
};
|
||||
assert_eq!(custom.program(), PathBuf::from(r"C:\tools\uv\uv.exe"));
|
||||
|
||||
let managed = UvSettings {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env: BTreeMap::new(),
|
||||
mirror_url: None,
|
||||
};
|
||||
assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_custom_source_without_a_runnable_uv() {
|
||||
assert!(validate(request(UvSource::Custom, None)).is_err());
|
||||
assert!(
|
||||
validate(request(
|
||||
UvSource::Custom,
|
||||
Some(r"C:\definitely\not\here\uv.exe")
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_custom_path_when_switching_away_from_custom() {
|
||||
let settings = validate(request(UvSource::Managed, Some(" C:\\tools\\uv.exe "))).unwrap();
|
||||
|
||||
assert_eq!(settings.source, UvSource::Managed);
|
||||
assert_eq!(settings.custom_path.as_deref(), Some(r"C:\tools\uv.exe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_uv_prefixed_environment_variables() {
|
||||
let mut extra_env = BTreeMap::new();
|
||||
extra_env.insert("UV_CACHE_DIR".to_owned(), " D:\\uv-cache ".to_owned());
|
||||
extra_env.insert("UV_HTTP_TIMEOUT".to_owned(), "120".to_owned());
|
||||
let settings = validate(UpdateUvSettingsRequest {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
mirror_url: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
settings.extra_env.get("UV_CACHE_DIR").map(String::as_str),
|
||||
Some("D:\\uv-cache")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_uv_environment_variables() {
|
||||
for name in ["PATH", "uv_cache_dir", "UV_", "UV-CACHE"] {
|
||||
let mut extra_env = BTreeMap::new();
|
||||
extra_env.insert(name.to_owned(), "x".to_owned());
|
||||
let result = validate(UpdateUvSettingsRequest {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
mirror_url: None,
|
||||
});
|
||||
assert!(result.is_err(), "{name} should be rejected");
|
||||
}
|
||||
|
||||
let mut extra_env = BTreeMap::new();
|
||||
extra_env.insert("UV_CACHE_DIR".to_owned(), " ".to_owned());
|
||||
assert!(
|
||||
validate(UpdateUvSettingsRequest {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
mirror_url: None,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_windows_archive_for_this_architecture() {
|
||||
let name = archive_name();
|
||||
|
||||
assert!(name.starts_with("uv-"));
|
||||
assert!(name.ends_with("-pc-windows-msvc.zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_sha256_sidecar() {
|
||||
let digest = "0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let text = format!("{digest} uv-x86_64-pc-windows-msvc.zip\n");
|
||||
|
||||
assert_eq!(
|
||||
parse_sha256(&text).as_deref(),
|
||||
Some(digest.to_ascii_lowercase().as_str())
|
||||
);
|
||||
assert!(parse_sha256("not a checksum").is_none());
|
||||
assert!(parse_sha256("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_uv_and_python_urls_from_the_mirror_base() {
|
||||
let settings = UvSettings {
|
||||
mirror_url: Some("https://mirror.example/github-release/".to_owned()),
|
||||
..UvSettings::default()
|
||||
};
|
||||
|
||||
assert_eq!(settings.mirror_base(), "https://mirror.example/github-release");
|
||||
assert_eq!(
|
||||
settings.uv_download_base(),
|
||||
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.python_mirror_url(),
|
||||
"https://mirror.example/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_builtin_mirror() {
|
||||
let settings = UvSettings::default();
|
||||
|
||||
assert_eq!(settings.mirror_base(), DEFAULT_MIRROR_BASE);
|
||||
assert_eq!(
|
||||
settings.uv_download_base(),
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.python_mirror_url(),
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_mirror_urls() {
|
||||
assert_eq!(validate_mirror_url(None).unwrap(), None);
|
||||
assert_eq!(validate_mirror_url(Some(" ".to_owned())).unwrap(), None);
|
||||
assert_eq!(
|
||||
validate_mirror_url(Some(
|
||||
"https://mirrors.ustc.edu.cn/github-release/".to_owned()
|
||||
))
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("https://mirrors.ustc.edu.cn/github-release")
|
||||
);
|
||||
assert!(validate_mirror_url(Some("ftp://mirror.example".to_owned())).is_err());
|
||||
assert!(validate_mirror_url(Some("not a url".to_owned())).is_err());
|
||||
assert!(validate_mirror_url(Some("https://user:[email protected]".to_owned())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_the_python_mirror_and_keeps_user_overrides() {
|
||||
let settings = UvSettings::default();
|
||||
assert_eq!(
|
||||
settings
|
||||
.env()
|
||||
.get("UV_PYTHON_INSTALL_MIRROR")
|
||||
.map(String::as_str),
|
||||
Some(
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
)
|
||||
);
|
||||
|
||||
let mut extra_env = BTreeMap::new();
|
||||
extra_env.insert(
|
||||
"UV_PYTHON_INSTALL_MIRROR".to_owned(),
|
||||
"https://my.mirror/python".to_owned(),
|
||||
);
|
||||
let custom = UvSettings {
|
||||
extra_env,
|
||||
..UvSettings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
custom
|
||||
.env()
|
||||
.get("UV_PYTHON_INSTALL_MIRROR")
|
||||
.map(String::as_str),
|
||||
Some("https://my.mirror/python")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_uv_from_the_archive() {
|
||||
use std::io::Write;
|
||||
|
||||
let mut buffer = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(&mut buffer);
|
||||
writer
|
||||
.start_file(UV_EXECUTABLE, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(b"fake uv").unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
let archive = buffer.into_inner();
|
||||
let directory = tempfile::TempDir::new().unwrap();
|
||||
let target = directory.path().join(UV_EXECUTABLE);
|
||||
|
||||
extract_uv_exe(&archive, &target).unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"fake uv");
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,30 @@ async fn directory_listing_endpoint_returns_subdirectories() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_listing_endpoint_includes_files_when_requested() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let tools = temp_dir.path().join("tools");
|
||||
fs::create_dir_all(tools.join("bin")).expect("bin directory should be created");
|
||||
fs::write(tools.join("uv.exe"), "x").expect("file should be written");
|
||||
|
||||
let uri = format!(
|
||||
"/api/v1/system/directories?path={}&include_files=true",
|
||||
encode_query_path(&tools)
|
||||
);
|
||||
let (status, body) = get_json(&app, &uri).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let entries = body["entries"]
|
||||
.as_array()
|
||||
.expect("entries should be an array");
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["name"], "bin");
|
||||
assert_eq!(entries[0]["is_dir"], true);
|
||||
assert_eq!(entries[1]["name"], "uv.exe");
|
||||
assert_eq!(entries[1]["is_dir"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_listing_endpoint_rejects_relative_paths() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (axum::Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn auth_token(app: &axum::Router) -> String {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
|
||||
json["token"]
|
||||
.as_str()
|
||||
.expect("token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn send_json(
|
||||
app: &axum::Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = if let Some(body) = body {
|
||||
builder
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body).expect("body should serialize"),
|
||||
))
|
||||
.expect("request should build")
|
||||
} else {
|
||||
builder.body(Body::empty()).expect("request should build")
|
||||
};
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_python_mirrors_with_one_default() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let mirrors = body.as_array().expect("mirrors should be an array");
|
||||
assert_eq!(mirrors.len(), 4);
|
||||
let defaults: Vec<&Value> = mirrors
|
||||
.iter()
|
||||
.filter(|mirror| mirror["is_default"] == true)
|
||||
.collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0]["id"], "tsinghua");
|
||||
assert!(mirrors.iter().all(|mirror| mirror["enabled"] == true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirrors_crud_and_default_switch() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, created) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "内网镜像",
|
||||
"index_url": "https://pypi.example.local/simple",
|
||||
"trusted_host": "pypi.example.local",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let id = created["id"]
|
||||
.as_str()
|
||||
.expect("created mirror should have an id")
|
||||
.to_owned();
|
||||
assert_eq!(created["is_default"], false);
|
||||
assert_eq!(created["trusted_host"], "pypi.example.local");
|
||||
|
||||
let (status, mirror) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
&format!("/api/v1/python/mirrors/{id}/default"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(mirror["is_default"], true);
|
||||
|
||||
let (status, list) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let defaults = list
|
||||
.as_array()
|
||||
.expect("mirrors should be an array")
|
||||
.iter()
|
||||
.filter(|mirror| mirror["is_default"] == true)
|
||||
.count();
|
||||
assert_eq!(defaults, 1);
|
||||
|
||||
let (status, updated) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "内网镜像(停用)",
|
||||
"index_url": "https://pypi.example.local/simple",
|
||||
"trusted_host": null,
|
||||
"enabled": false
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(updated["name"], "内网镜像(停用)");
|
||||
assert_eq!(updated["enabled"], false);
|
||||
// 停用默认镜像时连带清掉默认标记,避免执行器继续引用一个停用的源
|
||||
assert_eq!(updated["is_default"], false);
|
||||
|
||||
let (status, _) = send_json(
|
||||
&app,
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::GET,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["code"], "MIRROR_NOT_FOUND");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_mirror_url_and_duplicate_index() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "坏地址",
|
||||
"index_url": "ftp://example.com/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "index_url");
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "重复镜像",
|
||||
"index_url": "https://pypi.org/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(body["code"], "MIRROR_CONFLICT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirror_mutations_require_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
None,
|
||||
Some(json!({
|
||||
"name": "未授权",
|
||||
"index_url": "https://example.com/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
@@ -47,9 +47,22 @@ async fn fetch_token(app: &Router) -> String {
|
||||
}
|
||||
|
||||
async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
||||
post_json(app, "/api/v1/python/venv", token, payload).await
|
||||
}
|
||||
|
||||
async fn post_create(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
||||
post_json(app, "/api/v1/python/venv/create", token, payload).await
|
||||
}
|
||||
|
||||
async fn post_json(
|
||||
app: &Router,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
payload: Value,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/python/venv")
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
@@ -68,7 +81,7 @@ async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (Statu
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("probe body should be readable")
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
|
||||
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
||||
@@ -179,3 +192,76 @@ async fn venv_probe_reports_missing_working_directory() {
|
||||
.contains("工作目录")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_probe_reports_project_dependency_files() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = fetch_token(&app).await;
|
||||
let project = temp_dir.path().join("deps-project");
|
||||
fs::create_dir_all(&project).expect("project directory should be created");
|
||||
fs::write(
|
||||
project.join("pyproject.toml"),
|
||||
"[project]\nname = \"demo\"\n",
|
||||
)
|
||||
.expect("pyproject.toml should be written");
|
||||
fs::write(project.join("requirements.txt"), "").expect("requirements.txt should be written");
|
||||
|
||||
let (status, body) = post_probe(
|
||||
&app,
|
||||
Some(&token),
|
||||
json!({
|
||||
"working_dir": project.to_str().expect("project path should be UTF-8"),
|
||||
"interpreter": "python.exe"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["status"], "missing");
|
||||
assert_eq!(body["has_pyproject"], true);
|
||||
assert_eq!(body["has_requirements"], true);
|
||||
assert_eq!(body["requirements_files"], json!(["requirements.txt"]));
|
||||
assert_eq!(body["can_create"], body["uv_available"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_create_rejects_missing_working_directory() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = fetch_token(&app).await;
|
||||
let absent = temp_dir.path().join("absent-project");
|
||||
|
||||
let (status, body) = post_create(
|
||||
&app,
|
||||
Some(&token),
|
||||
json!({
|
||||
"working_dir": absent.to_str().expect("absent path should be UTF-8")
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VENV_CREATE_FAILED");
|
||||
assert!(
|
||||
body["message"]
|
||||
.as_str()
|
||||
.expect("message should be a string")
|
||||
.contains("工作目录")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_create_requires_management_token() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = post_create(
|
||||
&app,
|
||||
None,
|
||||
json!({
|
||||
"working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8")
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (axum::Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn auth_token(app: &axum::Router) -> String {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
|
||||
json["token"]
|
||||
.as_str()
|
||||
.expect("token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn send(
|
||||
app: &axum::Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = if let Some(body) = body {
|
||||
builder
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body).expect("body should serialize"),
|
||||
))
|
||||
.expect("request should build")
|
||||
} else {
|
||||
builder.body(Body::empty()).expect("request should build")
|
||||
};
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
|
||||
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_env_list_returns_an_array() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send(&app, Method::GET, "/api/v1/python/envs", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.is_array(), "envs should be an array: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_env_create_validates_input() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/envs",
|
||||
Some(&token),
|
||||
Some(json!({ "name": "../escape" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "name");
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/envs",
|
||||
Some(&token),
|
||||
Some(json!({ "name": "data-science", "python_version": "latest" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "python_version");
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/envs",
|
||||
Some(&token),
|
||||
Some(json!({ "name": "data-science", "packages": ["--index-url=http://x"] })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "packages");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_managed_env_returns_not_found() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::GET,
|
||||
"/api/v1/python/envs/absent/packages",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["code"], "ENV_NOT_FOUND");
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::DELETE,
|
||||
"/api/v1/python/envs/absent",
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["code"], "ENV_NOT_FOUND");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_env_mutations_require_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/envs",
|
||||
None,
|
||||
Some(json!({ "name": "no-token" })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
+247
-8
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use std::{fs, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
@@ -201,6 +201,98 @@ async fn wait_for_run(router: &Router, run_id: i64) -> Value {
|
||||
panic!("run {run_id} did not reach a terminal state");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn python_task_without_venv_fails_with_editor_hint() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().join("bare-python");
|
||||
fs::create_dir_all(&working_dir).expect("working directory should be created");
|
||||
fs::write(working_dir.join("main.py"), "print('hi')\n").expect("script should be written");
|
||||
|
||||
let mut task = script_task(
|
||||
"bare_python",
|
||||
&working_dir.to_string_lossy(),
|
||||
"python.exe",
|
||||
"main.py",
|
||||
vec![],
|
||||
"0 0 2 * * *",
|
||||
10,
|
||||
);
|
||||
task["action"]["language"] = json!("python");
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/bare_python/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||
let run = response_json(run_response).await;
|
||||
let completed = wait_for_run(&app.router, run["id"].as_i64().unwrap()).await;
|
||||
|
||||
assert_eq!(completed["status"], "failed");
|
||||
let message = completed["error_message"]
|
||||
.as_str()
|
||||
.expect("failed run should carry an error message");
|
||||
// 运行期不再自动装依赖,只提示去编辑器里创建。
|
||||
assert!(message.contains("编辑器里用 uv 创建"), "{message}");
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_language_runs_batch_file_with_cmd_c() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
fs::write(
|
||||
app._temp_dir.path().join("hello.bat"),
|
||||
"@echo off\r\necho shell-language-ok\r\n",
|
||||
)
|
||||
.expect("batch file should be written");
|
||||
|
||||
let mut task = script_task(
|
||||
"shell_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"hello.bat",
|
||||
vec![],
|
||||
"0 0 2 * * *",
|
||||
10,
|
||||
);
|
||||
task["action"]["language"] = json!("shell");
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/shell_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||
let run = response_json(run_response).await;
|
||||
let run_id = run["id"].as_i64().expect("run id should be an integer");
|
||||
|
||||
let completed = wait_for_run(&app.router, run_id).await;
|
||||
assert_eq!(completed["status"], "succeeded");
|
||||
let log_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let log = response_json(log_response).await;
|
||||
assert!(
|
||||
log["content"]
|
||||
.as_str()
|
||||
.expect("log content should be a string")
|
||||
.contains("shell-language-ok")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_script_run_captures_exit_code_and_logs() {
|
||||
let app = test_app().await;
|
||||
@@ -573,7 +665,12 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
|
||||
&working_dir,
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
vec!["-File", &script_path.to_string_lossy()],
|
||||
vec![
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
&script_path.to_string_lossy(),
|
||||
],
|
||||
"0 0 2 * * *",
|
||||
1,
|
||||
);
|
||||
@@ -603,7 +700,10 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
|
||||
assert_eq!(second_run["status"], "skipped");
|
||||
|
||||
let completed = wait_for_run(&app.router, first_run_id).await;
|
||||
assert_eq!(completed["status"], "timed_out");
|
||||
assert_eq!(
|
||||
completed["status"], "timed_out",
|
||||
"unexpected run: {completed}"
|
||||
);
|
||||
assert!(
|
||||
completed["error_message"]
|
||||
.as_str()
|
||||
@@ -646,11 +746,12 @@ async fn scheduler_runs_tasks_and_tracks_enable_state() {
|
||||
)
|
||||
.await;
|
||||
let runs = response_json(response).await;
|
||||
if let Some(item) = runs["items"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.filter(|item| item["trigger"] == "schedule")
|
||||
{
|
||||
// 每秒触发一次,机器忙时上一轮还没结束,最新一条可能是 skipped;取真正跑起来那条。
|
||||
if let Some(item) = runs["items"].as_array().and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item["trigger"] == "schedule" && item["status"] != "skipped")
|
||||
}) {
|
||||
scheduled_run = Some(item.clone());
|
||||
break;
|
||||
}
|
||||
@@ -821,3 +922,141 @@ async fn long_utf8_log_is_reassembled_across_chunk_boundaries() {
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_endpoint_aggregates_task_counts() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let script = script_task(
|
||||
"stats_script",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let http = http_task("http://127.0.0.1:9/never");
|
||||
for payload in [script.clone(), http.clone()] {
|
||||
let response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(payload)).await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
let response = send_json(&app.router, Method::GET, "/api/v1/stats", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let stats = response_json(response).await;
|
||||
|
||||
assert_eq!(stats["total"], 2);
|
||||
assert_eq!(stats["enabled"], 2);
|
||||
assert_eq!(stats["script"], 1);
|
||||
assert_eq!(stats["http"], 1);
|
||||
assert_eq!(stats["process"], 0);
|
||||
assert_eq!(stats["running"], 0);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_endpoint_reports_next_run_for_enabled_tasks() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"scheduled_stats",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"*/30 * * * * *",
|
||||
5,
|
||||
);
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
let response = send_json(&app.router, Method::GET, "/api/v1/schedule", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let schedule = response_json(response).await;
|
||||
let entries = schedule.as_array().expect("schedule should be an array");
|
||||
let entry = entries
|
||||
.iter()
|
||||
.find(|item| item["task_id"] == "scheduled_stats")
|
||||
.expect("enabled task should appear in the schedule");
|
||||
|
||||
assert!(
|
||||
entry["next_run_at"].is_string(),
|
||||
"next_run_at should be computed for a registered task"
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_tasks_are_registered_for_scheduling() {
|
||||
let app = test_app().await;
|
||||
let task = http_task("http://127.0.0.1:9/never");
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
assert!(
|
||||
app.state.scheduler.is_scheduled("http_task"),
|
||||
"HTTP tasks must take part in scheduled runs"
|
||||
);
|
||||
|
||||
let disabled = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/http_task/disable",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(disabled.status(), StatusCode::OK);
|
||||
assert!(!app.state.scheduler.is_scheduled("http_task"));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_marks_runs_interrupted_by_restart_as_cancelled() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"interrupted_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
// 模拟上一次进程被强杀后遗留的运行记录。
|
||||
sqlx::query(
|
||||
"INSERT INTO task_runs (task_id, trigger_type, status, created_at)
|
||||
VALUES ('interrupted_task', 'manual', 'running', ?)",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(&app.state.pool)
|
||||
.await
|
||||
.expect("stale run should be inserted");
|
||||
|
||||
let reconciled = task_pilot_backend::runs::repository::fail_interrupted_runs(&app.state.pool)
|
||||
.await
|
||||
.expect("reconciliation should run");
|
||||
assert_eq!(reconciled, 1);
|
||||
|
||||
let runs = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/interrupted_task/runs",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let body = response_json(runs).await;
|
||||
let item = &body["items"][0];
|
||||
assert_eq!(item["status"], "cancelled");
|
||||
assert!(
|
||||
item["error_message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("重启")
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
@@ -113,6 +113,20 @@ fn script_task(id: &str, name: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_interpreter_defaults_to_python() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let mut payload = script_task("default_interpreter", "Default interpreter");
|
||||
payload["action"]["interpreter"] = json!("");
|
||||
|
||||
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(payload)).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let created = response_json(response).await;
|
||||
assert_eq!(created["action"]["interpreter"], "python.exe");
|
||||
assert_eq!(created["action"]["language"], "python");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_crud_flow_works_end_to_end() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (axum::Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn auth_token(app: &axum::Router) -> String {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
|
||||
json["token"]
|
||||
.as_str()
|
||||
.expect("token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn send_json(
|
||||
app: &axum::Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = if let Some(body) = body {
|
||||
builder
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body).expect("body should serialize"),
|
||||
))
|
||||
.expect("request should build")
|
||||
} else {
|
||||
builder.body(Body::empty()).expect("request should build")
|
||||
};
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
|
||||
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_settings_default_to_the_managed_uv() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["source"], "managed");
|
||||
assert_eq!(body["custom_path"], Value::Null);
|
||||
let managed_dir = body["managed_dir"]
|
||||
.as_str()
|
||||
.expect("managed_dir should be a string");
|
||||
assert_eq!(
|
||||
Path::new(managed_dir).file_name().unwrap(),
|
||||
std::ffi::OsStr::new("uv")
|
||||
);
|
||||
// 不再读系统 PATH,候选里只有程序目录 uv(自定义路径未设置时也不出现)。
|
||||
let sources = body["candidates"]
|
||||
.as_array()
|
||||
.expect("candidates should be an array")
|
||||
.iter()
|
||||
.map(|candidate| candidate["source"].as_str().unwrap().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(sources, vec!["managed"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_settings_reject_unusable_custom_path_and_persist_valid_ones() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({ "source": "custom", "custom_path": null })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "custom_path");
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({ "source": "custom", "custom_path": "C:\\missing\\uv.exe" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "custom_path");
|
||||
|
||||
// 文件存在但不是可运行的 uv,也要拒绝保存。
|
||||
let fake = temp_dir.path().join("fake.exe");
|
||||
fs::write(&fake, b"not a program").expect("fake executable should be written");
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"source": "custom",
|
||||
"custom_path": fake.to_string_lossy()
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "custom_path");
|
||||
|
||||
// 切回程序目录 uv 时保留已填写的自定义路径,方便再次切换。
|
||||
let (status, saved) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"source": "managed",
|
||||
"custom_path": " C:\\tools\\uv.exe "
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved["source"], "managed");
|
||||
assert_eq!(saved["custom_path"], "C:\\tools\\uv.exe");
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["source"], "managed");
|
||||
assert_eq!(body["custom_path"], "C:\\tools\\uv.exe");
|
||||
assert!(
|
||||
body["candidates"]
|
||||
.as_array()
|
||||
.expect("candidates should be an array")
|
||||
.iter()
|
||||
.any(|candidate| candidate["source"] == "custom")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_settings_mutations_require_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
None,
|
||||
Some(json!({ "source": "managed", "custom_path": null })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_mirror_is_saved_with_the_settings_and_drives_both_urls() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
body["mirror_url"],
|
||||
"https://mirrors.ustc.edu.cn/github-release"
|
||||
);
|
||||
assert_eq!(
|
||||
body["uv_download_url"],
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
|
||||
);
|
||||
assert_eq!(
|
||||
body["python_mirror_url"],
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
);
|
||||
// 解释器镜像作为默认环境变量出现在列表里,可以被显式覆盖
|
||||
assert_eq!(
|
||||
body["env_defaults"]["UV_PYTHON_INSTALL_MIRROR"],
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
);
|
||||
|
||||
// 非法镜像地址会被拒绝
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"source": "managed",
|
||||
"mirror_url": "ftp://mirror.example"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "url");
|
||||
|
||||
// 保存自定义镜像:两个下载地址都由它派生
|
||||
let (status, saved) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"source": "managed",
|
||||
"mirror_url": "https://mirror.example/github-release/"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved["mirror_url"], "https://mirror.example/github-release");
|
||||
assert_eq!(
|
||||
saved["uv_download_url"],
|
||||
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
|
||||
);
|
||||
|
||||
// 传 null 恢复内置默认
|
||||
let (status, restored) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({ "source": "managed", "mirror_url": null })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
restored["mirror_url"],
|
||||
"https://mirrors.ustc.edu.cn/github-release"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_download_requires_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::POST, "/api/v1/uv/download", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
+1143
-41
File diff suppressed because it is too large
Load Diff
+32
-4
@@ -16,8 +16,27 @@
|
||||
|
||||
## 数据目录
|
||||
|
||||
- SQLite 数据库:`data/task_pilot.db`
|
||||
- SQLite 数据库:`data/task_pilot.db`(WAL 模式,运行中会额外出现 `-wal`/`-shm` 文件)
|
||||
- 运行日志:`logs/{task_id}/{run_id}.log`
|
||||
- 托管虚拟环境:`data/envs/<名称>/`(「虚拟环境」弹窗创建,删除环境就是删掉整个目录)
|
||||
- uv 下载的 Python 解释器:`python/<版本>/`
|
||||
- uv 缓存:`uv-cache/`(首次装包/下载 Python 后才会出现)
|
||||
- 程序下载的 uv:`uv/uv.exe`
|
||||
|
||||
`UV_PYTHON_INSTALL_DIR` 与 `UV_CACHE_DIR` 默认是**相对本目录**的 `python` / `uv-cache`,
|
||||
整个发布目录可以整体搬走;想复用机器上的全局缓存,在 uv 配置弹窗里把这两个变量改成绝对路径。
|
||||
|
||||
## Python 与 uv
|
||||
|
||||
用 uv 创建虚拟环境时,uv 的来源在 uv 配置弹窗(任务调度页右栏「uv → 配置」)切换(不读系统 PATH):
|
||||
|
||||
- 本目录下的 `uv/uv.exe`(默认):没有 uv 时会出现「下载 uv」,会从科大镜像下载并校验 sha256 后放进来;也可以自己下载后放到这里
|
||||
- 任意位置的 `uv.exe`:选「指定路径」,保存时会先校验能运行;本机已装 uv 就把它的路径填进来
|
||||
|
||||
同一弹窗还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`),会注入到每一次 uv 调用;
|
||||
任务运行时会把这些变量和默认镜像 `UV_DEFAULT_INDEX` 一并注入任务进程(任务自己的环境变量优先)。
|
||||
|
||||
「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;离线环境请自己准备 uv 并指定路径。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -33,16 +52,25 @@ $env:TASK_PILOT_STATIC_DIR = "web"
|
||||
|
||||
`TASK_PILOT_LOG_ENCODING` 默认 `auto`,会按 UTF-8 → 系统 ANSI 代码页(简体中文为 GBK)的顺序解码子进程输出;日志出现乱码时可显式指定 `gbk` 或 `utf-8`。
|
||||
|
||||
单次运行日志上限 5 MiB,单行上限 256 KiB;日志按保留期与总容量清理,正在写入的文件不会被删除。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 服务只监听 `127.0.0.1`。
|
||||
- 浏览器跨站 Origin 会被拒绝。
|
||||
- 写操作需要运行期随机管理令牌。
|
||||
- 管理令牌在进程重启后自动更换。
|
||||
- 写操作需要运行期随机管理令牌,进程重启后自动更换。
|
||||
- 请求体上限 2 MiB。
|
||||
|
||||
## 离线使用
|
||||
|
||||
前端资源、字体(`LXGW WenKai` / `Outfit-Medium`)与所有脚本都随包自带,不需要联网即可打开界面与执行任务。
|
||||
|
||||
## 故障排查
|
||||
|
||||
- 3000 端口被占用时,通过 `TASK_PILOT_PORT` 修改端口;打包版前端使用同源 API,无需重新构建。
|
||||
- 页面无法打开时,确认 `web/index.html` 存在。
|
||||
- 任务未执行时,确认任务已启用、Action 配置有效,并在日志抽屉查看错误。
|
||||
- 任务未执行时,确认任务已启用、Cron 正确,并在任务表的「调度周期」列查看下次触发时间。
|
||||
- PowerShell 脚本任务报「不是数字签名」时,是执行策略拦截未签名脚本:在参数里加 `-ExecutionPolicy Bypass`,或对脚本目录执行 `Unblock-File`。
|
||||
- 进程无法启动时,检查解释器或可执行文件路径、工作目录和执行权限。
|
||||
- 日志里出现 `����`:改用 UTF-8 输出,或设置 `TASK_PILOT_LOG_ENCODING=gbk`。
|
||||
- 进程被强杀后,遗留的运行会在下次启动时标记为「已取消(进程重启中断)」,不是数据损坏。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR-0006:视觉风格对齐主题并自托管字体
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-25
|
||||
|
||||
## 背景
|
||||
|
||||
产品要求前端视觉与参考站点(Alright / nicetheme)一致,包括字体、配色和交互,但不改变既有布局。参考站点使用 Bootstrap 5 + 自定义变量,正文依赖 CDN 的 `lxgw-wenkai-webfont`,标题使用自托管的 `Outfit-Medium`。
|
||||
|
||||
本项目的目标是"本机、离线可复现":依赖 CDN 会在断网、内网或 CDN 故障时导致字体回退,从而破坏视觉一致性。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 从参考站点抓取其 CSS 的变量定义,把真实色值映射到本项目既有语义变量(`--primary`、`--text-main`、`--border`、`--success` 等),布局相关声明一律不改。
|
||||
2. 交互细节对齐主题:`0.3s ease-in-out` 过渡、主/危险按钮 hover 使用 `opacity: .85`、输入框聚焦使用主色半透明光环、选中项使用主色浅染。
|
||||
3. 字重遵循主题的 `--font-weight-bold: 500`;标题与指标数字使用 `Outfit-Medium`,正文使用 `LXGW WenKai`,等宽使用主题的 `SFMono-Regular…Consolas` 栈。
|
||||
4. 字体全部自托管:`Outfit-Medium.ttf` 直接取自主题;`LXGW WenKai` 由官方 webfont 的 97 个 woff2 子集合并后,再裁剪到 GB2312 + 常用符号子集,输出简体中文 Regular 与 Bold 两个文件。
|
||||
5. 移除所有 Google Fonts / CDN 字体链接,`index.html` 只 preload 本地字体文件。
|
||||
6. 新增端到端用例"renders fully offline":拦截并拒绝一切非 `127.0.0.1` 请求,断言页面正常渲染、`document.fonts.check` 命中两种字体且没有任何外部请求。
|
||||
|
||||
## 原因
|
||||
|
||||
- 直接用主题的变量值,颜色不会"看起来像"而是确实一致。
|
||||
- 字体子集化把 8.8 MB 的 194 个子集压到 6.7 MB 的两个文件,仓库与安装包体积可控,浏览器只需一次请求。
|
||||
- 离线断言把"可复现"变成可回归的测试,而不是口头承诺。
|
||||
|
||||
## 边界
|
||||
|
||||
- 字体子集覆盖 GB2312(6763 字)+ Latin + CJK 标点 + 全角符号;更生僻的汉字(GB18030 扩展区)会按浏览器逐字回退到系统字体,显示正常但字形风格不一致。
|
||||
- 只包含 400 / 700 两个字重,500 / 600 由 CSS 字重匹配落到 400 / 700;主题的 Light(300)未包含。
|
||||
- 未实现参考站点的暗色模式(`data-scheme="dark"`)。
|
||||
- 字体文件随 `frontend/public/fonts` 一起进入安装包,体积增加约 6.7 MB。
|
||||
|
||||
## 后续调整(2026-10-26)
|
||||
|
||||
仓库托管的远程仓库对单次推送体积有上限,6.7 MB 的字体二进制无法一次推上去。因此:
|
||||
|
||||
- 中文字体二进制 **不再纳入版本库**(`/frontend/public/fonts/LXGWWenKai-*.woff2` 已加入 `.gitignore`);
|
||||
- 新增 `tools/build-fonts.py`,一条命令即可从官方 CDN 重新下载、合并并子集化生成这两个文件;
|
||||
- `@font-face` 仍然保留,字体缺失时浏览器回退到系统字体,功能不受影响;
|
||||
- 对应的端到端用例改为「无外部网络渲染」:始终断言没有外部请求,只有字体文件存在时才断言字体命中;
|
||||
- `Outfit-Medium.ttf`(55 KB)体积很小,继续随仓库提供。
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-0007:运行期可靠性优化
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-25
|
||||
|
||||
## 背景
|
||||
|
||||
代码评审与端到端自检暴露出五类问题,都会让"看起来正常"的功能在边界条件下失真:
|
||||
|
||||
1. 取消任务时执行器直接删除 `running` 句柄,而旧运行的收尾逻辑又无条件删除同任务句柄。取消后立刻重跑时,旧运行会把新运行的闸门删掉,跳过策略失效,同一任务可能并发执行。
|
||||
2. 调度器用 `JoinHandle::abort()` 取消循环。若恰好卡在 `executor.trigger()` 的数据库 await 中,`running` 表项和 `queued` 运行会永久残留,该任务此后永远被判定为"运行中"并跳过。
|
||||
3. 进程被强杀后,数据库里的 `queued`/`running` 运行没有对账,前端会一直显示"运行中"并持续轮询。
|
||||
4. `mark_running`/`complete` 不检查 `rows_affected`,运行记录被级联删除后收尾静默失效。
|
||||
5. 调度器只注册脚本任务,HTTP 与进程任务"启用"后返回 200 却永远不会被定时触发。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 句柄带运行 ID,收尾使用 `remove_running_if(task_id, run_id)`:只有仍属于本次运行的句柄才会被移除;取消时依旧立即释放闸门,允许马上重跑。
|
||||
2. 调度循环只用 `CancellationToken` 退出,不再 `abort`;`ScheduledTask` 的 `JoinHandle` 仅用于保活。
|
||||
3. 启动时执行 `fail_interrupted_runs`:把遗留的 `queued`/`running` 统一改为 `cancelled`,并写入"进程重启,运行被中断"。
|
||||
4. `mark_running`/`complete` 返回是否更新成功,执行器对 0 行更新记警告并同步清理内存句柄。
|
||||
5. 三类 Action 统一参与调度:`list_enabled_tasks` 不再按 `action_type` 过滤,`Scheduler::refresh` 只判断 `enabled`。
|
||||
6. SQLite 启用 WAL + `busy_timeout(5s)` + `synchronous=NORMAL`,避免执行器写日志时前端查询撞上 `database is locked`。
|
||||
7. 日志清理跳过正在写入的文件;单行输出上限 256 KiB,超出后截断并继续读行尾。
|
||||
8. 请求体上限 2 MiB;新增 `/api/v1/stats`(一次查询拿到全部计数)与 `/api/v1/schedule`(下次触发时间)。
|
||||
9. 前端缓存会话令牌,写请求收到 403 时丢弃缓存并重试一次(覆盖后端重启轮换令牌)。
|
||||
10. Python 任务默认注入 `PYTHONIOENCODING=utf-8`(用户显式设置优先),`PYTHONUNBUFFERED=1` 保持强制。
|
||||
|
||||
## 原因
|
||||
|
||||
- 句柄竞态与 `abort` 泄漏都会造成"任务不再执行"且无任何报错,是排障成本最高的一类缺陷。
|
||||
- 对账让崩溃恢复变成确定行为,前端轮询也能正常收敛。
|
||||
- 统一调度消除了"启用但永不触发"的假能力。
|
||||
- 统计与调度接口把仪表盘从 4 次请求降到 2 次,并让"下次执行时间"成为可见信息。
|
||||
|
||||
## 边界
|
||||
|
||||
- 取消后立即可重跑,意味着旧进程的终止与新进程的启动可能短暂重叠(取决于 Job Object 终止速度)。
|
||||
- 没有全局并发上限:多个任务在同一时刻触发会各自启动一个进程。
|
||||
- 对账只针对 `queued`/`running`,不会尝试恢复重试或补偿错过的调度。
|
||||
- 单行截断后该行内容不完整,日志里会多一条 `[system]` 说明。
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-0008:日志合并推送与虚拟滚动
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-27
|
||||
|
||||
## 背景
|
||||
|
||||
日志链路原本是"一行一个事件、一行一次渲染":
|
||||
|
||||
1. 子进程每输出一行,`LogSink::append` 就向 512 容量的 broadcast 通道发一条 `log` 事件。
|
||||
输出密集时事件按行堆积,SSE 订阅者很容易 `Lagged`,触发 `lagged` → 前端重新拉取,
|
||||
在日志最密集的时刻反而放大数据库与文件读取压力。
|
||||
2. 前端 `RunLogOutput` 把每条事件字符串拼到 `content` state,React 每次都要重渲染整个
|
||||
`<pre>` 文本;开销随行数线性增长,单次运行 5 MiB 上限意味着最坏情况下数万行 ×
|
||||
数万次更新,是典型的 O(n²)。
|
||||
3. 全部日志行都作为 DOM 文本存在,长日志下文本节点、布局与滚动都变得昂贵。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **后端按窗口合并**:`LogSink` 新增 `PendingBatch`,行仍然**立即写文件**(轮询读到的偏移不受影响),
|
||||
但 SSE 事件按"**攒满 16 KiB** 或 **满 100 ms**"合并成一条 `log` 事件,携带连续的
|
||||
`offset..next_offset`。
|
||||
2. **按时兜底**:每个 sink 起一个 50 ms tick 的后台任务,低输出量时也保证 100 ms 内推送;
|
||||
任务只持有 `Weak<LogSinkInner>`,sink 释放后自行退出。
|
||||
3. **析构 flush**:`Drop for LogSinkInner` 同步把残留批次发出去,运行结束时不会丢最后几行。
|
||||
4. **前端按帧合并**:增量先进行缓冲,`requestAnimationFrame` 内最多触发一次重渲染,
|
||||
并且在同一次渲染里把新行并入。
|
||||
5. **虚拟滚动**:`LogViewer` 以固定行高 20 px 换算可见区间,只渲染视口 ± 24 行的窗口;
|
||||
行内不折行(`white-space: pre`),超长行横向滚动,从而避免逐行测量高度。
|
||||
6. **跟随策略**:默认贴底跟随;用户上滚超过 24 px 即停止跟随并显示「回到最新」,
|
||||
点击后回到底部并恢复跟随。
|
||||
|
||||
## 原因
|
||||
|
||||
- 合并推送把事件数量从"每行一条"降到"每 100 ms 最多一条",SSE 背压与前端更新次数同时下降,
|
||||
而实时感没有可感知的损失(100 ms 窗口)。
|
||||
- 文件写入保持即时,轮询接口与下载接口的语义完全不变,合并只影响事件粒度。
|
||||
- 固定行高让滚动位置与行号一一对应,虚拟滚动不需要动态测量,代码量与出错面都小。
|
||||
- 行缓冲原地追加 + 一帧一次渲染,避免了复制整个字符串/数组的二次开销。
|
||||
|
||||
## 边界
|
||||
|
||||
- 合并意味着**事件粒度不再是行**:依赖"一条事件 = 一行"的消费者需要按 `content` 自行切分。
|
||||
- 不折行是显式取舍:超过面板宽度的行需要横向滚动;折行会让行高不定,虚拟滚动需要测量。
|
||||
- 单次运行仍受 5 MiB 总上限、单行 256 KiB 上限约束;虚拟滚动只解决渲染,不会保留更多日志。
|
||||
- `Drop` 里的 flush 是同步发送,若此时没有订阅者,这批事件不会被补发(轮询仍然能读到文件)。
|
||||
@@ -0,0 +1,32 @@
|
||||
# ADR-0009:uv 与托管 Python 环境
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-11-07
|
||||
|
||||
## 背景
|
||||
|
||||
Python 任务需要一个可复现的运行环境,但宿主机上的工具链状态不可控:PATH 上的 uv 版本随启动方式变化(终端 / 快捷方式 / 计划任务各不相同),也可能根本没装;pip、conda 与项目 `.venv` 的组合更是无法预设。之前的实现直接依赖 PATH 里的 uv,并要求用户先手动准备好虚拟环境,出了问题要到第一次运行时才暴露。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **uv 是唯一的环境工具**:建环境、装依赖、跑预装都走 uv,不依赖 `pip.exe`。
|
||||
2. **uv 来源只认平台副本或指定路径**:默认 `程序目录\uv\uv.exe`,或用户指定的 `uv.exe`;**不读系统 PATH**。缺失时从科大镜像 `mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease` 下载当前架构压缩包,校验 sha256 后安装到程序目录。
|
||||
3. **镜像只在装包时生效**:建环境 / 预装依赖时注入 `UV_DEFAULT_INDEX`(以及兼容的 `UV_INDEX_URL`);任务运行时不再注入 `PIP_*` 或镜像变量。
|
||||
4. **路径默认值相对程序目录**:`UV_PYTHON_INSTALL_DIR` 默认 `python`、`UV_CACHE_DIR` 默认 `uv-cache`,注入前统一按可执行文件目录解析成绝对路径;用户可改成绝对路径。
|
||||
5. **用户环境优先,缺失才创建**:解析顺序仍是显式 `venv` → 解释器路径推断 → 工作目录向上查找;都没有时在**保存任务**时用 uv 创建 `.venv`,运行期只校验、不再自动装依赖。
|
||||
6. **托管环境**:`data/envs/<名称>` 预创建常用环境,Python 版本来自 `uv python install` 下载到平台的列表,可预装包、查看已装包。
|
||||
7. **解释器下载归 uv 配置**:`uv python install` / `uv python list` 的入口放在「uv 配置」弹窗(解释器由 uv 管理、落在 `UV_PYTHON_INSTALL_DIR`);「虚拟环境」弹窗只负责从已下载列表里选版本创建环境。
|
||||
|
||||
## 原因
|
||||
|
||||
- PATH 上的 uv 版本与可用性不可控;平台副本随包分发,行为可预期。
|
||||
- 相对默认值让整个发布目录可以整体搬迁;注入前解析成绝对路径,是因为 uv 的 cwd 在调用间会变化(项目目录 / 环境目录),相对路径会漂。
|
||||
- 把装依赖限制在保存任务阶段,问题能当场暴露;运行期保持"只运行、不改环境"。
|
||||
- 单机场景下,用户自己调试好的虚拟环境就是最可信的运行环境,应当优先复用而不是覆盖。
|
||||
|
||||
## 边界
|
||||
|
||||
- 托管环境不会自动升级依赖,也不跟随 `pyproject.toml` 变更重建。
|
||||
- `uv python install` 走 uv 自己的 Python 发行源(python-build-standalone),和 PyPI 镜像不是一回事;uv 配置弹窗里配「下载镜像」统一给 uv 本体与解释器用,解释器还可以用 `UV_PYTHON_INSTALL_MIRROR` 单独覆盖。
|
||||
- 删除托管环境是直接删除目录,不可恢复。
|
||||
- 自定义 uv 若版本过旧,`uv python list/envs` 等子命令可能与平台预期的参数不一致。
|
||||
@@ -77,7 +77,7 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
// Cron 预览是异步渲染的,等它稳定后再点击,避免点击坐标在弹窗重排中失效。
|
||||
await expect(newTaskDialog.locator('.cron-run-item').first()).toBeVisible()
|
||||
await newTaskDialog.getByRole('button', { name: '选择文件夹…' }).click()
|
||||
const directoryPicker = page.getByRole('dialog', { name: '选择项目工作目录' })
|
||||
const directoryPicker = page.getByRole('dialog', { name: '选择工作目录' })
|
||||
await expect(directoryPicker).toBeVisible()
|
||||
const directoryPathInput = directoryPicker.getByLabel('目录路径')
|
||||
await directoryPathInput.fill(workingDir)
|
||||
@@ -140,7 +140,14 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
path: 'test-results/dashboard-log-drawer.png',
|
||||
fullPage: true,
|
||||
})
|
||||
await drawer.getByRole('button', { name: '关闭运行日志' }).click()
|
||||
await drawer.getByRole('button', { name: '关闭' }).click()
|
||||
|
||||
// 运行已结束时再打开日志,也要能回放(曾因 rAF 句柄未归零停在空态)。
|
||||
await page.getByRole('button', { name: '日志' }).click()
|
||||
const replayDrawer = page.getByRole('dialog', { name: 'E2E echo' })
|
||||
await expect(replayDrawer).toBeVisible()
|
||||
await expect(replayDrawer.getByText('e2e-output')).toBeVisible()
|
||||
await replayDrawer.getByRole('button', { name: '关闭' }).click()
|
||||
|
||||
await page.getByRole('button', { name: '编辑' }).click()
|
||||
const editor = page.getByRole('dialog', {
|
||||
@@ -161,10 +168,67 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
|
||||
await page.getByRole('button', { name: '删除' }).click()
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('dialog', { name: /删除任务/ })
|
||||
.getByRole('button', { name: '确认删除' })
|
||||
.click()
|
||||
await expect(page.getByRole('heading', { name: '还没有任务' })).toBeVisible()
|
||||
await expect(page.getByText('还没有任务')).toBeVisible()
|
||||
})
|
||||
|
||||
test('switches script language and adapts the form fields', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '+ 新建任务' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('button', { name: 'Python', exact: true }).click()
|
||||
await page.getByRole('option', { name: 'Shell (cmd)' }).click()
|
||||
await expect(dialog.getByText(/虚拟环境目录/)).toHaveCount(0)
|
||||
await expect(dialog.getByLabel('批处理文件或相对路径')).toHaveValue('run.bat')
|
||||
await expect(dialog.locator('.cli-line-cmd')).toContainText(
|
||||
'cmd.exe /C run.bat',
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Shell (cmd)' }).click()
|
||||
await page.getByRole('option', { name: 'Node.js' }).click()
|
||||
await expect(dialog.getByLabel('JavaScript 文件或相对路径')).toHaveValue(
|
||||
'index.js',
|
||||
)
|
||||
await expect(dialog.locator('.cli-line-cmd')).toContainText(
|
||||
'node.exe index.js',
|
||||
)
|
||||
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-script-language.png',
|
||||
fullPage: true,
|
||||
})
|
||||
await dialog.getByRole('button', { name: '取消' }).click()
|
||||
})
|
||||
|
||||
test('avoids a transient invalid venv when switching directories', async ({
|
||||
page,
|
||||
}) => {
|
||||
const venvDir = createVenvFixture()
|
||||
const emptyProject = join(venvDir, '..', 'empty-project')
|
||||
mkdirSync(emptyProject, { recursive: true })
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '+ 新建任务' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
|
||||
const workingDir = dialog.getByLabel(/项目工作目录/)
|
||||
const bar = dialog.locator('.env-detector-bar')
|
||||
|
||||
await workingDir.fill(venvDir)
|
||||
await expect(bar).toContainText('已关联虚拟环境')
|
||||
|
||||
await workingDir.fill(emptyProject)
|
||||
const states = new Set<string>()
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
states.add(await bar.innerText())
|
||||
await page.waitForTimeout(80)
|
||||
}
|
||||
|
||||
expect([...states].some((text) => text.includes('缺少'))).toBe(false)
|
||||
await expect(bar).toContainText('未检测到虚拟环境')
|
||||
})
|
||||
|
||||
test('keeps the dashboard usable on a narrow viewport', async ({
|
||||
@@ -221,3 +285,152 @@ test('cancels a running task from the log drawer', async ({
|
||||
await drawer.getByRole('button', { name: '取消运行' }).click()
|
||||
await expect(drawer.locator('.status-cancelled').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders without external network access', async ({ page }) => {
|
||||
const blocked: string[] = []
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url()
|
||||
const isLocal =
|
||||
url.startsWith('http://127.0.0.1:5173') ||
|
||||
url.startsWith('http://localhost:5173') ||
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('blob:')
|
||||
if (isLocal) {
|
||||
return route.continue()
|
||||
}
|
||||
blocked.push(url)
|
||||
return route.abort()
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '任务调度' }),
|
||||
).toBeVisible()
|
||||
|
||||
const state = await page.evaluate(async () => {
|
||||
// 字体二进制不入库;未生成时应回退系统字体,而不是报错。
|
||||
const probe = await fetch('/fonts/LXGWWenKai-Regular.woff2', {
|
||||
method: 'HEAD',
|
||||
})
|
||||
await document.fonts.ready
|
||||
return {
|
||||
wenkaiAvailable: probe.ok,
|
||||
wenkaiLoaded: document.fonts.check('14px "LXGW WenKai"'),
|
||||
displayLoaded: document.fonts.check('500 26px "Outfit-Medium"'),
|
||||
}
|
||||
})
|
||||
|
||||
expect(blocked).toEqual([])
|
||||
expect(state.displayLoaded).toBe(true)
|
||||
if (state.wenkaiAvailable) {
|
||||
expect(state.wenkaiLoaded).toBe(true)
|
||||
}
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-offline.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps a long run log scrollable without rendering every line', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await authHeaders(request)
|
||||
await request.delete('/api/v1/tasks/e2e_long_log', { headers })
|
||||
const response = await request.post('/api/v1/tasks', {
|
||||
headers,
|
||||
data: {
|
||||
...taskPayload,
|
||||
id: 'e2e_long_log',
|
||||
name: 'Long log task',
|
||||
timeout_secs: 60,
|
||||
action: {
|
||||
...taskPayload.action,
|
||||
args: ['for /L %i in (1,1,1500) do @echo line-%i'],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.status()).toBe(201)
|
||||
|
||||
await page.goto('/')
|
||||
await page
|
||||
.getByRole('row', { name: /Long log task/ })
|
||||
.getByRole('button', { name: '运行' })
|
||||
.click()
|
||||
const drawer = page.getByRole('dialog', { name: 'Long log task' })
|
||||
await expect(drawer).toBeVisible()
|
||||
await expect(drawer.locator('.status-succeeded').first()).toBeVisible({
|
||||
timeout: 60_000,
|
||||
})
|
||||
// 自动跟随到底部,最后几行在视口内。
|
||||
await expect(drawer.getByText('line-1500')).toBeVisible()
|
||||
|
||||
// 虚拟滚动:DOM 中只保留视口附近的少量行,而不是 1500 行全量渲染。
|
||||
const rendered = await drawer.locator('.log-line').count()
|
||||
expect(rendered).toBeLessThan(200)
|
||||
expect(rendered).toBeGreaterThan(5)
|
||||
|
||||
await drawer.locator('.log-viewer').evaluate((node) => {
|
||||
node.scrollTop = node.scrollHeight / 2
|
||||
})
|
||||
const jump = drawer.getByRole('button', { name: '回到最新 ↓' })
|
||||
await expect(jump).toBeVisible()
|
||||
const logBox = await drawer.locator('.log-viewer').boundingBox()
|
||||
const jumpBox = await jump.boundingBox()
|
||||
expect(logBox).not.toBeNull()
|
||||
expect(jumpBox).not.toBeNull()
|
||||
if (logBox && jumpBox) {
|
||||
// 按钮必须落在日志面板可视范围内,而不是被面板裁剪到外面。
|
||||
expect(jumpBox.y + jumpBox.height).toBeLessThanOrEqual(
|
||||
logBox.y + logBox.height + 1,
|
||||
)
|
||||
}
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-log-drawer-long.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('manages python package mirrors', async ({ page, request }) => {
|
||||
const headers = await authHeaders(request)
|
||||
|
||||
// 失败重跑时先清理上一次留下的测试镜像,并把默认源还原。
|
||||
const existing = await request.get('/api/v1/python/mirrors')
|
||||
expect(existing.ok()).toBeTruthy()
|
||||
const existingMirrors = (await existing.json()) as Array<{
|
||||
id: string
|
||||
index_url: string
|
||||
}>
|
||||
for (const mirror of existingMirrors) {
|
||||
if (mirror.index_url.includes('pypi.e2e.example')) {
|
||||
await request.delete(`/api/v1/python/mirrors/${mirror.id}`, { headers })
|
||||
}
|
||||
}
|
||||
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
|
||||
|
||||
await page.goto('/mirrors')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Python 镜像' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: /清华 TUNA/ })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '+ 新增镜像' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新增 Python 镜像' })
|
||||
await dialog.getByLabel('名称').fill('E2E 镜像')
|
||||
await dialog.getByLabel('索引地址').fill('https://pypi.e2e.example/simple')
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
|
||||
const row = page.getByRole('row', { name: /E2E 镜像/ })
|
||||
await expect(row).toBeVisible()
|
||||
await row.getByRole('button', { name: '设为默认' }).click()
|
||||
await expect(row.getByText('默认', { exact: true })).toBeVisible()
|
||||
|
||||
await row.getByRole('button', { name: '删除' }).click()
|
||||
await page
|
||||
.getByRole('dialog', { name: /删除镜像/ })
|
||||
.getByRole('button', { name: '确认删除' })
|
||||
.click()
|
||||
await expect(row).toHaveCount(0)
|
||||
|
||||
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
|
||||
})
|
||||
|
||||
+5
-4
@@ -7,11 +7,12 @@
|
||||
name="description"
|
||||
content="TaskPilot - Windows 本地定时任务管理平台"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
rel="preload"
|
||||
href="/fonts/Outfit-Medium.ttf"
|
||||
as="font"
|
||||
type="font/ttf"
|
||||
crossorigin
|
||||
/>
|
||||
<title>TaskPilot</title>
|
||||
</head>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.9.2",
|
||||
"@stash/ui": "file:../../stash-ui",
|
||||
"@tanstack/react-query": "^5.104.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
|
||||
Generated
+14
@@ -11,6 +11,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2([email protected](@types/[email protected])([email protected]))([email protected])
|
||||
'@stash/ui':
|
||||
specifier: file:../../stash-ui
|
||||
version: file:../../stash-ui([email protected]([email protected]))([email protected])
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.104.3
|
||||
version: 5.104.3([email protected])
|
||||
@@ -477,6 +480,12 @@ packages:
|
||||
'@standard-schema/[email protected]':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@stash/ui@file:../../stash-ui':
|
||||
resolution: {directory: ../../stash-ui, type: directory}
|
||||
peerDependencies:
|
||||
react: '>=19'
|
||||
react-dom: '>=19'
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
|
||||
|
||||
@@ -1361,6 +1370,11 @@ snapshots:
|
||||
|
||||
'@standard-schema/[email protected]': {}
|
||||
|
||||
'@stash/ui@file:../../stash-ui([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
|
||||
'@tanstack/[email protected]': {}
|
||||
|
||||
'@tanstack/[email protected]([email protected])':
|
||||
|
||||
Binary file not shown.
+1295
-1005
File diff suppressed because it is too large
Load Diff
+560
-18
@@ -9,15 +9,11 @@ import App from './App'
|
||||
const task: Task = {
|
||||
id: 'echo_task',
|
||||
name: 'Echo task',
|
||||
description: 'Echo the configured message',
|
||||
cron: '0 0 2 * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
enabled: true,
|
||||
timeout_secs: 300,
|
||||
concurrency_policy: 'skip',
|
||||
retry_policy: {
|
||||
max_attempts: 0,
|
||||
delay_secs: 0,
|
||||
},
|
||||
action: {
|
||||
type: 'script',
|
||||
working_dir: 'D:\\Projects\\echo',
|
||||
@@ -46,9 +42,74 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
|
||||
interface FetchMockOptions {
|
||||
directories?: (path: string | null) => Response
|
||||
envs?: Array<{
|
||||
name: string
|
||||
path: string
|
||||
python_version: string | null
|
||||
}>
|
||||
envPackages?: Record<string, Array<{ name: string; version: string }>>
|
||||
pythons?: Array<{ version: string; path: string }>
|
||||
/** 模拟「程序目录 uv 已安装」时的版本号,例如 `uv 0.9.0` */
|
||||
uvVersion?: string
|
||||
}
|
||||
|
||||
function uvSettingsPayload(
|
||||
source: string,
|
||||
customPath: string | null,
|
||||
managedVersion: string | null = null,
|
||||
mirrorUrl: string | null = null,
|
||||
) {
|
||||
const mirrorBase = mirrorUrl ?? 'https://mirrors.ustc.edu.cn/github-release'
|
||||
return {
|
||||
source,
|
||||
custom_path: customPath,
|
||||
extra_env: {},
|
||||
env_defaults: {
|
||||
UV_CACHE_DIR: 'uv-cache',
|
||||
UV_PYTHON_INSTALL_DIR: 'python',
|
||||
UV_PYTHON_INSTALL_MIRROR: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`,
|
||||
},
|
||||
managed_dir: 'D:\\TaskPilot\\uv',
|
||||
python_dir: 'D:\\TaskPilot\\python',
|
||||
cache_dir: 'D:\\TaskPilot\\uv-cache',
|
||||
mirror_url: mirrorBase,
|
||||
uv_download_url: `${mirrorBase}/astral-sh/uv/LatestRelease`,
|
||||
python_mirror_url: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`,
|
||||
executable:
|
||||
source === 'managed'
|
||||
? 'D:\\TaskPilot\\uv\\uv.exe'
|
||||
: (customPath ?? 'C:\\tools\\uv\\uv.exe'),
|
||||
version: source === 'managed' ? managedVersion : 'uv 0.9.0',
|
||||
candidates: [
|
||||
{
|
||||
source: 'managed',
|
||||
path: 'D:\\TaskPilot\\uv\\uv.exe',
|
||||
available: managedVersion !== null,
|
||||
version: managedVersion,
|
||||
},
|
||||
...(customPath
|
||||
? [
|
||||
{
|
||||
source: 'custom',
|
||||
path: customPath,
|
||||
available: true,
|
||||
version: 'uv 0.9.0',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createFetchMock(options: FetchMockOptions = {}) {
|
||||
let uvSource = 'managed'
|
||||
let uvCustomPath: string | null = null
|
||||
let uvManagedVersion: string | null = options.uvVersion ?? null
|
||||
let uvMirror: string | null = null
|
||||
let managedEnvs = options.envs ?? []
|
||||
let managedPythons = options.pythons ?? []
|
||||
const managedPackages = options.envPackages ?? {}
|
||||
|
||||
return vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
@@ -68,6 +129,114 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return jsonResponse({ token: 'test-token' })
|
||||
}
|
||||
if (url.endsWith('/api/v1/stats')) {
|
||||
return jsonResponse({
|
||||
total: 1,
|
||||
enabled: 1,
|
||||
running: 0,
|
||||
script: 1,
|
||||
http: 0,
|
||||
process: 0,
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/schedule')) {
|
||||
return jsonResponse([
|
||||
{ task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' },
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/mirrors')) {
|
||||
return jsonResponse([
|
||||
{
|
||||
id: 'tsinghua',
|
||||
name: '清华 TUNA',
|
||||
index_url: 'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
trusted_host: null,
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
created_at: '2026-10-18T10:00:00Z',
|
||||
updated_at: '2026-10-18T10:00:00Z',
|
||||
},
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/pythons') && init?.method === 'POST') {
|
||||
const request = JSON.parse(String(init.body)) as { version: string }
|
||||
const path = `D:\\TaskPilot\\python\\cpython-${request.version}-windows-x86_64-none\\python.exe`
|
||||
if (!managedPythons.some((item) => item.version === request.version)) {
|
||||
managedPythons = [
|
||||
...managedPythons,
|
||||
{ version: request.version, path },
|
||||
]
|
||||
}
|
||||
return jsonResponse(managedPythons)
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/pythons')) {
|
||||
return jsonResponse(managedPythons)
|
||||
}
|
||||
if (url.includes('/api/v1/python/envs/') && url.endsWith('/packages')) {
|
||||
const name = decodeURIComponent(
|
||||
url.split('/api/v1/python/envs/')[1].replace('/packages', ''),
|
||||
)
|
||||
return jsonResponse(managedPackages[name] ?? [])
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/envs') && init?.method === 'POST') {
|
||||
const request = JSON.parse(String(init.body)) as {
|
||||
name: string
|
||||
python_version?: string | null
|
||||
}
|
||||
const created = {
|
||||
name: request.name,
|
||||
path: `D:\\TaskPilot\\data\\envs\\${request.name}`,
|
||||
python_version: request.python_version ?? '3.11.11',
|
||||
}
|
||||
managedEnvs = [...managedEnvs, created]
|
||||
return jsonResponse(created, 201)
|
||||
}
|
||||
if (url.includes('/api/v1/python/envs/') && init?.method === 'DELETE') {
|
||||
const name = decodeURIComponent(
|
||||
url.split('/api/v1/python/envs/')[1],
|
||||
)
|
||||
managedEnvs = managedEnvs.filter((env) => env.name !== name)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/envs')) {
|
||||
return jsonResponse(managedEnvs)
|
||||
}
|
||||
if (url.endsWith('/api/v1/uv/download')) {
|
||||
uvSource = 'managed'
|
||||
uvManagedVersion = 'uv 0.9.0'
|
||||
return jsonResponse(
|
||||
uvSettingsPayload(
|
||||
uvSource,
|
||||
uvCustomPath,
|
||||
uvManagedVersion,
|
||||
uvMirror,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/api/v1/uv')) {
|
||||
const request = init?.body
|
||||
? (JSON.parse(String(init.body)) as {
|
||||
source: string
|
||||
custom_path?: string | null
|
||||
mirror_url?: string | null
|
||||
})
|
||||
: null
|
||||
if (request) {
|
||||
uvSource = request.source
|
||||
uvCustomPath = request.custom_path ?? null
|
||||
if (request.mirror_url !== undefined) {
|
||||
uvMirror = request.mirror_url
|
||||
}
|
||||
}
|
||||
return jsonResponse(
|
||||
uvSettingsPayload(
|
||||
uvSource,
|
||||
uvCustomPath,
|
||||
uvManagedVersion,
|
||||
uvMirror,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/api/v1/cron/preview')) {
|
||||
return jsonResponse({
|
||||
timezone: 'Asia/Shanghai',
|
||||
@@ -77,11 +246,27 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
],
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/venv/create')) {
|
||||
return jsonResponse({
|
||||
status: 'linked',
|
||||
interpreter_kind: 'python',
|
||||
venv_path: 'D:\\Projects\\uvproject\\.venv',
|
||||
display_path: '.venv\\Scripts\\python.exe',
|
||||
source: 'working_directory',
|
||||
message: '已创建虚拟环境',
|
||||
uv_available: true,
|
||||
has_pyproject: true,
|
||||
has_requirements: false,
|
||||
requirements_files: [],
|
||||
can_create: false,
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/venv')) {
|
||||
const probe = JSON.parse(String(init?.body)) as {
|
||||
working_dir: string
|
||||
}
|
||||
const linked = probe.working_dir.toLowerCase().includes('sync')
|
||||
const creatable = probe.working_dir.toLowerCase().includes('uvproject')
|
||||
return jsonResponse({
|
||||
status: linked ? 'linked' : 'missing',
|
||||
interpreter_kind: 'python',
|
||||
@@ -90,7 +275,14 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
source: linked ? 'working_directory' : null,
|
||||
message: linked
|
||||
? '已自动关联工作目录中的虚拟环境'
|
||||
: '未检测到虚拟环境,将直接使用系统解释器',
|
||||
: creatable
|
||||
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
|
||||
: '未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv',
|
||||
uv_available: creatable,
|
||||
has_pyproject: creatable,
|
||||
has_requirements: false,
|
||||
requirements_files: [],
|
||||
can_create: creatable,
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/system/directories')) {
|
||||
@@ -146,10 +338,342 @@ describe('TaskDashboard', () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
renderApp()
|
||||
|
||||
expect(await screen.findByText('Echo task')).toBeInTheDocument()
|
||||
expect(screen.getByText('echo_task')).toBeInTheDocument()
|
||||
// 任务名会同时出现在卡片和左下角「最近触发」里
|
||||
expect((await screen.findAllByText('Echo task')).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getByText('Echo the configured message'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument()
|
||||
expect(await screen.findByText('在线')).toBeInTheDocument()
|
||||
// 顶部品牌栏和用户卡片都会显示引擎状态
|
||||
expect((await screen.findAllByText('引擎在线')).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('opens the uv settings dialog from the right column', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 uv' }),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'uv 配置' }),
|
||||
).toBeInTheDocument()
|
||||
// 三段配置改用标签页切换:可执行文件 / 环境变量 / Python 解释器
|
||||
expect(screen.getByRole('tab', { name: 'uv 可执行文件' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: /环境变量/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: /Python 解释器/ })).toBeInTheDocument()
|
||||
// 顶部不再重复首页已有的版本 / 数量 / 镜像地址
|
||||
expect(screen.queryByText(/uv 下载/)).toBeNull()
|
||||
})
|
||||
|
||||
it('saves the uv download mirror from the uv dialog', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
|
||||
const input = await screen.findByLabelText('下载镜像')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'https://mirror.example/github-release')
|
||||
await user.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([request, init]) =>
|
||||
String(request).endsWith('/api/v1/uv') && init?.method === 'PUT',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
const saved = fetchMock.mock.calls.find(
|
||||
([request, init]) =>
|
||||
String(request).endsWith('/api/v1/uv') && init?.method === 'PUT',
|
||||
)
|
||||
expect(JSON.parse(String(saved?.[1]?.body)).mirror_url).toBe(
|
||||
'https://mirror.example/github-release',
|
||||
)
|
||||
})
|
||||
|
||||
it('saves the uv executable and env vars from the uv dialog', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '下载 uv' }),
|
||||
).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('radio', { name: '指定路径' }))
|
||||
await user.type(
|
||||
screen.getByLabelText('uv 可执行文件'),
|
||||
'D:\\tools\\uv.exe',
|
||||
)
|
||||
// 环境变量在第二个标签页里
|
||||
await user.click(screen.getByRole('tab', { name: /环境变量/ }))
|
||||
// 三个关键变量(两个路径 + 解释器镜像)固定在列表里,不能删除
|
||||
const uvDialog = await screen.findByRole('dialog', { name: 'uv 配置' })
|
||||
expect(
|
||||
within(uvDialog).queryByRole('button', { name: '删除' }),
|
||||
).toBeNull()
|
||||
await user.click(screen.getByRole('button', { name: '+ 添加变量' }))
|
||||
await user.type(screen.getByLabelText('环境变量名 4'), 'UV_HTTP_TIMEOUT')
|
||||
await user.type(screen.getByLabelText('环境变量值 4'), '60')
|
||||
await user.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/uv') && init?.method === 'PUT',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
const saved = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/uv') && init?.method === 'PUT',
|
||||
)
|
||||
expect(JSON.parse(String(saved?.[1]?.body))).toEqual({
|
||||
source: 'custom',
|
||||
custom_path: 'D:\\tools\\uv.exe',
|
||||
extra_env: { UV_HTTP_TIMEOUT: '60' },
|
||||
mirror_url: 'https://mirrors.ustc.edu.cn/github-release',
|
||||
})
|
||||
})
|
||||
|
||||
it('picks the uv executable through the file browser', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
createFetchMock({
|
||||
directories: (path) =>
|
||||
jsonResponse(
|
||||
path
|
||||
? {
|
||||
path,
|
||||
parent: 'C:\\',
|
||||
entries: [
|
||||
{
|
||||
name: 'uv.exe',
|
||||
path: `${path}${path.endsWith('\\') ? '' : '\\'}uv.exe`,
|
||||
is_dir: false,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
}
|
||||
: {
|
||||
path: null,
|
||||
parent: null,
|
||||
entries: [{ name: 'C:\\', path: 'C:\\', is_dir: true }],
|
||||
truncated: false,
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
|
||||
await user.click(screen.getByRole('radio', { name: '指定路径' }))
|
||||
await user.click(screen.getByRole('button', { name: '浏览' }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '选择 uv 可执行文件' }),
|
||||
).toBeInTheDocument()
|
||||
await user.click(await screen.findByRole('button', { name: 'C:\\' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'uv.exe' }))
|
||||
await user.click(screen.getByRole('button', { name: '使用此文件' }))
|
||||
|
||||
expect(screen.getByLabelText('uv 可执行文件')).toHaveValue('C:\\uv.exe')
|
||||
})
|
||||
|
||||
it('downloads uv from the mirror and switches to it', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
|
||||
await user.click(await screen.findByRole('button', { name: '下载 uv' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/uv/download') &&
|
||||
init?.method === 'POST',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
expect((await screen.findAllByText('0.9.0')).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('downloads a python version from the uv dialog', async () => {
|
||||
const fetchMock = createFetchMock({ uvVersion: 'uv 0.9.0' })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
|
||||
await user.click(screen.getByRole('tab', { name: /Python 解释器/ }))
|
||||
await user.type(screen.getByLabelText('要下载的 Python 版本'), '3.12')
|
||||
await user.click(screen.getByRole('button', { name: '下载 Python' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/python/pythons') &&
|
||||
init?.method === 'POST',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
const installed = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/python/pythons') &&
|
||||
init?.method === 'POST',
|
||||
)
|
||||
expect(JSON.parse(String(installed?.[1]?.body))).toEqual({
|
||||
version: '3.12',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a managed python environment with prefetched packages', async () => {
|
||||
const fetchMock = createFetchMock({
|
||||
pythons: [
|
||||
{
|
||||
version: '3.11.11',
|
||||
path: 'D:\\TaskPilot\\python\\cpython-3.11.11-windows-x86_64-none\\python.exe',
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '管理' }))
|
||||
const envDialog = await screen.findByRole('dialog', {
|
||||
name: '虚拟环境管理',
|
||||
})
|
||||
expect(
|
||||
await within(envDialog).findByText(/还没有预创建的环境/),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建环境' }))
|
||||
await user.type(screen.getByLabelText('环境名'), 'data-science')
|
||||
await user.click(screen.getByRole('button', { name: '默认(uv 自动选)' }))
|
||||
await user.click(await screen.findByRole('option', { name: '3.11.11' }))
|
||||
await user.type(
|
||||
screen.getByLabelText(/预装包/),
|
||||
'numpy==2.1.0{enter}pandas',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: '创建' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/python/envs') &&
|
||||
init?.method === 'POST',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
const created = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/python/envs') &&
|
||||
init?.method === 'POST',
|
||||
)
|
||||
expect(JSON.parse(String(created?.[1]?.body))).toEqual({
|
||||
name: 'data-science',
|
||||
python_version: '3.11.11',
|
||||
packages: ['numpy==2.1.0', 'pandas'],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows installed packages of a managed environment', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
createFetchMock({
|
||||
envs: [
|
||||
{
|
||||
name: 'data-science',
|
||||
path: 'D:\\TaskPilot\\data\\envs\\data-science',
|
||||
python_version: '3.11.11',
|
||||
},
|
||||
],
|
||||
envPackages: {
|
||||
'data-science': [{ name: 'numpy', version: '2.1.0' }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '管理' }))
|
||||
await user.click(await screen.findByRole('button', { name: '查看包' }))
|
||||
|
||||
expect(await screen.findByText('numpy')).toBeInTheDocument()
|
||||
expect(screen.getByText('2.1.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates the python environment before saving a new task', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.clear(screen.getByLabelText(/任务标识/))
|
||||
await user.type(screen.getByLabelText(/任务标识/), 'uv_env_task')
|
||||
await user.type(screen.getByLabelText('名称'), 'uv env task')
|
||||
await user.type(
|
||||
screen.getByLabelText(/项目工作目录/),
|
||||
'D:\\Projects\\uvproject',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: '保存并热刷新' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const requests = fetchMock.mock.calls.map(([input, init]) => ({
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
}))
|
||||
const venvIndex = requests.findIndex((request) =>
|
||||
request.url.endsWith('/api/v1/python/venv/create'),
|
||||
)
|
||||
const taskIndex = requests.findIndex(
|
||||
(request) =>
|
||||
request.url.endsWith('/api/v1/tasks') && request.method === 'POST',
|
||||
)
|
||||
expect(venvIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(taskIndex).toBeGreaterThan(venvIndex)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the task details from the card', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '详情' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '任务详情' })
|
||||
expect(within(dialog).getByText('echo_task')).toBeInTheDocument()
|
||||
expect(
|
||||
within(dialog).getByText('Echo the configured message'),
|
||||
).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('D:\\Projects\\echo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the next run time reported by the schedule endpoint', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
renderApp()
|
||||
|
||||
// 卡片里直接显示排好的触发时间(MM/DD HH:mm)
|
||||
expect(
|
||||
(await screen.findAllByText(/^\d{2}\/\d{2} \d{2}:\d{2}$/)).length,
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('creates a script task with the visible form values', async () => {
|
||||
@@ -211,7 +735,7 @@ describe('TaskDashboard', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the system interpreter when no venv is detected', async () => {
|
||||
it('shows an error when no venv or dependency files exist', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
@@ -223,11 +747,29 @@ describe('TaskDashboard', () => {
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/使用系统解释器:/, {}, { timeout: 3000 }),
|
||||
await screen.findByText(/未检测到虚拟环境;uv 不可用/, {}, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers uv environment creation when pyproject.toml exists', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.type(
|
||||
screen.getByLabelText(/项目工作目录/),
|
||||
'D:\\Projects\\uvproject',
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
|
||||
await screen.findByRole('button', { name: '用 uv 创建环境' }, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('pyproject.toml')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('browses folders in the page and fills the working directory', async () => {
|
||||
@@ -277,7 +819,7 @@ describe('TaskDashboard', () => {
|
||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择项目工作目录',
|
||||
name: '选择工作目录',
|
||||
})
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
|
||||
@@ -285,7 +827,7 @@ describe('TaskDashboard', () => {
|
||||
expect(within(dialog).getByText('D:\\Projects\\sync')).toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('button', { name: '使用此目录' }))
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: '选择工作目录' })).toBeNull()
|
||||
expect(screen.getByLabelText(/项目工作目录/)).toHaveValue(
|
||||
'D:\\Projects\\sync',
|
||||
)
|
||||
@@ -324,7 +866,7 @@ describe('TaskDashboard', () => {
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择项目工作目录',
|
||||
name: '选择工作目录',
|
||||
})
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
|
||||
@@ -358,12 +900,12 @@ describe('TaskDashboard', () => {
|
||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择项目工作目录',
|
||||
name: '选择工作目录',
|
||||
})
|
||||
await user.click(within(dialog).getByRole('button', { name: '取消' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull(),
|
||||
expect(screen.queryByRole('dialog', { name: '选择工作目录' })).toBeNull(),
|
||||
)
|
||||
expect(input).toHaveValue('D:\\Manual\\path')
|
||||
})
|
||||
@@ -391,7 +933,7 @@ describe('TaskDashboard', () => {
|
||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择项目工作目录',
|
||||
name: '选择工作目录',
|
||||
})
|
||||
expect(
|
||||
await within(dialog).findByText('目录不存在: Z:\\nope'),
|
||||
|
||||
+27
-17
@@ -1,30 +1,40 @@
|
||||
import { Link, Route, Routes } from 'react-router-dom'
|
||||
import { Button, Layout, Result } from '@stash/ui'
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom'
|
||||
import { AppHeader } from './components/AppHeader'
|
||||
import { TaskDashboard } from './pages/TaskDashboard'
|
||||
import './App.css'
|
||||
|
||||
function NotFoundPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<p className="eyebrow">404 / ROUTE NOT FOUND</p>
|
||||
<h1>页面不存在</h1>
|
||||
<p className="subtitle">该地址尚未配置到 TaskPilot 路由。</p>
|
||||
</div>
|
||||
</header>
|
||||
<Link className="button button-primary" to="/">
|
||||
返回任务列表
|
||||
</Link>
|
||||
</main>
|
||||
<div className="page-stack">
|
||||
<Result
|
||||
status="info"
|
||||
title="页面不存在"
|
||||
description="该地址尚未配置到 TaskPilot 路由。"
|
||||
actions={<Button onClick={() => navigate('/')}>返回任务列表</Button>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskDashboard />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
<Layout
|
||||
className="app-root"
|
||||
header={<AppHeader />}
|
||||
headerVariant="solid"
|
||||
maxWidth="full"
|
||||
contentPadding="none"
|
||||
>
|
||||
<div className="page-container">
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskDashboard />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+195
-19
@@ -18,12 +18,32 @@ export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
|
||||
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
|
||||
export type VenvProbeRequest = components['schemas']['VenvProbeRequest']
|
||||
export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
|
||||
export type CreatePythonVenvRequest =
|
||||
components['schemas']['CreatePythonVenvRequest']
|
||||
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
|
||||
export type VenvSource = components['schemas']['VenvSource']
|
||||
export type InterpreterKind = components['schemas']['InterpreterKind']
|
||||
export type DirectoryEntry = components['schemas']['DirectoryEntry']
|
||||
export type DirectoryListingResponse =
|
||||
components['schemas']['DirectoryListingResponse']
|
||||
export type TaskStats = components['schemas']['TaskStatsResponse']
|
||||
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
|
||||
export type PythonMirror = components['schemas']['PythonMirrorResponse']
|
||||
export type CreatePythonMirrorRequest =
|
||||
components['schemas']['CreatePythonMirrorRequest']
|
||||
export type UpdatePythonMirrorRequest =
|
||||
components['schemas']['UpdatePythonMirrorRequest']
|
||||
export type UvSettings = components['schemas']['UvSettingsResponse']
|
||||
export type UvCandidate = components['schemas']['UvCandidate']
|
||||
export type UvSource = components['schemas']['UvSource']
|
||||
export type UpdateUvSettingsRequest =
|
||||
components['schemas']['UpdateUvSettingsRequest']
|
||||
export type ManagedEnv = components['schemas']['ManagedEnvSummary']
|
||||
export type ManagedEnvPackage = components['schemas']['ManagedEnvPackage']
|
||||
export type CreateManagedEnvRequest =
|
||||
components['schemas']['CreateManagedEnvRequest']
|
||||
export type ManagedPython = components['schemas']['ManagedPython']
|
||||
export type InstallPythonRequest = components['schemas']['InstallPythonRequest']
|
||||
|
||||
export interface TaskFilters {
|
||||
q?: string
|
||||
@@ -77,20 +97,27 @@ async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
const method = (init.method ?? 'GET').toUpperCase()
|
||||
if (init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
headers.set('X-TaskPilot-Token', await fetchSessionToken())
|
||||
const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method)
|
||||
|
||||
const send = async (): Promise<Response> => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
if (init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (mutating) {
|
||||
headers.set('X-TaskPilot-Token', await fetchSessionToken())
|
||||
}
|
||||
return fetch(path, { ...init, headers })
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
let response = await send()
|
||||
if (mutating && response.status === 403) {
|
||||
// 后端重启会轮换管理令牌:丢弃缓存后重试一次。
|
||||
invalidateSessionToken()
|
||||
response = await send()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: ErrorResponse | undefined
|
||||
@@ -108,15 +135,34 @@ async function requestJson<T>(
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
let cachedSessionToken: string | null = null
|
||||
let pendingSessionToken: Promise<string> | null = null
|
||||
|
||||
export function invalidateSessionToken(): void {
|
||||
cachedSessionToken = null
|
||||
}
|
||||
|
||||
async function fetchSessionToken(): Promise<string> {
|
||||
const response = await fetch('/api/v1/session', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(response.status)
|
||||
if (cachedSessionToken) {
|
||||
return cachedSessionToken
|
||||
}
|
||||
const session = (await response.json()) as SessionResponse
|
||||
return session.token
|
||||
if (!pendingSessionToken) {
|
||||
pendingSessionToken = fetch('/api/v1/session', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(response.status)
|
||||
}
|
||||
const session = (await response.json()) as SessionResponse
|
||||
cachedSessionToken = session.token
|
||||
return session.token
|
||||
})
|
||||
.finally(() => {
|
||||
pendingSessionToken = null
|
||||
})
|
||||
}
|
||||
return pendingSessionToken
|
||||
}
|
||||
|
||||
function withQuery(path: string, values: object): string {
|
||||
@@ -252,16 +298,146 @@ export function probePythonVenv(
|
||||
})
|
||||
}
|
||||
|
||||
/** 用 uv 为项目创建/同步 .venv;优先复用已有环境。 */
|
||||
export function createPythonVenv(
|
||||
request: CreatePythonVenvRequest,
|
||||
): Promise<VenvProbeResponse> {
|
||||
return requestJson<VenvProbeResponse>('/api/v1/python/venv/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
||||
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
||||
* `includeFiles` 为 true 时一并返回文件(挑选 uv.exe 这类可执行文件时用)。
|
||||
*/
|
||||
export function listDirectories(
|
||||
path?: string,
|
||||
options: { includeFiles?: boolean } = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<DirectoryListingResponse> {
|
||||
return requestJson<DirectoryListingResponse>(
|
||||
withQuery('/api/v1/system/directories', { path }),
|
||||
withQuery('/api/v1/system/directories', {
|
||||
path,
|
||||
include_files: options.includeFiles ? 'true' : undefined,
|
||||
}),
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
/** 仪表盘计数:一次请求拿到全部指标。 */
|
||||
export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
|
||||
return requestJson<TaskStats>('/api/v1/stats', { signal })
|
||||
}
|
||||
|
||||
/** 已启用任务的下次触发时间。 */
|
||||
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
|
||||
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
|
||||
}
|
||||
|
||||
/** Python 包镜像(默认镜像会在脚本任务运行时注入 pip / uv 环境变量)。 */
|
||||
export function listPythonMirrors(
|
||||
signal?: AbortSignal,
|
||||
): Promise<PythonMirror[]> {
|
||||
return requestJson<PythonMirror[]>('/api/v1/python/mirrors', { signal })
|
||||
}
|
||||
|
||||
export function createPythonMirror(
|
||||
request: CreatePythonMirrorRequest,
|
||||
): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>('/api/v1/python/mirrors', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePythonMirror(
|
||||
id: string,
|
||||
request: UpdatePythonMirrorRequest,
|
||||
): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>(`/api/v1/python/mirrors/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePythonMirror(id: string): Promise<void> {
|
||||
return requestJson<void>(`/api/v1/python/mirrors/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>(
|
||||
`/api/v1/python/mirrors/${id}/default`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
/** uv 设置:只用程序目录 uv 或自己指定的可执行文件(不读系统 PATH)。 */
|
||||
export function getUvSettings(signal?: AbortSignal): Promise<UvSettings> {
|
||||
return requestJson<UvSettings>('/api/v1/uv', { signal })
|
||||
}
|
||||
|
||||
export function updateUvSettings(
|
||||
request: UpdateUvSettingsRequest,
|
||||
): Promise<UvSettings> {
|
||||
return requestJson<UvSettings>('/api/v1/uv', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
/** 本机没有 uv 时,从镜像下载到程序目录并切换过去。 */
|
||||
export function downloadUv(): Promise<UvSettings> {
|
||||
return requestJson<UvSettings>('/api/v1/uv/download', { method: 'POST' })
|
||||
}
|
||||
|
||||
/** 预创建的托管虚拟环境(放在 data/envs 下)。 */
|
||||
export function listManagedEnvs(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ManagedEnv[]> {
|
||||
return requestJson<ManagedEnv[]>('/api/v1/python/envs', { signal })
|
||||
}
|
||||
|
||||
export function createManagedEnv(
|
||||
request: CreateManagedEnvRequest,
|
||||
): Promise<ManagedEnv> {
|
||||
return requestJson<ManagedEnv>('/api/v1/python/envs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function listManagedEnvPackages(
|
||||
name: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ManagedEnvPackage[]> {
|
||||
return requestJson<ManagedEnvPackage[]>(
|
||||
`/api/v1/python/envs/${encodeURIComponent(name)}/packages`,
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteManagedEnv(name: string): Promise<void> {
|
||||
return requestJson<void>(
|
||||
`/api/v1/python/envs/${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** uv 下载到平台目录(`python\`)里的解释器。 */
|
||||
export function listPythons(signal?: AbortSignal): Promise<ManagedPython[]> {
|
||||
return requestJson<ManagedPython[]>('/api/v1/python/pythons', { signal })
|
||||
}
|
||||
|
||||
export function installPython(
|
||||
request: InstallPythonRequest,
|
||||
): Promise<ManagedPython[]> {
|
||||
return requestJson<ManagedPython[]>('/api/v1/python/pythons', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
+911
-15
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import { useIsFetching, useQueryClient } from '@tanstack/react-query'
|
||||
import { Brand, Icon, IconButton, toast } from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Scheme = 'light' | 'dark'
|
||||
|
||||
const THEME_STORAGE_KEY = 'taskpilot.theme'
|
||||
|
||||
function readScheme(): Scheme {
|
||||
const saved = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||
return saved === 'dark' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部栏:左边品牌、右边操作图标。
|
||||
*
|
||||
* 引擎状态、任务数量这些在左栏的用户卡片和「概览」里已经看得到,页头不再重复。
|
||||
* 明暗切换只写本机偏好(`<html data-scheme>`),刷新会重拉页面数据;
|
||||
* 「配置 / 退出」暂时没有对应入口,点了给个提示,不做假按钮。
|
||||
*/
|
||||
export function AppHeader() {
|
||||
const queryClient = useQueryClient()
|
||||
const [scheme, setScheme] = useState<Scheme>(readScheme)
|
||||
const fetching = useIsFetching() > 0
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.scheme = scheme
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, scheme)
|
||||
}, [scheme])
|
||||
|
||||
return (
|
||||
<div className="app-header">
|
||||
<Brand name="TaskPilot" mark="TP" />
|
||||
|
||||
<div className="app-header-actions">
|
||||
<IconButton
|
||||
label={scheme === 'dark' ? '切换到浅色' : '切换到深色'}
|
||||
icon={<Icon name={scheme === 'dark' ? 'sun_line' : 'moon_line'} />}
|
||||
onClick={() => setScheme(scheme === 'dark' ? 'light' : 'dark')}
|
||||
/>
|
||||
<IconButton
|
||||
label="刷新"
|
||||
icon={<Icon name="lightning_line" />}
|
||||
spinning={fetching}
|
||||
onClick={() => void queryClient.invalidateQueries()}
|
||||
/>
|
||||
<IconButton
|
||||
label="配置"
|
||||
icon={<Icon name="settings_1_line" />}
|
||||
onClick={() => toast('配置入口还在任务卡片里(uv / 镜像源 / 虚拟环境)')}
|
||||
/>
|
||||
<IconButton
|
||||
label="退出"
|
||||
icon={<Icon name="exit_line" />}
|
||||
onClick={() => toast('本地单机版本没有登录,可以直接关掉窗口')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
||||
import { Button, Modal, Text } from '@stash/ui'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
@@ -10,6 +9,10 @@ interface ConfirmDialogProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 危险操作确认。外壳交给组件库的 `Modal`(原生 dialog:焦点陷阱 / Esc / 背景 inert),
|
||||
* 这里只负责文案与「取消在左、危险操作在右」的按钮顺序。
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
@@ -18,66 +21,31 @@ export function ConfirmDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, dialogRef)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
const requestClose = () => {
|
||||
if (!busy) {
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !busy) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [busy, onCancel, open])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target && !busy) {
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="confirm-card"
|
||||
tabIndex={-1}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-description"
|
||||
>
|
||||
<p className="eyebrow">CONFIRM ACTION</p>
|
||||
<h2 id="confirm-title">{title}</h2>
|
||||
<p className="muted-copy">{description}</p>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={requestClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
closeOnOverlay={!busy}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={requestClose} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-danger"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onConfirm} loading={busy}>
|
||||
{busy ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Text tone="sub">{description}</Text>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
Spinner,
|
||||
Text,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { listDirectories } from '../api/client'
|
||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
||||
|
||||
interface DirectoryPickerDialogProps {
|
||||
open: boolean
|
||||
initialPath: string
|
||||
onSelect: (path: string) => void
|
||||
onClose: () => void
|
||||
/** directory:选目录(默认);file:选单个文件 */
|
||||
mode?: 'directory' | 'file'
|
||||
/** file 模式下只显示这些扩展名的文件,例如 ['.exe'];目录始终显示 */
|
||||
fileExtensions?: string[]
|
||||
/** 覆盖弹窗标题 */
|
||||
title?: string
|
||||
}
|
||||
|
||||
function isFilePath(value: string): boolean {
|
||||
return /[^\\/]+\.[A-Za-z0-9]+$/.test(value.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。
|
||||
* 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。
|
||||
*
|
||||
* `mode="file"` 时同一套浏览逻辑也用来挑单个文件(例如 `uv.exe`):
|
||||
* 目录可以继续进入,文件点击选中后点「使用此文件」。
|
||||
*
|
||||
* 外壳是组件库的 `Modal`,路径输入 / 按钮 / 空态 / 错误提示分别是
|
||||
* `Input` / `Button` / `Empty` / `Notice`。
|
||||
*/
|
||||
export function DirectoryPickerDialog({
|
||||
open,
|
||||
initialPath,
|
||||
onSelect,
|
||||
onClose,
|
||||
mode = 'directory',
|
||||
fileExtensions,
|
||||
title,
|
||||
}: DirectoryPickerDialogProps) {
|
||||
const isFileMode = mode === 'file'
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null)
|
||||
const [draftPath, setDraftPath] = useState('')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, dialogRef, '[data-picker-path]')
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -32,11 +60,17 @@ export function DirectoryPickerDialog({
|
||||
const start = initialPath.trim()
|
||||
setCurrentPath(start || null)
|
||||
setDraftPath(start)
|
||||
setSelectedFile(null)
|
||||
}, [initialPath, open])
|
||||
|
||||
const listing = useQuery({
|
||||
queryKey: ['directories', currentPath],
|
||||
queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal),
|
||||
queryKey: ['directories', currentPath, isFileMode],
|
||||
queryFn: ({ signal }) =>
|
||||
listDirectories(
|
||||
currentPath ?? undefined,
|
||||
{ includeFiles: isFileMode },
|
||||
signal,
|
||||
),
|
||||
enabled: open,
|
||||
retry: false,
|
||||
})
|
||||
@@ -47,162 +81,159 @@ export function DirectoryPickerDialog({
|
||||
}
|
||||
}, [listing.data?.path])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
const entries = (listing.data?.entries ?? []).filter((entry) => {
|
||||
// 老接口条目没有 is_dir 时按目录处理,行为与之前一致。
|
||||
if (!isFileMode || entry.is_dir !== false || !fileExtensions?.length) {
|
||||
return true
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries = listing.data?.entries ?? []
|
||||
const lower = entry.name.toLowerCase()
|
||||
return fileExtensions.some((extension) => lower.endsWith(extension))
|
||||
})
|
||||
const currentDirectory = listing.data?.path ?? null
|
||||
const parent = listing.data?.parent ?? null
|
||||
const typedFile = isFileMode && isFilePath(draftPath) ? draftPath.trim() : null
|
||||
const chosen = isFileMode ? (selectedFile ?? typedFile) : currentDirectory
|
||||
|
||||
const navigate = (path: string | null) => {
|
||||
setCurrentPath(path)
|
||||
setDraftPath(path ?? '')
|
||||
setSelectedFile(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="modal-card directory-picker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="directory-picker-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">WORKING DIRECTORY</p>
|
||||
<h2 id="directory-picker-title">选择项目工作目录</h2>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title ?? (isFileMode ? '选择文件' : '选择工作目录')}
|
||||
size="lg"
|
||||
className="directory-picker-modal"
|
||||
footer={
|
||||
<>
|
||||
<div className="directory-picker-summary">
|
||||
<Text size="xs" tone="muted">
|
||||
将使用
|
||||
</Text>
|
||||
<Text size="sm" tone="sub" truncate>
|
||||
{chosen ?? (isFileMode ? '尚未选择文件' : '尚未选择目录')}
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭目录选择"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="directory-picker-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => navigate(parent)}
|
||||
disabled={!parent}
|
||||
>
|
||||
上一级
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => navigate(null)}
|
||||
>
|
||||
盘符
|
||||
</button>
|
||||
<form
|
||||
className="directory-path-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
navigate(draftPath.trim() || null)
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!chosen}
|
||||
onClick={() => {
|
||||
if (chosen) {
|
||||
onSelect(chosen)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="field-input mono"
|
||||
value={draftPath}
|
||||
onChange={(event) => setDraftPath(event.target.value)}
|
||||
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
|
||||
aria-label="目录路径"
|
||||
data-picker-path
|
||||
spellCheck={false}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
{isFileMode ? '使用此文件' : '使用此目录'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="directory-picker-toolbar">
|
||||
<Button variant="secondary" onClick={() => navigate(parent)} disabled={!parent}>
|
||||
上一级
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => navigate(null)}>
|
||||
盘符
|
||||
</Button>
|
||||
<form
|
||||
className="directory-path-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
navigate(draftPath.trim() || null)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="directory-path-input"
|
||||
value={draftPath}
|
||||
onChange={(event) => setDraftPath(event.target.value)}
|
||||
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
|
||||
aria-label="目录路径"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="directory-picker-body">
|
||||
{listing.isPending ? (
|
||||
<p className="muted-copy">正在读取目录…</p>
|
||||
) : null}
|
||||
{listing.isError ? (
|
||||
<div className="inline-alert inline-alert-error" role="alert">
|
||||
{listing.error.message}
|
||||
</div>
|
||||
) : null}
|
||||
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
||||
<p className="empty-inline">
|
||||
{currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'}
|
||||
</p>
|
||||
) : null}
|
||||
{entries.length > 0 ? (
|
||||
<ul className="directory-list">
|
||||
{entries.map((entry) => (
|
||||
<div className="directory-picker-body">
|
||||
{listing.isPending ? (
|
||||
<div className="picker-status">
|
||||
<Spinner label="正在读取目录" />
|
||||
<Text tone="sub" size="sm">
|
||||
正在读取目录…
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{listing.isError ? (
|
||||
<Notice
|
||||
tone="error"
|
||||
title="目录读取失败"
|
||||
className="directory-picker-error"
|
||||
>
|
||||
{listing.error.message}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
||||
<Empty
|
||||
text={
|
||||
currentDirectory
|
||||
? isFileMode
|
||||
? fileExtensions?.length
|
||||
? `该目录下没有 ${fileExtensions.join(' / ')} 文件`
|
||||
: '该目录下没有文件'
|
||||
: '该目录下没有子文件夹'
|
||||
: '没有可用的盘符'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<ul className="directory-list">
|
||||
{entries.map((entry) => {
|
||||
const file = entry.is_dir === false
|
||||
const selected = file && entry.path === selectedFile
|
||||
|
||||
return (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="directory-row"
|
||||
onClick={() => navigate(entry.path)}
|
||||
className={
|
||||
selected
|
||||
? 'directory-row directory-row-selected'
|
||||
: 'directory-row'
|
||||
}
|
||||
onClick={() =>
|
||||
file ? setSelectedFile(entry.path) : navigate(entry.path)
|
||||
}
|
||||
title={entry.path}
|
||||
>
|
||||
<span className="directory-icon" aria-hidden="true">
|
||||
📁
|
||||
</span>
|
||||
<Icon
|
||||
className="directory-icon"
|
||||
name={file ? 'box_line' : 'folder_more_line'}
|
||||
/>
|
||||
<span className="directory-name">{entry.name}</span>
|
||||
{selected ? (
|
||||
<Icon className="directory-check" name="check_line" />
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{listing.data?.truncated ? (
|
||||
<p className="field-hint">文件夹过多,仅列出前 500 个。</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<footer className="directory-picker-footer">
|
||||
<div className="directory-picker-summary">
|
||||
<span className="section-label">将使用</span>
|
||||
<code>{currentDirectory ?? '尚未选择目录'}</code>
|
||||
</div>
|
||||
<div className="modal-actions directory-picker-actions">
|
||||
<button type="button" className="button button-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-primary"
|
||||
disabled={!currentDirectory}
|
||||
onClick={() => {
|
||||
if (currentDirectory) {
|
||||
onSelect(currentDirectory)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
使用此目录
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
{listing.data?.truncated ? (
|
||||
<Text size="xs" tone="muted">
|
||||
文件夹过多,仅列出前 500 个。
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LogViewer } from './LogViewer'
|
||||
|
||||
describe('LogViewer', () => {
|
||||
it('shows a placeholder before the first chunk arrives', () => {
|
||||
render(<LogViewer lines={[]} version={0} />)
|
||||
|
||||
expect(screen.getByText('等待日志输出...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('only renders the visible window of a long log', () => {
|
||||
const lines = Array.from({ length: 5000 }, (_, index) => `line ${index}`)
|
||||
|
||||
const { container } = render(<LogViewer lines={lines} version={1} />)
|
||||
|
||||
const rendered = container.querySelectorAll('.log-line')
|
||||
expect(rendered.length).toBeGreaterThan(0)
|
||||
expect(rendered.length).toBeLessThan(100)
|
||||
expect(screen.getByText('line 0')).toBeInTheDocument()
|
||||
expect(screen.queryByText('line 4999')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the row height fixed so offsets stay computable', () => {
|
||||
const lines = ['alpha', 'beta'] as const
|
||||
|
||||
const { container } = render(<LogViewer lines={lines} version={2} />)
|
||||
|
||||
const canvas = container.querySelector('.log-canvas')
|
||||
expect(canvas).not.toBeNull()
|
||||
expect((canvas as HTMLElement).style.height).toBe('52px')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { computeLogWindow, isNearBottom } from '../lib/logWindow'
|
||||
|
||||
/** 每行固定高度(与 CSS 中的 .log-line 一致),虚拟滚动据此换算位置。 */
|
||||
export const LOG_ROW_HEIGHT = 20
|
||||
const LOG_VERTICAL_PADDING = 6
|
||||
const OVERSCAN_ROWS = 24
|
||||
const FOLLOW_THRESHOLD_PX = 24
|
||||
|
||||
interface LogViewerProps {
|
||||
/** 已经切好的日志行;内容会被原地追加,靠 version 触发重渲染。 */
|
||||
lines: readonly string[]
|
||||
/** 每次内容变化递增,用于在"跟随中"时滚到底部。 */
|
||||
version: number
|
||||
emptyLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟滚动的日志面板:只渲染视口附近的行,长日志下 DOM 节点数量恒定。
|
||||
*
|
||||
* 行高固定、不自动换行(长行横向滚动),这样滚动位置可以直接由行号换算,
|
||||
* 不需要逐行测量高度。用户往上翻时停止跟随新日志,并给出"回到最新"按钮。
|
||||
*/
|
||||
export function LogViewer({
|
||||
lines,
|
||||
version,
|
||||
emptyLabel = '等待日志输出...',
|
||||
}: LogViewerProps) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const frameRef = useRef(0)
|
||||
const followingRef = useRef(true)
|
||||
const lastScrollTopRef = useRef(0)
|
||||
const [following, setFollowing] = useState(true)
|
||||
const [metrics, setMetrics] = useState({ scrollTop: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const node = viewportRef.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
const sync = () => {
|
||||
setMetrics((previous) =>
|
||||
previous.height === node.clientHeight
|
||||
? previous
|
||||
: { ...previous, height: node.clientHeight },
|
||||
)
|
||||
}
|
||||
sync()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const node = viewportRef.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
// 只有「用户往上翻」才停止跟随。日志流式追加时 scrollTop 不变但内容变高,
|
||||
// 此时不能把 following 关掉,否则后半段日志不会再自动贴底。
|
||||
const previousTop = lastScrollTopRef.current
|
||||
lastScrollTopRef.current = node.scrollTop
|
||||
const atBottom = isNearBottom(
|
||||
node.scrollTop,
|
||||
node.clientHeight,
|
||||
node.scrollHeight,
|
||||
FOLLOW_THRESHOLD_PX,
|
||||
)
|
||||
if (atBottom) {
|
||||
followingRef.current = true
|
||||
setFollowing(true)
|
||||
} else if (node.scrollTop < previousTop) {
|
||||
followingRef.current = false
|
||||
setFollowing(false)
|
||||
}
|
||||
|
||||
// 可见窗口的重算仍然按帧节流,避免滚动时每像素都 setState。
|
||||
if (frameRef.current) {
|
||||
return
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = 0
|
||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 有新内容时贴住底部;同步更新滚动位置,避免窗口还停在旧位置闪一下。
|
||||
useLayoutEffect(() => {
|
||||
if (!followingRef.current) {
|
||||
return
|
||||
}
|
||||
const node = viewportRef.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
node.scrollTop = node.scrollHeight
|
||||
lastScrollTopRef.current = node.scrollTop
|
||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||
}, [version])
|
||||
|
||||
const jumpToLatest = useCallback(() => {
|
||||
const node = viewportRef.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
node.scrollTop = node.scrollHeight
|
||||
lastScrollTopRef.current = node.scrollTop
|
||||
followingRef.current = true
|
||||
setFollowing(true)
|
||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||
}, [])
|
||||
|
||||
const { start, end } = computeLogWindow(
|
||||
lines.length,
|
||||
metrics.scrollTop,
|
||||
metrics.height,
|
||||
LOG_ROW_HEIGHT,
|
||||
OVERSCAN_ROWS,
|
||||
)
|
||||
const canvasHeight =
|
||||
lines.length * LOG_ROW_HEIGHT + LOG_VERTICAL_PADDING * 2
|
||||
|
||||
return (
|
||||
<div className="log-viewer-shell">
|
||||
<div
|
||||
className="log-viewer"
|
||||
ref={viewportRef}
|
||||
onScroll={handleScroll}
|
||||
role="log"
|
||||
aria-label="运行日志"
|
||||
tabIndex={0}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<p className="log-empty">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="log-canvas" style={{ height: canvasHeight }}>
|
||||
<div
|
||||
className="log-window"
|
||||
style={{ transform: `translateY(${start * LOG_ROW_HEIGHT}px)` }}
|
||||
>
|
||||
{lines.slice(start, end).map((line, index) => (
|
||||
<div className="log-line" key={start + index}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lines.length > 0 && !following ? (
|
||||
<button
|
||||
type="button"
|
||||
className="log-jump-latest"
|
||||
onClick={jumpToLatest}
|
||||
>
|
||||
回到最新 ↓
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
Result,
|
||||
Select,
|
||||
Space,
|
||||
Spinner,
|
||||
Table,
|
||||
Tag,
|
||||
Text,
|
||||
Textarea,
|
||||
TextButton,
|
||||
toast,
|
||||
type TableColumn,
|
||||
} from '@stash/ui'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createManagedEnv,
|
||||
deleteManagedEnv,
|
||||
listManagedEnvPackages,
|
||||
listManagedEnvs,
|
||||
listPythons,
|
||||
type ManagedEnv,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
|
||||
const ENV_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称', width: 200 },
|
||||
{ key: 'python_version', title: 'Python', width: 120 },
|
||||
{ key: 'path', title: '路径', width: 380 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 180 },
|
||||
]
|
||||
|
||||
const PACKAGE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '包', width: 320 },
|
||||
{ key: 'version', title: '版本', width: 160 },
|
||||
]
|
||||
|
||||
function linesToPackages(value: string): string[] {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「Python 环境」页的托管虚拟环境区块。
|
||||
*
|
||||
* 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进
|
||||
* 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。
|
||||
*
|
||||
* 解释器由 uv 下载(入口在「uv 配置」弹窗),这里只负责挑一个版本创建环境。
|
||||
*/
|
||||
export function PythonEnvsSection() {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [pythonVersion, setPythonVersion] = useState('')
|
||||
const [packagesText, setPackagesText] = useState('')
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<ManagedEnv | null>(null)
|
||||
|
||||
const envsQuery = useQuery({
|
||||
queryKey: ['managed-envs'],
|
||||
queryFn: ({ signal }) => listManagedEnvs(signal),
|
||||
})
|
||||
const pythonsQuery = useQuery({
|
||||
queryKey: ['managed-pythons'],
|
||||
queryFn: ({ signal }) => listPythons(signal),
|
||||
})
|
||||
const pythonOptions = [
|
||||
{ value: '', label: '默认(uv 自动选)' },
|
||||
...(pythonsQuery.data ?? []).map((python) => ({
|
||||
value: python.version,
|
||||
label: python.version,
|
||||
})),
|
||||
]
|
||||
const packagesQuery = useQuery({
|
||||
queryKey: ['managed-env-packages', selected],
|
||||
queryFn: ({ signal }) => listManagedEnvPackages(selected ?? '', signal),
|
||||
enabled: Boolean(selected),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createManagedEnv({
|
||||
name: name.trim(),
|
||||
python_version: pythonVersion.trim() || null,
|
||||
packages: linesToPackages(packagesText),
|
||||
}),
|
||||
onSuccess: (env: ManagedEnv) => {
|
||||
setCreateOpen(false)
|
||||
setName('')
|
||||
setPythonVersion('')
|
||||
setPackagesText('')
|
||||
toast.success(
|
||||
env.python_version
|
||||
? `已创建环境 ${env.name}(Python ${env.python_version})`
|
||||
: `已创建环境 ${env.name}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['managed-envs'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (env: ManagedEnv) => deleteManagedEnv(env.name),
|
||||
onSuccess: (_data, env) => {
|
||||
setDeleteTarget(null)
|
||||
if (selected === env.name) {
|
||||
setSelected(null)
|
||||
}
|
||||
toast.success(`已删除环境 ${env.name}`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['managed-envs'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const envs = envsQuery.data ?? []
|
||||
const rows = envs.map((env) => ({
|
||||
id: env.name,
|
||||
name: <Text strong>{env.name}</Text>,
|
||||
python_version: env.python_version ? (
|
||||
<Tag shape="rounded">{env.python_version}</Tag>
|
||||
) : (
|
||||
<Text size="sm" tone="muted">
|
||||
—
|
||||
</Text>
|
||||
),
|
||||
path: (
|
||||
<code className="code-chip" title={env.path}>
|
||||
{env.path}
|
||||
</code>
|
||||
),
|
||||
actions: (
|
||||
<Space justify="end" gap="xs" wrap>
|
||||
<TextButton onClick={() => setSelected(env.name)}>查看包</TextButton>
|
||||
<TextButton tone="danger" onClick={() => setDeleteTarget(env)}>
|
||||
删除
|
||||
</TextButton>
|
||||
</Space>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding="md">
|
||||
<div className="env-manager-head">
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Text strong>虚拟环境</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
用已下载的 Python 解释器创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
|
||||
</Text>
|
||||
</Space>
|
||||
<Button onClick={() => setCreateOpen(true)}>+ 新建环境</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="none">
|
||||
{envsQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="虚拟环境列表加载失败"
|
||||
description={envsQuery.error.message}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void envsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : envsQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取虚拟环境" />
|
||||
<Text tone="sub">正在读取虚拟环境...</Text>
|
||||
</div>
|
||||
) : envs.length === 0 ? (
|
||||
<Empty
|
||||
className="env-manager-empty"
|
||||
text="还没有预创建的环境,点右上角「+ 新建环境」创建一个。"
|
||||
/>
|
||||
) : (
|
||||
<Table columns={ENV_COLUMNS} rows={rows} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selected ? (
|
||||
<Card padding="none">
|
||||
<div className="env-packages-head">
|
||||
<Text strong>{selected} 已安装的包</Text>
|
||||
<Space gap="xs" align="center">
|
||||
<Text size="xs" tone="muted">
|
||||
共 {packagesQuery.data?.length ?? 0} 个
|
||||
</Text>
|
||||
<TextButton onClick={() => setSelected(null)}>收起</TextButton>
|
||||
</Space>
|
||||
</div>
|
||||
{packagesQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="包列表加载失败"
|
||||
description={packagesQuery.error.message}
|
||||
/>
|
||||
) : packagesQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取已安装的包" />
|
||||
</div>
|
||||
) : (packagesQuery.data?.length ?? 0) === 0 ? (
|
||||
<Empty className="empty-state" text="这个环境里没有安装第三方包" />
|
||||
) : (
|
||||
<Table
|
||||
columns={PACKAGE_COLUMNS}
|
||||
rows={packagesQuery.data.map((item) => ({
|
||||
id: item.name,
|
||||
name: <Text className="mono">{item.name}</Text>,
|
||||
version: <Text className="mono">{item.version}</Text>,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Notice title="环境创建后不会自动更新">
|
||||
「新建环境」会用 uv 建一个空环境并按需预装包(需要网络);之后不会自动升级依赖。
|
||||
运行任务不会改动这些环境。
|
||||
</Notice>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
onClose={() => {
|
||||
if (!createMutation.isPending) {
|
||||
setCreateOpen(false)
|
||||
}
|
||||
}}
|
||||
title="新建虚拟环境"
|
||||
size="md"
|
||||
closeOnOverlay={!createMutation.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-env-form"
|
||||
loading={createMutation.isPending}
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="create-env-form"
|
||||
className="mirror-form"
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error('请填写环境名')
|
||||
return
|
||||
}
|
||||
createMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
label="环境名"
|
||||
htmlFor="env-name"
|
||||
hint="只能包含字母、数字、点、下划线、连字符;同时是 data\envs 下的目录名"
|
||||
>
|
||||
<Input
|
||||
id="env-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="mono-input"
|
||||
placeholder="data-science"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Python 版本(可选)"
|
||||
hint={
|
||||
(pythonsQuery.data?.length ?? 0) === 0
|
||||
? '还没有下载解释器:先在「uv 配置 → Python 解释器」里下载,或选默认让 uv 自动决定'
|
||||
: '列表来自 uv 下载到程序目录的版本(在「uv 配置」里下载);留空由 uv 决定'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
options={pythonOptions}
|
||||
value={pythonVersion}
|
||||
onChange={setPythonVersion}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="预装包(可选)"
|
||||
htmlFor="env-packages"
|
||||
hint="每行一个,可带版本,例如 numpy==2.1.0"
|
||||
>
|
||||
<Textarea
|
||||
id="env-packages"
|
||||
value={packagesText}
|
||||
onChange={(event) => setPackagesText(event.target.value)}
|
||||
rows={5}
|
||||
placeholder={'numpy==2.1.0\npandas\nrequests'}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormField>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={`删除环境 ${deleteTarget?.name ?? ''}`}
|
||||
description="会直接删除 data\envs 下的整个环境目录,里面安装的包一并删除;正在使用它的任务下次运行会失败。"
|
||||
busy={deleteMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (!deleteMutation.isPending) {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate(deleteTarget)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
Text,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import type { PythonMirror } from '../api/client'
|
||||
|
||||
const mirrorSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入镜像名称')
|
||||
.max(64, '名称不能超过 64 个字符'),
|
||||
index_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入索引地址')
|
||||
.refine((value) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
Boolean(url.hostname) &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, '请输入 http(s) 开头的有效索引地址'),
|
||||
trusted_host: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
!value || (!value.includes('://') && !value.includes('/') && !/\s/.test(value)),
|
||||
'只填主机名,可带端口,例如 mirrors.aliyun.com',
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export type PythonMirrorFormValues = z.infer<typeof mirrorSchema>
|
||||
|
||||
function emptyValues(): PythonMirrorFormValues {
|
||||
return { name: '', index_url: '', trusted_host: '', enabled: true }
|
||||
}
|
||||
|
||||
function toValues(mirror: PythonMirror): PythonMirrorFormValues {
|
||||
return {
|
||||
name: mirror.name,
|
||||
index_url: mirror.index_url,
|
||||
trusted_host: mirror.trusted_host ?? '',
|
||||
enabled: mirror.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
interface PythonMirrorModalProps {
|
||||
open: boolean
|
||||
mirror: PythonMirror | null
|
||||
saving: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (values: PythonMirrorFormValues) => Promise<void>
|
||||
}
|
||||
|
||||
export function PythonMirrorModal({
|
||||
open,
|
||||
mirror,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: PythonMirrorModalProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<PythonMirrorFormValues>({
|
||||
resolver: zodResolver(mirrorSchema),
|
||||
defaultValues: emptyValues(),
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
reset(mirror ? toValues(mirror) : emptyValues())
|
||||
setSubmitError(null)
|
||||
}, [mirror, open, reset])
|
||||
|
||||
const requestClose = () => {
|
||||
if (!saving) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : '保存镜像失败')
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={requestClose}
|
||||
title={mirror ? '编辑 Python 镜像' : '新增 Python 镜像'}
|
||||
size="md"
|
||||
closeOnOverlay={!saving}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={requestClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="python-mirror-form"
|
||||
loading={saving}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="python-mirror-form"
|
||||
className="mirror-form"
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
>
|
||||
{submitError ? (
|
||||
<Notice tone="error" title="保存失败">
|
||||
{submitError}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<FormField label="名称" htmlFor="mirror-name" error={errors.name?.message}>
|
||||
<Input
|
||||
id="mirror-name"
|
||||
{...register('name')}
|
||||
placeholder="例如:清华 TUNA"
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="索引地址"
|
||||
htmlFor="mirror-url"
|
||||
hint="pip 的 index-url,例如 https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
error={errors.index_url?.message}
|
||||
>
|
||||
<Input
|
||||
id="mirror-url"
|
||||
{...register('index_url')}
|
||||
className="mono-input"
|
||||
placeholder="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.index_url)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="信任主机(可选)"
|
||||
htmlFor="mirror-trusted-host"
|
||||
hint="只有 http 源需要;https 源留空。会注入 PIP_TRUSTED_HOST"
|
||||
error={errors.trusted_host?.message}
|
||||
>
|
||||
<Input
|
||||
id="mirror-trusted-host"
|
||||
{...register('trusted_host')}
|
||||
className="mono-input"
|
||||
placeholder="mirrors.aliyun.com"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.trusted_host)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
label={
|
||||
<span className="mirror-enabled-copy">
|
||||
<Text strong size="sm">
|
||||
启用此镜像
|
||||
</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
停用后不会参与默认镜像选择
|
||||
</Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Notice,
|
||||
Result,
|
||||
Space,
|
||||
Spinner,
|
||||
Table,
|
||||
Tag,
|
||||
Text,
|
||||
TextButton,
|
||||
toast,
|
||||
type TableColumn,
|
||||
} from '@stash/ui'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createPythonMirror,
|
||||
deletePythonMirror,
|
||||
listPythonMirrors,
|
||||
setDefaultPythonMirror,
|
||||
updatePythonMirror,
|
||||
type PythonMirror,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
import {
|
||||
PythonMirrorModal,
|
||||
type PythonMirrorFormValues,
|
||||
} from './PythonMirrorModal'
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称', width: 180 },
|
||||
{ key: 'index_url', title: '索引地址', width: 330 },
|
||||
{ key: 'trusted_host', title: '信任主机', width: 130 },
|
||||
{ key: 'status', title: '状态', width: 100 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 220 },
|
||||
]
|
||||
|
||||
function toRequest(values: PythonMirrorFormValues) {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
index_url: values.index_url.trim(),
|
||||
trusted_host: values.trusted_host.trim() || null,
|
||||
enabled: values.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「Python 环境」页的包索引镜像区块。
|
||||
*
|
||||
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
|
||||
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
|
||||
*/
|
||||
export function PythonMirrorsSection() {
|
||||
const queryClient = useQueryClient()
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<PythonMirror | null>(null)
|
||||
|
||||
const mirrorsQuery = useQuery({
|
||||
queryKey: ['python-mirrors'],
|
||||
queryFn: ({ signal }) => listPythonMirrors(signal),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
mirror,
|
||||
values,
|
||||
}: {
|
||||
mirror: PythonMirror | null
|
||||
values: PythonMirrorFormValues
|
||||
}) =>
|
||||
mirror
|
||||
? updatePythonMirror(mirror.id, toRequest(values))
|
||||
: createPythonMirror(toRequest(values)),
|
||||
onSuccess: (mirror, variables) => {
|
||||
setEditorOpen(false)
|
||||
setEditingMirror(null)
|
||||
toast.success(
|
||||
variables.mirror ? `已更新 ${mirror.name}` : `已添加 ${mirror.name}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
})
|
||||
|
||||
const defaultMutation = useMutation({
|
||||
mutationFn: (mirror: PythonMirror) => setDefaultPythonMirror(mirror.id),
|
||||
onSuccess: (mirror) => {
|
||||
toast.success(`默认镜像已切换为 ${mirror.name}`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (mirror: PythonMirror) => deletePythonMirror(mirror.id),
|
||||
onSuccess: (_data, mirror) => {
|
||||
setDeleteTarget(null)
|
||||
toast.success(`已删除 ${mirror.name}`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const mirrors = mirrorsQuery.data ?? []
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingMirror(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (mirror: PythonMirror) => {
|
||||
setEditingMirror(mirror)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const rows = mirrors.map((mirror) => ({
|
||||
id: mirror.id,
|
||||
name: (
|
||||
<Space gap="xs" align="center" wrap>
|
||||
<Text strong>{mirror.name}</Text>
|
||||
{mirror.is_default ? (
|
||||
<Tag tone="primary" shape="rounded">
|
||||
默认
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
index_url: (
|
||||
<code className="code-chip mirror-url" title={mirror.index_url}>
|
||||
{mirror.index_url}
|
||||
</code>
|
||||
),
|
||||
trusted_host: mirror.trusted_host ? (
|
||||
<Text className="mono" size="sm">
|
||||
{mirror.trusted_host}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" tone="muted">
|
||||
—
|
||||
</Text>
|
||||
),
|
||||
status: (
|
||||
<Tag tone={mirror.enabled ? 'success' : 'neutral'} dot>
|
||||
{mirror.enabled ? '已启用' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
actions: (
|
||||
<Space justify="end" gap="xs" wrap>
|
||||
<TextButton
|
||||
disabled={mirror.is_default}
|
||||
onClick={() => defaultMutation.mutate(mirror)}
|
||||
>
|
||||
设为默认
|
||||
</TextButton>
|
||||
<TextButton onClick={() => openEdit(mirror)}>编辑</TextButton>
|
||||
<TextButton tone="danger" onClick={() => setDeleteTarget(mirror)}>
|
||||
删除
|
||||
</TextButton>
|
||||
</Space>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding="md">
|
||||
<Space justify="between" align="center" wrap>
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Text strong>包索引镜像</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
|
||||
</Text>
|
||||
</Space>
|
||||
<Button onClick={openCreate}>+ 新增镜像</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Notice title="镜像只影响任务运行,不会自动安装依赖">
|
||||
默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL
|
||||
优先。修改后对下一次运行生效。
|
||||
</Notice>
|
||||
|
||||
<Card padding="none" className="mirror-card">
|
||||
{mirrorsQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="镜像列表加载失败"
|
||||
description={mirrorsQuery.error.message}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void mirrorsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : mirrorsQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取镜像" />
|
||||
<Text tone="sub">正在读取镜像...</Text>
|
||||
</div>
|
||||
) : mirrors.length === 0 ? (
|
||||
<Empty
|
||||
className="empty-state"
|
||||
text="还没有 Python 镜像"
|
||||
action={<Button onClick={openCreate}>新增镜像</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
className="mirror-table"
|
||||
columns={TABLE_COLUMNS}
|
||||
rows={rows}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<PythonMirrorModal
|
||||
open={editorOpen}
|
||||
mirror={editingMirror}
|
||||
saving={saveMutation.isPending}
|
||||
onClose={() => {
|
||||
if (!saveMutation.isPending) {
|
||||
setEditorOpen(false)
|
||||
}
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
await saveMutation.mutateAsync({ mirror: editingMirror, values })
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={`删除镜像 ${deleteTarget?.name ?? ''}`}
|
||||
description="删除后如果它正是默认镜像,Python 任务将不再注入镜像环境变量;正在运行的任务不受影响。"
|
||||
busy={deleteMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (!deleteMutation.isPending) {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate(deleteTarget)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Modal,
|
||||
Notice,
|
||||
Select,
|
||||
Spinner,
|
||||
Text,
|
||||
Title,
|
||||
} from '@stash/ui'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
cancelRun,
|
||||
getRun,
|
||||
@@ -17,7 +27,8 @@ import {
|
||||
formatDuration,
|
||||
isTerminalRunStatus,
|
||||
} from '../lib/format'
|
||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
||||
import { appendLogChunk, createLogBuffer, flushLogTail } from '../lib/logLines'
|
||||
import { LogViewer } from './LogViewer'
|
||||
import { RunStatusBadge } from './StatusBadge'
|
||||
|
||||
interface RunLogDrawerProps {
|
||||
@@ -27,6 +38,30 @@ interface RunLogDrawerProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'queued', label: '排队中' },
|
||||
{ value: 'running', label: '运行中' },
|
||||
{ value: 'succeeded', label: '成功' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'timed_out', label: '已超时' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
{ value: 'skipped', label: '已跳过' },
|
||||
]
|
||||
|
||||
const TRIGGER_FILTERS = [
|
||||
{ value: '', label: '全部触发' },
|
||||
{ value: 'schedule', label: '定时' },
|
||||
{ value: 'manual', label: '手动' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 运行历史 + 日志面板。
|
||||
*
|
||||
* 外壳用组件库的 `Modal`(原生 dialog:焦点陷阱、Esc、背景 inert),
|
||||
* 左栏是运行列表(`Select` 筛选 + 状态标签),右栏是详情与虚拟滚动日志。
|
||||
* 日志读取本身仍是页面逻辑(SSE + 轮询兜底),不放进组件库。
|
||||
*/
|
||||
export function RunLogDrawer({
|
||||
open,
|
||||
task,
|
||||
@@ -41,15 +76,9 @@ export function RunLogDrawer({
|
||||
const [historyTrigger, setHistoryTrigger] = useState<RunTrigger | ''>('')
|
||||
const [sseConnected, setSseConnected] = useState(false)
|
||||
const [logEvents] = useState(() => new EventTarget())
|
||||
const drawerRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, drawerRef)
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: [
|
||||
'task-runs',
|
||||
task?.id,
|
||||
historyStatus,
|
||||
historyTrigger,
|
||||
],
|
||||
queryKey: ['task-runs', task?.id, historyStatus, historyTrigger],
|
||||
queryFn: ({ signal }) =>
|
||||
listTaskRuns(
|
||||
task!.id,
|
||||
@@ -115,9 +144,7 @@ export function RunLogDrawer({
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as ExecutionEvent
|
||||
if (payload.event === 'log') {
|
||||
logEvents.dispatchEvent(
|
||||
new CustomEvent('log', { detail: payload }),
|
||||
)
|
||||
logEvents.dispatchEvent(new CustomEvent('log', { detail: payload }))
|
||||
}
|
||||
} catch {
|
||||
setSseConnected(false)
|
||||
@@ -140,201 +167,164 @@ export function RunLogDrawer({
|
||||
}
|
||||
}, [logEvents, open, queryClient, task])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
if (!open || !task) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay drawer-overlay"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={task.name}
|
||||
size="lg"
|
||||
className="run-log-modal"
|
||||
closeOnOverlay
|
||||
>
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className="run-drawer"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="run-drawer-title"
|
||||
>
|
||||
<header className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">RUN HISTORY</p>
|
||||
<h2 id="run-drawer-title">{task.name}</h2>
|
||||
<p className="drawer-subtitle mono">{task.id}</p>
|
||||
<div className="run-log-body">
|
||||
<section className="run-history-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Text className="section-label" size="xs" tone="muted">
|
||||
RUN HISTORY
|
||||
</Text>
|
||||
<Title level={3}>最近运行</Title>
|
||||
<Text className="drawer-subtitle" size="xs" tone="muted">
|
||||
{task.id}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void runsQuery.refetch()}
|
||||
loading={runsQuery.isFetching}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭运行日志"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="run-drawer-body">
|
||||
<section className="run-history-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="section-label">RUNS</p>
|
||||
<h3>最近运行</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => void runsQuery.refetch()}
|
||||
disabled={runsQuery.isFetching}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<div className="history-filters">
|
||||
<Select
|
||||
className="history-select"
|
||||
options={STATUS_FILTERS}
|
||||
value={historyStatus}
|
||||
onChange={(value) => {
|
||||
setHistoryStatus(value as RunStatus | '')
|
||||
setSelectedRunId(null)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="history-select"
|
||||
options={TRIGGER_FILTERS}
|
||||
value={historyTrigger}
|
||||
onChange={(value) => {
|
||||
setHistoryTrigger(value as RunTrigger | '')
|
||||
setSelectedRunId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{runsQuery.isPending ? (
|
||||
<div className="panel-status">
|
||||
<Spinner label="正在加载运行记录" />
|
||||
<Text size="sm" tone="sub">
|
||||
正在加载运行记录...
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="history-filters">
|
||||
<select
|
||||
className="field-input compact-select"
|
||||
aria-label="运行状态筛选"
|
||||
value={historyStatus}
|
||||
onChange={(event) => {
|
||||
setHistoryStatus(event.target.value as RunStatus | '')
|
||||
setSelectedRunId(null)
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="queued">排队中</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="succeeded">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="timed_out">已超时</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
<option value="skipped">已跳过</option>
|
||||
</select>
|
||||
<select
|
||||
className="field-input compact-select"
|
||||
aria-label="触发方式筛选"
|
||||
value={historyTrigger}
|
||||
onChange={(event) => {
|
||||
setHistoryTrigger(event.target.value as RunTrigger | '')
|
||||
setSelectedRunId(null)
|
||||
}}
|
||||
>
|
||||
<option value="">全部触发</option>
|
||||
<option value="schedule">定时</option>
|
||||
<option value="manual">手动</option>
|
||||
<option value="retry">重试</option>
|
||||
</select>
|
||||
) : runsQuery.isError ? (
|
||||
<Notice tone="error" title="运行记录加载失败">
|
||||
{runsQuery.error.message}
|
||||
</Notice>
|
||||
) : runs.length === 0 ? (
|
||||
<Empty text="该任务还没有运行记录。" />
|
||||
) : (
|
||||
<div className="run-list">
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`run-list-item ${
|
||||
run.id === effectiveRunId ? 'is-selected' : ''
|
||||
}`}
|
||||
key={run.id}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
>
|
||||
<span className="run-list-main">
|
||||
<span className="mono">#{run.id}</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</span>
|
||||
<span className="run-list-meta">
|
||||
{formatDateTime(run.created_at)} · {run.trigger}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{runsQuery.isPending ? (
|
||||
<p className="muted-copy">正在加载运行记录...</p>
|
||||
) : runsQuery.isError ? (
|
||||
<div className="inline-alert inline-alert-error">
|
||||
{runsQuery.error.message}
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<p className="empty-inline">该任务还没有运行记录。</p>
|
||||
) : (
|
||||
<div className="run-list">
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`run-list-item ${
|
||||
run.id === effectiveRunId ? 'is-selected' : ''
|
||||
}`}
|
||||
key={run.id}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
>
|
||||
<span className="run-list-main">
|
||||
<span className="mono">#{run.id}</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</span>
|
||||
<span className="run-list-meta">
|
||||
{formatDateTime(run.created_at)} · {run.trigger}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="run-detail-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="section-label">DETAIL</p>
|
||||
<h3>
|
||||
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
|
||||
</h3>
|
||||
</div>
|
||||
{selectedRun ? (
|
||||
<div className="run-detail-actions">
|
||||
<RunStatusBadge status={selectedRun.status} />
|
||||
{!isTerminalRunStatus(selectedRun.status) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button button-danger button-small"
|
||||
disabled={
|
||||
cancelMutation.isPending &&
|
||||
cancelMutation.variables === selectedRun.id
|
||||
}
|
||||
onClick={() => cancelMutation.mutate(selectedRun.id)}
|
||||
>
|
||||
{cancelMutation.isPending ? '取消中...' : '取消运行'}
|
||||
</button>
|
||||
) : null}
|
||||
<a
|
||||
className="button button-secondary button-small"
|
||||
href={runLogDownloadUrl(selectedRun.id)}
|
||||
>
|
||||
下载日志
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<section className="run-detail-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Text className="section-label" size="xs" tone="muted">
|
||||
DETAIL
|
||||
</Text>
|
||||
<Title level={3}>
|
||||
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
{selectedRunQuery.isError ? (
|
||||
<div className="inline-alert inline-alert-error">
|
||||
{selectedRunQuery.error.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedRun ? (
|
||||
<RunMetadata run={selectedRun} />
|
||||
) : (
|
||||
<p className="empty-inline">选择一条运行记录查看日志。</p>
|
||||
)}
|
||||
|
||||
{effectiveRunId ? (
|
||||
<RunLogOutput
|
||||
key={effectiveRunId}
|
||||
runId={effectiveRunId}
|
||||
logEvents={logEvents}
|
||||
sseConnected={sseConnected}
|
||||
runComplete={
|
||||
selectedRun ? isTerminalRunStatus(selectedRun.status) : false
|
||||
}
|
||||
/>
|
||||
<div className="run-detail-actions">
|
||||
<RunStatusBadge status={selectedRun.status} />
|
||||
{!isTerminalRunStatus(selectedRun.status) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
loading={
|
||||
cancelMutation.isPending &&
|
||||
cancelMutation.variables === selectedRun.id
|
||||
}
|
||||
onClick={() => cancelMutation.mutate(selectedRun.id)}
|
||||
>
|
||||
取消运行
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.href = runLogDownloadUrl(selectedRun.id)
|
||||
}}
|
||||
>
|
||||
下载日志
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRunQuery.isError ? (
|
||||
<Notice tone="error" title="运行详情加载失败">
|
||||
{selectedRunQuery.error.message}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
{selectedRun ? (
|
||||
<RunMetadata run={selectedRun} />
|
||||
) : (
|
||||
<Empty text="选择一条运行记录查看日志。" />
|
||||
)}
|
||||
|
||||
{effectiveRunId ? (
|
||||
<RunLogOutput
|
||||
key={effectiveRunId}
|
||||
runId={effectiveRunId}
|
||||
logEvents={logEvents}
|
||||
sseConnected={sseConnected}
|
||||
runComplete={
|
||||
selectedRun ? isTerminalRunStatus(selectedRun.status) : false
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -380,15 +370,52 @@ function RunLogOutput({
|
||||
sseConnected,
|
||||
runComplete,
|
||||
}: RunLogOutputProps) {
|
||||
const [content, setContent] = useState('')
|
||||
const [version, setVersion] = useState(0)
|
||||
const [lines, setLines] = useState<readonly string[]>([])
|
||||
const [pollComplete, setPollComplete] = useState(runComplete)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [reconcileKey, setReconcileKey] = useState(0)
|
||||
const bufferRef = useRef(createLogBuffer())
|
||||
const offsetRef = useRef(0)
|
||||
const outputRef = useRef<HTMLPreElement>(null)
|
||||
const frameRef = useRef(0)
|
||||
const complete = runComplete || pollComplete
|
||||
|
||||
// 增量先并进缓冲区,一帧最多触发一次重渲染,避免逐行 setState 的 O(n²)。
|
||||
const scheduleRender = useCallback(() => {
|
||||
if (frameRef.current) {
|
||||
return
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = 0
|
||||
setLines(bufferRef.current.lines)
|
||||
setVersion((value) => value + 1)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const ingest = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
appendLogChunk(bufferRef.current, text)
|
||||
scheduleRender()
|
||||
},
|
||||
[scheduleRender],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
// StrictMode 会「挂载 → 清理 → 再挂载」,取消后必须把句柄归零,
|
||||
// 否则下一次 scheduleRender 会误以为还有帧在排队而不再安排渲染,
|
||||
// 已结束的 run(complete 在首帧就为 true)会一直停在「等待日志输出」。
|
||||
frameRef.current = 0
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const controller = new AbortController()
|
||||
@@ -404,12 +431,12 @@ function RunLogOutput({
|
||||
if (offsetRef.current !== startOffset) {
|
||||
continue
|
||||
}
|
||||
if (chunk.content) {
|
||||
setContent((current) => current + chunk.content)
|
||||
}
|
||||
ingest(chunk.content)
|
||||
offsetRef.current = chunk.next_offset
|
||||
if (chunk.complete) {
|
||||
flushLogTail(bufferRef.current)
|
||||
setPollComplete(true)
|
||||
scheduleRender()
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 600))
|
||||
@@ -431,21 +458,16 @@ function RunLogOutput({
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [reconcileKey, reloadKey, runId])
|
||||
}, [ingest, reconcileKey, reloadKey, runId, scheduleRender])
|
||||
|
||||
useEffect(() => {
|
||||
const onLog = (event: Event) => {
|
||||
const payload = (event as CustomEvent<ExecutionEvent>).detail
|
||||
if (
|
||||
payload.event !== 'log' ||
|
||||
payload.data.run_id !== runId
|
||||
) {
|
||||
if (payload.event !== 'log' || payload.data.run_id !== runId) {
|
||||
return
|
||||
}
|
||||
if (payload.data.offset === offsetRef.current) {
|
||||
if (payload.data.content) {
|
||||
setContent((current) => current + payload.data.content)
|
||||
}
|
||||
ingest(payload.data.content)
|
||||
offsetRef.current = payload.data.next_offset
|
||||
} else if (payload.data.offset > offsetRef.current) {
|
||||
setReconcileKey((value) => value + 1)
|
||||
@@ -453,13 +475,15 @@ function RunLogOutput({
|
||||
}
|
||||
logEvents.addEventListener('log', onLog)
|
||||
return () => logEvents.removeEventListener('log', onLog)
|
||||
}, [logEvents, runId])
|
||||
}, [ingest, logEvents, runId])
|
||||
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
||||
if (!complete) {
|
||||
return
|
||||
}
|
||||
}, [content])
|
||||
flushLogTail(bufferRef.current)
|
||||
scheduleRender()
|
||||
}, [complete, scheduleRender])
|
||||
|
||||
return (
|
||||
<div className="log-viewer-wrap">
|
||||
@@ -476,26 +500,27 @@ function RunLogOutput({
|
||||
</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="inline-alert inline-alert-error">
|
||||
{error}
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => {
|
||||
setContent('')
|
||||
<Notice
|
||||
tone="error"
|
||||
title="日志读取失败"
|
||||
className="log-viewer-error"
|
||||
action={{
|
||||
label: '重试',
|
||||
onClick: () => {
|
||||
bufferRef.current = createLogBuffer()
|
||||
offsetRef.current = 0
|
||||
setPollComplete(false)
|
||||
setError(null)
|
||||
offsetRef.current = 0
|
||||
setLines(bufferRef.current.lines)
|
||||
setVersion((value) => value + 1)
|
||||
setReloadKey((value) => value + 1)
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
},
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Notice>
|
||||
) : null}
|
||||
<pre className="log-viewer" ref={outputRef} aria-live="polite">
|
||||
{content || '等待日志输出...'}
|
||||
</pre>
|
||||
<LogViewer lines={lines} version={version} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
import { Tag, type TagTone } from '@stash/ui'
|
||||
import type { RunStatus } from '../api/client'
|
||||
import { runStatusLabels } from '../lib/format'
|
||||
|
||||
const RUN_STATUS_TONE: Record<RunStatus, TagTone> = {
|
||||
queued: 'primary',
|
||||
running: 'warning',
|
||||
succeeded: 'success',
|
||||
failed: 'danger',
|
||||
timed_out: 'danger',
|
||||
cancelled: 'neutral',
|
||||
skipped: 'neutral',
|
||||
}
|
||||
|
||||
interface RunStatusBadgeProps {
|
||||
status: RunStatus
|
||||
}
|
||||
|
||||
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
|
||||
return (
|
||||
<span className={`status-badge status-${status}`}>
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
<Tag
|
||||
tone={RUN_STATUS_TONE[status]}
|
||||
dot
|
||||
className={`status-${status}`}
|
||||
>
|
||||
{runStatusLabels[status]}
|
||||
</span>
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
interface EnabledBadgeProps {
|
||||
enabled: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
|
||||
export function EnabledBadge({ enabled, className }: EnabledBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`status-badge ${enabled ? 'status-enabled' : 'status-disabled'}`}
|
||||
<Tag
|
||||
tone={enabled ? 'success' : 'neutral'}
|
||||
dot
|
||||
className={[
|
||||
enabled ? 'status-enabled' : 'status-disabled',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
{enabled ? '活跃中' : '已暂停'}
|
||||
</span>
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Button, Divider, Modal, Space, Tag, Text } from '@stash/ui'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Task } from '../api/client'
|
||||
import {
|
||||
actionTypeLabel,
|
||||
formatDateTime,
|
||||
formatShortDateTime,
|
||||
} from '../lib/format'
|
||||
import { EnabledBadge } from './StatusBadge'
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Task | null
|
||||
nextRunAt?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<Text size="xs" tone="muted">
|
||||
{label}
|
||||
</Text>
|
||||
<div className="detail-value">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function envSummary(env: Record<string, string> | undefined): string {
|
||||
const entries = Object.entries(env ?? {})
|
||||
if (entries.length === 0) {
|
||||
return '无'
|
||||
}
|
||||
return entries.map(([key, value]) => `${key}=${value}`).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务详情:只读展示任务配置,入口是任务卡片上的「详情」。
|
||||
*
|
||||
* 和编辑表单分开:这里不做校验,也不会因为字段缺失而崩,方便核对线上实际配置。
|
||||
*/
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
nextRunAt,
|
||||
onClose,
|
||||
}: TaskDetailModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={task !== null}
|
||||
onClose={onClose}
|
||||
title="任务详情"
|
||||
size="lg"
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{task ? (
|
||||
<Space direction="column" gap="md" align="stretch">
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Space gap="xs" align="center" wrap>
|
||||
<Text size="lg" strong>
|
||||
{task.name}
|
||||
</Text>
|
||||
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
||||
<EnabledBadge enabled={task.enabled} />
|
||||
</Space>
|
||||
<Text className="mono" size="xs" tone="muted">
|
||||
{task.id}
|
||||
</Text>
|
||||
{task.description ? (
|
||||
<Text size="sm" tone="sub">
|
||||
{task.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
<Divider spacing="sm" />
|
||||
|
||||
<Text strong size="sm">
|
||||
基本信息
|
||||
</Text>
|
||||
<div className="detail-grid">
|
||||
<DetailRow label="Cron">
|
||||
<code className="code-chip">{task.cron}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label="时区">{task.timezone}</DetailRow>
|
||||
<DetailRow label="下次触发">
|
||||
{task.enabled
|
||||
? nextRunAt
|
||||
? formatShortDateTime(nextRunAt)
|
||||
: '调度中…'
|
||||
: '未调度'}
|
||||
</DetailRow>
|
||||
<DetailRow label="超时">{task.timeout_secs} 秒</DetailRow>
|
||||
</div>
|
||||
|
||||
<Divider spacing="sm" />
|
||||
|
||||
<Text strong size="sm">
|
||||
执行配置
|
||||
</Text>
|
||||
<div className="detail-grid">
|
||||
{task.action.type === 'script' ? (
|
||||
<>
|
||||
<DetailRow label="工作目录">
|
||||
{task.action.working_dir}
|
||||
</DetailRow>
|
||||
<DetailRow label="解释器">{task.action.interpreter}</DetailRow>
|
||||
<DetailRow label="脚本">{task.action.script}</DetailRow>
|
||||
<DetailRow label="运行参数">
|
||||
{task.action.args.length > 0
|
||||
? task.action.args.join(' ')
|
||||
: '无'}
|
||||
</DetailRow>
|
||||
<DetailRow label="环境变量">
|
||||
{envSummary(task.action.env)}
|
||||
</DetailRow>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{task.action.type === 'http' ? (
|
||||
<>
|
||||
<DetailRow label="请求 URL">{task.action.url}</DetailRow>
|
||||
<DetailRow label="请求方式">{task.action.method}</DetailRow>
|
||||
<DetailRow label="期望状态码">
|
||||
{task.action.expected_status.length > 0
|
||||
? task.action.expected_status.join(', ')
|
||||
: '2xx'}
|
||||
</DetailRow>
|
||||
<DetailRow label="请求 Header">
|
||||
{envSummary(task.action.headers)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Body">{task.action.body || '无'}</DetailRow>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{task.action.type === 'process' ? (
|
||||
<>
|
||||
<DetailRow label="可执行文件">
|
||||
{task.action.executable}
|
||||
</DetailRow>
|
||||
<DetailRow label="工作目录">
|
||||
{task.action.working_dir ?? '继承程序目录'}
|
||||
</DetailRow>
|
||||
<DetailRow label="运行参数">
|
||||
{task.action.args.length > 0
|
||||
? task.action.args.join(' ')
|
||||
: '无'}
|
||||
</DetailRow>
|
||||
<DetailRow label="环境变量">
|
||||
{envSummary(task.action.env)}
|
||||
</DetailRow>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Divider spacing="sm" />
|
||||
|
||||
<Text strong size="sm">
|
||||
时间
|
||||
</Text>
|
||||
<div className="detail-grid">
|
||||
<DetailRow label="创建时间">
|
||||
{formatDateTime(task.created_at)}
|
||||
</DetailRow>
|
||||
<DetailRow label="更新时间">
|
||||
{formatDateTime(task.updated_at)}
|
||||
</DetailRow>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,601 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
FormField,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
RadioGroup,
|
||||
Result,
|
||||
Space,
|
||||
Spinner,
|
||||
Tabs,
|
||||
Text,
|
||||
TextButton,
|
||||
toast,
|
||||
} from '@stash/ui'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
downloadUv,
|
||||
getUvSettings,
|
||||
installPython,
|
||||
listPythons,
|
||||
updateUvSettings,
|
||||
type ManagedPython,
|
||||
type UvSettings,
|
||||
type UvSource,
|
||||
} from '../api/client'
|
||||
import { shortUvVersion } from '../lib/format'
|
||||
import { DirectoryPickerDialog } from './DirectoryPickerDialog'
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'managed', label: '程序目录 uv' },
|
||||
{ value: 'custom', label: '指定路径' },
|
||||
]
|
||||
|
||||
const PYTHON_VERSION_HINT = '3.12'
|
||||
|
||||
type UvTab = 'binary' | 'env' | 'python'
|
||||
|
||||
interface EnvRow {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function rowsFrom(
|
||||
extraEnv: Record<string, string> | null | undefined,
|
||||
envDefaults: Record<string, string> | null | undefined,
|
||||
): EnvRow[] {
|
||||
const rows = Object.entries(extraEnv ?? {}).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}))
|
||||
// 没显式配置的路径变量显示成默认值那一行(相对程序目录),用户能看见也能改值;
|
||||
// 这两个变量决定解释器和缓存的位置,界面里不允许删除。
|
||||
for (const [name, value] of Object.entries(envDefaults ?? {})) {
|
||||
const configured = rows.some(
|
||||
(row) => row.name.toUpperCase() === name.toUpperCase(),
|
||||
)
|
||||
if (!configured) {
|
||||
rows.unshift({ name, value })
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function envMap(rows: EnvRow[]): Record<string, string> {
|
||||
const map: Record<string, string> = {}
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim()
|
||||
const value = row.value.trim()
|
||||
if (!name && !value) {
|
||||
continue
|
||||
}
|
||||
map[name] = value
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** `D:\tools\uv.exe` → `D:\tools`;拿不到目录时返回空串(让浏览器从盘符开始)。 */
|
||||
function directoryOf(path: string): string {
|
||||
const trimmed = path.trim()
|
||||
const index = Math.max(
|
||||
trimmed.lastIndexOf('\\'),
|
||||
trimmed.lastIndexOf('/'),
|
||||
)
|
||||
if (index <= 0) {
|
||||
return ''
|
||||
}
|
||||
const directory = trimmed.slice(0, index)
|
||||
return directory.endsWith(':') ? `${directory}\\` : directory
|
||||
}
|
||||
|
||||
interface UvSettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* uv 配置弹窗:三段配置用标签页切换——可执行文件 / `UV_*` 环境变量 / Python 解释器。
|
||||
*
|
||||
* - 来源二选一:程序目录(只读路径 + 缺失时「下载 uv」)或指定路径(可编辑 + 「浏览」挑 exe)。
|
||||
* - 下载镜像也在这一步:uv 本体与解释器都从它派生;解释器还可以用
|
||||
* `UV_PYTHON_INSTALL_MIRROR` 单独覆盖(它就在环境变量里)。
|
||||
* - 环境变量里 `UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` / `UV_PYTHON_INSTALL_MIRROR`
|
||||
* 是 uv 的关键变量,只能改值、不能删除;其余 `UV_*` 变量照常增删。
|
||||
* - 解释器由 uv 下载到 `UV_PYTHON_INSTALL_DIR`;虚拟环境弹窗只从这份列表里挑版本。
|
||||
* - 顶部不再放状态汇总:版本、解释器数量、镜像地址在首页的 uv 卡片里已经有了。
|
||||
*/
|
||||
export function UvSettingsModal({ open, onClose }: UvSettingsModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['uv-settings'],
|
||||
queryFn: ({ signal }) => getUvSettings(signal),
|
||||
})
|
||||
// 解释器列表由 uv 自己列举;uv 不可用时接口会报错,先不请求。
|
||||
const uvReady = Boolean(settingsQuery.data?.version)
|
||||
const pythonsQuery = useQuery({
|
||||
queryKey: ['managed-pythons'],
|
||||
queryFn: ({ signal }) => listPythons(signal),
|
||||
enabled: uvReady,
|
||||
})
|
||||
|
||||
const [draftSource, setDraftSource] = useState<UvSource | null>(null)
|
||||
const [draftCustomPath, setDraftCustomPath] = useState<string | null>(null)
|
||||
const [draftMirror, setDraftMirror] = useState<string | null>(null)
|
||||
const [draftEnv, setDraftEnv] = useState<EnvRow[] | null>(null)
|
||||
const [pythonVersion, setPythonVersion] = useState('')
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<UvTab>('binary')
|
||||
|
||||
const settings = settingsQuery.data
|
||||
// 编辑过就用草稿,否则跟随服务端当前设置(保存成功后清空草稿重新对齐)。
|
||||
const source = draftSource ?? settings?.source ?? 'managed'
|
||||
const customPath = draftCustomPath ?? settings?.custom_path ?? ''
|
||||
const mirrorUrl = draftMirror ?? settings?.mirror_url ?? ''
|
||||
const envRows =
|
||||
draftEnv ?? rowsFrom(settings?.extra_env, settings?.env_defaults)
|
||||
const pythons = pythonsQuery.data ?? []
|
||||
const managedPath = `${settings?.managed_dir ?? 'uv'}\\uv.exe`
|
||||
const lockedNames = Object.keys(settings?.env_defaults ?? {}).map((name) =>
|
||||
name.toUpperCase(),
|
||||
)
|
||||
const isLockedName = (name: string) =>
|
||||
lockedNames.includes(name.trim().toUpperCase())
|
||||
|
||||
const resetDrafts = () => {
|
||||
setDraftSource(null)
|
||||
setDraftCustomPath(null)
|
||||
setDraftMirror(null)
|
||||
setDraftEnv(null)
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const extraEnv = envMap(envRows)
|
||||
// 和默认值一样就不写死,删掉等于回到默认。
|
||||
for (const [name, value] of Object.entries(settings?.env_defaults ?? {})) {
|
||||
const configured = Object.keys(extraEnv).some(
|
||||
(key) => key.toUpperCase() === name.toUpperCase(),
|
||||
)
|
||||
if (configured && extraEnv[name]?.trim() === value) {
|
||||
delete extraEnv[name]
|
||||
}
|
||||
}
|
||||
return updateUvSettings({
|
||||
source,
|
||||
custom_path: customPath.trim() || null,
|
||||
extra_env: extraEnv,
|
||||
mirror_url: mirrorUrl.trim() || null,
|
||||
})
|
||||
},
|
||||
onSuccess: (saved: UvSettings) => {
|
||||
resetDrafts()
|
||||
toast.success(
|
||||
saved.version
|
||||
? `uv 设置已保存(${shortUvVersion(saved.version)})`
|
||||
: '已保存 uv 设置,但当前 uv 不可用',
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: () => downloadUv(),
|
||||
onSuccess: (saved: UvSettings) => {
|
||||
resetDrafts()
|
||||
toast.success(
|
||||
`已下载 ${shortUvVersion(saved.version) ?? 'uv'} 并切换到程序目录 uv`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: () => installPython({ version: pythonVersion.trim() }),
|
||||
onSuccess: (installed: ManagedPython[]) => {
|
||||
setPythonVersion('')
|
||||
queryClient.setQueryData(['managed-pythons'], installed)
|
||||
toast.success(`Python 已下载(当前 ${installed.length} 个版本可用)`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['managed-pythons'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const updateEnvRow = (index: number, patch: Partial<EnvRow>) => {
|
||||
setDraftEnv(
|
||||
envRows.map((row, position) =>
|
||||
position === index ? { ...row, ...patch } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const seen = new Set<string>()
|
||||
for (const row of envRows) {
|
||||
const name = row.name.trim()
|
||||
if (!name) {
|
||||
if (row.value.trim()) {
|
||||
toast.error('环境变量名不能为空')
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!name.toUpperCase().startsWith('UV_')) {
|
||||
toast.error(`环境变量名 ${name} 需要以 UV_ 开头`)
|
||||
return
|
||||
}
|
||||
const key = name.toUpperCase()
|
||||
if (seen.has(key)) {
|
||||
toast.error(`环境变量 ${name} 重复`)
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
// 两个路径变量决定解释器与缓存的位置,清空会让 uv 找不到目录。
|
||||
for (const name of lockedNames) {
|
||||
const row = envRows.find(
|
||||
(candidate) => candidate.name.trim().toUpperCase() === name,
|
||||
)
|
||||
if (!row || !row.value.trim()) {
|
||||
toast.error(`${name} 不能为空,它决定 uv 的 Python / 缓存目录`)
|
||||
return
|
||||
}
|
||||
}
|
||||
saveMutation.mutate()
|
||||
}
|
||||
|
||||
const saveDisabled = settingsQuery.isPending || settingsQuery.isError
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="uv 配置"
|
||||
size="lg"
|
||||
className="uv-settings-modal"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="uv-settings-form"
|
||||
loading={saveMutation.isPending}
|
||||
disabled={saveDisabled}
|
||||
>
|
||||
保存设置
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{settingsQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="uv 设置加载失败"
|
||||
description={settingsQuery.error.message}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void settingsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : settingsQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取 uv 设置" />
|
||||
<Text tone="sub">正在检测 uv...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
id="uv-settings-form"
|
||||
className="uv-settings"
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
className="uv-tabs"
|
||||
variant="line"
|
||||
items={[
|
||||
{ key: 'binary', label: 'uv 可执行文件' },
|
||||
{ key: 'env', label: `环境变量(${envRows.length})` },
|
||||
{ key: 'python', label: `Python 解释器(${pythons.length})` },
|
||||
]}
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as UvTab)}
|
||||
/>
|
||||
|
||||
{activeTab === 'binary' ? (
|
||||
<section
|
||||
className="uv-section"
|
||||
role="tabpanel"
|
||||
aria-label="uv 可执行文件设置"
|
||||
>
|
||||
<RadioGroup
|
||||
direction="row"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={source}
|
||||
onChange={(value) => setDraftSource(value as UvSource)}
|
||||
/>
|
||||
|
||||
<div className="uv-path-row">
|
||||
{source === 'managed' ? (
|
||||
// 程序目录的 uv 是固定位置,只展示、不可编辑;只有「指定路径」能改。
|
||||
<div className="uv-path-static" title={managedPath}>
|
||||
<Icon name="lock_line" />
|
||||
<span className="uv-path-static-text">{managedPath}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={customPath}
|
||||
onChange={(event) => setDraftCustomPath(event.target.value)}
|
||||
className="mono-input"
|
||||
aria-label="uv 可执行文件"
|
||||
placeholder="D:\\tools\\uv\\uv.exe"
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
{source === 'managed' ? (
|
||||
uvReady ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => downloadMutation.mutate()}
|
||||
loading={downloadMutation.isPending}
|
||||
>
|
||||
下载 uv
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
浏览
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text size="xs" tone="muted">
|
||||
{source === 'managed'
|
||||
? uvReady
|
||||
? '程序目录里的 uv,随发布目录整体搬走。'
|
||||
: '没装 uv 时可以从下面的镜像下载当前架构的版本,校验 sha256 后放进程序目录并自动切换。'
|
||||
: '填 uv.exe 的完整路径,或点「浏览」选择;保存时会校验它能否运行。'}
|
||||
</Text>
|
||||
|
||||
<FormField
|
||||
label="下载镜像"
|
||||
htmlFor="uv-mirror-url"
|
||||
hint="GitHub Release 镜像根:uv 本体与 Python 解释器都从这里下载,留空用内置默认。解释器地址也可以用下面的 UV_PYTHON_INSTALL_MIRROR 单独覆盖。"
|
||||
>
|
||||
<Input
|
||||
id="uv-mirror-url"
|
||||
value={mirrorUrl}
|
||||
onChange={(event) => setDraftMirror(event.target.value)}
|
||||
className="mono-input"
|
||||
placeholder="https://mirrors.ustc.edu.cn/github-release"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
) : activeTab === 'env' ? (
|
||||
<section
|
||||
className="uv-section"
|
||||
role="tabpanel"
|
||||
aria-label="UV 环境变量"
|
||||
>
|
||||
<div className="uv-panel-head">
|
||||
<Text size="xs" tone="muted">
|
||||
共 {envRows.length} 项
|
||||
</Text>
|
||||
<TextButton
|
||||
onClick={() =>
|
||||
setDraftEnv([...envRows, { name: '', value: '' }])
|
||||
}
|
||||
>
|
||||
+ 添加变量
|
||||
</TextButton>
|
||||
</div>
|
||||
|
||||
<Text size="xs" tone="muted">
|
||||
UV_PYTHON_INSTALL_DIR、UV_CACHE_DIR 决定解释器和缓存放在哪,
|
||||
UV_PYTHON_INSTALL_MIRROR 决定解释器从哪下载;这三项只能改值、不能删除。
|
||||
其余 UV_* 变量会注入到每一次 uv 调用,路径类可填相对程序目录的值。
|
||||
</Text>
|
||||
|
||||
{envRows.length === 0 ? (
|
||||
<Text size="sm" tone="muted">
|
||||
没有额外环境变量
|
||||
</Text>
|
||||
) : (
|
||||
<div className="uv-env-rows">
|
||||
{envRows.map((row, index) => {
|
||||
const locked = isLockedName(row.name)
|
||||
const isMirror =
|
||||
row.name.trim().toUpperCase() ===
|
||||
'UV_PYTHON_INSTALL_MIRROR'
|
||||
|
||||
return (
|
||||
<div className="uv-env-row" key={index}>
|
||||
{locked ? (
|
||||
<span
|
||||
className="uv-env-locked"
|
||||
title={
|
||||
isMirror
|
||||
? '解释器的下载源,不能删除;改值即可换镜像'
|
||||
: '决定 uv 的 Python / 缓存目录,不能删除'
|
||||
}
|
||||
>
|
||||
<Icon name="lock_line" />
|
||||
<span className="uv-env-locked-name">{row.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateEnvRow(index, { name: event.target.value })
|
||||
}
|
||||
className="mono-input"
|
||||
placeholder="UV_HTTP_TIMEOUT"
|
||||
spellCheck={false}
|
||||
aria-label={`环境变量名 ${index + 1}`}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
updateEnvRow(index, { value: event.target.value })
|
||||
}
|
||||
className="mono-input"
|
||||
placeholder={
|
||||
locked && !isMirror
|
||||
? '相对于程序目录,或绝对路径'
|
||||
: '值'
|
||||
}
|
||||
spellCheck={false}
|
||||
aria-label={`环境变量值 ${index + 1}`}
|
||||
/>
|
||||
{locked ? (
|
||||
<Text size="xs" tone="muted">
|
||||
固定
|
||||
</Text>
|
||||
) : (
|
||||
<TextButton
|
||||
tone="danger"
|
||||
onClick={() =>
|
||||
setDraftEnv(
|
||||
envRows.filter(
|
||||
(_, position) => position !== index,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</TextButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section
|
||||
className="uv-section"
|
||||
role="tabpanel"
|
||||
aria-label="Python 解释器设置"
|
||||
>
|
||||
<Text size="xs" tone="muted">
|
||||
由 uv 下载,共 {pythons.length} 个版本。解释器放在{' '}
|
||||
<Text className="mono" size="xs" tone="sub">
|
||||
{settings?.python_dir ?? 'python\\'}
|
||||
</Text>
|
||||
(UV_PYTHON_INSTALL_DIR);新建虚拟环境时直接选这里的版本,不用重复下载。
|
||||
</Text>
|
||||
|
||||
<Space gap="xs" align="center" wrap>
|
||||
<Input
|
||||
value={pythonVersion}
|
||||
onChange={(event) => setPythonVersion(event.target.value)}
|
||||
className="mono-input uv-version-input"
|
||||
placeholder={PYTHON_VERSION_HINT}
|
||||
aria-label="要下载的 Python 版本"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={installMutation.isPending}
|
||||
disabled={!uvReady}
|
||||
title={uvReady ? undefined : '请先安装或指定 uv'}
|
||||
onClick={() => {
|
||||
if (!pythonVersion.trim()) {
|
||||
toast.error('请填写要下载的 Python 版本,例如 3.12')
|
||||
return
|
||||
}
|
||||
installMutation.mutate()
|
||||
}}
|
||||
>
|
||||
下载 Python
|
||||
</Button>
|
||||
{installMutation.isPending ? (
|
||||
<Text size="xs" tone="muted">
|
||||
正在下载,可能需要几十秒...
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{pythonsQuery.isError ? (
|
||||
<Notice
|
||||
tone="error"
|
||||
title="解释器列表读取失败"
|
||||
action={{
|
||||
label: '重试',
|
||||
onClick: () => void pythonsQuery.refetch(),
|
||||
}}
|
||||
>
|
||||
{pythonsQuery.error.message}
|
||||
</Notice>
|
||||
) : pythonsQuery.isPending ? (
|
||||
uvReady ? (
|
||||
<div className="uv-inline-state">
|
||||
<Spinner size="sm" label="正在读取解释器" />
|
||||
<Text size="sm" tone="muted">
|
||||
正在读取解释器...
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm" tone="muted">
|
||||
先在上面安装或指定 uv,装好之后才能下载解释器。
|
||||
</Text>
|
||||
)
|
||||
) : pythons.length === 0 ? (
|
||||
<Empty
|
||||
className="uv-empty"
|
||||
text="还没有下载解释器。填一个版本号(例如 3.12)后点「下载 Python」。"
|
||||
/>
|
||||
) : (
|
||||
<ul className="uv-python-list">
|
||||
{pythons.map((python) => (
|
||||
<li className="uv-python-item" key={python.version}>
|
||||
<Text strong className="uv-python-version">
|
||||
{python.version}
|
||||
</Text>
|
||||
<code
|
||||
className="code-chip uv-python-path"
|
||||
title={python.path}
|
||||
>
|
||||
{python.path}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<DirectoryPickerDialog
|
||||
open={pickerOpen}
|
||||
initialPath={directoryOf(customPath)}
|
||||
mode="file"
|
||||
fileExtensions={['.exe']}
|
||||
title="选择 uv 可执行文件"
|
||||
onSelect={(path) => setDraftCustomPath(path)}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+84
-55
@@ -1,41 +1,42 @@
|
||||
:root {
|
||||
--bg-color: #f7f7f8;
|
||||
--card-bg: #ffffff;
|
||||
--text-main: #111827;
|
||||
--text-muted: #8c939d;
|
||||
--text-light: #b5b9c0;
|
||||
--primary: #111827;
|
||||
--primary-hover: #262e3d;
|
||||
--border-color: #f0f1f3;
|
||||
--badge-bg: #f3f4f6;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
--shadow-card: 0 8px 30px rgba(0, 0, 0, 0.03);
|
||||
--shadow-modal: 0 20px 40px rgba(15, 23, 42, 0.08);
|
||||
--background: var(--bg-color);
|
||||
--surface: var(--card-bg);
|
||||
--surface-muted: #fafafa;
|
||||
--text: var(--text-main);
|
||||
--text-secondary: var(--text-muted);
|
||||
--text-tertiary: #a4aab3;
|
||||
--border: #eceef1;
|
||||
--success: #10b981;
|
||||
--success-bg: #eefbf5;
|
||||
--danger: #f43f5e;
|
||||
--danger-bg: #fff1f2;
|
||||
--warning: #d97706;
|
||||
--warning-bg: #fffbeb;
|
||||
--shadow: var(--shadow-card);
|
||||
--font-sans:
|
||||
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', Consolas, 'Courier New', monospace;
|
||||
@font-face {
|
||||
font-family: 'Outfit-Medium';
|
||||
src: url('/fonts/Outfit-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
font-family: var(--font-sans);
|
||||
/* 正文中文字体:LXGW WenKai(自托管,GB2312 + 常用符号子集) */
|
||||
@font-face {
|
||||
font-family: 'LXGW WenKai';
|
||||
src: url('/fonts/LXGWWenKai-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'LXGW WenKai';
|
||||
src: url('/fonts/LXGWWenKai-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/*
|
||||
* 页面级基础:字体 / 重置 / 全局焦点兜底。
|
||||
* 颜色、间距、圆角、阴影一律取自 @stash/ui 的 --su-* 令牌,这里不再定义主题色。
|
||||
*/
|
||||
:root {
|
||||
/* 展示字体:标题用 Outfit,不参与令牌体系,页面自己持有 */
|
||||
--font-display: 'Outfit-Medium', var(--su-font-sans);
|
||||
|
||||
color: var(--su-text-main);
|
||||
background-color: var(--su-bg-page);
|
||||
font-family: var(--su-font-sans);
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
color-scheme: light;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -44,19 +45,23 @@
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--background);
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -66,7 +71,13 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* 组件库的「无底色按钮」依赖宿主这份 reset(Select 选项、Segmented、Tabs、Menu
|
||||
都只声明选中态底色)。少了它,浏览器默认的 ButtonFace 灰底和按钮边框会露出来,
|
||||
下拉选项会变成一块块灰条。 */
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -74,16 +85,34 @@ button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 键盘焦点环由组件库统一提供;这里只给「非表单控件」补兜底。
|
||||
表单控件不能加全局 outline:输入框点击时也会命中 :focus-visible,
|
||||
会和组件自己的边框态叠成「双框」。 */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
[tabindex]:not(input):focus-visible {
|
||||
outline: var(--su-focus-ring);
|
||||
outline-offset: var(--su-focus-ring-offset);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid rgba(17, 24, 39, 0.25);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
background: var(--su-color-primary-soft);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,21 @@ export function formatDateTime(value: string | null | undefined): string {
|
||||
return Number.isNaN(date.getTime()) ? '--' : dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
const shortDateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
export function formatShortDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '--' : shortDateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '--'
|
||||
@@ -38,6 +53,17 @@ export function actionTypeLabel(type: TaskActionType): string {
|
||||
}[type]
|
||||
}
|
||||
|
||||
/** uv --version 是 `uv 0.13.7 (hash 日期 架构)`,界面只显示版本号。 */
|
||||
export function shortUvVersion(
|
||||
version: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!version) {
|
||||
return undefined
|
||||
}
|
||||
const number = version.replace(/^uv\s+/i, '').split('(')[0]?.trim()
|
||||
return number || version
|
||||
}
|
||||
|
||||
export const runStatusLabels: Record<RunStatus, string> = {
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendLogChunk,
|
||||
createLogBuffer,
|
||||
flushLogTail,
|
||||
logLineCount,
|
||||
} from './logLines'
|
||||
|
||||
describe('appendLogChunk', () => {
|
||||
it('splits complete lines and keeps the unfinished tail', () => {
|
||||
const buffer = createLogBuffer()
|
||||
|
||||
appendLogChunk(buffer, 'first\nsecond\npart')
|
||||
|
||||
expect(buffer.lines).toEqual(['first', 'second'])
|
||||
expect(buffer.tail).toBe('part')
|
||||
expect(logLineCount(buffer)).toBe(3)
|
||||
})
|
||||
|
||||
it('joins chunks that split a line in the middle', () => {
|
||||
const buffer = createLogBuffer()
|
||||
|
||||
appendLogChunk(buffer, 'hel')
|
||||
appendLogChunk(buffer, 'lo\nnext')
|
||||
|
||||
expect(buffer.lines).toEqual(['hello'])
|
||||
expect(buffer.tail).toBe('next')
|
||||
})
|
||||
|
||||
it('drops the carriage return of Windows line endings', () => {
|
||||
const buffer = createLogBuffer()
|
||||
|
||||
appendLogChunk(buffer, 'one\r\ntwo\r\n')
|
||||
|
||||
expect(buffer.lines).toEqual(['one', 'two'])
|
||||
expect(buffer.tail).toBe('')
|
||||
})
|
||||
|
||||
it('ignores empty chunks', () => {
|
||||
const buffer = createLogBuffer()
|
||||
|
||||
appendLogChunk(buffer, '')
|
||||
appendLogChunk(buffer, 'x\n')
|
||||
|
||||
expect(buffer.lines).toEqual(['x'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('flushLogTail', () => {
|
||||
it('promotes an unterminated last line when the run ends', () => {
|
||||
const buffer = createLogBuffer()
|
||||
appendLogChunk(buffer, 'line\nno-newline')
|
||||
|
||||
flushLogTail(buffer)
|
||||
|
||||
expect(buffer.lines).toEqual(['line', 'no-newline'])
|
||||
expect(buffer.tail).toBe('')
|
||||
})
|
||||
|
||||
it('does nothing when the log ends with a newline', () => {
|
||||
const buffer = createLogBuffer()
|
||||
appendLogChunk(buffer, 'line\n')
|
||||
|
||||
flushLogTail(buffer)
|
||||
|
||||
expect(buffer.lines).toEqual(['line'])
|
||||
expect(logLineCount(buffer)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 运行日志的行缓冲。
|
||||
*
|
||||
* 日志按块到达(SSE 合并推送或轮询分片),块边界可能落在任意字符之间,
|
||||
* 所以只把完整行放进 `lines`,剩下的残行留在 `tail` 等下一块拼接。
|
||||
* 直接改数组而不是每块复制一份,避免长日志上的 O(n²) 拷贝。
|
||||
*/
|
||||
export interface LogBuffer {
|
||||
lines: string[]
|
||||
tail: string
|
||||
}
|
||||
|
||||
export function createLogBuffer(): LogBuffer {
|
||||
return { lines: [], tail: '' }
|
||||
}
|
||||
|
||||
/** 把一段增量日志并进缓冲区,只切出完整的行。 */
|
||||
export function appendLogChunk(buffer: LogBuffer, chunk: string): void {
|
||||
if (!chunk) {
|
||||
return
|
||||
}
|
||||
const combined = buffer.tail + chunk
|
||||
const parts = combined.split('\n')
|
||||
buffer.tail = parts.pop() ?? ''
|
||||
for (const line of parts) {
|
||||
// Windows 子进程输出以 \r\n 结尾,去掉行尾的 \r 免得显示成方框。
|
||||
buffer.lines.push(line.endsWith('\r') ? line.slice(0, -1) : line)
|
||||
}
|
||||
}
|
||||
|
||||
/** 运行结束:没有以换行符收尾的残行也要作为最后一行显示。 */
|
||||
export function flushLogTail(buffer: LogBuffer): void {
|
||||
if (buffer.tail) {
|
||||
buffer.lines.push(
|
||||
buffer.tail.endsWith('\r') ? buffer.tail.slice(0, -1) : buffer.tail,
|
||||
)
|
||||
buffer.tail = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 供界面展示的日志行数(包含尚未换行的最后一行)。 */
|
||||
export function logLineCount(buffer: LogBuffer): number {
|
||||
return buffer.lines.length + (buffer.tail ? 1 : 0)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeLogWindow, isNearBottom } from './logWindow'
|
||||
|
||||
describe('computeLogWindow', () => {
|
||||
it('renders a bounded window for a long log', () => {
|
||||
const window = computeLogWindow(1000, 0, 400, 20, 5)
|
||||
|
||||
expect(window.start).toBe(0)
|
||||
expect(window.end).toBe(25)
|
||||
})
|
||||
|
||||
it('moves the window with the scroll position', () => {
|
||||
const window = computeLogWindow(1000, 400, 400, 20, 5)
|
||||
|
||||
expect(window.start).toBe(15)
|
||||
expect(window.end).toBe(45)
|
||||
})
|
||||
|
||||
it('clamps the window at the end of the log', () => {
|
||||
const window = computeLogWindow(1000, 20_000, 400, 20, 5)
|
||||
|
||||
expect(window.start).toBe(995)
|
||||
expect(window.end).toBe(1000)
|
||||
})
|
||||
|
||||
it('returns an empty window for an empty log', () => {
|
||||
expect(computeLogWindow(0, 0, 400, 20, 5)).toEqual({ start: 0, end: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNearBottom', () => {
|
||||
it('treats the bottom edge as following', () => {
|
||||
expect(isNearBottom(1600, 400, 2000, 24)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports scrolling up as not following', () => {
|
||||
expect(isNearBottom(1200, 400, 2000, 24)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/** 虚拟滚动的可见窗口(左闭右开)。 */
|
||||
export interface LogWindow {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算需要渲染的行区间。
|
||||
*
|
||||
* `scrollTop` 以像素计,行高固定;`overscan` 是窗口上下额外渲染的行数,
|
||||
* 用来遮住快速滚动时的空白。
|
||||
*/
|
||||
export function computeLogWindow(
|
||||
total: number,
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
rowHeight: number,
|
||||
overscan: number,
|
||||
): LogWindow {
|
||||
if (total <= 0 || rowHeight <= 0) {
|
||||
return { start: 0, end: 0 }
|
||||
}
|
||||
const first = clamp(Math.floor(Math.max(0, scrollTop) / rowHeight), 0, total)
|
||||
const visible = Math.max(1, Math.ceil(Math.max(0, viewportHeight) / rowHeight))
|
||||
const start = Math.max(0, first - overscan)
|
||||
const end = Math.min(total, first + visible + overscan)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/** 视口是否已经贴着底部(留一点像素容差)。 */
|
||||
export function isNearBottom(
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
scrollHeight: number,
|
||||
threshold: number,
|
||||
): boolean {
|
||||
return scrollHeight - scrollTop - clientHeight <= threshold
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
@@ -28,6 +28,26 @@ describe('task form payload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('submits the selected script language and interpreter', () => {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = 'shell_task'
|
||||
values.name = 'Shell task'
|
||||
values.script.language = 'shell'
|
||||
values.script.working_dir = 'D:\\Projects\\shell-task'
|
||||
values.script.interpreter = 'cmd.exe'
|
||||
values.script.script = 'run.bat'
|
||||
|
||||
const payload = valuesToCreateRequest(values)
|
||||
|
||||
expect(payload.action).toMatchObject({
|
||||
type: 'script',
|
||||
language: 'shell',
|
||||
working_dir: 'D:\\Projects\\shell-task',
|
||||
interpreter: 'cmd.exe',
|
||||
script: 'run.bat',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates an HTTP action instead of forcing script type', () => {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = 'health_check'
|
||||
|
||||
@@ -12,9 +12,79 @@ export interface KeyValueFormValue {
|
||||
value: string
|
||||
}
|
||||
|
||||
export type ScriptLanguage = 'python' | 'shell' | 'node' | 'lua'
|
||||
|
||||
export const SCRIPT_LANGUAGE_META: Record<
|
||||
ScriptLanguage,
|
||||
{
|
||||
label: string
|
||||
defaultInterpreter: string
|
||||
defaultScript: string
|
||||
scriptLabel: string
|
||||
scriptPlaceholder: string
|
||||
hint: string
|
||||
}
|
||||
> = {
|
||||
python: {
|
||||
label: 'Python',
|
||||
defaultInterpreter: '',
|
||||
defaultScript: 'main.py',
|
||||
scriptLabel: '脚本文件或相对路径',
|
||||
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
|
||||
hint: '优先使用项目里的 .venv;没有环境时保存任务会自动用 uv 创建。',
|
||||
},
|
||||
shell: {
|
||||
label: 'Shell (cmd)',
|
||||
defaultInterpreter: 'cmd.exe',
|
||||
defaultScript: 'run.bat',
|
||||
scriptLabel: '批处理文件或相对路径',
|
||||
scriptPlaceholder: 'run.bat 或 scripts\\start.cmd',
|
||||
hint: '通过 cmd.exe /C 执行批处理文件,不需要虚拟环境。',
|
||||
},
|
||||
node: {
|
||||
label: 'Node.js',
|
||||
defaultInterpreter: 'node.exe',
|
||||
defaultScript: 'index.js',
|
||||
scriptLabel: 'JavaScript 文件或相对路径',
|
||||
scriptPlaceholder: 'index.js 或 scripts\\worker.js',
|
||||
hint: '通过 node.exe 执行,不需要虚拟环境;需已安装 Node.js。',
|
||||
},
|
||||
lua: {
|
||||
label: 'Lua',
|
||||
defaultInterpreter: 'lua.exe',
|
||||
defaultScript: 'main.lua',
|
||||
scriptLabel: 'Lua 文件或相对路径',
|
||||
scriptPlaceholder: 'main.lua 或 scripts\\task.lua',
|
||||
hint: '通过 lua.exe 执行,不需要虚拟环境;需已安装 Lua。',
|
||||
},
|
||||
}
|
||||
|
||||
export function detectScriptLanguage(interpreter: string): ScriptLanguage {
|
||||
const name = interpreter
|
||||
.trim()
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.replace(/\.exe$/, '')
|
||||
if (name === 'node' || name === 'nodejs') {
|
||||
return 'node'
|
||||
}
|
||||
if (name?.startsWith('lua')) {
|
||||
return 'lua'
|
||||
}
|
||||
if (
|
||||
name &&
|
||||
['cmd', 'powershell', 'pwsh', 'bash', 'sh', 'zsh'].includes(name)
|
||||
) {
|
||||
return 'shell'
|
||||
}
|
||||
return 'python'
|
||||
}
|
||||
|
||||
export interface TaskFormValues {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
cron: string
|
||||
timezone: string
|
||||
timeout_secs: number
|
||||
@@ -23,6 +93,7 @@ export interface TaskFormValues {
|
||||
script: {
|
||||
working_dir: string
|
||||
venv: string
|
||||
language: ScriptLanguage
|
||||
interpreter: string
|
||||
script: string
|
||||
args: KeyValueFormValue[]
|
||||
@@ -72,6 +143,10 @@ export const taskFormSchema = z
|
||||
.trim()
|
||||
.min(1, '请输入任务名称')
|
||||
.max(120, '任务名称不能超过 120 个字符'),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500, '任务说明不能超过 500 个字符'),
|
||||
cron: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -88,6 +163,7 @@ export const taskFormSchema = z
|
||||
script: z.object({
|
||||
working_dir: z.string(),
|
||||
venv: z.string(),
|
||||
language: z.enum(['python', 'shell', 'node', 'lua']),
|
||||
interpreter: z.string(),
|
||||
script: z.string(),
|
||||
args: z.array(keyValueSchema),
|
||||
@@ -116,13 +192,6 @@ export const taskFormSchema = z
|
||||
message: '请输入工作目录',
|
||||
})
|
||||
}
|
||||
if (!values.script.interpreter.trim()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['script', 'interpreter'],
|
||||
message: '请输入解释器或可执行程序',
|
||||
})
|
||||
}
|
||||
if (!values.script.script.trim()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
@@ -163,6 +232,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
cron: '0 0 2 * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
timeout_secs: 300,
|
||||
@@ -171,7 +241,8 @@ export function createEmptyTaskFormValues(): TaskFormValues {
|
||||
script: {
|
||||
working_dir: '',
|
||||
venv: '',
|
||||
interpreter: 'python.exe',
|
||||
language: 'python',
|
||||
interpreter: '',
|
||||
script: 'main.py',
|
||||
args: [],
|
||||
env: [],
|
||||
@@ -196,6 +267,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = task.id
|
||||
values.name = task.name
|
||||
values.description = task.description ?? ''
|
||||
values.cron = task.cron
|
||||
values.timezone = task.timezone
|
||||
values.timeout_secs = task.timeout_secs
|
||||
@@ -203,9 +275,11 @@ export function taskToFormValues(task: Task): TaskFormValues {
|
||||
values.actionType = task.action.type
|
||||
|
||||
if (task.action.type === 'script') {
|
||||
const language = task.action.language ?? detectScriptLanguage(task.action.interpreter)
|
||||
values.script = {
|
||||
working_dir: task.action.working_dir,
|
||||
venv: task.action.venv ?? '',
|
||||
venv: language === 'python' ? (task.action.venv ?? '') : '',
|
||||
language,
|
||||
interpreter: task.action.interpreter,
|
||||
script: task.action.script,
|
||||
args: argsToRows(task.action.args ?? []),
|
||||
@@ -245,6 +319,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
|
||||
type: 'script',
|
||||
working_dir: values.script.working_dir.trim(),
|
||||
venv: values.script.venv.trim() || null,
|
||||
language: values.script.language,
|
||||
interpreter: values.script.interpreter.trim(),
|
||||
script: values.script.script.trim(),
|
||||
args: argumentsToArray(values.script.args),
|
||||
@@ -291,15 +366,11 @@ export function valuesToUpdateRequest(
|
||||
): UpdateTaskRequest {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
cron: values.cron.trim(),
|
||||
timezone: values.timezone.trim(),
|
||||
timeout_secs: values.timeout_secs,
|
||||
enabled: values.enabled,
|
||||
concurrency_policy: 'skip',
|
||||
retry_policy: {
|
||||
max_attempts: 0,
|
||||
delay_secs: 0,
|
||||
},
|
||||
action: valuesToAction(values),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
export function useFocusTrap(
|
||||
open: boolean,
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
initialFocusSelector?: string,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!open || !containerRef.current) {
|
||||
return
|
||||
}
|
||||
const container = containerRef.current
|
||||
const previousFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
const focusFirst = () => {
|
||||
const initial = initialFocusSelector
|
||||
? container.querySelector<HTMLElement>(initialFocusSelector)
|
||||
: null
|
||||
const first =
|
||||
initial ?? container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
;(first ?? container).focus()
|
||||
}
|
||||
const frame = window.requestAnimationFrame(focusFirst)
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') {
|
||||
return
|
||||
}
|
||||
const focusable = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
).filter((element) => element.offsetParent !== null)
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault()
|
||||
container.focus()
|
||||
return
|
||||
}
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
previousFocus?.focus()
|
||||
}
|
||||
}, [containerRef, initialFocusSelector, open])
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from '@stash/ui'
|
||||
import '@stash/ui/style.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
@@ -20,6 +22,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,21 @@ import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* jsdom 30 还没有实现 `<dialog>` 的 showModal / close,
|
||||
* 而 @stash/ui 的 Modal / Drawer 依赖它们(浏览器原生支持)。
|
||||
* 这里补一个最小的开关实现:只维护 open 属性,焦点与 inert 由真实浏览器负责。
|
||||
*/
|
||||
const dialogPrototype = window.HTMLDialogElement?.prototype
|
||||
if (dialogPrototype && typeof dialogPrototype.showModal !== 'function') {
|
||||
dialogPrototype.showModal = function showModal(this: HTMLDialogElement) {
|
||||
this.setAttribute('open', '')
|
||||
}
|
||||
dialogPrototype.close = function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute('open')
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
@@ -3,6 +3,10 @@ import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// @stash/ui 是本地 file 依赖(软链),去重 React,避免加载两份运行时
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""TaskPilot 演示任务。
|
||||
|
||||
在 TaskPilot 里把「项目工作目录」指向本目录、解释器填 ``python.exe``、脚本填
|
||||
``main.py``,后端会自动关联同级的 ``.venv`` 并以虚拟环境解释器运行。
|
||||
|
||||
常用参数:
|
||||
--lines N 输出 N 行中文(默认 3)
|
||||
--sleep N 执行前等待 N 秒,用于验证超时与取消
|
||||
--fail 以退出码 3 结束,用于验证失败状态
|
||||
--append PATH 把一行结果追加到指定文件,用于验证副作用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="TaskPilot python demo task")
|
||||
parser.add_argument("--lines", type=int, default=3)
|
||||
parser.add_argument("--sleep", type=float, default=0.0)
|
||||
parser.add_argument("--fail", action="store_true")
|
||||
parser.add_argument("--append", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
started = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
print(f"[demo] 启动时间 {started}")
|
||||
print(f"[demo] 解释器 {sys.executable}")
|
||||
print(f"[demo] VIRTUAL_ENV {os.environ.get('VIRTUAL_ENV', '<未设置>')}")
|
||||
print(f"[demo] 工作目录 {Path.cwd()}")
|
||||
print(f"[demo] 参数 lines={args.lines} sleep={args.sleep} fail={args.fail}")
|
||||
|
||||
for index in range(args.lines):
|
||||
print(f"[demo] 第 {index + 1} 行:中文输出与勾选 √")
|
||||
|
||||
if args.append is not None:
|
||||
args.append.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.append.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{started} ok\n")
|
||||
print(f"[demo] 已追加写入 {args.append}")
|
||||
|
||||
if args.sleep > 0:
|
||||
print(f"[demo] 开始等待 {args.sleep} 秒", flush=True)
|
||||
time.sleep(args.sleep)
|
||||
|
||||
if args.fail:
|
||||
print("[demo] 按要求返回失败", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
print("[demo] 完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
[project]
|
||||
name = "taskpilot-python-demo"
|
||||
version = "0.1.0"
|
||||
description = "TaskPilot 演示任务:验证虚拟环境、中文输出与退出码"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
@@ -7,6 +7,12 @@ if (-not $releaseRoot.StartsWith($expectedPrefix, [System.StringComparison]::Ord
|
||||
throw "Refusing to build outside release directory: $releaseRoot"
|
||||
}
|
||||
|
||||
# 与 check.ps1 一致:临时目录放在仓库内,避免系统 %TEMP% 权限策略让 aws-lc-sys 的 cc 构建失败。
|
||||
$scratch = Join-Path $projectRoot '.tmp'
|
||||
[void](New-Item -ItemType Directory -Path $scratch -Force)
|
||||
$env:TMP = $scratch
|
||||
$env:TEMP = $scratch
|
||||
|
||||
function Assert-ExitCode {
|
||||
param([string]$Step)
|
||||
|
||||
@@ -17,6 +23,12 @@ function Assert-ExitCode {
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
# 前端依赖同级 stash-ui(file:../../stash-ui):发布包要求先有组件库产物
|
||||
$stashUiDist = [System.IO.Path]::GetFullPath((Join-Path $projectRoot '..\stash-ui\dist\stash-ui.js'))
|
||||
if (-not (Test-Path -LiteralPath $stashUiDist)) {
|
||||
throw "Missing @stash/ui build output: $stashUiDist. Run 'npm --prefix ..\stash-ui install' and 'npm --prefix ..\stash-ui run build' first."
|
||||
}
|
||||
|
||||
pnpm --dir frontend build
|
||||
Assert-ExitCode 'Frontend build'
|
||||
cargo build --release --bin task-pilot-backend --manifest-path backend\Cargo.toml
|
||||
@@ -27,6 +39,8 @@ try {
|
||||
}
|
||||
[void](New-Item -ItemType Directory -Path $releaseRoot -Force)
|
||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'web') -Force)
|
||||
# uv 管理页默认从这个目录找自己下载的 uv.exe
|
||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'uv') -Force)
|
||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'data') -Force)
|
||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'logs') -Force)
|
||||
|
||||
@@ -35,6 +49,10 @@ try {
|
||||
Copy-Item -Path 'frontend\dist\*' -Destination (Join-Path $releaseRoot 'web') -Recurse
|
||||
Copy-Item -LiteralPath 'docs\RELEASE.md' -Destination (Join-Path $releaseRoot 'README.md')
|
||||
|
||||
# 发布包不该继承工作区的低完整性标签:带着它启动的进程写不了 uv 缓存和用户文件。
|
||||
& icacls $releaseRoot /setintegritylevel '(OI)(CI)M' /T | Out-Null
|
||||
Assert-ExitCode 'Release integrity label'
|
||||
|
||||
Write-Host "Release package: $releaseRoot"
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$scratch = Join-Path $projectRoot '.tmp'
|
||||
|
||||
function Assert-ExitCode {
|
||||
param([string]$Step)
|
||||
@@ -10,6 +11,12 @@ function Assert-ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
# 测试统一使用仓库内的临时目录:不依赖系统 %TEMP% 的权限策略,
|
||||
# 也让清理、归档和排障都留在项目目录里。
|
||||
[void](New-Item -ItemType Directory -Path $scratch -Force)
|
||||
$env:TMP = $scratch
|
||||
$env:TEMP = $scratch
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
pnpm --dir frontend lint
|
||||
@@ -31,4 +38,5 @@ try {
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""重新生成界面字体(字体二进制不入库)。
|
||||
|
||||
用法:
|
||||
uv run --with fonttools --with brotli tools/build-fonts.py
|
||||
|
||||
流程:从 lxgw-wenkai-webfont 的 CDN 下载 Regular / Bold 的全部分包 woff2 子集,
|
||||
合并成单个字体后裁剪到 GB2312 + Latin + 中文标点,写入 frontend/public/fonts/。
|
||||
生成的字体仅供本地构建与打包使用,不提交到仓库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from fontTools.merge import Merger
|
||||
from fontTools.subset import Options, Subsetter
|
||||
from fontTools.ttLib import TTFont
|
||||
except ImportError: # pragma: no cover - 由 uv --with 保证
|
||||
sys.exit("缺少依赖:uv run --with fonttools --with brotli tools/build-fonts.py")
|
||||
|
||||
CDN = "https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/"
|
||||
WEIGHTS = {
|
||||
"Regular": "lxgwwenkai-regular.css",
|
||||
"Bold": "lxgwwenkai-bold.css",
|
||||
}
|
||||
OUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "fonts"
|
||||
|
||||
|
||||
def fetch(url: str) -> bytes:
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "taskpilot-font-builder"}
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def subset_urls(css_name: str) -> list[str]:
|
||||
css = fetch(CDN + css_name).decode("utf-8")
|
||||
# 形如:src: url('./files/lxgwwenkai-regular-subset-4.woff2')
|
||||
matches = re.findall(r"url\(\s*['\"]?([^'\")]+\.woff2)['\"]?\s*\)", css)
|
||||
urls = sorted({match.lstrip("./") for match in matches})
|
||||
if not urls:
|
||||
raise RuntimeError(f"未能从 {css_name} 解析出字体子集,CDN 格式可能已变更")
|
||||
return urls
|
||||
|
||||
|
||||
def build_codepoints() -> set[int]:
|
||||
points: set[int] = set()
|
||||
for start, end in (
|
||||
(0x20, 0x7F), # ASCII
|
||||
(0xA0, 0x100), # Latin-1
|
||||
(0x2000, 0x2070), # 通用标点
|
||||
(0x25A0, 0x2600), # 几何图形
|
||||
(0x3000, 0x3040), # 中文标点
|
||||
(0xFF00, 0xFFF0), # 全角字符
|
||||
):
|
||||
points.update(range(start, end))
|
||||
points.update(
|
||||
[0x00D7, 0x00F7, 0x2190, 0x2191, 0x2192, 0x2193, 0x2713, 0x2717, 0x23F3]
|
||||
)
|
||||
# GB2312 汉字与符号行
|
||||
for high in range(0xA1, 0xF8):
|
||||
for low in range(0xA1, 0xFF):
|
||||
try:
|
||||
points.add(ord(bytes([high, low]).decode("gb2312")))
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return points
|
||||
|
||||
|
||||
def build(weight: str, css_name: str, points: set[int], workdir: Path) -> None:
|
||||
urls = subset_urls(css_name)
|
||||
print(f"[{weight}] 下载 {len(urls)} 个子集…")
|
||||
paths: list[str] = []
|
||||
for url in urls:
|
||||
data = fetch(CDN + url)
|
||||
font = TTFont(io.BytesIO(data))
|
||||
target = workdir / Path(url).name.replace(".woff2", ".ttf")
|
||||
font.save(target)
|
||||
font.close()
|
||||
paths.append(str(target))
|
||||
|
||||
print(f"[{weight}] 合并 {len(paths)} 个字体…")
|
||||
merged = Merger().merge(paths)
|
||||
|
||||
options = Options()
|
||||
options.flavor = "woff2"
|
||||
options.hinting = False
|
||||
options.layout_features = ["*"]
|
||||
options.name_IDs = ["*"]
|
||||
subsetter = Subsetter(options=options)
|
||||
subsetter.populate(unicodes=points)
|
||||
subsetter.subset(merged)
|
||||
|
||||
output = OUT_DIR / f"LXGWWenKai-{weight}.woff2"
|
||||
merged.save(output)
|
||||
print(f"[{weight}] 写出 {output} {output.stat().st_size / 1024 / 1024:.2f} MB")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
points = build_codepoints()
|
||||
print(f"目标字符集:{len(points)} 个码位")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workdir = Path(directory)
|
||||
for weight, css_name in WEIGHTS.items():
|
||||
build(weight, css_name, points, workdir)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TaskPilot 端到端自检(仅标准库)。
|
||||
|
||||
用法:
|
||||
uv run tools/verify.py
|
||||
uv run tools/verify.py --base-url http://127.0.0.1:3000 --keep
|
||||
|
||||
它会真实地:创建任务 → 立即运行 → 读回日志 → 校验中文输出与虚拟环境注入 →
|
||||
校验调度与统计接口 → 删除任务(--keep 可保留)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
TASK_ID = "verify_python_demo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
def render(self) -> str:
|
||||
marker = "OK " if self.ok else "FAIL"
|
||||
suffix = f" {self.detail}" if self.detail else ""
|
||||
return f"[{marker}] {self.name}{suffix}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Api:
|
||||
base_url: str
|
||||
token: str | None = None
|
||||
checks: list[Check] = field(default_factory=list)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
authenticated: bool = False,
|
||||
) -> tuple[int, object]:
|
||||
url = f"{self.base_url}{path}"
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
if authenticated:
|
||||
headers["X-TaskPilot-Token"] = self.token or ""
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read()
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as error:
|
||||
raw = error.read()
|
||||
status = error.code
|
||||
except urllib.error.URLError as error:
|
||||
raise SystemExit(f"无法连接 {url}: {error.reason}") from error
|
||||
|
||||
if not raw:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(raw.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return status, raw.decode("utf-8", errors="replace")
|
||||
|
||||
def check(self, name: str, ok: bool, detail: str = "") -> bool:
|
||||
self.checks.append(Check(name, ok, detail))
|
||||
return ok
|
||||
|
||||
def session(self) -> str:
|
||||
status, body = self.request("GET", "/api/v1/session")
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise SystemExit(f"获取会话令牌失败: HTTP {status}")
|
||||
self.token = str(body["token"])
|
||||
return self.token
|
||||
|
||||
def poll_run(self, run_id: int, timeout: float) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
terminal = {"succeeded", "failed", "timed_out", "cancelled", "skipped"}
|
||||
while time.monotonic() < deadline:
|
||||
status, body = self.request("GET", f"/api/v1/runs/{run_id}")
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise SystemExit(f"读取运行记录失败: HTTP {status}")
|
||||
if body.get("status") in terminal:
|
||||
return body
|
||||
time.sleep(0.2)
|
||||
raise SystemExit(f"运行 {run_id} 在 {timeout} 秒内未结束")
|
||||
|
||||
def read_logs(self, run_id: int, timeout: float) -> str:
|
||||
deadline = time.monotonic() + timeout
|
||||
content = ""
|
||||
offset = 0
|
||||
while time.monotonic() < deadline:
|
||||
status, body = self.request(
|
||||
"GET", f"/api/v1/runs/{run_id}/logs?offset={offset}"
|
||||
)
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise SystemExit(f"读取运行日志失败: HTTP {status}")
|
||||
content += str(body.get("content", ""))
|
||||
offset = int(body.get("next_offset", offset))
|
||||
if body.get("complete"):
|
||||
return content
|
||||
time.sleep(0.1)
|
||||
return content
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="TaskPilot end-to-end self check")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:3000")
|
||||
parser.add_argument(
|
||||
"--working-dir",
|
||||
default=str(Path(__file__).resolve().parent.parent / "samples" / "python-task"),
|
||||
help="包含 .venv 的演示任务目录",
|
||||
)
|
||||
parser.add_argument("--keep", action="store_true", help="检查完成后保留任务")
|
||||
parser.add_argument("--timeout", type=float, default=60.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
working_dir = Path(args.working_dir).resolve()
|
||||
api = Api(args.base_url.rstrip("/"))
|
||||
|
||||
# 1. 健康检查
|
||||
status, health = api.request("GET", "/api/v1/health")
|
||||
api.check(
|
||||
"health endpoint",
|
||||
status == 200 and isinstance(health, dict) and health.get("status") == "ok",
|
||||
f"HTTP {status}",
|
||||
)
|
||||
|
||||
# 2. 会话令牌
|
||||
token = api.session()
|
||||
api.check("session token", bool(token), f"{len(token)} chars")
|
||||
|
||||
# 3. 目录列举能看到演示目录下的 .venv
|
||||
query = urllib.parse.urlencode({"path": str(working_dir)})
|
||||
status, listing = api.request("GET", f"/api/v1/system/directories?{query}")
|
||||
names = [entry["name"] for entry in (listing or {}).get("entries", [])]
|
||||
api.check(
|
||||
"directory listing sees .venv",
|
||||
status == 200 and ".venv" in names,
|
||||
f"entries={names}",
|
||||
)
|
||||
|
||||
# 4. 虚拟环境探测识别出 .venv\Scripts\python.exe
|
||||
status, probe = api.request(
|
||||
"POST",
|
||||
"/api/v1/python/venv",
|
||||
{"working_dir": str(working_dir), "interpreter": "python.exe", "venv": None},
|
||||
authenticated=True,
|
||||
)
|
||||
probe = probe if isinstance(probe, dict) else {}
|
||||
display_path = str(probe.get("display_path") or "")
|
||||
api.check(
|
||||
"venv probe links the demo environment",
|
||||
status == 200
|
||||
and probe.get("status") == "linked"
|
||||
and display_path.endswith(r"Scripts\python.exe"),
|
||||
f"status={probe.get('status')} path={display_path}",
|
||||
)
|
||||
|
||||
# 5. 创建任务(启用 → 自动注册调度)
|
||||
payload = {
|
||||
"id": TASK_ID,
|
||||
"name": "Python 演示任务",
|
||||
"cron": "0 0 3 * * *",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"enabled": True,
|
||||
"timeout_secs": 30,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": str(working_dir),
|
||||
"interpreter": "python.exe",
|
||||
"script": "main.py",
|
||||
"args": ["--lines", "2"],
|
||||
"env": {},
|
||||
},
|
||||
}
|
||||
api.request("DELETE", f"/api/v1/tasks/{TASK_ID}", authenticated=True)
|
||||
status, created = api.request(
|
||||
"POST", "/api/v1/tasks", payload, authenticated=True
|
||||
)
|
||||
api.check("create task", status == 201, f"HTTP {status}")
|
||||
|
||||
# 6. 调度接口给出下次触发时间
|
||||
status, schedule = api.request("GET", "/api/v1/schedule")
|
||||
entries = schedule if isinstance(schedule, list) else []
|
||||
entry = next((item for item in entries if item.get("task_id") == TASK_ID), None)
|
||||
api.check(
|
||||
"task is registered in the schedule",
|
||||
status == 200 and entry is not None and bool(entry.get("next_run_at")),
|
||||
f"next_run_at={(entry or {}).get('next_run_at')}",
|
||||
)
|
||||
|
||||
# 7. 立即运行并等待结束
|
||||
status, run = api.request(
|
||||
"POST", f"/api/v1/tasks/{TASK_ID}/run", authenticated=True
|
||||
)
|
||||
run = run if isinstance(run, dict) else {}
|
||||
run_id = int(run.get("id", 0))
|
||||
api.check("run accepted", status == 202 and run_id > 0, f"run={run_id}")
|
||||
|
||||
finished = api.poll_run(run_id, args.timeout)
|
||||
api.check(
|
||||
"run succeeded",
|
||||
finished.get("status") == "succeeded" and finished.get("exit_code") == 0,
|
||||
f"status={finished.get('status')} exit={finished.get('exit_code')}",
|
||||
)
|
||||
|
||||
# 8. 日志里能看到中文、虚拟环境注入与真实解释器路径
|
||||
log = api.read_logs(run_id, args.timeout)
|
||||
api.check("log contains Chinese output", "中文输出" in log)
|
||||
api.check(
|
||||
"log shows the venv interpreter",
|
||||
".venv" in log.lower() or "Scripts" in log,
|
||||
)
|
||||
api.check(
|
||||
"log shows VIRTUAL_ENV injection",
|
||||
"VIRTUAL_ENV" in log and "<未设置>" not in log,
|
||||
)
|
||||
api.check("log has no replacement characters", "\ufffd" not in log)
|
||||
|
||||
# 9. 统计接口口径正确
|
||||
status, stats = api.request("GET", "/api/v1/stats")
|
||||
stats = stats if isinstance(stats, dict) else {}
|
||||
api.check(
|
||||
"stats endpoint reports the task",
|
||||
status == 200 and int(stats.get("total", 0)) >= 1 and int(stats.get("running", -1)) == 0,
|
||||
f"total={stats.get('total')} enabled={stats.get('enabled')} running={stats.get('running')}",
|
||||
)
|
||||
|
||||
# 10. 清理
|
||||
if args.keep:
|
||||
api.check("cleanup skipped (--keep)", True)
|
||||
else:
|
||||
status, _ = api.request(
|
||||
"DELETE", f"/api/v1/tasks/{TASK_ID}", authenticated=True
|
||||
)
|
||||
api.check("delete task", status == 204, f"HTTP {status}")
|
||||
|
||||
failures = [check for check in api.checks if not check.ok]
|
||||
print(f"\nTaskPilot self check against {args.base_url}\n")
|
||||
for check in api.checks:
|
||||
print(check.render())
|
||||
print(f"\n{len(api.checks) - len(failures)}/{len(api.checks)} checks passed")
|
||||
if failures:
|
||||
print("\n失败的检查:")
|
||||
for check in failures:
|
||||
print(f" - {check.name}: {check.detail}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user