feat: 左侧导航改版与 Python 镜像管理

- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航
- 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方
- Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先
- 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除
- 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
This commit is contained in:
2026-11-03 22:12:11 +08:00
parent d7a6e17531
commit 29fc579275
25 changed files with 2491 additions and 47 deletions
+29 -7
View File
@@ -6,6 +6,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程 - 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
- SQLite 持久化,重启后任务与历史仍在 - SQLite 持久化,重启后任务与历史仍在
- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示 - 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示
- Python 包镜像管理:内置常用 PyPI 源,默认镜像在任务运行时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
- 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体 - 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。 > 根目录的 `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 管理进程树;超时或取消时终止整棵树 | | 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 | | 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 | | 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
| Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验 | | Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验、默认 PyPI 镜像注入 |
| 界面 | 三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志抽屉 | | 界面 | 左侧导航(任务调度 / Python 镜像)、三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 | | 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 | | 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
@@ -229,14 +230,19 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
## Web 界面 ## Web 界面
- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。 - **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
- **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。 - **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。 - **编辑器**:三类动态表单、Cron 预览(实时计算未来 3/5/8/10 次)、时区下拉、虚拟环境状态条、调用预览;
探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。 - **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。 - **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
默认镜像在 Python 脚本任务启动时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
(任务自身配置的环境变量优先),不会自动安装依赖。
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自 - **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与 `@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
业务块(任务表单元格、日志控制台、表单栅格)。 业务块(侧栏、任务表单元格、日志控制台、表单栅格)。
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用 - **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用 `LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
`tools/build-fonts.py` 生成,缺失时回退系统字体。 `tools/build-fonts.py` 生成,缺失时回退系统字体。
@@ -292,6 +298,17 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) | | `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 | | `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` | 设为默认(同一时间仅一个默认,并自动启用) |
### 任务与运行 ### 任务与运行
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
@@ -425,6 +442,7 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
| `executor.rs` | 三类 Action 执行、并发闸门、超时/取消、日志脱敏与截断 | | `executor.rs` | 三类 Action 执行、并发闸门、超时/取消、日志脱敏与截断 |
| `process_job.rs` | Windows Job Object(`KILL_ON_JOB_CLOSE`) | | `process_job.rs` | Windows Job Object(`KILL_ON_JOB_CLOSE`) |
| `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入 | | `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入 |
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) | | `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 | | `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
| `database.rs` | 连接池与迁移(WAL/busy_timeout) | | `database.rs` | 连接池与迁移(WAL/busy_timeout) |
@@ -434,7 +452,11 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
| 模块 | 职责 | | 模块 | 职责 |
| --- | --- | | --- | --- |
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 | | `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
| `components/AppSidebar.tsx` | 左侧导航、品牌、引擎状态 |
| `components/PageHeader.tsx` | 页面标题区(标题 / 说明 / 操作) |
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 | | `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
| `pages/PythonMirrorsPage.tsx` | Python 镜像列表、默认项、新增 / 编辑 / 删除 |
| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) |
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 | | `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 | | `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE | | `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
@@ -504,8 +526,8 @@ uv run --with fonttools --with brotli tools/build-fonts.py
| --- | --- | | --- | --- |
| Rust 单元(57) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | | Rust 单元(57) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 | | Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 |
| 前端 Vitest(28) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | | 前端 Vitest(29) | 仪表盘渲染、侧栏导航到镜像页、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Playwright(5) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动 | | Playwright(6) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
--- ---
@@ -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')
);
+18
View File
@@ -20,6 +20,14 @@ use crate::{
python_env::{ python_env::{
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource, self, 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::{ runs::{
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse}, model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
routes::{ routes::{
@@ -219,6 +227,12 @@ pub async fn list_schedule(
session, session,
preview_cron, preview_cron,
probe_python_venv, probe_python_venv,
list_python_mirrors,
get_python_mirror,
create_python_mirror,
update_python_mirror,
delete_python_mirror,
set_default_python_mirror,
list_directories, list_directories,
task_stats, task_stats,
list_schedule, list_schedule,
@@ -247,6 +261,9 @@ pub async fn list_schedule(
VenvProbeStatus, VenvProbeStatus,
VenvSource, VenvSource,
InterpreterKind, InterpreterKind,
PythonMirrorResponse,
CreatePythonMirrorRequest,
UpdatePythonMirrorRequest,
DirectoryEntry, DirectoryEntry,
DirectoryListingResponse, DirectoryListingResponse,
TaskStatsResponse, TaskStatsResponse,
@@ -305,6 +322,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/v1/schedule", get(list_schedule)) .route("/api/v1/schedule", get(list_schedule))
.route("/api/v1/openapi.json", get(openapi_json)) .route("/api/v1/openapi.json", get(openapi_json))
.merge(crate::tasks::routes::router()) .merge(crate::tasks::routes::router())
.merge(crate::python_mirrors::routes::router())
.merge(crate::runs::routes::router()) .merge(crate::runs::routes::router())
.route("/api/{*path}", any(api_not_found)) .route("/api/{*path}", any(api_not_found))
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。 // 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
+20
View File
@@ -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 { pub fn route_not_found(message: impl Into<String>) -> Self {
Self { Self {
status: StatusCode::NOT_FOUND, status: StatusCode::NOT_FOUND,
+1
View File
@@ -5,6 +5,7 @@ pub mod directory_browser;
pub mod error; pub mod error;
pub mod process_job; pub mod process_job;
pub mod python_env; pub mod python_env;
pub mod python_mirrors;
pub mod runs; pub mod runs;
pub mod scheduler; pub mod scheduler;
pub mod security; pub mod security;
+1 -1
View File
@@ -472,7 +472,7 @@ fn absolute_path(path: &Path) -> io::Result<PathBuf> {
std::path::absolute(path) std::path::absolute(path)
} }
fn is_python_interpreter(interpreter: &str) -> bool { pub fn is_python_interpreter(interpreter: &str) -> bool {
let name = Path::new(interpreter) let name = Path::new(interpreter)
.file_name() .file_name()
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
+4
View File
@@ -0,0 +1,4 @@
pub mod model;
pub mod repository;
pub mod routes;
pub mod service;
+52
View File
@@ -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>,
}
+215
View File
@@ -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)
}
+151
View File
@@ -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?))
}
+79
View File
@@ -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,
})
}
+48 -6
View File
@@ -1,5 +1,5 @@
use std::{ use std::{
collections::HashMap, collections::{BTreeMap, HashMap},
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Stdio, process::Stdio,
sync::{ sync::{
@@ -26,7 +26,7 @@ use tokio_util::sync::CancellationToken;
use crate::{ use crate::{
error::ApiResult, error::ApiResult,
process_job::ProcessJob, process_job::ProcessJob,
python_env::prepare_script_environment, python_env::{is_python_interpreter, prepare_script_environment},
runs::{ runs::{
model::{RunStatus, RunTrigger, TaskRunResponse}, model::{RunStatus, RunTrigger, TaskRunResponse},
repository, repository,
@@ -447,11 +447,48 @@ impl TaskExecutor {
}; };
let mut command = Command::new(&prepared.interpreter); let mut command = Command::new(&prepared.interpreter);
prepared.apply(&mut command); prepared.apply(&mut command);
let mut notes = Vec::new();
if let Some(venv) = &prepared.virtual_env {
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
}
if is_python_interpreter(interpreter) {
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 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"
);
}
}
}
command.arg(script).args(args).current_dir(working_dir); command.arg(script).args(args).current_dir(working_dir);
let startup_note = prepared let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
.virtual_env
.as_ref()
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
self.run_command(task, run_id, cancellation, command, startup_note) self.run_command(task, run_id, cancellation, command, startup_note)
.await .await
} }
@@ -1064,6 +1101,11 @@ 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 sensitive_values(action: &TaskAction) -> Vec<String> { fn sensitive_values(action: &TaskAction) -> Vec<String> {
match action { match action {
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
+243
View File
@@ -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");
}
+352
View File
@@ -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": { "/api/v1/python/venv": {
"post": { "post": {
"tags": [ "tags": [
@@ -876,6 +1130,31 @@
"skip" "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
},
"CreateTaskRequest": { "CreateTaskRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -1095,6 +1374,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": { "RetryPolicy": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1539,6 +1865,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": { "UpdateTaskRequest": {
"type": "object", "type": "object",
"required": [ "required": [
+47 -1
View File
@@ -246,7 +246,9 @@ test('renders without external network access', async ({ page }) => {
}) })
await page.goto('/') await page.goto('/')
await expect(page.getByRole('heading', { name: 'TaskPilot' })).toBeVisible() await expect(
page.getByRole('heading', { name: '任务调度' }),
).toBeVisible()
const state = await page.evaluate(async () => { const state = await page.evaluate(async () => {
// 字体二进制不入库;未生成时应回退系统字体,而不是报错。 // 字体二进制不入库;未生成时应回退系统字体,而不是报错。
@@ -331,3 +333,47 @@ test('keeps a long run log scrollable without rendering every line', async ({
fullPage: true, 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 })
})
+180 -7
View File
@@ -4,13 +4,68 @@
* 颜色与间距只引用 --su-* 令牌,不再另建一套主题变量。 * 颜色与间距只引用 --su-* 令牌,不再另建一套主题变量。
*/ */
.app-shell { /* ---------- 应用外壳:左侧导航 + 内容 ---------- */
.app-root {
min-height: 100vh;
}
.app-sider {
position: sticky;
top: 0;
display: flex; display: flex;
width: min(1040px, calc(100% - 48px)); height: 100vh;
box-sizing: border-box;
flex-direction: column; flex-direction: column;
gap: 24px; 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; 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;
} }
/* ---------- 页头 ---------- */ /* ---------- 页头 ---------- */
@@ -876,8 +931,119 @@
background-color: var(--su-gray-6); 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) { @media (max-width: 1040px) {
.run-log-body { .run-log-body {
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.5fr); grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.5fr);
@@ -885,10 +1051,17 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.app-shell { .page-container {
width: min(100% - 24px, 1040px); padding: 24px 20px 40px;
gap: 20px; }
padding: 24px 0 40px;
.app-sider {
gap: 10px;
padding: 10px 12px;
}
.app-sider-foot {
display: none;
} }
.page-header { .page-header {
+31
View File
@@ -83,6 +83,20 @@ function createFetchMock(options: FetchMockOptions = {}) {
{ task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' }, { 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')) { if (url.endsWith('/api/v1/cron/preview')) {
return jsonResponse({ return jsonResponse({
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
@@ -167,6 +181,23 @@ describe('TaskDashboard', () => {
expect(await screen.findByText('在线')).toBeInTheDocument() 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 () => { it('shows the next run time reported by the schedule endpoint', async () => {
vi.stubGlobal('fetch', createFetchMock()) vi.stubGlobal('fetch', createFetchMock())
renderApp() renderApp()
+20 -7
View File
@@ -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 { Route, Routes, useNavigate } from 'react-router-dom'
import { AppSidebar } from './components/AppSidebar'
import { PythonMirrorsPage } from './pages/PythonMirrorsPage'
import { TaskDashboard } from './pages/TaskDashboard' import { TaskDashboard } from './pages/TaskDashboard'
import './App.css' import './App.css'
@@ -7,23 +9,34 @@ function NotFoundPage() {
const navigate = useNavigate() const navigate = useNavigate()
return ( return (
<main className="app-shell"> <div className="page-stack">
<Result <Result
status="info" status="info"
title="页面不存在" title="页面不存在"
description="该地址尚未配置到 TaskPilot 路由。" description="该地址尚未配置到 TaskPilot 路由。"
actions={<Button onClick={() => navigate('/')}>返回任务列表</Button>} actions={<Button onClick={() => navigate('/')}>返回任务列表</Button>}
/> />
</main> </div>
) )
} }
function App() { function App() {
return ( return (
<Routes> <Layout
<Route path="/" element={<TaskDashboard />} /> className="app-root"
<Route path="*" element={<NotFoundPage />} /> sider={<AppSidebar />}
</Routes> 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>
) )
} }
+44
View File
@@ -26,6 +26,11 @@ export type DirectoryListingResponse =
components['schemas']['DirectoryListingResponse'] components['schemas']['DirectoryListingResponse']
export type TaskStats = components['schemas']['TaskStatsResponse'] export type TaskStats = components['schemas']['TaskStatsResponse']
export type ScheduledTask = components['schemas']['ScheduledTaskResponse'] 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 { export interface TaskFilters {
q?: string q?: string
@@ -303,3 +308,42 @@ export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> { export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal }) 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' },
)
}
+286
View File
@@ -52,6 +52,54 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/python/venv": {
parameters: { parameters: {
query?: never; query?: never;
@@ -298,6 +346,12 @@ export interface components {
schemas: { schemas: {
/** @enum {string} */ /** @enum {string} */
ConcurrencyPolicy: "skip"; ConcurrencyPolicy: "skip";
CreatePythonMirrorRequest: {
enabled?: boolean;
index_url: string;
name: string;
trusted_host?: string | null;
};
CreateTaskRequest: { CreateTaskRequest: {
action: components["schemas"]["TaskAction"]; action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"]; concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
@@ -360,6 +414,22 @@ export interface components {
/** Format: int64 */ /** Format: int64 */
next_offset: number; 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: { RetryPolicy: {
/** Format: int64 */ /** Format: int64 */
delay_secs?: number; delay_secs?: number;
@@ -501,6 +571,12 @@ export interface components {
*/ */
total: number; total: number;
}; };
UpdatePythonMirrorRequest: {
enabled: boolean;
index_url: string;
name: string;
trusted_host?: string | null;
};
UpdateTaskRequest: { UpdateTaskRequest: {
action: components["schemas"]["TaskAction"]; action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"]; concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
@@ -620,6 +696,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: { probe_python_venv: {
parameters: { parameters: {
query?: never; query?: never;
+72
View File
@@ -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>
)
}
+32
View File
@@ -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>
)
}
+275
View File
@@ -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>
)
}
+15 -18
View File
@@ -41,6 +41,7 @@ import {
type TaskFilters, type TaskFilters,
} from '../api/client' } from '../api/client'
import { ConfirmDialog } from '../components/ConfirmDialog' import { ConfirmDialog } from '../components/ConfirmDialog'
import { PageHeader } from '../components/PageHeader'
import { RunLogDrawer } from '../components/RunLogDrawer' import { RunLogDrawer } from '../components/RunLogDrawer'
import { EnabledBadge } from '../components/StatusBadge' import { EnabledBadge } from '../components/StatusBadge'
import { TaskEditorModal } from '../components/TaskEditorModal' import { TaskEditorModal } from '../components/TaskEditorModal'
@@ -68,12 +69,12 @@ const ACTION_TYPE_OPTIONS = [
] ]
const TABLE_COLUMNS: TableColumn[] = [ const TABLE_COLUMNS: TableColumn[] = [
{ key: 'name', title: '名称 / 任务标识', width: 250 }, { key: 'name', title: '名称 / 任务标识', width: 230 },
{ key: 'timezone', title: '时区', width: 120 }, { key: 'timezone', title: '时区', width: 110 },
{ key: 'schedule', title: '调度周期', width: 210 }, { key: 'schedule', title: '调度周期', width: 200 },
{ key: 'status', title: '状态', width: 110 }, { key: 'status', title: '状态', width: 100 },
{ key: 'timeout', title: '超时', width: 90 }, { key: 'timeout', title: '超时', width: 80 },
{ key: 'actions', title: '操作', align: 'right', width: 260 }, { key: 'actions', title: '操作', align: 'right', width: 240 },
] ]
export function TaskDashboard() { export function TaskDashboard() {
@@ -322,17 +323,13 @@ export function TaskDashboard() {
}) })
return ( return (
<main className="app-shell"> <div className="page-stack">
<header className="page-header"> <PageHeader
<div className="header-title"> eyebrow="TASKPILOT / WINDOWS SCHEDULER"
<p className="eyebrow">TASKPILOT / WINDOWS SCHEDULER</p> title="任务调度"
<Title level={1}>TaskPilot</Title> subtitle="Rust 本地轻量化任务引擎 · Windows 原生调度中心"
<Text tone="sub">Rust 本地轻量化任务引擎 · Windows 原生调度中心</Text> actions={<Button onClick={openCreate}>+ 新建任务</Button>}
</div> />
<Space gap="sm" wrap>
<Button onClick={openCreate}>+ 新建任务</Button>
</Space>
</header>
{actionError ? ( {actionError ? (
<Notice <Notice
@@ -529,7 +526,7 @@ export function TaskDashboard() {
} }
}} }}
/> />
</main> </div>
) )
} }