feat: 本地 uv 与托管 Python 环境管理
- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验 - uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程 - 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包 - 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用 - 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗 - 发布包构建后去掉工作区继承的低完整性标签 - 修复调度集成测试并发时误取 skipped 运行的问题
This commit is contained in:
@@ -50,6 +50,8 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台
|
|||||||
release/TaskPilot/
|
release/TaskPilot/
|
||||||
├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管)
|
├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管)
|
||||||
├── web/ # 前端静态资源(字体可选)
|
├── web/ # 前端静态资源(字体可选)
|
||||||
|
├── uv/ # 程序下载/自备的 uv.exe 放这里(Python 环境页可切换来源)
|
||||||
|
├── python/ # uv 下载的 Python 解释器(UV_PYTHON_INSTALL_DIR 默认指这里)
|
||||||
├── data/task_pilot.db # SQLite 数据库(首次启动创建)
|
├── data/task_pilot.db # SQLite 数据库(首次启动创建)
|
||||||
├── logs/{task_id}/{run_id}.log
|
├── logs/{task_id}/{run_id}.log
|
||||||
├── start.ps1
|
├── start.ps1
|
||||||
@@ -64,7 +66,7 @@ npm --prefix ..\stash-ui install
|
|||||||
npm --prefix ..\stash-ui run build
|
npm --prefix ..\stash-ui run build
|
||||||
|
|
||||||
pnpm --dir frontend install
|
pnpm --dir frontend install
|
||||||
cargo run --manifest-path backend/Cargo.toml # 终端一:API,127.0.0.1:3000
|
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
|
pnpm --dir frontend dev # 终端二:http://127.0.0.1:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -117,8 +119,8 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
|||||||
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
||||||
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
||||||
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
||||||
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 |
|
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 |
|
||||||
| 界面 | 左侧导航(任务调度 / Python 镜像)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务分组 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
||||||
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
||||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
||||||
|
|
||||||
@@ -220,14 +222,22 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
|
|
||||||
**没有虚拟环境时**:
|
**没有虚拟环境时**:
|
||||||
|
|
||||||
- 工作目录根部有 `pyproject.toml` 或 `requirements*.txt`,且 PATH 中能找到 `uv`:
|
- 先看用户自己的环境:任务里指定的 `venv`、解释器路径、工作目录向上查找,命中就直接用,不重复创建。
|
||||||
编辑器会显示「用 uv 创建环境」按钮,点击后
|
- 都没有时,**新建任务时会用 uv 自动创建 `.venv`**:`pyproject.toml` 走 `uv sync`,
|
||||||
`pyproject.toml` 走 `uv sync`,仅 requirements 走 `uv venv .venv` + `uv pip install -r`。
|
仅有 `requirements*.txt` 走 `uv venv .venv` + `uv pip install -r`,纯标准库脚本(没有依赖文件)建一个空环境。
|
||||||
已有 `.venv`/`venv`/`env` 会优先复用,不会重复创建。创建时会使用当前默认 PyPI 镜像
|
保存按钮会显示「正在创建虚拟环境…」,环境问题当场暴露。手动路径是编辑器里的「用 uv 创建环境」按钮。
|
||||||
(`PIP_INDEX_URL` / `UV_DEFAULT_INDEX`),不会绕过镜像设置。
|
- 运行期只做校验、不再自动装依赖:没有 `.venv` 的 Python 任务会直接失败并提示先创建环境。
|
||||||
- 没装 uv:必须手动准备虚拟环境;Python 任务缺少 `.venv` 时运行失败并给出提示。
|
- 创建时会使用当前默认 PyPI 镜像(`PIP_INDEX_URL` / `UV_DEFAULT_INDEX`),不会绕过镜像设置。
|
||||||
- 既没有虚拟环境、也没有 pyproject/requirements:编辑器显示错误,Python 任务运行失败。
|
- uv 只用平台认的两份:程序目录下的 `uv\uv.exe`(「程序目录 uv」,默认),或你指定的 `uv.exe`(「指定路径」)。
|
||||||
Python 任务不再静默回退到系统解释器。
|
**不读系统 PATH**——PATH 上的 uv 版本会随启动方式变化,不适合当调度器的隐式依赖。
|
||||||
|
没有 uv 时点「Python 环境」页的「下载 uv」,从科大镜像
|
||||||
|
`mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease` 下载当前架构的压缩包,
|
||||||
|
校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去;本机已经装了 uv 的话,把它填进「指定路径」即可。
|
||||||
|
- 「Python 环境」页还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`),
|
||||||
|
会注入到每一次 uv 调用,并优先于镜像注入与进程环境。
|
||||||
|
- uv 不可用:保存任务时不会自动建环境,运行时会失败并提示先去「Python 环境」配置。
|
||||||
|
- 运行任务时会把平台 uv 目录加到 PATH 前面,并注入 `UV_PYTHON_INSTALL_DIR`、页面里配置的 `UV_*` 和默认 `UV_DEFAULT_INDEX`
|
||||||
|
(任务自己配置的环境变量优先)。这样脚本里直接写 `uv ...` 用的也是平台这一份,装包默认走镜像;不会再注入 `PIP_*`。
|
||||||
- 解释器由语言自动决定,界面不再显示解释器输入框:
|
- 解释器由语言自动决定,界面不再显示解释器输入框:
|
||||||
Python 始终使用虚拟环境里的 Python,Shell 使用 `cmd.exe`,Node 使用 `node.exe`,Lua 使用 `lua.exe`。
|
Python 始终使用虚拟环境里的 Python,Shell 使用 `cmd.exe`,Node 使用 `node.exe`,Lua 使用 `lua.exe`。
|
||||||
自定义解释器仍可通过 API 的 `action.interpreter` 字段传入。
|
自定义解释器仍可通过 API 的 `action.interpreter` 字段传入。
|
||||||
@@ -248,7 +258,10 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
|
|
||||||
## Web 界面
|
## Web 界面
|
||||||
|
|
||||||
- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。
|
- **顶部品牌栏**:品牌 + 右侧 Rust 引擎在线状态与版本;页面级导航已去掉,配置都收到任务调度页的卡片里。
|
||||||
|
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + 按类型(脚本 / HTTP / 二进制)分组的任务卡片与分页;
|
||||||
|
右栏 uv(版本只显示版本号 / 缓存目录 / Python 目录)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**
|
||||||
|
(uv 与镜像源分属两个弹窗,不放在一起)。
|
||||||
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
||||||
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
|
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
|
||||||
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
|
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
|
||||||
@@ -257,9 +270,21 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。解释器由语言自动选择,界面不再要求手填。
|
缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。解释器由语言自动选择,界面不再要求手填。
|
||||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
||||||
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||||
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
|
- **Python 环境**:两块配置。
|
||||||
默认镜像在 Python 脚本任务启动时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
|
- uv 可执行文件:显示当前使用的 uv 与版本、候选来源探测结果;可切「程序目录 uv(`uv\uv.exe`)」
|
||||||
(任务自身配置的环境变量优先),不会自动安装依赖。
|
或「指定路径」;本机已装 uv 就把它的路径填进「指定路径」;本机没有就点「下载 uv」,
|
||||||
|
从科大镜像下载并自动切换。保存自定义路径时会先运行 `uv --version` 校验。
|
||||||
|
- uv 环境变量:键值列表,只接受 `UV_` 前缀(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`、
|
||||||
|
`UV_HTTP_TIMEOUT`),会注入到每一次 uv 调用,并优先于镜像注入与进程环境。
|
||||||
|
`UV_PYTHON_INSTALL_DIR` 未配置时默认指向程序目录下的 `python\`。
|
||||||
|
- 包索引镜像:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
|
||||||
|
只在**装包**(建环境 / 预装依赖)时生效,任务运行时不再注入镜像变量。
|
||||||
|
- **虚拟环境**:管理 uv 提供的 Python 版本和预创建的托管环境。
|
||||||
|
- Python 版本:列出 uv 下载到程序目录 `python\` 的解释器(只算平台目录里的,不含系统 Python);
|
||||||
|
填版本(如 `3.12`)点「下载 Python」即用 uv 下载,之后可在这里和新建环境下拉里选用。
|
||||||
|
- 托管虚拟环境:预创建常用环境(放在 `data\envs\<名称>`),Python 版本从已下载列表里选,
|
||||||
|
可选预装包;列表显示版本与绝对路径,「查看包」按需读取 `uv pip list` 的已安装包。
|
||||||
|
任务里把该路径填进「虚拟环境目录」即可复用。环境只在创建时装包,不会自动升级。
|
||||||
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
|
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
|
||||||
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
|
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
|
||||||
业务块(侧栏、任务表单元格、日志控制台、表单栅格)。
|
业务块(侧栏、任务表单元格、日志控制台、表单栅格)。
|
||||||
@@ -330,6 +355,25 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
| `DELETE` | `/api/v1/python/mirrors/{id}` | 删除镜像 |
|
| `DELETE` | `/api/v1/python/mirrors/{id}` | 删除镜像 |
|
||||||
| `POST` | `/api/v1/python/mirrors/{id}/default` | 设为默认(同一时间仅一个默认,并自动启用) |
|
| `POST` | `/api/v1/python/mirrors/{id}/default` | 设为默认(同一时间仅一个默认,并自动启用) |
|
||||||
|
|
||||||
|
### uv
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/v1/uv` | 当前 uv 来源、可执行文件、版本、`UV_*` 环境变量,以及各来源的探测结果 |
|
||||||
|
| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径和 `extra_env`;`custom` 会校验可运行 |
|
||||||
|
| `POST` | `/api/v1/uv/download` | 从科大镜像下载当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 |
|
||||||
|
|
||||||
|
### 托管虚拟环境
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/v1/python/pythons` | uv 下载到程序目录 `python\` 的解释器(版本、路径) |
|
||||||
|
| `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}` | 删除整个环境目录 |
|
||||||
|
|
||||||
### 任务与运行
|
### 任务与运行
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
@@ -475,7 +519,7 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
|
|||||||
| 模块 | 职责 |
|
| 模块 | 职责 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
|
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
|
||||||
| `components/AppSidebar.tsx` | 左侧导航、品牌、引擎状态 |
|
| `components/AppHeader.tsx` | 顶部导航、品牌、引擎状态 |
|
||||||
| `components/PageHeader.tsx` | 页面标题区(标题 / 说明 / 操作) |
|
| `components/PageHeader.tsx` | 页面标题区(标题 / 说明 / 操作) |
|
||||||
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
|
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
|
||||||
| `pages/PythonMirrorsPage.tsx` | Python 镜像列表、默认项、新增 / 编辑 / 删除 |
|
| `pages/PythonMirrorsPage.tsx` | Python 镜像列表、默认项、新增 / 编辑 / 删除 |
|
||||||
@@ -547,9 +591,9 @@ uv run --with fonttools --with brotli tools/build-fonts.py
|
|||||||
|
|
||||||
| 层 | 覆盖 |
|
| 层 | 覆盖 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
| Rust 单元(73) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
||||||
| Rust 集成(39) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建、Shell 脚本执行 |
|
| Rust 集成(48) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 |
|
||||||
| 前端 Vitest(31) | 仪表盘渲染、侧栏导航到镜像页、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
| 前端 Vitest(37) | 仪表盘渲染、侧栏导航到 Python 环境页、保存 uv 来源与 `UV_*` 环境变量、镜像下载、Python 版本下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
||||||
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
||||||
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
||||||
|
|
||||||
@@ -578,7 +622,9 @@ uv run --with fonttools --with brotli tools/build-fonts.py
|
|||||||
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
||||||
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
||||||
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
||||||
- 用 uv 创建环境是编辑器里的显式操作;运行中的任务不会在后台自动安装依赖。首次创建需要网络与可写的 uv 缓存目录。
|
- Python 任务缺少虚拟环境时,新建任务会先用 uv 创建(需要网络与可写的 uv 缓存目录);运行期不会再自动装依赖。已有环境的项目不会被自动升级。
|
||||||
|
- 托管虚拟环境只负责创建 / 删除 / 查看已装包,不会自动升级依赖;任务运行也不会改动它。
|
||||||
|
- 「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;网络不通时请自己下载 uv 放到程序同级 `uv` 目录或指定路径。
|
||||||
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
||||||
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
||||||
- 错过的调度不会补跑。
|
- 错过的调度不会补跑。
|
||||||
|
|||||||
Generated
+87
@@ -263,6 +263,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -318,6 +324,15 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
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]]
|
[[package]]
|
||||||
name = "croner"
|
name = "croner"
|
||||||
version = "4.0.1"
|
version = "4.0.1"
|
||||||
@@ -455,6 +470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.1",
|
"block-buffer 0.12.1",
|
||||||
|
"const-oid",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
]
|
]
|
||||||
@@ -553,6 +569,15 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
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]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -1234,6 +1259,18 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1835,6 +1872,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd_cesu8"
|
name = "simd_cesu8"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -2172,6 +2215,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.1",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -2187,6 +2231,7 @@ dependencies = [
|
|||||||
"utoipa-axum",
|
"utoipa-axum",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2450,6 +2495,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -2947,8 +2998,44 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
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"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.154"
|
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"] }
|
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] }
|
||||||
thiserror = "2.0.21"
|
thiserror = "2.0.21"
|
||||||
tokio = { version = "1.53.3", features = ["full"] }
|
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"
|
utoipa-axum = "0.3.0"
|
||||||
uuid = { version = "1.28.0", features = ["v4"] }
|
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"] }
|
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]
|
[dev-dependencies]
|
||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.5"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- uv 可执行文件来自哪里:系统 PATH(system)、程序目录下的 uv 目录(managed),
|
||||||
|
-- 或用户自己下载后指定的路径(custom)。只保留一行。
|
||||||
|
CREATE TABLE IF NOT EXISTS uv_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
source TEXT NOT NULL DEFAULT 'system' CHECK (source IN ('system', 'managed', 'custom')),
|
||||||
|
custom_path TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at)
|
||||||
|
VALUES (1, 'system', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- uv 只认平台自己的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
|
||||||
|
CREATE TABLE uv_settings_v2 (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
|
||||||
|
custom_path TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO uv_settings_v2 (id, source, custom_path, updated_at)
|
||||||
|
SELECT id,
|
||||||
|
CASE WHEN source = 'custom' THEN 'custom' ELSE 'managed' END,
|
||||||
|
custom_path,
|
||||||
|
updated_at
|
||||||
|
FROM uv_settings;
|
||||||
|
|
||||||
|
DROP TABLE uv_settings;
|
||||||
|
|
||||||
|
ALTER TABLE uv_settings_v2 RENAME TO uv_settings;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at)
|
||||||
|
VALUES (1, 'managed', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- uv 管理页允许额外注入 UV_* 环境变量(缓存目录、Python 安装目录、超时等)。
|
||||||
|
ALTER TABLE uv_settings ADD COLUMN extra_env TEXT NOT NULL DEFAULT '{}';
|
||||||
+42
-5
@@ -21,6 +21,11 @@ use crate::{
|
|||||||
self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse,
|
self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse,
|
||||||
VenvProbeStatus, VenvSource,
|
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::{
|
python_mirrors::{
|
||||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||||
routes::{
|
routes::{
|
||||||
@@ -49,6 +54,10 @@ use crate::{
|
|||||||
__path_get_task, __path_list_tasks, __path_update_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)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
@@ -137,9 +146,11 @@ pub async fn preview_cron(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn probe_python_venv(
|
pub async fn probe_python_venv(
|
||||||
|
State(state): State<AppState>,
|
||||||
ApiJson(request): ApiJson<VenvProbeRequest>,
|
ApiJson(request): ApiJson<VenvProbeRequest>,
|
||||||
) -> ApiResult<Json<VenvProbeResponse>> {
|
) -> 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
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(ApiError::internal)
|
.map_err(ApiError::internal)
|
||||||
@@ -165,10 +176,16 @@ pub async fn create_python_venv(
|
|||||||
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
|
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
|
||||||
None
|
None
|
||||||
});
|
});
|
||||||
python_env::create_project_venv(&request.working_dir, mirror.as_ref())
|
let uv = crate::uv::current(&state.pool).await;
|
||||||
.await
|
python_env::create_project_venv(
|
||||||
.map(Json)
|
&request.working_dir,
|
||||||
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
|
mirror.as_ref(),
|
||||||
|
&uv.program(),
|
||||||
|
&uv.env(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -255,12 +272,21 @@ pub async fn list_schedule(
|
|||||||
preview_cron,
|
preview_cron,
|
||||||
probe_python_venv,
|
probe_python_venv,
|
||||||
create_python_venv,
|
create_python_venv,
|
||||||
|
get_uv_settings,
|
||||||
|
update_uv_settings,
|
||||||
|
download_uv,
|
||||||
list_python_mirrors,
|
list_python_mirrors,
|
||||||
get_python_mirror,
|
get_python_mirror,
|
||||||
create_python_mirror,
|
create_python_mirror,
|
||||||
update_python_mirror,
|
update_python_mirror,
|
||||||
delete_python_mirror,
|
delete_python_mirror,
|
||||||
set_default_python_mirror,
|
set_default_python_mirror,
|
||||||
|
list_envs,
|
||||||
|
create_env,
|
||||||
|
list_env_packages,
|
||||||
|
delete_env,
|
||||||
|
list_pythons,
|
||||||
|
install_python,
|
||||||
list_directories,
|
list_directories,
|
||||||
task_stats,
|
task_stats,
|
||||||
list_schedule,
|
list_schedule,
|
||||||
@@ -293,6 +319,15 @@ pub async fn list_schedule(
|
|||||||
PythonMirrorResponse,
|
PythonMirrorResponse,
|
||||||
CreatePythonMirrorRequest,
|
CreatePythonMirrorRequest,
|
||||||
UpdatePythonMirrorRequest,
|
UpdatePythonMirrorRequest,
|
||||||
|
UvSettingsResponse,
|
||||||
|
UpdateUvSettingsRequest,
|
||||||
|
UvCandidate,
|
||||||
|
UvSource,
|
||||||
|
ManagedEnvSummary,
|
||||||
|
ManagedEnvPackage,
|
||||||
|
CreateManagedEnvRequest,
|
||||||
|
ManagedPython,
|
||||||
|
InstallPythonRequest,
|
||||||
DirectoryEntry,
|
DirectoryEntry,
|
||||||
DirectoryListingResponse,
|
DirectoryListingResponse,
|
||||||
TaskStatsResponse,
|
TaskStatsResponse,
|
||||||
@@ -357,6 +392,8 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/v1/openapi.json", get(openapi_json))
|
.route("/api/v1/openapi.json", get(openapi_json))
|
||||||
.merge(crate::tasks::routes::router())
|
.merge(crate::tasks::routes::router())
|
||||||
.merge(crate::python_mirrors::routes::router())
|
.merge(crate::python_mirrors::routes::router())
|
||||||
|
.merge(crate::python_envs::router())
|
||||||
|
.merge(crate::uv::router())
|
||||||
.merge(crate::runs::routes::router())
|
.merge(crate::runs::routes::router())
|
||||||
.route("/api/{*path}", any(api_not_found))
|
.route("/api/{*path}", any(api_not_found))
|
||||||
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
|
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
pub fn mirror_conflict(message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
status: StatusCode::CONFLICT,
|
status: StatusCode::CONFLICT,
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ pub mod directory_browser;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod process_job;
|
pub mod process_job;
|
||||||
pub mod python_env;
|
pub mod python_env;
|
||||||
|
pub mod python_envs;
|
||||||
pub mod python_mirrors;
|
pub mod python_mirrors;
|
||||||
pub mod runs;
|
pub mod runs;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
|
pub mod uv;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|||||||
+81
-44
@@ -33,6 +33,28 @@ impl PreparedPython {
|
|||||||
}
|
}
|
||||||
command.envs(&self.environment);
|
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(
|
pub fn prepare_script_environment(
|
||||||
@@ -244,7 +266,7 @@ pub struct VenvProbeResponse {
|
|||||||
pub has_requirements: bool,
|
pub has_requirements: bool,
|
||||||
/// 探测到的 requirements 文件名(仅根目录)
|
/// 探测到的 requirements 文件名(仅根目录)
|
||||||
pub requirements_files: Vec<String>,
|
pub requirements_files: Vec<String>,
|
||||||
/// 没有虚拟环境时,是否具备用 uv 创建的条件
|
/// 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)
|
||||||
pub can_create: bool,
|
pub can_create: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +277,7 @@ pub struct CreatePythonVenvRequest {
|
|||||||
pub working_dir: String,
|
pub working_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
pub fn probe_venv(request: &VenvProbeRequest, uv: &Path) -> VenvProbeResponse {
|
||||||
let working_dir = request.working_dir.trim();
|
let working_dir = request.working_dir.trim();
|
||||||
let interpreter = {
|
let interpreter = {
|
||||||
let trimmed = request.interpreter.trim();
|
let trimmed = request.interpreter.trim();
|
||||||
@@ -271,7 +293,7 @@ pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
let interpreter_kind = classify_interpreter(interpreter);
|
let interpreter_kind = classify_interpreter(interpreter);
|
||||||
let uv = uv_available();
|
let uv = crate::uv::probe_version(uv).is_some();
|
||||||
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
|
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
|
||||||
|
|
||||||
if working_dir.is_empty() {
|
if working_dir.is_empty() {
|
||||||
@@ -367,16 +389,12 @@ fn finalize_probe(
|
|||||||
response.has_pyproject = files.has_pyproject;
|
response.has_pyproject = files.has_pyproject;
|
||||||
response.has_requirements = !files.requirements.is_empty();
|
response.has_requirements = !files.requirements.is_empty();
|
||||||
response.requirements_files = files.requirements;
|
response.requirements_files = files.requirements;
|
||||||
response.can_create = response.status == VenvProbeStatus::Missing
|
response.can_create = response.status == VenvProbeStatus::Missing && uv_available;
|
||||||
&& uv_available
|
|
||||||
&& (response.has_pyproject || response.has_requirements);
|
|
||||||
if response.status == VenvProbeStatus::Missing {
|
if response.status == VenvProbeStatus::Missing {
|
||||||
response.message = if response.can_create {
|
response.message = if response.can_create {
|
||||||
"未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv".to_owned()
|
"未检测到虚拟环境,保存任务时会用 uv 自动创建(首次较慢)".to_owned()
|
||||||
} else if !uv_available {
|
|
||||||
"未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv".to_owned()
|
|
||||||
} else {
|
} else {
|
||||||
"未检测到虚拟环境,也没有 pyproject.toml / requirements.txt,无法自动创建".to_owned()
|
"未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv".to_owned()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
@@ -426,25 +444,12 @@ pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
|
|||||||
files
|
files
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 本机 PATH 中是否有可用的 uv。
|
|
||||||
pub fn uv_available() -> bool {
|
|
||||||
let mut command = std::process::Command::new("uv");
|
|
||||||
command
|
|
||||||
.arg("--version")
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null());
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
command.creation_flags(0x0800_0000);
|
|
||||||
}
|
|
||||||
command.status().is_ok_and(|status| status.success())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 用 uv 创建/同步项目虚拟环境。优先返回已有环境。
|
/// 用 uv 创建/同步项目虚拟环境。优先返回已有环境。
|
||||||
pub async fn create_project_venv(
|
pub async fn create_project_venv(
|
||||||
working_dir: &str,
|
working_dir: &str,
|
||||||
mirror: Option<&DefaultPythonMirror>,
|
mirror: Option<&DefaultPythonMirror>,
|
||||||
|
uv: &Path,
|
||||||
|
uv_env: &BTreeMap<String, String>,
|
||||||
) -> Result<VenvProbeResponse, String> {
|
) -> Result<VenvProbeResponse, String> {
|
||||||
let working_directory =
|
let working_directory =
|
||||||
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
|
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
|
||||||
@@ -457,25 +462,23 @@ pub async fn create_project_venv(
|
|||||||
interpreter: "python.exe".to_owned(),
|
interpreter: "python.exe".to_owned(),
|
||||||
venv: None,
|
venv: None,
|
||||||
};
|
};
|
||||||
let existing = probe_venv(&probe_request);
|
let existing = probe_venv(&probe_request, uv);
|
||||||
if existing.status == VenvProbeStatus::Linked {
|
if existing.status == VenvProbeStatus::Linked {
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
let files = scan_project_files(working_dir);
|
let files = scan_project_files(working_dir);
|
||||||
if !files.has_pyproject && files.requirements.is_empty() {
|
if crate::uv::probe_version(uv).is_none() {
|
||||||
return Err(
|
return Err(
|
||||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt,无法创建".to_owned(),
|
"uv 不可用,无法自动创建虚拟环境;请先在「uv 管理」中配置,或手动创建 .venv".to_owned(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !uv_available() {
|
|
||||||
return Err("本机 PATH 中找不到 uv,无法自动创建虚拟环境;请手动创建 .venv".to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 纯标准库脚本没有依赖文件:建一个空环境即可,运行期仍然走 .venv 里的 Python。
|
||||||
if files.has_pyproject {
|
if files.has_pyproject {
|
||||||
run_uv(&working_directory, &["sync"], mirror).await?;
|
run_uv(&working_directory, &["sync"], mirror, uv, uv_env).await?;
|
||||||
} else {
|
} else {
|
||||||
run_uv(&working_directory, &["venv", ".venv"], mirror).await?;
|
run_uv(&working_directory, &["venv", ".venv"], mirror, uv, uv_env).await?;
|
||||||
let python = working_directory
|
let python = working_directory
|
||||||
.join(".venv")
|
.join(".venv")
|
||||||
.join("Scripts")
|
.join("Scripts")
|
||||||
@@ -486,12 +489,14 @@ pub async fn create_project_venv(
|
|||||||
&working_directory,
|
&working_directory,
|
||||||
&["pip", "install", "--python", &python, "-r", requirement],
|
&["pip", "install", "--python", &python, "-r", requirement],
|
||||||
mirror,
|
mirror,
|
||||||
|
uv,
|
||||||
|
uv_env,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let probe = probe_venv(&probe_request);
|
let probe = probe_venv(&probe_request, uv);
|
||||||
if probe.status != VenvProbeStatus::Linked {
|
if probe.status != VenvProbeStatus::Linked {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"uv 执行完成,但没有找到有效的 .venv:{}",
|
"uv 执行完成,但没有找到有效的 .venv:{}",
|
||||||
@@ -501,12 +506,14 @@ pub async fn create_project_venv(
|
|||||||
Ok(probe)
|
Ok(probe)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_uv(
|
pub(crate) async fn run_uv(
|
||||||
working_dir: &Path,
|
working_dir: &Path,
|
||||||
args: &[&str],
|
args: &[&str],
|
||||||
mirror: Option<&DefaultPythonMirror>,
|
mirror: Option<&DefaultPythonMirror>,
|
||||||
|
uv: &Path,
|
||||||
|
uv_env: &BTreeMap<String, String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let mut command = Command::new("uv");
|
let mut command = Command::new(uv);
|
||||||
command
|
command
|
||||||
.args(args)
|
.args(args)
|
||||||
.current_dir(working_dir)
|
.current_dir(working_dir)
|
||||||
@@ -526,6 +533,8 @@ async fn run_uv(
|
|||||||
command.env("PIP_TRUSTED_HOST", host);
|
command.env("PIP_TRUSTED_HOST", host);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 页面里配置的 UV_* 覆盖镜像注入与进程环境,用户显式设置优先。
|
||||||
|
command.envs(uv_env);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
command.creation_flags(0x0800_0000);
|
command.creation_flags(0x0800_0000);
|
||||||
|
|
||||||
@@ -812,6 +821,30 @@ mod tests {
|
|||||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
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]
|
#[test]
|
||||||
fn non_python_interpreter_is_not_modified() {
|
fn non_python_interpreter_is_not_modified() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
@@ -934,11 +967,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse {
|
fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse {
|
||||||
probe_venv(&VenvProbeRequest {
|
probe_venv(
|
||||||
working_dir: working_dir.to_owned(),
|
&VenvProbeRequest {
|
||||||
interpreter: interpreter.to_owned(),
|
working_dir: working_dir.to_owned(),
|
||||||
venv: venv.map(str::to_owned),
|
interpreter: interpreter.to_owned(),
|
||||||
})
|
venv: venv.map(str::to_owned),
|
||||||
|
},
|
||||||
|
Path::new("uv"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -985,7 +1021,7 @@ mod tests {
|
|||||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||||
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
||||||
assert!(response.venv_path.is_none());
|
assert!(response.venv_path.is_none());
|
||||||
assert!(!response.can_create);
|
assert_eq!(response.can_create, response.uv_available);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1004,12 +1040,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn probe_does_not_offer_uv_without_dependency_files() {
|
fn probe_offers_uv_creation_even_without_dependency_files() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||||
|
|
||||||
assert!(!response.can_create);
|
// 纯标准库脚本也能建一个空环境,所以这里只看 uv 是否可用。
|
||||||
|
assert_eq!(response.can_create, response.uv_available);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -437,7 +437,7 @@ impl TaskExecutor {
|
|||||||
} => {
|
} => {
|
||||||
let script_language =
|
let script_language =
|
||||||
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
|
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
|
||||||
let prepared = match prepare_script_environment(
|
let mut prepared = match prepare_script_environment(
|
||||||
working_dir,
|
working_dir,
|
||||||
interpreter,
|
interpreter,
|
||||||
venv.as_deref(),
|
venv.as_deref(),
|
||||||
@@ -448,58 +448,46 @@ impl TaskExecutor {
|
|||||||
return failed_outcome(format!("准备 Python 运行环境失败: {error}"));
|
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);
|
let mut command = Command::new(&prepared.interpreter);
|
||||||
prepared.apply(&mut command);
|
prepared.apply(&mut command);
|
||||||
|
|
||||||
let mut notes = Vec::new();
|
// uv 的平台配置(UV_PYTHON_INSTALL_DIR、页面里配的 UV_*)和默认镜像也带进任务进程;
|
||||||
if let Some(venv) = &prepared.virtual_env {
|
// 任务自己配的环境变量优先。
|
||||||
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
|
||||||
} else if script_language == ScriptLanguage::Python {
|
|
||||||
// Python 任务强制使用项目虚拟环境:没有 .venv 时不再悄悄退回系统解释器。
|
|
||||||
let project = crate::python_env::scan_project_files(working_dir);
|
|
||||||
if project.has_pyproject || !project.requirements.is_empty() {
|
|
||||||
return failed_outcome(
|
|
||||||
"项目有 pyproject.toml / requirements.txt,但还没有 .venv;请先在编辑器中用 uv 创建虚拟环境",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return failed_outcome(
|
|
||||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt;请先创建 .venv 或补充依赖文件",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if script_language == ScriptLanguage::Python {
|
if script_language == ScriptLanguage::Python {
|
||||||
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
for (key, value) in uv.env() {
|
||||||
{
|
if !env_has(env, &key) {
|
||||||
Ok(Some(mirror)) => {
|
command.env(key, value);
|
||||||
if !env_has(env, "PIP_INDEX_URL") {
|
|
||||||
command.env("PIP_INDEX_URL", &mirror.index_url);
|
|
||||||
}
|
|
||||||
if !env_has(env, "UV_INDEX_URL") {
|
|
||||||
command.env("UV_INDEX_URL", &mirror.index_url);
|
|
||||||
}
|
|
||||||
if !env_has(env, "UV_DEFAULT_INDEX") {
|
|
||||||
command.env("UV_DEFAULT_INDEX", &mirror.index_url);
|
|
||||||
}
|
|
||||||
if let Some(host) = mirror
|
|
||||||
.trusted_host
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
&& !env_has(env, "PIP_TRUSTED_HOST")
|
|
||||||
{
|
|
||||||
command.env("PIP_TRUSTED_HOST", host);
|
|
||||||
}
|
|
||||||
notes.push(format!(
|
|
||||||
"Python 包镜像: {} ({})",
|
|
||||||
mirror.name, mirror.index_url
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
}
|
||||||
Err(error) => {
|
if let Ok(Some(mirror)) =
|
||||||
tracing::warn!(
|
crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||||
task_id = %task.id,
|
{
|
||||||
%error,
|
for key in ["UV_DEFAULT_INDEX", "UV_INDEX_URL"] {
|
||||||
"failed to load default python mirror"
|
if !env_has(env, key) {
|
||||||
);
|
command.env(key, &mirror.index_url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,717 @@
|
|||||||
|
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 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";
|
||||||
|
/// 没有本机 uv 时从这里下载(中科大维护的 GitHub Release 镜像)。
|
||||||
|
const UV_MIRROR: &str = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease";
|
||||||
|
const MAX_UV_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024;
|
||||||
|
const MAX_UV_ENV_ENTRIES: usize = 32;
|
||||||
|
const MAX_UV_ENV_VALUE_CHARS: usize = 512;
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UvSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
source: UvSource::Managed,
|
||||||
|
custom_path: None,
|
||||||
|
extra_env: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 会变,相对路径不能直接交给它。
|
||||||
|
pub fn env(&self) -> BTreeMap<String, String> {
|
||||||
|
let mut env = self.extra_env.clone();
|
||||||
|
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 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() -> 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!("{UV_MIRROR}/{}", 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,
|
||||||
|
/// 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行
|
||||||
|
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 设置;读取失败时回退到程序目录 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(),
|
||||||
|
env_defaults: UV_PATH_DEFAULTS
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
||||||
|
.collect(),
|
||||||
|
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)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load(pool: &SqlitePool) -> ApiResult<UvSettings> {
|
||||||
|
let row = sqlx::query_as::<_, (String, Option<String>, String)>(
|
||||||
|
"SELECT source, custom_path, extra_env FROM uv_settings WHERE id = 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((source, custom_path, extra_env)) = 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
source = excluded.source,
|
||||||
|
custom_path = excluded.custom_path,
|
||||||
|
extra_env = excluded.extra_env,
|
||||||
|
updated_at = excluded.updated_at",
|
||||||
|
)
|
||||||
|
.bind(settings.source.as_str())
|
||||||
|
.bind(settings.custom_path.as_deref())
|
||||||
|
.bind(extra_env)
|
||||||
|
.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 version = download_and_install()
|
||||||
|
.await
|
||||||
|
.map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?;
|
||||||
|
tracing::info!(%version, "downloaded uv into the managed directory");
|
||||||
|
|
||||||
|
let mut settings = load(&state.pool).await?;
|
||||||
|
// 下载的目的就是让它生效,所以直接切到程序目录 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(),
|
||||||
|
};
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
.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 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,17 +225,16 @@ async fn venv_probe_reports_project_dependency_files() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn venv_create_rejects_project_without_dependency_files() {
|
async fn venv_create_rejects_missing_working_directory() {
|
||||||
let (app, temp_dir) = test_app().await;
|
let (app, temp_dir) = test_app().await;
|
||||||
let token = fetch_token(&app).await;
|
let token = fetch_token(&app).await;
|
||||||
let project = temp_dir.path().join("empty-project");
|
let absent = temp_dir.path().join("absent-project");
|
||||||
fs::create_dir_all(&project).expect("project directory should be created");
|
|
||||||
|
|
||||||
let (status, body) = post_create(
|
let (status, body) = post_create(
|
||||||
&app,
|
&app,
|
||||||
Some(&token),
|
Some(&token),
|
||||||
json!({
|
json!({
|
||||||
"working_dir": project.to_str().expect("project path should be UTF-8")
|
"working_dir": absent.to_str().expect("absent path should be UTF-8")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -246,7 +245,7 @@ async fn venv_create_rejects_project_without_dependency_files() {
|
|||||||
body["message"]
|
body["message"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("message should be a string")
|
.expect("message should be a string")
|
||||||
.contains("pyproject.toml")
|
.contains("工作目录")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
+46
-5
@@ -201,6 +201,46 @@ async fn wait_for_run(router: &Router, run_id: i64) -> Value {
|
|||||||
panic!("run {run_id} did not reach a terminal state");
|
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]
|
#[tokio::test]
|
||||||
async fn shell_language_runs_batch_file_with_cmd_c() {
|
async fn shell_language_runs_batch_file_with_cmd_c() {
|
||||||
let app = test_app().await;
|
let app = test_app().await;
|
||||||
@@ -706,11 +746,12 @@ async fn scheduler_runs_tasks_and_tracks_enable_state() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let runs = response_json(response).await;
|
let runs = response_json(response).await;
|
||||||
if let Some(item) = runs["items"]
|
// 每秒触发一次,机器忙时上一轮还没结束,最新一条可能是 skipped;取真正跑起来那条。
|
||||||
.as_array()
|
if let Some(item) = runs["items"].as_array().and_then(|items| {
|
||||||
.and_then(|items| items.first())
|
items
|
||||||
.filter(|item| item["trigger"] == "schedule")
|
.iter()
|
||||||
{
|
.find(|item| item["trigger"] == "schedule" && item["status"] != "skipped")
|
||||||
|
}) {
|
||||||
scheduled_run = Some(item.clone());
|
scheduled_run = Some(item.clone());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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_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");
|
||||||
|
}
|
||||||
+532
-1
@@ -89,6 +89,145 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/python/envs": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"python-envs"
|
||||||
|
],
|
||||||
|
"operationId": "list_envs",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Managed virtual environments",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ManagedEnvSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"python-envs"
|
||||||
|
],
|
||||||
|
"operationId": "create_env",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CreateManagedEnvRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Virtual environment created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ManagedEnvSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation or uv failure",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/python/envs/{name}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"python-envs"
|
||||||
|
],
|
||||||
|
"operationId": "delete_env",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Environment name",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Environment deleted"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Environment not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/python/envs/{name}/packages": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"python-envs"
|
||||||
|
],
|
||||||
|
"operationId": "list_env_packages",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Environment name",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Installed packages",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ManagedEnvPackage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Environment not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/python/mirrors": {
|
"/api/v1/python/mirrors": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -343,6 +482,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/python/pythons": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"python-envs"
|
||||||
|
],
|
||||||
|
"operationId": "list_pythons",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Pythons downloaded by uv into the platform directory",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ManagedPython"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "uv unavailable",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"python-envs"
|
||||||
|
],
|
||||||
|
"operationId": "install_python",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/InstallPythonRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Python downloaded; returns the installed list",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ManagedPython"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation or uv failure",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/python/venv": {
|
"/api/v1/python/venv": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1160,6 +1373,94 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uv": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"uv"
|
||||||
|
],
|
||||||
|
"operationId": "get_uv_settings",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "uv settings and detected executables",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UvSettingsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"uv"
|
||||||
|
],
|
||||||
|
"operationId": "update_uv_settings",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateUvSettingsRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "uv settings saved",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UvSettingsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation failed",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uv/download": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"uv"
|
||||||
|
],
|
||||||
|
"operationId": "download_uv",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "uv downloaded from the mirror and switched to it",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UvSettingsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Download, verification or install failed",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -1170,6 +1471,33 @@
|
|||||||
"skip"
|
"skip"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"CreateManagedEnvRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识"
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "预装包,每行一个,例如 `numpy==2.1.0`"
|
||||||
|
},
|
||||||
|
"python_version": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"CreatePythonMirrorRequest": {
|
"CreatePythonMirrorRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1398,6 +1726,19 @@
|
|||||||
"POST"
|
"POST"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"InstallPythonRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要下载的版本,例如 `3.12` 或 `3.12.4`"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"InterpreterKind": {
|
"InterpreterKind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1427,6 +1768,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ManagedEnvPackage": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ManagedEnvSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "虚拟环境根目录的绝对路径"
|
||||||
|
},
|
||||||
|
"python_version": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "取自 `pyvenv.cfg` 的 `version_info`"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ManagedPython": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"version",
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "uv 下载到平台目录下的解释器路径"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"PythonMirrorResponse": {
|
"PythonMirrorResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "一个 Python 包索引镜像(pip / uv)。",
|
"description": "一个 Python 包索引镜像(pip / uv)。",
|
||||||
@@ -2000,6 +2395,142 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"UpdateUvSettingsRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"source"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"custom_path": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra_env": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "额外注入给 uv 的环境变量;只允许 `UV_` 前缀",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"$ref": "#/components/schemas/UvSource"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"UvCandidate": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"source",
|
||||||
|
"path",
|
||||||
|
"available"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"available": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"$ref": "#/components/schemas/UvSource"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"UvSettingsResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"source",
|
||||||
|
"extra_env",
|
||||||
|
"managed_dir",
|
||||||
|
"python_dir",
|
||||||
|
"cache_dir",
|
||||||
|
"env_defaults",
|
||||||
|
"candidates"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"cache_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "实际生效的 uv 缓存目录(绝对路径)"
|
||||||
|
},
|
||||||
|
"candidates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/UvCandidate"
|
||||||
|
},
|
||||||
|
"description": "各来源的探测结果"
|
||||||
|
},
|
||||||
|
"custom_path": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"env_defaults": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"executable": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "当前设置指向的 uv;不可用时 `version` 为空"
|
||||||
|
},
|
||||||
|
"extra_env": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "额外注入给 uv 的环境变量;只允许 `UV_` 前缀",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"managed_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "程序目录下的 uv 目录"
|
||||||
|
},
|
||||||
|
"python_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"$ref": "#/components/schemas/UvSource"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"UvSource": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性\n会随启动方式变化,不适合当调度器的隐式依赖。",
|
||||||
|
"enum": [
|
||||||
|
"managed",
|
||||||
|
"custom"
|
||||||
|
]
|
||||||
|
},
|
||||||
"VenvProbeRequest": {
|
"VenvProbeRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "编辑器在用户填写工作目录时发起的探测请求。",
|
"description": "编辑器在用户填写工作目录时发起的探测请求。",
|
||||||
@@ -2041,7 +2572,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"can_create": {
|
"can_create": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "没有虚拟环境时,是否具备用 uv 创建的条件"
|
"description": "没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)"
|
||||||
},
|
},
|
||||||
"display_path": {
|
"display_path": {
|
||||||
"type": [
|
"type": [
|
||||||
|
|||||||
@@ -18,6 +18,19 @@
|
|||||||
|
|
||||||
- SQLite 数据库:`data/task_pilot.db`(WAL 模式,运行中会额外出现 `-wal`/`-shm` 文件)
|
- SQLite 数据库:`data/task_pilot.db`(WAL 模式,运行中会额外出现 `-wal`/`-shm` 文件)
|
||||||
- 运行日志:`logs/{task_id}/{run_id}.log`
|
- 运行日志:`logs/{task_id}/{run_id}.log`
|
||||||
|
- 托管虚拟环境:`data/envs/<名称>/`(「虚拟环境」页创建,删除环境就是删掉整个目录)
|
||||||
|
- uv 下载的 Python 解释器:`python/<版本>/`(`UV_PYTHON_INSTALL_DIR` 默认指这里)
|
||||||
|
|
||||||
|
## Python 与 uv
|
||||||
|
|
||||||
|
用 uv 创建虚拟环境时,uv 的来源在「Python 环境」页切换(不读系统 PATH):
|
||||||
|
|
||||||
|
- 本目录下的 `uv/uv.exe`(默认):没有 uv 时点「下载 uv」,会从科大镜像下载并校验 sha256 后放进来;也可以自己下载后放到这里
|
||||||
|
- 任意位置的 `uv.exe`:选「指定路径」,保存时会先校验能运行;本机已装 uv 就把它的路径填进来
|
||||||
|
|
||||||
|
同一页还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`),会注入到每一次 uv 调用。
|
||||||
|
|
||||||
|
「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;离线环境请自己准备 uv 并指定路径。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
|
|||||||
+318
-2
@@ -48,10 +48,9 @@
|
|||||||
|
|
||||||
.page-container {
|
.page-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 32px 48px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-stack {
|
.page-stack {
|
||||||
@@ -59,6 +58,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
padding: 32px 32px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */
|
/* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */
|
||||||
@@ -1203,3 +1203,319 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uv-env-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uv-env-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 260px) minmax(240px, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.uv-env-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-packages-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 顶部导航(三栏工作台) ---------- */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-status {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 三栏工作台 ---------- */
|
||||||
|
.dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 16px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-head {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--su-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--su-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-metric-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-metric-danger {
|
||||||
|
color: var(--su-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--su-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-list-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-kv {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-kv > * {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--su-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--su-bg-subtle);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--su-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--su-bg-card);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-text {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dashboard {
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-right {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-right > * {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.dashboard {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 左栏用户卡片 ---------- */
|
||||||
|
.user-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-online {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 2px solid var(--su-bg-card);
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bio {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--su-border);
|
||||||
|
color: var(--su-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗里的多个区块 */
|
||||||
|
.modal-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* uv 弹窗:环境变量左标签 + 底部操作 */
|
||||||
|
/* 弹窗表单:区块标题 + 选项 chip + 底部操作条 */
|
||||||
|
.form-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-icon {
|
||||||
|
color: var(--su-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions > button:last-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.form-section-head {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
+332
-5
@@ -46,9 +46,58 @@ function jsonResponse(body: unknown, status = 200): Response {
|
|||||||
|
|
||||||
interface FetchMockOptions {
|
interface FetchMockOptions {
|
||||||
directories?: (path: string | null) => Response
|
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 }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function uvSettingsPayload(
|
||||||
|
source: string,
|
||||||
|
customPath: string | null,
|
||||||
|
managedVersion: string | null = null,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
custom_path: customPath,
|
||||||
|
managed_dir: 'D:\\TaskPilot\\uv',
|
||||||
|
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 = {}) {
|
function createFetchMock(options: FetchMockOptions = {}) {
|
||||||
|
let uvSource = 'managed'
|
||||||
|
let uvCustomPath: string | null = null
|
||||||
|
let uvManagedVersion: string | null = null
|
||||||
|
let managedEnvs = options.envs ?? []
|
||||||
|
let managedPythons = options.pythons ?? []
|
||||||
|
const managedPackages = options.envPackages ?? {}
|
||||||
|
|
||||||
return vi.fn(
|
return vi.fn(
|
||||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
const url =
|
const url =
|
||||||
@@ -97,6 +146,69 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
if (url.endsWith('/api/v1/uv')) {
|
||||||
|
const request = init?.body
|
||||||
|
? (JSON.parse(String(init.body)) as {
|
||||||
|
source: string
|
||||||
|
custom_path?: string | null
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
if (request) {
|
||||||
|
uvSource = request.source
|
||||||
|
uvCustomPath = request.custom_path ?? null
|
||||||
|
}
|
||||||
|
return jsonResponse(
|
||||||
|
uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (url.endsWith('/api/v1/cron/preview')) {
|
if (url.endsWith('/api/v1/cron/preview')) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
timezone: 'Asia/Shanghai',
|
timezone: 'Asia/Shanghai',
|
||||||
@@ -106,6 +218,21 @@ 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')) {
|
if (url.endsWith('/api/v1/python/venv')) {
|
||||||
const probe = JSON.parse(String(init?.body)) as {
|
const probe = JSON.parse(String(init?.body)) as {
|
||||||
working_dir: string
|
working_dir: string
|
||||||
@@ -122,7 +249,7 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
|||||||
? '已自动关联工作目录中的虚拟环境'
|
? '已自动关联工作目录中的虚拟环境'
|
||||||
: creatable
|
: creatable
|
||||||
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
|
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
|
||||||
: '未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv',
|
: '未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv',
|
||||||
uv_available: creatable,
|
uv_available: creatable,
|
||||||
has_pyproject: creatable,
|
has_pyproject: creatable,
|
||||||
has_requirements: false,
|
has_requirements: false,
|
||||||
@@ -189,21 +316,221 @@ describe('TaskDashboard', () => {
|
|||||||
expect(await screen.findByText('在线')).toBeInTheDocument()
|
expect(await screen.findByText('在线')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('navigates to python mirrors from the sidebar', async () => {
|
it('navigates to the python settings page from the sidebar', async () => {
|
||||||
vi.stubGlobal('fetch', createFetchMock())
|
vi.stubGlobal('fetch', createFetchMock())
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderApp()
|
renderApp()
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
await screen.findByRole('button', { name: 'Python 镜像' }),
|
await screen.findByRole('button', { name: 'Python 环境' }),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: 'Python 镜像' }),
|
await screen.findByRole('heading', { name: 'Python 环境' }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
// 镜像和 uv 在同一页
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('row', { name: /清华 TUNA/ }),
|
await screen.findByRole('row', { name: /清华 TUNA/ }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
await screen.findByText('uv 可执行文件'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves the uv executable and env vars picked on the python page', async () => {
|
||||||
|
const fetchMock = createFetchMock()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Python 环境' }))
|
||||||
|
expect(await screen.findByText('未检测到可用的 uv')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('radio', { name: '指定自己下载的 uv 路径' }),
|
||||||
|
)
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('uv 可执行文件'),
|
||||||
|
'D:\\tools\\uv.exe',
|
||||||
|
)
|
||||||
|
await user.click(screen.getByRole('button', { name: '+ 添加变量' }))
|
||||||
|
await user.type(screen.getByLabelText('环境变量名 1'), 'UV_CACHE_DIR')
|
||||||
|
await user.type(screen.getByLabelText('环境变量值 1'), 'D:\\uv-cache')
|
||||||
|
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_CACHE_DIR: 'D:\\uv-cache' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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: 'Python 环境' }))
|
||||||
|
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('uv 0.9.0')).length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('downloads a python version with uv', async () => {
|
||||||
|
const fetchMock = createFetchMock()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
|
||||||
|
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: '虚拟环境' }))
|
||||||
|
expect(await screen.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('shows the next run time reported by the schedule endpoint', async () => {
|
it('shows the next run time reported by the schedule endpoint', async () => {
|
||||||
@@ -284,7 +611,7 @@ describe('TaskDashboard', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(/未检测到虚拟环境;本机未安装 uv/, {}, {
|
await screen.findByText(/未检测到虚拟环境;uv 不可用/, {}, {
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
}),
|
}),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button, Layout, Result } from '@stash/ui'
|
import { Button, Layout, Result } from '@stash/ui'
|
||||||
import { Route, Routes, useNavigate } from 'react-router-dom'
|
import { Route, Routes, useNavigate } from 'react-router-dom'
|
||||||
import { AppSidebar } from './components/AppSidebar'
|
import { AppHeader } from './components/AppHeader'
|
||||||
import { PythonMirrorsPage } from './pages/PythonMirrorsPage'
|
|
||||||
import { TaskDashboard } from './pages/TaskDashboard'
|
import { TaskDashboard } from './pages/TaskDashboard'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -24,15 +23,13 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
className="app-root"
|
className="app-root"
|
||||||
sider={<AppSidebar />}
|
header={<AppHeader />}
|
||||||
siderWidth={224}
|
|
||||||
maxWidth="full"
|
maxWidth="full"
|
||||||
contentPadding="none"
|
contentPadding="none"
|
||||||
>
|
>
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<TaskDashboard />} />
|
<Route path="/" element={<TaskDashboard />} />
|
||||||
<Route path="/mirrors" element={<PythonMirrorsPage />} />
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ export type CreatePythonMirrorRequest =
|
|||||||
components['schemas']['CreatePythonMirrorRequest']
|
components['schemas']['CreatePythonMirrorRequest']
|
||||||
export type UpdatePythonMirrorRequest =
|
export type UpdatePythonMirrorRequest =
|
||||||
components['schemas']['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 {
|
export interface TaskFilters {
|
||||||
q?: string
|
q?: string
|
||||||
@@ -359,3 +370,69 @@ export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
|
|||||||
{ method: 'POST' },
|
{ 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,54 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/python/envs": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["list_envs"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["create_env"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/python/envs/{name}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["delete_env"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/python/envs/{name}/packages": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["list_env_packages"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/python/mirrors": {
|
"/api/v1/python/mirrors": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -100,6 +148,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/python/pythons": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["list_pythons"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["install_python"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/python/venv": {
|
"/api/v1/python/venv": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -356,12 +420,52 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/uv": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["get_uv_settings"];
|
||||||
|
put: operations["update_uv_settings"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/uv/download": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["download_uv"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
ConcurrencyPolicy: "skip";
|
ConcurrencyPolicy: "skip";
|
||||||
|
CreateManagedEnvRequest: {
|
||||||
|
/** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */
|
||||||
|
name: string;
|
||||||
|
/** @description 预装包,每行一个,例如 `numpy==2.1.0` */
|
||||||
|
packages?: string[];
|
||||||
|
/** @description 创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定 */
|
||||||
|
python_version?: string | null;
|
||||||
|
};
|
||||||
CreatePythonMirrorRequest: {
|
CreatePythonMirrorRequest: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
index_url: string;
|
index_url: string;
|
||||||
@@ -426,6 +530,10 @@ export interface components {
|
|||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
HttpMethod: "GET" | "POST";
|
HttpMethod: "GET" | "POST";
|
||||||
|
InstallPythonRequest: {
|
||||||
|
/** @description 要下载的版本,例如 `3.12` 或 `3.12.4` */
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
InterpreterKind: "python" | "node" | "other";
|
InterpreterKind: "python" | "node" | "other";
|
||||||
LogChunkResponse: {
|
LogChunkResponse: {
|
||||||
@@ -434,6 +542,22 @@ export interface components {
|
|||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
next_offset: number;
|
next_offset: number;
|
||||||
};
|
};
|
||||||
|
ManagedEnvPackage: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
ManagedEnvSummary: {
|
||||||
|
name: string;
|
||||||
|
/** @description 虚拟环境根目录的绝对路径 */
|
||||||
|
path: string;
|
||||||
|
/** @description 取自 `pyvenv.cfg` 的 `version_info` */
|
||||||
|
python_version?: string | null;
|
||||||
|
};
|
||||||
|
ManagedPython: {
|
||||||
|
/** @description uv 下载到平台目录下的解释器路径 */
|
||||||
|
path: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
/** @description 一个 Python 包索引镜像(pip / uv)。 */
|
/** @description 一个 Python 包索引镜像(pip / uv)。 */
|
||||||
PythonMirrorResponse: {
|
PythonMirrorResponse: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -611,6 +735,49 @@ export interface components {
|
|||||||
timeout_secs?: number;
|
timeout_secs?: number;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
};
|
};
|
||||||
|
UpdateUvSettingsRequest: {
|
||||||
|
custom_path?: string | null;
|
||||||
|
/** @description 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 */
|
||||||
|
extra_env?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
source: components["schemas"]["UvSource"];
|
||||||
|
};
|
||||||
|
UvCandidate: {
|
||||||
|
available: boolean;
|
||||||
|
path: string;
|
||||||
|
source: components["schemas"]["UvSource"];
|
||||||
|
version?: string | null;
|
||||||
|
};
|
||||||
|
UvSettingsResponse: {
|
||||||
|
/** @description 实际生效的 uv 缓存目录(绝对路径) */
|
||||||
|
cache_dir: string;
|
||||||
|
/** @description 各来源的探测结果 */
|
||||||
|
candidates: components["schemas"]["UvCandidate"][];
|
||||||
|
custom_path?: string | null;
|
||||||
|
/** @description 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行 */
|
||||||
|
env_defaults: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
/** @description 当前设置指向的 uv;不可用时 `version` 为空 */
|
||||||
|
executable?: string | null;
|
||||||
|
/** @description 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 */
|
||||||
|
extra_env: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
/** @description 程序目录下的 uv 目录 */
|
||||||
|
managed_dir: string;
|
||||||
|
/** @description 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) */
|
||||||
|
python_dir: string;
|
||||||
|
source: components["schemas"]["UvSource"];
|
||||||
|
version?: string | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @description uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
|
||||||
|
* 会随启动方式变化,不适合当调度器的隐式依赖。
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
UvSource: "managed" | "custom";
|
||||||
/** @description 编辑器在用户填写工作目录时发起的探测请求。 */
|
/** @description 编辑器在用户填写工作目录时发起的探测请求。 */
|
||||||
VenvProbeRequest: {
|
VenvProbeRequest: {
|
||||||
/** @description 解释器或可执行程序,对应 `action.interpreter` */
|
/** @description 解释器或可执行程序,对应 `action.interpreter` */
|
||||||
@@ -621,7 +788,7 @@ export interface components {
|
|||||||
working_dir: string;
|
working_dir: string;
|
||||||
};
|
};
|
||||||
VenvProbeResponse: {
|
VenvProbeResponse: {
|
||||||
/** @description 没有虚拟环境时,是否具备用 uv 创建的条件 */
|
/** @description 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境) */
|
||||||
can_create: boolean;
|
can_create: boolean;
|
||||||
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
|
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
|
||||||
display_path?: string | null;
|
display_path?: string | null;
|
||||||
@@ -729,6 +896,121 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_envs: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Managed virtual environments */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ManagedEnvSummary"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_env: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateManagedEnvRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Virtual environment created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ManagedEnvSummary"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation or uv failure */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_env: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Environment name */
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Environment deleted */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Environment not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_env_packages: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Environment name */
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Installed packages */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ManagedEnvPackage"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Environment not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_python_mirrors: {
|
list_python_mirrors: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -939,6 +1221,68 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_pythons: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Pythons downloaded by uv into the platform directory */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ManagedPython"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description uv unavailable */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
install_python: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InstallPythonRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Python downloaded; returns the installed list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ManagedPython"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation or uv failure */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
probe_python_venv: {
|
probe_python_venv: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1544,4 +1888,86 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
get_uv_settings: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description uv settings and detected executables */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UvSettingsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update_uv_settings: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateUvSettingsRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description uv settings saved */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UvSettingsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation failed */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
download_uv: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description uv downloaded from the mirror and switched to it */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UvSettingsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Download, verification or install failed */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Brand, Space, Tag, Text } from '@stash/ui'
|
||||||
|
import { fetchHealth } from '../api/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶部导航:品牌 + 引擎状态。
|
||||||
|
*
|
||||||
|
* 配置入口都收进任务调度页的三栏卡片里(uv / 镜像源 / 虚拟环境都用弹窗),
|
||||||
|
* 所以这里不再放页面级导航。
|
||||||
|
*/
|
||||||
|
export function AppHeader() {
|
||||||
|
const healthQuery = useQuery({
|
||||||
|
queryKey: ['health'],
|
||||||
|
queryFn: ({ signal }) => fetchHealth(signal),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = healthQuery.isPending
|
||||||
|
? { tone: 'neutral' as const, label: '检测中' }
|
||||||
|
: healthQuery.isError
|
||||||
|
? { tone: 'danger' as const, label: '引擎离线' }
|
||||||
|
: { tone: 'success' as const, label: '引擎在线' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-header">
|
||||||
|
<div className="app-header-brand">
|
||||||
|
<Brand name="TaskPilot" mark="TP" />
|
||||||
|
<Text className="app-header-tagline" size="xs" tone="muted">
|
||||||
|
本地任务调度 · Windows
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space className="app-header-status" gap="xs" align="center">
|
||||||
|
<Tag tone={status.tone} dot>
|
||||||
|
{status.label}
|
||||||
|
</Tag>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
{healthQuery.data?.version
|
||||||
|
? `Rust 引擎 v${healthQuery.data.version}`
|
||||||
|
: '127.0.0.1:3000'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Brand, Icon, Menu, Tag, Text, type MenuItem } from '@stash/ui'
|
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
|
||||||
import { fetchHealth } from '../api/client'
|
|
||||||
|
|
||||||
const NAV_ITEMS: MenuItem[] = [
|
|
||||||
{
|
|
||||||
key: '/',
|
|
||||||
label: '任务调度',
|
|
||||||
icon: <Icon name="calendar_2_line" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '/mirrors',
|
|
||||||
label: 'Python 镜像',
|
|
||||||
icon: <Icon name="download_line" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 左侧导航:品牌 / 主导航 / 引擎状态。
|
|
||||||
*
|
|
||||||
* 页面本身只负责内容,导航与全局状态收在这里;窄屏时 Layout 会把侧栏
|
|
||||||
* 堆到内容上方,这里再用横向布局变形成顶部导航条。
|
|
||||||
*/
|
|
||||||
export function AppSidebar() {
|
|
||||||
const location = useLocation()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const activeKey = location.pathname === '/mirrors' ? '/mirrors' : '/'
|
|
||||||
const healthQuery = useQuery({
|
|
||||||
queryKey: ['health'],
|
|
||||||
queryFn: ({ signal }) => fetchHealth(signal),
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = healthQuery.isPending
|
|
||||||
? { tone: 'neutral' as const, label: '检测中' }
|
|
||||||
: healthQuery.isError
|
|
||||||
? { tone: 'danger' as const, label: '引擎离线' }
|
|
||||||
: { tone: 'success' as const, label: '引擎在线' }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app-sider">
|
|
||||||
<div className="app-sider-brand">
|
|
||||||
<Brand name="TaskPilot" mark="TP" />
|
|
||||||
<Text className="app-sider-tagline" size="xs" tone="muted">
|
|
||||||
本地任务调度 · Windows
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Menu
|
|
||||||
className="app-nav"
|
|
||||||
items={NAV_ITEMS}
|
|
||||||
activeKey={activeKey}
|
|
||||||
onSelect={(key) => navigate(key)}
|
|
||||||
itemHeight={38}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="app-sider-foot">
|
|
||||||
<div className="app-sider-status">
|
|
||||||
<Tag tone={status.tone} dot>
|
|
||||||
{status.label}
|
|
||||||
</Tag>
|
|
||||||
<Text size="xs" tone="muted">
|
|
||||||
{healthQuery.data?.version
|
|
||||||
? `Rust 引擎 v${healthQuery.data.version}`
|
|
||||||
: '127.0.0.1:3000'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Space, Text, Title } from '@stash/ui'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
eyebrow: string
|
|
||||||
title: string
|
|
||||||
subtitle?: ReactNode
|
|
||||||
actions?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 页面标题区:左侧标题 / 说明,右侧操作。所有页面共用。 */
|
|
||||||
export function PageHeader({
|
|
||||||
eyebrow,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
actions,
|
|
||||||
}: PageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<header className="page-header">
|
|
||||||
<div className="header-title">
|
|
||||||
<p className="eyebrow">{eyebrow}</p>
|
|
||||||
<Title level={1}>{title}</Title>
|
|
||||||
{subtitle !== undefined ? <Text tone="sub">{subtitle}</Text> : null}
|
|
||||||
</div>
|
|
||||||
{actions !== undefined ? (
|
|
||||||
<Space gap="sm" wrap>
|
|
||||||
{actions}
|
|
||||||
</Space>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
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`,包列表按需查询。
|
||||||
|
*/
|
||||||
|
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">
|
||||||
|
<Space justify="between" align="center" wrap>
|
||||||
|
<Space direction="column" gap="xs">
|
||||||
|
<Text strong>虚拟环境</Text>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
预创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>+ 新建环境</Button>
|
||||||
|
</Space>
|
||||||
|
</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="empty-state"
|
||||||
|
text="还没有预创建的环境"
|
||||||
|
action={<Button onClick={() => setCreateOpen(true)}>新建环境</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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
|
||||||
|
? '还没有下载 Python;先在上方「Python 版本」里下载,或选默认让 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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+18
-16
@@ -24,12 +24,11 @@ import {
|
|||||||
updatePythonMirror,
|
updatePythonMirror,
|
||||||
type PythonMirror,
|
type PythonMirror,
|
||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
import { ConfirmDialog } from './ConfirmDialog'
|
||||||
import { PageHeader } from '../components/PageHeader'
|
|
||||||
import {
|
import {
|
||||||
PythonMirrorModal,
|
PythonMirrorModal,
|
||||||
type PythonMirrorFormValues,
|
type PythonMirrorFormValues,
|
||||||
} from '../components/PythonMirrorModal'
|
} from './PythonMirrorModal'
|
||||||
|
|
||||||
const TABLE_COLUMNS: TableColumn[] = [
|
const TABLE_COLUMNS: TableColumn[] = [
|
||||||
{ key: 'name', title: '名称', width: 180 },
|
{ key: 'name', title: '名称', width: 180 },
|
||||||
@@ -49,12 +48,12 @@ function toRequest(values: PythonMirrorFormValues) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Python 包镜像管理。
|
* 「Python 环境」页的包索引镜像区块。
|
||||||
*
|
*
|
||||||
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
|
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
|
||||||
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
|
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
|
||||||
*/
|
*/
|
||||||
export function PythonMirrorsPage() {
|
export function PythonMirrorsSection() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
|
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
|
||||||
@@ -166,22 +165,25 @@ export function PythonMirrorsPage() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<>
|
||||||
<PageHeader
|
<Card padding="md">
|
||||||
eyebrow="PYTHON / PACKAGE MIRROR"
|
<Space justify="between" align="center" wrap>
|
||||||
title="Python 镜像"
|
<Space direction="column" gap="xs">
|
||||||
subtitle="管理 pip / uv 的包索引;默认镜像会在脚本任务运行时注入环境变量。"
|
<Text strong>包索引镜像</Text>
|
||||||
actions={<Button onClick={openCreate}>+ 新增镜像</Button>}
|
<Text size="xs" tone="muted">
|
||||||
/>
|
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Button onClick={openCreate}>+ 新增镜像</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card padding="md" className="mirror-summary">
|
<Card padding="md" className="mirror-summary">
|
||||||
<div className="mirror-summary-main">
|
<div className="mirror-summary-main">
|
||||||
<Text size="xs" tone="muted">
|
<Text size="xs" tone="muted">
|
||||||
当前默认镜像
|
当前默认镜像
|
||||||
</Text>
|
</Text>
|
||||||
<Title level={2}>
|
<Title level={2}>{defaultMirror?.name ?? '未设置默认镜像'}</Title>
|
||||||
{defaultMirror?.name ?? '未设置默认镜像'}
|
|
||||||
</Title>
|
|
||||||
<Text className="mono" size="sm" tone="sub">
|
<Text className="mono" size="sm" tone="sub">
|
||||||
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
|
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -270,6 +272,6 @@ export function PythonMirrorsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Result,
|
||||||
|
Space,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
toast,
|
||||||
|
type TableColumn,
|
||||||
|
} from '@stash/ui'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
getUvSettings,
|
||||||
|
installPython,
|
||||||
|
listPythons,
|
||||||
|
type ManagedPython,
|
||||||
|
} from '../api/client'
|
||||||
|
|
||||||
|
const PYTHON_COLUMNS: TableColumn[] = [
|
||||||
|
{ key: 'version', title: '版本', width: 160 },
|
||||||
|
{ key: 'path', title: '解释器路径', width: 480 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PYTHON_VERSION_HINT = '3.12'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「虚拟环境」页的 Python 版本区块:列出 uv 下载到平台目录的解释器,并支持下载新版本。
|
||||||
|
*
|
||||||
|
* 下载位置由 `UV_PYTHON_INSTALL_DIR` 决定,默认指向程序目录下的 `python\`。
|
||||||
|
*/
|
||||||
|
export function PythonRuntimeSection() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [version, setVersion] = useState('')
|
||||||
|
const pythonsQuery = useQuery({
|
||||||
|
queryKey: ['managed-pythons'],
|
||||||
|
queryFn: ({ signal }) => listPythons(signal),
|
||||||
|
})
|
||||||
|
const uvQuery = useQuery({
|
||||||
|
queryKey: ['uv-settings'],
|
||||||
|
queryFn: ({ signal }) => getUvSettings(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const installMutation = useMutation({
|
||||||
|
mutationFn: () => installPython({ version: version.trim() }),
|
||||||
|
onSuccess: (pythons: ManagedPython[]) => {
|
||||||
|
setVersion('')
|
||||||
|
toast.success(`Python 已下载(当前 ${pythons.length} 个版本可用)`)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['managed-pythons'] })
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pythons = pythonsQuery.data ?? []
|
||||||
|
const rows = pythons.map((python) => ({
|
||||||
|
id: python.version,
|
||||||
|
version: <Text strong>{python.version}</Text>,
|
||||||
|
path: (
|
||||||
|
<code className="code-chip" title={python.path}>
|
||||||
|
{python.path}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="md">
|
||||||
|
<Space direction="column" gap="md">
|
||||||
|
<Space direction="column" gap="xs">
|
||||||
|
<Text strong>Python 版本</Text>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
uv 下载的解释器放在{' '}
|
||||||
|
<Text className="mono">{uvQuery.data?.python_dir ?? 'python\\'}</Text>
|
||||||
|
(`UV_PYTHON_INSTALL_DIR` 默认指这里);新建环境时从已下载的版本里选。
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space gap="xs" wrap align="center">
|
||||||
|
<Input
|
||||||
|
value={version}
|
||||||
|
onChange={(event) => setVersion(event.target.value)}
|
||||||
|
className="mono-input"
|
||||||
|
placeholder={PYTHON_VERSION_HINT}
|
||||||
|
aria-label="要下载的 Python 版本"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
loading={installMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!version.trim()) {
|
||||||
|
toast.error('请填写要下载的 Python 版本,例如 3.12')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
installMutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下载 Python
|
||||||
|
</Button>
|
||||||
|
{installMutation.isPending ? (
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
正在下载,可能需要几十秒...
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{pythonsQuery.isError ? (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Python 列表加载失败"
|
||||||
|
description={pythonsQuery.error.message}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void pythonsQuery.refetch()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : pythonsQuery.isPending ? (
|
||||||
|
<div className="state-block">
|
||||||
|
<Spinner label="正在读取 Python 版本" />
|
||||||
|
</div>
|
||||||
|
) : pythons.length === 0 ? (
|
||||||
|
<Empty className="empty-state" text="还没有下载 Python 版本" />
|
||||||
|
) : (
|
||||||
|
<Table columns={PYTHON_COLUMNS} rows={rows} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -96,6 +96,8 @@ export function TaskEditorModal({
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
// 新建 Python 任务时先跑一次 uv 建环境,这期间不允许关闭弹窗。
|
||||||
|
const [preparingVenv, setPreparingVenv] = useState(false)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
||||||
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
||||||
@@ -338,8 +340,10 @@ export function TaskEditorModal({
|
|||||||
document.getElementById(task ? 'task-name' : 'task-id')?.focus()
|
document.getElementById(task ? 'task-name' : 'task-id')?.focus()
|
||||||
}, [open, task])
|
}, [open, task])
|
||||||
|
|
||||||
|
const busy = saving || preparingVenv
|
||||||
|
|
||||||
const requestClose = () => {
|
const requestClose = () => {
|
||||||
if (!saving) {
|
if (!busy) {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +351,39 @@ export function TaskEditorModal({
|
|||||||
const submit = handleSubmit(async (values) => {
|
const submit = handleSubmit(async (values) => {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
try {
|
try {
|
||||||
|
// 新建 Python 任务时先把环境准备好:环境问题当场暴露,不用等第一次运行。
|
||||||
|
if (
|
||||||
|
!task &&
|
||||||
|
values.actionType === 'script' &&
|
||||||
|
values.script.language === 'python'
|
||||||
|
) {
|
||||||
|
const workingDir = values.script.working_dir.trim()
|
||||||
|
if (workingDir) {
|
||||||
|
setPreparingVenv(true)
|
||||||
|
try {
|
||||||
|
const probe = await probePythonVenv({
|
||||||
|
working_dir: workingDir,
|
||||||
|
interpreter: values.script.interpreter.trim() || 'python.exe',
|
||||||
|
venv: values.script.venv.trim() || null,
|
||||||
|
})
|
||||||
|
if (probe.can_create) {
|
||||||
|
const created = await createPythonVenv({
|
||||||
|
working_dir: workingDir,
|
||||||
|
})
|
||||||
|
toast.success(
|
||||||
|
created.venv_path
|
||||||
|
? `已创建虚拟环境:${created.venv_path}`
|
||||||
|
: '虚拟环境已创建',
|
||||||
|
)
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ['python-venv'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPreparingVenv(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
await onSubmit(values)
|
await onSubmit(values)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiRequestError && error.field) {
|
if (error instanceof ApiRequestError && error.field) {
|
||||||
@@ -372,14 +409,18 @@ export function TaskEditorModal({
|
|||||||
title={task ? `编辑任务 [${task.id}]` : '新建定时任务'}
|
title={task ? `编辑任务 [${task.id}]` : '新建定时任务'}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="task-editor-modal"
|
className="task-editor-modal"
|
||||||
closeOnOverlay={!saving}
|
closeOnOverlay={!busy}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={requestClose} disabled={saving}>
|
<Button variant="secondary" onClick={requestClose} disabled={busy}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="task-editor-form" loading={saving}>
|
<Button type="submit" form="task-editor-form" loading={busy}>
|
||||||
{task ? '保存修改' : '保存并热刷新'}
|
{preparingVenv
|
||||||
|
? '正在创建虚拟环境…'
|
||||||
|
: task
|
||||||
|
? '保存修改'
|
||||||
|
: '保存并热刷新'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormField,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Result,
|
||||||
|
Space,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
TextButton,
|
||||||
|
toast,
|
||||||
|
} from '@stash/ui'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
downloadUv,
|
||||||
|
getUvSettings,
|
||||||
|
updateUvSettings,
|
||||||
|
type UvSettings,
|
||||||
|
type UvSource,
|
||||||
|
} from '../api/client'
|
||||||
|
import { shortUvVersion } from '../lib/format'
|
||||||
|
|
||||||
|
const SOURCE_OPTIONS = [
|
||||||
|
{ value: 'managed', label: '程序目录 uv' },
|
||||||
|
{ value: 'custom', label: '指定路径' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UvSettingsSectionProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uv 配置:可执行文件来源 + 环境变量。
|
||||||
|
*
|
||||||
|
* 不读系统 PATH:只用程序目录下的 `uv\uv.exe`(下载或自己放进来),或指定路径。
|
||||||
|
* 只在「uv 配置」弹窗里渲染,不常驻页面。
|
||||||
|
*/
|
||||||
|
export function UvSettingsSection({ onClose }: UvSettingsSectionProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const settingsQuery = useQuery({
|
||||||
|
queryKey: ['uv-settings'],
|
||||||
|
queryFn: ({ signal }) => getUvSettings(signal),
|
||||||
|
})
|
||||||
|
const [draftSource, setDraftSource] = useState<UvSource | null>(null)
|
||||||
|
const [draftCustomPath, setDraftCustomPath] = useState<string | null>(null)
|
||||||
|
const [draftEnv, setDraftEnv] = useState<EnvRow[] | null>(null)
|
||||||
|
|
||||||
|
const settings = settingsQuery.data
|
||||||
|
// 编辑过就用草稿,否则跟随服务端当前设置(保存成功后清空草稿重新对齐)。
|
||||||
|
const source = draftSource ?? settings?.source ?? 'managed'
|
||||||
|
const customPath = draftCustomPath ?? settings?.custom_path ?? ''
|
||||||
|
const envRows =
|
||||||
|
draftEnv ?? rowsFrom(settings?.extra_env, settings?.env_defaults)
|
||||||
|
|
||||||
|
const resetDrafts = () => {
|
||||||
|
setDraftSource(null)
|
||||||
|
setDraftCustomPath(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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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 updateEnvRow = (index: number, patch: Partial<EnvRow>) => {
|
||||||
|
setDraftEnv(
|
||||||
|
envRows.map((row, position) =>
|
||||||
|
position === index ? { ...row, ...patch } : row,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDisabled = settingsQuery.isPending || settingsQuery.isError
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="column" gap="lg">
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="form-section">
|
||||||
|
<div className="form-section-head">
|
||||||
|
<Space gap="xs" align="center">
|
||||||
|
<Icon name="chip_line" className="form-section-icon" />
|
||||||
|
<Text strong>uv 可执行文件</Text>
|
||||||
|
</Space>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
必选 1 项
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chip-row">
|
||||||
|
{SOURCE_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
size="sm"
|
||||||
|
shape="pill"
|
||||||
|
variant={source === option.value ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => setDraftSource(option.value as UvSource)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text className="mono" size="xs" tone="sub">
|
||||||
|
{settings?.version
|
||||||
|
? `当前 ${shortUvVersion(settings.version)} · ${settings.executable}`
|
||||||
|
: '未检测到可用的 uv'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{source === 'managed' ? (
|
||||||
|
<Space gap="xs" align="center" wrap>
|
||||||
|
<Text
|
||||||
|
className="mono"
|
||||||
|
size="xs"
|
||||||
|
tone="muted"
|
||||||
|
title={`${settings?.managed_dir ?? 'uv'}\\uv.exe`}
|
||||||
|
>
|
||||||
|
{settings?.managed_dir ?? '程序目录'}\uv.exe
|
||||||
|
</Text>
|
||||||
|
{settings?.version ? null : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => downloadMutation.mutate()}
|
||||||
|
loading={downloadMutation.isPending}
|
||||||
|
>
|
||||||
|
下载 uv
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
label="uv 可执行文件"
|
||||||
|
htmlFor="uv-custom-path"
|
||||||
|
hint="填 uv.exe 的完整路径,例如 C:\\Users\\你\\.local\\bin\\uv.exe;保存时会校验它能否运行"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="uv-custom-path"
|
||||||
|
value={customPath}
|
||||||
|
onChange={(event) => setDraftCustomPath(event.target.value)}
|
||||||
|
className="mono-input"
|
||||||
|
placeholder="D:\\tools\\uv\\uv.exe"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<div className="form-section-head">
|
||||||
|
<Space gap="xs" align="center">
|
||||||
|
<Icon name="box_line" className="form-section-icon" />
|
||||||
|
<Text strong>环境变量</Text>
|
||||||
|
</Space>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
可选 · 已设 {envRows.length} 项
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space justify="between" align="center" wrap>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
UV_* 变量(注入到每一次 uv 调用;路径类可填相对程序目录的路径,也可填绝对路径)
|
||||||
|
</Text>
|
||||||
|
<TextButton
|
||||||
|
onClick={() =>
|
||||||
|
setDraftEnv([...envRows, { name: '', value: '' }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ 添加变量
|
||||||
|
</TextButton>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{envRows.length === 0 ? (
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
没有额外环境变量
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<div className="uv-env-rows">
|
||||||
|
{envRows.map((row, index) => (
|
||||||
|
<div className="uv-env-row" key={index}>
|
||||||
|
<Input
|
||||||
|
value={row.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateEnvRow(index, { name: event.target.value })
|
||||||
|
}
|
||||||
|
className="mono-input"
|
||||||
|
placeholder="UV_CACHE_DIR"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label={`环境变量名 ${index + 1}`}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={row.value}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateEnvRow(index, { value: event.target.value })
|
||||||
|
}
|
||||||
|
className="mono-input"
|
||||||
|
placeholder="D:\\uv-cache"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label={`环境变量值 ${index + 1}`}
|
||||||
|
/>
|
||||||
|
<TextButton
|
||||||
|
tone="danger"
|
||||||
|
onClick={() =>
|
||||||
|
setDraftEnv(
|
||||||
|
envRows.filter((_, position) => position !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</TextButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<IconButton
|
||||||
|
label="关闭"
|
||||||
|
icon={<Icon name="close_line" />}
|
||||||
|
variant="soft"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
disabled={saveDisabled}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -53,6 +53,17 @@ export function actionTypeLabel(type: TaskActionType): string {
|
|||||||
}[type]
|
}[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> = {
|
export const runStatusLabels: Record<RunStatus, string> = {
|
||||||
queued: '排队中',
|
queued: '排队中',
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const SCRIPT_LANGUAGE_META: Record<
|
|||||||
defaultScript: 'main.py',
|
defaultScript: 'main.py',
|
||||||
scriptLabel: '脚本文件或相对路径',
|
scriptLabel: '脚本文件或相对路径',
|
||||||
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
|
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
|
||||||
hint: '自动使用项目 .venv;没有环境时可在下方用 uv 创建。',
|
hint: '优先使用项目里的 .venv;没有环境时保存任务会自动用 uv 创建。',
|
||||||
},
|
},
|
||||||
shell: {
|
shell: {
|
||||||
label: 'Shell (cmd)',
|
label: 'Shell (cmd)',
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Empty,
|
Empty,
|
||||||
Grid,
|
Modal,
|
||||||
Notice,
|
Notice,
|
||||||
Pagination,
|
Pagination,
|
||||||
Result,
|
Result,
|
||||||
@@ -16,13 +17,11 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spinner,
|
Spinner,
|
||||||
Table,
|
|
||||||
Tag,
|
Tag,
|
||||||
Text,
|
Text,
|
||||||
TextButton,
|
TextButton,
|
||||||
Title,
|
Title,
|
||||||
toast,
|
toast,
|
||||||
type TableColumn,
|
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
@@ -32,6 +31,9 @@ import {
|
|||||||
fetchHealth,
|
fetchHealth,
|
||||||
fetchSchedule,
|
fetchSchedule,
|
||||||
fetchStats,
|
fetchStats,
|
||||||
|
getUvSettings,
|
||||||
|
listManagedEnvs,
|
||||||
|
listPythonMirrors,
|
||||||
listTasks,
|
listTasks,
|
||||||
runTask,
|
runTask,
|
||||||
setTaskEnabled,
|
setTaskEnabled,
|
||||||
@@ -41,11 +43,18 @@ import {
|
|||||||
type TaskFilters,
|
type TaskFilters,
|
||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||||
import { PageHeader } from '../components/PageHeader'
|
import { PythonEnvsSection } from '../components/PythonEnvsSection'
|
||||||
|
import { PythonMirrorsSection } from '../components/PythonMirrorsSection'
|
||||||
|
import { PythonRuntimeSection } from '../components/PythonRuntimeSection'
|
||||||
import { RunLogDrawer } from '../components/RunLogDrawer'
|
import { RunLogDrawer } from '../components/RunLogDrawer'
|
||||||
import { EnabledBadge } from '../components/StatusBadge'
|
import { EnabledBadge } from '../components/StatusBadge'
|
||||||
import { TaskEditorModal } from '../components/TaskEditorModal'
|
import { TaskEditorModal } from '../components/TaskEditorModal'
|
||||||
import { actionTypeLabel, formatShortDateTime } from '../lib/format'
|
import { UvSettingsSection } from '../components/UvSettingsSection'
|
||||||
|
import {
|
||||||
|
actionTypeLabel,
|
||||||
|
formatShortDateTime,
|
||||||
|
shortUvVersion,
|
||||||
|
} from '../lib/format'
|
||||||
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
||||||
import {
|
import {
|
||||||
type TaskFormValues,
|
type TaskFormValues,
|
||||||
@@ -68,13 +77,11 @@ const ACTION_TYPE_OPTIONS = [
|
|||||||
{ value: 'process', label: '进程' },
|
{ value: 'process', label: '进程' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TABLE_COLUMNS: TableColumn[] = [
|
/// 中间栏按任务类型分组,和顶部筛选配合使用。
|
||||||
{ key: 'name', title: '名称 / 任务标识', width: 230 },
|
const TASK_GROUPS: Array<{ type: TaskActionType; label: string }> = [
|
||||||
{ key: 'timezone', title: '时区', width: 110 },
|
{ type: 'script', label: '脚本' },
|
||||||
{ key: 'schedule', title: '调度周期', width: 200 },
|
{ type: 'http', label: 'HTTP 请求' },
|
||||||
{ key: 'status', title: '状态', width: 100 },
|
{ type: 'process', label: '二进制程序' },
|
||||||
{ key: 'timeout', title: '超时', width: 80 },
|
|
||||||
{ key: 'actions', title: '操作', align: 'right', width: 240 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function TaskDashboard() {
|
export function TaskDashboard() {
|
||||||
@@ -91,6 +98,9 @@ export function TaskDashboard() {
|
|||||||
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [uvSettingsOpen, setUvSettingsOpen] = useState(false)
|
||||||
|
const [mirrorSettingsOpen, setMirrorSettingsOpen] = useState(false)
|
||||||
|
const [envManagerOpen, setEnvManagerOpen] = useState(false)
|
||||||
const [queryDraft, setQueryDraft] = useState(q)
|
const [queryDraft, setQueryDraft] = useState(q)
|
||||||
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
||||||
|
|
||||||
@@ -132,9 +142,26 @@ export function TaskDashboard() {
|
|||||||
queryFn: ({ signal }) => fetchSchedule(signal),
|
queryFn: ({ signal }) => fetchSchedule(signal),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
const uvQuery = useQuery({
|
||||||
|
queryKey: ['uv-settings'],
|
||||||
|
queryFn: ({ signal }) => getUvSettings(signal),
|
||||||
|
})
|
||||||
|
const mirrorsQuery = useQuery({
|
||||||
|
queryKey: ['python-mirrors'],
|
||||||
|
queryFn: ({ signal }) => listPythonMirrors(signal),
|
||||||
|
})
|
||||||
|
const envsQuery = useQuery({
|
||||||
|
queryKey: ['managed-envs'],
|
||||||
|
queryFn: ({ signal }) => listManagedEnvs(signal),
|
||||||
|
})
|
||||||
|
|
||||||
const nextRuns = new Map(
|
const nextRuns = new Map(
|
||||||
(scheduleQuery.data ?? []).map((entry) => [entry.task_id, entry.next_run_at]),
|
(scheduleQuery.data ?? []).map((entry) => [entry.task_id, entry.next_run_at]),
|
||||||
)
|
)
|
||||||
|
const defaultMirror =
|
||||||
|
mirrorsQuery.data?.find((mirror) => mirror.is_default) ?? null
|
||||||
|
const uv = uvQuery.data
|
||||||
|
const managedEnvs = envsQuery.data ?? []
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
@@ -208,6 +235,11 @@ export function TaskDashboard() {
|
|||||||
const tasks = tasksQuery.data?.items ?? []
|
const tasks = tasksQuery.data?.items ?? []
|
||||||
const total = tasksQuery.data?.total ?? 0
|
const total = tasksQuery.data?.total ?? 0
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
const groups = TASK_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
tasks: tasks.filter((task) => task.action.type === group.type),
|
||||||
|
})).filter((group) => group.tasks.length > 0)
|
||||||
|
const nearestRun = [...nextRuns.values()].sort()[0]
|
||||||
|
|
||||||
function updateFilters(changes: Record<string, string | undefined>) {
|
function updateFilters(changes: Record<string, string | undefined>) {
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
@@ -246,230 +278,254 @@ export function TaskDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = tasks.map((task) => {
|
|
||||||
const isRunning =
|
|
||||||
runMutation.isPending && runMutation.variables?.id === task.id
|
|
||||||
const isToggling =
|
|
||||||
toggleMutation.isPending && toggleMutation.variables?.id === task.id
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: task.id,
|
|
||||||
name: (
|
|
||||||
<div className="task-name-cell">
|
|
||||||
<div className="task-name-row">
|
|
||||||
<Text className="task-name" strong>
|
|
||||||
{task.name}
|
|
||||||
</Text>
|
|
||||||
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
|
||||||
</div>
|
|
||||||
<Text className="task-id" size="xs" tone="muted">
|
|
||||||
{task.id}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
schedule: (
|
|
||||||
<div className="schedule-cell">
|
|
||||||
<code className="code-chip">{task.cron}</code>
|
|
||||||
<Text size="xs" tone="muted">
|
|
||||||
{task.enabled
|
|
||||||
? nextRuns.get(task.id)
|
|
||||||
? `下次 ${formatShortDateTime(nextRuns.get(task.id))}`
|
|
||||||
: '调度中…'
|
|
||||||
: '未调度'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
timezone: (
|
|
||||||
<Text className="timezone-cell" size="sm" tone="sub">
|
|
||||||
{task.timezone}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
status: <EnabledBadge enabled={task.enabled} />,
|
|
||||||
timeout: (
|
|
||||||
<Text size="sm" tone="sub">
|
|
||||||
{task.timeout_secs}s
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
actions: (
|
|
||||||
<Space justify="end" gap="xs" wrap>
|
|
||||||
<TextButton
|
|
||||||
disabled={isRunning}
|
|
||||||
title="立即运行"
|
|
||||||
onClick={() => runMutation.mutate(task)}
|
|
||||||
>
|
|
||||||
{isRunning ? '提交中' : '运行'}
|
|
||||||
</TextButton>
|
|
||||||
<TextButton
|
|
||||||
onClick={() => {
|
|
||||||
setLogTask(task)
|
|
||||||
setInitialRunId(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
日志
|
|
||||||
</TextButton>
|
|
||||||
<TextButton onClick={() => openEdit(task)}>编辑</TextButton>
|
|
||||||
<TextButton
|
|
||||||
disabled={isToggling}
|
|
||||||
onClick={() => toggleMutation.mutate(task)}
|
|
||||||
>
|
|
||||||
{task.enabled ? '停用' : '启用'}
|
|
||||||
</TextButton>
|
|
||||||
<TextButton tone="danger" onClick={() => setDeleteTarget(task)}>
|
|
||||||
删除
|
|
||||||
</TextButton>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<div className="dashboard">
|
||||||
<PageHeader
|
<div className="dashboard-head">
|
||||||
eyebrow="TASKPILOT / WINDOWS SCHEDULER"
|
<Space direction="column" gap="xs">
|
||||||
title="任务调度"
|
<Title level={2} as="h1">
|
||||||
subtitle="Rust 本地轻量化任务引擎 · Windows 原生调度中心"
|
任务调度
|
||||||
actions={<Button onClick={openCreate}>+ 新建任务</Button>}
|
</Title>
|
||||||
/>
|
<Text size="sm" tone="sub">
|
||||||
|
Rust 本地轻量化任务引擎 · Windows 原生调度中心
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Button onClick={openCreate}>+ 新建任务</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{actionError ? (
|
{/* 左栏:用户 / 概览 / 调度 */}
|
||||||
<Notice
|
<aside className="dashboard-column dashboard-left" aria-label="概览">
|
||||||
tone="error"
|
<Card padding="md">
|
||||||
title="操作失败"
|
<div className="user-card">
|
||||||
className="page-alert"
|
<div className="user-avatar">
|
||||||
action={{ label: '关闭', onClick: () => setActionError(null) }}
|
<Avatar fallback="本" size="lg" tone="primary" verified />
|
||||||
>
|
<span className="user-online" />
|
||||||
{actionError}
|
</div>
|
||||||
</Notice>
|
<div className="user-meta">
|
||||||
) : null}
|
<Space gap="xs" align="center" wrap>
|
||||||
|
<Text strong>本地用户</Text>
|
||||||
<section aria-label="任务统计" className="metrics-section">
|
<Tag tone="success" shape="rounded">
|
||||||
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="md">
|
单机
|
||||||
<MetricCard
|
</Tag>
|
||||||
label="已托管任务"
|
</Space>
|
||||||
value={statsQuery.data?.total}
|
<Text size="xs" tone="muted">
|
||||||
loading={statsQuery.isPending}
|
永远本地模式
|
||||||
/>
|
<span className="user-dot" />
|
||||||
<MetricCard
|
</Text>
|
||||||
label="运行中任务"
|
</div>
|
||||||
value={statsQuery.data?.running}
|
|
||||||
loading={statsQuery.isPending}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="可用脚本"
|
|
||||||
value={statsQuery.data?.script}
|
|
||||||
loading={statsQuery.isPending}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Rust 引擎"
|
|
||||||
value={
|
|
||||||
healthQuery.isPending
|
|
||||||
? '...'
|
|
||||||
: healthQuery.isError
|
|
||||||
? '离线'
|
|
||||||
: '在线'
|
|
||||||
}
|
|
||||||
loading={false}
|
|
||||||
danger={healthQuery.isError}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Card padding="none" className="task-card">
|
|
||||||
<div className="toolbar">
|
|
||||||
<SearchField
|
|
||||||
className="toolbar-search"
|
|
||||||
value={queryDraft}
|
|
||||||
onChange={setQueryDraft}
|
|
||||||
placeholder="搜索任务名称或 ID"
|
|
||||||
size="md"
|
|
||||||
maxWidth={420}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="toolbar-filters">
|
|
||||||
<Select
|
|
||||||
className="filter-select"
|
|
||||||
options={ENABLED_OPTIONS}
|
|
||||||
value={enabledValue ?? ''}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateFilters({
|
|
||||||
enabled: value || undefined,
|
|
||||||
page: '1',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
className="filter-select"
|
|
||||||
options={ACTION_TYPE_OPTIONS}
|
|
||||||
value={actionTypeValue ?? ''}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateFilters({
|
|
||||||
action_type: value || undefined,
|
|
||||||
page: '1',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{hasFilters ? (
|
|
||||||
<TextButton onClick={() => setSearchParams({})}>
|
|
||||||
清除筛选
|
|
||||||
</TextButton>
|
|
||||||
) : null}
|
|
||||||
<TextButton
|
|
||||||
onClick={() => void refreshAll()}
|
|
||||||
disabled={refreshing}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</TextButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="user-bio">
|
||||||
|
任务、日志和 Python 环境都保存在本机;不依赖云服务,也不会把数据发出去。
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<Text strong size="sm">
|
||||||
|
概览
|
||||||
|
</Text>
|
||||||
|
<div className="dash-metrics">
|
||||||
|
<DashMetric
|
||||||
|
label="已托管任务"
|
||||||
|
value={statsQuery.data?.total}
|
||||||
|
loading={statsQuery.isPending}
|
||||||
|
/>
|
||||||
|
<DashMetric
|
||||||
|
label="运行中任务"
|
||||||
|
value={statsQuery.data?.running}
|
||||||
|
loading={statsQuery.isPending}
|
||||||
|
/>
|
||||||
|
<DashMetric
|
||||||
|
label="可用脚本"
|
||||||
|
value={statsQuery.data?.script}
|
||||||
|
loading={statsQuery.isPending}
|
||||||
|
/>
|
||||||
|
<DashMetric
|
||||||
|
label="Rust 引擎"
|
||||||
|
value={
|
||||||
|
healthQuery.isPending
|
||||||
|
? '...'
|
||||||
|
: healthQuery.isError
|
||||||
|
? '离线'
|
||||||
|
: '在线'
|
||||||
|
}
|
||||||
|
loading={false}
|
||||||
|
danger={healthQuery.isError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<Text strong size="sm">
|
||||||
|
调度
|
||||||
|
</Text>
|
||||||
|
<div className="dash-kv">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
已启用 / 已停用
|
||||||
|
</Text>
|
||||||
|
<Text className="mono" size="sm">
|
||||||
|
{statsQuery.data
|
||||||
|
? `${statsQuery.data.enabled} / ${statsQuery.data.total - statsQuery.data.enabled}`
|
||||||
|
: '--'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="dash-kv">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
最近触发
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{nearestRun ? formatShortDateTime(nearestRun) : '暂无'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 中栏:筛选 + 按类型分组的任务 */}
|
||||||
|
<section className="dashboard-column dashboard-main" aria-label="任务列表">
|
||||||
|
<Card padding="md">
|
||||||
|
<Space gap="sm" wrap align="center">
|
||||||
|
<SearchField
|
||||||
|
className="toolbar-search"
|
||||||
|
value={queryDraft}
|
||||||
|
onChange={setQueryDraft}
|
||||||
|
placeholder="搜索任务名称或 ID"
|
||||||
|
size="md"
|
||||||
|
maxWidth={360}
|
||||||
|
/>
|
||||||
|
<Space gap="xs" wrap align="center">
|
||||||
|
<Select
|
||||||
|
className="filter-select"
|
||||||
|
options={ENABLED_OPTIONS}
|
||||||
|
value={enabledValue ?? ''}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateFilters({
|
||||||
|
enabled: value || undefined,
|
||||||
|
page: '1',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className="filter-select"
|
||||||
|
options={ACTION_TYPE_OPTIONS}
|
||||||
|
value={actionTypeValue ?? ''}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateFilters({
|
||||||
|
action_type: value || undefined,
|
||||||
|
page: '1',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{hasFilters ? (
|
||||||
|
<TextButton onClick={() => setSearchParams({})}>
|
||||||
|
清除筛选
|
||||||
|
</TextButton>
|
||||||
|
) : null}
|
||||||
|
<TextButton
|
||||||
|
onClick={() => void refreshAll()}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</TextButton>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{actionError ? (
|
||||||
|
<Notice
|
||||||
|
tone="error"
|
||||||
|
title="操作失败"
|
||||||
|
className="page-alert"
|
||||||
|
action={{ label: '关闭', onClick: () => setActionError(null) }}
|
||||||
|
>
|
||||||
|
{actionError}
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{tasksQuery.isError ? (
|
{tasksQuery.isError ? (
|
||||||
<Result
|
<Card padding="none">
|
||||||
status="error"
|
<Result
|
||||||
title="任务列表加载失败"
|
status="error"
|
||||||
description={tasksQuery.error.message}
|
title="任务列表加载失败"
|
||||||
actions={
|
description={tasksQuery.error.message}
|
||||||
<Button
|
actions={
|
||||||
variant="secondary"
|
<Button
|
||||||
onClick={() => void tasksQuery.refetch()}
|
variant="secondary"
|
||||||
>
|
onClick={() => void tasksQuery.refetch()}
|
||||||
重试
|
>
|
||||||
</Button>
|
重试
|
||||||
}
|
</Button>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
) : tasksQuery.isPending ? (
|
) : tasksQuery.isPending ? (
|
||||||
<div className="state-block">
|
<Card padding="none">
|
||||||
<Spinner label="正在读取任务" />
|
<div className="state-block">
|
||||||
<Text tone="sub">正在读取任务...</Text>
|
<Spinner label="正在读取任务" />
|
||||||
</div>
|
<Text tone="sub">正在读取任务...</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
) : tasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<Empty
|
<Card padding="none">
|
||||||
className="empty-state"
|
<Empty
|
||||||
text={
|
className="empty-state"
|
||||||
<span className="empty-copy">
|
text={
|
||||||
<Text as="span" size="lg" strong>
|
<span className="empty-copy">
|
||||||
{hasFilters ? '没有匹配任务' : '还没有任务'}
|
<Text as="span" size="lg" strong>
|
||||||
</Text>
|
{hasFilters ? '没有匹配任务' : '还没有任务'}
|
||||||
<Text as="span" size="md" tone="sub">
|
</Text>
|
||||||
{hasFilters
|
<Text as="span" size="md" tone="sub">
|
||||||
? '请调整搜索条件后重试。'
|
{hasFilters
|
||||||
: '创建第一个定时任务,开始使用真实调度能力。'}
|
? '请调整搜索条件后重试。'
|
||||||
</Text>
|
: '创建第一个定时任务,开始使用真实调度能力。'}
|
||||||
</span>
|
</Text>
|
||||||
}
|
</span>
|
||||||
action={
|
}
|
||||||
hasFilters ? undefined : (
|
action={
|
||||||
<Button onClick={openCreate}>新建任务</Button>
|
hasFilters ? undefined : (
|
||||||
)
|
<Button onClick={openCreate}>新建任务</Button>
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Table
|
{groups.map((group) => (
|
||||||
className="task-table"
|
<Card key={group.type} padding="md">
|
||||||
columns={TABLE_COLUMNS}
|
<Space justify="between" align="center">
|
||||||
rows={rows}
|
<Space gap="xs" align="center">
|
||||||
size="md"
|
<Text strong>{group.label}</Text>
|
||||||
/>
|
<Tag shape="rounded">{group.tasks.length}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
{actionTypeLabel(group.type)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<div className="task-grid">
|
||||||
|
{group.tasks.map((task) => (
|
||||||
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
nextRunAt={nextRuns.get(task.id)}
|
||||||
|
running={
|
||||||
|
runMutation.isPending &&
|
||||||
|
runMutation.variables?.id === task.id
|
||||||
|
}
|
||||||
|
toggling={
|
||||||
|
toggleMutation.isPending &&
|
||||||
|
toggleMutation.variables?.id === task.id
|
||||||
|
}
|
||||||
|
onRun={() => runMutation.mutate(task)}
|
||||||
|
onLogs={() => {
|
||||||
|
setLogTask(task)
|
||||||
|
setInitialRunId(null)
|
||||||
|
}}
|
||||||
|
onEdit={() => openEdit(task)}
|
||||||
|
onToggle={() => toggleMutation.mutate(task)}
|
||||||
|
onDelete={() => setDeleteTarget(task)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
<footer className="pagination-bar">
|
<footer className="pagination-bar">
|
||||||
<Text size="sm" tone="sub">
|
<Text size="sm" tone="sub">
|
||||||
共 {total} 个任务,第 {page} / {totalPages} 页
|
共 {total} 个任务,第 {page} / {totalPages} 页
|
||||||
@@ -483,7 +539,146 @@ export function TaskDashboard() {
|
|||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</section>
|
||||||
|
|
||||||
|
{/* 右栏:uv / 镜像 / 虚拟环境摘要(配置走弹窗) */}
|
||||||
|
<aside className="dashboard-column dashboard-right" aria-label="Python 环境">
|
||||||
|
<Card padding="md">
|
||||||
|
<Space justify="between" align="center">
|
||||||
|
<Text strong size="sm">
|
||||||
|
uv
|
||||||
|
</Text>
|
||||||
|
<TextButton onClick={() => setUvSettingsOpen(true)}>
|
||||||
|
配置
|
||||||
|
</TextButton>
|
||||||
|
</Space>
|
||||||
|
<div className="dash-kv">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
版本
|
||||||
|
</Text>
|
||||||
|
<Text className="mono" size="sm">
|
||||||
|
{shortUvVersion(uv?.version) ?? '未检测到'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="dash-kv">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
缓存目录
|
||||||
|
</Text>
|
||||||
|
<Text className="mono" size="xs" tone="sub" title="UV_CACHE_DIR">
|
||||||
|
{uv?.cache_dir ?? 'uv-cache'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="dash-kv">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
Python 目录
|
||||||
|
</Text>
|
||||||
|
<Text className="mono" size="xs" tone="sub">
|
||||||
|
{uv?.python_dir ?? 'python'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<Space justify="between" align="center">
|
||||||
|
<Text strong size="sm">
|
||||||
|
镜像源
|
||||||
|
</Text>
|
||||||
|
<TextButton onClick={() => setMirrorSettingsOpen(true)}>
|
||||||
|
配置
|
||||||
|
</TextButton>
|
||||||
|
</Space>
|
||||||
|
<div className="dash-kv">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
当前默认镜像
|
||||||
|
</Text>
|
||||||
|
<Text className="mono" size="xs" tone="sub">
|
||||||
|
{defaultMirror?.index_url ?? '未设置'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<Space justify="between" align="center">
|
||||||
|
<Text strong size="sm">
|
||||||
|
虚拟环境
|
||||||
|
</Text>
|
||||||
|
<TextButton onClick={() => setEnvManagerOpen(true)}>
|
||||||
|
管理
|
||||||
|
</TextButton>
|
||||||
|
</Space>
|
||||||
|
{managedEnvs.length === 0 ? (
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
还没有预创建的环境
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<div className="dash-list">
|
||||||
|
{managedEnvs.slice(0, 5).map((env) => (
|
||||||
|
<div className="dash-list-row" key={env.name}>
|
||||||
|
<Text size="sm" strong>
|
||||||
|
{env.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" tone="muted" className="mono">
|
||||||
|
{env.python_version ?? '—'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 配置弹窗:uv 与镜像源分开,各自一个弹窗;关闭时不渲染内容,避免重复表单 */}
|
||||||
|
{uvSettingsOpen ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => setUvSettingsOpen(false)}
|
||||||
|
title="uv 配置"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<UvSettingsSection onClose={() => setUvSettingsOpen(false)} />
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mirrorSettingsOpen ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => setMirrorSettingsOpen(false)}
|
||||||
|
title="镜像源配置"
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setMirrorSettingsOpen(false)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PythonMirrorsSection />
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{envManagerOpen ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => setEnvManagerOpen(false)}
|
||||||
|
title="虚拟环境管理"
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setEnvManagerOpen(false)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="modal-stack">
|
||||||
|
<PythonRuntimeSection />
|
||||||
|
<PythonEnvsSection />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TaskEditorModal
|
<TaskEditorModal
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
@@ -513,7 +708,7 @@ export function TaskDashboard() {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
title={`删除任务 ${deleteTarget?.id ?? ''}`}
|
title={`删除任务 ${deleteTarget?.id ?? ''}`}
|
||||||
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不能撤销。"
|
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不可撤销。"
|
||||||
busy={deleteMutation.isPending}
|
busy={deleteMutation.isPending}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (!deleteMutation.isPending) {
|
if (!deleteMutation.isPending) {
|
||||||
@@ -530,28 +725,90 @@ export function TaskDashboard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface DashMetricProps {
|
||||||
label: string
|
label: string
|
||||||
value: number | string | undefined
|
value: number | string | undefined
|
||||||
loading: boolean
|
loading: boolean
|
||||||
danger?: boolean
|
danger?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ label, value, loading, danger = false }: MetricCardProps) {
|
function DashMetric({ label, value, loading, danger = false }: DashMetricProps) {
|
||||||
return (
|
return (
|
||||||
<Card padding="md" className="metric-card" hoverable>
|
<div className="dash-metric">
|
||||||
<Text tone="sub" size="sm">
|
<Text size="xs" tone="muted">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Title
|
<span className={danger ? 'dash-metric-value dash-metric-danger' : 'dash-metric-value'}>
|
||||||
level={1}
|
|
||||||
as="div"
|
|
||||||
weight="medium"
|
|
||||||
className={danger ? 'metric-value metric-danger' : 'metric-value'}
|
|
||||||
>
|
|
||||||
{loading ? '...' : (value ?? '--')}
|
{loading ? '...' : (value ?? '--')}
|
||||||
</Title>
|
</span>
|
||||||
</Card>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskItemProps {
|
||||||
|
task: Task
|
||||||
|
nextRunAt: string | undefined
|
||||||
|
running: boolean
|
||||||
|
toggling: boolean
|
||||||
|
onRun: () => void
|
||||||
|
onLogs: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
onToggle: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskItem({
|
||||||
|
task,
|
||||||
|
nextRunAt,
|
||||||
|
running,
|
||||||
|
toggling,
|
||||||
|
onRun,
|
||||||
|
onLogs,
|
||||||
|
onEdit,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
}: TaskItemProps) {
|
||||||
|
const mark = task.name.trim().charAt(0).toUpperCase() || 'T'
|
||||||
|
return (
|
||||||
|
<div className="task-item">
|
||||||
|
<div className="task-item-main">
|
||||||
|
<span className={`task-item-mark task-item-mark-${task.action.type}`}>
|
||||||
|
{mark}
|
||||||
|
</span>
|
||||||
|
<div className="task-item-text">
|
||||||
|
<Space gap="xs" align="center" wrap>
|
||||||
|
<Text strong>{task.name}</Text>
|
||||||
|
<EnabledBadge enabled={task.enabled} />
|
||||||
|
</Space>
|
||||||
|
<Text className="task-id" size="xs" tone="muted">
|
||||||
|
{task.id}
|
||||||
|
</Text>
|
||||||
|
<Space gap="xs" align="center" wrap>
|
||||||
|
<code className="code-chip">{task.cron}</code>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
{task.enabled
|
||||||
|
? nextRunAt
|
||||||
|
? `下次 ${formatShortDateTime(nextRunAt)}`
|
||||||
|
: '调度中…'
|
||||||
|
: '未调度'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Space className="task-item-actions" gap="xs" wrap justify="end">
|
||||||
|
<TextButton disabled={running} title="立即运行" onClick={onRun}>
|
||||||
|
{running ? '提交中' : '运行'}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton onClick={onLogs}>日志</TextButton>
|
||||||
|
<TextButton onClick={onEdit}>编辑</TextButton>
|
||||||
|
<TextButton disabled={toggling} onClick={onToggle}>
|
||||||
|
{task.enabled ? '停用' : '启用'}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton tone="danger" onClick={onDelete}>
|
||||||
|
删除
|
||||||
|
</TextButton>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ if (-not $releaseRoot.StartsWith($expectedPrefix, [System.StringComparison]::Ord
|
|||||||
throw "Refusing to build outside release directory: $releaseRoot"
|
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 {
|
function Assert-ExitCode {
|
||||||
param([string]$Step)
|
param([string]$Step)
|
||||||
|
|
||||||
@@ -33,6 +39,8 @@ try {
|
|||||||
}
|
}
|
||||||
[void](New-Item -ItemType Directory -Path $releaseRoot -Force)
|
[void](New-Item -ItemType Directory -Path $releaseRoot -Force)
|
||||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'web') -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 'data') -Force)
|
||||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'logs') -Force)
|
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'logs') -Force)
|
||||||
|
|
||||||
@@ -41,6 +49,10 @@ try {
|
|||||||
Copy-Item -Path 'frontend\dist\*' -Destination (Join-Path $releaseRoot 'web') -Recurse
|
Copy-Item -Path 'frontend\dist\*' -Destination (Join-Path $releaseRoot 'web') -Recurse
|
||||||
Copy-Item -LiteralPath 'docs\RELEASE.md' -Destination (Join-Path $releaseRoot 'README.md')
|
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"
|
Write-Host "Release package: $releaseRoot"
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user