Compare commits
8
Commits
d7a6e17531
...
2aa2a1ac18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa2a1ac18 | ||
|
|
62f1661b2c | ||
|
|
8501deba07 | ||
|
|
78ddc17fce | ||
|
|
10f8e6bb66 | ||
|
|
917674880b | ||
|
|
8e6b3b92b9 | ||
|
|
29fc579275 |
@@ -6,6 +6,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台
|
||||
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
|
||||
- SQLite 持久化,重启后任务与历史仍在
|
||||
- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示
|
||||
- Python 包镜像管理:内置常用 PyPI 源,默认镜像在任务运行时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
|
||||
- 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体
|
||||
|
||||
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。
|
||||
@@ -116,8 +117,8 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
||||
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
||||
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
||||
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
||||
| Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验 |
|
||||
| 界面 | 三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志抽屉 |
|
||||
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 |
|
||||
| 界面 | 左侧导航(任务调度 / Python 镜像)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
||||
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
||||
|
||||
@@ -142,11 +143,15 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
||||
### 三类 Action
|
||||
|
||||
```jsonc
|
||||
// 脚本
|
||||
// 脚本(language 省略时按 interpreter 推断,旧任务兼容)
|
||||
{ "type": "script", "working_dir": "D:\\Projects\\sync", "venv": null,
|
||||
"interpreter": "python.exe", "script": "main.py",
|
||||
"language": "python", "interpreter": "python.exe", "script": "main.py",
|
||||
"args": ["--mode", "full"], "env": { "PYTHONUNBUFFERED": "1" } }
|
||||
|
||||
// Shell:cmd.exe /C run.bat;Node/Lua 同理
|
||||
{ "type": "script", "working_dir": "D:\\Projects\\ops", "language": "shell",
|
||||
"interpreter": "cmd.exe", "script": "run.bat", "args": [], "env": {} }
|
||||
|
||||
// HTTP
|
||||
{ "type": "http", "url": "https://api.example.com/health", "method": "GET",
|
||||
"headers": { "X-Token": "..." }, "body": null, "expected_status": [200, 204] }
|
||||
@@ -205,7 +210,7 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
||||
|
||||
## Python 与虚拟环境
|
||||
|
||||
解析顺序(执行期与编辑器探测共用同一份逻辑):
|
||||
编辑器和执行器共用同一份解析逻辑:
|
||||
|
||||
1. 手动指定的 `action.venv`
|
||||
2. 从解释器路径推断(`...\.venv\Scripts\python.exe`)
|
||||
@@ -213,6 +218,20 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
||||
|
||||
虚拟环境必须同时存在 `pyvenv.cfg` 与 `Scripts\python.exe`,否则显式配置在保存时被拒绝。
|
||||
|
||||
**没有虚拟环境时**:
|
||||
|
||||
- 工作目录根部有 `pyproject.toml` 或 `requirements*.txt`,且 PATH 中能找到 `uv`:
|
||||
编辑器会显示「用 uv 创建环境」按钮,点击后
|
||||
`pyproject.toml` 走 `uv sync`,仅 requirements 走 `uv venv .venv` + `uv pip install -r`。
|
||||
已有 `.venv`/`venv`/`env` 会优先复用,不会重复创建。创建时会使用当前默认 PyPI 镜像
|
||||
(`PIP_INDEX_URL` / `UV_DEFAULT_INDEX`),不会绕过镜像设置。
|
||||
- 没装 uv:必须手动准备虚拟环境;Python 任务缺少 `.venv` 时运行失败并给出提示。
|
||||
- 既没有虚拟环境、也没有 pyproject/requirements:编辑器显示错误,Python 任务运行失败。
|
||||
Python 任务不再静默回退到系统解释器。
|
||||
- 解释器由语言自动决定,界面不再显示解释器输入框:
|
||||
Python 始终使用虚拟环境里的 Python,Shell 使用 `cmd.exe`,Node 使用 `node.exe`,Lua 使用 `lua.exe`。
|
||||
自定义解释器仍可通过 API 的 `action.interpreter` 字段传入。
|
||||
|
||||
命中虚拟环境后注入:
|
||||
|
||||
| 变量 | 行为 |
|
||||
@@ -229,14 +248,21 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
||||
|
||||
## Web 界面
|
||||
|
||||
- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。
|
||||
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
||||
- **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。
|
||||
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。
|
||||
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
|
||||
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
|
||||
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
|
||||
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
|
||||
缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。解释器由语言自动选择,界面不再要求手填。
|
||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
||||
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
|
||||
默认镜像在 Python 脚本任务启动时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
|
||||
(任务自身配置的环境变量优先),不会自动安装依赖。
|
||||
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
|
||||
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
|
||||
业务块(任务表单元格、日志控制台、表单栅格)。
|
||||
业务块(侧栏、任务表单元格、日志控制台、表单栅格)。
|
||||
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
|
||||
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
|
||||
`tools/build-fonts.py` 生成,缺失时回退系统字体。
|
||||
@@ -287,11 +313,23 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
||||
| `GET` | `/api/v1/session` | 获取当前进程的管理令牌 |
|
||||
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
||||
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
|
||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境 |
|
||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 |
|
||||
| `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) |
|
||||
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) |
|
||||
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
|
||||
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
|
||||
|
||||
### Python 镜像
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/python/mirrors` | 镜像列表(默认项优先) |
|
||||
| `POST` | `/api/v1/python/mirrors` | 新增镜像 |
|
||||
| `GET` | `/api/v1/python/mirrors/{id}` | 镜像详情 |
|
||||
| `PUT` | `/api/v1/python/mirrors/{id}` | 更新名称 / 地址 / 信任主机 / 启用状态 |
|
||||
| `DELETE` | `/api/v1/python/mirrors/{id}` | 删除镜像 |
|
||||
| `POST` | `/api/v1/python/mirrors/{id}/default` | 设为默认(同一时间仅一个默认,并自动启用) |
|
||||
|
||||
### 任务与运行
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
@@ -374,7 +412,9 @@ Content-Type: application/json
|
||||
| `timezone` | 有效 IANA 名称 |
|
||||
| `timeout_secs` | 1–86400 |
|
||||
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
|
||||
| `action.working_dir` / `interpreter` / `script` | 非空 |
|
||||
| `action.working_dir` / `script` | 非空 |
|
||||
| `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 |
|
||||
| `action.interpreter` | 可空;空时按语言默认(Python 为 `python.exe`,Shell 为 `cmd.exe`,Node 为 `node.exe`,Lua 为 `lua.exe`) |
|
||||
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
|
||||
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
|
||||
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
|
||||
@@ -425,6 +465,7 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
|
||||
| `executor.rs` | 三类 Action 执行、并发闸门、超时/取消、日志脱敏与截断 |
|
||||
| `process_job.rs` | Windows Job Object(`KILL_ON_JOB_CLOSE`) |
|
||||
| `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入 |
|
||||
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
|
||||
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
|
||||
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
|
||||
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
|
||||
@@ -434,7 +475,11 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
|
||||
| `components/AppSidebar.tsx` | 左侧导航、品牌、引擎状态 |
|
||||
| `components/PageHeader.tsx` | 页面标题区(标题 / 说明 / 操作) |
|
||||
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
|
||||
| `pages/PythonMirrorsPage.tsx` | Python 镜像列表、默认项、新增 / 编辑 / 删除 |
|
||||
| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) |
|
||||
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
|
||||
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
|
||||
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
|
||||
@@ -502,10 +547,10 @@ uv run --with fonttools --with brotli tools/build-fonts.py
|
||||
|
||||
| 层 | 覆盖 |
|
||||
| --- | --- |
|
||||
| Rust 单元(57) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
||||
| Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 |
|
||||
| 前端 Vitest(28) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
||||
| Playwright(5) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动 |
|
||||
| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
||||
| Rust 集成(39) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建、Shell 脚本执行 |
|
||||
| 前端 Vitest(31) | 仪表盘渲染、侧栏导航到镜像页、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
||||
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
||||
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
||||
|
||||
---
|
||||
@@ -533,6 +578,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py
|
||||
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
||||
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
||||
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
||||
- 用 uv 创建环境是编辑器里的显式操作;运行中的任务不会在后台自动安装依赖。首次创建需要网络与可写的 uv 缓存目录。
|
||||
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
||||
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
||||
- 错过的调度不会补跑。
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE IF NOT EXISTS python_mirrors (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
index_url TEXT NOT NULL,
|
||||
trusted_host TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 同一时间只允许一个默认镜像
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
|
||||
ON python_mirrors(is_default)
|
||||
WHERE is_default = 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
|
||||
ON python_mirrors(index_url);
|
||||
|
||||
INSERT OR IGNORE INTO python_mirrors
|
||||
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
||||
VALUES
|
||||
(
|
||||
'tsinghua',
|
||||
'清华 TUNA',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
NULL,
|
||||
1,
|
||||
1,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'aliyun',
|
||||
'阿里云',
|
||||
'https://mirrors.aliyun.com/pypi/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'ustc',
|
||||
'中科大',
|
||||
'https://pypi.mirrors.ustc.edu.cn/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'pypi',
|
||||
'PyPI 官方',
|
||||
'https://pypi.org/simple',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
);
|
||||
+55
-3
@@ -18,7 +18,16 @@ use crate::{
|
||||
},
|
||||
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
|
||||
python_env::{
|
||||
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
|
||||
self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse,
|
||||
VenvProbeStatus, VenvSource,
|
||||
},
|
||||
python_mirrors::{
|
||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||
routes::{
|
||||
__path_create_python_mirror, __path_delete_python_mirror, __path_get_python_mirror,
|
||||
__path_list_python_mirrors, __path_set_default_python_mirror,
|
||||
__path_update_python_mirror,
|
||||
},
|
||||
},
|
||||
runs::{
|
||||
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
|
||||
@@ -32,8 +41,8 @@ use crate::{
|
||||
tasks::{
|
||||
model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
|
||||
TaskAction, TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse,
|
||||
UpdateTaskRequest,
|
||||
ScriptLanguage, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
|
||||
TaskStatsResponse, UpdateTaskRequest,
|
||||
},
|
||||
routes::{
|
||||
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
||||
@@ -136,6 +145,32 @@ pub async fn probe_python_venv(
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/venv/create",
|
||||
tag = "system",
|
||||
request_body = CreatePythonVenvRequest,
|
||||
responses(
|
||||
(status = 200, description = "Created or reused project virtual environment", body = VenvProbeResponse),
|
||||
(status = 400, description = "Project cannot create a virtual environment", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_python_venv(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreatePythonVenvRequest>,
|
||||
) -> ApiResult<Json<VenvProbeResponse>> {
|
||||
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
|
||||
None
|
||||
});
|
||||
python_env::create_project_venv(&request.working_dir, mirror.as_ref())
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/system/directories",
|
||||
@@ -219,6 +254,13 @@ pub async fn list_schedule(
|
||||
session,
|
||||
preview_cron,
|
||||
probe_python_venv,
|
||||
create_python_venv,
|
||||
list_python_mirrors,
|
||||
get_python_mirror,
|
||||
create_python_mirror,
|
||||
update_python_mirror,
|
||||
delete_python_mirror,
|
||||
set_default_python_mirror,
|
||||
list_directories,
|
||||
task_stats,
|
||||
list_schedule,
|
||||
@@ -244,15 +286,20 @@ pub async fn list_schedule(
|
||||
CronPreviewResponse,
|
||||
VenvProbeRequest,
|
||||
VenvProbeResponse,
|
||||
CreatePythonVenvRequest,
|
||||
VenvProbeStatus,
|
||||
VenvSource,
|
||||
InterpreterKind,
|
||||
PythonMirrorResponse,
|
||||
CreatePythonMirrorRequest,
|
||||
UpdatePythonMirrorRequest,
|
||||
DirectoryEntry,
|
||||
DirectoryListingResponse,
|
||||
TaskStatsResponse,
|
||||
ScheduledTaskResponse,
|
||||
ErrorResponse,
|
||||
TaskActionType,
|
||||
ScriptLanguage,
|
||||
HttpMethod,
|
||||
ConcurrencyPolicy,
|
||||
RetryPolicy,
|
||||
@@ -300,11 +347,16 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/v1/python/venv",
|
||||
axum::routing::post(probe_python_venv),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/venv/create",
|
||||
axum::routing::post(create_python_venv),
|
||||
)
|
||||
.route("/api/v1/system/directories", get(list_directories))
|
||||
.route("/api/v1/stats", get(task_stats))
|
||||
.route("/api/v1/schedule", get(list_schedule))
|
||||
.route("/api/v1/openapi.json", get(openapi_json))
|
||||
.merge(crate::tasks::routes::router())
|
||||
.merge(crate::python_mirrors::routes::router())
|
||||
.merge(crate::runs::routes::router())
|
||||
.route("/api/{*path}", any(api_not_found))
|
||||
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
|
||||
|
||||
@@ -109,6 +109,26 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mirror_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "MIRROR_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mirror_conflict(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: "MIRROR_CONFLICT",
|
||||
message: message.into(),
|
||||
field: Some("index_url".to_owned()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod directory_browser;
|
||||
pub mod error;
|
||||
pub mod process_job;
|
||||
pub mod python_env;
|
||||
pub mod python_mirrors;
|
||||
pub mod runs;
|
||||
pub mod scheduler;
|
||||
pub mod security;
|
||||
|
||||
+314
-39
@@ -2,14 +2,20 @@ use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
ffi::OsString,
|
||||
io,
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
use tokio::{
|
||||
process::Command,
|
||||
time::{Duration, timeout},
|
||||
};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::python_mirrors::model::DefaultPythonMirror;
|
||||
|
||||
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -230,76 +236,152 @@ pub struct VenvProbeResponse {
|
||||
pub display_path: Option<String>,
|
||||
pub source: Option<VenvSource>,
|
||||
pub message: String,
|
||||
/// 本机 PATH 中是否能找到 uv
|
||||
pub uv_available: bool,
|
||||
/// 工作目录根部是否存在 pyproject.toml
|
||||
pub has_pyproject: bool,
|
||||
/// 工作目录根部是否存在 requirements*.txt
|
||||
pub has_requirements: bool,
|
||||
/// 探测到的 requirements 文件名(仅根目录)
|
||||
pub requirements_files: Vec<String>,
|
||||
/// 没有虚拟环境时,是否具备用 uv 创建的条件
|
||||
pub can_create: bool,
|
||||
}
|
||||
|
||||
/// 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreatePythonVenvRequest {
|
||||
pub working_dir: String,
|
||||
}
|
||||
|
||||
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
||||
let working_dir = request.working_dir.trim();
|
||||
let interpreter = request.interpreter.trim();
|
||||
let interpreter = {
|
||||
let trimmed = request.interpreter.trim();
|
||||
if trimmed.is_empty() {
|
||||
"python.exe"
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
let configured_venv = request
|
||||
.venv
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let interpreter_kind = classify_interpreter(interpreter);
|
||||
let uv = uv_available();
|
||||
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
|
||||
|
||||
if working_dir.is_empty() {
|
||||
return incomplete_probe(interpreter_kind, "请先填写工作目录");
|
||||
}
|
||||
if interpreter.is_empty() {
|
||||
return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序");
|
||||
return respond(incomplete_probe(interpreter_kind, "请先填写工作目录"));
|
||||
}
|
||||
if !matches!(interpreter_kind, InterpreterKind::Python) {
|
||||
return VenvProbeResponse {
|
||||
return respond(VenvProbeResponse {
|
||||
status: VenvProbeStatus::NotApplicable,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: Some(interpreter.to_owned()),
|
||||
source: None,
|
||||
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
|
||||
};
|
||||
..base_probe()
|
||||
});
|
||||
}
|
||||
|
||||
let working_directory = match absolute_path(Path::new(working_dir)) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}"));
|
||||
return respond(incomplete_probe(
|
||||
interpreter_kind,
|
||||
&format!("工作目录无效: {error}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !working_directory.is_dir() {
|
||||
return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境");
|
||||
return respond(incomplete_probe(
|
||||
interpreter_kind,
|
||||
"工作目录不存在,暂时无法探测虚拟环境",
|
||||
));
|
||||
}
|
||||
|
||||
match resolve_venv(working_dir, interpreter, configured_venv) {
|
||||
Ok(ResolvedVenv {
|
||||
venv: Some(venv),
|
||||
source,
|
||||
}) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Linked,
|
||||
interpreter_kind,
|
||||
display_path: Some(python_display_path(&venv, working_dir)),
|
||||
venv_path: Some(venv.to_string_lossy().into_owned()),
|
||||
source,
|
||||
message: venv_source_message(source),
|
||||
},
|
||||
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Missing,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: Some(interpreter.to_owned()),
|
||||
source: None,
|
||||
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
|
||||
},
|
||||
Err(error) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Invalid,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: error.to_string(),
|
||||
respond(
|
||||
match resolve_venv(working_dir, interpreter, configured_venv) {
|
||||
Ok(ResolvedVenv {
|
||||
venv: Some(venv),
|
||||
source,
|
||||
}) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Linked,
|
||||
interpreter_kind,
|
||||
display_path: Some(python_display_path(&venv, working_dir)),
|
||||
venv_path: Some(venv.to_string_lossy().into_owned()),
|
||||
source,
|
||||
message: venv_source_message(source),
|
||||
..base_probe()
|
||||
},
|
||||
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Missing,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: Some(interpreter.to_owned()),
|
||||
source: None,
|
||||
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
|
||||
..base_probe()
|
||||
},
|
||||
Err(error) => VenvProbeResponse {
|
||||
status: VenvProbeStatus::Invalid,
|
||||
interpreter_kind,
|
||||
venv_path: None,
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: error.to_string(),
|
||||
..base_probe()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn base_probe() -> VenvProbeResponse {
|
||||
VenvProbeResponse {
|
||||
status: VenvProbeStatus::Incomplete,
|
||||
interpreter_kind: InterpreterKind::Other,
|
||||
venv_path: None,
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: String::new(),
|
||||
uv_available: false,
|
||||
has_pyproject: false,
|
||||
has_requirements: false,
|
||||
requirements_files: Vec::new(),
|
||||
can_create: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_probe(
|
||||
mut response: VenvProbeResponse,
|
||||
working_dir: &str,
|
||||
uv_available: bool,
|
||||
) -> VenvProbeResponse {
|
||||
let files = scan_project_files(working_dir);
|
||||
response.uv_available = uv_available;
|
||||
response.has_pyproject = files.has_pyproject;
|
||||
response.has_requirements = !files.requirements.is_empty();
|
||||
response.requirements_files = files.requirements;
|
||||
response.can_create = response.status == VenvProbeStatus::Missing
|
||||
&& uv_available
|
||||
&& (response.has_pyproject || response.has_requirements);
|
||||
if response.status == VenvProbeStatus::Missing {
|
||||
response.message = if response.can_create {
|
||||
"未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv".to_owned()
|
||||
} else if !uv_available {
|
||||
"未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv".to_owned()
|
||||
} else {
|
||||
"未检测到虚拟环境,也没有 pyproject.toml / requirements.txt,无法自动创建".to_owned()
|
||||
};
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
|
||||
VenvProbeResponse {
|
||||
status: VenvProbeStatus::Incomplete,
|
||||
@@ -308,9 +390,177 @@ fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvPro
|
||||
display_path: None,
|
||||
source: None,
|
||||
message: message.to_owned(),
|
||||
..base_probe()
|
||||
}
|
||||
}
|
||||
|
||||
/// 工作目录根部的 Python 依赖文件(不递归子目录)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProjectFiles {
|
||||
pub has_pyproject: bool,
|
||||
pub requirements: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
|
||||
let mut files = ProjectFiles::default();
|
||||
let Ok(entries) = fs::read_dir(working_dir) else {
|
||||
return files;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower == "pyproject.toml" {
|
||||
files.has_pyproject = true;
|
||||
} else if lower.starts_with("requirements") && lower.ends_with(".txt") {
|
||||
files.requirements.push(name);
|
||||
}
|
||||
}
|
||||
files.requirements.sort();
|
||||
files
|
||||
}
|
||||
|
||||
/// 本机 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 创建/同步项目虚拟环境。优先返回已有环境。
|
||||
pub async fn create_project_venv(
|
||||
working_dir: &str,
|
||||
mirror: Option<&DefaultPythonMirror>,
|
||||
) -> Result<VenvProbeResponse, String> {
|
||||
let working_directory =
|
||||
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
|
||||
if !working_directory.is_dir() {
|
||||
return Err("工作目录不存在,无法创建虚拟环境".to_owned());
|
||||
}
|
||||
|
||||
let probe_request = VenvProbeRequest {
|
||||
working_dir: working_dir.to_owned(),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
venv: None,
|
||||
};
|
||||
let existing = probe_venv(&probe_request);
|
||||
if existing.status == VenvProbeStatus::Linked {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let files = scan_project_files(working_dir);
|
||||
if !files.has_pyproject && files.requirements.is_empty() {
|
||||
return Err(
|
||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt,无法创建".to_owned(),
|
||||
);
|
||||
}
|
||||
if !uv_available() {
|
||||
return Err("本机 PATH 中找不到 uv,无法自动创建虚拟环境;请手动创建 .venv".to_owned());
|
||||
}
|
||||
|
||||
if files.has_pyproject {
|
||||
run_uv(&working_directory, &["sync"], mirror).await?;
|
||||
} else {
|
||||
run_uv(&working_directory, &["venv", ".venv"], mirror).await?;
|
||||
let python = working_directory
|
||||
.join(".venv")
|
||||
.join("Scripts")
|
||||
.join("python.exe");
|
||||
let python = python.to_string_lossy().into_owned();
|
||||
for requirement in &files.requirements {
|
||||
run_uv(
|
||||
&working_directory,
|
||||
&["pip", "install", "--python", &python, "-r", requirement],
|
||||
mirror,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let probe = probe_venv(&probe_request);
|
||||
if probe.status != VenvProbeStatus::Linked {
|
||||
return Err(format!(
|
||||
"uv 执行完成,但没有找到有效的 .venv:{}",
|
||||
probe.message
|
||||
));
|
||||
}
|
||||
Ok(probe)
|
||||
}
|
||||
|
||||
async fn run_uv(
|
||||
working_dir: &Path,
|
||||
args: &[&str],
|
||||
mirror: Option<&DefaultPythonMirror>,
|
||||
) -> Result<String, String> {
|
||||
let mut command = Command::new("uv");
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(mirror) = mirror {
|
||||
command
|
||||
.env("PIP_INDEX_URL", &mirror.index_url)
|
||||
.env("UV_INDEX_URL", &mirror.index_url)
|
||||
.env("UV_DEFAULT_INDEX", &mirror.index_url);
|
||||
if let Some(host) = mirror
|
||||
.trusted_host
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
command.env("PIP_TRUSTED_HOST", host);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(0x0800_0000);
|
||||
|
||||
let output = timeout(Duration::from_secs(600), command.output())
|
||||
.await
|
||||
.map_err(|_| format!("uv {} 超时(600 秒)", args.join(" ")))?
|
||||
.map_err(|error| format!("无法启动 uv: {error}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success() {
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
stderr.trim()
|
||||
};
|
||||
return Err(format!(
|
||||
"uv {} 失败: {}",
|
||||
args.join(" "),
|
||||
truncate_output(detail)
|
||||
));
|
||||
}
|
||||
Ok(stdout.into_owned())
|
||||
}
|
||||
|
||||
fn truncate_output(value: &str) -> String {
|
||||
const MAX_CHARS: usize = 1200;
|
||||
if value.chars().count() <= MAX_CHARS {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut truncated = value.chars().take(MAX_CHARS).collect::<String>();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
|
||||
fn venv_source_message(source: Option<VenvSource>) -> String {
|
||||
match source {
|
||||
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
|
||||
@@ -472,7 +722,7 @@ fn absolute_path(path: &Path) -> io::Result<PathBuf> {
|
||||
std::path::absolute(path)
|
||||
}
|
||||
|
||||
fn is_python_interpreter(interpreter: &str) -> bool {
|
||||
pub fn is_python_interpreter(interpreter: &str) -> bool {
|
||||
let name = Path::new(interpreter)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
@@ -735,6 +985,31 @@ mod tests {
|
||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
||||
assert!(response.venv_path.is_none());
|
||||
assert!(!response.can_create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_project_dependency_files_for_uv_creation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
fs::write(temp.path().join("pyproject.toml"), "[project]\n").unwrap();
|
||||
fs::write(temp.path().join("requirements-dev.txt"), "").unwrap();
|
||||
|
||||
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||
|
||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||
assert!(response.has_pyproject);
|
||||
assert!(response.has_requirements);
|
||||
assert_eq!(response.requirements_files, vec!["requirements-dev.txt"]);
|
||||
assert_eq!(response.can_create, response.uv_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_does_not_offer_uv_without_dependency_files() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||
|
||||
assert!(!response.can_create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod model;
|
||||
pub mod repository;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,52 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 一个 Python 包索引镜像(pip / uv)。
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct PythonMirrorResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL`
|
||||
pub index_url: String,
|
||||
/// pip 的 `--trusted-host`;https 源通常为空
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
/// 当前默认镜像;同一时间最多一个
|
||||
pub is_default: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreatePythonMirrorRequest {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
#[serde(default)]
|
||||
pub trusted_host: Option<String>,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdatePythonMirrorRequest {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
#[serde(default)]
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
const fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 执行器每次运行 Python 任务时读取的默认镜像。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefaultPythonMirror {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
pub trusted_host: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
python_mirrors::{
|
||||
model::{DefaultPythonMirror, PythonMirrorResponse},
|
||||
service::ValidatedMirror,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct MirrorRow {
|
||||
id: String,
|
||||
name: String,
|
||||
index_url: String,
|
||||
trusted_host: Option<String>,
|
||||
enabled: bool,
|
||||
is_default: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MirrorRow> for PythonMirrorResponse {
|
||||
fn from(row: MirrorRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
index_url: row.index_url,
|
||||
trusted_host: row.trusted_host,
|
||||
enabled: row.enabled,
|
||||
is_default: row.is_default,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> ApiResult<Vec<PythonMirrorResponse>> {
|
||||
let rows = sqlx::query_as::<_, MirrorRow>(
|
||||
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
|
||||
FROM python_mirrors
|
||||
ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
|
||||
let row = sqlx::query_as::<_, MirrorRow>(
|
||||
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
|
||||
FROM python_mirrors WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult<PythonMirrorResponse> {
|
||||
if index_url_exists(pool, &input.index_url, None).await? {
|
||||
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().simple().to_string();
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO python_mirrors
|
||||
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.index_url)
|
||||
.bind(input.trusted_host.as_deref())
|
||||
.bind(input.enabled)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(map_mirror_error)?;
|
||||
|
||||
get(pool, &id).await
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
input: ValidatedMirror,
|
||||
) -> ApiResult<PythonMirrorResponse> {
|
||||
if index_url_exists(pool, &input.index_url, Some(id)).await? {
|
||||
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
"UPDATE python_mirrors
|
||||
SET name = ?, index_url = ?, trusted_host = ?, enabled = ?,
|
||||
is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END,
|
||||
updated_at = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.index_url)
|
||||
.bind(input.trusted_host.as_deref())
|
||||
.bind(input.enabled)
|
||||
.bind(input.enabled)
|
||||
.bind(now)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(map_mirror_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
get(pool, id).await
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
|
||||
let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。
|
||||
pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
|
||||
let now = Utc::now();
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
transaction.rollback().await?;
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1")
|
||||
.bind(now)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
get(pool, id).await
|
||||
}
|
||||
|
||||
/// 执行器读取:只返回「启用且是默认」的镜像。
|
||||
pub async fn default_enabled(pool: &SqlitePool) -> ApiResult<Option<DefaultPythonMirror>> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT name, index_url, trusted_host
|
||||
FROM python_mirrors
|
||||
WHERE enabled = 1 AND is_default = 1
|
||||
LIMIT 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(
|
||||
row.map(|(name, index_url, trusted_host)| DefaultPythonMirror {
|
||||
name,
|
||||
index_url,
|
||||
trusted_host,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn index_url_exists(
|
||||
pool: &SqlitePool,
|
||||
index_url: &str,
|
||||
excluding_id: Option<&str>,
|
||||
) -> ApiResult<bool> {
|
||||
let existing: Option<(String,)> = match excluding_id {
|
||||
Some(id) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?",
|
||||
)
|
||||
.bind(index_url)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)")
|
||||
.bind(index_url)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(existing.is_some())
|
||||
}
|
||||
|
||||
fn map_mirror_error(error: sqlx::Error) -> ApiError {
|
||||
if let sqlx::Error::Database(database_error) = &error
|
||||
&& database_error.is_unique_violation()
|
||||
{
|
||||
return ApiError::mirror_conflict("该索引地址已经存在");
|
||||
}
|
||||
ApiError::from(error)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiJson, ApiResult},
|
||||
python_mirrors::{
|
||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||
repository, service,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/python/mirrors",
|
||||
get(list_python_mirrors).post(create_python_mirror),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/mirrors/{id}",
|
||||
get(get_python_mirror)
|
||||
.put(update_python_mirror)
|
||||
.delete(delete_python_mirror),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/mirrors/{id}/default",
|
||||
post(set_default_python_mirror),
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/mirrors",
|
||||
tag = "python-mirrors",
|
||||
responses(
|
||||
(status = 200, description = "Python package mirrors", body = Vec<PythonMirrorResponse>)
|
||||
)
|
||||
)]
|
||||
pub async fn list_python_mirrors(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<PythonMirrorResponse>>> {
|
||||
Ok(Json(repository::list(&state.pool).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/mirrors",
|
||||
tag = "python-mirrors",
|
||||
request_body = CreatePythonMirrorRequest,
|
||||
responses(
|
||||
(status = 201, description = "Mirror created", body = PythonMirrorResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreatePythonMirrorRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<PythonMirrorResponse>)> {
|
||||
let input = service::validate(
|
||||
&request.name,
|
||||
&request.index_url,
|
||||
request.trusted_host.as_deref(),
|
||||
request.enabled,
|
||||
)?;
|
||||
let mirror = repository::create(&state.pool, input).await?;
|
||||
Ok((StatusCode::CREATED, Json(mirror)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Mirror detail", body = PythonMirrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
Ok(Json(repository::get(&state.pool, &id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
request_body = UpdatePythonMirrorRequest,
|
||||
responses(
|
||||
(status = 200, description = "Mirror updated", body = PythonMirrorResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn update_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
ApiJson(request): ApiJson<UpdatePythonMirrorRequest>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
let input = service::validate(
|
||||
&request.name,
|
||||
&request.index_url,
|
||||
request.trusted_host.as_deref(),
|
||||
request.enabled,
|
||||
)?;
|
||||
Ok(Json(repository::update(&state.pool, &id, input).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 204, description = "Mirror deleted"),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
repository::delete(&state.pool, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/mirrors/{id}/default",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Default mirror updated", body = PythonMirrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn set_default_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
Ok(Json(repository::set_default(&state.pool, &id).await?))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use url::Url;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
|
||||
const MAX_NAME_CHARS: usize = 64;
|
||||
const MAX_URL_CHARS: usize = 2048;
|
||||
|
||||
/// 校验并规范化后的镜像输入。
|
||||
#[derive(Debug)]
|
||||
pub struct ValidatedMirror {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub fn validate(
|
||||
name: &str,
|
||||
index_url: &str,
|
||||
trusted_host: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> ApiResult<ValidatedMirror> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ApiError::validation("name", "请输入镜像名称"));
|
||||
}
|
||||
if name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(ApiError::validation(
|
||||
"name",
|
||||
format!("镜像名称不能超过 {MAX_NAME_CHARS} 个字符"),
|
||||
));
|
||||
}
|
||||
|
||||
let index_url = index_url.trim();
|
||||
if index_url.is_empty() {
|
||||
return Err(ApiError::validation("index_url", "请输入索引地址"));
|
||||
}
|
||||
if index_url.chars().count() > MAX_URL_CHARS {
|
||||
return Err(ApiError::validation("index_url", "索引地址过长"));
|
||||
}
|
||||
let parsed = Url::parse(index_url)
|
||||
.map_err(|_| ApiError::validation("index_url", "索引地址不是有效的 URL"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(ApiError::validation(
|
||||
"index_url",
|
||||
"索引地址只支持 http 或 https",
|
||||
));
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
return Err(ApiError::validation("index_url", "索引地址缺少主机名"));
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(ApiError::validation(
|
||||
"index_url",
|
||||
"索引地址不能包含用户名或密码",
|
||||
));
|
||||
}
|
||||
|
||||
let trusted_host = trusted_host
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
if value.contains("://") || value.contains('/') || value.contains(char::is_whitespace) {
|
||||
return Err(ApiError::validation(
|
||||
"trusted_host",
|
||||
"信任主机只填主机名,可带端口,例如 mirrors.aliyun.com",
|
||||
));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(ValidatedMirror {
|
||||
name: name.to_owned(),
|
||||
index_url: index_url.to_owned(),
|
||||
trusted_host,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
sync::{
|
||||
@@ -31,7 +31,7 @@ use crate::{
|
||||
model::{RunStatus, RunTrigger, TaskRunResponse},
|
||||
repository,
|
||||
},
|
||||
tasks::model::{HttpMethod, TaskAction, TaskResponse},
|
||||
tasks::model::{HttpMethod, ScriptLanguage, TaskAction, TaskResponse},
|
||||
};
|
||||
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
@@ -429,11 +429,14 @@ impl TaskExecutor {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
venv,
|
||||
language,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
let script_language =
|
||||
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
|
||||
let prepared = match prepare_script_environment(
|
||||
working_dir,
|
||||
interpreter,
|
||||
@@ -447,11 +450,68 @@ impl TaskExecutor {
|
||||
};
|
||||
let mut command = Command::new(&prepared.interpreter);
|
||||
prepared.apply(&mut command);
|
||||
|
||||
let mut notes = Vec::new();
|
||||
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 {
|
||||
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||
{
|
||||
Ok(Some(mirror)) => {
|
||||
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) => {
|
||||
tracing::warn!(
|
||||
task_id = %task.id,
|
||||
%error,
|
||||
"failed to load default python mirror"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if script_language == ScriptLanguage::Shell
|
||||
&& is_cmd_interpreter(interpreter)
|
||||
&& !script.trim_start().starts_with('/')
|
||||
{
|
||||
command.arg("/C");
|
||||
}
|
||||
command.arg(script).args(args).current_dir(working_dir);
|
||||
let startup_note = prepared
|
||||
.virtual_env
|
||||
.as_ref()
|
||||
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
|
||||
self.run_command(task, run_id, cancellation, command, startup_note)
|
||||
.await
|
||||
}
|
||||
@@ -1064,6 +1124,18 @@ async fn wait_after_termination(child: &mut Child) {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_has(env: &BTreeMap<String, String>, key: &str) -> bool {
|
||||
env.keys()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
fn is_cmd_interpreter(interpreter: &str) -> bool {
|
||||
Path::new(interpreter)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.is_some_and(|stem| stem.eq_ignore_ascii_case("cmd"))
|
||||
}
|
||||
|
||||
fn sensitive_values(action: &TaskAction) -> Vec<String> {
|
||||
match action {
|
||||
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
|
||||
|
||||
@@ -198,7 +198,7 @@ fn lock_next_runs(
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, TaskAction};
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, ScriptLanguage, TaskAction};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -215,6 +215,7 @@ mod tests {
|
||||
action: TaskAction::Script {
|
||||
working_dir: ".".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Shell),
|
||||
interpreter: "cmd.exe".to_owned(),
|
||||
script: "/C".to_owned(),
|
||||
args: vec!["exit".to_owned(), "0".to_owned()],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -8,6 +8,56 @@ use utoipa::ToSchema;
|
||||
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScriptLanguage {
|
||||
Python,
|
||||
Shell,
|
||||
Node,
|
||||
Lua,
|
||||
}
|
||||
|
||||
impl ScriptLanguage {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Python => "python",
|
||||
Self::Shell => "shell",
|
||||
Self::Node => "node",
|
||||
Self::Lua => "lua",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn default_interpreter(self) -> &'static str {
|
||||
match self {
|
||||
Self::Python => "python.exe",
|
||||
Self::Shell => "cmd.exe",
|
||||
Self::Node => "node.exe",
|
||||
Self::Lua => "lua.exe",
|
||||
}
|
||||
}
|
||||
|
||||
/// 旧任务没有 `language` 字段时,从解释器文件名推断。
|
||||
pub fn from_interpreter(interpreter: &str) -> Self {
|
||||
let stem = Path::new(interpreter)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or(interpreter)
|
||||
.to_ascii_lowercase();
|
||||
if matches!(stem.as_str(), "node" | "nodejs") {
|
||||
Self::Node
|
||||
} else if stem.starts_with("lua") {
|
||||
Self::Lua
|
||||
} else if matches!(
|
||||
stem.as_str(),
|
||||
"cmd" | "powershell" | "pwsh" | "bash" | "sh" | "zsh"
|
||||
) {
|
||||
Self::Shell
|
||||
} else {
|
||||
Self::Python
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskActionType {
|
||||
@@ -57,6 +107,8 @@ pub enum TaskAction {
|
||||
working_dir: String,
|
||||
#[serde(default)]
|
||||
venv: Option<String>,
|
||||
#[serde(default)]
|
||||
language: Option<ScriptLanguage>,
|
||||
interpreter: String,
|
||||
script: String,
|
||||
#[serde(default)]
|
||||
@@ -93,6 +145,18 @@ impl TaskAction {
|
||||
Self::Process { .. } => TaskActionType::Process,
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本语言;旧任务未存 language 时从解释器推断。
|
||||
pub fn script_language(&self) -> Option<ScriptLanguage> {
|
||||
match self {
|
||||
Self::Script {
|
||||
language,
|
||||
interpreter,
|
||||
..
|
||||
} => Some(language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
|
||||
@@ -28,6 +28,7 @@ pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiRes
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
normalize_action(&mut request.action);
|
||||
|
||||
validate_task_id(&request.id)?;
|
||||
validate_common(
|
||||
@@ -57,6 +58,7 @@ pub async fn update(
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
normalize_action(&mut request.action);
|
||||
|
||||
validate_common(
|
||||
&request.name,
|
||||
@@ -180,11 +182,32 @@ fn validate_common(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_action(action: &mut TaskAction) {
|
||||
if let TaskAction::Script {
|
||||
venv,
|
||||
language,
|
||||
interpreter,
|
||||
..
|
||||
} = action
|
||||
{
|
||||
let resolved =
|
||||
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter.trim()));
|
||||
*language = Some(resolved);
|
||||
if interpreter.trim().is_empty() {
|
||||
*interpreter = resolved.default_interpreter().to_owned();
|
||||
}
|
||||
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
|
||||
*venv = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
||||
match action {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
venv,
|
||||
language: _,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
@@ -332,6 +355,7 @@ mod tests {
|
||||
TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Python),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: "main.py".to_owned(),
|
||||
args: vec!["--mode".to_owned(), "full".to_owned()],
|
||||
@@ -372,6 +396,7 @@ mod tests {
|
||||
let action = TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Python),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: " ".to_owned(),
|
||||
args: Vec::new(),
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (axum::Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn auth_token(app: &axum::Router) -> String {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
|
||||
json["token"]
|
||||
.as_str()
|
||||
.expect("token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn send_json(
|
||||
app: &axum::Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = if let Some(body) = body {
|
||||
builder
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body).expect("body should serialize"),
|
||||
))
|
||||
.expect("request should build")
|
||||
} else {
|
||||
builder.body(Body::empty()).expect("request should build")
|
||||
};
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_python_mirrors_with_one_default() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let mirrors = body.as_array().expect("mirrors should be an array");
|
||||
assert_eq!(mirrors.len(), 4);
|
||||
let defaults: Vec<&Value> = mirrors
|
||||
.iter()
|
||||
.filter(|mirror| mirror["is_default"] == true)
|
||||
.collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0]["id"], "tsinghua");
|
||||
assert!(mirrors.iter().all(|mirror| mirror["enabled"] == true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirrors_crud_and_default_switch() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, created) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "内网镜像",
|
||||
"index_url": "https://pypi.example.local/simple",
|
||||
"trusted_host": "pypi.example.local",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let id = created["id"]
|
||||
.as_str()
|
||||
.expect("created mirror should have an id")
|
||||
.to_owned();
|
||||
assert_eq!(created["is_default"], false);
|
||||
assert_eq!(created["trusted_host"], "pypi.example.local");
|
||||
|
||||
let (status, mirror) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
&format!("/api/v1/python/mirrors/{id}/default"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(mirror["is_default"], true);
|
||||
|
||||
let (status, list) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let defaults = list
|
||||
.as_array()
|
||||
.expect("mirrors should be an array")
|
||||
.iter()
|
||||
.filter(|mirror| mirror["is_default"] == true)
|
||||
.count();
|
||||
assert_eq!(defaults, 1);
|
||||
|
||||
let (status, updated) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "内网镜像(停用)",
|
||||
"index_url": "https://pypi.example.local/simple",
|
||||
"trusted_host": null,
|
||||
"enabled": false
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(updated["name"], "内网镜像(停用)");
|
||||
assert_eq!(updated["enabled"], false);
|
||||
// 停用默认镜像时连带清掉默认标记,避免执行器继续引用一个停用的源
|
||||
assert_eq!(updated["is_default"], false);
|
||||
|
||||
let (status, _) = send_json(
|
||||
&app,
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::GET,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["code"], "MIRROR_NOT_FOUND");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_mirror_url_and_duplicate_index() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "坏地址",
|
||||
"index_url": "ftp://example.com/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "index_url");
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "重复镜像",
|
||||
"index_url": "https://pypi.org/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(body["code"], "MIRROR_CONFLICT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirror_mutations_require_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
None,
|
||||
Some(json!({
|
||||
"name": "未授权",
|
||||
"index_url": "https://example.com/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
@@ -47,9 +47,22 @@ async fn fetch_token(app: &Router) -> String {
|
||||
}
|
||||
|
||||
async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
||||
post_json(app, "/api/v1/python/venv", token, payload).await
|
||||
}
|
||||
|
||||
async fn post_create(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
||||
post_json(app, "/api/v1/python/venv/create", token, payload).await
|
||||
}
|
||||
|
||||
async fn post_json(
|
||||
app: &Router,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
payload: Value,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/python/venv")
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
@@ -68,7 +81,7 @@ async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (Statu
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("probe body should be readable")
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
|
||||
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
||||
@@ -179,3 +192,77 @@ async fn venv_probe_reports_missing_working_directory() {
|
||||
.contains("工作目录")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_probe_reports_project_dependency_files() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = fetch_token(&app).await;
|
||||
let project = temp_dir.path().join("deps-project");
|
||||
fs::create_dir_all(&project).expect("project directory should be created");
|
||||
fs::write(
|
||||
project.join("pyproject.toml"),
|
||||
"[project]\nname = \"demo\"\n",
|
||||
)
|
||||
.expect("pyproject.toml should be written");
|
||||
fs::write(project.join("requirements.txt"), "").expect("requirements.txt should be written");
|
||||
|
||||
let (status, body) = post_probe(
|
||||
&app,
|
||||
Some(&token),
|
||||
json!({
|
||||
"working_dir": project.to_str().expect("project path should be UTF-8"),
|
||||
"interpreter": "python.exe"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["status"], "missing");
|
||||
assert_eq!(body["has_pyproject"], true);
|
||||
assert_eq!(body["has_requirements"], true);
|
||||
assert_eq!(body["requirements_files"], json!(["requirements.txt"]));
|
||||
assert_eq!(body["can_create"], body["uv_available"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_create_rejects_project_without_dependency_files() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = fetch_token(&app).await;
|
||||
let project = temp_dir.path().join("empty-project");
|
||||
fs::create_dir_all(&project).expect("project directory should be created");
|
||||
|
||||
let (status, body) = post_create(
|
||||
&app,
|
||||
Some(&token),
|
||||
json!({
|
||||
"working_dir": project.to_str().expect("project path should be UTF-8")
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "VENV_CREATE_FAILED");
|
||||
assert!(
|
||||
body["message"]
|
||||
.as_str()
|
||||
.expect("message should be a string")
|
||||
.contains("pyproject.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_create_requires_management_token() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = post_create(
|
||||
&app,
|
||||
None,
|
||||
json!({
|
||||
"working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8")
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
|
||||
+53
-1
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use std::{fs, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
@@ -201,6 +201,58 @@ async fn wait_for_run(router: &Router, run_id: i64) -> Value {
|
||||
panic!("run {run_id} did not reach a terminal state");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_language_runs_batch_file_with_cmd_c() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
fs::write(
|
||||
app._temp_dir.path().join("hello.bat"),
|
||||
"@echo off\r\necho shell-language-ok\r\n",
|
||||
)
|
||||
.expect("batch file should be written");
|
||||
|
||||
let mut task = script_task(
|
||||
"shell_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"hello.bat",
|
||||
vec![],
|
||||
"0 0 2 * * *",
|
||||
10,
|
||||
);
|
||||
task["action"]["language"] = json!("shell");
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/shell_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||
let run = response_json(run_response).await;
|
||||
let run_id = run["id"].as_i64().expect("run id should be an integer");
|
||||
|
||||
let completed = wait_for_run(&app.router, run_id).await;
|
||||
assert_eq!(completed["status"], "succeeded");
|
||||
let log_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let log = response_json(log_response).await;
|
||||
assert!(
|
||||
log["content"]
|
||||
.as_str()
|
||||
.expect("log content should be a string")
|
||||
.contains("shell-language-ok")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_script_run_captures_exit_code_and_logs() {
|
||||
let app = test_app().await;
|
||||
|
||||
@@ -113,6 +113,20 @@ fn script_task(id: &str, name: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_interpreter_defaults_to_python() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let mut payload = script_task("default_interpreter", "Default interpreter");
|
||||
payload["action"]["interpreter"] = json!("");
|
||||
|
||||
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(payload)).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let created = response_json(response).await;
|
||||
assert_eq!(created["action"]["interpreter"], "python.exe");
|
||||
assert_eq!(created["action"]["language"], "python");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_crud_flow_works_end_to_end() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
+453
-1
@@ -89,6 +89,260 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/python/mirrors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"python-mirrors"
|
||||
],
|
||||
"operationId": "list_python_mirrors",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Python package mirrors",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PythonMirrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"python-mirrors"
|
||||
],
|
||||
"operationId": "create_python_mirror",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePythonMirrorRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Mirror created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PythonMirrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Mirror URL already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/python/mirrors/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"python-mirrors"
|
||||
],
|
||||
"operationId": "get_python_mirror",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Mirror identifier",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Mirror detail",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PythonMirrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Mirror not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"python-mirrors"
|
||||
],
|
||||
"operationId": "update_python_mirror",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Mirror identifier",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdatePythonMirrorRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Mirror updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PythonMirrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Mirror not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Mirror URL already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"python-mirrors"
|
||||
],
|
||||
"operationId": "delete_python_mirror",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Mirror identifier",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Mirror deleted"
|
||||
},
|
||||
"404": {
|
||||
"description": "Mirror not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/python/mirrors/{id}/default": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"python-mirrors"
|
||||
],
|
||||
"operationId": "set_default_python_mirror",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Mirror identifier",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Default mirror updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PythonMirrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Mirror not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/python/venv": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -129,6 +383,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/python/venv/create": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"system"
|
||||
],
|
||||
"operationId": "create_python_venv",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePythonVenvRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Created or reused project virtual environment",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VenvProbeResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Project cannot create a virtual environment",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/runs/{run_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -876,6 +1170,44 @@
|
||||
"skip"
|
||||
]
|
||||
},
|
||||
"CreatePythonMirrorRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"index_url"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"index_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"trusted_host": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CreatePythonVenvRequest": {
|
||||
"type": "object",
|
||||
"description": "用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。",
|
||||
"required": [
|
||||
"working_dir"
|
||||
],
|
||||
"properties": {
|
||||
"working_dir": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CreateTaskRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1095,6 +1427,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PythonMirrorResponse": {
|
||||
"type": "object",
|
||||
"description": "一个 Python 包索引镜像(pip / uv)。",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"index_url",
|
||||
"enabled",
|
||||
"is_default",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
],
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"index_url": {
|
||||
"type": "string",
|
||||
"description": "pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL`"
|
||||
},
|
||||
"is_default": {
|
||||
"type": "boolean",
|
||||
"description": "当前默认镜像;同一时间最多一个"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"trusted_host": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "pip 的 `--trusted-host`;https 源通常为空"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RetryPolicy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1151,6 +1530,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScriptLanguage": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"python",
|
||||
"shell",
|
||||
"node",
|
||||
"lua"
|
||||
]
|
||||
},
|
||||
"SessionResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1192,6 +1580,16 @@
|
||||
"interpreter": {
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ScriptLanguage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"script": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1539,6 +1937,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdatePythonMirrorRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"index_url",
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"index_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"trusted_host": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UpdateTaskRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1607,9 +2031,18 @@
|
||||
"required": [
|
||||
"status",
|
||||
"interpreter_kind",
|
||||
"message"
|
||||
"message",
|
||||
"uv_available",
|
||||
"has_pyproject",
|
||||
"has_requirements",
|
||||
"requirements_files",
|
||||
"can_create"
|
||||
],
|
||||
"properties": {
|
||||
"can_create": {
|
||||
"type": "boolean",
|
||||
"description": "没有虚拟环境时,是否具备用 uv 创建的条件"
|
||||
},
|
||||
"display_path": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -1617,12 +2050,27 @@
|
||||
],
|
||||
"description": "供界面展示的解释器路径;位于工作目录内时使用相对路径"
|
||||
},
|
||||
"has_pyproject": {
|
||||
"type": "boolean",
|
||||
"description": "工作目录根部是否存在 pyproject.toml"
|
||||
},
|
||||
"has_requirements": {
|
||||
"type": "boolean",
|
||||
"description": "工作目录根部是否存在 requirements*.txt"
|
||||
},
|
||||
"interpreter_kind": {
|
||||
"$ref": "#/components/schemas/InterpreterKind"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requirements_files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "探测到的 requirements 文件名(仅根目录)"
|
||||
},
|
||||
"source": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -1636,6 +2084,10 @@
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/VenvProbeStatus"
|
||||
},
|
||||
"uv_available": {
|
||||
"type": "boolean",
|
||||
"description": "本机 PATH 中是否能找到 uv"
|
||||
},
|
||||
"venv_path": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -174,6 +174,63 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
await expect(page.getByText('还没有任务')).toBeVisible()
|
||||
})
|
||||
|
||||
test('switches script language and adapts the form fields', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '+ 新建任务' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('button', { name: 'Python', exact: true }).click()
|
||||
await page.getByRole('option', { name: 'Shell (cmd)' }).click()
|
||||
await expect(dialog.getByText(/虚拟环境目录/)).toHaveCount(0)
|
||||
await expect(dialog.getByLabel('批处理文件或相对路径')).toHaveValue('run.bat')
|
||||
await expect(dialog.locator('.cli-line-cmd')).toContainText(
|
||||
'cmd.exe /C run.bat',
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Shell (cmd)' }).click()
|
||||
await page.getByRole('option', { name: 'Node.js' }).click()
|
||||
await expect(dialog.getByLabel('JavaScript 文件或相对路径')).toHaveValue(
|
||||
'index.js',
|
||||
)
|
||||
await expect(dialog.locator('.cli-line-cmd')).toContainText(
|
||||
'node.exe index.js',
|
||||
)
|
||||
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-script-language.png',
|
||||
fullPage: true,
|
||||
})
|
||||
await dialog.getByRole('button', { name: '取消' }).click()
|
||||
})
|
||||
|
||||
test('avoids a transient invalid venv when switching directories', async ({
|
||||
page,
|
||||
}) => {
|
||||
const venvDir = createVenvFixture()
|
||||
const emptyProject = join(venvDir, '..', 'empty-project')
|
||||
mkdirSync(emptyProject, { recursive: true })
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '+ 新建任务' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
|
||||
const workingDir = dialog.getByLabel(/项目工作目录/)
|
||||
const bar = dialog.locator('.env-detector-bar')
|
||||
|
||||
await workingDir.fill(venvDir)
|
||||
await expect(bar).toContainText('已关联虚拟环境')
|
||||
|
||||
await workingDir.fill(emptyProject)
|
||||
const states = new Set<string>()
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
states.add(await bar.innerText())
|
||||
await page.waitForTimeout(80)
|
||||
}
|
||||
|
||||
expect([...states].some((text) => text.includes('缺少'))).toBe(false)
|
||||
await expect(bar).toContainText('未检测到虚拟环境')
|
||||
})
|
||||
|
||||
test('keeps the dashboard usable on a narrow viewport', async ({
|
||||
page,
|
||||
request,
|
||||
@@ -246,7 +303,9 @@ test('renders without external network access', async ({ page }) => {
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: 'TaskPilot' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '任务调度' }),
|
||||
).toBeVisible()
|
||||
|
||||
const state = await page.evaluate(async () => {
|
||||
// 字体二进制不入库;未生成时应回退系统字体,而不是报错。
|
||||
@@ -331,3 +390,47 @@ test('keeps a long run log scrollable without rendering every line', async ({
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('manages python package mirrors', async ({ page, request }) => {
|
||||
const headers = await authHeaders(request)
|
||||
|
||||
// 失败重跑时先清理上一次留下的测试镜像,并把默认源还原。
|
||||
const existing = await request.get('/api/v1/python/mirrors')
|
||||
expect(existing.ok()).toBeTruthy()
|
||||
const existingMirrors = (await existing.json()) as Array<{
|
||||
id: string
|
||||
index_url: string
|
||||
}>
|
||||
for (const mirror of existingMirrors) {
|
||||
if (mirror.index_url.includes('pypi.e2e.example')) {
|
||||
await request.delete(`/api/v1/python/mirrors/${mirror.id}`, { headers })
|
||||
}
|
||||
}
|
||||
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
|
||||
|
||||
await page.goto('/mirrors')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Python 镜像' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: /清华 TUNA/ })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '+ 新增镜像' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新增 Python 镜像' })
|
||||
await dialog.getByLabel('名称').fill('E2E 镜像')
|
||||
await dialog.getByLabel('索引地址').fill('https://pypi.e2e.example/simple')
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
|
||||
const row = page.getByRole('row', { name: /E2E 镜像/ })
|
||||
await expect(row).toBeVisible()
|
||||
await row.getByRole('button', { name: '设为默认' }).click()
|
||||
await expect(row.getByText('默认', { exact: true })).toBeVisible()
|
||||
|
||||
await row.getByRole('button', { name: '删除' }).click()
|
||||
await page
|
||||
.getByRole('dialog', { name: /删除镜像/ })
|
||||
.getByRole('button', { name: '确认删除' })
|
||||
.click()
|
||||
await expect(row).toHaveCount(0)
|
||||
|
||||
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
|
||||
})
|
||||
|
||||
+217
-12
@@ -4,13 +4,68 @@
|
||||
* 颜色与间距只引用 --su-* 令牌,不再另建一套主题变量。
|
||||
*/
|
||||
|
||||
.app-shell {
|
||||
/* ---------- 应用外壳:左侧导航 + 内容 ---------- */
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
width: min(1040px, calc(100% - 48px));
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px;
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.app-sider-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-sider-foot {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--su-border);
|
||||
padding: 16px 8px 0;
|
||||
}
|
||||
|
||||
.app-sider-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
padding: 48px 0;
|
||||
padding: 32px 32px 48px;
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */
|
||||
.page-stack > *,
|
||||
.task-card,
|
||||
.task-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---------- 页头 ---------- */
|
||||
@@ -237,9 +292,21 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 虚拟环境目录通常是一条完整路径,给它更多宽度;解释器名很短 */
|
||||
.venv-grid {
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
|
||||
.script-language-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.script-language-field {
|
||||
min-width: 0;
|
||||
flex: 0 0 260px;
|
||||
}
|
||||
|
||||
.script-language-hint {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@@ -389,6 +456,17 @@
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.env-status-badge.danger {
|
||||
color: var(--su-color-danger);
|
||||
}
|
||||
|
||||
.env-detector-message {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.env-status-badge code {
|
||||
overflow: hidden;
|
||||
border-radius: var(--su-radius-xs);
|
||||
@@ -876,8 +954,119 @@
|
||||
background-color: var(--su-gray-6);
|
||||
}
|
||||
|
||||
/* ---------- Python 镜像 ---------- */
|
||||
|
||||
.mirror-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mirror-summary-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mirror-summary-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mirror-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mirror-table {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mirror-url {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mirror-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mirror-enabled-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
/* Layout 在窄屏把侧栏堆到上方;它的内部 main/inner 还保留内容最小宽度,
|
||||
表格这类宽内容会把整页撑开,这里强制回到容器宽度。 */
|
||||
.app-root > div,
|
||||
.app-root > div > div,
|
||||
.app-root main,
|
||||
.app-root main > div {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
position: static;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
overflow: visible;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.app-sider-brand {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-sider-tagline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
flex: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Menu 内部是纵向 ul,窄屏改横向 */
|
||||
.app-nav > ul {
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-nav > ul > li {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.app-nav > ul button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.app-sider-foot {
|
||||
margin-top: 0;
|
||||
border-top: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.run-log-body {
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.5fr);
|
||||
@@ -885,10 +1074,17 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
width: min(100% - 24px, 1040px);
|
||||
gap: 20px;
|
||||
padding: 24px 0 40px;
|
||||
.page-container {
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.app-sider-foot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -920,8 +1116,17 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.venv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.script-language-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.script-language-field {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.script-language-hint {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.cron-run-grid {
|
||||
|
||||
@@ -83,6 +83,20 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
{ task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' },
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/python/mirrors')) {
|
||||
return jsonResponse([
|
||||
{
|
||||
id: 'tsinghua',
|
||||
name: '清华 TUNA',
|
||||
index_url: 'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
trusted_host: null,
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
created_at: '2026-10-18T10:00:00Z',
|
||||
updated_at: '2026-10-18T10:00:00Z',
|
||||
},
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/cron/preview')) {
|
||||
return jsonResponse({
|
||||
timezone: 'Asia/Shanghai',
|
||||
@@ -97,6 +111,7 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
working_dir: string
|
||||
}
|
||||
const linked = probe.working_dir.toLowerCase().includes('sync')
|
||||
const creatable = probe.working_dir.toLowerCase().includes('uvproject')
|
||||
return jsonResponse({
|
||||
status: linked ? 'linked' : 'missing',
|
||||
interpreter_kind: 'python',
|
||||
@@ -105,7 +120,14 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
source: linked ? 'working_directory' : null,
|
||||
message: linked
|
||||
? '已自动关联工作目录中的虚拟环境'
|
||||
: '未检测到虚拟环境,将直接使用系统解释器',
|
||||
: creatable
|
||||
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
|
||||
: '未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv',
|
||||
uv_available: creatable,
|
||||
has_pyproject: creatable,
|
||||
has_requirements: false,
|
||||
requirements_files: [],
|
||||
can_create: creatable,
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/system/directories')) {
|
||||
@@ -167,6 +189,23 @@ describe('TaskDashboard', () => {
|
||||
expect(await screen.findByText('在线')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to python mirrors from the sidebar', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Python 镜像' }),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Python 镜像' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByRole('row', { name: /清华 TUNA/ }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the next run time reported by the schedule endpoint', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
renderApp()
|
||||
@@ -233,7 +272,7 @@ describe('TaskDashboard', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the system interpreter when no venv is detected', async () => {
|
||||
it('shows an error when no venv or dependency files exist', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
@@ -245,11 +284,29 @@ describe('TaskDashboard', () => {
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/使用系统解释器:/, {}, { timeout: 3000 }),
|
||||
await screen.findByText(/未检测到虚拟环境;本机未安装 uv/, {}, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers uv environment creation when pyproject.toml exists', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.type(
|
||||
screen.getByLabelText(/项目工作目录/),
|
||||
'D:\\Projects\\uvproject',
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
|
||||
await screen.findByRole('button', { name: '用 uv 创建环境' }, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('pyproject.toml')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('browses folders in the page and fills the working directory', async () => {
|
||||
|
||||
+20
-7
@@ -1,5 +1,7 @@
|
||||
import { Button, Result } from '@stash/ui'
|
||||
import { Button, Layout, Result } from '@stash/ui'
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom'
|
||||
import { AppSidebar } from './components/AppSidebar'
|
||||
import { PythonMirrorsPage } from './pages/PythonMirrorsPage'
|
||||
import { TaskDashboard } from './pages/TaskDashboard'
|
||||
import './App.css'
|
||||
|
||||
@@ -7,23 +9,34 @@ function NotFoundPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="page-stack">
|
||||
<Result
|
||||
status="info"
|
||||
title="页面不存在"
|
||||
description="该地址尚未配置到 TaskPilot 路由。"
|
||||
actions={<Button onClick={() => navigate('/')}>返回任务列表</Button>}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskDashboard />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
<Layout
|
||||
className="app-root"
|
||||
sider={<AppSidebar />}
|
||||
siderWidth={224}
|
||||
maxWidth="full"
|
||||
contentPadding="none"
|
||||
>
|
||||
<div className="page-container">
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskDashboard />} />
|
||||
<Route path="/mirrors" element={<PythonMirrorsPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
|
||||
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
|
||||
export type VenvProbeRequest = components['schemas']['VenvProbeRequest']
|
||||
export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
|
||||
export type CreatePythonVenvRequest =
|
||||
components['schemas']['CreatePythonVenvRequest']
|
||||
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
|
||||
export type VenvSource = components['schemas']['VenvSource']
|
||||
export type InterpreterKind = components['schemas']['InterpreterKind']
|
||||
@@ -26,6 +28,11 @@ export type DirectoryListingResponse =
|
||||
components['schemas']['DirectoryListingResponse']
|
||||
export type TaskStats = components['schemas']['TaskStatsResponse']
|
||||
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
|
||||
export type PythonMirror = components['schemas']['PythonMirrorResponse']
|
||||
export type CreatePythonMirrorRequest =
|
||||
components['schemas']['CreatePythonMirrorRequest']
|
||||
export type UpdatePythonMirrorRequest =
|
||||
components['schemas']['UpdatePythonMirrorRequest']
|
||||
|
||||
export interface TaskFilters {
|
||||
q?: string
|
||||
@@ -280,6 +287,16 @@ export function probePythonVenv(
|
||||
})
|
||||
}
|
||||
|
||||
/** 用 uv 为项目创建/同步 .venv;优先复用已有环境。 */
|
||||
export function createPythonVenv(
|
||||
request: CreatePythonVenvRequest,
|
||||
): Promise<VenvProbeResponse> {
|
||||
return requestJson<VenvProbeResponse>('/api/v1/python/venv/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
||||
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
||||
@@ -303,3 +320,42 @@ export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
|
||||
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
|
||||
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
|
||||
}
|
||||
|
||||
/** Python 包镜像(默认镜像会在脚本任务运行时注入 pip / uv 环境变量)。 */
|
||||
export function listPythonMirrors(
|
||||
signal?: AbortSignal,
|
||||
): Promise<PythonMirror[]> {
|
||||
return requestJson<PythonMirror[]>('/api/v1/python/mirrors', { signal })
|
||||
}
|
||||
|
||||
export function createPythonMirror(
|
||||
request: CreatePythonMirrorRequest,
|
||||
): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>('/api/v1/python/mirrors', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePythonMirror(
|
||||
id: string,
|
||||
request: UpdatePythonMirrorRequest,
|
||||
): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>(`/api/v1/python/mirrors/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePythonMirror(id: string): Promise<void> {
|
||||
return requestJson<void>(`/api/v1/python/mirrors/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
|
||||
return requestJson<PythonMirror>(
|
||||
`/api/v1/python/mirrors/${id}/default`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/mirrors": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_python_mirrors"];
|
||||
put?: never;
|
||||
post: operations["create_python_mirror"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/mirrors/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["get_python_mirror"];
|
||||
put: operations["update_python_mirror"];
|
||||
post?: never;
|
||||
delete: operations["delete_python_mirror"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/mirrors/{id}/default": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["set_default_python_mirror"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/venv": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -68,6 +116,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/python/venv/create": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["create_python_venv"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/runs/{run_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -298,6 +362,16 @@ export interface components {
|
||||
schemas: {
|
||||
/** @enum {string} */
|
||||
ConcurrencyPolicy: "skip";
|
||||
CreatePythonMirrorRequest: {
|
||||
enabled?: boolean;
|
||||
index_url: string;
|
||||
name: string;
|
||||
trusted_host?: string | null;
|
||||
};
|
||||
/** @description 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。 */
|
||||
CreatePythonVenvRequest: {
|
||||
working_dir: string;
|
||||
};
|
||||
CreateTaskRequest: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||
@@ -360,6 +434,22 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
next_offset: number;
|
||||
};
|
||||
/** @description 一个 Python 包索引镜像(pip / uv)。 */
|
||||
PythonMirrorResponse: {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
/** @description pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL` */
|
||||
index_url: string;
|
||||
/** @description 当前默认镜像;同一时间最多一个 */
|
||||
is_default: boolean;
|
||||
name: string;
|
||||
/** @description pip 的 `--trusted-host`;https 源通常为空 */
|
||||
trusted_host?: string | null;
|
||||
/** Format: date-time */
|
||||
updated_at: string;
|
||||
};
|
||||
RetryPolicy: {
|
||||
/** Format: int64 */
|
||||
delay_secs?: number;
|
||||
@@ -379,6 +469,8 @@ export interface components {
|
||||
next_run_at?: string | null;
|
||||
task_id: string;
|
||||
};
|
||||
/** @enum {string} */
|
||||
ScriptLanguage: "python" | "shell" | "node" | "lua";
|
||||
SessionResponse: {
|
||||
/** @description Random per-process token required for mutating browser requests. */
|
||||
token: string;
|
||||
@@ -389,6 +481,7 @@ export interface components {
|
||||
[key: string]: string;
|
||||
};
|
||||
interpreter: string;
|
||||
language?: components["schemas"]["ScriptLanguage"] | null;
|
||||
script: string;
|
||||
/** @enum {string} */
|
||||
type: "script";
|
||||
@@ -501,6 +594,12 @@ export interface components {
|
||||
*/
|
||||
total: number;
|
||||
};
|
||||
UpdatePythonMirrorRequest: {
|
||||
enabled: boolean;
|
||||
index_url: string;
|
||||
name: string;
|
||||
trusted_host?: string | null;
|
||||
};
|
||||
UpdateTaskRequest: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||
@@ -522,12 +621,22 @@ export interface components {
|
||||
working_dir: string;
|
||||
};
|
||||
VenvProbeResponse: {
|
||||
/** @description 没有虚拟环境时,是否具备用 uv 创建的条件 */
|
||||
can_create: boolean;
|
||||
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
|
||||
display_path?: string | null;
|
||||
/** @description 工作目录根部是否存在 pyproject.toml */
|
||||
has_pyproject: boolean;
|
||||
/** @description 工作目录根部是否存在 requirements*.txt */
|
||||
has_requirements: boolean;
|
||||
interpreter_kind: components["schemas"]["InterpreterKind"];
|
||||
message: string;
|
||||
/** @description 探测到的 requirements 文件名(仅根目录) */
|
||||
requirements_files: string[];
|
||||
source?: components["schemas"]["VenvSource"] | null;
|
||||
status: components["schemas"]["VenvProbeStatus"];
|
||||
/** @description 本机 PATH 中是否能找到 uv */
|
||||
uv_available: boolean;
|
||||
/** @description 虚拟环境根目录的绝对路径 */
|
||||
venv_path?: string | null;
|
||||
};
|
||||
@@ -620,6 +729,216 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_python_mirrors: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Python package mirrors */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreatePythonMirrorRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Mirror created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror URL already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Mirror identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Mirror detail */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Mirror identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdatePythonMirrorRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Mirror updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror URL already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Mirror identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Mirror deleted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Mirror not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
set_default_python_mirror: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Mirror identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Default mirror updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PythonMirrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Mirror not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
probe_python_venv: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -653,6 +972,39 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
create_python_venv: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreatePythonVenvRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created or reused project virtual environment */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["VenvProbeResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Project cannot create a virtual environment */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_run: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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,214 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
Text,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import type { PythonMirror } from '../api/client'
|
||||
|
||||
const mirrorSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入镜像名称')
|
||||
.max(64, '名称不能超过 64 个字符'),
|
||||
index_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入索引地址')
|
||||
.refine((value) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
Boolean(url.hostname) &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, '请输入 http(s) 开头的有效索引地址'),
|
||||
trusted_host: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
!value || (!value.includes('://') && !value.includes('/') && !/\s/.test(value)),
|
||||
'只填主机名,可带端口,例如 mirrors.aliyun.com',
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export type PythonMirrorFormValues = z.infer<typeof mirrorSchema>
|
||||
|
||||
function emptyValues(): PythonMirrorFormValues {
|
||||
return { name: '', index_url: '', trusted_host: '', enabled: true }
|
||||
}
|
||||
|
||||
function toValues(mirror: PythonMirror): PythonMirrorFormValues {
|
||||
return {
|
||||
name: mirror.name,
|
||||
index_url: mirror.index_url,
|
||||
trusted_host: mirror.trusted_host ?? '',
|
||||
enabled: mirror.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
interface PythonMirrorModalProps {
|
||||
open: boolean
|
||||
mirror: PythonMirror | null
|
||||
saving: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (values: PythonMirrorFormValues) => Promise<void>
|
||||
}
|
||||
|
||||
export function PythonMirrorModal({
|
||||
open,
|
||||
mirror,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: PythonMirrorModalProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<PythonMirrorFormValues>({
|
||||
resolver: zodResolver(mirrorSchema),
|
||||
defaultValues: emptyValues(),
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
reset(mirror ? toValues(mirror) : emptyValues())
|
||||
setSubmitError(null)
|
||||
}, [mirror, open, reset])
|
||||
|
||||
const requestClose = () => {
|
||||
if (!saving) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : '保存镜像失败')
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={requestClose}
|
||||
title={mirror ? '编辑 Python 镜像' : '新增 Python 镜像'}
|
||||
size="md"
|
||||
closeOnOverlay={!saving}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={requestClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="python-mirror-form"
|
||||
loading={saving}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="python-mirror-form"
|
||||
className="mirror-form"
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
>
|
||||
{submitError ? (
|
||||
<Notice tone="error" title="保存失败">
|
||||
{submitError}
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<FormField label="名称" htmlFor="mirror-name" error={errors.name?.message}>
|
||||
<Input
|
||||
id="mirror-name"
|
||||
{...register('name')}
|
||||
placeholder="例如:清华 TUNA"
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="索引地址"
|
||||
htmlFor="mirror-url"
|
||||
hint="pip 的 index-url,例如 https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
error={errors.index_url?.message}
|
||||
>
|
||||
<Input
|
||||
id="mirror-url"
|
||||
{...register('index_url')}
|
||||
className="mono-input"
|
||||
placeholder="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.index_url)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="信任主机(可选)"
|
||||
htmlFor="mirror-trusted-host"
|
||||
hint="只有 http 源需要;https 源留空。会注入 PIP_TRUSTED_HOST"
|
||||
error={errors.trusted_host?.message}
|
||||
>
|
||||
<Input
|
||||
id="mirror-trusted-host"
|
||||
{...register('trusted_host')}
|
||||
className="mono-input"
|
||||
placeholder="mirrors.aliyun.com"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(errors.trusted_host)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
label={
|
||||
<span className="mirror-enabled-copy">
|
||||
<Text strong size="sm">
|
||||
启用此镜像
|
||||
</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
停用后不会参与默认镜像选择
|
||||
</Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
TextButton,
|
||||
toast,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
} from 'react-hook-form'
|
||||
import {
|
||||
ApiRequestError,
|
||||
createPythonVenv,
|
||||
previewCron,
|
||||
probePythonVenv,
|
||||
type Task,
|
||||
@@ -34,9 +41,11 @@ import { DirectoryPickerDialog } from './DirectoryPickerDialog'
|
||||
import {
|
||||
createEmptyTaskFormValues,
|
||||
mapServerFieldToFormField,
|
||||
SCRIPT_LANGUAGE_META,
|
||||
taskFormSchema,
|
||||
taskToFormValues,
|
||||
type KeyValueFormValue,
|
||||
type ScriptLanguage,
|
||||
type TaskFormValues,
|
||||
} from '../lib/taskForm'
|
||||
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
||||
@@ -50,11 +59,17 @@ interface TaskEditorModalProps {
|
||||
}
|
||||
|
||||
const ACTION_TYPE_OPTIONS = [
|
||||
{ value: 'script', label: '执行脚本 (Python / Shell / Node / Lua)' },
|
||||
{ value: 'script', label: '执行脚本' },
|
||||
{ value: 'http', label: '发起 HTTP 请求' },
|
||||
{ value: 'process', label: '管理常驻/独立二进制' },
|
||||
{ value: 'process', label: '运行二进制程序' },
|
||||
]
|
||||
|
||||
const SCRIPT_LANGUAGE_OPTIONS = (
|
||||
Object.entries(SCRIPT_LANGUAGE_META) as Array<
|
||||
[ScriptLanguage, (typeof SCRIPT_LANGUAGE_META)[ScriptLanguage]]
|
||||
>
|
||||
).map(([value, meta]) => ({ value, label: meta.label }))
|
||||
|
||||
const HTTP_METHOD_OPTIONS = [
|
||||
{ value: 'GET', label: 'GET' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
@@ -81,6 +96,7 @@ export function TaskEditorModal({
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
||||
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
||||
const autoVenvRef = useRef('')
|
||||
@@ -106,6 +122,9 @@ export function TaskEditorModal({
|
||||
const cronValue = watch('cron')
|
||||
const timezoneValue = watch('timezone')
|
||||
const scriptPreview = watch('script')
|
||||
const scriptLanguage = scriptPreview.language
|
||||
const scriptMeta = SCRIPT_LANGUAGE_META[scriptLanguage]
|
||||
const isPythonScript = scriptLanguage === 'python'
|
||||
// 当前只开放 Asia/Shanghai;编辑旧任务时把它已有的时区也保留成一个选项,
|
||||
// 避免下拉显示成空值。
|
||||
const timezoneOptions = Array.from(
|
||||
@@ -122,6 +141,10 @@ export function TaskEditorModal({
|
||||
350,
|
||||
)
|
||||
const debouncedVenv = useDebouncedValue(scriptPreview.venv ?? '', 350)
|
||||
// 切换工作目录时会同步清掉自动填入的 venv;如果实时值已经为空,
|
||||
// 探测必须立刻当作没有 venv,不能继续用防抖中的旧值,
|
||||
// 否则会先报“缺少 pyvenv.cfg”,再跳到“未检测到虚拟环境”。
|
||||
const probeVenv = scriptPreview.venv.trim() ? debouncedVenv : ''
|
||||
const cronPreview = useQuery({
|
||||
queryKey: [
|
||||
'cron-preview',
|
||||
@@ -144,31 +167,103 @@ export function TaskEditorModal({
|
||||
Boolean(debouncedTimezone.trim()),
|
||||
retry: false,
|
||||
})
|
||||
const applyScriptLanguage = (language: ScriptLanguage) => {
|
||||
const meta = SCRIPT_LANGUAGE_META[language]
|
||||
const currentScript = getValues('script.script').trim()
|
||||
const knownDefaults = Object.values(SCRIPT_LANGUAGE_META).map(
|
||||
(item) => item.defaultScript,
|
||||
)
|
||||
setValue('script.language', language, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
setValue('script.interpreter', meta.defaultInterpreter, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
if (!currentScript || knownDefaults.includes(currentScript)) {
|
||||
setValue('script.script', meta.defaultScript, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
if (language !== 'python') {
|
||||
setValue('script.venv', '', {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
autoVenvRef.current = ''
|
||||
}
|
||||
}
|
||||
|
||||
const clearAutoVenv = () => {
|
||||
if (
|
||||
autoVenvRef.current &&
|
||||
getValues('script.venv').trim() === autoVenvRef.current
|
||||
) {
|
||||
setValue('script.venv', '', { shouldDirty: false })
|
||||
autoVenvRef.current = ''
|
||||
}
|
||||
}
|
||||
|
||||
const workingDirField = register('script.working_dir')
|
||||
|
||||
const venvProbe = useQuery({
|
||||
queryKey: [
|
||||
'python-venv',
|
||||
debouncedWorkingDir,
|
||||
debouncedInterpreter,
|
||||
debouncedVenv,
|
||||
probeVenv,
|
||||
scriptLanguage,
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
probePythonVenv(
|
||||
{
|
||||
working_dir: debouncedWorkingDir,
|
||||
interpreter: debouncedInterpreter,
|
||||
venv: debouncedVenv.trim() || null,
|
||||
interpreter: debouncedInterpreter.trim() || 'python.exe',
|
||||
venv: probeVenv.trim() || null,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
enabled:
|
||||
open && actionType === 'script' && Boolean(debouncedWorkingDir.trim()),
|
||||
open &&
|
||||
actionType === 'script' &&
|
||||
isPythonScript &&
|
||||
Boolean(debouncedWorkingDir.trim()),
|
||||
// 探测期间保留上一次最终结果,不显示“正在检测”这种中间状态。
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
staleTime: 15_000,
|
||||
})
|
||||
const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null
|
||||
const previewVirtualEnv =
|
||||
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
||||
const previewInterpreter = linkedVenv?.display_path ?? null
|
||||
const createVenvMutation = useMutation({
|
||||
mutationFn: (workingDir: string) =>
|
||||
createPythonVenv({ working_dir: workingDir }),
|
||||
onSuccess: (probe, workingDir) => {
|
||||
toast.success('虚拟环境已创建')
|
||||
if (probe.venv_path) {
|
||||
const value = venvFieldValue(workingDir, probe.venv_path)
|
||||
autoVenvRef.current = value
|
||||
setValue('script.venv', value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-venv'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
const linkedVenv =
|
||||
isPythonScript &&
|
||||
!venvProbe.isPlaceholderData &&
|
||||
venvProbe.data?.status === 'linked'
|
||||
? venvProbe.data
|
||||
: null
|
||||
const previewVirtualEnv = isPythonScript
|
||||
? scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
||||
: ''
|
||||
const previewInterpreter = isPythonScript
|
||||
? (linkedVenv?.display_path ?? null)
|
||||
: null
|
||||
|
||||
const scriptArgs = useFieldArray({ control, name: 'script.args' })
|
||||
const scriptEnv = useFieldArray({ control, name: 'script.env' })
|
||||
@@ -192,21 +287,28 @@ export function TaskEditorModal({
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
if (autoVenvRef.current && getValues('script.venv').trim() === autoVenvRef.current) {
|
||||
if (
|
||||
autoVenvRef.current &&
|
||||
getValues('script.venv').trim() === autoVenvRef.current
|
||||
) {
|
||||
setValue('script.venv', '', { shouldDirty: false })
|
||||
autoVenvRef.current = ''
|
||||
}
|
||||
autoVenvRef.current = ''
|
||||
}, [debouncedWorkingDir, getValues, open, setValue])
|
||||
|
||||
// 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (!open || !isPythonScript || venvProbe.isPlaceholderData) {
|
||||
return
|
||||
}
|
||||
const probe = venvProbe.data
|
||||
if (!probe || probe.status !== 'linked' || !probe.venv_path) {
|
||||
return
|
||||
}
|
||||
// 工作目录还在防抖中时,旧查询结果不能回填到新目录。
|
||||
if (debouncedWorkingDir !== scriptPreview.working_dir.trim()) {
|
||||
return
|
||||
}
|
||||
const current = getValues('script.venv').trim()
|
||||
// 防抖中的输入可能还没落到查询键上:只有「当前输入」与「本次探测用的输入」
|
||||
// 一致时才回填,否则工作目录刚变化时可能把上一轮探测结果又填回去。
|
||||
@@ -225,7 +327,7 @@ export function TaskEditorModal({
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}, [debouncedVenv, debouncedWorkingDir, getValues, open, setValue, venvProbe.data])
|
||||
}, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, scriptPreview.working_dir, setValue, venvProbe.data, venvProbe.isPlaceholderData])
|
||||
|
||||
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
|
||||
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
||||
@@ -455,6 +557,32 @@ export function TaskEditorModal({
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{actionType === 'script' ? (
|
||||
<div className="script-language-row">
|
||||
<div className="script-language-field">
|
||||
<FormField label="脚本语言">
|
||||
<Controller
|
||||
control={control}
|
||||
name="script.language"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
className="form-select"
|
||||
options={SCRIPT_LANGUAGE_OPTIONS}
|
||||
value={field.value}
|
||||
onChange={(value) =>
|
||||
applyScriptLanguage(value as ScriptLanguage)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<Text className="script-language-hint" size="sm" tone="sub">
|
||||
{scriptMeta.hint}
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionType === 'script' ? (
|
||||
<div className="dynamic-fields">
|
||||
<FormField
|
||||
@@ -465,7 +593,13 @@ export function TaskEditorModal({
|
||||
<div className="field-with-action">
|
||||
<Input
|
||||
id="task-working-dir"
|
||||
{...register('script.working_dir')}
|
||||
{...workingDirField}
|
||||
onChange={(event) => {
|
||||
// 先清掉旧目录自动填入的 venv,再更新工作目录;
|
||||
// 这样新目录的第一次探测就不会带着旧 .venv 去报“缺少 pyvenv.cfg”。
|
||||
clearAutoVenv()
|
||||
void workingDirField.onChange(event)
|
||||
}}
|
||||
className="mono-input"
|
||||
placeholder="D:\Projects\sync"
|
||||
aria-invalid={Boolean(errors.script?.working_dir)}
|
||||
@@ -482,43 +616,36 @@ export function TaskEditorModal({
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="form-grid venv-grid">
|
||||
<FormField
|
||||
label="虚拟环境目录 (可选)"
|
||||
htmlFor="task-venv"
|
||||
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
|
||||
error={errors.script?.venv?.message}
|
||||
>
|
||||
<Input
|
||||
id="task-venv"
|
||||
{...register('script.venv')}
|
||||
className="mono-input"
|
||||
placeholder="例如:.venv"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="解释器"
|
||||
htmlFor="task-interpreter"
|
||||
error={errors.script?.interpreter?.message}
|
||||
>
|
||||
<Input
|
||||
id="task-interpreter"
|
||||
{...register('script.interpreter')}
|
||||
className="mono-input"
|
||||
placeholder="python.exe"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{isPythonScript ? (
|
||||
<>
|
||||
<FormField
|
||||
label="虚拟环境目录"
|
||||
htmlFor="task-venv"
|
||||
hint="自动探测项目里的 .venv;需要时也可以手动指定。Python 任务始终使用 .venv 里的 Python"
|
||||
error={errors.script?.venv?.message}
|
||||
>
|
||||
<Input
|
||||
id="task-venv"
|
||||
{...register('script.venv')}
|
||||
className="mono-input"
|
||||
placeholder="留空自动探测,例如 .venv"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<VenvDetectorBar
|
||||
workingDir={debouncedWorkingDir}
|
||||
probe={venvProbe.data}
|
||||
pending={venvProbe.isPending}
|
||||
failed={venvProbe.isError}
|
||||
/>
|
||||
<VenvDetectorBar
|
||||
workingDir={scriptPreview.working_dir}
|
||||
probe={venvProbe.data}
|
||||
failed={venvProbe.isError}
|
||||
creating={createVenvMutation.isPending}
|
||||
onCreate={() =>
|
||||
createVenvMutation.mutate(scriptPreview.working_dir.trim())
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
label="脚本文件或相对路径"
|
||||
label={scriptMeta.scriptLabel}
|
||||
htmlFor="task-script"
|
||||
error={errors.script?.script?.message}
|
||||
>
|
||||
@@ -526,7 +653,7 @@ export function TaskEditorModal({
|
||||
id="task-script"
|
||||
{...register('script.script')}
|
||||
className="mono-input"
|
||||
placeholder="main.py 或 scripts\sync.py"
|
||||
placeholder={scriptMeta.scriptPlaceholder}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -612,9 +739,11 @@ export function TaskEditorModal({
|
||||
</div>
|
||||
))}
|
||||
<div className="cli-line-cmd">
|
||||
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '}
|
||||
{scriptPreview.script.trim() || 'main.py'}
|
||||
{formatArgumentPreview(scriptPreview.args)}
|
||||
{formatScriptCommandPreview(
|
||||
scriptPreview,
|
||||
previewInterpreter,
|
||||
scriptLanguage,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -803,6 +932,7 @@ export function TaskEditorModal({
|
||||
open={pickerOpen}
|
||||
initialPath={scriptPreview.working_dir}
|
||||
onSelect={(path) => {
|
||||
clearAutoVenv()
|
||||
setValue('script.working_dir', path, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
@@ -852,51 +982,88 @@ function venvFieldValue(workingDir: string, venvPath: string): string {
|
||||
|
||||
function formatInterpreterPreview(
|
||||
script: TaskFormValues['script'],
|
||||
linkedPythonPath?: string | null,
|
||||
linkedPythonPath: string | null | undefined,
|
||||
language: ScriptLanguage,
|
||||
): string {
|
||||
if (script.venv.trim()) {
|
||||
if (language === 'python' && script.venv.trim()) {
|
||||
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
|
||||
}
|
||||
return linkedPythonPath ?? (script.interpreter.trim() || 'python.exe')
|
||||
if (linkedPythonPath) {
|
||||
return linkedPythonPath
|
||||
}
|
||||
if (script.interpreter.trim()) {
|
||||
return script.interpreter.trim()
|
||||
}
|
||||
return language === 'python'
|
||||
? 'python.exe'
|
||||
: SCRIPT_LANGUAGE_META[language].defaultInterpreter
|
||||
}
|
||||
|
||||
function isCmdInterpreter(interpreter: string): boolean {
|
||||
return (
|
||||
interpreter
|
||||
.trim()
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.replace(/\.exe$/, '') === 'cmd'
|
||||
)
|
||||
}
|
||||
|
||||
function formatScriptCommandPreview(
|
||||
script: TaskFormValues['script'],
|
||||
linkedPythonPath: string | null | undefined,
|
||||
language: ScriptLanguage,
|
||||
): string {
|
||||
const interpreter = formatInterpreterPreview(
|
||||
script,
|
||||
linkedPythonPath,
|
||||
language,
|
||||
)
|
||||
const file =
|
||||
script.script.trim() || SCRIPT_LANGUAGE_META[language].defaultScript
|
||||
const cmdPrefix =
|
||||
language === 'shell' &&
|
||||
isCmdInterpreter(script.interpreter) &&
|
||||
!file.startsWith('/')
|
||||
? '/C '
|
||||
: ''
|
||||
return `${interpreter} ${cmdPrefix}${file}${formatArgumentPreview(script.args)}`
|
||||
}
|
||||
|
||||
interface VenvDetectorBarProps {
|
||||
workingDir: string
|
||||
probe: VenvProbeResponse | undefined
|
||||
pending: boolean
|
||||
failed: boolean
|
||||
creating: boolean
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
function VenvDetectorBar({
|
||||
workingDir,
|
||||
probe,
|
||||
pending,
|
||||
failed,
|
||||
creating,
|
||||
onCreate,
|
||||
}: VenvDetectorBarProps) {
|
||||
if (!workingDir.trim()) {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge warn">
|
||||
● 请输入有效的工作目录以探测环境
|
||||
</span>
|
||||
<span className="env-detector-hint">填写工作目录后自动检测环境</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge pending">⏳ 正在探测解释器...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (failed || !probe) {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge warn">● 环境探测失败,可稍后重试</span>
|
||||
</div>
|
||||
)
|
||||
if (!probe) {
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge warn">● 环境探测失败,可稍后重试</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 还没有最终结果:保持空状态,不显示跳动的“正在检测”。
|
||||
return <div className="env-detector-bar" role="status" />
|
||||
}
|
||||
|
||||
if (probe.status === 'linked') {
|
||||
@@ -926,12 +1093,31 @@ function VenvDetectorBar({
|
||||
}
|
||||
|
||||
if (probe.status === 'missing') {
|
||||
if (probe.can_create) {
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<div className="env-detector-message">
|
||||
<span className="env-status-badge warn">● {probe.message}</span>
|
||||
<span className="env-detector-hint">
|
||||
{probe.has_pyproject
|
||||
? 'pyproject.toml'
|
||||
: probe.requirements_files.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={creating}
|
||||
onClick={onCreate}
|
||||
>
|
||||
用 uv 创建环境
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="env-detector-bar" role="status">
|
||||
<span className="env-status-badge ok">
|
||||
● 使用系统解释器: <code>{probe.display_path}</code>
|
||||
</span>
|
||||
<span className="env-detector-hint">{probe.message}</span>
|
||||
<span className="env-status-badge danger">● {probe.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,26 @@ describe('task form payload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('submits the selected script language and interpreter', () => {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = 'shell_task'
|
||||
values.name = 'Shell task'
|
||||
values.script.language = 'shell'
|
||||
values.script.working_dir = 'D:\\Projects\\shell-task'
|
||||
values.script.interpreter = 'cmd.exe'
|
||||
values.script.script = 'run.bat'
|
||||
|
||||
const payload = valuesToCreateRequest(values)
|
||||
|
||||
expect(payload.action).toMatchObject({
|
||||
type: 'script',
|
||||
language: 'shell',
|
||||
working_dir: 'D:\\Projects\\shell-task',
|
||||
interpreter: 'cmd.exe',
|
||||
script: 'run.bat',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates an HTTP action instead of forcing script type', () => {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = 'health_check'
|
||||
|
||||
@@ -12,6 +12,75 @@ export interface KeyValueFormValue {
|
||||
value: string
|
||||
}
|
||||
|
||||
export type ScriptLanguage = 'python' | 'shell' | 'node' | 'lua'
|
||||
|
||||
export const SCRIPT_LANGUAGE_META: Record<
|
||||
ScriptLanguage,
|
||||
{
|
||||
label: string
|
||||
defaultInterpreter: string
|
||||
defaultScript: string
|
||||
scriptLabel: string
|
||||
scriptPlaceholder: string
|
||||
hint: string
|
||||
}
|
||||
> = {
|
||||
python: {
|
||||
label: 'Python',
|
||||
defaultInterpreter: '',
|
||||
defaultScript: 'main.py',
|
||||
scriptLabel: '脚本文件或相对路径',
|
||||
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
|
||||
hint: '自动使用项目 .venv;没有环境时可在下方用 uv 创建。',
|
||||
},
|
||||
shell: {
|
||||
label: 'Shell (cmd)',
|
||||
defaultInterpreter: 'cmd.exe',
|
||||
defaultScript: 'run.bat',
|
||||
scriptLabel: '批处理文件或相对路径',
|
||||
scriptPlaceholder: 'run.bat 或 scripts\\start.cmd',
|
||||
hint: '通过 cmd.exe /C 执行批处理文件,不需要虚拟环境。',
|
||||
},
|
||||
node: {
|
||||
label: 'Node.js',
|
||||
defaultInterpreter: 'node.exe',
|
||||
defaultScript: 'index.js',
|
||||
scriptLabel: 'JavaScript 文件或相对路径',
|
||||
scriptPlaceholder: 'index.js 或 scripts\\worker.js',
|
||||
hint: '通过 node.exe 执行,不需要虚拟环境;需已安装 Node.js。',
|
||||
},
|
||||
lua: {
|
||||
label: 'Lua',
|
||||
defaultInterpreter: 'lua.exe',
|
||||
defaultScript: 'main.lua',
|
||||
scriptLabel: 'Lua 文件或相对路径',
|
||||
scriptPlaceholder: 'main.lua 或 scripts\\task.lua',
|
||||
hint: '通过 lua.exe 执行,不需要虚拟环境;需已安装 Lua。',
|
||||
},
|
||||
}
|
||||
|
||||
export function detectScriptLanguage(interpreter: string): ScriptLanguage {
|
||||
const name = interpreter
|
||||
.trim()
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.replace(/\.exe$/, '')
|
||||
if (name === 'node' || name === 'nodejs') {
|
||||
return 'node'
|
||||
}
|
||||
if (name?.startsWith('lua')) {
|
||||
return 'lua'
|
||||
}
|
||||
if (
|
||||
name &&
|
||||
['cmd', 'powershell', 'pwsh', 'bash', 'sh', 'zsh'].includes(name)
|
||||
) {
|
||||
return 'shell'
|
||||
}
|
||||
return 'python'
|
||||
}
|
||||
|
||||
export interface TaskFormValues {
|
||||
id: string
|
||||
name: string
|
||||
@@ -23,6 +92,7 @@ export interface TaskFormValues {
|
||||
script: {
|
||||
working_dir: string
|
||||
venv: string
|
||||
language: ScriptLanguage
|
||||
interpreter: string
|
||||
script: string
|
||||
args: KeyValueFormValue[]
|
||||
@@ -88,6 +158,7 @@ export const taskFormSchema = z
|
||||
script: z.object({
|
||||
working_dir: z.string(),
|
||||
venv: z.string(),
|
||||
language: z.enum(['python', 'shell', 'node', 'lua']),
|
||||
interpreter: z.string(),
|
||||
script: z.string(),
|
||||
args: z.array(keyValueSchema),
|
||||
@@ -116,13 +187,6 @@ export const taskFormSchema = z
|
||||
message: '请输入工作目录',
|
||||
})
|
||||
}
|
||||
if (!values.script.interpreter.trim()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['script', 'interpreter'],
|
||||
message: '请输入解释器或可执行程序',
|
||||
})
|
||||
}
|
||||
if (!values.script.script.trim()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
@@ -171,7 +235,8 @@ export function createEmptyTaskFormValues(): TaskFormValues {
|
||||
script: {
|
||||
working_dir: '',
|
||||
venv: '',
|
||||
interpreter: 'python.exe',
|
||||
language: 'python',
|
||||
interpreter: '',
|
||||
script: 'main.py',
|
||||
args: [],
|
||||
env: [],
|
||||
@@ -203,9 +268,11 @@ export function taskToFormValues(task: Task): TaskFormValues {
|
||||
values.actionType = task.action.type
|
||||
|
||||
if (task.action.type === 'script') {
|
||||
const language = task.action.language ?? detectScriptLanguage(task.action.interpreter)
|
||||
values.script = {
|
||||
working_dir: task.action.working_dir,
|
||||
venv: task.action.venv ?? '',
|
||||
venv: language === 'python' ? (task.action.venv ?? '') : '',
|
||||
language,
|
||||
interpreter: task.action.interpreter,
|
||||
script: task.action.script,
|
||||
args: argsToRows(task.action.args ?? []),
|
||||
@@ -245,6 +312,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
|
||||
type: 'script',
|
||||
working_dir: values.script.working_dir.trim(),
|
||||
venv: values.script.venv.trim() || null,
|
||||
language: values.script.language,
|
||||
interpreter: values.script.interpreter.trim(),
|
||||
script: values.script.script.trim(),
|
||||
args: argumentsToArray(values.script.args),
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Notice,
|
||||
Result,
|
||||
Space,
|
||||
Spinner,
|
||||
Table,
|
||||
Tag,
|
||||
Text,
|
||||
TextButton,
|
||||
Title,
|
||||
toast,
|
||||
type TableColumn,
|
||||
} from '@stash/ui'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createPythonMirror,
|
||||
deletePythonMirror,
|
||||
listPythonMirrors,
|
||||
setDefaultPythonMirror,
|
||||
updatePythonMirror,
|
||||
type PythonMirror,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import {
|
||||
PythonMirrorModal,
|
||||
type PythonMirrorFormValues,
|
||||
} from '../components/PythonMirrorModal'
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称', width: 180 },
|
||||
{ key: 'index_url', title: '索引地址', width: 330 },
|
||||
{ key: 'trusted_host', title: '信任主机', width: 130 },
|
||||
{ key: 'status', title: '状态', width: 100 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 220 },
|
||||
]
|
||||
|
||||
function toRequest(values: PythonMirrorFormValues) {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
index_url: values.index_url.trim(),
|
||||
trusted_host: values.trusted_host.trim() || null,
|
||||
enabled: values.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Python 包镜像管理。
|
||||
*
|
||||
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
|
||||
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
|
||||
*/
|
||||
export function PythonMirrorsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<PythonMirror | null>(null)
|
||||
|
||||
const mirrorsQuery = useQuery({
|
||||
queryKey: ['python-mirrors'],
|
||||
queryFn: ({ signal }) => listPythonMirrors(signal),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
mirror,
|
||||
values,
|
||||
}: {
|
||||
mirror: PythonMirror | null
|
||||
values: PythonMirrorFormValues
|
||||
}) =>
|
||||
mirror
|
||||
? updatePythonMirror(mirror.id, toRequest(values))
|
||||
: createPythonMirror(toRequest(values)),
|
||||
onSuccess: (mirror, variables) => {
|
||||
setEditorOpen(false)
|
||||
setEditingMirror(null)
|
||||
toast.success(
|
||||
variables.mirror ? `已更新 ${mirror.name}` : `已添加 ${mirror.name}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
})
|
||||
|
||||
const defaultMutation = useMutation({
|
||||
mutationFn: (mirror: PythonMirror) => setDefaultPythonMirror(mirror.id),
|
||||
onSuccess: (mirror) => {
|
||||
toast.success(`默认镜像已切换为 ${mirror.name}`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (mirror: PythonMirror) => deletePythonMirror(mirror.id),
|
||||
onSuccess: (_data, mirror) => {
|
||||
setDeleteTarget(null)
|
||||
toast.success(`已删除 ${mirror.name}`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const mirrors = mirrorsQuery.data ?? []
|
||||
const defaultMirror = mirrors.find((mirror) => mirror.is_default) ?? null
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingMirror(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (mirror: PythonMirror) => {
|
||||
setEditingMirror(mirror)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const rows = mirrors.map((mirror) => ({
|
||||
id: mirror.id,
|
||||
name: (
|
||||
<Space gap="xs" align="center" wrap>
|
||||
<Text strong>{mirror.name}</Text>
|
||||
{mirror.is_default ? (
|
||||
<Tag tone="primary" shape="rounded">
|
||||
默认
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
index_url: (
|
||||
<code className="code-chip mirror-url" title={mirror.index_url}>
|
||||
{mirror.index_url}
|
||||
</code>
|
||||
),
|
||||
trusted_host: mirror.trusted_host ? (
|
||||
<Text className="mono" size="sm">
|
||||
{mirror.trusted_host}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" tone="muted">
|
||||
—
|
||||
</Text>
|
||||
),
|
||||
status: (
|
||||
<Tag tone={mirror.enabled ? 'success' : 'neutral'} dot>
|
||||
{mirror.enabled ? '已启用' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
actions: (
|
||||
<Space justify="end" gap="xs" wrap>
|
||||
<TextButton
|
||||
disabled={mirror.is_default}
|
||||
onClick={() => defaultMutation.mutate(mirror)}
|
||||
>
|
||||
设为默认
|
||||
</TextButton>
|
||||
<TextButton onClick={() => openEdit(mirror)}>编辑</TextButton>
|
||||
<TextButton tone="danger" onClick={() => setDeleteTarget(mirror)}>
|
||||
删除
|
||||
</TextButton>
|
||||
</Space>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader
|
||||
eyebrow="PYTHON / PACKAGE MIRROR"
|
||||
title="Python 镜像"
|
||||
subtitle="管理 pip / uv 的包索引;默认镜像会在脚本任务运行时注入环境变量。"
|
||||
actions={<Button onClick={openCreate}>+ 新增镜像</Button>}
|
||||
/>
|
||||
|
||||
<Card padding="md" className="mirror-summary">
|
||||
<div className="mirror-summary-main">
|
||||
<Text size="xs" tone="muted">
|
||||
当前默认镜像
|
||||
</Text>
|
||||
<Title level={2}>
|
||||
{defaultMirror?.name ?? '未设置默认镜像'}
|
||||
</Title>
|
||||
<Text className="mono" size="sm" tone="sub">
|
||||
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="mirror-summary-meta">
|
||||
<Text size="xs" tone="muted">
|
||||
运行时注入
|
||||
</Text>
|
||||
<Space gap="xs" wrap>
|
||||
<Tag shape="rounded">PIP_INDEX_URL</Tag>
|
||||
<Tag shape="rounded">UV_INDEX_URL</Tag>
|
||||
{defaultMirror?.trusted_host ? (
|
||||
<Tag shape="rounded">PIP_TRUSTED_HOST</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Notice title="镜像只影响任务运行,不会自动安装依赖">
|
||||
默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL
|
||||
优先。修改后对下一次运行生效。
|
||||
</Notice>
|
||||
|
||||
<Card padding="none" className="mirror-card">
|
||||
{mirrorsQuery.isError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="镜像列表加载失败"
|
||||
description={mirrorsQuery.error.message}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void mirrorsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : mirrorsQuery.isPending ? (
|
||||
<div className="state-block">
|
||||
<Spinner label="正在读取镜像" />
|
||||
<Text tone="sub">正在读取镜像...</Text>
|
||||
</div>
|
||||
) : mirrors.length === 0 ? (
|
||||
<Empty
|
||||
className="empty-state"
|
||||
text="还没有 Python 镜像"
|
||||
action={<Button onClick={openCreate}>新增镜像</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
className="mirror-table"
|
||||
columns={TABLE_COLUMNS}
|
||||
rows={rows}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<PythonMirrorModal
|
||||
open={editorOpen}
|
||||
mirror={editingMirror}
|
||||
saving={saveMutation.isPending}
|
||||
onClose={() => {
|
||||
if (!saveMutation.isPending) {
|
||||
setEditorOpen(false)
|
||||
}
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
await saveMutation.mutateAsync({ mirror: editingMirror, values })
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={`删除镜像 ${deleteTarget?.name ?? ''}`}
|
||||
description="删除后如果它正是默认镜像,Python 任务将不再注入镜像环境变量;正在运行的任务不受影响。"
|
||||
busy={deleteMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (!deleteMutation.isPending) {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate(deleteTarget)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type TaskFilters,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { RunLogDrawer } from '../components/RunLogDrawer'
|
||||
import { EnabledBadge } from '../components/StatusBadge'
|
||||
import { TaskEditorModal } from '../components/TaskEditorModal'
|
||||
@@ -68,12 +69,12 @@ const ACTION_TYPE_OPTIONS = [
|
||||
]
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'name', title: '名称 / 任务标识', width: 250 },
|
||||
{ key: 'timezone', title: '时区', width: 120 },
|
||||
{ key: 'schedule', title: '调度周期', width: 210 },
|
||||
{ key: 'status', title: '状态', width: 110 },
|
||||
{ key: 'timeout', title: '超时', width: 90 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 260 },
|
||||
{ key: 'name', title: '名称 / 任务标识', width: 230 },
|
||||
{ key: 'timezone', title: '时区', width: 110 },
|
||||
{ key: 'schedule', title: '调度周期', width: 200 },
|
||||
{ key: 'status', title: '状态', width: 100 },
|
||||
{ key: 'timeout', title: '超时', width: 80 },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: 240 },
|
||||
]
|
||||
|
||||
export function TaskDashboard() {
|
||||
@@ -322,17 +323,13 @@ export function TaskDashboard() {
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="page-header">
|
||||
<div className="header-title">
|
||||
<p className="eyebrow">TASKPILOT / WINDOWS SCHEDULER</p>
|
||||
<Title level={1}>TaskPilot</Title>
|
||||
<Text tone="sub">Rust 本地轻量化任务引擎 · Windows 原生调度中心</Text>
|
||||
</div>
|
||||
<Space gap="sm" wrap>
|
||||
<Button onClick={openCreate}>+ 新建任务</Button>
|
||||
</Space>
|
||||
</header>
|
||||
<div className="page-stack">
|
||||
<PageHeader
|
||||
eyebrow="TASKPILOT / WINDOWS SCHEDULER"
|
||||
title="任务调度"
|
||||
subtitle="Rust 本地轻量化任务引擎 · Windows 原生调度中心"
|
||||
actions={<Button onClick={openCreate}>+ 新建任务</Button>}
|
||||
/>
|
||||
|
||||
{actionError ? (
|
||||
<Notice
|
||||
@@ -529,7 +526,7 @@ export function TaskDashboard() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user